feat: 系统配置页动态渲染 + AI 配置完善

- 配置页重写:从 API 动态加载配置项,不再依赖硬编码字段列表
- AI 配置扩展:新增 provider API Key/URL、配额、Token 等 11 项配置
- 站点配置扩展:联系电话、公司名称
- 支持新增自定义配置项(+ 新增配置按钮)
- 配置项显示已有 DB 数据 + 预定义字段的合并列表
This commit is contained in:
yuzhiran-dev
2026-05-25 13:54:20 +08:00
parent 25ebcdc622
commit 0b66d752ce
3 changed files with 133 additions and 103 deletions
+120 -59
View File
@@ -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>
);
}
}