Some checks failed
CI / check (pull_request) Failing after 2m42s
Drop unused KeyOutlined import and refactor init.sql to drop all tables safely
755 lines
24 KiB
TypeScript
755 lines
24 KiB
TypeScript
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||
import {
|
||
App,
|
||
Card,
|
||
Form,
|
||
Input,
|
||
Button,
|
||
Select,
|
||
AutoComplete,
|
||
InputNumber,
|
||
Tag,
|
||
Descriptions,
|
||
Spin,
|
||
Alert,
|
||
Typography,
|
||
Space,
|
||
Steps,
|
||
} from 'antd';
|
||
import {
|
||
SaveOutlined,
|
||
ApiOutlined,
|
||
CheckCircleOutlined,
|
||
CloseCircleOutlined,
|
||
WarningOutlined,
|
||
ReloadOutlined,
|
||
CloudServerOutlined,
|
||
SafetyOutlined,
|
||
RobotOutlined,
|
||
} from '@ant-design/icons';
|
||
import api from '../../api';
|
||
import { message } from '../../ui/app-message';
|
||
import { usePermission } from '../../hooks/usePermission';
|
||
import type { AiProvider } from './helpers';
|
||
import {
|
||
PROVIDER_OPTIONS,
|
||
PROVIDER_DEFAULTS,
|
||
FIXED_PROVIDERS,
|
||
formatDateTime,
|
||
sourceLabel,
|
||
sourceColor,
|
||
shouldAutoSwapBaseUrl,
|
||
extractErrorMessage,
|
||
} from './helpers';
|
||
import styles from './index.module.css';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface AiConfigData {
|
||
id: number;
|
||
provider: AiProvider;
|
||
baseUrl: string;
|
||
hasApiKey: boolean;
|
||
hasDatabaseKey: boolean;
|
||
maskedApiKey: string | null;
|
||
keySource: 'database' | 'environment' | 'none';
|
||
defaultModel: string | null;
|
||
enabled: boolean;
|
||
timeoutMs: number;
|
||
verified: boolean;
|
||
lastTestedAt: string | null;
|
||
lastTestLatencyMs: number | null;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
interface TestResult {
|
||
success: boolean;
|
||
latencyMs: number | null;
|
||
modelCount: number | null;
|
||
modelAvailable: boolean;
|
||
testedAt: string;
|
||
message: string;
|
||
}
|
||
|
||
interface FetchModelsResult {
|
||
success: boolean;
|
||
models: Array<{ id: string }>;
|
||
message?: string;
|
||
}
|
||
|
||
interface ApiResponse<T> {
|
||
success: boolean;
|
||
data: T;
|
||
message?: string;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Form state — mirrors all form fields, survives Step unmounts
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface FormValues {
|
||
provider: AiProvider;
|
||
baseUrl: string;
|
||
apiKey: string;
|
||
defaultModel: string;
|
||
timeoutMs: number;
|
||
}
|
||
|
||
const DEFAULT_FORM_VALUES: FormValues = {
|
||
provider: 'DEEPSEEK',
|
||
baseUrl: PROVIDER_DEFAULTS['DEEPSEEK'],
|
||
apiKey: '',
|
||
defaultModel: '',
|
||
timeoutMs: 30000,
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Step definitions
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const STEP_ITEMS = [
|
||
{ title: '服务商', description: '选择 AI 服务商' },
|
||
{ title: '密钥', description: '配置 API 密钥' },
|
||
{ title: '模型', description: '获取并选择模型' },
|
||
{ title: '完成', description: '保存并测试连接' },
|
||
];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Page Component
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const AiConfigPage: React.FC = () => {
|
||
const { hasPermission } = usePermission();
|
||
const { modal } = App.useApp();
|
||
const [form] = Form.useForm();
|
||
|
||
const [currentStep, setCurrentStep] = useState(0);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [testing, setTesting] = useState(false);
|
||
const [fetchingModels, setFetchingModels] = useState(false);
|
||
const [config, setConfig] = useState<AiConfigData | null>(null);
|
||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||
const [modelOptions, setModelOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Central form state — survives Step transitions when Form.Items unmount
|
||
const [formValues, setFormValues] = useState<FormValues>(DEFAULT_FORM_VALUES);
|
||
|
||
const lastProviderRef = useRef<AiProvider | null>(null);
|
||
|
||
const canWrite = hasPermission('ai:config:write');
|
||
const canTest = hasPermission('ai:config:test');
|
||
const canRead = hasPermission('ai:config:read');
|
||
|
||
// ── Sync form → state ──
|
||
|
||
const handleFormChange = useCallback((_changed: Partial<FormValues>, all: Partial<FormValues>) => {
|
||
setFormValues((prev) => ({ ...prev, ...all }));
|
||
}, []);
|
||
|
||
// ── Load config (full) — used on initial mount and after save ──
|
||
|
||
const loadConfig = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await api.get<ApiResponse<AiConfigData>>('/ai/config');
|
||
setConfig(res.data);
|
||
|
||
const initial: FormValues = {
|
||
provider: res.data.provider,
|
||
baseUrl: res.data.baseUrl,
|
||
apiKey: '',
|
||
defaultModel: res.data.defaultModel ?? '',
|
||
timeoutMs: res.data.timeoutMs,
|
||
};
|
||
form.setFieldsValue(initial);
|
||
setFormValues(initial);
|
||
lastProviderRef.current = res.data.provider;
|
||
|
||
if (res.data.defaultModel) {
|
||
setModelOptions([{ value: res.data.defaultModel, label: res.data.defaultModel }]);
|
||
}
|
||
} catch (err: unknown) {
|
||
setError(extractErrorMessage(err, '加载配置失败'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [form]);
|
||
|
||
// ── Refresh config (light) — only updates the config info display,
|
||
// does NOT touch form values. Used after test/fetch-models. ──
|
||
|
||
const refreshConfig = useCallback(async () => {
|
||
try {
|
||
const res = await api.get<ApiResponse<AiConfigData>>('/ai/config');
|
||
setConfig(res.data);
|
||
} catch {
|
||
// silent — config display refresh is non-critical
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadConfig();
|
||
}, [loadConfig]);
|
||
|
||
// ── Provider change → swap baseUrl ──
|
||
|
||
const handleProviderChange = useCallback(
|
||
(provider: AiProvider) => {
|
||
const result = shouldAutoSwapBaseUrl(provider, formValues.baseUrl, lastProviderRef.current);
|
||
if (result.shouldSwap) {
|
||
form.setFieldValue('baseUrl', result.baseUrl);
|
||
}
|
||
lastProviderRef.current = provider;
|
||
},
|
||
[form, formValues.baseUrl],
|
||
);
|
||
|
||
const currentProvider = formValues.provider;
|
||
const isFixedProvider = FIXED_PROVIDERS.includes(currentProvider);
|
||
|
||
// ── Fetch models from provider ──
|
||
|
||
const handleFetchModels = useCallback(async () => {
|
||
setFetchingModels(true);
|
||
try {
|
||
await form.validateFields(['provider', 'baseUrl']);
|
||
|
||
const { provider, baseUrl, apiKey: formKey } = formValues;
|
||
const body: Record<string, unknown> = { provider };
|
||
if (baseUrl) body.baseUrl = baseUrl;
|
||
if (formKey && formKey !== '••••') body.apiKey = formKey;
|
||
|
||
const res = await api.post<FetchModelsResult>('/ai/config/models', body);
|
||
if (res.success && res.models.length > 0) {
|
||
const options = res.models.map((m) => ({ value: m.id, label: m.id }));
|
||
setModelOptions(options);
|
||
message.success(`获取到 ${res.models.length} 个模型`);
|
||
} else {
|
||
message.warning(res.message || '未获取到可用模型');
|
||
}
|
||
} catch (err: unknown) {
|
||
message.error(extractErrorMessage(err, '获取模型列表失败'));
|
||
} finally {
|
||
setFetchingModels(false);
|
||
}
|
||
}, [form, formValues]);
|
||
|
||
// ── Save ──
|
||
|
||
const handleSave = useCallback(async () => {
|
||
try {
|
||
// Validate fields (for UI error display) — actual values come from state
|
||
await form.validateFields(['provider', 'baseUrl', 'timeoutMs']);
|
||
|
||
const { provider, baseUrl, defaultModel, apiKey, timeoutMs } = formValues;
|
||
|
||
if (provider === 'OPENAI_COMPATIBLE' && !baseUrl) {
|
||
message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL');
|
||
return;
|
||
}
|
||
|
||
setSaving(true);
|
||
|
||
const resolvedBaseUrl = baseUrl || PROVIDER_DEFAULTS[provider] || '';
|
||
|
||
const body: Record<string, unknown> = {
|
||
provider,
|
||
baseUrl: resolvedBaseUrl,
|
||
defaultModel: defaultModel || undefined,
|
||
enabled: true,
|
||
timeoutMs,
|
||
};
|
||
|
||
if (apiKey && apiKey !== '••••') {
|
||
body.apiKey = apiKey;
|
||
}
|
||
|
||
await api.put('/ai/config', body);
|
||
message.success('配置已保存');
|
||
form.setFieldValue('apiKey', '');
|
||
setFormValues((prev) => ({ ...prev, apiKey: '' }));
|
||
} catch (err: unknown) {
|
||
message.error(extractErrorMessage(err, '保存失败'));
|
||
setSaving(false);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await loadConfig();
|
||
} catch (err: unknown) {
|
||
message.warning(extractErrorMessage(err, '配置已保存,但刷新失败'));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}, [formValues, form, loadConfig]);
|
||
|
||
// ── Test connection ──
|
||
|
||
const handleTest = useCallback(async () => {
|
||
try {
|
||
const fieldsToValidate = ['provider', 'timeoutMs'] as string[];
|
||
if (currentProvider === 'OPENAI_COMPATIBLE') {
|
||
fieldsToValidate.push('baseUrl');
|
||
}
|
||
await form.validateFields(fieldsToValidate);
|
||
|
||
setTesting(true);
|
||
setTestResult(null);
|
||
|
||
const { provider, baseUrl, defaultModel, apiKey, timeoutMs } = formValues;
|
||
const body: Record<string, unknown> = { provider, timeoutMs };
|
||
if (baseUrl) body.baseUrl = baseUrl;
|
||
if (defaultModel) body.defaultModel = defaultModel;
|
||
if (apiKey && apiKey !== '••••') body.apiKey = apiKey;
|
||
|
||
const res = await api.post<TestResult>('/ai/config/test', body);
|
||
setTestResult(res);
|
||
await refreshConfig();
|
||
} catch (err: unknown) {
|
||
setTestResult({
|
||
success: false,
|
||
latencyMs: null,
|
||
modelCount: null,
|
||
modelAvailable: false,
|
||
testedAt: new Date().toISOString(),
|
||
message: extractErrorMessage(err, '测试请求失败'),
|
||
});
|
||
} finally {
|
||
setTesting(false);
|
||
}
|
||
}, [formValues, form, loadConfig, currentProvider]);
|
||
|
||
// ── Clear key ──
|
||
|
||
const handleClearKey = useCallback(() => {
|
||
const isEnv = config?.keySource === 'environment';
|
||
modal.confirm({
|
||
title: '确认清除密钥',
|
||
content: isEnv
|
||
? '数据库中的密钥将被清除,但环境变量 AI_API_KEY 仍可使用。确定继续?'
|
||
: '密钥将被永久清除,之后将无法使用 AI 功能。确定继续?',
|
||
okText: '确认清除',
|
||
okType: 'danger',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await api.post('/ai/config/clear-key');
|
||
message.success('密钥已清除');
|
||
await loadConfig();
|
||
} catch (err: unknown) {
|
||
message.error(extractErrorMessage(err, '清除失败'));
|
||
}
|
||
},
|
||
});
|
||
}, [config, loadConfig, modal]);
|
||
|
||
// ── Step navigation ──
|
||
|
||
const goNext = useCallback(async () => {
|
||
// Validate current step fields before moving
|
||
try {
|
||
if (currentStep === 0) {
|
||
await form.validateFields(['provider', 'baseUrl', 'timeoutMs']);
|
||
} else if (currentStep === 1) {
|
||
// API key step — optional, no validation needed
|
||
} else if (currentStep === 2) {
|
||
await form.validateFields(['defaultModel']);
|
||
}
|
||
setCurrentStep((s) => Math.min(s + 1, STEP_ITEMS.length - 1));
|
||
} catch {
|
||
// Validation failed — form will show errors
|
||
}
|
||
}, [currentStep, form]);
|
||
|
||
const goPrev = useCallback(() => {
|
||
setCurrentStep((s) => Math.max(s - 1, 0));
|
||
}, []);
|
||
|
||
// ── No read permission ──
|
||
|
||
if (!canRead) {
|
||
return (
|
||
<div className={styles.container}>
|
||
<Alert type="error" title="您没有查看 AI 配置的权限" showIcon />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className={styles.container} style={{ textAlign: 'center', paddingTop: 80 }}>
|
||
<Spin size="large" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error && !config) {
|
||
return (
|
||
<div className={styles.container}>
|
||
<Alert type="error" title={error} showIcon />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Render step content ──
|
||
|
||
const renderStepContent = () => {
|
||
switch (currentStep) {
|
||
// Step 0: Provider + Base URL + Timeout
|
||
case 0:
|
||
return (
|
||
<Card
|
||
title={<span className={styles.cardTitle}>服务商配置</span>}
|
||
extra={<CloudServerOutlined />}
|
||
>
|
||
<Form.Item
|
||
name="provider"
|
||
label="Provider"
|
||
rules={[{ required: true, message: '请选择 Provider' }]}
|
||
preserve
|
||
>
|
||
<Select
|
||
options={PROVIDER_OPTIONS}
|
||
onChange={handleProviderChange}
|
||
disabled={!canWrite}
|
||
size="large"
|
||
/>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="baseUrl"
|
||
label="Base URL"
|
||
rules={[
|
||
{ required: true, message: '请输入 Base URL' },
|
||
{ type: 'url', message: '请输入合法的 URL' },
|
||
]}
|
||
preserve
|
||
>
|
||
<Input
|
||
placeholder={
|
||
config?.provider
|
||
? PROVIDER_DEFAULTS[config.provider]
|
||
: 'https://api.deepseek.com'
|
||
}
|
||
disabled={!canWrite || (isFixedProvider && canWrite)}
|
||
size="large"
|
||
/>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
name="timeoutMs"
|
||
label="请求超时 (毫秒)"
|
||
rules={[
|
||
{ required: true, message: '请输入超时时间' },
|
||
{ type: 'number', min: 1000, max: 120000, message: '范围: 1000-120000' },
|
||
]}
|
||
preserve
|
||
>
|
||
<InputNumber
|
||
min={1000}
|
||
max={120000}
|
||
step={1000}
|
||
style={{ width: '100%' }}
|
||
disabled={!canWrite}
|
||
size="large"
|
||
/>
|
||
</Form.Item>
|
||
</Card>
|
||
);
|
||
|
||
// Step 1: API Key
|
||
case 1:
|
||
return (
|
||
<Card
|
||
title={<span className={styles.cardTitle}>密钥配置</span>}
|
||
extra={<SafetyOutlined />}
|
||
>
|
||
<Form.Item name="apiKey" label="API Key" preserve>
|
||
<Input.Password
|
||
placeholder={config?.hasApiKey ? '已安全保存,留空则保持不变' : '请输入 API Key'}
|
||
disabled={!canWrite}
|
||
autoComplete="new-password"
|
||
size="large"
|
||
/>
|
||
</Form.Item>
|
||
|
||
{config && (
|
||
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
|
||
<Descriptions.Item label="状态">
|
||
{config.hasApiKey ? (
|
||
<Tag color="green">{config.maskedApiKey || '••••'}</Tag>
|
||
) : (
|
||
<Tag color="default">未配置</Tag>
|
||
)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="来源">
|
||
<Tag color={sourceColor(config.keySource)}>{sourceLabel(config.keySource)}</Tag>
|
||
{config.keySource === 'environment' && (
|
||
<span style={{ marginLeft: 8, fontSize: 12, color: '#999' }}>
|
||
由环境变量托管,需在服务器修改
|
||
</span>
|
||
)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="最后更新">
|
||
{formatDateTime(config.updatedAt)}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
)}
|
||
|
||
{config?.hasDatabaseKey && canWrite && (
|
||
<div style={{ marginBottom: 8 }}>
|
||
<Button danger size="small" onClick={handleClearKey}>
|
||
清除服务器保存密钥
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{config?.keySource === 'environment' && !config.hasDatabaseKey && (
|
||
<div style={{ marginBottom: 8, fontSize: 12, color: '#999' }}>
|
||
密钥由环境变量提供,无法通过页面清除
|
||
</div>
|
||
)}
|
||
|
||
<div className={styles.safetyNote}>
|
||
API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。传输层通过 HTTPS
|
||
保护,服务端日志不记录密钥。
|
||
</div>
|
||
<div className={styles.safetyNoteKey}>
|
||
也可通过环境变量 <Typography.Text code>AI_API_KEY</Typography.Text> 注入密钥,
|
||
环境变量优先级高于数据库存储。
|
||
</div>
|
||
</Card>
|
||
);
|
||
|
||
// Step 2: Model selection
|
||
case 2:
|
||
return (
|
||
<Card
|
||
title={<span className={styles.cardTitle}>模型选择</span>}
|
||
extra={<RobotOutlined />}
|
||
>
|
||
<div className={styles.modelFetchRow}>
|
||
<Button
|
||
icon={<ReloadOutlined />}
|
||
onClick={handleFetchModels}
|
||
loading={fetchingModels}
|
||
disabled={!canWrite}
|
||
>
|
||
获取模型列表
|
||
</Button>
|
||
{modelOptions.length > 0 && (
|
||
<Tag color="blue">{modelOptions.length} 个可用模型</Tag>
|
||
)}
|
||
</div>
|
||
|
||
<Form.Item
|
||
name="defaultModel"
|
||
label="默认模型"
|
||
rules={[{ required: true, message: '请选择或输入默认模型' }]}
|
||
style={{ marginTop: 16 }}
|
||
preserve
|
||
>
|
||
<AutoComplete
|
||
options={modelOptions}
|
||
placeholder="选择或输入模型名称,如 deepseek-chat, gpt-4"
|
||
disabled={!canWrite}
|
||
size="large"
|
||
filterOption={(inputValue, option) =>
|
||
option?.value?.toLowerCase().includes(inputValue.toLowerCase()) ?? false
|
||
}
|
||
/>
|
||
</Form.Item>
|
||
|
||
{config?.verified && (
|
||
<div style={{ marginTop: 8 }}>
|
||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||
上次验证通过
|
||
</Tag>
|
||
{config.lastTestLatencyMs != null && (
|
||
<span style={{ marginLeft: 8, fontSize: 12, color: '#999' }}>
|
||
延迟: {config.lastTestLatencyMs}ms
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
);
|
||
|
||
// Step 3: Save & Test
|
||
case 3:
|
||
return (
|
||
<Card
|
||
title={<span className={styles.cardTitle}>保存并测试</span>}
|
||
extra={<CheckCircleOutlined />}
|
||
>
|
||
<Alert
|
||
type="info"
|
||
message="配置预览"
|
||
description={
|
||
<Descriptions column={1} size="small" style={{ marginTop: 8 }}>
|
||
<Descriptions.Item label="服务商">
|
||
<Tag color="blue">
|
||
{PROVIDER_OPTIONS.find((o) => o.value === currentProvider)?.label ??
|
||
currentProvider ??
|
||
'-'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="Base URL">
|
||
<Typography.Text code>
|
||
{formValues.baseUrl || '-'}
|
||
</Typography.Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="默认模型">
|
||
<Tag>{formValues.defaultModel || '未设置'}</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="密钥">
|
||
{(() => {
|
||
const hasFormKey = formValues.apiKey && formValues.apiKey !== '••••';
|
||
if (config?.hasApiKey) {
|
||
return <Tag color="green">{config.maskedApiKey || '••••'}</Tag>;
|
||
}
|
||
if (hasFormKey) {
|
||
return <Tag color="blue">已填写(未保存)</Tag>;
|
||
}
|
||
return <Tag color="red">未配置</Tag>;
|
||
})()}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="超时">
|
||
{formValues.timeoutMs}ms
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
<Tag color={config?.enabled ? 'green' : 'default'}>
|
||
{config?.enabled ? '已启用' : '未启用'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
}
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
|
||
<Space>
|
||
{canWrite && (
|
||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving} size="large">
|
||
保存配置
|
||
</Button>
|
||
)}
|
||
{canTest && (
|
||
<Button icon={<ApiOutlined />} onClick={handleTest} loading={testing} size="large">
|
||
测试连接
|
||
</Button>
|
||
)}
|
||
</Space>
|
||
|
||
{/* Test result */}
|
||
{testResult && (
|
||
<Card size="small" className={styles.testResult}>
|
||
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
|
||
<Descriptions.Item label="结果">
|
||
{testResult.success ? (
|
||
testResult.modelAvailable ? (
|
||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||
成功
|
||
</Tag>
|
||
) : (
|
||
<Tag icon={<WarningOutlined />} color="warning">
|
||
模型未找到
|
||
</Tag>
|
||
)
|
||
) : (
|
||
<Tag icon={<CloseCircleOutlined />} color="error">
|
||
失败
|
||
</Tag>
|
||
)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="延迟">
|
||
{testResult.latencyMs != null ? `${testResult.latencyMs} ms` : '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="模型数量">
|
||
{testResult.modelCount != null ? testResult.modelCount : '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="测试时间">
|
||
{formatDateTime(testResult.testedAt)}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
<Alert
|
||
type={
|
||
testResult.success
|
||
? testResult.modelAvailable
|
||
? 'success'
|
||
: 'warning'
|
||
: 'error'
|
||
}
|
||
title={testResult.message}
|
||
style={{ marginTop: 8 }}
|
||
/>
|
||
</Card>
|
||
)}
|
||
</Card>
|
||
);
|
||
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// ── Render ──
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
<div className={styles.header}>
|
||
<h2>AI 模型配置</h2>
|
||
<p className={styles.headerDesc}>密钥仅保存在服务器端,浏览器无法读取明文</p>
|
||
<div className={styles.statusRow}>
|
||
<Space size="small">
|
||
<Tag color={config?.enabled ? 'green' : 'default'}>
|
||
{config?.enabled ? '已启用' : '未启用'}
|
||
</Tag>
|
||
{config?.verified && <Tag color="blue">已验证</Tag>}
|
||
{config?.hasApiKey && (
|
||
<Tag color={sourceColor(config?.keySource || 'none')}>
|
||
密钥: {sourceLabel(config?.keySource || 'none')}
|
||
</Tag>
|
||
)}
|
||
</Space>
|
||
</div>
|
||
</div>
|
||
|
||
<Steps
|
||
current={currentStep}
|
||
items={STEP_ITEMS}
|
||
onChange={setCurrentStep}
|
||
className={styles.steps}
|
||
size="small"
|
||
/>
|
||
|
||
<Form
|
||
form={form}
|
||
layout="vertical"
|
||
initialValues={{ timeoutMs: 30000 }}
|
||
onValuesChange={handleFormChange}
|
||
>
|
||
<div className={styles.stepContent}>{renderStepContent()}</div>
|
||
|
||
<div className={styles.stepNav}>
|
||
<Button onClick={goPrev} disabled={currentStep === 0} icon={<span>←</span>}>
|
||
上一步
|
||
</Button>
|
||
{currentStep < STEP_ITEMS.length - 1 ? (
|
||
<Button type="primary" onClick={goNext} icon={<span>→</span>}>
|
||
下一步
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</Form>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AiConfigPage;
|