fix: harden frontend state and tooling

This commit is contained in:
zhoujun0601
2026-08-13 12:04:23 -04:00
parent 833908ac9c
commit a027edda88
19 changed files with 1773 additions and 392 deletions
+19
View File
@@ -0,0 +1,19 @@
name: Frontend
on:
push:
pull_request:
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check frontend
run: |
docker run --rm -v "$PWD/frontend:/src:ro" node:24.19.0-bookworm-slim \
sh -c 'cp -a /src /tmp/frontend && cd /tmp/frontend && npm ci --no-audit --no-fund && npm run check'
- name: Browser tests
run: |
docker run --rm --ipc=host -v "$PWD/frontend:/src:ro" mcr.microsoft.com/playwright:v1.62.1-noble \
sh -c 'cp -a /src /tmp/frontend && cd /tmp/frontend && npm ci --no-audit --no-fund && npm run test:e2e'
+1
View File
@@ -3,6 +3,7 @@ FROM node:24.19.0-bookworm-slim AS frontend
WORKDIR /src/frontend WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json frontend/tsconfig.json frontend/vite.config.ts ./ COPY frontend/package.json frontend/package-lock.json frontend/tsconfig.json frontend/vite.config.ts ./
COPY frontend/src ./src COPY frontend/src ./src
COPY frontend/scripts ./scripts
COPY frontend/index.html ./index.html COPY frontend/index.html ./index.html
RUN npm ci && npm run build RUN npm ci && npm run build
+2
View File
@@ -219,6 +219,8 @@ npm install
npm run dev npm run dev
``` ```
前端提交前检查(格式、lint、类型、生产构建、bundle 门禁和 Vitest)使用 `npm run check`;浏览器回归测试使用 `npm run test:e2e`。维护者应按开发指南在固定版本容器中执行这些命令。
启动后端: 启动后端:
```bash ```bash
+5 -5
View File
@@ -1,18 +1,18 @@
# 前端应用 # 前端应用
前端位于 `frontend/`,使用 React 19、TypeScript、Vite、React Router、Lucide 和 Recharts。`frontend/src/main.tsx` 当前集中承载应用组件和 API 数据类型,`frontend/src/api.ts` 是 fetch 包装层。 前端位于 `frontend/`,使用 React 19、TypeScript、Vite、React Router、Lucide 和 Recharts。应用壳和页面位于 `frontend/src/main.tsx`,API 错误与请求生命周期集中在 `api.ts`,传输类型和运行时 decoder 位于 `types.ts`,认证与主题分别由 context 管理。Recharts 图表通过动态 import 独立打包。
## 状态与路由 ## 状态与路由
应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页和登录后侧栏的品牌区域。这些分支只负责交互,服务端 session 才是安全边界。 应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页和登录后侧栏的品牌区域。这些分支只负责交互,服务端 session 才是安全边界。
主题和当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并每 30 秒刷新内存数据;切换账号必须清空旧 Dashboard,避免短暂展示另一账号信息。手动刷新调用账号级 sync 后重新读取 Dashboard。 主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并在每轮请求完成 30 秒后刷新;切换账号会取消旧请求并校验响应账号,避免迟到响应覆盖当前账号。手动刷新调用账号级 sync 后重新读取 Dashboard。
## API 客户端 ## API 客户端
所有请求使用相对 `/api/v1/`、`credentials: same-origin`、JSON content type 和 `X-Requested-With: codex-helper`。非 2xx 响应优先显示 `{error}`,否则退化为 HTTP 状态。新增下载或非 JSON 响应不能直接套用当前 `api` helper。 所有请求使用相对 `/api/v1/`、`credentials: same-origin` 和 `X-Requested-With: codex-helper`,仅 JSON body 设置 content type。请求支持取消和 timeout;非 2xx 响应转换为保留 status 的 `ApiError`,401 会统一回到登录界面。JSON 响应先作为 `unknown`,经端点 decoder 校验后进入组件。新增下载或非 JSON 响应不能直接套用当前 `api` helper。
当前 `main.tsx` 的 API 类型将部分后端 `null` 值建模为 optional,渲染层通过 truthy 检查同时兼容 `undefined` 和 `null`;空数组及 unknown 套餐按后端返回值处理。邮箱在 Web 界面的账号选择器、总览和设置中统一经过 `maskEmail`;不得把未掩码邮箱添加到新的 Web 可见位置。Telegram `/account` 是独立的已绑定会话输出,当前显示完整邮箱。 API 类型精确区分后端 `null` 与 optional,并为秘密设置拆分读写形状;空数组及 unknown 套餐按后端返回值处理。邮箱在 Web 界面的账号选择器、总览和设置中统一经过 `maskEmail`;不得把未掩码邮箱添加到新的 Web 可见位置。Telegram `/account` 是独立的已绑定会话输出,当前显示完整邮箱。
## 总览与设置 ## 总览与设置
@@ -22,4 +22,4 @@
## 验证重点 ## 验证重点
纯逻辑和组件测试使用 Vitest;现有浏览器测试使用 Playwright 覆盖设置布局、键盘操作、隐藏表单隔离、账号删除、窄屏溢出和邮箱掩码。修改 effect、轮询或异步加载时应覆盖卸载清理、错误状态及旧响应覆盖新状态;修改 CSS 时同时跑 desktop 和 mobile projects。 纯逻辑和组件测试使用 Vitest;Oxlint 检查 TypeScript/React 正确性,Prettier 检查格式,构建门禁限制单个 JavaScript chunk 不超过 500 KB。浏览器测试使用 Playwright 覆盖设置布局、键盘操作、隐藏表单隔离、账号删除、窄屏溢出、邮箱掩码、session 失效和跨账号竞态。修改 effect、轮询或异步加载时应覆盖卸载清理、错误状态及旧响应覆盖新状态;修改 CSS 时同时跑 desktop 和 mobile projects。
+1 -1
View File
@@ -25,7 +25,7 @@ docker run --rm \
```bash ```bash
docker run --rm -v "$PWD/frontend:/src:ro" node:24.19.0-bookworm-slim \ docker run --rm -v "$PWD/frontend:/src:ro" node:24.19.0-bookworm-slim \
sh -c 'cp -a /src /tmp/frontend && cd /tmp/frontend && npm ci --no-audit --no-fund && npm run build && npm test' sh -c 'cp -a /src /tmp/frontend && cd /tmp/frontend && npm ci --no-audit --no-fund && npm run check'
``` ```
依赖或安全改动额外运行: 依赖或安全改动额外运行:
+6
View File
@@ -0,0 +1,6 @@
node_modules
../backend/internal/web/dist
package-lock.json
tsconfig.tsbuildinfo
test-results
playwright-report
+452 -90
View File
@@ -18,10 +18,12 @@
"vite": "8.2.1" "vite": "8.2.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.1", "@playwright/test": "1.62.1",
"@types/react": "19.1.10", "@types/react": "19.1.10",
"@types/react-dom": "19.1.7", "@types/react-dom": "19.1.7",
"vitest": "3.2.4" "oxlint": "1.78.0",
"prettier": "3.9.0",
"vitest": "3.2.7"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -31,7 +33,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -48,7 +49,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -65,7 +65,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -82,7 +81,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -99,7 +97,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -116,7 +113,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -133,7 +129,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -150,7 +145,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -167,7 +161,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -184,7 +177,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -201,7 +193,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -218,7 +209,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -235,7 +225,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -252,7 +241,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -269,7 +257,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -286,7 +273,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -303,7 +289,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -320,7 +305,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -337,7 +321,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -354,7 +337,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -371,7 +353,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -388,7 +369,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -405,7 +385,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -422,7 +401,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -439,7 +417,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -456,7 +433,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -499,6 +475,353 @@
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.78.0.tgz",
"integrity": "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.78.0.tgz",
"integrity": "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.78.0.tgz",
"integrity": "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.78.0.tgz",
"integrity": "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.78.0.tgz",
"integrity": "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.78.0.tgz",
"integrity": "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.78.0.tgz",
"integrity": "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.78.0.tgz",
"integrity": "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.78.0.tgz",
"integrity": "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.78.0.tgz",
"integrity": "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.78.0.tgz",
"integrity": "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.78.0.tgz",
"integrity": "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.78.0.tgz",
"integrity": "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.78.0.tgz",
"integrity": "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.78.0.tgz",
"integrity": "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.78.0.tgz",
"integrity": "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.78.0.tgz",
"integrity": "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.78.0.tgz",
"integrity": "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.78.0.tgz",
"integrity": "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.62.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
@@ -1603,15 +1926,15 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.7",
"chai": "^5.2.0", "chai": "^5.2.0",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@@ -1633,13 +1956,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.7",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"strip-literal": "^3.0.0" "strip-literal": "^3.0.0"
}, },
@@ -1648,13 +1971,13 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.2.4", "@vitest/pretty-format": "3.2.7",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -1662,23 +1985,10 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1689,13 +1999,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.2.4", "@vitest/pretty-format": "3.2.7",
"loupe": "^3.1.4", "loupe": "^3.1.4",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@@ -1703,19 +2013,6 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@vitest/utils/node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^2.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2399,6 +2696,55 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/oxlint": {
"version": "1.78.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.78.0.tgz",
"integrity": "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==",
"dev": true,
"license": "MIT",
"bin": {
"oxlint": "bin/oxlint"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.78.0",
"@oxlint/binding-android-arm64": "1.78.0",
"@oxlint/binding-darwin-arm64": "1.78.0",
"@oxlint/binding-darwin-x64": "1.78.0",
"@oxlint/binding-freebsd-x64": "1.78.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.78.0",
"@oxlint/binding-linux-arm-musleabihf": "1.78.0",
"@oxlint/binding-linux-arm64-gnu": "1.78.0",
"@oxlint/binding-linux-arm64-musl": "1.78.0",
"@oxlint/binding-linux-ppc64-gnu": "1.78.0",
"@oxlint/binding-linux-riscv64-gnu": "1.78.0",
"@oxlint/binding-linux-riscv64-musl": "1.78.0",
"@oxlint/binding-linux-s390x-gnu": "1.78.0",
"@oxlint/binding-linux-x64-gnu": "1.78.0",
"@oxlint/binding-linux-x64-musl": "1.78.0",
"@oxlint/binding-openharmony-arm64": "1.78.0",
"@oxlint/binding-win32-arm64-msvc": "1.78.0",
"@oxlint/binding-win32-ia32-msvc": "1.78.0",
"@oxlint/binding-win32-x64-msvc": "1.78.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=7.0.2001",
"vite-plus": "*"
},
"peerDependenciesMeta": {
"oxlint-tsgolint": {
"optional": true
},
"vite-plus": {
"optional": true
}
}
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -2509,6 +2855,22 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prettier": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.0.tgz",
"integrity": "sha512-LjIqSIC5VYLzs9WedVmJ2ljNAGnU+DteIClbahu4L/DBeWjZ6iT/k1lAYyu9JUh+1xINxWadaPw/Pl63y/agAw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.7", "version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -3086,20 +3448,20 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.4", "@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "^3.2.4", "@vitest/pretty-format": "^3.2.7",
"@vitest/runner": "3.2.4", "@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.4", "@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.7",
"chai": "^5.2.0", "chai": "^5.2.0",
"debug": "^4.4.1", "debug": "^4.4.1",
"expect-type": "^1.2.1", "expect-type": "^1.2.1",
@@ -3129,8 +3491,8 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4", "@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.4", "@vitest/ui": "3.2.7",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },
@@ -3159,13 +3521,13 @@
} }
}, },
"node_modules/vitest/node_modules/@vitest/mocker": { "node_modules/vitest/node_modules/@vitest/mocker": {
"version": "3.2.4", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.7",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.17" "magic-string": "^0.30.17"
}, },
+34 -1
View File
@@ -1 +1,34 @@
{"name":"codex-helper-ui","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite","build":"tsc -b && vite build","test":"vitest run --passWithNoTests --exclude 'tests/**'","test:e2e":"playwright test"},"dependencies":{"@vitejs/plugin-react":"latest","lucide-react":"0.539.0","react":"19.2.7","react-dom":"19.2.7","react-router-dom":"7.18.2","recharts":"3.1.2","typescript":"7.0.2","vite":"8.2.1"},"devDependencies":{"@playwright/test":"^1.62.1","@types/react":"19.1.10","@types/react-dom":"19.1.7","vitest":"3.2.4"}} {
"name": "codex-helper-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build && node scripts/check-bundle.mjs",
"lint": "oxlint --deny-warnings src tests",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run --exclude 'tests/**'",
"test:e2e": "playwright test",
"check": "npm run format:check && npm run lint && npm run build && npm test"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"lucide-react": "0.539.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-router-dom": "7.18.2",
"recharts": "3.1.2",
"typescript": "7.0.2",
"vite": "8.2.1"
},
"devDependencies": {
"@playwright/test": "1.62.1",
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"oxlint": "1.78.0",
"prettier": "3.9.0",
"vitest": "3.2.7"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { readdir, stat } from "node:fs/promises";
import { resolve } from "node:path";
const assets = resolve("../backend/internal/web/dist/assets");
const files = (await readdir(assets)).filter((file) => file.endsWith(".js"));
const limit = 500_000;
const oversized = [];
for (const file of files) {
const size = (await stat(resolve(assets, file))).size;
if (size > limit) oversized.push(`${file}: ${size} bytes`);
}
if (oversized.length) {
throw new Error(
`JavaScript bundle 超过 ${limit} bytes:\n${oversized.join("\n")}`,
);
}
+125 -4
View File
@@ -1,4 +1,125 @@
export async function api<T>(path:string,init:RequestInit={}):Promise<T>{const r=await fetch('/api/v1/'+path,{credentials:'same-origin',...init,headers:{'Content-Type':'application/json','X-Requested-With':'codex-helper',...(init.headers||{})}});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);return j} export class ApiError extends Error {
export const post=<T>(p:string,v:unknown={})=>api<T>(p,{method:'POST',body:JSON.stringify(v)}) constructor(
export const put=<T>(p:string,v:unknown)=>api<T>(p,{method:'PUT',body:JSON.stringify(v)}) message: string,
export const del=<T>(p:string)=>api<T>(p,{method:'DELETE'}) readonly kind: "http" | "network" | "timeout" | "invalid-response",
readonly status?: number,
) {
super(message);
this.name = "ApiError";
}
}
export type Decoder<T> = (value: unknown) => T;
let unauthorizedHandler: (() => void) | undefined;
export function onUnauthorized(handler?: () => void) {
unauthorizedHandler = handler;
}
export const unknownResponse: Decoder<unknown> = (value) => value;
export async function api<T>(
path: string,
decoder: Decoder<T>,
init: RequestInit = {},
timeoutMs = 30_000,
): Promise<T> {
const timeout = new AbortController();
const timer = window.setTimeout(() => timeout.abort("timeout"), timeoutMs);
const signal = init.signal
? AbortSignal.any([init.signal, timeout.signal])
: timeout.signal;
const headers = new Headers(init.headers);
headers.set("X-Requested-With", "codex-helper");
if (init.body != null) headers.set("Content-Type", "application/json");
try {
const response = await fetch(`/api/v1/${path}`, {
credentials: "same-origin",
...init,
headers,
signal,
});
const json: unknown = await response.json().catch(() => undefined);
if (!response.ok) {
if (response.status === 401) unauthorizedHandler?.();
const message =
isRecord(json) && typeof json.error === "string"
? json.error
: `HTTP ${response.status}`;
throw new ApiError(message, "http", response.status);
}
try {
return decoder(json);
} catch (error) {
throw new ApiError(toErrorMessage(error), "invalid-response");
}
} catch (error) {
if (error instanceof ApiError) throw error;
if (signal.aborted) {
if (init.signal?.aborted) throw error;
throw new ApiError("请求超时,请重试", "timeout");
}
throw new ApiError("网络请求失败,请检查连接后重试", "network");
} finally {
window.clearTimeout(timer);
}
}
export const get = <T>(
path: string,
decoder: Decoder<T>,
signal?: AbortSignal,
) => api(path, decoder, { signal });
export const post = <T>(
path: string,
decoder: Decoder<T>,
value: unknown = {},
signal?: AbortSignal,
timeoutMs = 30_000,
) =>
api(
path,
decoder,
{ method: "POST", body: JSON.stringify(value), signal },
timeoutMs,
);
export const put = <T>(path: string, decoder: Decoder<T>, value: unknown) =>
api(path, decoder, { method: "PUT", body: JSON.stringify(value) });
export const del = <T>(path: string, decoder: Decoder<T>) =>
api(path, decoder, { method: "DELETE" });
export function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "发生未知错误";
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function record(value: unknown, name = "响应"): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${name}格式无效`);
return value;
}
export function string(value: unknown, name: string): string {
if (typeof value !== "string") throw new Error(`${name}格式无效`);
return value;
}
export function number(value: unknown, name: string): number {
if (typeof value !== "number" || !Number.isFinite(value))
throw new Error(`${name}格式无效`);
return value;
}
export function boolean(value: unknown, name: string): boolean {
if (typeof value !== "boolean") throw new Error(`${name}格式无效`);
return value;
}
export function nullableString(value: unknown, name: string): string | null {
if (value === null) return null;
return string(value, name);
}
+112
View File
@@ -0,0 +1,112 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { ApiError, get, onUnauthorized, post, toErrorMessage } from "./api";
import { decodeOK, decodeStatus, type AsyncState, type Status } from "./types";
type AuthContextValue = {
status: Status;
authenticated: boolean;
login: (username: string, password: string) => Promise<void>;
setup: (
username: string,
password: string,
timezone: string,
) => Promise<void>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<
AsyncState<{ status: Status; authenticated: boolean }>
>({ status: "loading" });
const load = async () => {
setState({ status: "loading" });
try {
const status = await get("system/status", decodeStatus);
if (!status.initialized)
return setState({
status: "success",
data: { status, authenticated: false },
});
try {
await get("auth/me", () => undefined);
setState({ status: "success", data: { status, authenticated: true } });
} catch (error) {
if (error instanceof ApiError && error.status === 401)
setState({
status: "success",
data: { status, authenticated: false },
});
else throw error;
}
} catch (error) {
setState({ status: "error", message: toErrorMessage(error) });
}
};
useEffect(() => {
void load();
}, []);
useEffect(() => {
onUnauthorized(() =>
setState((current) =>
current.status === "success"
? {
status: "success",
data: { ...current.data, authenticated: false },
}
: current,
),
);
return () => onUnauthorized();
}, []);
if (state.status === "loading")
return (
<main className="center">
<div className="spinner" />
</main>
);
if (state.status === "error")
return (
<main className="center">
<section className="panel form">
<h2>无法启动应用</h2>
<p className="error">{state.message}</p>
<button onClick={() => void load()}>重试</button>
</section>
</main>
);
const { status, authenticated } = state.data;
const value: AuthContextValue = {
status,
authenticated,
login: async (username, password) => {
await post("auth/login", decodeOK, { username, password });
setState({ status: "success", data: { status, authenticated: true } });
},
setup: async (username, password, timezone) => {
await post("setup", decodeOK, { username, password, timezone });
setState({
status: "success",
data: { status: { ...status, initialized: true }, authenticated: true },
});
},
logout: async () => {
await post("auth/logout", decodeOK);
setState({ status: "success", data: { status, authenticated: false } });
},
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const value = useContext(AuthContext);
if (!value) throw new Error("useAuth 必须在 AuthProvider 内使用");
return value;
}
+341 -246
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { lazy, Suspense, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { import {
BrowserRouter, BrowserRouter,
@@ -19,97 +19,40 @@ import {
Trash2, Trash2,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { api, del, get, post, put, toErrorMessage } from "./api";
import { AuthProvider, useAuth } from "./auth";
import { ThemeProvider, useTheme } from "./theme";
import { import {
Area, decodeAccounts,
AreaChart, decodeCode,
CartesianGrid, decodeDashboard,
ResponsiveContainer, decodeDeviceLogin,
Tooltip, decodeGeneral,
XAxis, decodeOK,
YAxis, decodeSMTP,
} from "recharts"; decodeTelegram,
import { api, del, post, put } from "./api"; type Account,
type Dashboard as Dash,
type DeviceLogin,
type GeneralSettings,
type Limit,
type SMTPSettingsForm,
type TelegramSettingsForm,
} from "./types";
import "./styles.css"; import "./styles.css";
type Status = { initialized: boolean; appServer: boolean; version: string }; const UsageChart = lazy(() => import("./usage-chart"));
type Limit = {
limitId: string;
limitName?: string;
windowType: string;
usedPercent: number;
windowDurationMinutes: number;
resetsAt: number;
};
type Point = { date: string; totalTokens: number };
type Account = {
id: number;
displayName: string;
email?: string;
planType?: string;
expectedKind: "any" | "personal" | "team";
actualKind: "unknown" | "personal" | "team";
validationStatus: "pending" | "matched" | "mismatch" | "unknown";
possibleDuplicate: boolean;
connected: boolean;
};
type DeviceLogin = {
accountId: number;
verificationUrl: string;
userCode: string;
};
type Dash = {
accountId: number;
displayName: string;
account: { email?: string; planType?: string; connected: boolean };
limits: Limit[];
summary: {
lifetimeTokens?: number;
peakDailyTokens?: number;
currentStreakDays?: number;
longestRunningTurnSec?: number;
};
usage: Point[];
fetchedAt: number;
stale: boolean;
lastError?: string;
};
function App() { function App() {
const [s, setS] = useState<Status | null>(null); const { status, authenticated } = useAuth();
const [auth, setAuth] = useState<boolean | null>(null); if (!status.initialized) return <Setup version={status.version} />;
useEffect(() => {
api<Status>("system/status").then((x) => {
setS(x);
if (x.initialized)
api("auth/me")
.then(() => setAuth(true))
.catch(() => setAuth(false));
else setAuth(false);
});
}, []);
if (!s || auth === null) return <Splash />;
if (!s.initialized)
return (
<Setup
version={s.version}
onDone={() => {
setS({ ...s, initialized: true });
setAuth(true);
}}
/>
);
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
{auth ? ( {authenticated ? (
<Route <Route path="*" element={<Shell version={status.version} />} />
path="*"
element={<Shell version={s.version} logout={() => setAuth(false)} />}
/>
) : ( ) : (
<Route <Route path="*" element={<Login version={status.version} />} />
path="*"
element={<Login version={s.version} done={() => setAuth(true)} />}
/>
)} )}
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
@@ -131,7 +74,8 @@ function Brand({ version }: { version?: string }) {
</div> </div>
); );
} }
function Setup({ version, onDone }: { version: string; onDone: () => void }) { function Setup({ version }: { version: string }) {
const { setup } = useAuth();
const [form, set] = useState({ const [form, set] = useState({
username: "admin", username: "admin",
password: "", password: "",
@@ -141,10 +85,9 @@ function Setup({ version, onDone }: { version: string; onDone: () => void }) {
async function submit(e: React.FormEvent) { async function submit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
try { try {
await post("setup", form); await setup(form.username, form.password, form.timezone);
onDone();
} catch (x) { } catch (x) {
setErr((x as Error).message); setErr(toErrorMessage(x));
} }
} }
return ( return (
@@ -197,7 +140,8 @@ function Setup({ version, onDone }: { version: string; onDone: () => void }) {
</main> </main>
); );
} }
function Login({ version, done }: { version: string; done: () => void }) { function Login({ version }: { version: string }) {
const { login } = useAuth();
const [u, setU] = useState("admin"), const [u, setU] = useState("admin"),
[p, setP] = useState(""), [p, setP] = useState(""),
[e, setE] = useState(""); [e, setE] = useState("");
@@ -208,10 +152,9 @@ function Login({ version, done }: { version: string; done: () => void }) {
onSubmit={async (x) => { onSubmit={async (x) => {
x.preventDefault(); x.preventDefault();
try { try {
await post("auth/login", { username: u, password: p }); await login(u, p);
done();
} catch (q) { } catch (q) {
setE((q as Error).message); setE(toErrorMessage(q));
} }
}} }}
> >
@@ -235,18 +178,11 @@ function Login({ version, done }: { version: string; done: () => void }) {
</main> </main>
); );
} }
function Shell({ version, logout }: { version: string; logout: () => void }) { function Shell({ version }: { version: string }) {
const { logout } = useAuth();
const nav = useNavigate(); const nav = useNavigate();
const [theme, setTheme] = useState(localStorage.theme || "system"); const { theme, saveTheme, error: themeError } = useTheme();
useEffect(() => { const [shellError, setShellError] = useState("");
document.documentElement.dataset.theme =
theme === "system"
? matchMedia("(prefers-color-scheme:dark)").matches
? "dark"
: "light"
: theme;
localStorage.theme = theme;
}, [theme]);
return ( return (
<div className="app"> <div className="app">
<aside> <aside>
@@ -262,19 +198,31 @@ function Shell({ version, logout }: { version: string; logout: () => void }) {
</button> </button>
</nav> </nav>
<div className="aside-bottom"> <div className="aside-bottom">
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}> <button
onClick={() =>
void saveTheme(theme === "dark" ? "light" : "dark").catch(
() => undefined,
)
}
>
{theme === "dark" ? <Sun /> : <Moon />}切换主题 {theme === "dark" ? <Sun /> : <Moon />}切换主题
</button> </button>
{themeError && <small className="error">{themeError}</small>}
<button <button
onClick={async () => { onClick={async () => {
if (!confirm("确定要退出 Codex Helper 管理后台吗?")) return; if (!confirm("确定要退出 Codex Helper 管理后台吗?")) return;
await post("auth/logout"); try {
logout(); setShellError("");
await logout();
} catch (error) {
setShellError(toErrorMessage(error));
}
}} }}
> >
<LogOut /> <LogOut />
退出 退出
</button> </button>
{shellError && <small className="error">{shellError}</small>}
</div> </div>
</aside> </aside>
<section className="content"> <section className="content">
@@ -288,50 +236,90 @@ function Shell({ version, logout }: { version: string; logout: () => void }) {
); );
} }
function Dashboard() { function Dashboard() {
const [accounts, setAccounts] = useState<Account[]>([]), const [accounts, setAccounts] = useState<Account[] | null>(null),
[id, setId] = useState(+(localStorage.accountId || 0)), [id, setId] = useState(+(localStorage.accountId || 0)),
[d, setD] = useState<Dash | null>(null), [d, setD] = useState<Dash | null>(null),
[e, setE] = useState(""), [e, setE] = useState(""),
[refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle"); [refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle");
const loadAccounts = () => const requestRef = useRef(0);
api<Account[]>("accounts").then((xs) => { const abortRef = useRef<AbortController | null>(null);
const loadAccounts = async () => {
try {
const xs = await get("accounts", decodeAccounts);
setAccounts(xs); setAccounts(xs);
setE("");
const next = xs.some((x) => x.id === id) ? id : xs[0]?.id || 0; const next = xs.some((x) => x.id === id) ? id : xs[0]?.id || 0;
if (next !== id) setId(next); if (next !== id) setId(next);
}); } catch (error) {
const load = async (accountId = id) => { setE(toErrorMessage(error));
}
};
const load = async (accountId = id, signal?: AbortSignal) => {
if (!accountId) return; if (!accountId) return;
const request = ++requestRef.current;
try { try {
setD(await api<Dash>("dashboard?accountId=" + accountId)); const dashboard = await get(
"dashboard?accountId=" + accountId,
decodeDashboard,
signal,
);
if (request === requestRef.current && dashboard.accountId === accountId) {
setD(dashboard);
setE(""); setE("");
}
} catch (x) { } catch (x) {
setE((x as Error).message); if (!signal?.aborted && request === requestRef.current)
setE(toErrorMessage(x));
} }
}; };
useEffect(() => { useEffect(() => {
loadAccounts(); void loadAccounts();
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
localStorage.accountId = String(id); localStorage.setItem("accountId", String(id));
setD(null); setD(null);
load(id); abortRef.current?.abort();
const t = setInterval(() => load(id), 30000); const controller = new AbortController();
return () => clearInterval(t); abortRef.current = controller;
let timer = 0;
const poll = async () => {
await load(id, controller.signal);
if (!controller.signal.aborted) timer = window.setTimeout(poll, 30_000);
};
void poll();
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [id]); }, [id]);
const sync = async () => { const sync = async () => {
setRefresh("loading"); setRefresh("loading");
setE(""); setE("");
try { try {
await post(`accounts/${id}/sync`); await post(`accounts/${id}/sync`, decodeOK, {}, undefined, 60_000);
await load(id); await load(id);
setRefresh("done"); setRefresh("done");
setTimeout(() => setRefresh("idle"), 2000); window.setTimeout(() => setRefresh("idle"), 2000);
} catch (x) { } catch (x) {
setE((x as Error).message); setE(toErrorMessage(x));
setRefresh("idle"); setRefresh("idle");
} }
}; };
if (accounts === null)
return (
<>
<Header title="用量总览" />
{e ? (
<div className="panel empty">
<p className="error">{e}</p>
<button onClick={() => void loadAccounts()}>重试</button>
</div>
) : (
<Splash />
)}
</>
);
if (!accounts.length) if (!accounts.length)
return ( return (
<> <>
@@ -339,7 +327,20 @@ function Dashboard() {
<div className="panel empty">尚未添加 Codex 账号</div> <div className="panel empty">尚未添加 Codex 账号</div>
</> </>
); );
if (!d) return <Splash />; if (!d)
return (
<>
<Header title="用量总览" />
{e ? (
<div className="panel empty">
<p className="error">{e}</p>
<button onClick={() => void load(id)}>重试</button>
</div>
) : (
<Splash />
)}
</>
);
return ( return (
<> <>
<Header title="用量总览"> <Header title="用量总览">
@@ -376,9 +377,8 @@ function Dashboard() {
<div> <div>
<small>Codex Account</small> <small>Codex Account</small>
<b> <b>
{d.displayName} · {d.account.email {d.displayName} ·{" "}
? maskEmail(d.account.email) {d.account.email ? maskEmail(d.account.email) : "尚未连接"}
: "尚未连接"}
</b> </b>
</div> </div>
<span className="badge">{planLabel(d.account.planType)}</span> <span className="badge">{planLabel(d.account.planType)}</span>
@@ -415,7 +415,9 @@ function Dashboard() {
v={duration(d.summary.longestRunningTurnSec)} v={duration(d.summary.longestRunningTurnSec)}
/> />
</div> </div>
<Chart data={d.usage} /> <Suspense fallback={<SettingsLoading />}>
<UsageChart data={d.usage} />
</Suspense>
</> </>
); );
} }
@@ -468,49 +470,6 @@ const Stat = ({
<b className={muted ? "muted" : ""}>{v}</b> <b className={muted ? "muted" : ""}>{v}</b>
</div> </div>
); );
const Chart = ({ data }: { data: Point[] }) => (
<div className="panel chart">
<div>
<h3>每日 Token 趋势</h3>
</div>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={data}>
<defs>
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#5ee7f7" stopOpacity={0.35} />
<stop offset="1" stopColor="#5ee7f7" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--grid)" vertical={false} />
<XAxis dataKey="date" stroke="var(--muted)" />
<YAxis
width={55}
tickCount={5}
tickFormatter={compactAxis}
stroke="var(--muted)"
/>
<Tooltip
formatter={(v) => [
new Intl.NumberFormat("zh-CN").format(Number(v)),
"Tokens",
]}
contentStyle={{
background: "var(--panel)",
border: "1px solid var(--border)",
borderRadius: 12,
}}
/>
<Area
type="monotone"
dataKey="totalTokens"
stroke="#4dd8ed"
fill="url(#a)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
type SettingsTab = "general" | "codex" | "telegram" | "smtp"; type SettingsTab = "general" | "codex" | "telegram" | "smtp";
const settingsTabs: Array<{ const settingsTabs: Array<{
@@ -540,7 +499,8 @@ function SettingsPage() {
if (event.key === "ArrowRight" || event.key === "ArrowDown") if (event.key === "ArrowRight" || event.key === "ArrowDown")
nextIndex = (currentIndex + 1) % settingsTabs.length; nextIndex = (currentIndex + 1) % settingsTabs.length;
if (event.key === "ArrowLeft" || event.key === "ArrowUp") if (event.key === "ArrowLeft" || event.key === "ArrowUp")
nextIndex = (currentIndex - 1 + settingsTabs.length) % settingsTabs.length; nextIndex =
(currentIndex - 1 + settingsTabs.length) % settingsTabs.length;
if (event.key === "Home") nextIndex = 0; if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = settingsTabs.length - 1; if (event.key === "End") nextIndex = settingsTabs.length - 1;
if (nextIndex === undefined) return; if (nextIndex === undefined) return;
@@ -605,25 +565,47 @@ const SettingsLoading = () => (
</div> </div>
); );
function General() { function General() {
const [v, setV] = useState<any>(null), const [v, setV] = useState<GeneralSettings | null>(null),
[msg, setMsg] = useState(""); [msg, setMsg] = useState(""),
[loadError, setLoadError] = useState("");
const { applyTheme } = useTheme();
const loadGeneral = async () => {
try {
setLoadError("");
const value = await get("settings/general", decodeGeneral);
setV(value);
applyTheme(value.theme);
} catch (error) {
setLoadError(toErrorMessage(error));
}
};
useEffect(() => { useEffect(() => {
api("settings/general").then(setV); void loadGeneral();
}, []); }, []);
if (!v) return <SettingsLoading />; if (!v)
return loadError ? (
<div className="panel wide">
<p className="error">{loadError}</p>
<button onClick={() => void loadGeneral()}>重试</button>
</div>
) : (
<SettingsLoading />
);
return ( return (
<form <form
className="panel form wide" className="panel form wide"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
await api("settings/general", { const saved = await api("settings/general", decodeGeneral, {
method: "PUT", method: "PUT",
body: JSON.stringify(v), body: JSON.stringify(v),
}); });
setV(saved);
applyTheme(saved.theme);
setMsg("设置已保存"); setMsg("设置已保存");
} catch (x) { } catch (x) {
setMsg((x as Error).message); setMsg(toErrorMessage(x));
} }
}} }}
> >
@@ -640,7 +622,11 @@ function General() {
主题 主题
<select <select
value={v.theme} value={v.theme}
onChange={(e) => setV({ ...v, theme: e.target.value })} onChange={(e) => {
const theme = e.target.value;
if (theme === "system" || theme === "dark" || theme === "light")
setV({ ...v, theme });
}}
> >
<option value="system">跟随系统</option> <option value="system">跟随系统</option>
<option value="dark">深色</option> <option value="dark">深色</option>
@@ -705,29 +691,45 @@ function CodexSettings() {
[newKind, setNewKind] = useState<"personal" | "team">("team"), [newKind, setNewKind] = useState<"personal" | "team">("team"),
[busy, setBusy] = useState(false), [busy, setBusy] = useState(false),
[err, setErr] = useState(""); [err, setErr] = useState("");
const load = () => api<Account[]>("accounts").then((accounts) => { setXs(accounts); return accounts; }); const load = async (signal?: AbortSignal) => {
const accounts = await get("accounts", decodeAccounts, signal);
setXs(accounts);
return accounts;
};
useEffect(() => { useEffect(() => {
load(); void load().catch((error) => setErr(toErrorMessage(error)));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!deviceLogin) return; if (!deviceLogin) return;
const started = Date.now(); const started = Date.now();
const timer = setInterval(async () => { const controller = new AbortController();
const accounts = await load(); let timer = 0;
const poll = async () => {
try {
const accounts = await load(controller.signal);
const account = accounts.find((x) => x.id === deviceLogin.accountId); const account = accounts.find((x) => x.id === deviceLogin.accountId);
if (!account) { if (!account) {
setDeviceLogin(null); setDeviceLogin(null);
clearInterval(timer);
return; return;
} }
if ( if (["matched", "mismatch"].includes(account.validationStatus)) return;
["matched", "mismatch"].includes(account.validationStatus) if (Date.now() - started >= 120_000) {
) setErr("设备码登录检测已超时,请重新生成设备码");
clearInterval(timer); return;
else if (Date.now() - started >= 120000) }
clearInterval(timer); timer = window.setTimeout(poll, 2000);
}, 2000); } catch (error) {
return () => clearInterval(timer); if (!controller.signal.aborted) {
setErr(toErrorMessage(error));
timer = window.setTimeout(poll, 2000);
}
}
};
timer = window.setTimeout(poll, 2000);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [deviceLogin]); }, [deviceLogin]);
const login = async (id: number) => { const login = async (id: number) => {
try { try {
@@ -735,12 +737,16 @@ function CodexSettings() {
setActive(id); setActive(id);
setErr(""); setErr("");
setDeviceLogin(null); setDeviceLogin(null);
const result = await post<Omit<DeviceLogin, "accountId">>( const result = await post(
`accounts/${id}/login/device`, `accounts/${id}/login/device`,
decodeDeviceLogin,
{},
undefined,
60_000,
); );
setDeviceLogin({ accountId: id, ...result }); setDeviceLogin({ accountId: id, ...result });
} catch (q) { } catch (q) {
setErr((q as Error).message); setErr(toErrorMessage(q));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -751,18 +757,22 @@ function CodexSettings() {
setActive(0); setActive(0);
setErr(""); setErr("");
setDeviceLogin(null); setDeviceLogin(null);
const x = await post<Account>("accounts", { const x = await post("accounts", (value) => decodeAccounts([value])[0], {
displayName: `账号 ${xs.length + 1}`, displayName: `账号 ${xs.length + 1}`,
expectedKind: newKind, expectedKind: newKind,
}); });
await load(); await load();
setActive(x.id); setActive(x.id);
const result = await post<Omit<DeviceLogin, "accountId">>( const result = await post(
`accounts/${x.id}/login/device`, `accounts/${x.id}/login/device`,
decodeDeviceLogin,
{},
undefined,
60_000,
); );
setDeviceLogin({ accountId: x.id, ...result }); setDeviceLogin({ accountId: x.id, ...result });
} catch (q) { } catch (q) {
setErr((q as Error).message); setErr(toErrorMessage(q));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -771,7 +781,9 @@ function CodexSettings() {
<div className="codex-settings wide"> <div className="codex-settings wide">
<div className="codex-heading"> <div className="codex-heading">
<h2>Codex 账户与工作区</h2> <h2>Codex 账户与工作区</h2>
<p>个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。</p> <p>
个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。
</p>
</div> </div>
<section className="panel add-account-card"> <section className="panel add-account-card">
<div> <div>
@@ -813,18 +825,22 @@ function CodexSettings() {
onBlur={async (e) => { onBlur={async (e) => {
const name = e.target.value.trim(); const name = e.target.value.trim();
if (name && name !== x.displayName) { if (name && name !== x.displayName) {
await put(`accounts/${x.id}`, { await put(`accounts/${x.id}`, decodeOK, {
displayName: name, displayName: name,
expectedKind: x.expectedKind, expectedKind: x.expectedKind,
}); });
load(); void load().catch((error) =>
setErr(toErrorMessage(error)),
);
} }
}} }}
/> />
<div className="account-meta"> <div className="account-meta">
<span>{x.email ? maskEmail(x.email) : "尚未登录"}</span> <span>{x.email ? maskEmail(x.email) : "尚未登录"}</span>
<span>{planLabel(x.planType)}</span> <span>{planLabel(x.planType)}</span>
<span className={x.validationStatus === "mismatch" ? "error" : ""}> <span
className={x.validationStatus === "mismatch" ? "error" : ""}
>
{validationLabel(x)} {validationLabel(x)}
</span> </span>
</div> </div>
@@ -842,11 +858,11 @@ function CodexSettings() {
aria-label="预期连接类型" aria-label="预期连接类型"
value={x.expectedKind} value={x.expectedKind}
onChange={async (e) => { onChange={async (e) => {
await put(`accounts/${x.id}`, { await put(`accounts/${x.id}`, decodeOK, {
displayName: x.displayName, displayName: x.displayName,
expectedKind: e.target.value, expectedKind: e.target.value,
}); });
load(); void load().catch((error) => setErr(toErrorMessage(error)));
}} }}
> >
<option value="any">不校验</option> <option value="any">不校验</option>
@@ -855,21 +871,28 @@ function CodexSettings() {
</select> </select>
</label> </label>
<div className="account-buttons"> <div className="account-buttons">
<button className="secondary" disabled={busy} onClick={() => login(x.id)}> <button
className="secondary"
disabled={busy}
onClick={() => login(x.id)}
>
{busy && active === x.id ? "服务启动中…" : "设备码登录"} {busy && active === x.id ? "服务启动中…" : "设备码登录"}
</button> </button>
<button <button
className="secondary" className="secondary"
disabled={busy} disabled={busy}
onClick={async () => { onClick={async () => {
if (!confirm(`确定要退出“${x.displayName}”的 Codex 账号吗?`)) return; if (
!confirm(`确定要退出“${x.displayName}”的 Codex 账号吗?`)
)
return;
try { try {
setErr(""); setErr("");
await post(`accounts/${x.id}/logout`); await post(`accounts/${x.id}/logout`, decodeOK);
if (deviceLogin?.accountId === x.id) setDeviceLogin(null); if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
await load(); await load();
} catch (q) { } catch (q) {
setErr((q as Error).message); setErr(toErrorMessage(q));
} }
}} }}
> >
@@ -881,16 +904,22 @@ function CodexSettings() {
title="删除连接" title="删除连接"
aria-label={`删除“${x.displayName}”`} aria-label={`删除“${x.displayName}”`}
onClick={async () => { onClick={async () => {
if (!confirm(`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`)) return; if (
!confirm(
`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`,
)
)
return;
try { try {
setErr(""); setErr("");
await del(`accounts/${x.id}`); await del(`accounts/${x.id}`, decodeOK);
if (deviceLogin?.accountId === x.id) setDeviceLogin(null); if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
if (active === x.id) setActive(0); if (active === x.id) setActive(0);
if (+localStorage.accountId === x.id) localStorage.removeItem("accountId"); if (+localStorage.accountId === x.id)
localStorage.removeItem("accountId");
await load(); await load();
} catch (q) { } catch (q) {
setErr((q as Error).message); setErr(toErrorMessage(q));
} }
}} }}
> >
@@ -902,7 +931,11 @@ function CodexSettings() {
<div className="codebox"> <div className="codebox">
<span> <span>
在浏览器中访问{" "} 在浏览器中访问{" "}
<a href={deviceLogin.verificationUrl} target="_blank" rel="noreferrer"> <a
href={deviceLogin.verificationUrl}
target="_blank"
rel="noreferrer"
>
{deviceLogin.verificationUrl} {deviceLogin.verificationUrl}
</a> </a>
,然后输入设备码 ,然后输入设备码
@@ -918,27 +951,45 @@ function CodexSettings() {
); );
} }
function Telegram() { function Telegram() {
const [v, setV] = useState<any>(null), const [v, setV] = useState<TelegramSettingsForm | null>(null),
[msg, setMsg] = useState(""), [msg, setMsg] = useState(""),
[code, setCode] = useState(""); [code, setCode] = useState(""),
[loadError, setLoadError] = useState("");
const loadTelegram = async () => {
try {
setLoadError("");
const value = await get("settings/telegram", decodeTelegram);
setV({ ...value, token: "" });
} catch (error) {
setLoadError(toErrorMessage(error));
}
};
useEffect(() => { useEffect(() => {
api("settings/telegram").then(setV); void loadTelegram();
}, []); }, []);
if (!v) return <SettingsLoading />; if (!v)
return loadError ? (
<div className="panel wide">
<p className="error">{loadError}</p>
<button onClick={() => void loadTelegram()}>重试</button>
</div>
) : (
<SettingsLoading />
);
return ( return (
<form <form
className="panel form wide" className="panel form wide"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
const x: any = await api("settings/telegram", { const x = await api("settings/telegram", decodeTelegram, {
method: "PUT", method: "PUT",
body: JSON.stringify(v), body: JSON.stringify(v),
}); });
setV(x); setV({ ...x, token: "" });
setMsg("Bot 已验证并保存"); setMsg("Bot 已验证并保存");
} catch (x) { } catch (x) {
setMsg((x as Error).message); setMsg(toErrorMessage(x));
} }
}} }}
> >
@@ -978,8 +1029,12 @@ function Telegram() {
type="button" type="button"
className="secondary" className="secondary"
onClick={async () => { onClick={async () => {
const x: any = await post("settings/telegram/bind"); try {
const x = await post("settings/telegram/bind", decodeCode);
setCode(x.code); setCode(x.code);
} catch (error) {
setMsg(toErrorMessage(error));
}
}} }}
> >
生成绑定码 生成绑定码
@@ -989,10 +1044,10 @@ function Telegram() {
className="secondary" className="secondary"
onClick={async () => { onClick={async () => {
try { try {
await post("settings/telegram/test"); await post("settings/telegram/test", decodeOK);
setMsg("测试消息已发送"); setMsg("测试消息已发送");
} catch (x) { } catch (x) {
setMsg((x as Error).message); setMsg(toErrorMessage(x));
} }
}} }}
> >
@@ -1010,21 +1065,41 @@ function Telegram() {
); );
} }
function SMTP() { function SMTP() {
const [v, setV] = useState<any>(null), const [v, setV] = useState<SMTPSettingsForm | null>(null),
[msg, setMsg] = useState(""); [msg, setMsg] = useState(""),
[loadError, setLoadError] = useState("");
const loadSMTP = async () => {
try {
setLoadError("");
const value = await get("settings/smtp", decodeSMTP);
setV({ ...value, password: "" });
} catch (error) {
setLoadError(toErrorMessage(error));
}
};
useEffect(() => { useEffect(() => {
api("settings/smtp").then(setV); void loadSMTP();
}, []); }, []);
if (!v) return <SettingsLoading />; if (!v)
return loadError ? (
<div className="panel wide">
<p className="error">{loadError}</p>
<button onClick={() => void loadSMTP()}>重试</button>
</div>
) : (
<SettingsLoading />
);
const save = async (e: React.FormEvent) => { const save = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
setV( const saved = await api("settings/smtp", decodeSMTP, {
await api("settings/smtp", { method: "PUT", body: JSON.stringify(v) }), method: "PUT",
); body: JSON.stringify(v),
});
setV({ ...saved, password: "" });
setMsg("SMTP 设置已保存"); setMsg("SMTP 设置已保存");
} catch (x) { } catch (x) {
setMsg((x as Error).message); setMsg(toErrorMessage(x));
} }
}; };
return ( return (
@@ -1067,7 +1142,15 @@ function SMTP() {
加密 加密
<select <select
value={v.security} value={v.security}
onChange={(e) => setV({ ...v, security: e.target.value })} onChange={(e) => {
const security = e.target.value;
if (
security === "starttls" ||
security === "tls" ||
security === "none"
)
setV({ ...v, security });
}}
> >
<option value="starttls">STARTTLS</option> <option value="starttls">STARTTLS</option>
<option value="tls">隐式 TLS</option> <option value="tls">隐式 TLS</option>
@@ -1113,10 +1196,10 @@ function SMTP() {
className="secondary" className="secondary"
onClick={async () => { onClick={async () => {
try { try {
await post("settings/smtp/test"); await post("settings/smtp/test", decodeOK);
setMsg("测试邮件已发送"); setMsg("测试邮件已发送");
} catch (x) { } catch (x) {
setMsg((x as Error).message); setMsg(toErrorMessage(x));
} }
}} }}
> >
@@ -1146,19 +1229,14 @@ function Header({
</header> </header>
); );
} }
const num = (v?: number) => const num = (v?: number | null) =>
v == null v == null
? "暂无" ? "暂无"
: new Intl.NumberFormat("zh-CN", { : new Intl.NumberFormat("zh-CN", {
notation: "compact", notation: "compact",
maximumFractionDigits: 1, maximumFractionDigits: 1,
}).format(v); }).format(v);
const compactAxis = (v: number) => const duration = (v?: number | null) =>
new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
}).format(v);
const duration = (v?: number) =>
v == null v == null
? "暂无" ? "暂无"
: v < 60 : v < 60
@@ -1182,22 +1260,33 @@ const maskEmail = (email: string) => {
) )
.join(".")}`; .join(".")}`;
}; };
const planLabel = (plan?: string) => { const planLabel = (plan?: string | null) => {
if (!plan) return "未识别套餐"; if (!plan) return "未识别套餐";
const labels: Record<string, string> = { const labels: Record<string, string> = {
free: "Free", go: "Go", plus: "Plus", pro: "Pro", prolite: "Pro Lite", free: "Free",
team: "Team", business: "Business", go: "Go",
plus: "Plus",
pro: "Pro",
prolite: "Pro Lite",
team: "Team",
business: "Business",
self_serve_business_prolite: "Business Pro Lite", self_serve_business_prolite: "Business Pro Lite",
self_serve_business_usage_based: "Business(按量)", self_serve_business_usage_based: "Business(按量)",
enterprise: "Enterprise", edu: "Edu", enterprise: "Enterprise",
edu: "Edu",
}; };
return labels[plan.toLowerCase()] || plan; return labels[plan.toLowerCase()] || plan;
}; };
const validationLabel = (account: Account) => { const validationLabel = (account: Account) => {
if (account.expectedKind === "any") return "未启用套餐类型校验"; if (account.expectedKind === "any") return "未启用套餐类型校验";
if (account.validationStatus === "pending") return `等待验证${account.expectedKind === "team" ? " Team / Business" : "个人订阅"}`; if (account.validationStatus === "pending")
if (account.validationStatus === "matched") return account.actualKind === "team" ? "已连接 Team / Business 工作区" : "已连接个人订阅"; return `等待验证${account.expectedKind === "team" ? " Team / Business" : "个人订阅"}`;
if (account.validationStatus === "mismatch") return `类型不匹配:实际为 ${planLabel(account.planType)},请退出后重新授权`; if (account.validationStatus === "matched")
return account.actualKind === "team"
? "已连接 Team / Business 工作区"
: "已连接个人订阅";
if (account.validationStatus === "mismatch")
return `类型不匹配:实际为 ${planLabel(account.planType)},请退出后重新授权`;
return `无法验证工作区类型:${planLabel(account.planType)}`; return `无法验证工作区类型:${planLabel(account.planType)}`;
}; };
const relative = (ts: number) => { const relative = (ts: number) => {
@@ -1208,8 +1297,14 @@ const relative = (ts: number) => {
m = Math.floor((s % 3600) / 60); m = Math.floor((s % 3600) / 60);
return d ? `${d} 天 ${h} 小时后` : h ? `${h} 小时 ${m} 分后` : `${m} 分钟后`; return d ? `${d} 天 ${h} 小时后` : h ? `${h} 小时 ${m} 分后` : `${m} 分钟后`;
}; };
createRoot(document.getElementById("root")!).render( const root = document.getElementById("root");
if (!root) throw new Error("缺少 #root 挂载节点");
createRoot(root).render(
<React.StrictMode> <React.StrictMode>
<AuthProvider>
<ThemeProvider>
<App /> <App />
</ThemeProvider>
</AuthProvider>
</React.StrictMode>, </React.StrictMode>,
); );
+77
View File
@@ -0,0 +1,77 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import type { Theme } from "./types";
import { get, put, toErrorMessage } from "./api";
import { decodeGeneral } from "./types";
import { useAuth } from "./auth";
type ThemeContextValue = {
theme: Theme;
applyTheme: (theme: Theme) => void;
saveTheme: (theme: Theme) => Promise<void>;
error: string;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
const validTheme = (value: string | null): value is Theme =>
value === "system" || value === "dark" || value === "light";
export function ThemeProvider({ children }: { children: ReactNode }) {
const { authenticated } = useAuth();
const stored = localStorage.getItem("theme");
const [theme, setThemeState] = useState<Theme>(
validTheme(stored) ? stored : "system",
);
const [error, setError] = useState("");
useEffect(() => {
const media = matchMedia("(prefers-color-scheme:dark)");
const apply = () => {
document.documentElement.dataset.theme =
theme === "system" ? (media.matches ? "dark" : "light") : theme;
};
apply();
if (theme === "system") media.addEventListener("change", apply);
localStorage.setItem("theme", theme);
return () => media.removeEventListener("change", apply);
}, [theme]);
useEffect(() => {
if (!authenticated) return;
const controller = new AbortController();
void get("settings/general", decodeGeneral, controller.signal)
.then((settings) => setThemeState(settings.theme))
.catch((cause) => {
if (!controller.signal.aborted) setError(toErrorMessage(cause));
});
return () => controller.abort();
}, [authenticated]);
const saveTheme = async (next: Theme) => {
const previous = theme;
setThemeState(next);
setError("");
try {
const general = await get("settings/general", decodeGeneral);
await put("settings/general", decodeGeneral, { ...general, theme: next });
} catch (cause) {
setThemeState(previous);
setError(toErrorMessage(cause));
throw cause;
}
};
return (
<ThemeContext.Provider
value={{ theme, applyTheme: setThemeState, saveTheme, error }}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const value = useContext(ThemeContext);
if (!value) throw new Error("useTheme 必须在 ThemeProvider 内使用");
return value;
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { ApiError, toErrorMessage } from "./api";
import { decodeDashboard, decodeDeviceLogin, decodeGeneral } from "./types";
describe("API decoders", () => {
it("保留 Dashboard 的 null 与 optional 摘要", () => {
const value = decodeDashboard({
accountId: 2,
displayName: "Team",
account: { email: null, planType: null, connected: false },
limits: [],
summary: { lifetimeTokens: null },
usage: [],
fetchedAt: 0,
stale: true,
});
expect(value.summary.lifetimeTokens).toBeNull();
expect(value.account.email).toBeNull();
});
it("拒绝未知主题", () => {
expect(() =>
decodeGeneral({
timezone: "UTC",
theme: "blue",
syncMinutes: 5,
retentionDays: 90,
beforeMinutes: 30,
notifyBefore: true,
notifyAfter: true,
}),
).toThrow("theme格式无效");
});
it("只接受 HTTPS 设备授权地址", () => {
expect(() =>
decodeDeviceLogin({
verificationUrl: "http://example.com",
userCode: "ABC",
}),
).toThrow("HTTPS");
expect(
decodeDeviceLogin({
verificationUrl: "https://example.com",
userCode: "ABC",
}).userCode,
).toBe("ABC");
});
});
describe("errors", () => {
it("保留结构化 API 错误", () => {
const error = new ApiError("未登录", "http", 401);
expect(error.status).toBe(401);
expect(toErrorMessage(error)).toBe("未登录");
expect(toErrorMessage("bad")).toBe("发生未知错误");
});
});
+263
View File
@@ -0,0 +1,263 @@
import {
boolean,
nullableString,
number,
record,
string,
type Decoder,
} from "./api";
export type Theme = "system" | "dark" | "light";
export type ExpectedKind = "any" | "personal" | "team";
export type ValidationStatus = "pending" | "matched" | "mismatch" | "unknown";
export type AsyncState<T> =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; data: T };
export interface Status {
initialized: boolean;
appServer: boolean;
version: string;
}
export interface Account {
id: number;
displayName: string;
email: string | null;
planType: string | null;
expectedKind: ExpectedKind;
actualKind: "unknown" | "personal" | "team";
validationStatus: ValidationStatus;
possibleDuplicate: boolean;
connected: boolean;
}
export interface DeviceLogin {
accountId: number;
verificationUrl: string;
userCode: string;
}
export interface Limit {
limitId: string;
limitName: string | null;
windowType: string;
usedPercent: number;
windowDurationMinutes: number;
resetsAt: number;
}
export interface Point {
date: string;
totalTokens: number;
}
export interface Dashboard {
accountId: number;
displayName: string;
account: {
email: string | null;
planType: string | null;
connected: boolean;
};
limits: Limit[];
summary: {
lifetimeTokens?: number | null;
peakDailyTokens?: number | null;
currentStreakDays?: number | null;
longestRunningTurnSec?: number | null;
};
usage: Point[];
fetchedAt: number;
stale: boolean;
lastError?: string;
}
export interface GeneralSettings {
timezone: string;
theme: Theme;
syncMinutes: number;
retentionDays: number;
beforeMinutes: number;
notifyBefore: boolean;
notifyAfter: boolean;
}
export interface TelegramSettingsResponse {
chatId: number;
enabled: boolean;
menuEnabled: boolean;
configured: boolean;
botName?: string;
}
export type TelegramSettingsForm = TelegramSettingsResponse & { token: string };
export interface SMTPSettingsResponse {
host: string;
port: number;
username: string;
from: string;
fromName: string;
to: string;
security: "starttls" | "tls" | "none";
enabled: boolean;
configured: boolean;
}
export type SMTPSettingsForm = SMTPSettingsResponse & { password: string };
const enumValue = <T extends string>(
value: unknown,
values: readonly T[],
name: string,
): T => {
const result = string(value, name);
if (!values.includes(result as T)) throw new Error(`${name}格式无效`);
return result as T;
};
const optionalNumber = (value: unknown, name: string) =>
value == null ? value : number(value, name);
export const decodeStatus: Decoder<Status> = (value) => {
const x = record(value);
return {
initialized: boolean(x.initialized, "initialized"),
appServer: boolean(x.appServer, "appServer"),
version: string(x.version, "version"),
};
};
export const decodeAccount: Decoder<Account> = (value) => {
const x = record(value, "账号");
return {
id: number(x.id, "id"),
displayName: string(x.displayName, "displayName"),
email: nullableString(x.email, "email"),
planType: nullableString(x.planType, "planType"),
expectedKind: enumValue(
x.expectedKind,
["any", "personal", "team"],
"expectedKind",
),
actualKind: enumValue(
x.actualKind,
["unknown", "personal", "team"],
"actualKind",
),
validationStatus: enumValue(
x.validationStatus,
["pending", "matched", "mismatch", "unknown"],
"validationStatus",
),
possibleDuplicate: boolean(x.possibleDuplicate, "possibleDuplicate"),
connected: boolean(x.connected, "connected"),
};
};
export const decodeAccounts: Decoder<Account[]> = (value) => {
if (!Array.isArray(value)) throw new Error("账号列表格式无效");
return value.map(decodeAccount);
};
export const decodeDashboard: Decoder<Dashboard> = (value) => {
const x = record(value, "Dashboard"),
account = record(x.account, "account"),
summary = record(x.summary, "summary");
if (!Array.isArray(x.limits) || !Array.isArray(x.usage))
throw new Error("Dashboard列表格式无效");
return {
accountId: number(x.accountId, "accountId"),
displayName: string(x.displayName, "displayName"),
account: {
email: nullableString(account.email, "email"),
planType: nullableString(account.planType, "planType"),
connected: boolean(account.connected, "connected"),
},
limits: x.limits.map((v) => {
const l = record(v, "limit");
return {
limitId: string(l.limitId, "limitId"),
limitName: nullableString(l.limitName, "limitName"),
windowType: string(l.windowType, "windowType"),
usedPercent: number(l.usedPercent, "usedPercent"),
windowDurationMinutes: number(
l.windowDurationMinutes,
"windowDurationMinutes",
),
resetsAt: number(l.resetsAt, "resetsAt"),
};
}),
summary: {
lifetimeTokens: optionalNumber(summary.lifetimeTokens, "lifetimeTokens"),
peakDailyTokens: optionalNumber(
summary.peakDailyTokens,
"peakDailyTokens",
),
currentStreakDays: optionalNumber(
summary.currentStreakDays,
"currentStreakDays",
),
longestRunningTurnSec: optionalNumber(
summary.longestRunningTurnSec,
"longestRunningTurnSec",
),
},
usage: x.usage.map((v) => {
const p = record(v, "usage");
return {
date: string(p.date, "date"),
totalTokens: number(p.totalTokens, "totalTokens"),
};
}),
fetchedAt: number(x.fetchedAt, "fetchedAt"),
stale: boolean(x.stale, "stale"),
...(typeof x.lastError === "string" ? { lastError: x.lastError } : {}),
};
};
export const decodeGeneral: Decoder<GeneralSettings> = (value) => {
const x = record(value);
return {
timezone: string(x.timezone, "timezone"),
theme: enumValue(x.theme, ["system", "dark", "light"], "theme"),
syncMinutes: number(x.syncMinutes, "syncMinutes"),
retentionDays: number(x.retentionDays, "retentionDays"),
beforeMinutes: number(x.beforeMinutes, "beforeMinutes"),
notifyBefore: boolean(x.notifyBefore, "notifyBefore"),
notifyAfter: boolean(x.notifyAfter, "notifyAfter"),
};
};
export const decodeTelegram: Decoder<TelegramSettingsResponse> = (value) => {
const x = record(value);
return {
chatId: number(x.chatId ?? 0, "chatId"),
enabled: boolean(x.enabled, "enabled"),
menuEnabled: boolean(x.menuEnabled, "menuEnabled"),
configured: boolean(x.configured, "configured"),
...(typeof x.botName === "string" ? { botName: x.botName } : {}),
};
};
export const decodeSMTP: Decoder<SMTPSettingsResponse> = (value) => {
const x = record(value);
return {
host: string(x.host, "host"),
port: number(x.port, "port"),
username: string(x.username, "username"),
from: string(x.from, "from"),
fromName: string(x.fromName, "fromName"),
to: string(x.to, "to"),
security: enumValue(x.security, ["starttls", "tls", "none"], "security"),
enabled: boolean(x.enabled, "enabled"),
configured: boolean(x.configured, "configured"),
};
};
export const decodeDeviceLogin: Decoder<Omit<DeviceLogin, "accountId">> = (
value,
) => {
const x = record(value),
verificationUrl = string(x.verificationUrl, "verificationUrl");
let url: URL;
try {
url = new URL(verificationUrl);
} catch {
throw new Error("设备授权地址无效");
}
if (url.protocol !== "https:") throw new Error("设备授权地址必须使用 HTTPS");
return { verificationUrl, userCode: string(x.userCode, "userCode") };
};
export const decodeOK: Decoder<{ ok: boolean }> = (value) => {
const x = record(value);
return { ok: boolean(x.ok, "ok") };
};
export const decodeCode: Decoder<{ code: string }> = (value) => {
const x = record(value);
return { code: string(x.code, "code") };
};
+62
View File
@@ -0,0 +1,62 @@
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { Point } from "./types";
const compactAxis = (value: number) =>
new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
}).format(value);
export default function UsageChart({ data }: { data: Point[] }) {
return (
<div className="panel chart">
<div>
<h3>每日 Token 趋势</h3>
</div>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={data}>
<defs>
<linearGradient id="a" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#5ee7f7" stopOpacity={0.35} />
<stop offset="1" stopColor="#5ee7f7" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--grid)" vertical={false} />
<XAxis dataKey="date" stroke="var(--muted)" />
<YAxis
width={55}
tickCount={5}
tickFormatter={compactAxis}
stroke="var(--muted)"
/>
<Tooltip
formatter={(value) => [
new Intl.NumberFormat("zh-CN").format(Number(value)),
"Tokens",
]}
contentStyle={{
background: "var(--panel)",
border: "1px solid var(--border)",
borderRadius: 12,
}}
/>
<Area
type="monotone"
dataKey="totalTokens"
stroke="#4dd8ed"
fill="url(#a)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
+152 -23
View File
@@ -12,7 +12,8 @@ const responses: Record<string, unknown> = {
notifyBefore: true, notifyBefore: true,
notifyAfter: true, notifyAfter: true,
}, },
accounts: [{ accounts: [
{
id: 1, id: 1,
displayName: "默认账号", displayName: "默认账号",
email: "test@example.com", email: "test@example.com",
@@ -22,8 +23,14 @@ const responses: Record<string, unknown> = {
validationStatus: "matched", validationStatus: "matched",
possibleDuplicate: false, possibleDuplicate: false,
connected: true, connected: true,
}], },
"settings/telegram": { token: "", configured: true, enabled: true, menuEnabled: true }, ],
"settings/telegram": {
token: "",
configured: true,
enabled: true,
menuEnabled: true,
},
"settings/smtp": { "settings/smtp": {
host: "smtp.example.com", host: "smtp.example.com",
port: 587, port: 587,
@@ -45,7 +52,9 @@ async function openSettings(page: Page) {
await route.fulfill({ json: responses[key] ?? {} }); await route.fulfill({ json: responses[key] ?? {} });
}); });
await page.goto("/settings"); await page.goto("/settings");
await expect(page.locator("#settings-panel-smtp h2", { hasText: "SMTP 邮件" })).toBeAttached(); await expect(
page.locator("#settings-panel-smtp h2", { hasText: "SMTP 邮件" }),
).toBeAttached();
await expect(page.locator(".settings-loading")).toHaveCount(0); await expect(page.locator(".settings-loading")).toHaveCount(0);
} }
@@ -58,7 +67,9 @@ test("shows the build version in the authenticated brand", async ({ page }) => {
test("shows the build version before setup", async ({ page }) => { test("shows the build version before setup", async ({ page }) => {
await page.route("**/api/v1/system/status", (route) => await page.route("**/api/v1/system/status", (route) =>
route.fulfill({ json: { initialized: false, appServer: false, version: "test" } }), route.fulfill({
json: { initialized: false, appServer: false, version: "test" },
}),
); );
await page.goto("/"); await page.goto("/");
const badge = page.locator( const badge = page.locator(
@@ -72,7 +83,9 @@ test("shows the build version before setup", async ({ page }) => {
test("shows the build version on login", async ({ page }) => { test("shows the build version on login", async ({ page }) => {
await page.route("**/api/v1/system/status", (route) => await page.route("**/api/v1/system/status", (route) =>
route.fulfill({ json: { initialized: true, appServer: false, version: "test" } }), route.fulfill({
json: { initialized: true, appServer: false, version: "test" },
}),
); );
await page.route("**/api/v1/auth/me", (route) => await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({ status: 401, json: { error: "unauthorized" } }), route.fulfill({ status: 401, json: { error: "unauthorized" } }),
@@ -89,11 +102,18 @@ async function layout(page: Page) {
const rect = document.querySelector(selector)!.getBoundingClientRect(); const rect = document.querySelector(selector)!.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}; };
return { content: box(".settings-content"), tabs: box(".tabs"), heading: box("header h1"), scrollY: window.scrollY }; return {
content: box(".settings-content"),
tabs: box(".tabs"),
heading: box("header h1"),
scrollY: window.scrollY,
};
}); });
} }
test("switching every settings panel keeps the layout stable", async ({ page }) => { test("switching every settings panel keeps the layout stable", async ({
page,
}) => {
await openSettings(page); await openSettings(page);
const initial = await layout(page); const initial = await layout(page);
for (const name of ["Codex", "Telegram", "SMTP 邮件", "通用"]) { for (const name of ["Codex", "Telegram", "SMTP 邮件", "通用"]) {
@@ -102,7 +122,9 @@ test("switching every settings panel keeps the layout stable", async ({ page })
} }
}); });
test("switching panels preserves form state and isolates hidden controls", async ({ page }) => { test("switching panels preserves form state and isolates hidden controls", async ({
page,
}) => {
await openSettings(page); await openSettings(page);
const timezone = page.getByLabel("时区"); const timezone = page.getByLabel("时区");
await timezone.fill("Asia/Shanghai"); await timezone.fill("Asia/Shanghai");
@@ -116,14 +138,19 @@ test("switching panels preserves form state and isolates hidden controls", async
await expect(timezone).toHaveValue("Asia/Shanghai"); await expect(timezone).toHaveValue("Asia/Shanghai");
}); });
test("arrow, Home, and End keys move focus and activate tabs", async ({ page }) => { test("arrow, Home, and End keys move focus and activate tabs", async ({
page,
}) => {
await openSettings(page); await openSettings(page);
const general = page.getByRole("tab", { name: "通用" }); const general = page.getByRole("tab", { name: "通用" });
await general.focus(); await general.focus();
await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight");
await expect(page.getByRole("tab", { name: "Codex" })).toBeFocused(); await expect(page.getByRole("tab", { name: "Codex" })).toBeFocused();
await expect(page.getByRole("tab", { name: "Codex" })).toHaveAttribute("aria-selected", "true"); await expect(page.getByRole("tab", { name: "Codex" })).toHaveAttribute(
"aria-selected",
"true",
);
await page.keyboard.press("End"); await page.keyboard.press("End");
await expect(page.getByRole("tab", { name: "SMTP 邮件" })).toBeFocused(); await expect(page.getByRole("tab", { name: "SMTP 邮件" })).toBeFocused();
@@ -137,16 +164,28 @@ test("arrow, Home, and End keys move focus and activate tabs", async ({ page })
await expect(general).toHaveAttribute("aria-selected", "true"); await expect(general).toHaveAttribute("aria-selected", "true");
}); });
test("programmatic tab changes do not clamp an existing scroll position", async ({ page }) => { test("programmatic tab changes do not clamp an existing scroll position", async ({
page,
}) => {
await openSettings(page); await openSettings(page);
await page.evaluate(() => window.scrollTo(0, 240)); await page.evaluate(() => window.scrollTo(0, 240));
const before = await layout(page); const before = await layout(page);
await page.evaluate(() => document.querySelector<HTMLButtonElement>("#settings-tab-telegram")!.click()); await page.evaluate(() =>
await expect.poll(async () => (await layout(page)).scrollY).toBe(before.scrollY); document
await expect.poll(async () => (await layout(page)).content).toEqual(before.content); .querySelector<HTMLButtonElement>("#settings-tab-telegram")!
.click(),
);
await expect
.poll(async () => (await layout(page)).scrollY)
.toBe(before.scrollY);
await expect
.poll(async () => (await layout(page)).content)
.toEqual(before.content);
}); });
test("deleting a newly added account clears its device authorization", async ({ page }) => { test("deleting a newly added account clears its device authorization", async ({
page,
}) => {
let accounts: Array<(typeof responses.accounts)[number]> = []; let accounts: Array<(typeof responses.accounts)[number]> = [];
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const request = route.request(); const request = route.request();
@@ -183,12 +222,18 @@ test("deleting a newly added account clears its device authorization", async ({
await page.getByRole("tab", { name: "Codex" }).click(); await page.getByRole("tab", { name: "Codex" }).click();
await page.getByRole("button", { name: "添加账号" }).click(); await page.getByRole("button", { name: "添加账号" }).click();
await expect(page.getByText("PLTJ-7M6I6")).toBeVisible(); await expect(page.getByText("PLTJ-7M6I6")).toBeVisible();
await expect(page.getByRole("link", { name: "https://auth.openai.com/codex/device" })).toBeVisible(); await expect(
page.getByRole("link", { name: "https://auth.openai.com/codex/device" }),
).toBeVisible();
await page.getByRole("button", { name: "删除“账号 1”" }).click(); await page.getByRole("button", { name: "删除“账号 1”" }).click();
await expect(page.getByText("PLTJ-7M6I6")).toHaveCount(0); await expect(page.getByText("PLTJ-7M6I6")).toHaveCount(0);
await expect(page.getByRole("link", { name: "https://auth.openai.com/codex/device" })).toHaveCount(0); await expect(
await expect(page.getByText("尚未添加 Codex 账号", { exact: false })).toBeVisible(); page.getByRole("link", { name: "https://auth.openai.com/codex/device" }),
).toHaveCount(0);
await expect(
page.getByText("尚未添加 Codex 账号", { exact: false }),
).toBeVisible();
}); });
test("Codex account cards fit within the viewport", async ({ page }) => { test("Codex account cards fit within the viewport", async ({ page }) => {
@@ -197,8 +242,11 @@ test("Codex account cards fit within the viewport", async ({ page }) => {
const overflow = await page.evaluate(() => ({ const overflow = await page.evaluate(() => ({
documentWidth: document.documentElement.scrollWidth, documentWidth: document.documentElement.scrollWidth,
viewportWidth: document.documentElement.clientWidth, viewportWidth: document.documentElement.clientWidth,
cardWidth: document.querySelector(".account-card")!.getBoundingClientRect().width, cardWidth: document.querySelector(".account-card")!.getBoundingClientRect()
contentWidth: document.querySelector(".settings-content")!.getBoundingClientRect().width, .width,
contentWidth: document
.querySelector(".settings-content")!
.getBoundingClientRect().width,
})); }));
expect(overflow.documentWidth).toBeLessThanOrEqual(overflow.viewportWidth); expect(overflow.documentWidth).toBeLessThanOrEqual(overflow.viewportWidth);
expect(overflow.cardWidth).toBeLessThanOrEqual(overflow.contentWidth); expect(overflow.cardWidth).toBeLessThanOrEqual(overflow.contentWidth);
@@ -222,6 +270,7 @@ test("Codex account emails are masked everywhere they are displayed", async ({
limits: [ limits: [
{ {
limitId: "primary", limitId: "primary",
limitName: null,
windowType: "5h", windowType: "5h",
usedPercent: 25, usedPercent: 25,
windowDurationMinutes: 300, windowDurationMinutes: 300,
@@ -238,7 +287,9 @@ test("Codex account emails are masked everywhere they are displayed", async ({
}); });
await page.goto("/"); await page.goto("/");
await expect(page.locator(".account-select")).toContainText("t**t@e*****e.com"); await expect(page.locator(".account-select")).toContainText(
"t**t@e*****e.com",
);
await expect(page.locator(".account b")).toContainText("t**t@e*****e.com"); await expect(page.locator(".account b")).toContainText("t**t@e*****e.com");
await expect(page.getByText("已使用 25%", { exact: true })).toHaveCount(0); await expect(page.getByText("已使用 25%", { exact: true })).toHaveCount(0);
await expect(page.getByText("剩余 75%", { exact: true })).toHaveCount(0); await expect(page.getByText("剩余 75%", { exact: true })).toHaveCount(0);
@@ -250,6 +301,84 @@ test("Codex account emails are masked everywhere they are displayed", async ({
await page.goto("/settings"); await page.goto("/settings");
await page.getByRole("tab", { name: "Codex" }).click(); await page.getByRole("tab", { name: "Codex" }).click();
await expect(page.locator(".account-meta").first()).toContainText("t**t@e*****e.com"); await expect(page.locator(".account-meta").first()).toContainText(
"t**t@e*****e.com",
);
await expect(page.locator("body")).not.toContainText("test@example.com"); await expect(page.locator("body")).not.toContainText("test@example.com");
}); });
test("returns to login when an authenticated request receives 401", async ({
page,
}) => {
await page.route("**/api/v1/**", async (route) => {
const key = new URL(route.request().url()).pathname.replace("/api/v1/", "");
if (key === "accounts/1/sync")
return route.fulfill({ status: 401, json: { error: "未登录" } });
if (key === "dashboard")
return route.fulfill({
json: {
accountId: 1,
displayName: "默认账号",
account: { email: null, planType: null, connected: false },
limits: [],
summary: {},
usage: [],
fetchedAt: 0,
stale: false,
},
});
return route.fulfill({ json: responses[key] ?? {} });
});
await page.goto("/");
await expect(page.locator(".account-select")).toBeVisible();
await page.getByRole("button", { name: "立即刷新" }).click();
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
});
test("a slow previous account response cannot replace the selected account", async ({
page,
}) => {
const accounts = [
responses.accounts[0],
{
...responses.accounts[0],
id: 2,
displayName: "第二账号",
email: "second@example.com",
},
];
await page.route("**/api/v1/**", async (route) => {
const requestUrl = new URL(route.request().url());
const key = requestUrl.pathname.replace("/api/v1/", "");
if (key === "accounts") return route.fulfill({ json: accounts });
if (key === "dashboard") {
const accountId = Number(requestUrl.searchParams.get("accountId"));
if (accountId === 1)
await new Promise((resolve) => setTimeout(resolve, 250));
return route.fulfill({
json: {
accountId,
displayName: accountId === 1 ? "默认账号" : "第二账号",
account: {
email: accountId === 1 ? "test@example.com" : "second@example.com",
planType: "plus",
connected: true,
},
limits: [],
summary: {},
usage: [],
fetchedAt: 0,
stale: false,
},
});
}
return route.fulfill({ json: responses[key] ?? {} });
});
await page.goto("/");
const selector = page.locator(".account-select");
await selector.selectOption("2");
await expect(page.locator(".account b")).toContainText("第二账号");
await page.waitForTimeout(350);
await expect(page.locator(".account b")).toContainText("第二账号");
await expect(page.locator(".account b")).not.toContainText("默认账号");
});
+21 -1
View File
@@ -1 +1,21 @@
{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"lib":["ES2022","DOM","DOM.Iterable"],"allowJs":false,"skipLibCheck":true,"esModuleInterop":true,"allowSyntheticDefaultImports":true,"strict":true,"forceConsistentCasingInFileNames":true,"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx"},"include":["src"],"references":[]} {
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}
+10 -5
View File
@@ -1,8 +1,13 @@
import { defineConfig } from 'vite' import { defineConfig } from "vite";
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: { outDir: '../backend/internal/web/dist', emptyOutDir: true }, build: { outDir: "../backend/internal/web/dist", emptyOutDir: true },
server: { proxy: { '/api': 'http://localhost:8080', '/health': 'http://localhost:8080' } }, server: {
}) proxy: {
"/api": "http://localhost:8080",
"/health": "http://localhost:8080",
},
},
});