forked from boos/BoosAPI
[codex] Split site API endpoint pool from panel host (#373)
* feat: add site api endpoint schema scaffolding * feat: wire site api endpoints into backup migration * feat: add site api endpoint selection service * feat: add site api endpoint contracts * feat: route discovery through site api endpoints * feat: route proxy traffic through site api endpoints * feat: add site api endpoint pool editor * fix: keep proxy video tasks on site endpoint pool * fix: restore core test compatibility for site endpoint pool * fix: address endpoint pool review findings
This commit is contained in:
+219
-6
@@ -20,11 +20,14 @@ import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||
import { resolveInitialConnectionSegment } from './helpers/defaultConnectionSegment.js';
|
||||
import {
|
||||
buildSiteSaveAction,
|
||||
emptySiteApiEndpoint,
|
||||
emptySiteCustomHeader,
|
||||
emptySiteForm,
|
||||
serializeSiteApiEndpoints,
|
||||
serializeSiteCustomHeaders,
|
||||
siteFormFromSite,
|
||||
type SiteEditorState,
|
||||
type SiteApiEndpointField,
|
||||
type SiteForm,
|
||||
} from './helpers/sitesEditor.js';
|
||||
import {
|
||||
@@ -59,12 +62,33 @@ type SiteRow = {
|
||||
totalBalance?: number;
|
||||
subscriptionSummary?: SiteSubscriptionSummary | null;
|
||||
createdAt?: string;
|
||||
apiEndpoints?: Array<{
|
||||
id?: number;
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
sortOrder?: number;
|
||||
cooldownUntil?: string | null;
|
||||
lastFailureReason?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function hasConfiguredCustomHeaders(customHeaders?: string | null): boolean {
|
||||
return typeof customHeaders === 'string' && customHeaders.trim().length > 0;
|
||||
}
|
||||
|
||||
function getConfiguredSiteApiEndpoints(site?: Pick<SiteRow, 'apiEndpoints'> | null) {
|
||||
return Array.isArray(site?.apiEndpoints)
|
||||
? site.apiEndpoints.filter((item) => typeof item?.url === 'string' && item.url.trim())
|
||||
: [];
|
||||
}
|
||||
|
||||
function buildSiteApiEndpointSummary(site?: Pick<SiteRow, 'apiEndpoints'> | null): string {
|
||||
const endpoints = getConfiguredSiteApiEndpoints(site);
|
||||
if (endpoints.length <= 0) return '跟随主站点 URL';
|
||||
const enabledCount = endpoints.filter((item) => item.enabled !== false).length;
|
||||
return `${enabledCount}/${endpoints.length} 条启用`;
|
||||
}
|
||||
|
||||
function formatUsd(value?: number | null): string {
|
||||
return `$${(value || 0).toFixed(2)}`;
|
||||
}
|
||||
@@ -203,7 +227,27 @@ export default function Sites() {
|
||||
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
||||
const [highlightSiteId, setHighlightSiteId] = useState<number | null>(null);
|
||||
const [editor, setEditor] = useState<SiteEditorState | null>(null);
|
||||
const [form, setForm] = useState<SiteForm>(emptySiteForm());
|
||||
const apiEndpointDraftIdRef = useRef(0);
|
||||
const createApiEndpointDraftId = () => {
|
||||
apiEndpointDraftIdRef.current += 1;
|
||||
return `site-api-endpoint-draft-${apiEndpointDraftIdRef.current}`;
|
||||
};
|
||||
const createEmptyApiEndpointRow = (): SiteApiEndpointField => ({
|
||||
...emptySiteApiEndpoint(),
|
||||
draftId: createApiEndpointDraftId(),
|
||||
});
|
||||
const hydrateSiteForm = (value: SiteForm): SiteForm => {
|
||||
const sourceRows = value.apiEndpoints.length > 0 ? value.apiEndpoints : [createEmptyApiEndpointRow()];
|
||||
return {
|
||||
...value,
|
||||
apiEndpoints: sourceRows.map((endpoint) => ({
|
||||
...endpoint,
|
||||
draftId: endpoint.draftId || createApiEndpointDraftId(),
|
||||
})),
|
||||
};
|
||||
};
|
||||
const createEmptySiteForm = (): SiteForm => hydrateSiteForm(emptySiteForm());
|
||||
const [form, setForm] = useState<SiteForm>(() => createEmptySiteForm());
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState<number | null>(null);
|
||||
@@ -364,7 +408,7 @@ export default function Sites() {
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditor(null);
|
||||
setForm(emptySiteForm());
|
||||
setForm(createEmptySiteForm());
|
||||
setSelectedInitializationPresetId(null);
|
||||
};
|
||||
|
||||
@@ -381,14 +425,14 @@ export default function Sites() {
|
||||
return;
|
||||
}
|
||||
setEditor({ mode: 'add' });
|
||||
setForm(emptySiteForm());
|
||||
setForm(createEmptySiteForm());
|
||||
setSelectedInitializationPresetId(null);
|
||||
scrollToEditorTop();
|
||||
};
|
||||
|
||||
const openEdit = (site: SiteRow) => {
|
||||
setEditor({ mode: 'edit', editingSiteId: site.id });
|
||||
setForm(siteFormFromSite(site));
|
||||
setForm(hydrateSiteForm(siteFormFromSite(site)));
|
||||
setSelectedInitializationPresetId(detectSiteInitializationPreset(site.url, site.platform)?.id || null);
|
||||
scrollToEditorTop();
|
||||
// Load disabled models and discovered models independently so a best-effort
|
||||
@@ -471,6 +515,11 @@ export default function Sites() {
|
||||
toast.error(serializedCustomHeaders.error || '自定义请求头格式不正确');
|
||||
return;
|
||||
}
|
||||
const serializedApiEndpoints = serializeSiteApiEndpoints(form.apiEndpoints);
|
||||
if (!serializedApiEndpoints.valid) {
|
||||
toast.error(serializedApiEndpoints.error || 'AI 请求地址格式不正确');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
@@ -480,6 +529,7 @@ export default function Sites() {
|
||||
initializationPresetId: selectedInitializationPresetId,
|
||||
proxyUrl: form.proxyUrl.trim(),
|
||||
useSystemProxy: !!form.useSystemProxy,
|
||||
apiEndpoints: serializedApiEndpoints.apiEndpoints,
|
||||
customHeaders: serializedCustomHeaders.customHeaders,
|
||||
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
|
||||
};
|
||||
@@ -551,6 +601,48 @@ export default function Sites() {
|
||||
});
|
||||
};
|
||||
|
||||
const updateApiEndpointRow = (index: number, patch: Partial<SiteApiEndpointField>) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
apiEndpoints: prev.apiEndpoints.map((item, itemIndex) => (
|
||||
itemIndex === index
|
||||
? { ...item, ...patch }
|
||||
: item
|
||||
)),
|
||||
}));
|
||||
};
|
||||
|
||||
const addApiEndpointRow = () => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
apiEndpoints: [...prev.apiEndpoints, createEmptyApiEndpointRow()],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeApiEndpointRow = (index: number) => {
|
||||
setForm((prev) => {
|
||||
const nextEndpoints = prev.apiEndpoints.filter((_, itemIndex) => itemIndex !== index);
|
||||
return {
|
||||
...prev,
|
||||
apiEndpoints: nextEndpoints.length > 0 ? nextEndpoints : [createEmptyApiEndpointRow()],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const moveApiEndpointRow = (index: number, direction: 'up' | 'down') => {
|
||||
setForm((prev) => {
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= prev.apiEndpoints.length) return prev;
|
||||
const nextEndpoints = [...prev.apiEndpoints];
|
||||
const [current] = nextEndpoints.splice(index, 1);
|
||||
nextEndpoints.splice(targetIndex, 0, current);
|
||||
return {
|
||||
...prev,
|
||||
apiEndpoints: nextEndpoints,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSiteCreatedChoice = (choice: 'session' | 'apikey' | 'later') => {
|
||||
if (!createdSiteForChoice) return;
|
||||
|
||||
@@ -935,7 +1027,8 @@ export default function Sites() {
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<input
|
||||
placeholder="站点 URL (例如 https://api.example.com)"
|
||||
data-testid="site-primary-url-input"
|
||||
placeholder="站点 URL(面板/登录/签到地址,如 https://nih.cc)"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
||||
onBlur={() => {
|
||||
@@ -1008,6 +1101,100 @@ export default function Sites() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||
主站点 URL 用于登录、签到、面板接口和系统访问令牌管理;如果 AI 请求地址和主站点不同,请在下面的 AI 请求地址池里填写。
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
padding: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
AI 请求地址池
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addApiEndpointRow}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
+ 添加 AI 地址
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
|
||||
这里只用于 `/v1/*`、模型发现和 API Key 验证。不填时默认跟随主站点 URL;多条地址会按列表顺序参与轮询,禁用的地址不会参与调度。
|
||||
</div>
|
||||
{form.apiEndpoints.map((endpoint, index) => (
|
||||
<div
|
||||
key={endpoint.draftId || `site-api-endpoint-draft-${index}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: 10,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-bg)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
|
||||
<input
|
||||
placeholder="AI 请求地址(如 https://api.nih.cc)"
|
||||
value={endpoint.url}
|
||||
onChange={(e) => updateApiEndpointRow(index, { url: e.target.value })}
|
||||
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={endpoint.enabled !== false}
|
||||
onChange={(e) => updateApiEndpointRow(index, { enabled: e.target.checked })}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
<span>顺序 #{index + 1}</span>
|
||||
{endpoint.cooldownUntil ? <span>冷却至 {formatDateTimeLocal(endpoint.cooldownUntil)}</span> : null}
|
||||
{endpoint.lastFailureReason ? <span>最近失败: {endpoint.lastFailureReason}</span> : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApiEndpointRow(index, 'up')}
|
||||
disabled={index === 0}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
上移
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveApiEndpointRow(index, 'down')}
|
||||
disabled={index >= form.apiEndpoints.length - 1}
|
||||
className="btn btn-link btn-link-muted"
|
||||
>
|
||||
下移
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeApiEndpointRow(index)}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -1218,7 +1405,7 @@ export default function Sites() {
|
||||
style={formInputStyle}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。
|
||||
这里只是 HTTP/SOCKS 代理地址,不是上游 AI API 地址。填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。
|
||||
</div>
|
||||
</div>
|
||||
<label style={{
|
||||
@@ -1368,6 +1555,29 @@ export default function Sites() {
|
||||
</a>
|
||||
) : '-'}
|
||||
/>
|
||||
<MobileField
|
||||
label="AI 请求地址"
|
||||
stacked
|
||||
value={(
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span>{buildSiteApiEndpointSummary(site)}</span>
|
||||
{getConfiguredSiteApiEndpoints(site).map((endpoint, endpointIndex) => (
|
||||
<span
|
||||
key={`mobile-site-endpoint-${site.id}-${endpoint.id ?? endpointIndex}`}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: endpoint.enabled === false ? 'var(--color-text-muted)' : 'var(--color-text-secondary)',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{endpoint.url}
|
||||
{endpoint.enabled === false ? '(已禁用)' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<MobileField
|
||||
label="系统代理"
|
||||
value={(
|
||||
@@ -1505,6 +1715,9 @@ export default function Sites() {
|
||||
自定义头
|
||||
</span>
|
||||
) : null}
|
||||
<span className={`badge ${getConfiguredSiteApiEndpoints(site).length > 0 ? 'badge-warning' : 'badge-muted'}`} style={{ fontSize: 11 }}>
|
||||
AI 地址: {buildSiteApiEndpointSummary(site)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildSiteSaveAction,
|
||||
emptySiteApiEndpoint,
|
||||
emptySiteCustomHeader,
|
||||
emptySiteForm,
|
||||
serializeSiteApiEndpoints,
|
||||
serializeSiteCustomHeaders,
|
||||
siteFormFromSite,
|
||||
} from './sitesEditor.js';
|
||||
@@ -17,6 +19,10 @@ describe('buildSiteSaveAction', () => {
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||
apiEndpoints: [
|
||||
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
|
||||
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
|
||||
],
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
useSystemProxy: false,
|
||||
globalWeight: 1.2,
|
||||
@@ -31,6 +37,10 @@ describe('buildSiteSaveAction', () => {
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||
apiEndpoints: [
|
||||
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
|
||||
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
|
||||
],
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
useSystemProxy: false,
|
||||
globalWeight: 1.2,
|
||||
@@ -48,6 +58,7 @@ describe('buildSiteSaveAction', () => {
|
||||
platform: 'one-api',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: true,
|
||||
apiEndpoints: [],
|
||||
customHeaders: '',
|
||||
globalWeight: 0.8,
|
||||
},
|
||||
@@ -63,6 +74,7 @@ describe('buildSiteSaveAction', () => {
|
||||
platform: 'one-api',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: true,
|
||||
apiEndpoints: [],
|
||||
customHeaders: '',
|
||||
globalWeight: 0.8,
|
||||
},
|
||||
@@ -80,6 +92,7 @@ describe('buildSiteSaveAction', () => {
|
||||
platform: '',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: false,
|
||||
apiEndpoints: [],
|
||||
customHeaders: '',
|
||||
globalWeight: 1,
|
||||
},
|
||||
@@ -94,6 +107,14 @@ describe('buildSiteSaveAction', () => {
|
||||
externalCheckinUrl: null,
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:8080',
|
||||
apiEndpoints: [
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
enabled: false,
|
||||
cooldownUntil: '2026-04-01T00:05:00.000Z',
|
||||
lastFailureReason: 'HTTP 502',
|
||||
},
|
||||
],
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
globalWeight: 1,
|
||||
apiKey: 'sk-legacy-site-key',
|
||||
@@ -101,11 +122,20 @@ describe('buildSiteSaveAction', () => {
|
||||
|
||||
expect(emptySiteForm()).not.toHaveProperty('apiKey');
|
||||
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
|
||||
expect(emptySiteForm().apiEndpoints).toEqual([emptySiteApiEndpoint()]);
|
||||
expect(emptySiteForm().proxyUrl).toBe('');
|
||||
expect(siteFormFromSite(legacySite)).not.toHaveProperty('apiKey');
|
||||
expect(siteFormFromSite({
|
||||
proxyUrl: 'http://127.0.0.1:8080',
|
||||
}).proxyUrl).toBe('http://127.0.0.1:8080');
|
||||
expect(siteFormFromSite(legacySite).apiEndpoints).toEqual([
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
enabled: false,
|
||||
cooldownUntil: '2026-04-01T00:05:00.000Z',
|
||||
lastFailureReason: 'HTTP 502',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses custom headers json into key value rows', () => {
|
||||
@@ -139,4 +169,29 @@ describe('buildSiteSaveAction', () => {
|
||||
error: '请求头 "authorization" 重复了',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes api endpoint rows into ordered payloads', () => {
|
||||
expect(serializeSiteApiEndpoints([
|
||||
{ url: 'https://api-a.example.com/', enabled: true },
|
||||
{ url: 'https://api-b.example.com', enabled: false },
|
||||
emptySiteApiEndpoint(),
|
||||
])).toEqual({
|
||||
valid: true,
|
||||
apiEndpoints: [
|
||||
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
|
||||
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate api endpoints after normalization', () => {
|
||||
expect(serializeSiteApiEndpoints([
|
||||
{ url: 'https://api.example.com/', enabled: true },
|
||||
{ url: 'https://api.example.com', enabled: true },
|
||||
])).toEqual({
|
||||
valid: false,
|
||||
apiEndpoints: [],
|
||||
error: 'AI 请求地址 "https://api.example.com" 重复了',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,14 @@ export type SiteCustomHeaderField = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SiteApiEndpointField = {
|
||||
draftId?: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
cooldownUntil?: string | null;
|
||||
lastFailureReason?: string | null;
|
||||
};
|
||||
|
||||
export type SiteForm = {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -10,6 +18,7 @@ export type SiteForm = {
|
||||
platform: string;
|
||||
proxyUrl: string;
|
||||
useSystemProxy: boolean;
|
||||
apiEndpoints: SiteApiEndpointField[];
|
||||
customHeaders: SiteCustomHeaderField[];
|
||||
globalWeight: string;
|
||||
};
|
||||
@@ -26,6 +35,11 @@ export type SiteSavePayload = {
|
||||
initializationPresetId?: string | null;
|
||||
proxyUrl: string;
|
||||
useSystemProxy: boolean;
|
||||
apiEndpoints: Array<{
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
customHeaders: string;
|
||||
globalWeight: number;
|
||||
};
|
||||
@@ -38,6 +52,15 @@ export function emptySiteCustomHeader(): SiteCustomHeaderField {
|
||||
return { key: '', value: '' };
|
||||
}
|
||||
|
||||
export function emptySiteApiEndpoint(): SiteApiEndpointField {
|
||||
return {
|
||||
url: '',
|
||||
enabled: true,
|
||||
cooldownUntil: null,
|
||||
lastFailureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureSiteCustomHeaderRows(rows: SiteCustomHeaderField[]): SiteCustomHeaderField[] {
|
||||
return rows.length > 0 ? rows : [emptySiteCustomHeader()];
|
||||
}
|
||||
@@ -50,11 +73,16 @@ export function emptySiteForm(): SiteForm {
|
||||
platform: '',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: false,
|
||||
apiEndpoints: [emptySiteApiEndpoint()],
|
||||
customHeaders: [emptySiteCustomHeader()],
|
||||
globalWeight: '1',
|
||||
};
|
||||
}
|
||||
|
||||
function ensureSiteApiEndpointRows(rows: SiteApiEndpointField[]): SiteApiEndpointField[] {
|
||||
return rows.length > 0 ? rows : [emptySiteApiEndpoint()];
|
||||
}
|
||||
|
||||
function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
|
||||
if (typeof raw !== 'string') {
|
||||
return ensureSiteCustomHeaderRows([]);
|
||||
@@ -80,10 +108,35 @@ function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
|
||||
function parseApiEndpointsForEditor(raw: unknown): SiteApiEndpointField[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return ensureSiteApiEndpointRows([]);
|
||||
}
|
||||
|
||||
const rows: SiteApiEndpointField[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
rows.push({
|
||||
url: typeof row.url === 'string' ? row.url : '',
|
||||
enabled: row.enabled !== false,
|
||||
cooldownUntil: typeof row.cooldownUntil === 'string' ? row.cooldownUntil : null,
|
||||
lastFailureReason: typeof row.lastFailureReason === 'string' ? row.lastFailureReason : null,
|
||||
});
|
||||
}
|
||||
return ensureSiteApiEndpointRows(rows);
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'apiEndpoints' | 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
|
||||
externalCheckinUrl?: string | null;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
apiEndpoints?: Array<{
|
||||
url?: string | null;
|
||||
enabled?: boolean | null;
|
||||
cooldownUntil?: string | null;
|
||||
lastFailureReason?: string | null;
|
||||
}> | null;
|
||||
customHeaders?: string | null;
|
||||
globalWeight?: number | string | null;
|
||||
}): SiteForm {
|
||||
@@ -96,11 +149,27 @@ export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' |
|
||||
platform: site.platform ?? '',
|
||||
proxyUrl: site.proxyUrl ?? '',
|
||||
useSystemProxy: !!site.useSystemProxy,
|
||||
apiEndpoints: parseApiEndpointsForEditor(site.apiEndpoints),
|
||||
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
|
||||
globalWeight,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep this in sync with normalizeSiteApiEndpointBaseUrl in
|
||||
// src/server/services/siteApiEndpointService.ts.
|
||||
function normalizeSiteApiEndpointUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeSiteCustomHeaders(fields: SiteCustomHeaderField[]): {
|
||||
valid: boolean;
|
||||
customHeaders: string;
|
||||
@@ -131,6 +200,48 @@ export function serializeSiteCustomHeaders(fields: SiteCustomHeaderField[]): {
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeSiteApiEndpoints(fields: SiteApiEndpointField[]): {
|
||||
valid: boolean;
|
||||
apiEndpoints: Array<{
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
error?: string;
|
||||
} {
|
||||
const apiEndpoints: Array<{
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const field of fields) {
|
||||
const rawUrl = field.url.trim();
|
||||
if (!rawUrl) continue;
|
||||
const normalizedUrl = normalizeSiteApiEndpointUrl(rawUrl);
|
||||
if (!normalizedUrl) continue;
|
||||
if (seen.has(normalizedUrl)) {
|
||||
return {
|
||||
valid: false,
|
||||
apiEndpoints: [],
|
||||
error: `AI 请求地址 "${normalizedUrl}" 重复了`,
|
||||
};
|
||||
}
|
||||
seen.add(normalizedUrl);
|
||||
apiEndpoints.push({
|
||||
url: normalizedUrl || rawUrl,
|
||||
enabled: field.enabled !== false,
|
||||
sortOrder: apiEndpoints.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
apiEndpoints,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteSavePayload): SiteSaveAction {
|
||||
if (editor.mode === 'edit') {
|
||||
if (!Number.isFinite(editor.editingSiteId)) {
|
||||
|
||||
@@ -33,6 +33,13 @@ async function flushMicrotasks() {
|
||||
});
|
||||
}
|
||||
|
||||
function findPrimarySiteUrlInput(root: ReactTestRenderer) {
|
||||
return root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['data-testid'] === 'site-primary-url-input'
|
||||
));
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div>{`${location.pathname}${location.search}`}</div>;
|
||||
@@ -78,7 +85,7 @@ async function createSiteAndClickModalChoice(
|
||||
const nameInput = root.root.find((node) => node.type === 'input' && node.props.placeholder === '站点名称');
|
||||
const urlInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
|
||||
&& node.props['data-testid'] === 'site-primary-url-input'
|
||||
));
|
||||
const selects = root.root.findAllByType(ModernSelect);
|
||||
const platformSelect = selects.at(-1);
|
||||
@@ -307,10 +314,7 @@ describe('Sites create redirect', () => {
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const urlInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
|
||||
));
|
||||
const urlInput = findPrimarySiteUrlInput(root);
|
||||
const selects = root.root.findAllByType(ModernSelect);
|
||||
const platformSelect = selects.at(-1);
|
||||
|
||||
@@ -363,10 +367,7 @@ describe('Sites create redirect', () => {
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const urlInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
|
||||
));
|
||||
const urlInput = findPrimarySiteUrlInput(root);
|
||||
const platformSelect = root.root.findAllByType(ModernSelect).at(-1);
|
||||
|
||||
await act(async () => {
|
||||
@@ -419,10 +420,7 @@ describe('Sites create redirect', () => {
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const urlInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
|
||||
));
|
||||
const urlInput = findPrimarySiteUrlInput(root);
|
||||
|
||||
await act(async () => {
|
||||
urlInput.props.onChange({ target: { value: 'https://coding.dashscope.aliyuncs.com/v1' } });
|
||||
@@ -531,10 +529,7 @@ describe('Sites create redirect', () => {
|
||||
|
||||
// Fill form
|
||||
const nameInput = root.root.find((node) => node.type === 'input' && node.props.placeholder === '站点名称');
|
||||
const urlInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
|
||||
));
|
||||
const urlInput = findPrimarySiteUrlInput(root);
|
||||
const selects = root.root.findAllByType(ModernSelect);
|
||||
const platformSelect = selects.at(-1);
|
||||
const saveButton = root.root.find((node) => (
|
||||
|
||||
Reference in New Issue
Block a user