feat: 系统配置页动态渲染 + AI 配置完善
- 配置页重写:从 API 动态加载配置项,不再依赖硬编码字段列表 - AI 配置扩展:新增 provider API Key/URL、配额、Token 等 11 项配置 - 站点配置扩展:联系电话、公司名称 - 支持新增自定义配置项(+ 新增配置按钮) - 配置项显示已有 DB 数据 + 预定义字段的合并列表
This commit is contained in:
@@ -3,18 +3,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Config {
|
||||
key: string;
|
||||
value: string;
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
interface Config { key: string; value: string; description: string; category: string }
|
||||
|
||||
const CATEGORIES = ['site', 'ai', 'member'];
|
||||
|
||||
const CATEGORY_NAMES: Record<string, string> = { site: '站点设置', ai: 'AI 配置', member: '会员设置' };
|
||||
|
||||
const CATEGORY_LABELS: Record<string, { key: string; label: string; type: string; placeholder: string }[]> = {
|
||||
site: [
|
||||
{ key: 'site_name', label: '网站名称', type: 'text', placeholder: '宇之然 AI' },
|
||||
{ key: 'site_logo', label: 'Logo URL', type: 'text', placeholder: 'https://...' },
|
||||
{ key: 'icp_number', label: '备案号', type: 'text', placeholder: '京ICP备...' },
|
||||
{ key: 'contact_email', label: '联系邮箱', type: 'email', placeholder: 'admin@example.com' },
|
||||
{ key: 'contact_phone', label: '联系电话', type: 'text', placeholder: '010-...' },
|
||||
{ key: 'company_name', label: '公司名称', type: 'text', placeholder: '北京宇之然科技中心' },
|
||||
],
|
||||
ai: [
|
||||
{ key: 'default_model', label: '默认模型', type: 'text', placeholder: 'general' },
|
||||
{ key: 'available_models', label: '可用模型(逗号分隔)', type: 'text', placeholder: 'general,deepseek-v4-flash' },
|
||||
{ key: 'daily_quota_free', label: '免费用户日配额', type: 'number', placeholder: '10' },
|
||||
{ key: 'daily_quota_monthly', label: '月卡用户日配额', type: 'number', placeholder: '100' },
|
||||
{ key: 'daily_quota_yearly', label: '年卡用户日配额', type: 'number', placeholder: '200' },
|
||||
{ key: 'openai_api_key', label: 'OpenAI API Key', type: 'password', placeholder: 'sk-...' },
|
||||
{ key: 'openai_api_url', label: 'OpenAI API URL', type: 'text', placeholder: 'https://api.openai.com/v1' },
|
||||
{ key: 'sensenova_api_key', label: '商汤 API Key', type: 'password', placeholder: 'sk-...' },
|
||||
{ key: 'sensenova_api_url', label: '商汤 API URL', type: 'text', placeholder: 'https://token.sensenova.cn/v1' },
|
||||
{ key: 'max_tokens', label: '最大 Token 数', type: 'number', placeholder: '2000' },
|
||||
{ key: 'temperature', label: '默认 Temperature', type: 'text', placeholder: '0.7' },
|
||||
],
|
||||
member: [
|
||||
{ key: 'price_monthly', label: '月卡价格(元)', type: 'number', placeholder: '29.9' },
|
||||
{ key: 'price_yearly', label: '年卡价格(元)', type: 'number', placeholder: '199' },
|
||||
{ key: 'quota_monthly', label: '月卡日配额', type: 'number', placeholder: '100' },
|
||||
{ key: 'quota_yearly', label: '年卡日配额', type: 'number', placeholder: '200' },
|
||||
],
|
||||
};
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [configs, setConfigs] = useState<Config[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [category, setCategory] = useState('site');
|
||||
const [form, setForm] = useState<Record<string, string>>({});
|
||||
const [saveMsg, setSaveMsg] = useState('');
|
||||
const [showNewKey, setShowNewKey] = useState(false);
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
useEffect(() => { loadConfigs(); }, [category]);
|
||||
|
||||
@@ -22,9 +56,7 @@ export default function ConfigPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
const res = await fetch(`${API_BASE}/admin/config/${category}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/admin/config/${category}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const configMap: Record<string, string> = {};
|
||||
@@ -38,78 +70,107 @@ export default function ConfigPage() {
|
||||
|
||||
async function saveConfig(key: string) {
|
||||
const token = localStorage.getItem('adminToken');
|
||||
await fetch(`${API_BASE}/admin/config/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ value: form[key] }),
|
||||
});
|
||||
alert('保存成功');
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/config/${key}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ value: form[key] || '' }),
|
||||
});
|
||||
if (res.ok) { setSaveMsg('保存成功'); setTimeout(() => setSaveMsg(''), 2000); }
|
||||
else { setSaveMsg('保存失败'); }
|
||||
} catch { setSaveMsg('保存失败'); }
|
||||
}
|
||||
|
||||
const categories = [
|
||||
{ id: 'site', name: '站点设置' },
|
||||
{ id: 'ai', name: 'AI 配置' },
|
||||
{ id: 'member', name: '会员设置' },
|
||||
];
|
||||
async function addNewConfig() {
|
||||
if (!newKey.trim()) return;
|
||||
const token = localStorage.getItem('adminToken');
|
||||
try {
|
||||
await fetch(`${API_BASE}/admin/config/${newKey}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ value: newValue, description: newDesc, category }),
|
||||
});
|
||||
setShowNewKey(false); setNewKey(''); setNewValue(''); setNewDesc('');
|
||||
loadConfigs();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const fields: Record<string, { key: string; label: string; type: string; placeholder: string }[]> = {
|
||||
site: [
|
||||
{ key: 'site_name', label: '网站名称', type: 'text', placeholder: '宇之然 AI' },
|
||||
{ key: 'site_logo', label: 'Logo URL', type: 'text', placeholder: 'https://...' },
|
||||
{ key: 'icp_number', label: '备案号', type: 'text', placeholder: '京ICP备...' },
|
||||
{ key: 'contact_email', label: '联系邮箱', type: 'email', placeholder: 'admin@example.com' },
|
||||
],
|
||||
ai: [
|
||||
{ key: 'default_model', label: '默认模型', type: 'text', placeholder: 'general' },
|
||||
{ key: 'available_models', label: '可用模型(逗号分隔)', type: 'text', placeholder: 'general,deepseek-v4-flash' },
|
||||
{ key: 'daily_quota_free', label: '免费用户日配额', type: 'number', placeholder: '10' },
|
||||
],
|
||||
member: [
|
||||
{ key: 'price_monthly', label: '月卡价格(元)', type: 'number', placeholder: '29.9' },
|
||||
{ key: 'price_yearly', label: '年卡价格(元)', type: 'number', placeholder: '199' },
|
||||
{ key: 'quota_monthly', label: '月卡日配额', type: 'number', placeholder: '100' },
|
||||
{ key: 'quota_yearly', label: '年卡日配额', type: 'number', placeholder: '200' },
|
||||
],
|
||||
};
|
||||
function getLabel(key: string): string | undefined {
|
||||
for (const cat of Object.values(CATEGORY_LABELS)) {
|
||||
const found = cat.find(f => f.key === key);
|
||||
if (found) return found.label;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function getPlaceholder(key: string): string | undefined {
|
||||
for (const cat of Object.values(CATEGORY_LABELS)) {
|
||||
const found = cat.find(f => f.key === key);
|
||||
if (found) return found.placeholder;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-6">加载中...</div>;
|
||||
|
||||
const allKeys = [...new Set([...(CATEGORY_LABELS[category] || []).map(f => f.key), ...configs.map(c => c.key)])];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-foreground">系统配置</h1>
|
||||
<p className="text-sm text-muted-foreground">配置站点、AI、会员等设置</p>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">系统配置</h1>
|
||||
<p className="text-sm text-muted-foreground">配置站点、AI、会员等设置</p>
|
||||
</div>
|
||||
<button onClick={() => setShowNewKey(!showNewKey)}
|
||||
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
|
||||
{showNewKey ? '取消' : '+ 新增配置'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveMsg && (
|
||||
<div className={`mb-4 px-4 py-2 rounded-lg text-sm ${saveMsg === '保存成功' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{saveMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNewKey && (
|
||||
<div className="bg-card border border-border rounded-xl p-4 mb-6 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">新增配置项</h3>
|
||||
<input value={newKey} onChange={e => setNewKey(e.target.value)} placeholder="配置键名" className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm" />
|
||||
<input value={newValue} onChange={e => setNewValue(e.target.value)} placeholder="配置值" className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm" />
|
||||
<input value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="描述(可选)" className="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm" />
|
||||
<button onClick={addNewConfig} className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">创建</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 mb-6">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setCategory(cat.id)}
|
||||
className={`px-4 py-2 rounded-lg ${category === cat.id ? 'bg-brand-600 text-white' : 'bg-muted text-muted-foreground'}`}
|
||||
>
|
||||
{cat.name}
|
||||
{CATEGORIES.map(cat => (
|
||||
<button key={cat} onClick={() => setCategory(cat)}
|
||||
className={`px-4 py-2 rounded-lg text-sm ${category === cat ? 'bg-brand-600 text-white' : 'bg-muted text-muted-foreground hover:bg-accent'}`}>
|
||||
{CATEGORY_NAMES[cat]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
|
||||
{(fields[category] || []).map(field => (
|
||||
<div key={field.key} className="grid grid-cols-3 gap-4 items-center">
|
||||
<label className="text-sm text-muted-foreground">{field.label}</label>
|
||||
{allKeys.map(key => (
|
||||
<div key={key} className="grid grid-cols-3 gap-4 items-center">
|
||||
<label className="text-sm text-muted-foreground">{getLabel(key) || key}</label>
|
||||
<div className="col-span-2 flex gap-2">
|
||||
<input
|
||||
type={field.type}
|
||||
value={form[field.key] || ''}
|
||||
onChange={e => setForm({ ...form, [field.key]: e.target.value })}
|
||||
placeholder={field.placeholder}
|
||||
className="flex-1 px-3 py-2 border border-border rounded-lg bg-background text-foreground"
|
||||
type="text"
|
||||
value={form[key] || ''}
|
||||
onChange={e => setForm({ ...form, [key]: e.target.value })}
|
||||
placeholder={getPlaceholder(key)}
|
||||
className="flex-1 px-3 py-2 border border-border rounded-lg bg-background text-foreground text-sm"
|
||||
/>
|
||||
<button onClick={() => saveConfig(field.key)} className="px-4 py-2 bg-brand-600 text-white rounded-lg">保存</button>
|
||||
<button onClick={() => saveConfig(key)} className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm whitespace-nowrap hover:bg-brand-700">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{allKeys.length === 0 && <p className="text-sm text-muted-foreground text-center py-8">暂无配置项</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user