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
@@ -13,23 +13,12 @@ export class SettingsController {
@Get('roles')
async roles() {
return {
items: await this.prisma.adminRole.findMany({
where: { status: 'ACTIVE' },
orderBy: { createdAt: 'desc' },
}),
};
return { items: await this.prisma.adminRole.findMany({ where: { status: 'ACTIVE' }, orderBy: { createdAt: 'desc' } }) };
}
@Post('roles')
async createRole(@Body() body: { name: string; description?: string; permissions?: string[] }) {
return this.prisma.adminRole.create({
data: {
name: body.name,
description: body.description || '',
permissions: body.permissions || [],
},
});
return this.prisma.adminRole.create({ data: { name: body.name, description: body.description || '', permissions: body.permissions || [] } });
}
@Put('roles/:id')
@@ -48,27 +37,14 @@ export class SettingsController {
@Get('admins')
async admins() {
return {
items: await this.prisma.adminUser.findMany({
where: { status: 'ACTIVE' },
include: { role: true },
orderBy: { createdAt: 'desc' },
}),
};
return { items: await this.prisma.adminUser.findMany({ where: { status: 'ACTIVE' }, include: { role: true }, orderBy: { createdAt: 'desc' } }) };
}
@Post('admins')
async createAdmin(@Body() body: { username: string; password: string; nickname?: string; roleId?: string }) {
const bcrypt = require('bcryptjs');
const passwordHash = await bcrypt.hash(body.password, 10);
return this.prisma.adminUser.create({
data: {
username: body.username,
passwordHash,
nickname: body.nickname || '',
roleId: body.roleId || null,
},
});
return this.prisma.adminUser.create({ data: { username: body.username, passwordHash, nickname: body.nickname || '', roleId: body.roleId || null } });
}
@Put('admins/:id')
@@ -89,20 +65,13 @@ export class SettingsController {
permissions() {
return {
items: [
{ key: 'dashboard', name: '仪表盘', category: '首页' },
{ key: 'users', name: '用户管理', category: '用户' },
{ key: 'courses', name: '课程管理', category: '内容' },
{ key: 'prompts', name: '提示词管理', category: '内容' },
{ key: 'contents', name: '内容管理', category: '内容' },
{ key: 'tools', name: '工具管理', category: '内容' },
{ key: 'orders', name: '订单管理', category: '交易' },
{ key: 'enterprise', name: '企业版管理', category: '交易' },
{ key: 'comments', name: '评论审核', category: '社区' },
{ key: 'analytics', name: '数据分析', category: '统计' },
{ key: 'roles', name: '角色权限', category: '设置' },
{ key: 'admins', name: '管理员', category: '设置' },
{ key: 'config', name: '系统配置', category: '设置' },
{ key: 'banners', name: 'Banner管理', category: '运营' },
{ key: 'dashboard', name: '仪表盘', category: '首页' }, { key: 'users', name: '用户管理', category: '用户' },
{ key: 'courses', name: '课程管理', category: '内容' }, { key: 'prompts', name: '提示词管理', category: '内容' },
{ key: 'contents', name: '内容管理', category: '内容' }, { key: 'tools', name: '工具管理', category: '内容' },
{ key: 'orders', name: '订单管理', category: '交易' }, { key: 'enterprise', name: '企业版管理', category: '交易' },
{ key: 'comments', name: '评论审核', category: '社区' }, { key: 'analytics', name: '数据分析', category: '统计' },
{ key: 'roles', name: '角色权限', category: '设置' }, { key: 'admins', name: '管理', category: '设置' },
{ key: 'config', name: '系统配置', category: '设置' }, { key: 'banners', name: 'Banner管理', category: '运营' },
{ key: 'notifications', name: '推送管理', category: '运营' },
],
};
File diff suppressed because one or more lines are too long
+114 -53
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,77 +70,106 @@ export default function ConfigPage() {
async function saveConfig(key: string) {
const token = localStorage.getItem('adminToken');
await fetch(`${API_BASE}/admin/config/${key}`, {
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] }),
body: JSON.stringify({ value: form[key] || '' }),
});
alert('保存成功');
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">
<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>
);