Files
ai-learning-platform/frontend/src/app/admin/settings/roles/page.tsx
T
yuzhiran-dev 23edb74bce feat: 支付闭环 + 运营助手 Tool Calling + 管理后台完善
- 支付系统:微信支付 mock 自动完成、NATIVE 扫码支付、JSAPI 集成
- 运营助手:Tool Calling 架构,19 个可执行工具,AI 驱动操作
- 角色管理:表格布局 + Dialog 表单 + 权限勾选
- 配置统一:config.ts 单一数据源
- API 审计:补齐 status toggle / comments 端点
- 暗黑模式硬件编码颜色全部替换为 CSS 变量
2026-05-20 18:29:00 +08:00

428 lines
16 KiB
TypeScript

'use client';
import { useEffect, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/config';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import * as Dialog from '@/components/ui/dialog';
interface Role {
id: string;
name: string;
description: string;
permissions: string[];
status: string;
}
interface Permission {
key: string;
name: string;
category: string;
}
interface Admin {
id: number;
username: string;
nickname: string;
roleId: string | null;
role: { id: string; name: string } | null;
status: string;
}
function getAuthHeaders() {
const token = localStorage.getItem('adminToken');
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}
export default function SettingsRolesPage() {
const router = useRouter();
const [roles, setRoles] = useState<Role[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'roles' | 'admins'>('roles');
const loadData = useCallback(async () => {
setLoading(true);
try {
const headers = getAuthHeaders();
const [rolesRes, permsRes] = await Promise.all([
fetch(`${API_BASE}/admin/settings/roles`, { headers }),
fetch(`${API_BASE}/admin/settings/permissions`, { headers }),
]);
if (rolesRes.ok) setRoles((await rolesRes.json()).items || []);
if (permsRes.ok) setPermissions((await permsRes.json()).items || []);
} catch (e) { console.error(e); }
setLoading(false);
}, []);
useEffect(() => { loadData(); }, [loadData]);
const categories = [...new Set(permissions.map(p => p.category))];
if (loading) {
return <div className="p-6 text-center text-muted-foreground">...</div>;
}
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
</div>
</div>
<div className="flex gap-4 mb-6">
<Button variant={tab === 'roles' ? 'default' : 'outline'} onClick={() => setTab('roles')}></Button>
<Button variant={tab === 'admins' ? 'default' : 'outline'} onClick={() => setTab('admins')}></Button>
</div>
{tab === 'roles' ? (
<RolesTab roles={roles} permissions={permissions} categories={categories} onReload={loadData} />
) : (
<AdminsTab onReload={loadData} />
)}
</div>
);
}
function RolesTab({ roles, permissions, categories, onReload }: {
roles: Role[]; permissions: Permission[]; categories: string[]; onReload: () => void;
}) {
const [editRole, setEditRole] = useState<Role | null>(null);
const [open, setOpen] = useState(false);
return (
<div>
<div className="flex justify-end mb-4">
<Button onClick={() => { setEditRole(null); setOpen(true); }}></Button>
</div>
<RoleDialog
role={editRole}
permissions={permissions}
categories={categories}
open={open}
onOpenChange={setOpen}
onSaved={() => { setOpen(false); onReload(); }}
/>
{roles.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"></div>
) : (
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="text-left px-4 py-3 font-medium text-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-foreground"></th>
<th className="text-right px-4 py-3 font-medium text-foreground"></th>
</tr>
</thead>
<tbody>
{roles.map(role => (
<tr key={role.id} className="border-b border-border hover:bg-muted/30">
<td className="px-4 py-3 font-medium text-foreground">{role.name}</td>
<td className="px-4 py-3 text-muted-foreground">{role.description || '-'}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(role.permissions || []).length === 0 ? (
<span className="text-xs text-muted-foreground"></span>
) : (
role.permissions.map(p => (
<Badge key={p} variant="secondary" className="text-xs">
{permissions.find(perm => perm.key === p)?.name || p}
</Badge>
))
)}
</div>
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => { setEditRole(role); setOpen(true); }}></Button>
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700"
onClick={async () => {
if (!confirm('确定要删除此角色吗?')) return;
await fetch(`${API_BASE}/admin/settings/roles/${role.id}`, { method: 'DELETE', headers: getAuthHeaders() });
onReload();
}}
></Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function RoleDialog({ role, permissions, categories, open, onOpenChange, onSaved }: {
role: Role | null; permissions: Permission[]; categories: string[]; open: boolean; onOpenChange: (v: boolean) => void; onSaved: () => void;
}) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [selectedPerms, setSelectedPerms] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) {
setName(role?.name || '');
setDescription(role?.description || '');
setSelectedPerms(role?.permissions || []);
}
}, [open, role]);
async function handleSave() {
if (!name.trim()) return;
setSaving(true);
try {
const headers = getAuthHeaders();
if (role) {
await fetch(`${API_BASE}/admin/settings/roles/${role.id}`, {
method: 'PUT', headers,
body: JSON.stringify({ name, description, permissions: selectedPerms }),
});
} else {
await fetch(`${API_BASE}/admin/settings/roles`, {
method: 'POST', headers,
body: JSON.stringify({ name, description, permissions: selectedPerms }),
});
}
onSaved();
} catch (e) { console.error(e); }
setSaving(false);
}
return (
<Dialog.Dialog open={open} onOpenChange={onOpenChange}>
<Dialog.DialogContent>
<Dialog.DialogHeader>
<Dialog.DialogTitle>{role ? '编辑角色' : '新建角色'}</Dialog.DialogTitle>
</Dialog.DialogHeader>
<div className="space-y-4 py-2">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="请输入角色名称" />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<textarea
className="flex w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm"
rows={2}
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="请输入角色描述"
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2"></label>
{categories.map(cat => (
<div key={cat} className="mb-2">
<div className="text-xs font-medium text-muted-foreground mb-1">{cat}</div>
<div className="flex flex-wrap gap-2">
{permissions.filter(p => p.category === cat).map(perm => (
<label key={perm.key} className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={selectedPerms.includes(perm.key)}
onChange={e => {
setSelectedPerms(prev =>
e.target.checked
? [...prev, perm.key]
: prev.filter(k => k !== perm.key)
);
}}
className="rounded border-border"
/>
<span className="text-sm text-foreground">{perm.name}</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<Dialog.DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={handleSave} disabled={!name.trim() || saving}>
{saving ? '保存中...' : '保存'}
</Button>
</Dialog.DialogFooter>
</Dialog.DialogContent>
</Dialog.Dialog>
);
}
function AdminsTab({ onReload }: { onReload: () => void }) {
const [admins, setAdmins] = useState<Admin[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [editAdmin, setEditAdmin] = useState<Admin | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
try {
const headers = getAuthHeaders();
const [adminsRes, rolesRes] = await Promise.all([
fetch(`${API_BASE}/admin/settings/admins`, { headers }),
fetch(`${API_BASE}/admin/settings/roles`, { headers }),
]);
if (adminsRes.ok) setAdmins((await adminsRes.json()).items || []);
if (rolesRes.ok) setRoles((await rolesRes.json()).items || []);
} catch (e) { console.error(e); }
setLoading(false);
}, []);
useEffect(() => { loadData(); }, [loadData]);
if (loading) return <div className="text-center py-8 text-muted-foreground">...</div>;
return (
<div>
<div className="flex justify-end mb-4">
<Button onClick={() => { setEditAdmin(null); setOpen(true); }}></Button>
</div>
<AdminDialog
admin={editAdmin}
roles={roles}
open={open}
onOpenChange={setOpen}
onSaved={() => { setOpen(false); loadData(); onReload(); }}
/>
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="text-left px-4 py-3 font-medium text-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-foreground"></th>
<th className="text-left px-4 py-3 font-medium text-foreground"></th>
<th className="text-right px-4 py-3 font-medium text-foreground"></th>
</tr>
</thead>
<tbody>
{admins.map(admin => (
<tr key={admin.id} className="border-b border-border hover:bg-muted/30">
<td className="px-4 py-3 font-medium text-foreground">{admin.username}</td>
<td className="px-4 py-3 text-muted-foreground">{admin.nickname || '-'}</td>
<td className="px-4 py-3">
<Badge variant="secondary" className="text-xs">{admin.role?.name || '未分配'}</Badge>
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => { setEditAdmin(admin); setOpen(true); }}></Button>
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700"
onClick={async () => {
if (!confirm('确定要禁用此管理员吗?')) return;
await fetch(`${API_BASE}/admin/settings/admins/${admin.id}`, {
method: 'PUT', headers: getAuthHeaders(),
body: JSON.stringify({ status: 'DISABLED' }),
});
loadData();
}}
></Button>
</div>
</td>
</tr>
))}
{admins.length === 0 && (
<tr><td colSpan={4} className="text-center py-8 text-muted-foreground"></td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function AdminDialog({ admin, roles, open, onOpenChange, onSaved }: {
admin: Admin | null; roles: Role[]; open: boolean; onOpenChange: (v: boolean) => void; onSaved: () => void;
}) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [nickname, setNickname] = useState('');
const [roleId, setRoleId] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) {
setUsername(admin?.username || '');
setPassword('');
setNickname(admin?.nickname || '');
setRoleId(admin?.roleId || '');
}
}, [open, admin]);
async function handleSave() {
if (!username.trim()) return;
if (!admin && !password.trim()) return;
setSaving(true);
try {
const headers = getAuthHeaders();
if (admin) {
await fetch(`${API_BASE}/admin/settings/admins/${admin.id}`, {
method: 'PUT', headers,
body: JSON.stringify({ nickname, roleId: roleId || null }),
});
} else {
await fetch(`${API_BASE}/admin/settings/admins`, {
method: 'POST', headers,
body: JSON.stringify({ username, password, nickname, roleId: roleId || undefined }),
});
}
onSaved();
} catch (e) { console.error(e); }
setSaving(false);
}
return (
<Dialog.Dialog open={open} onOpenChange={onOpenChange}>
<Dialog.DialogContent>
<Dialog.DialogHeader>
<Dialog.DialogTitle>{admin ? '编辑管理员' : '新建管理员'}</Dialog.DialogTitle>
</Dialog.DialogHeader>
<div className="space-y-4 py-2">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={username} onChange={e => setUsername(e.target.value)} placeholder="登录用户名"
disabled={!!admin} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">{admin ? '新密码(留空不修改)' : '密码'}</label>
<Input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder={admin ? '留空则不修改' : '请输入密码'} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<Input value={nickname} onChange={e => setNickname(e.target.value)} placeholder="可选" />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<select
className="flex w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm"
value={roleId}
onChange={e => setRoleId(e.target.value)}
>
<option value=""></option>
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
</div>
<Dialog.DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={handleSave} disabled={!username.trim() || (!admin && !password.trim()) || saving}>
{saving ? '保存中...' : '保存'}
</Button>
</Dialog.DialogFooter>
</Dialog.DialogContent>
</Dialog.Dialog>
);
}