From 97a7dff31d38864a97e16a623df7006418a1dd42 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Wed, 2 Sep 2026 19:29:02 +0800 Subject: [PATCH 01/16] feat: add account status tab --- app/account-status/account-status.tsx | 365 ++++++++++++++++++++++++++ app/account-status/page.tsx | 15 ++ app/admin-api/account-status/route.ts | 37 +++ app/globals.scss | 71 +++++ app/page-data.ts | 14 +- app/page-loader.ts | 8 + app/page-shell.tsx | 10 +- lib/server/domain/account-status.ts | 202 ++++++++++++++ lib/server/proxy/codebuddy.ts | 19 +- messages/en-US.json | 23 ++ messages/ja-JP.json | 23 ++ messages/zh-CN.json | 23 ++ next-env.d.ts | 1 + 13 files changed, 801 insertions(+), 10 deletions(-) create mode 100644 app/account-status/account-status.tsx create mode 100644 app/account-status/page.tsx create mode 100644 app/admin-api/account-status/route.ts create mode 100644 lib/server/domain/account-status.ts diff --git a/app/account-status/account-status.tsx b/app/account-status/account-status.tsx new file mode 100644 index 0000000..c11e76f --- /dev/null +++ b/app/account-status/account-status.tsx @@ -0,0 +1,365 @@ +'use client'; + +import { + Alert, + Block, + Collapse, + Empty, + Flexbox, + SkeletonButton, + SkeletonParagraph, + SkeletonTags, + SkeletonTitle, + Tag, + Text, + Tooltip, +} from '@lobehub/ui'; +import { Button } from '@lobehub/ui/base-ui'; +import { RefreshCw } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { CredentialSummary } from '@/app/credentials/credentials'; + +interface AccountStatusProps { + credentials: CredentialSummary[]; +} + +interface AccountStatusSnapshot { + checkin: { claimed: boolean | null; message: string | null }; + credits: { + total: number | null; + used: number | null; + remaining: number | null; + plan: string | null; + resetAt: string | null; + }; + error: string | null; + filename: string; + models: string[]; + queriedAt: string; +} + +const initialSnapshot = (filename: string): AccountStatusSnapshot => ({ + checkin: { claimed: null, message: null }, + credits: { + total: null, + used: null, + remaining: null, + plan: null, + resetAt: null, + }, + error: null, + filename, + models: [], + queriedAt: '', +}); + +const quotaPercent = (snapshot: AccountStatusSnapshot): number | null => { + const { total, used } = snapshot.credits; + if (total === null || total <= 0 || used === null) return null; + return Math.min(100, Math.max(0, (used / total) * 100)); +}; + +const QuotaProgress = ({ snapshot }: { snapshot: AccountStatusSnapshot }) => { + const percent = quotaPercent(snapshot); + const tone = + percent === null + ? 'unknown' + : percent >= 100 + ? 'exhausted' + : percent >= 80 + ? 'warning' + : 'normal'; + return ( + + + {percent === null ? '—' : `${percent.toFixed(0)}%`} + + {snapshot.credits.used ?? '—'} / {snapshot.credits.total ?? '—'} + + + + + ); +}; + +const AccountStatusSkeleton = () => ( + + + + + + + +); + +const AccountStatusCard = ({ + credential, + snapshot, + busy, + onRefresh, + onCheckin, +}: { + credential: CredentialSummary; + snapshot: AccountStatusSnapshot; + busy: string | null; + onRefresh: () => void; + onCheckin: () => void; +}) => { + const text = useTranslations('Admin'); + const percent = quotaPercent(snapshot); + const models = snapshot.models.slice(0, 8); + const hasMoreModels = snapshot.models.length > models.length; + return ( + + + + {credential.email || credential.user_id} + + + + {credential.filename} + + + + {snapshot.error ? : null} + + {text('accountStatus.quota')} + + + + {text('accountStatus.total')}: {snapshot.credits.total ?? '—'} + + + {text('accountStatus.used')}: {snapshot.credits.used ?? '—'} + + + {text('accountStatus.remaining')}:{' '} + {snapshot.credits.remaining ?? '—'} + + + + {text('accountStatus.plan')}: {snapshot.credits.plan ?? '—'} + + + {text('accountStatus.resetAt')}: {snapshot.credits.resetAt ?? '—'} + + {percent !== null && percent >= 100 ? ( + {text('accountStatus.exhausted')} + ) : null} + + + + {text('accountStatus.checkin')}:{' '} + {snapshot.checkin.claimed === true + ? text('accountStatus.checkedIn') + : snapshot.checkin.claimed === false + ? text('accountStatus.notCheckedIn') + : '—'} + + + + + {text('accountStatus.models')} + {models.length ? ( + + {snapshot.models.map((model) => ( + {model} + ))} + + ), + }, + ]} + variant="borderless" + /> + ) : ( + {text('accountStatus.noModels')} + )} + + + + {snapshot.queriedAt + ? new Date(snapshot.queriedAt).toLocaleString() + : '—'} + + + + + ); +}; + +const AccountStatus = ({ credentials }: AccountStatusProps) => { + const text = useTranslations('Admin'); + const [snapshots, setSnapshots] = useState< + Record + >({}); + const [busy, setBusy] = useState>({}); + const [page, setPage] = useState(1); + const [batchBusy, setBatchBusy] = useState(null); + const loadOne = useCallback( + async (filename: string, action: 'refresh' | 'checkin' = 'refresh') => { + setBusy((current) => ({ ...current, [filename]: action })); + try { + const response = await fetch('/admin-api/account-status', { + body: JSON.stringify({ action, filename }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const payload = (await response.json()) as { + status?: AccountStatusSnapshot; + statuses?: AccountStatusSnapshot[]; + }; + const snapshot = payload.status ?? payload.statuses?.[0]; + if (snapshot) + setSnapshots((current) => ({ ...current, [filename]: snapshot })); + } finally { + setBusy((current) => { + const next = { ...current }; + delete next[filename]; + return next; + }); + } + }, + [], + ); + const loadAll = useCallback( + async (action: 'refresh' | 'checkin') => { + setBatchBusy(action); + await Promise.all( + credentials.map((credential) => loadOne(credential.filename, action)), + ); + setBatchBusy(null); + }, + [credentials, loadOne], + ); + useEffect(() => { + const timer = window.setTimeout(() => { + void loadAll('refresh'); + }, 0); + return () => window.clearTimeout(timer); + }, [loadAll]); + const pageCredentials = useMemo( + () => + credentials.length > 50 + ? credentials.slice((page - 1) * 12, page * 12) + : credentials, + [credentials, page], + ); + const pageCount = + credentials.length > 50 ? Math.ceil(credentials.length / 12) : 1; + if (!credentials.length) return ; + return ( + + + {text('accountStatus.title')} + + + + + + {snapshots && Object.keys(snapshots).length === 0 ? ( + + ) : ( + pageCredentials.map((credential) => ( + void loadOne(credential.filename, 'checkin')} + onRefresh={() => void loadOne(credential.filename)} + /> + )) + )} + {pageCount > 1 ? ( + + + + {page} / {pageCount} + + + + ) : null} + + ); +}; + +export default AccountStatus; diff --git a/app/account-status/page.tsx b/app/account-status/page.tsx new file mode 100644 index 0000000..06916f8 --- /dev/null +++ b/app/account-status/page.tsx @@ -0,0 +1,15 @@ +import { AdminPage } from '@/app/page'; +import { getAccountStatusCredentials } from '@/lib/server/domain/account-status'; +import AccountStatus from './account-status'; + +const AccountStatusPage = async () => { + const credentials = await getAccountStatusCredentials(); + + return ( + + + + ); +}; + +export default AccountStatusPage; diff --git a/app/admin-api/account-status/route.ts b/app/admin-api/account-status/route.ts new file mode 100644 index 0000000..8ff3afa --- /dev/null +++ b/app/admin-api/account-status/route.ts @@ -0,0 +1,37 @@ +import { getAdminSessionErrorResponse } from '@/lib/server/admin/session'; +import { + checkinAccounts, + checkinAccount, + getAccountStatus, + getAccountStatusCredentials, +} from '@/lib/server/domain/account-status'; +import { getJsonBody } from '@/lib/server/shared/http'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const GET = async (request: Request): Promise => { + const authError = await getAdminSessionErrorResponse(request); + if (authError) return authError; + const credentials = await getAccountStatusCredentials(); + return Response.json({ credentials, statuses: await getAccountStatus() }); +}; + +export const POST = async (request: Request): Promise => { + const authError = await getAdminSessionErrorResponse(request); + if (authError) return authError; + const body = await getJsonBody<{ action?: unknown; filename?: unknown }>( + request, + ); + const filename = + typeof body.filename === 'string' ? body.filename.trim() : ''; + if (body.action === 'checkin') { + if (filename) { + return Response.json({ status: await checkinAccount(filename) }); + } + return Response.json({ statuses: await checkinAccounts() }); + } + return Response.json({ + statuses: await getAccountStatus(filename ? [filename] : undefined), + }); +}; diff --git a/app/globals.scss b/app/globals.scss index 5e9dc1a..ed2ab7f 100644 --- a/app/globals.scss +++ b/app/globals.scss @@ -266,6 +266,77 @@ textarea { padding: 24px 32px 48px; } +.account-status-progress { + display: block; + height: 8px; + width: 100%; + overflow: hidden; + border-radius: var(--lobe-border-radius-sm); + background: var(--lobe-color-fill-secondary); +} + +.account-status-progress::-webkit-progress-bar { + border-radius: inherit; + background: var(--lobe-color-fill-secondary); +} + +.account-status-progress::-webkit-progress-value { + border-radius: inherit; + background: var(--lobe-color-primary); + transition: width 180ms ease; +} + +.account-status-progress::-moz-progress-bar { + border-radius: inherit; + background: var(--lobe-color-primary); +} + +.account-status-progress-warning::-webkit-progress-value { + background: var(--lobe-color-warning, #d97706); +} + +.account-status-progress-warning::-moz-progress-bar { + background: var(--lobe-color-warning, #d97706); +} + +.account-status-progress-exhausted::-webkit-progress-value { + background: var(--lobe-color-error); +} + +.account-status-progress-exhausted::-moz-progress-bar { + background: var(--lobe-color-error); +} + +.account-status-progress-unknown::-webkit-progress-value, +.account-status-progress-unknown::-moz-progress-bar { + background: var(--lobe-color-fill); +} + +.account-status-card-busy { + opacity: 0.72; +} + +@media (max-width: 767px) { + .console-main { + padding-inline: 16px; + } + + .account-status-quota-values { + flex-direction: column; + gap: 8px; + } + + .account-status-card button { + flex: 1 1 0; + } +} + +@media (min-width: 768px) and (max-width: 1199px) { + .console-main { + padding-inline: 24px; + } +} + .usage-auto-refresh-control { align-items: center; display: inline-flex; diff --git a/app/page-data.ts b/app/page-data.ts index f5df143..43ab58f 100644 --- a/app/page-data.ts +++ b/app/page-data.ts @@ -8,7 +8,13 @@ import type { import type { AdminDebugSnapshot } from '@/app/debug/debug'; export type TabKey = - 'dashboard' | 'usage' | 'credentials' | 'api-test' | 'debug' | 'settings'; + | 'dashboard' + | 'usage' + | 'credentials' + | 'account-status' + | 'api-test' + | 'debug' + | 'settings'; export interface AdminSettingsSnapshot { labels: Record; @@ -35,6 +41,11 @@ export interface CredentialsTabInitialData { tab: 'credentials'; } +export interface AccountStatusTabInitialData { + credentials: CredentialSummary[]; + tab: 'account-status'; +} + export interface ApiTestInitialData { credentialModels: Record; credentials: CredentialSummary[]; @@ -55,6 +66,7 @@ export interface SettingsTabInitialData { export type AdminConsoleInitialData = | ApiTestInitialData + | AccountStatusTabInitialData | CredentialsTabInitialData | DashboardInitialData | DebugTabInitialData diff --git a/app/page-loader.ts b/app/page-loader.ts index 49c439e..4b5d012 100644 --- a/app/page-loader.ts +++ b/app/page-loader.ts @@ -141,6 +141,14 @@ export const getInitialData = async ({ tab, }; } + case 'account-status': { + const credentials = await listCredentials(); + + return { + credentials: credentials.credentials as unknown as CredentialSummary[], + tab, + }; + } case 'api-test': { const eligibleCredentials = await listEligibleCredentialRecords(); const [credentials, currentCredential, models] = await Promise.all([ diff --git a/app/page-shell.tsx b/app/page-shell.tsx index 8608562..2835e1a 100644 --- a/app/page-shell.tsx +++ b/app/page-shell.tsx @@ -72,11 +72,18 @@ const tabs: Array<{ icon: typeof LayoutDashboard; key: TabKey; labelKey: - 'apiTest' | 'credentials' | 'dashboard' | 'debug' | 'settings' | 'usage'; + | 'apiTest' + | 'credentials' + | 'dashboard' + | 'accountStatus' + | 'debug' + | 'settings' + | 'usage'; }> = [ { icon: LayoutDashboard, key: 'dashboard', labelKey: 'dashboard' }, { icon: ChartLine, key: 'usage', labelKey: 'usage' }, { icon: KeyRound, key: 'credentials', labelKey: 'credentials' }, + { icon: KeyRound, key: 'account-status', labelKey: 'accountStatus' }, { icon: Send, key: 'api-test', labelKey: 'apiTest' }, { icon: Bug, key: 'debug', labelKey: 'debug' }, { icon: Settings2, key: 'settings', labelKey: 'settings' }, @@ -1913,6 +1920,7 @@ const AdminPageLayoutContent = ({ {children} ) : null} + {activeTab === 'account-status' ? children : null} {activeTab === 'usage' ? ( + String( + credential.data.bearer_token ?? credential.data.access_token ?? '', + ).trim(); + +const asRecord = (value: unknown): Record | null => + value && typeof value === 'object' + ? (value as Record) + : null; + +const findValue = (value: unknown, keys: string[]): unknown => { + const record = asRecord(value); + if (record) { + for (const key of keys) { + if (record[key] !== undefined && record[key] !== null) return record[key]; + } + for (const nested of Object.values(record)) { + const found = findValue(nested, keys); + if (found !== undefined) return found; + } + } + if (Array.isArray(value)) { + for (const item of value) { + const found = findValue(item, keys); + if (found !== undefined) return found; + } + } + return undefined; +}; + +const toNumber = (value: unknown): number | null => { + const number = Number(value); + return Number.isFinite(number) ? number : null; +}; + +const fetchJson = async ( + credential: CredentialRecord, + path: string, + method = 'GET', +): Promise => { + const response = await fetch(new URL(path, await getCodeBuddyApiEndpoint()), { + method, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${getBearerToken(credential)}`, + }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw new Error(`${path} returned ${response.status}`); + return response.json(); +}; + +const loadAccountStatus = async ( + credential: CredentialRecord, +): Promise => { + const errors: string[] = []; + let creditsPayload: unknown; + let checkinPayload: unknown; + let models: string[] = []; + + try { + creditsPayload = await fetchJson(credential, '/api/v2/quota/usage'); + } catch (error) { + errors.push( + error instanceof Error ? error.message : 'Credits query failed', + ); + } + try { + checkinPayload = await fetchJson( + credential, + '/sash/api/v1/me/daily-check-in/status', + ); + } catch (error) { + errors.push( + error instanceof Error ? error.message : 'Check-in query failed', + ); + } + try { + models = ( + await getModelsForCredential({ + bearerToken: getBearerToken(credential), + credentialData: credential.data, + }) + ).map((model) => model.id); + } catch (error) { + errors.push(error instanceof Error ? error.message : 'Model query failed'); + } + + const claimedValue = findValue(checkinPayload, [ + 'claimed', + 'isClaimed', + 'checkedIn', + 'status', + ]); + const claimed = + typeof claimedValue === 'boolean' + ? claimedValue + : typeof claimedValue === 'string' + ? ['CLAIMED', 'ALREADY_CLAIMED', 'CHECKED_IN'].includes( + claimedValue.toUpperCase(), + ) + : null; + return { + checkin: { + claimed, + message: typeof claimedValue === 'string' ? claimedValue : null, + }, + credits: { + total: toNumber( + findValue(creditsPayload, ['total', 'total_size', 'quota']), + ), + used: toNumber(findValue(creditsPayload, ['used', 'total_used'])), + remaining: toNumber( + findValue(creditsPayload, ['remaining', 'total_remain']), + ), + plan: + String( + findValue(creditsPayload, ['plan', 'planName', 'userType']) ?? '', + ) || null, + resetAt: + String( + findValue(creditsPayload, ['resetAt', 'reset_at', 'resetTime']) ?? '', + ) || null, + }, + error: errors.length ? errors.join('; ') : null, + filename: credential.filename, + models, + queriedAt: new Date().toISOString(), + }; +}; + +export const getAccountStatus = async ( + filenames?: string[], +): Promise => { + const credentials = await listEligibleCredentialRecords(filenames); + const results: AccountStatusSnapshot[] = []; + for (let index = 0; index < credentials.length; index += 4) { + const chunk = credentials.slice(index, index + 4); + results.push(...(await Promise.all(chunk.map(loadAccountStatus)))); + } + return results; +}; + +export const getAccountStatusCredentials = async () => { + const response = await listCredentials(); + return response.credentials; +}; + +export const checkinAccount = async ( + filename: string, +): Promise => { + const credential = (await listEligibleCredentialRecords([filename]))[0]; + if (!credential) throw new Error('Credential is unavailable'); + try { + await fetchJson(credential, '/sash/api/v1/me/daily-check-in/claim', 'POST'); + } catch (error) { + return { + ...(await loadAccountStatus(credential)), + error: error instanceof Error ? error.message : 'Check-in failed', + }; + } + return loadAccountStatus(credential); +}; + +export const checkinAccounts = async ( + filenames?: string[], +): Promise => { + const credentials = await listEligibleCredentialRecords(filenames); + const results: AccountStatusSnapshot[] = []; + for (let index = 0; index < credentials.length; index += 4) { + const chunk = credentials.slice(index, index + 4); + results.push( + ...(await Promise.all( + chunk.map((credential) => checkinAccount(credential.filename)), + )), + ); + } + return results; +}; diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index 50d49a6..7f1274a 100644 --- a/lib/server/proxy/codebuddy.ts +++ b/lib/server/proxy/codebuddy.ts @@ -2203,10 +2203,7 @@ export const getModelsForCredential = async ({ bearerToken: string; credentialData: CredentialData; }): Promise => { - const endpoint = new URL( - '/console/enterprises/personal/models', - await getCodeBuddyApiEndpoint(), - ); + const apiEndpoint = await getCodeBuddyApiEndpoint(); const headers = new Headers({ Accept: 'application/json', Authorization: `Bearer ${bearerToken}`, @@ -2232,10 +2229,16 @@ export const getModelsForCredential = async ({ headers.set('X-Tenant-Id', String(tenantId)); } - const response = await fetch(endpoint, { - headers, - signal: AbortSignal.timeout(15_000), - }); + const fetchModels = async (path: string): Promise => + fetch(new URL(path, apiEndpoint), { + headers, + signal: AbortSignal.timeout(15_000), + }); + let response = await fetchModels('/v3/config'); + + if (response.status === 404 || response.status === 405) { + response = await fetchModels('/console/enterprises/personal/models'); + } if (!response.ok) { throw new Error(`Model discovery failed with status ${response.status}`); diff --git a/messages/en-US.json b/messages/en-US.json index 926ac67..b80edcc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -25,10 +25,33 @@ "dashboard": "Dashboard", "usage": "Usage", "credentials": "Credentials", + "accountStatus": "Account Status", "apiTest": "API Test", "debug": "Debug", "settings": "Settings" }, + "accountStatus": { + "title": "Account Status", + "quota": "Quota", + "total": "Total", + "used": "Used", + "remaining": "Remaining", + "plan": "Plan", + "resetAt": "Reset time", + "exhausted": "Quota exhausted", + "checkin": "Check-in", + "checkedIn": "Checked in today", + "notCheckedIn": "Not checked in today", + "checkinAction": "Check in", + "models": "Available models", + "showModels": "Show all ({count})", + "modelCount": "Models ({count})", + "noModels": "No models", + "refresh": "Refresh", + "refreshAll": "Refresh all", + "checkinAll": "Check in all", + "empty": "No credentials" + }, "dashboard": { "welcomeTitle": "Ready to crank through some tokens?", "active": "{count} active", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index 4a41dfc..92544d0 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -25,10 +25,33 @@ "dashboard": "ダッシュボード", "usage": "使用量", "credentials": "認証情報", + "accountStatus": "アカウント状態", "apiTest": "API テスト", "debug": "Debug", "settings": "設定" }, + "accountStatus": { + "title": "アカウント状態", + "quota": "使用量", + "total": "合計", + "used": "使用済み", + "remaining": "残り", + "plan": "プラン", + "resetAt": "リセット時刻", + "exhausted": "使用量上限に達しました", + "checkin": "チェックイン", + "checkedIn": "本日はチェックイン済み", + "notCheckedIn": "本日は未チェックイン", + "checkinAction": "チェックイン", + "models": "利用可能なモデル", + "showModels": "すべて表示({count})", + "modelCount": "モデル({count})", + "noModels": "モデルなし", + "refresh": "更新", + "refreshAll": "すべて更新", + "checkinAll": "すべてチェックイン", + "empty": "認証情報がありません" + }, "dashboard": { "welcomeTitle": "トークンをぶん回す準備はできた?", "active": "{count} 件が有効", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 71db5c9..bd8742c 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -25,10 +25,33 @@ "dashboard": "仪表板", "usage": "用量统计", "credentials": "凭证管理", + "accountStatus": "账号状态", "apiTest": "API 测试", "debug": "Debug", "settings": "设置" }, + "accountStatus": { + "title": "账号状态", + "quota": "额度", + "total": "总额", + "used": "已使用", + "remaining": "剩余", + "plan": "套餐", + "resetAt": "重置时间", + "exhausted": "额度已用尽", + "checkin": "签到", + "checkedIn": "今日已签到", + "notCheckedIn": "今日未签到", + "checkinAction": "签到", + "models": "可用模型", + "showModels": "展开全部({count} 个)", + "modelCount": "模型({count} 个)", + "noModels": "暂无模型", + "refresh": "刷新", + "refreshAll": "刷新全部", + "checkinAll": "全部签到", + "empty": "暂无凭据" + }, "dashboard": { "welcomeTitle": "做好蹬的准备了吗?", "active": "{count} 个有效", diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..ce4e94a 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 738f71cfbbb04bb41210559719edd44c81623c20 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Wed, 2 Sep 2026 19:41:04 +0800 Subject: [PATCH 02/16] test: cover account status flows --- app/account-status/account-status.tsx | 54 +++++-- tests/admin/page-loader.test.ts | 24 +-- tests/server/account-status-route.test.ts | 82 ++++++++++ tests/server/account-status.test.ts | 177 ++++++++++++++++++++++ 4 files changed, 312 insertions(+), 25 deletions(-) create mode 100644 tests/server/account-status-route.test.ts create mode 100644 tests/server/account-status.test.ts diff --git a/app/account-status/account-status.tsx b/app/account-status/account-status.tsx index c11e76f..fcaff85 100644 --- a/app/account-status/account-status.tsx +++ b/app/account-status/account-status.tsx @@ -55,6 +55,20 @@ const initialSnapshot = (filename: string): AccountStatusSnapshot => ({ queriedAt: '', }); +const unavailableSnapshot = (filename: string): AccountStatusSnapshot => ({ + ...initialSnapshot(filename), + error: 'Credential is unavailable', +}); + +const failedSnapshot = ( + filename: string, + error: unknown, +): AccountStatusSnapshot => ({ + ...initialSnapshot(filename), + error: error instanceof Error ? error.message : 'Account status query failed', + queriedAt: new Date().toISOString(), +}); + const quotaPercent = (snapshot: AccountStatusSnapshot): number | null => { const { total, used } = snapshot.credits; if (total === null || total <= 0 || used === null) return null; @@ -253,6 +267,9 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => { headers: { 'Content-Type': 'application/json' }, method: 'POST', }); + if (!response.ok) { + throw new Error(`Account status request failed (${response.status})`); + } const payload = (await response.json()) as { status?: AccountStatusSnapshot; statuses?: AccountStatusSnapshot[]; @@ -260,6 +277,11 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => { const snapshot = payload.status ?? payload.statuses?.[0]; if (snapshot) setSnapshots((current) => ({ ...current, [filename]: snapshot })); + } catch (error) { + setSnapshots((current) => ({ + ...current, + [filename]: current[filename] ?? failedSnapshot(filename, error), + })); } finally { setBusy((current) => { const next = { ...current }; @@ -273,10 +295,15 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => { const loadAll = useCallback( async (action: 'refresh' | 'checkin') => { setBatchBusy(action); - await Promise.all( - credentials.map((credential) => loadOne(credential.filename, action)), - ); - setBatchBusy(null); + try { + await Promise.all( + credentials + .filter((credential) => !credential.is_expired) + .map((credential) => loadOne(credential.filename, action)), + ); + } finally { + setBatchBusy(null); + } }, [credentials, loadOne], ); @@ -322,23 +349,22 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => { - {snapshots && Object.keys(snapshots).length === 0 ? ( - - ) : ( - pageCredentials.map((credential) => ( + {pageCredentials.map((credential) => { + const snapshot = snapshots[credential.filename]; + if (!snapshot && !credential.is_expired) { + return ; + } + return ( void loadOne(credential.filename, 'checkin')} onRefresh={() => void loadOne(credential.filename)} /> - )) - )} + ); + })} {pageCount > 1 ? ( - {pageCredentials.map((credential) => { - const snapshot = snapshots[credential.filename]; - if (!snapshot && !credential.is_expired) { - return ; - } - return ( - void loadOne(credential.filename, 'checkin')} - onRefresh={() => void loadOne(credential.filename)} - /> - ); - })} + {credentials.length ? ( + pageCredentials.map((credential) => { + const snapshot = snapshots[credential.filename]; + if (!snapshot && !credential.is_expired) { + return ; + } + return ( + void loadOne(credential.filename, 'checkin')} + onRefresh={() => void loadOne(credential.filename)} + /> + ); + }) + ) : ( + + )} {pageCount > 1 ? ( {snapshot.error ? : null} @@ -167,36 +174,16 @@ const AccountStatusCard = ({ + remainingLabel={(value) => text('accountStatus.quotaUsed', { percent: value.toFixed(0) }) } /> - - - {text('accountStatus.total')}: {snapshot.credits.total ?? '—'} - - - {text('accountStatus.used')}: {snapshot.credits.used ?? '—'} - - - {text('accountStatus.remaining')}:{' '} - {snapshot.credits.remaining ?? '—'} - - {text('accountStatus.plan')}: {snapshot.credits.plan ?? '—'} {text('accountStatus.resetAt')}: {snapshot.credits.resetAt ?? '—'} - {percent !== null && percent >= 100 ? ( - {text('accountStatus.exhausted')} - ) : null} @@ -217,48 +204,16 @@ const AccountStatusCard = ({ {text('accountStatus.models')} - {models.length ? ( - - {snapshot.models.map((model) => ( - {model} - ))} - - ), - }, - ]} - variant="borderless" - /> + {snapshot.models.length ? ( + + {snapshot.models.map((model) => ( + {model} + ))} + ) : ( {text('accountStatus.noModels')} )} - - - {snapshot.queriedAt - ? new Date(snapshot.queriedAt).toLocaleString() - : '—'} - - - ); }; diff --git a/app/lobe-ui-provider.tsx b/app/lobe-ui-provider.tsx index 3b3928e..7c4d3a0 100644 --- a/app/lobe-ui-provider.tsx +++ b/app/lobe-ui-provider.tsx @@ -2,7 +2,12 @@ import { ConfigProvider, ThemeProvider } from '@lobehub/ui'; import { motion } from 'motion/react'; -import { useEffect, useState, type ReactNode } from 'react'; +import { + useEffect, + useState, + useSyncExternalStore, + type ReactNode, +} from 'react'; import { themeChangeEventName, type ThemeMode } from '@/lib/theme'; @@ -16,6 +21,11 @@ const resolveThemeMode = (theme: ThemeMode) => { }; const LobeUiProvider = ({ children, initialTheme }: LobeUiProviderProps) => { + const mounted = useSyncExternalStore( + () => () => undefined, + () => true, + () => false, + ); const [appearance, setAppearance] = useState<'dark' | 'light'>( initialTheme === 'dark' ? 'dark' : 'light', ); @@ -50,14 +60,18 @@ const LobeUiProvider = ({ children, initialTheme }: LobeUiProviderProps) => { return ( - - {children} - + {mounted ? ( + + {children} + + ) : ( + children + )} ); }; diff --git a/app/page-shell.tsx b/app/page-shell.tsx index 2835e1a..9a5d8cb 100644 --- a/app/page-shell.tsx +++ b/app/page-shell.tsx @@ -10,6 +10,7 @@ import { Button, ToastHost, toast } from '@lobehub/ui/base-ui'; import { Bug, ChartLine, + CircleUserRound, KeyRound, LayoutDashboard, LogOut, @@ -83,7 +84,7 @@ const tabs: Array<{ { icon: LayoutDashboard, key: 'dashboard', labelKey: 'dashboard' }, { icon: ChartLine, key: 'usage', labelKey: 'usage' }, { icon: KeyRound, key: 'credentials', labelKey: 'credentials' }, - { icon: KeyRound, key: 'account-status', labelKey: 'accountStatus' }, + { icon: CircleUserRound, key: 'account-status', labelKey: 'accountStatus' }, { icon: Send, key: 'api-test', labelKey: 'apiTest' }, { icon: Bug, key: 'debug', labelKey: 'debug' }, { icon: Settings2, key: 'settings', labelKey: 'settings' }, diff --git a/lib/server/domain/account-status.ts b/lib/server/domain/account-status.ts index 624d934..0fce907 100644 --- a/lib/server/domain/account-status.ts +++ b/lib/server/domain/account-status.ts @@ -220,6 +220,8 @@ const loadAccountStatus = async ( 'claimed', 'isClaimed', 'checkedIn', + 'today_checked_in', + 'todayCheckedIn', 'status', ]); const claimed = @@ -258,11 +260,21 @@ const loadAccountStatus = async ( ), plan: String( - findValue(creditsPayload, ['plan', 'planName', 'userType']) ?? '', + findValue(creditsPayload, [ + 'plan', + 'planName', + 'userType', + 'PackageName', + ]) ?? '', ) || null, resetAt: String( - findValue(creditsPayload, ['resetAt', 'reset_at', 'resetTime']) ?? '', + findValue(creditsPayload, [ + 'resetAt', + 'reset_at', + 'resetTime', + 'CycleEndTime', + ]) ?? '', ) || null, }, error: errors.length ? errors.join('; ') : null, diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index c2c8c2a..2b6d6ac 100644 --- a/lib/server/proxy/codebuddy.ts +++ b/lib/server/proxy/codebuddy.ts @@ -745,6 +745,7 @@ const buildUpstreamHeaders = async ( headers.set('X-IDE-Name', 'CLI'); headers.set('X-IDE-Type', 'CLI'); headers.set('X-IDE-Version', CODEBUDDY_CLI_VERSION); + headers.set('X-Client-Platform', 'web'); headers.set('X-Product', 'SaaS'); headers.set('X-Product-Version', CODEBUDDY_CLI_VERSION); headers.set('X-Request-ID', requestId); @@ -2260,7 +2261,7 @@ export const getModelsForCredential = async ({ }); let response = await fetchModels('/v3/config'); - if (response.status === 404 || response.status === 405) { + if ([400, 404, 405].includes(response.status)) { response = await fetchModels('/console/enterprises/personal/models'); } @@ -2305,6 +2306,11 @@ export const getModelsForCredential = async ({ ]; }), ); + const declaredModelIds = new Set( + (payload.data?.models ?? []) + .map((model) => (typeof model.id === 'string' ? model.id.trim() : '')) + .filter(Boolean), + ); if (!Array.isArray(cliModels)) { return []; @@ -2316,7 +2322,15 @@ export const getModelsForCredential = async ({ } const model = modelsById.get(modelId); - return model ? [model] : []; + if (!model && declaredModelIds.has(modelId)) { + return []; + } + return [ + model ?? { + displayName: modelId, + id: modelId, + }, + ]; }); }; diff --git a/messages/en-US.json b/messages/en-US.json index 72f88c2..6d3dc80 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -52,7 +52,7 @@ "checkinAll": "Check in all", "empty": "No credentials", "quotaUnknown": "Unknown quota", - "quotaUsed": "{percent}% quota used" + "quotaUsed": "{percent}% quota remaining" }, "dashboard": { "welcomeTitle": "Ready to crank through some tokens?", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index c94ee0f..3a13dc1 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -52,7 +52,7 @@ "checkinAll": "すべてチェックイン", "empty": "認証情報がありません", "quotaUnknown": "使用量不明", - "quotaUsed": "使用量 {percent}%" + "quotaUsed": "残り容量 {percent}%" }, "dashboard": { "welcomeTitle": "トークンをぶん回す準備はできた?", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index e423b8d..de49f88 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -52,7 +52,7 @@ "checkinAll": "全部签到", "empty": "暂无凭据", "quotaUnknown": "额度未知", - "quotaUsed": "已使用 {percent}% 额度" + "quotaUsed": "剩余额度 {percent}%" }, "dashboard": { "welcomeTitle": "做好蹬的准备了吗?", diff --git a/tests/server/account-status.test.ts b/tests/server/account-status.test.ts index 52c454c..276405e 100644 --- a/tests/server/account-status.test.ts +++ b/tests/server/account-status.test.ts @@ -70,6 +70,7 @@ describe('account status domain', () => { it('records partial upstream errors', async () => { vi.spyOn(globalThis, 'fetch') .mockResolvedValueOnce(jsonResponse({ userQuota: { total: 0 } })) + .mockResolvedValueOnce(jsonResponse({}, 404)) .mockResolvedValueOnce(jsonResponse({}, 404)); vi.mocked(getModelsForCredential).mockRejectedValueOnce( new Error('models unavailable'), From b61712e21c2793e90bf92287959ae9c09b4d4e58 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Thu, 3 Sep 2026 17:04:34 +0800 Subject: [PATCH 11/16] fix: show account refresh errors --- app/account-status/account-status.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/account-status/account-status.tsx b/app/account-status/account-status.tsx index e96c180..146d40b 100644 --- a/app/account-status/account-status.tsx +++ b/app/account-status/account-status.tsx @@ -94,7 +94,7 @@ const QuotaProgress = ({ : 'normal'; return ( - + {snapshot.credits.remaining ?? '—'} / {snapshot.credits.total ?? '—'} @@ -248,7 +248,13 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => { } catch (error) { setSnapshots((current) => ({ ...current, - [filename]: current[filename] ?? failedSnapshot(filename, error), + [filename]: { + ...(current[filename] ?? failedSnapshot(filename, error)), + error: + error instanceof Error + ? error.message + : 'Account status query failed', + }, })); } finally { setBusy((current) => { @@ -298,7 +304,6 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => { horizontal wrap="wrap" > - {text('accountStatus.title')}