forked from boos/BoosAPI
[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:
@@ -9,7 +9,6 @@ describe('SiteCreatedModal', () => {
|
||||
const root = create(
|
||||
<SiteCreatedModal
|
||||
siteName="Demo Site"
|
||||
platform="new-api"
|
||||
onChoice={onChoice}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
|
||||
|
||||
type NextStepChoice = 'session' | 'apikey' | 'later';
|
||||
|
||||
type Props = {
|
||||
siteName: string;
|
||||
platform?: string | null;
|
||||
initializationPresetId?: string | null;
|
||||
initialSegment?: 'session' | 'apikey';
|
||||
onChoice: (choice: NextStepChoice) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function SiteCreatedModal({ siteName, platform, onChoice, onClose }: Props) {
|
||||
export default function SiteCreatedModal({
|
||||
siteName,
|
||||
initializationPresetId,
|
||||
initialSegment = 'session',
|
||||
onChoice,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const preset = getSiteInitializationPreset(initializationPresetId);
|
||||
const apiKeyFirst = initialSegment === 'apikey';
|
||||
const helperText = preset?.description
|
||||
|| (apiKeyFirst
|
||||
? '该平台更适合直接通过 Base URL + API Key 接入,后续再补模型初始化。'
|
||||
: '接下来您可以继续补充登录连接或 API Key。');
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
@@ -27,14 +41,22 @@ export default function SiteCreatedModal({ siteName, platform, onChoice, onClose
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onChoice('later');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="modal"
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onCancel={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => {
|
||||
if (e.target === dialogRef.current) {
|
||||
onChoice('later');
|
||||
@@ -46,22 +68,46 @@ export default function SiteCreatedModal({ siteName, platform, onChoice, onClose
|
||||
站点创建成功
|
||||
</h3>
|
||||
<p className="py-2 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
站点 <strong>"{siteName}"</strong> 已添加成功。接下来您想做什么?
|
||||
站点 <strong>"{siteName}"</strong> 已添加成功。
|
||||
</p>
|
||||
{preset && (
|
||||
<div className="alert alert-info" style={{ marginBottom: 12 }}>
|
||||
<div className="alert-title">{preset.label}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4, lineHeight: 1.7 }}>
|
||||
{helperText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!preset && (
|
||||
<p className="py-1 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="modal-action" style={{ flexDirection: 'column', gap: 12 }}>
|
||||
<button
|
||||
className="btn btn-primary btn-block"
|
||||
onClick={() => onChoice('session')}
|
||||
>
|
||||
添加账号(用户名密码登录)
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline btn-block"
|
||||
onClick={() => onChoice('apikey')}
|
||||
>
|
||||
添加 API Key
|
||||
</button>
|
||||
{apiKeyFirst ? (
|
||||
<button
|
||||
className="btn btn-primary btn-block"
|
||||
onClick={() => onChoice('apikey')}
|
||||
>
|
||||
添加 API Key(推荐)
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-primary btn-block"
|
||||
onClick={() => onChoice('session')}
|
||||
>
|
||||
添加账号(用户名密码登录)
|
||||
</button>
|
||||
)}
|
||||
{!apiKeyFirst && (
|
||||
<button
|
||||
className="btn btn-outline btn-block"
|
||||
onClick={() => onChoice('apikey')}
|
||||
>
|
||||
添加 API Key
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost btn-block"
|
||||
onClick={() => onChoice('later')}
|
||||
|
||||
@@ -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
@@ -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([
|
||||
|
||||
@@ -23,6 +23,7 @@ export type SiteSavePayload = {
|
||||
url: string;
|
||||
externalCheckinUrl: string;
|
||||
platform: string;
|
||||
initializationPresetId?: string | null;
|
||||
proxyUrl: string;
|
||||
useSystemProxy: boolean;
|
||||
customHeaders: string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user