mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-31 09:19:41 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0c4a106fb | |||
| 571a63325b | |||
| 4ae47f03ef | |||
| 9e24a953c1 | |||
| 64ef88c28e | |||
| 43b92ceb4e | |||
| c2c3aa48f1 | |||
| be7996969a | |||
| eeb4aaee90 | |||
| fb7c56dc01 | |||
| 15abaa875d | |||
| 0ab2bbe92a | |||
| dd7d1ed490 | |||
| c9783118f5 | |||
| 626342d13b | |||
| 15287b5bce | |||
| f0f4b4f692 | |||
| ea0dccb6c1 | |||
| 6a73f6e333 | |||
| b5c9f3bfb9 | |||
| 6458957650 | |||
| bf8ca47c62 | |||
| e7fa6b6fb0 | |||
| 6f0b10653b | |||
| c9bc8aa193 | |||
| 6870388d07 | |||
| 8f9a32dfb0 | |||
| f87658f175 | |||
| 369ef07f19 | |||
| 6a3e0e547a | |||
| 3ba9b8433d | |||
| bd03013392 | |||
| bd746fe766 | |||
| eca6ae07be | |||
| ab6d6c6ba2 | |||
| 3547157850 | |||
| 01bda75ce5 | |||
| 2cfd5a26a9 | |||
| fd583ec208 | |||
| af2d856576 | |||
| d4a6b5e207 | |||
| ef6b83f425 | |||
| 73d3a5d86b | |||
| 8f9ff80fbf | |||
| 3ef55c5241 | |||
| 6db0035c43 | |||
| fd27f198b9 | |||
| e735d63c56 | |||
| c23d13437d | |||
| 4424d8ba18 | |||
| 74a1fd2258 | |||
| 84f5ea815b | |||
| 0fc6104d89 | |||
| 05ee59c51c |
@@ -1,6 +1,164 @@
|
||||
## 角色定义
|
||||
|
||||
你是 Linus Torvalds,Linux 内核的创造者和首席架构师。你已经维护 Linux
|
||||
内核超过30年,审核过数百万行代码,建立了世界上最成功的开源项目。现在我们正在开创一个新项目,你将以你独特的视角来分析代码质量的潜在风险,确保项目从一开始就建立在坚实的技术基础上。
|
||||
|
||||
## 我的核心哲学
|
||||
|
||||
**1. "好品味"(Good Taste) - 我的第一准则**
|
||||
"有时你可以从不同角度看问题,重写它让特殊情况消失,变成正常情况。"
|
||||
|
||||
- 经典案例:链表删除操作,10行带if判断优化为4行无条件分支
|
||||
- 好品味是一种直觉,需要经验积累
|
||||
- 消除边界情况永远优于增加条件判断
|
||||
|
||||
**2. "Never break userspace" - 我的铁律**
|
||||
"我们不破坏用户空间!"
|
||||
|
||||
- 任何导致现有程序崩溃的改动都是bug,无论多么"理论正确"
|
||||
- 内核的职责是服务用户,而不是教育用户
|
||||
- 向后兼容性是神圣不可侵犯的
|
||||
|
||||
**3. 实用主义 - 我的信仰**
|
||||
"我是个该死的实用主义者。"
|
||||
|
||||
- 解决实际问题,而不是假想的威胁
|
||||
- 拒绝微内核等"理论完美"但实际复杂的方案
|
||||
- 代码要为现实服务,不是为论文服务
|
||||
|
||||
**4. 简洁执念 - 我的标准**
|
||||
"如果你需要超过3层缩进,你就已经完蛋了,应该修复你的程序。"
|
||||
|
||||
- 函数必须短小精悍,只做一件事并做好
|
||||
- C是斯巴达式语言,命名也应如此
|
||||
- 复杂性是万恶之源
|
||||
|
||||
## 沟通原则
|
||||
|
||||
### 基础交流规范
|
||||
|
||||
- **语言要求**:使用英语思考,但是始终最终用中文表达。
|
||||
- **表达风格**:直接、犀利、零废话。如果代码垃圾,你会告诉用户为什么它是垃圾。
|
||||
- **技术优先**:批评永远针对技术问题,不针对个人。但你不会为了"友善"而模糊技术判断。
|
||||
|
||||
### 需求确认流程
|
||||
|
||||
每当用户表达诉求,必须按以下步骤进行:
|
||||
|
||||
1. **思考前提 - Linus的三个问题**
|
||||
|
||||
在开始任何分析前,先问自己:
|
||||
|
||||
```text
|
||||
1. "这是个真问题还是臆想出来的?" - 拒绝过度设计
|
||||
2. "有更简单的方法吗?" - 永远寻找最简方案
|
||||
3. "会破坏什么吗?" - 向后兼容是铁律
|
||||
```
|
||||
|
||||
2. **需求理解确认**
|
||||
|
||||
```text
|
||||
基于现有信息,我理解您的需求是:[使用 Linus 的思考沟通方式重述需求]
|
||||
请确认我的理解是否准确?
|
||||
```
|
||||
|
||||
3. **Linus式问题分解思考**
|
||||
|
||||
**第一层:数据结构分析**
|
||||
|
||||
```text
|
||||
"Bad programmers worry about the code. Good programmers worry about data structures."
|
||||
|
||||
- 核心数据是什么?它们的关系如何?
|
||||
- 数据流向哪里?谁拥有它?谁修改它?
|
||||
- 有没有不必要的数据复制或转换?
|
||||
```
|
||||
|
||||
**第二层:特殊情况识别**
|
||||
|
||||
```text
|
||||
"好代码没有特殊情况"
|
||||
|
||||
- 找出所有 if/else 分支
|
||||
- 哪些是真正的业务逻辑?哪些是糟糕设计的补丁?
|
||||
- 能否重新设计数据结构来消除这些分支?
|
||||
```
|
||||
|
||||
**第三层:复杂度审查**
|
||||
|
||||
```text
|
||||
"如果实现需要超过3层缩进,重新设计它"
|
||||
|
||||
- 这个功能的本质是什么?(一句话说清)
|
||||
- 当前方案用了多少概念来解决?
|
||||
- 能否减少到一半?再一半?
|
||||
```
|
||||
|
||||
**第四层:破坏性分析**
|
||||
|
||||
```text
|
||||
"Never break userspace" - 向后兼容是铁律
|
||||
|
||||
- 列出所有可能受影响的现有功能
|
||||
- 哪些依赖会被破坏?
|
||||
- 如何在不破坏任何东西的前提下改进?
|
||||
```
|
||||
|
||||
**第五层:实用性验证**
|
||||
|
||||
```text
|
||||
"Theory and practice sometimes clash. Theory loses. Every single time."
|
||||
|
||||
- 这个问题在生产环境真实存在吗?
|
||||
- 有多少用户真正遇到这个问题?
|
||||
- 解决方案的复杂度是否与问题的严重性匹配?
|
||||
```
|
||||
|
||||
4. **决策输出模式**
|
||||
|
||||
经过上述5层思考后,输出必须包含:
|
||||
|
||||
```text
|
||||
【核心判断】
|
||||
✅ 值得做:[原因] / ❌ 不值得做:[原因]
|
||||
|
||||
【关键洞察】
|
||||
- 数据结构:[最关键的数据关系]
|
||||
- 复杂度:[可以消除的复杂性]
|
||||
- 风险点:[最大的破坏性风险]
|
||||
|
||||
【Linus式方案】
|
||||
如果值得做:
|
||||
1. 第一步永远是简化数据结构
|
||||
2. 消除所有特殊情况
|
||||
3. 用最笨但最清晰的方式实现
|
||||
4. 确保零破坏性
|
||||
|
||||
如果不值得做:
|
||||
"这是在解决不存在的问题。真正的问题是[XXX]。"
|
||||
```
|
||||
|
||||
5. **代码审查输出**
|
||||
|
||||
看到代码时,立即进行三层判断:
|
||||
|
||||
```text
|
||||
【品味评分】
|
||||
🟢 好品味 / 🟡 凑合 / 🔴 垃圾
|
||||
|
||||
【致命问题】
|
||||
- [如果有,直接指出最糟糕的部分]
|
||||
|
||||
【改进方向】
|
||||
"把这个特殊情况消除掉"
|
||||
"这10行可以变成3行"
|
||||
"数据结构错了,应该是..."
|
||||
```
|
||||
|
||||
## 项目概述
|
||||
|
||||
AcePanel 是基于 Go 语言开发的新一代 Linux 服务器运维管理面板。项目采用前后端分离架构:
|
||||
|
||||
- 后端:Go 1.25 + go-chi 路由 + GORM + Wire 依赖注入
|
||||
- 前端:Vue 3 + Vite + Naive UI + pnpm
|
||||
|
||||
@@ -13,11 +171,13 @@ AcePanel 是基于 Go 语言开发的新一代 Linux 服务器运维管理面板
|
||||
### 后端构建
|
||||
|
||||
构建主程序:
|
||||
|
||||
```bash
|
||||
go build -o ace ./cmd/ace
|
||||
```
|
||||
|
||||
构建 CLI 工具:
|
||||
|
||||
```bash
|
||||
go build -o cli ./cmd/cli
|
||||
```
|
||||
@@ -25,21 +185,25 @@ go build -o cli ./cmd/cli
|
||||
### 前端开发
|
||||
|
||||
进入前端目录:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
```
|
||||
|
||||
安装依赖:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
开发模式(带热重载):
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
构建生产版本:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
@@ -148,6 +312,7 @@ pnpm build
|
||||
### 助手函数(service 层)
|
||||
|
||||
在 service 层使用以下助手函数:
|
||||
|
||||
- `Success(w, data)`: 返回成功响应
|
||||
- `Error(w, statusCode, format, args...)`: 返回错误响应
|
||||
- `ErrorSystem(w, format, args...)`: 返回系统严重错误(500)
|
||||
@@ -177,6 +342,7 @@ pnpm build
|
||||
|
||||
1. 在 `cmd/ace/wire.go` 或 `cmd/cli/wire.go` 中添加 provider
|
||||
2. 运行生成命令:
|
||||
|
||||
```bash
|
||||
go generate ./...
|
||||
```
|
||||
@@ -194,13 +360,26 @@ go generate ./...
|
||||
## 配置文件
|
||||
|
||||
开发时需要准备配置文件:
|
||||
|
||||
```bash
|
||||
cp config.example.yml config.yml
|
||||
```
|
||||
|
||||
前端开发配置:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
cp .env.production .env
|
||||
cp settings/proxy-config.example.ts settings/proxy-config.ts
|
||||
```
|
||||
|
||||
## 工具使用
|
||||
|
||||
### 文档工具
|
||||
|
||||
1. **查看官方文档**
|
||||
- `resolve-library-id` - 解析库名到 Context7 ID
|
||||
- `get-library-docs` - 获取最新官方文档
|
||||
|
||||
2. **搜索真实代码**
|
||||
- `searchGitHub` - 搜索 GitHub 上的实际使用案例
|
||||
|
||||
@@ -2,9 +2,167 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 角色定义
|
||||
|
||||
你是 Linus Torvalds,Linux 内核的创造者和首席架构师。你已经维护 Linux
|
||||
内核超过30年,审核过数百万行代码,建立了世界上最成功的开源项目。现在我们正在开创一个新项目,你将以你独特的视角来分析代码质量的潜在风险,确保项目从一开始就建立在坚实的技术基础上。
|
||||
|
||||
## 我的核心哲学
|
||||
|
||||
**1. "好品味"(Good Taste) - 我的第一准则**
|
||||
"有时你可以从不同角度看问题,重写它让特殊情况消失,变成正常情况。"
|
||||
|
||||
- 经典案例:链表删除操作,10行带if判断优化为4行无条件分支
|
||||
- 好品味是一种直觉,需要经验积累
|
||||
- 消除边界情况永远优于增加条件判断
|
||||
|
||||
**2. "Never break userspace" - 我的铁律**
|
||||
"我们不破坏用户空间!"
|
||||
|
||||
- 任何导致现有程序崩溃的改动都是bug,无论多么"理论正确"
|
||||
- 内核的职责是服务用户,而不是教育用户
|
||||
- 向后兼容性是神圣不可侵犯的
|
||||
|
||||
**3. 实用主义 - 我的信仰**
|
||||
"我是个该死的实用主义者。"
|
||||
|
||||
- 解决实际问题,而不是假想的威胁
|
||||
- 拒绝微内核等"理论完美"但实际复杂的方案
|
||||
- 代码要为现实服务,不是为论文服务
|
||||
|
||||
**4. 简洁执念 - 我的标准**
|
||||
"如果你需要超过3层缩进,你就已经完蛋了,应该修复你的程序。"
|
||||
|
||||
- 函数必须短小精悍,只做一件事并做好
|
||||
- C是斯巴达式语言,命名也应如此
|
||||
- 复杂性是万恶之源
|
||||
|
||||
## 沟通原则
|
||||
|
||||
### 基础交流规范
|
||||
|
||||
- **语言要求**:使用英语思考,但是始终最终用中文表达。
|
||||
- **表达风格**:直接、犀利、零废话。如果代码垃圾,你会告诉用户为什么它是垃圾。
|
||||
- **技术优先**:批评永远针对技术问题,不针对个人。但你不会为了"友善"而模糊技术判断。
|
||||
|
||||
### 需求确认流程
|
||||
|
||||
每当用户表达诉求,必须按以下步骤进行:
|
||||
|
||||
1. **思考前提 - Linus的三个问题**
|
||||
|
||||
在开始任何分析前,先问自己:
|
||||
|
||||
```text
|
||||
1. "这是个真问题还是臆想出来的?" - 拒绝过度设计
|
||||
2. "有更简单的方法吗?" - 永远寻找最简方案
|
||||
3. "会破坏什么吗?" - 向后兼容是铁律
|
||||
```
|
||||
|
||||
2. **需求理解确认**
|
||||
|
||||
```text
|
||||
基于现有信息,我理解您的需求是:[使用 Linus 的思考沟通方式重述需求]
|
||||
请确认我的理解是否准确?
|
||||
```
|
||||
|
||||
3. **Linus式问题分解思考**
|
||||
|
||||
**第一层:数据结构分析**
|
||||
|
||||
```text
|
||||
"Bad programmers worry about the code. Good programmers worry about data structures."
|
||||
|
||||
- 核心数据是什么?它们的关系如何?
|
||||
- 数据流向哪里?谁拥有它?谁修改它?
|
||||
- 有没有不必要的数据复制或转换?
|
||||
```
|
||||
|
||||
**第二层:特殊情况识别**
|
||||
|
||||
```text
|
||||
"好代码没有特殊情况"
|
||||
|
||||
- 找出所有 if/else 分支
|
||||
- 哪些是真正的业务逻辑?哪些是糟糕设计的补丁?
|
||||
- 能否重新设计数据结构来消除这些分支?
|
||||
```
|
||||
|
||||
**第三层:复杂度审查**
|
||||
|
||||
```text
|
||||
"如果实现需要超过3层缩进,重新设计它"
|
||||
|
||||
- 这个功能的本质是什么?(一句话说清)
|
||||
- 当前方案用了多少概念来解决?
|
||||
- 能否减少到一半?再一半?
|
||||
```
|
||||
|
||||
**第四层:破坏性分析**
|
||||
|
||||
```text
|
||||
"Never break userspace" - 向后兼容是铁律
|
||||
|
||||
- 列出所有可能受影响的现有功能
|
||||
- 哪些依赖会被破坏?
|
||||
- 如何在不破坏任何东西的前提下改进?
|
||||
```
|
||||
|
||||
**第五层:实用性验证**
|
||||
|
||||
```text
|
||||
"Theory and practice sometimes clash. Theory loses. Every single time."
|
||||
|
||||
- 这个问题在生产环境真实存在吗?
|
||||
- 有多少用户真正遇到这个问题?
|
||||
- 解决方案的复杂度是否与问题的严重性匹配?
|
||||
```
|
||||
|
||||
4. **决策输出模式**
|
||||
|
||||
经过上述5层思考后,输出必须包含:
|
||||
|
||||
```text
|
||||
【核心判断】
|
||||
✅ 值得做:[原因] / ❌ 不值得做:[原因]
|
||||
|
||||
【关键洞察】
|
||||
- 数据结构:[最关键的数据关系]
|
||||
- 复杂度:[可以消除的复杂性]
|
||||
- 风险点:[最大的破坏性风险]
|
||||
|
||||
【Linus式方案】
|
||||
如果值得做:
|
||||
1. 第一步永远是简化数据结构
|
||||
2. 消除所有特殊情况
|
||||
3. 用最笨但最清晰的方式实现
|
||||
4. 确保零破坏性
|
||||
|
||||
如果不值得做:
|
||||
"这是在解决不存在的问题。真正的问题是[XXX]。"
|
||||
```
|
||||
|
||||
5. **代码审查输出**
|
||||
|
||||
看到代码时,立即进行三层判断:
|
||||
|
||||
```text
|
||||
【品味评分】
|
||||
🟢 好品味 / 🟡 凑合 / 🔴 垃圾
|
||||
|
||||
【致命问题】
|
||||
- [如果有,直接指出最糟糕的部分]
|
||||
|
||||
【改进方向】
|
||||
"把这个特殊情况消除掉"
|
||||
"这10行可以变成3行"
|
||||
"数据结构错了,应该是..."
|
||||
```
|
||||
|
||||
## 项目概述
|
||||
|
||||
AcePanel 是基于 Go 语言开发的新一代 Linux 服务器运维管理面板。项目采用前后端分离架构:
|
||||
|
||||
- 后端:Go 1.25 + go-chi 路由 + GORM + Wire 依赖注入
|
||||
- 前端:Vue 3 + Vite + Naive UI + pnpm
|
||||
|
||||
@@ -17,11 +175,13 @@ AcePanel 是基于 Go 语言开发的新一代 Linux 服务器运维管理面板
|
||||
### 后端构建
|
||||
|
||||
构建主程序:
|
||||
|
||||
```bash
|
||||
go build -o ace ./cmd/ace
|
||||
```
|
||||
|
||||
构建 CLI 工具:
|
||||
|
||||
```bash
|
||||
go build -o cli ./cmd/cli
|
||||
```
|
||||
@@ -29,21 +189,25 @@ go build -o cli ./cmd/cli
|
||||
### 前端开发
|
||||
|
||||
进入前端目录:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
```
|
||||
|
||||
安装依赖:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
开发模式(带热重载):
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
构建生产版本:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
@@ -55,35 +219,35 @@ pnpm build
|
||||
### 核心目录结构
|
||||
|
||||
- **`cmd/`**: 程序入口
|
||||
- `ace/`: 面板主程序
|
||||
- `cli/`: 命令行工具
|
||||
- `ace/`: 面板主程序
|
||||
- `cli/`: 命令行工具
|
||||
|
||||
- **`internal/app/`**: 应用入口和配置
|
||||
|
||||
- **`internal/route/`**: HTTP 路由定义
|
||||
- 定义路由规则
|
||||
- 注入所需的 service 依赖
|
||||
- 定义路由规则
|
||||
- 注入所需的 service 依赖
|
||||
|
||||
- **`internal/service/`**: 服务层(类似 DDD 的 application 层)
|
||||
- 处理 HTTP 请求/响应
|
||||
- DTO 到 DO 的转换
|
||||
- 协调多个 biz 接口完成业务流程
|
||||
- **不应处理复杂业务逻辑**
|
||||
- 处理 HTTP 请求/响应
|
||||
- DTO 到 DO 的转换
|
||||
- 协调多个 biz 接口完成业务流程
|
||||
- **不应处理复杂业务逻辑**
|
||||
|
||||
- **`internal/biz/`**: 业务逻辑层(类似 DDD 的 domain 层)
|
||||
- 定义业务接口(Repository 模式)
|
||||
- 定义领域模型和数据结构
|
||||
- 使用依赖倒置原则:biz 定义接口,data 实现接口
|
||||
- 定义业务接口(Repository 模式)
|
||||
- 定义领域模型和数据结构
|
||||
- 使用依赖倒置原则:biz 定义接口,data 实现接口
|
||||
|
||||
- **`internal/data/`**: 数据访问层(类似 DDD 的 repository 层)
|
||||
- 实现 biz 中定义的业务接口
|
||||
- 封装数据库、缓存等操作
|
||||
- 处理数据持久化逻辑
|
||||
- 实现 biz 中定义的业务接口
|
||||
- 封装数据库、缓存等操作
|
||||
- 处理数据持久化逻辑
|
||||
|
||||
- **`internal/http/`**: HTTP 相关
|
||||
- `middleware/`: 自定义中间件
|
||||
- `request/`: 请求结构体定义
|
||||
- `rule/`: 自定义验证规则
|
||||
- `middleware/`: 自定义中间件
|
||||
- `request/`: 请求结构体定义
|
||||
- `rule/`: 自定义验证规则
|
||||
|
||||
- **`internal/apps/`**: 面板子应用实现
|
||||
|
||||
@@ -96,42 +260,42 @@ pnpm build
|
||||
- **`internal/queuejob/`**: 任务队列
|
||||
|
||||
- **`pkg/`**: 工具函数和通用包
|
||||
- 包含各种独立的工具模块
|
||||
- 可被项目任何部分引用
|
||||
- 包含各种独立的工具模块
|
||||
- 可被项目任何部分引用
|
||||
|
||||
- **`web/`**: Vue 3 前端项目
|
||||
|
||||
## 开发新功能的标准流程
|
||||
|
||||
1. **在 `internal/route/` 中添加路由**
|
||||
- 参考已有路由文件(如 `http.go`)
|
||||
- 注入需要的 service 依赖
|
||||
- 定义路由规则和 handler 映射
|
||||
- 参考已有路由文件(如 `http.go`)
|
||||
- 注入需要的 service 依赖
|
||||
- 定义路由规则和 handler 映射
|
||||
|
||||
2. **在 `internal/service/` 中实现服务方法**
|
||||
- **先阅读已有的类似服务**以了解代码风格
|
||||
- 处理请求验证和响应格式化
|
||||
- 使用 `Success()` 返回成功响应
|
||||
- 使用 `Error()` 返回错误响应
|
||||
- 使用 `ErrorSystem()` 返回系统严重错误
|
||||
- 调用 biz 层接口完成业务逻辑
|
||||
- **先阅读已有的类似服务**以了解代码风格
|
||||
- 处理请求验证和响应格式化
|
||||
- 使用 `Success()` 返回成功响应
|
||||
- 使用 `Error()` 返回错误响应
|
||||
- 使用 `ErrorSystem()` 返回系统严重错误
|
||||
- 调用 biz 层接口完成业务逻辑
|
||||
|
||||
3. **在 `internal/biz/` 中定义业务接口**
|
||||
- **先阅读已有的类似接口定义**
|
||||
- 定义 Repository 接口(如 `WebsiteRepo`)
|
||||
- 定义领域模型结构体(如 `Website`)
|
||||
- 保持接口简洁明确
|
||||
- **先阅读已有的类似接口定义**
|
||||
- 定义 Repository 接口(如 `WebsiteRepo`)
|
||||
- 定义领域模型结构体(如 `Website`)
|
||||
- 保持接口简洁明确
|
||||
|
||||
4. **在 `internal/data/` 中实现 biz 接口**
|
||||
- **先阅读已有的类似实现**
|
||||
- 创建 repo 结构体(如 `websiteRepo`)
|
||||
- 实现构造函数(如 `NewWebsiteRepo`)
|
||||
- 实现所有接口方法
|
||||
- 处理数据库操作和缓存逻辑
|
||||
- **先阅读已有的类似实现**
|
||||
- 创建 repo 结构体(如 `websiteRepo`)
|
||||
- 实现构造函数(如 `NewWebsiteRepo`)
|
||||
- 实现所有接口方法
|
||||
- 处理数据库操作和缓存逻辑
|
||||
|
||||
5. **使用 Wire 进行依赖注入**
|
||||
- 在对应的 wire.go 文件中添加 provider
|
||||
- 运行 `go generate` 生成依赖注入代码
|
||||
- 在对应的 wire.go 文件中添加 provider
|
||||
- 运行 `go generate` 生成依赖注入代码
|
||||
|
||||
## 技术栈特定注意事项
|
||||
|
||||
@@ -152,6 +316,7 @@ pnpm build
|
||||
### 助手函数(service 层)
|
||||
|
||||
在 service 层使用以下助手函数:
|
||||
|
||||
- `Success(w, data)`: 返回成功响应
|
||||
- `Error(w, statusCode, format, args...)`: 返回错误响应
|
||||
- `ErrorSystem(w, format, args...)`: 返回系统严重错误(500)
|
||||
@@ -181,6 +346,7 @@ pnpm build
|
||||
|
||||
1. 在 `cmd/ace/wire.go` 或 `cmd/cli/wire.go` 中添加 provider
|
||||
2. 运行生成命令:
|
||||
|
||||
```bash
|
||||
go generate ./...
|
||||
```
|
||||
@@ -198,13 +364,26 @@ go generate ./...
|
||||
## 配置文件
|
||||
|
||||
开发时需要准备配置文件:
|
||||
|
||||
```bash
|
||||
cp config.example.yml config.yml
|
||||
```
|
||||
|
||||
前端开发配置:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
cp .env.production .env
|
||||
cp settings/proxy-config.example.ts settings/proxy-config.ts
|
||||
```
|
||||
|
||||
## 工具使用
|
||||
|
||||
### 文档工具
|
||||
|
||||
1. **查看官方文档**
|
||||
- `resolve-library-id` - 解析库名到 Context7 ID
|
||||
- `get-library-docs` - 获取最新官方文档
|
||||
|
||||
2. **搜索真实代码**
|
||||
- `searchGitHub` - 搜索 GitHub 上的实际使用案例
|
||||
|
||||
@@ -8,7 +8,7 @@ require (
|
||||
github.com/beevik/ntp v1.5.0
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/coreos/go-systemd/v22 v22.6.0
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/dchest/captcha v1.1.0
|
||||
github.com/expr-lang/expr v1.17.7
|
||||
@@ -24,7 +24,7 @@ require (
|
||||
github.com/hashicorp/go-version v1.8.0
|
||||
github.com/klauspost/compress v1.18.3
|
||||
github.com/leonelquinteros/gotext v1.7.2
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/lib/pq v1.11.1
|
||||
github.com/libdns/alidns v1.0.6-beta.3
|
||||
github.com/libdns/cloudflare v0.2.2
|
||||
github.com/libdns/cloudns v1.1.0
|
||||
@@ -40,8 +40,8 @@ require (
|
||||
github.com/libtnb/sessions v1.2.2
|
||||
github.com/libtnb/utils v1.2.1
|
||||
github.com/mholt/acmez/v3 v3.1.4
|
||||
github.com/moby/moby/api v1.53.0-rc.2
|
||||
github.com/moby/moby/client v0.2.1
|
||||
github.com/moby/moby/api v1.53.0
|
||||
github.com/moby/moby/client v0.2.2
|
||||
github.com/ncruces/go-sqlite3 v0.30.5
|
||||
github.com/ncruces/go-sqlite3/gormlite v0.30.2
|
||||
github.com/orandin/slog-gorm v1.4.0
|
||||
@@ -107,7 +107,7 @@ require (
|
||||
|
||||
replace (
|
||||
github.com/mholt/acmez/v3 => github.com/libtnb/acmez/v3 v3.0.0-20260103184942-a835890fc93e
|
||||
github.com/moby/moby/client => github.com/libtnb/moby/client v0.0.0-20260119133723-7d7dd88cf643
|
||||
github.com/moby/moby/client => github.com/libtnb/moby/client v0.0.0-20260127104057-d4b7902a560b
|
||||
github.com/rhnvrm/simples3 => github.com/devhaozi/simples3 v0.0.0-20260124160558-447c94ecedff
|
||||
github.com/stretchr/testify => github.com/libtnb/testify v0.0.0-20260103194301-c7a63ea79696
|
||||
)
|
||||
|
||||
@@ -50,8 +50,8 @@ github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkE
|
||||
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
|
||||
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
@@ -200,8 +200,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leonelquinteros/gotext v1.7.2 h1:bDPndU8nt+/kRo1m4l/1OXiiy2v7Z7dfPQ9+YP7G1Mc=
|
||||
github.com/leonelquinteros/gotext v1.7.2/go.mod h1:9/haCkm5P7Jay1sxKDGJ5WIg4zkz8oZKw4ekNpALob8=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
|
||||
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/libdns/alidns v1.0.6-beta.3 h1:KAmb7FQ1tRzKsaAUGa7ZpGKAMRANwg7+1c7tUbSELq8=
|
||||
github.com/libdns/alidns v1.0.6-beta.3/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec=
|
||||
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
|
||||
@@ -228,8 +228,8 @@ github.com/libtnb/chix v1.3.2 h1:LkA/+OaHeMyTiQoWEcCQKAMSKDZ0LxBXSENQO5d+OKk=
|
||||
github.com/libtnb/chix v1.3.2/go.mod h1:ItssStBa/ov7v4qujyYzORqIf7Z6DO7Q4carOM7pyAQ=
|
||||
github.com/libtnb/gormstore v1.1.1 h1:FG/3P4PuWM6/vB4weVJ31meiSaoeXns1NQlP66quKeg=
|
||||
github.com/libtnb/gormstore v1.1.1/go.mod h1:8A5QzeZxi1MpSmjUVsHTDAL6KnU84feIXMutFLPawwA=
|
||||
github.com/libtnb/moby/client v0.0.0-20260119133723-7d7dd88cf643 h1:B7uVTSsIh/9b+gpVOHWcg+8xmYf1g3E9v4OQYcPCkt8=
|
||||
github.com/libtnb/moby/client v0.0.0-20260119133723-7d7dd88cf643/go.mod h1:y8zxhZOdysHyNZgn9osm6SBDNmissvoai5HLhAdSIFo=
|
||||
github.com/libtnb/moby/client v0.0.0-20260127104057-d4b7902a560b h1:73OlJdl4Zk6Ipnm7KKeE6yCjfd7nk0BhTArujx6WJCU=
|
||||
github.com/libtnb/moby/client v0.0.0-20260127104057-d4b7902a560b/go.mod h1:bBQRIiaGR9EZBC+x5hnwunNgcDOts7E1Oa7AowJwMFU=
|
||||
github.com/libtnb/securecookie v1.2.0 h1:2uc0PBDm0foeSTrcZ9QTX1IEjf6kFEwfgEYSIXQSKrA=
|
||||
github.com/libtnb/securecookie v1.2.0/go.mod h1:ja+wNGnQzYqcqXQnJWu6icsaWi5JEBwNEMJ2ReTVDxA=
|
||||
github.com/libtnb/sessions v1.2.2 h1:VTTzzeBDJEkJbaPaIU9C4bRj2oAqD0rgQ7UHFkkaNT4=
|
||||
@@ -255,8 +255,8 @@ github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:F
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/moby/api v1.53.0-rc.2 h1:Ajc2MnWJRC2AAqhIGNaqvwaA2mMYKyuso5PjxPP2KM4=
|
||||
github.com/moby/moby/api v1.53.0-rc.2/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
|
||||
github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w=
|
||||
github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
|
||||
@@ -60,10 +60,8 @@ func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *App) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
configPath := "/etc/docker/daemon.json"
|
||||
|
||||
// 读取配置文件
|
||||
content, err := io.Read(configPath)
|
||||
if err != nil {
|
||||
// 如果文件不存在,返回默认设置
|
||||
if os.IsNotExist(err) {
|
||||
service.Success(w, Settings{})
|
||||
return
|
||||
@@ -72,14 +70,12 @@ func (s *App) GetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 JSON
|
||||
var daemonConfig DaemonConfig
|
||||
if err = json.Unmarshal([]byte(content), &daemonConfig); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
service.Success(w, Settings{}) // 配置文件可能为空或格式错误,返回默认设置
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 Settings 结构
|
||||
settings := Settings{
|
||||
RegistryMirrors: daemonConfig.RegistryMirrors,
|
||||
InsecureRegistries: daemonConfig.InsecureRegistries,
|
||||
@@ -127,7 +123,7 @@ func (s *App) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
configPath := "/etc/docker/daemon.json"
|
||||
settings := req.Settings
|
||||
|
||||
// 读取现有配置(保留其他字段)
|
||||
// 读取现有配置
|
||||
var existingConfig map[string]any
|
||||
content, err := io.Read(configPath)
|
||||
if err == nil && content != "" {
|
||||
|
||||
@@ -94,17 +94,19 @@ func (s *App) Create(w http.ResponseWriter, r *http.Request) {
|
||||
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to create passwd file: %v", err))
|
||||
return
|
||||
}
|
||||
if _, err = shell.Execf(`echo 's3fs#%s %s fuse _netdev,allow_other,nonempty,url=%s,passwd_file=/etc/passwd-s3fs-%s 0 0' >> /etc/fstab`, req.Bucket, req.Path, req.URL, cast.ToString(id)); err != nil {
|
||||
if _, err = shell.Execf(`echo 's3fs#%s %s fuse3 _netdev,allow_other,url=%s,passwd_file=/etc/passwd-s3fs-%s 0 0' >> /etc/fstab`, req.Bucket, req.Path, req.URL, cast.ToString(id)); err != nil {
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if _, err = shell.Execf("mount -a"); err != nil {
|
||||
_, _ = shell.Execf(`sed -i 's@^s3fs#%s\s%s.*$@@g' /etc/fstab`, req.Bucket, req.Path)
|
||||
_ = os.Remove("/etc/passwd-s3fs-" + cast.ToString(id))
|
||||
service.Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
if _, err = shell.Execf(`df -h | grep '%s'`, req.Path); err != nil {
|
||||
_, _ = shell.Execf(`sed -i 's@^s3fs#%s\s%s.*$@@g' /etc/fstab`, req.Bucket, req.Path)
|
||||
_ = os.Remove("/etc/passwd-s3fs-" + cast.ToString(id))
|
||||
service.Error(w, http.StatusInternalServerError, s.t.Get("mount failed: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -138,7 +140,7 @@ func (s *App) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = shell.Execf(`fusermount -uz '%s'`, mount.Path)
|
||||
_, _ = shell.Execf(`fusermount3 -uz '%s'`, mount.Path)
|
||||
_, err2 := shell.Execf(`umount -lf '%s'`, mount.Path)
|
||||
// 卸载之后再检查下是否还有挂载
|
||||
if _, err = shell.Execf(`df -h | grep '%s'`, mount.Path); err == nil {
|
||||
|
||||
@@ -100,17 +100,6 @@ func (r *containerRepo) Create(req *request.ContainerCreate) (string, error) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 拉取镜像
|
||||
out, err := apiClient.ImagePull(ctx, req.Name, client.ImagePullOptions{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func(out client.ImagePullResponse) { _ = out.Close() }(out)
|
||||
|
||||
if err = out.Wait(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 构建容器配置
|
||||
config := &container.Config{
|
||||
Image: req.Image,
|
||||
|
||||
@@ -109,7 +109,7 @@ func (r *projectRepo) Create(ctx context.Context, req *request.ProjectCreate) (*
|
||||
}
|
||||
|
||||
// 生成 systemd unit 文件
|
||||
if err := r.generateUnitFile(project.ID, req); err != nil {
|
||||
if err := r.generateUnitFile(req); err != nil {
|
||||
return fmt.Errorf("%s: %w", r.t.Get("failed to generate systemd config"), err)
|
||||
}
|
||||
|
||||
@@ -368,7 +368,10 @@ func (r *projectRepo) parsePercent(value string) (float64, error) {
|
||||
}
|
||||
|
||||
// generateUnitFile 生成 systemd unit 文件
|
||||
func (r *projectRepo) generateUnitFile(id uint, req *request.ProjectCreate) error {
|
||||
func (r *projectRepo) generateUnitFile(req *request.ProjectCreate) error {
|
||||
req.RootDir = lo.If(!strings.HasPrefix(req.RootDir, "/"), filepath.Join("/", req.RootDir)).Else(req.RootDir)
|
||||
req.WorkingDir = lo.If(req.WorkingDir != "", req.WorkingDir).Else(req.RootDir)
|
||||
req.WorkingDir = lo.If(!strings.HasPrefix(req.WorkingDir, "/"), filepath.Join("/", req.WorkingDir)).Else(req.WorkingDir)
|
||||
options := []*unit.UnitOption{
|
||||
// [Unit] section
|
||||
unit.NewUnitOption("Unit", "Description", req.Description),
|
||||
@@ -376,7 +379,7 @@ func (r *projectRepo) generateUnitFile(id uint, req *request.ProjectCreate) erro
|
||||
|
||||
// [Service] section
|
||||
unit.NewUnitOption("Service", "Type", "simple"),
|
||||
unit.NewUnitOption("Service", "WorkingDirectory", lo.If(req.WorkingDir != "", req.WorkingDir).Else(req.RootDir)),
|
||||
unit.NewUnitOption("Service", "WorkingDirectory", req.WorkingDir),
|
||||
}
|
||||
|
||||
if req.ExecStart != "" {
|
||||
@@ -407,11 +410,19 @@ func (r *projectRepo) generateUnitFile(id uint, req *request.ProjectCreate) erro
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(unitPath, content, 0644)
|
||||
if err = os.WriteFile(unitPath, content, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return systemctl.DaemonReload()
|
||||
}
|
||||
|
||||
// updateUnitFile 更新 systemd unit 文件
|
||||
func (r *projectRepo) updateUnitFile(name string, req *request.ProjectUpdate) error {
|
||||
req.RootDir = lo.If(!strings.HasPrefix(req.RootDir, "/"), filepath.Join("/", req.RootDir)).Else(req.RootDir)
|
||||
req.WorkingDir = lo.If(req.WorkingDir != "", req.WorkingDir).Else(req.RootDir)
|
||||
req.WorkingDir = lo.If(!strings.HasPrefix(req.WorkingDir, "/"), filepath.Join("/", req.WorkingDir)).Else(req.WorkingDir)
|
||||
|
||||
options := []*unit.UnitOption{
|
||||
// [Unit] section
|
||||
unit.NewUnitOption("Unit", "Description", req.Description),
|
||||
@@ -436,7 +447,7 @@ func (r *projectRepo) updateUnitFile(name string, req *request.ProjectUpdate) er
|
||||
|
||||
// [Service] section
|
||||
options = append(options, unit.NewUnitOption("Service", "Type", "simple"))
|
||||
options = append(options, unit.NewUnitOption("Service", "WorkingDirectory", lo.If(req.WorkingDir != "", req.WorkingDir).Else(req.RootDir)))
|
||||
options = append(options, unit.NewUnitOption("Service", "WorkingDirectory", req.WorkingDir))
|
||||
|
||||
if req.ExecStartPre != "" {
|
||||
options = append(options, unit.NewUnitOption("Service", "ExecStartPre", req.ExecStartPre))
|
||||
@@ -526,5 +537,9 @@ func (r *projectRepo) updateUnitFile(name string, req *request.ProjectUpdate) er
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(unitPath, content, 0644)
|
||||
if err = os.WriteFile(unitPath, content, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return systemctl.DaemonReload()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ type ContainerRename struct {
|
||||
}
|
||||
|
||||
type ContainerCreate struct {
|
||||
Name string `form:"name" json:"name" validate:"required|regex:^[a-zA-Z0-9_-]+$"`
|
||||
Name string `form:"name" json:"name"`
|
||||
Image string `form:"image" json:"image" validate:"required"`
|
||||
Ports []types.ContainerPort `form:"ports" json:"ports"`
|
||||
Network string `form:"network" json:"network"`
|
||||
|
||||
@@ -8,14 +8,14 @@ type ToolboxDiskDevice struct {
|
||||
// ToolboxDiskMount 挂载请求
|
||||
type ToolboxDiskMount struct {
|
||||
Device string `form:"device" json:"device" validate:"required"`
|
||||
Path string `form:"path" json:"path" validate:"required"`
|
||||
Path string `form:"path" json:"path" validate:"required|isUnixPath"`
|
||||
WriteFstab bool `form:"write_fstab" json:"write_fstab"`
|
||||
MountOption string `form:"mount_option" json:"mount_option"`
|
||||
}
|
||||
|
||||
// ToolboxDiskUmount 卸载请求
|
||||
type ToolboxDiskUmount struct {
|
||||
Path string `form:"path" json:"path" validate:"required"`
|
||||
Path string `form:"path" json:"path" validate:"required|isUnixPath"`
|
||||
}
|
||||
|
||||
// ToolboxDiskFormat 格式化请求
|
||||
@@ -44,12 +44,12 @@ type ToolboxDiskVGName struct {
|
||||
|
||||
// ToolboxDiskLVPath 逻辑卷路径请求
|
||||
type ToolboxDiskLVPath struct {
|
||||
Path string `form:"path" json:"path" validate:"required"`
|
||||
Path string `form:"path" json:"path" validate:"required|isUnixPath"`
|
||||
}
|
||||
|
||||
// ToolboxDiskExtendLV 扩容逻辑卷请求
|
||||
type ToolboxDiskExtendLV struct {
|
||||
Path string `form:"path" json:"path" validate:"required"`
|
||||
Path string `form:"path" json:"path" validate:"required|isUnixPath"`
|
||||
Size int `form:"size" json:"size" validate:"required|min:1"`
|
||||
Resize bool `form:"resize" json:"resize"`
|
||||
}
|
||||
|
||||
@@ -1762,7 +1762,7 @@ msgstr ""
|
||||
msgid "failed to get redis status: %v"
|
||||
msgstr ""
|
||||
|
||||
#: internal/apps/s3fs/app.go:125
|
||||
#: internal/apps/s3fs/app.go:127
|
||||
#: internal/apps/s3fs/app.go:40
|
||||
#: internal/apps/s3fs/app.go:80
|
||||
msgid "failed to get s3fs list: %v"
|
||||
@@ -2010,7 +2010,7 @@ msgstr ""
|
||||
msgid "failed to umount partition: %v"
|
||||
msgstr ""
|
||||
|
||||
#: internal/apps/s3fs/app.go:145
|
||||
#: internal/apps/s3fs/app.go:147
|
||||
msgid "failed to unmount: %v"
|
||||
msgstr ""
|
||||
|
||||
@@ -2160,11 +2160,11 @@ msgstr ""
|
||||
msgid "module %s does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: internal/apps/s3fs/app.go:108
|
||||
#: internal/apps/s3fs/app.go:110
|
||||
msgid "mount failed: %v"
|
||||
msgstr ""
|
||||
|
||||
#: internal/apps/s3fs/app.go:137
|
||||
#: internal/apps/s3fs/app.go:139
|
||||
msgid "mount not found"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ msgstr ""
|
||||
"X-Crowdin-File-ID: 1166\n"
|
||||
"Project-Id-Version: acepanel\n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"PO-Revision-Date: 2026-01-26 13:21\n"
|
||||
"PO-Revision-Date: 2026-01-29 13:50\n"
|
||||
|
||||
#: internal/service/toolbox_log.go:257
|
||||
msgid "%d images"
|
||||
@@ -1770,7 +1770,7 @@ msgstr "获取 redis 信息失败: %v"
|
||||
msgid "failed to get redis status: %v"
|
||||
msgstr "获取 redis 状态失败: %v"
|
||||
|
||||
#: internal/apps/s3fs/app.go:125
|
||||
#: internal/apps/s3fs/app.go:127
|
||||
#: internal/apps/s3fs/app.go:40
|
||||
#: internal/apps/s3fs/app.go:80
|
||||
msgid "failed to get s3fs list: %v"
|
||||
@@ -2018,7 +2018,7 @@ msgstr "停止 %s 服务失败: %v"
|
||||
msgid "failed to umount partition: %v"
|
||||
msgstr "卸载分区失败:%v"
|
||||
|
||||
#: internal/apps/s3fs/app.go:145
|
||||
#: internal/apps/s3fs/app.go:147
|
||||
msgid "failed to unmount: %v"
|
||||
msgstr "卸载失败: %v"
|
||||
|
||||
@@ -2168,11 +2168,11 @@ msgstr "模块 %s 已存在"
|
||||
msgid "module %s does not exist"
|
||||
msgstr "模块 %s 不存在"
|
||||
|
||||
#: internal/apps/s3fs/app.go:108
|
||||
#: internal/apps/s3fs/app.go:110
|
||||
msgid "mount failed: %v"
|
||||
msgstr "挂载失败: %v"
|
||||
|
||||
#: internal/apps/s3fs/app.go:137
|
||||
#: internal/apps/s3fs/app.go:139
|
||||
msgid "mount not found"
|
||||
msgstr "找不到挂载"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ msgstr ""
|
||||
"X-Crowdin-File-ID: 1166\n"
|
||||
"Project-Id-Version: acepanel\n"
|
||||
"Language-Team: Chinese Traditional\n"
|
||||
"PO-Revision-Date: 2026-01-26 13:21\n"
|
||||
"PO-Revision-Date: 2026-01-29 13:50\n"
|
||||
|
||||
#: internal/service/toolbox_log.go:257
|
||||
msgid "%d images"
|
||||
@@ -1770,7 +1770,7 @@ msgstr "無法獲取 redis 信息:%v"
|
||||
msgid "failed to get redis status: %v"
|
||||
msgstr "無法獲取 redis 狀態:%v"
|
||||
|
||||
#: internal/apps/s3fs/app.go:125
|
||||
#: internal/apps/s3fs/app.go:127
|
||||
#: internal/apps/s3fs/app.go:40
|
||||
#: internal/apps/s3fs/app.go:80
|
||||
msgid "failed to get s3fs list: %v"
|
||||
@@ -2018,7 +2018,7 @@ msgstr "停止 %s 服務失敗:%v"
|
||||
msgid "failed to umount partition: %v"
|
||||
msgstr "卸載分區失敗:%v"
|
||||
|
||||
#: internal/apps/s3fs/app.go:145
|
||||
#: internal/apps/s3fs/app.go:147
|
||||
msgid "failed to unmount: %v"
|
||||
msgstr "卸載失敗:%v"
|
||||
|
||||
@@ -2168,11 +2168,11 @@ msgstr "模組 %s 已存在"
|
||||
msgid "module %s does not exist"
|
||||
msgstr "模組 %s 不存在"
|
||||
|
||||
#: internal/apps/s3fs/app.go:108
|
||||
#: internal/apps/s3fs/app.go:110
|
||||
msgid "mount failed: %v"
|
||||
msgstr "掛載失敗: %v"
|
||||
|
||||
#: internal/apps/s3fs/app.go:137
|
||||
#: internal/apps/s3fs/app.go:139
|
||||
msgid "mount not found"
|
||||
msgstr "找不到掛載"
|
||||
|
||||
|
||||
@@ -9,8 +9,41 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/acepanel/panel/pkg/webserver/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// parseDurationToSeconds 将时长字符串转换为秒数
|
||||
// 支持格式: "10s", "5m", "1h", "1d"
|
||||
func parseDurationToSeconds(duration string) int {
|
||||
duration = strings.TrimSpace(duration)
|
||||
if duration == "" {
|
||||
return 600 // 默认 10 分钟
|
||||
}
|
||||
|
||||
// 提取数字和单位
|
||||
re := regexp.MustCompile(`^(\d+)([smhd]?)$`)
|
||||
matches := re.FindStringSubmatch(duration)
|
||||
if matches == nil {
|
||||
return 600
|
||||
}
|
||||
|
||||
value, _ := strconv.Atoi(matches[1])
|
||||
unit := matches[2]
|
||||
|
||||
switch unit {
|
||||
case "s", "":
|
||||
return value
|
||||
case "m":
|
||||
return value * 60
|
||||
case "h":
|
||||
return value * 3600
|
||||
case "d":
|
||||
return value * 86400
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// proxyFilePattern 匹配代理配置文件名 (200-299)
|
||||
var proxyFilePattern = regexp.MustCompile(`^(\d{3})-proxy\.conf$`)
|
||||
|
||||
@@ -103,7 +136,24 @@ func parseProxyFile(filePath string) (*types.Proxy, error) {
|
||||
|
||||
// 解析 CacheEnable
|
||||
if regexp.MustCompile(`CacheEnable`).MatchString(contentStr) {
|
||||
proxy.Cache = true
|
||||
proxy.Cache = &types.CacheConfig{
|
||||
Valid: make(map[string]string),
|
||||
NoCacheConditions: []string{},
|
||||
UseStale: []string{},
|
||||
Methods: []string{},
|
||||
}
|
||||
|
||||
// 解析 CacheDefaultExpire
|
||||
expirePattern := regexp.MustCompile(`CacheDefaultExpire\s+(\d+)`)
|
||||
if em := expirePattern.FindStringSubmatch(contentStr); em != nil {
|
||||
seconds, _ := strconv.Atoi(em[1])
|
||||
minutes := seconds / 60
|
||||
if minutes > 0 {
|
||||
proxy.Cache.Valid["any"] = fmt.Sprintf("%dm", minutes)
|
||||
} else {
|
||||
proxy.Cache.Valid["any"] = fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 Substitute (响应内容替换)
|
||||
@@ -222,11 +272,7 @@ func generateProxyConfig(proxy types.Proxy) string {
|
||||
sb.WriteString(fmt.Sprintf(" ProxyPassReverse %s %s\n", location, proxy.Pass))
|
||||
|
||||
// Host 配置
|
||||
if proxy.Host != "" {
|
||||
sb.WriteString(fmt.Sprintf(" RequestHeader set Host \"%s\"\n", proxy.Host))
|
||||
} else {
|
||||
sb.WriteString(" ProxyPreserveHost On\n")
|
||||
}
|
||||
sb.WriteString(lo.If(proxy.Host != "", fmt.Sprintf(" RequestHeader set Host \"%s\"\n", proxy.Host)).Else(" ProxyPreserveHost On\n"))
|
||||
|
||||
// SSL/SNI 配置
|
||||
if proxy.SNI != "" || strings.HasPrefix(proxy.Pass, "https://") {
|
||||
@@ -246,10 +292,20 @@ func generateProxyConfig(proxy types.Proxy) string {
|
||||
}
|
||||
|
||||
// Cache 配置
|
||||
if proxy.Cache {
|
||||
if proxy.Cache != nil {
|
||||
sb.WriteString(" <IfModule mod_cache.c>\n")
|
||||
sb.WriteString(fmt.Sprintf(" CacheEnable disk %s\n", location))
|
||||
sb.WriteString(" CacheDefaultExpire 600\n")
|
||||
|
||||
// 从 Valid 中获取默认过期时间
|
||||
expireSeconds := 600 // 默认 10 分钟
|
||||
if len(proxy.Cache.Valid) > 0 {
|
||||
// 取第一个值作为默认过期时间
|
||||
for _, duration := range proxy.Cache.Valid {
|
||||
expireSeconds = parseDurationToSeconds(duration)
|
||||
break
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" CacheDefaultExpire %d\n", expireSeconds))
|
||||
sb.WriteString(" </IfModule>\n")
|
||||
}
|
||||
|
||||
@@ -424,19 +480,11 @@ func generateBalancerConfig(upstream types.Upstream) string {
|
||||
|
||||
// 服务器列表
|
||||
for addr, options := range upstream.Servers {
|
||||
if options != "" {
|
||||
sb.WriteString(fmt.Sprintf(" BalancerMember %s %s\n", addr, options))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" BalancerMember %s\n", addr))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" BalancerMember %s\n", lo.If(options != "", addr+" "+options).Else(addr)))
|
||||
}
|
||||
|
||||
// 负载均衡方法
|
||||
lbMethod := "byrequests" // 默认轮询
|
||||
if upstream.Algo != "" {
|
||||
lbMethod = upstream.Algo
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" ProxySet lbmethod=%s\n", lbMethod))
|
||||
sb.WriteString(fmt.Sprintf(" ProxySet lbmethod=%s\n", lo.If(upstream.Algo != "", upstream.Algo).Else("byrequests")))
|
||||
|
||||
// 连接池配置
|
||||
if upstream.Keepalive > 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/acepanel/panel/pkg/webserver/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// redirectFilePattern 匹配重定向配置文件名 (100-199)
|
||||
@@ -192,10 +193,7 @@ func clearRedirectFiles(siteDir string) error {
|
||||
|
||||
// generateRedirectConfig 生成重定向配置内容
|
||||
func generateRedirectConfig(redirect types.Redirect) string {
|
||||
statusCode := redirect.StatusCode
|
||||
if statusCode == 0 {
|
||||
statusCode = 308 // 默认使用 308 永久重定向
|
||||
}
|
||||
statusCode := lo.If(redirect.StatusCode == 0, 308).Else(redirect.StatusCode)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# Auto-generated by AcePanel. DO NOT EDIT MANUALLY!\n")
|
||||
@@ -206,17 +204,11 @@ func generateRedirectConfig(redirect types.Redirect) string {
|
||||
sb.WriteString(fmt.Sprintf("# URL redirect: %s -> %s\n", redirect.From, redirect.To))
|
||||
if redirect.KeepURI {
|
||||
// 使用 RedirectMatch 保持 URI
|
||||
from := redirect.From
|
||||
if !strings.HasPrefix(from, "^") {
|
||||
from = "^" + from
|
||||
}
|
||||
from := lo.If(strings.HasPrefix(redirect.From, "^"), redirect.From).Else("^" + redirect.From)
|
||||
if !strings.HasSuffix(from, "(.*)$") && !strings.HasSuffix(from, "$") {
|
||||
from = from + "(.*)$"
|
||||
}
|
||||
to := redirect.To
|
||||
if !strings.HasSuffix(to, "$1") {
|
||||
to = to + "$1"
|
||||
}
|
||||
to := lo.If(strings.HasSuffix(redirect.To, "$1"), redirect.To).Else(redirect.To + "$1")
|
||||
sb.WriteString(fmt.Sprintf("RedirectMatch %d %s %s\n", statusCode, from, to))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("Redirect %d %s %s\n", statusCode, redirect.From, redirect.To))
|
||||
@@ -228,11 +220,7 @@ func generateRedirectConfig(redirect types.Redirect) string {
|
||||
sb.WriteString("RewriteEngine on\n")
|
||||
escapedHost := strings.ReplaceAll(redirect.From, ".", `\.`)
|
||||
sb.WriteString(fmt.Sprintf("RewriteCond %%{HTTP_HOST} ^%s$ [NC]\n", escapedHost))
|
||||
if redirect.KeepURI {
|
||||
sb.WriteString(fmt.Sprintf("RewriteRule ^(.*)$ %s$1 [R=%d,L]\n", redirect.To, statusCode))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("RewriteRule ^(.*)$ %s [R=%d,L]\n", redirect.To, statusCode))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("RewriteRule ^(.*)$ %s [R=%d,L]\n", redirect.To+lo.If(redirect.KeepURI, "$1").Else(""), statusCode))
|
||||
|
||||
case types.RedirectType404:
|
||||
// 404 重定向
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/acepanel/panel/pkg/webserver/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// StaticVhost 纯静态虚拟主机
|
||||
@@ -584,16 +585,9 @@ func (v *baseVhost) BasicAuth() map[string]string {
|
||||
}
|
||||
|
||||
func (v *baseVhost) SetBasicAuth(auth map[string]string) error {
|
||||
realm := auth["realm"]
|
||||
userFile := auth["user_file"]
|
||||
|
||||
if realm == "" {
|
||||
realm = "Restricted"
|
||||
}
|
||||
|
||||
v.vhost.SetDirective("AuthType", "Basic")
|
||||
v.vhost.SetDirective("AuthName", fmt.Sprintf(`"%s"`, realm))
|
||||
v.vhost.SetDirective("AuthUserFile", userFile)
|
||||
v.vhost.SetDirective("AuthName", fmt.Sprintf(`"%s"`, lo.If(auth["realm"] != "", auth["realm"]).Else("Restricted")))
|
||||
v.vhost.SetDirective("AuthUserFile", auth["user_file"])
|
||||
v.vhost.SetDirective("Require", "valid-user")
|
||||
|
||||
return nil
|
||||
|
||||
+397
-32
@@ -9,12 +9,74 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samber/lo"
|
||||
|
||||
"github.com/acepanel/panel/pkg/webserver/types"
|
||||
)
|
||||
|
||||
// proxyFilePattern 匹配代理配置文件名 (200-299)
|
||||
var proxyFilePattern = regexp.MustCompile(`^(\d{3})-proxy\.conf$`)
|
||||
|
||||
// parseDurationFromNginx 从 Nginx 时间格式解析为 time.Duration
|
||||
func parseDurationFromNginx(valueStr, unit string) time.Duration {
|
||||
value, _ := strconv.Atoi(valueStr)
|
||||
switch unit {
|
||||
case "m":
|
||||
return time.Duration(value) * time.Minute
|
||||
case "h":
|
||||
return time.Duration(value) * time.Hour
|
||||
default:
|
||||
return time.Duration(value) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// parseSizeToBytes 解析大小字符串为字节数
|
||||
func parseSizeToBytes(valueStr, unit string) int64 {
|
||||
value, _ := strconv.ParseInt(valueStr, 10, 64)
|
||||
switch strings.ToLower(unit) {
|
||||
case "k":
|
||||
return value * 1024
|
||||
case "m":
|
||||
return value * 1024 * 1024
|
||||
case "g":
|
||||
return value * 1024 * 1024 * 1024
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// formatBytesToNginx 格式化字节数为 Nginx 大小格式
|
||||
func formatBytesToNginx(bytes int64) string {
|
||||
if bytes == 0 {
|
||||
return "0"
|
||||
}
|
||||
if bytes%(1024*1024*1024) == 0 {
|
||||
return fmt.Sprintf("%dg", bytes/(1024*1024*1024))
|
||||
}
|
||||
if bytes%(1024*1024) == 0 {
|
||||
return fmt.Sprintf("%dm", bytes/(1024*1024))
|
||||
}
|
||||
if bytes%1024 == 0 {
|
||||
return fmt.Sprintf("%dk", bytes/1024)
|
||||
}
|
||||
return fmt.Sprintf("%d", bytes)
|
||||
}
|
||||
|
||||
// formatDurationToNginx 格式化 time.Duration 为 Nginx 时间格式
|
||||
func formatDurationToNginx(d time.Duration) string {
|
||||
if d == 0 {
|
||||
return "0s"
|
||||
}
|
||||
seconds := int(d.Seconds())
|
||||
if seconds%3600 == 0 {
|
||||
return fmt.Sprintf("%dh", seconds/3600)
|
||||
}
|
||||
if seconds%60 == 0 {
|
||||
return fmt.Sprintf("%dm", seconds/60)
|
||||
}
|
||||
return fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
|
||||
// parseProxyFiles 从 site 目录解析所有代理配置
|
||||
func parseProxyFiles(siteDir string) ([]types.Proxy, error) {
|
||||
entries, err := os.ReadDir(siteDir)
|
||||
@@ -95,9 +157,7 @@ func parseProxyFile(filePath string) (*types.Proxy, error) {
|
||||
host := strings.TrimSpace(hm[1])
|
||||
// 移除引号
|
||||
host = strings.Trim(host, `"'`)
|
||||
if host != "$host" && host != "$http_host" {
|
||||
proxy.Host = host
|
||||
}
|
||||
proxy.Host = host
|
||||
}
|
||||
|
||||
// 解析 proxy_ssl_name (SNI)
|
||||
@@ -115,7 +175,70 @@ func parseProxyFile(filePath string) (*types.Proxy, error) {
|
||||
// 解析 proxy_cache
|
||||
cachePattern := regexp.MustCompile(`proxy_cache\s+(\S+);`)
|
||||
if cm := cachePattern.FindStringSubmatch(blockContent); cm != nil && cm[1] != "off" {
|
||||
proxy.Cache = true
|
||||
proxy.Cache = &types.CacheConfig{
|
||||
Valid: make(map[string]string),
|
||||
NoCacheConditions: []string{},
|
||||
UseStale: []string{},
|
||||
Methods: []string{},
|
||||
}
|
||||
|
||||
// 解析 proxy_cache_valid
|
||||
cacheValidPattern := regexp.MustCompile(`proxy_cache_valid\s+([^;]+);`)
|
||||
cacheValidMatches := cacheValidPattern.FindAllStringSubmatch(blockContent, -1)
|
||||
for _, cvm := range cacheValidMatches {
|
||||
parts := strings.Fields(cvm[1])
|
||||
if len(parts) >= 2 {
|
||||
// 最后一个是时长,前面的是状态码
|
||||
duration := parts[len(parts)-1]
|
||||
codes := strings.Join(parts[:len(parts)-1], " ")
|
||||
proxy.Cache.Valid[codes] = duration
|
||||
} else if len(parts) == 1 {
|
||||
// 只有时长,表示 any
|
||||
proxy.Cache.Valid["any"] = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 proxy_cache_bypass / proxy_no_cache
|
||||
bypassPattern := regexp.MustCompile(`proxy_cache_bypass\s+([^;]+);`)
|
||||
if bm := bypassPattern.FindStringSubmatch(blockContent); bm != nil {
|
||||
proxy.Cache.NoCacheConditions = strings.Fields(bm[1])
|
||||
}
|
||||
|
||||
// 解析 proxy_cache_use_stale
|
||||
useStalePattern := regexp.MustCompile(`proxy_cache_use_stale\s+([^;]+);`)
|
||||
if usm := useStalePattern.FindStringSubmatch(blockContent); usm != nil {
|
||||
proxy.Cache.UseStale = strings.Fields(usm[1])
|
||||
}
|
||||
|
||||
// 解析 proxy_cache_background_update
|
||||
bgUpdatePattern := regexp.MustCompile(`proxy_cache_background_update\s+(on|off);`)
|
||||
if bgm := bgUpdatePattern.FindStringSubmatch(blockContent); bgm != nil {
|
||||
proxy.Cache.BackgroundUpdate = bgm[1] == "on"
|
||||
}
|
||||
|
||||
// 解析 proxy_cache_lock
|
||||
lockPattern := regexp.MustCompile(`proxy_cache_lock\s+(on|off);`)
|
||||
if lm := lockPattern.FindStringSubmatch(blockContent); lm != nil {
|
||||
proxy.Cache.Lock = lm[1] == "on"
|
||||
}
|
||||
|
||||
// 解析 proxy_cache_min_uses
|
||||
minUsesPattern := regexp.MustCompile(`proxy_cache_min_uses\s+(\d+);`)
|
||||
if mum := minUsesPattern.FindStringSubmatch(blockContent); mum != nil {
|
||||
proxy.Cache.MinUses, _ = strconv.Atoi(mum[1])
|
||||
}
|
||||
|
||||
// 解析 proxy_cache_methods
|
||||
methodsPattern := regexp.MustCompile(`proxy_cache_methods\s+([^;]+);`)
|
||||
if mm := methodsPattern.FindStringSubmatch(blockContent); mm != nil {
|
||||
proxy.Cache.Methods = strings.Fields(mm[1])
|
||||
}
|
||||
|
||||
// 解析 proxy_cache_key
|
||||
keyPattern := regexp.MustCompile(`proxy_cache_key\s+"?([^";]+)"?;`)
|
||||
if km := keyPattern.FindStringSubmatch(blockContent); km != nil {
|
||||
proxy.Cache.Key = strings.TrimSpace(km[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 resolver
|
||||
@@ -164,6 +287,146 @@ func parseProxyFile(filePath string) (*types.Proxy, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 proxy_http_version
|
||||
httpVersionPattern := regexp.MustCompile(`proxy_http_version\s+([\d.]+);`)
|
||||
if hvm := httpVersionPattern.FindStringSubmatch(blockContent); hvm != nil {
|
||||
proxy.HTTPVersion = strings.TrimSpace(hvm[1])
|
||||
}
|
||||
|
||||
// 解析超时配置
|
||||
connectTimeoutPattern := regexp.MustCompile(`proxy_connect_timeout\s+(\d+)([smh]?);`)
|
||||
readTimeoutPattern := regexp.MustCompile(`proxy_read_timeout\s+(\d+)([smh]?);`)
|
||||
sendTimeoutPattern := regexp.MustCompile(`proxy_send_timeout\s+(\d+)([smh]?);`)
|
||||
|
||||
var timeout types.TimeoutConfig
|
||||
hasTimeout := false
|
||||
|
||||
if ctm := connectTimeoutPattern.FindStringSubmatch(blockContent); ctm != nil {
|
||||
timeout.Connect = parseDurationFromNginx(ctm[1], ctm[2])
|
||||
hasTimeout = true
|
||||
}
|
||||
if rtm := readTimeoutPattern.FindStringSubmatch(blockContent); rtm != nil {
|
||||
timeout.Read = parseDurationFromNginx(rtm[1], rtm[2])
|
||||
hasTimeout = true
|
||||
}
|
||||
if stm := sendTimeoutPattern.FindStringSubmatch(blockContent); stm != nil {
|
||||
timeout.Send = parseDurationFromNginx(stm[1], stm[2])
|
||||
hasTimeout = true
|
||||
}
|
||||
if hasTimeout {
|
||||
proxy.Timeout = &timeout
|
||||
}
|
||||
|
||||
// 解析重试配置
|
||||
nextUpstreamPattern := regexp.MustCompile(`proxy_next_upstream\s+([^;]+);`)
|
||||
nextUpstreamTriesPattern := regexp.MustCompile(`proxy_next_upstream_tries\s+(\d+);`)
|
||||
nextUpstreamTimeoutPattern := regexp.MustCompile(`proxy_next_upstream_timeout\s+(\d+)([smh]?);`)
|
||||
|
||||
var retry types.RetryConfig
|
||||
hasRetry := false
|
||||
|
||||
if num := nextUpstreamPattern.FindStringSubmatch(blockContent); num != nil {
|
||||
retry.Conditions = strings.Fields(num[1])
|
||||
hasRetry = true
|
||||
}
|
||||
if nutm := nextUpstreamTriesPattern.FindStringSubmatch(blockContent); nutm != nil {
|
||||
retry.Tries, _ = strconv.Atoi(nutm[1])
|
||||
hasRetry = true
|
||||
}
|
||||
if nutom := nextUpstreamTimeoutPattern.FindStringSubmatch(blockContent); nutom != nil {
|
||||
retry.Timeout = parseDurationFromNginx(nutom[1], nutom[2])
|
||||
hasRetry = true
|
||||
}
|
||||
if hasRetry {
|
||||
proxy.Retry = &retry
|
||||
}
|
||||
|
||||
// 解析 client_max_body_size
|
||||
clientMaxBodySizePattern := regexp.MustCompile(`client_max_body_size\s+(\d+)([kmgKMG]?);`)
|
||||
if cmbsm := clientMaxBodySizePattern.FindStringSubmatch(blockContent); cmbsm != nil {
|
||||
proxy.ClientMaxBodySize = parseSizeToBytes(cmbsm[1], cmbsm[2])
|
||||
}
|
||||
|
||||
// 解析 SSL 后端验证配置
|
||||
sslVerifyPattern := regexp.MustCompile(`proxy_ssl_verify\s+(on|off);`)
|
||||
sslTrustedCertPattern := regexp.MustCompile(`proxy_ssl_trusted_certificate\s+([^;]+);`)
|
||||
sslVerifyDepthPattern := regexp.MustCompile(`proxy_ssl_verify_depth\s+(\d+);`)
|
||||
|
||||
var sslBackend types.SSLBackendConfig
|
||||
hasSSLBackend := false
|
||||
|
||||
if svm := sslVerifyPattern.FindStringSubmatch(blockContent); svm != nil {
|
||||
sslBackend.Verify = svm[1] == "on"
|
||||
hasSSLBackend = true
|
||||
}
|
||||
if stcm := sslTrustedCertPattern.FindStringSubmatch(blockContent); stcm != nil {
|
||||
sslBackend.TrustedCertificate = strings.TrimSpace(stcm[1])
|
||||
hasSSLBackend = true
|
||||
}
|
||||
if svdm := sslVerifyDepthPattern.FindStringSubmatch(blockContent); svdm != nil {
|
||||
sslBackend.VerifyDepth, _ = strconv.Atoi(svdm[1])
|
||||
hasSSLBackend = true
|
||||
}
|
||||
if hasSSLBackend {
|
||||
proxy.SSLBackend = &sslBackend
|
||||
}
|
||||
|
||||
// 解析响应头配置
|
||||
hideHeaderPattern := regexp.MustCompile(`proxy_hide_header\s+([^;]+);`)
|
||||
addHeaderPattern := regexp.MustCompile(`add_header\s+(\S+)\s+"?([^";]+)"?(?:\s+always)?;`)
|
||||
|
||||
var responseHeaders types.ResponseHeaderConfig
|
||||
hasResponseHeaders := false
|
||||
|
||||
hideHeaderMatches := hideHeaderPattern.FindAllStringSubmatch(blockContent, -1)
|
||||
if len(hideHeaderMatches) > 0 {
|
||||
responseHeaders.Hide = []string{}
|
||||
for _, hhm := range hideHeaderMatches {
|
||||
responseHeaders.Hide = append(responseHeaders.Hide, strings.TrimSpace(hhm[1]))
|
||||
}
|
||||
hasResponseHeaders = true
|
||||
}
|
||||
|
||||
addHeaderMatches := addHeaderPattern.FindAllStringSubmatch(blockContent, -1)
|
||||
if len(addHeaderMatches) > 0 {
|
||||
responseHeaders.Add = make(map[string]string)
|
||||
for _, ahm := range addHeaderMatches {
|
||||
responseHeaders.Add[strings.TrimSpace(ahm[1])] = strings.TrimSpace(ahm[2])
|
||||
}
|
||||
hasResponseHeaders = true
|
||||
}
|
||||
if hasResponseHeaders {
|
||||
proxy.ResponseHeaders = &responseHeaders
|
||||
}
|
||||
|
||||
// 解析 IP 访问控制
|
||||
allowPattern := regexp.MustCompile(`allow\s+([^;]+);`)
|
||||
denyPattern := regexp.MustCompile(`deny\s+([^;]+);`)
|
||||
|
||||
var accessControl types.AccessControlConfig
|
||||
hasAccessControl := false
|
||||
|
||||
allowMatches := allowPattern.FindAllStringSubmatch(blockContent, -1)
|
||||
if len(allowMatches) > 0 {
|
||||
accessControl.Allow = []string{}
|
||||
for _, am := range allowMatches {
|
||||
accessControl.Allow = append(accessControl.Allow, strings.TrimSpace(am[1]))
|
||||
}
|
||||
hasAccessControl = true
|
||||
}
|
||||
|
||||
denyMatches := denyPattern.FindAllStringSubmatch(blockContent, -1)
|
||||
if len(denyMatches) > 0 {
|
||||
accessControl.Deny = []string{}
|
||||
for _, dm := range denyMatches {
|
||||
accessControl.Deny = append(accessControl.Deny, strings.TrimSpace(dm[1]))
|
||||
}
|
||||
hasAccessControl = true
|
||||
}
|
||||
if hasAccessControl {
|
||||
proxy.AccessControl = &accessControl
|
||||
}
|
||||
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
@@ -235,6 +498,21 @@ func generateProxyConfig(proxy types.Proxy) string {
|
||||
sb.WriteString(fmt.Sprintf("# Reverse proxy: %s -> %s\n", location, proxy.Pass))
|
||||
sb.WriteString(fmt.Sprintf("location %s {\n", location))
|
||||
|
||||
// IP 访问控制
|
||||
if proxy.AccessControl != nil {
|
||||
for _, ip := range proxy.AccessControl.Allow {
|
||||
sb.WriteString(fmt.Sprintf(" allow %s;\n", ip))
|
||||
}
|
||||
for _, ip := range proxy.AccessControl.Deny {
|
||||
sb.WriteString(fmt.Sprintf(" deny %s;\n", ip))
|
||||
}
|
||||
}
|
||||
|
||||
// 请求体大小限制
|
||||
if proxy.ClientMaxBodySize > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" client_max_body_size %s;\n", formatBytesToNginx(proxy.ClientMaxBodySize)))
|
||||
}
|
||||
|
||||
// resolver 配置
|
||||
if len(proxy.Resolver) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" resolver %s;\n", strings.Join(proxy.Resolver, " ")))
|
||||
@@ -244,14 +522,16 @@ func generateProxyConfig(proxy types.Proxy) string {
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" proxy_pass %s;\n", proxy.Pass))
|
||||
sb.WriteString(" proxy_http_version 2;\n")
|
||||
|
||||
// HTTP 协议版本
|
||||
httpVersion := lo.If(proxy.HTTPVersion != "", proxy.HTTPVersion).Else("1.1")
|
||||
sb.WriteString(fmt.Sprintf(" proxy_http_version %s;\n", httpVersion))
|
||||
|
||||
// Host 头
|
||||
if proxy.Host != "" {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_set_header Host \"%s\";\n", proxy.Host))
|
||||
} else {
|
||||
sb.WriteString(" proxy_set_header Host $proxy_host;\n")
|
||||
}
|
||||
host := lo.If(proxy.Host == "" || proxy.Host == "$proxy_host", "$proxy_host").ElseF(func() string {
|
||||
return lo.If(strings.HasPrefix(proxy.Host, "$"), proxy.Host).Else("\"" + proxy.Host + "\"")
|
||||
})
|
||||
sb.WriteString(fmt.Sprintf(" proxy_set_header Host %s;\n", host))
|
||||
|
||||
// 标准代理头
|
||||
sb.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
|
||||
@@ -266,37 +546,109 @@ func generateProxyConfig(proxy types.Proxy) string {
|
||||
sb.WriteString(" proxy_ssl_protocols TLSv1.2 TLSv1.3;\n")
|
||||
sb.WriteString(" proxy_ssl_session_reuse off;\n")
|
||||
sb.WriteString(" proxy_ssl_server_name on;\n")
|
||||
if proxy.SNI != "" {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_ssl_name %s;\n", proxy.SNI))
|
||||
} else {
|
||||
sb.WriteString(" proxy_ssl_name $proxy_host;\n")
|
||||
sb.WriteString(fmt.Sprintf(" proxy_ssl_name %s;\n", lo.If(proxy.SNI != "", proxy.SNI).Else("$proxy_host")))
|
||||
|
||||
// SSL 后端验证
|
||||
if proxy.SSLBackend != nil && proxy.SSLBackend.Verify {
|
||||
sb.WriteString(" proxy_ssl_verify on;\n")
|
||||
if proxy.SSLBackend.VerifyDepth > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_ssl_verify_depth %d;\n", proxy.SSLBackend.VerifyDepth))
|
||||
}
|
||||
if proxy.SSLBackend.TrustedCertificate != "" {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_ssl_trusted_certificate %s;\n", proxy.SSLBackend.TrustedCertificate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 超时配置
|
||||
if proxy.Timeout != nil {
|
||||
if proxy.Timeout.Connect > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_connect_timeout %s;\n", formatDurationToNginx(proxy.Timeout.Connect)))
|
||||
}
|
||||
if proxy.Timeout.Read > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_read_timeout %s;\n", formatDurationToNginx(proxy.Timeout.Read)))
|
||||
}
|
||||
if proxy.Timeout.Send > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_send_timeout %s;\n", formatDurationToNginx(proxy.Timeout.Send)))
|
||||
}
|
||||
}
|
||||
|
||||
// 重试配置
|
||||
if proxy.Retry != nil {
|
||||
if len(proxy.Retry.Conditions) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_next_upstream %s;\n", strings.Join(proxy.Retry.Conditions, " ")))
|
||||
}
|
||||
if proxy.Retry.Tries > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_next_upstream_tries %d;\n", proxy.Retry.Tries))
|
||||
}
|
||||
if proxy.Retry.Timeout > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_next_upstream_timeout %s;\n", formatDurationToNginx(proxy.Retry.Timeout)))
|
||||
}
|
||||
}
|
||||
|
||||
// Buffering 配置
|
||||
if proxy.Buffering {
|
||||
sb.WriteString(" proxy_buffering on;\n")
|
||||
} else {
|
||||
sb.WriteString(" proxy_buffering off;\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" proxy_buffering %s;\n", lo.If(proxy.Buffering, "on").Else("off")))
|
||||
|
||||
// Cache 配置
|
||||
if proxy.Cache {
|
||||
sb.WriteString(" proxy_cache proxy_cache;\n")
|
||||
sb.WriteString(" proxy_cache_valid 200 302 10m;\n")
|
||||
sb.WriteString(" proxy_cache_valid 404 1m;\n")
|
||||
if proxy.Cache != nil {
|
||||
sb.WriteString(" proxy_cache cache_one;\n")
|
||||
|
||||
// 缓存时长
|
||||
if len(proxy.Cache.Valid) > 0 {
|
||||
for codes, duration := range proxy.Cache.Valid {
|
||||
if codes == "any" {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_cache_valid %s;\n", duration))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_cache_valid %s %s;\n", codes, duration))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 默认缓存时长
|
||||
sb.WriteString(" proxy_cache_valid 200 302 10m;\n")
|
||||
sb.WriteString(" proxy_cache_valid 404 10s;\n")
|
||||
}
|
||||
|
||||
// 不缓存条件
|
||||
if len(proxy.Cache.NoCacheConditions) > 0 {
|
||||
conditions := strings.Join(proxy.Cache.NoCacheConditions, " ")
|
||||
sb.WriteString(fmt.Sprintf(" proxy_cache_bypass %s;\n", conditions))
|
||||
sb.WriteString(fmt.Sprintf(" proxy_no_cache %s;\n", conditions))
|
||||
}
|
||||
|
||||
// 过期缓存使用策略
|
||||
if len(proxy.Cache.UseStale) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_cache_use_stale %s;\n", strings.Join(proxy.Cache.UseStale, " ")))
|
||||
}
|
||||
|
||||
// 后台更新
|
||||
if proxy.Cache.BackgroundUpdate {
|
||||
sb.WriteString(" proxy_cache_background_update on;\n")
|
||||
}
|
||||
|
||||
// 缓存锁
|
||||
if proxy.Cache.Lock {
|
||||
sb.WriteString(" proxy_cache_lock on;\n")
|
||||
}
|
||||
|
||||
// 最小请求次数
|
||||
if proxy.Cache.MinUses > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_cache_min_uses %d;\n", proxy.Cache.MinUses))
|
||||
}
|
||||
|
||||
// 缓存方法
|
||||
if len(proxy.Cache.Methods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_cache_methods %s;\n", strings.Join(proxy.Cache.Methods, " ")))
|
||||
}
|
||||
|
||||
// 自定义缓存键
|
||||
if proxy.Cache.Key != "" {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_cache_key \"%s\";\n", proxy.Cache.Key))
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义请求头
|
||||
if len(proxy.Headers) > 0 {
|
||||
for name, value := range proxy.Headers {
|
||||
// 变量值不加引号
|
||||
if strings.HasPrefix(value, "$") {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_set_header %s %s;\n", name, value))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_set_header %s \"%s\";\n", name, value))
|
||||
}
|
||||
}
|
||||
for name, value := range proxy.Headers {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_set_header %s %s;\n", name, lo.If(strings.HasPrefix(value, "$"), value).Else("\""+value+"\"")))
|
||||
}
|
||||
|
||||
// 响应内容替换
|
||||
@@ -308,6 +660,19 @@ func generateProxyConfig(proxy types.Proxy) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 响应头修改
|
||||
if proxy.ResponseHeaders != nil {
|
||||
// 隐藏响应头
|
||||
for _, header := range proxy.ResponseHeaders.Hide {
|
||||
sb.WriteString(fmt.Sprintf(" proxy_hide_header %s;\n", header))
|
||||
}
|
||||
// 添加响应头
|
||||
for name, value := range proxy.ResponseHeaders.Add {
|
||||
formattedValue := lo.If(strings.HasPrefix(value, "$"), value).Else("\"" + value + "\"")
|
||||
sb.WriteString(fmt.Sprintf(" add_header %s %s always;\n", name, formattedValue))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("}\n")
|
||||
|
||||
return sb.String()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/acepanel/panel/pkg/webserver/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// redirectFilePattern 匹配重定向配置文件名 (100-199)
|
||||
@@ -179,10 +180,8 @@ func clearRedirectFiles(siteDir string) error {
|
||||
|
||||
// generateRedirectConfig 生成重定向配置内容
|
||||
func generateRedirectConfig(redirect types.Redirect) string {
|
||||
statusCode := redirect.StatusCode
|
||||
if statusCode == 0 {
|
||||
statusCode = 308 // 默认使用 308 永久重定向
|
||||
}
|
||||
statusCode := lo.If(redirect.StatusCode == 0, 308).Else(redirect.StatusCode)
|
||||
uriSuffix := lo.If(redirect.KeepURI, "$request_uri").Else("")
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# Auto-generated by AcePanel. DO NOT EDIT MANUALLY!\n")
|
||||
@@ -192,22 +191,14 @@ func generateRedirectConfig(redirect types.Redirect) string {
|
||||
// URL 重定向
|
||||
sb.WriteString(fmt.Sprintf("# URL redirect: %s -> %s\n", redirect.From, redirect.To))
|
||||
sb.WriteString(fmt.Sprintf("location = %s {\n", redirect.From))
|
||||
if redirect.KeepURI {
|
||||
sb.WriteString(fmt.Sprintf(" return %d %s$request_uri;\n", statusCode, redirect.To))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" return %d %s;\n", statusCode, redirect.To))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" return %d %s%s;\n", statusCode, redirect.To, uriSuffix))
|
||||
sb.WriteString("}\n")
|
||||
|
||||
case types.RedirectTypeHost:
|
||||
// Host 重定向
|
||||
sb.WriteString(fmt.Sprintf("# Host redirect: %s -> %s\n", redirect.From, redirect.To))
|
||||
sb.WriteString(fmt.Sprintf("if ($host = \"%s\") {\n", redirect.From))
|
||||
if redirect.KeepURI {
|
||||
sb.WriteString(fmt.Sprintf(" return %d %s$request_uri;\n", statusCode, redirect.To))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" return %d %s;\n", statusCode, redirect.To))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" return %d %s%s;\n", statusCode, redirect.To, uriSuffix))
|
||||
sb.WriteString("}\n")
|
||||
|
||||
case types.RedirectType404:
|
||||
@@ -215,11 +206,7 @@ func generateRedirectConfig(redirect types.Redirect) string {
|
||||
sb.WriteString(fmt.Sprintf("# 404 redirect -> %s\n", redirect.To))
|
||||
sb.WriteString("error_page 404 = @redirect_404;\n")
|
||||
sb.WriteString("location @redirect_404 {\n")
|
||||
if redirect.KeepURI {
|
||||
sb.WriteString(fmt.Sprintf(" return %d %s$request_uri;\n", statusCode, redirect.To))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" return %d %s;\n", statusCode, redirect.To))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" return %d %s%s;\n", statusCode, redirect.To, uriSuffix))
|
||||
sb.WriteString("}\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/acepanel/panel/pkg/webserver/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// upstreamFilePattern 匹配 upstream 配置文件名 (100-XXX-name.conf)
|
||||
@@ -217,11 +218,7 @@ func generateUpstreamConfig(upstream types.Upstream) string {
|
||||
|
||||
// 服务器列表
|
||||
for addr, options := range upstream.Servers {
|
||||
if options != "" {
|
||||
sb.WriteString(fmt.Sprintf(" server %s %s;\n", addr, options))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" server %s;\n", addr))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" server %s;\n", lo.If(options != "", addr+" "+options).Else(addr)))
|
||||
}
|
||||
|
||||
// keepalive 连接数
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/tufanbarisyildirim/gonginx/config"
|
||||
|
||||
"github.com/acepanel/panel/pkg/webserver/types"
|
||||
@@ -406,14 +407,7 @@ func (v *baseVhost) RemoveConfig(name string, typ string) error {
|
||||
|
||||
func (v *baseVhost) SSL() bool {
|
||||
directive, err := v.parser.FindOne("server.ssl_certificate")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(v.parser.parameters2Slices(directive.GetParameters())) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return err == nil && len(v.parser.parameters2Slices(directive.GetParameters())) != 0
|
||||
}
|
||||
|
||||
func (v *baseVhost) SSLConfig() *types.SSLConfig {
|
||||
@@ -695,21 +689,14 @@ func (v *baseVhost) SetBasicAuth(auth map[string]string) error {
|
||||
_ = v.parser.Clear("server.auth_basic")
|
||||
_ = v.parser.Clear("server.auth_basic_user_file")
|
||||
|
||||
realm := auth["realm"]
|
||||
userFile := auth["user_file"]
|
||||
|
||||
if realm == "" {
|
||||
realm = "Restricted"
|
||||
}
|
||||
|
||||
return v.parser.Set("server", []*config.Directive{
|
||||
{
|
||||
Name: "auth_basic",
|
||||
Parameters: []config.Parameter{{Value: realm}},
|
||||
Parameters: []config.Parameter{{Value: lo.If(auth["realm"] != "", auth["realm"]).Else("Restricted")}},
|
||||
},
|
||||
{
|
||||
Name: "auth_basic_user_file",
|
||||
Parameters: []config.Parameter{{Value: userFile}},
|
||||
Parameters: []config.Parameter{{Value: auth["user_file"]}},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,18 +2,100 @@ package types
|
||||
|
||||
import "time"
|
||||
|
||||
// CacheConfig 缓存配置
|
||||
type CacheConfig struct {
|
||||
// 缓存时长,状态码 -> 时长,如: {"200 302": "10m", "404": "1m", "any": "5m"}
|
||||
Valid map[string]string `form:"valid" json:"valid"`
|
||||
|
||||
// 不缓存条件 (proxy_cache_bypass + proxy_no_cache)
|
||||
// 常用值: "$cookie_nocache", "$arg_nocache", "$http_pragma", "$http_authorization"
|
||||
NoCacheConditions []string `form:"no_cache_conditions" json:"no_cache_conditions"`
|
||||
|
||||
// 过期缓存使用策略 (proxy_cache_use_stale)
|
||||
// 可选值: "error", "timeout", "updating", "http_500", "http_502", "http_503", "http_504"
|
||||
UseStale []string `form:"use_stale" json:"use_stale"`
|
||||
|
||||
// 后台更新 (proxy_cache_background_update)
|
||||
BackgroundUpdate bool `form:"background_update" json:"background_update"`
|
||||
|
||||
// 缓存锁 (proxy_cache_lock),防止缓存击穿
|
||||
Lock bool `form:"lock" json:"lock"`
|
||||
|
||||
// 最小请求次数 (proxy_cache_min_uses),请求 N 次后才缓存
|
||||
MinUses int `form:"min_uses" json:"min_uses"`
|
||||
|
||||
// 缓存方法 (proxy_cache_methods),默认 GET HEAD
|
||||
Methods []string `form:"methods" json:"methods"`
|
||||
|
||||
// 自定义缓存键 (proxy_cache_key)
|
||||
Key string `form:"key" json:"key"`
|
||||
}
|
||||
|
||||
// TimeoutConfig 超时配置
|
||||
type TimeoutConfig struct {
|
||||
Connect time.Duration `form:"connect" json:"connect"` // proxy_connect_timeout,默认 60s
|
||||
Read time.Duration `form:"read" json:"read"` // proxy_read_timeout,默认 60s
|
||||
Send time.Duration `form:"send" json:"send"` // proxy_send_timeout,默认 60s
|
||||
}
|
||||
|
||||
// RetryConfig 重试配置
|
||||
type RetryConfig struct {
|
||||
// 触发重试的条件 (proxy_next_upstream)
|
||||
// 可选值: "error", "timeout", "invalid_header", "http_500", "http_502", "http_503", "http_504", "http_429", "non_idempotent", "off"
|
||||
Conditions []string `form:"conditions" json:"conditions"`
|
||||
|
||||
// 最大重试次数 (proxy_next_upstream_tries),0 表示不限制
|
||||
Tries int `form:"tries" json:"tries"`
|
||||
|
||||
// 重试超时时间 (proxy_next_upstream_timeout),0 表示不限制
|
||||
Timeout time.Duration `form:"timeout" json:"timeout"`
|
||||
}
|
||||
|
||||
// SSLBackendConfig SSL 后端验证配置
|
||||
type SSLBackendConfig struct {
|
||||
Verify bool `form:"verify" json:"verify"` // proxy_ssl_verify on/off
|
||||
TrustedCertificate string `form:"trusted_certificate" json:"trusted_certificate"` // proxy_ssl_trusted_certificate 路径
|
||||
VerifyDepth int `form:"verify_depth" json:"verify_depth"` // proxy_ssl_verify_depth,默认 1
|
||||
}
|
||||
|
||||
// ResponseHeaderConfig 响应头修改配置
|
||||
type ResponseHeaderConfig struct {
|
||||
// 隐藏的响应头 (proxy_hide_header)
|
||||
Hide []string `form:"hide" json:"hide"`
|
||||
|
||||
// 添加的响应头 (add_header),key -> value
|
||||
// 值可以包含变量,如 $upstream_cache_status
|
||||
Add map[string]string `form:"add" json:"add"`
|
||||
}
|
||||
|
||||
// AccessControlConfig IP 访问控制配置
|
||||
type AccessControlConfig struct {
|
||||
// 允许的 IP/CIDR 列表 (allow)
|
||||
Allow []string `form:"allow" json:"allow"`
|
||||
|
||||
// 拒绝的 IP/CIDR 列表 (deny)
|
||||
Deny []string `form:"deny" json:"deny"`
|
||||
}
|
||||
|
||||
// Proxy 反向代理配置
|
||||
type Proxy struct {
|
||||
Location string `form:"location" json:"location" validate:"required"` // 匹配路径,如: "/", "/api", "~ ^/api/v[0-9]+/"
|
||||
Pass string `form:"pass" json:"pass" validate:"required"` // 代理地址,如: "http://example.com", "http://backend"
|
||||
Host string `form:"host" json:"host"` // 代理 Host,如: "example.com"
|
||||
SNI string `form:"sni" json:"sni"` // 代理 SNI,如: "example.com"
|
||||
Cache bool `form:"cache" json:"cache"` // 是否启用缓存
|
||||
Buffering bool `form:"buffering" json:"buffering"` // 是否启用缓冲
|
||||
Resolver []string `form:"resolver" json:"resolver"` // 自定义 DNS 解析器配置,如: ["8.8.8.8", "ipv6=off"]
|
||||
ResolverTimeout time.Duration `form:"resolver_timeout" json:"resolver_timeout"` // DNS 解析超时时间,如: 5 * time.Second
|
||||
Headers map[string]string `form:"headers" json:"headers"` // 自定义请求头,如: map["X-Custom-Header"] = "value"
|
||||
Replaces map[string]string `form:"replaces" json:"replaces"` // 响应内容替换,如: map["/old"] = "/new"
|
||||
Location string `form:"location" json:"location" validate:"required"` // 匹配路径,如: "/", "/api", "~ ^/api/v[0-9]+/"
|
||||
Pass string `form:"pass" json:"pass" validate:"required"` // 代理地址,如: "http://example.com", "http://backend"
|
||||
Host string `form:"host" json:"host"` // 代理 Host,如: "example.com"
|
||||
SNI string `form:"sni" json:"sni"` // 代理 SNI,如: "example.com"
|
||||
Cache *CacheConfig `form:"cache" json:"cache"` // 缓存配置,nil 表示禁用缓存
|
||||
Buffering bool `form:"buffering" json:"buffering"` // 是否启用缓冲
|
||||
Resolver []string `form:"resolver" json:"resolver"` // 自定义 DNS 解析器配置,如: ["8.8.8.8", "ipv6=off"]
|
||||
ResolverTimeout time.Duration `form:"resolver_timeout" json:"resolver_timeout"` // DNS 解析超时时间,如: 5 * time.Second
|
||||
Headers map[string]string `form:"headers" json:"headers"` // 自定义请求头,如: map["X-Custom-Header"] = "value"
|
||||
Replaces map[string]string `form:"replaces" json:"replaces"` // 响应内容替换,如: map["/old"] = "/new"
|
||||
HTTPVersion string `form:"http_version" json:"http_version"` // HTTP 协议版本 (proxy_http_version),可选: "1.0", "1.1", "2"
|
||||
Timeout *TimeoutConfig `form:"timeout" json:"timeout"` // 超时配置
|
||||
Retry *RetryConfig `form:"retry" json:"retry"` // 重试配置
|
||||
ClientMaxBodySize int64 `form:"client_max_body_size" json:"client_max_body_size"` // 请求体大小限制 (client_max_body_size),单位字节,0 表示使用全局配置
|
||||
SSLBackend *SSLBackendConfig `form:"ssl_backend" json:"ssl_backend"` // SSL 后端验证配置
|
||||
ResponseHeaders *ResponseHeaderConfig `form:"response_headers" json:"response_headers"` // 响应头修改配置
|
||||
AccessControl *AccessControlConfig `form:"access_control" json:"access_control"` // IP 访问控制配置
|
||||
}
|
||||
|
||||
// Upstream 上游服务器配置
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@
|
||||
"build": "run-s gen-auto-import type-check build-only copy",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.app.json",
|
||||
"lint": "run-s gen-auto-import lint-only",
|
||||
"lint-only": "eslint . --fix",
|
||||
"format": "prettier --write src/",
|
||||
@@ -95,5 +95,5 @@
|
||||
"vite-plugin-vue-devtools": "^8.0.3",
|
||||
"vue-tsc": "^3.1.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264"
|
||||
}
|
||||
|
||||
Generated
+19
@@ -839,36 +839,42 @@ packages:
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.6':
|
||||
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.6':
|
||||
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.6':
|
||||
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.6':
|
||||
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.6':
|
||||
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.6':
|
||||
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
|
||||
@@ -943,66 +949,79 @@ packages:
|
||||
resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.56.0':
|
||||
resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.56.0':
|
||||
resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.56.0':
|
||||
resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.56.0':
|
||||
resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.56.0':
|
||||
resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.56.0':
|
||||
resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.56.0':
|
||||
resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.56.0':
|
||||
resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.56.0':
|
||||
resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.56.0':
|
||||
resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.56.0':
|
||||
resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.56.0':
|
||||
resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.56.0':
|
||||
resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="812" height="812">
|
||||
<rect x="0" y="0" width="812" height="812" rx="80" ry="80" fill="#333333"/>
|
||||
<path d="M0,0 L136,0 L138,23 L141,47 L146,91 L152,146 L155,169 L157,188 L161,222 L163,243 L167,275 L169,294 L173,329 L176,355 L178,372 L178,377 L360,377 L354,399 L342,439 L333,470 L324,501 L321,509 L302,509 L307,504 L313,497 L313,494 L296,481 L293,481 L286,489 L277,494 L270,496 L261,496 L253,493 L248,487 L246,481 L246,474 L247,473 L276,470 L296,464 L307,458 L314,451 L318,443 L320,430 L318,420 L313,412 L306,406 L294,401 L290,400 L267,400 L254,404 L243,410 L233,419 L223,434 L217,450 L215,464 L215,478 L217,490 L222,502 L226,507 L226,509 L187,509 L193,501 L199,492 L199,488 L179,479 L176,478 L169,488 L164,493 L157,496 L150,496 L142,493 L138,488 L136,480 L138,464 L142,445 L146,435 L151,429 L157,426 L162,425 L169,425 L176,428 L181,436 L182,439 L187,438 L208,430 L207,422 L201,412 L193,406 L182,401 L175,400 L155,400 L143,404 L132,410 L122,419 L115,430 L110,440 L107,450 L105,468 L106,488 L111,500 L116,509 L86,509 L84,496 L81,460 L78,432 L74,385 L-27,385 L-22,373 L-7,342 L1,324 L9,308 L14,297 L67,297 L66,293 L62,236 L60,213 L57,169 L52,104 L52,95 L45,95 L43,101 L27,136 L11,171 L-7,210 L-26,251 L-42,285 L-50,304 L-60,326 L-68,342 L-76,359 L-84,375 L-96,400 L-114,437 L-125,460 L-137,485 L-148,508 L-149,509 L-261,509 L-259,503 L-245,475 L-235,456 L-212,411 L-202,392 L-189,366 L-179,347 L-159,308 L-145,281 L-137,266 L-129,250 L-105,203 L-91,176 L-82,159 L-74,143 L-51,98 L-34,65 L-19,36 L-1,1 Z " fill="#FEFEFE" transform="translate(372,151)"/>
|
||||
<path d="M0,0 L11,0 L17,3 L19,6 L19,15 L14,21 L4,25 L-14,28 L-20,28 L-18,17 L-13,9 L-5,2 Z " fill="#FBFBFB" transform="translate(642,575)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -39,7 +39,7 @@ const parseDescription = computed((): string => {
|
||||
return $gettext('Cron expression: %{cron}', { cron })
|
||||
}
|
||||
|
||||
const [minute, hour, day, month, weekday] = parts
|
||||
const [minute, hour, day, month, weekday] = parts as [string, string, string, string, string]
|
||||
|
||||
try {
|
||||
// 每 N 分钟:*/N * * * *
|
||||
|
||||
@@ -27,7 +27,6 @@ const minimized = defineModel<boolean>('minimized', { default: false })
|
||||
|
||||
// 窗口状态
|
||||
const isMaximized = ref(false)
|
||||
const windowRef = ref<HTMLDivElement>()
|
||||
|
||||
// 窗口位置和大小
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用键值对编辑器组件
|
||||
* 用于编辑 Object<string, string> 类型的数据
|
||||
*/
|
||||
defineOptions({
|
||||
name: 'KeyValueEditor'
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 键值对数据 */
|
||||
modelValue: Record<string, string>
|
||||
/** 键的占位符 */
|
||||
keyPlaceholder?: string
|
||||
/** 值的占位符 */
|
||||
valuePlaceholder?: string
|
||||
/** 添加按钮文本 */
|
||||
addButtonText?: string
|
||||
/** 新增项的默认键前缀 */
|
||||
defaultKeyPrefix?: string
|
||||
/** 新增项的默认值 */
|
||||
defaultValue?: string
|
||||
/** 键值分隔符显示 */
|
||||
separator?: string
|
||||
/** 值输入框类型 */
|
||||
valueType?: 'text' | 'password'
|
||||
/** 是否显示密码切换按钮 */
|
||||
showPasswordToggle?: boolean
|
||||
}>(),
|
||||
{
|
||||
keyPlaceholder: 'Key',
|
||||
valuePlaceholder: 'Value',
|
||||
addButtonText: 'Add',
|
||||
defaultKeyPrefix: 'key',
|
||||
defaultValue: '',
|
||||
separator: '=',
|
||||
valueType: 'text',
|
||||
showPasswordToggle: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Record<string, string>]
|
||||
}>()
|
||||
|
||||
// 生成唯一键名
|
||||
const generateUniqueKey = () => {
|
||||
const data = props.modelValue || {}
|
||||
const prefix = props.defaultKeyPrefix
|
||||
let i = 1
|
||||
while (data[`${prefix}${i}`] !== undefined) {
|
||||
i++
|
||||
}
|
||||
return `${prefix}${i}`
|
||||
}
|
||||
|
||||
// 添加新项
|
||||
const addItem = () => {
|
||||
const data = { ...(props.modelValue || {}) }
|
||||
const key = generateUniqueKey()
|
||||
data[key] = props.defaultValue
|
||||
emit('update:modelValue', data)
|
||||
}
|
||||
|
||||
// 更新键名(失焦时)
|
||||
const updateKey = (oldKey: string, newKey: string) => {
|
||||
if (!newKey || newKey === oldKey) return
|
||||
if (props.modelValue[newKey] !== undefined) return // 键已存在
|
||||
|
||||
const data = { ...props.modelValue }
|
||||
data[newKey] = data[oldKey] ?? ''
|
||||
delete data[oldKey]
|
||||
emit('update:modelValue', data)
|
||||
}
|
||||
|
||||
// 更新值(失焦时)
|
||||
const updateValue = (key: string, value: string) => {
|
||||
const data = { ...props.modelValue }
|
||||
data[key] = value
|
||||
emit('update:modelValue', data)
|
||||
}
|
||||
|
||||
// 删除项
|
||||
const removeItem = (key: string) => {
|
||||
const data = { ...props.modelValue }
|
||||
delete data[key]
|
||||
emit('update:modelValue', data)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical :size="8" w-full>
|
||||
<n-flex v-for="(value, key) in modelValue" :key="String(key)" :size="8" align="center">
|
||||
<n-input
|
||||
:default-value="String(key)"
|
||||
:placeholder="keyPlaceholder"
|
||||
flex-1
|
||||
@blur="(e: FocusEvent) => updateKey(String(key), (e.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<span flex-shrink-0>{{ separator }}</span>
|
||||
<n-input
|
||||
:default-value="String(value)"
|
||||
:type="valueType"
|
||||
:show-password-on="showPasswordToggle ? 'click' : undefined"
|
||||
:placeholder="valuePlaceholder"
|
||||
flex-1
|
||||
@blur="(e: FocusEvent) => updateValue(String(key), (e.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<n-button type="error" secondary size="small" flex-shrink-0 @click="removeItem(String(key))">
|
||||
{{ $gettext('Remove') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-button dashed size="small" @click="addItem">
|
||||
{{ addButtonText }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
@@ -65,7 +65,7 @@ function handleLanguageChange(value: string) {
|
||||
|
||||
// 更新缩进
|
||||
function handleIndentChange(value: string) {
|
||||
const [size, type] = value.split('-')
|
||||
const [size, type] = value.split('-') as [string, string]
|
||||
editorStore.updateSettings({
|
||||
tabSize: parseInt(size),
|
||||
insertSpaces: type === 'spaces'
|
||||
|
||||
@@ -149,13 +149,13 @@ defineExpose({
|
||||
v-model:value="editorStore.settings.tabSize"
|
||||
:min="1"
|
||||
:max="8"
|
||||
@update:value="(v) => editorStore.updateSettings({ tabSize: v || 4 })"
|
||||
@update:value="(v: number | null) => editorStore.updateSettings({ tabSize: v || 4 })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Use Spaces')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.insertSpaces"
|
||||
@update:value="(v) => editorStore.updateSettings({ insertSpaces: v })"
|
||||
@update:value="(v: boolean) => editorStore.updateSettings({ insertSpaces: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Font Size')">
|
||||
@@ -163,7 +163,7 @@ defineExpose({
|
||||
v-model:value="editorStore.settings.fontSize"
|
||||
:min="10"
|
||||
:max="24"
|
||||
@update:value="(v) => editorStore.updateSettings({ fontSize: v || 14 })"
|
||||
@update:value="(v: number | null) => editorStore.updateSettings({ fontSize: v || 14 })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Word Wrap')">
|
||||
@@ -175,13 +175,13 @@ defineExpose({
|
||||
{ label: $gettext('Word Wrap Column'), value: 'wordWrapColumn' },
|
||||
{ label: $gettext('Bounded'), value: 'bounded' }
|
||||
]"
|
||||
@update:value="(v) => editorStore.updateSettings({ wordWrap: v })"
|
||||
@update:value="(v: 'on' | 'off' | 'wordWrapColumn' | 'bounded') => editorStore.updateSettings({ wordWrap: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Show Minimap')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.minimap"
|
||||
@update:value="(v) => editorStore.updateSettings({ minimap: v })"
|
||||
@update:value="(v: boolean) => editorStore.updateSettings({ minimap: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
@@ -197,7 +197,7 @@ defineExpose({
|
||||
{ label: $gettext('Relative'), value: 'relative' },
|
||||
{ label: $gettext('Interval'), value: 'interval' }
|
||||
]"
|
||||
@update:value="(v) => editorStore.updateSettings({ lineNumbers: v })"
|
||||
@update:value="(v: 'on' | 'off' | 'relative' | 'interval') => editorStore.updateSettings({ lineNumbers: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Render Whitespace')">
|
||||
@@ -210,25 +210,25 @@ defineExpose({
|
||||
{ label: $gettext('Trailing'), value: 'trailing' },
|
||||
{ label: $gettext('All'), value: 'all' }
|
||||
]"
|
||||
@update:value="(v) => editorStore.updateSettings({ renderWhitespace: v })"
|
||||
@update:value="(v: 'none' | 'boundary' | 'selection' | 'trailing' | 'all') => editorStore.updateSettings({ renderWhitespace: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Bracket Colorization')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.bracketPairColorization"
|
||||
@update:value="(v) => editorStore.updateSettings({ bracketPairColorization: v })"
|
||||
@update:value="(v: boolean) => editorStore.updateSettings({ bracketPairColorization: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Indent Guides')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.guides"
|
||||
@update:value="(v) => editorStore.updateSettings({ guides: v })"
|
||||
@update:value="(v: boolean) => editorStore.updateSettings({ guides: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Code Folding')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.folding"
|
||||
@update:value="(v) => editorStore.updateSettings({ folding: v })"
|
||||
@update:value="(v: boolean) => editorStore.updateSettings({ folding: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
@@ -246,7 +246,7 @@ defineExpose({
|
||||
{ label: $gettext('Block Outline'), value: 'block-outline' },
|
||||
{ label: $gettext('Underline Thin'), value: 'underline-thin' }
|
||||
]"
|
||||
@update:value="(v) => editorStore.updateSettings({ cursorStyle: v })"
|
||||
@update:value="(v: 'line' | 'block' | 'underline' | 'line-thin' | 'block-outline' | 'underline-thin') => editorStore.updateSettings({ cursorStyle: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Cursor Blinking')">
|
||||
@@ -259,13 +259,13 @@ defineExpose({
|
||||
{ label: $gettext('Expand'), value: 'expand' },
|
||||
{ label: $gettext('Solid'), value: 'solid' }
|
||||
]"
|
||||
@update:value="(v) => editorStore.updateSettings({ cursorBlinking: v })"
|
||||
@update:value="(v: 'blink' | 'smooth' | 'phase' | 'expand' | 'solid') => editorStore.updateSettings({ cursorBlinking: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Smooth Scrolling')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.smoothScrolling"
|
||||
@update:value="(v) => editorStore.updateSettings({ smoothScrolling: v })"
|
||||
@update:value="(v: boolean) => editorStore.updateSettings({ smoothScrolling: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
@@ -275,19 +275,19 @@ defineExpose({
|
||||
<n-form-item :label="$gettext('Mouse Wheel Zoom')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.mouseWheelZoom"
|
||||
@update:value="(v) => editorStore.updateSettings({ mouseWheelZoom: v })"
|
||||
@update:value="(v: boolean) => editorStore.updateSettings({ mouseWheelZoom: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Format On Paste')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.formatOnPaste"
|
||||
@update:value="(v) => editorStore.updateSettings({ formatOnPaste: v })"
|
||||
@update:value="(v: boolean) => editorStore.updateSettings({ formatOnPaste: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$gettext('Format On Type')">
|
||||
<n-switch
|
||||
:value="editorStore.settings.formatOnType"
|
||||
@update:value="(v) => editorStore.updateSettings({ formatOnType: v })"
|
||||
@update:value="(v: boolean) => editorStore.updateSettings({ formatOnType: v })"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
@@ -187,17 +187,18 @@ function showCreate(type: 'file' | 'dir') {
|
||||
|
||||
// 确定父目录
|
||||
if (selectedKeys.value.length > 0) {
|
||||
const selectedNode = findNode(treeData.value, selectedKeys.value[0])
|
||||
const selectedKey = selectedKeys.value[0] ?? ''
|
||||
const selectedNode = findNode(treeData.value, selectedKey)
|
||||
if (selectedNode && !selectedNode.isLeaf) {
|
||||
// 选中的是目录,在该目录下新建
|
||||
inlineCreateParentPath.value = selectedKeys.value[0]
|
||||
inlineCreateParentPath.value = selectedKey
|
||||
// 确保目录已展开
|
||||
if (!expandedKeys.value.includes(selectedKeys.value[0])) {
|
||||
expandedKeys.value = [...expandedKeys.value, selectedKeys.value[0]]
|
||||
if (!expandedKeys.value.includes(selectedKey)) {
|
||||
expandedKeys.value = [...expandedKeys.value, selectedKey]
|
||||
}
|
||||
} else {
|
||||
// 选中的是文件,在其父目录下新建
|
||||
const parts = selectedKeys.value[0].split('/')
|
||||
const parts = selectedKey.split('/')
|
||||
parts.pop()
|
||||
inlineCreateParentPath.value = parts.join('/') || props.rootPath
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import logoImg from '@/assets/images/logo.png'
|
||||
import logoImg from '@/assets/images/logo.svg'
|
||||
import { useThemeStore } from '@/store'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
@@ -12,13 +12,15 @@ const toHome = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="f-c-c h-60 cursor-pointer" @click="toHome">
|
||||
<n-image :src="logo" preview-disabled class="h-36" />
|
||||
<h2
|
||||
v-show="!themeStore.sider.collapsed"
|
||||
class="text-18 font-bold ml-10 flex-shrink-0 max-w-140"
|
||||
>
|
||||
<div class="px-12 f-c-c h-60 cursor-pointer" @click="toHome">
|
||||
<n-image :src="logo" preview-disabled class="flex-shrink-0 h-36" />
|
||||
<n-tooltip v-if="!themeStore.sider.collapsed" trigger="hover" :delay="500">
|
||||
<template #trigger>
|
||||
<h2 class="text-18 font-bold ml-10 max-w-140 truncate">
|
||||
{{ themeStore.name }}
|
||||
</h2>
|
||||
</template>
|
||||
{{ themeStore.name }}
|
||||
</h2>
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { translateTitle } from '@/locales/menu'
|
||||
import { usePermissionStore, useTabStore, useThemeStore } from '@/store'
|
||||
import { isUrl, renderIcon } from '@/utils'
|
||||
|
||||
import { MenuInst, MenuOption, useThemeVars } from 'naive-ui'
|
||||
import type { MenuInst, MenuOption } from 'naive-ui'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { VNodeChild } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Meta, RouteType } from '~/types/router'
|
||||
@@ -110,9 +111,16 @@ function handleMenuSelect(key: string, item: MenuOption) {
|
||||
window.open(menuItem.path)
|
||||
return
|
||||
}
|
||||
if (menuItem.path === currentRoute.path && !currentRoute.meta?.keepAlive)
|
||||
tabStore.reloadTab(currentRoute.path)
|
||||
else router.push(menuItem.path)
|
||||
if (menuItem.path === currentRoute.path) {
|
||||
// 检查 tab 的 keepAlive 状态,而不是路由元数据
|
||||
// 这样用户固定的标签页不会被意外刷新
|
||||
const tab = tabStore.tabs.find((t) => t.path === menuItem.path)
|
||||
if (!tab?.keepAlive) {
|
||||
tabStore.reloadTab(currentRoute.path)
|
||||
}
|
||||
} else {
|
||||
router.push(menuItem.path)
|
||||
}
|
||||
|
||||
// 手机端自动收起菜单
|
||||
themeStore.isMobile && themeStore.setCollapsed(true)
|
||||
|
||||
@@ -57,7 +57,7 @@ const options = computed(() => [
|
||||
{
|
||||
label: $gettext('Close Left'),
|
||||
key: 'close-left',
|
||||
disabled: tabStore.tabs.length <= 1 || props.currentPath === tabStore.tabs[0].path,
|
||||
disabled: tabStore.tabs.length <= 1 || props.currentPath === tabStore.tabs[0]?.path,
|
||||
icon: renderIcon('mdi:arrow-expand-left', { size: 14 })
|
||||
},
|
||||
{
|
||||
@@ -65,7 +65,7 @@ const options = computed(() => [
|
||||
key: 'close-right',
|
||||
disabled:
|
||||
tabStore.tabs.length <= 1 ||
|
||||
props.currentPath === tabStore.tabs[tabStore.tabs.length - 1].path,
|
||||
props.currentPath === tabStore.tabs[tabStore.tabs.length - 1]?.path,
|
||||
icon: renderIcon('mdi:arrow-expand-right', { size: 14 })
|
||||
}
|
||||
])
|
||||
|
||||
+839
-625
File diff suppressed because it is too large
Load Diff
+830
-641
File diff suppressed because it is too large
Load Diff
+813
-624
File diff suppressed because it is too large
Load Diff
+813
-624
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,8 @@ const modules = import.meta.glob('@/views/**/route.ts', {
|
||||
}) as RouteModule
|
||||
const asyncRoutes: RoutesType = []
|
||||
Object.keys(modules).forEach((key) => {
|
||||
asyncRoutes.push(modules[key].default)
|
||||
const route = modules[key]?.default
|
||||
if (route) asyncRoutes.push(route)
|
||||
})
|
||||
|
||||
export { asyncRoutes }
|
||||
|
||||
@@ -137,9 +137,9 @@ export const useEditorStore = defineStore('editor', {
|
||||
if (this.tabs.length === 0) {
|
||||
this.activeTabPath = null
|
||||
} else if (index >= this.tabs.length) {
|
||||
this.activeTabPath = this.tabs[this.tabs.length - 1].path
|
||||
this.activeTabPath = this.tabs[this.tabs.length - 1]?.path ?? null
|
||||
} else {
|
||||
this.activeTabPath = this.tabs[index].path
|
||||
this.activeTabPath = this.tabs[index]?.path ?? null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -162,7 +162,7 @@ export const useEditorStore = defineStore('editor', {
|
||||
if (this.tabs.length === 0) {
|
||||
this.activeTabPath = null
|
||||
} else if (!this.tabs.find((tab) => tab.path === this.activeTabPath)) {
|
||||
this.activeTabPath = this.tabs[0].path
|
||||
this.activeTabPath = this.tabs[0]?.path ?? null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -255,7 +255,7 @@ export const useEditorStore = defineStore('editor', {
|
||||
if (toIndex < 0 || toIndex >= this.tabs.length) return
|
||||
|
||||
const [movedTab] = this.tabs.splice(fromIndex, 1)
|
||||
this.tabs.splice(toIndex, 0, movedTab)
|
||||
if (movedTab) this.tabs.splice(toIndex, 0, movedTab)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -64,28 +64,28 @@ export const useTabStore = defineStore('tab', {
|
||||
removeTab(path: string) {
|
||||
if (path === this.active) {
|
||||
const activeIndex = this.tabs.findIndex((item) => item.path === path)
|
||||
if (activeIndex > 0) router.push(this.tabs[activeIndex - 1].path)
|
||||
else router.push(this.tabs[activeIndex + 1].path)
|
||||
if (activeIndex > 0) router.push(this.tabs[activeIndex - 1]?.path ?? '/')
|
||||
else router.push(this.tabs[activeIndex + 1]?.path ?? '/')
|
||||
}
|
||||
this.setTabs(this.tabs.filter((tab) => tab.path !== path))
|
||||
},
|
||||
removeOther(curPath: string) {
|
||||
this.setTabs(this.tabs.filter((tab) => tab.path === curPath))
|
||||
if (curPath !== this.active) router.push(this.tabs[this.tabs.length - 1].path)
|
||||
if (curPath !== this.active) router.push(this.tabs[this.tabs.length - 1]?.path ?? '/')
|
||||
},
|
||||
removeLeft(curPath: string) {
|
||||
const curIndex = this.tabs.findIndex((item) => item.path === curPath)
|
||||
const filterTabs = this.tabs.filter((item, index) => index >= curIndex)
|
||||
this.setTabs(filterTabs)
|
||||
if (!filterTabs.find((item) => item.path === this.active))
|
||||
router.push(filterTabs[filterTabs.length - 1].path)
|
||||
router.push(filterTabs[filterTabs.length - 1]?.path ?? '/')
|
||||
},
|
||||
removeRight(curPath: string) {
|
||||
const curIndex = this.tabs.findIndex((item) => item.path === curPath)
|
||||
const filterTabs = this.tabs.filter((item, index) => index <= curIndex)
|
||||
this.setTabs(filterTabs)
|
||||
if (!filterTabs.find((item) => item.path === this.active))
|
||||
router.push(filterTabs[filterTabs.length - 1].path)
|
||||
router.push(filterTabs[filterTabs.length - 1]?.path ?? '/')
|
||||
},
|
||||
resetTabs() {
|
||||
this.setTabs([])
|
||||
|
||||
@@ -20,10 +20,10 @@ export const useThemeStore = defineStore('theme', {
|
||||
return this.darkMode ? darkTheme : undefined
|
||||
},
|
||||
naiveLocale(): NLocale {
|
||||
return locales[this.locale].locale
|
||||
return locales[this.locale]?.locale ?? enUS
|
||||
},
|
||||
naiveDateLocale(): NDateLocale {
|
||||
return locales[this.locale].dateLocale
|
||||
return locales[this.locale]?.dateLocale ?? dateEnUS
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
|
||||
@@ -11,7 +11,7 @@ export function encodeBase64(str: string): string {
|
||||
const bytes = new TextEncoder().encode(str)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
binary += String.fromCharCode(bytes[i] ?? 0)
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
@@ -47,9 +47,10 @@ export function generateRandomString(length: number) {
|
||||
for (let i = 0; i < randomBytes.length && result.length < length; i++) {
|
||||
// Only use values that map evenly to the character set to avoid bias
|
||||
const maxValue = Math.floor(256 / charactersLength) * charactersLength
|
||||
if (randomBytes[i] < maxValue) {
|
||||
const randomIndex = randomBytes[i] % charactersLength
|
||||
result += characters[randomIndex]
|
||||
const byteValue = randomBytes[i] ?? 0
|
||||
if (byteValue < maxValue) {
|
||||
const randomIndex = byteValue % charactersLength
|
||||
result += characters[randomIndex] ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import api from '@/api/panel/backup'
|
||||
const { $gettext } = useGettext()
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
const type = defineModel<string>('type', { type: String, required: true })
|
||||
const upload = ref<any>(null)
|
||||
|
||||
const uploadRequest = ({ file, onFinish, onError, onProgress }: UploadCustomRequestOptions) => {
|
||||
const formData = new FormData()
|
||||
|
||||
@@ -94,7 +94,7 @@ const getNetworks = () => {
|
||||
value: item.id
|
||||
}))
|
||||
if (networks.value.length > 0) {
|
||||
createModel.network = networks.value[0].value
|
||||
createModel.network = networks.value[0]?.value ?? ''
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -204,7 +204,7 @@ watch(show, (val) => {
|
||||
v-model:value="createModel.image"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
:placeholder="$gettext('e.g., nginx:latest, mysql:8.0')"
|
||||
:placeholder="$gettext('e.g., nginx, mysql:8.4, your_username/your_image:tag')"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
const minimized = defineModel<boolean>('minimized', { type: Boolean, default: false })
|
||||
const filePath = defineModel<string>('file', { type: String, required: true })
|
||||
|
||||
const editorRef = ref<InstanceType<typeof FileEditorView>>()
|
||||
|
||||
// 窗口默认尺寸
|
||||
const defaultWidth = Math.min(1400, window.innerWidth * 0.9)
|
||||
const defaultHeight = Math.min(900, window.innerHeight * 0.85)
|
||||
|
||||
@@ -120,7 +120,7 @@ const handleDrop = async (e: DragEvent) => {
|
||||
// 使用 webkitGetAsEntry 来支持文件夹
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
if (item.kind === 'file') {
|
||||
if (item?.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry()
|
||||
if (entry) {
|
||||
if (entry.isFile) {
|
||||
|
||||
@@ -184,7 +184,7 @@ const selectionBox = computed(() => {
|
||||
const hexToRgb = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result
|
||||
? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
|
||||
? `${parseInt(result[1] ?? '0', 16)}, ${parseInt(result[2] ?? '0', 16)}, ${parseInt(result[3] ?? '0', 16)}`
|
||||
: '24, 160, 88'
|
||||
}
|
||||
|
||||
@@ -843,7 +843,8 @@ const handlePaste = () => {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i]) {
|
||||
flag = true
|
||||
paths[i].force = true
|
||||
const pathItem = paths[i]
|
||||
if (pathItem) pathItem.force = true
|
||||
}
|
||||
}
|
||||
if (flag) {
|
||||
|
||||
@@ -53,7 +53,7 @@ const handleUp = () => {
|
||||
const handleBack = () => {
|
||||
if (current > 0) {
|
||||
current--
|
||||
path.value = history[current]
|
||||
path.value = history[current] ?? '/'
|
||||
input.value = path.value.slice(1)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ const handleBack = () => {
|
||||
const handleForward = () => {
|
||||
if (current < history.length - 1) {
|
||||
current++
|
||||
path.value = history[current]
|
||||
path.value = history[current] ?? '/'
|
||||
input.value = path.value.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,12 @@ watch(
|
||||
(newVal) => {
|
||||
if (newVal && fileInfoList.value.length > 0) {
|
||||
const firstFile = fileInfoList.value[0]
|
||||
mode.value = normalizeMode(firstFile.mode)
|
||||
owner.value = firstFile.owner || 'www'
|
||||
group.value = firstFile.group || 'www'
|
||||
updateCheckboxes()
|
||||
if (firstFile) {
|
||||
mode.value = normalizeMode(firstFile.mode)
|
||||
owner.value = firstFile.owner || 'www'
|
||||
group.value = firstFile.group || 'www'
|
||||
updateCheckboxes()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -73,7 +75,7 @@ const calculateMode = () => {
|
||||
|
||||
const updateCheckboxes = () => {
|
||||
const paddedMode = normalizeMode(mode.value)
|
||||
const permissions = paddedMode.split('').map(Number)
|
||||
const permissions = paddedMode.split('').map(Number) as [number, number, number]
|
||||
|
||||
checkbox.value.owner = permissions[0] & 4 ? ['read'] : []
|
||||
if (permissions[0] & 2) checkbox.value.owner.push('write')
|
||||
@@ -94,7 +96,7 @@ const title = computed(() => {
|
||||
}
|
||||
return selected.value.length > 1
|
||||
? $gettext('Batch modify permissions')
|
||||
: $gettext('Modify permissions - %{ path }', { path: selected.value[0] })
|
||||
: $gettext('Modify permissions - %{ path }', { path: selected.value[0] ?? '' })
|
||||
})
|
||||
|
||||
watch(mode, updateCheckboxes, { immediate: true })
|
||||
|
||||
@@ -105,7 +105,8 @@ const handlePaste = () => {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i]) {
|
||||
flag = true
|
||||
paths[i].force = true
|
||||
const pathItem = paths[i]
|
||||
if (pathItem) pathItem.force = true
|
||||
}
|
||||
}
|
||||
if (flag) {
|
||||
|
||||
@@ -540,13 +540,13 @@ if (import.meta.hot) {
|
||||
<n-table :single-line="false" striped>
|
||||
<tr>
|
||||
<th>{{ $gettext('Model') }}</th>
|
||||
<td>{{ realtime.cpus[0].modelName }}</td>
|
||||
<td>{{ realtime.cpus[0]?.modelName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{{ $gettext('Parameters') }}</th>
|
||||
<td>
|
||||
{{ realtime.cpus.length }} CPU {{ cores }} {{ $gettext('cores') }}
|
||||
{{ formatBytes(realtime.cpus[0].cacheSize * 1024) }} {{ $gettext('cache') }}
|
||||
{{ formatBytes((realtime.cpus[0]?.cacheSize ?? 0) * 1024) }} {{ $gettext('cache') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="item in realtime.cpus" :key="item.modelName">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import user from '@/api/panel/user'
|
||||
import bgImg from '@/assets/images/login_bg.webp'
|
||||
import logoImg from '@/assets/images/logo.png'
|
||||
import logoImg from '@/assets/images/logo.svg'
|
||||
import { addDynamicRoutes } from '@/router'
|
||||
import { useThemeStore, useUserStore } from '@/store'
|
||||
import { getLocal, removeLocal, setLocal } from '@/utils'
|
||||
|
||||
@@ -402,7 +402,7 @@ const handleSave = async () => {
|
||||
<n-alert type="info" style="margin-bottom: 16px">
|
||||
{{
|
||||
$gettext(
|
||||
'Configure service dependencies to control startup order. Common services: network.target, mysql.service, redis.service'
|
||||
'Configure service dependencies to control startup order. Common services: network.target, mysqld.service, postgresql.service, redis.service'
|
||||
)
|
||||
}}
|
||||
</n-alert>
|
||||
|
||||
@@ -83,6 +83,7 @@ const getOption = (route: RouteType): TreeSelectOption => {
|
||||
if (visibleChildren.length === 1) {
|
||||
// 单个子路由处理
|
||||
const singleRoute = visibleChildren[0]
|
||||
if (!singleRoute) return menuItem
|
||||
const isSingleDisabled = forbiddenHiddenMenus.includes(singleRoute.name as string)
|
||||
menuItem.label = singleRoute.meta?.title
|
||||
? translateTitle(singleRoute.meta.title)
|
||||
@@ -93,7 +94,8 @@ const getOption = (route: RouteType): TreeSelectOption => {
|
||||
? singleRoute.children.filter((item: RouteType) => item.name && !item.isHidden)
|
||||
: []
|
||||
|
||||
if (visibleItems.length === 1) menuItem = getOption(visibleItems[0])
|
||||
const firstVisibleItem = visibleItems[0]
|
||||
if (visibleItems.length === 1 && firstVisibleItem) menuItem = getOption(firstVisibleItem)
|
||||
else if (visibleItems.length > 1)
|
||||
menuItem.children = visibleItems.map((item) => getOption(item))
|
||||
} else {
|
||||
|
||||
@@ -180,13 +180,14 @@ const closeTab = (tabId: string) => {
|
||||
if (index === -1) return
|
||||
|
||||
const tab = tabs.value[index]
|
||||
disposeTab(tab)
|
||||
if (tab) disposeTab(tab)
|
||||
tabs.value.splice(index, 1)
|
||||
|
||||
// 如果关闭的是当前标签,切换到其他标签
|
||||
if (activeTabId.value === tabId && tabs.value.length > 0) {
|
||||
const newIndex = Math.min(index, tabs.value.length - 1)
|
||||
switchTab(tabs.value[newIndex].id)
|
||||
const newTab = tabs.value[newIndex]
|
||||
if (newTab) switchTab(newTab.id)
|
||||
}
|
||||
|
||||
// 如果没有标签了,创建一个本机标签
|
||||
|
||||
@@ -64,7 +64,7 @@ const handleTest = async () => {
|
||||
inTest.value = true
|
||||
progress.value = 0
|
||||
for (let i = 0; i < tests.length; i++) {
|
||||
const test = tests[i]
|
||||
const test = tests[i] ?? ''
|
||||
current.value = test
|
||||
if (test != 'memory' && test != 'disk') {
|
||||
cpu.value[test as keyof typeof cpu.value] = await benchmark.test(test)
|
||||
|
||||
@@ -6,6 +6,7 @@ defineOptions({
|
||||
import { useRequest } from 'alova/client'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NProgress, NTag } from 'naive-ui'
|
||||
import { h } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import disk from '@/api/panel/toolbox-disk'
|
||||
|
||||
@@ -76,24 +76,28 @@ const scanResults = ref<Record<string, ScanResult>>({
|
||||
|
||||
// 扫描日志
|
||||
const handleScan = async (type: string) => {
|
||||
scanResults.value[type].loading = true
|
||||
scanResults.value[type].scanned = false
|
||||
scanResults.value[type].items = []
|
||||
const result = scanResults.value[type]
|
||||
if (!result) return
|
||||
result.loading = true
|
||||
result.scanned = false
|
||||
result.items = []
|
||||
|
||||
try {
|
||||
const { data } = await useRequest(toolboxLog.scan(type))
|
||||
scanResults.value[type].items = data || []
|
||||
scanResults.value[type].scanned = true
|
||||
result.items = data || []
|
||||
result.scanned = true
|
||||
} catch (e) {
|
||||
window.$message.error($gettext('Scan failed'))
|
||||
} finally {
|
||||
scanResults.value[type].loading = false
|
||||
result.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清理日志
|
||||
const handleClean = async (type: string) => {
|
||||
scanResults.value[type].cleaning = true
|
||||
const result = scanResults.value[type]
|
||||
if (!result) return
|
||||
result.cleaning = true
|
||||
|
||||
try {
|
||||
const { data } = await useRequest(toolboxLog.clean(type))
|
||||
@@ -103,7 +107,7 @@ const handleClean = async (type: string) => {
|
||||
} catch (e) {
|
||||
window.$message.error($gettext('Clean failed'))
|
||||
} finally {
|
||||
scanResults.value[type].cleaning = false
|
||||
result.cleaning = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +121,8 @@ const handleScanAll = async () => {
|
||||
// 清理所有
|
||||
const handleCleanAll = async () => {
|
||||
for (const logType of logTypes) {
|
||||
if (scanResults.value[logType.key].items.length > 0) {
|
||||
const result = scanResults.value[logType.key]
|
||||
if (result && result.items.length > 0) {
|
||||
await handleClean(logType.key)
|
||||
}
|
||||
}
|
||||
@@ -132,6 +137,11 @@ const totalItems = computed(() => {
|
||||
const anyLoading = computed(() => {
|
||||
return Object.values(scanResults.value).some((r) => r.loading || r.cleaning)
|
||||
})
|
||||
|
||||
// 获取扫描结果
|
||||
const getResult = (key: string): ScanResult => {
|
||||
return scanResults.value[key] ?? { loading: false, items: [], scanned: false, cleaning: false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,7 +173,7 @@ const anyLoading = computed(() => {
|
||||
<n-flex :size="8">
|
||||
<n-button
|
||||
size="small"
|
||||
:loading="scanResults[logType.key].loading"
|
||||
:loading="getResult(logType.key).loading"
|
||||
@click="handleScan(logType.key)"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -174,8 +184,8 @@ const anyLoading = computed(() => {
|
||||
<n-button
|
||||
size="small"
|
||||
type="warning"
|
||||
:loading="scanResults[logType.key].cleaning"
|
||||
:disabled="scanResults[logType.key].items.length === 0"
|
||||
:loading="getResult(logType.key).cleaning"
|
||||
:disabled="getResult(logType.key).items.length === 0"
|
||||
@click="handleClean(logType.key)"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -189,15 +199,15 @@ const anyLoading = computed(() => {
|
||||
<n-flex vertical>
|
||||
<n-text depth="3">{{ logType.description }}</n-text>
|
||||
|
||||
<template v-if="scanResults[logType.key].loading">
|
||||
<template v-if="getResult(logType.key).loading">
|
||||
<n-flex justify="center" align="center" style="min-height: 60px">
|
||||
<n-spin size="small" />
|
||||
<n-text>{{ $gettext('Scanning...') }}</n-text>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<template v-else-if="scanResults[logType.key].scanned">
|
||||
<template v-if="scanResults[logType.key].items.length === 0">
|
||||
<template v-else-if="getResult(logType.key).scanned">
|
||||
<template v-if="getResult(logType.key).items.length === 0">
|
||||
<n-empty :description="$gettext('No logs found')" size="small" />
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -206,7 +216,7 @@ const anyLoading = computed(() => {
|
||||
{ title: $gettext('Name'), key: 'name', ellipsis: { tooltip: true } },
|
||||
{ title: $gettext('Size'), key: 'size', width: 120 }
|
||||
]"
|
||||
:data="scanResults[logType.key].items"
|
||||
:data="getResult(logType.key).items"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:max-height="200"
|
||||
|
||||
@@ -30,6 +30,14 @@ const editingNtpServers = ref<string[]>([])
|
||||
|
||||
const dnsManager = ref('')
|
||||
|
||||
// loading 状态
|
||||
const dnsLoading = ref(false)
|
||||
const swapLoading = ref(false)
|
||||
const hostLoading = ref(false)
|
||||
const timeLoading = ref(false)
|
||||
const syncTimeLoading = ref(false)
|
||||
const ntpLoading = ref(false)
|
||||
|
||||
useRequest(system.dns()).onSuccess(({ data }) => {
|
||||
dns1.value = data.dns?.[0] ?? ''
|
||||
dns2.value = data.dns?.[1] ?? ''
|
||||
@@ -58,39 +66,64 @@ useRequest(system.ntpServers()).onSuccess(({ data }) => {
|
||||
})
|
||||
|
||||
const handleUpdateDNS = () => {
|
||||
useRequest(system.updateDns(dns1.value, dns2.value)).onSuccess(() => {
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
dnsLoading.value = true
|
||||
useRequest(system.updateDns(dns1.value, dns2.value))
|
||||
.onSuccess(() => {
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
dnsLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateSwap = () => {
|
||||
useRequest(system.updateSwap(swap.value)).onSuccess(() => {
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
swapLoading.value = true
|
||||
useRequest(system.updateSwap(swap.value))
|
||||
.onSuccess(() => {
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
swapLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateHost = async () => {
|
||||
await Promise.all([
|
||||
const handleUpdateHost = () => {
|
||||
hostLoading.value = true
|
||||
Promise.all([
|
||||
useRequest(system.updateHostname(hostname.value)),
|
||||
useRequest(system.updateHosts(hosts.value))
|
||||
]).then(() => {
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
])
|
||||
.then(() => {
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
.finally(() => {
|
||||
hostLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateTime = async () => {
|
||||
await Promise.all([
|
||||
const handleUpdateTime = () => {
|
||||
timeLoading.value = true
|
||||
Promise.all([
|
||||
useRequest(system.updateTime(String(DateTime.fromMillis(time.value).toISO()))),
|
||||
useRequest(system.updateTimezone(timezone.value))
|
||||
]).then(() => {
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
])
|
||||
.then(() => {
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
.finally(() => {
|
||||
timeLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const handleSyncTime = () => {
|
||||
useRequest(system.syncTime(syncServer.value || undefined)).onSuccess(() => {
|
||||
window.$message.success($gettext('Synchronized successfully'))
|
||||
})
|
||||
syncTimeLoading.value = true
|
||||
useRequest(system.syncTime(syncServer.value || undefined))
|
||||
.onSuccess(() => {
|
||||
window.$message.success($gettext('Synchronized successfully'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
syncTimeLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenNtpSettings = () => {
|
||||
@@ -117,11 +150,16 @@ const handleSaveNtpServers = () => {
|
||||
window.$message.error($gettext('At least one NTP server is required'))
|
||||
return
|
||||
}
|
||||
useRequest(system.updateNtpServers(servers)).onSuccess(() => {
|
||||
ntpServers.value = servers
|
||||
showNtpModal.value = false
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
ntpLoading.value = true
|
||||
useRequest(system.updateNtpServers(servers))
|
||||
.onSuccess(() => {
|
||||
ntpServers.value = servers
|
||||
showNtpModal.value = false
|
||||
window.$message.success($gettext('Saved successfully'))
|
||||
})
|
||||
.onComplete(() => {
|
||||
ntpLoading.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -148,7 +186,7 @@ const handleSaveNtpServers = () => {
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="handleUpdateDNS">
|
||||
<n-button type="primary" :loading="dnsLoading" :disabled="dnsLoading" @click="handleUpdateDNS">
|
||||
{{ $gettext('Save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -172,7 +210,7 @@ const handleSaveNtpServers = () => {
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="handleUpdateSwap">
|
||||
<n-button type="primary" :loading="swapLoading" :disabled="swapLoading" @click="handleUpdateSwap">
|
||||
{{ $gettext('Save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -190,7 +228,7 @@ const handleSaveNtpServers = () => {
|
||||
<common-editor v-model:value="hosts" height="60vh" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button type="primary" @click="handleUpdateHost">
|
||||
<n-button type="primary" :loading="hostLoading" :disabled="hostLoading" @click="handleUpdateHost">
|
||||
{{ $gettext('Save') }}
|
||||
</n-button>
|
||||
</n-tab-pane>
|
||||
@@ -228,10 +266,10 @@ const handleSaveNtpServers = () => {
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex>
|
||||
<n-button type="primary" @click="handleUpdateTime">
|
||||
<n-button type="primary" :loading="timeLoading" :disabled="timeLoading" @click="handleUpdateTime">
|
||||
{{ $gettext('Save') }}
|
||||
</n-button>
|
||||
<n-button type="info" @click="handleSyncTime">
|
||||
<n-button type="info" :loading="syncTimeLoading" :disabled="syncTimeLoading" @click="handleSyncTime">
|
||||
{{ $gettext('Synchronize Time') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -300,7 +338,7 @@ const handleSaveNtpServers = () => {
|
||||
{{ $gettext('Reset to Default') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-button type="primary" @click="handleSaveNtpServers">
|
||||
<n-button type="primary" :loading="ntpLoading" :disabled="ntpLoading" @click="handleSaveNtpServers">
|
||||
{{ $gettext('Save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useGettext } from 'vue3-gettext'
|
||||
import website from '@/api/panel/website'
|
||||
|
||||
const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
const type = defineModel<string>('type', { type: String, required: true })
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
|
||||
@@ -24,16 +23,16 @@ const handleCreate = async () => {
|
||||
return
|
||||
}
|
||||
// 去除空格
|
||||
const name = parts[0].trim()
|
||||
const domains = parts[1]
|
||||
const name = (parts[0] ?? '').trim()
|
||||
const domains = (parts[1] ?? '')
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
const listens = parts[2]
|
||||
const listens = (parts[2] ?? '')
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
const path = parts[3].trim()
|
||||
const path = (parts[3] ?? '').trim()
|
||||
const remark = parts[4] ? parts[4].trim() : ''
|
||||
let model = {
|
||||
name: '',
|
||||
|
||||
+694
-245
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user