feat: refresh accounts automatically every five minutes
This commit is contained in:
@@ -58,7 +58,7 @@ http://服务器地址:8180
|
||||
4. 点击页面显示的 OpenAI 验证地址,或在另一台设备的浏览器中打开该地址。
|
||||
5. 登录需要监控的 ChatGPT/Codex 账户。
|
||||
6. 输入页面显示的一次性设备码并确认授权。
|
||||
7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“刷新全部”或对应账号卡片中的“刷新账号”。
|
||||
7. 返回 Codex Helper,服务器会在后台自动同步账号用量;首次同步通常会在数秒内完成,之后固定每 5 分钟同步一次。
|
||||
8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。
|
||||
|
||||
设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。默认连接的登录凭据保存在 `/data/codex`,新增连接保存在 `/data/accounts/<账号 ID>/codex`,不会写入浏览器或项目源码。
|
||||
@@ -72,7 +72,7 @@ http://服务器地址:8180
|
||||
进入“设置中心” → “通用”,可设置:
|
||||
|
||||
- 时区:用于初始化配置;界面时间按浏览器本地时区显示
|
||||
- 同步间隔:1–60 分钟
|
||||
- 自动同步:服务器固定每 5 分钟同步全部账号
|
||||
- 历史保留时间:30、60、90、180 或 365 天
|
||||
- 提前提醒时间:重置前 1–1440 分钟
|
||||
- 是否发送重置前提醒和重置后确认;重置后确认也会通过额度百分比回落识别并提醒官方活动、临时补发等提前重置
|
||||
@@ -191,7 +191,7 @@ docker compose restart codex-helper
|
||||
|
||||
### 完成设备码授权后仍显示“尚未连接”
|
||||
|
||||
- 等待几秒后点击“刷新全部”或对应账号卡片中的“刷新账号”。
|
||||
- 等待首次后台同步完成;之后服务器每 5 分钟自动同步一次。若持续未连接,请检查 app-server 和网络日志。
|
||||
- 确认授权的是需要监控的 ChatGPT 账户。
|
||||
- 重新进入 Codex 设置,退出账户后再次生成设备码。
|
||||
- 检查 `docker compose logs -f codex-helper` 中是否存在网络或认证错误。
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@
|
||||
{timezone,theme,syncMinutes,retentionDays,beforeMinutes,notifyBefore,notifyAfter}
|
||||
```
|
||||
|
||||
任意非 `GET` 方法都按更新处理,前端使用 `PUT`;请求接受完整对象。`syncMinutes` 为 1–60,`retentionDays` 为 30–365,`beforeMinutes` 为 1–1440,时区必须能由 Go 加载;非法值返回 400。成功返回保存后的对象。
|
||||
任意非 `GET` 方法都按更新处理,前端使用 `PUT`;请求接受完整对象。`syncMinutes` 为兼容旧客户端保留的字段,服务端始终按 5 分钟自动同步并返回 `5`;`retentionDays` 为 30–365,`beforeMinutes` 为 1–1440,时区必须能由 Go 加载;非法值返回 400。成功返回保存后的对象。
|
||||
|
||||
### SMTP
|
||||
|
||||
|
||||
@@ -408,9 +408,9 @@ func (a *App) logout(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) general() GeneralSettings {
|
||||
g := defaults()
|
||||
a.store.GetJSON("general", &g)
|
||||
if g.SyncMinutes < 1 {
|
||||
g.SyncMinutes = 5
|
||||
}
|
||||
// Keep the legacy field in the response for old clients, but automatic
|
||||
// account synchronization is intentionally fixed at five minutes.
|
||||
g.SyncMinutes = automaticSyncMinutes
|
||||
if g.RetentionDays < 1 {
|
||||
g.RetentionDays = 90
|
||||
}
|
||||
@@ -430,6 +430,7 @@ func (a *App) generalAPI(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 400, map[string]string{"error": "无效时区"})
|
||||
return
|
||||
}
|
||||
g.SyncMinutes = automaticSyncMinutes
|
||||
_ = a.store.SetJSON("general", g)
|
||||
jsonOut(w, 200, g)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ type accountRuntime struct {
|
||||
stopped bool
|
||||
}
|
||||
|
||||
const automaticSyncInterval = time.Duration(automaticSyncMinutes) * time.Minute
|
||||
|
||||
type codexClient interface {
|
||||
Start(context.Context) error
|
||||
Initialize(context.Context) error
|
||||
@@ -267,17 +269,18 @@ func (a *App) syncAll(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
func (a *App) scheduler() {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
maintenanceTicker := time.NewTicker(time.Minute)
|
||||
syncTicker := time.NewTicker(automaticSyncInterval)
|
||||
defer maintenanceTicker.Stop()
|
||||
defer syncTicker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
case <-syncTicker.C:
|
||||
a.syncAll(context.Background())
|
||||
case <-maintenanceTicker.C:
|
||||
g := a.general()
|
||||
if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 {
|
||||
a.syncAll(context.Background())
|
||||
}
|
||||
_, _ = a.store.Cleanup(g.RetentionDays)
|
||||
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) {
|
||||
var limit rawLimit
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
|
||||
@@ -9,6 +9,9 @@ type GeneralSettings struct {
|
||||
NotifyBefore bool `json:"notifyBefore"`
|
||||
NotifyAfter bool `json:"notifyAfter"`
|
||||
}
|
||||
|
||||
const automaticSyncMinutes = 5
|
||||
|
||||
type SMTPSettings struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
@@ -92,5 +95,5 @@ type Dashboard struct {
|
||||
}
|
||||
|
||||
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` 使用。
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
- `internal/store/store.go`:SQLite schema、兼容迁移和数据方法。
|
||||
- `internal/codex/client.go`:与 `codex app-server` 的 JSONL 请求/响应关联。
|
||||
|
||||
所有路由由 `http.ServeMux` 承载。API 先处理三个匿名入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。
|
||||
所有路由由 `http.ServeMux` 承载。API 先处理 status、setup、login 和初始化后的账号/Dashboard 匿名只读入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。
|
||||
|
||||
## 后台任务
|
||||
|
||||
- `keepCodex` 每秒检查未就绪的账号,串行完成进程启动与协议初始化,成功后立即同步。
|
||||
- `scheduler` 每分钟按 `syncMinutes` 的 Unix 时间取模触发全账号同步,清理过期历史,并异步处理提醒。
|
||||
- `scheduler` 每五分钟固定触发全账号同步;每分钟清理过期历史并异步处理提醒。通用设置中的 `syncMinutes` 仅为旧客户端兼容字段,不改变固定调度周期。
|
||||
- `telegramLoop` 使用 Bot API long polling;仅已配置 Token 才请求更新。
|
||||
- app-server 的登录、账号和限额通知会触发带短退避的同步;多次失败将内存 Dashboard 标为 stale 并记录 `lastError`。
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
应用启动先请求 `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 触控区域。
|
||||
|
||||
|
||||
+11
-110
@@ -12,7 +12,6 @@ import {
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Coins,
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
LogOut,
|
||||
Moon,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Sun,
|
||||
Trash2,
|
||||
@@ -378,22 +376,19 @@ type DashboardCardState = {
|
||||
dashboard: Dash | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
refresh: "idle" | "loading" | "done";
|
||||
};
|
||||
|
||||
const emptyDashboardCardState = (): DashboardCardState => ({
|
||||
dashboard: null,
|
||||
loading: false,
|
||||
error: "",
|
||||
refresh: "idle",
|
||||
});
|
||||
|
||||
function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
||||
const [accounts, setAccounts] = useState<Account[] | null>(null),
|
||||
[cards, setCards] = useState<Record<number, DashboardCardState>>({}),
|
||||
[expanded, setExpanded] = useState<Record<number, boolean>>({}),
|
||||
[error, setError] = useState(""),
|
||||
[refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle");
|
||||
[error, setError] = useState("");
|
||||
const requestRef = useRef(new Map<number, number>()),
|
||||
activeControllersRef = useRef(new Map<number, AbortController>());
|
||||
|
||||
@@ -410,11 +405,7 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
||||
}));
|
||||
};
|
||||
|
||||
const loadDashboard = async (
|
||||
accountId: number,
|
||||
signal?: AbortSignal,
|
||||
manual = false,
|
||||
) => {
|
||||
const loadDashboard = async (accountId: number, signal?: AbortSignal) => {
|
||||
const request = (requestRef.current.get(accountId) || 0) + 1;
|
||||
requestRef.current.set(accountId, request);
|
||||
let ownController: AbortController | undefined;
|
||||
@@ -427,7 +418,6 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
||||
updateCard(accountId, {
|
||||
loading: true,
|
||||
error: "",
|
||||
...(manual ? { refresh: "loading" as const } : {}),
|
||||
});
|
||||
try {
|
||||
const dashboard = await get(
|
||||
@@ -444,26 +434,13 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
||||
dashboard,
|
||||
loading: false,
|
||||
error: "",
|
||||
...(manual ? { refresh: "done" as const } : {}),
|
||||
});
|
||||
if (manual) {
|
||||
window.setTimeout(
|
||||
() =>
|
||||
setCards((current) => {
|
||||
const card = current[accountId];
|
||||
if (!card || card.refresh !== "done") return current;
|
||||
return { ...current, [accountId]: { ...card, refresh: "idle" } };
|
||||
}),
|
||||
2000,
|
||||
);
|
||||
}
|
||||
} catch (value) {
|
||||
if (signal.aborted || requestRef.current.get(accountId) !== request)
|
||||
return;
|
||||
updateCard(accountId, {
|
||||
loading: false,
|
||||
error: toErrorMessage(value),
|
||||
...(manual ? { refresh: "idle" as const } : {}),
|
||||
});
|
||||
} finally {
|
||||
if (
|
||||
@@ -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)
|
||||
return (
|
||||
<>
|
||||
@@ -582,21 +523,8 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
||||
<>
|
||||
<Header
|
||||
title="用量总览"
|
||||
sub={`共 ${accounts.length} 个账号,点击卡片查看完整详情`}
|
||||
>
|
||||
<button
|
||||
className={"secondary refresh " + refresh}
|
||||
disabled={refresh === "loading"}
|
||||
onClick={() => void syncAll()}
|
||||
>
|
||||
{refresh === "done" ? <Check /> : <RefreshCw />}
|
||||
{refresh === "loading"
|
||||
? "刷新中"
|
||||
: refresh === "done"
|
||||
? "刷新完成"
|
||||
: "刷新全部"}
|
||||
</button>
|
||||
</Header>
|
||||
sub={`${publicView ? "公开只读 · " : ""}共 ${accounts.length} 个账号,服务器每 5 分钟自动同步`}
|
||||
/>
|
||||
{error && <div className="banner">{error}</div>}
|
||||
<div className="account-overview-list">
|
||||
{accounts.map((account) => (
|
||||
@@ -612,7 +540,6 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) {
|
||||
[account.id]: !current[account.id],
|
||||
}))
|
||||
}
|
||||
onRefresh={() => void syncAccount(account.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -626,14 +553,12 @@ function AccountOverviewCard({
|
||||
publicView,
|
||||
expanded,
|
||||
onToggle,
|
||||
onRefresh,
|
||||
}: {
|
||||
account: Account;
|
||||
state: DashboardCardState;
|
||||
publicView: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const dashboard = state.dashboard;
|
||||
const email = dashboard?.account.email ?? account.email;
|
||||
@@ -675,7 +600,6 @@ function AccountOverviewCard({
|
||||
<AccountDashboardDetails
|
||||
dashboard={dashboard}
|
||||
state={state}
|
||||
onRefresh={onRefresh}
|
||||
publicView={publicView}
|
||||
/>
|
||||
) : (
|
||||
@@ -684,11 +608,6 @@ function AccountOverviewCard({
|
||||
<p className={state.error ? "error" : ""}>
|
||||
{state.error || "正在读取账号用量…"}
|
||||
</p>
|
||||
{state.error && (
|
||||
<button className="secondary" onClick={onRefresh}>
|
||||
<RefreshCw /> 重试
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -739,12 +658,10 @@ function AccountQuickSummary({
|
||||
function AccountDashboardDetails({
|
||||
dashboard,
|
||||
state,
|
||||
onRefresh,
|
||||
publicView,
|
||||
}: {
|
||||
dashboard: Dash;
|
||||
state: DashboardCardState;
|
||||
onRefresh: () => void;
|
||||
publicView: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -764,18 +681,6 @@ function AccountDashboardDetails({
|
||||
? new Date(dashboard.fetchedAt * 1000).toLocaleString()
|
||||
: "尚未同步"}
|
||||
</span>
|
||||
<button
|
||||
className={`secondary refresh ${state.refresh}`}
|
||||
disabled={state.refresh === "loading"}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{state.refresh === "done" ? <Check /> : <RefreshCw />}
|
||||
{state.refresh === "loading"
|
||||
? "刷新中"
|
||||
: state.refresh === "done"
|
||||
? "刷新完成"
|
||||
: "刷新账号"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{(state.error || dashboard.lastError) && (
|
||||
@@ -821,7 +726,9 @@ function AccountDashboardDetails({
|
||||
</div>
|
||||
{!dashboard.limits.length && (
|
||||
<div className="panel empty">
|
||||
该连接暂无限额数据,请确认登录后刷新。
|
||||
{publicView
|
||||
? "该连接暂无限额数据"
|
||||
: "该连接暂无限额数据,服务器会自动同步。"}
|
||||
</div>
|
||||
)}
|
||||
{dashboard.currentCycle && (
|
||||
@@ -1111,16 +1018,10 @@ function General() {
|
||||
<option value="light">浅色</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
同步间隔(分钟)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={v.syncMinutes}
|
||||
onChange={(e) => setV({ ...v, syncMinutes: +e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="fixed-setting">
|
||||
<span>自动同步</span>
|
||||
<b>服务器每 5 分钟自动刷新所有账号</b>
|
||||
</div>
|
||||
<label>
|
||||
历史保留(天)
|
||||
<select
|
||||
|
||||
+16
-17
@@ -328,17 +328,6 @@ header p {
|
||||
border: 1px solid var(--border);
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -721,6 +710,22 @@ header p {
|
||||
.checks input {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -995,9 +1000,6 @@ a {
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.refresh {
|
||||
width: 100%;
|
||||
}
|
||||
.account-summary-toggle {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px 12px;
|
||||
@@ -1036,9 +1038,6 @@ a {
|
||||
.account-detail-actions .fresh {
|
||||
display: block;
|
||||
}
|
||||
.account-detail-actions .refresh {
|
||||
width: 100%;
|
||||
}
|
||||
.account-info-grid {
|
||||
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("本周期 123.5K Tokens")).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-select")).toHaveCount(0);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user