Compare commits

...
10 Commits
22 changed files with 2096 additions and 424 deletions
+1 -1
View File
@@ -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
+10 -7
View File
@@ -6,11 +6,12 @@
- 支持在同一总览页查看多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区)
- 展示 Codex 账户、套餐、剩余额度和下次重置时间
- 展示累计 Token、单日峰值、连续使用天数及每日 Token 趋势
- 展示当前重置周期 Token 合计、本周期单日峰值及每日 Token 趋势
- 有可用重置卡时展示卡片数量和到期时间
- 在本地 SQLite 中保留历史用量和限额快照
- 在限额重置前、重置后发送 Telegram 或邮件提醒
- 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息
- 所有运行期配置均可通过前端完成
- 初始化后无需登录即可查看公开只读账号总览;只有登录管理员后才能添加账号、同步用量和修改运行期配置
- 前端适配 320px 起的手机浏览器,支持移动底部导航、iOS 安全区与深浅主题
- React 前端、Go 后端、Codex CLI 和 SQLite 运行在同一个容器中
@@ -46,6 +47,8 @@ http://服务器地址:8180
首次打开页面时创建管理员账号,并设置所在时区。用户名至少 3 位,密码至少 10 位。
初始化完成后,首页会显示已勾选“公开显示到未登录总览”的账号;点击“登录后配置”并使用管理员账号登录,才能进入设置中心添加账号、同步数据、修改账号公开状态或配置提醒。未勾选公开的账号只会在登录后的总览中显示。
> 首次初始化没有额外安装码。创建管理员之前,不要将端口直接暴露到不可信网络。公网部署应使用 HTTPS 反向代理,并限制初始化阶段的访问来源。
## 连接 Codex 账户
@@ -56,7 +59,7 @@ http://服务器地址:8180
4. 点击页面显示的 OpenAI 验证地址,或在另一台设备的浏览器中打开该地址。
5. 登录需要监控的 ChatGPT/Codex 账户。
6. 输入页面显示的一次性设备码并确认授权。
7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“刷新全部”或对应账号卡片中的“刷新账号”。
7. 返回 Codex Helper,服务器会在后台自动同步账号用量;首次同步通常会在数秒内完成,之后固定每 5 分钟同步一次。
8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。
设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。默认连接的登录凭据保存在 `/data/codex`,新增连接保存在 `/data/accounts/<账号 ID>/codex`,不会写入浏览器或项目源码。
@@ -70,7 +73,7 @@ http://服务器地址:8180
进入“设置中心” → “通用”,可设置:
- 时区:用于初始化配置;界面时间按浏览器本地时区显示
- 同步间隔:1–60 分钟
- 自动同步:服务器固定每 5 分钟同步全部账号
- 历史保留时间:30、60、90、180 或 365 天
- 提前提醒时间:重置前 1–1440 分钟
- 是否发送重置前提醒和重置后确认;重置后确认也会通过额度百分比回落识别并提醒官方活动、临时补发等提前重置
@@ -169,10 +172,10 @@ Codex Helper 展示的是 Codex app-server 实际返回的数据。当前接口
- ChatGPT/Codex 账户和套餐类型
- 限额窗口使用百分比、窗口长度和重置时间
- 累计 Token、单日峰值、连续使用天数、最长任务时长等摘要
- 当前重置周期 Token 合计、本周期单日峰值等摘要
- 每日总 Token 桶
当前接口不提供调用次数、输入 Token、输出 Token、订阅价格或账单续期日,因此总览使用“最长任务时长”作为替代摘要指标。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 OpenAI 官方文档,`account/usage/read` 需要 Codex 服务支持的身份认证;仅 API Key 或 Bedrock 登录不能读取这些 ChatGPT 用量数据。
当前总览的 Token 合计按 app-server 返回的当前有效最长限额窗口汇总每日 Token bucket,通常对应 secondary 周窗口;周期边界所在日期按整日统计。系统不根据 Token 推算 Credits、美元价值或订阅价格。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 OpenAI 官方文档,`account/usage/read` 需要 Codex 服务支持的身份认证;仅 API Key 或 Bedrock 登录不能读取这些 ChatGPT 用量数据。
## 常见问题
@@ -189,7 +192,7 @@ docker compose restart codex-helper
### 完成设备码授权后仍显示“尚未连接”
- 等待几秒后点击“刷新全部”或对应账号卡片中的“刷新账号”。
- 等待首次后台同步完成;之后服务器每 5 分钟自动同步一次。若持续未连接,请检查 app-server 和网络日志。
- 确认授权的是需要监控的 ChatGPT 账户。
- 重新进入 Codex 设置,退出账户后再次生成设备码。
- 检查 `docker compose logs -f codex-helper` 中是否存在网络或认证错误。
+14 -9
View File
@@ -8,7 +8,7 @@
- `GET /health/live` 始终返回 `200 {"status":"ok"}`;`GET /health/ready` 在 SQLite 可用时返回 `200 {"status":"ok","appServer":bool}`,数据库不可用时返回 `503 {"error":string}`。`appServer` 表示至少一个账号的 app-server 已完成初始化。
- JSON 请求体最多读取 1 MiB,拒绝未知字段;业务错误统一为 `{"error":string}`。未匹配 API 返回 `404 {"error":"接口不存在"}`。
- 所有响应带 `X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Referrer-Policy: same-origin` 和同源 CSP。
- `GET /api/v1/system/status`、`POST /api/v1/setup`、`POST /api/v1/auth/login` 匿名可用。status 的其他方法返回 405;其余 API 要求有效 `session` cookie,非 `GET`/`HEAD` 请求还要求 `X-Requested-With: codex-helper`,否则分别返回 401 或 403。当前 dispatcher 只对部分路由显式限制 HTTP method;下文使用“任意方法”或“非 `GET`”的地方是对实际兼容行为的记录。
- `GET /api/v1/system/status`、`POST /api/v1/setup`、`POST /api/v1/auth/login` 匿名可用;初始化完成后,`GET`/`HEAD /api/v1/accounts` 和 `GET`/`HEAD /api/v1/dashboard` 也提供已标记为公开账号的匿名只读总览。匿名总览会隐藏邮箱、认证方式、账号配置校验和内部错误字段;未公开账号对匿名请求按不存在处理。其余 API 要求有效 `session` cookie,非 `GET`/`HEAD` 请求还要求 `X-Requested-With: codex-helper`,否则分别返回 401 或 403。当前 dispatcher 只对部分路由显式限制 HTTP method;下文使用“任意方法”或“非 `GET`”的地方是对实际兼容行为的记录。
- session 有效期七天,cookie 为 `HttpOnly`、`SameSite=Strict`、`Path=/`;数据库只保存 token 摘要。登录失败按 `RemoteAddr` 在进程内限制为 15 分钟最多 10 次,超限返回 429。
- 未命中静态文件的非 API GET 路径返回嵌入的 `index.html`,供前端路由回退。
@@ -18,7 +18,7 @@
```text
{id, displayName, email:string|null, planType:string|null,
expectedKind:"any"|"personal"|"team",
expectedKind:"any"|"personal"|"team", publicVisible:bool,
actualKind:"unknown"|"personal"|"team",
validationStatus:"pending"|"matched"|"mismatch"|"unknown",
possibleDuplicate:bool, connected:bool, createdAt, updatedAt}
@@ -31,19 +31,23 @@
account:{email:string|null, authMode:string|null, planType:string|null, connected:bool},
limits:[{limitId, limitName:string|null, windowType,
usedPercent, windowDurationMinutes, resetsAt, planType:string|null}],
currentCycle?:{limitId, windowType, windowDurationMinutes,
startedAt, resetsAt, totalTokens},
resetCredits?:{availableCount, expiresAt:[Unix seconds]},
summary:{lifetimeTokens?, peakDailyTokens?, longestRunningTurnSec?,
currentStreakDays?, longestStreakDays?, callCount?, inputTokens?, outputTokens?},
usage:[{date,totalTokens,callCount?,inputTokens?,outputTokens?}],
fetchedAt, stale, lastError?}
```
时间字段为 Unix 秒。app-server 未提供的摘要字段可以是 `null`;列表应返回数组而非 `null`。
时间字段为 Unix 秒。`currentCycle` 是按当前仍有效的最长限额窗口计算的当前重置周期;`totalTokens` 由 `account/usage/read` 的每日 Token bucket 汇总,受每日粒度影响,无法精确切分周期起止日期内的单日数据。`summary.peakDailyTokens` 是同一当前重置周期内每日 Token bucket 的最大值,不再直接采用 app-server 未限定口径的峰值摘要;没有有效窗口或周期内没有每日数据时为 `null`。app-server 未提供的其他摘要字段可以是 `null`;列表应返回数组而非 `null`。
`resetCredits` 仅在 app-server 返回可用重置卡且 `availableCount > 0` 时出现;`expiresAt` 是已返回卡片的去重到期时间列表,服务端未提供卡片详情时可以为空。公开账号的匿名 Dashboard 也会返回该字段;邮箱、认证方式和内部错误字段仍会隐藏。
## 3. 系统、初始化与会话
| 方法与路径 | 鉴权 | 行为 |
| --- | --- | --- |
| `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`。 |
@@ -55,14 +59,15 @@
| 方法与路径 | 请求与响应 |
| --- | --- |
| `GET /api/v1/accounts` | 返回 `200 Account[]`,按 ID 升序。 |
| `POST /api/v1/accounts` | body `{displayName,expectedKind}`;空名称默认为 `新账号`,空类型默认为 `any`;成功返回 `201 Account`。 |
| `PUT /api/v1/accounts/{id}` | body `{displayName,expectedKind?}`;名称不能为空,省略类型时保留旧值;成功返回 `200 {ok:true}`。 |
| `GET /api/v1/accounts` | 初始化后匿名可读;匿名只返回 `publicVisible=true` 的账号,按 ID 升序;匿名响应隐藏 `email`、`expectedKind`、`actualKind`、`validationStatus`、`possibleDuplicate` 和创建/更新时间。登录后返回全部账号及完整字段。 |
| `POST /api/v1/accounts` | body `{displayName,expectedKind,publicVisible}`;空名称默认为 `新账号`,空类型默认为 `any`,`publicVisible` 省略时默认为 `false`;成功返回 `201 Account`。 |
| `PUT /api/v1/accounts/{id}` | body `{displayName,expectedKind?,publicVisible?}`;名称不能为空,省略类型或 `publicVisible` 时分别保留旧值;成功返回 `200 {ok:true}`。 |
| `DELETE /api/v1/accounts/{id}` | 停止该账号进程,删除账号及级联历史,再删除对应凭据目录;成功返回 `200 {ok:true}`。 |
| `POST /api/v1/accounts/{id}/login/device` | 启动并初始化 app-server,调用 `account/login/start` 的 `chatgptDeviceCode` 流程;返回含 `verificationUrl`、`userCode` 和 `loginId` 的结果。 |
| `POST /api/v1/accounts/{id}/logout` | 调用 `account/logout` 并将连接状态置为 false;返回 `200 {ok:true}`。 |
| `POST /api/v1/accounts/{id}/sync` | 同步指定账号;成功 `200 {ok:true}`,上游失败 502。 |
| `任意方法 /api/v1/dashboard?accountId={id}` | 返回内存中的 `Dashboard`;非 `GET`/`HEAD` 还需来源头。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 |
| `GET`/`HEAD /api/v1/dashboard?accountId={id}` | 初始化后匿名可读公开账号,返回内存中的 `Dashboard`;匿名访问未公开账号返回 404,匿名响应隐藏邮箱、认证方式和内部错误字段,但保留重置卡信息。登录后可读取全部账号。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 |
| `任意非读方法 /api/v1/dashboard?accountId={id}` | 要求 session;非 `GET`/`HEAD` 还需来源头。保持兼容的读取行为,省略或无效的零值 ID 使用账号 1。 |
| `POST /api/v1/sync?accountId={id}` | 旧兼容入口,同步指定账号;省略或零值 ID 使用账号 1。 |
账号不存在返回 404 `账号不存在`;非法路径 ID 返回 400 `账号 ID 无效`;无效 `expectedKind` 返回 400 `连接类型无效`。未知账号套餐不得猜测为个人或团队。
@@ -77,7 +82,7 @@
{timezone,theme,syncMinutes,retentionDays,beforeMinutes,notifyBefore,notifyAfter}
```
任意非 `GET` 方法都按更新处理,前端使用 `PUT`;请求接受完整对象。`syncMinutes` 为 1–60,`retentionDays` 为 30–365,`beforeMinutes` 为 1–1440,时区必须能由 Go 加载;非法值返回 400。成功返回保存后的对象。
任意非 `GET` 方法都按更新处理,前端使用 `PUT`;请求接受完整对象。`syncMinutes` 为兼容旧客户端保留的字段,服务端始终按 5 分钟自动同步并返回 `5`;`retentionDays` 为 30–365,`beforeMinutes` 为 1–1440,时区必须能由 Go 加载;非法值返回 400。成功返回保存后的对象。
### SMTP
+213 -31
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -43,6 +44,14 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
a.login(w, r)
return
}
if p == "accounts" && readOnlyMethod(r.Method) {
a.accountsAPI(w, r)
return
}
if p == "dashboard" && readOnlyMethod(r.Method) {
a.dashboardAPI(w, r)
return
}
if !a.require(w, r) {
return
}
@@ -53,17 +62,11 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, map[string]string{"username": username})
case p == "auth/logout" && r.Method == "POST":
a.logout(w, r)
case p == "accounts" && r.Method == "GET":
x, e := a.store.Accounts()
if e != nil {
jsonOut(w, 500, map[string]string{"error": e.Error()})
} else {
jsonOut(w, 200, x)
}
case p == "accounts" && r.Method == "POST":
var in struct {
DisplayName string `json:"displayName"`
ExpectedKind string `json:"expectedKind"`
PublicVisible bool `json:"publicVisible"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "请求格式错误"})
@@ -80,7 +83,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, map[string]string{"error": "连接类型无效"})
break
}
x, e := a.store.CreateAccount(in.DisplayName, in.ExpectedKind)
x, e := a.store.CreateAccountWithVisibility(in.DisplayName, in.ExpectedKind, in.PublicVisible)
if e == nil {
a.addRuntime(x.ID)
jsonOut(w, 201, x)
@@ -90,25 +93,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(p, "accounts/"):
a.accountAPI(w, r, p)
case p == "dashboard":
id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64)
if id == 0 {
id = 1
}
rt := a.runtime(id)
if rt == nil {
jsonOut(w, 404, map[string]string{"error": "账号不存在"})
} else {
rt.syncing.Lock()
d := rt.dash
rt.syncing.Unlock()
if d.Limits == nil {
d.Limits = []LimitBucket{}
}
if d.Usage == nil {
d.Usage = []UsagePoint{}
}
jsonOut(w, 200, d)
}
a.dashboardAPI(w, r)
case p == "sync" && r.Method == "POST":
id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64)
if id == 0 {
@@ -162,6 +147,93 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
}
}
func readOnlyMethod(method string) bool {
return method == http.MethodGet || method == http.MethodHead
}
func (a *App) accountsAPI(w http.ResponseWriter, r *http.Request) {
if !a.store.Initialized() {
jsonOut(w, http.StatusConflict, map[string]string{"error": "请先初始化"})
return
}
x, e := a.store.Accounts()
if e != nil {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": e.Error()})
return
}
if !a.authed(r) {
visible := make([]store.Account, 0, len(x))
for _, account := range x {
if !account.PublicVisible {
continue
}
visible = append(visible, publicAccount(account))
}
x = visible
}
jsonOut(w, http.StatusOK, x)
}
func publicAccount(account store.Account) store.Account {
account.Email = nil
account.ExpectedKind = "any"
account.ActualKind = "unknown"
account.ValidationStatus = "unknown"
account.PossibleDuplicate = false
account.CreatedAt = 0
account.UpdatedAt = 0
return account
}
func (a *App) dashboardAPI(w http.ResponseWriter, r *http.Request) {
if !a.store.Initialized() {
jsonOut(w, http.StatusConflict, map[string]string{"error": "请先初始化"})
return
}
id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64)
if id == 0 {
id = 1
}
account, accountErr := a.store.Account(id)
if accountErr != nil {
if accountErr == sql.ErrNoRows {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"})
} else {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": accountErr.Error()})
}
return
}
if !a.authed(r) && !account.PublicVisible {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"})
return
}
rt := a.runtime(id)
if rt == nil {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"})
return
}
rt.syncing.Lock()
d := rt.dash
rt.syncing.Unlock()
if d.Limits == nil {
d.Limits = []LimitBucket{}
}
if d.Usage == nil {
d.Usage = []UsagePoint{}
}
if !a.authed(r) {
d = publicDashboard(d)
}
jsonOut(w, http.StatusOK, d)
}
func publicDashboard(d Dashboard) Dashboard {
d.Account.Email = nil
d.Account.AuthMode = nil
d.LastError = ""
return d
}
func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
parts := strings.Split(p, "/")
if len(parts) < 2 {
@@ -187,6 +259,7 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
var in struct {
DisplayName string `json:"displayName"`
ExpectedKind string `json:"expectedKind"`
PublicVisible *bool `json:"publicVisible"`
}
if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" {
jsonOut(w, 400, map[string]string{"error": "名称不能为空"})
@@ -205,7 +278,7 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
jsonOut(w, 400, map[string]string{"error": "连接类型无效"})
return
}
e = a.store.UpdateAccountSettings(id, strings.TrimSpace(in.DisplayName), in.ExpectedKind)
e = a.store.UpdateAccountSettingsWithVisibility(id, strings.TrimSpace(in.DisplayName), in.ExpectedKind, in.PublicVisible)
if e == nil {
jsonOut(w, 200, map[string]bool{"ok": true})
}
@@ -336,9 +409,9 @@ func (a *App) logout(w http.ResponseWriter, r *http.Request) {
func (a *App) general() GeneralSettings {
g := defaults()
a.store.GetJSON("general", &g)
if g.SyncMinutes < 1 {
g.SyncMinutes = 5
}
// Keep the legacy field in the response for old clients, but automatic
// account synchronization is intentionally fixed at five minutes.
g.SyncMinutes = automaticSyncMinutes
if g.RetentionDays < 1 {
g.RetentionDays = 90
}
@@ -358,6 +431,7 @@ func (a *App) generalAPI(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, map[string]string{"error": "无效时区"})
return
}
g.SyncMinutes = automaticSyncMinutes
_ = a.store.SetJSON("general", g)
jsonOut(w, 200, g)
}
@@ -418,8 +492,10 @@ func (a *App) syncAccount(ctx context.Context, id int64) error {
var lr struct {
RateLimits *rawLimit `json:"rateLimits"`
By map[string]rawLimit `json:"rateLimitsByLimitId"`
ResetCredits *rawResetCredits `json:"rateLimitResetCredits"`
}
if e := rt.client.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil {
d.ResetCredits = normalizeResetCredits(lr.ResetCredits)
if len(lr.By) > 0 {
for _, x := range lr.By {
d.Limits = append(d.Limits, flattenLimit(x)...)
@@ -460,6 +536,8 @@ func (a *App) syncAccount(ctx context.Context, id int64) error {
d.Usage = append(d.Usage, p)
_, _ = a.store.DB.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?) ON CONFLICT(account_id,date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", id, x.StartDate, x.Tokens, d.FetchedAt)
}
d.CurrentCycle = currentTokenCycle(d.Limits, d.Usage, d.FetchedAt)
d.Summary.PeakDailyTokens = peakDailyTokensForCycle(d.CurrentCycle, d.Usage, d.FetchedAt)
}
resetDetected, e := a.storeLimitSnapshots(d)
if e != nil {
@@ -530,6 +608,110 @@ type rawLimit struct {
Secondary *LimitWindow `json:"secondary"`
}
type rawResetCredits struct {
AvailableCount int `json:"availableCount"`
Credits []rawResetCredit `json:"credits"`
}
type rawResetCredit struct {
ExpiresAt *int64 `json:"expiresAt"`
}
func normalizeResetCredits(raw *rawResetCredits) *ResetCreditsSummary {
if raw == nil || raw.AvailableCount <= 0 {
return nil
}
expiresAt := make([]int64, 0, len(raw.Credits))
for _, credit := range raw.Credits {
if credit.ExpiresAt != nil && *credit.ExpiresAt > 0 {
expiresAt = append(expiresAt, *credit.ExpiresAt)
}
}
sort.Slice(expiresAt, func(i, j int) bool { return expiresAt[i] < expiresAt[j] })
uniqueExpiresAt := expiresAt[:0]
for _, value := range expiresAt {
if len(uniqueExpiresAt) == 0 || uniqueExpiresAt[len(uniqueExpiresAt)-1] != value {
uniqueExpiresAt = append(uniqueExpiresAt, value)
}
}
return &ResetCreditsSummary{AvailableCount: raw.AvailableCount, ExpiresAt: uniqueExpiresAt}
}
func currentTokenCycle(limits []LimitBucket, usage []UsagePoint, fetchedAt int64) *TokenCycle {
current := LimitBucket{}
found := false
for _, limit := range limits {
if limit.WindowDurationMinutes <= 0 || limit.ResetsAt <= fetchedAt {
continue
}
if !found || betterTokenCycleLimit(limit, current) {
current = limit
found = true
}
}
if !found {
return nil
}
startedAt := current.ResetsAt - int64(current.WindowDurationMinutes)*60
startDate := time.Unix(startedAt, 0).UTC().Format("2006-01-02")
endDate := time.Unix(fetchedAt, 0).UTC().Format("2006-01-02")
var total int64
for _, point := range usage {
if point.Date < startDate || point.Date > endDate {
continue
}
if _, err := time.Parse("2006-01-02", point.Date); err != nil {
continue
}
total += point.TotalTokens
}
return &TokenCycle{
LimitID: current.LimitID,
WindowType: current.WindowType,
WindowDurationMinutes: current.WindowDurationMinutes,
StartedAt: startedAt,
ResetsAt: current.ResetsAt,
TotalTokens: total,
}
}
func peakDailyTokensForCycle(cycle *TokenCycle, usage []UsagePoint, fetchedAt int64) *int64 {
if cycle == nil {
return nil
}
startDate := time.Unix(cycle.StartedAt, 0).UTC().Format("2006-01-02")
endDate := time.Unix(fetchedAt, 0).UTC().Format("2006-01-02")
var peak int64
found := false
for _, point := range usage {
if _, err := time.Parse("2006-01-02", point.Date); err != nil || point.Date < startDate || point.Date > endDate {
continue
}
if !found || point.TotalTokens > peak {
peak = point.TotalTokens
found = true
}
}
if !found {
return nil
}
return &peak
}
func betterTokenCycleLimit(candidate, current LimitBucket) bool {
if candidate.WindowDurationMinutes != current.WindowDurationMinutes {
return candidate.WindowDurationMinutes > current.WindowDurationMinutes
}
if candidate.WindowType != current.WindowType {
return candidate.WindowType == "secondary"
}
if candidate.LimitID != current.LimitID {
return candidate.LimitID < current.LimitID
}
return candidate.ResetsAt > current.ResetsAt
}
func flattenLimit(x rawLimit) []LimitBucket {
out := []LimitBucket{}
if x.Primary != nil {
+10 -7
View File
@@ -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
@@ -52,6 +52,8 @@ type accountRuntime struct {
stopped bool
}
const automaticSyncInterval = time.Duration(automaticSyncMinutes) * time.Minute
type codexClient interface {
Start(context.Context) error
Initialize(context.Context) error
@@ -267,17 +269,18 @@ func (a *App) syncAll(ctx context.Context) {
}
}
func (a *App) scheduler() {
t := time.NewTicker(time.Minute)
defer t.Stop()
maintenanceTicker := time.NewTicker(time.Minute)
syncTicker := time.NewTicker(automaticSyncInterval)
defer maintenanceTicker.Stop()
defer syncTicker.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-t.C:
g := a.general()
if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 {
case <-syncTicker.C:
a.syncAll(context.Background())
}
case <-maintenanceTicker.C:
g := a.general()
_, _ = a.store.Cleanup(g.RetentionDays)
go a.processReminders()
}
+336
View File
@@ -6,11 +6,15 @@ import (
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"sync"
"testing"
"time"
"codex-helper/internal/security"
"codex-helper/internal/store"
)
func TestSystemStatusRejectsNonGETMethods(t *testing.T) {
@@ -47,6 +51,9 @@ func TestSystemStatusReturnsBuildVersion(t *testing.T) {
func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) {
a := newReminderTestApp(t)
if err := a.store.Set("initialized", "true"); err != nil {
t.Fatal(err)
}
a.runtimes[1] = &accountRuntime{}
_, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken("test-session"), time.Now().Add(time.Hour).Unix(), time.Now().Unix())
if err != nil {
@@ -71,6 +78,335 @@ func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) {
}
}
func TestAnonymousOverviewIsReadOnly(t *testing.T) {
a := newReminderTestApp(t)
if err := a.store.Set("initialized", "true"); err != nil {
t.Fatal(err)
}
email := "owner@example.com"
plan := "plus"
if err := a.store.UpdateAccount(1, &email, &plan, true); err != nil {
t.Fatal(err)
}
publicVisible := true
if err := a.store.UpdateAccountSettingsWithVisibility(1, "默认账号", "any", &publicVisible); err != nil {
t.Fatal(err)
}
a.runtimes[1] = &accountRuntime{
dash: Dashboard{
AccountID: 1,
DisplayName: "默认账号",
Account: AccountView{Email: &email, PlanType: &plan, Connected: true},
Limits: []LimitBucket{},
Usage: []UsagePoint{},
ResetCredits: &ResetCreditsSummary{AvailableCount: 2, ExpiresAt: []int64{1784246400}},
FetchedAt: time.Now().Unix(),
},
}
accountsRecorder := httptest.NewRecorder()
a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil))
if accountsRecorder.Code != http.StatusOK {
t.Fatalf("anonymous accounts status = %d, body = %s", accountsRecorder.Code, accountsRecorder.Body.String())
}
var accounts []struct {
Email *string `json:"email"`
ExpectedKind string `json:"expectedKind"`
PublicVisible bool `json:"publicVisible"`
ValidationState string `json:"validationStatus"`
}
if err := json.Unmarshal(accountsRecorder.Body.Bytes(), &accounts); err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].Email != nil || !accounts[0].PublicVisible || accounts[0].ExpectedKind != "any" || accounts[0].ValidationState != "unknown" {
t.Fatalf("anonymous account data = %#v; sensitive account fields were not redacted", accounts)
}
dashboardRecorder := httptest.NewRecorder()
a.api(dashboardRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil))
if dashboardRecorder.Code != http.StatusOK {
t.Fatalf("anonymous dashboard status = %d, body = %s", dashboardRecorder.Code, dashboardRecorder.Body.String())
}
var publicDashboardBody Dashboard
if err := json.Unmarshal(dashboardRecorder.Body.Bytes(), &publicDashboardBody); err != nil {
t.Fatal(err)
}
if publicDashboardBody.Account.Email != nil || publicDashboardBody.Account.AuthMode != nil {
t.Fatalf("anonymous dashboard account = %#v; identity fields were not redacted", publicDashboardBody.Account)
}
if publicDashboardBody.ResetCredits == nil || publicDashboardBody.ResetCredits.AvailableCount != 2 {
t.Fatalf("anonymous dashboard reset credits = %#v; want two credits", publicDashboardBody.ResetCredits)
}
for _, path := range []string{"/api/v1/settings/general", "/api/v1/accounts", "/api/v1/accounts/1/sync"} {
recorder := httptest.NewRecorder()
method := http.MethodGet
if path == "/api/v1/accounts" || strings.HasSuffix(path, "/sync") {
method = http.MethodPost
}
a.api(recorder, httptest.NewRequest(method, path, nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("anonymous %s status = %d, body = %s; configuration must require login", path, recorder.Code, recorder.Body.String())
}
}
session := "test-session"
if _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil {
t.Fatal(err)
}
privateRecorder := httptest.NewRecorder()
privateRequest := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil)
privateRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
a.api(privateRecorder, privateRequest)
if privateRecorder.Code != http.StatusOK {
t.Fatalf("authenticated dashboard status = %d, body = %s", privateRecorder.Code, privateRecorder.Body.String())
}
var privateDashboardBody Dashboard
if err := json.Unmarshal(privateRecorder.Body.Bytes(), &privateDashboardBody); err != nil {
t.Fatal(err)
}
if privateDashboardBody.Account.Email == nil || *privateDashboardBody.Account.Email != email {
t.Fatalf("authenticated dashboard email = %v; want %q", privateDashboardBody.Account.Email, email)
}
if privateDashboardBody.ResetCredits == nil || privateDashboardBody.ResetCredits.AvailableCount != 2 {
t.Fatalf("authenticated dashboard reset credits = %#v; want two credits", privateDashboardBody.ResetCredits)
}
configRecorder := httptest.NewRecorder()
configRequest := httptest.NewRequest(http.MethodPut, "/api/v1/settings/general", strings.NewReader(`{"timezone":"UTC","theme":"system","syncMinutes":5,"retentionDays":90,"beforeMinutes":30,"notifyBefore":true,"notifyAfter":true}`))
configRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
configRequest.Header.Set("X-Requested-With", "codex-helper")
a.api(configRecorder, configRequest)
if configRecorder.Code != http.StatusOK {
t.Fatalf("authenticated settings status = %d, body = %s", configRecorder.Code, configRecorder.Body.String())
}
}
func TestAccountVisibilityFiltersAnonymousOverviewAndCanBeUpdated(t *testing.T) {
a := newReminderTestApp(t)
if err := a.store.Set("initialized", "true"); err != nil {
t.Fatal(err)
}
publicAccount, err := a.store.CreateAccountWithVisibility("公开账号", "team", true)
if err != nil {
t.Fatal(err)
}
privateAccount, err := a.store.CreateAccount("私有账号", "personal")
if err != nil {
t.Fatal(err)
}
a.runtimes[publicAccount.ID] = &accountRuntime{}
a.runtimes[privateAccount.ID] = &accountRuntime{}
accountsRecorder := httptest.NewRecorder()
a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil))
if accountsRecorder.Code != http.StatusOK {
t.Fatalf("anonymous accounts status = %d, body = %s", accountsRecorder.Code, accountsRecorder.Body.String())
}
var visible []struct {
ID int64 `json:"id"`
PublicVisible bool `json:"publicVisible"`
}
if err := json.Unmarshal(accountsRecorder.Body.Bytes(), &visible); err != nil {
t.Fatal(err)
}
if len(visible) != 1 || visible[0].ID != publicAccount.ID || !visible[0].PublicVisible {
t.Fatalf("anonymous accounts = %#v; want only public account %d", visible, publicAccount.ID)
}
privateDashboard := httptest.NewRecorder()
privatePath := "/api/v1/dashboard?accountId=" + strconv.FormatInt(privateAccount.ID, 10)
a.api(privateDashboard, httptest.NewRequest(http.MethodGet, privatePath, nil))
if privateDashboard.Code != http.StatusNotFound {
t.Fatalf("anonymous private dashboard status = %d, body = %s", privateDashboard.Code, privateDashboard.Body.String())
}
publicDashboard := httptest.NewRecorder()
publicPath := "/api/v1/dashboard?accountId=" + strconv.FormatInt(publicAccount.ID, 10)
a.api(publicDashboard, httptest.NewRequest(http.MethodGet, publicPath, nil))
if publicDashboard.Code != http.StatusOK {
t.Fatalf("anonymous public dashboard status = %d, body = %s", publicDashboard.Code, publicDashboard.Body.String())
}
session := "visibility-session"
if _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil {
t.Fatal(err)
}
createRecorder := httptest.NewRecorder()
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/accounts", strings.NewReader(`{"displayName":"接口公开账号","expectedKind":"team","publicVisible":true}`))
createRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
createRequest.Header.Set("X-Requested-With", "codex-helper")
a.api(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("authenticated account creation status = %d, body = %s", createRecorder.Code, createRecorder.Body.String())
}
var created store.Account
if err := json.Unmarshal(createRecorder.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
if !created.PublicVisible {
t.Fatalf("created account = %#v; want publicVisible=true", created)
}
authenticatedAccounts := httptest.NewRecorder()
authenticatedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil)
authenticatedRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
a.api(authenticatedAccounts, authenticatedRequest)
if authenticatedAccounts.Code != http.StatusOK {
t.Fatalf("authenticated accounts status = %d, body = %s", authenticatedAccounts.Code, authenticatedAccounts.Body.String())
}
var all []struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(authenticatedAccounts.Body.Bytes(), &all); err != nil {
t.Fatal(err)
}
if len(all) != 4 {
t.Fatalf("authenticated accounts = %#v; want default, public, private, and newly created accounts", all)
}
anonymousUpdate := httptest.NewRecorder()
anonymousUpdateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/"+strconv.FormatInt(privateAccount.ID, 10), strings.NewReader(`{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`))
a.api(anonymousUpdate, anonymousUpdateRequest)
if anonymousUpdate.Code != http.StatusUnauthorized {
t.Fatalf("anonymous visibility update status = %d, body = %s", anonymousUpdate.Code, anonymousUpdate.Body.String())
}
authenticatedUpdate := httptest.NewRecorder()
authenticatedUpdateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/"+strconv.FormatInt(privateAccount.ID, 10), strings.NewReader(`{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`))
authenticatedUpdateRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
authenticatedUpdateRequest.Header.Set("X-Requested-With", "codex-helper")
a.api(authenticatedUpdate, authenticatedUpdateRequest)
if authenticatedUpdate.Code != http.StatusOK {
t.Fatalf("authenticated visibility update status = %d, body = %s", authenticatedUpdate.Code, authenticatedUpdate.Body.String())
}
updated, err := a.store.Account(privateAccount.ID)
if err != nil {
t.Fatal(err)
}
if !updated.PublicVisible {
t.Fatalf("updated account = %#v; want publicVisible=true", updated)
}
}
func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) {
now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC)
reset := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC)
cycle := currentTokenCycle(
[]LimitBucket{
{
LimitID: "codex",
WindowType: "primary",
WindowDurationMinutes: 300,
ResetsAt: now.Add(5 * time.Hour).Unix(),
},
{
LimitID: "codex",
WindowType: "secondary",
WindowDurationMinutes: 7 * 24 * 60,
ResetsAt: reset.Unix(),
},
},
[]UsagePoint{
{Date: "2026-08-07", TotalTokens: 50},
{Date: "2026-08-08", TotalTokens: 100},
{Date: "2026-08-10", TotalTokens: 200},
{Date: "2026-08-14", TotalTokens: 300},
{Date: "2026-08-15", TotalTokens: 400},
},
now.Unix(),
)
if cycle == nil {
t.Fatal("currentTokenCycle() returned nil")
}
if cycle.WindowType != "secondary" || cycle.WindowDurationMinutes != 7*24*60 {
t.Fatalf("cycle window = %#v; want the seven-day secondary window", cycle)
}
if cycle.StartedAt != time.Date(2026, time.August, 8, 0, 0, 0, 0, time.UTC).Unix() {
t.Fatalf("cycle start = %d; want 2026-08-08", cycle.StartedAt)
}
if cycle.ResetsAt != reset.Unix() || cycle.TotalTokens != 600 {
t.Fatalf("cycle = %#v; want reset %d and 600 tokens", cycle, reset.Unix())
}
}
func TestNormalizeResetCreditsKeepsAvailableCountAndUniqueExpiryTimes(t *testing.T) {
earlier := int64(1781654400)
later := int64(1784246400)
credits := normalizeResetCredits(&rawResetCredits{
AvailableCount: 3,
Credits: []rawResetCredit{
{ExpiresAt: &later},
{ExpiresAt: nil},
{ExpiresAt: &earlier},
{ExpiresAt: &later},
},
})
if credits == nil || credits.AvailableCount != 3 {
t.Fatalf("reset credits = %#v; want three available credits", credits)
}
if want := []int64{earlier, later}; !reflect.DeepEqual(credits.ExpiresAt, want) {
t.Fatalf("expiry times = %v; want %v", credits.ExpiresAt, want)
}
if normalizeResetCredits(&rawResetCredits{}) != nil {
t.Fatal("zero available credits should be hidden")
}
}
func TestCurrentTokenCycleRequiresAValidFutureResetWindow(t *testing.T) {
cycle := currentTokenCycle(
[]LimitBucket{{WindowDurationMinutes: 0, ResetsAt: 1}},
[]UsagePoint{{Date: "2026-08-14", TotalTokens: 100}},
time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC).Unix(),
)
if cycle != nil {
t.Fatalf("currentTokenCycle() = %#v; want nil", cycle)
}
}
func TestPeakDailyTokensUsesOnlyTheCurrentTokenCycle(t *testing.T) {
now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC)
cycle := &TokenCycle{
StartedAt: time.Date(2026, time.August, 8, 0, 0, 0, 0, time.UTC).Unix(),
ResetsAt: time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC).Unix(),
}
peak := peakDailyTokensForCycle(cycle, []UsagePoint{
{Date: "2026-08-07", TotalTokens: 900},
{Date: "2026-08-08", TotalTokens: 100},
{Date: "2026-08-10", TotalTokens: 200},
{Date: "2026-08-14", TotalTokens: 300},
{Date: "2026-08-15", TotalTokens: 800},
}, now.Unix())
if peak == nil || *peak != 300 {
t.Fatalf("cycle peak = %v; want 300", peak)
}
}
func TestAutomaticSyncIntervalIsFixedAtFiveMinutes(t *testing.T) {
a := newReminderTestApp(t)
if err := a.store.SetJSON("general", GeneralSettings{SyncMinutes: 60, RetentionDays: 90, BeforeMinutes: 30}); err != nil {
t.Fatal(err)
}
if got := a.general().SyncMinutes; got != automaticSyncMinutes {
t.Fatalf("sync minutes = %d; want %d", got, automaticSyncMinutes)
}
if automaticSyncInterval != 5*time.Minute {
t.Fatalf("automatic sync interval = %s; want 5m", automaticSyncInterval)
}
}
func TestFlattenLimitReadsAppServerWindowDuration(t *testing.T) {
var limit rawLimit
if err := json.Unmarshal([]byte(`{
"limitId":"codex",
"primary":{"usedPercent":20,"windowDurationMins":10080,"resetsAt":1787197043}
}`), &limit); err != nil {
t.Fatal(err)
}
flattened := flattenLimit(limit)
if len(flattened) != 1 || flattened[0].WindowDurationMinutes != 10080 {
t.Fatalf("flattened limit = %#v; want a 10080-minute window", flattened)
}
}
type fakeCodexClient struct {
mu sync.Mutex
connected bool
+19 -2
View File
@@ -9,6 +9,9 @@ type GeneralSettings struct {
NotifyBefore bool `json:"notifyBefore"`
NotifyAfter bool `json:"notifyAfter"`
}
const automaticSyncMinutes = 5
type SMTPSettings struct {
Host string `json:"host"`
Port int `json:"port"`
@@ -41,7 +44,7 @@ type AccountView struct {
}
type LimitWindow struct {
UsedPercent float64 `json:"usedPercent"`
WindowDurationMins int `json:"windowDurationMinutes"`
WindowDurationMins int `json:"windowDurationMins"`
ResetsAt int64 `json:"resetsAt"`
}
type LimitBucket struct {
@@ -70,6 +73,18 @@ type UsagePoint struct {
InputTokens *int64 `json:"inputTokens"`
OutputTokens *int64 `json:"outputTokens"`
}
type TokenCycle struct {
LimitID string `json:"limitId"`
WindowType string `json:"windowType"`
WindowDurationMinutes int `json:"windowDurationMinutes"`
StartedAt int64 `json:"startedAt"`
ResetsAt int64 `json:"resetsAt"`
TotalTokens int64 `json:"totalTokens"`
}
type ResetCreditsSummary struct {
AvailableCount int `json:"availableCount"`
ExpiresAt []int64 `json:"expiresAt"`
}
type Dashboard struct {
AccountID int64 `json:"accountId"`
DisplayName string `json:"displayName"`
@@ -77,11 +92,13 @@ type Dashboard struct {
Limits []LimitBucket `json:"limits"`
Summary UsageSummary `json:"summary"`
Usage []UsagePoint `json:"usage"`
CurrentCycle *TokenCycle `json:"currentCycle,omitempty"`
ResetCredits *ResetCreditsSummary `json:"resetCredits,omitempty"`
FetchedAt int64 `json:"fetchedAt"`
Stale bool `json:"stale"`
LastError string `json:"lastError,omitempty"`
}
func defaults() GeneralSettings {
return GeneralSettings{Timezone: "UTC", Theme: "system", SyncMinutes: 5, RetentionDays: 90, BeforeMinutes: 30, NotifyBefore: true, NotifyAfter: true}
return GeneralSettings{Timezone: "UTC", Theme: "system", SyncMinutes: automaticSyncMinutes, RetentionDays: 90, BeforeMinutes: 30, NotifyBefore: true, NotifyAfter: true}
}
+44 -6
View File
@@ -95,13 +95,14 @@ func (s *Store) migrateAccounts() error {
email TEXT,
plan_type TEXT,
expected_kind TEXT NOT NULL DEFAULT 'any',
public_visible INTEGER NOT NULL DEFAULT 0,
connected INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`); err != nil {
return err
}
var hasExpectedKind bool
var hasExpectedKind, hasPublicVisible bool
rows, qerr := tx.Query("PRAGMA table_info(accounts)")
if qerr != nil {
return qerr
@@ -112,6 +113,7 @@ func (s *Store) migrateAccounts() error {
var def any
_ = rows.Scan(&cid, &name, &typ, &notnull, &def, &pk)
hasExpectedKind = hasExpectedKind || name == "expected_kind"
hasPublicVisible = hasPublicVisible || name == "public_visible"
}
rows.Close()
if !hasExpectedKind {
@@ -119,6 +121,11 @@ func (s *Store) migrateAccounts() error {
return err
}
}
if !hasPublicVisible {
if _, err = tx.Exec("ALTER TABLE accounts ADD COLUMN public_visible INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
}
var count int
if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil {
return err
@@ -181,6 +188,7 @@ type Account struct {
Email *string `json:"email"`
PlanType *string `json:"planType"`
ExpectedKind string `json:"expectedKind"`
PublicVisible bool `json:"publicVisible"`
ActualKind string `json:"actualKind"`
ValidationStatus string `json:"validationStatus"`
PossibleDuplicate bool `json:"possibleDuplicate"`
@@ -190,7 +198,7 @@ type Account struct {
}
func (s *Store) Accounts() ([]Account, error) {
rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,expected_kind,connected,created_at,updated_at FROM accounts ORDER BY id")
rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,expected_kind,public_visible,connected,created_at,updated_at FROM accounts ORDER BY id")
if e != nil {
return nil, e
}
@@ -198,7 +206,7 @@ func (s *Store) Accounts() ([]Account, error) {
out := []Account{}
for rows.Next() {
var a Account
if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil {
if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.PublicVisible, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil {
return nil, e
}
a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType)
@@ -217,21 +225,51 @@ func (s *Store) Accounts() ([]Account, error) {
}
return out, rows.Err()
}
func (s *Store) Account(id int64) (Account, error) {
var a Account
err := s.DB.QueryRow("SELECT id,display_name,email,plan_type,expected_kind,public_visible,connected,created_at,updated_at FROM accounts WHERE id=?", id).
Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.PublicVisible, &a.Connected, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return Account{}, err
}
a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType)
return a, nil
}
func (s *Store) CreateAccount(name string, kinds ...string) (Account, error) {
return s.createAccount(name, false, kinds...)
}
func (s *Store) CreateAccountWithVisibility(name, expectedKind string, publicVisible bool) (Account, error) {
return s.createAccount(name, publicVisible, expectedKind)
}
func (s *Store) createAccount(name string, publicVisible bool, kinds ...string) (Account, error) {
expectedKind := "any"
if len(kinds) > 0 {
expectedKind = kinds[0]
}
now := time.Now().Unix()
r, e := s.DB.Exec("INSERT INTO accounts(display_name,expected_kind,created_at,updated_at) VALUES(?,?,?,?)", name, expectedKind, now, now)
r, e := s.DB.Exec("INSERT INTO accounts(display_name,expected_kind,public_visible,created_at,updated_at) VALUES(?,?,?,?,?)", name, expectedKind, publicVisible, now, now)
if e != nil {
return Account{}, e
}
id, _ := r.LastInsertId()
return Account{ID: id, DisplayName: name, ExpectedKind: expectedKind, ActualKind: "unknown", ValidationStatus: "pending", CreatedAt: now, UpdatedAt: now}, nil
return Account{ID: id, DisplayName: name, ExpectedKind: expectedKind, PublicVisible: publicVisible, ActualKind: "unknown", ValidationStatus: "pending", CreatedAt: now, UpdatedAt: now}, nil
}
func (s *Store) UpdateAccountSettings(id int64, name, expectedKind string) error {
r, e := s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,updated_at=? WHERE id=?", name, expectedKind, time.Now().Unix(), id)
return s.UpdateAccountSettingsWithVisibility(id, name, expectedKind, nil)
}
func (s *Store) UpdateAccountSettingsWithVisibility(id int64, name, expectedKind string, publicVisible *bool) error {
var r sql.Result
var e error
if publicVisible == nil {
r, e = s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,updated_at=? WHERE id=?", name, expectedKind, time.Now().Unix(), id)
} else {
r, e = s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,public_visible=?,updated_at=? WHERE id=?", name, expectedKind, *publicVisible, time.Now().Unix(), id)
}
if e != nil {
return e
}
+43 -2
View File
@@ -33,7 +33,7 @@ func TestAccountsAndPerAccountUsage(t *testing.T) {
}
defer s.DB.Close()
accounts, err := s.Accounts()
if err != nil || len(accounts) != 1 || accounts[0].ID != 1 {
if err != nil || len(accounts) != 1 || accounts[0].ID != 1 || accounts[0].PublicVisible {
t.Fatalf("default accounts = %#v, %v", accounts, err)
}
second, err := s.CreateAccount("Team workspace")
@@ -97,11 +97,52 @@ func TestExistingAccountsGainExpectedKind(t *testing.T) {
if err != nil || len(accounts) != 1 {
t.Fatalf("accounts = %#v, %v", accounts, err)
}
if accounts[0].ExpectedKind != "any" || accounts[0].ActualKind != "team" || accounts[0].ValidationStatus != "matched" {
if accounts[0].ExpectedKind != "any" || accounts[0].PublicVisible || accounts[0].ActualKind != "team" || accounts[0].ValidationStatus != "matched" {
t.Fatalf("migrated account = %#v", accounts[0])
}
}
func TestAccountVisibilitySettings(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.DB.Close()
private, err := s.CreateAccount("私有账号")
if err != nil {
t.Fatal(err)
}
public, err := s.CreateAccountWithVisibility("公开账号", "team", true)
if err != nil {
t.Fatal(err)
}
if private.PublicVisible || !public.PublicVisible {
t.Fatalf("created accounts = %#v, %#v", private, public)
}
if err := s.UpdateAccountSettings(public.ID, "公开账号重命名", "team"); err != nil {
t.Fatal(err)
}
unchanged, err := s.Account(public.ID)
if err != nil {
t.Fatal(err)
}
if !unchanged.PublicVisible {
t.Fatal("legacy settings update unexpectedly changed public visibility")
}
visible := false
if err := s.UpdateAccountSettingsWithVisibility(public.ID, "公开账号重命名", "team", &visible); err != nil {
t.Fatal(err)
}
updated, err := s.Account(public.ID)
if err != nil {
t.Fatal(err)
}
if updated.PublicVisible {
t.Fatal("explicit false visibility update was not persisted")
}
}
func ptr(value string) *string { return &value }
func TestLegacyUsageMigratesToDefaultAccount(t *testing.T) {
@@ -22,4 +22,6 @@ Codex OAuth 凭据由 app-server 写入各账号隔离的 `CODEX_HOME`,不经
## HTTP 边界
初始化完成后,标记为公开的账号列表和 Dashboard 以匿名只读方式开放,供公开总览加载;未标记账号对匿名请求不可见。匿名响应不返回邮箱、Codex 认证方式、账号配置校验字段或内部错误。新增账号、设备码登录、同步、删除、公开状态修改、提醒和所有设置接口仍必须经过 `require` 的 session 与来源校验。
JSON 解码限制为 1 MiB 并拒绝未知字段。统一安全头包括限制性 CSP、`nosniff`、禁止 iframe 和 same-origin referrer。前端路由、按钮禁用和邮箱掩码均不是服务端授权边界;所有新敏感端点必须在后端经过 `require`,改变 API 方法时还要核对来源头逻辑。
+6 -2
View File
@@ -23,13 +23,17 @@ account/login/start {type:"chatgptDeviceCode"}
一次同步依次读取 `account/read`、`account/rateLimits/read` 和 `account/usage/read`:
- `account/read` 决定连接、邮箱、认证模式和套餐;读取失败会使整次同步失败。
- 限额读取兼容 `rateLimitsByLimitId` 多 bucket 和旧 `rateLimits` 单 bucket,再把 primary/secondary 展平。失败时保留空限额而不令整次同步失败。
- 限额读取兼容 `rateLimitsByLimitId` 多 bucket 和旧 `rateLimits` 单 bucket,再把 primary/secondary 展平;同时读取 `rateLimitResetCredits`,只在有可用卡时保留数量和到期时间。失败时保留空限额而不令整次同步失败。
- 套餐未知时,可从所有可分类且一致的限额 bucket 回填;冲突或未知时必须保持 unknown。
- 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。
- 用量读取保存 summary 和每日 Token bucket;接口失败时使用空摘要和空历史,不令整次同步失败。同步成功后,以当前仍有效的最长限额窗口作为当前重置周期,按每日 bucket 汇总周期起点至当前日期的 Token,写入 Dashboard 的 `currentCycle`;周期外和未来日期不会计入。
- 每次限额同步写入快照、检测用量百分比显著回落,并更新账号元数据及内存 Dashboard。
`account/usage/read` 的 optional 指标和 daily buckets 可能暂未提供。仅 API Key 或 Bedrock 登录不能保证读取 ChatGPT 用量;不得在缺失数据时合成调用次数、输入/输出 Token、价格或账单日期。
重置卡来自 `account/rateLimits/read` 的 `rateLimitResetCredits`。该信息只保存在内存 Dashboard 中,下一次同步会重新读取;没有可用卡时不返回到 Dashboard。当前项目只展示数量和到期时间,不执行消耗操作。
`currentCycle` 的周期长度和重置时间来自 `account/rateLimits/read`,不硬编码为七天;通常会选择 secondary 周窗口。由于 daily bucket 只有日期没有每次请求时间,周期边界所在日期按整日汇总,因此该值是当前周期的日级统计,不是 Credits 或美元估值。
## 套餐类型
用户期望类型仅为连接后的校验提示:`any`、`personal`、`team`。当前 personal 包括 free/go/plus/pro/prolite;team 包括 team/business 及两种 self-serve business 标识。未知新套餐必须显示 unknown,不能静默当作某一类型。相同邮箱和相同已知实际类型的多个账号标记 `possibleDuplicate`,但不会自动合并或删除。
@@ -6,7 +6,7 @@
- `settings` 保存通用、SMTP、Telegram、绑定码及安装标记;秘密单独以密文 key 保存。
- `admin` 与 `sessions` 保存唯一管理员和登录会话。
- `accounts` 保存 Codex 连接元数据与期望套餐类型。
- `accounts` 保存 Codex 连接元数据、期望套餐类型和 `public_visible`;该字段默认 `0`,旧账号迁移后保持私有,只有管理员明确开启后才进入匿名总览。
- `daily_usage` 和 `limit_snapshots` 按 `account_id` 保存历史,删除账号时级联删除。
- `notifications` 保存稳定去重键、调度时间、结构化消息、状态、次数和错误。
- `telegram_updates` 保存 Bot API offset。
+3 -3
View File
@@ -2,7 +2,7 @@
## 启动与关闭
入口为 `backend/cmd/server/main.go`。普通启动调用 `app.New`:打开 `/data/codex-helper.db` 并执行兼容迁移、打开或创建 `/data/secret.key`、为数据库中的每个账号创建运行时对象,再组装 HTTP server。`Run` 启动 app-server 保活、每分钟调度器和 Telegram long polling;SIGINT/SIGTERM 触发五秒 HTTP 优雅关闭、停止所有账号进程并关闭数据库。
入口为 `backend/cmd/server/main.go`。普通启动调用 `app.New`:打开 `/data/codex-helper.db` 并执行兼容迁移、打开或创建 `/data/secret.key`、为数据库中的每个账号创建运行时对象,再组装 HTTP server。`Run` 启动 app-server 保活、后台调度器和 Telegram long polling;SIGINT/SIGTERM 触发五秒 HTTP 优雅关闭、停止所有账号进程并关闭数据库。
`codex-helper healthcheck` 将 `${LISTEN_ADDR:-:8080}` 的通配地址转换为 `127.0.0.1` 并请求 `/health/live`,供 Docker `HEALTHCHECK` 使用。
@@ -14,12 +14,12 @@
- `internal/store/store.go`:SQLite schema、兼容迁移和数据方法。
- `internal/codex/client.go`:与 `codex app-server` 的 JSONL 请求/响应关联。
所有路由由 `http.ServeMux` 承载。API 先处理三个匿名入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。
所有路由由 `http.ServeMux` 承载。API 先处理 status、setup、login 和初始化后的账号/Dashboard 匿名只读入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。
## 后台任务
- `keepCodex` 每秒检查未就绪的账号,串行完成进程启动与协议初始化,成功后立即同步。
- `scheduler` 每分钟按 `syncMinutes` 的 Unix 时间取模触发全账号同步,清理过期历史,并异步处理提醒。
- `scheduler` 每五分钟固定触发全账号同步;每分钟清理过期历史并异步处理提醒。通用设置中的 `syncMinutes` 仅为旧客户端兼容字段,不改变固定调度周期。
- `telegramLoop` 使用 Bot API long polling;仅已配置 Token 才请求更新。
- app-server 的登录、账号和限额通知会触发带短退避的同步;多次失败将内存 Dashboard 标为 stale 并记录 `lastError`。
+3 -3
View File
@@ -4,9 +4,9 @@
## 状态与路由
应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。
应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;初始化后未登录渲染公开只读 `/` 总览和 `/login` 登录页,登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未登录访问 `/settings` 回到公开总览,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页、公开总览和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。
主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后刷新;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。总览默认只展示账号摘要,展开卡片后显示完整限额、统计和 Token 图;顶部“刷新全部”和卡片内的账号级刷新都会先调用对应的 sync,再重新读取 Dashboard。
主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后重新读取;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。公开总览使用匿名只读接口并隐藏身份配置字段,隐藏手动刷新按钮和设置导航;服务器固定每 5 分钟同步全部账号,登录后总览默认只展示账号摘要。折叠摘要分别显示小时额度和周额度的剩余百分比及各自重置倒计时;周额度固定取 Codex 的 168 小时窗口,`gpt-reserve` 等同周期的命名附加额度不得替代它;仅有周额度时摘要居中显示该窗口。展开卡片后显示完整限额、统计和 Token 图。
桌面端使用固定侧栏,`800px` 及以下改为紧凑顶栏和固定底部主导航;GitHub、主题和退出位于顶栏辅助菜单。移动布局最低支持 320px,使用动态视口高度和 CSS safe-area 环境变量避开 iOS 浏览器工具栏与设备安全区。内容必须为底部导航保留空间,表单控件避免 iOS 聚焦缩放,主要交互保持至少 44px 触控区域。
@@ -18,7 +18,7 @@ API 类型精确区分后端 `null` 与 optional,并为秘密设置拆分读
## 总览与设置
总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、四项摘要及每日 Token 图。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。
总览显示账户连接、套餐、每个限额窗口的剩余百分比和重置时间、当前重置周期 Token 合计、本周期单日峰值、其他摘要及每日 Token 图;账号有可用重置卡时,详情额外显示卡片数量和到期时间,公开账号的匿名总览也会显示。当前周期 Token 与本周期单日峰值都由后端按 app-server 的有效最长限额窗口和每日 bucket 汇总,不包含周期外或未来日期;详情会显示周期起止时间和“按每日数据汇总”提示。`usedPercent` 展示前限制到 0–100,但原始数据语义不得在 API 类型层改写。缺失摘要显示“暂无”,缺失限额显示明确空状态。匿名公开 Dashboard 仍隐藏邮箱、认证方式和内部错误。
设置页四个 tab 全部保持挂载,以保留未提交表单状态;非活动 panel 使用 `aria-hidden` 和 `inert` 隔离。tab 支持方向键、Home 和 End,程序化切换不得改变页面滚动和布局。账号删除必须保留不可撤销确认,并清理正在显示的设备码和本地账号选择。
+1 -1
View File
@@ -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` 子命令。
+1 -1
View File
@@ -4,7 +4,7 @@
## API 与认证
- [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 是路径、状态码、响应字段和兼容文案的契约。匿名入口只能是当前 status、setup 和 login。
- [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 是路径、状态码、响应字段和兼容文案的契约。匿名入口包括 status、setup、login,以及初始化完成后已标记公开账号的列表和 Dashboard 只读总览;新增或修改配置、账号、同步和凭据接口仍必须要求 session。
- 所有受保护端点必须回查 session;非只读请求还必须验证 `X-Requested-With`。前端路由与按钮不能代替后端门禁。
- session 原 token 只进入 cookie,SQLite 只保存摘要;密码保持 argon2id。错误和日志不得包含密码、cookie、Bot Token、SMTP 密码或 Codex token。
- 初始化是事务性单管理员创建。新增自动初始化能力前必须保留并发与首次公网暴露的安全边界。
+201 -154
View File
@@ -2,6 +2,7 @@ import React, { lazy, Suspense, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
BrowserRouter,
Link,
Navigate,
Route,
Routes,
@@ -11,23 +12,21 @@ import {
import {
Activity,
Bell,
Check,
ChevronDown,
Clock,
Coins,
Flame,
Github,
Gauge,
LogIn,
Mail,
LogOut,
Moon,
MoreHorizontal,
RefreshCw,
Settings,
Sun,
Ticket,
Trash2,
TrendingUp,
Timer,
Zap,
Wifi,
WifiOff,
@@ -83,7 +82,13 @@ function App() {
{authenticated ? (
<Route path="*" element={<Shell version={status.version} />} />
) : (
<Route path="*" element={<Login version={status.version} />} />
<>
<Route path="/login" element={<Login version={status.version} />} />
<Route
path="*"
element={<PublicShell version={status.version} />}
/>
</>
)}
</Routes>
</BrowserRouter>
@@ -206,10 +211,32 @@ function Login({ version }: { version: string }) {
</label>
{e && <p className="error">{e}</p>}
<button>登录</button>
<p className="hint public-login-link">
<Link to="/">查看公开总览</Link>
</p>
</form>
</main>
);
}
function PublicShell({ version }: { version: string }) {
const nav = useNavigate();
return (
<div className="public-app">
<header className="public-header">
<Brand version={version} />
<button className="secondary" onClick={() => nav("/login")}>
<LogIn /> 登录后配置
</button>
</header>
<main className="public-content">
<Routes>
<Route path="/" element={<Dashboard publicView />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
);
}
function Shell({ version }: { version: string }) {
const { logout } = useAuth();
const nav = useNavigate();
@@ -348,22 +375,19 @@ type DashboardCardState = {
dashboard: Dash | null;
loading: boolean;
error: string;
refresh: "idle" | "loading" | "done";
};
const emptyDashboardCardState = (): DashboardCardState => ({
dashboard: null,
loading: false,
error: "",
refresh: "idle",
});
function Dashboard() {
function Dashboard({ publicView = false }: { publicView?: boolean }) {
const [accounts, setAccounts] = useState<Account[] | null>(null),
[cards, setCards] = useState<Record<number, DashboardCardState>>({}),
[expanded, setExpanded] = useState<Record<number, boolean>>({}),
[error, setError] = useState(""),
[refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle");
[error, setError] = useState("");
const requestRef = useRef(new Map<number, number>()),
activeControllersRef = useRef(new Map<number, AbortController>());
@@ -380,11 +404,7 @@ function Dashboard() {
}));
};
const loadDashboard = async (
accountId: number,
signal?: AbortSignal,
manual = false,
) => {
const loadDashboard = async (accountId: number, signal?: AbortSignal) => {
const request = (requestRef.current.get(accountId) || 0) + 1;
requestRef.current.set(accountId, request);
let ownController: AbortController | undefined;
@@ -397,7 +417,6 @@ function Dashboard() {
updateCard(accountId, {
loading: true,
error: "",
...(manual ? { refresh: "loading" as const } : {}),
});
try {
const dashboard = await get(
@@ -414,26 +433,13 @@ function Dashboard() {
dashboard,
loading: false,
error: "",
...(manual ? { refresh: "done" as const } : {}),
});
if (manual) {
window.setTimeout(
() =>
setCards((current) => {
const card = current[accountId];
if (!card || card.refresh !== "done") return current;
return { ...current, [accountId]: { ...card, refresh: "idle" } };
}),
2000,
);
}
} catch (value) {
if (signal.aborted || requestRef.current.get(accountId) !== request)
return;
updateCard(accountId, {
loading: false,
error: toErrorMessage(value),
...(manual ? { refresh: "idle" as const } : {}),
});
} finally {
if (
@@ -486,42 +492,6 @@ function Dashboard() {
[],
);
const syncAccount = async (accountId: number) => {
try {
setError("");
await post(`accounts/${accountId}/sync`, decodeOK, {}, undefined, 60_000);
await loadDashboard(accountId, undefined, true);
} catch (value) {
updateCard(accountId, {
loading: false,
refresh: "idle",
error: toErrorMessage(value),
});
}
};
const syncAll = async () => {
if (!accounts?.length) return;
setRefresh("loading");
setError("");
const results = await Promise.allSettled(
accounts.map(async (account) => {
await post(
`accounts/${account.id}/sync`,
decodeOK,
{},
undefined,
60_000,
);
await loadDashboard(account.id, undefined, true);
}),
);
const failed = results.filter((result) => result.status === "rejected");
if (failed.length)
setError(`${failed.length} 个账号刷新失败,请展开对应卡片查看详情。`);
setRefresh(failed.length ? "idle" : "done");
if (!failed.length) window.setTimeout(() => setRefresh("idle"), 2000);
};
if (accounts === null)
return (
<>
@@ -539,7 +509,12 @@ function Dashboard() {
if (!accounts.length)
return (
<>
<Header title="用量总览" sub="请先在设置中心添加账号" />
<Header
title="用量总览"
sub={
publicView ? "当前还没有可公开展示的账号" : "请先在设置中心添加账号"
}
/>
<div className="panel empty">尚未添加 Codex 账号</div>
</>
);
@@ -547,21 +522,8 @@ function Dashboard() {
<>
<Header
title="用量总览"
sub={`共 ${accounts.length} 个账号,点击卡片查看完整详情`}
>
<button
className={"secondary refresh " + refresh}
disabled={refresh === "loading"}
onClick={() => void syncAll()}
>
{refresh === "done" ? <Check /> : <RefreshCw />}
{refresh === "loading"
? "刷新中"
: refresh === "done"
? "刷新完成"
: "刷新全部"}
</button>
</Header>
sub={`${publicView ? "公开只读 · " : ""}共 ${accounts.length} 个账号,服务器每 5 分钟自动同步`}
/>
{error && <div className="banner">{error}</div>}
<div className="account-overview-list">
{accounts.map((account) => (
@@ -569,6 +531,7 @@ function Dashboard() {
key={account.id}
account={account}
state={cards[account.id] || emptyDashboardCardState()}
publicView={publicView}
expanded={Boolean(expanded[account.id])}
onToggle={() =>
setExpanded((current) => ({
@@ -576,7 +539,6 @@ function Dashboard() {
[account.id]: !current[account.id],
}))
}
onRefresh={() => void syncAccount(account.id)}
/>
))}
</div>
@@ -587,15 +549,15 @@ function Dashboard() {
function AccountOverviewCard({
account,
state,
publicView,
expanded,
onToggle,
onRefresh,
}: {
account: Account;
state: DashboardCardState;
publicView: boolean;
expanded: boolean;
onToggle: () => void;
onRefresh: () => void;
}) {
const dashboard = state.dashboard;
const email = dashboard?.account.email ?? account.email;
@@ -619,7 +581,7 @@ function AccountOverviewCard({
<span className="account-summary-identity">
<strong>{title}</strong>
<span>
{email ? maskEmail(email) : "尚未登录"} ·{" "}
{publicView ? "公开只读" : email ? maskEmail(email) : "尚未登录"} ·{" "}
{connected ? "已连接" : "未连接"}
</span>
</span>
@@ -637,7 +599,7 @@ function AccountOverviewCard({
<AccountDashboardDetails
dashboard={dashboard}
state={state}
onRefresh={onRefresh}
publicView={publicView}
/>
) : (
<div className="account-detail-state">
@@ -645,11 +607,6 @@ function AccountOverviewCard({
<p className={state.error ? "error" : ""}>
{state.error || "正在读取账号用量…"}
</p>
{state.error && (
<button className="secondary" onClick={onRefresh}>
<RefreshCw /> 重试
</button>
)}
</div>
)}
</div>
@@ -675,19 +632,44 @@ function AccountQuickSummary({
: "等待数据"}
</span>
);
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 <span className="account-summary-metrics">限额暂无</span>;
return (
<span className="account-summary-metrics">
<span>
{remaining == null ? "限额暂无" : `最低 ${Math.round(remaining)}% 剩余`}
<span
className={`account-summary-metrics account-summary-limits${limits.length === 1 ? " single" : ""}`}
>
{limits.map(({ kind, limit }) => {
const remaining = Math.round(remainingPercent(limit));
return (
<span
className="account-summary-limit"
data-window-kind={kind}
key={`${kind}-${limit.limitId}-${limit.windowType}`}
aria-label={`${quickLimitLabel(kind, limit)},剩余 ${remaining}%,${limit.resetsAt ? `${relative(limit.resetsAt)}重置` : "重置时间暂无"}`}
>
<span className="account-summary-limit-name">
{quickLimitLabel(kind, limit)}
</span>
<span>{reset ? `最近重置 ${relative(reset)}` : "暂无重置时间"}</span>
<strong style={{ color: `hsl(${remaining * 1.2} 70% 45%)` }}>
{remaining}%
</strong>
<span className="account-summary-limit-remaining">剩余</span>
<span
className="account-summary-limit-reset"
title={
limit.resetsAt
? new Date(limit.resetsAt * 1000).toLocaleString()
: undefined
}
>
{limit.resetsAt
? `${relative(limit.resetsAt)}重置`
: "重置时间暂无"}
</span>
</span>
);
})}
</span>
);
}
@@ -695,11 +677,11 @@ function AccountQuickSummary({
function AccountDashboardDetails({
dashboard,
state,
onRefresh,
publicView,
}: {
dashboard: Dash;
state: DashboardCardState;
onRefresh: () => void;
publicView: boolean;
}) {
return (
<>
@@ -718,18 +700,6 @@ function AccountDashboardDetails({
? new Date(dashboard.fetchedAt * 1000).toLocaleString()
: "尚未同步"}
</span>
<button
className={`secondary refresh ${state.refresh}`}
disabled={state.refresh === "loading"}
onClick={onRefresh}
>
{state.refresh === "done" ? <Check /> : <RefreshCw />}
{state.refresh === "loading"
? "刷新中"
: state.refresh === "done"
? "刷新完成"
: "刷新账号"}
</button>
</div>
</div>
{(state.error || dashboard.lastError) && (
@@ -742,7 +712,9 @@ function AccountDashboardDetails({
icon={<Mail />}
label="登录邮箱"
value={
dashboard.account.email
publicView
? "登录后查看"
: dashboard.account.email
? maskEmail(dashboard.account.email)
: "尚未连接"
}
@@ -763,6 +735,19 @@ function AccountDashboardDetails({
value={dashboard.stale ? "数据可能已过期" : "数据已更新"}
/>
</div>
{dashboard.resetCredits && dashboard.resetCredits.availableCount > 0 && (
<div className="reset-credits" data-testid="reset-credits">
<Ticket />
<div className="reset-credits-main">
<small>重置卡</small>
<b>可用 {dashboard.resetCredits.availableCount} 张</b>
</div>
<div className="reset-credits-expiry">
<small>到期时间</small>
<b>{formatResetCreditExpiry(dashboard.resetCredits.expiresAt)}</b>
</div>
</div>
)}
<div className="limits">
{dashboard.limits.map((limit, index) => (
<LimitCard
@@ -773,34 +758,33 @@ function AccountDashboardDetails({
</div>
{!dashboard.limits.length && (
<div className="panel empty">
该连接暂无限额数据,请确认登录后刷新。
{publicView
? "该连接暂无限额数据"
: "该连接暂无限额数据,服务器会自动同步。"}
</div>
)}
{dashboard.currentCycle && (
<div className="cycle-note">
<Clock />
<span>
本周期:{cycleTimestamp(dashboard.currentCycle.startedAt)} 至{" "}
{cycleTimestamp(dashboard.currentCycle.resetsAt)}(
{cycleDuration(dashboard.currentCycle.windowDurationMinutes)}
,按每日数据汇总)
</span>
</div>
)}
<div className="stats">
<Stat
icon={<Coins />}
label="累计 Tokens"
v={num(dashboard.summary.lifetimeTokens)}
label="本周期 Tokens"
v={num(dashboard.currentCycle?.totalTokens)}
/>
<Stat
icon={<TrendingUp />}
label="单日峰值"
label="本周期单日峰值"
v={num(dashboard.summary.peakDailyTokens)}
/>
<Stat
icon={<Flame />}
label="连续使用"
v={
dashboard.summary.currentStreakDays == null
? "暂无"
: dashboard.summary.currentStreakDays + " 天"
}
/>
<Stat
icon={<Timer />}
label="最长任务时长"
v={duration(dashboard.summary.longestRunningTurnSec)}
/>
</div>
<Suspense fallback={<SettingsLoading />}>
<UsageChart data={dashboard.usage} accountId={dashboard.accountId} />
@@ -1052,16 +1036,10 @@ function General() {
<option value="light">浅色</option>
</select>
</label>
<label>
同步间隔(分钟)
<input
type="number"
min="1"
max="60"
value={v.syncMinutes}
onChange={(e) => setV({ ...v, syncMinutes: +e.target.value })}
/>
</label>
<div className="fixed-setting">
<span>自动同步</span>
<b>服务器每 5 分钟自动刷新所有账号</b>
</div>
<label>
历史保留(天)
<select
@@ -1108,6 +1086,7 @@ function CodexSettings() {
[deviceLogin, setDeviceLogin] = useState<DeviceLogin | null>(null),
[active, setActive] = useState(0),
[newKind, setNewKind] = useState<"personal" | "team">("team"),
[newPublicVisible, setNewPublicVisible] = useState(false),
[busy, setBusy] = useState(false),
[err, setErr] = useState("");
const load = async (signal?: AbortSignal) => {
@@ -1179,6 +1158,7 @@ function CodexSettings() {
const x = await post("accounts", (value) => decodeAccounts([value])[0], {
displayName: `账号 ${xs.length + 1}`,
expectedKind: newKind,
publicVisible: newPublicVisible,
});
await load();
setActive(x.id);
@@ -1222,6 +1202,14 @@ function CodexSettings() {
<option value="personal">个人订阅</option>
</select>
</label>
<label className="checks">
<input
type="checkbox"
checked={newPublicVisible}
onChange={(e) => setNewPublicVisible(e.target.checked)}
/>
公开显示到未登录总览
</label>
<button disabled={busy} onClick={add}>
{busy && active === 0 ? "服务启动中…" : "添加账号"}
</button>
@@ -1247,6 +1235,7 @@ function CodexSettings() {
await put(`accounts/${x.id}`, decodeOK, {
displayName: name,
expectedKind: x.expectedKind,
publicVisible: x.publicVisible,
});
void load().catch((error) =>
setErr(toErrorMessage(error)),
@@ -1280,6 +1269,7 @@ function CodexSettings() {
await put(`accounts/${x.id}`, decodeOK, {
displayName: x.displayName,
expectedKind: e.target.value,
publicVisible: x.publicVisible,
});
void load().catch((error) => setErr(toErrorMessage(error)));
}}
@@ -1289,6 +1279,26 @@ function CodexSettings() {
<option value="team">Team / Business</option>
</select>
</label>
<label className="checks">
<input
type="checkbox"
checked={x.publicVisible}
onChange={async (e) => {
try {
setErr("");
await put(`accounts/${x.id}`, decodeOK, {
displayName: x.displayName,
expectedKind: x.expectedKind,
publicVisible: e.target.checked,
});
await load();
} catch (q) {
setErr(toErrorMessage(q));
}
}}
/>
公开显示到未登录总览
</label>
<div className="account-buttons">
<button
className="secondary"
@@ -1664,20 +1674,57 @@ function Header({
const num = (v?: number | null) =>
v == null
? "暂无"
: new Intl.NumberFormat("zh-CN", {
: new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
}).format(v);
const duration = (v?: number | null) =>
v == null
? "暂无"
: v < 60
? `${v} 秒`
: v < 3600
? `${Math.floor(v / 60)} 分 ${v % 60} 秒`
: `${(v / 3600).toFixed(1)} 小时`;
const cycleTimestamp = (value: number) =>
new Date(value * 1000).toLocaleString();
const formatResetCreditExpiry = (values: number[]) =>
values.length ? values.map(cycleTimestamp).join("、") : "服务端未提供";
const cycleDuration = (minutes: number) =>
minutes % (24 * 60) === 0
? `${minutes / (24 * 60)} 天窗口`
: minutes % 60 === 0
? `${minutes / 60} 小时窗口`
: `${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.limitId.toLowerCase() === "codex" &&
limit.windowDurationMinutes === 168 * 60,
) ??
sorted.find(
(limit) =>
limit.limitName == null && limit.windowDurationMinutes === 168 * 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) {
+853 -161
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -24,6 +24,45 @@ describe("API decoders", () => {
expect(value.account.email).toBeNull();
});
it("解码当前重置周期 Token 合计", () => {
const value = decodeDashboard({
accountId: 1,
displayName: "个人账号",
account: { email: "user@example.com", planType: "plus", connected: true },
limits: [],
currentCycle: {
limitId: "codex",
windowType: "secondary",
windowDurationMinutes: 10080,
startedAt: 1_900_000_000,
resetsAt: 1_900_604_800,
totalTokens: 123_456,
},
summary: {},
usage: [],
fetchedAt: 1_900_000_000,
stale: false,
});
expect(value.currentCycle?.windowType).toBe("secondary");
expect(value.currentCycle?.totalTokens).toBe(123_456);
});
it("解码可用重置卡及到期时间", () => {
const value = decodeDashboard({
accountId: 1,
displayName: "个人账号",
account: { email: null, planType: "plus", connected: true },
limits: [],
resetCredits: { availableCount: 2, expiresAt: [1_784_246_400] },
summary: {},
usage: [],
fetchedAt: 1_900_000_000,
stale: false,
});
expect(value.resetCredits?.availableCount).toBe(2);
expect(value.resetCredits?.expiresAt).toEqual([1_784_246_400]);
});
it("拒绝未知主题", () => {
expect(() =>
decodeGeneral({
+44
View File
@@ -26,6 +26,7 @@ export interface Account {
email: string | null;
planType: string | null;
expectedKind: ExpectedKind;
publicVisible: boolean;
actualKind: "unknown" | "personal" | "team";
validationStatus: ValidationStatus;
possibleDuplicate: boolean;
@@ -48,6 +49,18 @@ export interface Point {
date: string;
totalTokens: number;
}
export interface TokenCycle {
limitId: string;
windowType: string;
windowDurationMinutes: number;
startedAt: number;
resetsAt: number;
totalTokens: number;
}
export interface ResetCreditsSummary {
availableCount: number;
expiresAt: number[];
}
export interface Dashboard {
accountId: number;
displayName: string;
@@ -57,6 +70,7 @@ export interface Dashboard {
connected: boolean;
};
limits: Limit[];
currentCycle?: TokenCycle;
summary: {
lifetimeTokens?: number | null;
peakDailyTokens?: number | null;
@@ -66,6 +80,7 @@ export interface Dashboard {
usage: Point[];
fetchedAt: number;
stale: boolean;
resetCredits?: ResetCreditsSummary;
lastError?: string;
}
export interface GeneralSettings {
@@ -135,6 +150,7 @@ export const decodeAccount: Decoder<Account> = (value) => {
["any", "personal", "team"],
"expectedKind",
),
publicVisible: boolean(x.publicVisible, "publicVisible"),
actualKind: enumValue(
x.actualKind,
["unknown", "personal", "team"],
@@ -153,6 +169,28 @@ export const decodeAccounts: Decoder<Account[]> = (value) => {
if (!Array.isArray(value)) throw new Error("账号列表格式无效");
return value.map(decodeAccount);
};
const decodeTokenCycle: Decoder<TokenCycle> = (value) => {
const x = record(value, "currentCycle");
return {
limitId: string(x.limitId, "limitId"),
windowType: string(x.windowType, "windowType"),
windowDurationMinutes: number(
x.windowDurationMinutes,
"windowDurationMinutes",
),
startedAt: number(x.startedAt, "startedAt"),
resetsAt: number(x.resetsAt, "resetsAt"),
totalTokens: number(x.totalTokens, "totalTokens"),
};
};
const decodeResetCredits: Decoder<ResetCreditsSummary> = (value) => {
const x = record(value, "resetCredits");
if (!Array.isArray(x.expiresAt)) throw new Error("expiresAt格式无效");
return {
availableCount: number(x.availableCount, "availableCount"),
expiresAt: x.expiresAt.map((v, index) => number(v, `expiresAt[${index}]`)),
};
};
export const decodeDashboard: Decoder<Dashboard> = (value) => {
const x = record(value, "Dashboard"),
account = record(x.account, "account"),
@@ -181,6 +219,12 @@ export const decodeDashboard: Decoder<Dashboard> = (value) => {
resetsAt: number(l.resetsAt, "resetsAt"),
};
}),
...(x.currentCycle == null
? {}
: { currentCycle: decodeTokenCycle(x.currentCycle) }),
...(x.resetCredits == null
? {}
: { resetCredits: decodeResetCredits(x.resetCredits) }),
summary: {
lifetimeTokens: optionalNumber(summary.lifetimeTokens, "lifetimeTokens"),
peakDailyTokens: optionalNumber(
+32 -5
View File
@@ -48,14 +48,21 @@ export default function UsageChart({
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#5ee7f7" stopOpacity={0.35} />
<stop offset="1" stopColor="#5ee7f7" stopOpacity={0} />
<stop offset="0%" stopColor="#3b82a6" stopOpacity={0.35} />
<stop offset="70%" stopColor="#6b61a6" stopOpacity={0.08} />
<stop offset="100%" stopColor="#6b61a6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--grid)" vertical={false} />
<CartesianGrid
stroke="var(--grid)"
strokeDasharray="3 3"
vertical={false}
/>
<XAxis
dataKey="date"
stroke="var(--muted)"
tickLine={false}
axisLine={{ stroke: "var(--border)" }}
tickFormatter={
compact ? (date: string) => date.slice(5) : undefined
}
@@ -66,6 +73,8 @@ export default function UsageChart({
tickCount={compact ? 4 : 5}
tickFormatter={compactAxis}
stroke="var(--muted)"
tickLine={false}
axisLine={false}
/>
<Tooltip
formatter={(value) => [
@@ -75,15 +84,33 @@ export default function UsageChart({
contentStyle={{
background: "var(--panel)",
border: "1px solid var(--border)",
borderRadius: 12,
borderRadius: 8,
boxShadow: "var(--shadow-md)",
padding: "8px 12px",
color: "var(--text)",
}}
labelStyle={{
fontWeight: 600,
color: "var(--muted)",
marginBottom: 4,
}}
itemStyle={{
color: "var(--accent)",
fontWeight: 650,
}}
/>
<Area
type="monotone"
dataKey="totalTokens"
stroke="#4dd8ed"
stroke="#3b82a6"
fill={`url(#${gradientId})`}
strokeWidth={2}
activeDot={{
r: 5,
fill: "#3b82a6",
stroke: "var(--panel)",
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>
+202 -10
View File
@@ -23,6 +23,7 @@ const responses: Record<string, unknown> = {
email: "test@example.com",
planType: "plus",
expectedKind: "personal",
publicVisible: false,
actualKind: "personal",
validationStatus: "matched",
possibleDuplicate: false,
@@ -82,6 +83,14 @@ test("shows every account as a collapsible summary on the overview", async ({
connected: true,
},
limits: [
{
limitId: "base_model_inference",
limitName: "gpt-reserve",
windowType: "primary",
usedPercent: 0,
windowDurationMinutes: 10_080,
resetsAt: 1_900_700_000,
},
{
limitId: "primary",
limitName: "5 小时限额",
@@ -90,13 +99,33 @@ test("shows every account as a collapsible summary on the overview", async ({
windowDurationMinutes: 300,
resetsAt: 1_900_000_000,
},
{
limitId: "codex",
limitName: null,
windowType: "secondary",
usedPercent: 20,
windowDurationMinutes: 10_080,
resetsAt: 1_900_604_800,
},
],
currentCycle: {
limitId: "codex",
windowType: "secondary",
windowDurationMinutes: 10_080,
startedAt: 1_899_999_000,
resetsAt: 1_900_604_800,
totalTokens: 123_456,
},
summary: {
lifetimeTokens: 12_345,
peakDailyTokens: 3_000,
currentStreakDays: 4,
longestRunningTurnSec: 80,
},
resetCredits: {
availableCount: 2,
expiresAt: [1_784_246_400],
},
usage: [{ date: "2026-08-13", totalTokens: 3_000 }],
fetchedAt: 1_900_000_000,
stale: false,
@@ -109,7 +138,16 @@ test("shows every account as a collapsible summary on the overview", async ({
planType: "team",
connected: true,
},
limits: [],
limits: [
{
limitId: "codex",
limitName: null,
windowType: "secondary",
usedPercent: 40,
windowDurationMinutes: 10_080,
resetsAt: 1_900_604_800,
},
],
summary: {},
usage: [],
fetchedAt: 0,
@@ -150,8 +188,31 @@ 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("限额暂无")).toBeVisible();
const firstSummary = page
.locator('[data-account-id="1"]')
.locator(".account-summary-limits");
await expect(firstSummary.locator('[data-window-kind="hour"]')).toContainText(
"5 小时额度65%剩余",
);
const weeklySummary = firstSummary.locator('[data-window-kind="week"]');
await expect(weeklySummary).toContainText("周额度80%剩余");
await expect(weeklySummary).not.toContainText("100%剩余");
await expect(weeklySummary).toHaveAttribute("aria-label", /周额度,剩余 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);
@@ -162,10 +223,77 @@ test("shows every account as a collapsible summary on the overview", async ({
await expect(
first.getByRole("heading", { name: "每日 Token 趋势" }),
).toBeVisible();
await expect(first.getByText("本周期 Tokens")).toBeVisible();
await expect(first.getByText("本周期单日峰值")).toBeVisible();
await expect(first.getByTestId("reset-credits")).toContainText("重置卡");
await expect(first.getByTestId("reset-credits")).toContainText("可用 2 张");
await expect(first.getByTestId("reset-credits")).toContainText("到期时间");
await expect(first.getByText("连续使用", { exact: true })).toHaveCount(0);
await expect(first.getByText("最长任务时长", { exact: true })).toHaveCount(0);
await expect(first.getByText("123.5K", { exact: true })).toBeVisible();
await expect(first.getByText("按每日数据汇总")).toBeVisible();
await expect(page.locator(".account-detail")).toHaveCount(1);
await first.getByRole("button", { name: "收起个人账号详情" }).click();
await expect(page.locator(".account-detail")).toHaveCount(0);
const second = page.locator('[data-account-id="2"]');
await second.getByRole("button", { name: "展开Team 工作区详情" }).click();
await expect(second.getByTestId("reset-credits")).toHaveCount(0);
});
test("shows a read-only overview before login", async ({ page }) => {
await page.route("**/api/v1/**", async (route) => {
const key = new URL(route.request().url()).pathname.replace("/api/v1/", "");
if (key === "system/status")
return route.fulfill({
json: { initialized: true, appServer: true, version: "test" },
});
if (key === "auth/me")
return route.fulfill({ status: 401, json: { error: "未登录" } });
if (key === "accounts") return route.fulfill({ json: responses.accounts });
if (key === "dashboard")
return route.fulfill({
json: {
accountId: 1,
displayName: "默认账号",
account: {
email: null,
planType: "plus",
connected: true,
},
limits: [],
resetCredits: {
availableCount: 2,
expiresAt: [1_784_246_400],
},
summary: {},
usage: [],
fetchedAt: 1_900_000_000,
stale: false,
},
});
return route.fulfill({ json: responses[key] ?? {} });
});
await page.goto("/");
await expect(page.getByRole("button", { name: "登录后配置" })).toBeVisible();
await expect(page.locator(".account-overview")).toHaveCount(1);
await expect(page.getByRole("button", { name: "刷新全部" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "设置中心" })).toHaveCount(0);
const account = page.locator('[data-account-id="1"]');
await account.getByRole("button", { name: "展开默认账号详情" }).click();
await expect(account.getByText("登录后查看")).toBeVisible();
await expect(account.getByTestId("reset-credits")).toContainText("可用 2 张");
await expect(account.getByTestId("reset-credits")).toContainText("到期时间");
await expect(account.locator(".account-detail .refresh")).toHaveCount(0);
await page.goto("/settings");
await expect(page).toHaveURL(/\/$/);
await page.getByRole("button", { name: "登录后配置" }).click();
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
});
test("shows the build version in the authenticated brand", async ({ page }) => {
@@ -204,14 +332,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) =>
@@ -248,7 +376,7 @@ test("shows the build version on login", async ({ page }) => {
await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({ status: 401, json: { error: "unauthorized" } }),
);
await page.goto("/");
await page.goto("/login");
const badge = page.locator(".login .version-badge");
await expect(badge).toBeVisible();
await expect(badge).toHaveText("vtest");
@@ -558,6 +686,71 @@ test("deleting a newly added account clears its device authorization", async ({
).toBeVisible();
});
test("sets public visibility when adding and editing an account", async ({
page,
}) => {
let accounts: Array<(typeof responses.accounts)[number]> = [];
let createdBody: { publicVisible?: boolean } | undefined;
let updatedBody: { publicVisible?: boolean } | undefined;
await page.route("**/api/v1/**", async (route) => {
const request = route.request();
const key = new URL(request.url()).pathname.replace("/api/v1/", "");
if (key === "accounts" && request.method() === "GET")
return route.fulfill({ json: accounts });
if (key === "accounts" && request.method() === "POST") {
createdBody = request.postDataJSON() as { publicVisible?: boolean };
const account = {
...responses.accounts[0],
id: 2,
displayName: "账号 1",
email: "",
connected: false,
validationStatus: "pending" as const,
publicVisible: createdBody.publicVisible === true,
};
accounts = [account];
return route.fulfill({ json: account });
}
if (key === "accounts/2/login/device")
return route.fulfill({
json: {
verificationUrl: "https://auth.openai.com/codex/device",
userCode: "PLTJ-7M6I6",
},
});
if (key === "accounts/2" && request.method() === "PUT") {
updatedBody = request.postDataJSON() as { publicVisible?: boolean };
accounts = [
{
...accounts[0],
publicVisible: updatedBody.publicVisible === true,
},
];
return route.fulfill({ json: { ok: true } });
}
return route.fulfill({ json: responses[key] ?? {} });
});
await page.goto("/settings");
await page.getByRole("tab", { name: "Codex" }).click();
const addVisibility = page.locator(
".add-account-controls input[type=checkbox]",
);
await expect(addVisibility).not.toBeChecked();
await addVisibility.check();
await page.getByRole("button", { name: "添加账号" }).click();
await expect.poll(() => createdBody?.publicVisible).toBe(true);
const accountCard = page.locator(".account-card");
const accountVisibility = accountCard.locator('input[type="checkbox"]');
await expect(accountVisibility).toBeChecked();
await accountVisibility.evaluate((checkbox) =>
(checkbox as HTMLInputElement).click(),
);
await expect.poll(() => updatedBody?.publicVisible).toBe(false);
await expect(accountVisibility).not.toBeChecked();
});
test("Codex account cards fit within the viewport", async ({ page }) => {
await openSettings(page);
await page.getByRole("tab", { name: "Codex" }).click();
@@ -633,12 +826,12 @@ test("Codex account emails are masked everywhere they are displayed", async ({
await expect(page.locator("body")).not.toContainText("test@example.com");
});
test("returns to login when an authenticated request receives 401", async ({
test("shows the public overview when the session is unavailable", 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")
if (key === "auth/me")
return route.fulfill({ status: 401, json: { error: "未登录" } });
if (key === "dashboard")
return route.fulfill({
@@ -657,8 +850,7 @@ test("returns to login when an authenticated request receives 401", async ({
});
await page.goto("/");
await expect(page.locator(".account-overview")).toBeVisible();
await page.getByRole("button", { name: "刷新全部" }).click();
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
await expect(page.getByRole("button", { name: "登录后配置" })).toBeVisible();
});
test("keeps each account dashboard in its own card when responses finish out of order", async ({