[codex] add coding plan site initialization flow (#351)

* add coding plan site initialization flow

* fix ci for coding plan site init flow

* address coding plan review comments
This commit is contained in:
Cita
2026-03-31 21:55:25 +08:00
committed by GitHub
parent e74e11375c
commit 0de9c5e3f1
20 changed files with 1272 additions and 67 deletions
+63
View File
@@ -28,6 +28,7 @@ import { tr } from '../i18n.js';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { SITE_DOCS_URL } from '../docsLink.js';
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
type ConnectionsSegment = 'session' | 'apikey' | 'tokens';
@@ -88,6 +89,8 @@ export default function Accounts() {
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
const [loginForm, setLoginForm] = useState(createLoginForm);
const [tokenForm, setTokenForm] = useState(() => createTokenForm('session'));
const [createIntentPresetId, setCreateIntentPresetId] = useState<string | null>(null);
const [applyCreatePresetModels, setApplyCreatePresetModels] = useState(false);
const [verifyResult, setVerifyResult] = useState<any>(null);
const [verifying, setVerifying] = useState(false);
const [saving, setSaving] = useState(false);
@@ -187,11 +190,17 @@ export default function Accounts() {
);
const isSub2ApiSelected = (selectedTokenSite?.platform || '').toLowerCase() === 'sub2api';
const activeAddCredentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
const createIntentPreset = useMemo(
() => getSiteInitializationPreset(createIntentPresetId),
[createIntentPresetId],
);
const resetAddForms = (credentialMode: 'session' | 'apikey' = activeAddCredentialMode) => {
setAddMode('token');
setLoginForm(createLoginForm());
setTokenForm(createTokenForm(credentialMode));
setCreateIntentPresetId(null);
setApplyCreatePresetModels(false);
setVerifyResult(null);
};
@@ -254,18 +263,23 @@ export default function Accounts() {
if (!shouldOpenCreate || !requestedSiteId) return;
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
const initializationPreset = getSiteInitializationPreset(params.get('initPreset'));
setShowAdd(true);
setAddMode('token');
setVerifyResult(null);
setCreateIntentPresetId(initializationPreset?.id || null);
setApplyCreatePresetModels(Boolean(initializationPreset?.recommendedModels?.length));
setLoginForm(createLoginForm());
setTokenForm({
...createTokenForm(credentialMode),
siteId: requestedSiteId,
skipModelFetch: credentialMode === 'apikey' && initializationPreset?.recommendedSkipModelFetch === true,
});
params.delete('create');
params.delete('siteId');
params.delete('from');
params.delete('initPreset');
const nextSearch = params.toString();
navigate(
{
@@ -343,6 +357,7 @@ export default function Accounts() {
return;
}
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
const initializationPreset = createIntentPreset;
setSaving(true);
try {
const result = await api.addAccount({
@@ -359,6 +374,24 @@ export default function Accounts() {
credentialMode,
skipModelFetch: tokenForm.skipModelFetch,
});
let seededRecommendedModels = false;
const recommendedModels = initializationPreset?.recommendedModels || [];
const createdAccountId = Number(result?.id) || 0;
const shouldSeedRecommendedModels = (
credentialMode === 'apikey'
&& tokenForm.skipModelFetch
&& applyCreatePresetModels
&& recommendedModels.length > 0
&& createdAccountId > 0
);
if (shouldSeedRecommendedModels) {
try {
await api.addAccountAvailableModels(createdAccountId, recommendedModels);
seededRecommendedModels = true;
} catch (seedErr: any) {
toast.error(seedErr?.message || '连接已添加,但推荐模型补录失败');
}
}
closeAddPanel();
if (result.queued) {
toast.info(result.message || '账号已添加,后台正在同步初始化信息。');
@@ -371,6 +404,9 @@ export default function Accounts() {
const extra = parts.length ? `(${parts.join(',')})` : '';
toast.success(`账号已添加${extra}`);
}
if (seededRecommendedModels) {
toast.success(`已补入 ${recommendedModels.length} 个推荐模型并重建路由`);
}
load();
} catch (e: any) {
toast.error(e.message || '添加失败');
@@ -1330,12 +1366,39 @@ export default function Accounts() {
<div className="info-tip">
API Key 连接只用于代理转发,不会自动派生账号令牌。系统会按站点平台能力自动引导到 Session 或 API Key 创建流程。
</div>
{createIntentPreset && (
<div className="alert alert-info animate-scale-in">
<div className="alert-title">{createIntentPreset.label}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4, lineHeight: 1.8 }}>
<div>{createIntentPreset.description}</div>
<div>推荐模型:{createIntentPreset.recommendedModels.join(' / ')}</div>
{createIntentPreset.recommendedSkipModelFetch && (
<div>建议直接跳过模型验证,先保存 Base URL + Key,再补入推荐模型完成初始化。</div>
)}
</div>
{createIntentPreset.recommendedModels.length > 0 && (
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, cursor: 'pointer', marginTop: 8 }}>
<input
type="checkbox"
checked={applyCreatePresetModels}
onChange={(e) => setApplyCreatePresetModels(e.target.checked)}
style={{ width: 14, height: 14 }}
/>
<span>添加后自动补入推荐模型并重建路由</span>
</label>
)}
</div>
)}
<ModernSelect
value={String(tokenForm.siteId || 0)}
onChange={(nextValue) => {
const nextSiteId = Number.parseInt(nextValue, 10) || 0;
setTokenForm((f) => ({ ...f, siteId: nextSiteId, credentialMode: 'apikey' }));
setVerifyResult(null);
if (createIntentPresetId && nextSiteId !== tokenForm.siteId) {
setCreateIntentPresetId(null);
setApplyCreatePresetModels(false);
}
}}
options={siteSelectOptions}
placeholder="选择站点"
+109 -26
View File
@@ -27,6 +27,10 @@ import {
type SiteEditorState,
type SiteForm,
} from './helpers/sitesEditor.js';
import {
getSiteInitializationPreset,
listSiteInitializationPresets,
} from '../../shared/siteInitializationPresets.js';
type SiteSubscriptionSummary = {
activeCount: number;
@@ -176,18 +180,18 @@ const platformColors: Record<string, string> = {
const SITE_PLATFORM_OPTIONS = [
{ value: '', label: '平台类型(可自动检测)' },
{ value: 'new-api', label: 'new-api' },
{ value: 'one-api', label: 'one-api' },
{ value: 'anyrouter', label: 'anyrouter' },
{ value: 'veloera', label: 'veloera' },
{ value: 'one-hub', label: 'one-hub' },
{ value: 'done-hub', label: 'done-hub' },
{ value: 'sub2api', label: 'sub2api' },
{ value: 'openai', label: 'openai' },
{ value: 'codex', label: 'codex' },
{ value: 'claude', label: 'claude' },
{ value: 'gemini', label: 'gemini' },
{ value: 'cliproxyapi', label: 'cliproxyapi' },
{ value: 'new-api', label: 'new-api', description: '聚合面板,适合多渠道统一管理' },
{ value: 'one-api', label: 'one-api', description: '经典聚合面板,常见于通用 OpenAI 中转' },
{ value: 'anyrouter', label: 'anyrouter', description: 'any大善人今天还能用吗' },
{ value: 'veloera', label: 'veloera', description: 'Veloera 兼容站点,常见于聚合代理场景' },
{ value: 'one-hub', label: 'one-hub', description: '聚合面板,偏向多账号统一管理' },
{ value: 'done-hub', label: 'done-hub', description: '聚合面板,适合统一转发与管理' },
{ value: 'sub2api', label: 'sub2api', description: '订阅式中转面板,可同步套餐与余额信息' },
{ value: 'openai', label: 'openai', description: '通用 OpenAI 兼容接口,手填 Base URL 即可' },
{ value: 'codex', label: 'codex', description: 'Codex OAuth / Session 优先入口' },
{ value: 'claude', label: 'claude', description: '通用 Claude / Anthropic 兼容接口' },
{ value: 'gemini', label: 'gemini', description: '通用 Gemini / Google AI 兼容接口' },
{ value: 'cliproxyapi', label: 'cliproxyapi', description: 'CPA接入口' },
];
export default function Sites() {
@@ -211,7 +215,9 @@ export default function Sites() {
id: number;
name: string;
platform?: string | null;
initializationPresetId?: string | null;
} | null>(null);
const [selectedInitializationPresetId, setSelectedInitializationPresetId] = useState<string | null>(null);
const isMobile = useIsMobile();
const [showMobileTools, setShowMobileTools] = useState(false);
const [batchActionLoading, setBatchActionLoading] = useState(false);
@@ -232,6 +238,11 @@ export default function Sites() {
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [disabledModelSearch, setDisabledModelSearch] = useState('');
const initializationPresetOptions = useMemo(() => listSiteInitializationPresets(), []);
const selectedInitializationPreset = useMemo(
() => getSiteInitializationPreset(selectedInitializationPresetId),
[selectedInitializationPresetId],
);
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
@@ -298,14 +309,28 @@ export default function Sites() {
const platformOptions = useMemo(() => {
const current = form.platform.trim();
if (!current || SITE_PLATFORM_OPTIONS.some((option) => option.value === current)) {
return SITE_PLATFORM_OPTIONS;
}
const genericOptions = (!current || SITE_PLATFORM_OPTIONS.some((option) => option.value === current))
? SITE_PLATFORM_OPTIONS
: [
...SITE_PLATFORM_OPTIONS,
{ value: current, label: `${current}(当前值)` },
];
const presetOptions = initializationPresetOptions.map((preset) => ({
value: `preset:${preset.id}`,
label: preset.label,
description: [
preset.defaultUrl ? '自动填充官方地址' : '',
preset.recommendedSkipModelFetch ? 'API Key 优先初始化' : '',
].filter(Boolean).join(' · '),
}));
return [
...SITE_PLATFORM_OPTIONS,
{ value: current, label: `${current}(当前值)` },
genericOptions[0]!,
...presetOptions,
...genericOptions.slice(1),
];
}, [form.platform]);
}, [form.platform, initializationPresetOptions]);
const activeInitializationPreset = selectedInitializationPreset;
const platformSelectValue = selectedInitializationPreset ? `preset:${selectedInitializationPreset.id}` : form.platform;
useEffect(() => {
return () => {
@@ -339,6 +364,7 @@ export default function Sites() {
const closeEditor = () => {
setEditor(null);
setForm(emptySiteForm());
setSelectedInitializationPresetId(null);
};
const scrollToEditorTop = () => {
@@ -355,12 +381,14 @@ export default function Sites() {
}
setEditor({ mode: 'add' });
setForm(emptySiteForm());
setSelectedInitializationPresetId(null);
scrollToEditorTop();
};
const openEdit = (site: SiteRow) => {
setEditor({ mode: 'edit', editingSiteId: site.id });
setForm(siteFormFromSite(site));
setSelectedInitializationPresetId(null);
scrollToEditorTop();
// Load disabled models and discovered models independently so a best-effort
// availability fetch cannot wipe the existing disabled-model state.
@@ -448,6 +476,7 @@ export default function Sites() {
url: form.url.trim(),
externalCheckinUrl: form.externalCheckinUrl.trim(),
platform: form.platform.trim(),
initializationPresetId: selectedInitializationPresetId,
proxyUrl: form.proxyUrl.trim(),
useSystemProxy: !!form.useSystemProxy,
customHeaders: serializedCustomHeaders.customHeaders,
@@ -466,14 +495,18 @@ export default function Sites() {
toast.success(`站点 "${payload.name}" 已添加`);
const createdSiteId = Number(created?.id) || 0;
if (createdSiteId > 0) {
// 所有平台都弹出选择Modal,让用户决定下一步
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
? created.platform.trim()
: payload.platform;
const returnedPreset = getSiteInitializationPreset(created?.initializationPresetId);
const fallbackPreset = selectedInitializationPreset && selectedInitializationPreset.platform === createdPlatform
? selectedInitializationPreset
: null;
setCreatedSiteForChoice({
id: createdSiteId,
name: payload.name,
platform: createdPlatform,
initializationPresetId: returnedPreset?.id || fallbackPreset?.id || null,
});
}
} else {
@@ -526,6 +559,9 @@ export default function Sites() {
create: '1',
siteId: String(siteId),
});
if (createdSiteForChoice.initializationPresetId) {
params.set('initPreset', createdSiteForChoice.initializationPresetId);
}
if (choice === 'session') {
// codex平台使用OAuth流程
@@ -557,8 +593,19 @@ export default function Sites() {
try {
const result = await api.detectSite(form.url.trim());
if (result?.platform) {
const detectedPreset = getSiteInitializationPreset(result?.initializationPresetId);
setForm((prev) => ({ ...prev, platform: result.platform }));
toast.success(`检测到平台: ${result.platform}`);
setSelectedInitializationPresetId((current) => {
if (detectedPreset) return detectedPreset.id;
const activePreset = getSiteInitializationPreset(current);
if (activePreset && activePreset.platform !== result.platform) return null;
return current;
});
toast.success(
detectedPreset
? `检测到平台: ${result.platform}(${detectedPreset.label})`
: `检测到平台: ${result.platform}`,
);
} else {
toast.error(result?.error || '无法识别平台类型');
}
@@ -832,7 +879,11 @@ export default function Sites() {
{createdSiteForChoice && (
<SiteCreatedModal
siteName={createdSiteForChoice.name}
platform={createdSiteForChoice.platform}
initializationPresetId={createdSiteForChoice.initializationPresetId}
initialSegment={
getSiteInitializationPreset(createdSiteForChoice.initializationPresetId)?.initialSegment
|| resolveInitialConnectionSegment(createdSiteForChoice.platform)
}
onChoice={handleSiteCreatedChoice}
onClose={() => {
setCreatedSiteForChoice(null);
@@ -904,15 +955,35 @@ export default function Sites() {
</div>
<div
style={{
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-primary) 28%, var(--color-border))' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-sm)',
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
transition: 'all 0.2s',
background: 'var(--color-bg)',
boxShadow: form.platform.trim() ? '0 0 0 2px color-mix(in srgb, var(--color-primary) 10%, transparent)' : 'none',
transition: 'border-color 0.2s ease, box-shadow 0.2s ease',
}}
>
<ModernSelect
value={form.platform}
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))}
value={platformSelectValue}
onChange={(value) => {
if (value.startsWith('preset:')) {
const preset = getSiteInitializationPreset(value.slice('preset:'.length));
if (!preset) return;
setSelectedInitializationPresetId(preset.id);
setForm((prev) => {
const currentUrl = prev.url.trim();
const shouldFillDefaultUrl = !currentUrl
|| (activeInitializationPreset?.defaultUrl && currentUrl === activeInitializationPreset.defaultUrl);
return {
...prev,
platform: preset.platform,
url: shouldFillDefaultUrl && preset.defaultUrl ? preset.defaultUrl : prev.url,
};
});
return;
}
setForm((prev) => ({ ...prev, platform: value }));
setSelectedInitializationPresetId(null);
}}
options={platformOptions}
placeholder="平台类型(可自动检测)"
/>
@@ -924,6 +995,18 @@ export default function Sites() {
style={formInputStyle}
/>
</ResponsiveFormGrid>
{activeInitializationPreset && (
<div className="alert alert-info animate-scale-in">
<div className="alert-title">已应用官方预设 · {activeInitializationPreset.label}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
<div>{activeInitializationPreset.description}</div>
{form.url.trim() === activeInitializationPreset.defaultUrl && (
<div>当前已自动填入官方地址;如需走自建网关,也可以直接改 URL。</div>
)}
<div>推荐模型:{activeInitializationPreset.recommendedModels.join(' / ')}</div>
</div>
</div>
)}
<div
style={{
display: 'flex',
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
addAccount: vi.fn(),
addAccountAvailableModels: 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('Accounts CodingPlan initialization', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getAccounts.mockResolvedValue([]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Aliyun CodingPlan', url: 'https://coding.dashscope.aliyuncs.com/v1', platform: 'openai', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([]);
apiMock.addAccount.mockResolvedValue({
id: 18,
siteId: 10,
tokenType: 'apikey',
queued: false,
});
apiMock.addAccountAvailableModels.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('preloads CodingPlan guidance, defaults skip-model-fetch, and seeds recommended models after add', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts?segment=apikey&create=1&siteId=10&initPreset=codingplan-openai']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('添加 API Key 连接');
expect(rendered).toContain('阿里云 CodingPlan / OpenAI');
expect(rendered).toContain('qwen3-coder-plus');
const checkedBoxes = root.root.findAll((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& node.props.checked === true
));
expect(checkedBoxes.length).toBeGreaterThanOrEqual(2);
const tokenInput = root.root.find((node) => (
node.type === 'textarea'
&& node.props.placeholder === '粘贴 API Key'
));
const addButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('添加连接')
));
await act(async () => {
tokenInput.props.onChange({ target: { value: 'sk-codingplan-demo' } });
});
await act(async () => {
await addButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.addAccount).toHaveBeenCalledWith(expect.objectContaining({
siteId: 10,
accessToken: 'sk-codingplan-demo',
credentialMode: 'apikey',
skipModelFetch: true,
}));
expect(apiMock.addAccountAvailableModels).toHaveBeenCalledWith(
18,
expect.arrayContaining(['qwen3-coder-plus', 'qwen3.5-plus', 'glm-5']),
);
} finally {
root?.unmount();
}
});
});
@@ -10,6 +10,7 @@ describe('defaultConnectionSegment', () => {
expect(resolveInitialConnectionSegment('one-hub')).toBe('session');
expect(resolveInitialConnectionSegment('done-hub')).toBe('session');
expect(resolveInitialConnectionSegment('sub2api')).toBe('session');
expect(resolveInitialConnectionSegment('codex')).toBe('session');
});
it('maps API-key-only platforms to the apikey segment', () => {
@@ -8,6 +8,7 @@ const SESSION_FIRST_PLATFORMS = new Set([
'one-hub',
'done-hub',
'sub2api',
'codex',
]);
const API_KEY_FIRST_PLATFORMS = new Set([
+1
View File
@@ -23,6 +23,7 @@ export type SiteSavePayload = {
url: string;
externalCheckinUrl: string;
platform: string;
initializationPresetId?: string | null;
proxyUrl: string;
useSystemProxy: boolean;
customHeaders: string;
+330 -4
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import ModernSelect from '../components/ModernSelect.js';
import { ToastProvider } from '../components/Toast.js';
@@ -38,11 +38,14 @@ function LocationProbe() {
return <div>{`${location.pathname}${location.search}`}</div>;
}
async function createSiteAndClickModalChoice(createdSite: { id: number; name: string; platform?: string | null }, choice: 'session' | 'apikey' | 'later') {
async function createSiteAndClickModalChoice(
createdSite: { id: number; name: string; platform?: string | null; initializationPresetId?: string | null },
choice: 'session' | 'apikey' | 'later',
) {
apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue(createdSite);
let root!: WebTestRenderer;
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
@@ -148,6 +151,20 @@ describe('Sites create redirect', () => {
expect(rendered).toContain('siteId=22');
});
it('passes CodingPlan initialization preset into the API key flow', async () => {
const rendered = await createSiteAndClickModalChoice({
id: 25,
name: 'Aliyun CodingPlan',
platform: 'openai',
initializationPresetId: 'codingplan-openai',
}, 'apikey');
expect(rendered).toContain('/accounts?');
expect(rendered).toContain('segment=apikey');
expect(rendered).toContain('siteId=25');
expect(rendered).toContain('initPreset=codingplan-openai');
});
it('shows modal after creating a codex site and allows choosing later', async () => {
const rendered = await createSiteAndClickModalChoice({ id: 23, name: 'Demo Site', platform: 'codex' }, 'later');
@@ -165,11 +182,320 @@ describe('Sites create redirect', () => {
expect(rendered).toContain('siteId=24');
});
it('lists vendor-specific site types directly in the platform selector', async () => {
apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue({ id: 30, name: 'Demo Site', platform: 'openai' });
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/sites']}>
<Routes>
<Route path="/sites" element={<Sites />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const addButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className?.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
addButton.props.onClick();
});
await flushMicrotasks();
const selects = root.root.findAllByType(ModernSelect);
const platformSelect = selects.at(-1);
expect(platformSelect?.props.options).toEqual(
expect.arrayContaining([
expect.objectContaining({ label: '阿里云 CodingPlan / OpenAI' }),
expect.objectContaining({ label: '阿里云 CodingPlan / Claude' }),
expect.objectContaining({ label: '智谱 Coding Plan / OpenAI' }),
expect.objectContaining({ label: '智谱 Coding Plan / Claude' }),
]),
);
} finally {
root?.unmount();
}
});
it('adds compact descriptions to generic site types in the platform selector', async () => {
apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue({ id: 34, name: 'Demo Site', platform: 'openai' });
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/sites']}>
<Routes>
<Route path="/sites" element={<Sites />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const addButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className?.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
addButton.props.onClick();
});
await flushMicrotasks();
const platformSelect = root.root.findAllByType(ModernSelect).at(-1);
expect(platformSelect?.props.options).toEqual(
expect.arrayContaining([
expect.objectContaining({ value: 'new-api', description: expect.stringContaining('聚合面板') }),
expect.objectContaining({ value: 'openai', description: expect.stringContaining('OpenAI 兼容接口') }),
expect.objectContaining({ value: 'codex', description: expect.stringContaining('OAuth') }),
expect.objectContaining({ value: 'claude', description: expect.stringContaining('Claude') }),
]),
);
} finally {
root?.unmount();
}
});
it('prefills the official base url when selecting a vendor-specific site type', async () => {
apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue({ id: 31, name: 'Demo Site', platform: 'openai' });
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/sites']}>
<Routes>
<Route path="/sites" element={<Sites />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const addButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className?.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
addButton.props.onClick();
});
await flushMicrotasks();
const urlInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
));
const selects = root.root.findAllByType(ModernSelect);
const platformSelect = selects.at(-1);
await act(async () => {
platformSelect?.props.onChange('preset:zhipu-coding-plan-openai');
});
await flushMicrotasks();
expect(urlInput.props.value).toBe('https://open.bigmodel.cn/api/coding/paas/v4');
expect(JSON.stringify(root.toJSON())).toContain('智谱 Coding Plan / OpenAI');
const presetAlerts = root.root.findAll((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('alert alert-info')
));
expect(presetAlerts).toHaveLength(1);
expect(collectText(presetAlerts[0]!)).toContain('已应用官方预设');
expect(collectText(presetAlerts[0]!)).not.toContain('建议地址:');
} finally {
root?.unmount();
}
});
it('stops claiming the official base url is auto-filled after the user edits it', async () => {
apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue({ id: 35, name: 'Demo Site', platform: 'openai' });
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/sites']}>
<Routes>
<Route path="/sites" element={<Sites />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const addButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className?.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
addButton.props.onClick();
});
await flushMicrotasks();
const urlInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
));
const platformSelect = root.root.findAllByType(ModernSelect).at(-1);
await act(async () => {
platformSelect?.props.onChange('preset:zhipu-coding-plan-openai');
});
await flushMicrotasks();
await act(async () => {
urlInput.props.onChange({ target: { value: 'https://gateway.example.com/coding' } });
});
await flushMicrotasks();
const presetAlert = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('alert alert-info')
));
expect(collectText(presetAlert)).toContain('已应用官方预设');
expect(collectText(presetAlert)).not.toContain('当前已自动填入官方地址');
} finally {
root?.unmount();
}
});
it('keeps a manually selected generic openai platform even when the url matches a Coding Plan preset', async () => {
apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue({ id: 32, name: 'Demo Site', platform: 'openai' });
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/sites']}>
<Routes>
<Route path="/sites" element={<Sites />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const addButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className?.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
addButton.props.onClick();
});
await flushMicrotasks();
const urlInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
));
await act(async () => {
urlInput.props.onChange({ target: { value: 'https://coding.dashscope.aliyuncs.com/v1' } });
});
await flushMicrotasks();
let platformSelect = root.root.findAllByType(ModernSelect).at(-1);
await act(async () => {
platformSelect?.props.onChange('openai');
});
await flushMicrotasks();
platformSelect = root.root.findAllByType(ModernSelect).at(-1);
expect(platformSelect?.props.value).toBe('openai');
expect(JSON.stringify(root.toJSON())).not.toContain('已应用官方预设');
} finally {
root?.unmount();
}
});
it('sends explicit preset metadata only when the vendor-specific type is selected', async () => {
apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue({ id: 33, name: 'Aliyun CodingPlan', platform: 'openai', initializationPresetId: 'codingplan-openai' });
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/sites']}>
<Routes>
<Route path="/sites" element={<Sites />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const addButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className?.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
addButton.props.onClick();
});
await flushMicrotasks();
const nameInput = root.root.find((node) => node.type === 'input' && node.props.placeholder === '站点名称');
let platformSelect = root.root.findAllByType(ModernSelect).at(-1);
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存站点')
));
await act(async () => {
nameInput.props.onChange({ target: { value: 'Aliyun CodingPlan' } });
platformSelect?.props.onChange('preset:codingplan-openai');
});
await flushMicrotasks();
await act(async () => {
await saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.addSite).toHaveBeenCalledWith(expect.objectContaining({
platform: 'openai',
initializationPresetId: 'codingplan-openai',
}));
} finally {
root?.unmount();
}
});
it('shows modal with all three choices after creating a site', async () => {
apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue({ id: 24, name: 'Demo Site', platform: 'new-api' });
let root!: WebTestRenderer;
let root!: ReactTestRenderer;
await act(async () => {
root = create(
<ToastProvider>