Files
video-gen/video-gen-admin/src/pages/AdminSettings.tsx
T
2026-07-22 09:27:33 +08:00

533 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Typography, Upload,
} from 'antd';
import {
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
} from '@ant-design/icons';
import {
createSystemConfig,
getGlobalResourceCapacity,
getSystemConfigs,
saveGlobalResourceCapacity,
updateSystemConfig,
uploadLogo,
uploadPdf,
uploadLoginVideo,
} from '../api';
import type { ResourceCapacityUnit, SystemConfig } from '../types';
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
{ value: 'MB', label: 'MB1024 × 1024 字节)' },
{ value: 'GB', label: 'GB1024 × 1024 × 1024 字节)' },
{ value: 'TB', label: 'TB1024 × 1024 × 1024 × 1024 字节)' },
];
const AdminSettings: React.FC = () => {
const [configs, setConfigs] = useState<SystemConfig[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState('');
const [form] = Form.useForm();
useEffect(() => {
load();
}, []);
const load = async () => {
setLoading(true);
try {
const [data, capacity] = await Promise.all([
getSystemConfigs(),
getGlobalResourceCapacity(),
]);
// 确保 llm_media_as_base64 配置存在
if (!data.some(c => c.key === 'llm_media_as_base64')) {
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
}
setConfigs(data);
const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; });
formValues.resource_capacity_enabled = capacity.enabled;
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
form.setFieldsValue(formValues);
} catch (e: any) {
message.error(e?.message || '加载配置失败');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
for (const config of configs) {
const newVal = values[config.key];
if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, String(newVal ?? ''));
}
}
await saveGlobalResourceCapacity({
enabled: !!values.resource_capacity_enabled,
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
limitUnit: values.resource_capacity_limit_unit || 'GB',
});
message.success('系统配置已保存');
await load();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleUpload = async (file: File, configKey: string) => {
setUploading(configKey);
try {
const res = await uploadPdf(file, configKey);
setConfigs(prev => prev.map(c => c.key === configKey ? { ...c, value: res.url } : c));
form.setFieldsValue({ [configKey]: res.url });
// Find the config and update to database
const config = configs.find(c => c.key === configKey);
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('PDF上传成功并已保存');
} catch {
message.error('上传失败');
} finally {
setUploading('');
}
return false;
};
const handleLogoUpload = async (file: File) => {
setUploading('site_logo');
try {
const res = await uploadLogo(file);
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
form.setFieldsValue({ site_logo: res.url });
const config = configs.find(c => c.key === 'site_logo');
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('Logo上传成功并已保存');
} catch {
message.error('上传失败');
} finally {
setUploading('');
}
return false;
};
const handleLoginVideoUpload = async (file: File) => {
setUploading('login_bg_video');
try {
const res = await uploadLoginVideo(file);
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: res.url } : c));
form.setFieldsValue({ login_bg_video: res.url });
const config = configs.find(c => c.key === 'login_bg_video');
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('登录背景视频上传成功并已保存');
} catch (e: any) {
message.error(e?.message || '上传失败');
} finally {
setUploading('');
}
return false;
};
const handleRemoveLoginVideo = async () => {
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: '' } : c));
form.setFieldsValue({ login_bg_video: '' });
const config = configs.find(c => c.key === 'login_bg_video');
if (config) {
await updateSystemConfig(config.id, '');
}
message.success('已移除登录背景视频');
};
const handleToggleBase64 = async (checked: boolean) => {
try {
let config = configs.find(c => c.key === 'llm_media_as_base64');
if (config && config.id && !config.id.startsWith('cfg_')) {
await updateSystemConfig(config.id, checked ? 'true' : 'false');
} else {
const res = await createSystemConfig('llm_media_as_base64', checked ? 'true' : 'false', '文字模型请求时图片/视频使用 base64 编码');
config = res;
}
setConfigs(prev => {
const exists = prev.some(c => c.key === 'llm_media_as_base64');
if (exists) return prev.map(c => c.key === 'llm_media_as_base64' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
return [...prev, config!];
});
message.success(`已${checked ? '开启' : '关闭'}文字模型媒体 base64 编码`);
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const groupedConfigs: Record<string, SystemConfig[]> = {
'站点信息': configs.filter(c => c.key.startsWith('site_')),
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
'其他配置': configs.filter(c => ['operation_manual', 'optimize_hold_credits'].includes(c.key)),
};
const getFieldDescription = (config: SystemConfig): string => {
const descMap: Record<string, string> = {
site_name: '平台显示名称,将展示在页面标题和导航栏',
site_logo: '平台Logo图片URL,建议尺寸 200x40px',
site_copyright: '显示在前台登录页底部的版权信息,例如:© 2024 民众智创 版权所有',
user_agreement_privacy_url: '用户登录时需同意的用户协议及隐私政策PDF文件',
seo_title: '搜索引擎结果中显示的标题',
seo_description: '搜索引擎结果中显示的描述文字,建议150字以内',
seo_keywords: '用逗号分隔的关键词列表',
user_register_credits: '用户注册时赠送的初始积分,默认100',
user_login_credits: '用户每日登录赠送的积分数量',
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
optimize_hold_credits: 'AI创作时预扣积分数量,用于防止并发超卖。预扣后按实际消耗多退少补',
};
return descMap[config.key] || config.description || '';
};
const LogoUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
const hasLogo = config.value && config.value.startsWith('/uploads/');
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const logoUrl = hasLogo ? `${baseUrl}${config.value}` : '';
const handleRemove = () => {
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: '' } : c));
form.setFieldsValue({ site_logo: '' });
message.success('Logo已移除');
};
return (
<div style={{
padding: '16px', borderRadius: 10,
border: '1px solid #f0f0f5', background: '#fafbfc',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Space>
<UploadOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<Typography.Text strong>网站Logo</Typography.Text>
</Space>
<Space>
{hasLogo && (
<Button size="small" danger onClick={handleRemove}>
移除
</Button>
)}
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={handleLogoUpload}
>
<Button size="small" type="primary" icon={<UploadOutlined />} loading={uploading === 'site_logo'}>
{hasLogo ? '重新上传' : '上传Logo'}
</Button>
</Upload>
</Space>
</div>
{hasLogo ? (
<div style={{ textAlign: 'center' }}>
<img
src={logoUrl}
alt="Logo预览"
style={{
maxWidth: 200,
maxHeight: 80,
objectFit: 'contain',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: 8,
}}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
建议尺寸:200x40px,支持 PNGJPG 格式
</Typography.Text>
</div>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
尚未上传Logo,将使用系统默认Logo
</Typography.Text>
)}
</div>
);
};
const PdfUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
const label = config.key === 'user_agreement_privacy_url' ? '用户协议及隐私政策' : '协议文件';
const hasFile = config.value && config.value.startsWith('/uploads/');
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
return (
<div style={{
padding: '16px', borderRadius: 10,
border: '1px solid #f0f0f5', background: '#fafbfc',
marginBottom: 12,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Space>
<FilePdfOutlined style={{ color: '#ef4444', fontSize: 18 }} />
<Typography.Text strong>{label}</Typography.Text>
</Space>
<Space>
{hasFile && (
<Button size="small" icon={<EyeOutlined />}
onClick={() => window.open(`${baseUrl}${config.value}`, '_blank')}>
预览
</Button>
)}
<Upload
accept=".pdf"
showUploadList={false}
beforeUpload={(file) => handleUpload(file, config.key)}
>
<Button size="small" type="primary" icon={<UploadOutlined />} loading={uploading === config.key}>
{hasFile ? '重新上传' : '上传PDF'}
</Button>
</Upload>
</Space>
</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{hasFile ? `已上传: ${config.value}` : '尚未上传,前台将不显示对应链接'}
</Typography.Text>
</div>
);
};
const getFieldComponent = (config: SystemConfig) => {
if (config.key === 'site_logo') {
return <LogoUploadField config={config} />;
}
if (config.key === 'seo_description') {
return <Input.TextArea rows={3} placeholder={config.description} size="large" />;
}
if (config.key === 'seo_keywords') {
return <Input placeholder="关键词1, 关键词2, 关键词3" size="large" />;
}
if (config.key === 'user_login_credits_enabled') {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Switch defaultChecked={config.value === 'true'} />
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{config.value === 'true' ? '已启用' : '已禁用'}
</Typography.Text>
</div>
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits') {
return <Input type="number" min={1} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
};
if (loading) {
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
}
const tabItems = [
{
key: 'basic',
label: '网站基础设置',
children: (
<Form form={form} layout="vertical">
{['站点信息', '协议配置', 'SEO 设置'].map(group => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
{group}
</Typography.Text>
{group === '协议配置' ? (
groupedConfigs[group]?.map(config => (
<PdfUploadField key={config.id} config={config} />
))
) : (
groupedConfigs[group]?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))
)}
</div>
))}
</Form>
),
},
{
key: 'credits',
label: '用户积分配置',
children: (
<Form form={form} layout="vertical">
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
用户积分配置
</Typography.Text>
{groupedConfigs['用户积分配置']?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))}
</div>
</Form>
),
},
{
key: 'other',
label: '其他配置',
children: (
<Form form={form} layout="vertical">
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
其他配置
</Typography.Text>
{groupedConfigs['其他配置']?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))}
</div>
{/* 登录背景视频 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
登录背景视频
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Space>
<VideoCameraOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<Typography.Text strong>背景视频</Typography.Text>
</Space>
<Space>
{form.getFieldValue('login_bg_video') && (
<Button size="small" danger onClick={handleRemoveLoginVideo}>
移除
</Button>
)}
<Upload
accept="video/*,image/gif,image/webp"
showUploadList={false}
beforeUpload={handleLoginVideoUpload}
>
<Button size="small" type="primary" icon={<UploadOutlined />} loading={uploading === 'login_bg_video'}>
上传视频
</Button>
</Upload>
</Space>
</div>
{(() => {
const url = form.getFieldValue('login_bg_video');
if (!url) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
未设置,前台将默认使用 backimage.png
</Typography.Text>
);
}
const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${url}`;
const isGif = url.toLowerCase().endsWith('.gif');
return isGif ? (
<img src={fullUrl} alt="预览" style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#f0f0f5', objectFit: 'contain' }} />
) : (
<video
src={fullUrl}
controls
muted
loop
playsInline
style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#000' }}
/>
);
})()}
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
支持 MP4WebMMOVGIFWebP,最大 50MB
</Typography.Text>
</div>
</div>
{/* 文字模型媒体编码 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
文字模型媒体编码
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<div>
<Typography.Text strong>图片/视频 base64 编码</Typography.Text>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
开启后文字模型请求时将媒体转 base64 发送,而非 URL 链接
</div>
</div>
</Space>
<Switch
checked={(configs.find(c => c.key === 'llm_media_as_base64') || {}).value === 'true'}
onChange={handleToggleBase64}
checkedChildren="base64"
unCheckedChildren="链接"
/>
</div>
</div>
</div>
</Form>
),
},
];
return (
<div style={{ maxWidth: 720 }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<SettingOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>系统设置</Typography.Title>
<Typography.Text type="secondary">管理站点基础信息、用户积分和系统配置</Typography.Text>
</div>
</div>
<Tabs items={tabItems} defaultActiveKey="basic" />
</Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}
size="large" style={{ borderRadius: 8, minWidth: 140 }}>
保存配置
</Button>
</div>
</div>
);
};
export default AdminSettings;