feat: add CASL authorization and AI configuration

This commit is contained in:
2026-07-11 14:25:34 +08:00
parent 8f0991a51f
commit 1e1c476bc3
59 changed files with 7733 additions and 120 deletions

View 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;