feat: 系统配置页动态渲染 + AI 配置完善
- 配置页重写:从 API 动态加载配置项,不再依赖硬编码字段列表 - AI 配置扩展:新增 provider API Key/URL、配额、Token 等 11 项配置 - 站点配置扩展:联系电话、公司名称 - 支持新增自定义配置项(+ 新增配置按钮) - 配置项显示已有 DB 数据 + 预定义字段的合并列表
This commit is contained in:
@@ -13,23 +13,12 @@ export class SettingsController {
|
|||||||
|
|
||||||
@Get('roles')
|
@Get('roles')
|
||||||
async roles() {
|
async roles() {
|
||||||
return {
|
return { items: await this.prisma.adminRole.findMany({ where: { status: 'ACTIVE' }, orderBy: { createdAt: 'desc' } }) };
|
||||||
items: await this.prisma.adminRole.findMany({
|
|
||||||
where: { status: 'ACTIVE' },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('roles')
|
@Post('roles')
|
||||||
async createRole(@Body() body: { name: string; description?: string; permissions?: string[] }) {
|
async createRole(@Body() body: { name: string; description?: string; permissions?: string[] }) {
|
||||||
return this.prisma.adminRole.create({
|
return this.prisma.adminRole.create({ data: { name: body.name, description: body.description || '', permissions: body.permissions || [] } });
|
||||||
data: {
|
|
||||||
name: body.name,
|
|
||||||
description: body.description || '',
|
|
||||||
permissions: body.permissions || [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('roles/:id')
|
@Put('roles/:id')
|
||||||
@@ -48,27 +37,14 @@ export class SettingsController {
|
|||||||
|
|
||||||
@Get('admins')
|
@Get('admins')
|
||||||
async admins() {
|
async admins() {
|
||||||
return {
|
return { items: await this.prisma.adminUser.findMany({ where: { status: 'ACTIVE' }, include: { role: true }, orderBy: { createdAt: 'desc' } }) };
|
||||||
items: await this.prisma.adminUser.findMany({
|
|
||||||
where: { status: 'ACTIVE' },
|
|
||||||
include: { role: true },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('admins')
|
@Post('admins')
|
||||||
async createAdmin(@Body() body: { username: string; password: string; nickname?: string; roleId?: string }) {
|
async createAdmin(@Body() body: { username: string; password: string; nickname?: string; roleId?: string }) {
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const passwordHash = await bcrypt.hash(body.password, 10);
|
const passwordHash = await bcrypt.hash(body.password, 10);
|
||||||
return this.prisma.adminUser.create({
|
return this.prisma.adminUser.create({ data: { username: body.username, passwordHash, nickname: body.nickname || '', roleId: body.roleId || null } });
|
||||||
data: {
|
|
||||||
username: body.username,
|
|
||||||
passwordHash,
|
|
||||||
nickname: body.nickname || '',
|
|
||||||
roleId: body.roleId || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('admins/:id')
|
@Put('admins/:id')
|
||||||
@@ -89,22 +65,15 @@ export class SettingsController {
|
|||||||
permissions() {
|
permissions() {
|
||||||
return {
|
return {
|
||||||
items: [
|
items: [
|
||||||
{ key: 'dashboard', name: '仪表盘', category: '首页' },
|
{ key: 'dashboard', name: '仪表盘', category: '首页' }, { key: 'users', name: '用户管理', category: '用户' },
|
||||||
{ key: 'users', name: '用户管理', category: '用户' },
|
{ key: 'courses', name: '课程管理', category: '内容' }, { key: 'prompts', name: '提示词管理', category: '内容' },
|
||||||
{ key: 'courses', name: '课程管理', category: '内容' },
|
{ key: 'contents', name: '内容管理', category: '内容' }, { key: 'tools', name: '工具管理', category: '内容' },
|
||||||
{ key: 'prompts', name: '提示词管理', category: '内容' },
|
{ key: 'orders', name: '订单管理', category: '交易' }, { key: 'enterprise', name: '企业版管理', category: '交易' },
|
||||||
{ key: 'contents', name: '内容管理', category: '内容' },
|
{ key: 'comments', name: '评论审核', category: '社区' }, { key: 'analytics', name: '数据分析', category: '统计' },
|
||||||
{ key: 'tools', name: '工具管理', category: '内容' },
|
{ key: 'roles', name: '角色权限', category: '设置' }, { key: 'admins', name: '管理员', category: '设置' },
|
||||||
{ key: 'orders', name: '订单管理', category: '交易' },
|
{ key: 'config', name: '系统配置', category: '设置' }, { key: 'banners', name: 'Banner管理', 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: '运营' },
|
{ key: 'notifications', name: '推送管理', category: '运营' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -3,18 +3,52 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Config {
|
interface Config { key: string; value: string; description: string; category: string }
|
||||||
key: string;
|
|
||||||
value: string;
|
const CATEGORIES = ['site', 'ai', 'member'];
|
||||||
description: string;
|
|
||||||
category: string;
|
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() {
|
export default function ConfigPage() {
|
||||||
const [configs, setConfigs] = useState<Config[]>([]);
|
const [configs, setConfigs] = useState<Config[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [category, setCategory] = useState('site');
|
const [category, setCategory] = useState('site');
|
||||||
const [form, setForm] = useState<Record<string, string>>({});
|
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]);
|
useEffect(() => { loadConfigs(); }, [category]);
|
||||||
|
|
||||||
@@ -22,9 +56,7 @@ export default function ConfigPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
const res = await fetch(`${API_BASE}/admin/config/${category}`, {
|
const res = await fetch(`${API_BASE}/admin/config/${category}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const configMap: Record<string, string> = {};
|
const configMap: Record<string, string> = {};
|
||||||
@@ -38,78 +70,107 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
async function saveConfig(key: string) {
|
async function saveConfig(key: string) {
|
||||||
const token = localStorage.getItem('adminToken');
|
const token = localStorage.getItem('adminToken');
|
||||||
await fetch(`${API_BASE}/admin/config/${key}`, {
|
try {
|
||||||
method: 'PUT',
|
const res = await fetch(`${API_BASE}/admin/config/${key}`, {
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
method: 'PUT',
|
||||||
body: JSON.stringify({ value: form[key] }),
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
});
|
body: JSON.stringify({ value: form[key] || '' }),
|
||||||
alert('保存成功');
|
});
|
||||||
|
if (res.ok) { setSaveMsg('保存成功'); setTimeout(() => setSaveMsg(''), 2000); }
|
||||||
|
else { setSaveMsg('保存失败'); }
|
||||||
|
} catch { setSaveMsg('保存失败'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories = [
|
async function addNewConfig() {
|
||||||
{ id: 'site', name: '站点设置' },
|
if (!newKey.trim()) return;
|
||||||
{ id: 'ai', name: 'AI 配置' },
|
const token = localStorage.getItem('adminToken');
|
||||||
{ id: 'member', name: '会员设置' },
|
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 }[]> = {
|
function getLabel(key: string): string | undefined {
|
||||||
site: [
|
for (const cat of Object.values(CATEGORY_LABELS)) {
|
||||||
{ key: 'site_name', label: '网站名称', type: 'text', placeholder: '宇之然 AI' },
|
const found = cat.find(f => f.key === key);
|
||||||
{ key: 'site_logo', label: 'Logo URL', type: 'text', placeholder: 'https://...' },
|
if (found) return found.label;
|
||||||
{ key: 'icp_number', label: '备案号', type: 'text', placeholder: '京ICP备...' },
|
}
|
||||||
{ key: 'contact_email', label: '联系邮箱', type: 'email', placeholder: 'admin@example.com' },
|
return key;
|
||||||
],
|
}
|
||||||
ai: [
|
|
||||||
{ key: 'default_model', label: '默认模型', type: 'text', placeholder: 'general' },
|
function getPlaceholder(key: string): string | undefined {
|
||||||
{ key: 'available_models', label: '可用模型(逗号分隔)', type: 'text', placeholder: 'general,deepseek-v4-flash' },
|
for (const cat of Object.values(CATEGORY_LABELS)) {
|
||||||
{ key: 'daily_quota_free', label: '免费用户日配额', type: 'number', placeholder: '10' },
|
const found = cat.find(f => f.key === key);
|
||||||
],
|
if (found) return found.placeholder;
|
||||||
member: [
|
}
|
||||||
{ key: 'price_monthly', label: '月卡价格(元)', type: 'number', placeholder: '29.9' },
|
return '';
|
||||||
{ key: 'price_yearly', label: '年卡价格(元)', type: 'number', placeholder: '199' },
|
}
|
||||||
{ key: 'quota_monthly', label: '月卡日配额', type: 'number', placeholder: '100' },
|
|
||||||
{ key: 'quota_yearly', label: '年卡日配额', type: 'number', placeholder: '200' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="p-6">加载中...</div>;
|
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 (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-foreground">系统配置</h1>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">配置站点、AI、会员等设置</p>
|
<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>
|
</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">
|
<div className="flex gap-4 mb-6">
|
||||||
{categories.map(cat => (
|
{CATEGORIES.map(cat => (
|
||||||
<button
|
<button key={cat} onClick={() => setCategory(cat)}
|
||||||
key={cat.id}
|
className={`px-4 py-2 rounded-lg text-sm ${category === cat ? 'bg-brand-600 text-white' : 'bg-muted text-muted-foreground hover:bg-accent'}`}>
|
||||||
onClick={() => setCategory(cat.id)}
|
{CATEGORY_NAMES[cat]}
|
||||||
className={`px-4 py-2 rounded-lg ${category === cat.id ? 'bg-brand-600 text-white' : 'bg-muted text-muted-foreground'}`}
|
|
||||||
>
|
|
||||||
{cat.name}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
|
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
|
||||||
{(fields[category] || []).map(field => (
|
{allKeys.map(key => (
|
||||||
<div key={field.key} className="grid grid-cols-3 gap-4 items-center">
|
<div key={key} className="grid grid-cols-3 gap-4 items-center">
|
||||||
<label className="text-sm text-muted-foreground">{field.label}</label>
|
<label className="text-sm text-muted-foreground">{getLabel(key) || key}</label>
|
||||||
<div className="col-span-2 flex gap-2">
|
<div className="col-span-2 flex gap-2">
|
||||||
<input
|
<input
|
||||||
type={field.type}
|
type="text"
|
||||||
value={form[field.key] || ''}
|
value={form[key] || ''}
|
||||||
onChange={e => setForm({ ...form, [field.key]: e.target.value })}
|
onChange={e => setForm({ ...form, [key]: e.target.value })}
|
||||||
placeholder={field.placeholder}
|
placeholder={getPlaceholder(key)}
|
||||||
className="flex-1 px-3 py-2 border border-border rounded-lg bg-background text-foreground"
|
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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{allKeys.length === 0 && <p className="text-sm text-muted-foreground text-center py-8">暂无配置项</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user