From 4c05dde0f1ad85c384c33e624b11cdd1b4bb518e Mon Sep 17 00:00:00 2001 From: zhoujun0601 Date: Thu, 13 Aug 2026 07:19:11 -0400 Subject: [PATCH] Add multi-account usage dashboard --- README.md | 13 +- backend/internal/app/api.go | 188 ++++++++++++++++++++++----- backend/internal/app/app.go | 98 ++++++++++---- backend/internal/app/notify.go | 134 +++++++++++-------- backend/internal/app/types.go | 16 ++- backend/internal/store/store.go | 133 ++++++++++++++++++- backend/internal/store/store_test.go | 98 ++++++++++++++ backend/internal/web/dist/index.html | 4 +- frontend/src/api.ts | 1 + frontend/src/main.tsx | 21 +-- frontend/src/styles.css | 2 +- 11 files changed, 573 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 9cf970b..b8e2299 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ ## 功能 -- 展示 Codex 账户、套餐、限额使用率和下次重置时间 +- 支持添加并切换多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区) +- 展示 Codex 账户、套餐、剩余额度和下次重置时间 - 展示累计 Token、单日峰值、连续使用天数及每日 Token 趋势 - 在本地 SQLite 中保留历史用量和限额快照 - 在限额重置前、重置后发送 Telegram 或邮件提醒 @@ -57,9 +58,9 @@ http://服务器地址:8080 7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“立即刷新”。 8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。 -设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。登录凭据保存在 Docker 数据卷的 `/data/codex` 中,不会写入浏览器或项目源码。 +设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。默认连接的登录凭据保存在 `/data/codex`,新增连接保存在 `/data/accounts/<账号 ID>/codex`,不会写入浏览器或项目源码。 -如需切换账户,在“设置中心” → “Codex”点击“退出 Codex 账户”,再重新生成设备码。 +如需添加其他账号,或同一邮箱下的个人订阅与 Team 工作区,请在“设置中心” → “Codex”中分别创建连接并完成设备码登录。每个连接使用隔离的登录凭据,可自定义名称并在总览顶部切换。删除连接会同时删除对应凭据和历史数据。 ## 通用设置与提醒时间 @@ -90,7 +91,7 @@ http://服务器地址:8080 绑定码十分钟内有效,一个实例只绑定一个 Telegram 会话。即使关闭查询菜单,仍可完成绑定并接收提醒;开启菜单后可使用以下按钮或命令: -- 当前用量 +- 当前用量(包含所有已添加连接) - 重置时间 / `/reset` - 历史概览 / `/usage` - 账户信息 / `/account` @@ -163,10 +164,10 @@ Codex Helper 展示的是 Codex app-server 实际返回的数据。当前接口 - ChatGPT/Codex 账户和套餐类型 - 限额窗口使用百分比、窗口长度和重置时间 -- 累计 Token、单日峰值、连续使用天数等摘要 +- 累计 Token、单日峰值、连续使用天数、最长任务时长等摘要 - 每日总 Token 桶 -当前接口不提供调用次数、输入 Token、输出 Token、订阅价格或账单续期日,因此页面会将这些字段标记为不可用。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 OpenAI 官方文档,`account/usage/read` 需要 Codex 服务支持的身份认证;仅 API Key 或 Bedrock 登录不能读取这些 ChatGPT 用量数据。 +当前接口不提供调用次数、输入 Token、输出 Token、订阅价格或账单续期日,因此总览使用“最长任务时长”作为替代摘要指标。部分摘要或每日数据也可能因账户或服务端暂未返回而显示为“暂无”。根据 OpenAI 官方文档,`account/usage/read` 需要 Codex 服务支持的身份认证;仅 API Key 或 Bedrock 登录不能读取这些 ChatGPT 用量数据。 ## 常见问题 diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go index 0457b6a..2ae774c 100644 --- a/backend/internal/app/api.go +++ b/backend/internal/app/api.go @@ -18,7 +18,16 @@ import ( func (a *App) api(w http.ResponseWriter, r *http.Request) { p := strings.TrimPrefix(r.URL.Path, "/api/v1/") if p == "system/status" { - jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": "0.1.0", "appServer": a.codex.Connected()}) + connected := false + a.mu.RLock() + for _, rt := range a.runtimes { + if rt.client.Connected() { + connected = true + break + } + } + a.mu.RUnlock() + jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": "0.2.0", "appServer": connected}) return } if p == "setup" && r.Method == "POST" { @@ -39,13 +48,54 @@ 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"` + } + if decode(r, &in) != nil { + jsonOut(w, 400, map[string]string{"error": "请求格式错误"}) + break + } + in.DisplayName = strings.TrimSpace(in.DisplayName) + if in.DisplayName == "" { + in.DisplayName = "新账号" + } + x, e := a.store.CreateAccount(in.DisplayName) + if e == nil { + a.addRuntime(x.ID) + jsonOut(w, 201, x) + } else { + jsonOut(w, 500, map[string]string{"error": e.Error()}) + } + case strings.HasPrefix(p, "accounts/"): + a.accountAPI(w, r, p) case p == "dashboard": - a.mu.RLock() - d := a.dash - a.mu.RUnlock() - jsonOut(w, 200, d) + 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() + jsonOut(w, 200, d) + } case p == "sync" && r.Method == "POST": - e := a.sync(r.Context()) + id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64) + if id == 0 { + id = 1 + } + e := a.syncAccount(r.Context(), id) if e != nil { jsonOut(w, 502, map[string]string{"error": e.Error()}) } else { @@ -65,16 +115,6 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { code := fmt.Sprintf("%06d", time.Now().UnixNano()%1000000) _ = a.store.SetJSON("telegram_bind", map[string]any{"code": code, "expires": time.Now().Add(10 * time.Minute).Unix()}) jsonOut(w, 200, map[string]string{"code": code}) - case p == "codex/login/device" && r.Method == "POST": - a.deviceLogin(w, r) - case p == "codex/logout" && r.Method == "POST": - var out any - e := a.codex.Call(r.Context(), "account/logout", map[string]any{}, &out) - if e != nil { - jsonOut(w, 502, map[string]string{"error": e.Error()}) - } else { - jsonOut(w, 200, map[string]bool{"ok": true}) - } case p == "maintenance/cleanup" && r.Method == "POST": n, e := a.store.Cleanup(a.general().RetentionDays) if e != nil { @@ -101,6 +141,78 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { } } +func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) { + parts := strings.Split(p, "/") + if len(parts) < 2 { + jsonOut(w, 404, map[string]string{"error": "接口不存在"}) + return + } + id, e := strconv.ParseInt(parts[1], 10, 64) + if e != nil { + jsonOut(w, 400, map[string]string{"error": "账号 ID 无效"}) + return + } + rt := a.runtime(id) + if rt == nil { + jsonOut(w, 404, map[string]string{"error": "账号不存在"}) + return + } + action := "" + if len(parts) > 2 { + action = parts[2] + } + switch { + case action == "" && r.Method == "PUT": + var in struct { + DisplayName string `json:"displayName"` + } + if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" { + jsonOut(w, 400, map[string]string{"error": "名称不能为空"}) + return + } + e = a.store.RenameAccount(id, strings.TrimSpace(in.DisplayName)) + if e == nil { + jsonOut(w, 200, map[string]bool{"ok": true}) + } + case action == "" && r.Method == "DELETE": + a.mu.Lock() + delete(a.runtimes, id) + a.mu.Unlock() + _ = rt.client.Close() + e = a.store.DeleteAccount(id) + if e == nil { + dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10)) + if id == 1 { + dir = filepath.Join(a.dataDir, "codex") + } + e = os.RemoveAll(dir) + } + if e == nil { + jsonOut(w, 200, map[string]bool{"ok": true}) + } + case action == "login" && len(parts) > 3 && parts[3] == "device" && r.Method == "POST": + a.deviceLogin(w, r, id) + case action == "logout" && r.Method == "POST": + var out any + e = rt.client.Call(r.Context(), "account/logout", map[string]any{}, &out) + if e == nil { + _ = a.store.UpdateAccount(id, nil, nil, false) + jsonOut(w, 200, map[string]bool{"ok": true}) + } + case action == "sync" && r.Method == "POST": + e = a.syncAccount(r.Context(), id) + if e == nil { + jsonOut(w, 200, map[string]bool{"ok": true}) + } + default: + jsonOut(w, 404, map[string]string{"error": "接口不存在"}) + return + } + if e != nil { + jsonOut(w, 502, map[string]string{"error": e.Error()}) + } +} + func (a *App) setup(w http.ResponseWriter, r *http.Request) { if a.store.Initialized() { jsonOut(w, 409, map[string]string{"error": "系统已初始化"}) @@ -211,9 +323,14 @@ func (a *App) generalAPI(w http.ResponseWriter, r *http.Request) { _ = a.store.SetJSON("general", g) jsonOut(w, 200, g) } -func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request) { +func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request, id int64) { var out map[string]any - e := a.codex.Call(r.Context(), "account/login/start", map[string]any{"type": "chatgptDeviceCode"}, &out) + rt := a.runtime(id) + if !rt.client.Connected() { + jsonOut(w, 503, map[string]string{"error": "该账号服务正在启动,请稍后重试"}) + return + } + e := rt.client.Call(r.Context(), "account/login/start", map[string]any{"type": "chatgptDeviceCode"}, &out) if e != nil { jsonOut(w, 502, map[string]string{"error": e.Error()}) return @@ -221,8 +338,14 @@ func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, out) } -func (a *App) sync(ctx context.Context) error { - if !a.codex.Connected() { +func (a *App) syncAccount(ctx context.Context, id int64) error { + rt := a.runtime(id) + if rt == nil { + return fmt.Errorf("账号不存在") + } + rt.syncing.Lock() + defer rt.syncing.Unlock() + if !rt.client.Connected() { return fmt.Errorf("app-server 未连接") } ctx, c := context.WithTimeout(ctx, 20*time.Second) @@ -234,10 +357,18 @@ func (a *App) sync(ctx context.Context) error { PlanType *string `json:"planType"` } `json:"account"` } - if e := a.codex.Call(ctx, "account/read", map[string]any{"refreshToken": false}, &ar); e != nil { + if e := rt.client.Call(ctx, "account/read", map[string]any{"refreshToken": false}, &ar); e != nil { return e } - d := Dashboard{FetchedAt: time.Now().Unix(), Account: AccountView{Connected: ar.Account != nil}, Limits: []LimitBucket{}, Usage: []UsagePoint{}} + name := "账号" + accounts, _ := a.store.Accounts() + for _, x := range accounts { + if x.ID == id { + name = x.DisplayName + break + } + } + d := Dashboard{AccountID: id, DisplayName: name, FetchedAt: time.Now().Unix(), Account: AccountView{Connected: ar.Account != nil}, Limits: []LimitBucket{}, Usage: []UsagePoint{}} if ar.Account != nil { d.Account.Email = ar.Account.Email d.Account.PlanType = ar.Account.PlanType @@ -247,7 +378,7 @@ func (a *App) sync(ctx context.Context) error { RateLimits *rawLimit `json:"rateLimits"` By map[string]rawLimit `json:"rateLimitsByLimitId"` } - if e := a.codex.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil { + if e := rt.client.Call(ctx, "account/rateLimits/read", map[string]any{}, &lr); e == nil { if len(lr.By) > 0 { for _, x := range lr.By { d.Limits = append(d.Limits, flattenLimit(x)...) @@ -263,20 +394,19 @@ func (a *App) sync(ctx context.Context) error { Tokens int64 `json:"tokens"` } `json:"dailyUsageBuckets"` } - if e := a.codex.Call(ctx, "account/usage/read", map[string]any{}, &ur); e == nil { + if e := rt.client.Call(ctx, "account/usage/read", map[string]any{}, &ur); e == nil { d.Summary = ur.Summary for _, x := range ur.Daily { p := UsagePoint{Date: x.StartDate, TotalTokens: x.Tokens} d.Usage = append(d.Usage, p) - _, _ = a.store.DB.Exec("INSERT INTO daily_usage(date,total_tokens,fetched_at) VALUES(?,?,?) ON CONFLICT(date) DO UPDATE SET total_tokens=excluded.total_tokens,fetched_at=excluded.fetched_at", x.StartDate, x.Tokens, d.FetchedAt) + _, _ = 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) } } for _, x := range d.Limits { - _, _ = a.store.DB.Exec("INSERT INTO limit_snapshots(limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at) VALUES(?,?,?,?,?,?)", x.LimitID, x.WindowType, x.UsedPercent, x.WindowDurationMinutes, x.ResetsAt, d.FetchedAt) + _, _ = a.store.DB.Exec("INSERT INTO limit_snapshots(limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at,account_id) VALUES(?,?,?,?,?,?,?)", x.LimitID, x.WindowType, x.UsedPercent, x.WindowDurationMinutes, x.ResetsAt, d.FetchedAt, id) } - a.mu.Lock() - a.dash = d - a.mu.Unlock() + rt.dash = d + _ = a.store.UpdateAccount(id, d.Account.Email, d.Account.PlanType, d.Account.Connected) return nil } diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 244f66f..2cfaecd 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -29,14 +29,18 @@ type App struct { dataDir string store *store.Store vault *security.Vault - codex *codex.Client server *http.Server ctx context.Context cancel context.CancelFunc mu sync.RWMutex - dash Dashboard + runtimes map[int64]*accountRuntime loginAttempts sync.Map } +type accountRuntime struct { + client *codex.Client + dash Dashboard + syncing sync.Mutex +} func New() (*App, error) { dir := env("DATA_DIR", "/data") @@ -49,8 +53,11 @@ func New() (*App, error) { return nil, e } ctx, cancel := context.WithCancel(context.Background()) - a := &App{dataDir: dir, store: s, vault: v, ctx: ctx, cancel: cancel, dash: Dashboard{Limits: []LimitBucket{}, Usage: []UsagePoint{}, Stale: true}} - a.codex = codex.New(filepath.Join(dir, "codex"), a.onCodexNotification) + a := &App{dataDir: dir, store: s, vault: v, ctx: ctx, cancel: cancel, runtimes: map[int64]*accountRuntime{}} + accounts, _ := s.Accounts() + for _, account := range accounts { + a.addRuntime(account.ID) + } a.server = &http.Server{Addr: env("LISTEN_ADDR", ":8080"), Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second} return a, nil } @@ -79,45 +86,81 @@ func (a *App) Close() { ctx, c := context.WithTimeout(context.Background(), 5*time.Second) defer c() _ = a.server.Shutdown(ctx) - _ = a.codex.Close() + a.mu.RLock() + for _, rt := range a.runtimes { + _ = rt.client.Close() + } + a.mu.RUnlock() _ = a.store.DB.Close() } func (a *App) keepCodex() { - delay := time.Second for { select { case <-a.ctx.Done(): return default: } - if !a.codex.Connected() { - if e := a.codex.Start(a.ctx); e == nil { + a.mu.RLock() + ids := make([]int64, 0, len(a.runtimes)) + for id := range a.runtimes { + ids = append(ids, id) + } + a.mu.RUnlock() + for _, id := range ids { + rt := a.runtime(id) + if rt == nil || rt.client.Connected() { + continue + } + if e := rt.client.Start(a.ctx); e == nil { ctx, c := context.WithTimeout(a.ctx, 20*time.Second) - e = a.codex.Initialize(ctx) + e = rt.client.Initialize(ctx) c() if e == nil { - delay = time.Second - _ = a.sync(context.Background()) + _ = a.syncAccount(context.Background(), id) } else { log.Printf("app-server initialize: %v", e) // A live process is not necessarily an initialized process. Tear it // down so the next iteration starts a fresh protocol session. - _ = a.codex.Close() - } - } - if !a.codex.Connected() { - time.Sleep(delay) - if delay < 30*time.Second { - delay *= 2 + _ = rt.client.Close() } } } time.Sleep(time.Second) } } -func (a *App) onCodexNotification(method string, _ json.RawMessage) { - if method == "account/updated" || method == "account/rateLimits/updated" { - go a.sync(context.Background()) +func (a *App) onCodexNotification(id int64) func(string, json.RawMessage) { + return func(method string, _ json.RawMessage) { + if method == "account/updated" || method == "account/rateLimits/updated" { + go a.syncAccount(context.Background(), id) + } + } +} +func (a *App) addRuntime(id int64) { + dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10), "codex") + if id == 1 { + // Account 1 deliberately keeps the legacy path so upgrades retain the + // existing login, and fresh installs use the same deterministic path. + dir = filepath.Join(a.dataDir, "codex") + } + rt := &accountRuntime{client: codex.New(dir, a.onCodexNotification(id)), dash: Dashboard{Limits: []LimitBucket{}, Usage: []UsagePoint{}, Stale: true}} + a.mu.Lock() + a.runtimes[id] = rt + a.mu.Unlock() +} +func (a *App) runtime(id int64) *accountRuntime { + a.mu.RLock() + defer a.mu.RUnlock() + return a.runtimes[id] +} +func (a *App) syncAll(ctx context.Context) { + a.mu.RLock() + ids := make([]int64, 0, len(a.runtimes)) + for id := range a.runtimes { + ids = append(ids, id) + } + a.mu.RUnlock() + for _, id := range ids { + _ = a.syncAccount(ctx, id) } } func (a *App) scheduler() { @@ -130,7 +173,7 @@ func (a *App) scheduler() { case <-t.C: g := a.general() if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 { - _ = a.sync(context.Background()) + a.syncAll(context.Background()) } _, _ = a.store.Cleanup(g.RetentionDays) go a.processReminders() @@ -146,7 +189,16 @@ func (a *App) routes() http.Handler { jsonOut(w, 503, map[string]string{"error": e.Error()}) return } - jsonOut(w, 200, map[string]any{"status": "ok", "appServer": a.codex.Connected()}) + connected := false + a.mu.RLock() + for _, rt := range a.runtimes { + if rt.client.Connected() { + connected = true + break + } + } + a.mu.RUnlock() + jsonOut(w, 200, map[string]any{"status": "ok", "appServer": connected}) }) m.HandleFunc("/api/v1/", a.api) sub, _ := fs.Sub(webassets.Assets, "dist") diff --git a/backend/internal/app/notify.go b/backend/internal/app/notify.go index e161e47..831bf9c 100644 --- a/backend/internal/app/notify.go +++ b/backend/internal/app/notify.go @@ -291,30 +291,45 @@ func (a *App) handleTG(t TelegramSettings, chat int64, text string) { return } if text == "立即刷新" || text == "/refresh" { - _ = a.sync(context.Background()) + a.syncAll(context.Background()) } - a.mu.RLock() - d := a.dash - a.mu.RUnlock() msg := "Codex 用量\n" + a.mu.RLock() + ds := make([]Dashboard, 0, len(a.runtimes)) + for _, rt := range a.runtimes { + rt.syncing.Lock() + ds = append(ds, rt.dash) + rt.syncing.Unlock() + } + a.mu.RUnlock() switch text { case "重置时间", "/reset": - for _, x := range d.Limits { - msg += fmt.Sprintf("%s/%s:%s\n", x.LimitID, x.WindowType, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + for _, d := range ds { + msg += "[" + d.DisplayName + "]\n" + for _, x := range d.Limits { + msg += fmt.Sprintf("%s/%s:%s\n", x.LimitID, x.WindowType, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + } } case "账户信息", "/account": - msg += fmt.Sprintf("连接:%v\n", d.Account.Connected) - if d.Account.Email != nil { - msg += "账户:" + *d.Account.Email + "\n" - } - if d.Account.PlanType != nil { - msg += "套餐:" + *d.Account.PlanType + for _, d := range ds { + msg += fmt.Sprintf("[%s] 连接:%v\n", d.DisplayName, d.Account.Connected) + if d.Account.Email != nil { + msg += "账户:" + *d.Account.Email + "\n" + } + if d.Account.PlanType != nil { + msg += "套餐:" + *d.Account.PlanType + "\n" + } } case "历史概览", "/usage": - msg += fmt.Sprintf("Lifetime tokens:%s\n历史天数:%d", num(d.Summary.LifetimeTokens), len(d.Usage)) + for _, d := range ds { + msg += fmt.Sprintf("[%s] Lifetime tokens:%s,历史天数:%d\n", d.DisplayName, num(d.Summary.LifetimeTokens), len(d.Usage)) + } default: - for _, x := range d.Limits { - msg += fmt.Sprintf("%s/%s:%.1f%%,重置 %s\n", x.LimitID, x.WindowType, x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + for _, d := range ds { + msg += "[" + d.DisplayName + "]\n" + for _, x := range d.Limits { + msg += fmt.Sprintf("%s/%s:剩余 %.1f%%,重置 %s\n", x.LimitID, x.WindowType, 100-x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + } } } _ = tgSend(t, msg) @@ -328,57 +343,64 @@ func num(n *int64) string { func (a *App) processReminders() { g := a.general() a.mu.RLock() - d := a.dash + ds := make([]Dashboard, 0, len(a.runtimes)) + for _, rt := range a.runtimes { + rt.syncing.Lock() + ds = append(ds, rt.dash) + rt.syncing.Unlock() + } a.mu.RUnlock() now := time.Now() - for _, x := range d.Limits { - for _, kind := range []string{"before", "after"} { - if kind == "before" && !g.NotifyBefore { - continue - } - if kind == "after" && !g.NotifyAfter { - continue - } - at := time.Unix(x.ResetsAt, 0) - if kind == "before" { - at = at.Add(-time.Duration(g.BeforeMinutes) * time.Minute) - } - if now.Before(at) || now.Sub(at) > 6*time.Hour { - continue - } - key := fmt.Sprintf("%s:%s:%d:%s", x.LimitID, x.WindowType, x.ResetsAt, kind) - var exists int - if a.store.DB.QueryRow("SELECT 1 FROM notifications WHERE dedupe_key=? AND status='sent'", key).Scan(&exists) == nil { - continue - } - body := fmt.Sprintf("Codex %s/%s 当前用量 %.1f%%,重置时间 %s。", x.LimitID, x.WindowType, x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) - ok := true - errs := []string{} - if t, e := a.telegramSecret(); e == nil && t.Enabled && t.ChatID != 0 { - if e = tgSend(t, body); e != nil { - ok = false - errs = append(errs, e.Error()) + for _, d := range ds { + for _, x := range d.Limits { + for _, kind := range []string{"before", "after"} { + if kind == "before" && !g.NotifyBefore { + continue } - } - if s, e := a.smtpSecret(); e == nil && s.Enabled { - if e = sendSMTP(s, "Codex 用量重置提醒", body); e != nil { - ok = false - errs = append(errs, e.Error()) + if kind == "after" && !g.NotifyAfter { + continue } - } - status := "sent" - var sent any = time.Now().Unix() - if !ok { - status = "failed" - sent = nil - } - _, _ = a.store.DB.Exec(`INSERT INTO notifications(dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at) + at := time.Unix(x.ResetsAt, 0) + if kind == "before" { + at = at.Add(-time.Duration(g.BeforeMinutes) * time.Minute) + } + if now.Before(at) || now.Sub(at) > 6*time.Hour { + continue + } + key := fmt.Sprintf("%d:%s:%s:%d:%s", d.AccountID, x.LimitID, x.WindowType, x.ResetsAt, kind) + var exists int + if a.store.DB.QueryRow("SELECT 1 FROM notifications WHERE dedupe_key=? AND status='sent'", key).Scan(&exists) == nil { + continue + } + body := fmt.Sprintf("Codex [%s] %s/%s 剩余 %.1f%%,重置时间 %s。", d.DisplayName, x.LimitID, x.WindowType, 100-x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) + ok := true + errs := []string{} + if t, e := a.telegramSecret(); e == nil && t.Enabled && t.ChatID != 0 { + if e = tgSend(t, body); e != nil { + ok = false + errs = append(errs, e.Error()) + } + } + if s, e := a.smtpSecret(); e == nil && s.Enabled { + if e = sendSMTP(s, "Codex 用量重置提醒", body); e != nil { + ok = false + errs = append(errs, e.Error()) + } + } + status := "sent" + var sent any = time.Now().Unix() + if !ok { + status = "failed" + sent = nil + } + _, _ = a.store.DB.Exec(`INSERT INTO notifications(dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(dedupe_key) DO UPDATE SET status=excluded.status, attempts=notifications.attempts+1, last_error=excluded.last_error, sent_at=excluded.sent_at`, key, "configured", kind, status, 1, strings.Join(errs, "; "), at.Unix(), sent) + } } } } diff --git a/backend/internal/app/types.go b/backend/internal/app/types.go index f9375e0..37e11ca 100644 --- a/backend/internal/app/types.go +++ b/backend/internal/app/types.go @@ -67,13 +67,15 @@ type UsagePoint struct { OutputTokens *int64 `json:"outputTokens"` } type Dashboard struct { - Account AccountView `json:"account"` - Limits []LimitBucket `json:"limits"` - Summary UsageSummary `json:"summary"` - Usage []UsagePoint `json:"usage"` - FetchedAt int64 `json:"fetchedAt"` - Stale bool `json:"stale"` - LastError string `json:"lastError,omitempty"` + AccountID int64 `json:"accountId"` + DisplayName string `json:"displayName"` + Account AccountView `json:"account"` + Limits []LimitBucket `json:"limits"` + Summary UsageSummary `json:"summary"` + Usage []UsagePoint `json:"usage"` + FetchedAt int64 `json:"fetchedAt"` + Stale bool `json:"stale"` + LastError string `json:"lastError,omitempty"` } func defaults() GeneralSettings { diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 89c3a17..9c588a5 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -42,7 +42,138 @@ CREATE TABLE IF NOT EXISTS notifications (dedupe_key TEXT PRIMARY KEY, channel T CREATE TABLE IF NOT EXISTS telegram_updates (id INTEGER PRIMARY KEY CHECK(id=1), offset INTEGER NOT NULL DEFAULT 0); INSERT OR IGNORE INTO telegram_updates(id,offset) VALUES(1,0); `) - return err + if err != nil { + return err + } + return s.migrateAccounts() +} + +func (s *Store) migrateAccounts() error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec(`CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, + email TEXT, + plan_type TEXT, + connected INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`); err != nil { + return err + } + var count int + if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil { + return err + } + if count == 0 { + if _, err = tx.Exec("INSERT INTO accounts(id,display_name,created_at,updated_at) VALUES(1,'默认账号',?,?)", time.Now().Unix(), time.Now().Unix()); err != nil { + return err + } + } + for _, table := range []string{"daily_usage", "limit_snapshots"} { + var found int + rows, qerr := tx.Query("PRAGMA table_info(" + table + ")") + if qerr != nil { + return qerr + } + for rows.Next() { + var cid, notnull, pk int + var name, typ string + var def any + _ = rows.Scan(&cid, &name, &typ, ¬null, &def, &pk) + if name == "account_id" { + found = 1 + } + } + rows.Close() + if found == 0 { + if table == "daily_usage" { + _, err = tx.Exec(`ALTER TABLE daily_usage RENAME TO daily_usage_legacy; + CREATE TABLE daily_usage (account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,date TEXT NOT NULL,total_tokens INTEGER NOT NULL,fetched_at INTEGER NOT NULL,PRIMARY KEY(account_id,date)); + INSERT INTO daily_usage SELECT 1,date,total_tokens,fetched_at FROM daily_usage_legacy; + DROP TABLE daily_usage_legacy;`) + } else { + _, err = tx.Exec(`ALTER TABLE limit_snapshots RENAME TO limit_snapshots_legacy; + CREATE TABLE limit_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + limit_id TEXT NOT NULL, + window_type TEXT NOT NULL, + used_percent REAL NOT NULL, + duration_mins INTEGER NOT NULL, + resets_at INTEGER NOT NULL, + fetched_at INTEGER NOT NULL + ); + INSERT INTO limit_snapshots(id,account_id,limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at) + SELECT id,1,limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at FROM limit_snapshots_legacy; + DROP TABLE limit_snapshots_legacy; + CREATE INDEX idx_limits_time ON limit_snapshots(fetched_at);`) + } + if err != nil { + return err + } + } + } + return tx.Commit() +} + +type Account struct { + ID int64 `json:"id"` + DisplayName string `json:"displayName"` + Email *string `json:"email"` + PlanType *string `json:"planType"` + Connected bool `json:"connected"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +func (s *Store) Accounts() ([]Account, error) { + rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,connected,created_at,updated_at FROM accounts ORDER BY id") + if e != nil { + return nil, e + } + defer rows.Close() + out := []Account{} + for rows.Next() { + var a Account + if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil { + return nil, e + } + out = append(out, a) + } + return out, rows.Err() +} +func (s *Store) CreateAccount(name string) (Account, error) { + now := time.Now().Unix() + r, e := s.DB.Exec("INSERT INTO accounts(display_name,created_at,updated_at) VALUES(?,?,?)", name, now, now) + if e != nil { + return Account{}, e + } + id, _ := r.LastInsertId() + return Account{ID: id, DisplayName: name, CreatedAt: now, UpdatedAt: now}, nil +} +func (s *Store) RenameAccount(id int64, name string) error { + r, e := s.DB.Exec("UPDATE accounts SET display_name=?,updated_at=? WHERE id=?", name, time.Now().Unix(), id) + if e != nil { + return e + } + n, _ := r.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} +func (s *Store) UpdateAccount(id int64, email, plan *string, connected bool) error { + _, e := s.DB.Exec("UPDATE accounts SET email=?,plan_type=?,connected=?,updated_at=? WHERE id=?", email, plan, connected, time.Now().Unix(), id) + return e +} +func (s *Store) DeleteAccount(id int64) error { + _, e := s.DB.Exec("DELETE FROM accounts WHERE id=?", id) + return e } func (s *Store) Get(key string) (string, bool) { diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 898daf9..97be351 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -26,6 +26,104 @@ func TestSettings(t *testing.T) { } } +func TestAccountsAndPerAccountUsage(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer s.DB.Close() + accounts, err := s.Accounts() + if err != nil || len(accounts) != 1 || accounts[0].ID != 1 { + t.Fatalf("default accounts = %#v, %v", accounts, err) + } + second, err := s.CreateAccount("Team workspace") + if err != nil { + t.Fatal(err) + } + for _, id := range []int64{1, second.ID} { + if _, err = s.DB.Exec("INSERT INTO daily_usage(account_id,date,total_tokens,fetched_at) VALUES(?,?,?,?)", id, "2026-08-13", id*100, 1); err != nil { + t.Fatal(err) + } + } + if err = s.DeleteAccount(second.ID); err != nil { + t.Fatal(err) + } + var count int + if err = s.DB.QueryRow("SELECT COUNT(*) FROM daily_usage WHERE account_id=?", second.ID).Scan(&count); err != nil || count != 0 { + t.Fatalf("usage was not cascaded: %d, %v", count, err) + } +} + +func TestLegacyUsageMigratesToDefaultAccount(t *testing.T) { + dir := t.TempDir() + db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db")) + if err != nil { + t.Fatal(err) + } + if _, err = db.Exec("CREATE TABLE daily_usage(date TEXT PRIMARY KEY,total_tokens INTEGER NOT NULL,fetched_at INTEGER NOT NULL); INSERT INTO daily_usage VALUES('2026-08-12',321,1)"); err != nil { + t.Fatal(err) + } + db.Close() + s, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer s.DB.Close() + var accountID, tokens int64 + if err = s.DB.QueryRow("SELECT account_id,total_tokens FROM daily_usage WHERE date='2026-08-12'").Scan(&accountID, &tokens); err != nil { + t.Fatal(err) + } + if accountID != 1 || tokens != 321 { + t.Fatalf("migrated row = account %d, tokens %d", accountID, tokens) + } +} + +func TestPopulatedLegacyLimitSnapshotsMigrateToDefaultAccount(t *testing.T) { + dir := t.TempDir() + db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db")) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(` + CREATE TABLE limit_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + limit_id TEXT NOT NULL, + window_type TEXT NOT NULL, + used_percent REAL NOT NULL, + duration_mins INTEGER NOT NULL, + resets_at INTEGER NOT NULL, + fetched_at INTEGER NOT NULL + ); + CREATE INDEX idx_limits_time ON limit_snapshots(fetched_at); + INSERT INTO limit_snapshots(id,limit_id,window_type,used_percent,duration_mins,resets_at,fetched_at) + VALUES(7,'codex','primary',42.5,300,1700000000,1699990000); + `) + if err != nil { + t.Fatal(err) + } + db.Close() + + s, err := Open(dir) + if err != nil { + t.Fatal(err) + } + defer s.DB.Close() + var id, accountID, duration int64 + var limitID, window string + var used float64 + err = s.DB.QueryRow("SELECT id,account_id,limit_id,window_type,used_percent,duration_mins FROM limit_snapshots").Scan(&id, &accountID, &limitID, &window, &used, &duration) + if err != nil { + t.Fatal(err) + } + if id != 7 || accountID != 1 || limitID != "codex" || window != "primary" || used != 42.5 || duration != 300 { + t.Fatalf("migrated limit = id %d, account %d, %s/%s, %.1f, duration %d", id, accountID, limitID, window, used, duration) + } + var indexCount int + if err = s.DB.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_limits_time'").Scan(&indexCount); err != nil || indexCount != 1 { + t.Fatalf("limit index was not recreated: %d, %v", indexCount, err) + } +} + func TestBackupIncludesCommittedWALData(t *testing.T) { dir := t.TempDir() s, err := Open(dir) diff --git a/backend/internal/web/dist/index.html b/backend/internal/web/dist/index.html index 3858b0f..bf3fe38 100644 --- a/backend/internal/web/dist/index.html +++ b/backend/internal/web/dist/index.html @@ -1,3 +1,3 @@ - - + +
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b9dbc30..71d69ab 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,3 +1,4 @@ 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'}) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9346ee7..5f25b4a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,28 +1,29 @@ import React,{useEffect,useState} from 'react' import{createRoot}from'react-dom/client' import{BrowserRouter,Navigate,Route,Routes,useNavigate}from'react-router-dom' -import{Activity,Bell,ChartNoAxesCombined,Database,LogOut,Moon,RefreshCw,Settings,Sun,Zap}from'lucide-react' +import{Activity,Bell,Check,LogOut,Moon,RefreshCw,Settings,Sun,Trash2,Zap}from'lucide-react' import{Area,AreaChart,CartesianGrid,ResponsiveContainer,Tooltip,XAxis,YAxis}from'recharts' -import{api,post}from'./api' +import{api,del,post,put}from'./api' 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 Dash={account:{email?:string;planType?:string;connected:boolean};limits:Limit[];summary:{lifetimeTokens?:number;peakDailyTokens?:number;currentStreakDays?:number;callCount?:number};usage:Point[];fetchedAt:number;stale:boolean;lastError?:string}; +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;connected:boolean};type Dash={accountId:number;displayName:string;account:{email?:string;planType?:string;connected:boolean};limits:Limit[];summary:{lifetimeTokens?:number;peakDailyTokens?:number;currentStreakDays?:number;longestRunningTurnSec?:number};usage:Point[];fetchedAt:number;stale:boolean;lastError?:string}; function App(){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)}}/>;return {auth?setAuth(false)}/>}/>:setAuth(true)}/>}/>}} const Splash=()=>
Codex Helper
; function Setup({onDone}:{onDone:()=>void}){const[form,set]=useState({username:'admin',password:'',timezone:Intl.DateTimeFormat().resolvedOptions().timeZone});const[err,setErr]=useState('');async function submit(e:React.FormEvent){e.preventDefault();try{await post('setup',form);onDone()}catch(x){setErr((x as Error).message)}}return
Codex Helper

连接你的 Codex
看见每一次增长

一个容器内完成用量洞察、重置提醒与账户管理。

首次初始化 · 1 / 1

创建管理员

{err&&

{err}

}

Codex、Telegram 与 SMTP 可在进入系统后通过设置中心完成。

} function Login({done}:{done:()=>void}){const[u,setU]=useState('admin'),[p,setP]=useState(''),[e,setE]=useState('');return
{x.preventDefault();try{await post('auth/login',{username:u,password:p});done()}catch(q){setE((q as Error).message)}}}>
Codex Helper

欢迎回来

{e&&

{e}

}
} -function Shell({logout}:{logout:()=>void}){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]);return
}/>}/>}/>}/>
} -function useDash(){const[d,setD]=useState(null),[e,setE]=useState('');const load=()=>api('dashboard').then(setD).catch(x=>setE(x.message));useEffect(()=>{load();const t=setInterval(load,30000);return()=>clearInterval(t)},[]);return{d,e,load}} -function Dashboard(){const{d,e,load}=useDash();if(!d)return ;return <>
{e&&
{e}
}
CODEx ACCOUNT{d.account.email||'尚未连接'}
{d.account.planType||'未识别套餐'}更新于 {new Date(d.fetchedAt*1000).toLocaleString()}
{d.limits.map((x,i)=>)}
} -function LimitCard({x}:{x:Limit}){const left=Math.max(0,100-x.usedPercent);return
{x.limitName||x.limitId} · {x.windowType}{x.usedPercent.toFixed(1)}%已使用
{left.toFixed(0)}%剩余
{x.windowDurationMinutes} 分钟窗口重置 {new Date(x.resetsAt*1000).toLocaleString()}
} +function Shell({logout}:{logout:()=>void}){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]);return
}/>}/>}/>
} +function Dashboard(){const[accounts,setAccounts]=useState([]),[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=>{setAccounts(xs);const next=xs.some(x=>x.id===id)?id:xs[0]?.id||0;if(next!==id)setId(next)});const load=async(accountId=id)=>{if(!accountId)return;try{setD(await api('dashboard?accountId='+accountId));setE('')}catch(x){setE((x as Error).message)}};useEffect(()=>{loadAccounts()},[]);useEffect(()=>{if(!id)return;localStorage.accountId=String(id);setD(null);load(id);const t=setInterval(()=>load(id),30000);return()=>clearInterval(t)},[id]);const sync=async()=>{setRefresh('loading');setE('');try{await post(`accounts/${id}/sync`);await load(id);setRefresh('done');setTimeout(()=>setRefresh('idle'),2000)}catch(x){setE((x as Error).message);setRefresh('idle')}};if(!accounts.length)return <>
尚未添加 Codex 账号
;if(!d)return ;return <>
{e&&
{e}
}
Codex Account{d.displayName} · {d.account.email||'尚未连接'}
{d.account.planType||'未识别套餐'}更新于 {d.fetchedAt?new Date(d.fetchedAt*1000).toLocaleString():'尚未同步'}
{d.limits.map((x,i)=>)}
{!d.limits.length&&
该连接暂无限额数据,请确认登录后刷新。
}
} +function LimitCard({x}:{x:Limit}){const used=Math.min(100,Math.max(0,x.usedPercent)),left=100-used;return
{x.limitName||x.limitId} · {x.windowType}
{left.toFixed(1)}%剩余
下次重置{x.resetsAt?new Date(x.resetsAt*1000).toLocaleString():'暂未提供'}{x.resetsAt?relative(x.resetsAt):''}
已使用 {used.toFixed(1)}%剩余 {left.toFixed(1)}%{x.windowDurationMinutes||'—'} 分钟窗口
} const Stat=({label,v,muted=false}:{label:string,v:string,muted?:boolean})=>
{label}{v}
; -const Chart=({data}:{data:Point[]})=>

每日 Token 趋势

Codex app server 提供的每日总量
; -function History(){const{d}=useDash();return <>
{d?<>
调用次数、输入 Token 和输出 Token 暂未由 Codex app server 提供。
:}} +const Chart=({data}:{data:Point[]})=>

每日 Token 趋势

Codex app server 提供的每日总量
[new Intl.NumberFormat('zh-CN').format(Number(v)),'Tokens']} contentStyle={{background:'var(--panel)',border:'1px solid var(--border)',borderRadius:12}}/>
; function SettingsPage(){const[tab,setTab]=useState('general');return <>
{tab==='general'?:tab==='codex'?:tab==='telegram'?:}
} function General(){const[v,setV]=useState(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/general').then(setV)},[]);if(!v)return ;return
{e.preventDefault();try{await api('settings/general',{method:'PUT',body:JSON.stringify(v)});setMsg('设置已保存')}catch(x){setMsg((x as Error).message)}}}>

通用设置

{msg&&

{msg}

}
} -function CodexSettings(){const[result,setResult]=useState(null),[err,setErr]=useState('');return

Codex 账户

使用设备码登录,适合 Docker 与远程部署。Codex app server 将安全管理登录凭据。

{result&&
访问 {result.verificationUrl}{result.userCode}
}{err&&

{err}

}
} +function CodexSettings(){const[xs,setXs]=useState([]),[result,setResult]=useState(null),[active,setActive]=useState(0),[err,setErr]=useState('');const load=()=>api('accounts').then(setXs);useEffect(()=>{load()},[]);const add=async()=>{try{const x=await post('accounts',{displayName:`账号 ${xs.length+1}`});await load();await login(x.id)}catch(q){setErr((q as Error).message)}};const login=async(id:number)=>{try{setActive(id);setResult(await post(`accounts/${id}/login/device`));setErr('')}catch(q){setErr((q as Error).message)}};return

Codex 账户与工作区

个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。

{xs.map(x=>
{const name=e.target.value.trim();if(name&&name!==x.displayName){await put(`accounts/${x.id}`,{displayName:name});load()}}}/>{x.email||'尚未登录'} · {x.planType||'未识别套餐'}
)}
{result&&
为“{xs.find(x=>x.id===active)?.displayName}”访问 {result.verificationUrl}{result.userCode}
}{err&&

{err}

}
} function Telegram(){const[v,setV]=useState(null),[msg,setMsg]=useState(''),[code,setCode]=useState('');useEffect(()=>{api('settings/telegram').then(setV)},[]);if(!v)return ;return
{e.preventDefault();try{const x:any=await api('settings/telegram',{method:'PUT',body:JSON.stringify(v)});setV(x);setMsg('Bot 已验证并保存')}catch(x){setMsg((x as Error).message)}}}>

Telegram Bot

{code&&
向 Bot 发送/bind {code}
}{msg&&

{msg}

}
} function SMTP(){const[v,setV]=useState(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/smtp').then(setV)},[]);if(!v)return ;const save=async(e:React.FormEvent)=>{e.preventDefault();try{setV(await api('settings/smtp',{method:'PUT',body:JSON.stringify(v)}));setMsg('SMTP 设置已保存')}catch(x){setMsg((x as Error).message)}};return

SMTP 邮件

{msg&&

{msg}

}
} function Header({title,sub,children}:{title:string,sub:string,children?:React.ReactNode}){return

{title}

{sub}

{children}
} const num=(v?:number)=>v==null?'暂无':new Intl.NumberFormat('zh-CN',{notation:'compact',maximumFractionDigits:1}).format(v); +const compactAxis=(v:number)=>new Intl.NumberFormat('en',{notation:'compact',maximumFractionDigits:1}).format(v); +const duration=(v?:number)=>v==null?'暂无':v<60?`${v} 秒`:v<3600?`${Math.floor(v/60)} 分 ${v%60} 秒`:`${(v/3600).toFixed(1)} 小时`; +const relative=(ts:number)=>{const s=Math.max(0,ts-Math.floor(Date.now()/1000));if(!s)return'即将重置';const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?`${d} 天 ${h} 小时后`:h?`${h} 小时 ${m} 分后`:`${m} 分钟后`}; createRoot(document.getElementById('root')!).render() diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 19591bf..b9a4e23 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,2 +1,2 @@ /* Fully self-contained: no remote font dependency. */ -:root{font-family:Inter,"PingFang SC",sans-serif;color-scheme:light;--bg:#f4f7fa;--panel:#fff;--text:#101820;--muted:#71808d;--border:#dfe7ec;--accent:#25bfd5;--accent2:#596ef3;--track:#e8eff2;--grid:#e5ecef;--danger:#e84e61}html[data-theme=dark]{color-scheme:dark;--bg:#071015;--panel:#0d181f;--text:#edfafd;--muted:#7f98a4;--border:#1b3039;--track:#17282f;--grid:#183039}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 85% 5%,color-mix(in srgb,var(--accent) 9%,transparent),transparent 32%),var(--bg);color:var(--text)}button,input,select{font:inherit}.center{min-height:100vh;display:grid;place-content:center;gap:24px}.spinner{width:34px;height:34px;border:3px solid var(--track);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;margin:auto}@keyframes spin{to{transform:rotate(360deg)}}.logo{display:flex;align-items:center;gap:10px;font-weight:700;letter-spacing:-.03em}.logo svg{color:var(--accent);width:22px}.setup{min-height:100vh;display:grid;grid-template-columns:1.1fr .9fr;align-items:center;gap:8vw;padding:7vw}.hero h1{font-size:clamp(42px,5vw,76px);line-height:1.05;letter-spacing:-.055em}.hero h1 span{color:var(--accent)}.hero p{color:var(--muted);font-size:18px}.panel{background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--border);border-radius:18px;box-shadow:0 16px 50px rgba(0,0,0,.05)}.form{padding:32px;display:flex;flex-direction:column;gap:18px}.form h2{margin:0;font-size:26px}.form small,.hint,.form p{color:var(--muted)}label{display:flex;flex-direction:column;gap:8px;font-size:13px;color:var(--muted)}input,select{border:1px solid var(--border);background:var(--bg);color:var(--text);border-radius:10px;padding:12px;outline:none}input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 16%,transparent)}button{border:0;border-radius:10px;padding:11px 16px;background:linear-gradient(120deg,var(--accent),var(--accent2));color:#fff;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:8px;font-weight:600}button svg{width:17px}.login{width:min(420px,90vw)}.error{color:var(--danger)!important}.app{display:grid;grid-template-columns:230px 1fr;min-height:100vh}aside{border-right:1px solid var(--border);padding:28px 18px;display:flex;flex-direction:column;position:sticky;top:0;height:100vh;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:blur(15px)}aside .logo{padding:0 10px 28px}nav{display:flex;flex-direction:column;gap:6px}aside button,.tabs button{justify-content:flex-start;background:transparent;color:var(--muted)}aside button:hover,.tabs button:hover,.tabs button.active{background:var(--track);color:var(--text)}.aside-bottom{margin-top:auto;display:flex;flex-direction:column;gap:5px}.content{padding:42px;max-width:1500px;width:100%;margin:auto}header{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px}header h1{margin:0;font-size:30px;letter-spacing:-.04em}header p{margin:8px 0;color:var(--muted)}.secondary{background:var(--panel);border:1px solid var(--border);color:var(--text)}.account{padding:20px 24px;display:flex;gap:14px;align-items:center;margin-bottom:20px}.account div{display:flex;flex-direction:column}.account small,.limit small,.stat small,.chart small{color:var(--muted)}.dot{width:10px;height:10px;border-radius:50%;background:var(--danger);box-shadow:0 0 10px currentColor}.dot.ok{background:#2fd397}.badge{background:var(--track);padding:6px 10px;border-radius:99px;font-size:12px}.fresh{margin-left:auto;color:var(--muted);font-size:12px}.limits{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:18px}.limit{padding:24px;display:grid;grid-template-columns:1fr auto;align-items:center}.limit strong{display:block;font-size:44px;font-variant-numeric:tabular-nums}.limit strong i{font-size:18px;font-style:normal}.limit div>span{color:var(--muted);font-size:12px}.ring{width:92px;height:92px;border-radius:50%;display:grid;place-items:center}.ring>div{width:72px;height:72px;background:var(--panel);border-radius:50%;display:grid;place-content:center;text-align:center;font-weight:700}.ring small{display:block;font-weight:400}.limit footer{grid-column:1/-1;border-top:1px solid var(--border);margin-top:20px;padding-top:15px;display:flex;justify-content:space-between;color:var(--muted);font-size:12px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:20px 0}.stat{padding:20px}.stat b{display:block;font-size:24px;margin-top:10px}.muted{font-size:14px!important;color:var(--muted)}.chart{padding:24px}.chart h3{margin:0 0 5px}.notice,.banner{padding:14px 18px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--border));background:color-mix(in srgb,var(--accent) 8%,var(--panel));border-radius:12px;margin-bottom:18px;color:var(--muted);display:flex;gap:10px}.notice svg{width:18px}.settings{display:grid;grid-template-columns:190px 1fr;gap:20px}.tabs{display:flex;flex-direction:column;gap:5px}.wide{max-width:860px}.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}.checks{display:flex;flex-direction:row;align-items:center;gap:10px}.checks input{width:auto}.actions{display:flex;gap:10px}.danger{background:transparent;border:1px solid var(--danger);color:var(--danger);margin-top:20px}.codebox{padding:16px;border:1px dashed var(--accent);border-radius:12px;display:flex;justify-content:space-between;align-items:center}.codebox strong{font-family:monospace;font-size:20px;color:var(--accent)}a{color:var(--accent)}@media(max-width:800px){.setup{grid-template-columns:1fr;padding:24px}.hero{display:none}.app{grid-template-columns:1fr}aside{position:static;height:auto;border:0;padding:14px;flex-direction:row;align-items:center;overflow:auto}aside .logo{padding:0;margin-right:auto}aside nav{flex-direction:row}.aside-bottom{margin:0;flex-direction:row}aside button{font-size:0;padding:10px}.content{padding:24px 16px}.stats{grid-template-columns:1fr 1fr}.settings{grid-template-columns:1fr}.tabs{flex-direction:row;overflow:auto}.grid2{grid-template-columns:1fr}.fresh{display:none}}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +:root{font-family:Inter,"PingFang SC",sans-serif;color-scheme:light;--bg:#f4f7fa;--panel:#fff;--text:#101820;--muted:#71808d;--border:#dfe7ec;--accent:#25bfd5;--accent2:#596ef3;--track:#e8eff2;--grid:#e5ecef;--danger:#e84e61;--green:#25b978}html[data-theme=dark]{color-scheme:dark;--bg:#071015;--panel:#0d181f;--text:#edfafd;--muted:#7f98a4;--border:#1b3039;--track:#263740;--grid:#183039;--green:#38d996}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 85% 5%,color-mix(in srgb,var(--accent) 9%,transparent),transparent 32%),var(--bg);color:var(--text)}button,input,select{font:inherit}.center{min-height:100vh;display:grid;place-content:center;gap:24px}.spinner{width:34px;height:34px;border:3px solid var(--track);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;margin:auto}@keyframes spin{to{transform:rotate(360deg)}}.logo{display:flex;align-items:center;gap:10px;font-weight:700;letter-spacing:-.03em}.logo svg{color:var(--accent);width:22px}.setup{min-height:100vh;display:grid;grid-template-columns:1.1fr .9fr;align-items:center;gap:8vw;padding:7vw}.hero h1{font-size:clamp(42px,5vw,76px);line-height:1.05;letter-spacing:-.055em}.hero h1 span{color:var(--accent)}.hero p{color:var(--muted);font-size:18px}.panel{background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--border);border-radius:18px;box-shadow:0 16px 50px rgba(0,0,0,.05)}.form{padding:32px;display:flex;flex-direction:column;gap:18px}.form h2{margin:0;font-size:26px}.form small,.hint,.form p{color:var(--muted)}label{display:flex;flex-direction:column;gap:8px;font-size:13px;color:var(--muted)}input,select{border:1px solid var(--border);background:var(--bg);color:var(--text);border-radius:10px;padding:12px;outline:none}input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 16%,transparent)}button{border:0;border-radius:10px;padding:11px 16px;background:linear-gradient(120deg,var(--accent),var(--accent2));color:#fff;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:8px;font-weight:600}button:disabled{opacity:.65;cursor:not-allowed}button svg{width:17px}.login{width:min(420px,90vw)}.error{color:var(--danger)!important}.app{display:grid;grid-template-columns:230px 1fr;min-height:100vh}aside{border-right:1px solid var(--border);padding:28px 18px;display:flex;flex-direction:column;position:sticky;top:0;height:100vh;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:blur(15px)}aside .logo{padding:0 10px 28px}nav{display:flex;flex-direction:column;gap:6px}aside button,.tabs button{justify-content:flex-start;background:transparent;color:var(--muted)}aside button:hover,.tabs button:hover,.tabs button.active{background:var(--track);color:var(--text)}.aside-bottom{margin-top:auto;display:flex;flex-direction:column;gap:5px}.content{padding:42px;max-width:1500px;width:100%;margin:auto}header{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px}header h1{margin:0;font-size:30px;letter-spacing:-.04em}header p{margin:8px 0;color:var(--muted)}.header-actions{display:flex;gap:10px}.account-select{min-width:220px}.secondary{background:var(--panel);border:1px solid var(--border);color:var(--text)}.refresh.loading svg{animation:spin .8s linear infinite}.refresh.done{color:var(--green);border-color:var(--green)}.account{padding:20px 24px;display:flex;gap:14px;align-items:center;margin-bottom:20px}.account div{display:flex;flex-direction:column}.account small,.limit small,.stat small,.chart small{color:var(--muted)}.dot{flex:none;width:10px;height:10px;border-radius:50%;background:var(--danger);box-shadow:0 0 10px currentColor}.dot.ok{background:var(--green)}.badge{background:var(--track);padding:6px 10px;border-radius:99px;font-size:12px}.fresh{margin-left:auto;color:var(--muted);font-size:12px}.limits{display:grid;grid-template-columns:repeat(auto-fit,minmax(350px,1fr));gap:18px}.limit{padding:24px;display:grid;grid-template-columns:1fr auto;gap:16px;align-items:start}.limit>small{grid-column:1/-1}.remaining strong{display:block;font-size:50px;line-height:1;font-variant-numeric:tabular-nums;color:var(--green)}.remaining strong i{font-size:20px;font-style:normal}.remaining span,.reset span{display:block;color:var(--muted);font-size:12px;margin-top:7px}.reset{text-align:right}.reset b{display:block;font-size:18px;margin-top:7px}.bar{grid-column:1/-1;display:flex;height:14px;border-radius:99px;overflow:hidden;background:var(--track)}.bar i{background:#9aa6ad}.bar em{background:var(--green)}.limit footer{grid-column:1/-1;border-top:1px solid var(--border);padding-top:14px;display:flex;gap:18px;color:var(--muted);font-size:12px}.limit footer span:last-child{margin-left:auto}.green{color:var(--green)}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:20px 0}.stat{padding:20px}.stat b{display:block;font-size:24px;margin-top:10px}.chart{padding:24px}.chart h3{margin:0 0 5px}.banner{padding:14px 18px;border:1px solid color-mix(in srgb,var(--danger) 35%,var(--border));background:color-mix(in srgb,var(--danger) 8%,var(--panel));border-radius:12px;margin-bottom:18px;color:var(--danger)}.empty{padding:32px;text-align:center;color:var(--muted)}.settings{display:grid;grid-template-columns:190px 1fr;gap:20px}.tabs{display:flex;flex-direction:column;gap:5px}.wide{max-width:960px}.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}.checks{display:flex;flex-direction:row;align-items:center;gap:10px}.checks input{width:auto}.actions,.settings-title{display:flex;gap:10px}.settings-title{justify-content:space-between;align-items:start}.settings-title p{margin-bottom:0}.danger{background:transparent;border:1px solid var(--danger);color:var(--danger)}.icon{padding:10px}.codebox{padding:16px;border:1px dashed var(--accent);border-radius:12px;display:flex;justify-content:space-between;align-items:center}.codebox strong{font-family:monospace;font-size:20px;color:var(--accent)}.account-list{display:flex;flex-direction:column;gap:10px}.account-row{display:grid;grid-template-columns:auto minmax(180px,1fr) auto auto auto;gap:10px;align-items:center;padding:14px;border:1px solid var(--border);border-radius:12px}.account-row>div{display:flex;flex-direction:column;gap:5px}.account-row input{padding:7px;font-weight:700}.account-row small{padding-left:8px}a{color:var(--accent)}@media(max-width:800px){.setup{grid-template-columns:1fr;padding:24px}.hero{display:none}.app{grid-template-columns:1fr}aside{position:static;height:auto;border:0;padding:14px;flex-direction:row;align-items:center;overflow:auto}aside .logo{padding:0;margin-right:auto}aside nav{flex-direction:row}.aside-bottom{margin:0;flex-direction:row}aside button{font-size:0;padding:10px}.content{padding:24px 16px}header{align-items:start;gap:15px}.header-actions{flex-direction:column}.account-select{min-width:0}.stats{grid-template-columns:1fr 1fr}.settings{grid-template-columns:1fr}.tabs{flex-direction:row;overflow:auto}.grid2{grid-template-columns:1fr}.fresh{display:none}.account-row{grid-template-columns:auto 1fr}.account-row button{grid-column:auto}.limit{grid-template-columns:1fr}.reset{text-align:left}.limit>small,.bar,.limit footer{grid-column:1}.settings-title{flex-direction:column}}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}