diff --git a/Dockerfile b/Dockerfile index 95b85ce..6938e35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY frontend/index.html ./index.html RUN npm ci && npm run build FROM golang:1.26.0-bookworm AS backend -ARG APP_VERSION=0.2.0 +ARG APP_VERSION=0.3.0 WORKDIR /src/backend COPY backend/go.mod backend/go.sum* ./ RUN go mod download diff --git a/backend/CONTRACT.md b/backend/CONTRACT.md index 814a16a..9768ef0 100644 --- a/backend/CONTRACT.md +++ b/backend/CONTRACT.md @@ -47,7 +47,7 @@ | 方法与路径 | 鉴权 | 行为 | | --- | --- | --- | -| `GET /api/v1/system/status` | 匿名 | `200 {initialized,version,appServer}`。`version` 为镜像构建时注入的应用版本,未注入时默认为 `0.2.0`。 | +| `GET /api/v1/system/status` | 匿名 | `200 {initialized,version,appServer}`。`version` 为镜像构建时注入的应用版本,未注入时默认为 `0.3.0`。 | | `POST /api/v1/setup` | 匿名、仅未初始化 | body `{username,password,timezone}`;用户名至少 3 位、密码至少 10 位,否则 400;时区有效时写入,否则使用默认 UTC。事务创建唯一管理员和通用设置,设置 session,返回 `201 {ok:true}`;已初始化返回 409。 | | `POST /api/v1/auth/login` | 匿名 | body `{username,password}`;未初始化返回 409,错误凭据返回 401,成功设置 session 并返回 `200 {ok:true}`,限流返回 429。 | | `任意方法 /api/v1/auth/me` | session;非 `GET`/`HEAD` 还需来源头 | `200 {username}`。前端使用 `GET`。 | diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 5e64de4..ea1b91e 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -26,7 +26,7 @@ import ( ) // Version is overridden at build time for release images. -var Version = "0.2.0" +var Version = "0.3.0" type App struct { dataDir string diff --git a/docs/frontend/application.md b/docs/frontend/application.md index dd832d0..bb535fe 100644 --- a/docs/frontend/application.md +++ b/docs/frontend/application.md @@ -6,7 +6,7 @@ 应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;初始化后未登录渲染公开只读 `/` 总览和 `/login` 登录页,登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未登录访问 `/settings` 回到公开总览,未知路径回到 `/`。状态响应中的构建版本以 `v` 徽标显示在安装页、登录页、公开总览和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。 -主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后重新读取;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。公开总览使用匿名只读接口并隐藏身份配置字段,隐藏手动刷新按钮和设置导航;服务器固定每 5 分钟同步全部账号,登录后总览默认只展示账号摘要,展开卡片后显示完整限额、统计和 Token 图。 +主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后重新读取;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。公开总览使用匿名只读接口并隐藏身份配置字段,隐藏手动刷新按钮和设置导航;服务器固定每 5 分钟同步全部账号,登录后总览默认只展示账号摘要。折叠摘要分别显示小时额度和周额度的剩余百分比及各自重置倒计时;仅有周额度时摘要居中显示该窗口。展开卡片后显示完整限额、统计和 Token 图。 桌面端使用固定侧栏,`800px` 及以下改为紧凑顶栏和固定底部主导航;GitHub、主题和退出位于顶栏辅助菜单。移动布局最低支持 320px,使用动态视口高度和 CSS safe-area 环境变量避开 iOS 浏览器工具栏与设备安全区。内容必须为底部导航保留空间,表单控件避免 iOS 聚焦缩放,主要交互保持至少 44px 触控区域。 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index a77eef6..982d18c 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -4,7 +4,7 @@ ## 镜像结构 -`Dockerfile` 有四个阶段:Node 24.19.0 构建 React 静态资源;Go 1.26.0 以 `CGO_ENABLED=0` 构建后端;Node 阶段安装固定 `@openai/codex`;最终 Debian bookworm 镜像只包含 CA、时区、后端、Node runtime 和 Codex 包。`APP_VERSION` 构建参数通过 Go linker 注入状态 API,未指定时默认为 `0.2.0`,前端在品牌区域显示该版本。 +`Dockerfile` 有四个阶段:Node 24.19.0 构建 React 静态资源;Go 1.26.0 以 `CGO_ENABLED=0` 构建后端;Node 阶段安装固定 `@openai/codex`;最终 Debian bookworm 镜像只包含 CA、时区、后端、Node runtime 和 Codex 包。`APP_VERSION` 构建参数通过 Go linker 注入状态 API,未指定时默认为 `0.3.0`,前端在品牌区域显示该版本。 前端产物复制到 `backend/internal/web/dist` 后嵌入二进制。运行层使用 UID `10001` 的 system 用户 `helper`,默认 `DATA_DIR=/data`、`LISTEN_ADDR=:8080`,并暴露 `/data` volume 和 8080。健康检查调用二进制自身的 `healthcheck` 子命令。 diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9b49caa..a7ebe99 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -632,24 +632,44 @@ function AccountQuickSummary({ : "等待数据"} ); - const remaining = dashboard.limits.length - ? Math.min(...dashboard.limits.map((limit) => remainingPercent(limit))) - : null; - const reset = dashboard.limits - .map((limit) => limit.resetsAt) - .filter((value) => value > 0) - .sort((a, b) => a - b)[0]; + const limits = quickSummaryLimits(dashboard.limits); + if (!limits.length) + return 限额暂无; return ( - - - {remaining == null ? "限额暂无" : `最低 ${Math.round(remaining)}% 剩余`} - - - {dashboard.currentCycle == null - ? "本周期 Token 暂无" - : `本周期 ${num(dashboard.currentCycle.totalTokens)} Tokens`} - - {reset ? `最近重置 ${relative(reset)}` : "暂无重置时间"} + + {limits.map(({ kind, limit }) => { + const remaining = Math.round(remainingPercent(limit)); + return ( + + + {quickLimitLabel(kind, limit)} + + + {remaining}% + + 剩余 + + {limit.resetsAt + ? `${relative(limit.resetsAt)}重置` + : "重置时间暂无"} + + + ); + })} ); } @@ -1670,6 +1690,36 @@ const cycleDuration = (minutes: number) => : `${minutes} 分钟窗口`; const remainingPercent = (limit: Limit) => 100 - Math.min(100, Math.max(0, limit.usedPercent)); +type QuickLimitKind = "hour" | "week" | "other"; +const quickSummaryLimits = ( + limits: Limit[], +): Array<{ kind: QuickLimitKind; limit: Limit }> => { + const sorted = [...limits].sort( + (a, b) => a.windowDurationMinutes - b.windowDurationMinutes, + ); + const hour = sorted.find( + (limit) => + limit.windowDurationMinutes > 0 && limit.windowDurationMinutes < 24 * 60, + ); + const week = sorted.find( + (limit) => + limit.windowDurationMinutes >= 6 * 24 * 60 && + limit.windowDurationMinutes <= 8 * 24 * 60, + ); + const selected: Array<{ kind: QuickLimitKind; limit: Limit }> = []; + if (hour) selected.push({ kind: "hour", limit: hour }); + if (week && week !== hour) selected.push({ kind: "week", limit: week }); + if (selected.length) return selected; + return sorted.slice(0, 2).map((limit) => ({ kind: "other", limit })); +}; +const quickLimitLabel = (kind: QuickLimitKind, limit: Limit) => { + if (kind === "week") return "周额度"; + if (kind === "hour") { + const hours = limit.windowDurationMinutes / 60; + return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} 小时额度`; + } + return limitLabel(limit).replace(/限额$/, "额度"); +}; const limitLabel = (limit: Limit) => { if (limit.limitName) return limit.limitName; if (limit.windowDurationMinutes >= 60) { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 83c3e49..ff14f56 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -391,6 +391,37 @@ header p { .account-summary-metrics span { white-space: nowrap; } +.account-summary-limits { + display: grid; + justify-content: end; + gap: 8px; + width: 100%; +} +.account-summary-limits.single { + justify-content: center; +} +.account-summary-limit { + display: grid; + grid-template-columns: minmax(76px, auto) 42px 28px minmax(92px, auto); + align-items: baseline; + gap: 6px; +} +.account-summary-limit-name { + color: var(--text); + font-weight: 650; +} +.account-summary-limit strong { + font-size: 16px; + text-align: right; +} +.account-summary-limit-remaining { + font-size: 12px; +} +.account-summary-limit-reset { + padding-left: 10px; + border-left: 1px solid var(--border); + text-align: left; +} .account-chevron { flex: none; width: 20px; @@ -1057,6 +1088,21 @@ a { justify-content: flex-start; gap: 6px 12px; } + .account-summary-limits { + justify-content: stretch; + } + .account-summary-limits.single { + justify-content: center; + } + .account-summary-limit { + grid-template-columns: auto auto auto minmax(0, 1fr); + gap: 4px; + } + .account-summary-limit-reset { + overflow: hidden; + padding-left: 6px; + text-overflow: ellipsis; + } .account-chevron { grid-column: 3; grid-row: 1; diff --git a/frontend/tests/settings-layout.spec.ts b/frontend/tests/settings-layout.spec.ts index 90afff5..8d91544 100644 --- a/frontend/tests/settings-layout.spec.ts +++ b/frontend/tests/settings-layout.spec.ts @@ -91,6 +91,14 @@ test("shows every account as a collapsible summary on the overview", async ({ windowDurationMinutes: 300, resetsAt: 1_900_000_000, }, + { + limitId: "secondary", + limitName: "周限额", + windowType: "weekly", + usedPercent: 20, + windowDurationMinutes: 10_080, + resetsAt: 1_900_604_800, + }, ], currentCycle: { limitId: "codex", @@ -122,7 +130,16 @@ test("shows every account as a collapsible summary on the overview", async ({ planType: "team", connected: true, }, - limits: [], + limits: [ + { + limitId: "secondary", + limitName: "周限额", + windowType: "weekly", + usedPercent: 40, + windowDurationMinutes: 10_080, + resetsAt: 1_900_604_800, + }, + ], summary: {}, usage: [], fetchedAt: 0, @@ -163,9 +180,29 @@ test("shows every account as a collapsible summary on the overview", async ({ await page.goto("/"); await expect(page.locator(".account-overview")).toHaveCount(2); await expect.poll(() => requestedDashboards.size).toBe(2); - await expect(page.getByText("最低 65% 剩余")).toBeVisible(); - await expect(page.getByText("本周期 123.5K Tokens")).toBeVisible(); - await expect(page.getByText("限额暂无")).toBeVisible(); + const firstSummary = page + .locator('[data-account-id="1"]') + .locator(".account-summary-limits"); + await expect(firstSummary.locator('[data-window-kind="hour"]')).toContainText( + "5 小时额度65%剩余", + ); + await expect(firstSummary.locator('[data-window-kind="week"]')).toContainText( + "周额度80%剩余", + ); + await expect( + firstSummary.locator(".account-summary-limit-reset"), + ).toHaveCount(2); + expect( + await firstSummary.evaluate( + (element) => element.scrollWidth <= element.clientWidth, + ), + ).toBe(true); + const weeklyOnlySummary = page + .locator('[data-account-id="2"]') + .locator(".account-summary-limits.single"); + await expect(weeklyOnlySummary).toContainText("周额度60%剩余"); + await expect(weeklyOnlySummary).toHaveCSS("justify-content", "center"); + await expect(page.getByText("本周期 123.5K Tokens")).toHaveCount(0); await expect(page.getByRole("button", { name: "刷新全部" })).toHaveCount(0); await expect(page.locator(".account-detail")).toHaveCount(0); await expect(page.locator(".account-select")).toHaveCount(0); @@ -286,14 +323,14 @@ test("shows the build version in the authenticated brand", async ({ page }) => { test("keeps the authenticated brand on one line with a release version", async ({ page, }) => { - await openSettings(page, "0.2.0"); + await openSettings(page, "0.3.0"); const brand = page.locator( (page.viewportSize()?.width ?? 0) <= 800 ? ".mobile-topbar .logo" : "aside .logo", ); await expect(brand).toContainText("Codex Helper"); - await expect(brand.locator(".version-badge")).toHaveText("v0.2.0"); + await expect(brand.locator(".version-badge")).toHaveText("v0.3.0"); const centers = await brand .locator("svg, .brand-name, .version-badge") .evaluateAll((elements) =>