Merge branch 'r-dev' into r-dev
This commit is contained in:
12
dashboard/docs/Caddyfile.docker.example
Normal file
12
dashboard/docs/Caddyfile.docker.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
maibot.example.com {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy core:8001
|
||||||
|
}
|
||||||
12
dashboard/docs/Caddyfile.host.example
Normal file
12
dashboard/docs/Caddyfile.host.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
maibot.example.com {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy 127.0.0.1:8001
|
||||||
|
}
|
||||||
203
dashboard/docs/webui-tls-ssl-compose.md
Normal file
203
dashboard/docs/webui-tls-ssl-compose.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# MaiBot WebUI Compose TLS/SSL 教程
|
||||||
|
|
||||||
|
本文档专门说明 Docker Compose 部署下如何通过 Caddy 为 MaiBot WebUI 提供 HTTPS。
|
||||||
|
|
||||||
|
## 1. 目标结构
|
||||||
|
|
||||||
|
启用后,网络结构应为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器
|
||||||
|
-> https://maibot.example.com
|
||||||
|
-> Caddy 容器 :80/:443
|
||||||
|
-> core 容器 :8001
|
||||||
|
-> MaiBot WebUI
|
||||||
|
```
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
1. core 不再直接对公网暴露 8001
|
||||||
|
2. Caddy 统一接管 80 和 443
|
||||||
|
3. Caddy 通过 Docker 网络访问 core:8001
|
||||||
|
|
||||||
|
## 2. 仓库里已经补了什么
|
||||||
|
|
||||||
|
本仓库已补充以下内容:
|
||||||
|
|
||||||
|
1. 根目录 docker-compose.yml 中新增了默认注释的 Caddy 示例块
|
||||||
|
2. 根目录 docker-compose.yml 中新增了默认注释的 Caddy 数据卷定义
|
||||||
|
3. dashboard/docs/Caddyfile.docker.example 提供了 Docker Compose 专用配置模板
|
||||||
|
4. dashboard/docs/Caddyfile.host.example 提供了非 Docker 宿主机专用配置模板
|
||||||
|
|
||||||
|
## 3. 需要手动注释或启用的段落
|
||||||
|
|
||||||
|
本文档按默认保持注释状态进行说明,下面明确列出需要操作的段落。
|
||||||
|
|
||||||
|
### 3.1 需要注释掉的现有段落
|
||||||
|
|
||||||
|
启用 Caddy 以后,请注释掉根目录 docker-compose.yml 中 core 服务下这一段端口映射:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "18001:8001"
|
||||||
|
```
|
||||||
|
|
||||||
|
原因很简单:
|
||||||
|
|
||||||
|
1. 这段会把 WebUI 的明文 HTTP 直接暴露到宿主机
|
||||||
|
2. 启用 HTTPS 以后,应由 Caddy 对外暴露 80 和 443
|
||||||
|
3. 避免出现“HTTPS 入口和 HTTP 入口同时暴露”的混乱状态
|
||||||
|
|
||||||
|
### 3.2 需要取消注释并启用的段落
|
||||||
|
|
||||||
|
启用时,需要在根目录 docker-compose.yml 中取消注释这两部分:
|
||||||
|
|
||||||
|
1. caddy 服务块
|
||||||
|
2. volumes 里的 caddy_data 和 caddy_config
|
||||||
|
|
||||||
|
## 4. 启用前需要准备什么
|
||||||
|
|
||||||
|
1. 域名已经解析到服务器公网 IP
|
||||||
|
2. 宿主机的 80 和 443 未被占用
|
||||||
|
3. 防火墙和云安全组已放行 80 和 443
|
||||||
|
4. WebUI 当前可以通过 compose 正常启动
|
||||||
|
5. 已准备修改 dashboard/docs/Caddyfile.docker.example 里的域名
|
||||||
|
|
||||||
|
## 5. Caddy 配置文件如何写
|
||||||
|
|
||||||
|
Docker Compose 模式请使用:dashboard/docs/Caddyfile.docker.example
|
||||||
|
|
||||||
|
非 Docker 宿主机模式请使用:dashboard/docs/Caddyfile.host.example
|
||||||
|
|
||||||
|
最小可用配置如下:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
maibot.example.com {
|
||||||
|
reverse_proxy core:8001
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
建议至少做这两处修改:
|
||||||
|
|
||||||
|
1. 把 maibot.example.com 改成实际使用的域名
|
||||||
|
2. 如果有额外安全要求,再按需增加 header 配置
|
||||||
|
|
||||||
|
## 6. compose 启用步骤
|
||||||
|
|
||||||
|
### 6.1 修改 WebUI 配置
|
||||||
|
|
||||||
|
先在 config/bot_config.toml 中确认:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[webui]
|
||||||
|
mode = "production"
|
||||||
|
secure_cookie = true
|
||||||
|
trust_xff = true
|
||||||
|
```
|
||||||
|
|
||||||
|
trusted_proxies 的建议值取决于实际网络环境。
|
||||||
|
|
||||||
|
如果 Caddy 和 core 在同一个 Docker 网络里,建议先按实际来源地址或网段填写。不要为了省事直接把范围开得过大。
|
||||||
|
|
||||||
|
### 6.2 修改 Caddyfile
|
||||||
|
|
||||||
|
编辑 dashboard/docs/Caddyfile.docker.example,将域名替换为真实值。
|
||||||
|
|
||||||
|
### 6.3 修改 compose
|
||||||
|
|
||||||
|
1. 注释掉 core 服务里对外暴露 WebUI 的 ports 段
|
||||||
|
2. 取消注释 caddy 服务块
|
||||||
|
3. 取消注释底部 volumes 里的 caddy_data 和 caddy_config
|
||||||
|
|
||||||
|
### 6.4 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 查看日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f caddy
|
||||||
|
docker compose logs -f core
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Let's Encrypt 申请与续期
|
||||||
|
|
||||||
|
### 7.1 证书申请触发条件
|
||||||
|
|
||||||
|
Caddy 容器启动后,满足以下条件时会自动申请证书:
|
||||||
|
|
||||||
|
1. 域名已解析到当前服务器
|
||||||
|
2. 80 和 443 对公网开放
|
||||||
|
3. Caddy 能成功接收到针对该域名的请求
|
||||||
|
|
||||||
|
### 7.2 自动续期说明
|
||||||
|
|
||||||
|
Caddy 会自动续期,通常不需要编写 crontab,也不需要手工执行 certbot。
|
||||||
|
|
||||||
|
只需要确保:
|
||||||
|
|
||||||
|
1. caddy_data 卷被持久化
|
||||||
|
2. 容器会长期运行
|
||||||
|
3. 域名长期指向同一台服务器或新服务器已同步迁移数据
|
||||||
|
4. 80 和 443 没被防火墙阻断
|
||||||
|
|
||||||
|
### 7.3 续期检查建议
|
||||||
|
|
||||||
|
建议定期执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs --tail=200 caddy
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
重点关注:
|
||||||
|
|
||||||
|
1. ACME 申请失败
|
||||||
|
2. 证书续期失败
|
||||||
|
3. 端口绑定失败
|
||||||
|
4. 域名解析不一致
|
||||||
|
|
||||||
|
## 8. 常见错误与排查
|
||||||
|
|
||||||
|
### 8.1 证书申请失败
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
1. 域名是否指向服务器公网 IP
|
||||||
|
2. 是否已经开启 CDN 代理但未正确放通验证流量
|
||||||
|
3. 80 和 443 是否被云厂商安全组拦截
|
||||||
|
4. 宿主机是否还有别的程序占用了 80 或 443
|
||||||
|
|
||||||
|
### 8.2 登录失败
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
1. webui.secure_cookie 是否已启用
|
||||||
|
2. 请求是否真正走 https:// 域名
|
||||||
|
3. 代理是否正确传递了 X-Forwarded-Proto
|
||||||
|
|
||||||
|
### 8.3 WebSocket 连接失败
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
1. Caddy 是否已正确反向代理到 core:8001
|
||||||
|
2. 页面是否通过 HTTPS 打开
|
||||||
|
3. 浏览器开发者工具里是否出现混合内容报错
|
||||||
|
|
||||||
|
## 9. 迁移建议
|
||||||
|
|
||||||
|
如果当前已经在使用:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "18001:8001"
|
||||||
|
```
|
||||||
|
|
||||||
|
那说明当前还是“宿主机明文 HTTP 暴露 WebUI”模式。迁移到 HTTPS 时建议:
|
||||||
|
|
||||||
|
1. 先准备好域名
|
||||||
|
2. 先改好 Caddyfile
|
||||||
|
3. 再切换 compose 暴露方式
|
||||||
|
4. 切换后直接以 https://域名 访问,不再继续使用 http://服务器IP:18001
|
||||||
465
dashboard/docs/webui-tls-ssl.md
Normal file
465
dashboard/docs/webui-tls-ssl.md
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
# MaiBot WebUI TLS/SSL 配置指南
|
||||||
|
|
||||||
|
本文档基于当前仓库实现整理,目标是让 WebUI 通过 HTTPS 提供访问能力,并保持登录、Cookie、WebSocket 和 Let's Encrypt 续期正常工作。
|
||||||
|
|
||||||
|
## 1. 先说结论
|
||||||
|
|
||||||
|
MaiBot 当前最合适的 TLS/SSL 方案是让反向代理终止 HTTPS,然后把请求转发到 WebUI 的 HTTP 服务。
|
||||||
|
|
||||||
|
推荐顺序如下:
|
||||||
|
|
||||||
|
1. Caddy 反向代理 + Let's Encrypt 自动签发与续期
|
||||||
|
2. 宝塔面板反向代理 + Let's Encrypt
|
||||||
|
3. 1Panel 反向代理 + Let's Encrypt
|
||||||
|
4. 不建议直接让 WebUI 自己监听 HTTPS,当前仓库没有现成的 WebUI 原生 TLS 配置入口
|
||||||
|
|
||||||
|
## 2. 当前项目的部署特征
|
||||||
|
|
||||||
|
当前仓库里,WebUI 的前后端是同源部署思路:
|
||||||
|
|
||||||
|
1. 后端是独立的 FastAPI WebUI 服务,默认监听 127.0.0.1:8001
|
||||||
|
2. 前端构建产物由这个 FastAPI 服务直接托管
|
||||||
|
3. 浏览器生产模式下默认按同源访问 API
|
||||||
|
4. 页面如果通过 HTTPS 打开,前端会自动把 WebSocket 协议切到 WSS
|
||||||
|
|
||||||
|
这意味着最稳妥的方式是:
|
||||||
|
|
||||||
|
1. MaiBot WebUI 继续在本机或容器内网跑 HTTP
|
||||||
|
2. 让 Caddy、宝塔 Nginx 或 1Panel OpenResty 对外暴露 443
|
||||||
|
3. 由代理把所有请求和 WebSocket 都转发到 WebUI
|
||||||
|
|
||||||
|
## 3. 配置前的准备工作
|
||||||
|
|
||||||
|
正式启用 HTTPS 之前,先确认下面几项:
|
||||||
|
|
||||||
|
1. 已准备一个已经解析到服务器公网 IP 的域名,例如 maibot.example.com
|
||||||
|
2. 80 和 443 端口可以从公网访问
|
||||||
|
3. 服务器没有其他程序占用 80 和 443
|
||||||
|
4. WebUI 可以在本机正常打开,例如 http://127.0.0.1:8001
|
||||||
|
|
||||||
|
如果采用 Docker Compose 部署,还要确认:
|
||||||
|
|
||||||
|
1. 容器已经能正常启动
|
||||||
|
2. 根目录的 docker-compose.yml 当前可以正常运行
|
||||||
|
3. HTTPS 入口将统一由反向代理接管
|
||||||
|
|
||||||
|
## 4. WebUI 自身配置
|
||||||
|
|
||||||
|
无论采用 Caddy、宝塔还是 1Panel,都建议先把 WebUI 配成生产模式。
|
||||||
|
|
||||||
|
修改 config/bot_config.toml 里的 webui 配置段,建议值如下:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[webui]
|
||||||
|
enabled = true
|
||||||
|
mode = "production"
|
||||||
|
anti_crawler_mode = "loose"
|
||||||
|
allowed_ips = "127.0.0.1"
|
||||||
|
trusted_proxies = "127.0.0.1"
|
||||||
|
trust_xff = true
|
||||||
|
secure_cookie = true
|
||||||
|
enable_paragraph_content = false
|
||||||
|
```
|
||||||
|
|
||||||
|
各项的意义:
|
||||||
|
|
||||||
|
1. mode = "production"
|
||||||
|
让 WebUI 按生产环境运行,并倾向启用更严格的安全行为。
|
||||||
|
2. secure_cookie = true
|
||||||
|
让登录 Cookie 仅在 HTTPS 下传输。
|
||||||
|
3. trust_xff = true
|
||||||
|
允许从反向代理传入的 X-Forwarded-For 获取真实来源 IP。
|
||||||
|
4. trusted_proxies = "127.0.0.1"
|
||||||
|
表示只有来自本机反向代理的 X-Forwarded-For 才被信任。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
1. 如果使用 Docker 内部的反向代理,trusted_proxies 不应固定写 127.0.0.1,而应填写反向代理容器到 MaiBot 的实际来源地址或所在网段。
|
||||||
|
2. 如果尚未切换到 HTTPS,不要提前开启 secure_cookie = true,否则可能出现登录 Cookie 不生效或握手异常的问题。
|
||||||
|
|
||||||
|
## 5. 直接部署方式如何配置 TLS/SSL
|
||||||
|
|
||||||
|
这里的“直接部署”指的是:
|
||||||
|
|
||||||
|
1. MaiBot 直接跑在宿主机上
|
||||||
|
2. WebUI 监听本机 127.0.0.1:8001
|
||||||
|
3. 宿主机安装 Caddy
|
||||||
|
4. 由 Caddy 负责申请证书和 HTTPS 反代
|
||||||
|
|
||||||
|
### 5.1 推荐的网络结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器
|
||||||
|
-> https://maibot.example.com
|
||||||
|
-> Caddy :443
|
||||||
|
-> 127.0.0.1:8001
|
||||||
|
-> MaiBot WebUI
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 宿主机直装 Caddy
|
||||||
|
|
||||||
|
以 Debian 或 Ubuntu 为例,参考步骤如下:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS Homebrew 参考:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Caddyfile 示例
|
||||||
|
|
||||||
|
仓库已提供两份可复制的示例文件,请按部署方式选择:
|
||||||
|
|
||||||
|
1. 非 Docker 宿主机部署:dashboard/docs/Caddyfile.host.example
|
||||||
|
2. Docker Compose 部署:dashboard/docs/Caddyfile.docker.example
|
||||||
|
|
||||||
|
宿主机直连部署可使用以下最简配置:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
maibot.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:8001
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如需显式添加安全头,可以使用增强版:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
maibot.example.com {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy 127.0.0.1:8001
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
非 Docker 直接部署建议直接从 dashboard/docs/Caddyfile.host.example 开始修改域名并投入使用。
|
||||||
|
|
||||||
|
### 5.4 HSTS 是否启用
|
||||||
|
|
||||||
|
可以启用,而且当前推荐由反向代理统一下发 HSTS 响应头,而不是让 WebUI 自己在 FastAPI 层单独处理。
|
||||||
|
|
||||||
|
当前仓库提供的两份 Caddy 示例都已经带了 HSTS:
|
||||||
|
|
||||||
|
1. dashboard/docs/Caddyfile.host.example
|
||||||
|
2. dashboard/docs/Caddyfile.docker.example
|
||||||
|
|
||||||
|
示例配置中的这一行就是 HSTS:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
```
|
||||||
|
|
||||||
|
这行配置的含义如下:
|
||||||
|
|
||||||
|
1. max-age=31536000
|
||||||
|
浏览器在 1 年内记住该站点只能使用 HTTPS。
|
||||||
|
2. includeSubDomains
|
||||||
|
所有子域名也必须强制使用 HTTPS。
|
||||||
|
3. preload
|
||||||
|
表示该域名计划提交到浏览器内置的 HSTS preload 列表。
|
||||||
|
|
||||||
|
HSTS 建议按下面的节奏启用:
|
||||||
|
|
||||||
|
1. 初次上线 HTTPS 时,可以先使用不带 preload 的版本。
|
||||||
|
2. 确认主域名和所有相关子域名都长期稳定支持 HTTPS 后,再考虑是否加入 preload。
|
||||||
|
3. 如果无法确认所有子域名都支持 HTTPS,不要轻易保留 includeSubDomains。
|
||||||
|
|
||||||
|
更稳妥的起步版本如下:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
Strict-Transport-Security "max-age=31536000"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果所有子域名都已经稳定支持 HTTPS,可以使用:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
```
|
||||||
|
|
||||||
|
只有在满足下面条件时,才建议使用 preload:
|
||||||
|
|
||||||
|
1. 主域名始终可通过 HTTPS 访问。
|
||||||
|
2. 所有子域名都始终可通过 HTTPS 访问。
|
||||||
|
3. 已明确理解 preload 是长期约束,而不是临时开关。
|
||||||
|
|
||||||
|
HSTS 的风险点主要有这些:
|
||||||
|
|
||||||
|
1. 一旦浏览器记住该域名只能用 HTTPS,后续临时切回 HTTP 会直接失败。
|
||||||
|
2. 如果开启 includeSubDomains,而某个子域名并没有部署 HTTPS,该子域名会被浏览器直接拦截。
|
||||||
|
3. 如果开启 preload 并提交到浏览器列表,撤销成本会比较高,生效和移除都不是即时的。
|
||||||
|
|
||||||
|
因此,本文档里的 Caddy 示例更适合作为“完整增强版示例”参考。首次部署时,建议先按实际域名情况,将 HSTS 调整成更合适的版本后再正式上线。
|
||||||
|
|
||||||
|
### 5.5 启动与验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||||
|
sudo systemctl restart caddy
|
||||||
|
sudo systemctl status caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
检查项:
|
||||||
|
|
||||||
|
1. 浏览器访问 https://maibot.example.com 能正常打开登录页
|
||||||
|
2. 登录后 Cookie 正常写入
|
||||||
|
3. 日志页和聊天页的 WebSocket 可以正常连接
|
||||||
|
4. 证书是 Let's Encrypt 或所选颁发机构签发的有效证书
|
||||||
|
|
||||||
|
### 5.6 直接部署方式的 Let's Encrypt 申请与续期
|
||||||
|
|
||||||
|
Caddy 默认会自动处理证书签发和续期,前提如下:
|
||||||
|
|
||||||
|
1. 域名已正确解析到服务器
|
||||||
|
2. 80 和 443 可从公网访问
|
||||||
|
3. 没有 CDN、WAF 或安全组拦截 ACME 验证请求
|
||||||
|
|
||||||
|
Caddy 的自动续期通常无需手工干预,只需确保:
|
||||||
|
|
||||||
|
1. 保持 Caddy 常驻运行
|
||||||
|
2. 不要阻断 80 和 443
|
||||||
|
3. 定期关注 Caddy 日志是否存在 ACME 失败记录
|
||||||
|
|
||||||
|
常用检查命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u caddy -n 200 --no-pager
|
||||||
|
sudo journalctl -u caddy -f
|
||||||
|
```
|
||||||
|
|
||||||
|
如果续期失败,优先检查:
|
||||||
|
|
||||||
|
1. 域名是否仍然解析到当前服务器
|
||||||
|
2. 80 和 443 是否被防火墙、面板或云安全组拦截
|
||||||
|
3. 是否存在另一个程序抢占了 80 或 443
|
||||||
|
|
||||||
|
## 6. 宝塔面板如何配置 SSL
|
||||||
|
|
||||||
|
宝塔适合已经习惯图形化管理 Nginx 站点的部署方式。思路仍然是:由宝塔的站点反向代理到 MaiBot WebUI。
|
||||||
|
|
||||||
|
### 6.1 推荐网络结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器
|
||||||
|
-> 宝塔站点 HTTPS
|
||||||
|
-> 宝塔 Nginx/OpenResty 反向代理
|
||||||
|
-> 127.0.0.1:8001
|
||||||
|
-> MaiBot WebUI
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 宝塔站点创建步骤
|
||||||
|
|
||||||
|
1. 登录宝塔面板。
|
||||||
|
2. 进入网站。
|
||||||
|
3. 添加站点。
|
||||||
|
4. 域名填写实际使用的 WebUI 域名,例如 maibot.example.com。
|
||||||
|
5. PHP 版本可以选纯静态或关闭运行环境,重点是站点存在即可。
|
||||||
|
|
||||||
|
### 6.3 反向代理配置步骤
|
||||||
|
|
||||||
|
1. 进入对应站点。
|
||||||
|
2. 打开反向代理。
|
||||||
|
3. 新增反向代理。
|
||||||
|
4. 目标 URL 填写 http://127.0.0.1:8001。
|
||||||
|
5. 发送域名通常保持目标域或原域名即可。
|
||||||
|
|
||||||
|
如果使用的是宝塔站点配置文件,也可以手动补这一段:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果宝塔环境没有现成的 connection_upgrade 变量,可以改成:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 宝塔中申请 Let's Encrypt 证书
|
||||||
|
|
||||||
|
1. 进入站点设置。
|
||||||
|
2. 打开 SSL。
|
||||||
|
3. 选择 Let's Encrypt。
|
||||||
|
4. 勾选对应域名。
|
||||||
|
5. 申请证书。
|
||||||
|
6. 开启强制 HTTPS。
|
||||||
|
|
||||||
|
### 6.5 宝塔中续期证书
|
||||||
|
|
||||||
|
宝塔一般会自动续期,但仍然需要检查:
|
||||||
|
|
||||||
|
1. 面板计划任务是否正常运行
|
||||||
|
2. 80 端口是否在验证时可达
|
||||||
|
3. 域名解析是否未被改动
|
||||||
|
|
||||||
|
建议定期查看:
|
||||||
|
|
||||||
|
1. 宝塔站点 SSL 到期时间
|
||||||
|
2. 宝塔计划任务执行日志
|
||||||
|
3. 站点错误日志和 Nginx 错误日志
|
||||||
|
|
||||||
|
### 6.6 宝塔模式下 WebUI 配置建议
|
||||||
|
|
||||||
|
建议保持:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[webui]
|
||||||
|
mode = "production"
|
||||||
|
secure_cookie = true
|
||||||
|
trust_xff = true
|
||||||
|
trusted_proxies = "127.0.0.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果宝塔和 MaiBot 不在同一台机器上,trusted_proxies 需要换成宝塔所在服务器到 MaiBot 的来源地址。
|
||||||
|
|
||||||
|
## 7. 1Panel 如何配置 SSL
|
||||||
|
|
||||||
|
1Panel 的逻辑和宝塔类似,本质上也是由面板管理的网关或站点反向代理到 MaiBot WebUI。
|
||||||
|
|
||||||
|
### 7.1 推荐网络结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器
|
||||||
|
-> 1Panel 网站/反向代理 HTTPS
|
||||||
|
-> OpenResty/Nginx 反向代理
|
||||||
|
-> 127.0.0.1:8001 或 core:8001
|
||||||
|
-> MaiBot WebUI
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 1Panel 配置步骤
|
||||||
|
|
||||||
|
1. 登录 1Panel。
|
||||||
|
2. 打开网站或反向代理管理。
|
||||||
|
3. 新建网站,域名填 maibot.example.com。
|
||||||
|
4. 添加反向代理规则,目标地址指向 http://127.0.0.1:8001。
|
||||||
|
5. 开启 WebSocket 支持。
|
||||||
|
6. 保存并重载站点配置。
|
||||||
|
|
||||||
|
如果是在 Docker 环境里通过 1Panel 管理容器,目标地址也可以填写容器服务名,例如 http://core:8001,但前提是 1Panel 管理的网关容器与 MaiBot 在同一个 Docker 网络内。
|
||||||
|
|
||||||
|
### 7.3 在 1Panel 申请 Let's Encrypt 证书
|
||||||
|
|
||||||
|
1. 打开证书管理。
|
||||||
|
2. 选择 Let's Encrypt。
|
||||||
|
3. 绑定域名。
|
||||||
|
4. 选择 HTTP-01 或面板默认验证方式。
|
||||||
|
5. 完成签发后,把证书绑定到对应网站。
|
||||||
|
6. 启用 HTTPS。
|
||||||
|
|
||||||
|
### 7.4 1Panel 中续期证书
|
||||||
|
|
||||||
|
1Panel 通常会自动续期,但需要确认:
|
||||||
|
|
||||||
|
1. 自动续期开关处于启用状态
|
||||||
|
2. 面板的任务调度正常
|
||||||
|
3. 80 和 443 端口验证时不被拦截
|
||||||
|
4. 域名始终指向正确服务器
|
||||||
|
|
||||||
|
### 7.5 1Panel 模式下的反代头
|
||||||
|
|
||||||
|
请确认面板生成的配置会向后端传递:
|
||||||
|
|
||||||
|
1. Host
|
||||||
|
2. X-Real-IP
|
||||||
|
3. X-Forwarded-For
|
||||||
|
4. X-Forwarded-Proto
|
||||||
|
5. Upgrade
|
||||||
|
6. Connection
|
||||||
|
|
||||||
|
缺少 X-Forwarded-Proto 时,WebUI 可能误判为 HTTP,进而影响 secure cookie 与登录行为。
|
||||||
|
|
||||||
|
## 8. Docker Compose 下如何配置 TLS/SSL
|
||||||
|
|
||||||
|
根目录 docker-compose.yml 已补充默认注释的 Caddy 示例块,用于容器化部署时启用 HTTPS。
|
||||||
|
|
||||||
|
Docker 模式下请使用:dashboard/docs/Caddyfile.docker.example
|
||||||
|
|
||||||
|
非 Docker 宿主机模式下请使用:dashboard/docs/Caddyfile.host.example
|
||||||
|
|
||||||
|
详细步骤请看另一份专项文档:dashboard/docs/webui-tls-ssl-compose.md
|
||||||
|
|
||||||
|
这里只先给结论:
|
||||||
|
|
||||||
|
1. 启用 Caddy 反向代理时,不应再把 core 的 8001 直接映射到公网
|
||||||
|
2. 应由 Caddy 容器暴露 80 和 443
|
||||||
|
3. Caddy 通过容器网络访问 core:8001
|
||||||
|
|
||||||
|
## 9. 常见问题
|
||||||
|
|
||||||
|
### 9.1 开了 HTTPS 后无法登录
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
1. webui.secure_cookie 是否在 HTTPS 环境下开启
|
||||||
|
2. 代理是否正确传递 X-Forwarded-Proto
|
||||||
|
3. 浏览器访问的是否确实是 https:// 域名而不是 http:// IP
|
||||||
|
4. Cookie 是否被浏览器策略、扩展或跨站配置拦截
|
||||||
|
|
||||||
|
### 9.2 页面能打开,但日志页或聊天页 WebSocket 失败
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
1. 代理是否支持 WebSocket Upgrade
|
||||||
|
2. 是否使用了 HTTPS 页面去连接 ws:// 明文地址
|
||||||
|
3. Caddy、Nginx、宝塔、1Panel 是否有单独的 WebSocket 开关或升级头配置
|
||||||
|
|
||||||
|
### 9.3 Let's Encrypt 申请失败
|
||||||
|
|
||||||
|
优先检查:
|
||||||
|
|
||||||
|
1. 域名解析是否正确
|
||||||
|
2. 80 端口是否可访问
|
||||||
|
3. 是否开启了 CDN 代理但没有正确放通验证流量
|
||||||
|
4. 面板或防火墙是否拦截 ACME 请求
|
||||||
|
|
||||||
|
### 9.4 是否能直接用 IP 申请 Let's Encrypt
|
||||||
|
|
||||||
|
不能。Let's Encrypt 只为域名签发公开可信证书,不为裸 IP 签发。
|
||||||
|
|
||||||
|
### 9.5 内网环境如何测试 HTTPS
|
||||||
|
|
||||||
|
可以使用 Caddy 的 tls internal 进行测试,但客户端必须手工信任内部 CA 根证书。正式对外服务仍建议使用有效公网域名和 Let's Encrypt。
|
||||||
|
|
||||||
|
## 10. 推荐实践
|
||||||
|
|
||||||
|
普通 Linux 服务器部署的推荐顺序如下:
|
||||||
|
|
||||||
|
1. 宿主机直装 Caddy
|
||||||
|
2. WebUI 绑定 127.0.0.1:8001
|
||||||
|
3. 域名指向服务器
|
||||||
|
4. 用 Caddy 反代并自动管理 Let's Encrypt
|
||||||
|
|
||||||
|
如果已经使用面板管理服务器,则:
|
||||||
|
|
||||||
|
1. 宝塔用户直接用宝塔反向代理和 Let's Encrypt
|
||||||
|
2. 1Panel 用户直接用 1Panel 网站或网关反代和证书管理
|
||||||
|
|
||||||
|
如果采用 Docker Compose 部署,则:
|
||||||
|
|
||||||
|
1. 使用根目录 compose 中提供的默认注释 Caddy 示例块
|
||||||
|
2. 注释掉 core 服务里直接暴露 WebUI 的端口映射
|
||||||
|
3. 由 Caddy 统一对外暴露 80 和 443
|
||||||
@@ -18,18 +18,9 @@ import {
|
|||||||
defaultBackgroundEffects,
|
defaultBackgroundEffects,
|
||||||
} from '@/lib/theme/tokens'
|
} from '@/lib/theme/tokens'
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Helper Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 HSL 字符串转换为 HEX 格式
|
|
||||||
* (从 settings.tsx 移植)
|
|
||||||
*/
|
|
||||||
function hslToHex(hsl: string): string {
|
function hslToHex(hsl: string): string {
|
||||||
if (!hsl) return '#000000'
|
if (!hsl) return '#000000'
|
||||||
|
|
||||||
// 解析 "221.2 83.2% 53.3%" 格式
|
|
||||||
const parts = hsl.split(' ').filter(Boolean)
|
const parts = hsl.split(' ').filter(Boolean)
|
||||||
if (parts.length < 3) return '#000000'
|
if (parts.length < 3) return '#000000'
|
||||||
|
|
||||||
@@ -39,72 +30,65 @@ function hslToHex(hsl: string): string {
|
|||||||
|
|
||||||
const sDecimal = s / 100
|
const sDecimal = s / 100
|
||||||
const lDecimal = l / 100
|
const lDecimal = l / 100
|
||||||
|
|
||||||
const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal
|
const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal
|
||||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
||||||
const m = lDecimal - c / 2
|
const m = lDecimal - c / 2
|
||||||
|
|
||||||
let r = 0,
|
let r = 0
|
||||||
g = 0,
|
let g = 0
|
||||||
b = 0
|
let b = 0
|
||||||
|
|
||||||
if (h >= 0 && h < 60) {
|
if (h >= 0 && h < 60) {
|
||||||
r = c
|
r = c
|
||||||
g = x
|
g = x
|
||||||
b = 0
|
|
||||||
} else if (h >= 60 && h < 120) {
|
} else if (h >= 60 && h < 120) {
|
||||||
r = x
|
r = x
|
||||||
g = c
|
g = c
|
||||||
b = 0
|
|
||||||
} else if (h >= 120 && h < 180) {
|
} else if (h >= 120 && h < 180) {
|
||||||
r = 0
|
|
||||||
g = c
|
g = c
|
||||||
b = x
|
b = x
|
||||||
} else if (h >= 180 && h < 240) {
|
} else if (h >= 180 && h < 240) {
|
||||||
r = 0
|
|
||||||
g = x
|
g = x
|
||||||
b = c
|
b = c
|
||||||
} else if (h >= 240 && h < 300) {
|
} else if (h >= 240 && h < 300) {
|
||||||
r = x
|
r = x
|
||||||
g = 0
|
|
||||||
b = c
|
b = c
|
||||||
} else if (h >= 300 && h < 360) {
|
} else if (h >= 300 && h < 360) {
|
||||||
r = c
|
r = c
|
||||||
g = 0
|
|
||||||
b = x
|
b = x
|
||||||
}
|
}
|
||||||
|
|
||||||
const toHex = (n: number) => {
|
const toHex = (value: number) => {
|
||||||
const hex = Math.round((n + m) * 255).toString(16)
|
const hex = Math.round((value + m) * 255).toString(16)
|
||||||
return hex.length === 1 ? '0' + hex : hex
|
return hex.length === 1 ? `0${hex}` : hex
|
||||||
}
|
}
|
||||||
|
|
||||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Component
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
type BackgroundEffectsControlsProps = {
|
type BackgroundEffectsControlsProps = {
|
||||||
effects: BackgroundEffects
|
effects: BackgroundEffects
|
||||||
onChange: (effects: BackgroundEffects) => void
|
onChange: (effects: BackgroundEffects) => void
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackgroundEffectsControls({
|
export function BackgroundEffectsControls({
|
||||||
effects,
|
effects,
|
||||||
onChange,
|
onChange,
|
||||||
|
disabled = false,
|
||||||
}: BackgroundEffectsControlsProps) {
|
}: BackgroundEffectsControlsProps) {
|
||||||
// 处理数值变更
|
|
||||||
const handleValueChange = (key: keyof BackgroundEffects, value: number) => {
|
const handleValueChange = (key: keyof BackgroundEffects, value: number) => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
onChange({
|
onChange({
|
||||||
...effects,
|
...effects,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理颜色变更
|
|
||||||
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
const hex = e.target.value
|
const hex = e.target.value
|
||||||
const hsl = hexToHSL(hex)
|
const hsl = hexToHSL(hex)
|
||||||
onChange({
|
onChange({
|
||||||
@@ -113,35 +97,38 @@ export function BackgroundEffectsControls({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理位置变更
|
|
||||||
const handlePositionChange = (value: string) => {
|
const handlePositionChange = (value: string) => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
onChange({
|
onChange({
|
||||||
...effects,
|
...effects,
|
||||||
position: value as BackgroundEffects['position'],
|
position: value as BackgroundEffects['position'],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理渐变变更
|
|
||||||
const handleGradientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleGradientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
onChange({
|
onChange({
|
||||||
...effects,
|
...effects,
|
||||||
gradientOverlay: e.target.value,
|
gradientOverlay: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置为默认值
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
if (disabled) return
|
||||||
onChange(defaultBackgroundEffects)
|
onChange(defaultBackgroundEffects)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className={disabled ? 'space-y-6 opacity-50' : 'space-y-6'}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium">背景效果调节</h3>
|
<h3 className="text-sm font-medium">背景效果调节</h3>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
|
disabled={disabled}
|
||||||
className="h-8 px-2 text-xs"
|
className="h-8 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
@@ -150,24 +137,21 @@ export function BackgroundEffectsControls({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
{/* 1. Blur (模糊) */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>模糊程度 (Blur)</Label>
|
<Label>模糊程度 (Blur)</Label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{effects.blur}px</span>
|
||||||
{effects.blur}px
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[effects.blur]}
|
value={[effects.blur]}
|
||||||
min={0}
|
min={0}
|
||||||
max={50}
|
max={50}
|
||||||
step={1}
|
step={1}
|
||||||
|
disabled={disabled}
|
||||||
onValueChange={(vals) => handleValueChange('blur', vals[0])}
|
onValueChange={(vals) => handleValueChange('blur', vals[0])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Overlay Color (遮罩颜色) */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>遮罩颜色 (Overlay Color)</Label>
|
<Label>遮罩颜色 (Overlay Color)</Label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -176,18 +160,19 @@ export function BackgroundEffectsControls({
|
|||||||
type="color"
|
type="color"
|
||||||
value={hslToHex(effects.overlayColor)}
|
value={hslToHex(effects.overlayColor)}
|
||||||
onChange={handleColorChange}
|
onChange={handleColorChange}
|
||||||
|
disabled={disabled}
|
||||||
className="h-[150%] w-[150%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0"
|
className="h-[150%] w-[150%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
value={hslToHex(effects.overlayColor)}
|
value={hslToHex(effects.overlayColor)}
|
||||||
readOnly
|
readOnly
|
||||||
|
disabled={disabled}
|
||||||
className="flex-1 font-mono uppercase"
|
className="flex-1 font-mono uppercase"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3. Overlay Opacity (遮罩不透明度) */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>遮罩不透明度 (Opacity)</Label>
|
<Label>遮罩不透明度 (Opacity)</Label>
|
||||||
@@ -200,17 +185,15 @@ export function BackgroundEffectsControls({
|
|||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
onValueChange={(vals) =>
|
disabled={disabled}
|
||||||
handleValueChange('overlayOpacity', vals[0] / 100)
|
onValueChange={(vals) => handleValueChange('overlayOpacity', vals[0] / 100)}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4. Position (位置) */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>背景位置 (Position)</Label>
|
<Label>背景位置 (Position)</Label>
|
||||||
<Select value={effects.position} onValueChange={handlePositionChange}>
|
<Select value={effects.position} onValueChange={handlePositionChange} disabled={disabled}>
|
||||||
<SelectTrigger>
|
<SelectTrigger disabled={disabled}>
|
||||||
<SelectValue placeholder="选择位置" />
|
<SelectValue placeholder="选择位置" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -222,69 +205,61 @@ export function BackgroundEffectsControls({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 5. Brightness (亮度) */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>亮度 (Brightness)</Label>
|
<Label>亮度 (Brightness)</Label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{effects.brightness}%</span>
|
||||||
{effects.brightness}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[effects.brightness]}
|
value={[effects.brightness]}
|
||||||
min={0}
|
min={0}
|
||||||
max={200}
|
max={200}
|
||||||
step={1}
|
step={1}
|
||||||
|
disabled={disabled}
|
||||||
onValueChange={(vals) => handleValueChange('brightness', vals[0])}
|
onValueChange={(vals) => handleValueChange('brightness', vals[0])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 6. Contrast (对比度) */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>对比度 (Contrast)</Label>
|
<Label>对比度 (Contrast)</Label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{effects.contrast}%</span>
|
||||||
{effects.contrast}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[effects.contrast]}
|
value={[effects.contrast]}
|
||||||
min={0}
|
min={0}
|
||||||
max={200}
|
max={200}
|
||||||
step={1}
|
step={1}
|
||||||
|
disabled={disabled}
|
||||||
onValueChange={(vals) => handleValueChange('contrast', vals[0])}
|
onValueChange={(vals) => handleValueChange('contrast', vals[0])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 7. Saturate (饱和度) */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>饱和度 (Saturate)</Label>
|
<Label>饱和度 (Saturate)</Label>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{effects.saturate}%</span>
|
||||||
{effects.saturate}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[effects.saturate]}
|
value={[effects.saturate]}
|
||||||
min={0}
|
min={0}
|
||||||
max={200}
|
max={200}
|
||||||
step={1}
|
step={1}
|
||||||
|
disabled={disabled}
|
||||||
onValueChange={(vals) => handleValueChange('saturate', vals[0])}
|
onValueChange={(vals) => handleValueChange('saturate', vals[0])}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 8. Gradient Overlay (渐变叠加) */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>CSS 渐变叠加 (Gradient Overlay)</Label>
|
<Label>CSS 渐变叠加 (Gradient Overlay)</Label>
|
||||||
<Input
|
<Input
|
||||||
value={effects.gradientOverlay || ''}
|
value={effects.gradientOverlay || ''}
|
||||||
onChange={handleGradientChange}
|
onChange={handleGradientChange}
|
||||||
|
disabled={disabled}
|
||||||
placeholder="e.g. linear-gradient(to bottom, transparent, black)"
|
placeholder="e.g. linear-gradient(to bottom, transparent, black)"
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">可选:输入有效的 CSS gradient 字符串</p>
|
||||||
可选:输入有效的 CSS gradient 字符串
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,31 @@ type BackgroundLayerProps = {
|
|||||||
layerId: string
|
layerId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAutoOverlayOpacity(layerId: string): number {
|
||||||
|
switch (layerId) {
|
||||||
|
case 'page':
|
||||||
|
return 0.62
|
||||||
|
case 'header':
|
||||||
|
return 0.72
|
||||||
|
case 'sidebar':
|
||||||
|
return 0.78
|
||||||
|
case 'card':
|
||||||
|
return 0.82
|
||||||
|
case 'dialog':
|
||||||
|
return 0.88
|
||||||
|
default:
|
||||||
|
return 0.68
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoGradientOverlay(layerId: string): string | undefined {
|
||||||
|
if (layerId !== 'page') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'linear-gradient(to bottom, hsl(var(--background) / 0.82), hsl(var(--background) / 0.52) 28%, hsl(var(--background) / 0.7) 100%)'
|
||||||
|
}
|
||||||
|
|
||||||
function buildFilterString(effects: BackgroundConfig['effects']): string {
|
function buildFilterString(effects: BackgroundConfig['effects']): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (effects.blur > 0) parts.push(`blur(${effects.blur}px)`)
|
if (effects.blur > 0) parts.push(`blur(${effects.blur}px)`)
|
||||||
@@ -84,10 +109,17 @@ export function BackgroundLayer({ config, layerId }: BackgroundLayerProps) {
|
|||||||
|
|
||||||
const filterString = buildFilterString(config.effects)
|
const filterString = buildFilterString(config.effects)
|
||||||
const { overlayColor, overlayOpacity, gradientOverlay } = config.effects
|
const { overlayColor, overlayOpacity, gradientOverlay } = config.effects
|
||||||
|
const hasExplicitOverlay = overlayOpacity > 0
|
||||||
|
const effectiveOverlayOpacity = hasExplicitOverlay ? overlayOpacity : getAutoOverlayOpacity(layerId)
|
||||||
|
const effectiveOverlayColor = hasExplicitOverlay
|
||||||
|
? `hsl(${overlayColor} / ${effectiveOverlayOpacity})`
|
||||||
|
: `hsl(var(--background) / ${effectiveOverlayOpacity})`
|
||||||
|
const effectiveGradientOverlay = gradientOverlay || getAutoGradientOverlay(layerId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={layerId}
|
key={layerId}
|
||||||
|
data-background-layer={layerId}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -136,25 +168,25 @@ export function BackgroundLayer({ config, layerId }: BackgroundLayerProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{overlayOpacity > 0 && (
|
{effectiveOverlayOpacity > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
backgroundColor: `hsl(${overlayColor} / ${overlayOpacity})`,
|
backgroundColor: effectiveOverlayColor,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gradientOverlay && (
|
{effectiveGradientOverlay && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
background: gradientOverlay,
|
background: effectiveGradientOverlay,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ type BackgroundUploaderProps = {
|
|||||||
assetId?: string
|
assetId?: string
|
||||||
onAssetSelect: (id: string | undefined) => void
|
onAssetSelect: (id: string | undefined) => void
|
||||||
className?: string
|
className?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackgroundUploader({ assetId, onAssetSelect, className }: BackgroundUploaderProps) {
|
export function BackgroundUploader({ assetId, onAssetSelect, className, disabled = false }: BackgroundUploaderProps) {
|
||||||
const { getAssetUrl } = useAssetStore()
|
const { getAssetUrl } = useAssetStore()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -62,6 +63,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
|||||||
}, [assetId, getAssetUrl, onAssetSelect])
|
}, [assetId, getAssetUrl, onAssetSelect])
|
||||||
|
|
||||||
const handleFile = async (file: File) => {
|
const handleFile = async (file: File) => {
|
||||||
|
if (disabled) return
|
||||||
setError(null)
|
setError(null)
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleUrlUpload = async () => {
|
const handleUrlUpload = async () => {
|
||||||
if (!urlInput) return
|
if (disabled || !urlInput) return
|
||||||
|
|
||||||
setError(null)
|
setError(null)
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -118,6 +120,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
|||||||
const handleDrag = (e: React.DragEvent) => {
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
if (disabled) return
|
||||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||||
setDragActive(true)
|
setDragActive(true)
|
||||||
} else if (e.type === 'dragleave') {
|
} else if (e.type === 'dragleave') {
|
||||||
@@ -130,12 +133,15 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setDragActive(false)
|
setDragActive(false)
|
||||||
|
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
handleFile(e.dataTransfer.files[0])
|
handleFile(e.dataTransfer.files[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
|
if (disabled) return
|
||||||
onAssetSelect(undefined)
|
onAssetSelect(undefined)
|
||||||
setPreviewUrl(undefined)
|
setPreviewUrl(undefined)
|
||||||
setAssetType(undefined)
|
setAssetType(undefined)
|
||||||
@@ -143,7 +149,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", disabled && 'opacity-50', className)}>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>背景资源</Label>
|
<Label>背景资源</Label>
|
||||||
|
|
||||||
@@ -151,6 +157,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex min-h-[200px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
|
"relative flex min-h-[200px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
|
||||||
|
disabled && 'pointer-events-none',
|
||||||
dragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25",
|
dragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25",
|
||||||
error ? "border-destructive/50 bg-destructive/5" : "",
|
error ? "border-destructive/50 bg-destructive/5" : "",
|
||||||
assetId ? "border-solid" : ""
|
assetId ? "border-solid" : ""
|
||||||
@@ -188,6 +195,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shadow-sm"
|
className="h-8 w-8 shadow-sm"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -212,6 +220,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
选择文件
|
选择文件
|
||||||
</Button>
|
</Button>
|
||||||
@@ -224,6 +233,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
accept="image/*,video/mp4,video/webm"
|
accept="image/*,video/mp4,video/webm"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
if (disabled) return
|
||||||
if (e.target.files?.[0]) {
|
if (e.target.files?.[0]) {
|
||||||
handleFile(e.target.files[0])
|
handleFile(e.target.files[0])
|
||||||
}
|
}
|
||||||
@@ -250,6 +260,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
|||||||
placeholder="https://example.com/image.jpg"
|
placeholder="https://example.com/image.jpg"
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
value={urlInput}
|
value={urlInput}
|
||||||
|
disabled={disabled}
|
||||||
onChange={(e) => setUrlInput(e.target.value)}
|
onChange={(e) => setUrlInput(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -262,7 +273,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={handleUrlUpload}
|
onClick={handleUrlUpload}
|
||||||
disabled={!urlInput || isLoading}
|
disabled={disabled || !urlInput || isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : '获取'}
|
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : '获取'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type ComponentCSSEditorProps = {
|
|||||||
label?: string
|
label?: string
|
||||||
/** 编辑器高度,默认 200px */
|
/** 编辑器高度,默认 200px */
|
||||||
height?: string
|
height?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,12 +29,13 @@ export function ComponentCSSEditor({
|
|||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
height = '200px',
|
height = '200px',
|
||||||
|
disabled = false,
|
||||||
}: ComponentCSSEditorProps) {
|
}: ComponentCSSEditorProps) {
|
||||||
// 实时计算 CSS 警告
|
// 实时计算 CSS 警告
|
||||||
const { warnings } = sanitizeCSS(value)
|
const { warnings } = sanitizeCSS(value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className={disabled ? 'space-y-2 opacity-50' : 'space-y-2'}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-sm font-medium">
|
<Label className="text-sm font-medium">
|
||||||
{label || '自定义 CSS'}
|
{label || '自定义 CSS'}
|
||||||
@@ -43,7 +45,7 @@ export function ComponentCSSEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onChange('')}
|
onClick={() => onChange('')}
|
||||||
disabled={!value}
|
disabled={disabled || !value}
|
||||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
|
||||||
title="清除所有 CSS"
|
title="清除所有 CSS"
|
||||||
>
|
>
|
||||||
@@ -55,8 +57,9 @@ export function ComponentCSSEditor({
|
|||||||
<div className="rounded-md border bg-card overflow-hidden">
|
<div className="rounded-md border bg-card overflow-hidden">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={disabled ? undefined : onChange}
|
||||||
language="css"
|
language="css"
|
||||||
|
readOnly={disabled}
|
||||||
height={height}
|
height={height}
|
||||||
placeholder={`/* 为 ${componentId} 组件编写自定义 CSS */\n\n/* 示例: */\n/* .custom-class { background: red; } */`}
|
placeholder={`/* 为 ${componentId} 组件编写自定义 CSS */\n\n/* 示例: */\n/* .custom-class { background: red; } */`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export function Header({
|
|||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const { t, i18n: i18nInstance } = useTranslation()
|
const { t, i18n: i18nInstance } = useTranslation()
|
||||||
const currentLang = i18nInstance.language || 'zh'
|
const currentLang = i18nInstance.language || 'zh'
|
||||||
const headerBg = useBackground('header')
|
const { config: headerBg, inheritedFrom } = useBackground('header')
|
||||||
|
const inheritsPageBackground = inheritedFrom === 'page'
|
||||||
const [backendManagerOpen, setBackendManagerOpen] = useState(false)
|
const [backendManagerOpen, setBackendManagerOpen] = useState(false)
|
||||||
const [activeBackendName, setActiveBackendName] = useState<string>('')
|
const [activeBackendName, setActiveBackendName] = useState<string>('')
|
||||||
|
|
||||||
@@ -61,9 +62,12 @@ export function Header({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 items-center justify-between border-b bg-card/80 backdrop-blur-md px-4 sticky top-0 z-10">
|
<header className={cn(
|
||||||
<BackgroundLayer config={headerBg} layerId="header" />
|
'sticky top-0 z-10 flex h-16 items-center justify-between border-b px-4 backdrop-blur-md isolate',
|
||||||
<div className="flex items-center gap-4">
|
inheritsPageBackground ? 'bg-transparent' : 'bg-card/80',
|
||||||
|
)}>
|
||||||
|
{!inheritsPageBackground && <BackgroundLayer config={headerBg} layerId="header" />}
|
||||||
|
<div className="relative z-10 flex items-center gap-4">
|
||||||
{/* 移动端菜单按钮 */}
|
{/* 移动端菜单按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={onMobileMenuToggle}
|
onClick={onMobileMenuToggle}
|
||||||
@@ -87,7 +91,7 @@ export function Header({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative z-10 flex items-center gap-2">
|
||||||
{/* 后端切换按钮(仅 Electron) */}
|
{/* 后端切换按钮(仅 Electron) */}
|
||||||
{isElectron() && (
|
{isElectron() && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actualTheme = getActualTheme()
|
const actualTheme = getActualTheme()
|
||||||
const pageBg = useBackground('page')
|
const { config: pageBg } = useBackground('page')
|
||||||
|
|
||||||
// 认证检查中,显示加载状态
|
// 认证检查中,显示加载状态
|
||||||
if (checking) {
|
if (checking) {
|
||||||
@@ -116,7 +116,9 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<SkipNav />
|
<SkipNav />
|
||||||
{isElectron() && <TitleBar />}
|
{isElectron() && <TitleBar />}
|
||||||
<div className={cn('flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
|
<div className={cn('relative isolate flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
|
||||||
|
<BackgroundLayer config={pageBg} layerId="page" />
|
||||||
|
<div className="relative z-10 flex h-full w-full overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<Sidebar
|
<Sidebar
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
@@ -155,16 +157,21 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="relative flex-1 overflow-hidden bg-background outline-none"
|
className={cn(
|
||||||
|
'relative isolate flex-1 overflow-hidden outline-none',
|
||||||
|
pageBg.type === 'none' ? 'bg-background' : 'bg-transparent',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<BackgroundLayer config={pageBg} layerId="page" />
|
<div className="relative z-10 h-full">
|
||||||
{children}
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Back to Top Button */}
|
{/* Back to Top Button */}
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,24 +23,29 @@ export function Sidebar({
|
|||||||
onMobileMenuClose
|
onMobileMenuClose
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const sidebarBg = useBackground('sidebar')
|
const { config: sidebarBg, inheritedFrom } = useBackground('sidebar')
|
||||||
|
const inheritsPageBackground = inheritedFrom === 'page'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-y-0 left-0 z-50 flex flex-col border-r bg-card transition-all duration-300 lg:relative lg:z-0',
|
'fixed inset-y-0 left-0 z-50 isolate flex flex-col border-r transition-all duration-300 lg:relative lg:z-0',
|
||||||
|
inheritsPageBackground ? 'bg-transparent' : 'bg-card',
|
||||||
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
|
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
|
||||||
'w-64 lg:w-auto',
|
'w-64 lg:w-auto',
|
||||||
sidebarOpen ? 'lg:w-64' : 'lg:w-16',
|
sidebarOpen ? 'lg:w-64' : 'lg:w-16',
|
||||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<BackgroundLayer config={sidebarBg} layerId="sidebar" />
|
{!inheritsPageBackground && <BackgroundLayer config={sidebarBg} layerId="sidebar" />}
|
||||||
|
|
||||||
{/* Logo 区域 */}
|
{/* Logo 区域 */}
|
||||||
<LogoArea sidebarOpen={sidebarOpen} />
|
<div className="relative z-10">
|
||||||
|
<LogoArea sidebarOpen={sidebarOpen} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<ScrollArea className={cn(
|
<ScrollArea className={cn(
|
||||||
|
'relative z-10',
|
||||||
"flex-1 overflow-x-hidden",
|
"flex-1 overflow-x-hidden",
|
||||||
!sidebarOpen && "lg:w-16"
|
!sidebarOpen && "lg:w-16"
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ export const CardWithBackground = forwardRef<
|
|||||||
ElementRef<typeof Card>,
|
ElementRef<typeof Card>,
|
||||||
CardWithBackgroundProps
|
CardWithBackgroundProps
|
||||||
>(({ className, children, ...props }, ref) => {
|
>(({ className, children, ...props }, ref) => {
|
||||||
const bg = useBackground('card')
|
const { config: bg } = useBackground('card')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card ref={ref} className={cn('relative', className)} {...props}>
|
<Card ref={ref} className={cn('relative isolate', className)} {...props}>
|
||||||
<BackgroundLayer config={bg} layerId="card" />
|
<BackgroundLayer config={bg} layerId="card" />
|
||||||
{children}
|
<div className="relative z-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ export const DialogContentWithBackground = forwardRef<
|
|||||||
ElementRef<typeof DialogContent>,
|
ElementRef<typeof DialogContent>,
|
||||||
DialogContentWithBackgroundProps
|
DialogContentWithBackgroundProps
|
||||||
>(({ className, children, ...props }, ref) => {
|
>(({ className, children, ...props }, ref) => {
|
||||||
const bg = useBackground('dialog')
|
const { config: bg } = useBackground('dialog')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent ref={ref} className={cn('relative', className)} {...props}>
|
<DialogContent ref={ref} className={cn('relative isolate', className)} {...props}>
|
||||||
<BackgroundLayer config={bg} layerId="dialog" />
|
<BackgroundLayer config={bg} layerId="dialog" />
|
||||||
{children}
|
<div className="relative z-10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ import { defaultBackgroundConfig } from '@/lib/theme/tokens'
|
|||||||
|
|
||||||
type BackgroundLayerId = 'page' | 'sidebar' | 'header' | 'card' | 'dialog'
|
type BackgroundLayerId = 'page' | 'sidebar' | 'header' | 'card' | 'dialog'
|
||||||
|
|
||||||
|
type ResolvedBackgroundState = {
|
||||||
|
config: BackgroundConfig
|
||||||
|
inheritEnabled: boolean
|
||||||
|
inheritedFrom: BackgroundLayerId | null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定层级的背景配置
|
* 获取指定层级的背景配置
|
||||||
* 处理继承逻辑:如果 inherit 为 true,返回页面级别配置
|
* 处理继承逻辑:如果 inherit 为 true,返回页面级别配置
|
||||||
* @param layerId - 背景层级标识
|
* @param layerId - 背景层级标识
|
||||||
* @returns 对应层级的背景配置
|
* @returns 对应层级的背景配置
|
||||||
*/
|
*/
|
||||||
export function useBackground(layerId: BackgroundLayerId): BackgroundConfig {
|
export function useBackground(layerId: BackgroundLayerId): ResolvedBackgroundState {
|
||||||
const { themeConfig } = useTheme()
|
const { themeConfig } = useTheme()
|
||||||
const bgMap = themeConfig.backgroundConfig ?? {}
|
const bgMap = themeConfig.backgroundConfig ?? {}
|
||||||
|
|
||||||
@@ -19,8 +25,16 @@ export function useBackground(layerId: BackgroundLayerId): BackgroundConfig {
|
|||||||
|
|
||||||
// 处理继承逻辑:非 page 层级且 inherit 为 true,返回 page 配置
|
// 处理继承逻辑:非 page 层级且 inherit 为 true,返回 page 配置
|
||||||
if (layerId !== 'page' && config.inherit) {
|
if (layerId !== 'page' && config.inherit) {
|
||||||
return bgMap.page ?? defaultBackgroundConfig
|
return {
|
||||||
|
config: bgMap.page ?? defaultBackgroundConfig,
|
||||||
|
inheritEnabled: true,
|
||||||
|
inheritedFrom: 'page',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return {
|
||||||
|
config,
|
||||||
|
inheritEnabled: !!config.inherit,
|
||||||
|
inheritedFrom: null,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,27 +112,27 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* Color Tokens */
|
/* Color Tokens */
|
||||||
--color-primary: 221.2 83.2% 53.3%;
|
--color-primary: 188.5 100% 45.5%;
|
||||||
--color-primary-foreground: 210 40% 98%;
|
--color-primary-foreground: 210 40% 98%;
|
||||||
--color-primary-gradient: none;
|
--color-primary-gradient: none;
|
||||||
--color-secondary: 210 40% 96.1%;
|
--color-secondary: 188.5 35% 96%;
|
||||||
--color-secondary-foreground: 222.2 47.4% 11.2%;
|
--color-secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
--color-muted: 210 40% 96.1%;
|
--color-muted: 188.5 12% 96%;
|
||||||
--color-muted-foreground: 215.4 16.3% 40%;
|
--color-muted-foreground: 188.5 20% 46.9%;
|
||||||
--color-accent: 210 40% 96.1%;
|
--color-accent: 223.5 60% 50.4%;
|
||||||
--color-accent-foreground: 222.2 47.4% 11.2%;
|
--color-accent-foreground: 222.2 47.4% 11.2%;
|
||||||
--color-destructive: 0 84.2% 45%;
|
--color-destructive: 0 84.2% 45%;
|
||||||
--color-destructive-foreground: 210 40% 98%;
|
--color-destructive-foreground: 210 40% 98%;
|
||||||
--color-background: 0 0% 100%;
|
--color-background: 0 0% 100%;
|
||||||
--color-foreground: 222.2 84% 4.9%;
|
--color-foreground: 222.2 84% 4.9%;
|
||||||
--color-card: 0 0% 100%;
|
--color-card: 188.5 14% 98.6%;
|
||||||
--color-card-foreground: 222.2 84% 4.9%;
|
--color-card-foreground: 222.2 84% 4.9%;
|
||||||
--color-popover: 0 0% 100%;
|
--color-popover: 188.5 16% 99.3%;
|
||||||
--color-popover-foreground: 222.2 84% 4.9%;
|
--color-popover-foreground: 222.2 84% 4.9%;
|
||||||
--color-border: 214.3 31.8% 91.4%;
|
--color-border: 188.5 20% 91.4%;
|
||||||
--color-input: 214.3 31.8% 91.4%;
|
--color-input: 188.5 20% 91.4%;
|
||||||
--color-ring: 221.2 83.2% 53.3%;
|
--color-ring: 188.5 100% 45.5%;
|
||||||
--color-chart-1: 221.2 83.2% 53.3%;
|
--color-chart-1: 188.5 100% 45.5%;
|
||||||
--color-chart-2: 160 60% 45%;
|
--color-chart-2: 160 60% 45%;
|
||||||
--color-chart-3: 30 80% 55%;
|
--color-chart-3: 30 80% 55%;
|
||||||
--color-chart-4: 280 65% 60%;
|
--color-chart-4: 280 65% 60%;
|
||||||
@@ -230,27 +230,27 @@
|
|||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
/* Color Tokens */
|
/* Color Tokens */
|
||||||
--color-primary: 217.2 91.2% 59.8%;
|
--color-primary: 188.5 100% 45.5%;
|
||||||
--color-primary-foreground: 210 40% 98%;
|
--color-primary-foreground: 210 40% 98%;
|
||||||
--color-primary-gradient: none;
|
--color-primary-gradient: none;
|
||||||
--color-secondary: 217.2 32.6% 17.5%;
|
--color-secondary: 188.5 35% 17.5%;
|
||||||
--color-secondary-foreground: 210 40% 98%;
|
--color-secondary-foreground: 210 40% 98%;
|
||||||
--color-muted: 217.2 32.6% 17.5%;
|
--color-muted: 188.5 12% 17.5%;
|
||||||
--color-muted-foreground: 215 20.2% 65.1%;
|
--color-muted-foreground: 188.5 20% 65.1%;
|
||||||
--color-accent: 217.2 32.6% 17.5%;
|
--color-accent: 223.5 60% 35.3%;
|
||||||
--color-accent-foreground: 210 40% 98%;
|
--color-accent-foreground: 210 40% 98%;
|
||||||
--color-destructive: 0 62.8% 30.6%;
|
--color-destructive: 0 62.8% 30.6%;
|
||||||
--color-destructive-foreground: 210 40% 98%;
|
--color-destructive-foreground: 210 40% 98%;
|
||||||
--color-background: 222.2 84% 4.9%;
|
--color-background: 222.2 84% 4.9%;
|
||||||
--color-foreground: 210 40% 98%;
|
--color-foreground: 210 40% 98%;
|
||||||
--color-card: 222.2 84% 4.9%;
|
--color-card: 188.5 18% 8.8%;
|
||||||
--color-card-foreground: 210 40% 98%;
|
--color-card-foreground: 210 40% 98%;
|
||||||
--color-popover: 222.2 84% 4.9%;
|
--color-popover: 188.5 21% 10.5%;
|
||||||
--color-popover-foreground: 210 40% 98%;
|
--color-popover-foreground: 210 40% 98%;
|
||||||
--color-border: 217.2 32.6% 17.5%;
|
--color-border: 188.5 20% 17.5%;
|
||||||
--color-input: 217.2 32.6% 17.5%;
|
--color-input: 188.5 20% 17.5%;
|
||||||
--color-ring: 224.3 76.3% 48%;
|
--color-ring: 188.5 100% 45.5%;
|
||||||
--color-chart-1: 217.2 91.2% 59.8%;
|
--color-chart-1: 188.5 100% 45.5%;
|
||||||
--color-chart-2: 160 60% 50%;
|
--color-chart-2: 160 60% 50%;
|
||||||
--color-chart-3: 30 80% 60%;
|
--color-chart-3: 30 80% 60%;
|
||||||
--color-chart-4: 280 65% 65%;
|
--color-chart-4: 280 65% 65%;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
* 统一管理所有前端 localStorage 设置
|
* 统一管理所有前端 localStorage 设置
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_ACCENT_COLOR_HSL } from './theme/palette'
|
||||||
|
|
||||||
// 所有设置的 key 定义
|
// 所有设置的 key 定义
|
||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
// 外观设置
|
// 外观设置
|
||||||
@@ -32,7 +34,7 @@ export const STORAGE_KEYS = {
|
|||||||
export const DEFAULT_SETTINGS = {
|
export const DEFAULT_SETTINGS = {
|
||||||
// 外观
|
// 外观
|
||||||
theme: 'system' as 'light' | 'dark' | 'system',
|
theme: 'system' as 'light' | 'dark' | 'system',
|
||||||
accentColor: 'blue',
|
accentColor: DEFAULT_ACCENT_COLOR_HSL,
|
||||||
enableAnimations: true,
|
enableAnimations: true,
|
||||||
enableWavesBackground: true,
|
enableWavesBackground: true,
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ type HSL = {
|
|||||||
l: number
|
l: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ACCENT_COLOR_HSL = '188.5 100% 45.5%'
|
||||||
|
export const DEFAULT_ACCENT_COLOR_HEX = '#00c7e8'
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number): number => {
|
const clamp = (value: number, min: number, max: number): number => {
|
||||||
if (value < min) return min
|
if (value < min) return min
|
||||||
if (value > max) return max
|
if (value > max) return max
|
||||||
@@ -45,6 +48,11 @@ export const formatHSL = (h: number, s: number, l: number): string => {
|
|||||||
return `${safeH} ${safeS}% ${safeL}%`
|
return `${safeH} ${safeS}% ${safeL}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isValidHSLString = (value: string): boolean => {
|
||||||
|
const cleaned = value.trim()
|
||||||
|
return /^-?\d+(?:\.\d+)?\s+-?\d+(?:\.\d+)?%\s+-?\d+(?:\.\d+)?%$/i.test(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
export const hexToHSL = (hex: string): string => {
|
export const hexToHSL = (hex: string): string => {
|
||||||
let cleaned = hex.trim().replace('#', '')
|
let cleaned = hex.trim().replace('#', '')
|
||||||
if (cleaned.length === 3) {
|
if (cleaned.length === 3) {
|
||||||
@@ -91,6 +99,25 @@ export const hexToHSL = (hex: string): string => {
|
|||||||
return formatHSL(h, s * 100, l * 100)
|
return formatHSL(h, s * 100, l * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const normalizeAccentColor = (accentColor?: string | null): string => {
|
||||||
|
const trimmed = accentColor?.trim()
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return DEFAULT_ACCENT_COLOR_HSL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('#')) {
|
||||||
|
return hexToHSL(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidHSLString(trimmed)) {
|
||||||
|
const { h, s, l } = parseHSL(trimmed)
|
||||||
|
return formatHSL(h, s, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_ACCENT_COLOR_HSL
|
||||||
|
}
|
||||||
|
|
||||||
export const adjustLightness = (hsl: string, amount: number): string => {
|
export const adjustLightness = (hsl: string, amount: number): string => {
|
||||||
const { h, s, l } = parseHSL(hsl)
|
const { h, s, l } = parseHSL(hsl)
|
||||||
return formatHSL(h, s, l + amount)
|
return formatHSL(h, s, l + amount)
|
||||||
@@ -170,8 +197,13 @@ export const generatePalette = (accentHSL: string, isDark: boolean): ColorTokens
|
|||||||
const chartSteps = [0, 72, 144, 216, 288]
|
const chartSteps = [0, 72, 144, 216, 288]
|
||||||
const charts = chartSteps.map((step) => rotateHue(chartBase, step))
|
const charts = chartSteps.map((step) => rotateHue(chartBase, step))
|
||||||
|
|
||||||
const card = adjustLightness(background, isDark ? 2 : -1)
|
const surfaceSaturation = clamp(accent.s * (isDark ? 0.18 : 0.14), isDark ? 10 : 6, isDark ? 24 : 16)
|
||||||
const popover = adjustLightness(background, isDark ? 3 : -0.5)
|
const card = formatHSL(accent.h, surfaceSaturation, isDark ? 8.8 : 98.6)
|
||||||
|
const popover = formatHSL(
|
||||||
|
accent.h,
|
||||||
|
clamp(surfaceSaturation + (isDark ? 3 : 2), 0, 100),
|
||||||
|
isDark ? 10.5 : 99.3,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
primary,
|
primary,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* 统一处理主题相关的存储操作,包括加载、保存、导出、导入和迁移旧 key
|
* 统一处理主题相关的存储操作,包括加载、保存、导出、导入和迁移旧 key
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_ACCENT_COLOR_HSL, normalizeAccentColor } from './palette'
|
||||||
import type { BackgroundConfigMap, UserThemeConfig } from './tokens'
|
import type { BackgroundConfigMap, UserThemeConfig } from './tokens'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +24,7 @@ export const THEME_STORAGE_KEYS = {
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_THEME_CONFIG: UserThemeConfig = {
|
const DEFAULT_THEME_CONFIG: UserThemeConfig = {
|
||||||
selectedPreset: 'light',
|
selectedPreset: 'light',
|
||||||
accentColor: 'blue',
|
accentColor: DEFAULT_ACCENT_COLOR_HSL,
|
||||||
tokenOverrides: {},
|
tokenOverrides: {},
|
||||||
customCSS: '',
|
customCSS: '',
|
||||||
backgroundConfig: {} as BackgroundConfigMap,
|
backgroundConfig: {} as BackgroundConfigMap,
|
||||||
@@ -65,7 +66,7 @@ export function loadThemeConfig(): UserThemeConfig {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
selectedPreset: preset || DEFAULT_THEME_CONFIG.selectedPreset,
|
selectedPreset: preset || DEFAULT_THEME_CONFIG.selectedPreset,
|
||||||
accentColor: accent || DEFAULT_THEME_CONFIG.accentColor,
|
accentColor: normalizeAccentColor(accent),
|
||||||
tokenOverrides,
|
tokenOverrides,
|
||||||
customCSS: customCSS || DEFAULT_THEME_CONFIG.customCSS,
|
customCSS: customCSS || DEFAULT_THEME_CONFIG.customCSS,
|
||||||
backgroundConfig,
|
backgroundConfig,
|
||||||
@@ -79,7 +80,7 @@ export function loadThemeConfig(): UserThemeConfig {
|
|||||||
*/
|
*/
|
||||||
export function saveThemeConfig(config: UserThemeConfig): void {
|
export function saveThemeConfig(config: UserThemeConfig): void {
|
||||||
localStorage.setItem(THEME_STORAGE_KEYS.PRESET, config.selectedPreset)
|
localStorage.setItem(THEME_STORAGE_KEYS.PRESET, config.selectedPreset)
|
||||||
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, config.accentColor)
|
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, normalizeAccentColor(config.accentColor))
|
||||||
localStorage.setItem(THEME_STORAGE_KEYS.OVERRIDES, JSON.stringify(config.tokenOverrides))
|
localStorage.setItem(THEME_STORAGE_KEYS.OVERRIDES, JSON.stringify(config.tokenOverrides))
|
||||||
localStorage.setItem(THEME_STORAGE_KEYS.CUSTOM_CSS, config.customCSS)
|
localStorage.setItem(THEME_STORAGE_KEYS.CUSTOM_CSS, config.customCSS)
|
||||||
if (config.backgroundConfig) {
|
if (config.backgroundConfig) {
|
||||||
@@ -215,7 +216,7 @@ export function migrateOldKeys(): void {
|
|||||||
const newAccent = localStorage.getItem(THEME_STORAGE_KEYS.ACCENT)
|
const newAccent = localStorage.getItem(THEME_STORAGE_KEYS.ACCENT)
|
||||||
|
|
||||||
if (accentColor && !newAccent) {
|
if (accentColor && !newAccent) {
|
||||||
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, accentColor)
|
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, normalizeAccentColor(accentColor))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除旧 key
|
// 删除旧 key
|
||||||
|
|||||||
@@ -154,27 +154,27 @@ export type UserThemeConfig = {
|
|||||||
|
|
||||||
export const defaultLightTokens: ThemeTokens = {
|
export const defaultLightTokens: ThemeTokens = {
|
||||||
color: {
|
color: {
|
||||||
primary: '221.2 83.2% 53.3%',
|
primary: '188.5 100% 45.5%',
|
||||||
'primary-foreground': '210 40% 98%',
|
'primary-foreground': '210 40% 98%',
|
||||||
'primary-gradient': 'none',
|
'primary-gradient': 'none',
|
||||||
secondary: '210 40% 96.1%',
|
secondary: '188.5 35% 96%',
|
||||||
'secondary-foreground': '222.2 47.4% 11.2%',
|
'secondary-foreground': '222.2 47.4% 11.2%',
|
||||||
muted: '210 40% 96.1%',
|
muted: '188.5 12% 96%',
|
||||||
'muted-foreground': '215.4 16.3% 46.9%',
|
'muted-foreground': '188.5 20% 46.9%',
|
||||||
accent: '210 40% 96.1%',
|
accent: '223.5 60% 50.4%',
|
||||||
'accent-foreground': '222.2 47.4% 11.2%',
|
'accent-foreground': '222.2 47.4% 11.2%',
|
||||||
destructive: '0 84.2% 60.2%',
|
destructive: '0 84.2% 60.2%',
|
||||||
'destructive-foreground': '210 40% 98%',
|
'destructive-foreground': '210 40% 98%',
|
||||||
background: '0 0% 100%',
|
background: '0 0% 100%',
|
||||||
foreground: '222.2 84% 4.9%',
|
foreground: '222.2 84% 4.9%',
|
||||||
card: '0 0% 100%',
|
card: '188.5 14% 98.6%',
|
||||||
'card-foreground': '222.2 84% 4.9%',
|
'card-foreground': '222.2 84% 4.9%',
|
||||||
popover: '0 0% 100%',
|
popover: '188.5 16% 99.3%',
|
||||||
'popover-foreground': '222.2 84% 4.9%',
|
'popover-foreground': '222.2 84% 4.9%',
|
||||||
border: '214.3 31.8% 91.4%',
|
border: '188.5 20% 91.4%',
|
||||||
input: '214.3 31.8% 91.4%',
|
input: '188.5 20% 91.4%',
|
||||||
ring: '221.2 83.2% 53.3%',
|
ring: '188.5 100% 45.5%',
|
||||||
'chart-1': '221.2 83.2% 53.3%',
|
'chart-1': '188.5 100% 45.5%',
|
||||||
'chart-2': '160 60% 45%',
|
'chart-2': '160 60% 45%',
|
||||||
'chart-3': '30 80% 55%',
|
'chart-3': '30 80% 55%',
|
||||||
'chart-4': '280 65% 60%',
|
'chart-4': '280 65% 60%',
|
||||||
@@ -249,27 +249,27 @@ export const defaultLightTokens: ThemeTokens = {
|
|||||||
|
|
||||||
export const defaultDarkTokens: ThemeTokens = {
|
export const defaultDarkTokens: ThemeTokens = {
|
||||||
color: {
|
color: {
|
||||||
primary: '217.2 91.2% 59.8%',
|
primary: '188.5 100% 45.5%',
|
||||||
'primary-foreground': '210 40% 98%',
|
'primary-foreground': '210 40% 98%',
|
||||||
'primary-gradient': 'none',
|
'primary-gradient': 'none',
|
||||||
secondary: '217.2 32.6% 17.5%',
|
secondary: '188.5 35% 17.5%',
|
||||||
'secondary-foreground': '210 40% 98%',
|
'secondary-foreground': '210 40% 98%',
|
||||||
muted: '217.2 32.6% 17.5%',
|
muted: '188.5 12% 17.5%',
|
||||||
'muted-foreground': '215 20.2% 65.1%',
|
'muted-foreground': '188.5 20% 65.1%',
|
||||||
accent: '217.2 32.6% 17.5%',
|
accent: '223.5 60% 35.3%',
|
||||||
'accent-foreground': '210 40% 98%',
|
'accent-foreground': '210 40% 98%',
|
||||||
destructive: '0 62.8% 30.6%',
|
destructive: '0 62.8% 30.6%',
|
||||||
'destructive-foreground': '210 40% 98%',
|
'destructive-foreground': '210 40% 98%',
|
||||||
background: '222.2 84% 4.9%',
|
background: '222.2 84% 4.9%',
|
||||||
foreground: '210 40% 98%',
|
foreground: '210 40% 98%',
|
||||||
card: '222.2 84% 4.9%',
|
card: '188.5 18% 8.8%',
|
||||||
'card-foreground': '210 40% 98%',
|
'card-foreground': '210 40% 98%',
|
||||||
popover: '222.2 84% 4.9%',
|
popover: '188.5 21% 10.5%',
|
||||||
'popover-foreground': '210 40% 98%',
|
'popover-foreground': '210 40% 98%',
|
||||||
border: '217.2 32.6% 17.5%',
|
border: '188.5 20% 17.5%',
|
||||||
input: '217.2 32.6% 17.5%',
|
input: '188.5 20% 17.5%',
|
||||||
ring: '224.3 76.3% 48%',
|
ring: '188.5 100% 45.5%',
|
||||||
'chart-1': '217.2 91.2% 59.8%',
|
'chart-1': '188.5 100% 45.5%',
|
||||||
'chart-2': '160 60% 50%',
|
'chart-2': '160 60% 50%',
|
||||||
'chart-3': '30 80% 60%',
|
'chart-3': '30 80% 60%',
|
||||||
'chart-4': '280 65% 65%',
|
'chart-4': '280 65% 65%',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useRef, useCallback } from 'react'
|
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { AlertTriangle, Download, RotateCcw, Trash2, Upload } from 'lucide-react'
|
import { AlertTriangle, Download, RotateCcw, Trash2, Upload } from 'lucide-react'
|
||||||
|
|
||||||
@@ -11,8 +11,8 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { getComputedTokens } from '@/lib/theme/pipeline'
|
import { applyThemePipeline, getComputedTokens } from '@/lib/theme/pipeline'
|
||||||
import { hexToHSL } from '@/lib/theme/palette'
|
import { DEFAULT_ACCENT_COLOR_HEX, DEFAULT_ACCENT_COLOR_HSL, hexToHSL } from '@/lib/theme/palette'
|
||||||
import { defaultBackgroundConfig, defaultBackgroundEffects, defaultLightTokens } from '@/lib/theme/tokens'
|
import { defaultBackgroundConfig, defaultBackgroundEffects, defaultLightTokens } from '@/lib/theme/tokens'
|
||||||
import { exportThemeJSON, importThemeJSON } from '@/lib/theme/storage'
|
import { exportThemeJSON, importThemeJSON } from '@/lib/theme/storage'
|
||||||
import type { BackgroundConfigMap, BackgroundEffects, ThemeTokens } from '@/lib/theme/tokens'
|
import type { BackgroundConfigMap, BackgroundEffects, ThemeTokens } from '@/lib/theme/tokens'
|
||||||
@@ -81,11 +81,37 @@ export function AppearanceTab() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '')
|
const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '')
|
||||||
|
const [accentInputValue, setAccentInputValue] = useState(() => {
|
||||||
|
if (themeConfig.accentColor) {
|
||||||
|
return hslToHex(themeConfig.accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_ACCENT_COLOR_HEX
|
||||||
|
})
|
||||||
|
const [accentPreviewHex, setAccentPreviewHex] = useState(() => {
|
||||||
|
if (themeConfig.accentColor) {
|
||||||
|
return hslToHex(themeConfig.accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_ACCENT_COLOR_HEX
|
||||||
|
})
|
||||||
|
const [bgDraftConfig, setBgDraftConfig] = useState<BackgroundConfigMap>(themeConfig.backgroundConfig ?? {})
|
||||||
const [cssWarnings, setCssWarnings] = useState<string[]>([])
|
const [cssWarnings, setCssWarnings] = useState<string[]>([])
|
||||||
|
const accentDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const cssDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const cssDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const bgDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const bgDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const isValidHexColor = useCallback((value: string) => /^#[0-9A-F]{6}$/i.test(value), [])
|
||||||
|
|
||||||
|
const persistAccentColor = useCallback((hex: string) => {
|
||||||
|
if (accentDebounceRef.current) clearTimeout(accentDebounceRef.current)
|
||||||
|
|
||||||
|
accentDebounceRef.current = setTimeout(() => {
|
||||||
|
updateThemeConfig({ accentColor: hexToHSL(hex) })
|
||||||
|
}, 160)
|
||||||
|
}, [updateThemeConfig])
|
||||||
|
|
||||||
const updateTokenSection = useCallback(
|
const updateTokenSection = useCallback(
|
||||||
<K extends keyof ThemeTokens>(section: K, partial: Partial<ThemeTokens[K]>) => {
|
<K extends keyof ThemeTokens>(section: K, partial: Partial<ThemeTokens[K]>) => {
|
||||||
updateThemeConfig({
|
updateThemeConfig({
|
||||||
@@ -122,21 +148,71 @@ export function AppearanceTab() {
|
|||||||
}, 500)
|
}, 500)
|
||||||
}, [updateThemeConfig])
|
}, [updateThemeConfig])
|
||||||
|
|
||||||
const currentAccentHex = useMemo(() => {
|
const previewAccentHSL = useMemo(() => {
|
||||||
if (themeConfig.accentColor) {
|
if (isValidHexColor(accentPreviewHex)) {
|
||||||
return hslToHex(themeConfig.accentColor)
|
return hexToHSL(accentPreviewHex)
|
||||||
}
|
}
|
||||||
return '#3b82f6' // 默认蓝色
|
|
||||||
|
return themeConfig.accentColor || DEFAULT_ACCENT_COLOR_HSL
|
||||||
|
}, [accentPreviewHex, isValidHexColor, themeConfig.accentColor])
|
||||||
|
|
||||||
|
const previewThemeConfig = useMemo(() => {
|
||||||
|
return {
|
||||||
|
...themeConfig,
|
||||||
|
accentColor: previewAccentHSL,
|
||||||
|
}
|
||||||
|
}, [previewAccentHSL, themeConfig])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const persistedHex = themeConfig.accentColor
|
||||||
|
? hslToHex(themeConfig.accentColor)
|
||||||
|
: DEFAULT_ACCENT_COLOR_HEX
|
||||||
|
|
||||||
|
setAccentInputValue(persistedHex)
|
||||||
|
setAccentPreviewHex(persistedHex)
|
||||||
}, [themeConfig.accentColor])
|
}, [themeConfig.accentColor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBgDraftConfig(themeConfig.backgroundConfig ?? {})
|
||||||
|
}, [themeConfig.backgroundConfig])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyThemePipeline(previewThemeConfig, resolvedTheme === 'dark')
|
||||||
|
}, [previewThemeConfig, resolvedTheme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (accentDebounceRef.current) clearTimeout(accentDebounceRef.current)
|
||||||
|
if (cssDebounceRef.current) clearTimeout(cssDebounceRef.current)
|
||||||
|
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleAccentColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAccentColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const hex = e.target.value
|
const hex = e.target.value
|
||||||
const hsl = hexToHSL(hex)
|
setAccentInputValue(hex)
|
||||||
updateThemeConfig({ accentColor: hsl })
|
setAccentPreviewHex(hex)
|
||||||
|
persistAccentColor(hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAccentTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value.toUpperCase()
|
||||||
|
setAccentInputValue(value)
|
||||||
|
|
||||||
|
if (!isValidHexColor(value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setAccentPreviewHex(value)
|
||||||
|
persistAccentColor(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetAccent = () => {
|
const handleResetAccent = () => {
|
||||||
updateThemeConfig({ accentColor: '' })
|
if (accentDebounceRef.current) clearTimeout(accentDebounceRef.current)
|
||||||
|
|
||||||
|
setAccentInputValue(DEFAULT_ACCENT_COLOR_HEX)
|
||||||
|
setAccentPreviewHex(DEFAULT_ACCENT_COLOR_HEX)
|
||||||
|
updateThemeConfig({ accentColor: DEFAULT_ACCENT_COLOR_HSL })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
@@ -178,10 +254,17 @@ export function AppearanceTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previewTokens = useMemo(() => {
|
const previewTokens = useMemo(() => {
|
||||||
return getComputedTokens(themeConfig, resolvedTheme === 'dark').color
|
return getComputedTokens(previewThemeConfig, resolvedTheme === 'dark').color
|
||||||
}, [themeConfig, resolvedTheme])
|
}, [previewThemeConfig, resolvedTheme])
|
||||||
|
|
||||||
const bgConfig: BackgroundConfigMap = themeConfig.backgroundConfig ?? {}
|
const bgConfig: BackgroundConfigMap = bgDraftConfig
|
||||||
|
|
||||||
|
const scheduleBackgroundConfigPersist = useCallback((nextConfig: BackgroundConfigMap) => {
|
||||||
|
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
||||||
|
bgDebounceRef.current = setTimeout(() => {
|
||||||
|
updateThemeConfig({ backgroundConfig: nextConfig })
|
||||||
|
}, 180)
|
||||||
|
}, [updateThemeConfig])
|
||||||
|
|
||||||
const handleBgAssetChange = (layerId: keyof BackgroundConfigMap, assetId: string | undefined) => {
|
const handleBgAssetChange = (layerId: keyof BackgroundConfigMap, assetId: string | undefined) => {
|
||||||
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
||||||
@@ -189,29 +272,29 @@ export function AppearanceTab() {
|
|||||||
...bgConfig,
|
...bgConfig,
|
||||||
[layerId]: { ...current, assetId, type: assetId ? 'image' : 'none' },
|
[layerId]: { ...current, assetId, type: assetId ? 'image' : 'none' },
|
||||||
}
|
}
|
||||||
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
setBgDraftConfig(newMap)
|
||||||
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
|
scheduleBackgroundConfigPersist(newMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBgEffectsChange = (layerId: keyof BackgroundConfigMap, effects: BackgroundEffects) => {
|
const handleBgEffectsChange = (layerId: keyof BackgroundConfigMap, effects: BackgroundEffects) => {
|
||||||
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
||||||
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, effects } }
|
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, effects } }
|
||||||
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
setBgDraftConfig(newMap)
|
||||||
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
|
scheduleBackgroundConfigPersist(newMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBgCSSChange = (layerId: keyof BackgroundConfigMap, css: string) => {
|
const handleBgCSSChange = (layerId: keyof BackgroundConfigMap, css: string) => {
|
||||||
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
||||||
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, customCSS: css } }
|
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, customCSS: css } }
|
||||||
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
setBgDraftConfig(newMap)
|
||||||
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
|
scheduleBackgroundConfigPersist(newMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBgInheritChange = (layerId: keyof BackgroundConfigMap, inherit: boolean) => {
|
const handleBgInheritChange = (layerId: keyof BackgroundConfigMap, inherit: boolean) => {
|
||||||
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
const current = bgConfig[layerId] ?? defaultBackgroundConfig
|
||||||
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, inherit } }
|
const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, inherit } }
|
||||||
if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
|
setBgDraftConfig(newMap)
|
||||||
bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
|
scheduleBackgroundConfigPersist(newMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -252,7 +335,7 @@ export function AppearanceTab() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleResetAccent}
|
onClick={handleResetAccent}
|
||||||
disabled={!themeConfig.accentColor}
|
disabled={themeConfig.accentColor === DEFAULT_ACCENT_COLOR_HSL}
|
||||||
className="h-8"
|
className="h-8"
|
||||||
>
|
>
|
||||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
@@ -267,7 +350,7 @@ export function AppearanceTab() {
|
|||||||
<div className="h-10 w-10 rounded-full border-2 border-border overflow-hidden relative shadow-sm">
|
<div className="h-10 w-10 rounded-full border-2 border-border overflow-hidden relative shadow-sm">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={currentAccentHex}
|
value={accentPreviewHex}
|
||||||
onChange={handleAccentColorChange}
|
onChange={handleAccentColorChange}
|
||||||
className="absolute inset-0 w-[150%] h-[150%] -top-1/4 -left-1/4 cursor-pointer p-0 border-0"
|
className="absolute inset-0 w-[150%] h-[150%] -top-1/4 -left-1/4 cursor-pointer p-0 border-0"
|
||||||
/>
|
/>
|
||||||
@@ -282,8 +365,8 @@ export function AppearanceTab() {
|
|||||||
<Input
|
<Input
|
||||||
id="accent-color-input"
|
id="accent-color-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={currentAccentHex}
|
value={accentInputValue}
|
||||||
onChange={handleAccentColorChange}
|
onChange={handleAccentTextChange}
|
||||||
className="font-mono uppercase w-32"
|
className="font-mono uppercase w-32"
|
||||||
maxLength={7}
|
maxLength={7}
|
||||||
/>
|
/>
|
||||||
@@ -661,6 +744,11 @@ export function AppearanceTab() {
|
|||||||
|
|
||||||
{(['page', 'sidebar', 'header', 'card', 'dialog'] as const).map((layerId) => (
|
{(['page', 'sidebar', 'header', 'card', 'dialog'] as const).map((layerId) => (
|
||||||
<TabsContent key={layerId} value={layerId} className="space-y-4 mt-4">
|
<TabsContent key={layerId} value={layerId} className="space-y-4 mt-4">
|
||||||
|
{(() => {
|
||||||
|
const isInheritedLayer = (layerId === 'sidebar' || layerId === 'header') && (bgConfig[layerId]?.inherit ?? false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
{layerId !== 'page' && (
|
{layerId !== 'page' && (
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3">
|
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -673,19 +761,30 @@ export function AppearanceTab() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isInheritedLayer && (
|
||||||
|
<div className="rounded-lg border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
该层当前直接继承界面背景,下面的资源、效果和 CSS 调节已禁用。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<BackgroundUploader
|
<BackgroundUploader
|
||||||
assetId={bgConfig[layerId]?.assetId}
|
assetId={bgConfig[layerId]?.assetId}
|
||||||
onAssetSelect={(id) => handleBgAssetChange(layerId, id)}
|
onAssetSelect={(id) => handleBgAssetChange(layerId, id)}
|
||||||
|
disabled={isInheritedLayer}
|
||||||
/>
|
/>
|
||||||
<BackgroundEffectsControls
|
<BackgroundEffectsControls
|
||||||
effects={bgConfig[layerId]?.effects ?? defaultBackgroundEffects}
|
effects={bgConfig[layerId]?.effects ?? defaultBackgroundEffects}
|
||||||
onChange={(effects) => handleBgEffectsChange(layerId, effects)}
|
onChange={(effects) => handleBgEffectsChange(layerId, effects)}
|
||||||
|
disabled={isInheritedLayer}
|
||||||
/>
|
/>
|
||||||
<ComponentCSSEditor
|
<ComponentCSSEditor
|
||||||
componentId={layerId}
|
componentId={layerId}
|
||||||
value={bgConfig[layerId]?.customCSS ?? ''}
|
value={bgConfig[layerId]?.customCSS ?? ''}
|
||||||
onChange={(css) => handleBgCSSChange(layerId, css)}
|
onChange={(css) => handleBgCSSChange(layerId, css)}
|
||||||
|
disabled={isInheritedLayer}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -44,6 +44,24 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- maim_bot
|
- maim_bot
|
||||||
|
|
||||||
|
# 启用 HTTPS 时,建议注释掉 core 服务中的 "18001:8001" 端口映射,
|
||||||
|
# 然后取消注释此 Caddy 反向代理示例块,并按 dashboard/docs/Caddyfile.docker.example 修改域名。
|
||||||
|
# caddy:
|
||||||
|
# image: caddy:2
|
||||||
|
# container_name: maibot-caddy
|
||||||
|
# restart: always
|
||||||
|
# ports:
|
||||||
|
# - "80:80"
|
||||||
|
# - "443:443"
|
||||||
|
# volumes:
|
||||||
|
# - ./dashboard/docs/Caddyfile.docker.example:/etc/caddy/Caddyfile:ro
|
||||||
|
# - caddy_data:/data
|
||||||
|
# - caddy_config:/config
|
||||||
|
# depends_on:
|
||||||
|
# - core
|
||||||
|
# networks:
|
||||||
|
# - maim_bot
|
||||||
napcat:
|
napcat:
|
||||||
environment:
|
environment:
|
||||||
- NAPCAT_UID=1000
|
- NAPCAT_UID=1000
|
||||||
@@ -89,6 +107,8 @@ services:
|
|||||||
|
|
||||||
# volumes: # 若需要持久化Python包时启用
|
# volumes: # 若需要持久化Python包时启用
|
||||||
# site-packages:
|
# site-packages:
|
||||||
|
# caddy_data:
|
||||||
|
# caddy_config:
|
||||||
networks:
|
networks:
|
||||||
maim_bot:
|
maim_bot:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
|||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@@ -196,8 +197,20 @@ def _install_stub_modules(monkeypatch):
|
|||||||
emoji = _EmojiConfig()
|
emoji = _EmojiConfig()
|
||||||
bot = _BotConfig()
|
bot = _BotConfig()
|
||||||
|
|
||||||
|
class _ConfigManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.reload_callbacks = []
|
||||||
|
|
||||||
|
def register_reload_callback(self, callback):
|
||||||
|
self.reload_callbacks.append(callback)
|
||||||
|
|
||||||
|
def unregister_reload_callback(self, callback):
|
||||||
|
if callback in self.reload_callbacks:
|
||||||
|
self.reload_callbacks.remove(callback)
|
||||||
|
|
||||||
config_mod.global_config = _GlobalConfig()
|
config_mod.global_config = _GlobalConfig()
|
||||||
config_mod.model_config = _ModelConfig()
|
config_mod.model_config = _ModelConfig()
|
||||||
|
config_mod.config_manager = _ConfigManager()
|
||||||
|
|
||||||
# src.llm_models.utils_model
|
# src.llm_models.utils_model
|
||||||
llm_mod = _stub_module("src.llm_models.utils_model")
|
llm_mod = _stub_module("src.llm_models.utils_model")
|
||||||
@@ -479,6 +492,62 @@ def test_load_emojis_from_db_empty(monkeypatch):
|
|||||||
assert any("成功加载" in m for m in _messages(logger.info_calls))
|
assert any("成功加载" in m for m in _messages(logger.info_calls))
|
||||||
|
|
||||||
|
|
||||||
|
def test_emoji_manager_registers_reload_callback(monkeypatch):
|
||||||
|
emoji_manager_new = import_emoji_manager_new(monkeypatch)
|
||||||
|
|
||||||
|
assert emoji_manager_new.emoji_manager.reload_runtime_config in emoji_manager_new.config_manager.reload_callbacks
|
||||||
|
|
||||||
|
|
||||||
|
def test_emoji_manager_shutdown_unregisters_reload_callback(monkeypatch):
|
||||||
|
emoji_manager_new = import_emoji_manager_new(monkeypatch)
|
||||||
|
manager = emoji_manager_new.EmojiManager()
|
||||||
|
|
||||||
|
assert manager.reload_runtime_config in emoji_manager_new.config_manager.reload_callbacks
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
|
||||||
|
assert manager.reload_runtime_config not in emoji_manager_new.config_manager.reload_callbacks
|
||||||
|
|
||||||
|
# 重复调用应保持幂等,不应抛错也不应重复注册
|
||||||
|
manager.shutdown()
|
||||||
|
|
||||||
|
assert manager.reload_runtime_config not in emoji_manager_new.config_manager.reload_callbacks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reload_runtime_config_wakes_maintenance_loop(monkeypatch):
|
||||||
|
emoji_manager_new = import_emoji_manager_new(monkeypatch)
|
||||||
|
manager = emoji_manager_new.EmojiManager()
|
||||||
|
|
||||||
|
emoji_manager_new.global_config.emoji.steal_emoji = False
|
||||||
|
emoji_manager_new.global_config.emoji.check_interval = 60
|
||||||
|
|
||||||
|
maintenance_runs = 0
|
||||||
|
second_run_event = asyncio.Event()
|
||||||
|
|
||||||
|
def _check_emoji_file_integrity():
|
||||||
|
nonlocal maintenance_runs
|
||||||
|
maintenance_runs += 1
|
||||||
|
if maintenance_runs >= 2:
|
||||||
|
second_run_event.set()
|
||||||
|
|
||||||
|
monkeypatch.setattr(manager, "check_emoji_file_integrity", _check_emoji_file_integrity)
|
||||||
|
monkeypatch.setattr(manager, "remove_untracked_emoji_files", lambda: None)
|
||||||
|
|
||||||
|
task = asyncio.create_task(manager.periodic_emoji_maintenance())
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
assert maintenance_runs >= 1
|
||||||
|
|
||||||
|
manager.reload_runtime_config()
|
||||||
|
|
||||||
|
await asyncio.wait_for(second_run_event.wait(), timeout=0.2)
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
|
||||||
def test_load_emojis_from_db_partial_bad_records(monkeypatch):
|
def test_load_emojis_from_db_partial_bad_records(monkeypatch):
|
||||||
emoji_manager_new = import_emoji_manager_new(monkeypatch)
|
emoji_manager_new = import_emoji_manager_new(monkeypatch)
|
||||||
logger = emoji_manager_new.logger
|
logger = emoji_manager_new.logger
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ from src.common.database.database_model import Images, ImageType
|
|||||||
from src.common.database.database import get_db_session, get_db_session_manual
|
from src.common.database.database import get_db_session, get_db_session_manual
|
||||||
from src.common.utils.utils_image import ImageUtils
|
from src.common.utils.utils_image import ImageUtils
|
||||||
from src.prompt.prompt_manager import prompt_manager
|
from src.prompt.prompt_manager import prompt_manager
|
||||||
from src.config.config import global_config
|
from src.config.config import config_manager, global_config, model_config
|
||||||
from src.config.config import model_config
|
|
||||||
from src.llm_models.utils_model import LLMRequest
|
from src.llm_models.utils_model import LLMRequest
|
||||||
|
|
||||||
logger = get_logger("emoji")
|
logger = get_logger("emoji")
|
||||||
@@ -32,7 +31,7 @@ EMOJI_REGISTERED_DIR = DATA_DIR / "emoji_registered" # 已注册的表情包注
|
|||||||
MAX_EMOJI_FOR_PROMPT = 20 # 最大允许的表情包描述数量于图片替换的 prompt 中
|
MAX_EMOJI_FOR_PROMPT = 20 # 最大允许的表情包描述数量于图片替换的 prompt 中
|
||||||
|
|
||||||
|
|
||||||
def _ensure_directories():
|
def _ensure_directories() -> None:
|
||||||
"""确保表情包相关目录存在"""
|
"""确保表情包相关目录存在"""
|
||||||
EMOJI_DIR.mkdir(parents=True, exist_ok=True)
|
EMOJI_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
EMOJI_REGISTERED_DIR.mkdir(parents=True, exist_ok=True)
|
EMOJI_REGISTERED_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -48,14 +47,33 @@ class EmojiManager:
|
|||||||
表情包管理器
|
表情包管理器
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
_ensure_directories()
|
_ensure_directories()
|
||||||
|
|
||||||
self._emoji_num: int = 0
|
self._emoji_num: int = 0
|
||||||
self.emojis: list[MaiEmoji] = []
|
self.emojis: list[MaiEmoji] = []
|
||||||
|
self._maintenance_wakeup_event: asyncio.Event = asyncio.Event()
|
||||||
|
self._reload_callback_registered: bool = False
|
||||||
|
|
||||||
|
config_manager.register_reload_callback(self.reload_runtime_config)
|
||||||
|
self._reload_callback_registered = True
|
||||||
|
|
||||||
logger.info("启动表情包管理器")
|
logger.info("启动表情包管理器")
|
||||||
|
|
||||||
|
def reload_runtime_config(self) -> None:
|
||||||
|
"""响应配置热重载,唤醒维护循环以尽快应用最新配置。"""
|
||||||
|
self._maintenance_wakeup_event.set()
|
||||||
|
logger.info("[配置热重载] Emoji 模块配置已更新,将立即应用到维护循环")
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""清理 EmojiManager 生命周期资源。"""
|
||||||
|
if not self._reload_callback_registered:
|
||||||
|
return
|
||||||
|
config_manager.unregister_reload_callback(self.reload_runtime_config)
|
||||||
|
self._reload_callback_registered = False
|
||||||
|
self._maintenance_wakeup_event.set()
|
||||||
|
logger.info("[关闭] Emoji 模块已注销配置热重载回调")
|
||||||
|
|
||||||
async def get_emoji_description(
|
async def get_emoji_description(
|
||||||
self, *, emoji_bytes: Optional[bytes] = None, emoji_hash: Optional[str] = None
|
self, *, emoji_bytes: Optional[bytes] = None, emoji_hash: Optional[str] = None
|
||||||
) -> Optional[Tuple[str, List[str]]]:
|
) -> Optional[Tuple[str, List[str]]]:
|
||||||
@@ -640,7 +658,13 @@ class EmojiManager:
|
|||||||
logger.info(f"[定期维护] 删除无法注册的表情包文件: {emoji_file.name}")
|
logger.info(f"[定期维护] 删除无法注册的表情包文件: {emoji_file.name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[定期维护] 删除文件 {emoji_file.name} 时出错: {e}")
|
logger.error(f"[定期维护] 删除文件 {emoji_file.name} 时出错: {e}")
|
||||||
await asyncio.sleep(global_config.emoji.check_interval * 60)
|
wait_seconds = max(global_config.emoji.check_interval * 60, 0)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._maintenance_wakeup_event.wait(), timeout=wait_seconds)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._maintenance_wakeup_event.clear()
|
||||||
|
|
||||||
async def register_emoji_by_filename(self, filename: Path | str) -> bool:
|
async def register_emoji_by_filename(self, filename: Path | str) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
20
src/main.py
20
src/main.py
@@ -1,5 +1,6 @@
|
|||||||
from maim_message import MessageServer
|
from maim_message import MessageServer
|
||||||
from rich.traceback import install
|
from rich.traceback import install
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
@@ -31,17 +32,21 @@ install(extra_lines=3)
|
|||||||
logger = get_logger("main")
|
logger = get_logger("main")
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.webui.webui_server import WebUIServer
|
||||||
|
|
||||||
|
|
||||||
class MainSystem:
|
class MainSystem:
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
# 使用消息API替代直接的FastAPI实例
|
# 使用消息API替代直接的FastAPI实例
|
||||||
self.app: MessageServer = get_global_api()
|
self.app: MessageServer = get_global_api()
|
||||||
self.server: Server = get_global_server()
|
self.server: Server = get_global_server()
|
||||||
self.webui_server = None # 独立的 WebUI 服务器
|
self.webui_server: WebUIServer | None = None # 独立的 WebUI 服务器
|
||||||
|
|
||||||
# 设置独立的 WebUI 服务器
|
# 设置独立的 WebUI 服务器
|
||||||
self._setup_webui_server()
|
self._setup_webui_server()
|
||||||
|
|
||||||
def _setup_webui_server(self):
|
def _setup_webui_server(self) -> None:
|
||||||
"""设置独立的 WebUI 服务器"""
|
"""设置独立的 WebUI 服务器"""
|
||||||
from src.config.config import global_config
|
from src.config.config import global_config
|
||||||
|
|
||||||
@@ -57,7 +62,7 @@ class MainSystem:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(t("startup.webui_server_init_failed", error=e))
|
logger.error(t("startup.webui_server_init_failed", error=e))
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self) -> None:
|
||||||
"""初始化系统组件"""
|
"""初始化系统组件"""
|
||||||
logger.info(t("startup.waking_up", nickname=global_config.bot.nickname))
|
logger.info(t("startup.waking_up", nickname=global_config.bot.nickname))
|
||||||
|
|
||||||
@@ -66,7 +71,7 @@ class MainSystem:
|
|||||||
|
|
||||||
logger.info(t("startup.initialization_completed_banner", nickname=global_config.bot.nickname))
|
logger.info(t("startup.initialization_completed_banner", nickname=global_config.bot.nickname))
|
||||||
|
|
||||||
async def _init_components(self):
|
async def _init_components(self) -> None:
|
||||||
"""初始化其他组件"""
|
"""初始化其他组件"""
|
||||||
init_start_time = time.time()
|
init_start_time = time.time()
|
||||||
|
|
||||||
@@ -126,7 +131,7 @@ class MainSystem:
|
|||||||
logger.error(t("startup.brain_external_world_failed", error=e))
|
logger.error(t("startup.brain_external_world_failed", error=e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def schedule_tasks(self):
|
async def schedule_tasks(self) -> None:
|
||||||
"""调度定时任务"""
|
"""调度定时任务"""
|
||||||
try:
|
try:
|
||||||
tasks = [
|
tasks = [
|
||||||
@@ -153,7 +158,7 @@ class MainSystem:
|
|||||||
# logger.info("[记忆遗忘] 记忆遗忘完成")
|
# logger.info("[记忆遗忘] 记忆遗忘完成")
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main() -> None:
|
||||||
"""主函数"""
|
"""主函数"""
|
||||||
system = MainSystem()
|
system = MainSystem()
|
||||||
try:
|
try:
|
||||||
@@ -166,6 +171,7 @@ async def main():
|
|||||||
await get_plugin_runtime_manager().bridge_event("on_stop")
|
await get_plugin_runtime_manager().bridge_event("on_stop")
|
||||||
await get_plugin_runtime_manager().stop()
|
await get_plugin_runtime_manager().stop()
|
||||||
await async_task_manager.stop_and_wait_all_tasks()
|
await async_task_manager.stop_and_wait_all_tasks()
|
||||||
|
emoji_manager.shutdown()
|
||||||
await config_manager.stop_file_watcher()
|
await config_manager.stop_file_watcher()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user