forked from boos/BoosAPI
Merge remote-tracking branch 'origin/main' into feat/account-level-proxy
# Conflicts: # src/server/routes/proxy/chat.ts # src/server/routes/proxy/responses.ts
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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 ImportExport from './ImportExport.js';
|
||||
|
||||
const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
exportBackup: vi.fn(),
|
||||
importBackup: vi.fn(),
|
||||
},
|
||||
toastMock: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Toast.js', () => ({
|
||||
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const allApiHubV2Payload = JSON.stringify({
|
||||
version: '2.0',
|
||||
timestamp: 1735689600000,
|
||||
accounts: {
|
||||
accounts: [
|
||||
{
|
||||
id: 'managed-account',
|
||||
site_url: 'https://newapi.example.com',
|
||||
site_type: 'new-api',
|
||||
site_name: 'Managed Site',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
id: 7788,
|
||||
username: 'managed-user',
|
||||
access_token: 'managed-session-token',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'direct-account',
|
||||
site_url: 'https://api.openai.com',
|
||||
site_type: 'openai',
|
||||
site_name: 'OpenAI Direct',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
username: 'openai-user',
|
||||
access_token: 'sk-openai-account',
|
||||
},
|
||||
},
|
||||
],
|
||||
bookmarks: [
|
||||
{
|
||||
id: 'bookmark-1',
|
||||
name: 'Ignored Bookmark',
|
||||
url: 'https://bookmark.example.com',
|
||||
},
|
||||
],
|
||||
pinnedAccountIds: ['direct-account'],
|
||||
orderedAccountIds: ['managed-account', 'direct-account'],
|
||||
last_updated: 1735689601000,
|
||||
},
|
||||
preferences: {
|
||||
language: 'zh-CN',
|
||||
},
|
||||
channelConfigs: {
|
||||
bySite: {
|
||||
demo: { enabled: true },
|
||||
},
|
||||
},
|
||||
tagStore: {
|
||||
version: 1,
|
||||
tagsById: {},
|
||||
},
|
||||
apiCredentialProfiles: {
|
||||
version: 2,
|
||||
profiles: [
|
||||
{
|
||||
id: 'profile-openai',
|
||||
name: 'OpenAI Profile',
|
||||
apiType: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiKey: 'sk-profile-openai',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689602000,
|
||||
updatedAt: 1735689603000,
|
||||
},
|
||||
{
|
||||
id: 'profile-gemini',
|
||||
name: 'Gemini Profile',
|
||||
apiType: 'google',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
apiKey: 'gemini-profile-key',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689604000,
|
||||
updatedAt: 1735689605000,
|
||||
},
|
||||
],
|
||||
lastUpdated: 1735689606000,
|
||||
},
|
||||
});
|
||||
|
||||
const nativeMetapiPayload = JSON.stringify({
|
||||
version: '2.0',
|
||||
timestamp: 1735689600000,
|
||||
accounts: {
|
||||
sites: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Native Site',
|
||||
url: 'https://native.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
siteId: 1,
|
||||
username: 'native-user',
|
||||
accessToken: 'session-token',
|
||||
apiToken: 'api-token',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
accountTokens: [
|
||||
{
|
||||
id: 1,
|
||||
accountId: 1,
|
||||
name: 'default',
|
||||
token: 'sk-native',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
tokenRoutes: [
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-5-nano',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
routeChannels: [
|
||||
{
|
||||
id: 1,
|
||||
routeId: 1,
|
||||
accountId: 1,
|
||||
tokenId: 1,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
},
|
||||
],
|
||||
routeGroupSources: [],
|
||||
},
|
||||
preferences: {
|
||||
settings: [
|
||||
{ key: 'locale', value: 'zh-CN' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('ImportExport', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('window', {
|
||||
confirm: vi.fn(() => true),
|
||||
});
|
||||
apiMock.importBackup.mockResolvedValue({
|
||||
allImported: true,
|
||||
sections: {
|
||||
accounts: true,
|
||||
preferences: true,
|
||||
},
|
||||
appliedSettings: [],
|
||||
summary: {
|
||||
importedSites: 3,
|
||||
importedAccounts: 2,
|
||||
importedProfiles: 2,
|
||||
importedApiKeyConnections: 3,
|
||||
skippedAccounts: 1,
|
||||
ignoredSections: ['accounts.bookmarks', 'channelConfigs', 'tagStore'],
|
||||
},
|
||||
warnings: ['跳过 ALL-API-Hub 账号 skipped-account:authType=none 不支持离线迁移'],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows ALL-API-Hub V2 preview counts and ignored sections', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ImportExport />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = root!.root.findByType('textarea');
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = collectText(root!.root);
|
||||
expect(rendered).toContain('ALL-API-Hub V2');
|
||||
expect(rendered).toContain('统计:账号 2 / 书签 1 / 独立 API 凭据 2');
|
||||
expect(rendered).toContain('accounts.bookmarks、channelConfigs、tagStore');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not label native metapi backups as ALL-API-Hub V2', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ImportExport />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = root!.root.findByType('textarea');
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: nativeMetapiPayload } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = collectText(root!.root);
|
||||
expect(rendered).not.toContain('ALL-API-Hub V2');
|
||||
expect(rendered).toContain('统计:站点 1 / 账号 1 / 令牌 1 / 路由 1 / 通道 1 / 设置 1');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses backend import summary in the completion toast', async () => {
|
||||
const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm);
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ImportExport />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = root!.root.findByType('textarea');
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const importButton = root!.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('导入')
|
||||
)).at(-1);
|
||||
|
||||
expect(importButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
importButton!.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(apiMock.importBackup).toHaveBeenCalledTimes(1);
|
||||
expect(toastMock.success).toHaveBeenCalledWith(
|
||||
expect.stringContaining('站点 3 / 账号 2 / API Key 连接 3 / 跳过 1'),
|
||||
);
|
||||
expect(toastMock.success).toHaveBeenCalledWith(
|
||||
expect.stringContaining('accounts.bookmarks、channelConfigs、tagStore'),
|
||||
);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
+111
-33
@@ -12,12 +12,16 @@ type ParsedSummary = {
|
||||
hasAccounts: boolean;
|
||||
hasPreferences: boolean;
|
||||
hasLegacyData: boolean;
|
||||
isAllApiHubV2: boolean;
|
||||
sitesCount: number;
|
||||
accountsCount: number;
|
||||
bookmarksCount: number;
|
||||
profilesCount: number;
|
||||
tokensCount: number;
|
||||
routesCount: number;
|
||||
channelsCount: number;
|
||||
settingsCount: number;
|
||||
ignoredSections: string[];
|
||||
};
|
||||
|
||||
function downloadJsonFile(data: unknown, filename: string) {
|
||||
@@ -35,23 +39,29 @@ function downloadJsonFile(data: unknown, filename: string) {
|
||||
function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
if (!raw.trim()) return null;
|
||||
|
||||
const invalidSummary = (): ParsedSummary => ({
|
||||
valid: false,
|
||||
version: '-',
|
||||
timestampLabel: '未知',
|
||||
hasAccounts: false,
|
||||
hasPreferences: false,
|
||||
hasLegacyData: false,
|
||||
isAllApiHubV2: false,
|
||||
sitesCount: 0,
|
||||
accountsCount: 0,
|
||||
bookmarksCount: 0,
|
||||
profilesCount: 0,
|
||||
tokensCount: 0,
|
||||
routesCount: 0,
|
||||
channelsCount: 0,
|
||||
settingsCount: 0,
|
||||
ignoredSections: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const data = JSON.parse(raw) as any;
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return {
|
||||
valid: false,
|
||||
version: '-',
|
||||
timestampLabel: '未知',
|
||||
hasAccounts: false,
|
||||
hasPreferences: false,
|
||||
hasLegacyData: false,
|
||||
sitesCount: 0,
|
||||
accountsCount: 0,
|
||||
tokensCount: 0,
|
||||
routesCount: 0,
|
||||
channelsCount: 0,
|
||||
settingsCount: 0,
|
||||
};
|
||||
return invalidSummary();
|
||||
}
|
||||
|
||||
const accountsSection = (data.accounts && typeof data.accounts === 'object' && !Array.isArray(data.accounts))
|
||||
@@ -63,6 +73,38 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
|
||||
const legacyAccounts = Boolean(data.data?.accounts || Array.isArray(data.accounts));
|
||||
const legacyPrefs = Boolean(data.data?.preferences);
|
||||
const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0;
|
||||
const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0;
|
||||
const isNativeMetapiBackup = Boolean(
|
||||
accountsSection
|
||||
&& Array.isArray(accountsSection.sites)
|
||||
&& Array.isArray(accountsSection.accountTokens)
|
||||
&& Array.isArray(accountsSection.tokenRoutes)
|
||||
&& Array.isArray(accountsSection.routeChannels)
|
||||
);
|
||||
const hasLegacyAccountRows = Array.isArray(accountsSection?.accounts)
|
||||
&& accountsSection.accounts.some((row: any) => row && typeof row === 'object' && !Array.isArray(row) && (
|
||||
'site_url' in row
|
||||
|| 'site_type' in row
|
||||
|| 'account_info' in row
|
||||
|| 'cookieAuth' in row
|
||||
|| 'authType' in row
|
||||
|| 'sub2apiAuth' in row
|
||||
));
|
||||
const isAllApiHubV2 = Boolean(
|
||||
accountsSection
|
||||
&& !isNativeMetapiBackup
|
||||
&& hasLegacyAccountRows
|
||||
&& Array.isArray(accountsSection.accounts)
|
||||
&& (
|
||||
(typeof data.version === 'string' && data.version.startsWith('2'))
|
||||
|| 'last_updated' in accountsSection
|
||||
|| Array.isArray(accountsSection.bookmarks)
|
||||
|| Array.isArray(accountsSection.pinnedAccountIds)
|
||||
|| Array.isArray(accountsSection.orderedAccountIds)
|
||||
|| profilesCount > 0
|
||||
)
|
||||
);
|
||||
|
||||
const hasAccounts = Boolean(
|
||||
data.type === 'accounts'
|
||||
@@ -74,6 +116,10 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
|| preferencesSection
|
||||
|| legacyPrefs,
|
||||
);
|
||||
const ignoredSections: string[] = [];
|
||||
if (bookmarksCount > 0) ignoredSections.push('accounts.bookmarks');
|
||||
if (data.channelConfigs && typeof data.channelConfigs === 'object' && !Array.isArray(data.channelConfigs)) ignoredSections.push('channelConfigs');
|
||||
if (data.tagStore && typeof data.tagStore === 'object' && !Array.isArray(data.tagStore)) ignoredSections.push('tagStore');
|
||||
|
||||
const toCount = (value: unknown): number => (Array.isArray(value) ? value.length : 0);
|
||||
|
||||
@@ -89,31 +135,52 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
hasAccounts,
|
||||
hasPreferences,
|
||||
hasLegacyData: legacyAccounts || legacyPrefs,
|
||||
isAllApiHubV2,
|
||||
sitesCount: toCount(accountsSection?.sites),
|
||||
accountsCount: toCount(accountsSection?.accounts),
|
||||
bookmarksCount,
|
||||
profilesCount,
|
||||
tokensCount: toCount(accountsSection?.accountTokens),
|
||||
routesCount: toCount(accountsSection?.tokenRoutes),
|
||||
channelsCount: toCount(accountsSection?.routeChannels),
|
||||
settingsCount: toCount(preferencesSection?.settings),
|
||||
ignoredSections,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
version: '-',
|
||||
timestampLabel: '未知',
|
||||
hasAccounts: false,
|
||||
hasPreferences: false,
|
||||
hasLegacyData: false,
|
||||
sitesCount: 0,
|
||||
accountsCount: 0,
|
||||
tokensCount: 0,
|
||||
routesCount: 0,
|
||||
channelsCount: 0,
|
||||
settingsCount: 0,
|
||||
};
|
||||
return invalidSummary();
|
||||
}
|
||||
}
|
||||
|
||||
function buildImportSuccessMessage(result: any): string {
|
||||
const sections: string[] = [];
|
||||
if (result?.sections?.accounts) sections.push('账号与路由');
|
||||
if (result?.sections?.preferences) sections.push('系统设置');
|
||||
|
||||
const parts = [`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`];
|
||||
if (result?.summary) {
|
||||
const summary = result.summary;
|
||||
parts.push(
|
||||
[
|
||||
`站点 ${summary.importedSites ?? 0}`,
|
||||
`账号 ${summary.importedAccounts ?? 0}`,
|
||||
`API Key 连接 ${summary.importedApiKeyConnections ?? summary.importedProfiles ?? 0}`,
|
||||
`跳过 ${summary.skippedAccounts ?? 0}`,
|
||||
].join(' / '),
|
||||
);
|
||||
|
||||
if (Array.isArray(summary.ignoredSections) && summary.ignoredSections.length > 0) {
|
||||
parts.push(`未原生导入 ${summary.ignoredSections.join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(result?.warnings) && result.warnings.length > 0) {
|
||||
const preview = result.warnings.slice(0, 2).join(';');
|
||||
parts.push(`提示:${preview}${result.warnings.length > 2 ? ` 等 ${result.warnings.length} 项` : ''}`);
|
||||
}
|
||||
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
export default function ImportExport() {
|
||||
const toast = useToast();
|
||||
const [exportingType, setExportingType] = useState<BackupType | ''>('');
|
||||
@@ -193,7 +260,10 @@ export default function ImportExport() {
|
||||
toast.error('当前 JSON 结构无法识别');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('导入会覆盖账号/路由或系统设置,确认继续?')) {
|
||||
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
|
||||
? true
|
||||
: window.confirm('导入会覆盖账号/路由或系统设置,确认继续?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,10 +271,7 @@ export default function ImportExport() {
|
||||
try {
|
||||
const parsed = JSON.parse(importData);
|
||||
const result = await api.importBackup(parsed);
|
||||
const sections: string[] = [];
|
||||
if (result?.sections?.accounts) sections.push('账号与路由');
|
||||
if (result?.sections?.preferences) sections.push('系统设置');
|
||||
toast.success(`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`);
|
||||
toast.success(buildImportSuccessMessage(result));
|
||||
setImportData('');
|
||||
setSelectedFileName('');
|
||||
} catch (err: any) {
|
||||
@@ -379,6 +446,17 @@ export default function ImportExport() {
|
||||
<>
|
||||
<div>结构有效,版本:{summary.version},时间:{summary.timestampLabel}</div>
|
||||
<div>包含分区:{summary.hasAccounts ? '账号路由' : ''}{summary.hasAccounts && summary.hasPreferences ? ' + ' : ''}{summary.hasPreferences ? '系统设置' : ''}</div>
|
||||
{summary.isAllApiHubV2 ? (
|
||||
<>
|
||||
<div>检测到 ALL-API-Hub V2 兼容备份:将离线迁移可用连接。</div>
|
||||
<div>
|
||||
统计:账号 {summary.accountsCount} / 书签 {summary.bookmarksCount} / 独立 API 凭据 {summary.profilesCount}
|
||||
</div>
|
||||
{summary.ignoredSections.length ? (
|
||||
<div>不会原生导入:{summary.ignoredSections.join('、')}</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{(summary.sitesCount || summary.accountsCount || summary.tokensCount || summary.routesCount || summary.channelsCount || summary.settingsCount) ? (
|
||||
<div>
|
||||
统计:站点 {summary.sitesCount} / 账号 {summary.accountsCount} / 令牌 {summary.tokensCount} / 路由 {summary.routesCount} / 通道 {summary.channelsCount} / 设置 {summary.settingsCount}
|
||||
|
||||
@@ -563,6 +563,15 @@ export default function TokenRoutes() {
|
||||
}) as [string, number][];
|
||||
}, [listVisibleRoutes, routeEndpointTypesByRouteId]);
|
||||
|
||||
const sourceEndpointTypesByRouteId = useMemo(() => {
|
||||
const next: Record<number, string[]> = {};
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
next[route.id] = Array.from(routeEndpointTypesByRouteId[route.id] || new Set<string>())
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
return next;
|
||||
}, [exactSourceRouteOptions, routeEndpointTypesByRouteId]);
|
||||
|
||||
const routeBrandIconCandidates = useMemo(() => {
|
||||
const byIcon = new Map<string, BrandInfo>();
|
||||
|
||||
@@ -600,6 +609,7 @@ export default function TokenRoutes() {
|
||||
brand: routeBrandById.get(route.id) || null,
|
||||
modelPattern: route.modelPattern,
|
||||
channelCount: route.channelCount,
|
||||
sourceRouteCount: Array.isArray(route.sourceRouteIds) ? route.sourceRouteIds.length : 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.channelCount === b.channelCount) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' });
|
||||
@@ -1152,6 +1162,7 @@ export default function TokenRoutes() {
|
||||
routeIconSelectOptions={routeIconSelectOptions}
|
||||
previewModelSamples={previewModelSamples}
|
||||
exactSourceRouteOptions={exactSourceRouteOptions}
|
||||
sourceEndpointTypesByRouteId={sourceEndpointTypesByRouteId}
|
||||
onSave={handleAddRoute}
|
||||
onCancel={handleCancelEditRoute}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { useEffect, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react';
|
||||
import { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js';
|
||||
import CenteredModal from '../../components/CenteredModal.js';
|
||||
import ModernSelect from '../../components/ModernSelect.js';
|
||||
import { tr } from '../../i18n.js';
|
||||
@@ -11,6 +12,9 @@ import {
|
||||
matchesModelPattern,
|
||||
normalizeRouteDisplayIconValue,
|
||||
normalizeRouteMode,
|
||||
resolveEndpointTypeIconModel,
|
||||
resolveRouteBrand,
|
||||
siteAvatarLetters,
|
||||
} from './utils.js';
|
||||
|
||||
type RouteEditorForm = {
|
||||
@@ -32,6 +36,7 @@ type ManualRoutePanelProps = {
|
||||
routeIconSelectOptions: RouteIconOption[];
|
||||
previewModelSamples: string[];
|
||||
exactSourceRouteOptions: RouteSummaryRow[];
|
||||
sourceEndpointTypesByRouteId: Record<number, string[]>;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
@@ -71,6 +76,41 @@ function SearchField({
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
label,
|
||||
count,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
count?: number;
|
||||
icon?: ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-chip ${active ? 'active' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon ? <span className="filter-chip-icon">{icon}</span> : null}
|
||||
<span className="filter-chip-label">{label}</span>
|
||||
{count !== undefined ? <span className="filter-chip-count">{count}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="route-filter-row">
|
||||
<span className="route-filter-row-label">{label}</span>
|
||||
<div className="route-filter-row-chips">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ManualRoutePanel({
|
||||
show,
|
||||
editingRouteId,
|
||||
@@ -81,18 +121,25 @@ export default function ManualRoutePanel({
|
||||
routeIconSelectOptions,
|
||||
previewModelSamples,
|
||||
exactSourceRouteOptions,
|
||||
sourceEndpointTypesByRouteId,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: ManualRoutePanelProps) {
|
||||
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
||||
const [sourceSearch, setSourceSearch] = useState('');
|
||||
const [sourcePickerSelection, setSourcePickerSelection] = useState<number[]>([]);
|
||||
const [activeSourceBrand, setActiveSourceBrand] = useState<string | null>(null);
|
||||
const [activeSourceSite, setActiveSourceSite] = useState<string | null>(null);
|
||||
const [activeSourceEndpointType, setActiveSourceEndpointType] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
setShowSourcePicker(false);
|
||||
setSourceSearch('');
|
||||
setSourcePickerSelection([]);
|
||||
setActiveSourceBrand(null);
|
||||
setActiveSourceSite(null);
|
||||
setActiveSourceEndpointType(null);
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
@@ -119,14 +166,136 @@ export default function ManualRoutePanel({
|
||||
return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern));
|
||||
}, [form.modelPattern, modelPatternError, previewModelSamples]);
|
||||
|
||||
const sourceRouteBrandById = useMemo(() => {
|
||||
const next = new Map<number, BrandInfo | null>();
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
next.set(route.id, resolveRouteBrand(route));
|
||||
}
|
||||
return next;
|
||||
}, [exactSourceRouteOptions]);
|
||||
|
||||
const sourceBrandList = useMemo(() => {
|
||||
const grouped = new Map<string, { count: number; brand: BrandInfo }>();
|
||||
let otherCount = 0;
|
||||
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
const brand = sourceRouteBrandById.get(route.id) || null;
|
||||
if (!brand) {
|
||||
otherCount += 1;
|
||||
continue;
|
||||
}
|
||||
const existing = grouped.get(brand.name);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
grouped.set(brand.name, { count: 1, brand });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
list: [...grouped.entries()].sort((a, b) => {
|
||||
if (a[1].count === b[1].count) {
|
||||
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||
}
|
||||
return b[1].count - a[1].count;
|
||||
}) as [string, { count: number; brand: BrandInfo }][],
|
||||
otherCount,
|
||||
};
|
||||
}, [exactSourceRouteOptions, sourceRouteBrandById]);
|
||||
|
||||
const sourceSiteList = useMemo(() => {
|
||||
const grouped = new Map<string, number>();
|
||||
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
const seenSites = new Set<string>();
|
||||
for (const siteName of route.siteNames || []) {
|
||||
const normalizedSite = String(siteName || '').trim();
|
||||
if (!normalizedSite || seenSites.has(normalizedSite)) continue;
|
||||
seenSites.add(normalizedSite);
|
||||
grouped.set(normalizedSite, (grouped.get(normalizedSite) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return [...grouped.entries()].sort((a, b) => {
|
||||
if (a[1] === b[1]) {
|
||||
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||
}
|
||||
return b[1] - a[1];
|
||||
}) as [string, number][];
|
||||
}, [exactSourceRouteOptions]);
|
||||
|
||||
const sourceEndpointTypeList = useMemo(() => {
|
||||
const grouped = new Map<string, number>();
|
||||
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
const endpointTypes = sourceEndpointTypesByRouteId[route.id] || [];
|
||||
for (const endpointType of endpointTypes) {
|
||||
const normalizedType = String(endpointType || '').trim();
|
||||
if (!normalizedType) continue;
|
||||
grouped.set(normalizedType, (grouped.get(normalizedType) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return [...grouped.entries()].sort((a, b) => {
|
||||
if (a[1] === b[1]) {
|
||||
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||
}
|
||||
return b[1] - a[1];
|
||||
}) as [string, number][];
|
||||
}, [exactSourceRouteOptions, sourceEndpointTypesByRouteId]);
|
||||
|
||||
const filteredSourceRoutes = useMemo(() => {
|
||||
let list = [...exactSourceRouteOptions];
|
||||
|
||||
if (activeSourceBrand) {
|
||||
if (activeSourceBrand === '__other__') {
|
||||
list = list.filter((route) => !(sourceRouteBrandById.get(route.id) || null));
|
||||
} else {
|
||||
list = list.filter((route) => (sourceRouteBrandById.get(route.id)?.name || '') === activeSourceBrand);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSourceSite) {
|
||||
list = list.filter((route) => (route.siteNames || []).includes(activeSourceSite));
|
||||
}
|
||||
|
||||
if (activeSourceEndpointType) {
|
||||
list = list.filter((route) => (sourceEndpointTypesByRouteId[route.id] || []).includes(activeSourceEndpointType));
|
||||
}
|
||||
|
||||
const normalizedSearch = sourceSearch.trim().toLowerCase();
|
||||
if (!normalizedSearch) return exactSourceRouteOptions;
|
||||
return exactSourceRouteOptions.filter((route) => {
|
||||
const label = renderRouteOptionLabel(route).toLowerCase();
|
||||
return label.includes(normalizedSearch) || route.modelPattern.toLowerCase().includes(normalizedSearch);
|
||||
if (normalizedSearch) {
|
||||
list = list.filter((route) => {
|
||||
const label = renderRouteOptionLabel(route).toLowerCase();
|
||||
const modelPattern = route.modelPattern.toLowerCase();
|
||||
const brandName = (sourceRouteBrandById.get(route.id)?.name || '').toLowerCase();
|
||||
const siteText = (route.siteNames || []).join(' ').toLowerCase();
|
||||
const endpointTypes = (sourceEndpointTypesByRouteId[route.id] || []).join(' ').toLowerCase();
|
||||
return (
|
||||
label.includes(normalizedSearch)
|
||||
|| modelPattern.includes(normalizedSearch)
|
||||
|| brandName.includes(normalizedSearch)
|
||||
|| siteText.includes(normalizedSearch)
|
||||
|| endpointTypes.includes(normalizedSearch)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return list.sort((a, b) => {
|
||||
if (a.channelCount === b.channelCount) {
|
||||
return renderRouteOptionLabel(a).localeCompare(renderRouteOptionLabel(b), undefined, { sensitivity: 'base' });
|
||||
}
|
||||
return b.channelCount - a.channelCount;
|
||||
});
|
||||
}, [exactSourceRouteOptions, sourceSearch]);
|
||||
}, [
|
||||
activeSourceBrand,
|
||||
activeSourceEndpointType,
|
||||
activeSourceSite,
|
||||
exactSourceRouteOptions,
|
||||
sourceEndpointTypesByRouteId,
|
||||
sourceRouteBrandById,
|
||||
sourceSearch,
|
||||
]);
|
||||
|
||||
const selectedSourceRoutes = useMemo(() => {
|
||||
const routeById = new Map(exactSourceRouteOptions.map((route) => [route.id, route]));
|
||||
@@ -135,18 +304,29 @@ export default function ManualRoutePanel({
|
||||
.filter((route): route is RouteSummaryRow => !!route);
|
||||
}, [exactSourceRouteOptions, form.sourceRouteIds]);
|
||||
|
||||
const sourcePickerSelectionSet = useMemo(
|
||||
() => new Set(sourcePickerSelection),
|
||||
[sourcePickerSelection],
|
||||
);
|
||||
|
||||
const autoBrandIconEnabled = !isRouteIconNoneValue(form.displayIcon);
|
||||
const hasExplicitIconValue = !!normalizeRouteDisplayIconValue(form.displayIcon);
|
||||
|
||||
const openSourcePicker = () => {
|
||||
setSourcePickerSelection([...form.sourceRouteIds]);
|
||||
setSourceSearch('');
|
||||
setActiveSourceBrand(null);
|
||||
setActiveSourceSite(null);
|
||||
setActiveSourceEndpointType(null);
|
||||
setShowSourcePicker(true);
|
||||
};
|
||||
|
||||
const closeSourcePicker = () => {
|
||||
setShowSourcePicker(false);
|
||||
setSourceSearch('');
|
||||
setActiveSourceBrand(null);
|
||||
setActiveSourceSite(null);
|
||||
setActiveSourceEndpointType(null);
|
||||
};
|
||||
|
||||
const confirmSourcePicker = () => {
|
||||
@@ -156,6 +336,9 @@ export default function ManualRoutePanel({
|
||||
}));
|
||||
setShowSourcePicker(false);
|
||||
setSourceSearch('');
|
||||
setActiveSourceBrand(null);
|
||||
setActiveSourceSite(null);
|
||||
setActiveSourceEndpointType(null);
|
||||
};
|
||||
|
||||
const footer = (
|
||||
@@ -584,7 +767,101 @@ export default function ManualRoutePanel({
|
||||
placeholder={tr('搜索来源模型')}
|
||||
/>
|
||||
|
||||
<div style={{ maxHeight: 440, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="route-filter-bar">
|
||||
<div className="route-filter-bar-expanded" style={{ opacity: 1, transform: 'none' }}>
|
||||
<FilterRow label={tr('品牌')}>
|
||||
<FilterChip
|
||||
active={!activeSourceBrand}
|
||||
label={tr('全部')}
|
||||
count={exactSourceRouteOptions.length}
|
||||
icon={<span style={{ fontSize: 10 }}>✦</span>}
|
||||
onClick={() => setActiveSourceBrand(null)}
|
||||
/>
|
||||
{sourceBrandList.list.map(([brandName, { count, brand }]) => (
|
||||
<FilterChip
|
||||
key={brandName}
|
||||
active={activeSourceBrand === brandName}
|
||||
label={brandName}
|
||||
count={count}
|
||||
icon={<BrandGlyph brand={brand} size={12} fallbackText={brandName} />}
|
||||
onClick={() => setActiveSourceBrand(activeSourceBrand === brandName ? null : brandName)}
|
||||
/>
|
||||
))}
|
||||
{sourceBrandList.otherCount > 0 ? (
|
||||
<FilterChip
|
||||
active={activeSourceBrand === '__other__'}
|
||||
label={tr('其他')}
|
||||
count={sourceBrandList.otherCount}
|
||||
icon={<span style={{ fontSize: 10 }}>?</span>}
|
||||
onClick={() => setActiveSourceBrand(activeSourceBrand === '__other__' ? null : '__other__')}
|
||||
/>
|
||||
) : null}
|
||||
</FilterRow>
|
||||
|
||||
{sourceSiteList.length > 0 ? (
|
||||
<FilterRow label={tr('站点')}>
|
||||
<FilterChip
|
||||
active={!activeSourceSite}
|
||||
label={tr('全部')}
|
||||
count={exactSourceRouteOptions.length}
|
||||
icon={<span style={{ fontSize: 10 }}>⚡</span>}
|
||||
onClick={() => setActiveSourceSite(null)}
|
||||
/>
|
||||
{sourceSiteList.map(([siteName, count]) => (
|
||||
<FilterChip
|
||||
key={siteName}
|
||||
active={activeSourceSite === siteName}
|
||||
label={siteName}
|
||||
count={count}
|
||||
icon={(
|
||||
<span
|
||||
style={{
|
||||
fontSize: 8,
|
||||
background: hashColor(siteName),
|
||||
color: 'white',
|
||||
borderRadius: 3,
|
||||
padding: '1px 2px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{siteAvatarLetters(siteName)}
|
||||
</span>
|
||||
)}
|
||||
onClick={() => setActiveSourceSite(activeSourceSite === siteName ? null : siteName)}
|
||||
/>
|
||||
))}
|
||||
</FilterRow>
|
||||
) : null}
|
||||
|
||||
<FilterRow label={tr('能力')}>
|
||||
<FilterChip
|
||||
active={!activeSourceEndpointType}
|
||||
label={tr('全部')}
|
||||
count={exactSourceRouteOptions.length}
|
||||
icon={<span style={{ fontSize: 10 }}>⚙</span>}
|
||||
onClick={() => setActiveSourceEndpointType(null)}
|
||||
/>
|
||||
{sourceEndpointTypeList.map(([endpointType, count]) => {
|
||||
const iconModel = resolveEndpointTypeIconModel(endpointType);
|
||||
return (
|
||||
<FilterChip
|
||||
key={endpointType}
|
||||
active={activeSourceEndpointType === endpointType}
|
||||
label={endpointType}
|
||||
count={count}
|
||||
icon={iconModel ? <InlineBrandIcon model={iconModel} size={12} /> : <span style={{ fontSize: 10 }}>⚙</span>}
|
||||
onClick={() => setActiveSourceEndpointType(activeSourceEndpointType === endpointType ? null : endpointType)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{sourceEndpointTypeList.length === 0 ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{tr('暂无接口能力数据')}</span>
|
||||
) : null}
|
||||
</FilterRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: 520, overflowY: 'auto', paddingRight: 4 }}>
|
||||
{filteredSourceRoutes.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '12px 0', textAlign: 'center' }}>
|
||||
{exactSourceRouteOptions.length === 0
|
||||
@@ -592,50 +869,131 @@ export default function ManualRoutePanel({
|
||||
: tr('没有匹配的来源模型。')}
|
||||
</div>
|
||||
) : (
|
||||
filteredSourceRoutes.map((route) => {
|
||||
const selected = sourcePickerSelection.includes(route.id);
|
||||
const label = renderRouteOptionLabel(route);
|
||||
return (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => setSourcePickerSelection((current) => toggleSourceRouteId(current, route.id))}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
background: selected
|
||||
? 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))'
|
||||
: 'var(--color-bg-card)',
|
||||
boxShadow: selected
|
||||
? '0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
readOnly
|
||||
style={{ marginTop: 2, cursor: 'pointer', pointerEvents: 'none' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{label}</span>
|
||||
{label !== route.modelPattern && (
|
||||
<code style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
|
||||
<div
|
||||
className="source-route-picker-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
gap: 10,
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
{filteredSourceRoutes.map((route) => {
|
||||
const selected = sourcePickerSelectionSet.has(route.id);
|
||||
const label = renderRouteOptionLabel(route);
|
||||
const brand = sourceRouteBrandById.get(route.id) || null;
|
||||
const endpointTypes = (sourceEndpointTypesByRouteId[route.id] || []).slice(0, 3);
|
||||
const siteNames = Array.from(new Set((route.siteNames || []).filter((siteName) => String(siteName || '').trim())));
|
||||
|
||||
return (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => setSourcePickerSelection((current) => toggleSourceRouteId(current, route.id))}
|
||||
className="btn btn-ghost source-route-picker-card"
|
||||
style={{
|
||||
minHeight: 156,
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
textAlign: 'left',
|
||||
padding: 0,
|
||||
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
background: selected
|
||||
? 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))'
|
||||
: 'var(--color-bg-card)',
|
||||
boxShadow: selected
|
||||
? '0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, width: '100%', padding: '14px 15px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
readOnly
|
||||
style={{ marginTop: 2, cursor: 'pointer', pointerEvents: 'none', flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 22, height: 22, flexShrink: 0 }}>
|
||||
{brand ? <BrandGlyph brand={brand} size={18} fallbackText={label} /> : <InlineBrandIcon model={route.modelPattern} size={18} />}
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, minWidth: 0, flex: 1 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{label !== route.modelPattern ? (
|
||||
<code
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{route.modelPattern}
|
||||
</code>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={selected ? 'badge badge-info' : 'badge badge-muted'} style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{selected ? tr('已选中') : tr('可选择')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{siteNames.length} {tr('站点')}
|
||||
</span>
|
||||
{endpointTypes.map((endpointType) => (
|
||||
<span key={`${route.id}-${endpointType}`} className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{endpointType}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{siteNames.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{siteNames.slice(0, 3).map((siteName) => (
|
||||
<span
|
||||
key={`${route.id}-${siteName}`}
|
||||
className="badge badge-muted"
|
||||
style={{ fontSize: 10 }}
|
||||
>
|
||||
{siteName}
|
||||
</span>
|
||||
))}
|
||||
{siteNames.length > 3 ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
+{siteNames.length - 3}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
{tr('当前未绑定站点信息')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={selected ? 'badge badge-info' : 'badge badge-muted'} style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{selected ? tr('已选中') : tr('可选择')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,6 +116,7 @@ function RouteCardInner({
|
||||
const routeIcon = resolveRouteIcon(route);
|
||||
const exactRoute = isRouteExactModel(route);
|
||||
const explicitGroupRoute = isExplicitGroupRoute(route);
|
||||
const explicitGroupSourceCount = Array.isArray(route.sourceRouteIds) ? route.sourceRouteIds.length : 0;
|
||||
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
|
||||
const channelManagementDisabled = explicitGroupRoute;
|
||||
const title = resolveRouteTitle(route);
|
||||
@@ -206,9 +207,20 @@ function RouteCardInner({
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
|
||||
<>
|
||||
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{explicitGroupSourceCount} {tr('来源模型')}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{readOnlyRoute ? (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
@@ -269,9 +281,20 @@ function RouteCardInner({
|
||||
{route.enabled ? tr('启用') : tr('禁用')}
|
||||
</button>
|
||||
)}
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
|
||||
<>
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{explicitGroupSourceCount} {tr('来源模型')}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
)}
|
||||
{readOnlyRoute && (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10 }}>
|
||||
{tr('0 通道')}
|
||||
@@ -445,35 +468,29 @@ function RouteCardInner({
|
||||
|
||||
<AnimatedCollapseSection open={isGroupExpanded}>
|
||||
{explicitGroupRoute ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{group.channels.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
padding: '8px 12px',
|
||||
borderLeft: '2px solid var(--color-primary)',
|
||||
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
|
||||
background: 'rgba(79,70,229,0.02)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||
{channel.account?.username || `account-${channel.accountId}`}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{channel.site?.name || 'unknown'}
|
||||
</span>
|
||||
{channel.sourceModel ? (
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{channel.sourceModel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
||||
{group.channels.map((channel) => {
|
||||
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
|
||||
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
|
||||
return (
|
||||
<SortableChannelRow
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
decisionCandidate={decisionMap.get(channel.id)}
|
||||
isExactRoute={exactRoute}
|
||||
loadingDecision={loadingDecision}
|
||||
isSavingPriority={!!savingPriority}
|
||||
readOnly
|
||||
tokenOptions={tokenOptions}
|
||||
activeTokenId={activeTokenId}
|
||||
isUpdatingToken={!!updatingChannel[channel.id]}
|
||||
onTokenDraftChange={onTokenDraftChange}
|
||||
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
||||
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
||||
{group.channels.map((channel) => {
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
|
||||
key={groupRoute.id}
|
||||
active={activeGroupFilter === groupRoute.id}
|
||||
label={groupRoute.title}
|
||||
count={groupRoute.channelCount}
|
||||
count={groupRoute.sourceRouteCount > 0 ? groupRoute.sourceRouteCount : groupRoute.channelCount}
|
||||
icon={
|
||||
groupRoute.icon.kind === 'brand' ? (
|
||||
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
|
||||
|
||||
@@ -16,6 +16,7 @@ export function SortableChannelRow({
|
||||
isExactRoute,
|
||||
loadingDecision,
|
||||
isSavingPriority,
|
||||
readOnly = false,
|
||||
tokenOptions,
|
||||
activeTokenId,
|
||||
isUpdatingToken,
|
||||
@@ -33,7 +34,7 @@ export function SortableChannelRow({
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: channel.id,
|
||||
disabled: isSavingPriority,
|
||||
disabled: isSavingPriority || readOnly,
|
||||
});
|
||||
|
||||
const rowStyle: CSSProperties = {
|
||||
@@ -42,7 +43,7 @@ export function SortableChannelRow({
|
||||
opacity: isDragging ? 0.72 : 1,
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) auto auto',
|
||||
gridTemplateColumns: readOnly ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
@@ -63,7 +64,7 @@ export function SortableChannelRow({
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
disabled={isSavingPriority}
|
||||
disabled={isSavingPriority || readOnly}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
width: 22,
|
||||
@@ -72,9 +73,10 @@ export function SortableChannelRow({
|
||||
padding: 0,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: isSavingPriority ? 'not-allowed' : 'grab',
|
||||
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
|
||||
opacity: readOnly ? 0.65 : 1,
|
||||
}}
|
||||
data-tooltip="拖拽调整优先级"
|
||||
data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'}
|
||||
aria-label="拖拽调整优先级"
|
||||
>
|
||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
|
||||
@@ -197,46 +199,50 @@ export function SortableChannelRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ minWidth: 220, flex: 1 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(activeTokenId || 0)}
|
||||
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
|
||||
disabled={isUpdatingToken}
|
||||
options={[
|
||||
{
|
||||
value: '0',
|
||||
label: '跟随账号默认',
|
||||
description: tokenBinding.followOptionDescription,
|
||||
},
|
||||
...tokenOptions.map((token) => ({
|
||||
value: String(token.id),
|
||||
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
|
||||
description: buildFixedTokenOptionDescription(token),
|
||||
})),
|
||||
]}
|
||||
placeholder="选择令牌绑定方式"
|
||||
/>
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
||||
{tokenBinding.helperText}
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ minWidth: 220, flex: 1 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(activeTokenId || 0)}
|
||||
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
|
||||
disabled={isUpdatingToken}
|
||||
options={[
|
||||
{
|
||||
value: '0',
|
||||
label: '跟随账号默认',
|
||||
description: tokenBinding.followOptionDescription,
|
||||
},
|
||||
...tokenOptions.map((token) => ({
|
||||
value: String(token.id),
|
||||
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
|
||||
description: buildFixedTokenOptionDescription(token),
|
||||
})),
|
||||
]}
|
||||
placeholder="选择令牌绑定方式"
|
||||
/>
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
||||
{tokenBinding.helperText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSaveToken}
|
||||
disabled={isUpdatingToken}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSaveToken}
|
||||
disabled={isUpdatingToken}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
<button
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ export type SortableChannelRowProps = {
|
||||
isExactRoute: boolean;
|
||||
loadingDecision: boolean;
|
||||
isSavingPriority: boolean;
|
||||
readOnly?: boolean;
|
||||
tokenOptions: RouteTokenOption[];
|
||||
activeTokenId: number;
|
||||
isUpdatingToken: boolean;
|
||||
@@ -165,4 +166,5 @@ export type GroupRouteItem = {
|
||||
brand: BrandInfo | null;
|
||||
modelPattern: string;
|
||||
channelCount: number;
|
||||
sourceRouteCount: number;
|
||||
};
|
||||
|
||||
@@ -48,6 +48,23 @@ function findButtonByText(root: ReactTestInstance, text: string): ReactTestInsta
|
||||
));
|
||||
}
|
||||
|
||||
function findButtonByClassAndText(root: ReactTestInstance, className: string, text: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& String(node.props.className || '').includes(className)
|
||||
&& collectText(node).includes(text)
|
||||
));
|
||||
}
|
||||
|
||||
function findButtonByAriaLabel(root: ReactTestInstance, label: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props['aria-label'] === 'string'
|
||||
&& node.props['aria-label'] === label
|
||||
));
|
||||
}
|
||||
|
||||
function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'input'
|
||||
@@ -865,6 +882,195 @@ describe('TokenRoutes grouped source models', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the source picker like the route page with brand, site, ability filters and a card grid', async () => {
|
||||
getBrandMock.mockImplementation((modelName: string) => {
|
||||
const model = String(modelName);
|
||||
if (model.includes('gpt')) {
|
||||
return { name: 'OpenAI', icon: 'openai', color: 'linear-gradient(135deg,#111,#555)' };
|
||||
}
|
||||
if (model.includes('claude')) {
|
||||
return { name: 'Anthropic', icon: 'anthropic', color: 'linear-gradient(135deg,#d97706,#f59e0b)' };
|
||||
}
|
||||
if (model.includes('gemini')) {
|
||||
return { name: 'Gemini', icon: 'gemini', color: 'linear-gradient(135deg,#2563eb,#7c3aed)' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 11, modelPattern: 'gpt-5.4', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 3, enabledChannelCount: 3, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 2, enabledChannelCount: 2, siteNames: ['Alpha'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 13, modelPattern: 'gemini-2.5-pro', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 1, enabledChannelCount: 1, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||
models: {},
|
||||
endpointTypesByModel: {
|
||||
'gpt-5.4': ['openai'],
|
||||
'claude-sonnet-4-5': ['anthropic'],
|
||||
'gemini-2.5-pro': ['gemini'],
|
||||
},
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(root.root, '新建群组').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(root.root, '选择来源模型').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const pickerGrid = root.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('source-route-picker-grid')
|
||||
));
|
||||
expect(String(pickerGrid.props.style?.display || '')).toBe('grid');
|
||||
expect(String(pickerGrid.props.style?.gridTemplateColumns || '')).toContain('repeat(');
|
||||
|
||||
expect(findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI')).toBeTruthy();
|
||||
expect(findButtonByClassAndText(root.root, 'filter-chip', 'Wong')).toBeTruthy();
|
||||
expect(findButtonByClassAndText(root.root, 'filter-chip', 'gemini')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByClassAndText(root.root, 'filter-chip', 'Wong').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(collectText(pickerGrid)).toContain('gpt-5.4');
|
||||
expect(collectText(pickerGrid)).toContain('gemini-2.5-pro');
|
||||
expect(collectText(pickerGrid)).not.toContain('claude-sonnet-4-5');
|
||||
|
||||
await act(async () => {
|
||||
findButtonByClassAndText(root.root, 'filter-chip', 'Wong').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(collectText(pickerGrid)).toContain('OpenAI');
|
||||
expect(collectText(pickerGrid)).toContain('gpt-5.4');
|
||||
expect(collectText(pickerGrid)).not.toContain('claude-sonnet-4-5');
|
||||
expect(collectText(pickerGrid)).not.toContain('gemini-2.5-pro');
|
||||
|
||||
await act(async () => {
|
||||
findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByClassAndText(root.root, 'filter-chip', 'anthropic').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(collectText(pickerGrid)).toContain('Anthropic');
|
||||
expect(collectText(pickerGrid)).toContain('claude-sonnet-4-5');
|
||||
expect(collectText(pickerGrid)).not.toContain('gpt-5.4');
|
||||
expect(collectText(pickerGrid)).not.toContain('gemini-2.5-pro');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows explicit-group source counts instead of aggregated channel counts in the route list and filter chips', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 11, modelPattern: 'deepseek-chat', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 40, enabledChannelCount: 40, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 12, modelPattern: 'deepseek-reasoner', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 55, enabledChannelCount: 55, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 21, modelPattern: 'deepseekv1', displayName: 'deepseekv1',
|
||||
displayIcon: '', modelMapping: null, enabled: true,
|
||||
routeMode: 'explicit_group', sourceRouteIds: [11, 12],
|
||||
channelCount: 95, enabledChannelCount: 95, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getRouteChannels.mockResolvedValue([
|
||||
{
|
||||
id: 101, accountId: 1, tokenId: 1, sourceModel: 'deepseek-chat',
|
||||
priority: 0, weight: 1, enabled: true, manualOverride: false,
|
||||
successCount: 0, failCount: 0,
|
||||
account: { username: 'user-a' }, site: { name: 'Wong' }, token: null,
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const filterSummary = root.root.find((node) =>
|
||||
node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
|
||||
);
|
||||
await act(async () => {
|
||||
filterSummary.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const groupChip = findButtonByClassAndText(root.root, 'filter-chip', 'deepseekv1');
|
||||
expect(collectText(groupChip)).toContain('2');
|
||||
expect(collectText(groupChip)).not.toContain('95');
|
||||
|
||||
const routeCard = root.root.find((node) =>
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||
&& collectText(node).includes('deepseekv1'),
|
||||
);
|
||||
expect(collectText(routeCard).replace(/\s+/g, '')).toContain('2来源模型');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a dedicated source picker modal and submits explicit-group sourceRouteIds', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
@@ -1169,4 +1375,80 @@ describe('TokenRoutes grouped source models', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses the standard channel row presentation for explicit-group details in read-only mode', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 11, modelPattern: 'claude-haiku-4-5-20251001', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 6, enabledChannelCount: 6, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 21, modelPattern: 'claude-haiku-proxy', displayName: 'claude-haiku-proxy',
|
||||
displayIcon: '', modelMapping: null, enabled: true,
|
||||
routeMode: 'explicit_group', sourceRouteIds: [11],
|
||||
channelCount: 6, enabledChannelCount: 6, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getRouteChannels.mockResolvedValue([
|
||||
{
|
||||
id: 101,
|
||||
routeId: 11,
|
||||
accountId: 301,
|
||||
tokenId: 401,
|
||||
sourceModel: 'claude-haiku-4-5-20251001',
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
successCount: 6,
|
||||
failCount: 1,
|
||||
account: { username: 'linuxdo_131936' },
|
||||
site: { name: 'Wong' },
|
||||
token: { id: 401, name: 'token-a', accountId: 301, enabled: true, isDefault: true },
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandBtn = root.root.find((node) =>
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||
&& collectText(node).includes('claude-haiku-proxy'),
|
||||
);
|
||||
await act(async () => {
|
||||
expandBtn.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(root.root, 'claude-haiku-4-5-20251001').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandedText = collectText(root.root);
|
||||
expect(expandedText).toContain('P0');
|
||||
expect(expandedText).toContain('当前生效:token-a');
|
||||
expect(expandedText).toContain('选中概率');
|
||||
expect(findButtonByAriaLabel(root.root, '拖拽调整优先级').props.disabled).toBe(true);
|
||||
expect(expandedText).not.toContain('保存');
|
||||
expect(expandedText).not.toContain('移除');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user