diff --git a/.windsurf/workflows/release.md b/.windsurf/workflows/release.md index 116ad88..1bf7350 100644 --- a/.windsurf/workflows/release.md +++ b/.windsurf/workflows/release.md @@ -11,16 +11,17 @@ git status 2. 确认 CI 全部通过(main 分支绿灯) -3. 更新 `CHANGELOG.md`,在顶部加入本次版本的变更记录 - -4. 更新 `src-tauri/tauri.conf.json` 中的 `version` 字段,与即将发布的版本号保持一致: -```json -{ "version": "1.2.3" } +3. 更新版本号(一条命令自动同步 package.json → tauri.conf.json → Cargo.toml → docs/index.html): +// turbo +```bash +npm run version:set 1.2.3 ``` +4. 更新 `CHANGELOG.md`,在顶部加入本次版本的变更记录 + 5. 提交版本更新: ```bash -git add CHANGELOG.md src-tauri/tauri.conf.json +git add -A git commit -m "chore: release v1.2.3" git push origin main ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29c29d1..c155b12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,37 @@ -# 贡献指南 +# 贡献指南 & 维护手册 -感谢你对 ClawPanel 项目的关注!以下是参与贡献的相关说明。 +感谢你对 ClawPanel 项目的关注!本文档同时作为**贡献指南**和**项目维护手册**,涵盖开发、构建、发版、部署的完整工作流。 + +> 🌐 **官网**: [claw.qt.cool](https://claw.qt.cool/) | 📦 **仓库**: [github.com/qingchencloud/clawpanel](https://github.com/qingchencloud/clawpanel) + +--- + +## 目录 + +- [开发环境要求](#开发环境要求) +- [项目结构](#项目结构) +- [运行模式](#运行模式) +- [版本管理](#版本管理) +- [发版流程](#发版流程) +- [CI/CD 工作流](#cicd-工作流) +- [配置文件说明](#配置文件说明) +- [关键脚本](#关键脚本) +- [前端开发约定](#前端开发约定) +- [Rust 后端约定](#rust-后端约定) +- [安全机制](#安全机制) +- [部署模式](#部署模式) +- [分支与提交规范](#分支与提交规范) +- [PR 流程](#pr-流程) +- [代码规范](#代码规范) +- [问题反馈](#问题反馈) + +--- ## 开发环境要求 | 依赖 | 最低版本 | 说明 | |------|----------|------| -| Node.js | 18+ | 前端构建 | +| Node.js | 18+ | 前端构建(推荐 22 LTS) | | Rust | stable | Tauri 后端编译 | | Tauri CLI | v2 | `cargo install tauri-cli --version "^2"` | @@ -24,45 +49,286 @@ npm install #### macOS / Linux ```bash -# 启动开发模式(完整 Tauri 桌面应用) +# 启动完整 Tauri 桌面应用 ./scripts/dev.sh -# 仅启动前端(浏览器调试,使用 mock 数据) +# 仅启动前端(浏览器调试,含 dev-api 真实后端) ./scripts/dev.sh web ``` #### Windows ```powershell -# 启动开发模式(完整 Tauri 桌面应用) +# 启动完整 Tauri 桌面应用 npm run tauri dev -# 仅启动前端(浏览器调试,使用 mock 数据) +# 仅启动前端(浏览器调试,含 dev-api 真实后端) npm run dev ``` > Windows 开发需要安装 [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)(勾选「使用 C++ 的桌面开发」工作负载)和 [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)(Win10+ 通常已预装)。 +--- + ## 项目结构 ``` clawpanel/ -├── src/ # 前端源码(Vanilla JS) -│ ├── pages/ # 页面模块(每个页面导出 render 函数) -│ ├── components/ # 通用组件(侧边栏、弹窗、Toast) -│ ├── lib/ # 工具库(Tauri API 封装、主题切换) -│ ├── style/ # CSS 样式(CSS Variables 驱动) -│ ├── router.js # Hash 路由 -│ └── main.js # 入口文件 -├── src-tauri/ # Rust 后端 -│ ├── src/commands/ # Tauri 命令(按功能模块拆分) -│ ├── Cargo.toml # Rust 依赖 -│ └── tauri.conf.json # Tauri 配置 -├── public/ # 静态资源(图片、图标) -└── .github/workflows/ # CI/CD 工作流 +├── src/ # 前端源码(Vanilla JS + Vite) +│ ├── pages/ # 页面模块(每个导出 render()) +│ │ ├── dashboard.js # 仪表盘 +│ │ ├── assistant.js # AI 助手 +│ │ ├── chat.js # 实时聊天 +│ │ ├── chat-debug.js # 聊天调试(WebSocket) +│ │ ├── services.js # 服务管理 +│ │ ├── logs.js # 日志查看 +│ │ ├── config.js # 模型配置 +│ │ ├── gateway.js # 网关配置 +│ │ ├── agents.js # Agent 管理 +│ │ ├── memory.js # 记忆管理 +│ │ ├── extensions.js # 扩展工具 +│ │ ├── security.js # 安全设置 +│ │ ├── setup.js # 初始设置向导 +│ │ └── about.js # 关于页面 +│ ├── components/ # 通用组件 +│ │ ├── sidebar.js # 侧边导航栏 +│ │ ├── toast.js # 消息提示 +│ │ └── modal.js # 弹窗组件 +│ ├── lib/ # 工具库 +│ │ ├── tauri-api.js # Tauri API 封装(含 Web fallback + mock) +│ │ ├── theme.js # 主题切换(暗色/亮色) +│ │ └── app-state.js # 应用状态管理 +│ ├── style/ # CSS 样式(CSS Variables 驱动) +│ │ ├── variables.css # CSS 变量定义(主题色、间距、字号) +│ │ ├── base.css # 基础样式重置 +│ │ ├── layout.css # 布局(侧边栏、内容区) +│ │ ├── components.css # 组件样式(按钮、表单、卡片) +│ │ ├── pages.css # 页面通用样式 +│ │ ├── chat.css # 聊天页样式 +│ │ ├── agents.css # Agent 页样式 +│ │ ├── debug.css # 调试页样式 +│ │ └── assistant.css # AI 助手页样式 +│ ├── router.js # Hash 路由 +│ └── main.js # 入口文件(含密码保护逻辑) +├── src-tauri/ # Rust 后端(Tauri v2) +│ ├── src/ +│ │ ├── lib.rs # 入口 + 命令注册 +│ │ ├── commands/ # Tauri 命令(按功能模块拆分) +│ │ │ ├── mod.rs # 模块注册 + enhanced_path() +│ │ │ ├── config.rs # 配置读写 + 版本管理 + 面板配置 +│ │ │ ├── service.rs # Gateway 服务管理(跨平台) +│ │ │ ├── agent.rs # Agent CRUD +│ │ │ ├── memory.rs # 记忆文件管理 +│ │ │ ├── logs.rs # 日志读取/搜索 +│ │ │ ├── device.rs # 设备密钥 + Gateway 握手 +│ │ │ ├── pairing.rs # 设备配对 +│ │ │ ├── extensions.rs # 扩展工具(cftunnel / clawapp) +│ │ │ └── assistant.rs # AI 助手工具调用 +│ │ ├── models/ # 数据模型 +│ │ ├── tray.rs # 系统托盘 +│ │ └── utils.rs # 工具函数 +│ ├── Cargo.toml # Rust 依赖 + 版本号 +│ └── tauri.conf.json # Tauri 配置 + 版本号 +├── scripts/ # 开发与运维脚本 +│ ├── dev.sh # macOS/Linux 开发启动 +│ ├── dev-api.js # Vite 插件:Web 模式真实后端 API +│ ├── build.sh # macOS/Linux 编译与打包 +│ ├── linux-deploy.sh # Linux 服务器一键部署 +│ └── sync-version.js # 版本号同步脚本 +├── docs/ # 文档与截图 +│ ├── index.html # 官网(claw.qt.cool) +│ ├── linux-deploy.md # Linux 部署指南 +│ └── docker-deploy.md # Docker 部署指南 +├── public/ # 静态资源(图标、Logo) +├── .github/workflows/ # CI/CD +│ ├── ci.yml # 持续集成(push/PR → 检查) +│ └── release.yml # 发布构建(tag → 全平台打包) +├── .windsurf/workflows/ # Cascade AI 工作流 +│ └── release.md # 发版工作流指令 +├── package.json # 前端依赖 + 版本号(唯一真相源) +├── vite.config.js # Vite 配置 +├── CHANGELOG.md # 更新日志 +├── CONTRIBUTING.md # 本文件 +├── SECURITY.md # 安全政策 +└── README.md # 项目介绍 ``` -### 前端页面开发约定 +--- + +## 运行模式 + +ClawPanel 有两种运行模式,前端代码通过 `isTauri` 标志自动适配: + +| 模式 | 启动方式 | 后端 | API 通信 | 适用场景 | +|------|----------|------|----------|----------| +| **Tauri 桌面** | `npm run tauri dev` | Rust (IPC) | `window.__TAURI_INTERNALS__` → `invoke()` | macOS / Windows / Linux 桌面 | +| **Web 浏览器** | `npm run dev` | Node.js (`dev-api.js`) | `fetch('/__api/xxx')` | Linux 服务器远程管理 | + +### API 调用链路 + +``` +前端代码 + ↓ +tauri-api.js → isTauri? + ├─ YES → invoke() → Rust IPC → src-tauri/src/commands/*.rs + └─ NO → webInvoke() → fetch('/__api/cmd') → scripts/dev-api.js + ↓ 失败时 + mockInvoke() → 内置 mock 数据(仅用于无后端调试) +``` + +### Web 模式后端 (`dev-api.js`) + +`scripts/dev-api.js` 是一个 Vite 插件,在 Web 模式下提供与 Tauri IPC 等效的 HTTP API。它: +- 拦截 `/__api/*` 请求 +- 调用与 Rust 命令同名的 handler 函数 +- 提供密码保护中间件(session + cookie) +- 实际执行 `openclaw` CLI 命令操作服务器 + +--- + +## 版本管理 + +### 版本号位置 + +版本号以 `package.json` 为**唯一真相源**,通过同步脚本分发到其他文件: + +| 文件 | 字段 | 用途 | +|------|------|------| +| `package.json` | `version` | **主版本源** — npm、前端构建、侧边栏显示 | +| `src-tauri/tauri.conf.json` | `version` | Tauri 打包版本号 | +| `src-tauri/Cargo.toml` | `version` | Rust crate 版本号 | +| `docs/index.html` | `softwareVersion` | 官网 JSON-LD SEO | +| `CHANGELOG.md` | `## [x.y.z]` | 变更日志(需手动编写内容) | + +### 同步命令 + +```bash +# 设置新版本并自动同步到所有文件 +npm run version:set 0.6.0 + +# 仅同步当前 package.json 版本到其他文件 +npm run version:sync +``` + +### 前端版本读取 + +侧边栏底部自动从 `package.json` 读取版本号,无需手动维护: + +```javascript +import { version as APP_VERSION } from '../../package.json' +``` + +--- + +## 发版流程 + +### 完整步骤 + +```bash +# 1. 确认工作区干净 +git status + +# 2. 设置新版本号(自动同步到 tauri.conf.json / Cargo.toml / docs/index.html) +npm run version:set 0.6.0 + +# 3. 编写 CHANGELOG.md 变更记录 + +# 4. 提交 +git add -A +git commit -m "chore: release v0.6.0" +git push origin main + +# 5. 打 tag 触发自动构建 +git tag v0.6.0 +git push origin v0.6.0 +``` + +### 发版后自动执行 + +推送 tag 后,GitHub Actions (`release.yml`) 会自动: +1. **并行构建** macOS ARM64 / macOS Intel / Linux / Windows 四个平台 +2. **创建 GitHub Release** 并上传安装包(.dmg / .exe / .msi / .AppImage / .deb / .rpm) +3. 所有平台构建完成后,**自动生成 Release Notes**(含下载表格 + 分类 Changelog) + +### 回滚 + +```bash +git tag -d v0.6.0 +git push origin :refs/tags/v0.6.0 +# 修复后重新打 tag +``` + +--- + +## CI/CD 工作流 + +### `ci.yml` — 持续集成 + +- **触发**:push 到 `main` 分支 或 PR 到 `main` +- **平台**:macOS / Linux / Windows 三平台并行 +- **检查项**: + 1. `npm ci` — 前端依赖安装 + 2. `cargo fmt --check` — Rust 代码格式 + 3. `cargo check` — Rust 编译检查 + 4. `cargo clippy -- -D warnings` — Rust lint(警告即失败) + 5. `npm run build` — 前端构建验证 + +### `release.yml` — 发布构建 + +- **触发**:推送 `v*` 标签 或 手动触发 +- **平台**:macOS ARM64 / macOS Intel / Linux x64 / Windows x64 +- **产物**:通过 `tauri-apps/tauri-action@v0` 构建并上传到 GitHub Release +- **Release Notes**:独立 job,等所有平台构建完成后统一生成 + +--- + +## 配置文件说明 + +### `~/.openclaw/openclaw.json` + +OpenClaw 主配置文件,包含模型配置、网关配置等。由 ClawPanel 的"模型配置"和"网关配置"页面读写。 + +### `~/.openclaw/clawpanel.json` + +ClawPanel 面板自身的配置文件,独立于 OpenClaw: + +```json +{ + "accessPassword": "用户设置的访问密码", + "mustChangePassword": true, + "ignoreRisk": false, + "nodePath": "/custom/node/path" +} +``` + +| 字段 | 说明 | +|------|------| +| `accessPassword` | 面板访问密码(明文存储,桌面端本地比对) | +| `mustChangePassword` | `true` = 首次登录后强制修改默认密码 | +| `ignoreRisk` | `true` = 无视风险模式,跳过密码保护 | +| `nodePath` | 用户自定义 Node.js 路径,补充到 PATH | + +### `~/.openclaw/npm-registry.txt` + +用户配置的 npm 源地址,默认 `https://registry.npmmirror.com`。 + +--- + +## 关键脚本 + +| 脚本 | 用途 | +|------|------| +| `scripts/dev.sh` | macOS/Linux 开发启动(清理旧进程 → 启动 Vite 或 Tauri) | +| `scripts/dev-api.js` | Vite 插件,Web 模式的 Node.js 后端(API + 认证中间件) | +| `scripts/build.sh` | macOS/Linux 构建脚本(支持 `check` / `release` 模式) | +| `scripts/linux-deploy.sh` | Linux 服务器一键部署(安装依赖 → 克隆仓库 → systemd 服务) | +| `scripts/sync-version.js` | 版本号同步(`package.json` → 其他 4 个文件) | + +--- + +## 前端开发约定 + +### 页面模块 每个页面是一个独立 JS 模块,导出 `render()` 函数: @@ -78,9 +344,58 @@ export async function render() { } ``` -关键原则:`render()` 必须立即返回 DOM 元素,不要 `await` 数据加载,否则会阻塞页面切换。 +**关键原则**:`render()` 必须立即返回 DOM 元素,不要 `await` 数据加载,否则会阻塞页面切换。 -### Rust 跨平台开发约定 +### 新增页面清单 + +1. 创建 `src/pages/xxx.js`,导出 `render()` +2. 在 `src/main.js` 注册路由:`registerRoute('/xxx', () => import('./pages/xxx.js'))` +3. 在 `src/components/sidebar.js` 的 `NAV_ITEMS_FULL` 中添加导航项 +4. 在 `ICONS` 对象中添加对应图标 SVG + +### API 调用 + +统一通过 `tauri-api.js` 封装,不要在页面中直接 `fetch`: + +```javascript +import { api } from '../lib/tauri-api.js' + +// 读(自带缓存) +const config = await api.readOpenclawConfig() + +// 写(自动清缓存) +await api.writeOpenclawConfig(config) +``` + +### 双模式适配 + +页面中需要区分 Tauri/Web 行为时: + +```javascript +const isTauri = !!window.__TAURI_INTERNALS__ + +if (isTauri) { + // 桌面端:通过 Tauri IPC + const { api } = await import('../lib/tauri-api.js') + const cfg = await api.readPanelConfig() +} else { + // Web 端:通过 HTTP API + const resp = await fetch('/__api/xxx', { method: 'POST', ... }) +} +``` + +--- + +## Rust 后端约定 + +### 新增 Tauri 命令 + +1. 在对应的 `src-tauri/src/commands/xxx.rs` 中添加 `#[tauri::command]` 函数 +2. 在 `src-tauri/src/lib.rs` 的 `invoke_handler` 中注册 +3. 在 `src/lib/tauri-api.js` 的 `api` 对象中添加前端包装方法 +4. 在 `mockInvoke` 的 `mocks` 对象中添加 mock 数据(供无后端调试) + +### 跨平台代码 平台相关代码使用条件编译: @@ -89,48 +404,103 @@ export async function render() { { // macOS: launchctl / plist } +#[cfg(target_os = "linux")] +{ + // Linux: 进程管理 / systemd +} #[cfg(target_os = "windows")] { // Windows: openclaw CLI / tasklist } ``` -## 分支策略 +### PATH 问题 + +Tauri 桌面应用启动时 PATH 可能不完整(macOS Finder 启动、Windows 非默认安装路径)。所有需要调用外部命令的地方必须使用 `super::enhanced_path()` 设置环境变量。 + +--- + +## 安全机制 + +### 密码保护 + +ClawPanel 支持访问密码保护,**Web 模式和 Tauri 桌面端均可启用**: + +| 模式 | 密码存储 | 验证方式 | 会话管理 | +|------|----------|----------|----------| +| Web | `clawpanel.json` | 后端比对 + HTTP-only Cookie | 服务端 session(24h TTL) | +| Tauri 桌面 | `clawpanel.json` | 前端本地比对 | `sessionStorage` | + +### 密码保护流程 + +``` +启动 → 读 clawpanel.json + ├─ 无密码 + ignoreRisk → 放行 + ├─ 有密码 + 未认证 → 弹出登录覆盖层 + └─ 有密码 + mustChangePassword → 登录后强制改密码 +``` + +### 安全设置页 (`/security`) + +- 查看当前密码状态 +- 修改密码(含强度校验:≥6 位、不能纯数字、不能常见弱密码) +- 无视风险模式(关闭密码保护,仅建议受信任内网) + +--- + +## 部署模式 + +### 1. 桌面应用(Tauri) + +面向 macOS / Windows / Linux 桌面用户,从 [GitHub Releases](https://github.com/qingchencloud/clawpanel/releases) 下载安装包。 + +### 2. Linux 服务器(Web 版) + +一键部署脚本,适用于无桌面环境的 Linux 服务器: + +```bash +curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawpanel/main/scripts/linux-deploy.sh | bash +``` + +部署后通过 `http://服务器IP:1420` 访问,自动生成默认密码。 + +详见 [Linux 部署指南](docs/linux-deploy.md)。 + +### 3. Docker + +详见 [Docker 部署指南](docs/docker-deploy.md)。 + +--- + +## 分支与提交规范 + +### 分支策略 - 所有开发基于 `main` 分支 - 新功能分支:`feature/功能描述`(例如 `feature/log-export`) - 修复分支:`fix/问题描述`(例如 `fix/model-save-crash`) - 完成后发起 PR 合并回 `main` -## 提交规范 +### 提交格式 -提交信息采用 [Conventional Commits](https://www.conventionalcommits.org/) 格式: +采用 [Conventional Commits](https://www.conventionalcommits.org/): ``` <类型>(可选范围): 简要描述 ``` -### 类型说明 +| 类型 | 说明 | 示例 | +|------|------|------| +| `feat` | 新功能 | `feat(model): 新增模型批量测试功能` | +| `fix` | 修复 Bug | `fix(gateway): 修复端口配置未生效的问题` | +| `docs` | 文档变更 | `docs: 更新安装说明` | +| `style` | 代码格式 | `style(css): 统一按钮圆角` | +| `refactor` | 重构 | `refactor(router): 简化路由匹配逻辑` | +| `perf` | 性能优化 | `perf(router): 添加模块缓存避免重复加载` | +| `chore` | 构建/工具 | `chore: release v0.6.0` | +| `security` | 安全修复 | `security(api): 修复命令注入漏洞` | -| 类型 | 说明 | -|------|------| -| `feat` | 新功能 | -| `fix` | 修复 Bug | -| `docs` | 文档变更 | -| `style` | 代码格式调整(不影响逻辑) | -| `refactor` | 重构(非新功能、非 Bug 修复) | -| `perf` | 性能优化 | -| `test` | 测试相关 | -| `chore` | 构建/工具/依赖变更 | - -### 示例 - -``` -feat(model): 新增模型批量测试功能 -fix(gateway): 修复端口配置未生效的问题 -perf(router): 添加模块缓存避免重复加载 -docs: 更新安装说明 -``` +--- ## PR 流程 @@ -142,14 +512,19 @@ docs: 更新安装说明 6. 发起 Pull Request,描述清楚变更内容和测试情况 7. 等待代码审查,根据反馈修改 +--- + ## 代码规范 - **前端**:使用 Vanilla JS,不引入第三方框架 - **注释**:所有代码注释使用中文 - **风格**:简洁清晰,避免过度封装 -- **命名**:变量和函数使用驼峰命名(camelCase),CSS 类名使用短横线命名(kebab-case) +- **命名**:变量和函数使用 camelCase,CSS 类名使用 kebab-case - **资源**:静态资源本地化,禁止引用远程 CDN -- **异步**:页面 render() 中禁止阻塞式 await,数据加载走后台异步 +- **异步**:页面 `render()` 中禁止阻塞式 await,数据加载走后台异步 +- **版本**:只改 `package.json`,运行 `npm run version:sync` 同步 + +--- ## 问题反馈 diff --git a/SECURITY.md b/SECURITY.md index 3441855..e08e372 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,9 @@ | 版本 | 支持状态 | |------|----------| -| 0.3.x | ✅ 安全更新 | -| < 0.3 | ❌ 不再维护 | +| 0.5.x | ✅ 安全更新 | +| 0.4.x | ⚠️ 仅关键修复 | +| < 0.4 | ❌ 不再维护 | ## 报告安全漏洞 diff --git a/docs/index.html b/docs/index.html index 1dd4582..08ebb62 100644 --- a/docs/index.html +++ b/docs/index.html @@ -34,7 +34,7 @@ "description": "OpenClaw AI Agent 可视化管理面板,基于 Tauri v2 的跨平台桌面应用。支持仪表盘监控、多模型配置、实时 AI 聊天、记忆管理、Agent 管理、网关配置、内网穿透等功能。", "url": "https://claw.qt.cool/", "downloadUrl": "https://github.com/qingchencloud/clawpanel/releases/latest", - "softwareVersion": "0.5.6", + "softwareVersion": "0.5.7", "author": { "@type": "Organization", "name": "晴辰云 QingchenCloud", diff --git a/package.json b/package.json index fba050c..9e7a53c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawpanel", - "version": "0.5.6", + "version": "0.5.7", "private": true, "description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用", "type": "module", @@ -25,7 +25,9 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "version:sync": "node scripts/sync-version.js", + "version:set": "node scripts/sync-version.js" }, "dependencies": { "@tauri-apps/api": "^2.5.0", diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 6c72ea4..2050043 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -21,15 +21,123 @@ const isWindows = process.platform === 'win32' const isMac = process.platform === 'darwin' const isLinux = process.platform === 'linux' const SCOPES = ['operator.admin', 'operator.approvals', 'operator.pairing', 'operator.read', 'operator.write'] +const PANEL_CONFIG_PATH = path.join(OPENCLAW_DIR, 'clawpanel.json') + +// === 访问密码 & Session 管理 === + +const _sessions = new Map() // token → { expires } +const SESSION_TTL = 24 * 60 * 60 * 1000 // 24h +const AUTH_EXEMPT = new Set(['auth_check', 'auth_login', 'auth_logout']) + +// 登录限速:防暴力破解(IP 级别,5次失败后锁定60秒) +const _loginAttempts = new Map() // ip → { count, lockedUntil } +const MAX_LOGIN_ATTEMPTS = 5 +const LOCKOUT_DURATION = 60 * 1000 // 60s + +function checkLoginRateLimit(ip) { + const now = Date.now() + const record = _loginAttempts.get(ip) + if (!record) return null + if (record.lockedUntil && now < record.lockedUntil) { + const remaining = Math.ceil((record.lockedUntil - now) / 1000) + return `登录失败次数过多,请 ${remaining} 秒后再试` + } + if (record.lockedUntil && now >= record.lockedUntil) { + _loginAttempts.delete(ip) + } + return null +} + +function recordLoginFailure(ip) { + const record = _loginAttempts.get(ip) || { count: 0, lockedUntil: null } + record.count++ + if (record.count >= MAX_LOGIN_ATTEMPTS) { + record.lockedUntil = Date.now() + LOCKOUT_DURATION + record.count = 0 + } + _loginAttempts.set(ip, record) +} + +function clearLoginAttempts(ip) { + _loginAttempts.delete(ip) +} + +// 配置缓存:避免每次请求同步读磁盘(TTL 2秒,写入时立即失效) +let _panelConfigCache = null +let _panelConfigCacheTime = 0 +const CONFIG_CACHE_TTL = 2000 // 2s + +function readPanelConfig() { + const now = Date.now() + if (_panelConfigCache && (now - _panelConfigCacheTime) < CONFIG_CACHE_TTL) { + return JSON.parse(JSON.stringify(_panelConfigCache)) + } + try { + if (fs.existsSync(PANEL_CONFIG_PATH)) { + _panelConfigCache = JSON.parse(fs.readFileSync(PANEL_CONFIG_PATH, 'utf8')) + _panelConfigCacheTime = now + return JSON.parse(JSON.stringify(_panelConfigCache)) + } + } catch {} + return {} +} + +function invalidateConfigCache() { + _panelConfigCache = null + _panelConfigCacheTime = 0 +} + +function getAccessPassword() { + return readPanelConfig().accessPassword || '' +} + +function parseCookies(req) { + const obj = {} + ;(req.headers.cookie || '').split(';').forEach(pair => { + const [k, ...v] = pair.trim().split('=') + if (k) obj[k] = decodeURIComponent(v.join('=')) + }) + return obj +} + +function isAuthenticated(req) { + const pw = getAccessPassword() + if (!pw) return true // 未设密码,放行 + const cookies = parseCookies(req) + const token = cookies.clawpanel_session + if (!token) return false + const session = _sessions.get(token) + if (!session || Date.now() > session.expires) { + _sessions.delete(token) + return false + } + return true +} + +function checkPasswordStrength(pw) { + if (!pw || pw.length < 6) return '密码至少 6 位' + if (pw.length > 64) return '密码不能超过 64 位' + if (/^\d+$/.test(pw)) return '密码不能是纯数字' + const weak = ['123456', '654321', 'password', 'admin', 'qwerty', 'abc123', '111111', '000000', 'letmein', 'welcome', 'clawpanel', 'openclaw'] + if (weak.includes(pw.toLowerCase())) return '密码太常见,请换一个更安全的密码' + return null // 通过 +} function isUnsafePath(p) { return !p || p.includes('..') || p.includes('\0') || path.isAbsolute(p) } +const MAX_BODY_SIZE = 1024 * 1024 // 1MB + function readBody(req) { return new Promise((resolve) => { let body = '' - req.on('data', chunk => body += chunk) + let size = 0 + req.on('data', chunk => { + size += chunk.length + if (size > MAX_BODY_SIZE) { req.destroy(); resolve({}); return } + body += chunk + }) req.on('end', () => { try { resolve(JSON.parse(body || '{}')) } catch { resolve({}) } @@ -201,6 +309,7 @@ function winStartGateway() { detached: true, stdio: ['ignore', out, err], shell: true, + windowsHide: true, cwd: homedir(), }) child.unref() @@ -210,7 +319,7 @@ function winStopGateway() { const { running, pid } = winCheckGateway() if (!running || !pid) throw new Error('Gateway 未运行') try { - execSync(`taskkill /F /PID ${pid} /T`, { timeout: 5000 }) + execSync(`taskkill /F /PID ${pid} /T`, { timeout: 5000, windowsHide: true }) } catch (e) { throw new Error('停止失败: ' + (e.message || e)) } @@ -220,7 +329,7 @@ function winCheckGateway() { const port = readGatewayPort() try { // 用 netstat 精确查找监听指定端口的进程 PID - const out = execSync(`netstat -ano | findstr ":${port}" | findstr "LISTENING"`, { timeout: 3000 }).toString().trim() + const out = execSync(`netstat -ano | findstr ":${port}" | findstr "LISTENING"`, { timeout: 3000, windowsHide: true }).toString().trim() if (!out) return { running: false, pid: null } // 提取 PID(最后一列) const parts = out.split('\n')[0].trim().split(/\s+/) @@ -228,7 +337,7 @@ function winCheckGateway() { if (!pid) return { running: false, pid: null } // 验证进程是否为 node/openclaw(排除其他程序碰巧占用同端口) try { - const taskOut = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { timeout: 3000 }).toString().trim() + const taskOut = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { timeout: 3000, windowsHide: true }).toString().trim() const isGateway = /node|openclaw/i.test(taskOut) return { running: isGateway, pid: isGateway ? pid : null } } catch { @@ -483,7 +592,7 @@ const handlers = { check_node() { try { - const ver = execSync('node --version 2>&1').toString().trim() + const ver = execSync('node --version 2>&1', { windowsHide: true }).toString().trim() return { installed: true, version: ver } } catch { return { installed: false, version: null } @@ -501,7 +610,7 @@ const handlers = { } catch {} } if (!current) { - try { current = execSync('openclaw --version 2>&1').toString().trim().split(/\s+/).pop() } catch {} + try { current = execSync('openclaw --version 2>&1', { windowsHide: true }).toString().trim().split(/\s+/).pop() } catch {} } return { current, latest: null, update_available: false, source: 'chinese' } }, @@ -573,7 +682,7 @@ const handlers = { const logPath = path.join(LOGS_DIR, file) if (!fs.existsSync(logPath)) return '' try { - return execSync(`tail -${lines} "${logPath}" 2>&1`).toString() + return execSync(`tail -${lines} "${logPath}" 2>&1`, { windowsHide: true }).toString() } catch { const content = fs.readFileSync(logPath, 'utf8') return content.split('\n').slice(-lines).join('\n') @@ -714,8 +823,8 @@ const handlers = { // Gateway 安装/卸载 install_gateway() { - try { execSync('openclaw --version 2>&1') } catch { throw new Error('openclaw CLI 未安装') } - return execSync('openclaw gateway install 2>&1').toString() || 'Gateway 服务已安装' + try { execSync('openclaw --version 2>&1', { windowsHide: true }) } catch { throw new Error('openclaw CLI 未安装') } + return execSync('openclaw gateway install 2>&1', { windowsHide: true }).toString() || 'Gateway 服务已安装' }, upgrade_openclaw({ source = 'chinese' } = {}) { @@ -723,7 +832,7 @@ const handlers = { const pkg = source === 'official' ? '@anthropic-ai/claw' : '@qingchencloud/openclaw-zh' const npmBin = isWindows ? 'npm.cmd' : 'npm' try { - const out = execSync(`${npmBin} install ${pkg}@latest --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000 }).toString() + const out = execSync(`${npmBin} install ${pkg}@latest --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000, windowsHide: true }).toString() return `升级完成 (${source})\n${out.slice(-200)}` } catch (e) { throw new Error('升级失败: ' + (e.stderr?.toString() || e.message).slice(-300)) @@ -931,6 +1040,22 @@ const handlers = { return null }, + // === 访问密码认证 === + auth_check() { + const pw = getAccessPassword() + return { required: !!pw, authenticated: false /* 由中间件覆写 */ } + }, + auth_login() { throw new Error('由中间件处理') }, + auth_logout() { throw new Error('由中间件处理') }, + auth_set_password({ password }) { + const cfg = readPanelConfig() + cfg.accessPassword = password || '' + fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2)) + // 清除所有 session(密码变更后强制重新登录) + _sessions.clear() + return true + }, + check_panel_update() { return { latest: null, url: 'https://github.com/qingchencloud/clawpanel/releases' } }, write_env_file({ path: p, config }) { const expanded = p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p @@ -944,37 +1069,233 @@ const handlers = { // === Vite 插件 === +// 初始化:密码检测 + 启动日志 + 定时清理 +function _initApi() { + const cfg = readPanelConfig() + if (!cfg.accessPassword && !cfg.ignoreRisk) { + cfg.accessPassword = '123456' + cfg.mustChangePassword = true + if (!fs.existsSync(OPENCLAW_DIR)) fs.mkdirSync(OPENCLAW_DIR, { recursive: true }) + fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2)) + invalidateConfigCache() + console.log('[api] ⚠️ 首次启动,默认访问密码: 123456') + console.log('[api] ⚠️ 首次登录后将强制要求修改密码') + } + const pw = getAccessPassword() + console.log('[api] API 已启动,配置目录:', OPENCLAW_DIR) + console.log('[api] 平台:', isMac ? 'macOS' : process.platform) + console.log('[api] 访问密码:', pw ? '已设置' : (cfg.ignoreRisk ? '无视风险模式(无密码)' : '未设置')) + + // 定时清理过期 session 和登录限速记录(每 10 分钟) + setInterval(() => { + const now = Date.now() + for (const [token, session] of _sessions) { + if (now > session.expires) _sessions.delete(token) + } + for (const [ip, record] of _loginAttempts) { + if (record.lockedUntil && now >= record.lockedUntil) _loginAttempts.delete(ip) + } + }, 10 * 60 * 1000) +} + +// API 中间件(dev server 和 preview server 共用) +async function _apiMiddleware(req, res, next) { + if (!req.url?.startsWith('/__api/')) return next() + + const cmd = req.url.slice(7).split('?')[0] + + // --- 认证特殊处理 --- + if (cmd === 'auth_check') { + const cfg = readPanelConfig() + const pw = cfg.accessPassword || '' + const isDefault = pw === '123456' + const resp = { + required: !!pw, + authenticated: !pw || isAuthenticated(req), + mustChangePassword: isDefault, + } + if (isDefault) resp.defaultPassword = '123456' + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(resp)) + return + } + + if (cmd === 'auth_login') { + const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || '' + const rateLimitErr = checkLoginRateLimit(clientIp) + if (rateLimitErr) { + res.statusCode = 429 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: rateLimitErr })) + return + } + const args = await readBody(req) + const cfg = readPanelConfig() + const pw = cfg.accessPassword || '' + if (!pw) { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ success: true })) + return + } + if (args.password !== pw) { + recordLoginFailure(clientIp) + res.statusCode = 401 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: '密码错误' })) + return + } + clearLoginAttempts(clientIp) + const token = crypto.randomUUID() + _sessions.set(token, { expires: Date.now() + SESSION_TTL }) + res.setHeader('Set-Cookie', `clawpanel_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}`) + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ success: true, mustChangePassword: !!cfg.mustChangePassword })) + return + } + + if (cmd === 'auth_change_password') { + const args = await readBody(req) + const cfg = readPanelConfig() + const pw = cfg.accessPassword || '' + if (pw && !isAuthenticated(req)) { + res.statusCode = 401 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: '未登录' })) + return + } + if (pw && args.oldPassword !== pw) { + res.statusCode = 400 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: '当前密码错误' })) + return + } + const weakErr = checkPasswordStrength(args.newPassword) + if (weakErr) { + res.statusCode = 400 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: weakErr })) + return + } + if (args.newPassword === pw) { + res.statusCode = 400 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: '新密码不能与旧密码相同' })) + return + } + cfg.accessPassword = args.newPassword + delete cfg.mustChangePassword + delete cfg.ignoreRisk + fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2)) + invalidateConfigCache() + _sessions.clear() + const token = crypto.randomUUID() + _sessions.set(token, { expires: Date.now() + SESSION_TTL }) + res.setHeader('Set-Cookie', `clawpanel_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}`) + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ success: true })) + return + } + + if (cmd === 'auth_status') { + const cfg = readPanelConfig() + if (cfg.accessPassword && !isAuthenticated(req)) { + res.statusCode = 401 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: '未登录' })) + return + } + const isDefault = cfg.accessPassword === '123456' + const result = { + hasPassword: !!cfg.accessPassword, + mustChangePassword: isDefault, + ignoreRisk: !!cfg.ignoreRisk, + } + if (isDefault) { + result.defaultPassword = '123456' + } + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(result)) + return + } + + if (cmd === 'auth_ignore_risk') { + if (!isAuthenticated(req)) { + res.statusCode = 401 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: '未登录' })) + return + } + const args = await readBody(req) + const cfg = readPanelConfig() + if (args.enable) { + delete cfg.accessPassword + delete cfg.mustChangePassword + cfg.ignoreRisk = true + _sessions.clear() + } else { + delete cfg.ignoreRisk + } + fs.writeFileSync(PANEL_CONFIG_PATH, JSON.stringify(cfg, null, 2)) + invalidateConfigCache() + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ success: true })) + return + } + + if (cmd === 'auth_logout') { + const cookies = parseCookies(req) + if (cookies.clawpanel_session) _sessions.delete(cookies.clawpanel_session) + res.setHeader('Set-Cookie', 'clawpanel_session=; Path=/; HttpOnly; Max-Age=0') + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ success: true })) + return + } + + // --- 认证中间件:非豁免接口必须校验 --- + if (!isAuthenticated(req)) { + res.statusCode = 401 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: '未登录', code: 'AUTH_REQUIRED' })) + return + } + + const handler = handlers[cmd] + + if (!handler) { + res.statusCode = 404 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: `未实现的命令: ${cmd}` })) + return + } + + try { + const args = await readBody(req) + const result = await handler(args) + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(result)) + } catch (e) { + res.statusCode = 500 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: e.message || String(e) })) + } +} + export function devApiPlugin() { + let _inited = false + function ensureInit() { + if (_inited) return + _inited = true + _initApi() + } return { name: 'clawpanel-dev-api', configureServer(server) { - console.log('[dev-api] 开发 API 已启动,配置目录:', OPENCLAW_DIR) - console.log('[dev-api] 平台:', isMac ? 'macOS' : process.platform) - - server.middlewares.use(async (req, res, next) => { - if (!req.url?.startsWith('/__api/')) return next() - - const cmd = req.url.slice(7).split('?')[0] - const handler = handlers[cmd] - - if (!handler) { - res.statusCode = 404 - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ error: `未实现的命令: ${cmd}` })) - return - } - - try { - const args = await readBody(req) - const result = await handler(args) - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(result)) - } catch (e) { - res.statusCode = 500 - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ error: e.message || String(e) })) - } - }) - } + ensureInit() + server.middlewares.use(_apiMiddleware) + }, + configurePreviewServer(server) { + ensureInit() + server.middlewares.use(_apiMiddleware) + }, } } diff --git a/scripts/linux-deploy.sh b/scripts/linux-deploy.sh index 451bcfd..2693a5c 100644 --- a/scripts/linux-deploy.sh +++ b/scripts/linux-deploy.sh @@ -140,6 +140,10 @@ install_clawpanel() { cd "$INSTALL_DIR" npm install fi + # 生产构建(生成优化后的静态文件) + echo "📦 构建生产版本..." + cd "$INSTALL_DIR" + npx vite build echo "✅ ClawPanel 安装完成: $INSTALL_DIR" } @@ -164,7 +168,7 @@ After=network.target Type=simple User=$(whoami) WorkingDirectory=$INSTALL_DIR -ExecStart=$(which npx) vite --port $PANEL_PORT --host 0.0.0.0 +ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0 Restart=on-failure RestartSec=5 Environment=NODE_ENV=production @@ -185,7 +189,7 @@ After=network.target [Service] Type=simple WorkingDirectory=$INSTALL_DIR -ExecStart=$(which npx) vite --port $PANEL_PORT --host 0.0.0.0 +ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0 Restart=on-failure RestartSec=5 Environment=NODE_ENV=production @@ -210,6 +214,32 @@ get_local_ip() { echo "localhost" } +# 生成默认访问密码 +setup_default_password() { + local config_dir="$HOME/.openclaw" + local config_file="$config_dir/clawpanel.json" + mkdir -p "$config_dir" + + # 已存在配置且有密码则跳过 + if [ -f "$config_file" ]; then + local existing_pw=$(grep -o '"accessPassword"[[:space:]]*:[[:space:]]*"[^"]*"' "$config_file" | head -1) + if [ -n "$existing_pw" ]; then + echo "ℹ️ 已有访问密码,跳过生成" + DEFAULT_PASSWORD="" + return + fi + fi + + DEFAULT_PASSWORD="123456" + cat > "$config_file" < Result { Ok(Value::Object(result)) } +// === 面板配置 (clawpanel.json) === + +#[tauri::command] +pub fn read_panel_config() -> Result { + let path = super::openclaw_dir().join("clawpanel.json"); + if !path.exists() { + return Ok(serde_json::json!({})); + } + let content = fs::read_to_string(&path).map_err(|e| format!("读取失败: {e}"))?; + serde_json::from_str(&content).map_err(|e| format!("解析失败: {e}")) +} + +#[tauri::command] +pub fn write_panel_config(config: Value) -> Result<(), String> { + let dir = super::openclaw_dir(); + if !dir.exists() { + fs::create_dir_all(&dir).map_err(|e| format!("创建目录失败: {e}"))?; + } + let path = dir.join("clawpanel.json"); + let json = serde_json::to_string_pretty(&config).map_err(|e| format!("序列化失败: {e}"))?; + fs::write(&path, json).map_err(|e| format!("写入失败: {e}")) +} + #[tauri::command] pub fn get_npm_registry() -> Result { Ok(get_configured_registry()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 20ed9f4..634196e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,6 +39,8 @@ pub fn run() { config::uninstall_gateway, config::patch_model_vision, config::check_panel_update, + config::read_panel_config, + config::write_panel_config, config::get_npm_registry, config::set_npm_registry, // 设备密钥 + Gateway 握手 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 65c679c..4cc141b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "ClawPanel", - "version": "0.5.6", + "version": "0.5.7", "identifier": "ai.openclaw.clawpanel", "build": { "frontendDist": "../dist", diff --git a/src/components/sidebar.js b/src/components/sidebar.js index 0bd7c9e..243a624 100644 --- a/src/components/sidebar.js +++ b/src/components/sidebar.js @@ -4,6 +4,7 @@ import { navigate, getCurrentRoute } from '../router.js' import { toggleTheme, getTheme } from '../lib/theme.js' import { isOpenclawReady } from '../lib/app-state.js' +import { version as APP_VERSION } from '../../package.json' const NAV_ITEMS_FULL = [ { @@ -22,6 +23,7 @@ const NAV_ITEMS_FULL = [ { route: '/models', label: '模型配置', icon: 'models' }, { route: '/agents', label: 'Agent 管理', icon: 'agents' }, { route: '/gateway', label: 'Gateway', icon: 'gateway' }, + { route: '/security', label: '安全设置', icon: 'security' }, ] }, { @@ -81,6 +83,7 @@ const ICONS = { extensions: '', about: '', assistant: '', + security: '', debug: '', } @@ -128,6 +131,10 @@ export function renderSidebar(el) { ${isDark ? sunIcon : moonIcon} ${isDark ? '日间模式' : '夜间模式'} + ` diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 9352d96..da85543 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -113,6 +113,10 @@ async function webInvoke(cmd, args) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(args), }) + if (resp.status === 401 && window.__clawpanel_show_login) { + window.__clawpanel_show_login() + throw new Error('需要登录') + } if (!resp.ok) { const data = await resp.json().catch(() => ({ error: `HTTP ${resp.status}` })) throw new Error(data.error || `HTTP ${resp.status}`) @@ -298,6 +302,10 @@ export const api = { deleteMemoryFile: (path, agentId) => { invalidate('list_memory_files'); return invoke('delete_memory_file', { path, agentId: agentId || null }) }, exportMemoryZip: (category, agentId) => invoke('export_memory_zip', { category, agentId: agentId || null }), + // 面板配置 (clawpanel.json) + readPanelConfig: () => invoke('read_panel_config'), + writePanelConfig: (config) => invoke('write_panel_config', { config }), + // 安装/部署 checkInstallation: () => cachedInvoke('check_installation', {}, 60000), initOpenclawConfig: () => { invalidate('check_installation'); return invoke('init_openclaw_config') }, diff --git a/src/main.js b/src/main.js index ac49ff1..e651705 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,7 @@ import { initTheme } from './lib/theme.js' import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart } from './lib/app-state.js' import { wsClient } from './lib/ws-client.js' import { api } from './lib/tauri-api.js' +import { version as APP_VERSION } from '../package.json' // 样式 import './style/variables.css' @@ -22,6 +23,130 @@ import './style/assistant.css' // 初始化主题 initTheme() +// === 访问密码保护(Web + 桌面端通用) === +const isTauri = !!window.__TAURI_INTERNALS__ + +async function checkAuth() { + if (isTauri) { + // 桌面端:读 clawpanel.json,检查密码配置 + try { + const { api } = await import('./lib/tauri-api.js') + const cfg = await api.readPanelConfig() + if (!cfg.accessPassword) return { ok: true } + if (sessionStorage.getItem('clawpanel_authed') === '1') return { ok: true } + // 默认密码:直接传给登录页,避免二次读取 + const defaultPw = (cfg.mustChangePassword && cfg.accessPassword) ? cfg.accessPassword : null + return { ok: false, defaultPw } + } catch { return { ok: true } } + } + // Web 模式 + try { + const resp = await fetch('/__api/auth_check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) + const data = await resp.json() + if (!data.required || data.authenticated) return { ok: true } + return { ok: false, defaultPw: data.defaultPassword || null } + } catch { return { ok: true } } +} + +const _logoSvg = `` + +function _hideSplash() { + const splash = document.getElementById('splash') + if (splash) { splash.classList.add('hide'); setTimeout(() => splash.remove(), 500) } +} + +function showLoginOverlay(defaultPw) { + const hasDefault = !!defaultPw + const overlay = document.createElement('div') + overlay.id = 'login-overlay' + overlay.innerHTML = ` + + ` + document.body.appendChild(overlay) + _hideSplash() + + return new Promise((resolve) => { + overlay.querySelector('#login-form').addEventListener('submit', async (e) => { + e.preventDefault() + const pw = overlay.querySelector('#login-pw').value + const btn = overlay.querySelector('.login-btn') + const errEl = overlay.querySelector('#login-error') + btn.disabled = true + btn.textContent = '登录中...' + errEl.textContent = '' + try { + if (isTauri) { + // 桌面端:本地比对密码 + const { api } = await import('./lib/tauri-api.js') + const cfg = await api.readPanelConfig() + if (pw !== cfg.accessPassword) { + errEl.textContent = '密码错误' + btn.disabled = false + btn.textContent = '登 录' + return + } + sessionStorage.setItem('clawpanel_authed', '1') + overlay.classList.add('hide') + setTimeout(() => overlay.remove(), 400) + if (cfg.accessPassword === '123456') { + sessionStorage.setItem('clawpanel_must_change_pw', '1') + } + resolve() + } else { + // Web 模式:调后端 + const resp = await fetch('/__api/auth_login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: pw }), + }) + const data = await resp.json() + if (!resp.ok) { + errEl.textContent = data.error || '登录失败' + btn.disabled = false + btn.textContent = '登 录' + return + } + overlay.classList.add('hide') + setTimeout(() => overlay.remove(), 400) + if (data.mustChangePassword || data.defaultPassword === '123456') { + sessionStorage.setItem('clawpanel_must_change_pw', '1') + } + resolve() + } + } catch (err) { + errEl.textContent = '网络错误: ' + (err.message || err) + btn.disabled = false + btn.textContent = '登 录' + } + }) + }) +} + +// 全局 401 拦截:API 返回 401 时弹出登录 +window.__clawpanel_show_login = async function() { + if (document.getElementById('login-overlay')) return + await showLoginOverlay() + location.reload() +} + const sidebar = document.getElementById('sidebar') const content = document.getElementById('content') @@ -37,6 +162,7 @@ async function boot() { registerRoute('/gateway', () => import('./pages/gateway.js')) registerRoute('/memory', () => import('./pages/memory.js')) registerRoute('/extensions', () => import('./pages/extensions.js')) + registerRoute('/security', () => import('./pages/security.js')) registerRoute('/about', () => import('./pages/about.js')) registerRoute('/assistant', () => import('./pages/assistant.js')) registerRoute('/setup', () => import('./pages/setup.js')) @@ -51,6 +177,19 @@ async function boot() { setTimeout(() => splash.remove(), 500) } + // 默认密码提醒横幅 + if (sessionStorage.getItem('clawpanel_must_change_pw') === '1') { + const banner = document.createElement('div') + banner.id = 'pw-change-banner' + banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:999;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;padding:10px 20px;display:flex;align-items:center;justify-content:center;gap:12px;font-size:13px;font-weight:500;box-shadow:0 2px 8px rgba(0,0,0,0.15)' + banner.innerHTML = ` + ⚠️ 当前使用的是系统生成的默认密码,为了安全请尽快修改 + 前往安全设置 + + ` + document.body.prepend(banner) + } + // 后台检测状态,检测完再决定是否跳转 setup detectOpenclawStatus().then(() => { // 重新渲染侧边栏(检测完成后 isOpenclawReady 状态已更新) @@ -240,4 +379,9 @@ function showGuardianRecovery() { }) } -boot() +// 启动:先检查认证,再加载应用 +;(async () => { + const auth = await checkAuth() + if (!auth.ok) await showLoginOverlay(auth.defaultPw) + boot() +})() diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 35e2522..95aea52 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -42,6 +42,13 @@ const MODES = { } const DEFAULT_MODE = 'execute' +// ── API 类型 ── +const API_TYPES = [ + { value: 'openai', label: 'OpenAI 兼容 (最常用)' }, + { value: 'anthropic', label: 'Anthropic 原生' }, + { value: 'google-gemini', label: 'Google Gemini' }, +] + // ── 系统提示词 ── const DEFAULT_NAME = '晴辰助手' const DEFAULT_PERSONALITY = '专业、友善、简洁。善于分析问题,给出可操作的解决方案。' @@ -935,6 +942,7 @@ function loadConfig() { if (!_config.assistantPersonality) _config.assistantPersonality = DEFAULT_PERSONALITY if (!_config.tools) _config.tools = { terminal: false, fileOps: false } if (!_config.mode) _config.mode = DEFAULT_MODE + if (!_config.apiType) _config.apiType = 'openai' return _config } @@ -1013,19 +1021,39 @@ function autoTitle(session) { // ── AI API 调用(自动兼容 Chat Completions + Responses API)── -function cleanBaseUrl(raw) { +function cleanBaseUrl(raw, apiType) { let base = raw.replace(/\/+$/, '') base = base.replace(/\/chat\/completions\/?$/, '') base = base.replace(/\/completions\/?$/, '') base = base.replace(/\/responses\/?$/, '') + base = base.replace(/\/messages\/?$/, '') + const type = apiType || _config.apiType || 'openai' + if (type === 'anthropic') { + // Anthropic: https://api.anthropic.com/v1 + if (!base.endsWith('/v1')) base += '/v1' + return base + } + if (type === 'google-gemini') { + // Gemini: https://generativelanguage.googleapis.com/v1beta + return base + } if (!base.endsWith('/v1')) base = base.replace(/\/v1\/.*$/, '/v1') return base } -function authHeaders() { +function authHeaders(apiType, apiKey) { + const type = apiType || _config.apiType || 'openai' + const key = apiKey || _config.apiKey || '' + if (type === 'anthropic') { + return { + 'Content-Type': 'application/json', + 'x-api-key': key, + 'anthropic-version': '2023-06-01', + } + } return { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${_config.apiKey}`, + 'Authorization': `Bearer ${key}`, } } @@ -1051,7 +1079,19 @@ async function callAI(messages, onChunk) { }, TIMEOUT_TOTAL) try { - // 先尝试 Chat Completions API + const apiType = _config.apiType || 'openai' + + if (apiType === 'anthropic') { + await callAnthropicMessages(base, allMessages, onChunk) + return + } + + if (apiType === 'google-gemini') { + await callGeminiGenerate(base, allMessages, onChunk) + return + } + + // OpenAI: 先尝试 Chat Completions API try { await callChatCompletions(base, allMessages, onChunk) return @@ -1064,7 +1104,6 @@ async function callAI(messages, onChunk) { const msg = err.message || '' if (msg.includes('legacy protocol') || msg.includes('/v1/responses') || msg.includes('not supported')) { console.log('[assistant] Chat Completions 不支持此模型,自动切换到 Responses API') - // 重新创建 abort controller(上一个可能已被消费) _abortController = new AbortController() await callResponsesAPI(base, allMessages, onChunk) return @@ -1211,6 +1250,133 @@ async function callResponsesAPI(base, messages, onChunk) { }) } +// ── Anthropic Messages API(/v1/messages)── +async function callAnthropicMessages(base, messages, onChunk) { + const url = base + '/messages' + const systemMsg = messages.find(m => m.role === 'system')?.content || '' + const chatMessages = messages.filter(m => m.role !== 'system') + + const body = { + model: _config.model, + max_tokens: 8192, + stream: true, + temperature: _config.temperature || 0.7, + } + if (systemMsg) body.system = systemMsg + body.messages = chatMessages + + const reqTime = Date.now() + _lastDebugInfo = { + url, method: 'POST', + requestBody: { ...body, messages: body.messages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content.slice(0, 200) + (m.content.length > 200 ? '...' : '') : '[multimodal]' })) }, + requestTime: new Date(reqTime).toLocaleString('zh-CN'), + } + + const resp = await fetchWithRetry(url, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify(body), + signal: _abortController.signal, + }) + + _lastDebugInfo.status = resp.status + _lastDebugInfo.contentType = resp.headers.get('content-type') || '' + _lastDebugInfo.responseTime = new Date().toLocaleString('zh-CN') + _lastDebugInfo.latency = Date.now() - reqTime + 'ms' + + if (!resp.ok) { + const errText = await resp.text().catch(() => '') + _lastDebugInfo.errorBody = errText.slice(0, 500) + let errMsg = `API 错误 ${resp.status}` + try { + const errJson = JSON.parse(errText) + errMsg = errJson.error?.message || errJson.message || errMsg + } catch { + if (errText) errMsg += `: ${errText.slice(0, 200)}` + } + throw new Error(errMsg) + } + + _lastDebugInfo.streaming = true + let chunkCount = 0, contentChunks = 0, thinkingChunks = 0 + let thinkingBuf = '' + + await readSSEStream(resp, (json) => { + chunkCount++ + if (json.type === 'content_block_delta') { + const delta = json.delta + if (delta?.type === 'text_delta' && delta.text) { + contentChunks++ + onChunk(delta.text) + } else if (delta?.type === 'thinking_delta' && delta.thinking) { + thinkingChunks++ + thinkingBuf += delta.thinking + } + } + }) + + _lastDebugInfo.chunks = { total: chunkCount, content: contentChunks, thinking: thinkingChunks } + + if (contentChunks === 0 && thinkingBuf) { + console.warn('[assistant] Anthropic: 无 text 块,使用 thinking 作为回复') + onChunk(thinkingBuf) + _lastDebugInfo.fallbackToThinking = true + } +} + +// ── Google Gemini API ── +async function callGeminiGenerate(base, messages, onChunk) { + const systemMsg = messages.find(m => m.role === 'system')?.content || '' + const chatMessages = messages.filter(m => m.role !== 'system') + + // Gemini 格式转换 + const contents = chatMessages.map(m => ({ + role: m.role === 'assistant' ? 'model' : 'user', + parts: [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }], + })) + + const body = { + contents, + generationConfig: { temperature: _config.temperature || 0.7 }, + } + if (systemMsg) { + body.systemInstruction = { parts: [{ text: systemMsg }] } + } + + const url = `${base}/models/${_config.model}:streamGenerateContent?alt=sse&key=${_config.apiKey}` + + const reqTime = Date.now() + _lastDebugInfo = { url: url.replace(_config.apiKey, '***'), method: 'POST', requestTime: new Date(reqTime).toLocaleString('zh-CN') } + + const resp = await fetchWithRetry(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: _abortController.signal, + }) + + _lastDebugInfo.status = resp.status + _lastDebugInfo.latency = Date.now() - reqTime + 'ms' + + if (!resp.ok) { + const errText = await resp.text().catch(() => '') + let errMsg = `API 错误 ${resp.status}` + try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {} + throw new Error(errMsg) + } + + _lastDebugInfo.streaming = true + let chunkCount = 0 + + await readSSEStream(resp, (json) => { + chunkCount++ + const text = json.candidates?.[0]?.content?.parts?.[0]?.text + if (text) onChunk(text) + }) + + _lastDebugInfo.chunks = { total: chunkCount } +} + // ── 通用 SSE 流读取 ── async function readSSEStream(resp, onEvent) { const reader = resp.body.getReader() @@ -1380,21 +1546,57 @@ async function confirmToolCall(tc, critical = false) { return result } +// 将 OpenAI 格式工具定义转为 Anthropic 格式 +function convertToolsForAnthropic(tools) { + return tools.map(t => ({ + name: t.function.name, + description: t.function.description || '', + input_schema: t.function.parameters || { type: 'object', properties: {} }, + })) +} + +// 将 OpenAI 格式工具定义转为 Gemini 格式 +function convertToolsForGemini(tools) { + return [{ functionDeclarations: tools.map(t => ({ + name: t.function.name, + description: t.function.description || '', + parameters: t.function.parameters || { type: 'object', properties: {} }, + }))}] +} + +// 工具调用执行(共用逻辑) +async function executeToolWithSafety(toolName, args, tcForConfirm) { + let result = '', approved = true + const mode = MODES[currentMode()] + const isCritical = toolName === 'run_command' && isCriticalCommand(args.command) + if (isCritical) { + approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } }, true) + if (!approved) result = '用户拒绝了此危险操作' + } else if (mode.confirmDanger && DANGEROUS_TOOLS.has(toolName)) { + approved = await confirmToolCall(tcForConfirm || { function: { name: toolName, arguments: JSON.stringify(args) } }) + if (!approved) result = '用户拒绝了此操作' + } + if (approved) { + try { result = await executeTool(toolName, args) } + catch (err) { result = `执行失败: ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}` } + } + return { result, approved } +} + // 带工具调用的 AI 请求(非流式,用于 tool_calls 检测循环) async function callAIWithTools(messages, onStatus, onToolProgress) { if (!_config.baseUrl || !_config.apiKey || !_config.model) { throw new Error('请先配置 AI 模型(点击右上角设置按钮)') } + const apiType = _config.apiType || 'openai' const base = cleanBaseUrl(_config.baseUrl) const tools = getEnabledTools() let currentMessages = [{ role: 'system', content: buildSystemPrompt() }, ...messages] - const toolHistory = [] // 记录工具调用历史 + const toolHistory = [] const MAX_AUTO_ROUNDS = 8 - // 工具调用循环(无硬性上限,超过阈值后询问用户) for (let round = 0; ; round++) { - // 超过自动轮次后,询问用户是否继续 if (round >= MAX_AUTO_ROUNDS) { const answer = await showAskUserCard({ question: `AI 已连续调用工具 ${round} 轮,可能陷入循环。你希望怎么做?`, @@ -1411,6 +1613,120 @@ async function callAIWithTools(messages, onStatus, onToolProgress) { _abortController = new AbortController() onStatus(round === 0 ? 'AI 思考中...' : `AI 处理工具结果 (第${round + 1}轮)...`) + // ── Anthropic 工具调用 ── + if (apiType === 'anthropic') { + const systemMsg = currentMessages.find(m => m.role === 'system')?.content || '' + const chatMsgs = currentMessages.filter(m => m.role !== 'system') + const body = { + model: _config.model, + max_tokens: 8192, + temperature: _config.temperature || 0.7, + messages: chatMsgs, + } + if (systemMsg) body.system = systemMsg + if (tools.length > 0) body.tools = convertToolsForAnthropic(tools) + + const resp = await fetchWithRetry(base + '/messages', { + method: 'POST', headers: authHeaders(), body: JSON.stringify(body), + signal: _abortController.signal, + }) + if (!resp.ok) { + const errText = await resp.text().catch(() => '') + let errMsg = `API 错误 ${resp.status}` + try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {} + throw new Error(errMsg) + } + + const data = await resp.json() + const contentBlocks = data.content || [] + const toolUses = contentBlocks.filter(b => b.type === 'tool_use') + const textContent = contentBlocks.filter(b => b.type === 'text').map(b => b.text).join('') + + if (toolUses.length > 0) { + // 将 assistant 消息加入上下文 + currentMessages.push({ role: 'assistant', content: contentBlocks }) + + const toolResults = [] + for (const tu of toolUses) { + const args = tu.input || {} + toolHistory.push({ name: tu.name, args, result: null, approved: true, pending: true }) + onToolProgress(toolHistory) + + const { result, approved } = await executeToolWithSafety(tu.name, args) + const last = toolHistory[toolHistory.length - 1] + last.result = result; last.approved = approved; last.pending = false + onToolProgress(toolHistory) + + toolResults.push({ + type: 'tool_result', + tool_use_id: tu.id, + content: typeof result === 'string' ? result : JSON.stringify(result), + }) + } + currentMessages.push({ role: 'user', content: toolResults }) + continue + } + + return { content: textContent, toolHistory } + } + + // ── Gemini 工具调用 ── + if (apiType === 'google-gemini') { + const systemMsg = currentMessages.find(m => m.role === 'system')?.content || '' + const chatMsgs = currentMessages.filter(m => m.role !== 'system') + const contents = chatMsgs.map(m => ({ + role: m.role === 'assistant' ? 'model' : m.role === 'tool' ? 'function' : 'user', + parts: m.functionResponse + ? [{ functionResponse: m.functionResponse }] + : [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }], + })) + const body = { contents, generationConfig: { temperature: _config.temperature || 0.7 } } + if (systemMsg) body.systemInstruction = { parts: [{ text: systemMsg }] } + if (tools.length > 0) body.tools = convertToolsForGemini(tools) + + const url = `${base}/models/${_config.model}:generateContent?key=${_config.apiKey}` + const resp = await fetchWithRetry(url, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), signal: _abortController.signal, + }) + if (!resp.ok) { + const errText = await resp.text().catch(() => '') + let errMsg = `API 错误 ${resp.status}` + try { errMsg = JSON.parse(errText).error?.message || errMsg } catch {} + throw new Error(errMsg) + } + + const data = await resp.json() + const parts = data.candidates?.[0]?.content?.parts || [] + const funcCalls = parts.filter(p => p.functionCall) + const textParts = parts.filter(p => p.text).map(p => p.text).join('') + + if (funcCalls.length > 0) { + currentMessages.push({ role: 'assistant', content: textParts, _geminiParts: parts }) + + for (const fc of funcCalls) { + const args = fc.functionCall.args || {} + toolHistory.push({ name: fc.functionCall.name, args, result: null, approved: true, pending: true }) + onToolProgress(toolHistory) + + const { result, approved } = await executeToolWithSafety(fc.functionCall.name, args) + const last = toolHistory[toolHistory.length - 1] + last.result = result; last.approved = approved; last.pending = false + onToolProgress(toolHistory) + + currentMessages.push({ + role: 'tool', + content: typeof result === 'string' ? result : JSON.stringify(result), + functionResponse: { name: fc.functionCall.name, response: { result: typeof result === 'string' ? result : JSON.stringify(result) } }, + }) + } + continue + } + + return { content: textParts, toolHistory } + } + + // ── OpenAI 工具调用 ── const body = { model: _config.model, messages: currentMessages, @@ -1438,9 +1754,7 @@ async function callAIWithTools(messages, onStatus, onToolProgress) { if (!assistantMsg) throw new Error('AI 未返回有效响应') - // 检查是否有 tool_calls if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) { - // 将 assistant 消息(含 tool_calls)加入上下文 currentMessages.push(assistantMsg) for (const tc of assistantMsg.tool_calls) { @@ -1448,40 +1762,14 @@ async function callAIWithTools(messages, onStatus, onToolProgress) { try { args = JSON.parse(tc.function.arguments) } catch { args = {} } const toolName = tc.function.name - // 先显示"执行中"状态的工具块 toolHistory.push({ name: toolName, args, result: null, approved: true, pending: true }) onToolProgress(toolHistory) - let result = '' - let approved = true - - // 安全围栏:极端危险命令任何模式都必须确认 - const mode = MODES[currentMode()] - const isCritical = toolName === 'run_command' && isCriticalCommand(args.command) - if (isCritical) { - approved = await confirmToolCall(tc, true) - if (!approved) result = '用户拒绝了此危险操作' - } else if (mode.confirmDanger && DANGEROUS_TOOLS.has(toolName)) { - approved = await confirmToolCall(tc) - if (!approved) result = '用户拒绝了此操作' - } - - if (approved) { - try { - result = await executeTool(toolName, args) - } catch (err) { - result = `执行失败: ${typeof err === 'string' ? err : err.message || JSON.stringify(err)}` - } - } - - // 更新工具调用历史(完成状态) + const { result, approved } = await executeToolWithSafety(toolName, args, tc) const last = toolHistory[toolHistory.length - 1] - last.result = result - last.approved = approved - last.pending = false + last.result = result; last.approved = approved; last.pending = false onToolProgress(toolHistory) - // 添加 tool 结果消息 currentMessages.push({ role: 'tool', tool_call_id: tc.id, @@ -1489,10 +1777,9 @@ async function callAIWithTools(messages, onStatus, onToolProgress) { }) } - continue // 继续循环,让 AI 处理工具结果 + continue } - // 没有 tool_calls,返回最终文本 const content = assistantMsg.content || assistantMsg.reasoning_content || '' return { content, toolHistory } } @@ -1681,6 +1968,12 @@ function showSettings() { +
+ + +
@@ -1706,7 +1999,11 @@ function showSettings() {
-
自动兼容 Chat Completions 和 Responses API
+
${{ + openai: '自动兼容 Chat Completions 和 Responses API', + anthropic: '使用 Anthropic Messages API(/v1/messages)', + 'google-gemini': '使用 Gemini generateContent API', + }[c.apiType || 'openai']}
工具开关优先级高于模式设置。关闭的工具在任何模式下都不可用。
@@ -1752,6 +2049,21 @@ function showSettings() { }) }) + // API 类型切换时更新提示文本和 placeholder + const apiTypeSelect = overlay.querySelector('#ast-apitype') + const apiHintEl = overlay.querySelector('#ast-api-hint') + const baseUrlInput = overlay.querySelector('#ast-baseurl') + const apiKeyInput = overlay.querySelector('#ast-apikey') + apiTypeSelect.addEventListener('change', () => { + const v = apiTypeSelect.value + const hints = { openai: '自动兼容 Chat Completions 和 Responses API', anthropic: '使用 Anthropic Messages API(/v1/messages)', 'google-gemini': '使用 Gemini generateContent API' } + const placeholders = { openai: 'https://api.openai.com/v1', anthropic: 'https://api.anthropic.com', 'google-gemini': 'https://generativelanguage.googleapis.com/v1beta' } + const keyPlaceholders = { openai: 'sk-...', anthropic: 'sk-ant-...', 'google-gemini': 'AIza...' } + apiHintEl.textContent = hints[v] || hints.openai + baseUrlInput.placeholder = placeholders[v] || placeholders.openai + apiKeyInput.placeholder = keyPlaceholders[v] || keyPlaceholders.openai + }) + const resultEl = overlay.querySelector('#ast-test-result') const modelInput = overlay.querySelector('#ast-model') const dropdown = overlay.querySelector('#ast-model-dropdown') @@ -1762,6 +2074,7 @@ function showSettings() { const baseUrl = overlay.querySelector('#ast-baseurl').value.trim() const apiKey = overlay.querySelector('#ast-apikey').value.trim() const model = overlay.querySelector('#ast-model').value.trim() + const selApiType = overlay.querySelector('#ast-apitype').value || 'openai' if (!baseUrl || !apiKey) { resultEl.innerHTML = '请先填写 Base URL 和 API Key' return @@ -1773,93 +2086,78 @@ function showSettings() { btn.disabled = true btn.textContent = '测试中...' resultEl.innerHTML = '正在发送测试消息...' - const base = cleanBaseUrl(baseUrl) - const hdrs = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey } + const base = cleanBaseUrl(baseUrl, selApiType) + const hdrs = authHeaders(selApiType, apiKey) const t0 = Date.now() - const reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 } - const reqUrl = base + '/chat/completions' - let respStatus = 0, respBody = '', reply = '', usedApi = 'Chat Completions', fallback = false + let respStatus = 0, respBody = '', reply = '', usedApi = '', reqUrl = '', reqBody = {} try { - const resp = await fetch(reqUrl, { - method: 'POST', headers: hdrs, - body: JSON.stringify(reqBody), - signal: AbortSignal.timeout(30000), - }) - respStatus = resp.status - respBody = await resp.text() - - if (!resp.ok) { - // 检查是否需要切到 Responses API - if (respBody.includes('legacy protocol') || respBody.includes('/v1/responses') || respBody.includes('not supported')) { - fallback = true - } - } - - if (!fallback) { - // 尝试从各种可能的格式中提取回复 + if (selApiType === 'anthropic') { + usedApi = 'Anthropic Messages' + reqUrl = base + '/messages' + reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 } + const resp = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) }) + respStatus = resp.status; respBody = await resp.text() try { const data = JSON.parse(respBody) - const msg = data.choices?.[0]?.message - reply = msg?.content - || msg?.reasoning_content - || data.choices?.[0]?.text - || data.output?.text - || data.result?.output?.text - || data.data?.choices?.[0]?.message?.content - || '' - // 如果 content 为空但有 reasoning_content,标记为推理内容 - if (!msg?.content && msg?.reasoning_content) { - reply = '[推理内容] ' + reply - } + reply = data.content?.filter(b => b.type === 'text').map(b => b.text).join('') || '' } catch {} + } else if (selApiType === 'google-gemini') { + usedApi = 'Gemini' + reqUrl = `${base}/models/${model}:generateContent?key=***` + reqBody = { contents: [{ role: 'user', parts: [{ text: '你好,请用一句话回复' }] }] } + const realUrl = `${base}/models/${model}:generateContent?key=${apiKey}` + const resp = await fetch(realUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) }) + respStatus = resp.status; respBody = await resp.text() + try { + const data = JSON.parse(respBody) + reply = data.candidates?.[0]?.content?.parts?.[0]?.text || '' + } catch {} + } else { + // OpenAI: Chat Completions + Responses fallback + usedApi = 'Chat Completions' + reqUrl = base + '/chat/completions' + reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 } + const resp = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) }) + respStatus = resp.status; respBody = await resp.text() + + let fallback = false + if (!resp.ok && (respBody.includes('legacy protocol') || respBody.includes('/v1/responses') || respBody.includes('not supported'))) { + fallback = true + } + + if (!fallback) { + try { + const data = JSON.parse(respBody) + const msg = data.choices?.[0]?.message + reply = msg?.content || msg?.reasoning_content || data.choices?.[0]?.text || data.output?.text || '' + if (!msg?.content && msg?.reasoning_content) reply = '[推理内容] ' + reply + } catch {} + } + + if (fallback) { + usedApi = 'Responses' + reqUrl = base + '/responses' + reqBody = { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 } + try { + const resp2 = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) }) + respStatus = resp2.status; respBody = await resp2.text() + try { const d = JSON.parse(respBody); reply = d.output_text || d.output?.[0]?.content?.[0]?.text || '' } catch {} + } catch (err2) { + resultEl.innerHTML = buildTestResult({ success: false, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus: 0, respBody: '', error: err2.message }) + btn.disabled = false; btn.textContent = '测试'; return + } + } } } catch (err) { - const elapsed = Date.now() - t0 - resultEl.innerHTML = buildTestResult({ - success: false, elapsed, usedApi, - reqUrl, reqBody, respStatus: 0, respBody: '', error: err.message, - }) - btn.disabled = false; btn.textContent = '测试对话'; return + resultEl.innerHTML = buildTestResult({ success: false, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus: 0, respBody: '', error: err.message }) + btn.disabled = false; btn.textContent = '测试'; return } - // Responses API fallback - if (fallback) { - usedApi = 'Responses' - const reqUrl2 = base + '/responses' - const reqBody2 = { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 } - try { - const resp2 = await fetch(reqUrl2, { - method: 'POST', headers: hdrs, - body: JSON.stringify(reqBody2), - signal: AbortSignal.timeout(30000), - }) - respStatus = resp2.status - respBody = await resp2.text() - try { - const data2 = JSON.parse(respBody) - reply = data2.output_text || data2.output?.[0]?.content?.[0]?.text || '' - } catch {} - } catch (err) { - const elapsed = Date.now() - t0 - resultEl.innerHTML = buildTestResult({ - success: false, elapsed, usedApi, - reqUrl: reqUrl2, reqBody: reqBody2, respStatus: 0, respBody: '', error: err.message, - }) - btn.disabled = false; btn.textContent = '测试对话'; return - } - } - - const elapsed = Date.now() - t0 - resultEl.innerHTML = buildTestResult({ - success: !!reply, elapsed, usedApi, - reqUrl: fallback ? base + '/responses' : reqUrl, - reqBody: fallback ? { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 } : reqBody, - respStatus, respBody, reply, - }) + resultEl.innerHTML = buildTestResult({ success: !!reply, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus, respBody, reply }) btn.disabled = false - btn.textContent = '测试对话' + btn.textContent = '测试' } // 获取模型列表 @@ -1874,27 +2172,55 @@ function showSettings() { btn.disabled = true btn.textContent = '获取中...' resultEl.innerHTML = '正在获取模型列表...' + const selApiType = overlay.querySelector('#ast-apitype').value || 'openai' try { - const base = cleanBaseUrl(baseUrl) - const resp = await fetch(base + '/models', { - headers: { 'Authorization': 'Bearer ' + apiKey }, - signal: AbortSignal.timeout(10000), - }) - if (!resp.ok) { - const text = await resp.text().catch(() => '') - let msg = 'HTTP ' + resp.status - try { msg = JSON.parse(text).error?.message || msg } catch {} - resultEl.innerHTML = '✗ ' + escHtml(msg) + '' - return + const base = cleanBaseUrl(baseUrl, selApiType) + const hdrs = authHeaders(selApiType, apiKey) + let models = [] + + if (selApiType === 'anthropic') { + // Anthropic: GET /v1/models + const resp = await fetch(base + '/models', { headers: hdrs, signal: AbortSignal.timeout(10000) }) + if (!resp.ok) { + const text = await resp.text().catch(() => '') + let msg = 'HTTP ' + resp.status + try { msg = JSON.parse(text).error?.message || msg } catch {} + resultEl.innerHTML = '✗ ' + escHtml(msg) + '' + return + } + const data = await resp.json() + models = (data.data || []).map(m => m.id).filter(Boolean).sort() + } else if (selApiType === 'google-gemini') { + // Gemini: GET /models?key=xxx + const resp = await fetch(base + '/models?key=' + apiKey, { signal: AbortSignal.timeout(10000) }) + if (!resp.ok) { + const text = await resp.text().catch(() => '') + let msg = 'HTTP ' + resp.status + try { msg = JSON.parse(text).error?.message || msg } catch {} + resultEl.innerHTML = '✗ ' + escHtml(msg) + '' + return + } + const data = await resp.json() + models = (data.models || []).map(m => m.name?.replace('models/', '') || m.name).filter(Boolean).sort() + } else { + // OpenAI: GET /v1/models + const resp = await fetch(base + '/models', { headers: hdrs, signal: AbortSignal.timeout(10000) }) + if (!resp.ok) { + const text = await resp.text().catch(() => '') + let msg = 'HTTP ' + resp.status + try { msg = JSON.parse(text).error?.message || msg } catch {} + resultEl.innerHTML = '✗ ' + escHtml(msg) + '' + return + } + const data = await resp.json() + models = (data.data || []).map(m => m.id).filter(Boolean).sort() } - const data = await resp.json() - const models = (data.data || []).map(m => m.id).filter(Boolean).sort() + if (models.length === 0) { resultEl.innerHTML = '未发现可用模型' return } resultEl.innerHTML = '✓ 发现 ' + models.length + ' 个模型,点击下方列表选择' - // 显示下拉列表 dropdown.innerHTML = models.map(m => '
' + escHtml(m) + '
' ).join('') @@ -1903,7 +2229,7 @@ function showSettings() { resultEl.innerHTML = '✗ ' + escHtml(err.message) + '' } finally { btn.disabled = false - btn.textContent = '获取模型列表' + btn.textContent = '拉取' } } @@ -1935,6 +2261,7 @@ function showSettings() { _config.apiKey = overlay.querySelector('#ast-apikey').value.trim() _config.model = overlay.querySelector('#ast-model').value.trim() _config.temperature = parseFloat(overlay.querySelector('#ast-temp').value) || 0.7 + _config.apiType = overlay.querySelector('#ast-apitype').value || 'openai' // 工具开关 _config.tools.terminal = overlay.querySelector('#ast-tool-terminal').checked _config.tools.fileOps = overlay.querySelector('#ast-tool-fileops').checked diff --git a/src/pages/chat-debug.js b/src/pages/chat-debug.js index 7e2fcf4..adfbc97 100644 --- a/src/pages/chat-debug.js +++ b/src/pages/chat-debug.js @@ -292,7 +292,8 @@ function testWebSocket(page) { api.readOpenclawConfig().then(config => { const port = config?.gateway?.port || 18789 const token = config?.gateway?.auth?.token || '' - const url = `ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}` + const wsHost = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host + const url = `ws://${wsHost}/ws?token=${encodeURIComponent(token)}` addLog(`📡 连接地址: ${url}`) addLog(`🔑 Token: ${token ? token.substring(0, 20) + '...' : '(空)'}`) @@ -512,7 +513,8 @@ async function fixPairing(page) { const config = await api.readOpenclawConfig() const port = config?.gateway?.port || 18789 const token = config?.gateway?.auth?.token || '' - const url = `ws://127.0.0.1:${port}/ws?token=${encodeURIComponent(token)}` + const wsHost = window.__TAURI_INTERNALS__ ? `127.0.0.1:${port}` : location.host + const url = `ws://${wsHost}/ws?token=${encodeURIComponent(token)}` const ws = new WebSocket(url) diff --git a/src/pages/models.js b/src/pages/models.js index 895a4e6..fa972ef 100644 --- a/src/pages/models.js +++ b/src/pages/models.js @@ -9,7 +9,7 @@ import { showModal, showConfirm } from '../components/modal.js' // API 接口类型选项 const API_TYPES = [ { value: 'openai-completions', label: 'OpenAI 兼容 (最常用)' }, - { value: 'anthropic', label: 'Anthropic 原生' }, + { value: 'anthropic-messages', label: 'Anthropic 原生' }, { value: 'openai-responses', label: 'OpenAI Responses' }, { value: 'google-gemini', label: 'Google Gemini' }, ] @@ -17,7 +17,7 @@ const API_TYPES = [ // 服务商快捷预设 const PROVIDER_PRESETS = [ { key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions' }, - { key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic' }, + { key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic-messages' }, { key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' }, { key: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', api: 'google-gemini' }, ] diff --git a/src/pages/security.js b/src/pages/security.js new file mode 100644 index 0000000..9ec71df --- /dev/null +++ b/src/pages/security.js @@ -0,0 +1,293 @@ +/** + * 安全设置页面 — 访问密码管理 & 无视风险模式 + * 支持 Web 部署模式和 Tauri 桌面端 + */ +import { toast } from '../components/toast.js' + +const isTauri = !!window.__TAURI_INTERNALS__ +let _tauriApi = null + +async function getTauriApi() { + if (!_tauriApi) _tauriApi = (await import('../lib/tauri-api.js')).api + return _tauriApi +} + +async function apiCall(cmd, args = {}) { + if (isTauri) { + // 桌面端:通过 Tauri IPC 读写 clawpanel.json + const api = await getTauriApi() + const cfg = await api.readPanelConfig() + + if (cmd === 'auth_status') { + const isDefault = cfg.accessPassword === '123456' + const result = { hasPassword: !!cfg.accessPassword, mustChangePassword: isDefault, ignoreRisk: !!cfg.ignoreRisk } + if (isDefault) result.defaultPassword = '123456' + return result + } + if (cmd === 'auth_change_password') { + if (cfg.accessPassword && args.oldPassword !== cfg.accessPassword) throw new Error('当前密码错误') + const weakErr = checkPasswordStrengthLocal(args.newPassword) + if (weakErr) throw new Error(weakErr) + if (args.newPassword === cfg.accessPassword) throw new Error('新密码不能与旧密码相同') + cfg.accessPassword = args.newPassword + delete cfg.mustChangePassword + delete cfg.ignoreRisk + await api.writePanelConfig(cfg) + sessionStorage.setItem('clawpanel_authed', '1') + return { success: true } + } + if (cmd === 'auth_ignore_risk') { + if (args.enable) { + delete cfg.accessPassword + delete cfg.mustChangePassword + cfg.ignoreRisk = true + sessionStorage.removeItem('clawpanel_authed') + } else { + delete cfg.ignoreRisk + } + await api.writePanelConfig(cfg) + return { success: true } + } + throw new Error('未知命令: ' + cmd) + } + // Web 模式 + const resp = await fetch(`/__api/${cmd}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(args), + }) + const data = await resp.json() + if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`) + return data +} + +function checkPasswordStrengthLocal(pw) { + if (!pw || pw.length < 6) return '密码至少 6 位' + if (pw.length > 64) return '密码不能超过 64 位' + if (/^\d+$/.test(pw)) return '密码不能是纯数字' + const weak = ['123456', '654321', 'password', 'admin', 'qwerty', 'abc123', '111111', '000000', 'letmein', 'welcome', 'clawpanel', 'openclaw'] + if (weak.includes(pw.toLowerCase())) return '密码太常见,请换一个更安全的密码' + return null +} + +function strengthLevel(pw) { + if (!pw) return { level: 0, text: '', color: '' } + if (pw.length < 6) return { level: 1, text: '太短', color: 'var(--error)' } + if (/^\d+$/.test(pw)) return { level: 1, text: '纯数字太弱', color: 'var(--error)' } + let score = 0 + if (pw.length >= 8) score++ + if (pw.length >= 12) score++ + if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++ + if (/\d/.test(pw)) score++ + if (/[^a-zA-Z0-9]/.test(pw)) score++ + if (score <= 1) return { level: 2, text: '一般', color: 'var(--warning)' } + if (score <= 3) return { level: 3, text: '良好', color: 'var(--primary)' } + return { level: 4, text: '强', color: 'var(--success)' } +} + +export async function render() { + const page = document.createElement('div') + page.className = 'page' + + page.innerHTML = ` + +
+
+
+ ` + + loadStatus(page) + return page +} + +async function loadStatus(page) { + const container = page.querySelector('#security-content') + try { + const status = await apiCall('auth_status') + renderContent(container, status) + } catch (e) { + container.innerHTML = `

加载失败: ${e.message}

` + } +} + +function renderContent(container, status) { + let html = '' + + // 当前状态 + const stateIcon = status.hasPassword ? '✅' : (status.ignoreRisk ? '⚠️' : '⚠️') + const stateText = status.hasPassword + ? (status.mustChangePassword ? '使用默认密码(需修改)' : '已设置自定义密码') + : (status.ignoreRisk ? '无视风险模式(无密码)' : '未设置密码') + const stateColor = status.hasPassword && !status.mustChangePassword ? 'var(--success)' : 'var(--warning)' + + html += ` +
+
访问密码状态
+
+ ${stateIcon} +
+
${stateText}
+
+ ${status.hasPassword + ? (isTauri ? '每次打开应用需输入密码' : '远程访问需输入密码才能进入面板') + : (isTauri ? '任何人打开应用即可使用' : '任何人都可以直接访问面板')} +
+
+
+
+ ` + + // 修改密码区域 + html += ` +
+
${status.hasPassword ? '修改密码' : '设置密码'}
+
+ ${status.hasPassword ? ` +
+ + + ${status.defaultPassword ? '
已自动填充默认密码,直接设置新密码即可
' : ''} +
+ ` : ''} +
+ + +
+
+
+ + +
+ + +
+
+ ` + + // 无视风险模式 + html += ` +
+
+ + 无视风险模式 +
+
+
+
+
关闭密码保护
+
+ 开启后任何人都可以直接访问面板,无需输入密码。
+ 仅建议在受信任的内网环境中使用。 +
+
+ +
+
+ +
+ ` + + container.innerHTML = html + bindSecurityEvents(container, status) +} + +function bindSecurityEvents(container, status) { + // 密码强度实时显示 + const newPwInput = container.querySelector('#sec-new-pw') + const strengthEl = container.querySelector('#pw-strength') + if (newPwInput && strengthEl) { + newPwInput.addEventListener('input', () => { + const s = strengthLevel(newPwInput.value) + if (!newPwInput.value) { strengthEl.innerHTML = ''; return } + const bars = [1,2,3,4].map(i => + `
` + ).join('') + strengthEl.innerHTML = `${bars}${s.text}` + }) + } + + // 修改密码表单 + const form = container.querySelector('#form-change-pw') + if (form) { + form.addEventListener('submit', async (e) => { + e.preventDefault() + const oldPw = container.querySelector('#sec-old-pw')?.value || '' + const newPw = container.querySelector('#sec-new-pw')?.value || '' + const confirmPw = container.querySelector('#sec-confirm-pw')?.value || '' + const msgEl = container.querySelector('#change-pw-msg') + const btn = form.querySelector('button[type="submit"]') + + if (newPw !== confirmPw) { msgEl.textContent = '两次输入的密码不一致'; msgEl.style.color = 'var(--error)'; return } + + btn.disabled = true + btn.textContent = '提交中...' + msgEl.textContent = '' + try { + await apiCall('auth_change_password', { oldPassword: oldPw, newPassword: newPw }) + msgEl.textContent = '密码修改成功' + msgEl.style.color = 'var(--success)' + toast('密码已更新', 'success') + // 清除默认密码横幅 + sessionStorage.removeItem('clawpanel_must_change_pw') + const banner = document.getElementById('pw-change-banner') + if (banner) banner.remove() + setTimeout(() => loadStatus(container.closest('.page')), 1000) + } catch (err) { + msgEl.textContent = err.message + msgEl.style.color = 'var(--error)' + btn.disabled = false + btn.textContent = status.hasPassword ? '确认修改' : '设置密码' + } + }) + } + + // 无视风险模式开关 + const toggle = container.querySelector('#toggle-ignore-risk') + const confirmBox = container.querySelector('#ignore-risk-confirm') + if (toggle && confirmBox) { + toggle.addEventListener('change', () => { + if (toggle.checked) { + // 想开启无视风险 → 显示确认框 + confirmBox.style.display = 'block' + toggle.checked = false // 先不改,等用户确认 + } else { + // 想关闭无视风险 → 直接关闭,刷新页面引导设密码 + handleIgnoreRisk(container, false) + } + }) + + container.querySelector('#btn-confirm-ignore')?.addEventListener('click', () => { + handleIgnoreRisk(container, true) + }) + container.querySelector('#btn-cancel-ignore')?.addEventListener('click', () => { + confirmBox.style.display = 'none' + }) + } +} + +async function handleIgnoreRisk(container, enable) { + try { + await apiCall('auth_ignore_risk', { enable }) + if (enable) { + toast('已开启无视风险模式,密码保护已关闭', 'warning') + } else { + toast('无视风险模式已关闭,请设置新密码', 'info') + } + setTimeout(() => loadStatus(container.closest('.page')), 500) + } catch (e) { + toast('操作失败: ' + e.message, 'error') + } +} diff --git a/src/pages/setup.js b/src/pages/setup.js index b93d58c..0a799b8 100644 --- a/src/pages/setup.js +++ b/src/pages/setup.js @@ -186,6 +186,61 @@ function renderSteps(page, { node, cliOk, config }) { } function renderInstallSection() { + const isWin = navigator.platform?.startsWith('Win') || navigator.userAgent?.includes('Windows') + const isMac = navigator.platform?.startsWith('Mac') || navigator.userAgent?.includes('Macintosh') + const isDesktop = !!window.__TAURI_INTERNALS__ + + let envHint = '' + if (isDesktop) { + envHint = ` +
+ 找不到已安装的 OpenClaw? +

ClawPanel 桌面版只能管理本机安装的 OpenClaw。以下环境中的安装无法被检测到:

+
    + ${isWin ? ` +
  • WSL (Windows 子系统) — OpenClaw 装在 WSL 里,Windows 侧无法访问
  • +
  • Docker 容器 — 容器内的安装与宿主机隔离
  • + ` : ''} + ${isMac ? ` +
  • Docker 容器 — 容器内的安装与宿主机隔离
  • +
  • 远程服务器 — 安装在其他机器上
  • + ` : ''} + ${!isWin && !isMac ? ` +
  • Docker 容器 — 容器内的安装与宿主机隔离
  • + ` : ''} +
+
+ + 在对应环境中安装管理面板 + +
+ ${isWin ? ` +
+
WSL 中使用 Web 版:
+
打开 WSL 终端,一键部署 ClawPanel Web 版:
+ curl -fsSL https://claw.qt.cool/deploy.sh | bash +
部署后在浏览器访问 WSL 的 IP 即可管理。
+
+ ` : ''} +
+
Docker 容器中使用:
+
在容器内安装 OpenClaw + ClawPanel Web 版:
+ npm i -g @qingchencloud/openclaw-zh + curl -fsSL https://claw.qt.cool/deploy.sh | bash +
+
+
远程服务器:
+
SSH 登录服务器后执行:
+ curl -fsSL https://claw.qt.cool/deploy.sh | bash +
+
+
+
+ 或者,你也可以在本机重新安装 OpenClaw(使用下方的「一键安装」)。 +
+
` + } + return `

选择版本后点击安装,将自动执行 npm 全局安装。 @@ -215,6 +270,7 @@ function renderInstallSection() {

+ ${envHint} ` } diff --git a/src/style/components.css b/src/style/components.css index 5dab6d5..3b5d24c 100644 --- a/src/style/components.css +++ b/src/style/components.css @@ -329,3 +329,87 @@ mark { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } + +/* === 登录覆盖层 === */ +#login-overlay { + position: fixed; inset: 0; z-index: 99998; + display: flex; align-items: center; justify-content: center; + background: var(--bg-primary, #f8f9fb); + transition: opacity 0.4s ease; +} +#login-overlay.hide { opacity: 0; pointer-events: none; } +.login-card { + width: 360px; max-width: 90vw; padding: 40px 32px; + background: var(--bg-card, #fff); + border: 1px solid var(--border, #e4e4e7); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0,0,0,0.08); + text-align: center; +} +.login-card .login-logo { + width: 48px; height: 48px; margin: 0 auto 16px; + color: var(--primary, #6366f1); +} +.login-card .login-title { + font-size: 20px; font-weight: 600; margin-bottom: 4px; + color: var(--text-primary, #18181b); +} +.login-card .login-desc { + font-size: 13px; color: var(--text-secondary, #71717a); + margin-bottom: 24px; +} +.login-card .login-input { + width: 100%; padding: 10px 14px; font-size: 14px; + border: 1px solid var(--border, #e4e4e7); + border-radius: 8px; outline: none; + background: var(--bg-secondary, #f4f4f5); + color: var(--text-primary, #18181b); + transition: border-color 0.2s; +} +.login-card .login-input:focus { + border-color: var(--primary, #6366f1); + box-shadow: 0 0 0 3px rgba(99,102,241,0.12); +} +.login-card .login-btn { + width: 100%; margin-top: 16px; padding: 10px 0; + font-size: 14px; font-weight: 500; + border: none; border-radius: 8px; cursor: pointer; + background: var(--primary, #6366f1); + color: #fff; transition: opacity 0.2s; +} +.login-card .login-btn:hover { opacity: 0.9; } +.login-card .login-btn:disabled { opacity: 0.6; cursor: not-allowed; } +.login-card .login-error { + margin-top: 12px; font-size: 13px; + color: var(--error, #ef4444); + min-height: 20px; +} + +/* === Toggle 开关 === */ +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; height: 24px; + flex-shrink: 0; +} +.toggle-switch input { opacity: 0; width: 0; height: 0; } +.toggle-slider { + position: absolute; inset: 0; + cursor: pointer; border-radius: 24px; + background: var(--border-primary, #d4d4d8); + transition: background 0.25s; +} +.toggle-slider::before { + content: ''; + position: absolute; left: 2px; top: 2px; + width: 20px; height: 20px; border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0,0,0,0.15); + transition: transform 0.25s; +} +.toggle-switch input:checked + .toggle-slider { + background: var(--error, #ef4444); +} +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(20px); +} diff --git a/src/style/layout.css b/src/style/layout.css index cb3d0c3..e0c7943 100644 --- a/src/style/layout.css +++ b/src/style/layout.css @@ -61,6 +61,26 @@ border-top: 1px solid var(--border-secondary); } +.sidebar-meta { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 12px 2px; + font-size: 11px; +} +.sidebar-link { + color: var(--text-tertiary); + text-decoration: none; + transition: color 0.2s; +} +.sidebar-link:hover { + color: var(--primary); +} +.sidebar-version { + color: var(--text-tertiary); + opacity: 0.7; +} + .nav-section { margin-bottom: var(--space-md); }