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.
This commit is contained in:
cita
2026-03-05 21:18:49 +08:00
parent 0d5e56f478
commit fdf4f30987
19 changed files with 313 additions and 156 deletions
+57
View File
@@ -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);
});
});
+16 -3
View File
@@ -411,8 +411,16 @@ function normalizeRunResult(result: unknown): { changes: number; lastInsertRowid
const wrappedObjects = new WeakMap<object, unknown>(); const wrappedObjects = new WeakMap<object, unknown>();
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<T>(value: T): T { function wrapQueryLike<T>(value: T): T {
if (!value || typeof value !== 'object') return value; if (!shouldWrapObject(value)) return value;
const target = value as unknown as object; const target = value as unknown as object;
if (wrappedObjects.has(target)) { if (wrappedObjects.has(target)) {
return wrappedObjects.get(target) as T; return wrappedObjects.get(target) as T;
@@ -446,7 +454,7 @@ function wrapQueryLike<T>(value: T): T {
return (...args: unknown[]) => { return (...args: unknown[]) => {
const result = original.apply(innerTarget, args); const result = original.apply(innerTarget, args);
if (result && typeof result === 'object' && typeof (result as Promise<unknown>).then !== 'function') { if (shouldWrapObject(result)) {
return wrapQueryLike(result); return wrapQueryLike(result);
} }
return result; return result;
@@ -483,7 +491,7 @@ function wrapDbClient<T extends object>(
return (...args: unknown[]) => { return (...args: unknown[]) => {
const result = original.apply(target, args); const result = original.apply(target, args);
if (result && typeof result === 'object' && typeof (result as Promise<unknown>).then !== 'function') { if (shouldWrapObject(result)) {
return wrapQueryLike(result); return wrapQueryLike(result);
} }
return result; return result;
@@ -636,3 +644,8 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD
throw error; throw error;
} }
} }
export const __dbProxyTestUtils = {
wrapQueryLike,
shouldWrapObject,
};
+16
View File
@@ -23,6 +23,7 @@ import { startProxyLogRetentionService, stopProxyLogRetentionService } from './s
import { buildStartupSummaryLines } from './services/startupInfo.js'; import { buildStartupSummaryLines } from './services/startupInfo.js';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { normalize, resolve, sep } from 'path'; import { normalize, resolve, sep } from 'path';
import { eq, isNull, or } from 'drizzle-orm';
import { db, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect } from './db/index.js'; import { db, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect } from './db/index.js';
function toSettingsMap(rows: Array<{ key: string; value: string }>) { function toSettingsMap(rows: Array<{ key: string; value: string }>) {
@@ -182,6 +183,21 @@ try {
const finalRows = await db.select().from(schema.settings).all(); const finalRows = await db.select().from(schema.settings).all();
const finalMap = toSettingsMap(finalRows); const finalMap = toSettingsMap(finalRows);
applyRuntimeSettings(finalMap); 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'); console.log('Loaded runtime settings overrides');
} catch { /* first run, table may not exist */ } } catch { /* first run, table may not exist */ }
+1
View File
@@ -274,6 +274,7 @@ async function appendTokenSyncEvent(result: SyncExecutionResult) {
level, level,
relatedId: result.accountId, relatedId: result.accountId,
relatedType: 'account', relatedType: 'account',
createdAt: new Date().toISOString(),
}).run(); }).run();
} catch {} } catch {}
} }
+2
View File
@@ -244,6 +244,7 @@ export async function sitesRoutes(app: FastifyInstance) {
level: 'warning', level: 'warning',
relatedId: id, relatedId: id,
relatedType: 'site', relatedType: 'site',
createdAt: new Date().toISOString(),
}).run(); }).run();
} catch {} } catch {}
} else { } else {
@@ -260,6 +261,7 @@ export async function sitesRoutes(app: FastifyInstance) {
level: 'info', level: 'info',
relatedId: id, relatedId: id,
relatedType: 'site', relatedType: 'site',
createdAt: new Date().toISOString(),
}).run(); }).run();
} catch {} } catch {}
} }
+1
View File
@@ -739,6 +739,7 @@ async function logProxy(
estimatedCost, estimatedCost,
errorMessage: normalizedErrorMessage, errorMessage: normalizedErrorMessage,
retryCount, retryCount,
createdAt: new Date().toISOString(),
}).run(); }).run();
} catch (error) { } catch (error) {
console.warn('[proxy/chat] failed to write proxy log', error); console.warn('[proxy/chat] failed to write proxy log', error);
+14 -13
View File
@@ -247,20 +247,21 @@ async function logProxy(
errorMessage, errorMessage,
}); });
await db.insert(schema.proxyLogs).values({ await db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId, routeId: selected.channel.routeId,
channelId: selected.channel.id, channelId: selected.channel.id,
accountId: selected.account.id, accountId: selected.account.id,
modelRequested, modelRequested,
modelActual: selected.actualModel, modelActual: selected.actualModel,
status, status,
httpStatus, httpStatus,
latencyMs, latencyMs,
promptTokens, promptTokens,
completionTokens, completionTokens,
totalTokens, totalTokens,
estimatedCost, estimatedCost,
errorMessage: normalizedErrorMessage, errorMessage: normalizedErrorMessage,
retryCount, retryCount,
createdAt: new Date().toISOString(),
}).run(); }).run();
} catch (error) { } catch (error) {
console.warn('[proxy/completions] failed to write proxy log', error); console.warn('[proxy/completions] failed to write proxy log', error);
+14 -13
View File
@@ -162,20 +162,21 @@ async function logProxy(
errorMessage, errorMessage,
}); });
await db.insert(schema.proxyLogs).values({ await db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId, routeId: selected.channel.routeId,
channelId: selected.channel.id, channelId: selected.channel.id,
accountId: selected.account.id, accountId: selected.account.id,
modelRequested, modelRequested,
modelActual: selected.actualModel, modelActual: selected.actualModel,
status, status,
httpStatus, httpStatus,
latencyMs, latencyMs,
promptTokens, promptTokens,
completionTokens, completionTokens,
totalTokens, totalTokens,
estimatedCost, estimatedCost,
errorMessage: normalizedErrorMessage, errorMessage: normalizedErrorMessage,
retryCount, retryCount,
createdAt: new Date().toISOString(),
}).run(); }).run();
} catch (error) { } catch (error) {
console.warn('[proxy/embeddings] failed to write proxy log', error); console.warn('[proxy/embeddings] failed to write proxy log', error);
+14 -13
View File
@@ -132,20 +132,21 @@ async function logProxy(
errorMessage, errorMessage,
}); });
await db.insert(schema.proxyLogs).values({ await db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId, routeId: selected.channel.routeId,
channelId: selected.channel.id, channelId: selected.channel.id,
accountId: selected.account.id, accountId: selected.account.id,
modelRequested, modelRequested,
modelActual: selected.actualModel, modelActual: selected.actualModel,
status, status,
httpStatus, httpStatus,
latencyMs, latencyMs,
promptTokens: 0, promptTokens: 0,
completionTokens: 0, completionTokens: 0,
totalTokens: 0, totalTokens: 0,
estimatedCost, estimatedCost,
errorMessage: normalizedErrorMessage, errorMessage: normalizedErrorMessage,
retryCount, retryCount,
createdAt: new Date().toISOString(),
}).run(); }).run();
} catch (error) { } catch (error) {
console.warn('[proxy/images] failed to write proxy log', error); console.warn('[proxy/images] failed to write proxy log', error);
+1
View File
@@ -1597,6 +1597,7 @@ async function logProxy(
estimatedCost, estimatedCost,
errorMessage: normalizedErrorMessage, errorMessage: normalizedErrorMessage,
retryCount, retryCount,
createdAt: new Date().toISOString(),
}).run(); }).run();
} catch (error) { } catch (error) {
console.warn('[proxy/responses] failed to write proxy log', error); console.warn('[proxy/responses] failed to write proxy log', error);
+2
View File
@@ -22,6 +22,7 @@ export async function reportTokenExpired(params: {
level: 'error', level: 'error',
relatedId: params.accountId, relatedId: params.accountId,
relatedType: 'account', relatedType: 'account',
createdAt: new Date().toISOString(),
}).run(); }).run();
await db.update(schema.accounts).set({ await db.update(schema.accounts).set({
@@ -49,6 +50,7 @@ export async function reportProxyAllFailed(params: { model: string; reason: stri
message: `模型=${params.model}, 原因=${params.reason}`, message: `模型=${params.model}, 原因=${params.reason}`,
level: 'error', level: 'error',
relatedType: 'route', relatedType: 'route',
createdAt: new Date().toISOString(),
}).run(); }).run();
await sendNotification( await sendNotification(
+4
View File
@@ -137,6 +137,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
accountId: account.id, accountId: account.id,
status: 'skipped', status: 'skipped',
message: 'site disabled', message: 'site disabled',
createdAt: new Date().toISOString(),
}).run(); }).run();
if (!options?.skipEvent) { if (!options?.skipEvent) {
@@ -147,6 +148,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
level: 'info', level: 'info',
relatedId: accountId, relatedId: accountId,
relatedType: 'account', relatedType: 'account',
createdAt: new Date().toISOString(),
}).run(); }).run();
} }
@@ -245,6 +247,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
status: normalizedStatus, status: normalizedStatus,
message: logMessage, message: logMessage,
reward: logReward, reward: logReward,
createdAt: new Date().toISOString(),
}).run(); }).run();
if (!options?.skipEvent) { if (!options?.skipEvent) {
@@ -257,6 +260,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
level: effectiveSuccess ? 'info' : 'error', level: effectiveSuccess ? 'info' : 'error',
relatedId: accountId, relatedId: accountId,
relatedType: 'account', relatedType: 'account',
createdAt: new Date().toISOString(),
}).run(); }).run();
} }
+3
View File
@@ -463,6 +463,9 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
}; };
walk(root); walk(root);
if (language !== 'en') {
return;
}
const observer = new MutationObserver((records) => { const observer = new MutationObserver((records) => {
for (const record of records) { for (const record of records) {
+90 -70
View File
@@ -92,11 +92,11 @@ function getSuccessBadgeClass(rate: number | null) {
function resolveMarketplaceDescription(model: ModelRow, metadataHydrating: boolean): string { function resolveMarketplaceDescription(model: ModelRow, metadataHydrating: boolean): string {
if (model.description && model.description.trim().length > 0) return model.description; 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; const hasOtherMetadata = model.tags.length > 0 || model.supportedEndpointTypes.length > 0 || model.pricingSources.length > 0;
if (hasOtherMetadata) return tr('上游未提供文字说明,但已同步标签、能力或价格信息。'); if (hasOtherMetadata) return tr('上游未提供描述文本,但已同步标签、能力或价格信息。');
return tr('当前上游仅返回模型 ID,未返回说明字段(常见于很多站点)。'); return tr('当前上游仅返回模型 ID,未返回描述字段。');
} }
function renderGroupPricingValue(pricing: ModelGroupPricing): string { function renderGroupPricingValue(pricing: ModelGroupPricing): string {
@@ -198,14 +198,20 @@ export default function Models() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let metadataTimer: ReturnType<typeof setTimeout> | null = null;
const bootstrap = async () => { const bootstrap = async () => {
const initial = await loadBaseMarketplace(false); const initial = await loadBaseMarketplace(false);
if (!initial || cancelled) return; if (!initial || cancelled) return;
void hydrateMarketplaceMetadata(initial.models); metadataTimer = setTimeout(() => {
if (!cancelled) {
void hydrateMarketplaceMetadata(initial.models);
}
}, 1200);
}; };
void bootstrap(); void bootstrap();
return () => { return () => {
cancelled = true; cancelled = true;
if (metadataTimer) clearTimeout(metadataTimer);
latestMetadataRequestRef.current += 1; latestMetadataRequestRef.current += 1;
}; };
}, [hydrateMarketplaceMetadata, loadBaseMarketplace]); }, [hydrateMarketplaceMetadata, loadBaseMarketplace]);
@@ -214,7 +220,9 @@ export default function Models() {
void (async () => { void (async () => {
const refreshed = await loadBaseMarketplace(true); const refreshed = await loadBaseMarketplace(true);
if (!refreshed) return; if (!refreshed) return;
void hydrateMarketplaceMetadata(refreshed.models); setTimeout(() => {
void hydrateMarketplaceMetadata(refreshed.models);
}, 600);
})(); })();
}, [hydrateMarketplaceMetadata, loadBaseMarketplace]); }, [hydrateMarketplaceMetadata, loadBaseMarketplace]);
@@ -339,15 +347,15 @@ export default function Models() {
{/* Brand filter */} {/* Brand filter */}
<div className="filter-panel-section"> <div className="filter-panel-section">
<div className="filter-panel-title"> <div className="filter-panel-title">
{tr('品牌')} {tr('鍝佺墝')}
{activeBrand && <button onClick={() => setActiveBrand(null)}>{tr('重置')}</button>} {activeBrand && <button onClick={() => setActiveBrand(null)}>{tr('閲嶇疆')}</button>}
</div> </div>
<div <div
className={`filter-item ${!activeBrand ? 'active' : ''}`} className={`filter-item ${!activeBrand ? 'active' : ''}`}
onClick={() => setActiveBrand(null)} onClick={() => setActiveBrand(null)}
> >
<span className="filter-item-icon" style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}>✦</span> <span className="filter-item-icon" style={{ background: 'var(--color-primary-light)', color: 'var(--color-primary)' }}>✓</span>
{tr('全部品牌')} {tr('鍏ㄩ儴鍝佺墝')}
<span className="filter-item-count">{data.models.length}</span> <span className="filter-item-count">{data.models.length}</span>
</div> </div>
{brandList.list.map(([brandName, { count, brand }]) => ( {brandList.list.map(([brandName, { count, brand }]) => (
@@ -375,7 +383,7 @@ export default function Models() {
onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')} onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
> >
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', color: 'var(--color-text-muted)', fontSize: 10, borderRadius: 4 }}>?</span> <span className="filter-item-icon" style={{ background: 'var(--color-bg)', color: 'var(--color-text-muted)', fontSize: 10, borderRadius: 4 }}>?</span>
{tr('其他')} {tr('鍏朵粬')}
<span className="filter-item-count">{brandList.otherCount}</span> <span className="filter-item-count">{brandList.otherCount}</span>
</div> </div>
)} )}
@@ -385,7 +393,7 @@ export default function Models() {
<div className="filter-panel-section"> <div className="filter-panel-section">
<div className="filter-panel-title"> <div className="filter-panel-title">
{tr('供应商')} {tr('供应商')}
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>} {activeSite && <button onClick={() => setActiveSite(null)}>{tr('閲嶇疆')}</button>}
</div> </div>
{siteMap.map(([site, count]) => ( {siteMap.map(([site, count]) => (
<div <div
@@ -404,13 +412,13 @@ export default function Models() {
{/* Sort */} {/* Sort */}
<div className="filter-panel-section"> <div className="filter-panel-section">
<div className="filter-panel-title">{tr('排序方式')}</div> <div className="filter-panel-title">{tr('鎺掑簭鏂瑰紡')}</div>
{[ {[
{ key: 'accountCount' as SortColumn, label: tr('账号数') }, { key: 'accountCount' as SortColumn, label: tr('账号数') },
{ key: 'tokenCount' 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: 'successRate' as SortColumn, label: tr('成功率') },
{ key: 'name' as SortColumn, label: tr('名称') }, { key: 'name' as SortColumn, label: tr('鍚嶇О') },
].map(opt => ( ].map(opt => (
<div <div
key={opt.key} key={opt.key}
@@ -439,7 +447,7 @@ export default function Models() {
style={{ width: '100%', fontSize: 12, padding: '6px 10px', marginTop: 8, justifyContent: 'center', border: '1px solid var(--color-border)' }} style={{ width: '100%', fontSize: 12, padding: '6px 10px', marginTop: 8, justifyContent: 'center', border: '1px solid var(--color-border)' }}
onClick={() => setFilterCollapsed(true)} onClick={() => setFilterCollapsed(true)}
> >
{tr('收起')} {tr('鏀惰捣')}
</button> </button>
</div> </div>
)} )}
@@ -450,7 +458,7 @@ export default function Models() {
<div className="page-header" style={{ marginBottom: 16 }}> <div className="page-header" style={{ marginBottom: 16 }}>
<div> <div>
<h2 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <h2 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{activeBrand || activeSite || tr('模型广场')} {activeBrand || activeSite || tr('妯″瀷骞垮満')}
<span className="badge badge-info" style={{ fontSize: 12, fontWeight: 500 }}> <span className="badge badge-info" style={{ fontSize: 12, fontWeight: 500 }}>
{tr('共')} {filteredModels.length} {tr('个模型')} {tr('共')} {filteredModels.length} {tr('个模型')}
</span> </span>
@@ -474,13 +482,13 @@ export default function Models() {
</svg> </svg>
</button> </button>
{metadataHydrating && ( {metadataHydrating && (
<span className="badge badge-muted" style={{ fontSize: 11 }}>{tr('加载元数据中...')}</span> <span className="badge badge-muted" style={{ fontSize: 11 }}>{tr('鍔犺浇鍏冩暟鎹腑...')}</span>
)} )}
<div className="view-toggle"> <div className="view-toggle">
<button className={`view-toggle-btn ${viewMode === 'card' ? 'active' : ''}`} onClick={() => setViewMode('card')} data-tooltip={tr('卡片视图')} aria-label={tr('卡片视图')}> <button className={`view-toggle-btn ${viewMode === 'card' ? 'active' : ''}`} onClick={() => setViewMode('card')} data-tooltip={tr('鍗$墖瑙嗗浘')} aria-label={tr('鍗$墖瑙嗗浘')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
</button> </button>
<button className={`view-toggle-btn ${viewMode === 'table' ? 'active' : ''}`} onClick={() => setViewMode('table')} data-tooltip={tr('表格视图')} aria-label={tr('表格视图')}> <button className={`view-toggle-btn ${viewMode === 'table' ? 'active' : ''}`} onClick={() => setViewMode('table')} data-tooltip={tr('琛ㄦ牸瑙嗗浘')} aria-label={tr('琛ㄦ牸瑙嗗浘')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18M3 6h18M3 18h18M10 3v18M14 3v18" /></svg> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M3 14h18M3 6h18M3 18h18M10 3v18M14 3v18" /></svg>
</button> </button>
</div> </div>
@@ -496,18 +504,18 @@ export default function Models() {
<input <input
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder={tr('模糊搜索模型名称')} placeholder={tr('妯$硦鎼滅储妯″瀷鍚嶇О')}
/> />
</div> </div>
{/* Quick stats */} {/* Quick stats */}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}> <div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}> <span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
{tr('覆盖槽位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b> {tr('瑕嗙洊妲戒綅')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
</span> </span>
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}> <span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b> {tr('鍘婚噸璐﹀彿')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
</span> </span>
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{avgLatency}ms</b></span> <span>{tr('骞冲潎寤惰繜')} <b style={{ color: getMetricColor(avgLatency) }}>{avgLatency}ms</b></span>
</div> </div>
</div> </div>
@@ -519,14 +527,16 @@ export default function Models() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg> </svg>
</div> </div>
<div className="empty-state-title">{tr('暂无模型数据')}</div> <div className="empty-state-title">{tr('鏆傛棤妯″瀷鏁版嵁')}</div>
<div className="empty-state-desc">{tr('请先检查站点与账号状态,然后点击刷新。')}</div> <div className="empty-state-desc">{tr('请先检查站点与账号状态,然后点击刷新。')}</div>
</div> </div>
) : viewMode === 'card' ? ( ) : viewMode === 'card' ? (
/* ====== Card View ====== */ /* ====== Card View ====== */
<div> <div>
{paged.map(m => ( {paged.map((m) => {
<div key={m.name} className="model-card" onClick={() => setExpanded(expanded === m.name ? null : m.name)}> const isExpanded = expanded === m.name;
return (
<div key={m.name} className="model-card" onClick={() => setExpanded(isExpanded ? null : m.name)}>
<div className="model-card-header"> <div className="model-card-header">
<BrandIcon model={m.name} size={44} /> <BrandIcon model={m.name} size={44} />
<div className="model-card-info"> <div className="model-card-info">
@@ -538,14 +548,14 @@ export default function Models() {
</span> </span>
<span> <span>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg> <svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
{m.tokenCount} {tr('令牌')} {m.tokenCount} {tr('浠ょ墝')}
</span> </span>
<span <span
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`} className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
style={{ fontVariantNumeric: 'tabular-nums' }} style={{ fontVariantNumeric: 'tabular-nums' }}
data-tooltip={tr('平均延迟')} data-tooltip={tr('骞冲潎寤惰繜')}
> >
{tr('延迟')} {m.avgLatency}ms {tr('寤惰繜')} {m.avgLatency}ms
</span> </span>
<span <span
className={`badge ${getSuccessBadgeClass(m.successRate)}`} className={`badge ${getSuccessBadgeClass(m.successRate)}`}
@@ -566,10 +576,10 @@ export default function Models() {
</button> </button>
<button <button
className="model-card-action-btn" className="model-card-action-btn"
data-tooltip={expanded === m.name ? tr('收起') : tr('展开')} data-tooltip={isExpanded ? tr('鏀惰捣') : tr('灞曞紑')}
aria-label={expanded === m.name ? tr('收起') : tr('展开')} aria-label={isExpanded ? tr('鏀惰捣') : tr('灞曞紑')}
> >
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: expanded === m.name ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: isExpanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </button>
@@ -585,10 +595,10 @@ export default function Models() {
<span key={site} className="model-tag model-tag-blue">{site}</span> <span key={site} className="model-tag model-tag-blue">{site}</span>
))} ))}
{m.successRate != null && m.successRate >= 90 && ( {m.successRate != null && m.successRate >= 90 && (
<span className="model-tag model-tag-green">{tr('健康')}</span> <span className="model-tag model-tag-green">{tr('鍋ュ悍')}</span>
)} )}
{m.successRate != null && m.successRate < 60 && ( {m.successRate != null && m.successRate < 60 && (
<span className="model-tag model-tag-orange">{tr('风险')}</span> <span className="model-tag model-tag-orange">{tr('椋庨櫓')}</span>
)} )}
{m.avgLatency <= 500 && ( {m.avgLatency <= 500 && (
<span className="model-tag model-tag-purple">{tr('低延迟')}</span> <span className="model-tag model-tag-purple">{tr('低延迟')}</span>
@@ -596,33 +606,34 @@ export default function Models() {
</div> </div>
{/* Expand: Account Details */} {/* Expand: Account Details */}
<div className={`anim-collapse ${expanded === m.name ? 'is-open' : ''}`.trim()} onClick={e => e.stopPropagation()}> {isExpanded ? (
<div className="anim-collapse is-open" onClick={e => e.stopPropagation()}>
<div className="anim-collapse-inner"> <div className="anim-collapse-inner">
<div className="model-card-expand"> <div className="model-card-expand">
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}> <div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
<div className="card" style={{ padding: 10 }}> <div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('基础信息')}</div> <div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('鍩虹淇℃伅')}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}> <div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
{resolveMarketplaceDescription(m, metadataHydrating)} {resolveMarketplaceDescription(m, metadataHydrating)}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
{m.tags.length > 0 ? m.tags.map((tag) => ( {m.tags.length > 0 ? m.tags.map((tag) => (
<span key={tag} className="badge badge-info">{tag}</span> <span key={tag} className="badge badge-info">{tag}</span>
)) : <span className="badge badge-muted">{metadataHydrating ? tr('加载元数据中...') : tr('暂无标签')}</span>} )) : <span className="badge badge-muted">{metadataHydrating ? tr('鍔犺浇鍏冩暟鎹腑...') : tr('鏆傛棤鏍囩')}</span>}
</div> </div>
</div> </div>
<div className="card" style={{ padding: 10 }}> <div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('接口能力')}</div> <div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('鎺ュ彛鑳藉姏')}</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{m.supportedEndpointTypes.length > 0 ? m.supportedEndpointTypes.map((endpoint) => ( {m.supportedEndpointTypes.length > 0 ? m.supportedEndpointTypes.map((endpoint) => (
<span key={endpoint} className="badge badge-success">{endpoint}</span> <span key={endpoint} className="badge badge-success">{endpoint}</span>
)) : <span className="badge badge-muted">{metadataHydrating ? tr('加载元数据中...') : tr('未提供')}</span>} )) : <span className="badge badge-muted">{metadataHydrating ? tr('鍔犺浇鍏冩暟鎹腑...') : tr('未提供')}</span>}
</div> </div>
</div> </div>
<div className="card" style={{ padding: 10 }}> <div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div> <div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('鍒嗙粍璁¤垂')}</div>
{m.pricingSources.length > 0 ? ( {m.pricingSources.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{m.pricingSources.map((source) => ( {m.pricingSources.map((source) => (
@@ -631,7 +642,7 @@ export default function Models() {
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }} style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
> >
<div style={{ fontSize: 12, marginBottom: 6 }}> <div style={{ fontSize: 12, marginBottom: 6 }}>
<strong>{source.siteName}</strong> · {source.username || `ID:${source.accountId}`} <strong>{source.siteName}</strong> 路 {source.username || `ID:${source.accountId}`}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => ( {Object.entries(source.groupPricing).map(([group, pricing]) => (
@@ -644,7 +655,7 @@ export default function Models() {
))} ))}
</div> </div>
) : ( ) : (
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span> <span className="badge badge-muted">{metadataHydrating ? tr('姝e湪鍔犺浇浠锋牸鍏冩暟鎹?..') : tr('暂无价格元数据')}</span>
)} )}
</div> </div>
</div> </div>
@@ -652,11 +663,11 @@ export default function Models() {
<table className="data-table" style={{ width: '100%' }}> <table className="data-table" style={{ width: '100%' }}>
<thead> <thead>
<tr> <tr>
<th style={{ fontWeight: 500 }}>{tr('站点')}</th> <th style={{ fontWeight: 500 }}>{tr('绔欑偣')}</th>
<th style={{ fontWeight: 500 }}>{tr('账号')}</th> <th style={{ fontWeight: 500 }}>{tr('璐﹀彿')}</th>
<th style={{ fontWeight: 500 }}>{tr('令牌')}</th> <th style={{ fontWeight: 500 }}>{tr('浠ょ墝')}</th>
<th style={{ fontWeight: 500 }}>{tr('延迟')}</th> <th style={{ fontWeight: 500 }}>{tr('寤惰繜')}</th>
<th style={{ fontWeight: 500 }}>{tr('余额')}</th> <th style={{ fontWeight: 500 }}>{tr('浣欓')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -682,8 +693,10 @@ export default function Models() {
</div> </div>
</div> </div>
</div> </div>
) : null}
</div> </div>
))} );
})}
</div> </div>
) : ( ) : (
/* ====== Table View ====== */ /* ====== Table View ====== */
@@ -693,7 +706,7 @@ export default function Models() {
<tr> <tr>
<th style={{ width: 44 }} /> <th style={{ width: 44 }} />
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('name'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> <th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('name'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('模型名称')} {sortBy === 'name' ? (sortDir === 'desc' ? '↓' : '↑') : ''} {tr('妯″瀷鍚嶇О')} {sortBy === 'name' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th> </th>
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> <th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''} {tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
@@ -702,18 +715,20 @@ export default function Models() {
{tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''} {tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th> </th>
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('avgLatency'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> <th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('avgLatency'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('延迟')} {sortBy === 'avgLatency' ? (sortDir === 'desc' ? '↓' : '↑') : ''} {tr('寤惰繜')} {sortBy === 'avgLatency' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th> </th>
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('successRate'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}> <th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('successRate'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('成功率')} {sortBy === 'successRate' ? (sortDir === 'desc' ? '↓' : '↑') : ''} {tr('成功率')} {sortBy === 'successRate' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th> </th>
<th style={{ width: 60 }}>{tr('操作')}</th> <th style={{ width: 60 }}>{tr('鎿嶄綔')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{paged.map(m => ( {paged.map((m) => {
const isExpanded = expanded === m.name;
return (
<React.Fragment key={m.name}> <React.Fragment key={m.name}>
<tr onClick={() => setExpanded(expanded === m.name ? null : m.name)} style={{ cursor: 'pointer' }}> <tr onClick={() => setExpanded(isExpanded ? null : m.name)} style={{ cursor: 'pointer' }}>
<td> <td>
<BrandIcon model={m.name} size={28} /> <BrandIcon model={m.name} size={28} />
</td> </td>
@@ -741,7 +756,7 @@ export default function Models() {
</span> </span>
</td> </td>
<td onClick={e => e.stopPropagation()}> <td onClick={e => e.stopPropagation()}>
<button className="model-card-action-btn" data-tooltip={tr('复制')} aria-label={tr('复制')} onClick={() => copyName(m.name)}> <button className="model-card-action-btn" data-tooltip={tr('澶嶅埗')} aria-label={tr('澶嶅埗')} onClick={() => copyName(m.name)}>
{copied === m.name ? ( {copied === m.name ? (
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg> <svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : ( ) : (
@@ -750,35 +765,36 @@ export default function Models() {
</button> </button>
</td> </td>
</tr> </tr>
<tr className={expanded === m.name ? 'log-detail-row' : ''}> {isExpanded ? (
<tr className="log-detail-row">
<td colSpan={7} style={{ padding: 0 }}> <td colSpan={7} style={{ padding: 0 }}>
<div className={`anim-collapse ${expanded === m.name ? 'is-open' : ''}`.trim()}> <div className="anim-collapse is-open">
<div className="anim-collapse-inner"> <div className="anim-collapse-inner">
<div style={{ padding: '12px 16px 12px 54px' }}> <div style={{ padding: '12px 16px 12px 54px' }}>
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}> <div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
<div className="card" style={{ padding: 10 }}> <div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('基础信息')}</div> <div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('鍩虹淇℃伅')}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}> <div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
{resolveMarketplaceDescription(m, metadataHydrating)} {resolveMarketplaceDescription(m, metadataHydrating)}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
{m.tags.length > 0 ? m.tags.map((tag) => ( {m.tags.length > 0 ? m.tags.map((tag) => (
<span key={tag} className="badge badge-info">{tag}</span> <span key={tag} className="badge badge-info">{tag}</span>
)) : <span className="badge badge-muted">{metadataHydrating ? tr('加载元数据中...') : tr('暂无标签')}</span>} )) : <span className="badge badge-muted">{metadataHydrating ? tr('鍔犺浇鍏冩暟鎹腑...') : tr('鏆傛棤鏍囩')}</span>}
</div> </div>
</div> </div>
<div className="card" style={{ padding: 10 }}> <div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('接口能力')}</div> <div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('鎺ュ彛鑳藉姏')}</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{m.supportedEndpointTypes.length > 0 ? m.supportedEndpointTypes.map((endpoint) => ( {m.supportedEndpointTypes.length > 0 ? m.supportedEndpointTypes.map((endpoint) => (
<span key={endpoint} className="badge badge-success">{endpoint}</span> <span key={endpoint} className="badge badge-success">{endpoint}</span>
)) : <span className="badge badge-muted">{metadataHydrating ? tr('加载元数据中...') : tr('未提供')}</span>} )) : <span className="badge badge-muted">{metadataHydrating ? tr('鍔犺浇鍏冩暟鎹腑...') : tr('未提供')}</span>}
</div> </div>
</div> </div>
<div className="card" style={{ padding: 10 }}> <div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div> <div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('鍒嗙粍璁¤垂')}</div>
{m.pricingSources.length > 0 ? ( {m.pricingSources.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>
{m.pricingSources.map((source) => ( {m.pricingSources.map((source) => (
@@ -787,7 +803,7 @@ export default function Models() {
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }} style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
> >
<div style={{ fontSize: 12, marginBottom: 6 }}> <div style={{ fontSize: 12, marginBottom: 6 }}>
<strong>{source.siteName}</strong> · {source.username || `ID:${source.accountId}`} <strong>{source.siteName}</strong> 路 {source.username || `ID:${source.accountId}`}
</div> </div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => ( {Object.entries(source.groupPricing).map(([group, pricing]) => (
@@ -800,18 +816,18 @@ export default function Models() {
))} ))}
</div> </div>
) : ( ) : (
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span> <span className="badge badge-muted">{metadataHydrating ? tr('姝e湪鍔犺浇浠锋牸鍏冩暟鎹?..') : tr('暂无价格元数据')}</span>
)} )}
</div> </div>
</div> </div>
<table style={{ width: '100%', fontSize: 12, borderCollapse: 'collapse' }}> <table style={{ width: '100%', fontSize: 12, borderCollapse: 'collapse' }}>
<thead><tr style={{ color: 'var(--color-text-muted)' }}> <thead><tr style={{ color: 'var(--color-text-muted)' }}>
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('站点')}</th> <th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('绔欑偣')}</th>
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('账号')}</th> <th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('璐﹀彿')}</th>
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('令牌')}</th> <th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('浠ょ墝')}</th>
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('延迟')}</th> <th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('寤惰繜')}</th>
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('余额')}</th> <th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 500 }}>{tr('浣欓')}</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{m.accounts.map(a => ( {m.accounts.map(a => (
@@ -836,8 +852,10 @@ export default function Models() {
</div> </div>
</td> </td>
</tr> </tr>
) : null}
</React.Fragment> </React.Fragment>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -870,7 +888,7 @@ export default function Models() {
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg> <svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
</button> </button>
<div className="pagination-size"> <div className="pagination-size">
{tr('每页条数')}: {tr('姣忛〉鏉℃暟')}:
<div style={{ minWidth: 86 }}> <div style={{ minWidth: 86 }}>
<ModernSelect <ModernSelect
size="sm" size="sm"
@@ -887,3 +905,5 @@ export default function Models() {
</div> </div>
); );
} }
+1 -1
View File
@@ -17,7 +17,7 @@ type ProgramEvent = {
createdAt?: string | null; createdAt?: string | null;
}; };
const PAGE_SIZE = 80; const PAGE_SIZE = 50;
const TYPE_OPTIONS = [ const TYPE_OPTIONS = [
{ value: '', label: '全部类型' }, { value: '', label: '全部类型' },
+34 -16
View File
@@ -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 { api } from '../api.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import { ModelBadge } from '../components/BrandIcon.js'; import { ModelBadge } from '../components/BrandIcon.js';
@@ -59,7 +59,7 @@ export default function ProxyLogs() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<number | null>(null); const [expanded, setExpanded] = useState<number | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(100); const [pageSize, setPageSize] = useState(50);
const toast = useToast(); const toast = useToast();
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -77,7 +77,7 @@ export default function ProxyLogs() {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
/* ---- derived ---- */ /* ---- derived ---- */
const filtered = logs.filter(log => { const filtered = useMemo(() => logs.filter(log => {
if (statusFilter === 'success' && log.status !== 'success') return false; if (statusFilter === 'success' && log.status !== 'success') return false;
if (statusFilter === 'failed' && log.status === 'success') return false; if (statusFilter === 'failed' && log.status === 'success') return false;
if (search) { if (search) {
@@ -86,20 +86,36 @@ export default function ProxyLogs() {
(log.modelActual || '').toLowerCase().includes(q); (log.modelActual || '').toLowerCase().includes(q);
} }
return true; return true;
}); }), [logs, statusFilter, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const safePage = Math.min(page, totalPages); 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]); useEffect(() => { setPage(1); }, [statusFilter, search, pageSize]);
/* ---- stats ---- */ /* ---- stats ---- */
const totalCount = logs.length; const summary = useMemo(() => {
const successCount = logs.filter(l => l.status === 'success').length; let successCount = 0;
const failedCount = totalCount - successCount; let totalCost = 0;
const totalCost = logs.reduce((s, l) => s + (l.estimatedCost || 0), 0); let totalTokensAll = 0;
const totalTokensAll = logs.reduce((s, l) => s + (l.totalTokens || 0), 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 ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
@@ -108,10 +124,10 @@ export default function ProxyLogs() {
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<h2 className="page-title">{tr('使用日志')}</h2> <h2 className="page-title">{tr('使用日志')}</h2>
<span className="kpi-chip kpi-chip-success"> <span className="kpi-chip kpi-chip-success">
消耗总额 ${totalCost.toFixed(4)} 消耗总额 ${summary.totalCost.toFixed(4)}
</span> </span>
<span className="kpi-chip kpi-chip-warning"> <span className="kpi-chip kpi-chip-warning">
{totalTokensAll.toLocaleString()} tokens {summary.totalTokensAll.toLocaleString()} tokens
</span> </span>
</div> </div>
<button onClick={load} disabled={loading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '6px 14px' }}> <button onClick={load} disabled={loading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '6px 14px' }}>
@@ -126,9 +142,9 @@ export default function ProxyLogs() {
<div className="toolbar" style={{ marginBottom: 12 }}> <div className="toolbar" style={{ marginBottom: 12 }}>
<div className="pill-tabs"> <div className="pill-tabs">
{([ {([
{ key: 'all' as StatusFilter, label: '全部', count: totalCount }, { key: 'all' as StatusFilter, label: '全部', count: summary.totalCount },
{ key: 'success' as StatusFilter, label: '成功', count: successCount }, { key: 'success' as StatusFilter, label: '成功', count: summary.successCount },
{ key: 'failed' as StatusFilter, label: '失败', count: failedCount }, { key: 'failed' as StatusFilter, label: '失败', count: summary.failedCount },
]).map(tab => ( ]).map(tab => (
<button <button
key={tab.key} key={tab.key}
@@ -244,9 +260,10 @@ export default function ProxyLogs() {
)} )}
</td> </td>
</tr> </tr>
{expanded === log.id && (
<tr style={{ background: 'var(--color-bg)' }}> <tr style={{ background: 'var(--color-bg)' }}>
<td colSpan={9} style={{ padding: 0 }}> <td colSpan={9} style={{ padding: 0 }}>
<div className={`anim-collapse ${expanded === log.id ? 'is-open' : ''}`.trim()}> <div className="anim-collapse is-open">
<div className="anim-collapse-inner"> <div className="anim-collapse-inner">
<div className="animate-fade-in" style={{ <div className="animate-fade-in" style={{
padding: '14px 20px 14px 40px', padding: '14px 20px 14px 40px',
@@ -328,6 +345,7 @@ export default function ProxyLogs() {
</div> </div>
</td> </td>
</tr> </tr>
)}
</React.Fragment> </React.Fragment>
)})} )})}
</tbody> </tbody>
+21 -27
View File
@@ -801,7 +801,12 @@ export default function TokenRoutes() {
normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName), normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName),
); );
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {}); setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
void loadRouteDecisions(normalizedRoutes, { force: true }); const decisionPlaceholder: Record<number, RouteDecision | null> = {};
for (const route of normalizedRoutes) {
decisionPlaceholder[route.id] = null;
}
setDecisionByRoute(decisionPlaceholder);
setDecisionAutoSkipped(normalizedRoutes.length > 0);
}; };
useEffect(() => { useEffect(() => {
@@ -1156,30 +1161,9 @@ export default function TokenRoutes() {
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK)); setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
}, [filteredRoutes.length]); }, [filteredRoutes.length]);
useEffect(() => { const handleLoadMoreRoutes = () => {
const initialVisible = getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK); setVisibleRouteCount((current) => getNextVisibleCount(current, filteredRoutes.length, ROUTE_RENDER_CHUNK));
if (initialVisible >= filteredRoutes.length) return; };
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | 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 visibleRoutes = useMemo( const visibleRoutes = useMemo(
() => filteredRoutes.slice(0, visibleRouteCount), () => filteredRoutes.slice(0, visibleRouteCount),
@@ -2274,8 +2258,18 @@ export default function TokenRoutes() {
})} })}
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && ( {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'center', padding: '4px 0 10px' }}> <div style={{ textAlign: 'center', padding: '8px 0 14px', display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
{tr('正在渲染路由')} {visibleRouteCount} / {filteredRoutes.length} <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length}
</div>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '7px 14px', fontSize: 12 }}
onClick={handleLoadMoreRoutes}
>
{tr('加载更多路由')}
</button>
</div> </div>
)} )}
@@ -19,4 +19,13 @@ describe('formatCheckinLogTime', () => {
expect(formatDateLocal('2026-02-25 03:51:58', 'en-US', 'UTC')).toBe('02/25/2026'); 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'); 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');
});
}); });
+13
View File
@@ -4,10 +4,23 @@ function parseServerUtcDate(value: string | null | undefined): { date: Date | nu
const raw = String(value).trim(); const raw = String(value).trim();
if (!raw) return { date: null, raw }; 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; let normalized = raw;
if (!normalized.includes('T') && normalized.includes(' ')) { if (!normalized.includes('T') && normalized.includes(' ')) {
normalized = normalized.replace(' ', 'T'); 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); const hasTimeZone = /[zZ]$|[+-]\d{2}:\d{2}$/.test(normalized);
if (!hasTimeZone) { if (!hasTimeZone) {