fix: harden frontend state and tooling
This commit is contained in:
@@ -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'
|
||||
@@ -3,6 +3,7 @@ FROM node:24.19.0-bookworm-slim AS frontend
|
||||
WORKDIR /src/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json frontend/tsconfig.json frontend/vite.config.ts ./
|
||||
COPY frontend/src ./src
|
||||
COPY frontend/scripts ./scripts
|
||||
COPY frontend/index.html ./index.html
|
||||
RUN npm ci && npm run build
|
||||
|
||||
|
||||
@@ -219,6 +219,8 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端提交前检查(格式、lint、类型、生产构建、bundle 门禁和 Vitest)使用 `npm run check`;浏览器回归测试使用 `npm run test:e2e`。维护者应按开发指南在固定版本容器中执行这些命令。
|
||||
|
||||
启动后端:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 才是安全边界。
|
||||
|
||||
主题和当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并每 30 秒刷新内存数据;切换账号必须清空旧 Dashboard,避免短暂展示另一账号信息。手动刷新调用账号级 sync 后重新读取 Dashboard。
|
||||
主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并在每轮请求完成 30 秒后刷新;切换账号会取消旧请求并校验响应账号,避免迟到响应覆盖当前账号。手动刷新调用账号级 sync 后重新读取 Dashboard。
|
||||
|
||||
## 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。
|
||||
|
||||
@@ -25,7 +25,7 @@ docker run --rm \
|
||||
|
||||
```bash
|
||||
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'
|
||||
```
|
||||
|
||||
依赖或安全改动额外运行:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
../backend/internal/web/dist
|
||||
package-lock.json
|
||||
tsconfig.tsbuildinfo
|
||||
test-results
|
||||
playwright-report
|
||||
Generated
+452
-90
@@ -18,10 +18,12 @@
|
||||
"vite": "8.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/react": "19.1.10",
|
||||
"@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": {
|
||||
@@ -31,7 +33,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -48,7 +49,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -65,7 +65,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -82,7 +81,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -99,7 +97,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -116,7 +113,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -133,7 +129,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -150,7 +145,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -167,7 +161,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -184,7 +177,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -201,7 +193,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -218,7 +209,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -235,7 +225,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -252,7 +241,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -269,7 +257,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -286,7 +273,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -303,7 +289,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -320,7 +305,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -337,7 +321,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -354,7 +337,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -371,7 +353,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -388,7 +369,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -405,7 +385,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -422,7 +401,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -439,7 +417,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -456,7 +433,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -499,6 +475,353 @@
|
||||
"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": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
@@ -1603,15 +1926,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
@@ -1633,13 +1956,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
@@ -1648,13 +1971,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1662,23 +1985,10 @@
|
||||
"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": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1689,13 +1999,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
@@ -1703,19 +2013,6 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"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_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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -2509,6 +2855,22 @@
|
||||
"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": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
@@ -3086,20 +3448,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"@vitest/pretty-format": "^3.2.4",
|
||||
"@vitest/runner": "3.2.4",
|
||||
"@vitest/snapshot": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/expect": "3.2.7",
|
||||
"@vitest/mocker": "3.2.7",
|
||||
"@vitest/pretty-format": "^3.2.7",
|
||||
"@vitest/runner": "3.2.7",
|
||||
"@vitest/snapshot": "3.2.7",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
@@ -3129,8 +3491,8 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"@vitest/browser": "3.2.7",
|
||||
"@vitest/ui": "3.2.7",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
@@ -3159,13 +3521,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@vitest/mocker": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
|
||||
+34
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 const post=<T>(p:string,v:unknown={})=>api<T>(p,{method:'POST',body:JSON.stringify(v)})
|
||||
export const put=<T>(p:string,v:unknown)=>api<T>(p,{method:'PUT',body:JSON.stringify(v)})
|
||||
export const del=<T>(p:string)=>api<T>(p,{method:'DELETE'})
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+348
-253
@@ -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 {
|
||||
BrowserRouter,
|
||||
@@ -19,97 +19,40 @@ import {
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { api, del, get, post, put, toErrorMessage } from "./api";
|
||||
import { AuthProvider, useAuth } from "./auth";
|
||||
import { ThemeProvider, useTheme } from "./theme";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { api, del, post, put } from "./api";
|
||||
decodeAccounts,
|
||||
decodeCode,
|
||||
decodeDashboard,
|
||||
decodeDeviceLogin,
|
||||
decodeGeneral,
|
||||
decodeOK,
|
||||
decodeSMTP,
|
||||
decodeTelegram,
|
||||
type Account,
|
||||
type Dashboard as Dash,
|
||||
type DeviceLogin,
|
||||
type GeneralSettings,
|
||||
type Limit,
|
||||
type SMTPSettingsForm,
|
||||
type TelegramSettingsForm,
|
||||
} from "./types";
|
||||
import "./styles.css";
|
||||
|
||||
type Status = { initialized: boolean; appServer: boolean; version: string };
|
||||
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;
|
||||
};
|
||||
const UsageChart = lazy(() => import("./usage-chart"));
|
||||
|
||||
function App() {
|
||||
const [s, setS] = useState<Status | null>(null);
|
||||
const [auth, setAuth] = useState<boolean | null>(null);
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const { status, authenticated } = useAuth();
|
||||
if (!status.initialized) return <Setup version={status.version} />;
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{auth ? (
|
||||
<Route
|
||||
path="*"
|
||||
element={<Shell version={s.version} logout={() => setAuth(false)} />}
|
||||
/>
|
||||
{authenticated ? (
|
||||
<Route path="*" element={<Shell version={status.version} />} />
|
||||
) : (
|
||||
<Route
|
||||
path="*"
|
||||
element={<Login version={s.version} done={() => setAuth(true)} />}
|
||||
/>
|
||||
<Route path="*" element={<Login version={status.version} />} />
|
||||
)}
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
@@ -131,7 +74,8 @@ function Brand({ version }: { version?: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Setup({ version, onDone }: { version: string; onDone: () => void }) {
|
||||
function Setup({ version }: { version: string }) {
|
||||
const { setup } = useAuth();
|
||||
const [form, set] = useState({
|
||||
username: "admin",
|
||||
password: "",
|
||||
@@ -141,10 +85,9 @@ function Setup({ version, onDone }: { version: string; onDone: () => void }) {
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await post("setup", form);
|
||||
onDone();
|
||||
await setup(form.username, form.password, form.timezone);
|
||||
} catch (x) {
|
||||
setErr((x as Error).message);
|
||||
setErr(toErrorMessage(x));
|
||||
}
|
||||
}
|
||||
return (
|
||||
@@ -197,7 +140,8 @@ function Setup({ version, onDone }: { version: string; onDone: () => void }) {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
function Login({ version, done }: { version: string; done: () => void }) {
|
||||
function Login({ version }: { version: string }) {
|
||||
const { login } = useAuth();
|
||||
const [u, setU] = useState("admin"),
|
||||
[p, setP] = useState(""),
|
||||
[e, setE] = useState("");
|
||||
@@ -208,10 +152,9 @@ function Login({ version, done }: { version: string; done: () => void }) {
|
||||
onSubmit={async (x) => {
|
||||
x.preventDefault();
|
||||
try {
|
||||
await post("auth/login", { username: u, password: p });
|
||||
done();
|
||||
await login(u, p);
|
||||
} catch (q) {
|
||||
setE((q as Error).message);
|
||||
setE(toErrorMessage(q));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -235,18 +178,11 @@ function Login({ version, done }: { version: string; done: () => void }) {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
function Shell({ version, logout }: { version: string; logout: () => void }) {
|
||||
function Shell({ version }: { version: string }) {
|
||||
const { logout } = useAuth();
|
||||
const nav = useNavigate();
|
||||
const [theme, setTheme] = useState(localStorage.theme || "system");
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme =
|
||||
theme === "system"
|
||||
? matchMedia("(prefers-color-scheme:dark)").matches
|
||||
? "dark"
|
||||
: "light"
|
||||
: theme;
|
||||
localStorage.theme = theme;
|
||||
}, [theme]);
|
||||
const { theme, saveTheme, error: themeError } = useTheme();
|
||||
const [shellError, setShellError] = useState("");
|
||||
return (
|
||||
<div className="app">
|
||||
<aside>
|
||||
@@ -262,19 +198,31 @@ function Shell({ version, logout }: { version: string; logout: () => void }) {
|
||||
</button>
|
||||
</nav>
|
||||
<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 />}切换主题
|
||||
</button>
|
||||
{themeError && <small className="error">{themeError}</small>}
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("确定要退出 Codex Helper 管理后台吗?")) return;
|
||||
await post("auth/logout");
|
||||
logout();
|
||||
try {
|
||||
setShellError("");
|
||||
await logout();
|
||||
} catch (error) {
|
||||
setShellError(toErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LogOut />
|
||||
退出
|
||||
</button>
|
||||
{shellError && <small className="error">{shellError}</small>}
|
||||
</div>
|
||||
</aside>
|
||||
<section className="content">
|
||||
@@ -288,50 +236,90 @@ function Shell({ version, logout }: { version: string; logout: () => void }) {
|
||||
);
|
||||
}
|
||||
function Dashboard() {
|
||||
const [accounts, setAccounts] = useState<Account[]>([]),
|
||||
const [accounts, setAccounts] = useState<Account[] | null>(null),
|
||||
[id, setId] = useState(+(localStorage.accountId || 0)),
|
||||
[d, setD] = useState<Dash | null>(null),
|
||||
[e, setE] = useState(""),
|
||||
[refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle");
|
||||
const loadAccounts = () =>
|
||||
api<Account[]>("accounts").then((xs) => {
|
||||
const requestRef = useRef(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const xs = await get("accounts", decodeAccounts);
|
||||
setAccounts(xs);
|
||||
setE("");
|
||||
const next = xs.some((x) => x.id === id) ? id : xs[0]?.id || 0;
|
||||
if (next !== id) setId(next);
|
||||
});
|
||||
const load = async (accountId = id) => {
|
||||
} catch (error) {
|
||||
setE(toErrorMessage(error));
|
||||
}
|
||||
};
|
||||
const load = async (accountId = id, signal?: AbortSignal) => {
|
||||
if (!accountId) return;
|
||||
const request = ++requestRef.current;
|
||||
try {
|
||||
setD(await api<Dash>("dashboard?accountId=" + accountId));
|
||||
setE("");
|
||||
const dashboard = await get(
|
||||
"dashboard?accountId=" + accountId,
|
||||
decodeDashboard,
|
||||
signal,
|
||||
);
|
||||
if (request === requestRef.current && dashboard.accountId === accountId) {
|
||||
setD(dashboard);
|
||||
setE("");
|
||||
}
|
||||
} catch (x) {
|
||||
setE((x as Error).message);
|
||||
if (!signal?.aborted && request === requestRef.current)
|
||||
setE(toErrorMessage(x));
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
void loadAccounts();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
localStorage.accountId = String(id);
|
||||
localStorage.setItem("accountId", String(id));
|
||||
setD(null);
|
||||
load(id);
|
||||
const t = setInterval(() => load(id), 30000);
|
||||
return () => clearInterval(t);
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
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]);
|
||||
const sync = async () => {
|
||||
setRefresh("loading");
|
||||
setE("");
|
||||
try {
|
||||
await post(`accounts/${id}/sync`);
|
||||
await post(`accounts/${id}/sync`, decodeOK, {}, undefined, 60_000);
|
||||
await load(id);
|
||||
setRefresh("done");
|
||||
setTimeout(() => setRefresh("idle"), 2000);
|
||||
window.setTimeout(() => setRefresh("idle"), 2000);
|
||||
} catch (x) {
|
||||
setE((x as Error).message);
|
||||
setE(toErrorMessage(x));
|
||||
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)
|
||||
return (
|
||||
<>
|
||||
@@ -339,7 +327,20 @@ function Dashboard() {
|
||||
<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 (
|
||||
<>
|
||||
<Header title="用量总览">
|
||||
@@ -376,9 +377,8 @@ function Dashboard() {
|
||||
<div>
|
||||
<small>Codex Account</small>
|
||||
<b>
|
||||
{d.displayName} · {d.account.email
|
||||
? maskEmail(d.account.email)
|
||||
: "尚未连接"}
|
||||
{d.displayName} ·{" "}
|
||||
{d.account.email ? maskEmail(d.account.email) : "尚未连接"}
|
||||
</b>
|
||||
</div>
|
||||
<span className="badge">{planLabel(d.account.planType)}</span>
|
||||
@@ -415,7 +415,9 @@ function Dashboard() {
|
||||
v={duration(d.summary.longestRunningTurnSec)}
|
||||
/>
|
||||
</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>
|
||||
</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";
|
||||
|
||||
const settingsTabs: Array<{
|
||||
@@ -540,7 +499,8 @@ function SettingsPage() {
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowDown")
|
||||
nextIndex = (currentIndex + 1) % settingsTabs.length;
|
||||
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 === "End") nextIndex = settingsTabs.length - 1;
|
||||
if (nextIndex === undefined) return;
|
||||
@@ -605,25 +565,47 @@ const SettingsLoading = () => (
|
||||
</div>
|
||||
);
|
||||
function General() {
|
||||
const [v, setV] = useState<any>(null),
|
||||
[msg, setMsg] = useState("");
|
||||
const [v, setV] = useState<GeneralSettings | null>(null),
|
||||
[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(() => {
|
||||
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 (
|
||||
<form
|
||||
className="panel form wide"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api("settings/general", {
|
||||
const saved = await api("settings/general", decodeGeneral, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
setV(saved);
|
||||
applyTheme(saved.theme);
|
||||
setMsg("设置已保存");
|
||||
} catch (x) {
|
||||
setMsg((x as Error).message);
|
||||
setMsg(toErrorMessage(x));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -640,7 +622,11 @@ function General() {
|
||||
主题
|
||||
<select
|
||||
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="dark">深色</option>
|
||||
@@ -705,29 +691,45 @@ function CodexSettings() {
|
||||
[newKind, setNewKind] = useState<"personal" | "team">("team"),
|
||||
[busy, setBusy] = useState(false),
|
||||
[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(() => {
|
||||
load();
|
||||
void load().catch((error) => setErr(toErrorMessage(error)));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!deviceLogin) return;
|
||||
const started = Date.now();
|
||||
const timer = setInterval(async () => {
|
||||
const accounts = await load();
|
||||
const account = accounts.find((x) => x.id === deviceLogin.accountId);
|
||||
if (!account) {
|
||||
setDeviceLogin(null);
|
||||
clearInterval(timer);
|
||||
return;
|
||||
const controller = new AbortController();
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const accounts = await load(controller.signal);
|
||||
const account = accounts.find((x) => x.id === deviceLogin.accountId);
|
||||
if (!account) {
|
||||
setDeviceLogin(null);
|
||||
return;
|
||||
}
|
||||
if (["matched", "mismatch"].includes(account.validationStatus)) return;
|
||||
if (Date.now() - started >= 120_000) {
|
||||
setErr("设备码登录检测已超时,请重新生成设备码");
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(poll, 2000);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setErr(toErrorMessage(error));
|
||||
timer = window.setTimeout(poll, 2000);
|
||||
}
|
||||
}
|
||||
if (
|
||||
["matched", "mismatch"].includes(account.validationStatus)
|
||||
)
|
||||
clearInterval(timer);
|
||||
else if (Date.now() - started >= 120000)
|
||||
clearInterval(timer);
|
||||
}, 2000);
|
||||
return () => clearInterval(timer);
|
||||
};
|
||||
timer = window.setTimeout(poll, 2000);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [deviceLogin]);
|
||||
const login = async (id: number) => {
|
||||
try {
|
||||
@@ -735,12 +737,16 @@ function CodexSettings() {
|
||||
setActive(id);
|
||||
setErr("");
|
||||
setDeviceLogin(null);
|
||||
const result = await post<Omit<DeviceLogin, "accountId">>(
|
||||
const result = await post(
|
||||
`accounts/${id}/login/device`,
|
||||
decodeDeviceLogin,
|
||||
{},
|
||||
undefined,
|
||||
60_000,
|
||||
);
|
||||
setDeviceLogin({ accountId: id, ...result });
|
||||
} catch (q) {
|
||||
setErr((q as Error).message);
|
||||
setErr(toErrorMessage(q));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -751,18 +757,22 @@ function CodexSettings() {
|
||||
setActive(0);
|
||||
setErr("");
|
||||
setDeviceLogin(null);
|
||||
const x = await post<Account>("accounts", {
|
||||
const x = await post("accounts", (value) => decodeAccounts([value])[0], {
|
||||
displayName: `账号 ${xs.length + 1}`,
|
||||
expectedKind: newKind,
|
||||
});
|
||||
await load();
|
||||
setActive(x.id);
|
||||
const result = await post<Omit<DeviceLogin, "accountId">>(
|
||||
const result = await post(
|
||||
`accounts/${x.id}/login/device`,
|
||||
decodeDeviceLogin,
|
||||
{},
|
||||
undefined,
|
||||
60_000,
|
||||
);
|
||||
setDeviceLogin({ accountId: x.id, ...result });
|
||||
} catch (q) {
|
||||
setErr((q as Error).message);
|
||||
setErr(toErrorMessage(q));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -771,7 +781,9 @@ function CodexSettings() {
|
||||
<div className="codex-settings wide">
|
||||
<div className="codex-heading">
|
||||
<h2>Codex 账户与工作区</h2>
|
||||
<p>个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。</p>
|
||||
<p>
|
||||
个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。
|
||||
</p>
|
||||
</div>
|
||||
<section className="panel add-account-card">
|
||||
<div>
|
||||
@@ -813,18 +825,22 @@ function CodexSettings() {
|
||||
onBlur={async (e) => {
|
||||
const name = e.target.value.trim();
|
||||
if (name && name !== x.displayName) {
|
||||
await put(`accounts/${x.id}`, {
|
||||
await put(`accounts/${x.id}`, decodeOK, {
|
||||
displayName: name,
|
||||
expectedKind: x.expectedKind,
|
||||
});
|
||||
load();
|
||||
void load().catch((error) =>
|
||||
setErr(toErrorMessage(error)),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="account-meta">
|
||||
<span>{x.email ? maskEmail(x.email) : "尚未登录"}</span>
|
||||
<span>{planLabel(x.planType)}</span>
|
||||
<span className={x.validationStatus === "mismatch" ? "error" : ""}>
|
||||
<span
|
||||
className={x.validationStatus === "mismatch" ? "error" : ""}
|
||||
>
|
||||
{validationLabel(x)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -842,11 +858,11 @@ function CodexSettings() {
|
||||
aria-label="预期连接类型"
|
||||
value={x.expectedKind}
|
||||
onChange={async (e) => {
|
||||
await put(`accounts/${x.id}`, {
|
||||
await put(`accounts/${x.id}`, decodeOK, {
|
||||
displayName: x.displayName,
|
||||
expectedKind: e.target.value,
|
||||
});
|
||||
load();
|
||||
void load().catch((error) => setErr(toErrorMessage(error)));
|
||||
}}
|
||||
>
|
||||
<option value="any">不校验</option>
|
||||
@@ -855,21 +871,28 @@ function CodexSettings() {
|
||||
</select>
|
||||
</label>
|
||||
<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 ? "服务启动中…" : "设备码登录"}
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (!confirm(`确定要退出“${x.displayName}”的 Codex 账号吗?`)) return;
|
||||
if (
|
||||
!confirm(`确定要退出“${x.displayName}”的 Codex 账号吗?`)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
setErr("");
|
||||
await post(`accounts/${x.id}/logout`);
|
||||
await post(`accounts/${x.id}/logout`, decodeOK);
|
||||
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
|
||||
await load();
|
||||
} catch (q) {
|
||||
setErr((q as Error).message);
|
||||
setErr(toErrorMessage(q));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -881,16 +904,22 @@ function CodexSettings() {
|
||||
title="删除连接"
|
||||
aria-label={`删除“${x.displayName}”`}
|
||||
onClick={async () => {
|
||||
if (!confirm(`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`)) return;
|
||||
if (
|
||||
!confirm(
|
||||
`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
setErr("");
|
||||
await del(`accounts/${x.id}`);
|
||||
await del(`accounts/${x.id}`, decodeOK);
|
||||
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
|
||||
if (active === x.id) setActive(0);
|
||||
if (+localStorage.accountId === x.id) localStorage.removeItem("accountId");
|
||||
if (+localStorage.accountId === x.id)
|
||||
localStorage.removeItem("accountId");
|
||||
await load();
|
||||
} catch (q) {
|
||||
setErr((q as Error).message);
|
||||
setErr(toErrorMessage(q));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -902,7 +931,11 @@ function CodexSettings() {
|
||||
<div className="codebox">
|
||||
<span>
|
||||
在浏览器中访问{" "}
|
||||
<a href={deviceLogin.verificationUrl} target="_blank" rel="noreferrer">
|
||||
<a
|
||||
href={deviceLogin.verificationUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{deviceLogin.verificationUrl}
|
||||
</a>
|
||||
,然后输入设备码
|
||||
@@ -918,27 +951,45 @@ function CodexSettings() {
|
||||
);
|
||||
}
|
||||
function Telegram() {
|
||||
const [v, setV] = useState<any>(null),
|
||||
const [v, setV] = useState<TelegramSettingsForm | null>(null),
|
||||
[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(() => {
|
||||
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 (
|
||||
<form
|
||||
className="panel form wide"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const x: any = await api("settings/telegram", {
|
||||
const x = await api("settings/telegram", decodeTelegram, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
setV(x);
|
||||
setV({ ...x, token: "" });
|
||||
setMsg("Bot 已验证并保存");
|
||||
} catch (x) {
|
||||
setMsg((x as Error).message);
|
||||
setMsg(toErrorMessage(x));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -978,8 +1029,12 @@ function Telegram() {
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={async () => {
|
||||
const x: any = await post("settings/telegram/bind");
|
||||
setCode(x.code);
|
||||
try {
|
||||
const x = await post("settings/telegram/bind", decodeCode);
|
||||
setCode(x.code);
|
||||
} catch (error) {
|
||||
setMsg(toErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
生成绑定码
|
||||
@@ -989,10 +1044,10 @@ function Telegram() {
|
||||
className="secondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await post("settings/telegram/test");
|
||||
await post("settings/telegram/test", decodeOK);
|
||||
setMsg("测试消息已发送");
|
||||
} catch (x) {
|
||||
setMsg((x as Error).message);
|
||||
setMsg(toErrorMessage(x));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1010,21 +1065,41 @@ function Telegram() {
|
||||
);
|
||||
}
|
||||
function SMTP() {
|
||||
const [v, setV] = useState<any>(null),
|
||||
[msg, setMsg] = useState("");
|
||||
const [v, setV] = useState<SMTPSettingsForm | null>(null),
|
||||
[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(() => {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setV(
|
||||
await api("settings/smtp", { method: "PUT", body: JSON.stringify(v) }),
|
||||
);
|
||||
const saved = await api("settings/smtp", decodeSMTP, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
setV({ ...saved, password: "" });
|
||||
setMsg("SMTP 设置已保存");
|
||||
} catch (x) {
|
||||
setMsg((x as Error).message);
|
||||
setMsg(toErrorMessage(x));
|
||||
}
|
||||
};
|
||||
return (
|
||||
@@ -1067,7 +1142,15 @@ function SMTP() {
|
||||
加密
|
||||
<select
|
||||
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="tls">隐式 TLS</option>
|
||||
@@ -1113,10 +1196,10 @@ function SMTP() {
|
||||
className="secondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await post("settings/smtp/test");
|
||||
await post("settings/smtp/test", decodeOK);
|
||||
setMsg("测试邮件已发送");
|
||||
} catch (x) {
|
||||
setMsg((x as Error).message);
|
||||
setMsg(toErrorMessage(x));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1146,19 +1229,14 @@ function Header({
|
||||
</header>
|
||||
);
|
||||
}
|
||||
const num = (v?: number) =>
|
||||
const num = (v?: number | null) =>
|
||||
v == null
|
||||
? "暂无"
|
||||
: new Intl.NumberFormat("zh-CN", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(v);
|
||||
const compactAxis = (v: number) =>
|
||||
new Intl.NumberFormat("en", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(v);
|
||||
const duration = (v?: number) =>
|
||||
const duration = (v?: number | null) =>
|
||||
v == null
|
||||
? "暂无"
|
||||
: v < 60
|
||||
@@ -1182,22 +1260,33 @@ const maskEmail = (email: string) => {
|
||||
)
|
||||
.join(".")}`;
|
||||
};
|
||||
const planLabel = (plan?: string) => {
|
||||
const planLabel = (plan?: string | null) => {
|
||||
if (!plan) return "未识别套餐";
|
||||
const labels: Record<string, string> = {
|
||||
free: "Free", go: "Go", plus: "Plus", pro: "Pro", prolite: "Pro Lite",
|
||||
team: "Team", business: "Business",
|
||||
free: "Free",
|
||||
go: "Go",
|
||||
plus: "Plus",
|
||||
pro: "Pro",
|
||||
prolite: "Pro Lite",
|
||||
team: "Team",
|
||||
business: "Business",
|
||||
self_serve_business_prolite: "Business Pro Lite",
|
||||
self_serve_business_usage_based: "Business(按量)",
|
||||
enterprise: "Enterprise", edu: "Edu",
|
||||
enterprise: "Enterprise",
|
||||
edu: "Edu",
|
||||
};
|
||||
return labels[plan.toLowerCase()] || plan;
|
||||
};
|
||||
const validationLabel = (account: Account) => {
|
||||
if (account.expectedKind === "any") return "未启用套餐类型校验";
|
||||
if (account.validationStatus === "pending") return `等待验证${account.expectedKind === "team" ? " Team / Business" : "个人订阅"}`;
|
||||
if (account.validationStatus === "matched") return account.actualKind === "team" ? "已连接 Team / Business 工作区" : "已连接个人订阅";
|
||||
if (account.validationStatus === "mismatch") return `类型不匹配:实际为 ${planLabel(account.planType)},请退出后重新授权`;
|
||||
if (account.validationStatus === "pending")
|
||||
return `等待验证${account.expectedKind === "team" ? " Team / Business" : "个人订阅"}`;
|
||||
if (account.validationStatus === "matched")
|
||||
return account.actualKind === "team"
|
||||
? "已连接 Team / Business 工作区"
|
||||
: "已连接个人订阅";
|
||||
if (account.validationStatus === "mismatch")
|
||||
return `类型不匹配:实际为 ${planLabel(account.planType)},请退出后重新授权`;
|
||||
return `无法验证工作区类型:${planLabel(account.planType)}`;
|
||||
};
|
||||
const relative = (ts: number) => {
|
||||
@@ -1208,8 +1297,14 @@ const relative = (ts: number) => {
|
||||
m = Math.floor((s % 3600) / 60);
|
||||
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>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("发生未知错误");
|
||||
});
|
||||
});
|
||||
@@ -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") };
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,18 +12,25 @@ const responses: Record<string, unknown> = {
|
||||
notifyBefore: true,
|
||||
notifyAfter: true,
|
||||
},
|
||||
accounts: [{
|
||||
id: 1,
|
||||
displayName: "默认账号",
|
||||
email: "test@example.com",
|
||||
planType: "plus",
|
||||
expectedKind: "personal",
|
||||
actualKind: "personal",
|
||||
validationStatus: "matched",
|
||||
possibleDuplicate: false,
|
||||
connected: true,
|
||||
}],
|
||||
"settings/telegram": { token: "", configured: true, enabled: true, menuEnabled: true },
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
displayName: "默认账号",
|
||||
email: "test@example.com",
|
||||
planType: "plus",
|
||||
expectedKind: "personal",
|
||||
actualKind: "personal",
|
||||
validationStatus: "matched",
|
||||
possibleDuplicate: false,
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
"settings/telegram": {
|
||||
token: "",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
menuEnabled: true,
|
||||
},
|
||||
"settings/smtp": {
|
||||
host: "smtp.example.com",
|
||||
port: 587,
|
||||
@@ -45,7 +52,9 @@ async function openSettings(page: Page) {
|
||||
await route.fulfill({ json: responses[key] ?? {} });
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -58,7 +67,9 @@ test("shows the build version in the authenticated brand", async ({ page }) => {
|
||||
|
||||
test("shows the build version before setup", async ({ page }) => {
|
||||
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("/");
|
||||
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 }) => {
|
||||
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) =>
|
||||
route.fulfill({ status: 401, json: { error: "unauthorized" } }),
|
||||
@@ -89,11 +102,18 @@ async function layout(page: Page) {
|
||||
const rect = document.querySelector(selector)!.getBoundingClientRect();
|
||||
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);
|
||||
const initial = await layout(page);
|
||||
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);
|
||||
const timezone = page.getByLabel("时区");
|
||||
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");
|
||||
});
|
||||
|
||||
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);
|
||||
const general = page.getByRole("tab", { name: "通用" });
|
||||
await general.focus();
|
||||
|
||||
await page.keyboard.press("ArrowRight");
|
||||
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 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");
|
||||
});
|
||||
|
||||
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 page.evaluate(() => window.scrollTo(0, 240));
|
||||
const before = await layout(page);
|
||||
await page.evaluate(() => document.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);
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
.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]> = [];
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
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("button", { name: "添加账号" }).click();
|
||||
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 expect(page.getByText("PLTJ-7M6I6")).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "https://auth.openai.com/codex/device" })).toHaveCount(0);
|
||||
await expect(page.getByText("尚未添加 Codex 账号", { exact: false })).toBeVisible();
|
||||
await expect(
|
||||
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 }) => {
|
||||
@@ -197,8 +242,11 @@ test("Codex account cards fit within the viewport", async ({ page }) => {
|
||||
const overflow = await page.evaluate(() => ({
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
cardWidth: document.querySelector(".account-card")!.getBoundingClientRect().width,
|
||||
contentWidth: document.querySelector(".settings-content")!.getBoundingClientRect().width,
|
||||
cardWidth: document.querySelector(".account-card")!.getBoundingClientRect()
|
||||
.width,
|
||||
contentWidth: document
|
||||
.querySelector(".settings-content")!
|
||||
.getBoundingClientRect().width,
|
||||
}));
|
||||
expect(overflow.documentWidth).toBeLessThanOrEqual(overflow.viewportWidth);
|
||||
expect(overflow.cardWidth).toBeLessThanOrEqual(overflow.contentWidth);
|
||||
@@ -222,6 +270,7 @@ test("Codex account emails are masked everywhere they are displayed", async ({
|
||||
limits: [
|
||||
{
|
||||
limitId: "primary",
|
||||
limitName: null,
|
||||
windowType: "5h",
|
||||
usedPercent: 25,
|
||||
windowDurationMinutes: 300,
|
||||
@@ -238,7 +287,9 @@ test("Codex account emails are masked everywhere they are displayed", async ({
|
||||
});
|
||||
|
||||
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.getByText("已使用 25%", { 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.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");
|
||||
});
|
||||
|
||||
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
@@ -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
@@ -1,8 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: { outDir: '../backend/internal/web/dist', emptyOutDir: true },
|
||||
server: { proxy: { '/api': 'http://localhost:8080', '/health': 'http://localhost:8080' } },
|
||||
})
|
||||
build: { outDir: "../backend/internal/web/dist", emptyOutDir: true },
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
"/health": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user