diff --git a/dashboard/docs/Caddyfile.docker.example b/dashboard/docs/Caddyfile.docker.example new file mode 100644 index 00000000..759300b8 --- /dev/null +++ b/dashboard/docs/Caddyfile.docker.example @@ -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 +} \ No newline at end of file diff --git a/dashboard/docs/Caddyfile.host.example b/dashboard/docs/Caddyfile.host.example new file mode 100644 index 00000000..d18d75d3 --- /dev/null +++ b/dashboard/docs/Caddyfile.host.example @@ -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 +} \ No newline at end of file diff --git a/dashboard/docs/webui-tls-ssl-compose.md b/dashboard/docs/webui-tls-ssl-compose.md new file mode 100644 index 00000000..efcdb15f --- /dev/null +++ b/dashboard/docs/webui-tls-ssl-compose.md @@ -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 diff --git a/dashboard/docs/webui-tls-ssl.md b/dashboard/docs/webui-tls-ssl.md new file mode 100644 index 00000000..960714a6 --- /dev/null +++ b/dashboard/docs/webui-tls-ssl.md @@ -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 diff --git a/dashboard/src/components/background-effects-controls.tsx b/dashboard/src/components/background-effects-controls.tsx index 3c31b20a..e466dd30 100644 --- a/dashboard/src/components/background-effects-controls.tsx +++ b/dashboard/src/components/background-effects-controls.tsx @@ -18,18 +18,9 @@ import { defaultBackgroundEffects, } from '@/lib/theme/tokens' -// ============================================================================ -// Helper Functions -// ============================================================================ - -/** - * 将 HSL 字符串转换为 HEX 格式 - * (从 settings.tsx 移植) - */ function hslToHex(hsl: string): string { if (!hsl) return '#000000' - // 解析 "221.2 83.2% 53.3%" 格式 const parts = hsl.split(' ').filter(Boolean) if (parts.length < 3) return '#000000' @@ -39,72 +30,65 @@ function hslToHex(hsl: string): string { const sDecimal = s / 100 const lDecimal = l / 100 - const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) const m = lDecimal - c / 2 - let r = 0, - g = 0, - b = 0 + let r = 0 + let g = 0 + let b = 0 if (h >= 0 && h < 60) { r = c g = x - b = 0 } else if (h >= 60 && h < 120) { r = x g = c - b = 0 } else if (h >= 120 && h < 180) { - r = 0 g = c b = x } else if (h >= 180 && h < 240) { - r = 0 g = x b = c } else if (h >= 240 && h < 300) { r = x - g = 0 b = c } else if (h >= 300 && h < 360) { r = c - g = 0 b = x } - const toHex = (n: number) => { - const hex = Math.round((n + m) * 255).toString(16) - return hex.length === 1 ? '0' + hex : hex + const toHex = (value: number) => { + const hex = Math.round((value + m) * 255).toString(16) + return hex.length === 1 ? `0${hex}` : hex } return `#${toHex(r)}${toHex(g)}${toHex(b)}` } -// ============================================================================ -// Component -// ============================================================================ - type BackgroundEffectsControlsProps = { effects: BackgroundEffects onChange: (effects: BackgroundEffects) => void + disabled?: boolean } export function BackgroundEffectsControls({ effects, onChange, + disabled = false, }: BackgroundEffectsControlsProps) { - // 处理数值变更 const handleValueChange = (key: keyof BackgroundEffects, value: number) => { + if (disabled) return + onChange({ ...effects, [key]: value, }) } - // 处理颜色变更 const handleColorChange = (e: React.ChangeEvent) => { + if (disabled) return + const hex = e.target.value const hsl = hexToHSL(hex) onChange({ @@ -113,35 +97,38 @@ export function BackgroundEffectsControls({ }) } - // 处理位置变更 const handlePositionChange = (value: string) => { + if (disabled) return + onChange({ ...effects, position: value as BackgroundEffects['position'], }) } - // 处理渐变变更 const handleGradientChange = (e: React.ChangeEvent) => { + if (disabled) return + onChange({ ...effects, gradientOverlay: e.target.value, }) } - // 重置为默认值 const handleReset = () => { + if (disabled) return onChange(defaultBackgroundEffects) } return ( -
+

背景效果调节

- {/* 1. Blur (模糊) */}
- - {effects.blur}px - + {effects.blur}px
handleValueChange('blur', vals[0])} />
- {/* 2. Overlay Color (遮罩颜色) */}
@@ -176,18 +160,19 @@ export function BackgroundEffectsControls({ type="color" value={hslToHex(effects.overlayColor)} onChange={handleColorChange} + disabled={disabled} className="h-[150%] w-[150%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0" />
- {/* 3. Overlay Opacity (遮罩不透明度) */}
@@ -200,17 +185,15 @@ export function BackgroundEffectsControls({ min={0} max={100} step={1} - onValueChange={(vals) => - handleValueChange('overlayOpacity', vals[0] / 100) - } + disabled={disabled} + onValueChange={(vals) => handleValueChange('overlayOpacity', vals[0] / 100)} />
- {/* 4. Position (位置) */}
- + @@ -222,69 +205,61 @@ export function BackgroundEffectsControls({
- {/* 5. Brightness (亮度) */}
- - {effects.brightness}% - + {effects.brightness}%
handleValueChange('brightness', vals[0])} />
- {/* 6. Contrast (对比度) */}
- - {effects.contrast}% - + {effects.contrast}%
handleValueChange('contrast', vals[0])} />
- {/* 7. Saturate (饱和度) */}
- - {effects.saturate}% - + {effects.saturate}%
handleValueChange('saturate', vals[0])} />
- {/* 8. Gradient Overlay (渐变叠加) */}
-

- 可选:输入有效的 CSS gradient 字符串 -

+

可选:输入有效的 CSS gradient 字符串

diff --git a/dashboard/src/components/background-layer.tsx b/dashboard/src/components/background-layer.tsx index e1f335ca..15dc7551 100644 --- a/dashboard/src/components/background-layer.tsx +++ b/dashboard/src/components/background-layer.tsx @@ -8,6 +8,31 @@ type BackgroundLayerProps = { 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 { const parts: string[] = [] 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 { 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 (
)} - {overlayOpacity > 0 && ( + {effectiveOverlayOpacity > 0 && (
)} - {gradientOverlay && ( + {effectiveGradientOverlay && (
diff --git a/dashboard/src/components/background-uploader.tsx b/dashboard/src/components/background-uploader.tsx index 215b75dc..503e36b0 100644 --- a/dashboard/src/components/background-uploader.tsx +++ b/dashboard/src/components/background-uploader.tsx @@ -12,9 +12,10 @@ type BackgroundUploaderProps = { assetId?: string onAssetSelect: (id: string | undefined) => void className?: string + disabled?: boolean } -export function BackgroundUploader({ assetId, onAssetSelect, className }: BackgroundUploaderProps) { +export function BackgroundUploader({ assetId, onAssetSelect, className, disabled = false }: BackgroundUploaderProps) { const { getAssetUrl } = useAssetStore() const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) @@ -62,6 +63,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr }, [assetId, getAssetUrl, onAssetSelect]) const handleFile = async (file: File) => { + if (disabled) return setError(null) setIsLoading(true) @@ -87,7 +89,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr } const handleUrlUpload = async () => { - if (!urlInput) return + if (disabled || !urlInput) return setError(null) setIsLoading(true) @@ -118,6 +120,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr const handleDrag = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() + if (disabled) return if (e.type === 'dragenter' || e.type === 'dragover') { setDragActive(true) } else if (e.type === 'dragleave') { @@ -130,12 +133,15 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr e.stopPropagation() setDragActive(false) + if (disabled) return + if (e.dataTransfer.files && e.dataTransfer.files[0]) { handleFile(e.dataTransfer.files[0]) } } const handleClear = () => { + if (disabled) return onAssetSelect(undefined) setPreviewUrl(undefined) setAssetType(undefined) @@ -143,7 +149,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr } return ( -
+
@@ -151,6 +157,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr
@@ -212,6 +220,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} + disabled={disabled} > 选择文件 @@ -224,6 +233,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr className="hidden" accept="image/*,video/mp4,video/webm" onChange={(e) => { + if (disabled) return if (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" className="pl-9" value={urlInput} + disabled={disabled} onChange={(e) => setUrlInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { @@ -262,7 +273,7 @@ export function BackgroundUploader({ assetId, onAssetSelect, className }: Backgr diff --git a/dashboard/src/components/component-css-editor.tsx b/dashboard/src/components/component-css-editor.tsx index f4449693..d91136b4 100644 --- a/dashboard/src/components/component-css-editor.tsx +++ b/dashboard/src/components/component-css-editor.tsx @@ -16,6 +16,7 @@ export type ComponentCSSEditorProps = { label?: string /** 编辑器高度,默认 200px */ height?: string + disabled?: boolean } /** @@ -28,12 +29,13 @@ export function ComponentCSSEditor({ onChange, label, height = '200px', + disabled = false, }: ComponentCSSEditorProps) { // 实时计算 CSS 警告 const { warnings } = sanitizeCSS(value) return ( -
+