feat: 完成TikTok短剧管理后台基础功能开发
本次提交搭建了完整的后台管理系统,包含: 1. 登录认证与权限校验体系 2. 工作台、短剧、用户、角色、人员、日志等全业务模块 3. 响应式布局与通用样式组件 4. 菜单动态过滤与路由权限控制 5. 移除了README中冗余的权限管理菜单项
This commit is contained in:
@@ -48,7 +48,6 @@ VITE_API_BASE_URL=http://localhost:3030
|
|||||||
- 工作台
|
- 工作台
|
||||||
- 短剧管理
|
- 短剧管理
|
||||||
- 用户管理
|
- 用户管理
|
||||||
- 权限管理
|
|
||||||
- 角色管理
|
- 角色管理
|
||||||
- 管理员管理
|
- 管理员管理
|
||||||
- 日志管理
|
- 日志管理
|
||||||
|
|||||||
1078
src/App.tsx
1078
src/App.tsx
File diff suppressed because it is too large
Load Diff
28
src/Dashboard/index.css
Normal file
28
src/Dashboard/index.css
Normal 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
40
src/Dashboard/index.tsx
Normal 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
47
src/Drama/index.tsx
Normal 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
11
src/Log/index.tsx
Normal 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;
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
.app-shell {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f2f3f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-page {
|
.login-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -156,185 +151,6 @@
|
|||||||
font-size: 12px;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.login-page {
|
.login-page {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -343,26 +159,4 @@
|
|||||||
.login-visual {
|
.login-visual {
|
||||||
display: none;
|
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
68
src/Login/index.tsx
Normal 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
147
src/Personnel/index.tsx
Normal 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
65
src/Profile/index.css
Normal 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
112
src/Profile/index.tsx
Normal 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;
|
||||||
163
src/Role/PermissionTreePicker.tsx
Normal file
163
src/Role/PermissionTreePicker.tsx
Normal 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
22
src/Role/index.css
Normal 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
117
src/Role/index.tsx
Normal 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
27
src/User/index.tsx
Normal 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
30
src/api/client.ts
Normal 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
86
src/api/types.ts
Normal 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;
|
||||||
|
};
|
||||||
11
src/components/NoAccessPage.tsx
Normal file
11
src/components/NoAccessPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/RequestLogTable.tsx
Normal file
15
src/components/RequestLogTable.tsx
Normal 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
31
src/hooks/usePageData.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -23,3 +23,14 @@ textarea,
|
|||||||
select {
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|||||||
23
src/layout/PermissionRoute.tsx
Normal file
23
src/layout/PermissionRoute.tsx
Normal 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;
|
||||||
|
}
|
||||||
10
src/layout/ProtectedRoute.tsx
Normal file
10
src/layout/ProtectedRoute.tsx
Normal 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
114
src/layout/index.tsx
Normal 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
79
src/layout/layout.css
Normal 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
78
src/layout/menu.tsx
Normal 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
22
src/utils/permission.ts
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user