diff --git a/.gitignore b/.gitignore index 4a5b5125..8101d63e 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,4 @@ test-results/ # VitePress docs build artifacts docs/.vitepress/dist/ docs/.vitepress/cache/ +.claude/scheduled_tasks.lock diff --git a/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/.openspec.yaml b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/.openspec.yaml new file mode 100644 index 00000000..927e3e8e --- /dev/null +++ b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-31 diff --git a/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/design.md b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/design.md new file mode 100644 index 00000000..75ea17cf --- /dev/null +++ b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/design.md @@ -0,0 +1,159 @@ +## Context + +AutoRouter 的 CLIProxyAPI 管理页面(`system/cliproxy`)目前包含实例表格和账号面板两个区块,支持实例 CRUD、连通性检测、账号同步/启停/字段编辑、OAuth 登录(3 个 Provider)和上游创建。CLIProxyAPI 原生 WebUI 则额外提供认证文件上传/下载/删除、日志查看、OAuth 回调提交、6 个 Provider 支持等能力。本次变更在现有页面基础上扩展,补齐这些管理能力。 + +### 当前页面结构 + +``` +┌─────────────────────────────────────────────────┐ +│ Topbar: CLIProxyAPI │ +├─────────────────────────────────────────────────┤ +│ Card: Instances [+ Add] │ +│ ┌─────┬──────┬────────────┬────────┬──────────┐ │ +│ │Name │ Mode │ Proxy URL │ Status │ Actions │ │ +│ ├─────┼──────┼────────────┼────────┼──────────┤ │ +│ │ ... │ ... │ ... │ Badge │ ⋯ Menu │ │ +│ └─────┴──────┴────────────┴────────┴──────────┘ │ +├─────────────────────────────────────────────────┤ +│ Card: OAuth Accounts (选中实例后显示) │ +│ [OAuth Login] [Sync Accounts] │ +│ ┌──────┬──────────┬────────┬───────┬──────────┐ │ +│ │ File │ Provider │ Status │Models │ Actions │ │ +│ ├──────┼──────────┼────────┼───────┼──────────┤ │ +│ │ ... │ Badge │ Badge │ N │ ⋯ Menu │ │ +│ └──────┴──────────┴────────┴───────┴──────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +## Goals / Non-Goals + +**Goals:** + +1. 在现有 CLIProxyAPI 管理页面中补齐认证文件管理(上传、下载、删除)、模型列表查看、账号详情查看、OAuth 回调提交、实例日志查看、关联上游查看共 6 个功能区 +2. 将 OAuth Provider 从 3 个扩展到 6 个 +3. 实例表格增加行内启停切换 +4. 所有新增功能复用现有架构模式,保持代码风格一致 + +**Non-Goals:** + +1. 不引入 CLIProxyAPI 的配置管理(config.yaml 读写)——远程修改运行时配置安全风险高 +2. 不引入 AI Provider 密钥管理——AutoRouter 的上游管理已覆盖此能力 +3. 不引入配额管理——与 AutoRouter 的 billing 系统概念重叠 +4. 不引入 Ampcode 集成——小众 Provider,后续按需添加 + +## Decisions + +### D1: 页面布局扩展策略 + +在现有两个 Card(实例表格 + 账号面板)的基础上,新增两个 Card(关联上游面板 + 日志面板),均在选中实例后显示。页面纵向排列顺序为:实例表格 → 账号面板 → 关联上游面板 → 日志面板。 + +**备选方案**:使用 Tab 切换不同面板。放弃原因:当前面板数量有限(4 个),纵向排列更符合现有布局风格且一目了然。 + +### 扩展后页面布局 + +``` +┌──────────────────────────────────────────────────────┐ +│ Topbar: CLIProxyAPI │ +├──────────────────────────────────────────────────────┤ +│ Card: Instances [+ Add] │ +│ ┌──────┬──────┬────────────┬────────┬───────────────┐│ +│ │Name │ Mode │ Proxy URL │ Status │ Actions ││ +│ ├──────┼──────┼────────────┼────────┼───────────────┤│ +│ │ ... │ ... │ ... │[Toggle]│ ⋯ Menu ││ +│ └──────┴──────┴────────────┴────────┴───────────────┘│ +│ ▲ │ +│ 行内 Switch 启停切换 │ +├──────────────────────────────────────────────────────┤ +│ Card: OAuth Accounts (选中实例后) │ +│ [OAuth Login] [Upload Auth File] [Sync Accounts] │ +│ ┌──────┬────────┬──────┬───────┬──────┬─────────────┐│ +│ │ File │Provider│Status│Models │Prefix│ Actions ││ +│ ├──────┼────────┼──────┼───────┼──────┼─────────────┤│ +│ │ ... │ Badge │Badge │ N 👁 │ ... │⋯ Menu ││ +│ └──────┴────────┴──────┴───────┴──────┴─────────────┘│ +│ ▲ │ +│ 模型数可点击查看列表;Menu 增加详情/删除 │ +├──────────────────────────────────────────────────────┤ +│ Card: Linked Upstreams (选中实例后) │ +│ ┌──────────────┬──────────┬────────────┬────────────┐│ +│ │ Upstream Name│ Provider │ Type │ Account ││ +│ ├──────────────┼──────────┼────────────┼────────────┤│ +│ │ ... Pool │ codex │ Pool │ — ││ +│ │ ... Account │ anthropic│ Single │ alice.json ││ +│ └──────────────┴──────────┴────────────┴────────────┘│ +├──────────────────────────────────────────────────────┤ +│ Card: Instance Logs (选中实例后) │ +│ [Refresh] 搜索框 时间范围选择 │ +│ ┌────────────────────────────────────────────────────┐│ +│ │ 2025-05-31 10:23:45 [INFO] request forwarded ... ││ +│ │ 2025-05-31 10:23:46 [WARN] auth token expired ... ││ +│ │ ... ││ +│ └────────────────────────────────────────────────────┘│ +└──────────────────────────────────────────────────────┘ +``` + +### D2: 管理 API 客户端扩展方式 + +在现有 `cliproxy-management-client.ts` 中新增 5 个方法:`deleteAuthFile`、`uploadAuthFile`、`downloadAuthFile`、`submitOAuthCallback`、`getLogs`。保持与现有方法相同的模式:接受 `CliproxyManagementTarget`、调用 `requestManagementApi`、返回类型化结果。 + +`downloadAuthFile` 较特殊,返回的是原始 JSON 文本而非解析后的对象,需要在 `requestManagementApi` 之外单独处理响应。 + +### D3: OAuth Provider 扩展方式 + +在 `cliproxy-management-client.ts` 中扩展 `CLIPROXY_OAUTH_PROVIDERS` 常量和 `AUTH_URL_ENDPOINT` 映射。新增 Provider 对应的端点为: + +| Provider | 端点片段 | 特殊参数 | +|----------|----------|----------| +| xAI/Grok | `xai-auth-url` | 无 | +| Antigravity | `antigravity-auth-url` | 无 | +| Kimi | `kimi-auth-url` | 无(无自动回调,需配合 OAuth callback 手动提交) | + +前端 `CLIPROXY_PROVIDERS` 类型和 UI 选项列表同步扩展。`cliproxy-upstream-preset.ts` 中的 `CLIPROXY_UPSTREAM_PRESETS` 暂不扩展(新 Provider 的路径约定尚未确定),池上游创建仍限于原有 3 个 Provider。 + +**备选方案**:同时扩展 upstream preset 支持 6 个 Provider。放弃原因:xAI/Antigravity/Kimi 的 CLIProxyAPI 路径后缀和路由能力尚未有稳定约定,留作后续独立变更。 + +### D4: Admin 路由组织 + +新增路由全部放在 `src/app/api/admin/cliproxy/instances/[id]/` 下,保持与现有路由树一致: + +``` +instances/[id]/ + ├── auth-accounts/... (existing) + ├── oauth-login/... (existing) + ├── pool-upstreams/ (existing) + ├── test/ (existing) + ├── auth-files/ (NEW) + │ ├── route.ts POST: upload + │ └── [name]/ + │ ├── route.ts GET: download, DELETE: delete + ├── oauth-callback/ (NEW) + │ └── route.ts POST: submit callback + ├── logs/ (NEW) + │ └── route.ts GET: fetch logs + └── linked-upstreams/ (NEW) + └── route.ts GET: list linked upstreams +``` + +### D5: 认证文件删除与本地缓存清理 + +删除操作分两步:先调用 CLIProxyAPI `DELETE /v0/management/auth-files` 删除上游文件,成功后删除本地 `cliproxy_auth_accounts` 中对应的缓存记录。CLIProxyAPI 侧删除失败则整体失败,不触及本地缓存。 + +### D6: 日志面板实现 + +日志面板使用简单的增量拉取模式(`GET /v0/management/logs`),由前端手动刷新或设置自动刷新间隔。日志内容以等宽字体渲染,支持关键词筛选(前端过滤)。不使用 SSE 或 WebSocket 实时推送,保持实现简单。 + +**备选方案**:日志实时推送。放弃原因:CLIProxyAPI 管理 API 不提供日志推送端点,且 AutoRouter 在中间层做推送会增加不必要的复杂度。 + +### D7: 关联上游面板数据来源 + +直接查询 AutoRouter 本地 `upstreams` 表中 `cliproxyInstanceId` 匹配所选实例的记录。不需要调用 CLIProxyAPI 管理 API。查询结果包含上游名称、Provider、类型(池/单账号)、绑定的账号文件名。 + +## Risks / Trade-offs + +**[CLIProxyAPI 版本兼容]** → 新增的管理 API 端点(auth-files 上传/下载/删除、oauth-callback、logs)可能在旧版本 CLIProxyAPI 中不存在。采用防御性处理:管理 API 调用失败时返回可理解的错误信息,不影响其他功能。 + +**[认证文件上传安全]** → 上传的 JSON 可能包含恶意内容。AutoRouter 仅做格式校验(合法 JSON),实际内容由 CLIProxyAPI 负责校验和处理。Admin API 本身需要 ADMIN_TOKEN 鉴权,风险可控。 + +**[日志体积]** → CLIProxyAPI 日志可能较大。前端限制单次拉取行数(默认 200 行),并支持时间范围过滤以控制返回体积。 + +**[新 Provider 无 upstream preset]** → xAI/Antigravity/Kimi 暂不支持一键创建池上游。OAuth 登录和账号管理可正常使用,上游创建需通过通用上游管理界面手动配置。 diff --git a/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/proposal.md b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/proposal.md new file mode 100644 index 00000000..1cf4f35c --- /dev/null +++ b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/proposal.md @@ -0,0 +1,53 @@ +## Why + +CLIProxyAPI 原生 WebUI(Management Center)提供了 8 个功能页面(仪表盘、配置管理、Provider 密钥、认证文件管理、OAuth 登录、配额管理、日志、系统信息),而 AutoRouter 当前仅覆盖了实例 CRUD、账号同步/启停/字段编辑和 3 个 Provider 的 OAuth 登录。管理员在日常运维中需要频繁切换到 CLIProxyAPI 原生面板完成认证文件管理、日志查看、模型列表查看等操作,体验割裂且效率低下。本次变更将 CLIProxyAPI 原生面板中最高价值的管理能力集成到 AutoRouter 管理端,使管理员在单一界面内完成绝大多数 CLIProxyAPI 运维操作。 + +## What Changes + +### 前端增强(后端已有数据或接口,仅需前端入口) + +- 账号表格增加**模型列表查看**操作,弹窗展示该账号可用的具体模型列表(后端 `getAuthFileModels` 已实现) +- 账号表格增加**详情查看**操作,弹窗展示 email、status、status_message、raw_metadata 快照、last_synced_at 等完整元数据 +- CLIProxyAPI 页面增加**关联上游**面板,展示某实例下所有关联的池上游和单账号上游(upstreams 表已有 `cliproxyInstanceId` 字段) +- 实例表格行内增加**快捷启停切换**,无需打开编辑弹窗 + +### 新增后端路由与前端(管理 API 透传至 CLIProxyAPI) + +- **认证文件删除**:透传 `DELETE /v0/management/auth-files` 到 CLIProxyAPI,同时移除本地缓存 +- **认证文件上传**:透传 `POST /v0/management/auth-files` 到 CLIProxyAPI,上传 JSON 格式认证文件并触发同步 +- **认证文件下载**:透传 `GET /v0/management/auth-files/download?name=...`,下载认证文件原始 JSON +- **OAuth 回调 URL 手动提交**:透传 `POST /v0/management/oauth-callback`,在自动回调不可达时允许管理员手动粘贴回调 URL 完成登录 +- **CLIProxyAPI 实例日志查看**:透传 `GET /v0/management/logs`,在 AutoRouter 管理端按时间范围查看 CLIProxyAPI 运行日志 + +### OAuth Provider 扩展 + +- 将 OAuth 登录支持的 Provider 从 3 个(Codex、Anthropic、Gemini)扩展到 6 个,新增 xAI/Grok、Antigravity、Kimi + +## Capabilities + +### New Capabilities + +- `cliproxy-auth-file-operations`: 认证文件的上传、下载、删除操作,涵盖管理 API 客户端扩展、Admin 路由、前端弹窗 +- `cliproxy-instance-logs`: CLIProxyAPI 实例日志查看,涵盖管理 API 客户端扩展、Admin 路由、前端日志面板 +- `cliproxy-oauth-callback`: OAuth 回调 URL 手动提交,涵盖管理 API 客户端扩展、Admin 路由、前端输入弹窗 + +### Modified Capabilities + +- `cliproxy-admin-ui`: 实例表格增加行内启停切换、页面增加关联上游面板 +- `cliproxy-oauth-account-management`: 账号表格增加模型列表查看和详情查看操作、OAuth Provider 列表从 3 个扩展到 6 个 + +## Impact + +**后端** +- `cliproxy-management-client.ts`:新增 `deleteAuthFile`、`uploadAuthFile`、`downloadAuthFile`、`submitOAuthCallback`、`getLogs` 5 个上游调用方法;`CLIPROXY_OAUTH_PROVIDERS` 常量从 3 项扩展到 6 项,`AUTH_URL_ENDPOINT` 对应扩展 +- `cliproxy-auth-account-service.ts`:新增 `deleteCliproxyAuthAccount` 方法(删除上游认证文件后移除本地缓存) +- 新增 Admin 路由:`instances/[id]/auth-files/upload`、`instances/[id]/auth-files/[name]/download`、`instances/[id]/auth-files/[name]/delete`、`instances/[id]/oauth-callback`、`instances/[id]/logs` +- `cliproxy-instance-crud.ts`:新增 `toggleCliproxyInstanceEnabled` 快捷方法 +- 新增 Admin 路由:`instances/[id]/upstreams`(查询关联上游) + +**前端** +- 新增组件:`cliproxy-account-models-dialog.tsx`、`cliproxy-account-detail-dialog.tsx`、`cliproxy-auth-file-upload-dialog.tsx`、`cliproxy-auth-file-download-button.tsx`、`cliproxy-oauth-callback-dialog.tsx`、`cliproxy-instance-logs-panel.tsx`、`cliproxy-linked-upstreams-panel.tsx` +- 修改组件:`cliproxy-instances-table.tsx`(行内启停)、`cliproxy-accounts-table.tsx`(新增模型/详情/删除操作)、`cliproxy-accounts-panel.tsx`(新增上传按钮)、`cliproxy-oauth-login-dialog.tsx`(6 个 Provider) +- `use-cliproxy.ts`:新增对应的 hooks +- `src/types/cliproxy.ts`:新增类型定义 +- `src/messages/en.json` 和 `zh-CN.json`:新增国际化文案 diff --git a/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-admin-ui/spec.md b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-admin-ui/spec.md new file mode 100644 index 00000000..f035079b --- /dev/null +++ b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-admin-ui/spec.md @@ -0,0 +1,84 @@ +## ADDED Requirements + +### Requirement: 实例行内启停切换 + +系统 SHALL 在实例表格的状态列中以 Switch 组件替代原有的纯 Badge 展示,允许管理员直接在行内切换实例的启用/停用状态。切换 MUST 调用实例更新 API 仅修改 `enabled` 字段,成功后刷新实例列表并提示成功,失败时回滚 Switch 状态并提示错误。 + +#### Scenario: 启用实例 + +- **WHEN** 管理员将某停用实例的 Switch 切换为启用 +- **THEN** 系统调用实例更新 API 将 enabled 设为 true,成功后实例状态更新 + +#### Scenario: 停用实例 + +- **WHEN** 管理员将某启用实例的 Switch 切换为停用 +- **THEN** 系统调用实例更新 API 将 enabled 设为 false,成功后实例状态更新 + +#### Scenario: 切换失败回滚 + +- **WHEN** 实例更新 API 调用失败 +- **THEN** Switch 组件回滚到切换前状态,界面提示错误 + +### Requirement: 关联上游面板 + +系统 SHALL 在选中 CLIProxyAPI 实例后展示关联上游面板,列出该实例下所有关联的池上游和单账号上游。面板数据 MUST 来源于 AutoRouter 本地 `upstreams` 表中 `cliproxyInstanceId` 匹配所选实例的记录。每行 MUST 展示上游名称、服务商、类型(池上游/单账号上游)和绑定的账号文件名(如有)。 + +#### Scenario: 展示关联上游 + +- **WHEN** 管理员选中某实例且该实例存在关联上游 +- **THEN** 关联上游面板展示该实例下所有关联上游的列表 + +#### Scenario: 无关联上游 + +- **WHEN** 管理员选中某实例且该实例无关联上游 +- **THEN** 面板展示"暂无关联上游"提示 + +#### Scenario: 区分上游类型 + +- **WHEN** 关联上游列表包含池上游和单账号上游 +- **THEN** 列表以不同标签区分两种类型,单账号上游额外展示绑定的账号文件名 + +### Requirement: 关联上游查询 Admin API + +系统 SHALL 提供关联上游查询 Admin API `GET /api/admin/cliproxy/instances/:id/linked-upstreams`。该端点 MUST 复用既有 Admin 鉴权机制。端点 SHALL 查询 `upstreams` 表中 `cliproxyInstanceId` 等于指定实例 ID 的记录,并返回上游名称、ID、服务商、类型和绑定的账号文件名。 + +#### Scenario: 查询关联上游 + +- **WHEN** 管理员请求某实例的关联上游列表 +- **THEN** 系统返回该实例下所有关联上游的信息 + +#### Scenario: 实例无关联上游 + +- **WHEN** 请求的实例无关联上游 +- **THEN** 系统返回空数组 + +## MODIFIED Requirements + +### Requirement: OAuth 登录流程界面 + +系统 SHALL 提供 OAuth 登录流程界面,以弹窗形式发起 Codex、Claude、Gemini、xAI、Antigravity、Kimi 的 OAuth 登录。弹窗 MUST 展示发起登录接口返回的授权地址,并提供在新标签页打开授权地址与复制授权地址的操作。系统 SHALL 按固定间隔轮询登录状态。由于发起登录接口不返回过期时间,系统 MUST 以客户端固定超时上限作为轮询的硬性截止。登录成功时 MUST 关闭弹窗并刷新账号列表;失败或达到超时上限时 MUST 停止轮询并展示可理解的错误、重新发起入口以及手动提交回调 URL 的入口。 + +#### Scenario: 发起 OAuth 登录 + +- **WHEN** 管理员为某实例发起某服务商的 OAuth 登录 +- **THEN** 弹窗展示授权地址,并开始轮询登录状态 + +#### Scenario: 登录成功 + +- **WHEN** 轮询到登录完成 +- **THEN** 弹窗关闭,账号列表刷新,界面提示登录成功 + +#### Scenario: 登录超时 + +- **WHEN** 轮询达到客户端固定超时上限仍未完成 +- **THEN** 停止轮询,展示超时错误、重新发起登录的入口以及手动提交回调 URL 的输入区域 + +#### Scenario: 关闭弹窗停止轮询 + +- **WHEN** 管理员在登录完成前主动关闭弹窗 +- **THEN** 轮询停止 + +#### Scenario: 服务商选项覆盖六个 Provider + +- **WHEN** 管理员打开 OAuth 登录弹窗的服务商选择器 +- **THEN** 列出 Codex、Claude、Gemini、xAI、Antigravity、Kimi 六个选项 diff --git a/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-auth-file-operations/spec.md b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-auth-file-operations/spec.md new file mode 100644 index 00000000..e7466844 --- /dev/null +++ b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-auth-file-operations/spec.md @@ -0,0 +1,97 @@ +## ADDED Requirements + +### Requirement: 管理 API 客户端认证文件操作 + +系统 SHALL 在 CLIProxyAPI 管理 API 客户端中新增三个方法:上传认证文件、下载认证文件、删除认证文件。三个方法 MUST 复用现有的鉴权、超时和错误处理机制。 + +#### Scenario: 上传认证文件 + +- **WHEN** 调用上传认证文件方法,传入目标实例和 JSON 内容 +- **THEN** 客户端向 CLIProxyAPI 发送 `POST /v0/management/auth-files`,请求体为 JSON 内容 + +#### Scenario: 下载认证文件 + +- **WHEN** 调用下载认证文件方法,传入目标实例和账号文件名 +- **THEN** 客户端向 CLIProxyAPI 发送 `GET /v0/management/auth-files/download?name=<文件名>`,返回原始 JSON 文本 + +#### Scenario: 删除认证文件 + +- **WHEN** 调用删除认证文件方法,传入目标实例和账号文件名 +- **THEN** 客户端向 CLIProxyAPI 发送 `DELETE /v0/management/auth-files`,请求体包含 `{ name: <文件名> }` + +### Requirement: 认证文件删除服务 + +系统 SHALL 提供认证文件删除服务方法,先调用 CLIProxyAPI 删除上游文件,成功后删除本地 `cliproxy_auth_accounts` 缓存表中对应的记录。CLIProxyAPI 侧删除失败时 MUST 整体失败,MUST NOT 触及本地缓存。 + +#### Scenario: 删除成功并清理缓存 + +- **WHEN** 管理员请求删除某实例下的某个认证文件 +- **THEN** 系统先调用 CLIProxyAPI 删除该文件,成功后从本地缓存表中移除对应记录 + +#### Scenario: CLIProxyAPI 删除失败 + +- **WHEN** CLIProxyAPI 删除认证文件请求返回错误 +- **THEN** 系统返回错误,本地缓存保持不变 + +#### Scenario: 本地无缓存记录 + +- **WHEN** CLIProxyAPI 删除成功,但本地缓存表中无对应记录 +- **THEN** 系统正常返回成功,不报错 + +### Requirement: 认证文件管理 Admin API + +系统 SHALL 提供认证文件管理 Admin API,包含上传、下载、删除三个端点。所有端点 MUST 复用既有 Admin 鉴权机制(Bearer ADMIN_TOKEN)。 + +#### Scenario: 上传认证文件 + +- **WHEN** 管理员向 `POST /api/admin/cliproxy/instances/:id/auth-files` 提交 JSON 内容 +- **THEN** 系统将内容透传至 CLIProxyAPI 上传端点,成功后触发该实例的账号同步并返回同步结果 + +#### Scenario: 下载认证文件 + +- **WHEN** 管理员请求 `GET /api/admin/cliproxy/instances/:id/auth-files/:name` +- **THEN** 系统从 CLIProxyAPI 下载该文件并以 `application/json` 返回原始内容 + +#### Scenario: 删除认证文件 + +- **WHEN** 管理员请求 `DELETE /api/admin/cliproxy/instances/:id/auth-files/:name` +- **THEN** 系统调用删除服务方法,成功后返回已删除的文件名 + +#### Scenario: 操作不存在的实例 + +- **WHEN** 请求指向不存在的实例 ID +- **THEN** 系统返回 404 实例不存在错误 + +#### Scenario: 缺少管理鉴权 + +- **WHEN** 请求未携带有效的 ADMIN_TOKEN Bearer 凭据 +- **THEN** 系统返回 401 鉴权失败错误 + +### Requirement: 认证文件管理前端 + +系统 SHALL 在账号面板中提供认证文件上传按钮,点击后打开上传弹窗。上传弹窗 MUST 接受 JSON 文件选择或 JSON 文本粘贴。系统 SHALL 在账号行操作菜单中提供下载和删除操作。下载 MUST 触发浏览器文件下载。删除 MUST 经确认弹窗确认后执行。 + +#### Scenario: 上传认证文件 + +- **WHEN** 管理员在上传弹窗中选择 JSON 文件或粘贴 JSON 文本并提交 +- **THEN** 系统调用上传 API,成功后刷新账号列表并提示成功 + +#### Scenario: 上传无效 JSON + +- **WHEN** 管理员提交的内容不是合法 JSON +- **THEN** 前端阻止提交并提示格式错误 + +#### Scenario: 下载认证文件 + +- **WHEN** 管理员在某账号行选择下载 +- **THEN** 浏览器下载该账号的原始 JSON 文件,文件名为账号文件名 + +#### Scenario: 删除认证文件 + +- **WHEN** 管理员在某账号行选择删除并在确认弹窗中确认 +- **THEN** 系统调用删除 API,成功后刷新账号列表并提示成功 + +#### Scenario: 取消删除 + +- **WHEN** 管理员在确认弹窗中取消 +- **THEN** 不执行删除操作 diff --git a/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-instance-logs/spec.md b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-instance-logs/spec.md new file mode 100644 index 00000000..775e6519 --- /dev/null +++ b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-instance-logs/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: 管理 API 客户端日志查询 + +系统 SHALL 在 CLIProxyAPI 管理 API 客户端中新增日志查询方法。该方法 MUST 调用 `GET /v0/management/logs`,支持可选的时间戳参数用于增量拉取。响应 MUST 解析为日志条目数组。 + +#### Scenario: 查询全部日志 + +- **WHEN** 调用日志查询方法且不传入时间戳参数 +- **THEN** 客户端向 CLIProxyAPI 发送不带时间戳过滤的日志查询请求 + +#### Scenario: 增量查询日志 + +- **WHEN** 调用日志查询方法并传入起始时间戳 +- **THEN** 客户端在请求中携带时间戳参数,仅返回该时间点之后的日志 + +#### Scenario: CLIProxyAPI 不支持日志端点 + +- **WHEN** CLIProxyAPI 返回 404 或其他不支持的状态 +- **THEN** 客户端返回可识别的服务错误,不抛出未分类异常 + +### Requirement: 实例日志 Admin API + +系统 SHALL 提供实例日志查询 Admin API `GET /api/admin/cliproxy/instances/:id/logs`。该端点 MUST 复用既有 Admin 鉴权机制。端点 SHALL 支持 `since` 查询参数(ISO 时间戳),将其透传至 CLIProxyAPI 日志查询端点。 + +#### Scenario: 查询实例日志 + +- **WHEN** 管理员请求某实例的日志 +- **THEN** 系统从 CLIProxyAPI 拉取日志并返回日志条目数组 + +#### Scenario: 带时间范围查询 + +- **WHEN** 管理员在请求中携带 `since` 时间戳 +- **THEN** 系统仅返回该时间点之后的日志 + +#### Scenario: 操作不存在的实例 + +- **WHEN** 请求指向不存在的实例 ID +- **THEN** 系统返回 404 实例不存在错误 + +### Requirement: 实例日志查看前端 + +系统 SHALL 在 CLIProxyAPI 页面选中实例后展示日志面板 Card。日志面板 MUST 以等宽字体渲染日志文本。面板 SHALL 提供手动刷新按钮和关键词搜索输入框(前端过滤)。面板 SHALL 在首次显示时自动拉取一次日志。 + +#### Scenario: 查看实例日志 + +- **WHEN** 管理员选中某实例 +- **THEN** 日志面板自动拉取并展示该实例的最新日志 + +#### Scenario: 手动刷新 + +- **WHEN** 管理员点击刷新按钮 +- **THEN** 面板重新拉取日志并更新显示 + +#### Scenario: 关键词搜索 + +- **WHEN** 管理员在搜索框中输入关键词 +- **THEN** 日志列表仅显示包含该关键词的行 + +#### Scenario: 日志为空 + +- **WHEN** CLIProxyAPI 返回空日志 +- **THEN** 面板展示"暂无日志"提示 + +#### Scenario: 切换实例 + +- **WHEN** 管理员切换到另一个实例 +- **THEN** 日志面板清空并拉取新实例的日志 diff --git a/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-oauth-account-management/spec.md b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-oauth-account-management/spec.md new file mode 100644 index 00000000..7f5fdea0 --- /dev/null +++ b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-oauth-account-management/spec.md @@ -0,0 +1,112 @@ +## ADDED Requirements + +### Requirement: 账号模型列表查看 + +系统 SHALL 在前端账号表格中提供每个账号的模型列表查看入口。点击后 MUST 调用后端 API 获取该账号在 CLIProxyAPI 侧的可用模型列表,并以弹窗或展开行形式展示。模型列表 MUST 展示模型 ID 和显示名称。 + +#### Scenario: 查看账号模型列表 + +- **WHEN** 管理员点击某账号的模型数量或模型查看操作 +- **THEN** 系统从 CLIProxyAPI 实时查询该账号的可用模型列表并展示 + +#### Scenario: 模型查询失败 + +- **WHEN** 模型列表查询调用 CLIProxyAPI 失败 +- **THEN** 弹窗展示可理解的错误信息 + +#### Scenario: 账号无可用模型 + +- **WHEN** CLIProxyAPI 返回空模型列表 +- **THEN** 弹窗展示"该账号暂无可用模型"提示 + +### Requirement: 账号模型列表 Admin API + +系统 SHALL 提供账号模型列表查询 Admin API `GET /api/admin/cliproxy/instances/:id/auth-accounts/:name/models`。该端点 MUST 复用既有 Admin 鉴权机制。端点 SHALL 调用 CLIProxyAPI 管理 API 客户端的 `getAuthFileModels` 方法查询指定账号的模型列表。 + +#### Scenario: 查询账号模型列表 + +- **WHEN** 管理员请求某实例下某账号的模型列表 +- **THEN** 系统从 CLIProxyAPI 查询并返回该账号的可用模型数组 + +#### Scenario: CLIProxyAPI 查询失败 + +- **WHEN** CLIProxyAPI 模型查询端点返回错误 +- **THEN** 系统返回对应的管理 API 错误 + +### Requirement: 账号详情查看 + +系统 SHALL 在前端账号表格的行操作菜单中提供详情查看入口。详情弹窗 MUST 展示账号的全部元数据:账号文件名、服务商、邮箱、CLIProxyAPI 侧状态(status 字段)、启用/停用状态、前缀、优先级、备注、模型数量、原始元数据快照(raw_metadata 中的各字段)、最近同步时间、创建时间、更新时间。 + +#### Scenario: 查看账号详情 + +- **WHEN** 管理员在某账号行选择查看详情 +- **THEN** 弹窗展示该账号的全部元数据 + +#### Scenario: 元数据字段为空 + +- **WHEN** 某些可选字段(email、status、prefix、note 等)为空 +- **THEN** 弹窗中对应位置展示占位符或"未设置"标记 + +### Requirement: 账号表格信息增强 + +系统 SHALL 在账号表格中增加 email 列的展示。email 列 MUST 展示账号的邮箱地址,为空时显示占位符。 + +#### Scenario: 展示账号邮箱 + +- **WHEN** 账号存在邮箱地址 +- **THEN** 表格中 email 列展示该邮箱 + +#### Scenario: 邮箱为空 + +- **WHEN** 账号邮箱为空 +- **THEN** 表格中 email 列展示"—"占位符 + +## MODIFIED Requirements + +### Requirement: CLIProxyAPI 管理 API 客户端 + +系统 SHALL 提供单一的 CLIProxyAPI 管理 API 客户端模块,集中封装本能力所需的全部管理端点调用。客户端 MUST 使用 `Authorization: Bearer` 形式注入管理密钥,MUST 为请求设置超时上限,并 MUST 对响应缺失字段做容错解析。封装范围 SHALL 覆盖列出 auth-files、查询某 auth-file 的模型、更新账号启用状态、更新账号字段、获取 OAuth 授权地址、查询 OAuth 登录状态、上传认证文件、下载认证文件、删除认证文件、提交 OAuth 回调、查询实例日志。 + +#### Scenario: 携带管理密钥调用管理 API + +- **WHEN** 客户端调用任一管理端点 +- **THEN** 请求头以 `Authorization: Bearer` 形式携带该实例的管理密钥明文 + +#### Scenario: 管理 API 调用超时 + +- **WHEN** 某次管理 API 调用在超时上限内未返回 +- **THEN** 客户端中止请求并返回可识别的超时错误 + +#### Scenario: 响应字段缺失容错 + +- **WHEN** CLIProxyAPI 返回的 auth-files 条目缺少部分可选字段 +- **THEN** 客户端按缺省值解析,不因可选字段缺失而抛出异常 + +### Requirement: OAuth 登录流程 + +系统 SHALL 允许管理员从管理端发起 Codex、Claude、Gemini、xAI、Antigravity、Kimi 的 OAuth 登录。发起登录时系统 MUST 调用 CLIProxyAPI 对应的授权地址端点并默认携带 `is_webui=true`,将返回的授权地址与会话标识返回管理端。系统 SHALL 提供登录状态查询,透传 CLIProxyAPI 的登录状态。当登录状态为成功时,系统 MUST 触发该实例的账号同步。系统 MUST NOT 在自身持久化 OAuth 登录会话。 + +#### Scenario: 发起 OAuth 登录 + +- **WHEN** 管理员对某实例选择服务商并发起 OAuth 登录 +- **THEN** 系统返回 CLIProxyAPI 给出的授权地址与会话标识 + +#### Scenario: 轮询登录进行中 + +- **WHEN** 管理员持会话标识查询登录状态且 CLIProxyAPI 返回进行中 +- **THEN** 系统返回进行中状态,供管理端继续轮询 + +#### Scenario: 登录成功触发同步 + +- **WHEN** 登录状态查询返回成功 +- **THEN** 系统触发该实例的账号同步,使新登录账号进入缓存表 + +#### Scenario: 登录失败返回错误 + +- **WHEN** CLIProxyAPI 返回登录失败 +- **THEN** 系统返回失败状态与错误信息 + +#### Scenario: 六个 Provider 均可发起 + +- **WHEN** 管理员选择 xAI、Antigravity 或 Kimi 发起登录 +- **THEN** 系统使用对应的授权地址端点发起登录,流程与既有三个 Provider 一致 diff --git a/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-oauth-callback/spec.md b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-oauth-callback/spec.md new file mode 100644 index 00000000..aec1b1e5 --- /dev/null +++ b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/specs/cliproxy-oauth-callback/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: 管理 API 客户端 OAuth 回调提交 + +系统 SHALL 在 CLIProxyAPI 管理 API 客户端中新增 OAuth 回调 URL 提交方法。该方法 MUST 调用 `POST /v0/management/oauth-callback`,请求体包含 `provider` 和 `redirect_url` 字段。 + +#### Scenario: 提交回调 URL + +- **WHEN** 调用 OAuth 回调提交方法,传入 Provider 和回调 URL +- **THEN** 客户端向 CLIProxyAPI 发送包含 provider 和 redirect_url 的 POST 请求 + +#### Scenario: CLIProxyAPI 返回错误 + +- **WHEN** CLIProxyAPI 拒绝回调 URL(例如格式错误或 state 过期) +- **THEN** 客户端返回可识别的服务错误 + +### Requirement: OAuth 回调 Admin API + +系统 SHALL 提供 OAuth 回调提交 Admin API `POST /api/admin/cliproxy/instances/:id/oauth-callback`。请求体 MUST 包含 `provider` 和 `redirect_url` 字段。端点 SHALL 将回调透传至 CLIProxyAPI,成功后触发该实例的账号同步。 + +#### Scenario: 提交回调成功 + +- **WHEN** 管理员提交有效的回调 URL +- **THEN** 系统透传至 CLIProxyAPI,成功后触发账号同步并返回同步结果 + +#### Scenario: 缺少必填字段 + +- **WHEN** 请求体缺少 provider 或 redirect_url +- **THEN** 系统返回 400 参数校验错误 + +#### Scenario: 操作不存在的实例 + +- **WHEN** 请求指向不存在的实例 ID +- **THEN** 系统返回 404 实例不存在错误 + +### Requirement: OAuth 回调提交前端 + +系统 SHALL 在 OAuth 登录弹窗中提供手动提交回调 URL 的入口。当 OAuth 登录超时或失败时,弹窗 MUST 展示回调 URL 输入区域,允许管理员粘贴从浏览器地址栏获取的回调 URL。提交后 MUST 刷新账号列表。 + +#### Scenario: 超时后展示回调输入 + +- **WHEN** OAuth 登录轮询超时或返回错误 +- **THEN** 弹窗展示回调 URL 输入框和提交按钮 + +#### Scenario: 手动提交回调成功 + +- **WHEN** 管理员粘贴回调 URL 并提交 +- **THEN** 系统调用回调 API,成功后关闭弹窗、刷新账号列表并提示成功 + +#### Scenario: 提交空回调 URL + +- **WHEN** 管理员未填写回调 URL 即提交 +- **THEN** 前端阻止提交并提示必填 diff --git a/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/tasks.md b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/tasks.md new file mode 100644 index 00000000..d10cc334 --- /dev/null +++ b/openspec/changes/archive/2026-05-31-enhance-cliproxy-management/tasks.md @@ -0,0 +1,68 @@ +## 1. 管理 API 客户端扩展 + +- [x] 1.1 在 `cliproxy-management-client.ts` 中扩展 `CLIPROXY_OAUTH_PROVIDERS` 和 `AUTH_URL_ENDPOINT`,新增 xAI、Antigravity、Kimi 三个 Provider;同步更新前端 `src/types/cliproxy.ts` 中的 `CliproxyProvider` 类型和 `CLIPROXY_PROVIDERS` 常量;编写对应的单元测试验证 `isCliproxyOAuthProvider` 对新 Provider 的识别 +- [x] 1.2 在 `cliproxy-management-client.ts` 中新增 `deleteAuthFile`、`uploadAuthFile`、`downloadAuthFile` 三个方法;编写单元测试覆盖成功、鉴权失败、超时三种情况 +- [x] 1.3 在 `cliproxy-management-client.ts` 中新增 `submitOAuthCallback` 方法;编写单元测试 +- [x] 1.4 在 `cliproxy-management-client.ts` 中新增 `getLogs` 方法(支持可选 `since` 参数);编写单元测试 + +## 2. 后端服务层扩展 + +- [x] 2.1 在 `cliproxy-auth-account-service.ts` 中新增 `deleteCliproxyAuthAccount`、`uploadCliproxyAuthFile`、`downloadCliproxyAuthFile`、`listCliproxyAccountModels` 服务方法;新增 `cliproxy-instance-logs-service.ts` 与 `cliproxy-linked-upstreams-service.ts` 两个独立服务;编写单元测试覆盖删除成功/失败/无缓存、上传后同步、下载、模型查询、日志获取、关联上游分类等场景 +- [x] 2.2 在 `cliproxy-oauth-login-service.ts` 中新增 `submitCliproxyOAuthCallback`;OAuth Provider 通过 `isCliproxyOAuthProvider` 已自动覆盖新增的 xAI/Antigravity/Kimi,无需单独改动 Error 类型 + +## 3. Admin API 路由 + +- [x] 3.1 新增 `instances/[id]/auth-files/route.ts`(POST: 上传认证文件,上传后触发同步并返回同步结果);编写路由测试 +- [x] 3.2 新增 `instances/[id]/auth-files/[name]/route.ts`(GET: 下载认证文件,DELETE: 删除认证文件);编写路由测试 +- [x] 3.3 新增 `instances/[id]/oauth-callback/route.ts`(POST: 提交回调 URL,成功后触发同步);编写路由测试 +- [x] 3.4 新增 `instances/[id]/logs/route.ts`(GET: 查询实例日志,支持 `since` 查询参数);编写路由测试 +- [x] 3.5 新增 `instances/[id]/linked-upstreams/route.ts`(GET: 查询关联上游列表);编写路由测试 +- [x] 3.6 新增 `instances/[id]/auth-accounts/[accountName]/models/route.ts`(GET: 查询账号模型列表);编写路由测试 + +## 4. 前端 hooks 与类型 + +- [x] 4.1 在 `use-cliproxy.ts` 中新增 `useUploadCliproxyAuthFile`、`useDownloadCliproxyAuthFile`、`useDeleteCliproxyAuthFile` 三个 mutation hooks +- [x] 4.2 在 `use-cliproxy.ts` 中新增 `useCliproxyAccountModels` query hook(按账号名查询模型列表) +- [x] 4.3 在 `use-cliproxy.ts` 中新增 `useSubmitCliproxyOAuthCallback` mutation hook +- [x] 4.4 在 `use-cliproxy.ts` 中新增 `useCliproxyInstanceLogs` query hook(支持 `since` 参数)和 `useCliproxyLinkedUpstreams` query hook +- [x] 4.5 在 `use-cliproxy.ts` 中新增 `useToggleCliproxyInstanceEnabled` mutation hook(调用实例更新 API 仅修改 enabled 字段) +- [x] 4.6 在 `src/types/cliproxy.ts` 中新增 `CliproxyAuthFileModel`、`CliproxyLogEntry`、`CliproxyLinkedUpstream` 等类型定义 + +## 5. 前端组件:实例表格增强与关联上游 + +- [x] 5.1 修改 `cliproxy-instances-table.tsx`,将状态列的 Badge 替换为 Switch 组件,实现行内启停切换;启停 mutation 由 `useToggleCliproxyInstanceEnabled` 提供 +- [x] 5.2 新增 `cliproxy-linked-upstreams-panel.tsx`,展示关联上游列表(上游名称、服务商、类型、绑定账号、状态);在 `page.tsx` 中挂载该面板,选中实例后显示 +- [x] 5.3 编写 `cliproxy-linked-upstreams-panel` 组件测试 + +## 6. 前端组件:账号管理增强 + +- [x] 6.1 新增 `cliproxy-account-models-dialog.tsx`,弹窗展示账号可用模型列表(模型 ID、显示名称);在 `cliproxy-accounts-table.tsx` 的模型数量列添加可点击入口 +- [x] 6.2 新增 `cliproxy-account-detail-dialog.tsx`,弹窗展示账号全部元数据(email、status、raw_metadata、last_synced_at 等);在 `cliproxy-accounts-table.tsx` 行菜单中添加"详情"操作 +- [x] 6.3 修改 `cliproxy-accounts-table.tsx`,新增 email 列展示;在行菜单中新增"详情/模型/下载/删除"四个操作入口 +- [x] 6.4 编写 `cliproxy-account-models-dialog` 和 `cliproxy-account-detail-dialog` 组件测试 + +## 7. 前端组件:认证文件操作 + +- [x] 7.1 新增 `cliproxy-auth-file-upload-dialog.tsx`,支持 JSON 文件选择和 JSON 文本粘贴两种上传方式,提交前校验 JSON 合法性;在 `cliproxy-accounts-panel.tsx` 中添加上传按钮 +- [x] 7.2 新增 `cliproxy-delete-auth-file-dialog.tsx`,确认弹窗删除认证文件;下载功能在账号面板的 handleDownload 中通过 useDownloadCliproxyAuthFile 触发浏览器原生下载 +- [x] 7.3 编写上传弹窗组件测试(删除弹窗逻辑简单,覆盖在路由测试中) + +## 8. 前端组件:OAuth 回调与 Provider 扩展 + +- [x] 8.1 OAuth Provider 已通过 Task 1 的类型扩展自动覆盖 6 个选项;OAuth 登录弹窗在登录超时/失败状态区域追加了手动回调 URL 输入框与提交按钮 +- [x] 8.2 OAuth 登录弹窗现有测试已适配新增的 useSubmitCliproxyOAuthCallback hook + +## 9. 前端组件:日志面板 + +- [x] 9.1 新增 `cliproxy-instance-logs-panel.tsx`,包含刷新按钮、关键词搜索输入框、等宽字体日志显示区域;在 `page.tsx` 中挂载该面板,选中实例后显示 +- [x] 9.2 编写日志面板组件测试 + +## 10. 国际化 + +- [x] 10.1 在 `en.json` 和 `zh-CN.json` 的 `cliproxy` 命名空间中补充所有新增功能的国际化文案,覆盖新 Provider 名称、认证文件操作、日志面板、关联上游面板、账号详情、账号模型、OAuth 回调等全部新增 UI 文案 + +## 11. 集成验证 + +- [x] 11.1 运行 `pnpm lint` 和 `pnpm exec tsc --noEmit` 确保代码质量 +- [x] 11.2 运行 `pnpm test:run` 确保全部测试通过(27 个 cliproxy 测试文件、214 个测试全部通过) +- [ ] 11.3 启动开发服务器,在浏览器中验证所有新增功能的完整交互流程(留待人工验证) diff --git a/openspec/specs/cliproxy-admin-ui/spec.md b/openspec/specs/cliproxy-admin-ui/spec.md index 5f9cd616..f2325243 100644 --- a/openspec/specs/cliproxy-admin-ui/spec.md +++ b/openspec/specs/cliproxy-admin-ui/spec.md @@ -67,7 +67,7 @@ TBD - created by archiving change cliproxy-admin-ui. Update Purpose after archiv ### Requirement: OAuth 登录流程界面 -系统 SHALL 提供 OAuth 登录流程界面,以弹窗形式发起 Codex、Claude、Gemini 的 OAuth 登录。弹窗 MUST 展示发起登录接口返回的授权地址,并提供在新标签页打开授权地址与复制授权地址的操作。系统 SHALL 按固定间隔轮询登录状态。由于发起登录接口不返回过期时间,系统 MUST 以客户端固定超时上限作为轮询的硬性截止。登录成功时 MUST 关闭弹窗并刷新账号列表;失败或达到超时上限时 MUST 停止轮询并展示可理解的错误与重新发起入口。 +系统 SHALL 提供 OAuth 登录流程界面,以弹窗形式发起 Codex、Claude、Gemini、xAI、Antigravity、Kimi 的 OAuth 登录。弹窗 MUST 展示发起登录接口返回的授权地址,并提供在新标签页打开授权地址与复制授权地址的操作。系统 SHALL 按固定间隔轮询登录状态。由于发起登录接口不返回过期时间,系统 MUST 以客户端固定超时上限作为轮询的硬性截止。登录成功时 MUST 关闭弹窗并刷新账号列表;失败或达到超时上限时 MUST 停止轮询并展示可理解的错误、重新发起入口以及手动提交回调 URL 的入口。 #### Scenario: 发起 OAuth 登录 @@ -82,13 +82,18 @@ TBD - created by archiving change cliproxy-admin-ui. Update Purpose after archiv #### Scenario: 登录超时 - **WHEN** 轮询达到客户端固定超时上限仍未完成 -- **THEN** 停止轮询,展示超时错误与重新发起登录的入口 +- **THEN** 停止轮询,展示超时错误、重新发起登录的入口以及手动提交回调 URL 的输入区域 #### Scenario: 关闭弹窗停止轮询 - **WHEN** 管理员在登录完成前主动关闭弹窗 - **THEN** 轮询停止 +#### Scenario: 服务商选项覆盖六个 Provider + +- **WHEN** 管理员打开 OAuth 登录弹窗的服务商选择器 +- **THEN** 列出 Codex、Claude、Gemini、xAI、Antigravity、Kimi 六个选项 + ### Requirement: CLI OAuth 上游创建入口 系统 SHALL 在实例行操作中提供按服务商一键创建池上游的入口,在账号行操作中提供将单个账号固定映射为上游的入口。两类创建操作 MUST 经确认后执行,成功后 MUST 提示成功。 @@ -103,3 +108,55 @@ TBD - created by archiving change cliproxy-admin-ui. Update Purpose after archiv - **WHEN** 管理员在某账号行选择映射为上游并确认 - **THEN** 系统调用单账号上游创建 API,成功后提示成功 +### Requirement: 实例行内启停切换 + +系统 SHALL 在实例表格的状态列中以 Switch 组件替代原有的纯 Badge 展示,允许管理员直接在行内切换实例的启用/停用状态。切换 MUST 调用实例更新 API 仅修改 `enabled` 字段,成功后刷新实例列表并提示成功,失败时回滚 Switch 状态并提示错误。 + +#### Scenario: 启用实例 + +- **WHEN** 管理员将某停用实例的 Switch 切换为启用 +- **THEN** 系统调用实例更新 API 将 enabled 设为 true,成功后实例状态更新 + +#### Scenario: 停用实例 + +- **WHEN** 管理员将某启用实例的 Switch 切换为停用 +- **THEN** 系统调用实例更新 API 将 enabled 设为 false,成功后实例状态更新 + +#### Scenario: 切换失败回滚 + +- **WHEN** 实例更新 API 调用失败 +- **THEN** Switch 组件回滚到切换前状态,界面提示错误 + +### Requirement: 关联上游面板 + +系统 SHALL 在选中 CLIProxyAPI 实例后展示关联上游面板,列出该实例下所有关联的池上游和单账号上游。面板数据 MUST 来源于 AutoRouter 本地 `upstreams` 表中 `cliproxyInstanceId` 匹配所选实例的记录。每行 MUST 展示上游名称、服务商、类型(池上游/单账号上游)和绑定的账号文件名(如有)。 + +#### Scenario: 展示关联上游 + +- **WHEN** 管理员选中某实例且该实例存在关联上游 +- **THEN** 关联上游面板展示该实例下所有关联上游的列表 + +#### Scenario: 无关联上游 + +- **WHEN** 管理员选中某实例且该实例无关联上游 +- **THEN** 面板展示"暂无关联上游"提示 + +#### Scenario: 区分上游类型 + +- **WHEN** 关联上游列表包含池上游和单账号上游 +- **THEN** 列表以不同标签区分两种类型,单账号上游额外展示绑定的账号文件名 + +### Requirement: 关联上游查询 Admin API + +系统 SHALL 提供关联上游查询 Admin API `GET /api/admin/cliproxy/instances/:id/linked-upstreams`。该端点 MUST 复用既有 Admin 鉴权机制。端点 SHALL 查询 `upstreams` 表中 `cliproxyInstanceId` 等于指定实例 ID 的记录,并返回上游名称、ID、服务商、类型和绑定的账号文件名。 + +#### Scenario: 查询关联上游 + +- **WHEN** 管理员请求某实例的关联上游列表 +- **THEN** 系统返回该实例下所有关联上游的信息 + +#### Scenario: 实例无关联上游 + +- **WHEN** 请求的实例无关联上游 +- **THEN** 系统返回空数组 + diff --git a/openspec/specs/cliproxy-auth-file-operations/spec.md b/openspec/specs/cliproxy-auth-file-operations/spec.md new file mode 100644 index 00000000..c648be57 --- /dev/null +++ b/openspec/specs/cliproxy-auth-file-operations/spec.md @@ -0,0 +1,101 @@ +# cliproxy-auth-file-operations Specification + +## Purpose +TBD - created by archiving change enhance-cliproxy-management. Update Purpose after archive. +## Requirements +### Requirement: 管理 API 客户端认证文件操作 + +系统 SHALL 在 CLIProxyAPI 管理 API 客户端中新增三个方法:上传认证文件、下载认证文件、删除认证文件。三个方法 MUST 复用现有的鉴权、超时和错误处理机制。 + +#### Scenario: 上传认证文件 + +- **WHEN** 调用上传认证文件方法,传入目标实例和 JSON 内容 +- **THEN** 客户端向 CLIProxyAPI 发送 `POST /v0/management/auth-files`,请求体为 JSON 内容 + +#### Scenario: 下载认证文件 + +- **WHEN** 调用下载认证文件方法,传入目标实例和账号文件名 +- **THEN** 客户端向 CLIProxyAPI 发送 `GET /v0/management/auth-files/download?name=<文件名>`,返回原始 JSON 文本 + +#### Scenario: 删除认证文件 + +- **WHEN** 调用删除认证文件方法,传入目标实例和账号文件名 +- **THEN** 客户端向 CLIProxyAPI 发送 `DELETE /v0/management/auth-files`,请求体包含 `{ name: <文件名> }` + +### Requirement: 认证文件删除服务 + +系统 SHALL 提供认证文件删除服务方法,先调用 CLIProxyAPI 删除上游文件,成功后删除本地 `cliproxy_auth_accounts` 缓存表中对应的记录。CLIProxyAPI 侧删除失败时 MUST 整体失败,MUST NOT 触及本地缓存。 + +#### Scenario: 删除成功并清理缓存 + +- **WHEN** 管理员请求删除某实例下的某个认证文件 +- **THEN** 系统先调用 CLIProxyAPI 删除该文件,成功后从本地缓存表中移除对应记录 + +#### Scenario: CLIProxyAPI 删除失败 + +- **WHEN** CLIProxyAPI 删除认证文件请求返回错误 +- **THEN** 系统返回错误,本地缓存保持不变 + +#### Scenario: 本地无缓存记录 + +- **WHEN** CLIProxyAPI 删除成功,但本地缓存表中无对应记录 +- **THEN** 系统正常返回成功,不报错 + +### Requirement: 认证文件管理 Admin API + +系统 SHALL 提供认证文件管理 Admin API,包含上传、下载、删除三个端点。所有端点 MUST 复用既有 Admin 鉴权机制(Bearer ADMIN_TOKEN)。 + +#### Scenario: 上传认证文件 + +- **WHEN** 管理员向 `POST /api/admin/cliproxy/instances/:id/auth-files` 提交 JSON 内容 +- **THEN** 系统将内容透传至 CLIProxyAPI 上传端点,成功后触发该实例的账号同步并返回同步结果 + +#### Scenario: 下载认证文件 + +- **WHEN** 管理员请求 `GET /api/admin/cliproxy/instances/:id/auth-files/:name` +- **THEN** 系统从 CLIProxyAPI 下载该文件并以 `application/json` 返回原始内容 + +#### Scenario: 删除认证文件 + +- **WHEN** 管理员请求 `DELETE /api/admin/cliproxy/instances/:id/auth-files/:name` +- **THEN** 系统调用删除服务方法,成功后返回已删除的文件名 + +#### Scenario: 操作不存在的实例 + +- **WHEN** 请求指向不存在的实例 ID +- **THEN** 系统返回 404 实例不存在错误 + +#### Scenario: 缺少管理鉴权 + +- **WHEN** 请求未携带有效的 ADMIN_TOKEN Bearer 凭据 +- **THEN** 系统返回 401 鉴权失败错误 + +### Requirement: 认证文件管理前端 + +系统 SHALL 在账号面板中提供认证文件上传按钮,点击后打开上传弹窗。上传弹窗 MUST 接受 JSON 文件选择或 JSON 文本粘贴。系统 SHALL 在账号行操作菜单中提供下载和删除操作。下载 MUST 触发浏览器文件下载。删除 MUST 经确认弹窗确认后执行。 + +#### Scenario: 上传认证文件 + +- **WHEN** 管理员在上传弹窗中选择 JSON 文件或粘贴 JSON 文本并提交 +- **THEN** 系统调用上传 API,成功后刷新账号列表并提示成功 + +#### Scenario: 上传无效 JSON + +- **WHEN** 管理员提交的内容不是合法 JSON +- **THEN** 前端阻止提交并提示格式错误 + +#### Scenario: 下载认证文件 + +- **WHEN** 管理员在某账号行选择下载 +- **THEN** 浏览器下载该账号的原始 JSON 文件,文件名为账号文件名 + +#### Scenario: 删除认证文件 + +- **WHEN** 管理员在某账号行选择删除并在确认弹窗中确认 +- **THEN** 系统调用删除 API,成功后刷新账号列表并提示成功 + +#### Scenario: 取消删除 + +- **WHEN** 管理员在确认弹窗中取消 +- **THEN** 不执行删除操作 + diff --git a/openspec/specs/cliproxy-instance-logs/spec.md b/openspec/specs/cliproxy-instance-logs/spec.md new file mode 100644 index 00000000..e70ac373 --- /dev/null +++ b/openspec/specs/cliproxy-instance-logs/spec.md @@ -0,0 +1,72 @@ +# cliproxy-instance-logs Specification + +## Purpose +TBD - created by archiving change enhance-cliproxy-management. Update Purpose after archive. +## Requirements +### Requirement: 管理 API 客户端日志查询 + +系统 SHALL 在 CLIProxyAPI 管理 API 客户端中新增日志查询方法。该方法 MUST 调用 `GET /v0/management/logs`,支持可选的时间戳参数用于增量拉取。响应 MUST 解析为日志条目数组。 + +#### Scenario: 查询全部日志 + +- **WHEN** 调用日志查询方法且不传入时间戳参数 +- **THEN** 客户端向 CLIProxyAPI 发送不带时间戳过滤的日志查询请求 + +#### Scenario: 增量查询日志 + +- **WHEN** 调用日志查询方法并传入起始时间戳 +- **THEN** 客户端在请求中携带时间戳参数,仅返回该时间点之后的日志 + +#### Scenario: CLIProxyAPI 不支持日志端点 + +- **WHEN** CLIProxyAPI 返回 404 或其他不支持的状态 +- **THEN** 客户端返回可识别的服务错误,不抛出未分类异常 + +### Requirement: 实例日志 Admin API + +系统 SHALL 提供实例日志查询 Admin API `GET /api/admin/cliproxy/instances/:id/logs`。该端点 MUST 复用既有 Admin 鉴权机制。端点 SHALL 支持 `since` 查询参数(ISO 时间戳),将其透传至 CLIProxyAPI 日志查询端点。 + +#### Scenario: 查询实例日志 + +- **WHEN** 管理员请求某实例的日志 +- **THEN** 系统从 CLIProxyAPI 拉取日志并返回日志条目数组 + +#### Scenario: 带时间范围查询 + +- **WHEN** 管理员在请求中携带 `since` 时间戳 +- **THEN** 系统仅返回该时间点之后的日志 + +#### Scenario: 操作不存在的实例 + +- **WHEN** 请求指向不存在的实例 ID +- **THEN** 系统返回 404 实例不存在错误 + +### Requirement: 实例日志查看前端 + +系统 SHALL 在 CLIProxyAPI 页面选中实例后展示日志面板 Card。日志面板 MUST 以等宽字体渲染日志文本。面板 SHALL 提供手动刷新按钮和关键词搜索输入框(前端过滤)。面板 SHALL 在首次显示时自动拉取一次日志。 + +#### Scenario: 查看实例日志 + +- **WHEN** 管理员选中某实例 +- **THEN** 日志面板自动拉取并展示该实例的最新日志 + +#### Scenario: 手动刷新 + +- **WHEN** 管理员点击刷新按钮 +- **THEN** 面板重新拉取日志并更新显示 + +#### Scenario: 关键词搜索 + +- **WHEN** 管理员在搜索框中输入关键词 +- **THEN** 日志列表仅显示包含该关键词的行 + +#### Scenario: 日志为空 + +- **WHEN** CLIProxyAPI 返回空日志 +- **THEN** 面板展示"暂无日志"提示 + +#### Scenario: 切换实例 + +- **WHEN** 管理员切换到另一个实例 +- **THEN** 日志面板清空并拉取新实例的日志 + diff --git a/openspec/specs/cliproxy-oauth-account-management/spec.md b/openspec/specs/cliproxy-oauth-account-management/spec.md index 9ad72427..15e5c4f7 100644 --- a/openspec/specs/cliproxy-oauth-account-management/spec.md +++ b/openspec/specs/cliproxy-oauth-account-management/spec.md @@ -5,7 +5,7 @@ TBD - created by archiving change cliproxy-oauth-account-management. Update Purp ## Requirements ### Requirement: CLIProxyAPI 管理 API 客户端 -系统 SHALL 提供单一的 CLIProxyAPI 管理 API 客户端模块,集中封装本能力所需的全部管理端点调用。客户端 MUST 使用 `Authorization: Bearer` 形式注入管理密钥,MUST 为请求设置超时上限,并 MUST 对响应缺失字段做容错解析。封装范围 SHALL 覆盖列出 auth-files、查询某 auth-file 的模型、更新账号启用状态、更新账号字段、获取 OAuth 授权地址、查询 OAuth 登录状态。 +系统 SHALL 提供单一的 CLIProxyAPI 管理 API 客户端模块,集中封装本能力所需的全部管理端点调用。客户端 MUST 使用 `Authorization: Bearer` 形式注入管理密钥,MUST 为请求设置超时上限,并 MUST 对响应缺失字段做容错解析。封装范围 SHALL 覆盖列出 auth-files、查询某 auth-file 的模型、更新账号启用状态、更新账号字段、获取 OAuth 授权地址、查询 OAuth 登录状态、上传认证文件、下载认证文件、删除认证文件、提交 OAuth 回调、查询实例日志。 #### Scenario: 携带管理密钥调用管理 API @@ -57,7 +57,7 @@ TBD - created by archiving change cliproxy-oauth-account-management. Update Purp ### Requirement: OAuth 登录流程 -系统 SHALL 允许管理员从管理端发起 Codex、Claude、Gemini 的 OAuth 登录。发起登录时系统 MUST 调用 CLIProxyAPI 对应的授权地址端点并默认携带 `is_webui=true`,将返回的授权地址与会话标识返回管理端。系统 SHALL 提供登录状态查询,透传 CLIProxyAPI 的登录状态。当登录状态为成功时,系统 MUST 触发该实例的账号同步。系统 MUST NOT 在自身持久化 OAuth 登录会话。 +系统 SHALL 允许管理员从管理端发起 Codex、Claude、Gemini、xAI、Antigravity、Kimi 的 OAuth 登录。发起登录时系统 MUST 调用 CLIProxyAPI 对应的授权地址端点并默认携带 `is_webui=true`,将返回的授权地址与会话标识返回管理端。系统 SHALL 提供登录状态查询,透传 CLIProxyAPI 的登录状态。当登录状态为成功时,系统 MUST 触发该实例的账号同步。系统 MUST NOT 在自身持久化 OAuth 登录会话。 #### Scenario: 发起 OAuth 登录 @@ -79,6 +79,11 @@ TBD - created by archiving change cliproxy-oauth-account-management. Update Purp - **WHEN** CLIProxyAPI 返回登录失败 - **THEN** 系统返回失败状态与错误信息 +#### Scenario: 六个 Provider 均可发起 + +- **WHEN** 管理员选择 xAI、Antigravity 或 Kimi 发起登录 +- **THEN** 系统使用对应的授权地址端点发起登录,流程与既有三个 Provider 一致 + ### Requirement: OAuth 账号启停与字段管理 系统 SHALL 允许管理员启停某个 OAuth 账号,以及设置账号的前缀、出站代理、优先级、备注。启停 MUST 调用 CLIProxyAPI 的账号状态端点,字段设置 MUST 调用 CLIProxyAPI 的账号字段端点。操作在 CLIProxyAPI 成功后,系统 SHALL 同步更新本地缓存表对应字段。 @@ -126,3 +131,64 @@ TBD - created by archiving change cliproxy-oauth-account-management. Update Purp - **WHEN** 管理员对一个不存在的实例发起账号同步或 OAuth 登录 - **THEN** 系统返回实例不存在错误 +### Requirement: 账号模型列表查看 + +系统 SHALL 在前端账号表格中提供每个账号的模型列表查看入口。点击后 MUST 调用后端 API 获取该账号在 CLIProxyAPI 侧的可用模型列表,并以弹窗或展开行形式展示。模型列表 MUST 展示模型 ID 和显示名称。 + +#### Scenario: 查看账号模型列表 + +- **WHEN** 管理员点击某账号的模型数量或模型查看操作 +- **THEN** 系统从 CLIProxyAPI 实时查询该账号的可用模型列表并展示 + +#### Scenario: 模型查询失败 + +- **WHEN** 模型列表查询调用 CLIProxyAPI 失败 +- **THEN** 弹窗展示可理解的错误信息 + +#### Scenario: 账号无可用模型 + +- **WHEN** CLIProxyAPI 返回空模型列表 +- **THEN** 弹窗展示"该账号暂无可用模型"提示 + +### Requirement: 账号模型列表 Admin API + +系统 SHALL 提供账号模型列表查询 Admin API `GET /api/admin/cliproxy/instances/:id/auth-accounts/:name/models`。该端点 MUST 复用既有 Admin 鉴权机制。端点 SHALL 调用 CLIProxyAPI 管理 API 客户端的 `getAuthFileModels` 方法查询指定账号的模型列表。 + +#### Scenario: 查询账号模型列表 + +- **WHEN** 管理员请求某实例下某账号的模型列表 +- **THEN** 系统从 CLIProxyAPI 查询并返回该账号的可用模型数组 + +#### Scenario: CLIProxyAPI 查询失败 + +- **WHEN** CLIProxyAPI 模型查询端点返回错误 +- **THEN** 系统返回对应的管理 API 错误 + +### Requirement: 账号详情查看 + +系统 SHALL 在前端账号表格的行操作菜单中提供详情查看入口。详情弹窗 MUST 展示账号的全部元数据:账号文件名、服务商、邮箱、CLIProxyAPI 侧状态(status 字段)、启用/停用状态、前缀、优先级、备注、模型数量、原始元数据快照(raw_metadata 中的各字段)、最近同步时间、创建时间、更新时间。 + +#### Scenario: 查看账号详情 + +- **WHEN** 管理员在某账号行选择查看详情 +- **THEN** 弹窗展示该账号的全部元数据 + +#### Scenario: 元数据字段为空 + +- **WHEN** 某些可选字段(email、status、prefix、note 等)为空 +- **THEN** 弹窗中对应位置展示占位符或"未设置"标记 + +### Requirement: 账号表格信息增强 + +系统 SHALL 在账号表格中增加 email 列的展示。email 列 MUST 展示账号的邮箱地址,为空时显示占位符。 + +#### Scenario: 展示账号邮箱 + +- **WHEN** 账号存在邮箱地址 +- **THEN** 表格中 email 列展示该邮箱 + +#### Scenario: 邮箱为空 + +- **WHEN** 账号邮箱为空 +- **THEN** 表格中 email 列展示"—"占位符 + diff --git a/openspec/specs/cliproxy-oauth-callback/spec.md b/openspec/specs/cliproxy-oauth-callback/spec.md new file mode 100644 index 00000000..9aee47f8 --- /dev/null +++ b/openspec/specs/cliproxy-oauth-callback/spec.md @@ -0,0 +1,57 @@ +# cliproxy-oauth-callback Specification + +## Purpose +TBD - created by archiving change enhance-cliproxy-management. Update Purpose after archive. +## Requirements +### Requirement: 管理 API 客户端 OAuth 回调提交 + +系统 SHALL 在 CLIProxyAPI 管理 API 客户端中新增 OAuth 回调 URL 提交方法。该方法 MUST 调用 `POST /v0/management/oauth-callback`,请求体包含 `provider` 和 `redirect_url` 字段。 + +#### Scenario: 提交回调 URL + +- **WHEN** 调用 OAuth 回调提交方法,传入 Provider 和回调 URL +- **THEN** 客户端向 CLIProxyAPI 发送包含 provider 和 redirect_url 的 POST 请求 + +#### Scenario: CLIProxyAPI 返回错误 + +- **WHEN** CLIProxyAPI 拒绝回调 URL(例如格式错误或 state 过期) +- **THEN** 客户端返回可识别的服务错误 + +### Requirement: OAuth 回调 Admin API + +系统 SHALL 提供 OAuth 回调提交 Admin API `POST /api/admin/cliproxy/instances/:id/oauth-callback`。请求体 MUST 包含 `provider` 和 `redirect_url` 字段。端点 SHALL 将回调透传至 CLIProxyAPI,成功后触发该实例的账号同步。 + +#### Scenario: 提交回调成功 + +- **WHEN** 管理员提交有效的回调 URL +- **THEN** 系统透传至 CLIProxyAPI,成功后触发账号同步并返回同步结果 + +#### Scenario: 缺少必填字段 + +- **WHEN** 请求体缺少 provider 或 redirect_url +- **THEN** 系统返回 400 参数校验错误 + +#### Scenario: 操作不存在的实例 + +- **WHEN** 请求指向不存在的实例 ID +- **THEN** 系统返回 404 实例不存在错误 + +### Requirement: OAuth 回调提交前端 + +系统 SHALL 在 OAuth 登录弹窗中提供手动提交回调 URL 的入口。当 OAuth 登录超时或失败时,弹窗 MUST 展示回调 URL 输入区域,允许管理员粘贴从浏览器地址栏获取的回调 URL。提交后 MUST 刷新账号列表。 + +#### Scenario: 超时后展示回调输入 + +- **WHEN** OAuth 登录轮询超时或返回错误 +- **THEN** 弹窗展示回调 URL 输入框和提交按钮 + +#### Scenario: 手动提交回调成功 + +- **WHEN** 管理员粘贴回调 URL 并提交 +- **THEN** 系统调用回调 API,成功后关闭弹窗、刷新账号列表并提示成功 + +#### Scenario: 提交空回调 URL + +- **WHEN** 管理员未填写回调 URL 即提交 +- **THEN** 前端阻止提交并提示必填 + diff --git a/src/app/[locale]/(dashboard)/system/cliproxy/page.tsx b/src/app/[locale]/(dashboard)/system/cliproxy/page.tsx index dac74048..09c5281b 100644 --- a/src/app/[locale]/(dashboard)/system/cliproxy/page.tsx +++ b/src/app/[locale]/(dashboard)/system/cliproxy/page.tsx @@ -13,6 +13,8 @@ import { DeleteCliproxyInstanceDialog } from "@/components/admin/delete-cliproxy import { CliproxyConnectionTestDialog } from "@/components/admin/cliproxy-connection-test-dialog"; import { CliproxyAccountsPanel } from "@/components/admin/cliproxy-accounts-panel"; import { CliproxyPoolUpstreamDialog } from "@/components/admin/cliproxy-pool-upstream-dialog"; +import { CliproxyLinkedUpstreamsPanel } from "@/components/admin/cliproxy-linked-upstreams-panel"; +import { CliproxyInstanceLogsPanel } from "@/components/admin/cliproxy-instance-logs-panel"; import { useCliproxyInstances } from "@/hooks/use-cliproxy"; import type { CliproxyInstance } from "@/types/cliproxy"; @@ -77,7 +79,11 @@ export default function CliproxyPage() { {selectedInstance ? ( - + <> + + + + ) : instances && instances.length > 0 ? ( diff --git a/src/app/api/admin/cliproxy/instances/[id]/auth-accounts/[accountName]/models/route.ts b/src/app/api/admin/cliproxy/instances/[id]/auth-accounts/[accountName]/models/route.ts new file mode 100644 index 00000000..9aec5f9d --- /dev/null +++ b/src/app/api/admin/cliproxy/instances/[id]/auth-accounts/[accountName]/models/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAdminAuth } from "@/lib/utils/auth"; +import { errorResponse } from "@/lib/utils/api-auth"; +import { listCliproxyAccountModels } from "@/lib/services/cliproxy-auth-account-service"; +import { handleCliproxyRouteError } from "@/lib/utils/cliproxy-route-errors"; +import { createLogger } from "@/lib/utils/logger"; + +const log = createLogger("admin-cliproxy-account-models"); + +type RouteContext = { params: Promise<{ id: string; accountName: string }> }; + +/** + * GET /api/admin/cliproxy/instances/:id/auth-accounts/:accountName/models - + * 查询账号在 CLIProxyAPI 侧的可用模型列表。 + * + * 本端点为只读窗口,不写入任何本地缓存。 + */ +export async function GET(request: NextRequest, context: RouteContext): Promise { + if (!validateAdminAuth(request.headers.get("authorization"))) { + return errorResponse("Unauthorized", 401); + } + + const { id, accountName } = await context.params; + + try { + const models = await listCliproxyAccountModels(id, decodeURIComponent(accountName)); + return NextResponse.json({ data: models }); + } catch (err) { + const mapped = handleCliproxyRouteError(err); + if (mapped) { + return mapped; + } + log.error({ err }, "Failed to list CLIProxyAPI auth account models"); + return errorResponse("Internal server error", 500); + } +} diff --git a/src/app/api/admin/cliproxy/instances/[id]/auth-files/[name]/route.ts b/src/app/api/admin/cliproxy/instances/[id]/auth-files/[name]/route.ts new file mode 100644 index 00000000..a1f096de --- /dev/null +++ b/src/app/api/admin/cliproxy/instances/[id]/auth-files/[name]/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAdminAuth } from "@/lib/utils/auth"; +import { errorResponse } from "@/lib/utils/api-auth"; +import { + deleteCliproxyAuthAccount, + downloadCliproxyAuthFile, +} from "@/lib/services/cliproxy-auth-account-service"; +import { handleCliproxyRouteError } from "@/lib/utils/cliproxy-route-errors"; +import { createLogger } from "@/lib/utils/logger"; + +const log = createLogger("admin-cliproxy-auth-files"); + +type RouteContext = { params: Promise<{ id: string; name: string }> }; + +/** + * 按 RFC 6266 构造 Content-Disposition 头。 + * + * 文件名先过滤 CR/LF 与控制字符,避免响应头被拆分;同时输出 + * `filename`(ASCII fallback)与 `filename*`(UTF-8 百分号编码)两种形式, + * 让任意字符的认证文件名都能被浏览器安全保存。 + */ +function buildContentDisposition(filename: string): string { + const sanitized = filename.replace(/[\r\n\x00-\x1f]/g, "_"); + const asciiFallback = sanitized.replace(/[^\x20-\x7e]/g, "?").replace(/[\\"]/g, "_"); + const utf8Encoded = encodeURIComponent(sanitized); + return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${utf8Encoded}`; +} + +/** + * GET /api/admin/cliproxy/instances/:id/auth-files/:name - 下载认证文件原始 JSON。 + */ +export async function GET(request: NextRequest, context: RouteContext): Promise { + if (!validateAdminAuth(request.headers.get("authorization"))) { + return errorResponse("Unauthorized", 401); + } + + const { id, name } = await context.params; + const authFileName = decodeURIComponent(name); + + try { + const content = await downloadCliproxyAuthFile(id, authFileName); + return new NextResponse(JSON.stringify(content), { + status: 200, + headers: { + "Content-Type": "application/json", + "Content-Disposition": buildContentDisposition(authFileName), + }, + }); + } catch (err) { + const mapped = handleCliproxyRouteError(err); + if (mapped) { + return mapped; + } + log.error({ err }, "Failed to download CLIProxyAPI auth file"); + return errorResponse("Internal server error", 500); + } +} + +/** + * DELETE /api/admin/cliproxy/instances/:id/auth-files/:name - 删除认证文件并清理本地缓存。 + */ +export async function DELETE(request: NextRequest, context: RouteContext): Promise { + if (!validateAdminAuth(request.headers.get("authorization"))) { + return errorResponse("Unauthorized", 401); + } + + const { id, name } = await context.params; + const authFileName = decodeURIComponent(name); + + try { + await deleteCliproxyAuthAccount(id, authFileName); + return NextResponse.json({ data: { name: authFileName } }); + } catch (err) { + const mapped = handleCliproxyRouteError(err); + if (mapped) { + return mapped; + } + log.error({ err }, "Failed to delete CLIProxyAPI auth file"); + return errorResponse("Internal server error", 500); + } +} diff --git a/src/app/api/admin/cliproxy/instances/[id]/auth-files/route.ts b/src/app/api/admin/cliproxy/instances/[id]/auth-files/route.ts new file mode 100644 index 00000000..746ff9f0 --- /dev/null +++ b/src/app/api/admin/cliproxy/instances/[id]/auth-files/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAdminAuth } from "@/lib/utils/auth"; +import { errorResponse } from "@/lib/utils/api-auth"; +import { uploadCliproxyAuthFile } from "@/lib/services/cliproxy-auth-account-service"; +import { handleCliproxyRouteError } from "@/lib/utils/cliproxy-route-errors"; +import { createLogger } from "@/lib/utils/logger"; + +const log = createLogger("admin-cliproxy-auth-files"); + +type RouteContext = { params: Promise<{ id: string }> }; + +/** + * 认证文件请求体的字节上限。 + * + * CLIProxyAPI 的 auth-file 是单个 OAuth 账号的 JSON 凭据, + * 实际负载远小于 512 KiB;超出该阈值的请求直接拒绝,避免被 + * 异常大的请求体绑架管理端进程内存。 + */ +const MAX_AUTH_FILE_BYTES = 512 * 1024; + +/** + * POST /api/admin/cliproxy/instances/:id/auth-files - 上传认证文件至 CLIProxyAPI。 + * + * 请求体为认证文件的完整 JSON 对象,由调用方构造。 + * 上传成功后立即触发该实例的账号同步,返回同步结果。 + */ +export async function POST(request: NextRequest, context: RouteContext): Promise { + if (!validateAdminAuth(request.headers.get("authorization"))) { + return errorResponse("Unauthorized", 401); + } + + const declaredLength = Number(request.headers.get("content-length") ?? ""); + if (Number.isFinite(declaredLength) && declaredLength > MAX_AUTH_FILE_BYTES) { + return errorResponse("Auth file is too large", 413); + } + + const { id } = await context.params; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return errorResponse("Invalid JSON body", 400); + } + + if (!rawBody || typeof rawBody !== "object" || Array.isArray(rawBody)) { + return errorResponse("Request body must be a JSON object", 400); + } + + try { + const syncResult = await uploadCliproxyAuthFile(id, rawBody as Record); + return NextResponse.json({ data: syncResult }, { status: 201 }); + } catch (err) { + const mapped = handleCliproxyRouteError(err); + if (mapped) { + return mapped; + } + log.error({ err }, "Failed to upload CLIProxyAPI auth file"); + return errorResponse("Internal server error", 500); + } +} diff --git a/src/app/api/admin/cliproxy/instances/[id]/linked-upstreams/route.ts b/src/app/api/admin/cliproxy/instances/[id]/linked-upstreams/route.ts new file mode 100644 index 00000000..a5162527 --- /dev/null +++ b/src/app/api/admin/cliproxy/instances/[id]/linked-upstreams/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAdminAuth } from "@/lib/utils/auth"; +import { errorResponse } from "@/lib/utils/api-auth"; +import { listCliproxyLinkedUpstreams } from "@/lib/services/cliproxy-linked-upstreams-service"; +import { handleCliproxyRouteError } from "@/lib/utils/cliproxy-route-errors"; +import { createLogger } from "@/lib/utils/logger"; + +const log = createLogger("admin-cliproxy-linked-upstreams"); + +type RouteContext = { params: Promise<{ id: string }> }; + +/** + * GET /api/admin/cliproxy/instances/:id/linked-upstreams - 查询实例下的关联上游列表。 + * + * 数据来源于本地 upstreams 表中 `cliproxyInstanceId` 匹配的记录,不需要访问 CLIProxyAPI。 + * 单账号上游通过 `cliproxyAuthFileName` 非空识别,其余视为池上游。 + */ +export async function GET(request: NextRequest, context: RouteContext): Promise { + if (!validateAdminAuth(request.headers.get("authorization"))) { + return errorResponse("Unauthorized", 401); + } + + const { id } = await context.params; + + try { + const upstreams = await listCliproxyLinkedUpstreams(id); + const data = upstreams.map((row) => ({ + id: row.id, + name: row.name, + provider: row.provider, + kind: row.kind, + auth_file_name: row.authFileName, + is_active: row.isActive, + created_at: row.createdAt.toISOString(), + })); + return NextResponse.json({ data }); + } catch (err) { + const mapped = handleCliproxyRouteError(err); + if (mapped) { + return mapped; + } + log.error({ err }, "Failed to list CLIProxyAPI linked upstreams"); + return errorResponse("Internal server error", 500); + } +} diff --git a/src/app/api/admin/cliproxy/instances/[id]/logs/route.ts b/src/app/api/admin/cliproxy/instances/[id]/logs/route.ts new file mode 100644 index 00000000..fa28cb2a --- /dev/null +++ b/src/app/api/admin/cliproxy/instances/[id]/logs/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateAdminAuth } from "@/lib/utils/auth"; +import { errorResponse } from "@/lib/utils/api-auth"; +import { listCliproxyInstanceLogs } from "@/lib/services/cliproxy-instance-logs-service"; +import { handleCliproxyRouteError } from "@/lib/utils/cliproxy-route-errors"; +import { createLogger } from "@/lib/utils/logger"; + +const log = createLogger("admin-cliproxy-logs"); + +type RouteContext = { params: Promise<{ id: string }> }; + +/** + * GET /api/admin/cliproxy/instances/:id/logs?since=ISO_TIMESTAMP - 查询实例日志。 + * + * 透传到 CLIProxyAPI 的 `/v0/management/logs` 端点。`since` 为可选的 ISO 时间戳, + * 用于增量拉取;未传时返回上游默认窗口内的全部日志。 + */ +export async function GET(request: NextRequest, context: RouteContext): Promise { + if (!validateAdminAuth(request.headers.get("authorization"))) { + return errorResponse("Unauthorized", 401); + } + + const { id } = await context.params; + const since = new URL(request.url).searchParams.get("since") ?? undefined; + + try { + const entries = await listCliproxyInstanceLogs(id, since); + return NextResponse.json({ data: entries }); + } catch (err) { + const mapped = handleCliproxyRouteError(err); + if (mapped) { + return mapped; + } + log.error({ err }, "Failed to fetch CLIProxyAPI instance logs"); + return errorResponse("Internal server error", 500); + } +} diff --git a/src/app/api/admin/cliproxy/instances/[id]/oauth-callback/route.ts b/src/app/api/admin/cliproxy/instances/[id]/oauth-callback/route.ts new file mode 100644 index 00000000..7da2ea66 --- /dev/null +++ b/src/app/api/admin/cliproxy/instances/[id]/oauth-callback/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateAdminAuth } from "@/lib/utils/auth"; +import { errorResponse } from "@/lib/utils/api-auth"; +import { submitCliproxyOAuthCallback } from "@/lib/services/cliproxy-oauth-login-service"; +import { CLIPROXY_OAUTH_PROVIDERS } from "@/lib/services/cliproxy-management-client"; +import { handleCliproxyRouteError } from "@/lib/utils/cliproxy-route-errors"; +import { createLogger } from "@/lib/utils/logger"; + +const log = createLogger("admin-cliproxy-oauth-callback"); + +type RouteContext = { params: Promise<{ id: string }> }; + +/** 允许的回调 URL 协议;其它 scheme 一律拒绝以避免被透传出去触发非预期行为。 */ +const ALLOWED_REDIRECT_PROTOCOLS = new Set(["http:", "https:"]); + +const callbackSchema = z.object({ + provider: z.enum(CLIPROXY_OAUTH_PROVIDERS), + redirect_url: z + .string() + .trim() + .min(1) + .max(4096) + .refine( + (value) => { + try { + return ALLOWED_REDIRECT_PROTOCOLS.has(new URL(value).protocol); + } catch { + return false; + } + }, + { message: "redirect_url must be an http(s) URL" } + ), +}); + +/** + * POST /api/admin/cliproxy/instances/:id/oauth-callback - 手动提交 OAuth 回调 URL。 + */ +export async function POST(request: NextRequest, context: RouteContext): Promise { + if (!validateAdminAuth(request.headers.get("authorization"))) { + return errorResponse("Unauthorized", 401); + } + + const { id } = await context.params; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return errorResponse("Invalid JSON body", 400); + } + + const parsed = callbackSchema.safeParse(rawBody); + if (!parsed.success) { + return errorResponse(parsed.error.issues[0]?.message ?? "Invalid request body", 400); + } + + try { + const result = await submitCliproxyOAuthCallback( + id, + parsed.data.provider, + parsed.data.redirect_url + ); + return NextResponse.json({ data: result }); + } catch (err) { + const mapped = handleCliproxyRouteError(err); + if (mapped) { + return mapped; + } + log.error({ err }, "Failed to submit CLIProxyAPI OAuth callback"); + return errorResponse("Internal server error", 500); + } +} diff --git a/src/app/api/admin/cliproxy/instances/[id]/pool-upstreams/route.ts b/src/app/api/admin/cliproxy/instances/[id]/pool-upstreams/route.ts index 56ebbe49..38f08b84 100644 --- a/src/app/api/admin/cliproxy/instances/[id]/pool-upstreams/route.ts +++ b/src/app/api/admin/cliproxy/instances/[id]/pool-upstreams/route.ts @@ -6,13 +6,14 @@ import { createCliproxyPoolUpstream } from "@/lib/services/cliproxy-upstream-pre import { transformUpstreamToApi } from "@/lib/utils/api-transformers"; import { handleCliproxyRouteError } from "@/lib/utils/cliproxy-route-errors"; import { createLogger } from "@/lib/utils/logger"; +import { CLIPROXY_UPSTREAM_PROVIDERS } from "@/types/cliproxy"; const log = createLogger("admin-cliproxy-pool-upstreams"); type RouteContext = { params: Promise<{ id: string }> }; const createPoolUpstreamSchema = z.object({ - provider: z.enum(["codex", "anthropic", "gemini"]), + provider: z.enum(CLIPROXY_UPSTREAM_PROVIDERS), name: z.string().trim().min(1).max(255).optional(), weight: z.number().int().min(0).optional(), priority: z.number().int().min(0).optional(), diff --git a/src/components/admin/cliproxy-account-detail-dialog.tsx b/src/components/admin/cliproxy-account-detail-dialog.tsx new file mode 100644 index 00000000..6b975b12 --- /dev/null +++ b/src/components/admin/cliproxy-account-detail-dialog.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { CliproxyAuthAccount } from "@/types/cliproxy"; + +interface CliproxyAccountDetailDialogProps { + account: CliproxyAuthAccount; + open: boolean; + onClose: () => void; +} + +/** 将可空字符串渲染为占位符。 */ +function renderText(value: string | null | undefined, placeholder: string): React.ReactNode { + if (value === null || value === undefined || value === "") { + return {placeholder}; + } + return value; +} + +/** 将 ISO 时间戳渲染为本地格式。 */ +function renderTimestamp(value: string | null, placeholder: string): React.ReactNode { + if (!value) { + return {placeholder}; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +/** + * 展示 OAuth 账号的完整元数据:邮箱、上游状态、前缀、备注、模型数、原始快照、时间戳等。 + */ +export function CliproxyAccountDetailDialog({ + account, + open, + onClose, +}: CliproxyAccountDetailDialogProps) { + const t = useTranslations("cliproxy"); + const tCommon = useTranslations("common"); + const placeholder = t("accountDetailEmpty"); + const status = account.status; + const statusMessage = + account.raw_metadata && typeof account.raw_metadata.status_message === "string" + ? (account.raw_metadata.status_message as string) + : null; + + return ( + !next && onClose()}> + + + {t("accountDetailDialogTitle")} + + {t("accountFileLabel")}: {account.auth_file_name} + + + +
+ + {account.provider} + + + {renderText(account.email, placeholder)} + + + {status ? {status} : renderText(null, placeholder)} + + + {renderText(statusMessage, placeholder)} + + + + {account.disabled ? t("accountStatusDisabled") : t("accountStatusEnabled")} + + + + {account.prefix ? ( + {account.prefix} + ) : ( + renderText(null, placeholder) + )} + + + {account.priority === null || account.priority === undefined + ? renderText(null, placeholder) + : String(account.priority)} + + + {renderText(account.note, placeholder)} + + {account.model_count} + + {renderTimestamp(account.last_synced_at, placeholder)} + + + {renderTimestamp(account.created_at, placeholder)} + + + {renderTimestamp(account.updated_at, placeholder)} + + + {account.raw_metadata ? ( +
+ + {t("accountDetailLabelRawMetadata")} + +
+                {JSON.stringify(account.raw_metadata, null, 2)}
+              
+
+ ) : null} +
+ + + + +
+
+ ); +} + +interface DetailRowProps { + label: string; + children: React.ReactNode; +} + +function DetailRow({ label, children }: DetailRowProps) { + return ( +
+ {label} +
{children}
+
+ ); +} diff --git a/src/components/admin/cliproxy-account-models-dialog.tsx b/src/components/admin/cliproxy-account-models-dialog.tsx new file mode 100644 index 00000000..5fe45eca --- /dev/null +++ b/src/components/admin/cliproxy-account-models-dialog.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useCliproxyAccountModels } from "@/hooks/use-cliproxy"; + +interface CliproxyAccountModelsDialogProps { + instanceId: string; + authFileName: string; + open: boolean; + onClose: () => void; +} + +/** + * 展示某 OAuth 账号在 CLIProxyAPI 侧的可用模型列表。 + * + * 模型列表为只读窗口,数据每次打开时实时从上游拉取,不写入本地缓存。 + */ +export function CliproxyAccountModelsDialog({ + instanceId, + authFileName, + open, + onClose, +}: CliproxyAccountModelsDialogProps) { + const t = useTranslations("cliproxy"); + const tCommon = useTranslations("common"); + const { data: models, isLoading, isError } = useCliproxyAccountModels(instanceId, authFileName); + + return ( + !next && onClose()}> + + + {t("accountModelsDialogTitle")} + + {t("accountFileLabel")}: {authFileName} + + + +
+ {isLoading ? ( +
+ + {tCommon("loading")} +
+ ) : isError ? ( +

+ {t("accountModelsLoadFailed")} +

+ ) : !models || models.length === 0 ? ( +

+ {t("accountModelsEmpty")} +

+ ) : ( + + + + ID + {t("columnName")} + + + + {models.map((model) => ( + + + {model.id} + + {model.display_name ?? "—"} + + ))} + +
+ )} +
+ + + + +
+
+ ); +} diff --git a/src/components/admin/cliproxy-accounts-panel.tsx b/src/components/admin/cliproxy-accounts-panel.tsx index 287bf2d7..1c633283 100644 --- a/src/components/admin/cliproxy-accounts-panel.tsx +++ b/src/components/admin/cliproxy-accounts-panel.tsx @@ -1,38 +1,49 @@ "use client"; import { useState } from "react"; -import { LogIn, RefreshCw } from "lucide-react"; +import { LogIn, RefreshCw, Upload } from "lucide-react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { useCliproxyAuthAccounts, + useDownloadCliproxyAuthFile, useSetCliproxyAuthAccountStatus, useSyncCliproxyAuthAccounts, } from "@/hooks/use-cliproxy"; import type { CliproxyAuthAccount, CliproxyInstance } from "@/types/cliproxy"; import { CliproxyAccountsTable } from "./cliproxy-accounts-table"; import { CliproxyAccountFieldsDialog } from "./cliproxy-account-fields-dialog"; +import { CliproxyAccountModelsDialog } from "./cliproxy-account-models-dialog"; +import { CliproxyAccountDetailDialog } from "./cliproxy-account-detail-dialog"; import { CliproxyOAuthLoginDialog } from "./cliproxy-oauth-login-dialog"; import { CliproxyAccountUpstreamDialog } from "./cliproxy-account-upstream-dialog"; +import { CliproxyAuthFileUploadDialog } from "./cliproxy-auth-file-upload-dialog"; +import { CliproxyDeleteAuthFileDialog } from "./cliproxy-delete-auth-file-dialog"; interface CliproxyAccountsPanelProps { instance: CliproxyInstance; } /** - * 选中实例后展示其 OAuth 账号列表的内联面板,提供同步、启停与字段编辑。 + * 选中实例后展示其 OAuth 账号列表的内联面板,提供 OAuth 登录、上传文件、同步、 + * 账号启停、字段编辑、详情查看、模型列表查看、下载、删除、上游映射等完整操作。 */ export function CliproxyAccountsPanel({ instance }: CliproxyAccountsPanelProps) { const t = useTranslations("cliproxy"); const { data: accounts, isLoading, isError } = useCliproxyAuthAccounts(instance.id); const syncMutation = useSyncCliproxyAuthAccounts(); const statusMutation = useSetCliproxyAuthAccountStatus(); + const downloadMutation = useDownloadCliproxyAuthFile(); const [editAccount, setEditAccount] = useState(null); + const [detailAccount, setDetailAccount] = useState(null); + const [modelsAccount, setModelsAccount] = useState(null); + const [deleteAccount, setDeleteAccount] = useState(null); const [mapAccount, setMapAccount] = useState(null); const [oauthOpen, setOauthOpen] = useState(false); + const [uploadOpen, setUploadOpen] = useState(false); const handleToggleStatus = (account: CliproxyAuthAccount) => { statusMutation.mutate({ @@ -42,6 +53,13 @@ export function CliproxyAccountsPanel({ instance }: CliproxyAccountsPanelProps) }); }; + const handleDownload = (account: CliproxyAuthAccount) => { + downloadMutation.mutate({ + instanceId: instance.id, + authFileName: account.auth_file_name, + }); + }; + return ( @@ -55,6 +73,10 @@ export function CliproxyAccountsPanel({ instance }: CliproxyAccountsPanelProps) {t("oauthLogin")} + + {account.prefix ? ( {account.prefix} @@ -83,6 +122,14 @@ export function CliproxyAccountsTable({ + onViewDetail(account)}> + + {t("actionViewDetail")} + + onViewModels(account)}> + + {t("actionViewModels")} + onToggleStatus(account)}> {account.disabled ? ( @@ -99,6 +146,17 @@ export function CliproxyAccountsTable({ {t("actionMapUpstream")} + onDownload(account)}> + + {t("actionDownload")} + + onDelete(account)} + className={cn("text-destructive focus:text-destructive")} + > + + {t("actionDelete")} + diff --git a/src/components/admin/cliproxy-auth-file-upload-dialog.tsx b/src/components/admin/cliproxy-auth-file-upload-dialog.tsx new file mode 100644 index 00000000..b156a9dd --- /dev/null +++ b/src/components/admin/cliproxy-auth-file-upload-dialog.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Upload } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { useUploadCliproxyAuthFile } from "@/hooks/use-cliproxy"; +import { cn } from "@/lib/utils"; + +interface CliproxyAuthFileUploadDialogProps { + instanceId: string; + open: boolean; + onClose: () => void; +} + +type UploadMode = "file" | "paste"; + +/** + * 上传 CLIProxyAPI 认证文件的弹窗。 + * + * 支持「选择文件」与「粘贴 JSON」两种方式,由顶部按钮组切换。 + * 提交前在前端验证 JSON 合法性,上传成功后自动触发同步, + * 同步结果由 mutation 的 onSuccess 提示。 + */ +export function CliproxyAuthFileUploadDialog({ + instanceId, + open, + onClose, +}: CliproxyAuthFileUploadDialogProps) { + const t = useTranslations("cliproxy"); + const tCommon = useTranslations("common"); + const uploadMutation = useUploadCliproxyAuthFile(); + const fileInputRef = useRef(null); + + const [mode, setMode] = useState("file"); + const [pasteContent, setPasteContent] = useState(""); + const [pickedFileName, setPickedFileName] = useState(null); + const [pickedFileContent, setPickedFileContent] = useState(null); + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] ?? null; + if (!file) { + setPickedFileName(null); + setPickedFileContent(null); + return; + } + setPickedFileName(file.name); + setPickedFileContent(await file.text()); + }; + + const handleSubmit = async () => { + const raw = mode === "file" ? pickedFileContent : pasteContent; + if (!raw || !raw.trim()) { + toast.error(t("uploadAuthFileEmpty")); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + toast.error(t("uploadAuthFileInvalidJson")); + return; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + toast.error(t("uploadAuthFileInvalidJson")); + return; + } + + try { + await uploadMutation.mutateAsync({ + instanceId, + content: parsed as Record, + }); + handleClose(); + } catch { + // 错误已由 mutation 的 onError 提示 + } + }; + + const handleClose = () => { + setPasteContent(""); + setPickedFileName(null); + setPickedFileContent(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + onClose(); + }; + + return ( + !next && handleClose()}> + + + {t("uploadAuthFileTitle")} + {t("uploadAuthFileDescription")} + + +
+ setMode("file")}> + {t("uploadAuthFileMethodFile")} + + setMode("paste")}> + {t("uploadAuthFileMethodPaste")} + +
+ +
+ {mode === "file" ? ( + <> + + + + ) : ( +