Add multi-account usage dashboard

This commit is contained in:
zhoujun0601
2026-08-13 07:19:11 -04:00
parent c12efdbe43
commit 4c05dde0f1
11 changed files with 573 additions and 135 deletions
+7 -6
View File
@@ -4,7 +4,8 @@
## 功能 ## 功能
- 展示 Codex 账户、套餐、限额使用率和下次重置时间 - 支持添加并切换多个 Codex 账号或工作区(包括同一邮箱的个人订阅与 Team 工作区)
- 展示 Codex 账户、套餐、剩余额度和下次重置时间
- 展示累计 Token、单日峰值、连续使用天数及每日 Token 趋势 - 展示累计 Token、单日峰值、连续使用天数及每日 Token 趋势
- 在本地 SQLite 中保留历史用量和限额快照 - 在本地 SQLite 中保留历史用量和限额快照
- 在限额重置前、重置后发送 Telegram 或邮件提醒 - 在限额重置前、重置后发送 Telegram 或邮件提醒
@@ -57,9 +58,9 @@ http://服务器地址:8080
7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“立即刷新”。 7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“立即刷新”。
8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。 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 会话。即使关闭查询菜单,仍可完成绑定并接收提醒;开启菜单后可使用以下按钮或命令: 绑定码十分钟内有效,一个实例只绑定一个 Telegram 会话。即使关闭查询菜单,仍可完成绑定并接收提醒;开启菜单后可使用以下按钮或命令:
- 当前用量 - 当前用量(包含所有已添加连接)
- 重置时间 / `/reset` - 重置时间 / `/reset`
- 历史概览 / `/usage` - 历史概览 / `/usage`
- 账户信息 / `/account` - 账户信息 / `/account`
@@ -163,10 +164,10 @@ Codex Helper 展示的是 Codex app-server 实际返回的数据。当前接口
- ChatGPT/Codex 账户和套餐类型 - ChatGPT/Codex 账户和套餐类型
- 限额窗口使用百分比、窗口长度和重置时间 - 限额窗口使用百分比、窗口长度和重置时间
- 累计 Token、单日峰值、连续使用天数等摘要 - 累计 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 用量数据。
## 常见问题 ## 常见问题
+159 -29
View File
@@ -18,7 +18,16 @@ import (
func (a *App) api(w http.ResponseWriter, r *http.Request) { func (a *App) api(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/api/v1/") p := strings.TrimPrefix(r.URL.Path, "/api/v1/")
if p == "system/status" { 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 return
} }
if p == "setup" && r.Method == "POST" { 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}) jsonOut(w, 200, map[string]string{"username": username})
case p == "auth/logout" && r.Method == "POST": case p == "auth/logout" && r.Method == "POST":
a.logout(w, r) 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": case p == "dashboard":
a.mu.RLock() id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64)
d := a.dash if id == 0 {
a.mu.RUnlock() id = 1
jsonOut(w, 200, d) }
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": 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 { if e != nil {
jsonOut(w, 502, map[string]string{"error": e.Error()}) jsonOut(w, 502, map[string]string{"error": e.Error()})
} else { } else {
@@ -65,16 +115,6 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
code := fmt.Sprintf("%06d", time.Now().UnixNano()%1000000) 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()}) _ = 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}) 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": case p == "maintenance/cleanup" && r.Method == "POST":
n, e := a.store.Cleanup(a.general().RetentionDays) n, e := a.store.Cleanup(a.general().RetentionDays)
if e != nil { 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) { func (a *App) setup(w http.ResponseWriter, r *http.Request) {
if a.store.Initialized() { if a.store.Initialized() {
jsonOut(w, 409, map[string]string{"error": "系统已初始化"}) 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) _ = a.store.SetJSON("general", g)
jsonOut(w, 200, 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 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 { if e != nil {
jsonOut(w, 502, map[string]string{"error": e.Error()}) jsonOut(w, 502, map[string]string{"error": e.Error()})
return return
@@ -221,8 +338,14 @@ func (a *App) deviceLogin(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, out) jsonOut(w, 200, out)
} }
func (a *App) sync(ctx context.Context) error { func (a *App) syncAccount(ctx context.Context, id int64) error {
if !a.codex.Connected() { 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 未连接") return fmt.Errorf("app-server 未连接")
} }
ctx, c := context.WithTimeout(ctx, 20*time.Second) ctx, c := context.WithTimeout(ctx, 20*time.Second)
@@ -234,10 +357,18 @@ func (a *App) sync(ctx context.Context) error {
PlanType *string `json:"planType"` PlanType *string `json:"planType"`
} `json:"account"` } `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 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 { if ar.Account != nil {
d.Account.Email = ar.Account.Email d.Account.Email = ar.Account.Email
d.Account.PlanType = ar.Account.PlanType d.Account.PlanType = ar.Account.PlanType
@@ -247,7 +378,7 @@ func (a *App) sync(ctx context.Context) error {
RateLimits *rawLimit `json:"rateLimits"` RateLimits *rawLimit `json:"rateLimits"`
By map[string]rawLimit `json:"rateLimitsByLimitId"` 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 { if len(lr.By) > 0 {
for _, x := range lr.By { for _, x := range lr.By {
d.Limits = append(d.Limits, flattenLimit(x)...) d.Limits = append(d.Limits, flattenLimit(x)...)
@@ -263,20 +394,19 @@ func (a *App) sync(ctx context.Context) error {
Tokens int64 `json:"tokens"` Tokens int64 `json:"tokens"`
} `json:"dailyUsageBuckets"` } `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 d.Summary = ur.Summary
for _, x := range ur.Daily { for _, x := range ur.Daily {
p := UsagePoint{Date: x.StartDate, TotalTokens: x.Tokens} p := UsagePoint{Date: x.StartDate, TotalTokens: x.Tokens}
d.Usage = append(d.Usage, p) 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 { 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() rt.dash = d
a.dash = d _ = a.store.UpdateAccount(id, d.Account.Email, d.Account.PlanType, d.Account.Connected)
a.mu.Unlock()
return nil return nil
} }
+75 -23
View File
@@ -29,14 +29,18 @@ type App struct {
dataDir string dataDir string
store *store.Store store *store.Store
vault *security.Vault vault *security.Vault
codex *codex.Client
server *http.Server server *http.Server
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
mu sync.RWMutex mu sync.RWMutex
dash Dashboard runtimes map[int64]*accountRuntime
loginAttempts sync.Map loginAttempts sync.Map
} }
type accountRuntime struct {
client *codex.Client
dash Dashboard
syncing sync.Mutex
}
func New() (*App, error) { func New() (*App, error) {
dir := env("DATA_DIR", "/data") dir := env("DATA_DIR", "/data")
@@ -49,8 +53,11 @@ func New() (*App, error) {
return nil, e return nil, e
} }
ctx, cancel := context.WithCancel(context.Background()) 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 := &App{dataDir: dir, store: s, vault: v, ctx: ctx, cancel: cancel, runtimes: map[int64]*accountRuntime{}}
a.codex = codex.New(filepath.Join(dir, "codex"), a.onCodexNotification) 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} a.server = &http.Server{Addr: env("LISTEN_ADDR", ":8080"), Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
return a, nil return a, nil
} }
@@ -79,45 +86,81 @@ func (a *App) Close() {
ctx, c := context.WithTimeout(context.Background(), 5*time.Second) ctx, c := context.WithTimeout(context.Background(), 5*time.Second)
defer c() defer c()
_ = a.server.Shutdown(ctx) _ = 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() _ = a.store.DB.Close()
} }
func (a *App) keepCodex() { func (a *App) keepCodex() {
delay := time.Second
for { for {
select { select {
case <-a.ctx.Done(): case <-a.ctx.Done():
return return
default: default:
} }
if !a.codex.Connected() { a.mu.RLock()
if e := a.codex.Start(a.ctx); e == nil { 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) ctx, c := context.WithTimeout(a.ctx, 20*time.Second)
e = a.codex.Initialize(ctx) e = rt.client.Initialize(ctx)
c() c()
if e == nil { if e == nil {
delay = time.Second _ = a.syncAccount(context.Background(), id)
_ = a.sync(context.Background())
} else { } else {
log.Printf("app-server initialize: %v", e) log.Printf("app-server initialize: %v", e)
// A live process is not necessarily an initialized process. Tear it // A live process is not necessarily an initialized process. Tear it
// down so the next iteration starts a fresh protocol session. // down so the next iteration starts a fresh protocol session.
_ = a.codex.Close() _ = rt.client.Close()
}
}
if !a.codex.Connected() {
time.Sleep(delay)
if delay < 30*time.Second {
delay *= 2
} }
} }
} }
time.Sleep(time.Second) time.Sleep(time.Second)
} }
} }
func (a *App) onCodexNotification(method string, _ json.RawMessage) { func (a *App) onCodexNotification(id int64) func(string, json.RawMessage) {
if method == "account/updated" || method == "account/rateLimits/updated" { return func(method string, _ json.RawMessage) {
go a.sync(context.Background()) 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() { func (a *App) scheduler() {
@@ -130,7 +173,7 @@ func (a *App) scheduler() {
case <-t.C: case <-t.C:
g := a.general() g := a.general()
if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 { if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 {
_ = a.sync(context.Background()) a.syncAll(context.Background())
} }
_, _ = a.store.Cleanup(g.RetentionDays) _, _ = a.store.Cleanup(g.RetentionDays)
go a.processReminders() go a.processReminders()
@@ -146,7 +189,16 @@ func (a *App) routes() http.Handler {
jsonOut(w, 503, map[string]string{"error": e.Error()}) jsonOut(w, 503, map[string]string{"error": e.Error()})
return 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) m.HandleFunc("/api/v1/", a.api)
sub, _ := fs.Sub(webassets.Assets, "dist") sub, _ := fs.Sub(webassets.Assets, "dist")
+78 -56
View File
@@ -291,30 +291,45 @@ func (a *App) handleTG(t TelegramSettings, chat int64, text string) {
return return
} }
if text == "立即刷新" || text == "/refresh" { if text == "立即刷新" || text == "/refresh" {
_ = a.sync(context.Background()) a.syncAll(context.Background())
} }
a.mu.RLock()
d := a.dash
a.mu.RUnlock()
msg := "Codex 用量\n" 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 { switch text {
case "重置时间", "/reset": case "重置时间", "/reset":
for _, x := range d.Limits { for _, d := range ds {
msg += fmt.Sprintf("%s/%s:%s\n", x.LimitID, x.WindowType, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) 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": case "账户信息", "/account":
msg += fmt.Sprintf("连接:%v\n", d.Account.Connected) for _, d := range ds {
if d.Account.Email != nil { msg += fmt.Sprintf("[%s] 连接:%v\n", d.DisplayName, d.Account.Connected)
msg += "账户:" + *d.Account.Email + "\n" if d.Account.Email != nil {
} msg += "账户:" + *d.Account.Email + "\n"
if d.Account.PlanType != nil { }
msg += "套餐:" + *d.Account.PlanType if d.Account.PlanType != nil {
msg += "套餐:" + *d.Account.PlanType + "\n"
}
} }
case "历史概览", "/usage": 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: default:
for _, x := range d.Limits { for _, d := range ds {
msg += fmt.Sprintf("%s/%s:%.1f%%,重置 %s\n", x.LimitID, x.WindowType, x.UsedPercent, time.Unix(x.ResetsAt, 0).Format(time.RFC3339)) 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) _ = tgSend(t, msg)
@@ -328,57 +343,64 @@ func num(n *int64) string {
func (a *App) processReminders() { func (a *App) processReminders() {
g := a.general() g := a.general()
a.mu.RLock() 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() a.mu.RUnlock()
now := time.Now() now := time.Now()
for _, x := range d.Limits { for _, d := range ds {
for _, kind := range []string{"before", "after"} { for _, x := range d.Limits {
if kind == "before" && !g.NotifyBefore { for _, kind := range []string{"before", "after"} {
continue 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())
} }
} if kind == "after" && !g.NotifyAfter {
if s, e := a.smtpSecret(); e == nil && s.Enabled { continue
if e = sendSMTP(s, "Codex 用量重置提醒", body); e != nil {
ok = false
errs = append(errs, e.Error())
} }
} at := time.Unix(x.ResetsAt, 0)
status := "sent" if kind == "before" {
var sent any = time.Now().Unix() at = at.Add(-time.Duration(g.BeforeMinutes) * time.Minute)
if !ok { }
status = "failed" if now.Before(at) || now.Sub(at) > 6*time.Hour {
sent = nil continue
} }
_, _ = a.store.DB.Exec(`INSERT INTO notifications(dedupe_key,channel,kind,status,attempts,last_error,scheduled_at,sent_at) 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(?,?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(dedupe_key) DO UPDATE SET ON CONFLICT(dedupe_key) DO UPDATE SET
status=excluded.status, status=excluded.status,
attempts=notifications.attempts+1, attempts=notifications.attempts+1,
last_error=excluded.last_error, last_error=excluded.last_error,
sent_at=excluded.sent_at`, key, "configured", kind, status, 1, strings.Join(errs, "; "), at.Unix(), sent) sent_at=excluded.sent_at`, key, "configured", kind, status, 1, strings.Join(errs, "; "), at.Unix(), sent)
}
} }
} }
} }
+9 -7
View File
@@ -67,13 +67,15 @@ type UsagePoint struct {
OutputTokens *int64 `json:"outputTokens"` OutputTokens *int64 `json:"outputTokens"`
} }
type Dashboard struct { type Dashboard struct {
Account AccountView `json:"account"` AccountID int64 `json:"accountId"`
Limits []LimitBucket `json:"limits"` DisplayName string `json:"displayName"`
Summary UsageSummary `json:"summary"` Account AccountView `json:"account"`
Usage []UsagePoint `json:"usage"` Limits []LimitBucket `json:"limits"`
FetchedAt int64 `json:"fetchedAt"` Summary UsageSummary `json:"summary"`
Stale bool `json:"stale"` Usage []UsagePoint `json:"usage"`
LastError string `json:"lastError,omitempty"` FetchedAt int64 `json:"fetchedAt"`
Stale bool `json:"stale"`
LastError string `json:"lastError,omitempty"`
} }
func defaults() GeneralSettings { func defaults() GeneralSettings {
+132 -1
View File
@@ -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); 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); 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, &notnull, &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) { func (s *Store) Get(key string) (string, bool) {
+98
View File
@@ -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) { func TestBackupIncludesCommittedWALData(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
s, err := Open(dir) s, err := Open(dir)
+2 -2
View File
@@ -1,3 +1,3 @@
<script type="module" crossorigin src="/assets/index-Bcayo4C9.js"></script> <script type="module" crossorigin src="/assets/index-CCkNuko2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4ZT533H.css"> <link rel="stylesheet" crossorigin href="/assets/index-2I4Pedo2.css">
<div id="root"></div> <div id="root"></div>
+1
View File
@@ -1,3 +1,4 @@
export async function api<T>(path:string,init:RequestInit={}):Promise<T>{const r=await fetch('/api/v1/'+path,{credentials:'same-origin',...init,headers:{'Content-Type':'application/json','X-Requested-With':'codex-helper',...(init.headers||{})}});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);return j} export async function api<T>(path:string,init:RequestInit={}):Promise<T>{const r=await fetch('/api/v1/'+path,{credentials:'same-origin',...init,headers:{'Content-Type':'application/json','X-Requested-With':'codex-helper',...(init.headers||{})}});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||`HTTP ${r.status}`);return j}
export const post=<T>(p:string,v:unknown={})=>api<T>(p,{method:'POST',body:JSON.stringify(v)}) export const post=<T>(p:string,v:unknown={})=>api<T>(p,{method:'POST',body:JSON.stringify(v)})
export const put=<T>(p:string,v:unknown)=>api<T>(p,{method:'PUT',body:JSON.stringify(v)}) export const put=<T>(p:string,v:unknown)=>api<T>(p,{method:'PUT',body:JSON.stringify(v)})
export const del=<T>(p:string)=>api<T>(p,{method:'DELETE'})
+11 -10
View File
@@ -1,28 +1,29 @@
import React,{useEffect,useState} from 'react' import React,{useEffect,useState} from 'react'
import{createRoot}from'react-dom/client' import{createRoot}from'react-dom/client'
import{BrowserRouter,Navigate,Route,Routes,useNavigate}from'react-router-dom' 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{Area,AreaChart,CartesianGrid,ResponsiveContainer,Tooltip,XAxis,YAxis}from'recharts'
import{api,post}from'./api' import{api,del,post,put}from'./api'
import'./styles.css' 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<Status|null>(null);const[auth,setAuth]=useState<boolean|null>(null);useEffect(()=>{api<Status>('system/status').then(x=>{setS(x);if(x.initialized)api('auth/me').then(()=>setAuth(true)).catch(()=>setAuth(false));else setAuth(false)})},[]);if(!s||auth===null)return <Splash/>;if(!s.initialized)return <Setup onDone={()=>{setS({...s,initialized:true});setAuth(true)}}/>;return <BrowserRouter><Routes>{auth?<Route path="*" element={<Shell logout={()=>setAuth(false)}/>}/>:<Route path="*" element={<Login done={()=>setAuth(true)}/>}/>}</Routes></BrowserRouter>} function App(){const[s,setS]=useState<Status|null>(null);const[auth,setAuth]=useState<boolean|null>(null);useEffect(()=>{api<Status>('system/status').then(x=>{setS(x);if(x.initialized)api('auth/me').then(()=>setAuth(true)).catch(()=>setAuth(false));else setAuth(false)})},[]);if(!s||auth===null)return <Splash/>;if(!s.initialized)return <Setup onDone={()=>{setS({...s,initialized:true});setAuth(true)}}/>;return <BrowserRouter><Routes>{auth?<Route path="*" element={<Shell logout={()=>setAuth(false)}/>}/>:<Route path="*" element={<Login done={()=>setAuth(true)}/>}/>}</Routes></BrowserRouter>}
const Splash=()=> <main className="center"><div className="logo"><Zap/> Codex Helper</div><div className="spinner"/></main>; const Splash=()=> <main className="center"><div className="logo"><Zap/> Codex Helper</div><div className="spinner"/></main>;
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 <main className="setup"><section className="hero"><div className="logo"><Zap/> Codex Helper</div><h1>连接你的 Codex<br/><span>看见每一次增长</span></h1><p>一个容器内完成用量洞察、重置提醒与账户管理。</p></section><form className="panel form" onSubmit={submit}><small>首次初始化 · 1 / 1</small><h2>创建管理员</h2><label>用户名<input value={form.username} onChange={e=>set({...form,username:e.target.value})}/></label><label>密码<input type="password" minLength={10} value={form.password} onChange={e=>set({...form,password:e.target.value})} placeholder="至少 10 位"/></label><label>时区<input value={form.timezone} onChange={e=>set({...form,timezone:e.target.value})}/></label>{err&&<p className="error">{err}</p>}<button>完成初始化</button><p className="hint">Codex、Telegram 与 SMTP 可在进入系统后通过设置中心完成。</p></form></main>} 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 <main className="setup"><section className="hero"><div className="logo"><Zap/> Codex Helper</div><h1>连接你的 Codex<br/><span>看见每一次增长</span></h1><p>一个容器内完成用量洞察、重置提醒与账户管理。</p></section><form className="panel form" onSubmit={submit}><small>首次初始化 · 1 / 1</small><h2>创建管理员</h2><label>用户名<input value={form.username} onChange={e=>set({...form,username:e.target.value})}/></label><label>密码<input type="password" minLength={10} value={form.password} onChange={e=>set({...form,password:e.target.value})} placeholder="至少 10 位"/></label><label>时区<input value={form.timezone} onChange={e=>set({...form,timezone:e.target.value})}/></label>{err&&<p className="error">{err}</p>}<button>完成初始化</button><p className="hint">Codex、Telegram 与 SMTP 可在进入系统后通过设置中心完成。</p></form></main>}
function Login({done}:{done:()=>void}){const[u,setU]=useState('admin'),[p,setP]=useState(''),[e,setE]=useState('');return <main className="center"><form className="panel form login" onSubmit={async x=>{x.preventDefault();try{await post('auth/login',{username:u,password:p});done()}catch(q){setE((q as Error).message)}}}><div className="logo"><Zap/> Codex Helper</div><h2>欢迎回来</h2><label>用户名<input value={u} onChange={x=>setU(x.target.value)}/></label><label>密码<input type="password" value={p} onChange={x=>setP(x.target.value)}/></label>{e&&<p className="error">{e}</p>}<button>登录</button></form></main>} function Login({done}:{done:()=>void}){const[u,setU]=useState('admin'),[p,setP]=useState(''),[e,setE]=useState('');return <main className="center"><form className="panel form login" onSubmit={async x=>{x.preventDefault();try{await post('auth/login',{username:u,password:p});done()}catch(q){setE((q as Error).message)}}}><div className="logo"><Zap/> Codex Helper</div><h2>欢迎回来</h2><label>用户名<input value={u} onChange={x=>setU(x.target.value)}/></label><label>密码<input type="password" value={p} onChange={x=>setP(x.target.value)}/></label>{e&&<p className="error">{e}</p>}<button>登录</button></form></main>}
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 <div className="app"><aside><div className="logo"><Zap/> Codex Helper</div><nav><button onClick={()=>nav('/')}><Activity/>总览</button><button onClick={()=>nav('/history')}><ChartNoAxesCombined/>历史用量</button><button onClick={()=>nav('/settings')}><Settings/>设置中心</button></nav><div className="aside-bottom"><button onClick={()=>setTheme(theme==='dark'?'light':'dark')}>{theme==='dark'?<Sun/>:<Moon/>}切换主题</button><button onClick={async()=>{await post('auth/logout');logout()}}><LogOut/>退出</button></div></aside><section className="content"><Routes><Route path="/" element={<Dashboard/>}/><Route path="/history" element={<History/>}/><Route path="/settings" element={<SettingsPage/>}/><Route path="*" element={<Navigate to="/"/>}/></Routes></section></div>} 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 <div className="app"><aside><div className="logo"><Zap/> Codex Helper</div><nav><button onClick={()=>nav('/')}><Activity/>总览</button><button onClick={()=>nav('/settings')}><Settings/>设置中心</button></nav><div className="aside-bottom"><button onClick={()=>setTheme(theme==='dark'?'light':'dark')}>{theme==='dark'?<Sun/>:<Moon/>}切换主题</button><button onClick={async()=>{await post('auth/logout');logout()}}><LogOut/>退出</button></div></aside><section className="content"><Routes><Route path="/" element={<Dashboard/>}/><Route path="/settings" element={<SettingsPage/>}/><Route path="*" element={<Navigate to="/"/>}/></Routes></section></div>}
function useDash(){const[d,setD]=useState<Dash|null>(null),[e,setE]=useState('');const load=()=>api<Dash>('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[accounts,setAccounts]=useState<Account[]>([]),[id,setId]=useState(+(localStorage.accountId||0)),[d,setD]=useState<Dash|null>(null),[e,setE]=useState(''),[refresh,setRefresh]=useState<'idle'|'loading'|'done'>('idle');const loadAccounts=()=>api<Account[]>('accounts').then(xs=>{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<Dash>('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 <><Header title="用量总览" sub="请先在设置中心添加账号"/><div className="panel empty">尚未添加 Codex 账号</div></>;if(!d)return <Splash/>;return <><Header title="用量总览" sub="实时掌握 Codex 账户限额与重置周期"><div className="header-actions"><select className="account-select" value={id} onChange={x=>setId(+x.target.value)}>{accounts.map(x=><option key={x.id} value={x.id}>{x.displayName}{x.email?' · '+x.email:''}</option>)}</select><button className={'secondary refresh '+refresh} disabled={refresh==='loading'} onClick={sync}>{refresh==='done'?<Check/>:<RefreshCw/>}{refresh==='loading'?'刷新中':refresh==='done'?'刷新完成':'立即刷新'}</button></div></Header>{e&&<div className="banner">{e}</div>}<div className="account panel"><span className={'dot '+(d.account.connected?'ok':'')}/><div><small>Codex Account</small><b>{d.displayName} · {d.account.email||'尚未连接'}</b></div><span className="badge">{d.account.planType||'未识别套餐'}</span><span className="fresh">更新于 {d.fetchedAt?new Date(d.fetchedAt*1000).toLocaleString():'尚未同步'}</span></div><div className="limits">{d.limits.map((x,i)=><LimitCard key={i} x={x}/>)}</div>{!d.limits.length&&<div className="panel empty">该连接暂无限额数据,请确认登录后刷新。</div>}<div className="stats"><Stat label="累计 Tokens" v={num(d.summary.lifetimeTokens)}/><Stat label="单日峰值" v={num(d.summary.peakDailyTokens)}/><Stat label="连续使用" v={d.summary.currentStreakDays==null?'暂无':d.summary.currentStreakDays+' 天'}/><Stat label="最长任务时长" v={duration(d.summary.longestRunningTurnSec)}/></div><Chart data={d.usage}/></>}
function Dashboard(){const{d,e,load}=useDash();if(!d)return <Splash/>;return <><Header title="用量总览" sub="实时掌握 Codex 账户限额与重置周期"><button className="secondary" onClick={async()=>{await post('sync');load()}}><RefreshCw/>立即刷新</button></Header>{e&&<div className="banner">{e}</div>}<div className="account panel"><span className={'dot '+(d.account.connected?'ok':'')}/><div><small>CODEx ACCOUNT</small><b>{d.account.email||'尚未连接'}</b></div><span className="badge">{d.account.planType||'未识别套餐'}</span><span className="fresh">更新于 {new Date(d.fetchedAt*1000).toLocaleString()}</span></div><div className="limits">{d.limits.map((x,i)=><LimitCard key={i} x={x}/>)}</div><div className="stats"><Stat label="累计 Tokens" v={num(d.summary.lifetimeTokens)}/><Stat label="单日峰值" v={num(d.summary.peakDailyTokens)}/><Stat label="连续使用" v={d.summary.currentStreakDays==null?'暂无':d.summary.currentStreakDays+' 天'}/><Stat label="调用次数" v="暂不提供" muted/></div><Chart data={d.usage}/></>} function LimitCard({x}:{x:Limit}){const used=Math.min(100,Math.max(0,x.usedPercent)),left=100-used;return <div className="panel limit"><small>{x.limitName||x.limitId} · {x.windowType}</small><div className="remaining"><strong>{left.toFixed(1)}<i>%</i></strong><span>剩余</span></div><div className="reset"><small>下次重置</small><b>{x.resetsAt?new Date(x.resetsAt*1000).toLocaleString():'暂未提供'}</b><span>{x.resetsAt?relative(x.resetsAt):''}</span></div><div className="bar" aria-label={`已使用 ${used.toFixed(1)}%,剩余 ${left.toFixed(1)}%`}><i style={{width:used+'%'}}/><em style={{width:left+'%'}}/></div><footer><span>已使用 {used.toFixed(1)}%</span><span className="green">剩余 {left.toFixed(1)}%</span><span>{x.windowDurationMinutes||'—'} 分钟窗口</span></footer></div>}
function LimitCard({x}:{x:Limit}){const left=Math.max(0,100-x.usedPercent);return <div className="panel limit"><div><small>{x.limitName||x.limitId} · {x.windowType}</small><strong>{x.usedPercent.toFixed(1)}<i>%</i></strong><span>已使用</span></div><div className="ring" style={{background:`conic-gradient(var(--accent) ${x.usedPercent}%,var(--track) 0)`}}><div>{left.toFixed(0)}%<small>剩余</small></div></div><footer><span>{x.windowDurationMinutes} 分钟窗口</span><span>重置 {new Date(x.resetsAt*1000).toLocaleString()}</span></footer></div>}
const Stat=({label,v,muted=false}:{label:string,v:string,muted?:boolean})=><div className="panel stat"><small>{label}</small><b className={muted?'muted':''}>{v}</b></div>; const Stat=({label,v,muted=false}:{label:string,v:string,muted?:boolean})=><div className="panel stat"><small>{label}</small><b className={muted?'muted':''}>{v}</b></div>;
const Chart=({data}:{data:Point[]})=><div className="panel chart"><div><h3>每日 Token 趋势</h3><small>Codex app server 提供的每日总量</small></div><ResponsiveContainer width="100%" height={280}><AreaChart data={data}><defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#5ee7f7" stopOpacity={.35}/><stop offset="1" stopColor="#5ee7f7" stopOpacity={0}/></linearGradient></defs><CartesianGrid stroke="var(--grid)" vertical={false}/><XAxis dataKey="date" stroke="var(--muted)"/><YAxis stroke="var(--muted)"/><Tooltip contentStyle={{background:'var(--panel)',border:'1px solid var(--border)',borderRadius:12}}/><Area type="monotone" dataKey="totalTokens" stroke="#4dd8ed" fill="url(#a)" strokeWidth={2}/></AreaChart></ResponsiveContainer></div>; const Chart=({data}:{data:Point[]})=><div className="panel chart"><div><h3>每日 Token 趋势</h3><small>Codex app server 提供的每日总量</small></div><ResponsiveContainer width="100%" height={280}><AreaChart data={data}><defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#5ee7f7" stopOpacity={.35}/><stop offset="1" stopColor="#5ee7f7" stopOpacity={0}/></linearGradient></defs><CartesianGrid stroke="var(--grid)" vertical={false}/><XAxis dataKey="date" stroke="var(--muted)"/><YAxis width={55} tickCount={5} tickFormatter={compactAxis} stroke="var(--muted)"/><Tooltip formatter={(v)=>[new Intl.NumberFormat('zh-CN').format(Number(v)),'Tokens']} contentStyle={{background:'var(--panel)',border:'1px solid var(--border)',borderRadius:12}}/><Area type="monotone" dataKey="totalTokens" stroke="#4dd8ed" fill="url(#a)" strokeWidth={2}/></AreaChart></ResponsiveContainer></div>;
function History(){const{d}=useDash();return <><Header title="历史用量" sub="最多展示本地保留期内的每日总 Token"/>{d?<><div className="notice"><Database/> 调用次数、输入 Token 和输出 Token 暂未由 Codex app server 提供。</div><Chart data={d.usage}/></>:<Splash/>}</>}
function SettingsPage(){const[tab,setTab]=useState('general');return <><Header title="设置中心" sub="所有运行期配置均可在这里完成"/><div className="settings"><div className="tabs"><button className={tab==='general'?'active':''} onClick={()=>setTab('general')}><Settings/>通用</button><button className={tab==='codex'?'active':''} onClick={()=>setTab('codex')}><Zap/>Codex</button><button className={tab==='telegram'?'active':''} onClick={()=>setTab('telegram')}><Bell/>Telegram</button><button className={tab==='smtp'?'active':''} onClick={()=>setTab('smtp')}><Bell/>SMTP 邮件</button></div>{tab==='general'?<General/>:tab==='codex'?<CodexSettings/>:tab==='telegram'?<Telegram/>:<SMTP/>}</div></>} function SettingsPage(){const[tab,setTab]=useState('general');return <><Header title="设置中心" sub="所有运行期配置均可在这里完成"/><div className="settings"><div className="tabs"><button className={tab==='general'?'active':''} onClick={()=>setTab('general')}><Settings/>通用</button><button className={tab==='codex'?'active':''} onClick={()=>setTab('codex')}><Zap/>Codex</button><button className={tab==='telegram'?'active':''} onClick={()=>setTab('telegram')}><Bell/>Telegram</button><button className={tab==='smtp'?'active':''} onClick={()=>setTab('smtp')}><Bell/>SMTP 邮件</button></div>{tab==='general'?<General/>:tab==='codex'?<CodexSettings/>:tab==='telegram'?<Telegram/>:<SMTP/>}</div></>}
function General(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/general').then(setV)},[]);if(!v)return <Splash/>;return <form className="panel form wide" onSubmit={async e=>{e.preventDefault();try{await api('settings/general',{method:'PUT',body:JSON.stringify(v)});setMsg('设置已保存')}catch(x){setMsg((x as Error).message)}}}><h2>通用设置</h2><div className="grid2"><label>时区<input value={v.timezone} onChange={e=>setV({...v,timezone:e.target.value})}/></label><label>主题<select value={v.theme} onChange={e=>setV({...v,theme:e.target.value})}><option value="system">跟随系统</option><option value="dark">深色</option><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><label>历史保留(天)<select value={v.retentionDays} onChange={e=>setV({...v,retentionDays:+e.target.value})}>{[30,60,90,180,365].map(x=><option key={x}>{x}</option>)}</select></label><label>提前提醒(分钟)<input type="number" min="1" max="1440" value={v.beforeMinutes} onChange={e=>setV({...v,beforeMinutes:+e.target.value})}/></label><label className="checks"><input type="checkbox" checked={v.notifyBefore} onChange={e=>setV({...v,notifyBefore:e.target.checked})}/>重置前提醒 <input type="checkbox" checked={v.notifyAfter} onChange={e=>setV({...v,notifyAfter:e.target.checked})}/>重置后确认</label></div><button>保存设置</button>{msg&&<p className="hint">{msg}</p>}</form>} function General(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/general').then(setV)},[]);if(!v)return <Splash/>;return <form className="panel form wide" onSubmit={async e=>{e.preventDefault();try{await api('settings/general',{method:'PUT',body:JSON.stringify(v)});setMsg('设置已保存')}catch(x){setMsg((x as Error).message)}}}><h2>通用设置</h2><div className="grid2"><label>时区<input value={v.timezone} onChange={e=>setV({...v,timezone:e.target.value})}/></label><label>主题<select value={v.theme} onChange={e=>setV({...v,theme:e.target.value})}><option value="system">跟随系统</option><option value="dark">深色</option><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><label>历史保留(天)<select value={v.retentionDays} onChange={e=>setV({...v,retentionDays:+e.target.value})}>{[30,60,90,180,365].map(x=><option key={x}>{x}</option>)}</select></label><label>提前提醒(分钟)<input type="number" min="1" max="1440" value={v.beforeMinutes} onChange={e=>setV({...v,beforeMinutes:+e.target.value})}/></label><label className="checks"><input type="checkbox" checked={v.notifyBefore} onChange={e=>setV({...v,notifyBefore:e.target.checked})}/>重置前提醒 <input type="checkbox" checked={v.notifyAfter} onChange={e=>setV({...v,notifyAfter:e.target.checked})}/>重置后确认</label></div><button>保存设置</button>{msg&&<p className="hint">{msg}</p>}</form>}
function CodexSettings(){const[result,setResult]=useState<any>(null),[err,setErr]=useState('');return <div className="panel form wide"><h2>Codex 账户</h2><p>使用设备码登录,适合 Docker 与远程部署。Codex app server 将安全管理登录凭据。</p><button onClick={async()=>{try{setResult(await post('codex/login/device'));setErr('')}catch(x){setErr((x as Error).message)}}}>生成设备码</button>{result&&<div className="codebox"><span>访问 <a href={result.verificationUrl} target="_blank">{result.verificationUrl}</a></span><strong>{result.userCode}</strong></div>}{err&&<p className="error">{err}</p>}<button className="danger" onClick={()=>post('codex/logout')}>退出 Codex 账户</button></div>} function CodexSettings(){const[xs,setXs]=useState<Account[]>([]),[result,setResult]=useState<any>(null),[active,setActive]=useState(0),[err,setErr]=useState('');const load=()=>api<Account[]>('accounts').then(setXs);useEffect(()=>{load()},[]);const add=async()=>{try{const x=await post<Account>('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 <div className="panel form wide"><div className="settings-title"><div><h2>Codex 账户与工作区</h2><p>个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。</p></div><button onClick={add}>添加账号</button></div><div className="account-list">{xs.map(x=><div className="account-row" key={x.id}><span className={'dot '+(x.connected?'ok':'')}/><div><input aria-label="连接名称" defaultValue={x.displayName} onBlur={async e=>{const name=e.target.value.trim();if(name&&name!==x.displayName){await put(`accounts/${x.id}`,{displayName:name});load()}}}/><small>{x.email||'尚未登录'} · {x.planType||'未识别套餐'}</small></div><button className="secondary" onClick={()=>login(x.id)}>设备码登录</button><button className="secondary" onClick={async()=>{await post(`accounts/${x.id}/logout`);load()}}>退出</button><button className="icon danger" title="删除连接" onClick={async()=>{if(confirm(`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`)){await del(`accounts/${x.id}`);if(+localStorage.accountId===x.id)localStorage.removeItem('accountId');load()}}}><Trash2/></button></div>)}</div>{result&&<div className="codebox"><span>为“{xs.find(x=>x.id===active)?.displayName}”访问 <a href={result.verificationUrl} target="_blank" rel="noreferrer">{result.verificationUrl}</a></span><strong>{result.userCode}</strong></div>}{err&&<p className="error">{err}</p>}</div>}
function Telegram(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState(''),[code,setCode]=useState('');useEffect(()=>{api('settings/telegram').then(setV)},[]);if(!v)return <Splash/>;return <form className="panel form wide" onSubmit={async e=>{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)}}}><h2>Telegram Bot</h2><label>Bot Token<input type="password" value={v.token||''} onChange={e=>setV({...v,token:e.target.value})} placeholder={v.configured?'已配置,留空表示不修改':'从 BotFather 获取'}/></label><div className="checks"><label><input type="checkbox" checked={v.enabled} onChange={e=>setV({...v,enabled:e.target.checked})}/>启用提醒</label><label><input type="checkbox" checked={v.menuEnabled} onChange={e=>setV({...v,menuEnabled:e.target.checked})}/>启用查询菜单</label></div><button>验证并保存</button><div className="actions"><button type="button" className="secondary" onClick={async()=>{const x:any=await post('settings/telegram/bind');setCode(x.code)}}>生成绑定码</button><button type="button" className="secondary" onClick={async()=>{try{await post('settings/telegram/test');setMsg('测试消息已发送')}catch(x){setMsg((x as Error).message)}}}>发送测试</button></div>{code&&<div className="codebox"><span>向 Bot 发送</span><strong>/bind {code}</strong></div>}{msg&&<p className="hint">{msg}</p>}</form>} function Telegram(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState(''),[code,setCode]=useState('');useEffect(()=>{api('settings/telegram').then(setV)},[]);if(!v)return <Splash/>;return <form className="panel form wide" onSubmit={async e=>{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)}}}><h2>Telegram Bot</h2><label>Bot Token<input type="password" value={v.token||''} onChange={e=>setV({...v,token:e.target.value})} placeholder={v.configured?'已配置,留空表示不修改':'从 BotFather 获取'}/></label><div className="checks"><label><input type="checkbox" checked={v.enabled} onChange={e=>setV({...v,enabled:e.target.checked})}/>启用提醒</label><label><input type="checkbox" checked={v.menuEnabled} onChange={e=>setV({...v,menuEnabled:e.target.checked})}/>启用查询菜单</label></div><button>验证并保存</button><div className="actions"><button type="button" className="secondary" onClick={async()=>{const x:any=await post('settings/telegram/bind');setCode(x.code)}}>生成绑定码</button><button type="button" className="secondary" onClick={async()=>{try{await post('settings/telegram/test');setMsg('测试消息已发送')}catch(x){setMsg((x as Error).message)}}}>发送测试</button></div>{code&&<div className="codebox"><span>向 Bot 发送</span><strong>/bind {code}</strong></div>}{msg&&<p className="hint">{msg}</p>}</form>}
function SMTP(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/smtp').then(setV)},[]);if(!v)return <Splash/>;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 <form className="panel form wide" onSubmit={save}><h2>SMTP 邮件</h2><div className="grid2"><label>服务器<input value={v.host} onChange={e=>setV({...v,host:e.target.value})} placeholder="smtp.example.com"/></label><label>端口<input type="number" value={v.port} onChange={e=>setV({...v,port:+e.target.value})}/></label><label>用户名<input value={v.username} onChange={e=>setV({...v,username:e.target.value})}/></label><label>密码<input type="password" value={v.password||''} onChange={e=>setV({...v,password:e.target.value})} placeholder={v.configured?'已配置,留空不修改':''}/></label><label>加密<select value={v.security} onChange={e=>setV({...v,security:e.target.value})}><option value="starttls">STARTTLS</option><option value="tls">隐式 TLS</option><option value="none">无加密</option></select></label><label>发件名称<input value={v.fromName} onChange={e=>setV({...v,fromName:e.target.value})}/></label><label>发件地址<input type="email" value={v.from} onChange={e=>setV({...v,from:e.target.value})}/></label><label>收件地址<input type="email" value={v.to} onChange={e=>setV({...v,to:e.target.value})}/></label></div><label className="checks"><input type="checkbox" checked={v.enabled} onChange={e=>setV({...v,enabled:e.target.checked})}/>启用邮件提醒</label><div className="actions"><button>保存</button><button type="button" className="secondary" onClick={async()=>{try{await post('settings/smtp/test');setMsg('测试邮件已发送')}catch(x){setMsg((x as Error).message)}}}>发送测试邮件</button></div>{msg&&<p className="hint">{msg}</p>}</form>} function SMTP(){const[v,setV]=useState<any>(null),[msg,setMsg]=useState('');useEffect(()=>{api('settings/smtp').then(setV)},[]);if(!v)return <Splash/>;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 <form className="panel form wide" onSubmit={save}><h2>SMTP 邮件</h2><div className="grid2"><label>服务器<input value={v.host} onChange={e=>setV({...v,host:e.target.value})} placeholder="smtp.example.com"/></label><label>端口<input type="number" value={v.port} onChange={e=>setV({...v,port:+e.target.value})}/></label><label>用户名<input value={v.username} onChange={e=>setV({...v,username:e.target.value})}/></label><label>密码<input type="password" value={v.password||''} onChange={e=>setV({...v,password:e.target.value})} placeholder={v.configured?'已配置,留空不修改':''}/></label><label>加密<select value={v.security} onChange={e=>setV({...v,security:e.target.value})}><option value="starttls">STARTTLS</option><option value="tls">隐式 TLS</option><option value="none">无加密</option></select></label><label>发件名称<input value={v.fromName} onChange={e=>setV({...v,fromName:e.target.value})}/></label><label>发件地址<input type="email" value={v.from} onChange={e=>setV({...v,from:e.target.value})}/></label><label>收件地址<input type="email" value={v.to} onChange={e=>setV({...v,to:e.target.value})}/></label></div><label className="checks"><input type="checkbox" checked={v.enabled} onChange={e=>setV({...v,enabled:e.target.checked})}/>启用邮件提醒</label><div className="actions"><button>保存</button><button type="button" className="secondary" onClick={async()=>{try{await post('settings/smtp/test');setMsg('测试邮件已发送')}catch(x){setMsg((x as Error).message)}}}>发送测试邮件</button></div>{msg&&<p className="hint">{msg}</p>}</form>}
function Header({title,sub,children}:{title:string,sub:string,children?:React.ReactNode}){return <header><div><h1>{title}</h1><p>{sub}</p></div>{children}</header>} function Header({title,sub,children}:{title:string,sub:string,children?:React.ReactNode}){return <header><div><h1>{title}</h1><p>{sub}</p></div>{children}</header>}
const num=(v?:number)=>v==null?'暂无':new Intl.NumberFormat('zh-CN',{notation:'compact',maximumFractionDigits:1}).format(v); 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(<React.StrictMode><App/></React.StrictMode>) createRoot(document.getElementById('root')!).render(<React.StrictMode><App/></React.StrictMode>)
File diff suppressed because one or more lines are too long