Compare commits

...

54 Commits

Author SHA1 Message Date
耗子 d0c4a106fb feat: 优化超长标题显示 2026-01-30 00:30:00 +08:00
github-actions[bot] 571a63325b New Crowdin translations by GitHub Action (#1306)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-30 00:18:26 +08:00
devhaozi 4ae47f03ef chore(l10n): update pot files 2026-01-29 16:16:47 +00:00
耗子 9e24a953c1 feat: 清理代码 2026-01-30 00:12:51 +08:00
耗子 64ef88c28e feat: 工具箱操作添加loading 2026-01-29 23:53:00 +08:00
耗子 43b92ceb4e fix: 修复固定标签页对菜单不生效 2026-01-29 23:52:24 +08:00
耗子 c2c3aa48f1 feat: 清理代码 2026-01-29 23:37:49 +08:00
耗子 be7996969a feat: 清理代码 2026-01-29 23:14:41 +08:00
renovate[bot] eeb4aaee90 chore(deps): Update module github.com/lib/pq to v1.11.1 (#1304)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 22:24:36 +08:00
github-actions[bot] fb7c56dc01 New Crowdin translations by GitHub Action (#1303)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-29 22:21:17 +08:00
devhaozi 15abaa875d chore(l10n): update pot files 2026-01-29 14:17:12 +00:00
耗子 0ab2bbe92a Revert "feat: 反向代理支持更多设置"
This reverts commit dd7d1ed490.
2026-01-29 22:13:45 +08:00
耗子 dd7d1ed490 feat: 反向代理支持更多设置 2026-01-29 22:12:55 +08:00
github-actions[bot] c9783118f5 New Crowdin translations by GitHub Action (#1302)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-29 21:52:16 +08:00
devhaozi 626342d13b chore(l10n): update pot files 2026-01-29 12:28:48 +00:00
耗子 15287b5bce fix: lint 2026-01-29 20:26:54 +08:00
耗子 f0f4b4f692 fix: lint 2026-01-29 20:25:07 +08:00
耗子 ea0dccb6c1 Merge remote-tracking branch 'origin/main' 2026-01-29 20:06:48 +08:00
耗子 6a73f6e333 feat: 反向代理支持更多设置 2026-01-29 20:06:44 +08:00
devhaozi b5c9f3bfb9 chore(l10n): update pot files 2026-01-29 11:44:22 +00:00
耗子 6458957650 feat: lint 2026-01-29 19:42:05 +08:00
耗子 bf8ca47c62 feat: 默认404缓存改为10s 2026-01-29 19:38:14 +08:00
耗子 e7fa6b6fb0 Merge remote-tracking branch 'origin/main' 2026-01-29 19:37:55 +08:00
耗子 6f0b10653b feat: 优化引导词 2026-01-29 19:37:51 +08:00
devhaozi c9bc8aa193 chore(l10n): update pot files 2026-01-29 11:07:23 +00:00
耗子 6870388d07 feat: 优化代理缓存设置 2026-01-29 18:33:28 +08:00
耗子 8f9a32dfb0 feat: 优化代理缓存设置 2026-01-29 17:47:42 +08:00
耗子 f87658f175 fix: 优化描述 2026-01-29 17:33:10 +08:00
耗子 369ef07f19 feat: 创建容器不再重复拉取镜像 2026-01-29 17:02:19 +08:00
耗子 6a3e0e547a Merge remote-tracking branch 'origin/main' 2026-01-29 16:57:16 +08:00
耗子 3ba9b8433d feat: 优化容器创建描述 2026-01-29 16:57:08 +08:00
renovate[bot] bd03013392 chore(deps): Update module github.com/lib/pq to v1.11.0 (#1301)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 04:22:02 +00:00
耗子 bd746fe766 feat: 代码优化 2026-01-29 08:14:02 +08:00
耗子 eca6ae07be feat: 代码优化 2026-01-29 08:06:48 +08:00
耗子 ab6d6c6ba2 fix: 反向代理缓存zone错误 2026-01-29 05:45:34 +08:00
耗子 3547157850 fix: 阻止酒吧点炒饭 2026-01-28 21:16:04 +08:00
renovate[bot] 01bda75ce5 chore(deps): Update module github.com/coreos/go-systemd/v22 to v22.7.0 (#1299)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-28 17:15:09 +08:00
耗子 2cfd5a26a9 fix: 兼容docker配置文件为空的情况 2026-01-28 07:46:01 +08:00
耗子 fd583ec208 fix: s3fs挂载失败删除密码 2026-01-28 07:33:09 +08:00
耗子 af2d856576 fix: s3fs卸载 2026-01-28 07:21:28 +08:00
耗子 d4a6b5e207 fix: s3fs卸载 2026-01-28 07:17:10 +08:00
耗子 ef6b83f425 fix: s3fs挂载 2026-01-28 07:04:43 +08:00
耗子 73d3a5d86b fix: s3fs挂载 2026-01-28 06:14:14 +08:00
github-actions[bot] 8f9ff80fbf New Crowdin translations by GitHub Action (#1298)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-27 23:11:37 +08:00
devhaozi 3ef55c5241 chore(l10n): update pot files 2026-01-27 15:10:45 +00:00
耗子 6db0035c43 fix: 自动 systemctl daemon-reload 2026-01-27 23:09:20 +08:00
耗子 fd27f198b9 feat: 优化logo 2026-01-27 22:08:11 +08:00
github-actions[bot] e735d63c56 New Crowdin translations by GitHub Action (#1297)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-01-27 21:52:15 +08:00
devhaozi c23d13437d chore(l10n): update pot files 2026-01-27 13:47:18 +00:00
耗子 4424d8ba18 fix: 编辑网站部分输入框会失去焦点 2026-01-27 21:45:31 +08:00
耗子 74a1fd2258 Merge remote-tracking branch 'origin/main' 2026-01-27 20:06:04 +08:00
耗子 84f5ea815b fix: 部分后端不支持h2代理 2026-01-27 20:06:00 +08:00
renovate[bot] 0fc6104d89 chore(deps): Update github.com/libtnb/moby/client digest to d4b7902 (#1296)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 19:11:53 +08:00
renovate[bot] 05ee59c51c chore(deps): Update non-major dependencies (#1295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 05:16:25 +08:00
63 changed files with 5359 additions and 3114 deletions
+179
View File
@@ -1,6 +1,164 @@
## 角色定义
你是 Linus TorvaldsLinux 内核的创造者和首席架构师。你已经维护 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 上的实际使用案例
+218 -39
View File
@@ -2,9 +2,167 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 角色定义
你是 Linus TorvaldsLinux 内核的创造者和首席架构师。你已经维护 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 上的实际使用案例
+5 -5
View File
@@ -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
)
+8 -8
View File
@@ -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=
+2 -6
View File
@@ -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 != "" {
+4 -2
View File
@@ -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 {
-11
View File
@@ -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,
+21 -6
View File
@@ -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()
}
+1 -1
View File
@@ -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"`
+4 -4
View File
@@ -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"`
}
+4 -4
View File
@@ -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 ""
+5 -5
View File
@@ -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 "找不到挂载"
+5 -5
View File
@@ -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 "找不到掛載"
+66 -18
View File
@@ -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 {
+5 -17
View File
@@ -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 重定向
+3 -9
View File
@@ -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
View File
@@ -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()
+6 -19
View File
@@ -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")
}
+2 -5
View File
@@ -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 连接数
+4 -17
View File
@@ -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"]}},
},
})
}
+92 -10
View 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
View File
@@ -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"
}
+19
View File
@@ -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

+6
View File
@@ -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

+1 -1
View File
@@ -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>
+6 -5
View File
@@ -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
}
+10 -8
View File
@@ -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>
+12 -4
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+813 -624
View File
File diff suppressed because it is too large Load Diff
+813 -624
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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 }
+4 -4
View File
@@ -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)
}
},
+5 -5
View File
@@ -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([])
+2 -2
View File
@@ -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: {
+1 -1
View File
@@ -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)
}
+4 -3
View File
@@ -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] ?? ''
}
}
}
-1
View File
@@ -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()
+2 -2
View File
@@ -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>
-2
View File
@@ -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)
+1 -1
View File
@@ -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) {
+3 -2
View File
@@ -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) {
+2 -2
View File
@@ -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)
}
}
+8 -6
View File
@@ -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 })
+2 -1
View File
@@ -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) {
+2 -2
View File
@@ -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 -1
View File
@@ -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'
+1 -1
View File
@@ -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>
+3 -1
View File
@@ -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 {
+3 -2
View File
@@ -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)
}
//
+1 -1
View File
@@ -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)
+1
View File
@@ -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'
+26 -16
View File
@@ -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"
+68 -30
View File
@@ -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>
+4 -5
View File
@@ -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: '',
File diff suppressed because it is too large Load Diff