From fdf4f3098784d0ebb20419248b200a254fc4f50e Mon Sep 17 00:00:00 2001 From: cita Date: Thu, 5 Mar 2026 21:18:49 +0800 Subject: [PATCH] feat: add createdAt timestamps to check-in logs and improve i18n translations - Added createdAt timestamps to various check-in log entries in checkinService.ts for better tracking. - Updated i18n translations in Models.tsx to improve user experience with accurate Chinese translations. - Reduced default page size in ProgramLogs.tsx and ProxyLogs.tsx from 80 and 100 to 50 for better performance. - Enhanced ProxyLogs.tsx with memoization for filtered logs and summary statistics. - Implemented support for parsing Unix timestamps and PostgreSQL-style timezone offsets in checkinLogTime.ts. - Added comprehensive tests for database proxy query wrapper in index.proxy-wrap.test.ts to ensure functionality. --- src/server/db/index.proxy-wrap.test.ts | 57 +++++++ src/server/db/index.ts | 19 ++- src/server/index.ts | 16 ++ src/server/routes/api/accountTokens.ts | 1 + src/server/routes/api/sites.ts | 2 + src/server/routes/proxy/chat.ts | 1 + src/server/routes/proxy/completions.ts | 27 ++-- src/server/routes/proxy/embeddings.ts | 27 ++-- src/server/routes/proxy/images.ts | 27 ++-- src/server/routes/proxy/responses.ts | 1 + src/server/services/alertService.ts | 2 + src/server/services/checkinService.ts | 4 + src/web/i18n.tsx | 3 + src/web/pages/Models.tsx | 160 +++++++++++-------- src/web/pages/ProgramLogs.tsx | 2 +- src/web/pages/ProxyLogs.tsx | 50 ++++-- src/web/pages/TokenRoutes.tsx | 48 +++--- src/web/pages/helpers/checkinLogTime.test.ts | 9 ++ src/web/pages/helpers/checkinLogTime.ts | 13 ++ 19 files changed, 313 insertions(+), 156 deletions(-) create mode 100644 src/server/db/index.proxy-wrap.test.ts diff --git a/src/server/db/index.proxy-wrap.test.ts b/src/server/db/index.proxy-wrap.test.ts new file mode 100644 index 0000000..7e68a0e --- /dev/null +++ b/src/server/db/index.proxy-wrap.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +type DbModule = typeof import('./index.js'); + +describe('db proxy query wrapper', () => { + let testUtils: DbModule['__dbProxyTestUtils']; + + beforeAll(async () => { + process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'metapi-db-proxy-wrap-')); + const dbModule = await import('./index.js'); + testUtils = dbModule.__dbProxyTestUtils; + }); + + it('wraps thenable query builders and provides all/get shims', async () => { + const execute = vi.fn(async () => [{ id: 1, name: 'demo' }]); + const queryLike = { + execute, + where() { + return this; + }, + then(onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) { + return Promise.resolve(execute()).then(onFulfilled, onRejected); + }, + }; + + expect(testUtils.shouldWrapObject(queryLike)).toBe(true); + const wrapped = testUtils.wrapQueryLike(queryLike as any); + + const rows = await wrapped.where().all(); + const row = await wrapped.where().get(); + + expect(execute).toHaveBeenCalledTimes(2); + expect(rows).toEqual([{ id: 1, name: 'demo' }]); + expect(row).toEqual({ id: 1, name: 'demo' }); + }); + + it('provides run shim for thenable query builders', async () => { + const execute = vi.fn(async () => [{ changes: 3, lastInsertRowid: 9 }]); + const queryLike = { + execute, + then(onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) { + return Promise.resolve(execute()).then(onFulfilled, onRejected); + }, + }; + + const wrapped = testUtils.wrapQueryLike(queryLike as any); + const runResult = await wrapped.run(); + + expect(execute).toHaveBeenCalledTimes(1); + expect(runResult).toEqual({ changes: 3, lastInsertRowid: 9 }); + expect(testUtils.shouldWrapObject(Promise.resolve())).toBe(false); + }); +}); + diff --git a/src/server/db/index.ts b/src/server/db/index.ts index f251e00..fe6af7c 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -411,8 +411,16 @@ function normalizeRunResult(result: unknown): { changes: number; lastInsertRowid const wrappedObjects = new WeakMap(); +function shouldWrapObject(value: unknown): value is object { + if (!value || typeof value !== 'object') return false; + // Drizzle query builders are thenable objects (QueryPromise) but are not native Promises. + // They still need wrapping so we can provide sqlite-style `.all/.get/.run` shims. + if (value instanceof Promise) return false; + return true; +} + function wrapQueryLike(value: T): T { - if (!value || typeof value !== 'object') return value; + if (!shouldWrapObject(value)) return value; const target = value as unknown as object; if (wrappedObjects.has(target)) { return wrappedObjects.get(target) as T; @@ -446,7 +454,7 @@ function wrapQueryLike(value: T): T { return (...args: unknown[]) => { const result = original.apply(innerTarget, args); - if (result && typeof result === 'object' && typeof (result as Promise).then !== 'function') { + if (shouldWrapObject(result)) { return wrapQueryLike(result); } return result; @@ -483,7 +491,7 @@ function wrapDbClient( return (...args: unknown[]) => { const result = original.apply(target, args); - if (result && typeof result === 'object' && typeof (result as Promise).then !== 'function') { + if (shouldWrapObject(result)) { return wrapQueryLike(result); } return result; @@ -636,3 +644,8 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD throw error; } } + +export const __dbProxyTestUtils = { + wrapQueryLike, + shouldWrapObject, +}; diff --git a/src/server/index.ts b/src/server/index.ts index 796eaaf..ec88256 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -23,6 +23,7 @@ import { startProxyLogRetentionService, stopProxyLogRetentionService } from './s import { buildStartupSummaryLines } from './services/startupInfo.js'; import { existsSync } from 'fs'; import { normalize, resolve, sep } from 'path'; +import { eq, isNull, or } from 'drizzle-orm'; import { db, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect } from './db/index.js'; function toSettingsMap(rows: Array<{ key: string; value: string }>) { @@ -182,6 +183,21 @@ try { const finalRows = await db.select().from(schema.settings).all(); const finalMap = toSettingsMap(finalRows); applyRuntimeSettings(finalMap); + + const repairedAt = new Date().toISOString(); + await db.update(schema.events) + .set({ createdAt: repairedAt }) + .where(or(isNull(schema.events.createdAt), eq(schema.events.createdAt, ''))) + .run(); + await db.update(schema.proxyLogs) + .set({ createdAt: repairedAt }) + .where(or(isNull(schema.proxyLogs.createdAt), eq(schema.proxyLogs.createdAt, ''))) + .run(); + await db.update(schema.checkinLogs) + .set({ createdAt: repairedAt }) + .where(or(isNull(schema.checkinLogs.createdAt), eq(schema.checkinLogs.createdAt, ''))) + .run(); + console.log('Loaded runtime settings overrides'); } catch { /* first run, table may not exist */ } diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 336fc5f..a48aaea 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -274,6 +274,7 @@ async function appendTokenSyncEvent(result: SyncExecutionResult) { level, relatedId: result.accountId, relatedType: 'account', + createdAt: new Date().toISOString(), }).run(); } catch {} } diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index 20220eb..ad08480 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -244,6 +244,7 @@ export async function sitesRoutes(app: FastifyInstance) { level: 'warning', relatedId: id, relatedType: 'site', + createdAt: new Date().toISOString(), }).run(); } catch {} } else { @@ -260,6 +261,7 @@ export async function sitesRoutes(app: FastifyInstance) { level: 'info', relatedId: id, relatedType: 'site', + createdAt: new Date().toISOString(), }).run(); } catch {} } diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 3cddd6b..477e6b0 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -739,6 +739,7 @@ async function logProxy( estimatedCost, errorMessage: normalizedErrorMessage, retryCount, + createdAt: new Date().toISOString(), }).run(); } catch (error) { console.warn('[proxy/chat] failed to write proxy log', error); diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 4aec8d1..7c5e312 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -247,20 +247,21 @@ async function logProxy( errorMessage, }); await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost, + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost, errorMessage: normalizedErrorMessage, - retryCount, + retryCount, + createdAt: new Date().toISOString(), }).run(); } catch (error) { console.warn('[proxy/completions] failed to write proxy log', error); diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 95da808..fc80209 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -162,20 +162,21 @@ async function logProxy( errorMessage, }); await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost, + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost, errorMessage: normalizedErrorMessage, - retryCount, + retryCount, + createdAt: new Date().toISOString(), }).run(); } catch (error) { console.warn('[proxy/embeddings] failed to write proxy log', error); diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 308ccef..6556c60 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -132,20 +132,21 @@ async function logProxy( errorMessage, }); await db.insert(schema.proxyLogs).values({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - estimatedCost, + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel, + status, + httpStatus, + latencyMs, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + estimatedCost, errorMessage: normalizedErrorMessage, - retryCount, + retryCount, + createdAt: new Date().toISOString(), }).run(); } catch (error) { console.warn('[proxy/images] failed to write proxy log', error); diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 4238bf5..0d7c14b 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -1597,6 +1597,7 @@ async function logProxy( estimatedCost, errorMessage: normalizedErrorMessage, retryCount, + createdAt: new Date().toISOString(), }).run(); } catch (error) { console.warn('[proxy/responses] failed to write proxy log', error); diff --git a/src/server/services/alertService.ts b/src/server/services/alertService.ts index 33bf181..23bb59d 100644 --- a/src/server/services/alertService.ts +++ b/src/server/services/alertService.ts @@ -22,6 +22,7 @@ export async function reportTokenExpired(params: { level: 'error', relatedId: params.accountId, relatedType: 'account', + createdAt: new Date().toISOString(), }).run(); await db.update(schema.accounts).set({ @@ -49,6 +50,7 @@ export async function reportProxyAllFailed(params: { model: string; reason: stri message: `模型=${params.model}, 原因=${params.reason}`, level: 'error', relatedType: 'route', + createdAt: new Date().toISOString(), }).run(); await sendNotification( diff --git a/src/server/services/checkinService.ts b/src/server/services/checkinService.ts index 909600c..820371f 100644 --- a/src/server/services/checkinService.ts +++ b/src/server/services/checkinService.ts @@ -137,6 +137,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: accountId: account.id, status: 'skipped', message: 'site disabled', + createdAt: new Date().toISOString(), }).run(); if (!options?.skipEvent) { @@ -147,6 +148,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: level: 'info', relatedId: accountId, relatedType: 'account', + createdAt: new Date().toISOString(), }).run(); } @@ -245,6 +247,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: status: normalizedStatus, message: logMessage, reward: logReward, + createdAt: new Date().toISOString(), }).run(); if (!options?.skipEvent) { @@ -257,6 +260,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?: level: effectiveSuccess ? 'info' : 'error', relatedId: accountId, relatedType: 'account', + createdAt: new Date().toISOString(), }).run(); } diff --git a/src/web/i18n.tsx b/src/web/i18n.tsx index 72d29d6..2b36584 100644 --- a/src/web/i18n.tsx +++ b/src/web/i18n.tsx @@ -463,6 +463,9 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { }; walk(root); + if (language !== 'en') { + return; + } const observer = new MutationObserver((records) => { for (const record of records) { diff --git a/src/web/pages/Models.tsx b/src/web/pages/Models.tsx index 119d552..8703e9e 100644 --- a/src/web/pages/Models.tsx +++ b/src/web/pages/Models.tsx @@ -92,11 +92,11 @@ function getSuccessBadgeClass(rate: number | null) { function resolveMarketplaceDescription(model: ModelRow, metadataHydrating: boolean): string { if (model.description && model.description.trim().length > 0) return model.description; - if (metadataHydrating) return tr('正在加载模型元数据...'); + if (metadataHydrating) return tr('姝e湪鍔犺浇妯″瀷鍏冩暟鎹?..'); const hasOtherMetadata = model.tags.length > 0 || model.supportedEndpointTypes.length > 0 || model.pricingSources.length > 0; - if (hasOtherMetadata) return tr('上游未提供文字说明,但已同步标签、能力或价格信息。'); - return tr('当前上游仅返回模型 ID,未返回说明字段(常见于很多站点)。'); + if (hasOtherMetadata) return tr('上游未提供描述文本,但已同步标签、能力或价格信息。'); + return tr('当前上游仅返回模型 ID,未返回描述字段。'); } function renderGroupPricingValue(pricing: ModelGroupPricing): string { @@ -198,14 +198,20 @@ export default function Models() { useEffect(() => { let cancelled = false; + let metadataTimer: ReturnType | null = null; const bootstrap = async () => { const initial = await loadBaseMarketplace(false); if (!initial || cancelled) return; - void hydrateMarketplaceMetadata(initial.models); + metadataTimer = setTimeout(() => { + if (!cancelled) { + void hydrateMarketplaceMetadata(initial.models); + } + }, 1200); }; void bootstrap(); return () => { cancelled = true; + if (metadataTimer) clearTimeout(metadataTimer); latestMetadataRequestRef.current += 1; }; }, [hydrateMarketplaceMetadata, loadBaseMarketplace]); @@ -214,7 +220,9 @@ export default function Models() { void (async () => { const refreshed = await loadBaseMarketplace(true); if (!refreshed) return; - void hydrateMarketplaceMetadata(refreshed.models); + setTimeout(() => { + void hydrateMarketplaceMetadata(refreshed.models); + }, 600); })(); }, [hydrateMarketplaceMetadata, loadBaseMarketplace]); @@ -339,15 +347,15 @@ export default function Models() { {/* Brand filter */}
- {tr('品牌')} - {activeBrand && } + {tr('鍝佺墝')} + {activeBrand && }
setActiveBrand(null)} > - ✦ - {tr('全部品牌')} + ✓ + {tr('鍏ㄩ儴鍝佺墝')} {data.models.length}
{brandList.list.map(([brandName, { count, brand }]) => ( @@ -375,7 +383,7 @@ export default function Models() { onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')} > ? - {tr('其他')} + {tr('鍏朵粬')} {brandList.otherCount}
)} @@ -385,7 +393,7 @@ export default function Models() {
{tr('供应商')} - {activeSite && } + {activeSite && }
{siteMap.map(([site, count]) => (
-
{tr('排序方式')}
+
{tr('鎺掑簭鏂瑰紡')}
{[ { key: 'accountCount' as SortColumn, label: tr('账号数') }, { key: 'tokenCount' as SortColumn, label: tr('令牌数') }, - { key: 'avgLatency' as SortColumn, label: tr('延迟') }, + { key: 'avgLatency' as SortColumn, label: tr('寤惰繜') }, { key: 'successRate' as SortColumn, label: tr('成功率') }, - { key: 'name' as SortColumn, label: tr('名称') }, + { key: 'name' as SortColumn, label: tr('鍚嶇О') }, ].map(opt => (
setFilterCollapsed(true)} > - {tr('收起')} + {tr('鏀惰捣')}
)} @@ -450,7 +458,7 @@ export default function Models() {

- {activeBrand || activeSite || tr('模型广场')} + {activeBrand || activeSite || tr('妯″瀷骞垮満')} {tr('共')} {filteredModels.length} {tr('个模型')} @@ -474,13 +482,13 @@ export default function Models() { {metadataHydrating && ( - {tr('加载元数据中...')} + {tr('鍔犺浇鍏冩暟鎹腑...')} )}
- -
@@ -496,18 +504,18 @@ export default function Models() { setSearch(e.target.value)} - placeholder={tr('模糊搜索模型名称')} + placeholder={tr('妯$硦鎼滅储妯″瀷鍚嶇О')} />

{/* Quick stats */}
- {tr('覆盖槽位')} {totalCoverageSlots} + {tr('瑕嗙洊妲戒綅')} {totalCoverageSlots} - {tr('去重账号')} {uniqueAccountCount} + {tr('鍘婚噸璐﹀彿')} {uniqueAccountCount} - {tr('平均延迟')} {avgLatency}ms + {tr('骞冲潎寤惰繜')} {avgLatency}ms
@@ -519,14 +527,16 @@ export default function Models() {
-
{tr('暂无模型数据')}
+
{tr('鏆傛棤妯″瀷鏁版嵁')}
{tr('请先检查站点与账号状态,然后点击刷新。')}
) : viewMode === 'card' ? ( /* ====== Card View ====== */
- {paged.map(m => ( -
setExpanded(expanded === m.name ? null : m.name)}> + {paged.map((m) => { + const isExpanded = expanded === m.name; + return ( +
setExpanded(isExpanded ? null : m.name)}>
@@ -538,14 +548,14 @@ export default function Models() { - {m.tokenCount} {tr('令牌')} + {m.tokenCount} {tr('浠ょ墝')} - {tr('延迟')} {m.avgLatency}ms + {tr('寤惰繜')} {m.avgLatency}ms @@ -585,10 +595,10 @@ export default function Models() { {site} ))} {m.successRate != null && m.successRate >= 90 && ( - {tr('健康')} + {tr('鍋ュ悍')} )} {m.successRate != null && m.successRate < 60 && ( - {tr('风险')} + {tr('椋庨櫓')} )} {m.avgLatency <= 500 && ( {tr('低延迟')} @@ -596,33 +606,34 @@ export default function Models() {
{/* Expand: Account Details */} -
e.stopPropagation()}> + {isExpanded ? ( +
e.stopPropagation()}>
-
{tr('基础信息')}
+
{tr('鍩虹淇℃伅')}
{resolveMarketplaceDescription(m, metadataHydrating)}
{m.tags.length > 0 ? m.tags.map((tag) => ( {tag} - )) : {metadataHydrating ? tr('加载元数据中...') : tr('暂无标签')}} + )) : {metadataHydrating ? tr('鍔犺浇鍏冩暟鎹腑...') : tr('鏆傛棤鏍囩')}}
-
{tr('接口能力')}
+
{tr('鎺ュ彛鑳藉姏')}
{m.supportedEndpointTypes.length > 0 ? m.supportedEndpointTypes.map((endpoint) => ( {endpoint} - )) : {metadataHydrating ? tr('加载元数据中...') : tr('未提供')}} + )) : {metadataHydrating ? tr('鍔犺浇鍏冩暟鎹腑...') : tr('未提供')}}
-
{tr('分组计费')}
+
{tr('鍒嗙粍璁¤垂')}
{m.pricingSources.length > 0 ? (
{m.pricingSources.map((source) => ( @@ -631,7 +642,7 @@ export default function Models() { style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }} >
- {source.siteName} · {source.username || `ID:${source.accountId}`} + {source.siteName} 路 {source.username || `ID:${source.accountId}`}
{Object.entries(source.groupPricing).map(([group, pricing]) => ( @@ -644,7 +655,7 @@ export default function Models() { ))}
) : ( - {metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')} + {metadataHydrating ? tr('姝e湪鍔犺浇浠锋牸鍏冩暟鎹?..') : tr('暂无价格元数据')} )}
@@ -652,11 +663,11 @@ export default function Models() { - - - - - + + + + + @@ -682,8 +693,10 @@ export default function Models() { + ) : null} - ))} + ); + })} ) : ( /* ====== Table View ====== */ @@ -693,7 +706,7 @@ export default function Models() { - + - {paged.map(m => ( + {paged.map((m) => { + const isExpanded = expanded === m.name; + return ( - setExpanded(expanded === m.name ? null : m.name)} style={{ cursor: 'pointer' }}> + setExpanded(isExpanded ? null : m.name)} style={{ cursor: 'pointer' }}> @@ -741,7 +756,7 @@ export default function Models() { - + {isExpanded ? ( + + {expanded === log.id && ( + )} )})} diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index f76eb95..935eeb3 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -801,7 +801,12 @@ export default function TokenRoutes() { normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName), ); setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {}); - void loadRouteDecisions(normalizedRoutes, { force: true }); + const decisionPlaceholder: Record = {}; + for (const route of normalizedRoutes) { + decisionPlaceholder[route.id] = null; + } + setDecisionByRoute(decisionPlaceholder); + setDecisionAutoSkipped(normalizedRoutes.length > 0); }; useEffect(() => { @@ -1156,30 +1161,9 @@ export default function TokenRoutes() { setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK)); }, [filteredRoutes.length]); - useEffect(() => { - const initialVisible = getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK); - if (initialVisible >= filteredRoutes.length) return; - let cancelled = false; - let timer: ReturnType | null = null; - - const pump = () => { - if (cancelled) return; - setVisibleRouteCount((current) => { - if (current >= filteredRoutes.length) return current; - const next = getNextVisibleCount(current, filteredRoutes.length, ROUTE_RENDER_CHUNK); - if (next < filteredRoutes.length) { - timer = setTimeout(pump, 16); - } - return next; - }); - }; - - timer = setTimeout(pump, 16); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - }; - }, [filteredRoutes.length]); + const handleLoadMoreRoutes = () => { + setVisibleRouteCount((current) => getNextVisibleCount(current, filteredRoutes.length, ROUTE_RENDER_CHUNK)); + }; const visibleRoutes = useMemo( () => filteredRoutes.slice(0, visibleRouteCount), @@ -2274,8 +2258,18 @@ export default function TokenRoutes() { })} {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( -
- {tr('正在渲染路由')} {visibleRouteCount} / {filteredRoutes.length} +
+
+ {tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length} +
+
)} diff --git a/src/web/pages/helpers/checkinLogTime.test.ts b/src/web/pages/helpers/checkinLogTime.test.ts index b90f688..1855967 100644 --- a/src/web/pages/helpers/checkinLogTime.test.ts +++ b/src/web/pages/helpers/checkinLogTime.test.ts @@ -19,4 +19,13 @@ describe('formatCheckinLogTime', () => { expect(formatDateLocal('2026-02-25 03:51:58', 'en-US', 'UTC')).toBe('02/25/2026'); expect(formatDateTimeMinuteLocal('2026-02-25 03:51:58', 'en-US', 'UTC')).toBe('02/25/2026, 03:51'); }); + + it('supports postgres-style timezone offset without colon', () => { + expect(formatDateTimeMinuteLocal('2026-03-05 20:14:39+08', 'en-US', 'UTC')).toBe('03/05/2026, 12:14'); + expect(formatDateTimeMinuteLocal('2026-03-05T20:14:39+0800', 'en-US', 'UTC')).toBe('03/05/2026, 12:14'); + }); + + it('supports unix timestamp strings', () => { + expect(formatDateTimeMinuteLocal('1709640000', 'en-US', 'UTC')).toBe('03/05/2024, 12:00'); + }); }); diff --git a/src/web/pages/helpers/checkinLogTime.ts b/src/web/pages/helpers/checkinLogTime.ts index d795280..757d276 100644 --- a/src/web/pages/helpers/checkinLogTime.ts +++ b/src/web/pages/helpers/checkinLogTime.ts @@ -4,10 +4,23 @@ function parseServerUtcDate(value: string | null | undefined): { date: Date | nu const raw = String(value).trim(); if (!raw) return { date: null, raw }; + if (/^\d{10,13}$/.test(raw)) { + const asNumber = Number(raw); + if (Number.isFinite(asNumber)) { + const epochMs = raw.length === 13 ? asNumber : asNumber * 1000; + const date = new Date(epochMs); + if (!Number.isNaN(date.getTime())) { + return { date, raw }; + } + } + } + let normalized = raw; if (!normalized.includes('T') && normalized.includes(' ')) { normalized = normalized.replace(' ', 'T'); } + normalized = normalized.replace(/([+-]\d{2})(\d{2})$/, '$1:$2'); + normalized = normalized.replace(/([+-]\d{2})$/, '$1:00'); const hasTimeZone = /[zZ]$|[+-]\d{2}:\d{2}$/.test(normalized); if (!hasTimeZone) {
{tr('站点')}{tr('账号')}{tr('令牌')}{tr('延迟')}{tr('余额')}{tr('绔欑偣')}{tr('璐﹀彿')}{tr('浠ょ墝')}{tr('寤惰繜')}{tr('浣欓')}
{ setSortBy('name'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> - {tr('模型名称')} {sortBy === 'name' ? (sortDir === 'desc' ? '↓' : '↑') : ''} + {tr('妯″瀷鍚嶇О')} {sortBy === 'name' ? (sortDir === 'desc' ? '↓' : '↑') : ''} { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> {tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''} @@ -702,18 +715,20 @@ export default function Models() { {tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''} { setSortBy('avgLatency'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> - {tr('延迟')} {sortBy === 'avgLatency' ? (sortDir === 'desc' ? '↓' : '↑') : ''} + {tr('寤惰繜')} {sortBy === 'avgLatency' ? (sortDir === 'desc' ? '↓' : '↑') : ''} { setSortBy('successRate'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> {tr('成功率')} {sortBy === 'successRate' ? (sortDir === 'desc' ? '↓' : '↑') : ''} {tr('操作')}{tr('鎿嶄綔')}
e.stopPropagation()}> -
-
+
-
{tr('基础信息')}
+
{tr('鍩虹淇℃伅')}
{resolveMarketplaceDescription(m, metadataHydrating)}
{m.tags.length > 0 ? m.tags.map((tag) => ( {tag} - )) : {metadataHydrating ? tr('加载元数据中...') : tr('暂无标签')}} + )) : {metadataHydrating ? tr('鍔犺浇鍏冩暟鎹腑...') : tr('鏆傛棤鏍囩')}}
-
{tr('接口能力')}
+
{tr('鎺ュ彛鑳藉姏')}
{m.supportedEndpointTypes.length > 0 ? m.supportedEndpointTypes.map((endpoint) => ( {endpoint} - )) : {metadataHydrating ? tr('加载元数据中...') : tr('未提供')}} + )) : {metadataHydrating ? tr('鍔犺浇鍏冩暟鎹腑...') : tr('未提供')}}
-
{tr('分组计费')}
+
{tr('鍒嗙粍璁¤垂')}
{m.pricingSources.length > 0 ? (
{m.pricingSources.map((source) => ( @@ -787,7 +803,7 @@ export default function Models() { style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }} >
- {source.siteName} · {source.username || `ID:${source.accountId}`} + {source.siteName} 路 {source.username || `ID:${source.accountId}`}
{Object.entries(source.groupPricing).map(([group, pricing]) => ( @@ -800,18 +816,18 @@ export default function Models() { ))}
) : ( - {metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')} + {metadataHydrating ? tr('姝e湪鍔犺浇浠锋牸鍏冩暟鎹?..') : tr('暂无价格元数据')} )}
- - - - - + + + + + {m.accounts.map(a => ( @@ -836,8 +852,10 @@ export default function Models() { + ) : null} - ))} + ); + })}
{tr('站点')}{tr('账号')}{tr('令牌')}{tr('延迟')}{tr('余额')}{tr('绔欑偣')}{tr('璐﹀彿')}{tr('浠ょ墝')}{tr('寤惰繜')}{tr('浣欓')}
@@ -870,7 +888,7 @@ export default function Models() {
- {tr('每页条数')}: + {tr('姣忛〉鏉℃暟')}:
); } + + diff --git a/src/web/pages/ProgramLogs.tsx b/src/web/pages/ProgramLogs.tsx index 44a7788..4f7bf41 100644 --- a/src/web/pages/ProgramLogs.tsx +++ b/src/web/pages/ProgramLogs.tsx @@ -17,7 +17,7 @@ type ProgramEvent = { createdAt?: string | null; }; -const PAGE_SIZE = 80; +const PAGE_SIZE = 50; const TYPE_OPTIONS = [ { value: '', label: '全部类型' }, diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index c7c77d1..7083d47 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { api } from '../api.js'; import { useToast } from '../components/Toast.js'; import { ModelBadge } from '../components/BrandIcon.js'; @@ -59,7 +59,7 @@ export default function ProxyLogs() { const [search, setSearch] = useState(''); const [expanded, setExpanded] = useState(null); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(100); + const [pageSize, setPageSize] = useState(50); const toast = useToast(); const load = useCallback(async () => { @@ -77,7 +77,7 @@ export default function ProxyLogs() { useEffect(() => { load(); }, [load]); /* ---- derived ---- */ - const filtered = logs.filter(log => { + const filtered = useMemo(() => logs.filter(log => { if (statusFilter === 'success' && log.status !== 'success') return false; if (statusFilter === 'failed' && log.status === 'success') return false; if (search) { @@ -86,20 +86,36 @@ export default function ProxyLogs() { (log.modelActual || '').toLowerCase().includes(q); } return true; - }); + }), [logs, statusFilter, search]); const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const safePage = Math.min(page, totalPages); - const paged = filtered.slice((safePage - 1) * pageSize, safePage * pageSize); + const paged = useMemo( + () => filtered.slice((safePage - 1) * pageSize, safePage * pageSize), + [filtered, safePage, pageSize], + ); useEffect(() => { setPage(1); }, [statusFilter, search, pageSize]); /* ---- stats ---- */ - const totalCount = logs.length; - const successCount = logs.filter(l => l.status === 'success').length; - const failedCount = totalCount - successCount; - const totalCost = logs.reduce((s, l) => s + (l.estimatedCost || 0), 0); - const totalTokensAll = logs.reduce((s, l) => s + (l.totalTokens || 0), 0); + const summary = useMemo(() => { + let successCount = 0; + let totalCost = 0; + let totalTokensAll = 0; + for (const log of logs) { + if (log.status === 'success') successCount += 1; + totalCost += log.estimatedCost || 0; + totalTokensAll += log.totalTokens || 0; + } + const totalCount = logs.length; + return { + totalCount, + successCount, + failedCount: totalCount - successCount, + totalCost, + totalTokensAll, + }; + }, [logs]); return (
@@ -108,10 +124,10 @@ export default function ProxyLogs() {

{tr('使用日志')}

- 消耗总额 ${totalCost.toFixed(4)} + 消耗总额 ${summary.totalCost.toFixed(4)} - {totalTokensAll.toLocaleString()} tokens + {summary.totalTokensAll.toLocaleString()} tokens
-
+