[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
@@ -259,4 +259,227 @@ describe('sites proxy settings', () => {
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('must use a string value'); expect((response.json() as { error?: string }).error).toContain('must use a string value');
}); });
it('rejects create payloads whose name is not a string', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 123,
url: 'https://typed-site.example.com',
platform: 'new-api',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid name');
});
it('rejects update payloads whose url is not a string', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'typed-site',
url: 'https://typed-site.example.com',
platform: 'new-api',
},
});
expect(created.statusCode).toBe(200);
const site = created.json() as { id: number };
const response = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}`,
payload: {
url: 123,
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid url');
});
it('rejects create payloads that are not objects', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: null,
});
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid site payload');
});
it('rejects update payloads that are not objects', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'typed-site',
url: 'https://typed-site.example.com',
platform: 'new-api',
},
});
expect(created.statusCode).toBe(200);
const site = created.json() as { id: number };
const response = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}`,
payload: null,
});
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid site payload');
});
it('detects Aliyun CodingPlan endpoints with initialization preset metadata', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites/detect',
payload: {
url: 'https://coding.dashscope.aliyuncs.com/v1',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
platform: 'openai',
initializationPresetId: 'codingplan-openai',
});
});
it('creates CodingPlan sites without explicit platform by using preset-backed detection', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'Aliyun CodingPlan',
url: 'https://coding.dashscope.aliyuncs.com/v1',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
name: 'Aliyun CodingPlan',
platform: 'openai',
initializationPresetId: 'codingplan-openai',
});
});
it('does not force CodingPlan preset metadata when the user explicitly chooses generic openai', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'Aliyun Generic OpenAI',
url: 'https://coding.dashscope.aliyuncs.com/v1',
platform: 'openai',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
name: 'Aliyun Generic OpenAI',
platform: 'openai',
});
expect(response.json()).not.toHaveProperty('initializationPresetId');
});
it('preserves explicit preset metadata even when the preset uses a custom gateway url', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'Aliyun CodingPlan Gateway',
url: 'https://gateway.example.com/coding/v1',
platform: 'openai',
initializationPresetId: 'codingplan-openai',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
name: 'Aliyun CodingPlan Gateway',
platform: 'openai',
initializationPresetId: 'codingplan-openai',
});
});
it('canonicalizes create payload url and platform before persistence and conflict checks', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'Aliyun Generic OpenAI',
url: ' coding.dashscope.aliyuncs.com/v1/ ',
platform: ' OPENAI ',
},
});
expect(created.statusCode).toBe(200);
expect(created.json()).toMatchObject({
name: 'Aliyun Generic OpenAI',
url: 'https://coding.dashscope.aliyuncs.com/v1',
platform: 'openai',
});
const duplicate = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'Aliyun Generic OpenAI Duplicate',
url: 'https://coding.dashscope.aliyuncs.com/v1',
platform: 'openai',
},
});
expect(duplicate.statusCode).toBe(409);
expect((duplicate.json() as { error?: string }).error).toContain('already exists');
});
it('canonicalizes update payload url and platform before saving', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'Update Canonicalization',
url: 'https://update-canonicalization.example.com',
platform: 'new-api',
},
});
expect(created.statusCode).toBe(200);
const response = await app.inject({
method: 'PUT',
url: `/api/sites/${(created.json() as { id: number }).id}`,
payload: {
url: ' coding.dashscope.aliyuncs.com/v1/ ',
platform: ' OPENAI ',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
url: 'https://coding.dashscope.aliyuncs.com/v1',
platform: 'openai',
});
});
it('detects Zhipu Coding Plan OpenAI endpoint with initialization preset metadata', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites/detect',
payload: {
url: 'https://open.bigmodel.cn/api/coding/paas/v4',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
platform: 'openai',
initializationPresetId: 'zhipu-coding-plan-openai',
});
});
}); });
+80 -13
View File
@@ -7,6 +7,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js'; import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js'; import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js'; import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js';
import { stripTrailingSlashes } from '../../services/urlNormalization.js';
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null { function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
if (input === undefined || input === null) return null; if (input === undefined || input === null) return null;
@@ -83,7 +85,14 @@ type ErrorLike = {
}; };
function normalizeSiteUrl(url: string): string { function normalizeSiteUrl(url: string): string {
return url.replace(/\/+$/, ''); const trimmed = url.trim();
const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`;
return stripTrailingSlashes(withScheme);
}
function normalizeSitePlatform(platform?: string): string | undefined {
if (platform === undefined) return undefined;
return platform.trim().toLowerCase();
} }
function getErrorChain(error: unknown): ErrorLike[] { function getErrorChain(error: unknown): ErrorLike[] {
@@ -280,6 +289,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name: string; name: string;
url: string; url: string;
platform?: string; platform?: string;
initializationPresetId?: string | null;
proxyUrl?: string | null; proxyUrl?: string | null;
useSystemProxy?: boolean; useSystemProxy?: boolean;
customHeaders?: string | null; customHeaders?: string | null;
@@ -289,7 +299,32 @@ export async function sitesRoutes(app: FastifyInstance) {
sortOrder?: number; sortOrder?: number;
globalWeight?: number; globalWeight?: number;
} }>('/api/sites', async (request, reply) => { } }>('/api/sites', async (request, reply) => {
const { name, url, platform, proxyUrl, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body; if (!request.body || typeof request.body !== 'object' || Array.isArray(request.body)) {
return reply.code(400).send({ error: 'Invalid site payload. Expected object.' });
}
const {
name,
url,
platform,
initializationPresetId,
proxyUrl,
useSystemProxy,
customHeaders,
externalCheckinUrl,
status,
isPinned,
sortOrder,
globalWeight,
} = request.body;
if (typeof name !== 'string' || !name.trim()) {
return reply.code(400).send({ error: 'Invalid name. Expected non-empty string.' });
}
if (typeof url !== 'string' || !url.trim()) {
return reply.code(400).send({ error: 'Invalid url. Expected non-empty string.' });
}
if (platform !== undefined && typeof platform !== 'string') {
return reply.code(400).send({ error: 'Invalid platform. Expected string.' });
}
const normalizedStatus = normalizeSiteStatus(status); const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) { if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
@@ -322,29 +357,45 @@ export async function sitesRoutes(app: FastifyInstance) {
if (!normalizedCustomHeaders.valid) { if (!normalizedCustomHeaders.valid) {
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' }); return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
} }
const explicitInitializationPreset = initializationPresetId == null || initializationPresetId === ''
? null
: getSiteInitializationPreset(initializationPresetId);
if (initializationPresetId != null && initializationPresetId !== '' && !explicitInitializationPreset) {
return reply.code(400).send({ error: 'Invalid initializationPresetId.' });
}
const existingSites = await db.select().from(schema.sites).all(); const existingSites = await db.select().from(schema.sites).all();
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1); const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
const normalizedUrl = normalizeSiteUrl(url); const canonicalUrl = normalizeSiteUrl(url);
const canonicalPlatform = normalizeSitePlatform(platform);
let detectedPlatform = platform; let detectedPlatform = canonicalPlatform;
let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null;
if (!detectedPlatform) { if (!detectedPlatform) {
const detected = await detectSite(url); if (explicitInitializationPreset) {
detectedPlatform = detected?.platform; detectedPlatform = explicitInitializationPreset.platform;
} else {
const detected = await detectSite(canonicalUrl);
detectedPlatform = detected?.platform;
responseInitializationPresetId = detected?.initializationPresetId || null;
}
}
if (explicitInitializationPreset && explicitInitializationPreset.platform !== detectedPlatform) {
return reply.code(400).send({ error: 'initializationPresetId does not match the selected platform.' });
} }
if (!detectedPlatform) { if (!detectedPlatform) {
return { error: 'Could not detect platform. Please specify manually.' }; return { error: 'Could not detect platform. Please specify manually.' };
} }
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl); const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, canonicalUrl);
if (conflictingSite) { if (conflictingSite) {
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl); return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
} }
let inserted; let inserted;
try { try {
inserted = await db.insert(schema.sites).values({ inserted = await db.insert(schema.sites).values({
name, name,
url: normalizedUrl, url: canonicalUrl,
platform: detectedPlatform, platform: detectedPlatform,
proxyUrl: normalizedProxyUrl.proxyUrl, proxyUrl: normalizedProxyUrl.proxyUrl,
useSystemProxy: normalizedUseSystemProxy ?? false, useSystemProxy: normalizedUseSystemProxy ?? false,
@@ -357,7 +408,7 @@ export async function sitesRoutes(app: FastifyInstance) {
}).run(); }).run();
} catch (error) { } catch (error) {
if (isSitesPlatformUrlConflict(error)) { if (isSitesPlatformUrlConflict(error)) {
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl); return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
} }
throw error; throw error;
} }
@@ -370,7 +421,10 @@ export async function sitesRoutes(app: FastifyInstance) {
return reply.code(500).send({ error: 'Create site failed' }); return reply.code(500).send({ error: 'Create site failed' });
} }
invalidateSiteCaches(); invalidateSiteCaches();
return result; return {
...result,
...(responseInitializationPresetId ? { initializationPresetId: responseInitializationPresetId } : {}),
};
}); });
// Update a site // Update a site
@@ -398,7 +452,19 @@ export async function sitesRoutes(app: FastifyInstance) {
} }
const updates: any = {}; const updates: any = {};
if (!request.body || typeof request.body !== 'object' || Array.isArray(request.body)) {
return reply.code(400).send({ error: 'Invalid site payload. Expected object.' });
}
const body = request.body; const body = request.body;
if (body.name !== undefined && (typeof body.name !== 'string' || !body.name.trim())) {
return reply.code(400).send({ error: 'Invalid name. Expected non-empty string.' });
}
if (body.url !== undefined && (typeof body.url !== 'string' || !body.url.trim())) {
return reply.code(400).send({ error: 'Invalid url. Expected non-empty string.' });
}
if (body.platform !== undefined && typeof body.platform !== 'string') {
return reply.code(400).send({ error: 'Invalid platform. Expected string.' });
}
const normalizedStatus = normalizeSiteStatus(body.status); const normalizedStatus = normalizeSiteStatus(body.status);
if (body.status !== undefined && !normalizedStatus) { if (body.status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
@@ -432,8 +498,9 @@ export async function sitesRoutes(app: FastifyInstance) {
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' }); return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
} }
const canonicalPlatform = body.platform !== undefined ? normalizeSitePlatform(body.platform) : undefined;
const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url; const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url;
const nextPlatform = body.platform !== undefined ? body.platform : existingSite.platform; const nextPlatform = canonicalPlatform !== undefined ? canonicalPlatform : existingSite.platform;
const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform; const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform;
if (siteIdentityChanged) { if (siteIdentityChanged) {
const siteRows = await db.select({ const siteRows = await db.select({
@@ -449,7 +516,7 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.name !== undefined) updates.name = body.name; if (body.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = nextUrl; if (body.url !== undefined) updates.url = nextUrl;
if (body.platform !== undefined) updates.platform = body.platform; if (body.platform !== undefined) updates.platform = canonicalPlatform;
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl; if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy; if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders; if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
+2 -1
View File
@@ -9,9 +9,10 @@ import {
type SiteAnnouncement, type SiteAnnouncement,
UserInfo, UserInfo,
} from './base.js'; } from './base.js';
import { stripTrailingSlashes } from '../urlNormalization.js';
function normalizeBaseUrl(baseUrl: string): string { function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, ''); return stripTrailingSlashes(baseUrl || '');
} }
/** /**
+3 -1
View File
@@ -1,3 +1,5 @@
import { stripTrailingSlashes } from '../urlNormalization.js';
export type TitleHintPlatform = export type TitleHintPlatform =
| 'anyrouter' | 'anyrouter'
| 'done-hub' | 'done-hub'
@@ -34,7 +36,7 @@ function normalizeBaseUrl(url: string): string {
const parsed = new URL(trimmed); const parsed = new URL(trimmed);
return `${parsed.protocol}//${parsed.host}`; return `${parsed.protocol}//${parsed.host}`;
} catch { } catch {
return trimmed.replace(/\/+$/, ''); return stripTrailingSlashes(trimmed);
} }
} }
+11 -1
View File
@@ -1,7 +1,17 @@
import { detectPlatform } from './platforms/index.js'; import { detectPlatform } from './platforms/index.js';
import { detectSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
import { stripTrailingSlashes } from './urlNormalization.js';
export async function detectSite(url: string) { export async function detectSite(url: string) {
const normalizedUrl = url.replace(/\/+$/, ''); const normalizedUrl = stripTrailingSlashes(url);
const preset = detectSiteInitializationPreset(normalizedUrl);
if (preset) {
return {
url: normalizedUrl,
platform: preset.platform,
initializationPresetId: preset.id,
};
}
const adapter = await detectPlatform(normalizedUrl); const adapter = await detectPlatform(normalizedUrl);
if (!adapter) return null; if (!adapter) return null;
return { url: normalizedUrl, platform: adapter.platformName }; return { url: normalizedUrl, platform: adapter.platformName };
+3 -3
View File
@@ -10,6 +10,7 @@ import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
import { Agent as UndiciAgent, ProxyAgent } from 'undici'; import { Agent as UndiciAgent, ProxyAgent } from 'undici';
import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js'; import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js';
import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js'; import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js';
import { stripTrailingSlashes } from './urlNormalization.js';
const SITE_PROXY_CACHE_TTL_MS = 3_000; const SITE_PROXY_CACHE_TTL_MS = 3_000;
const SUPPORTED_PROXY_PROTOCOLS = new Set([ const SUPPORTED_PROXY_PROTOCOLS = new Set([
@@ -100,11 +101,10 @@ function normalizeSiteUrl(value: string): string {
try { try {
const parsed = new URL(trimmed); const parsed = new URL(trimmed);
const pathname = parsed.pathname.replace(/\/+$/, ''); const normalizedPath = stripTrailingSlashes(parsed.pathname);
const normalizedPath = pathname === '/' ? '' : pathname;
return `${parsed.origin}${normalizedPath}`; return `${parsed.origin}${normalizedPath}`;
} catch { } catch {
return trimmed.replace(/\/+$/, ''); return stripTrailingSlashes(trimmed);
} }
} }
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { stripTrailingSlashes } from './urlNormalization.js';
describe('stripTrailingSlashes', () => {
it('removes trailing slashes without using regex behavior', () => {
expect(stripTrailingSlashes('')).toBe('');
expect(stripTrailingSlashes('https://api.example.com')).toBe('https://api.example.com');
expect(stripTrailingSlashes('https://api.example.com///')).toBe('https://api.example.com');
expect(stripTrailingSlashes('/api/v1///')).toBe('/api/v1');
expect(stripTrailingSlashes('////')).toBe('');
});
});
+7
View File
@@ -0,0 +1,7 @@
export function stripTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0 && value.charCodeAt(end - 1) === 47) {
end -= 1;
}
return end === value.length ? value : value.slice(0, end);
}
+21
View File
@@ -0,0 +1,21 @@
export type SiteInitializationPresetId =
| 'codingplan-openai'
| 'codingplan-claude'
| 'zhipu-coding-plan-openai'
| 'zhipu-coding-plan-claude';
export type SiteInitializationPreset = {
id: SiteInitializationPresetId;
label: string;
providerLabel: string;
description: string;
platform: string;
defaultUrl?: string;
initialSegment: 'session' | 'apikey';
recommendedSkipModelFetch: boolean;
recommendedModels: string[];
docsUrl?: string;
};
export declare function listSiteInitializationPresets(): SiteInitializationPreset[];
export declare function getSiteInitializationPreset(id: string | null | undefined): SiteInitializationPreset | null;
export declare function detectSiteInitializationPreset(url: string, platform?: string | null): SiteInitializationPreset | null;
+142
View File
@@ -0,0 +1,142 @@
function normalizeUrlCandidate(url) {
return typeof url === 'string' ? url.trim() : '';
}
function parseUrlCandidate(url) {
const normalized = normalizeUrlCandidate(url);
if (!normalized) return null;
const candidates = normalized.includes('://')
? [normalized]
: [`https://${normalized}`];
for (const candidate of candidates) {
try {
return new URL(candidate);
} catch {}
}
return null;
}
function normalizePathname(pathname) {
let normalized = typeof pathname === 'string' ? pathname.trim() : '';
if (!normalized.startsWith('/')) normalized = `/${normalized}`;
while (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
const CODINGPLAN_RECOMMENDED_MODELS = Object.freeze([
'qwen3-coder-plus',
'qwen3-coder-next',
'qwen3.5-plus',
'glm-5',
]);
const ZHIPU_CODING_PLAN_RECOMMENDED_MODELS = Object.freeze([
'glm-4.7',
'glm-4.6',
'glm-4.5',
'glm-4.5-air',
]);
const SITE_INITIALIZATION_PRESETS = Object.freeze([
Object.freeze({
id: 'codingplan-openai',
label: '阿里云 CodingPlan / OpenAI',
providerLabel: '阿里云 CodingPlan',
description: '适合阿里云 CodingPlan 的 OpenAI 兼容入口,建议先添加 API Key,再补入推荐模型完成初始化。',
platform: 'openai',
defaultUrl: 'https://coding.dashscope.aliyuncs.com/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: CODINGPLAN_RECOMMENDED_MODELS,
docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq',
matches(url) {
const parsed = parseUrlCandidate(url);
if (!parsed) return false;
return parsed.hostname === 'coding.dashscope.aliyuncs.com'
&& normalizePathname(parsed.pathname) === '/v1';
},
}),
Object.freeze({
id: 'codingplan-claude',
label: '阿里云 CodingPlan / Claude',
providerLabel: '阿里云 CodingPlan',
description: '适合阿里云 CodingPlan 的 Claude 兼容入口,建议先添加 API Key,再补入推荐模型完成初始化。',
platform: 'claude',
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: CODINGPLAN_RECOMMENDED_MODELS,
docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq',
matches(url) {
const parsed = parseUrlCandidate(url);
if (!parsed) return false;
const pathname = normalizePathname(parsed.pathname);
return parsed.hostname === 'coding.dashscope.aliyuncs.com'
&& (pathname === '/apps/anthropic' || pathname.startsWith('/apps/anthropic/'));
},
}),
Object.freeze({
id: 'zhipu-coding-plan-openai',
label: '智谱 Coding Plan / OpenAI',
providerLabel: '智谱 Coding Plan',
description: '适合智谱 Coding Plan 的 OpenAI 兼容入口,建议先添加 API Key,再补入常用 GLM 编程模型。',
platform: 'openai',
defaultUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ZHIPU_CODING_PLAN_RECOMMENDED_MODELS,
docsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/faq',
matches(url) {
const parsed = parseUrlCandidate(url);
if (!parsed) return false;
return parsed.hostname === 'open.bigmodel.cn'
&& normalizePathname(parsed.pathname) === '/api/coding/paas/v4';
},
}),
Object.freeze({
id: 'zhipu-coding-plan-claude',
label: '智谱 Coding Plan / Claude',
providerLabel: '智谱 Coding Plan',
description: '适合智谱 Coding Plan 的 Claude 兼容入口。由于该地址也可作为通用兼容入口,这里默认只提供手动预设,不按 URL 强制自动识别。',
platform: 'claude',
defaultUrl: 'https://open.bigmodel.cn/api/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
recommendedModels: ZHIPU_CODING_PLAN_RECOMMENDED_MODELS,
docsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/faq',
matches() {
return false;
},
}),
]);
function clonePreset(preset) {
if (!preset) return null;
return {
...preset,
recommendedModels: [...preset.recommendedModels],
};
}
export function listSiteInitializationPresets() {
return SITE_INITIALIZATION_PRESETS.map((preset) => clonePreset(preset));
}
export function getSiteInitializationPreset(id) {
const normalizedId = typeof id === 'string' ? id.trim() : '';
if (!normalizedId) return null;
return clonePreset(SITE_INITIALIZATION_PRESETS.find((preset) => preset.id === normalizedId) || null);
}
export function detectSiteInitializationPreset(url, platform) {
const normalizedPlatform = typeof platform === 'string' ? platform.trim().toLowerCase() : '';
for (const preset of SITE_INITIALIZATION_PRESETS) {
if (normalizedPlatform && preset.platform !== normalizedPlatform) continue;
if (preset.matches(url)) return clonePreset(preset);
}
return null;
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import {
detectSiteInitializationPreset,
getSiteInitializationPreset,
listSiteInitializationPresets,
} from './siteInitializationPresets.js';
describe('siteInitializationPresets', () => {
it('exposes CodingPlan presets with recommended API-key-first initialization', () => {
const presetIds = listSiteInitializationPresets().map((preset) => preset.id);
expect(presetIds).toEqual(expect.arrayContaining([
'codingplan-openai',
'codingplan-claude',
'zhipu-coding-plan-openai',
'zhipu-coding-plan-claude',
]));
const openaiPreset = getSiteInitializationPreset('codingplan-openai');
expect(openaiPreset).toMatchObject({
id: 'codingplan-openai',
platform: 'openai',
defaultUrl: 'https://coding.dashscope.aliyuncs.com/v1',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
});
expect(openaiPreset?.recommendedModels).toEqual(expect.arrayContaining(['qwen3-coder-plus', 'qwen3.5-plus']));
const claudePreset = getSiteInitializationPreset('codingplan-claude');
expect(claudePreset).toMatchObject({
id: 'codingplan-claude',
platform: 'claude',
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
});
expect(claudePreset?.recommendedModels).toEqual(expect.arrayContaining(['qwen3-coder-next', 'glm-5']));
const zhipuOpenAiPreset = getSiteInitializationPreset('zhipu-coding-plan-openai');
expect(zhipuOpenAiPreset).toMatchObject({
id: 'zhipu-coding-plan-openai',
platform: 'openai',
defaultUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
});
expect(zhipuOpenAiPreset?.recommendedModels).toEqual(['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air']);
const zhipuClaudePreset = getSiteInitializationPreset('zhipu-coding-plan-claude');
expect(zhipuClaudePreset).toMatchObject({
id: 'zhipu-coding-plan-claude',
platform: 'claude',
defaultUrl: 'https://open.bigmodel.cn/api/anthropic',
initialSegment: 'apikey',
recommendedSkipModelFetch: true,
});
expect(zhipuClaudePreset?.recommendedModels).toEqual(['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air']);
});
it('detects Aliyun CodingPlan endpoints by URL', () => {
expect(detectSiteInitializationPreset('https://coding.dashscope.aliyuncs.com/v1')).toMatchObject({
id: 'codingplan-openai',
platform: 'openai',
});
expect(detectSiteInitializationPreset('https://coding.dashscope.aliyuncs.com/apps/anthropic')).toMatchObject({
id: 'codingplan-claude',
platform: 'claude',
});
expect(detectSiteInitializationPreset('https://api.openai.com/v1')).toBeNull();
});
it('detects Zhipu Coding Plan OpenAI endpoints by URL but keeps Claude-compatible entry manual-only', () => {
expect(detectSiteInitializationPreset('https://open.bigmodel.cn/api/coding/paas/v4')).toMatchObject({
id: 'zhipu-coding-plan-openai',
platform: 'openai',
});
expect(detectSiteInitializationPreset('https://open.bigmodel.cn/api/coding/paas/v4/')).toMatchObject({
id: 'zhipu-coding-plan-openai',
platform: 'openai',
});
expect(detectSiteInitializationPreset('https://open.bigmodel.cn/api/anthropic')).toBeNull();
});
});
@@ -9,7 +9,6 @@ describe('SiteCreatedModal', () => {
const root = create( const root = create(
<SiteCreatedModal <SiteCreatedModal
siteName="Demo Site" siteName="Demo Site"
platform="new-api"
onChoice={onChoice} onChoice={onChoice}
onClose={onClose} onClose={onClose}
/>, />,
+63 -17
View File
@@ -1,16 +1,30 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
type NextStepChoice = 'session' | 'apikey' | 'later'; type NextStepChoice = 'session' | 'apikey' | 'later';
type Props = { type Props = {
siteName: string; siteName: string;
platform?: string | null; initializationPresetId?: string | null;
initialSegment?: 'session' | 'apikey';
onChoice: (choice: NextStepChoice) => void; onChoice: (choice: NextStepChoice) => void;
onClose: () => 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 dialogRef = useRef<HTMLDialogElement>(null);
const preset = getSiteInitializationPreset(initializationPresetId);
const apiKeyFirst = initialSegment === 'apikey';
const helperText = preset?.description
|| (apiKeyFirst
? '该平台更适合直接通过 Base URL + API Key 接入,后续再补模型初始化。'
: '接下来您可以继续补充登录连接或 API Key。');
useEffect(() => { useEffect(() => {
const dialog = dialogRef.current; 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 ( return (
<dialog <dialog
ref={dialogRef} ref={dialogRef}
className="modal" className="modal"
onCancel={(event) => { onCancel={(e) => {
event.preventDefault(); e.preventDefault();
onClose(); onClose();
}} }}
onKeyDown={handleKeyDown}
onClick={(e) => { onClick={(e) => {
if (e.target === dialogRef.current) { if (e.target === dialogRef.current) {
onChoice('later'); onChoice('later');
@@ -46,22 +68,46 @@ export default function SiteCreatedModal({ siteName, platform, onChoice, onClose
站点创建成功 站点创建成功
</h3> </h3>
<p className="py-2 text-sm" style={{ color: 'var(--color-text-muted)' }}> <p className="py-2 text-sm" style={{ color: 'var(--color-text-muted)' }}>
站点 <strong>"{siteName}"</strong> 已添加成功。接下来您想做什么? 站点 <strong>"{siteName}"</strong> 已添加成功。
</p> </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 }}> <div className="modal-action" style={{ flexDirection: 'column', gap: 12 }}>
<button {apiKeyFirst ? (
className="btn btn-primary btn-block" <button
onClick={() => onChoice('session')} className="btn btn-primary btn-block"
> onClick={() => onChoice('apikey')}
添加账号(用户名密码登录) >
</button> 添加 API Key(推荐)
<button </button>
className="btn btn-outline btn-block" ) : (
onClick={() => onChoice('apikey')} <button
> className="btn btn-primary btn-block"
添加 API Key onClick={() => onChoice('session')}
</button> >
添加账号(用户名密码登录)
</button>
)}
{!apiKeyFirst && (
<button
className="btn btn-outline btn-block"
onClick={() => onChoice('apikey')}
>
添加 API Key
</button>
)}
<button <button
className="btn btn-ghost btn-block" className="btn btn-ghost btn-block"
onClick={() => onChoice('later')} onClick={() => onChoice('later')}
+63
View File
@@ -28,6 +28,7 @@ import { tr } from '../i18n.js';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js'; import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js'; import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { SITE_DOCS_URL } from '../docsLink.js'; import { SITE_DOCS_URL } from '../docsLink.js';
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
type ConnectionsSegment = 'session' | 'apikey' | 'tokens'; type ConnectionsSegment = 'session' | 'apikey' | 'tokens';
@@ -88,6 +89,8 @@ export default function Accounts() {
const [addMode, setAddMode] = useState<'token' | 'login'>('token'); const [addMode, setAddMode] = useState<'token' | 'login'>('token');
const [loginForm, setLoginForm] = useState(createLoginForm); const [loginForm, setLoginForm] = useState(createLoginForm);
const [tokenForm, setTokenForm] = useState(() => createTokenForm('session')); 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 [verifyResult, setVerifyResult] = useState<any>(null);
const [verifying, setVerifying] = useState(false); const [verifying, setVerifying] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -187,11 +190,17 @@ export default function Accounts() {
); );
const isSub2ApiSelected = (selectedTokenSite?.platform || '').toLowerCase() === 'sub2api'; const isSub2ApiSelected = (selectedTokenSite?.platform || '').toLowerCase() === 'sub2api';
const activeAddCredentialMode = activeSegment === 'apikey' ? 'apikey' : 'session'; const activeAddCredentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
const createIntentPreset = useMemo(
() => getSiteInitializationPreset(createIntentPresetId),
[createIntentPresetId],
);
const resetAddForms = (credentialMode: 'session' | 'apikey' = activeAddCredentialMode) => { const resetAddForms = (credentialMode: 'session' | 'apikey' = activeAddCredentialMode) => {
setAddMode('token'); setAddMode('token');
setLoginForm(createLoginForm()); setLoginForm(createLoginForm());
setTokenForm(createTokenForm(credentialMode)); setTokenForm(createTokenForm(credentialMode));
setCreateIntentPresetId(null);
setApplyCreatePresetModels(false);
setVerifyResult(null); setVerifyResult(null);
}; };
@@ -254,18 +263,23 @@ export default function Accounts() {
if (!shouldOpenCreate || !requestedSiteId) return; if (!shouldOpenCreate || !requestedSiteId) return;
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session'; const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
const initializationPreset = getSiteInitializationPreset(params.get('initPreset'));
setShowAdd(true); setShowAdd(true);
setAddMode('token'); setAddMode('token');
setVerifyResult(null); setVerifyResult(null);
setCreateIntentPresetId(initializationPreset?.id || null);
setApplyCreatePresetModels(Boolean(initializationPreset?.recommendedModels?.length));
setLoginForm(createLoginForm()); setLoginForm(createLoginForm());
setTokenForm({ setTokenForm({
...createTokenForm(credentialMode), ...createTokenForm(credentialMode),
siteId: requestedSiteId, siteId: requestedSiteId,
skipModelFetch: credentialMode === 'apikey' && initializationPreset?.recommendedSkipModelFetch === true,
}); });
params.delete('create'); params.delete('create');
params.delete('siteId'); params.delete('siteId');
params.delete('from'); params.delete('from');
params.delete('initPreset');
const nextSearch = params.toString(); const nextSearch = params.toString();
navigate( navigate(
{ {
@@ -343,6 +357,7 @@ export default function Accounts() {
return; return;
} }
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session'; const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
const initializationPreset = createIntentPreset;
setSaving(true); setSaving(true);
try { try {
const result = await api.addAccount({ const result = await api.addAccount({
@@ -359,6 +374,24 @@ export default function Accounts() {
credentialMode, credentialMode,
skipModelFetch: tokenForm.skipModelFetch, 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(); closeAddPanel();
if (result.queued) { if (result.queued) {
toast.info(result.message || '账号已添加,后台正在同步初始化信息。'); toast.info(result.message || '账号已添加,后台正在同步初始化信息。');
@@ -371,6 +404,9 @@ export default function Accounts() {
const extra = parts.length ? `(${parts.join(',')})` : ''; const extra = parts.length ? `(${parts.join(',')})` : '';
toast.success(`账号已添加${extra}`); toast.success(`账号已添加${extra}`);
} }
if (seededRecommendedModels) {
toast.success(`已补入 ${recommendedModels.length} 个推荐模型并重建路由`);
}
load(); load();
} catch (e: any) { } catch (e: any) {
toast.error(e.message || '添加失败'); toast.error(e.message || '添加失败');
@@ -1330,12 +1366,39 @@ export default function Accounts() {
<div className="info-tip"> <div className="info-tip">
API Key 连接只用于代理转发,不会自动派生账号令牌。系统会按站点平台能力自动引导到 Session 或 API Key 创建流程。 API Key 连接只用于代理转发,不会自动派生账号令牌。系统会按站点平台能力自动引导到 Session 或 API Key 创建流程。
</div> </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 <ModernSelect
value={String(tokenForm.siteId || 0)} value={String(tokenForm.siteId || 0)}
onChange={(nextValue) => { onChange={(nextValue) => {
const nextSiteId = Number.parseInt(nextValue, 10) || 0; const nextSiteId = Number.parseInt(nextValue, 10) || 0;
setTokenForm((f) => ({ ...f, siteId: nextSiteId, credentialMode: 'apikey' })); setTokenForm((f) => ({ ...f, siteId: nextSiteId, credentialMode: 'apikey' }));
setVerifyResult(null); setVerifyResult(null);
if (createIntentPresetId && nextSiteId !== tokenForm.siteId) {
setCreateIntentPresetId(null);
setApplyCreatePresetModels(false);
}
}} }}
options={siteSelectOptions} options={siteSelectOptions}
placeholder="选择站点" placeholder="选择站点"
+109 -26
View File
@@ -27,6 +27,10 @@ import {
type SiteEditorState, type SiteEditorState,
type SiteForm, type SiteForm,
} from './helpers/sitesEditor.js'; } from './helpers/sitesEditor.js';
import {
getSiteInitializationPreset,
listSiteInitializationPresets,
} from '../../shared/siteInitializationPresets.js';
type SiteSubscriptionSummary = { type SiteSubscriptionSummary = {
activeCount: number; activeCount: number;
@@ -176,18 +180,18 @@ const platformColors: Record<string, string> = {
const SITE_PLATFORM_OPTIONS = [ const SITE_PLATFORM_OPTIONS = [
{ value: '', label: '平台类型(可自动检测)' }, { value: '', label: '平台类型(可自动检测)' },
{ value: 'new-api', label: 'new-api' }, { value: 'new-api', label: 'new-api', description: '聚合面板,适合多渠道统一管理' },
{ value: 'one-api', label: 'one-api' }, { value: 'one-api', label: 'one-api', description: '经典聚合面板,常见于通用 OpenAI 中转' },
{ value: 'anyrouter', label: 'anyrouter' }, { value: 'anyrouter', label: 'anyrouter', description: 'any大善人今天还能用吗' },
{ value: 'veloera', label: 'veloera' }, { value: 'veloera', label: 'veloera', description: 'Veloera 兼容站点,常见于聚合代理场景' },
{ value: 'one-hub', label: 'one-hub' }, { value: 'one-hub', label: 'one-hub', description: '聚合面板,偏向多账号统一管理' },
{ value: 'done-hub', label: 'done-hub' }, { value: 'done-hub', label: 'done-hub', description: '聚合面板,适合统一转发与管理' },
{ value: 'sub2api', label: 'sub2api' }, { value: 'sub2api', label: 'sub2api', description: '订阅式中转面板,可同步套餐与余额信息' },
{ value: 'openai', label: 'openai' }, { value: 'openai', label: 'openai', description: '通用 OpenAI 兼容接口,手填 Base URL 即可' },
{ value: 'codex', label: 'codex' }, { value: 'codex', label: 'codex', description: 'Codex OAuth / Session 优先入口' },
{ value: 'claude', label: 'claude' }, { value: 'claude', label: 'claude', description: '通用 Claude / Anthropic 兼容接口' },
{ value: 'gemini', label: 'gemini' }, { value: 'gemini', label: 'gemini', description: '通用 Gemini / Google AI 兼容接口' },
{ value: 'cliproxyapi', label: 'cliproxyapi' }, { value: 'cliproxyapi', label: 'cliproxyapi', description: 'CPA接入口' },
]; ];
export default function Sites() { export default function Sites() {
@@ -211,7 +215,9 @@ export default function Sites() {
id: number; id: number;
name: string; name: string;
platform?: string | null; platform?: string | null;
initializationPresetId?: string | null;
} | null>(null); } | null>(null);
const [selectedInitializationPresetId, setSelectedInitializationPresetId] = useState<string | null>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [showMobileTools, setShowMobileTools] = useState(false); const [showMobileTools, setShowMobileTools] = useState(false);
const [batchActionLoading, setBatchActionLoading] = useState(false); const [batchActionLoading, setBatchActionLoading] = useState(false);
@@ -232,6 +238,11 @@ export default function Sites() {
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false); const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]); const [availableModels, setAvailableModels] = useState<string[]>([]);
const [disabledModelSearch, setDisabledModelSearch] = useState(''); const [disabledModelSearch, setDisabledModelSearch] = useState('');
const initializationPresetOptions = useMemo(() => listSiteInitializationPresets(), []);
const selectedInitializationPreset = useMemo(
() => getSiteInitializationPreset(selectedInitializationPresetId),
[selectedInitializationPresetId],
);
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]); const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
@@ -298,14 +309,28 @@ export default function Sites() {
const platformOptions = useMemo(() => { const platformOptions = useMemo(() => {
const current = form.platform.trim(); const current = form.platform.trim();
if (!current || SITE_PLATFORM_OPTIONS.some((option) => option.value === current)) { const genericOptions = (!current || SITE_PLATFORM_OPTIONS.some((option) => option.value === current))
return SITE_PLATFORM_OPTIONS; ? 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 [ return [
...SITE_PLATFORM_OPTIONS, genericOptions[0]!,
{ value: current, label: `${current}(当前值)` }, ...presetOptions,
...genericOptions.slice(1),
]; ];
}, [form.platform]); }, [form.platform, initializationPresetOptions]);
const activeInitializationPreset = selectedInitializationPreset;
const platformSelectValue = selectedInitializationPreset ? `preset:${selectedInitializationPreset.id}` : form.platform;
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -339,6 +364,7 @@ export default function Sites() {
const closeEditor = () => { const closeEditor = () => {
setEditor(null); setEditor(null);
setForm(emptySiteForm()); setForm(emptySiteForm());
setSelectedInitializationPresetId(null);
}; };
const scrollToEditorTop = () => { const scrollToEditorTop = () => {
@@ -355,12 +381,14 @@ export default function Sites() {
} }
setEditor({ mode: 'add' }); setEditor({ mode: 'add' });
setForm(emptySiteForm()); setForm(emptySiteForm());
setSelectedInitializationPresetId(null);
scrollToEditorTop(); scrollToEditorTop();
}; };
const openEdit = (site: SiteRow) => { const openEdit = (site: SiteRow) => {
setEditor({ mode: 'edit', editingSiteId: site.id }); setEditor({ mode: 'edit', editingSiteId: site.id });
setForm(siteFormFromSite(site)); setForm(siteFormFromSite(site));
setSelectedInitializationPresetId(null);
scrollToEditorTop(); scrollToEditorTop();
// Load disabled models and discovered models independently so a best-effort // Load disabled models and discovered models independently so a best-effort
// availability fetch cannot wipe the existing disabled-model state. // availability fetch cannot wipe the existing disabled-model state.
@@ -448,6 +476,7 @@ export default function Sites() {
url: form.url.trim(), url: form.url.trim(),
externalCheckinUrl: form.externalCheckinUrl.trim(), externalCheckinUrl: form.externalCheckinUrl.trim(),
platform: form.platform.trim(), platform: form.platform.trim(),
initializationPresetId: selectedInitializationPresetId,
proxyUrl: form.proxyUrl.trim(), proxyUrl: form.proxyUrl.trim(),
useSystemProxy: !!form.useSystemProxy, useSystemProxy: !!form.useSystemProxy,
customHeaders: serializedCustomHeaders.customHeaders, customHeaders: serializedCustomHeaders.customHeaders,
@@ -466,14 +495,18 @@ export default function Sites() {
toast.success(`站点 "${payload.name}" 已添加`); toast.success(`站点 "${payload.name}" 已添加`);
const createdSiteId = Number(created?.id) || 0; const createdSiteId = Number(created?.id) || 0;
if (createdSiteId > 0) { if (createdSiteId > 0) {
// 所有平台都弹出选择Modal,让用户决定下一步
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim() const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
? created.platform.trim() ? created.platform.trim()
: payload.platform; : payload.platform;
const returnedPreset = getSiteInitializationPreset(created?.initializationPresetId);
const fallbackPreset = selectedInitializationPreset && selectedInitializationPreset.platform === createdPlatform
? selectedInitializationPreset
: null;
setCreatedSiteForChoice({ setCreatedSiteForChoice({
id: createdSiteId, id: createdSiteId,
name: payload.name, name: payload.name,
platform: createdPlatform, platform: createdPlatform,
initializationPresetId: returnedPreset?.id || fallbackPreset?.id || null,
}); });
} }
} else { } else {
@@ -526,6 +559,9 @@ export default function Sites() {
create: '1', create: '1',
siteId: String(siteId), siteId: String(siteId),
}); });
if (createdSiteForChoice.initializationPresetId) {
params.set('initPreset', createdSiteForChoice.initializationPresetId);
}
if (choice === 'session') { if (choice === 'session') {
// codex平台使用OAuth流程 // codex平台使用OAuth流程
@@ -557,8 +593,19 @@ export default function Sites() {
try { try {
const result = await api.detectSite(form.url.trim()); const result = await api.detectSite(form.url.trim());
if (result?.platform) { if (result?.platform) {
const detectedPreset = getSiteInitializationPreset(result?.initializationPresetId);
setForm((prev) => ({ ...prev, platform: result.platform })); 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 { } else {
toast.error(result?.error || '无法识别平台类型'); toast.error(result?.error || '无法识别平台类型');
} }
@@ -832,7 +879,11 @@ export default function Sites() {
{createdSiteForChoice && ( {createdSiteForChoice && (
<SiteCreatedModal <SiteCreatedModal
siteName={createdSiteForChoice.name} siteName={createdSiteForChoice.name}
platform={createdSiteForChoice.platform} initializationPresetId={createdSiteForChoice.initializationPresetId}
initialSegment={
getSiteInitializationPreset(createdSiteForChoice.initializationPresetId)?.initialSegment
|| resolveInitialConnectionSegment(createdSiteForChoice.platform)
}
onChoice={handleSiteCreatedChoice} onChoice={handleSiteCreatedChoice}
onClose={() => { onClose={() => {
setCreatedSiteForChoice(null); setCreatedSiteForChoice(null);
@@ -904,15 +955,35 @@ export default function Sites() {
</div> </div>
<div <div
style={{ 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)', borderRadius: 'var(--radius-sm)',
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)', background: 'var(--color-bg)',
transition: 'all 0.2s', 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 <ModernSelect
value={form.platform} value={platformSelectValue}
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))} 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} options={platformOptions}
placeholder="平台类型(可自动检测)" placeholder="平台类型(可自动检测)"
/> />
@@ -924,6 +995,18 @@ export default function Sites() {
style={formInputStyle} style={formInputStyle}
/> />
</ResponsiveFormGrid> </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 <div
style={{ style={{
display: 'flex', 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('one-hub')).toBe('session');
expect(resolveInitialConnectionSegment('done-hub')).toBe('session'); expect(resolveInitialConnectionSegment('done-hub')).toBe('session');
expect(resolveInitialConnectionSegment('sub2api')).toBe('session'); expect(resolveInitialConnectionSegment('sub2api')).toBe('session');
expect(resolveInitialConnectionSegment('codex')).toBe('session');
}); });
it('maps API-key-only platforms to the apikey segment', () => { it('maps API-key-only platforms to the apikey segment', () => {
@@ -8,6 +8,7 @@ const SESSION_FIRST_PLATFORMS = new Set([
'one-hub', 'one-hub',
'done-hub', 'done-hub',
'sub2api', 'sub2api',
'codex',
]); ]);
const API_KEY_FIRST_PLATFORMS = new Set([ const API_KEY_FIRST_PLATFORMS = new Set([
+1
View File
@@ -23,6 +23,7 @@ export type SiteSavePayload = {
url: string; url: string;
externalCheckinUrl: string; externalCheckinUrl: string;
platform: string; platform: string;
initializationPresetId?: string | null;
proxyUrl: string; proxyUrl: string;
useSystemProxy: boolean; useSystemProxy: boolean;
customHeaders: string; customHeaders: string;
+330 -4
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 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 { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import ModernSelect from '../components/ModernSelect.js'; import ModernSelect from '../components/ModernSelect.js';
import { ToastProvider } from '../components/Toast.js'; import { ToastProvider } from '../components/Toast.js';
@@ -38,11 +38,14 @@ function LocationProbe() {
return <div>{`${location.pathname}${location.search}`}</div>; 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.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue(createdSite); apiMock.addSite.mockResolvedValue(createdSite);
let root!: WebTestRenderer; let root!: ReactTestRenderer;
try { try {
await act(async () => { await act(async () => {
root = create( root = create(
@@ -148,6 +151,20 @@ describe('Sites create redirect', () => {
expect(rendered).toContain('siteId=22'); 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 () => { 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'); 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'); 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 () => { it('shows modal with all three choices after creating a site', async () => {
apiMock.getSites.mockResolvedValue([]); apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue({ id: 24, name: 'Demo Site', platform: 'new-api' }); apiMock.addSite.mockResolvedValue({ id: 24, name: 'Demo Site', platform: 'new-api' });
let root!: WebTestRenderer; let root!: ReactTestRenderer;
await act(async () => { await act(async () => {
root = create( root = create(
<ToastProvider> <ToastProvider>