diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000..4a25d90 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -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' diff --git a/Dockerfile b/Dockerfile index 595ff12..95b85ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 55d3377..cb35ab7 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,8 @@ npm install npm run dev ``` +前端提交前检查(格式、lint、类型、生产构建、bundle 门禁和 Vitest)使用 `npm run check`;浏览器回归测试使用 `npm run test:e2e`。维护者应按开发指南在固定版本容器中执行这些命令。 + 启动后端: ```bash diff --git a/docs/frontend/application.md b/docs/frontend/application.md index ca32651..688dea5 100644 --- a/docs/frontend/application.md +++ b/docs/frontend/application.md @@ -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` 徽标显示在安装页、登录页和登录后侧栏的品牌区域。这些分支只负责交互,服务端 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。 diff --git a/docs/guides/development-and-validation.md b/docs/guides/development-and-validation.md index f72b623..25895a9 100644 --- a/docs/guides/development-and-validation.md +++ b/docs/guides/development-and-validation.md @@ -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' ``` 依赖或安全改动额外运行: diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..16542c7 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,6 @@ +node_modules +../backend/internal/web/dist +package-lock.json +tsconfig.tsbuildinfo +test-results +playwright-report diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 34dbccc..14cbf01 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" }, diff --git a/frontend/package.json b/frontend/package.json index 0a54ffd..42f1c91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/scripts/check-bundle.mjs b/frontend/scripts/check-bundle.mjs new file mode 100644 index 0000000..fac8983 --- /dev/null +++ b/frontend/scripts/check-bundle.mjs @@ -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")}`, + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 71d69ab..641f9c9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,125 @@ -export async function api(path:string,init:RequestInit={}):Promise{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=(p:string,v:unknown={})=>api(p,{method:'POST',body:JSON.stringify(v)}) -export const put=(p:string,v:unknown)=>api(p,{method:'PUT',body:JSON.stringify(v)}) -export const del=(p:string)=>api(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 = (value: unknown) => T; + +let unauthorizedHandler: (() => void) | undefined; + +export function onUnauthorized(handler?: () => void) { + unauthorizedHandler = handler; +} + +export const unknownResponse: Decoder = (value) => value; + +export async function api( + path: string, + decoder: Decoder, + init: RequestInit = {}, + timeoutMs = 30_000, +): Promise { + 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 = ( + path: string, + decoder: Decoder, + signal?: AbortSignal, +) => api(path, decoder, { signal }); +export const post = ( + path: string, + decoder: Decoder, + value: unknown = {}, + signal?: AbortSignal, + timeoutMs = 30_000, +) => + api( + path, + decoder, + { method: "POST", body: JSON.stringify(value), signal }, + timeoutMs, + ); +export const put = (path: string, decoder: Decoder, value: unknown) => + api(path, decoder, { method: "PUT", body: JSON.stringify(value) }); +export const del = (path: string, decoder: Decoder) => + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function record(value: unknown, name = "响应"): Record { + 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); +} diff --git a/frontend/src/auth.tsx b/frontend/src/auth.tsx new file mode 100644 index 0000000..dd518b8 --- /dev/null +++ b/frontend/src/auth.tsx @@ -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; + setup: ( + username: string, + password: string, + timezone: string, + ) => Promise; + logout: () => Promise; +}; + +const AuthContext = createContext(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 ( +
+
+
+ ); + if (state.status === "error") + return ( +
+
+

无法启动应用

+

{state.message}

+ +
+
+ ); + 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 {children}; +} + +export function useAuth() { + const value = useContext(AuthContext); + if (!value) throw new Error("useAuth 必须在 AuthProvider 内使用"); + return value; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2dfa2cc..6d05927 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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(null); - const [auth, setAuth] = useState(null); - useEffect(() => { - api("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 ; - if (!s.initialized) - return ( - { - setS({ ...s, initialized: true }); - setAuth(true); - }} - /> - ); + const { status, authenticated } = useAuth(); + if (!status.initialized) return ; return ( - {auth ? ( - setAuth(false)} />} - /> + {authenticated ? ( + } /> ) : ( - setAuth(true)} />} - /> + } /> )} @@ -131,7 +74,8 @@ function Brand({ version }: { version?: string }) { ); } -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 }) { ); } -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 }) { ); } -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 (
@@ -288,50 +236,90 @@ function Shell({ version, logout }: { version: string; logout: () => void }) { ); } function Dashboard() { - const [accounts, setAccounts] = useState([]), + const [accounts, setAccounts] = useState(null), [id, setId] = useState(+(localStorage.accountId || 0)), [d, setD] = useState(null), [e, setE] = useState(""), [refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle"); - const loadAccounts = () => - api("accounts").then((xs) => { + const requestRef = useRef(0); + const abortRef = useRef(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("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 ( + <> +
+ {e ? ( +
+

{e}

+ +
+ ) : ( + + )} + + ); if (!accounts.length) return ( <> @@ -339,7 +327,20 @@ function Dashboard() {
尚未添加 Codex 账号
); - if (!d) return ; + if (!d) + return ( + <> +
+ {e ? ( +
+

{e}

+ +
+ ) : ( + + )} + + ); return ( <>
@@ -376,9 +377,8 @@ function Dashboard() {
Codex Account - {d.displayName} · {d.account.email - ? maskEmail(d.account.email) - : "尚未连接"} + {d.displayName} ·{" "} + {d.account.email ? maskEmail(d.account.email) : "尚未连接"}
{planLabel(d.account.planType)} @@ -415,7 +415,9 @@ function Dashboard() { v={duration(d.summary.longestRunningTurnSec)} />
- + }> + + ); } @@ -468,49 +470,6 @@ const Stat = ({ {v} ); -const Chart = ({ data }: { data: Point[] }) => ( -
-
-

每日 Token 趋势

-
- - - - - - - - - - - - [ - new Intl.NumberFormat("zh-CN").format(Number(v)), - "Tokens", - ]} - contentStyle={{ - background: "var(--panel)", - border: "1px solid var(--border)", - borderRadius: 12, - }} - /> - - - -
-); 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 = () => ( ); function General() { - const [v, setV] = useState(null), - [msg, setMsg] = useState(""); + const [v, setV] = useState(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 ; + if (!v) + return loadError ? ( +
+

{loadError}

+ +
+ ) : ( + + ); return (
{ 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() { 主题
- +
+ ) : ( + + ); return ( { 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(null), - [msg, setMsg] = useState(""); + const [v, setV] = useState(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 ; + if (!v) + return loadError ? ( +
+

{loadError}

+ +
+ ) : ( + + ); 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() { 加密