feat: 完成TikTok短剧管理后台基础功能开发

本次提交搭建了完整的后台管理系统,包含:
1.  登录认证与权限校验体系
2.  工作台、短剧、用户、角色、人员、日志等全业务模块
3.  响应式布局与通用样式组件
4.  菜单动态过滤与路由权限控制
5.  移除了README中冗余的权限管理菜单项
This commit is contained in:
2026-07-01 15:24:13 +08:00
parent 6722752d5e
commit 51e26025c6
27 changed files with 1364 additions and 1282 deletions

View File

@@ -48,7 +48,6 @@ VITE_API_BASE_URL=http://localhost:3030
- 工作台
- 短剧管理
- 用户管理
- 权限管理
- 角色管理
- 管理员管理
- 日志管理

File diff suppressed because it is too large Load Diff

28
src/Dashboard/index.css Normal file
View File

@@ -0,0 +1,28 @@
.page-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.metric-card .label {
color: #86909c;
margin-bottom: 8px;
}
.metric-card .value {
font-size: 28px;
font-weight: 700;
}
@media (max-width: 900px) {
.page-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.page-grid {
grid-template-columns: 1fr;
}
}

40
src/Dashboard/index.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { Card } from '@arco-design/web-react';
import type { AdminProfile, AppUser, Drama, RequestLog } from '../api/types';
import { usePageData } from '../hooks/usePageData';
import { hasPermissions } from '../utils/permission';
import { NoAccessPage } from '../components/NoAccessPage';
import { RequestLogTable } from '../components/RequestLogTable';
import './index.css';
function MetricCard({ label, value }: { label: string; value: number | string }) {
return <Card className="metric-card"><div className="label">{label}</div><div className="value">{value}</div></Card>;
}
function DashboardPage({ profile }: { profile: AdminProfile | null }) {
const canReadDramas = hasPermissions(profile, ['drama:read']);
const canReadUsers = hasPermissions(profile, ['user:read']);
const canReadLogs = hasPermissions(profile, ['log:read']);
const dramas = usePageData<Drama>('/api/admin/v1/dramas', canReadDramas);
const users = usePageData<AppUser>('/api/admin/v1/users', canReadUsers);
const logs = usePageData<RequestLog>('/api/admin/v1/logs/requests', canReadLogs);
const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs;
return (
<>
<div className="page-grid">
{canReadDramas && <MetricCard label="短剧管理" value={dramas.data.pagination.total} />}
{canReadUsers && <MetricCard label="用户管理" value={users.data.pagination.total} />}
{canReadLogs && <MetricCard label="请求日志" value={logs.data.pagination.total} />}
<MetricCard label="接口状态" value="运行中" />
</div>
{canReadLogs ? (
<Card className="section-card" title="最近请求">
<RequestLogTable data={logs.data.list.slice(0, 8)} loading={logs.loading} />
</Card>
) : null}
{!hasAnyDataModule ? <NoAccessPage /> : null}
</>
);
}
export default DashboardPage;

47
src/Drama/index.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import { Button, Card, Form, Input, Message, Modal, Table, Tag } from '@arco-design/web-react';
import { IconPlus } from '@arco-design/web-react/icon';
import type { Drama } from '../api/types';
import { api } from '../api/client';
import { usePageData } from '../hooks/usePageData';
function DramasPage() {
const { data, loading, reload } = usePageData<Drama>('/api/admin/v1/dramas');
const [visible, setVisible] = useState(false);
const [creating, setCreating] = useState(false);
const createDrama = async (values: Partial<Drama>) => {
setCreating(true);
try {
await api.post('/api/admin/v1/dramas', { ...values, status: values.status || 'draft' });
Message.success('短剧创建成功');
setVisible(false);
await reload();
} finally {
setCreating(false);
}
};
return (
<Card className="section-card" title="短剧管理" extra={<Button type="primary" icon={<IconPlus />} onClick={() => setVisible(true)}></Button>}>
<Table rowKey="id" loading={loading} data={data.list} pagination={{ total: data.pagination.total, pageSize: data.pagination.pageSize }} columns={[
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '标题', dataIndex: 'title' },
{ title: '类型', dataIndex: 'type', width: 140 },
{ title: '状态', dataIndex: 'status', width: 120, render: (status) => <Tag color={status === 'published' ? 'green' : 'gray'}>{status}</Tag> },
{ title: '推荐', dataIndex: 'isRecommended', width: 140, render: (value) => value ? <Tag color="arcoblue"></Tag> : <Tag></Tag> },
]} />
<Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null}>
<Form layout="vertical" onSubmit={createDrama}>
<Form.Item label="标题" field="title" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item label="类型" field="type" rules={[{ required: true }]}><Input placeholder="revenge、romance、action" /></Form.Item>
<Form.Item label="状态" field="status"><Input placeholder="draft 或 published" /></Form.Item>
<Button type="primary" htmlType="submit" loading={creating}></Button>
</Form>
</Modal>
</Card>
);
}
export default DramasPage;

11
src/Log/index.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { Card } from '@arco-design/web-react';
import type { RequestLog } from '../api/types';
import { usePageData } from '../hooks/usePageData';
import { RequestLogTable } from '../components/RequestLogTable';
function LogsPage() {
const { data, loading } = usePageData<RequestLog>('/api/admin/v1/logs/requests');
return <Card className="section-card" title="请求日志"><RequestLogTable data={data.list} loading={loading} /></Card>;
}
export default LogsPage;

View File

@@ -1,8 +1,3 @@
.app-shell {
min-height: 100vh;
background: #f2f3f5;
}
.login-page {
min-height: 100vh;
display: grid;
@@ -156,185 +151,6 @@
font-size: 12px;
}
.layout {
min-height: 100vh;
}
.sidebar {
box-shadow: 1px 0 0 #e5e6eb;
}
.logo-row {
height: 56px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
border-bottom: 1px solid #e5e6eb;
font-weight: 700;
}
.logo-mark {
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: #ffffff;
background: #165dff;
border-radius: 6px;
font-size: 14px;
}
.header {
height: 56px;
padding: 0 20px;
background: #ffffff;
border-bottom: 1px solid #e5e6eb;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.header-title strong {
font-size: 16px;
}
.header-title span {
color: #86909c;
font-size: 12px;
}
.user-menu-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: #1d2129;
cursor: pointer;
}
.user-menu-trigger:hover {
background: #f2f3f5;
}
.content {
padding: 20px;
}
.page-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.metric-card .label {
color: #86909c;
margin-bottom: 8px;
}
.metric-card .value {
font-size: 28px;
font-weight: 700;
}
.section-card {
border-radius: 8px;
}
.form-label {
margin: 0 0 8px;
color: #4e5969;
font-size: 14px;
font-weight: 500;
}
.form-help {
margin: -12px 0 18px;
color: #86909c;
font-size: 13px;
line-height: 1.6;
}
.permission-tree-panel {
height: 360px;
overflow: auto;
padding: 12px 16px;
border: 1px solid #e5e6eb;
border-radius: 6px;
background: #fbfcfd;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.profile-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 420px;
gap: 16px;
align-items: start;
}
.profile-grid .section-card:first-child {
grid-row: span 2;
}
.profile-summary {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.profile-summary h2 {
margin: 0 0 6px;
font-size: 22px;
}
.profile-summary p {
margin: 0;
color: #86909c;
}
.profile-info-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.profile-info-list div {
padding: 14px;
border: 1px solid #e5e6eb;
border-radius: 8px;
background: #fbfcfd;
}
.profile-info-list span {
display: block;
margin-bottom: 8px;
color: #86909c;
font-size: 13px;
}
.profile-info-list strong {
color: #1d2129;
font-weight: 500;
word-break: break-word;
}
@media (max-width: 900px) {
.login-page {
grid-template-columns: 1fr;
@@ -343,26 +159,4 @@
.login-visual {
display: none;
}
.page-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.profile-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.content {
padding: 12px;
}
.page-grid {
grid-template-columns: 1fr;
}
.profile-info-list {
grid-template-columns: 1fr;
}
}

68
src/Login/index.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { useState } from 'react';
import { Button, Form, Input, Message } from '@arco-design/web-react';
import { IconLock, IconUser } from '@arco-design/web-react/icon';
import { useNavigate } from 'react-router-dom';
import type { ApiResponse } from '../api/types';
import { API_BASE_URL, TOKEN_KEY, api } from '../api/client';
import './index.css';
function LoginPage() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const onSubmit = async (values: { username: string; password: string }) => {
setLoading(true);
try {
const response = await api.post<ApiResponse<{ accessToken: string }>>('/api/admin/v1/auth/login', values);
localStorage.setItem(TOKEN_KEY, response.data.data.accessToken);
Message.success('登录成功');
navigate('/dashboard', { replace: true });
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<div className="login-visual">
<div className="login-logo">
<span className="logo-mark">CT</span>
<span>cth-tk-admin</span>
</div>
<div className="login-brand">
<span className="login-eyebrow">TikTok </span>
<h1>CTH TikTok </h1>
<p></p>
</div>
<div className="login-metrics">
<div className="login-metric"><strong></strong><span></span></div>
<div className="login-metric"><strong></strong><span></span></div>
<div className="login-metric"><strong></strong><span></span></div>
</div>
</div>
<div className="login-panel">
<div className="login-card">
<div className="login-card-header">
<span className="login-card-mark">CT</span>
<div>
<h2></h2>
<p className="subtitle">使</p>
</div>
</div>
<Form layout="vertical" onSubmit={onSubmit} initialValues={{ username: 'admin', password: 'admin123' }}>
<Form.Item label="用户名" field="username" rules={[{ required: true }]}>
<Input prefix={<IconUser />} placeholder="admin" />
</Form.Item>
<Form.Item label="密码" field="password" rules={[{ required: true }]}>
<Input.Password prefix={<IconLock />} placeholder="admin123" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={loading} long></Button>
</Form>
<div className="login-card-footer">API {API_BASE_URL}</div>
</div>
</div>
</div>
);
}
export default LoginPage;

147
src/Personnel/index.tsx Normal file
View File

@@ -0,0 +1,147 @@
import { useState } from 'react';
import { Button, Card, Form, Input, Message, Modal, Select, Space, Table, Tag } from '@arco-design/web-react';
import { IconPlus } from '@arco-design/web-react/icon';
import type { AdminUser, Role } from '../api/types';
import { api } from '../api/client';
import { usePageData } from '../hooks/usePageData';
function PersonnelPage() {
const { data, loading, reload } = usePageData<AdminUser>('/api/admin/v1/admin-users');
const roles = usePageData<Role>('/api/admin/v1/roles');
const [createForm] = Form.useForm();
const [createVisible, setCreateVisible] = useState(false);
const [creating, setCreating] = useState(false);
const [assignVisible, setAssignVisible] = useState(false);
const [assigning, setAssigning] = useState(false);
const [selectedAdmin, setSelectedAdmin] = useState<AdminUser | null>(null);
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>([]);
const openCreate = () => {
createForm.resetFields();
setCreateVisible(true);
};
const createPersonnel = async (values: {
username: string;
password: string;
nickname?: string;
email?: string;
roleIds?: number[];
}) => {
setCreating(true);
try {
await api.post('/api/admin/v1/admin-users', {
...values,
roleIds: values.roleIds ?? [],
});
Message.success('人员创建成功');
setCreateVisible(false);
await reload();
} finally {
setCreating(false);
}
};
const openAssignRoles = (admin: AdminUser) => {
setSelectedAdmin(admin);
setSelectedRoleIds(admin.roles.map((role) => role.id));
setAssignVisible(true);
};
const saveAssignedRoles = async () => {
if (!selectedAdmin) {
return;
}
setAssigning(true);
try {
await api.patch(`/api/admin/v1/admin-users/${selectedAdmin.id}/roles`, {
roleIds: selectedRoleIds,
});
Message.success('角色分配已更新');
setAssignVisible(false);
await reload();
} finally {
setAssigning(false);
}
};
const updateStatus = async (id: number, status: 'active' | 'disabled') => {
await api.patch(`/api/admin/v1/admin-users/${id}/status`, { status });
Message.success('人员状态已更新');
await reload();
};
const roleOptions = roles.data.list.map((role) => (
<Select.Option key={role.id} value={role.id}>{role.name}</Select.Option>
));
return (
<Card className="section-card" title="人员管理" extra={<Button type="primary" icon={<IconPlus />} onClick={openCreate}></Button>}>
<Table rowKey="id" loading={loading} data={data.list} pagination={{ total: data.pagination.total, pageSize: data.pagination.pageSize }} columns={[
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username' },
{ title: '姓名', dataIndex: 'nickname' },
{ title: '邮箱', dataIndex: 'email' },
{ title: '角色', render: (_, record: AdminUser) => <Space wrap>{record.isSuperAdmin ? <Tag color="gold"></Tag> : record.roles.map((role) => <Tag key={role.id}>{role.name}</Tag>)}</Space> },
{ title: '状态', dataIndex: 'status', width: 120, render: (status) => <Tag color={status === 'active' ? 'green' : 'red'}>{status === 'active' ? '启用' : '禁用'}</Tag> },
{ title: '创建时间', dataIndex: 'createdAt', width: 190 },
{
title: '操作',
width: 220,
render: (_, record: AdminUser) => (
<Space>
<Button size="small" onClick={() => openAssignRoles(record)} disabled={record.isSuperAdmin}></Button>
<Button size="small" status={record.status === 'active' ? 'danger' : 'success'} onClick={() => updateStatus(record.id, record.status === 'active' ? 'disabled' : 'active')} disabled={record.isSuperAdmin}>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
</Space>
),
},
]} />
<Modal title="新增人员" visible={createVisible} onCancel={() => setCreateVisible(false)} footer={null}>
<Form form={createForm} layout="vertical" onSubmit={createPersonnel}>
<Form.Item label="用户名" field="username" rules={[{ required: true }]}>
<Input placeholder="请输入登录用户名" />
</Form.Item>
<Form.Item label="初始密码" field="password" rules={[{ required: true }, { minLength: 6, message: '密码至少 6 位' }]}>
<Input.Password placeholder="请输入初始密码" />
</Form.Item>
<Form.Item label="姓名" field="nickname">
<Input placeholder="请输入姓名" />
</Form.Item>
<Form.Item label="邮箱" field="email" rules={[{ type: 'email', message: '请输入正确的邮箱地址' }]}>
<Input placeholder="请输入邮箱" />
</Form.Item>
<Form.Item label="角色" field="roleIds">
<Select mode="multiple" placeholder="请选择角色" allowClear>
{roleOptions}
</Select>
</Form.Item>
<div className="modal-actions">
<Button onClick={() => setCreateVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={creating}></Button>
</div>
</Form>
</Modal>
<Modal
title={`分配角色${selectedAdmin ? ` - ${selectedAdmin.username}` : ''}`}
visible={assignVisible}
confirmLoading={assigning}
onOk={saveAssignedRoles}
onCancel={() => setAssignVisible(false)}
>
<Select
mode="multiple"
placeholder="请选择角色"
value={selectedRoleIds}
onChange={(value) => setSelectedRoleIds(value as number[])}
allowClear
>
{roleOptions}
</Select>
</Modal>
</Card>
);
}
export default PersonnelPage;

65
src/Profile/index.css Normal file
View File

@@ -0,0 +1,65 @@
.profile-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 420px;
gap: 16px;
align-items: start;
}
.profile-grid .section-card:first-child {
grid-row: span 2;
}
.profile-summary {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.profile-summary h2 {
margin: 0 0 6px;
font-size: 22px;
}
.profile-summary p {
margin: 0;
color: #86909c;
}
.profile-info-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.profile-info-list div {
padding: 14px;
border: 1px solid #e5e6eb;
border-radius: 8px;
background: #fbfcfd;
}
.profile-info-list span {
display: block;
margin-bottom: 8px;
color: #86909c;
font-size: 13px;
}
.profile-info-list strong {
color: #1d2129;
font-weight: 500;
word-break: break-word;
}
@media (max-width: 900px) {
.profile-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.profile-info-list {
grid-template-columns: 1fr;
}
}

112
src/Profile/index.tsx Normal file
View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react';
import { Avatar, Button, Card, Form, Input, Message } from '@arco-design/web-react';
import { useNavigate } from 'react-router-dom';
import type { ApiResponse, AdminProfile } from '../api/types';
import { TOKEN_KEY, api } from '../api/client';
import './index.css';
function ProfilePage() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [passwordForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [profile, setProfile] = useState<AdminProfile | null>(null);
const loadProfile = async () => {
setLoading(true);
try {
const response = await api.get<ApiResponse<AdminProfile>>('/api/admin/v1/auth/profile');
setProfile(response.data.data);
form.setFieldsValue({
nickname: response.data.data.nickname,
email: response.data.data.email,
});
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadProfile();
}, []);
const updateProfile = async (values: { nickname?: string; email?: string }) => {
setSaving(true);
try {
const response = await api.patch<ApiResponse<AdminProfile>>('/api/admin/v1/auth/profile', values);
setProfile(response.data.data);
Message.success('个人信息已更新');
} finally {
setSaving(false);
}
};
const changePassword = async (values: { oldPassword: string; newPassword: string; confirmPassword: string }) => {
if (values.newPassword !== values.confirmPassword) {
Message.error('两次输入的新密码不一致');
return;
}
setChangingPassword(true);
try {
await api.patch('/api/admin/v1/auth/password', {
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
Message.success('密码修改成功,请重新登录');
passwordForm.resetFields();
localStorage.removeItem(TOKEN_KEY);
navigate('/login', { replace: true });
} finally {
setChangingPassword(false);
}
};
return (
<div className="profile-grid">
<Card className="section-card" title="个人中心" loading={loading}>
<div className="profile-summary">
<Avatar size={56}>{profile?.username?.slice(0, 1).toUpperCase() || 'A'}</Avatar>
<div>
<h2>{profile?.nickname || profile?.username || '管理员'}</h2>
<p>{profile?.email || '未设置邮箱'}</p>
</div>
</div>
<div className="profile-info-list">
<div><span></span><strong>{profile?.username || '-'}</strong></div>
<div><span></span><strong>{profile?.status === 'active' ? '启用' : '禁用'}</strong></div>
<div><span></span><strong>{profile?.isSuperAdmin ? '超级管理员' : profile?.roles.map((role) => role.name).join('、') || '-'}</strong></div>
<div><span></span><strong>{profile?.createdAt || '-'}</strong></div>
</div>
</Card>
<Card className="section-card" title="修改个人信息">
<Form form={form} layout="vertical" onSubmit={updateProfile}>
<Form.Item label="姓名" field="nickname">
<Input placeholder="请输入姓名" />
</Form.Item>
<Form.Item label="邮箱" field="email" rules={[{ type: 'email', message: '请输入正确的邮箱地址' }]}>
<Input placeholder="请输入邮箱" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={saving}></Button>
</Form>
</Card>
<Card className="section-card" title="修改密码">
<Form form={passwordForm} layout="vertical" onSubmit={changePassword}>
<Form.Item label="旧密码" field="oldPassword" rules={[{ required: true }]}>
<Input.Password placeholder="请输入旧密码" />
</Form.Item>
<Form.Item label="新密码" field="newPassword" rules={[{ required: true }, { minLength: 6, message: '新密码至少 6 位' }]}>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item label="确认新密码" field="confirmPassword" rules={[{ required: true }]}>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={changingPassword}></Button>
</Form>
</Card>
</div>
);
}
export default ProfilePage;

View File

@@ -0,0 +1,163 @@
import { useMemo } from 'react';
import { Tree } from '@arco-design/web-react';
import type { Permission } from '../api/types';
type PermissionTreeNode = {
key: string;
title: string;
children?: PermissionTreeNode[];
};
type PermissionModuleConfig = {
prefix: string;
title: string;
subject: string;
};
const systemPermissionModules: PermissionModuleConfig[] = [
{ prefix: 'permission', title: '权限管理', subject: '权限' },
{ prefix: 'role', title: '角色管理', subject: '角色' },
{ prefix: 'admin-user', title: '人员管理', subject: '人员' },
{ prefix: 'user', title: '用户管理', subject: '用户' },
{ prefix: 'log', title: '日志管理', subject: '日志' },
];
const businessPermissionModules: PermissionModuleConfig[] = [
{ prefix: 'drama', title: '短剧管理', subject: '短剧' },
{ prefix: 'episode', title: '剧集管理', subject: '剧集' },
{ prefix: 'media', title: '媒体管理', subject: '媒体' },
{ prefix: 'payment', title: '支付管理', subject: '支付' },
];
const actionNames: Record<string, string> = {
read: '查看',
create: '创建',
update: '更新',
delete: '删除',
manage: '管理',
};
function permissionKey(id: number) {
return `permission:${id}`;
}
function permissionIdFromKey(key: string) {
const id = Number(key.replace('permission:', ''));
return Number.isInteger(id) ? id : null;
}
function getPermissionPrefix(code: string) {
return code.split(':')[0];
}
function getPermissionAction(code: string) {
return code.split(':')[1] || code;
}
function getPermissionTitle(permission: Permission, module?: PermissionModuleConfig) {
return permission.name || (module ? `${actionNames[getPermissionAction(permission.code)]}${module.subject}` : permission.code);
}
function buildPermissionModuleNode(
permissions: Permission[],
module: PermissionModuleConfig,
): PermissionTreeNode | null {
const children = permissions
.filter((permission) => getPermissionPrefix(permission.code) === module.prefix)
.map((permission) => ({
key: permissionKey(permission.id),
title: getPermissionTitle(permission, module),
}));
if (!children.length) {
return null;
}
return {
key: `module:${module.prefix}`,
title: module.title,
children,
};
}
function buildPermissionTree(permissions: Permission[]): PermissionTreeNode[] {
const knownPrefixes = new Set([
...systemPermissionModules.map((module) => module.prefix),
...businessPermissionModules.map((module) => module.prefix),
]);
const systemChildren = systemPermissionModules
.map((module) => buildPermissionModuleNode(permissions, module))
.filter((node): node is PermissionTreeNode => Boolean(node));
const businessChildren = businessPermissionModules
.map((module) => buildPermissionModuleNode(permissions, module))
.filter((node): node is PermissionTreeNode => Boolean(node));
const otherPermissions = permissions
.filter((permission) => !knownPrefixes.has(getPermissionPrefix(permission.code)))
.map((permission) => ({
key: permissionKey(permission.id),
title: permission.name,
}));
if (otherPermissions.length) {
businessChildren.push({
key: 'module:other',
title: '其他权限',
children: otherPermissions,
});
}
return [
{ key: 'group:system', title: '系统权限', children: systemChildren },
{ key: 'group:business', title: '业务权限', children: businessChildren },
];
}
function collectExpandedKeys(nodes: PermissionTreeNode[]) {
const keys: string[] = [];
const visit = (items: PermissionTreeNode[]) => {
items.forEach((item) => {
if (item.children?.length) {
keys.push(item.key);
visit(item.children);
}
});
};
visit(nodes);
return keys;
}
export function PermissionTreePicker({
permissions,
value,
onChange,
}: {
permissions: Permission[];
value: number[];
onChange: (permissionIds: number[]) => void;
}) {
const treeData = useMemo(() => buildPermissionTree(permissions), [permissions]);
const expandedKeys = useMemo(() => collectExpandedKeys(treeData), [treeData]);
const checkedKeys = value.map((id) => permissionKey(id));
return (
<div className="permission-tree-panel">
<Tree
blockNode
checkable
checkedStrategy="child"
checkedKeys={checkedKeys}
defaultExpandedKeys={expandedKeys}
selectable={false}
showLine
treeData={treeData}
onCheck={(keys) => {
onChange(
keys
.map((key) => permissionIdFromKey(key))
.filter((id): id is number => id !== null),
);
}}
/>
</div>
);
}

22
src/Role/index.css Normal file
View File

@@ -0,0 +1,22 @@
.form-label {
margin: 0 0 8px;
color: #4e5969;
font-size: 14px;
font-weight: 500;
}
.form-help {
margin: -12px 0 18px;
color: #86909c;
font-size: 13px;
line-height: 1.6;
}
.permission-tree-panel {
height: 360px;
overflow: auto;
padding: 12px 16px;
border: 1px solid #e5e6eb;
border-radius: 6px;
background: #fbfcfd;
}

117
src/Role/index.tsx Normal file
View File

@@ -0,0 +1,117 @@
import { useState } from 'react';
import { Button, Card, Form, Input, Message, Modal, Space, Table, Tag } from '@arco-design/web-react';
import { IconPlus } from '@arco-design/web-react/icon';
import type { Permission, Role } from '../api/types';
import { api } from '../api/client';
import { usePageData } from '../hooks/usePageData';
import { PermissionTreePicker } from './PermissionTreePicker';
import './index.css';
function RolesPage() {
const { data, loading, reload } = usePageData<Role>('/api/admin/v1/roles');
const permissions = usePageData<Permission>('/api/admin/v1/permissions');
const [createForm] = Form.useForm();
const [createVisible, setCreateVisible] = useState(false);
const [creatingRole, setCreatingRole] = useState(false);
const [createPermissionIds, setCreatePermissionIds] = useState<number[]>([]);
const [assignmentVisible, setAssignmentVisible] = useState(false);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const [selectedPermissionIds, setSelectedPermissionIds] = useState<number[]>([]);
const [savingPermissions, setSavingPermissions] = useState(false);
const openCreateRole = () => {
createForm.resetFields();
setCreatePermissionIds([]);
setCreateVisible(true);
};
const createRole = async (values: { code: string; name: string; description?: string }) => {
setCreatingRole(true);
try {
await api.post('/api/admin/v1/roles', {
...values,
permissionIds: createPermissionIds,
});
Message.success('角色创建成功');
setCreateVisible(false);
await reload();
} finally {
setCreatingRole(false);
}
};
const openAssignPermissions = (role: Role) => {
setSelectedRole(role);
setSelectedPermissionIds(role.permissions.map((permission) => permission.id));
setAssignmentVisible(true);
};
const saveRolePermissions = async () => {
if (!selectedRole) {
return;
}
setSavingPermissions(true);
try {
await api.patch(`/api/admin/v1/roles/${selectedRole.id}/permissions`, {
permissionIds: selectedPermissionIds,
});
Message.success('角色权限已更新');
setAssignmentVisible(false);
await reload();
} finally {
setSavingPermissions(false);
}
};
return (
<Card className="section-card" title="角色管理" extra={<Button type="primary" icon={<IconPlus />} onClick={openCreateRole}></Button>}>
<Table rowKey="id" loading={loading} data={data.list} pagination={{ total: data.pagination.total, pageSize: data.pagination.pageSize }} columns={[
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '编码', dataIndex: 'code' },
{ title: '名称', dataIndex: 'name' },
{ title: '权限列表', render: (_, record: Role) => <Space wrap>{record.permissions.map((item) => <Tag key={item.id}>{item.name}</Tag>)}</Space> },
{ title: '操作', width: 140, render: (_, record: Role) => <Button size="small" onClick={() => openAssignPermissions(record)}></Button> },
]} />
<Modal title="新增角色" visible={createVisible} onCancel={() => setCreateVisible(false)} footer={null}>
<Form form={createForm} layout="vertical" onSubmit={createRole}>
<Form.Item label="角色编码" field="code" rules={[{ required: true }]}>
<Input placeholder="请输入角色编码,例如 drama_operator" />
</Form.Item>
<div className="form-help"></div>
<Form.Item label="角色名称" field="name" rules={[{ required: true }]}>
<Input placeholder="请输入角色名称" />
</Form.Item>
<Form.Item label="角色描述" field="description">
<Input.TextArea placeholder="请输入角色描述" autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<div className="form-label"></div>
<PermissionTreePicker
permissions={permissions.data.list}
value={createPermissionIds}
onChange={setCreatePermissionIds}
/>
<div className="form-help"></div>
<div className="modal-actions">
<Button onClick={() => setCreateVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={creatingRole}></Button>
</div>
</Form>
</Modal>
<Modal
title={`分配权限${selectedRole ? ` - ${selectedRole.name}` : ''}`}
visible={assignmentVisible}
confirmLoading={savingPermissions}
onOk={saveRolePermissions}
onCancel={() => setAssignmentVisible(false)}
>
<PermissionTreePicker
permissions={permissions.data.list}
value={selectedPermissionIds}
onChange={setSelectedPermissionIds}
/>
</Modal>
</Card>
);
}
export default RolesPage;

27
src/User/index.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { Button, Card, Message, Table, Tag } from '@arco-design/web-react';
import type { AppUser } from '../api/types';
import { api } from '../api/client';
import { usePageData } from '../hooks/usePageData';
function UsersPage() {
const { data, loading, reload } = usePageData<AppUser>('/api/admin/v1/users');
const update状态 = async (id: number, status: 'active' | 'disabled') => {
await api.patch(`/api/admin/v1/users/${id}/status`, { status });
Message.success('用户状态已更新');
await reload();
};
return (
<Card className="section-card" title="小程序用户管理">
<Table rowKey="id" loading={loading} data={data.list} pagination={{ total: data.pagination.total, pageSize: data.pagination.pageSize }} columns={[
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: 'TikTok Open ID', dataIndex: 'tiktokOpenId' },
{ title: '昵称', dataIndex: 'nickname' },
{ title: '状态', dataIndex: 'status', width: 120, render: (status) => <Tag color={status === 'active' ? 'green' : 'red'}>{status}</Tag> },
{ title: '操作', width: 160, render: (_, record: AppUser) => <Button size="small" onClick={() => update状态(record.id, record.status === 'active' ? 'disabled' : 'active')}>{record.status === 'active' ? '禁用' : '启用'}</Button> },
]} />
</Card>
);
}
export default UsersPage;

30
src/api/client.ts Normal file
View File

@@ -0,0 +1,30 @@
import axios, { AxiosError } from 'axios';
import { Message } from '@arco-design/web-react';
import type { ApiResponse, PageData } from './types';
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
export const TOKEN_KEY = 'cth_tk_admin_token';
export const api = axios.create({ baseURL: API_BASE_URL });
api.interceptors.request.use((config) => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiResponse<unknown>>) => {
const message = error.response?.data?.message || error.message || '请求失败';
Message.error(message);
return Promise.reject(error);
},
);
export async function fetchPage<T>(url: string): Promise<PageData<T>> {
const response = await api.get<ApiResponse<PageData<T>>>(url);
return response.data.data;
}

86
src/api/types.ts Normal file
View File

@@ -0,0 +1,86 @@
export type ApiResponse<T> = {
code: number;
data: T;
message: string;
timestamp: string;
requestId: string;
cost: string;
};
export type PageData<T> = {
list: T[];
pagination: {
page: number;
pageSize: number;
total: number;
};
};
export type AdminProfile = {
id: number;
username: string;
nickname?: string;
email?: string;
status: string;
isSuperAdmin: boolean;
roles: Role[];
permissions: Permission[] | ['*'];
createdAt: string;
};
export type Drama = {
id: number;
title: string;
type: string;
status: string;
coverUrl: string;
isRecommended: boolean;
createdAt: string;
};
export type AppUser = {
id: number;
tiktokOpenId: string;
nickname?: string;
status: string;
createdAt: string;
};
export type Permission = {
id: number;
code: string;
name: string;
description?: string;
};
export type Role = {
id: number;
code: string;
name: string;
description?: string;
permissionIds?: number[];
permissions: Permission[];
};
export type AdminUser = {
id: number;
username: string;
nickname?: string;
email?: string;
status: string;
isSuperAdmin: boolean;
roles: Role[];
createdAt: string;
};
export type RequestLog = {
id: number;
requestId: string;
method: string;
path: string;
statusCode: number;
responseCode: number;
message: string;
costMs: number;
createdAt: string;
};

View File

@@ -0,0 +1,11 @@
import { Card, Typography } from '@arco-design/web-react';
const { Text } = Typography;
export function NoAccessPage() {
return (
<Card className="section-card" title="暂无权限">
<Text type="secondary">访</Text>
</Card>
);
}

View File

@@ -0,0 +1,15 @@
import { Table, Tag } from '@arco-design/web-react';
import type { RequestLog } from '../api/types';
export function RequestLogTable({ data, loading }: { data: RequestLog[]; loading: boolean }) {
return (
<Table rowKey="requestId" loading={loading} data={data} pagination={false} columns={[
{ title: '请求 ID', dataIndex: 'requestId', ellipsis: true },
{ title: '方法', dataIndex: 'method', width: 100, render: (value) => <Tag>{value}</Tag> },
{ title: '路径', dataIndex: 'path', ellipsis: true },
{ title: '状态', dataIndex: 'statusCode', width: 100, render: (value) => <Tag color={value < 400 ? 'green' : 'red'}>{value}</Tag> },
{ title: '耗时', dataIndex: 'costMs', width: 100, render: (value) => String(value) + 'ms' },
{ title: '消息', dataIndex: 'message', width: 180 },
]} />
);
}

31
src/hooks/usePageData.ts Normal file
View File

@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { fetchPage } from '../api/client';
import type { PageData } from '../api/types';
export function usePageData<T>(url: string, enabled = true) {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<PageData<T>>({
list: [],
pagination: { page: 1, pageSize: 20, total: 0 },
});
const load = async () => {
if (!enabled) {
return;
}
setLoading(true);
try {
setData(await fetchPage<T>(url));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (enabled) {
void load();
}
}, [url, enabled]);
return { data, loading, reload: load };
}

View File

@@ -23,3 +23,14 @@ textarea,
select {
font: inherit;
}
.section-card {
border-radius: 8px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}

View File

@@ -0,0 +1,23 @@
import type { ReactElement } from 'react';
import { Card } from '@arco-design/web-react';
import type { AdminProfile } from '../api/types';
import { hasPermissions } from '../utils/permission';
import { NoAccessPage } from '../components/NoAccessPage';
export function PermissionRoute({
profile,
requiredPermissions,
children,
}: {
profile: AdminProfile | null;
requiredPermissions?: string[];
children: ReactElement;
}) {
if (!profile) {
return <Card className="section-card" loading />;
}
if (!hasPermissions(profile, requiredPermissions)) {
return <NoAccessPage />;
}
return children;
}

View File

@@ -0,0 +1,10 @@
import type { ReactElement } from 'react';
import { Navigate } from 'react-router-dom';
import { TOKEN_KEY } from '../api/client';
export function ProtectedRoute({ children }: { children: ReactElement }) {
if (!localStorage.getItem(TOKEN_KEY)) {
return <Navigate to="/login" replace />;
}
return children;
}

114
src/layout/index.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { useEffect, useMemo, useState } from 'react';
import {
Avatar,
Button,
Dropdown,
Layout,
Menu,
Space,
Typography,
} from '@arco-design/web-react';
import { IconMenuFold, IconMenuUnfold } from '@arco-design/web-react/icon';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import type { ApiResponse, AdminProfile } from '../api/types';
import { API_BASE_URL, TOKEN_KEY, api } from '../api/client';
import { filterMenuItems, findCurrentMenu, menuItems, renderMenuItem } from './menu';
import { PermissionRoute } from './PermissionRoute';
import DashboardPage from '../Dashboard';
import DramasPage from '../Drama';
import UsersPage from '../User';
import RolesPage from '../Role';
import PersonnelPage from '../Personnel';
import ProfilePage from '../Profile';
import LogsPage from '../Log';
import './layout.css';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
export function AdminLayout() {
const navigate = useNavigate();
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
const [profile, setProfile] = useState<AdminProfile | null>(null);
const current = useMemo(() => findCurrentMenu(location.pathname), [location.pathname]);
const visibleMenuItems = useMemo(() => filterMenuItems(menuItems, profile), [profile]);
const [openMenuKeys, setOpenMenuKeys] = useState<string[]>(current?.parent ? [current.parent.key] : []);
useEffect(() => {
api.get<ApiResponse<AdminProfile>>('/api/admin/v1/auth/profile')
.then((response) => setProfile(response.data.data))
.catch(() => {
localStorage.removeItem(TOKEN_KEY);
navigate('/login', { replace: true });
});
}, [navigate]);
useEffect(() => {
if (current?.parent && !openMenuKeys.includes(current.parent.key)) {
setOpenMenuKeys((keys) => [...keys, current.parent!.key]);
}
}, [current, openMenuKeys]);
const logout = () => {
localStorage.removeItem(TOKEN_KEY);
navigate('/login', { replace: true });
};
return (
<Layout className="layout">
<Sider className="sidebar" width={220} collapsed={collapsed} collapsible trigger={null}>
<div className="logo-row">
<span className="logo-mark">TK</span>
{!collapsed && <span>TK短剧管理后台</span>}
</div>
<Menu
selectedKeys={[current?.item.key || '/dashboard']}
openKeys={openMenuKeys}
onClickMenuItem={(key) => navigate(key)}
onClickSubMenu={(_, openKeys) => setOpenMenuKeys(openKeys)}
>
{visibleMenuItems.map((item) => renderMenuItem(item))}
</Menu>
</Sider>
<Layout>
<Header className="header">
<Space>
<Button icon={collapsed ? <IconMenuUnfold /> : <IconMenuFold />} onClick={() => setCollapsed(!collapsed)} />
<div className="header-title">
<strong>{current?.item.label || '工作台'}</strong>
<span> API {API_BASE_URL}</span>
</div>
</Space>
<Dropdown
position="br"
trigger="click"
droplist={(
<Menu>
<Menu.Item key="profile" onClick={() => navigate('/profile')}></Menu.Item>
<Menu.Item key="logout" onClick={logout}>退</Menu.Item>
</Menu>
)}
>
<button className="user-menu-trigger" type="button">
<Avatar size={28}>{profile?.username?.slice(0, 1).toUpperCase() || 'A'}</Avatar>
<Text>{profile?.nickname || profile?.username || '管理员'}</Text>
</button>
</Dropdown>
</Header>
<Content className="content">
<Routes>
<Route path="/dashboard" element={<DashboardPage profile={profile} />} />
<Route path="/dramas" element={<PermissionRoute profile={profile} requiredPermissions={['drama:read']}><DramasPage /></PermissionRoute>} />
<Route path="/users" element={<PermissionRoute profile={profile} requiredPermissions={['user:read']}><UsersPage /></PermissionRoute>} />
<Route path="/roles" element={<PermissionRoute profile={profile} requiredPermissions={['role:read']}><RolesPage /></PermissionRoute>} />
<Route path="/personnel" element={<PermissionRoute profile={profile} requiredPermissions={['admin-user:read']}><PersonnelPage /></PermissionRoute>} />
<Route path="/profile" element={<PermissionRoute profile={profile}><ProfilePage /></PermissionRoute>} />
<Route path="/logs" element={<PermissionRoute profile={profile} requiredPermissions={['log:read']}><LogsPage /></PermissionRoute>} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Content>
</Layout>
</Layout>
);
}

79
src/layout/layout.css Normal file
View File

@@ -0,0 +1,79 @@
.layout {
min-height: 100vh;
}
.sidebar {
box-shadow: 1px 0 0 #e5e6eb;
}
.logo-row {
height: 56px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
border-bottom: 1px solid #e5e6eb;
font-weight: 700;
}
.logo-mark {
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: #ffffff;
background: #165dff;
border-radius: 6px;
font-size: 14px;
}
.header {
height: 56px;
padding: 0 20px;
background: #ffffff;
border-bottom: 1px solid #e5e6eb;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.header-title strong {
font-size: 16px;
}
.header-title span {
color: #86909c;
font-size: 12px;
}
.user-menu-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: #1d2129;
cursor: pointer;
}
.user-menu-trigger:hover {
background: #f2f3f5;
}
.content {
padding: 20px;
}
@media (max-width: 640px) {
.content {
padding: 12px;
}
}

78
src/layout/menu.tsx Normal file
View File

@@ -0,0 +1,78 @@
import type { ReactNode } from 'react';
import { Menu } from '@arco-design/web-react';
import {
IconApps,
IconDashboard,
IconFile,
IconSettings,
IconUser,
IconVideoCamera,
} from '@arco-design/web-react/icon';
import type { AdminProfile } from '../api/types';
import { hasPermissions } from '../utils/permission';
export type MenuConfig = {
key: string;
label: string;
icon?: ReactNode;
requiredPermissions?: string[];
children?: MenuConfig[];
};
export const SYSTEM_MENU_KEY = '/system';
export const menuItems: MenuConfig[] = [
{ key: '/dashboard', label: '工作台', icon: <IconDashboard /> },
{ key: '/dramas', label: '短剧管理', icon: <IconVideoCamera />, requiredPermissions: ['drama:read'] },
{ key: '/users', label: '用户管理', icon: <IconUser />, requiredPermissions: ['user:read'] },
{
key: SYSTEM_MENU_KEY,
label: '系统管理',
icon: <IconSettings />,
children: [
{ key: '/roles', label: '角色管理', icon: <IconApps />, requiredPermissions: ['role:read'] },
{ key: '/personnel', label: '人员管理', icon: <IconUser />, requiredPermissions: ['admin-user:read'] },
{ key: '/profile', label: '个人中心', icon: <IconUser /> },
{ key: '/logs', label: '日志管理', icon: <IconFile />, requiredPermissions: ['log:read'] },
],
},
];
export function filterMenuItems(items: MenuConfig[], profile: AdminProfile | null): MenuConfig[] {
return items
.map((item) => {
if (item.children) {
const children = filterMenuItems(item.children, profile);
return children.length ? { ...item, children } : null;
}
return hasPermissions(profile, item.requiredPermissions) ? item : null;
})
.filter((item): item is MenuConfig => Boolean(item));
}
export function findCurrentMenu(pathname: string) {
for (const item of menuItems) {
if (item.children) {
const child = item.children.find((childItem) => pathname.startsWith(childItem.key));
if (child) {
return { item: child, parent: item };
}
}
if (pathname.startsWith(item.key)) {
return { item };
}
}
return undefined;
}
export function renderMenuItem(item: MenuConfig) {
if (item.children) {
return (
<Menu.SubMenu key={item.key} title={<span>{item.icon}{item.label}</span>}>
{item.children.map((child) => renderMenuItem(child))}
</Menu.SubMenu>
);
}
return <Menu.Item key={item.key}>{item.icon}{item.label}</Menu.Item>;
}

22
src/utils/permission.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { AdminProfile, Permission } from '../api/types';
export function getPermissionCodes(profile: AdminProfile | null) {
if (!profile) {
return [];
}
if (profile.isSuperAdmin || profile.permissions[0] === '*') {
return ['*'];
}
return (profile.permissions as Permission[]).map((permission) => permission.code);
}
export function hasPermissions(profile: AdminProfile | null, requiredPermissions?: string[]) {
if (!requiredPermissions?.length) {
return true;
}
const codes = getPermissionCodes(profile);
if (codes.includes('*')) {
return true;
}
return requiredPermissions.every((permission) => codes.includes(permission));
}