feat: refresh accounts automatically every five minutes
This commit is contained in:
@@ -58,7 +58,7 @@ http://服务器地址:8180
|
|||||||
4. 点击页面显示的 OpenAI 验证地址,或在另一台设备的浏览器中打开该地址。
|
4. 点击页面显示的 OpenAI 验证地址,或在另一台设备的浏览器中打开该地址。
|
||||||
5. 登录需要监控的 ChatGPT/Codex 账户。
|
5. 登录需要监控的 ChatGPT/Codex 账户。
|
||||||
6. 输入页面显示的一次性设备码并确认授权。
|
6. 输入页面显示的一次性设备码并确认授权。
|
||||||
7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“刷新全部”或对应账号卡片中的“刷新账号”。
|
7. 返回 Codex Helper,服务器会在后台自动同步账号用量;首次同步通常会在数秒内完成,之后固定每 5 分钟同步一次。
|
||||||
8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。
|
8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。
|
||||||
|
|
||||||
设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。默认连接的登录凭据保存在 `/data/codex`,新增连接保存在 `/data/accounts/<账号 ID>/codex`,不会写入浏览器或项目源码。
|
设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。默认连接的登录凭据保存在 `/data/codex`,新增连接保存在 `/data/accounts/<账号 ID>/codex`,不会写入浏览器或项目源码。
|
||||||
@@ -72,7 +72,7 @@ http://服务器地址:8180
|
|||||||
进入“设置中心” → “通用”,可设置:
|
进入“设置中心” → “通用”,可设置:
|
||||||
|
|
||||||
- 时区:用于初始化配置;界面时间按浏览器本地时区显示
|
- 时区:用于初始化配置;界面时间按浏览器本地时区显示
|
||||||
- 同步间隔:1–60 分钟
|
- 自动同步:服务器固定每 5 分钟同步全部账号
|
||||||
- 历史保留时间:30、60、90、180 或 365 天
|
- 历史保留时间:30、60、90、180 或 365 天
|
||||||
- 提前提醒时间:重置前 1–1440 分钟
|
- 提前提醒时间:重置前 1–1440 分钟
|
||||||
- 是否发送重置前提醒和重置后确认;重置后确认也会通过额度百分比回落识别并提醒官方活动、临时补发等提前重置
|
- 是否发送重置前提醒和重置后确认;重置后确认也会通过额度百分比回落识别并提醒官方活动、临时补发等提前重置
|
||||||
@@ -191,7 +191,7 @@ docker compose restart codex-helper
|
|||||||
|
|
||||||
### 完成设备码授权后仍显示“尚未连接”
|
### 完成设备码授权后仍显示“尚未连接”
|
||||||
|
|
||||||
- 等待几秒后点击“刷新全部”或对应账号卡片中的“刷新账号”。
|
- 等待首次后台同步完成;之后服务器每 5 分钟自动同步一次。若持续未连接,请检查 app-server 和网络日志。
|
||||||
- 确认授权的是需要监控的 ChatGPT 账户。
|
- 确认授权的是需要监控的 ChatGPT 账户。
|
||||||
- 重新进入 Codex 设置,退出账户后再次生成设备码。
|
- 重新进入 Codex 设置,退出账户后再次生成设备码。
|
||||||
- 检查 `docker compose logs -f codex-helper` 中是否存在网络或认证错误。
|
- 检查 `docker compose logs -f codex-helper` 中是否存在网络或认证错误。
|
||||||
|
|||||||
+1
-1
@@ -80,7 +80,7 @@
|
|||||||
{timezone,theme,syncMinutes,retentionDays,beforeMinutes,notifyBefore,notifyAfter}
|
{timezone,theme,syncMinutes,retentionDays,beforeMinutes,notifyBefore,notifyAfter}
|
||||||
```
|
```
|
||||||
|
|
||||||
任意非 `GET` 方法都按更新处理,前端使用 `PUT`;请求接受完整对象。`syncMinutes` 为 1–60,`retentionDays` 为 30–365,`beforeMinutes` 为 1–1440,时区必须能由 Go 加载;非法值返回 400。成功返回保存后的对象。
|
任意非 `GET` 方法都按更新处理,前端使用 `PUT`;请求接受完整对象。`syncMinutes` 为兼容旧客户端保留的字段,服务端始终按 5 分钟自动同步并返回 `5`;`retentionDays` 为 30–365,`beforeMinutes` 为 1–1440,时区必须能由 Go 加载;非法值返回 400。成功返回保存后的对象。
|
||||||
|
|
||||||
### SMTP
|
### SMTP
|
||||||
|
|
||||||
|
|||||||
@@ -408,9 +408,9 @@ func (a *App) logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (a *App) general() GeneralSettings {
|
func (a *App) general() GeneralSettings {
|
||||||
g := defaults()
|
g := defaults()
|
||||||
a.store.GetJSON("general", &g)
|
a.store.GetJSON("general", &g)
|
||||||
if g.SyncMinutes < 1 {
|
// Keep the legacy field in the response for old clients, but automatic
|
||||||
g.SyncMinutes = 5
|
// account synchronization is intentionally fixed at five minutes.
|
||||||
}
|
g.SyncMinutes = automaticSyncMinutes
|
||||||
if g.RetentionDays < 1 {
|
if g.RetentionDays < 1 {
|
||||||
g.RetentionDays = 90
|
g.RetentionDays = 90
|
||||||
}
|
}
|
||||||
@@ -430,6 +430,7 @@ func (a *App) generalAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOut(w, 400, map[string]string{"error": "无效时区"})
|
jsonOut(w, 400, map[string]string{"error": "无效时区"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
g.SyncMinutes = automaticSyncMinutes
|
||||||
_ = a.store.SetJSON("general", g)
|
_ = a.store.SetJSON("general", g)
|
||||||
jsonOut(w, 200, g)
|
jsonOut(w, 200, g)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ type accountRuntime struct {
|
|||||||
stopped bool
|
stopped bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const automaticSyncInterval = time.Duration(automaticSyncMinutes) * time.Minute
|
||||||
|
|
||||||
type codexClient interface {
|
type codexClient interface {
|
||||||
Start(context.Context) error
|
Start(context.Context) error
|
||||||
Initialize(context.Context) error
|
Initialize(context.Context) error
|
||||||
@@ -267,17 +269,18 @@ func (a *App) syncAll(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (a *App) scheduler() {
|
func (a *App) scheduler() {
|
||||||
t := time.NewTicker(time.Minute)
|
maintenanceTicker := time.NewTicker(time.Minute)
|
||||||
defer t.Stop()
|
syncTicker := time.NewTicker(automaticSyncInterval)
|
||||||
|
defer maintenanceTicker.Stop()
|
||||||
|
defer syncTicker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-a.ctx.Done():
|
case <-a.ctx.Done():
|
||||||
return
|
return
|
||||||
case <-t.C:
|
case <-syncTicker.C:
|
||||||
|
a.syncAll(context.Background())
|
||||||
|
case <-maintenanceTicker.C:
|
||||||
g := a.general()
|
g := a.general()
|
||||||
if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 {
|
|
||||||
a.syncAll(context.Background())
|
|
||||||
}
|
|
||||||
_, _ = a.store.Cleanup(g.RetentionDays)
|
_, _ = a.store.Cleanup(g.RetentionDays)
|
||||||
go a.processReminders()
|
go a.processReminders()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,6 +349,19 @@ func TestPeakDailyTokensUsesOnlyTheCurrentTokenCycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAutomaticSyncIntervalIsFixedAtFiveMinutes(t *testing.T) {
|
||||||
|
a := newReminderTestApp(t)
|
||||||
|
if err := a.store.SetJSON("general", GeneralSettings{SyncMinutes: 60, RetentionDays: 90, BeforeMinutes: 30}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := a.general().SyncMinutes; got != automaticSyncMinutes {
|
||||||
|
t.Fatalf("sync minutes = %d; want %d", got, automaticSyncMinutes)
|
||||||
|
}
|
||||||
|
if automaticSyncInterval != 5*time.Minute {
|
||||||
|
t.Fatalf("automatic sync interval = %s; want 5m", automaticSyncInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFlattenLimitReadsAppServerWindowDuration(t *testing.T) {
|
func TestFlattenLimitReadsAppServerWindowDuration(t *testing.T) {
|
||||||
var limit rawLimit
|
var limit rawLimit
|
||||||
if err := json.Unmarshal([]byte(`{
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ type GeneralSettings struct {
|
|||||||
NotifyBefore bool `json:"notifyBefore"`
|
NotifyBefore bool `json:"notifyBefore"`
|
||||||
NotifyAfter bool `json:"notifyAfter"`
|
NotifyAfter bool `json:"notifyAfter"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const automaticSyncMinutes = 5
|
||||||
|
|
||||||
type SMTPSettings struct {
|
type SMTPSettings struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
@@ -92,5 +95,5 @@ type Dashboard struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaults() GeneralSettings {
|
func defaults() GeneralSettings {
|
||||||
return GeneralSettings{Timezone: "UTC", Theme: "system", SyncMinutes: 5, RetentionDays: 90, BeforeMinutes: 30, NotifyBefore: true, NotifyAfter: true}
|
return GeneralSettings{Timezone: "UTC", Theme: "system", SyncMinutes: automaticSyncMinutes, RetentionDays: 90, BeforeMinutes: 30, NotifyBefore: true, NotifyAfter: true}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 启动与关闭
|
## 启动与关闭
|
||||||
|
|
||||||
入口为 `backend/cmd/server/main.go`。普通启动调用 `app.New`:打开 `/data/codex-helper.db` 并执行兼容迁移、打开或创建 `/data/secret.key`、为数据库中的每个账号创建运行时对象,再组装 HTTP server。`Run` 启动 app-server 保活、每分钟调度器和 Telegram long polling;SIGINT/SIGTERM 触发五秒 HTTP 优雅关闭、停止所有账号进程并关闭数据库。
|
入口为 `backend/cmd/server/main.go`。普通启动调用 `app.New`:打开 `/data/codex-helper.db` 并执行兼容迁移、打开或创建 `/data/secret.key`、为数据库中的每个账号创建运行时对象,再组装 HTTP server。`Run` 启动 app-server 保活、后台调度器和 Telegram long polling;SIGINT/SIGTERM 触发五秒 HTTP 优雅关闭、停止所有账号进程并关闭数据库。
|
||||||
|
|
||||||
`codex-helper healthcheck` 将 `${LISTEN_ADDR:-:8080}` 的通配地址转换为 `127.0.0.1` 并请求 `/health/live`,供 Docker `HEALTHCHECK` 使用。
|
`codex-helper healthcheck` 将 `${LISTEN_ADDR:-:8080}` 的通配地址转换为 `127.0.0.1` 并请求 `/health/live`,供 Docker `HEALTHCHECK` 使用。
|
||||||
|
|
||||||
@@ -14,12 +14,12 @@
|
|||||||
- `internal/store/store.go`:SQLite schema、兼容迁移和数据方法。
|
- `internal/store/store.go`:SQLite schema、兼容迁移和数据方法。
|
||||||
- `internal/codex/client.go`:与 `codex app-server` 的 JSONL 请求/响应关联。
|
- `internal/codex/client.go`:与 `codex app-server` 的 JSONL 请求/响应关联。
|
||||||
|
|
||||||
所有路由由 `http.ServeMux` 承载。API 先处理三个匿名入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。
|
所有路由由 `http.ServeMux` 承载。API 先处理 status、setup、login 和初始化后的账号/Dashboard 匿名只读入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。
|
||||||
|
|
||||||
## 后台任务
|
## 后台任务
|
||||||
|
|
||||||
- `keepCodex` 每秒检查未就绪的账号,串行完成进程启动与协议初始化,成功后立即同步。
|
- `keepCodex` 每秒检查未就绪的账号,串行完成进程启动与协议初始化,成功后立即同步。
|
||||||
- `scheduler` 每分钟按 `syncMinutes` 的 Unix 时间取模触发全账号同步,清理过期历史,并异步处理提醒。
|
- `scheduler` 每五分钟固定触发全账号同步;每分钟清理过期历史并异步处理提醒。通用设置中的 `syncMinutes` 仅为旧客户端兼容字段,不改变固定调度周期。
|
||||||
- `telegramLoop` 使用 Bot API long polling;仅已配置 Token 才请求更新。
|
- `telegramLoop` 使用 Bot API long polling;仅已配置 Token 才请求更新。
|
||||||
- app-server 的登录、账号和限额通知会触发带短退避的同步;多次失败将内存 Dashboard 标为 stale 并记录 `lastError`。
|
- app-server 的登录、账号和限额通知会触发带短退避的同步;多次失败将内存 Dashboard 标为 stale 并记录 `lastError`。
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;初始化后未登录渲染公开只读 `/` 总览和 `/login` 登录页,登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未登录访问 `/settings` 回到公开总览,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页、公开总览和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。
|
应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;初始化后未登录渲染公开只读 `/` 总览和 `/login` 登录页,登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未登录访问 `/settings` 回到公开总览,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页、公开总览和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。
|
||||||
|
|
||||||
主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后刷新;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。总览默认只展示账号摘要,展开卡片后显示完整限额、统计和 Token 图;顶部“刷新全部”和卡片内的账号级刷新都会先调用对应的 sync,再重新读取 Dashboard。
|
主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后重新读取;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。公开总览使用匿名只读接口并隐藏身份配置字段,隐藏手动刷新按钮和设置导航;服务器固定每 5 分钟同步全部账号,登录后总览默认只展示账号摘要,展开卡片后显示完整限额、统计和 Token 图。
|
||||||
|
|
||||||
桌面端使用固定侧栏,`800px` 及以下改为紧凑顶栏和固定底部主导航;GitHub、主题和退出位于顶栏辅助菜单。移动布局最低支持 320px,使用动态视口高度和 CSS safe-area 环境变量避开 iOS 浏览器工具栏与设备安全区。内容必须为底部导航保留空间,表单控件避免 iOS 聚焦缩放,主要交互保持至少 44px 触控区域。
|
桌面端使用固定侧栏,`800px` 及以下改为紧凑顶栏和固定底部主导航;GitHub、主题和退出位于顶栏辅助菜单。移动布局最低支持 320px,使用动态视口高度和 CSS safe-area 环境变量避开 iOS 浏览器工具栏与设备安全区。内容必须为底部导航保留空间,表单控件避免 iOS 聚焦缩放,主要交互保持至少 44px 触控区域。
|
||||||
|
|
||||||
|
|||||||
+11
-110
@@ -12,7 +12,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Bell,
|
Bell,
|
||||||
Check,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Clock,
|
Clock,
|
||||||
Coins,
|
Coins,
|
||||||
@@ -24,7 +23,6 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
RefreshCw,
|
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -378,22 +376,19 @@ type DashboardCardState = {
|
|||||||
dashboard: Dash | null;
|
dashboard: Dash | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
refresh: "idle" | "loading" | "done";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyDashboardCardState = (): DashboardCardState => ({
|
const emptyDashboardCardState = (): DashboardCardState => ({
|
||||||
dashboard: null,
|
dashboard: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: "",
|
error: "",
|
||||||
refresh: "idle",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
||||||
const [accounts, setAccounts] = useState<Account[] | null>(null),
|
const [accounts, setAccounts] = useState<Account[] | null>(null),
|
||||||
[cards, setCards] = useState<Record<number, DashboardCardState>>({}),
|
[cards, setCards] = useState<Record<number, DashboardCardState>>({}),
|
||||||
[expanded, setExpanded] = useState<Record<number, boolean>>({}),
|
[expanded, setExpanded] = useState<Record<number, boolean>>({}),
|
||||||
[error, setError] = useState(""),
|
[error, setError] = useState("");
|
||||||
[refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle");
|
|
||||||
const requestRef = useRef(new Map<number, number>()),
|
const requestRef = useRef(new Map<number, number>()),
|
||||||
activeControllersRef = useRef(new Map<number, AbortController>());
|
activeControllersRef = useRef(new Map<number, AbortController>());
|
||||||
|
|
||||||
@@ -410,11 +405,7 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadDashboard = async (
|
const loadDashboard = async (accountId: number, signal?: AbortSignal) => {
|
||||||
accountId: number,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
manual = false,
|
|
||||||
) => {
|
|
||||||
const request = (requestRef.current.get(accountId) || 0) + 1;
|
const request = (requestRef.current.get(accountId) || 0) + 1;
|
||||||
requestRef.current.set(accountId, request);
|
requestRef.current.set(accountId, request);
|
||||||
let ownController: AbortController | undefined;
|
let ownController: AbortController | undefined;
|
||||||
@@ -427,7 +418,6 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
|||||||
updateCard(accountId, {
|
updateCard(accountId, {
|
||||||
loading: true,
|
loading: true,
|
||||||
error: "",
|
error: "",
|
||||||
...(manual ? { refresh: "loading" as const } : {}),
|
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const dashboard = await get(
|
const dashboard = await get(
|
||||||
@@ -444,26 +434,13 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
|||||||
dashboard,
|
dashboard,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: "",
|
error: "",
|
||||||
...(manual ? { refresh: "done" as const } : {}),
|
|
||||||
});
|
});
|
||||||
if (manual) {
|
|
||||||
window.setTimeout(
|
|
||||||
() =>
|
|
||||||
setCards((current) => {
|
|
||||||
const card = current[accountId];
|
|
||||||
if (!card || card.refresh !== "done") return current;
|
|
||||||
return { ...current, [accountId]: { ...card, refresh: "idle" } };
|
|
||||||
}),
|
|
||||||
2000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (value) {
|
} catch (value) {
|
||||||
if (signal.aborted || requestRef.current.get(accountId) !== request)
|
if (signal.aborted || requestRef.current.get(accountId) !== request)
|
||||||
return;
|
return;
|
||||||
updateCard(accountId, {
|
updateCard(accountId, {
|
||||||
loading: false,
|
loading: false,
|
||||||
error: toErrorMessage(value),
|
error: toErrorMessage(value),
|
||||||
...(manual ? { refresh: "idle" as const } : {}),
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (
|
if (
|
||||||
@@ -516,42 +493,6 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncAccount = async (accountId: number) => {
|
|
||||||
try {
|
|
||||||
setError("");
|
|
||||||
await post(`accounts/${accountId}/sync`, decodeOK, {}, undefined, 60_000);
|
|
||||||
await loadDashboard(accountId, undefined, true);
|
|
||||||
} catch (value) {
|
|
||||||
updateCard(accountId, {
|
|
||||||
loading: false,
|
|
||||||
refresh: "idle",
|
|
||||||
error: toErrorMessage(value),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const syncAll = async () => {
|
|
||||||
if (!accounts?.length) return;
|
|
||||||
setRefresh("loading");
|
|
||||||
setError("");
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
accounts.map(async (account) => {
|
|
||||||
await post(
|
|
||||||
`accounts/${account.id}/sync`,
|
|
||||||
decodeOK,
|
|
||||||
{},
|
|
||||||
undefined,
|
|
||||||
60_000,
|
|
||||||
);
|
|
||||||
await loadDashboard(account.id, undefined, true);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const failed = results.filter((result) => result.status === "rejected");
|
|
||||||
if (failed.length)
|
|
||||||
setError(`${failed.length} 个账号刷新失败,请展开对应卡片查看详情。`);
|
|
||||||
setRefresh(failed.length ? "idle" : "done");
|
|
||||||
if (!failed.length) window.setTimeout(() => setRefresh("idle"), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (accounts === null)
|
if (accounts === null)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -582,21 +523,8 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
|||||||
<>
|
<>
|
||||||
<Header
|
<Header
|
||||||
title="用量总览"
|
title="用量总览"
|
||||||
sub={`共 ${accounts.length} 个账号,点击卡片查看完整详情`}
|
sub={`${publicView ? "公开只读 · " : ""}共 ${accounts.length} 个账号,服务器每 5 分钟自动同步`}
|
||||||
>
|
/>
|
||||||
<button
|
|
||||||
className={"secondary refresh " + refresh}
|
|
||||||
disabled={refresh === "loading"}
|
|
||||||
onClick={() => void syncAll()}
|
|
||||||
>
|
|
||||||
{refresh === "done" ? <Check /> : <RefreshCw />}
|
|
||||||
{refresh === "loading"
|
|
||||||
? "刷新中"
|
|
||||||
: refresh === "done"
|
|
||||||
? "刷新完成"
|
|
||||||
: "刷新全部"}
|
|
||||||
</button>
|
|
||||||
</Header>
|
|
||||||
{error && <div className="banner">{error}</div>}
|
{error && <div className="banner">{error}</div>}
|
||||||
<div className="account-overview-list">
|
<div className="account-overview-list">
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
@@ -612,7 +540,6 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
|||||||
[account.id]: !current[account.id],
|
[account.id]: !current[account.id],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
onRefresh={() => void syncAccount(account.id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -626,14 +553,12 @@ function AccountOverviewCard({
|
|||||||
publicView,
|
publicView,
|
||||||
expanded,
|
expanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
onRefresh,
|
|
||||||
}: {
|
}: {
|
||||||
account: Account;
|
account: Account;
|
||||||
state: DashboardCardState;
|
state: DashboardCardState;
|
||||||
publicView: boolean;
|
publicView: boolean;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onRefresh: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const dashboard = state.dashboard;
|
const dashboard = state.dashboard;
|
||||||
const email = dashboard?.account.email ?? account.email;
|
const email = dashboard?.account.email ?? account.email;
|
||||||
@@ -675,7 +600,6 @@ function AccountOverviewCard({
|
|||||||
<AccountDashboardDetails
|
<AccountDashboardDetails
|
||||||
dashboard={dashboard}
|
dashboard={dashboard}
|
||||||
state={state}
|
state={state}
|
||||||
onRefresh={onRefresh}
|
|
||||||
publicView={publicView}
|
publicView={publicView}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -684,11 +608,6 @@ function AccountOverviewCard({
|
|||||||
<p className={state.error ? "error" : ""}>
|
<p className={state.error ? "error" : ""}>
|
||||||
{state.error || "正在读取账号用量…"}
|
{state.error || "正在读取账号用量…"}
|
||||||
</p>
|
</p>
|
||||||
{state.error && (
|
|
||||||
<button className="secondary" onClick={onRefresh}>
|
|
||||||
<RefreshCw /> 重试
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -739,12 +658,10 @@ function AccountQuickSummary({
|
|||||||
function AccountDashboardDetails({
|
function AccountDashboardDetails({
|
||||||
dashboard,
|
dashboard,
|
||||||
state,
|
state,
|
||||||
onRefresh,
|
|
||||||
publicView,
|
publicView,
|
||||||
}: {
|
}: {
|
||||||
dashboard: Dash;
|
dashboard: Dash;
|
||||||
state: DashboardCardState;
|
state: DashboardCardState;
|
||||||
onRefresh: () => void;
|
|
||||||
publicView: boolean;
|
publicView: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -764,18 +681,6 @@ function AccountDashboardDetails({
|
|||||||
? new Date(dashboard.fetchedAt * 1000).toLocaleString()
|
? new Date(dashboard.fetchedAt * 1000).toLocaleString()
|
||||||
: "尚未同步"}
|
: "尚未同步"}
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
className={`secondary refresh ${state.refresh}`}
|
|
||||||
disabled={state.refresh === "loading"}
|
|
||||||
onClick={onRefresh}
|
|
||||||
>
|
|
||||||
{state.refresh === "done" ? <Check /> : <RefreshCw />}
|
|
||||||
{state.refresh === "loading"
|
|
||||||
? "刷新中"
|
|
||||||
: state.refresh === "done"
|
|
||||||
? "刷新完成"
|
|
||||||
: "刷新账号"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(state.error || dashboard.lastError) && (
|
{(state.error || dashboard.lastError) && (
|
||||||
@@ -821,7 +726,9 @@ function AccountDashboardDetails({
|
|||||||
</div>
|
</div>
|
||||||
{!dashboard.limits.length && (
|
{!dashboard.limits.length && (
|
||||||
<div className="panel empty">
|
<div className="panel empty">
|
||||||
该连接暂无限额数据,请确认登录后刷新。
|
{publicView
|
||||||
|
? "该连接暂无限额数据"
|
||||||
|
: "该连接暂无限额数据,服务器会自动同步。"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{dashboard.currentCycle && (
|
{dashboard.currentCycle && (
|
||||||
@@ -1111,16 +1018,10 @@ function General() {
|
|||||||
<option value="light">浅色</option>
|
<option value="light">浅色</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<div className="fixed-setting">
|
||||||
同步间隔(分钟)
|
<span>自动同步</span>
|
||||||
<input
|
<b>服务器每 5 分钟自动刷新所有账号</b>
|
||||||
type="number"
|
</div>
|
||||||
min="1"
|
|
||||||
max="60"
|
|
||||||
value={v.syncMinutes}
|
|
||||||
onChange={(e) => setV({ ...v, syncMinutes: +e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
历史保留(天)
|
历史保留(天)
|
||||||
<select
|
<select
|
||||||
|
|||||||
+16
-17
@@ -328,17 +328,6 @@ header p {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.refresh.loading svg {
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
.refresh {
|
|
||||||
width: 116px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.refresh.done {
|
|
||||||
color: var(--green);
|
|
||||||
border-color: var(--green);
|
|
||||||
}
|
|
||||||
.account-overview-list {
|
.account-overview-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -721,6 +710,22 @@ header p {
|
|||||||
.checks input {
|
.checks input {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
.fixed-setting {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.fixed-setting b {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -995,9 +1000,6 @@ a {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.refresh {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.account-summary-toggle {
|
.account-summary-toggle {
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
gap: 10px 12px;
|
gap: 10px 12px;
|
||||||
@@ -1036,9 +1038,6 @@ a {
|
|||||||
.account-detail-actions .fresh {
|
.account-detail-actions .fresh {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.account-detail-actions .refresh {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.account-info-grid {
|
.account-info-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ test("shows every account as a collapsible summary on the overview", async ({
|
|||||||
await expect(page.getByText("最低 65% 剩余")).toBeVisible();
|
await expect(page.getByText("最低 65% 剩余")).toBeVisible();
|
||||||
await expect(page.getByText("本周期 123.5K Tokens")).toBeVisible();
|
await expect(page.getByText("本周期 123.5K Tokens")).toBeVisible();
|
||||||
await expect(page.getByText("限额暂无")).toBeVisible();
|
await expect(page.getByText("限额暂无")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "刷新全部" })).toHaveCount(0);
|
||||||
await expect(page.locator(".account-detail")).toHaveCount(0);
|
await expect(page.locator(".account-detail")).toHaveCount(0);
|
||||||
await expect(page.locator(".account-select")).toHaveCount(0);
|
await expect(page.locator(".account-select")).toHaveCount(0);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user