Refactor logging timestamps to use formatUtcSqlDateTime function across proxy routes and services; add storedTimestampRepairService for fixing createdAt values in database
This commit is contained in:
+2
-15
@@ -21,9 +21,9 @@ import { proxyRoutes } from './routes/proxy/router.js';
|
|||||||
import { startScheduler } from './services/checkinScheduler.js';
|
import { startScheduler } from './services/checkinScheduler.js';
|
||||||
import { startProxyLogRetentionService, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
import { startProxyLogRetentionService, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||||
|
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.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 }>) {
|
||||||
@@ -183,20 +183,7 @@ 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);
|
||||||
|
await repairStoredCreatedAtValues();
|
||||||
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 */ }
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ describe('accounts health refresh runtime state', () => {
|
|||||||
let db: DbModule['db'];
|
let db: DbModule['db'];
|
||||||
let schema: DbModule['schema'];
|
let schema: DbModule['schema'];
|
||||||
let dataDir = '';
|
let dataDir = '';
|
||||||
|
let resetBackgroundTasks: (() => void) | null = null;
|
||||||
|
let getBackgroundTask: ((taskId: string) => { status: string; result: unknown; finishedAt: string | null } | null) | null = null;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-health-refresh-'));
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-health-refresh-'));
|
||||||
@@ -25,8 +27,11 @@ describe('accounts health refresh runtime state', () => {
|
|||||||
await import('../../db/migrate.js');
|
await import('../../db/migrate.js');
|
||||||
const dbModule = await import('../../db/index.js');
|
const dbModule = await import('../../db/index.js');
|
||||||
const routesModule = await import('./accounts.js');
|
const routesModule = await import('./accounts.js');
|
||||||
|
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
|
||||||
db = dbModule.db;
|
db = dbModule.db;
|
||||||
schema = dbModule.schema;
|
schema = dbModule.schema;
|
||||||
|
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
|
||||||
|
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
|
||||||
|
|
||||||
app = Fastify();
|
app = Fastify();
|
||||||
await app.register(routesModule.accountsRoutes);
|
await app.register(routesModule.accountsRoutes);
|
||||||
@@ -34,6 +39,7 @@ describe('accounts health refresh runtime state', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
refreshBalanceMock.mockReset();
|
refreshBalanceMock.mockReset();
|
||||||
|
resetBackgroundTasks?.();
|
||||||
await db.delete(schema.proxyLogs).run();
|
await db.delete(schema.proxyLogs).run();
|
||||||
await db.delete(schema.checkinLogs).run();
|
await db.delete(schema.checkinLogs).run();
|
||||||
await db.delete(schema.routeChannels).run();
|
await db.delete(schema.routeChannels).run();
|
||||||
@@ -41,6 +47,7 @@ describe('accounts health refresh runtime state', () => {
|
|||||||
await db.delete(schema.tokenModelAvailability).run();
|
await db.delete(schema.tokenModelAvailability).run();
|
||||||
await db.delete(schema.modelAvailability).run();
|
await db.delete(schema.modelAvailability).run();
|
||||||
await db.delete(schema.accountTokens).run();
|
await db.delete(schema.accountTokens).run();
|
||||||
|
await db.delete(schema.events).run();
|
||||||
await db.delete(schema.accounts).run();
|
await db.delete(schema.accounts).run();
|
||||||
await db.delete(schema.sites).run();
|
await db.delete(schema.sites).run();
|
||||||
});
|
});
|
||||||
@@ -101,4 +108,149 @@ describe('accounts health refresh runtime state', () => {
|
|||||||
message: '站点不支持签到接口',
|
message: '站点不支持签到接口',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns readable task messages when starting a background refresh for all accounts', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'Wind Hub',
|
||||||
|
url: 'https://windhub.cc',
|
||||||
|
platform: 'done-hub',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'tester',
|
||||||
|
accessToken: 'token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
refreshBalanceMock.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/accounts/health/refresh',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(202);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
message: '已开始刷新账号运行健康状态,请稍后查看账号列表',
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i += 1) {
|
||||||
|
const events = await db.select().from(schema.events).all();
|
||||||
|
if (events.length > 0) {
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
title: '刷新全部账号运行健康状态已开始',
|
||||||
|
message: '刷新全部账号运行健康状态 已开始执行',
|
||||||
|
level: 'info',
|
||||||
|
type: 'status',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('expected a background task event to be recorded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails a single-account health refresh after 10 seconds when the site never responds', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'Slow Site',
|
||||||
|
url: 'https://slow.example.com',
|
||||||
|
platform: 'done-hub',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'slow-user',
|
||||||
|
accessToken: 'token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
refreshBalanceMock.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
const responsePromise = app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/accounts/health/refresh',
|
||||||
|
payload: { accountId: account.id, wait: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10_001);
|
||||||
|
|
||||||
|
const response = await responsePromise;
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const body = response.json() as {
|
||||||
|
success: boolean;
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
results: Array<{ status: string; state: string; message: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.summary).toMatchObject({
|
||||||
|
total: 1,
|
||||||
|
failed: 1,
|
||||||
|
});
|
||||||
|
expect(body.results[0]).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
state: 'unhealthy',
|
||||||
|
message: '站点健康检查超时(10s)',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
it('finishes the background refresh-all task after the 10 second site timeout instead of staying in progress', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'Slow Site',
|
||||||
|
url: 'https://slow.example.com',
|
||||||
|
platform: 'done-hub',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'slow-user',
|
||||||
|
accessToken: 'token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
refreshBalanceMock.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
const responsePromise = app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/accounts/health/refresh',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
const response = await responsePromise;
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(202);
|
||||||
|
const body = response.json() as { jobId: string };
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10_001);
|
||||||
|
|
||||||
|
const task = getBackgroundTask?.(body.jobId);
|
||||||
|
expect(task).toMatchObject({
|
||||||
|
status: 'succeeded',
|
||||||
|
result: {
|
||||||
|
summary: {
|
||||||
|
total: 1,
|
||||||
|
failed: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(task?.finishedAt).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import {
|
|||||||
} from '../../services/localTimeService.js';
|
} from '../../services/localTimeService.js';
|
||||||
|
|
||||||
type DbModule = typeof import('../../db/index.js');
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
type RepairModule = typeof import('../../services/storedTimestampRepairService.js');
|
||||||
|
|
||||||
describe('accounts api today reward fallback', () => {
|
describe('accounts api today reward fallback', () => {
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let db: DbModule['db'];
|
let db: DbModule['db'];
|
||||||
let schema: DbModule['schema'];
|
let schema: DbModule['schema'];
|
||||||
|
let repairStoredCreatedAtValues: RepairModule['repairStoredCreatedAtValues'];
|
||||||
let dataDir = '';
|
let dataDir = '';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -25,8 +27,10 @@ describe('accounts api today reward fallback', () => {
|
|||||||
await import('../../db/migrate.js');
|
await import('../../db/migrate.js');
|
||||||
const dbModule = await import('../../db/index.js');
|
const dbModule = await import('../../db/index.js');
|
||||||
const routesModule = await import('./accounts.js');
|
const routesModule = await import('./accounts.js');
|
||||||
|
const repairModule = await import('../../services/storedTimestampRepairService.js');
|
||||||
db = dbModule.db;
|
db = dbModule.db;
|
||||||
schema = dbModule.schema;
|
schema = dbModule.schema;
|
||||||
|
repairStoredCreatedAtValues = repairModule.repairStoredCreatedAtValues;
|
||||||
|
|
||||||
app = Fastify();
|
app = Fastify();
|
||||||
await app.register(routesModule.accountsRoutes);
|
await app.register(routesModule.accountsRoutes);
|
||||||
@@ -90,6 +94,39 @@ describe('accounts api today reward fallback', () => {
|
|||||||
expect(target?.todayReward).toBe(12.5);
|
expect(target?.todayReward).toBe(12.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('repairs ISO timestamps for today reward filtering', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'iso-reward-site',
|
||||||
|
url: 'https://iso-reward-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'iso-reward-user',
|
||||||
|
accessToken: 'token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.checkinLogs).values({
|
||||||
|
accountId: account.id,
|
||||||
|
status: 'success',
|
||||||
|
message: 'checkin success',
|
||||||
|
reward: '1.8',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}).run();
|
||||||
|
await repairStoredCreatedAtValues();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/accounts',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const rows = response.json() as Array<{ id: number; todayReward: number }>;
|
||||||
|
const target = rows.find((row) => row.id === account.id);
|
||||||
|
expect(target?.todayReward).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
it('prefers parsed checkin reward when available', async () => {
|
it('prefers parsed checkin reward when available', async () => {
|
||||||
const today = formatLocalDate(new Date());
|
const today = formatLocalDate(new Date());
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
@@ -182,4 +219,40 @@ describe('accounts api today reward fallback', () => {
|
|||||||
const target = rows.find((row) => row.id === account.id);
|
const target = rows.find((row) => row.id === account.id);
|
||||||
expect(target?.todaySpend).toBe(2);
|
expect(target?.todaySpend).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('repairs ISO timestamps for today spend filtering', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'iso-spend-site',
|
||||||
|
url: 'https://iso-spend-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'iso-spend-user',
|
||||||
|
accessToken: 'token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const { startUtc } = getLocalDayRangeUtc();
|
||||||
|
const startDate = parseStoredUtcDateTime(startUtc)!;
|
||||||
|
const inRangeIso = new Date(startDate.getTime() + 60_000).toISOString();
|
||||||
|
|
||||||
|
await db.insert(schema.proxyLogs).values({
|
||||||
|
accountId: account.id,
|
||||||
|
status: 'success',
|
||||||
|
estimatedCost: 2.25,
|
||||||
|
createdAt: inRangeIso,
|
||||||
|
}).run();
|
||||||
|
await repairStoredCreatedAtValues();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/accounts',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const rows = response.json() as Array<{ id: number; todaySpend: number }>;
|
||||||
|
const target = rows.find((row) => row.id === account.id);
|
||||||
|
expect(target?.todaySpend).toBe(2.25);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ type LoginFailureInfo = {
|
|||||||
shieldBlocked: boolean;
|
shieldBlocked: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ACCOUNT_HEALTH_REFRESH_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
function normalizeLoginFailure(message: string | null | undefined): LoginFailureInfo {
|
function normalizeLoginFailure(message: string | null | undefined): LoginFailureInfo {
|
||||||
const raw = (message || '').trim();
|
const raw = (message || '').trim();
|
||||||
const lowered = raw.toLowerCase();
|
const lowered = raw.toLowerCase();
|
||||||
@@ -154,8 +156,8 @@ function normalizeLoginFailure(message: string | null | undefined): LoginFailure
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeAccountHealthRefresh(results: AccountHealthRefreshResult[]) {
|
function summarizeAccountHealthRefresh(results: AccountHealthRefreshResult[]) {
|
||||||
return {
|
return {
|
||||||
total: results.length,
|
total: results.length,
|
||||||
healthy: results.filter((item) => item.state === 'healthy').length,
|
healthy: results.filter((item) => item.state === 'healthy').length,
|
||||||
unhealthy: results.filter((item) => item.state === 'unhealthy').length,
|
unhealthy: results.filter((item) => item.state === 'unhealthy').length,
|
||||||
@@ -165,10 +167,24 @@ function summarizeAccountHealthRefresh(results: AccountHealthRefreshResult[]) {
|
|||||||
success: results.filter((item) => item.status === 'success').length,
|
success: results.filter((item) => item.status === 'success').length,
|
||||||
failed: results.filter((item) => item.status === 'failed').length,
|
failed: results.filter((item) => item.status === 'failed').length,
|
||||||
skipped: results.filter((item) => item.status === 'skipped').length,
|
skipped: results.filter((item) => item.status === 'skipped').length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<AccountHealthRefreshResult> {
|
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
fn(),
|
||||||
|
new Promise<T>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<AccountHealthRefreshResult> {
|
||||||
const accountId = row.accounts.id;
|
const accountId = row.accounts.id;
|
||||||
const username = row.accounts.username;
|
const username = row.accounts.username;
|
||||||
const siteName = row.sites.name;
|
const siteName = row.sites.name;
|
||||||
@@ -187,10 +203,14 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
|
|||||||
state: 'disabled',
|
state: 'disabled',
|
||||||
message: '账号或站点已禁用',
|
message: '账号或站点已禁用',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await refreshBalance(accountId);
|
await withTimeout(
|
||||||
|
() => refreshBalance(accountId),
|
||||||
|
ACCOUNT_HEALTH_REFRESH_TIMEOUT_MS,
|
||||||
|
`站点健康检查超时(${Math.max(1, Math.round(ACCOUNT_HEALTH_REFRESH_TIMEOUT_MS / 1000))}s)`,
|
||||||
|
);
|
||||||
const refreshedAccount = await db.select().from(schema.accounts)
|
const refreshedAccount = await db.select().from(schema.accounts)
|
||||||
.where(eq(schema.accounts.id, accountId))
|
.where(eq(schema.accounts.id, accountId))
|
||||||
.get();
|
.get();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify';
|
|||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance) {
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
// Change admin auth token (requires old token verification)
|
// Change admin auth token (requires old token verification)
|
||||||
@@ -32,13 +33,14 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
config.authToken = newToken;
|
config.authToken = newToken;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
await db.insert(schema.events).values({
|
await db.insert(schema.events).values({
|
||||||
type: 'token',
|
type: 'token',
|
||||||
title: '管理员登录令牌已更新',
|
title: '管理员登录令牌已更新',
|
||||||
message: '管理员登录 Token 已被修改,请使用新 Token 登录。',
|
message: '管理员登录 Token 已被修改,请使用新 Token 登录。',
|
||||||
level: 'warning',
|
level: 'warning',
|
||||||
relatedType: 'settings',
|
relatedType: 'settings',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).run();
|
}).run();
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
testDatabaseConnection,
|
testDatabaseConnection,
|
||||||
type MigrationDialect,
|
type MigrationDialect,
|
||||||
} from '../../services/databaseMigrationService.js';
|
} from '../../services/databaseMigrationService.js';
|
||||||
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
||||||
|
|
||||||
type RoutingWeights = typeof config.routingWeights;
|
type RoutingWeights = typeof config.routingWeights;
|
||||||
@@ -87,13 +88,14 @@ async function appendSettingsEvent(input: {
|
|||||||
level?: 'info' | 'warning' | 'error';
|
level?: 'info' | 'warning' | 'error';
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
await db.insert(schema.events).values({
|
await db.insert(schema.events).values({
|
||||||
type: input.type,
|
type: input.type,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
message: input.message,
|
message: input.message,
|
||||||
level: input.level || 'info',
|
level: input.level || 'info',
|
||||||
relatedType: 'settings',
|
relatedType: 'settings',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).run();
|
}).run();
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db, schema } from '../../db/index.js';
|
|||||||
import { and, eq, sql } from 'drizzle-orm';
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
import { detectSite } from '../../services/siteDetector.js';
|
import { detectSite } from '../../services/siteDetector.js';
|
||||||
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
||||||
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
|
||||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||||
if (input === undefined || input === null) return null;
|
if (input === undefined || input === null) return null;
|
||||||
@@ -237,6 +238,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
.run();
|
.run();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
await db.insert(schema.events).values({
|
await db.insert(schema.events).values({
|
||||||
type: 'status',
|
type: 'status',
|
||||||
title: '站点已禁用',
|
title: '站点已禁用',
|
||||||
@@ -244,7 +246,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
level: 'warning',
|
level: 'warning',
|
||||||
relatedId: id,
|
relatedId: id,
|
||||||
relatedType: 'site',
|
relatedType: 'site',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).run();
|
}).run();
|
||||||
} catch {}
|
} catch {}
|
||||||
} else {
|
} else {
|
||||||
@@ -254,6 +256,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
.run();
|
.run();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
await db.insert(schema.events).values({
|
await db.insert(schema.events).values({
|
||||||
type: 'status',
|
type: 'status',
|
||||||
title: '站点已启用',
|
title: '站点已启用',
|
||||||
@@ -261,7 +264,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
level: 'info',
|
level: 'info',
|
||||||
relatedId: id,
|
relatedId: id,
|
||||||
relatedType: 'site',
|
relatedType: 'site',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).run();
|
}).run();
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import {
|
|||||||
} from '../../services/localTimeService.js';
|
} from '../../services/localTimeService.js';
|
||||||
|
|
||||||
type DbModule = typeof import('../../db/index.js');
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
type RepairModule = typeof import('../../services/storedTimestampRepairService.js');
|
||||||
|
|
||||||
describe('stats dashboard today reward fallback', () => {
|
describe('stats dashboard today reward fallback', () => {
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let db: DbModule['db'];
|
let db: DbModule['db'];
|
||||||
let schema: DbModule['schema'];
|
let schema: DbModule['schema'];
|
||||||
|
let repairStoredCreatedAtValues: RepairModule['repairStoredCreatedAtValues'];
|
||||||
let dataDir = '';
|
let dataDir = '';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -25,8 +27,10 @@ describe('stats dashboard today reward fallback', () => {
|
|||||||
await import('../../db/migrate.js');
|
await import('../../db/migrate.js');
|
||||||
const dbModule = await import('../../db/index.js');
|
const dbModule = await import('../../db/index.js');
|
||||||
const routesModule = await import('./stats.js');
|
const routesModule = await import('./stats.js');
|
||||||
|
const repairModule = await import('../../services/storedTimestampRepairService.js');
|
||||||
db = dbModule.db;
|
db = dbModule.db;
|
||||||
schema = dbModule.schema;
|
schema = dbModule.schema;
|
||||||
|
repairStoredCreatedAtValues = repairModule.repairStoredCreatedAtValues;
|
||||||
|
|
||||||
app = Fastify();
|
app = Fastify();
|
||||||
await app.register(routesModule.statsRoutes);
|
await app.register(routesModule.statsRoutes);
|
||||||
@@ -89,6 +93,38 @@ describe('stats dashboard today reward fallback', () => {
|
|||||||
expect(body.todayReward).toBe(4.8);
|
expect(body.todayReward).toBe(4.8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('repairs ISO timestamps for dashboard today reward filtering', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'stats-iso-site',
|
||||||
|
url: 'https://stats-iso.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'stats-iso-user',
|
||||||
|
accessToken: 'token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.checkinLogs).values({
|
||||||
|
accountId: account.id,
|
||||||
|
status: 'success',
|
||||||
|
message: 'checked in',
|
||||||
|
reward: '3.6',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}).run();
|
||||||
|
await repairStoredCreatedAtValues();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/stats/dashboard',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as { todayReward: number };
|
||||||
|
expect(body.todayReward).toBe(3.6);
|
||||||
|
});
|
||||||
|
|
||||||
it('counts dashboard today spend only inside local-day range', async () => {
|
it('counts dashboard today spend only inside local-day range', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'stats-spend-site',
|
name: 'stats-spend-site',
|
||||||
@@ -139,4 +175,39 @@ describe('stats dashboard today reward fallback', () => {
|
|||||||
const body = response.json() as { todaySpend: number };
|
const body = response.json() as { todaySpend: number };
|
||||||
expect(body.todaySpend).toBe(3);
|
expect(body.todaySpend).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('repairs ISO timestamps for dashboard today spend filtering', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'stats-iso-spend-site',
|
||||||
|
url: 'https://stats-iso-spend.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'stats-iso-spend-user',
|
||||||
|
accessToken: 'token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const { startUtc } = getLocalDayRangeUtc();
|
||||||
|
const startDate = parseStoredUtcDateTime(startUtc)!;
|
||||||
|
const inRangeIso = new Date(startDate.getTime() + 60_000).toISOString();
|
||||||
|
|
||||||
|
await db.insert(schema.proxyLogs).values({
|
||||||
|
accountId: account.id,
|
||||||
|
status: 'success',
|
||||||
|
estimatedCost: 6.4,
|
||||||
|
createdAt: inRangeIso,
|
||||||
|
}).run();
|
||||||
|
await repairStoredCreatedAtValues();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/stats/dashboard',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as { todaySpend: number };
|
||||||
|
expect(body.todaySpend).toBe(6.4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { and, desc, gte, eq, lt, sql } from 'drizzle-orm';
|
import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
refreshModelsForAccount,
|
refreshModelsForAccount,
|
||||||
refreshModelsAndRebuildRoutes,
|
refreshModelsAndRebuildRoutes,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from './downstreamPolicy.js';
|
} from './downstreamPolicy.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
||||||
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
const CLAUDE_SSE_EVENT_NAMES = new Set([
|
const CLAUDE_SSE_EVENT_NAMES = new Set([
|
||||||
@@ -719,6 +720,7 @@ async function logProxy(
|
|||||||
upstreamPath: string | null = null,
|
upstreamPath: string | null = null,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
@@ -739,7 +741,7 @@ async function logProxy(
|
|||||||
estimatedCost,
|
estimatedCost,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).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);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../servi
|
|||||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||||
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -242,6 +243,7 @@ async function logProxy(
|
|||||||
estimatedCost = 0,
|
estimatedCost = 0,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
downstreamPath: '/v1/completions',
|
downstreamPath: '/v1/completions',
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@@ -261,7 +263,7 @@ async function logProxy(
|
|||||||
estimatedCost,
|
estimatedCost,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).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);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
|||||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||||
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -157,6 +158,7 @@ async function logProxy(
|
|||||||
estimatedCost = 0,
|
estimatedCost = 0,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
downstreamPath: '/v1/embeddings',
|
downstreamPath: '/v1/embeddings',
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@@ -176,7 +178,7 @@ async function logProxy(
|
|||||||
estimatedCost,
|
estimatedCost,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).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);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
|||||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||||
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -127,6 +128,7 @@ async function logProxy(
|
|||||||
estimatedCost = 0,
|
estimatedCost = 0,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
downstreamPath: '/v1/images/generations',
|
downstreamPath: '/v1/images/generations',
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@@ -146,7 +148,7 @@ async function logProxy(
|
|||||||
estimatedCost,
|
estimatedCost,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).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);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
||||||
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -1577,6 +1578,7 @@ async function logProxy(
|
|||||||
upstreamPath: string | null = null,
|
upstreamPath: string | null = null,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
@@ -1597,7 +1599,7 @@ async function logProxy(
|
|||||||
estimatedCost,
|
estimatedCost,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).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);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { sendNotification } from './notifyService.js';
|
import { sendNotification } from './notifyService.js';
|
||||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||||
import { appendSessionTokenRebindHint } from './alertRules.js';
|
import { appendSessionTokenRebindHint } from './alertRules.js';
|
||||||
|
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||||
|
|
||||||
export async function reportTokenExpired(params: {
|
export async function reportTokenExpired(params: {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
@@ -14,6 +15,7 @@ export async function reportTokenExpired(params: {
|
|||||||
const siteLabel = params.siteName || 'unknown-site';
|
const siteLabel = params.siteName || 'unknown-site';
|
||||||
const detailText = params.detail ? appendSessionTokenRebindHint(params.detail) : '';
|
const detailText = params.detail ? appendSessionTokenRebindHint(params.detail) : '';
|
||||||
const detail = detailText ? ` (${detailText})` : '';
|
const detail = detailText ? ` (${detailText})` : '';
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
|
|
||||||
await db.insert(schema.events).values({
|
await db.insert(schema.events).values({
|
||||||
type: 'token',
|
type: 'token',
|
||||||
@@ -22,7 +24,7 @@ export async function reportTokenExpired(params: {
|
|||||||
level: 'error',
|
level: 'error',
|
||||||
relatedId: params.accountId,
|
relatedId: params.accountId,
|
||||||
relatedType: 'account',
|
relatedType: 'account',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
await db.update(schema.accounts).set({
|
await db.update(schema.accounts).set({
|
||||||
@@ -44,13 +46,14 @@ export async function reportTokenExpired(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function reportProxyAllFailed(params: { model: string; reason: string }) {
|
export async function reportProxyAllFailed(params: { model: string; reason: string }) {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
await db.insert(schema.events).values({
|
await db.insert(schema.events).values({
|
||||||
type: 'proxy',
|
type: 'proxy',
|
||||||
title: '代理全部失败',
|
title: '代理全部失败',
|
||||||
message: `模型=${params.model}, 原因=${params.reason}`,
|
message: `模型=${params.model}, 原因=${params.reason}`,
|
||||||
level: 'error',
|
level: 'error',
|
||||||
relatedType: 'route',
|
relatedType: 'route',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
await sendNotification(
|
await sendNotification(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from './accountExtraConfig.js';
|
} from './accountExtraConfig.js';
|
||||||
import { decryptAccountPassword } from './accountCredentialService.js';
|
import { decryptAccountPassword } from './accountCredentialService.js';
|
||||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||||
|
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||||
|
|
||||||
type CheckinExecutionStatus = 'success' | 'failed' | 'skipped';
|
type CheckinExecutionStatus = 'success' | 'failed' | 'skipped';
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
|
|||||||
const site = rows[0].sites;
|
const site = rows[0].sites;
|
||||||
|
|
||||||
if (isSiteDisabled(site.status)) {
|
if (isSiteDisabled(site.status)) {
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
setAccountRuntimeHealth(account.id, {
|
setAccountRuntimeHealth(account.id, {
|
||||||
state: 'disabled',
|
state: 'disabled',
|
||||||
reason: '\u7ad9\u70b9\u5df2\u7981\u7528',
|
reason: '\u7ad9\u70b9\u5df2\u7981\u7528',
|
||||||
@@ -137,7 +139,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(),
|
createdAt,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
if (!options?.skipEvent) {
|
if (!options?.skipEvent) {
|
||||||
@@ -148,7 +150,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(),
|
createdAt,
|
||||||
}).run();
|
}).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,12 +244,13 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
await db.insert(schema.checkinLogs).values({
|
await db.insert(schema.checkinLogs).values({
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
status: normalizedStatus,
|
status: normalizedStatus,
|
||||||
message: logMessage,
|
message: logMessage,
|
||||||
reward: logReward,
|
reward: logReward,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
if (!options?.skipEvent) {
|
if (!options?.skipEvent) {
|
||||||
@@ -260,7 +263,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(),
|
createdAt,
|
||||||
}).run();
|
}).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { eq, isNull, like, or, sql } from 'drizzle-orm';
|
||||||
|
import { db, schema } from '../db/index.js';
|
||||||
|
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||||
|
|
||||||
|
function normalizedCreatedAtSql(column: any) {
|
||||||
|
return sql<string>`replace(substr(${column}, 1, 19), 'T', ' ')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repairStoredCreatedAtValues(now = new Date()): Promise<void> {
|
||||||
|
const repairedAt = formatUtcSqlDateTime(now);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
await db.update(schema.events)
|
||||||
|
.set({ createdAt: normalizedCreatedAtSql(schema.events.createdAt) })
|
||||||
|
.where(like(schema.events.createdAt, '%T%'))
|
||||||
|
.run();
|
||||||
|
await db.update(schema.proxyLogs)
|
||||||
|
.set({ createdAt: normalizedCreatedAtSql(schema.proxyLogs.createdAt) })
|
||||||
|
.where(like(schema.proxyLogs.createdAt, '%T%'))
|
||||||
|
.run();
|
||||||
|
await db.update(schema.checkinLogs)
|
||||||
|
.set({ createdAt: normalizedCreatedAtSql(schema.checkinLogs.createdAt) })
|
||||||
|
.where(like(schema.checkinLogs.createdAt, '%T%'))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
|
import Models from './Models.js';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getModelsMarketplace: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
const children = node.children || [];
|
||||||
|
return children.map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Models marketplace text', () => {
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
const originalMutationObserver = globalThis.MutationObserver;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
globalThis.document = {
|
||||||
|
documentElement: {
|
||||||
|
getAttribute: () => 'light',
|
||||||
|
},
|
||||||
|
} as unknown as Document;
|
||||||
|
globalThis.MutationObserver = class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof MutationObserver;
|
||||||
|
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'gpt-4o',
|
||||||
|
accountCount: 1,
|
||||||
|
tokenCount: 1,
|
||||||
|
avgLatency: 320,
|
||||||
|
successRate: 98,
|
||||||
|
description: null,
|
||||||
|
tags: [],
|
||||||
|
supportedEndpointTypes: [],
|
||||||
|
pricingSources: [],
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
site: 'Demo Site',
|
||||||
|
username: 'tester',
|
||||||
|
latency: 320,
|
||||||
|
balance: 12.5,
|
||||||
|
tokens: [{ id: 1, name: 'default', isDefault: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
globalThis.document = originalDocument;
|
||||||
|
globalThis.MutationObserver = originalMutationObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders readable Chinese labels and fallback descriptions for marketplace models', async () => {
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/models']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Models />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const initialText = collectText(root!.root);
|
||||||
|
expect(initialText).toContain('品牌');
|
||||||
|
expect(initialText).toContain('排序方式');
|
||||||
|
expect(initialText).toContain('模型广场');
|
||||||
|
|
||||||
|
const cards = root!.root.findAll((node) => (
|
||||||
|
node.type === 'div'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('model-card')
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
));
|
||||||
|
expect(cards.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
cards[0]!.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const expandedText = collectText(root!.root);
|
||||||
|
expect(expandedText).toContain('当前上游仅返回模型 ID,未返回描述字段。');
|
||||||
|
expect(expandedText).toContain('基础信息');
|
||||||
|
expect(expandedText).toContain('站点');
|
||||||
|
expect(expandedText).toContain('余额');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+55
-55
@@ -92,7 +92,7 @@ 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('姝e湪鍔犺浇妯″瀷鍏冩暟鎹?..');
|
if (metadataHydrating) return tr('正在加载模型元数据...');
|
||||||
|
|
||||||
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('上游未提供描述文本,但已同步标签、能力或价格信息。');
|
||||||
@@ -347,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 }]) => (
|
||||||
@@ -383,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>
|
||||||
)}
|
)}
|
||||||
@@ -393,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
|
||||||
@@ -412,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}
|
||||||
@@ -447,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>
|
||||||
)}
|
)}
|
||||||
@@ -458,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>
|
||||||
@@ -482,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>
|
||||||
@@ -504,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>
|
||||||
|
|
||||||
@@ -527,7 +527,7 @@ 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' ? (
|
||||||
@@ -548,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)}`}
|
||||||
@@ -576,8 +576,8 @@ export default function Models() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="model-card-action-btn"
|
className="model-card-action-btn"
|
||||||
data-tooltip={isExpanded ? tr('鏀惰捣') : tr('灞曞紑')}
|
data-tooltip={isExpanded ? tr('收起') : tr('展开')}
|
||||||
aria-label={isExpanded ? tr('鏀惰捣') : tr('灞曞紑')}
|
aria-label={isExpanded ? tr('收起') : tr('展开')}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: isExpanded ? '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" />
|
||||||
@@ -595,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>
|
||||||
@@ -612,28 +612,28 @@ export default function Models() {
|
|||||||
<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) => (
|
||||||
@@ -642,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]) => (
|
||||||
@@ -655,7 +655,7 @@ export default function Models() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="badge badge-muted">{metadataHydrating ? tr('姝e湪鍔犺浇浠锋牸鍏冩暟鎹?..') : tr('暂无价格元数据')}</span>
|
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -663,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>
|
||||||
@@ -706,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' ? '↓' : '↑') : ''}
|
||||||
@@ -715,12 +715,12 @@ 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>
|
||||||
@@ -756,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>
|
||||||
) : (
|
) : (
|
||||||
@@ -773,28 +773,28 @@ export default function Models() {
|
|||||||
<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) => (
|
||||||
@@ -803,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]) => (
|
||||||
@@ -816,18 +816,18 @@ export default function Models() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="badge badge-muted">{metadataHydrating ? tr('姝e湪鍔犺浇浠锋牸鍏冩暟鎹?..') : tr('暂无价格元数据')}</span>
|
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : 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 => (
|
||||||
@@ -888,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"
|
||||||
|
|||||||
Reference in New Issue
Block a user