feat: add CASL authorization and AI configuration
This commit is contained in:
@@ -32,6 +32,7 @@ const AttendancePage = lazy(() => import('./pages/Attendance'));
|
||||
const TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace'));
|
||||
const NotificationsPage = lazy(() => import('./pages/Notifications'));
|
||||
const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig'));
|
||||
const AiConfigPage = lazy(() => import('./pages/AiConfig'));
|
||||
|
||||
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const token = localStorage.getItem('token');
|
||||
@@ -274,6 +275,15 @@ const App: React.FC = () => {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="ai-config"
|
||||
element={
|
||||
<PermissionRoute permission="ai:config:read">
|
||||
<AiConfigPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
LaptopOutlined,
|
||||
BellOutlined,
|
||||
ApiOutlined,
|
||||
RobotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import api from '../api';
|
||||
@@ -115,6 +116,7 @@ const allMenuItems: MenuItemType[] = [
|
||||
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
|
||||
{ key: '/permissions', icon: <KeyOutlined />, label: '权限一览', permission: 'role:view' },
|
||||
{ key: '/integration-config', icon: <ApiOutlined />, label: '钉钉集成配置', permission: 'integration:read' },
|
||||
{ key: '/ai-config', icon: <RobotOutlined />, label: 'AI 模型配置', permission: 'ai:config:read' },
|
||||
{ key: '/users', icon: <SettingOutlined />, label: '账号管理', permission: 'user:view' },
|
||||
],
|
||||
},
|
||||
|
||||
115
apps/admin/src/pages/AiConfig/helpers.integration.test.ts
Normal file
115
apps/admin/src/pages/AiConfig/helpers.integration.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
shouldAutoSwapBaseUrl,
|
||||
formatDateTime,
|
||||
sourceLabel,
|
||||
sourceColor,
|
||||
PROVIDER_DEFAULTS,
|
||||
extractErrorMessage,
|
||||
} from './helpers';
|
||||
|
||||
describe('AiConfig helpers', () => {
|
||||
describe('shouldAutoSwapBaseUrl', () => {
|
||||
it('swaps to default on first provider selection', () => {
|
||||
const result = shouldAutoSwapBaseUrl('DEEPSEEK', '', null);
|
||||
expect(result.shouldSwap).toBe(true);
|
||||
expect(result.baseUrl).toBe(PROVIDER_DEFAULTS.DEEPSEEK);
|
||||
});
|
||||
|
||||
it('swaps when current baseUrl matches previous provider default', () => {
|
||||
const result = shouldAutoSwapBaseUrl(
|
||||
'DEEPSEEK',
|
||||
'https://api.openai.com/v1',
|
||||
'OPENAI',
|
||||
);
|
||||
expect(result.shouldSwap).toBe(true);
|
||||
expect(result.baseUrl).toBe(PROVIDER_DEFAULTS.DEEPSEEK);
|
||||
});
|
||||
|
||||
it('swaps when current baseUrl is empty', () => {
|
||||
const result = shouldAutoSwapBaseUrl('DEEPSEEK', '', 'OPENAI');
|
||||
expect(result.shouldSwap).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps custom baseUrl unchanged', () => {
|
||||
const result = shouldAutoSwapBaseUrl(
|
||||
'OPENAI',
|
||||
'https://custom.api.com/v1',
|
||||
'DEEPSEEK',
|
||||
);
|
||||
expect(result.shouldSwap).toBe(false);
|
||||
expect(result.baseUrl).toBe('https://custom.api.com/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('returns hyphen for null', () => {
|
||||
expect(formatDateTime(null)).toBe('-');
|
||||
});
|
||||
|
||||
it('returns Chinese locale string for ISO date', () => {
|
||||
expect(formatDateTime('2024-01-15T10:30:00Z')).toContain('2024');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceLabel', () => {
|
||||
it('returns Chinese labels', () => {
|
||||
expect(sourceLabel('database')).toBe('数据库');
|
||||
expect(sourceLabel('environment')).toBe('环境变量');
|
||||
expect(sourceLabel('none')).toBe('未配置');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceColor', () => {
|
||||
it('returns correct colors', () => {
|
||||
expect(sourceColor('database')).toBe('green');
|
||||
expect(sourceColor('environment')).toBe('blue');
|
||||
expect(sourceColor('none')).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractErrorMessage', () => {
|
||||
it('extracts axios-style response error message', () => {
|
||||
const err = {
|
||||
response: { data: { message: 'API出错' } },
|
||||
};
|
||||
expect(extractErrorMessage(err)).toBe('API出错');
|
||||
});
|
||||
|
||||
it('falls back to message property', () => {
|
||||
const err = { message: 'Network error' };
|
||||
expect(extractErrorMessage(err)).toBe('Network error');
|
||||
});
|
||||
|
||||
it('falls back to default on unknown type', () => {
|
||||
expect(extractErrorMessage('unknown string')).toBe('操作失败');
|
||||
expect(extractErrorMessage(null)).toBe('操作失败');
|
||||
expect(extractErrorMessage(undefined)).toBe('操作失败');
|
||||
});
|
||||
|
||||
it('sanitizes: newlines replaced with spaces', () => {
|
||||
const err = { message: 'line1\nline2\r\nline3' };
|
||||
expect(extractErrorMessage(err)).toBe('line1 line2 line3');
|
||||
});
|
||||
|
||||
it('sanitizes: message > 120 chars truncated with ellipsis', () => {
|
||||
const long = 'x'.repeat(200);
|
||||
const err = { message: long };
|
||||
const result = extractErrorMessage(err);
|
||||
expect(result).toHaveLength(121); // 120 + '…' (1 char)
|
||||
expect(result.endsWith('\u2026')).toBe(true);
|
||||
});
|
||||
|
||||
it('sanitizes: empty trimmed message falls back', () => {
|
||||
const err = { message: ' ' };
|
||||
expect(extractErrorMessage(err)).toBe('操作失败');
|
||||
});
|
||||
|
||||
it('sanitizes: plain object message property sanitized', () => {
|
||||
const err = {
|
||||
message: ' some \n\nerror \r\nmessage ',
|
||||
};
|
||||
expect(extractErrorMessage(err)).toBe('some error message');
|
||||
});
|
||||
});
|
||||
});
|
||||
89
apps/admin/src/pages/AiConfig/helpers.ts
Normal file
89
apps/admin/src/pages/AiConfig/helpers.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// AiConfig helpers — pure functions, no React / DOM dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AiProvider = 'OPENAI' | 'DEEPSEEK' | 'OPENAI_COMPATIBLE';
|
||||
|
||||
export const PROVIDER_OPTIONS: { value: AiProvider; label: string }[] = [
|
||||
{ value: 'OPENAI', label: 'OpenAI' },
|
||||
{ value: 'DEEPSEEK', label: 'DeepSeek' },
|
||||
{ value: 'OPENAI_COMPATIBLE', label: 'OpenAI 兼容' },
|
||||
];
|
||||
|
||||
export const PROVIDER_DEFAULTS: Record<AiProvider, string> = {
|
||||
OPENAI: 'https://api.openai.com/v1',
|
||||
DEEPSEEK: 'https://api.deepseek.com',
|
||||
OPENAI_COMPATIBLE: '',
|
||||
} as const;
|
||||
|
||||
export const FIXED_PROVIDERS: AiProvider[] = ['OPENAI', 'DEEPSEEK'];
|
||||
|
||||
export function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return '-';
|
||||
return new Date(iso).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function sourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case 'database':
|
||||
return '数据库';
|
||||
case 'environment':
|
||||
return '环境变量';
|
||||
default:
|
||||
return '未配置';
|
||||
}
|
||||
}
|
||||
|
||||
export function sourceColor(source: string): 'green' | 'blue' | 'default' {
|
||||
switch (source) {
|
||||
case 'database':
|
||||
return 'green';
|
||||
case 'environment':
|
||||
return 'blue';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldAutoSwapBaseUrl(
|
||||
provider: AiProvider,
|
||||
currentBaseUrl: string,
|
||||
lastProvider: AiProvider | null,
|
||||
): { baseUrl: string; shouldSwap: boolean } {
|
||||
if (!lastProvider) {
|
||||
return { baseUrl: PROVIDER_DEFAULTS[provider], shouldSwap: true };
|
||||
}
|
||||
const prevDefault = PROVIDER_DEFAULTS[lastProvider];
|
||||
if (!currentBaseUrl || currentBaseUrl === prevDefault) {
|
||||
return { baseUrl: PROVIDER_DEFAULTS[provider], shouldSwap: true };
|
||||
}
|
||||
return { baseUrl: currentBaseUrl, shouldSwap: false };
|
||||
}
|
||||
|
||||
/** Extract a safe user-facing error message from any caught value */
|
||||
export function extractErrorMessage(err: unknown, fallback: string = '操作失败'): string {
|
||||
let msg = '';
|
||||
|
||||
// axios-style error: { response: { data: { message: string } } }
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const resp: unknown = err.response;
|
||||
if (resp && typeof resp === 'object' && 'data' in resp) {
|
||||
const data: unknown = resp.data;
|
||||
if (data && typeof data === 'object' && 'message' in data && typeof data.message === 'string') {
|
||||
msg = data.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// standard Error or any object with a string message property
|
||||
if (!msg && err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||
msg = err.message;
|
||||
}
|
||||
|
||||
// sanitize: trim, collapse whitespace, strip newlines, truncate
|
||||
const trimmed = msg.trim();
|
||||
if (!trimmed) return fallback;
|
||||
|
||||
const singleLine = trimmed.replace(/[\n\r]+/g, ' ').replace(/ {2,}/g, ' ');
|
||||
return singleLine.length > 120 ? singleLine.slice(0, 120) + '\u2026' : singleLine;
|
||||
}
|
||||
73
apps/admin/src/pages/AiConfig/index.module.css
Normal file
73
apps/admin/src/pages/AiConfig/index.module.css
Normal file
@@ -0,0 +1,73 @@
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.headerDesc {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.statusRow {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.testResult {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.safetyNote {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #389e0d;
|
||||
}
|
||||
|
||||
.safetyNoteKey {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #fff7e6;
|
||||
border: 1px solid #ffd591;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #d46b08;
|
||||
}
|
||||
506
apps/admin/src/pages/AiConfig/index.tsx
Normal file
506
apps/admin/src/pages/AiConfig/index.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
App,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Select,
|
||||
Switch,
|
||||
InputNumber,
|
||||
Tag,
|
||||
Descriptions,
|
||||
Spin,
|
||||
Alert,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined,
|
||||
ApiOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
KeyOutlined,
|
||||
DeleteOutlined,
|
||||
WarningOutlined,
|
||||
} 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 ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AiConfigPage: React.FC = () => {
|
||||
const { hasPermission } = usePermission();
|
||||
const { modal } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [config, setConfig] = useState<AiConfigData | null>(null);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const lastProviderRef = useRef<AiProvider | null>(null);
|
||||
|
||||
const canWrite = hasPermission('ai:config:write');
|
||||
const canTest = hasPermission('ai:config:test');
|
||||
const canRead = hasPermission('ai:config:read');
|
||||
|
||||
// ── Load config ──
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get<ApiResponse<AiConfigData>>('/ai/config');
|
||||
setConfig(res.data);
|
||||
form.setFieldsValue({
|
||||
provider: res.data.provider,
|
||||
baseUrl: res.data.baseUrl,
|
||||
defaultModel: res.data.defaultModel ?? undefined,
|
||||
enabled: res.data.enabled,
|
||||
timeoutMs: res.data.timeoutMs,
|
||||
});
|
||||
lastProviderRef.current = res.data.provider;
|
||||
} catch (err: unknown) {
|
||||
setError(extractErrorMessage(err, '加载配置失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
// ── Provider change → swap baseUrl ──
|
||||
|
||||
const handleProviderChange = useCallback(
|
||||
(provider: AiProvider) => {
|
||||
const currentBaseUrl = form.getFieldValue('baseUrl') || '';
|
||||
const result = shouldAutoSwapBaseUrl(provider, currentBaseUrl, lastProviderRef.current);
|
||||
if (result.shouldSwap) {
|
||||
form.setFieldValue('baseUrl', result.baseUrl);
|
||||
}
|
||||
lastProviderRef.current = provider;
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const currentProvider = Form.useWatch('provider', form) as AiProvider | undefined;
|
||||
const isFixedProvider = currentProvider ? FIXED_PROVIDERS.includes(currentProvider) : false;
|
||||
|
||||
// ── Save ──
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
// Validate baseUrl for OPENAI_COMPATIBLE
|
||||
if (values.provider === 'OPENAI_COMPATIBLE' && !values.baseUrl) {
|
||||
message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
provider: values.provider,
|
||||
baseUrl: values.baseUrl,
|
||||
defaultModel: values.defaultModel || undefined,
|
||||
enabled: values.enabled,
|
||||
timeoutMs: values.timeoutMs,
|
||||
};
|
||||
|
||||
if (values.apiKey && values.apiKey !== '••••') {
|
||||
body.apiKey = values.apiKey;
|
||||
}
|
||||
|
||||
await api.put('/ai/config', body);
|
||||
message.success('配置已保存');
|
||||
form.setFieldValue('apiKey', '');
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
message.error(extractErrorMessage(err, '保存失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, loadConfig]);
|
||||
|
||||
// ── Test connection ──
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
try {
|
||||
// Validated fields: compatible requires baseUrl
|
||||
const fieldsToValidate = ['timeoutMs'] as string[];
|
||||
if (currentProvider === 'OPENAI_COMPATIBLE') {
|
||||
fieldsToValidate.push('baseUrl');
|
||||
}
|
||||
const values = await form.validateFields(fieldsToValidate);
|
||||
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
timeoutMs: values.timeoutMs,
|
||||
};
|
||||
|
||||
// Always send provider if form has it
|
||||
if (currentProvider) body.provider = currentProvider;
|
||||
if (values.baseUrl) body.baseUrl = values.baseUrl;
|
||||
|
||||
// Include defaultModel so backend checks target model
|
||||
const defaultModel = form.getFieldValue('defaultModel');
|
||||
if (defaultModel) body.defaultModel = defaultModel;
|
||||
|
||||
const typedKey = form.getFieldValue('apiKey');
|
||||
if (typedKey && typedKey !== '••••') {
|
||||
body.apiKey = typedKey;
|
||||
}
|
||||
|
||||
const res = await api.post<TestResult>('/ai/config/test', body);
|
||||
setTestResult(res);
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: new Date().toISOString(),
|
||||
message: extractErrorMessage(err, '测试请求失败'),
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [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]);
|
||||
|
||||
// ── 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 ──
|
||||
|
||||
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>
|
||||
|
||||
<Form form={form} layout="vertical" initialValues={{ timeoutMs: 30000, enabled: false }}>
|
||||
<div className={styles.grid}>
|
||||
{/* Left: 模型路由 */}
|
||||
<Card title={<span className={styles.cardTitle}>模型路由</span>} extra={<ApiOutlined />}>
|
||||
<Form.Item
|
||||
name="provider"
|
||||
label="Provider"
|
||||
rules={[{ required: true, message: '请选择 Provider' }]}
|
||||
>
|
||||
<Select
|
||||
options={PROVIDER_OPTIONS}
|
||||
onChange={handleProviderChange}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label="Base URL"
|
||||
rules={[
|
||||
{ required: true, message: '请输入 Base URL' },
|
||||
{ type: 'url', message: '请输入合法的 URL' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={
|
||||
config?.provider
|
||||
? PROVIDER_DEFAULTS[config.provider]
|
||||
: 'https://api.openai.com/v1'
|
||||
}
|
||||
disabled={!canWrite || (isFixedProvider && canWrite)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.enabled !== curr.enabled}>
|
||||
{({ getFieldValue }) => {
|
||||
const enabled = getFieldValue('enabled');
|
||||
return (
|
||||
<Form.Item
|
||||
name="defaultModel"
|
||||
label="默认模型"
|
||||
rules={enabled ? [{ required: true, message: '启用时默认模型为必填项' }] : []}
|
||||
>
|
||||
<Input placeholder="例如: gpt-4, deepseek-chat" disabled={!canWrite} />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch disabled={!canWrite} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="timeoutMs"
|
||||
label="请求超时 (毫秒)"
|
||||
rules={[
|
||||
{ required: true, message: '请输入超时时间' },
|
||||
{ type: 'number', min: 1000, max: 120000, message: '范围: 1000-120000' },
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1000}
|
||||
max={120000}
|
||||
step={1000}
|
||||
style={{ width: '100%' }}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* Right: 密钥保险库 */}
|
||||
<Card
|
||||
title={<span className={styles.cardTitle}>密钥保险库</span>}
|
||||
extra={<KeyOutlined />}
|
||||
>
|
||||
<Form.Item name="apiKey" label="API Key">
|
||||
<Input.Password
|
||||
placeholder={config?.hasApiKey ? '已安全保存,留空则保持不变' : '请输入 API Key'}
|
||||
disabled={!canWrite}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</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" icon={<DeleteOutlined />} 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>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={styles.actions}>
|
||||
<Tooltip title={!canWrite ? '当前角色无写入权限' : undefined}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!canWrite}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={!canTest ? '当前角色无测试权限' : undefined}>
|
||||
<Button
|
||||
icon={<ApiOutlined />}
|
||||
onClick={handleTest}
|
||||
loading={testing}
|
||||
disabled={!canTest}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiConfigPage;
|
||||
Reference in New Issue
Block a user