feat: refresh accounts automatically every five minutes

This commit is contained in:
2026-08-14 15:35:27 +08:00
parent c1985852e1
commit 5f50669b98
11 changed files with 66 additions and 145 deletions
+3 -3
View File
@@ -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
View File
@@ -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
+4 -3
View File
@@ -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)
} }
+9 -6
View File
@@ -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:
g := a.general()
if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 {
a.syncAll(context.Background()) a.syncAll(context.Background())
} case <-maintenanceTicker.C:
g := a.general()
_, _ = a.store.Cleanup(g.RetentionDays) _, _ = a.store.Cleanup(g.RetentionDays)
go a.processReminders() go a.processReminders()
} }
+13
View File
@@ -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(`{
+4 -1
View File
@@ -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}
} }
+3 -3
View File
@@ -2,7 +2,7 @@
## 启动与关闭 ## 启动与关闭
入口为 `backend/cmd/server/main.go`。普通启动调用 `app.New`:打开 `/data/codex-helper.db` 并执行兼容迁移、打开或创建 `/data/secret.key`、为数据库中的每个账号创建运行时对象,再组装 HTTP server。`Run` 启动 app-server 保活、每分钟调度器和 Telegram long polling;SIGINT/SIGTERM 触发五秒 HTTP 优雅关闭、停止所有账号进程并关闭数据库。 入口为 `backend/cmd/server/main.go`。普通启动调用 `app.New`:打开 `/data/codex-helper.db` 并执行兼容迁移、打开或创建 `/data/secret.key`、为数据库中的每个账号创建运行时对象,再组装 HTTP server。`Run` 启动 app-server 保活、后台调度器和 Telegram long polling;SIGINT/SIGTERM 触发五秒 HTTP 优雅关闭、停止所有账号进程并关闭数据库。
`codex-helper healthcheck` 将 `${LISTEN_ADDR:-:8080}` 的通配地址转换为 `127.0.0.1` 并请求 `/health/live`,供 Docker `HEALTHCHECK` 使用。 `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`。
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }
+1
View File
@@ -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);