From 51e26025c6b2722c9555bcbf64c0b6662f9c36a0 Mon Sep 17 00:00:00 2001 From: zhxiao1124 Date: Wed, 1 Jul 2026 15:24:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90TikTok=E7=9F=AD?= =?UTF-8?q?=E5=89=A7=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=E5=9F=BA=E7=A1=80?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交搭建了完整的后台管理系统,包含: 1. 登录认证与权限校验体系 2. 工作台、短剧、用户、角色、人员、日志等全业务模块 3. 响应式布局与通用样式组件 4. 菜单动态过滤与路由权限控制 5. 移除了README中冗余的权限管理菜单项 --- README.md | 1 - src/App.tsx | 1082 +--------------------------- src/Dashboard/index.css | 28 + src/Dashboard/index.tsx | 40 + src/Drama/index.tsx | 47 ++ src/Log/index.tsx | 11 + src/{App.css => Login/index.css} | 206 ------ src/Login/index.tsx | 68 ++ src/Personnel/index.tsx | 147 ++++ src/Profile/index.css | 65 ++ src/Profile/index.tsx | 112 +++ src/Role/PermissionTreePicker.tsx | 163 +++++ src/Role/index.css | 22 + src/Role/index.tsx | 117 +++ src/User/index.tsx | 27 + src/api/client.ts | 30 + src/api/types.ts | 86 +++ src/components/NoAccessPage.tsx | 11 + src/components/RequestLogTable.tsx | 15 + src/hooks/usePageData.ts | 31 + src/index.css | 11 + src/layout/PermissionRoute.tsx | 23 + src/layout/ProtectedRoute.tsx | 10 + src/layout/index.tsx | 114 +++ src/layout/layout.css | 79 ++ src/layout/menu.tsx | 78 ++ src/utils/permission.ts | 22 + 27 files changed, 1364 insertions(+), 1282 deletions(-) create mode 100644 src/Dashboard/index.css create mode 100644 src/Dashboard/index.tsx create mode 100644 src/Drama/index.tsx create mode 100644 src/Log/index.tsx rename src/{App.css => Login/index.css} (50%) create mode 100644 src/Login/index.tsx create mode 100644 src/Personnel/index.tsx create mode 100644 src/Profile/index.css create mode 100644 src/Profile/index.tsx create mode 100644 src/Role/PermissionTreePicker.tsx create mode 100644 src/Role/index.css create mode 100644 src/Role/index.tsx create mode 100644 src/User/index.tsx create mode 100644 src/api/client.ts create mode 100644 src/api/types.ts create mode 100644 src/components/NoAccessPage.tsx create mode 100644 src/components/RequestLogTable.tsx create mode 100644 src/hooks/usePageData.ts create mode 100644 src/layout/PermissionRoute.tsx create mode 100644 src/layout/ProtectedRoute.tsx create mode 100644 src/layout/index.tsx create mode 100644 src/layout/layout.css create mode 100644 src/layout/menu.tsx create mode 100644 src/utils/permission.ts diff --git a/README.md b/README.md index b1f5a46..179eda7 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,6 @@ VITE_API_BASE_URL=http://localhost:3030 - 工作台 - 短剧管理 - 用户管理 -- 权限管理 - 角色管理 - 管理员管理 - 日志管理 diff --git a/src/App.tsx b/src/App.tsx index c0be9bc..ebef6ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,1085 +1,17 @@ -import { useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react'; -import axios, { AxiosError } from 'axios'; -import { - Avatar, - Button, - Card, - Dropdown, - Form, - Input, - Layout, - Menu, - Message, - Modal, - Select, - Space, - Table, - Tag, - Tree, - Typography, - ConfigProvider, -} from '@arco-design/web-react'; -import { - IconApps, - IconDashboard, - IconFile, - IconLock, - IconMenuFold, - IconMenuUnfold, - IconPlus, - IconSafe, - IconSettings, - IconUser, - IconVideoCamera, -} from '@arco-design/web-react/icon'; -import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { ConfigProvider } from '@arco-design/web-react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; import zhCN from '@arco-design/web-react/es/locale/zh-CN'; -import './App.css'; - -const { Header, Sider, Content } = Layout; -const { Text } = Typography; - -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; -const TOKEN_KEY = 'cth_tk_admin_token'; - -type ApiResponse = { - code: number; - data: T; - message: string; - timestamp: string; - requestId: string; - cost: string; -}; - -type PageData = { - list: T[]; - pagination: { - page: number; - pageSize: number; - total: number; - }; -}; - -type AdminProfile = { - id: number; - username: string; - nickname?: string; - email?: string; - status: string; - isSuperAdmin: boolean; - roles: Role[]; - permissions: Permission[] | ['*']; - createdAt: string; -}; - -type Drama = { - id: number; - title: string; - type: string; - status: string; - coverUrl: string; - isRecommended: boolean; - createdAt: string; -}; - -type AppUser = { - id: number; - tiktokOpenId: string; - nickname?: string; - status: string; - createdAt: string; -}; - -type Permission = { - id: number; - code: string; - name: string; - description?: string; -}; - -type Role = { - id: number; - code: string; - name: string; - description?: string; - permissionIds?: number[]; - permissions: Permission[]; -}; - -type AdminUser = { - id: number; - username: string; - nickname?: string; - email?: string; - status: string; - isSuperAdmin: boolean; - roles: Role[]; - createdAt: string; -}; - -type RequestLog = { - id: number; - requestId: string; - method: string; - path: string; - statusCode: number; - responseCode: number; - message: string; - costMs: number; - createdAt: string; -}; - -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>) => { - const message = error.response?.data?.message || error.message || '请求失败'; - Message.error(message); - return Promise.reject(error); - }, -); - -async function fetchPage(url: string): Promise> { - const response = await api.get>>(url); - return response.data.data; -} - -function usePageData(url: string, enabled = true) { - const [loading, setLoading] = useState(false); - const [data, setData] = useState>({ - list: [], - pagination: { page: 1, pageSize: 20, total: 0 }, - }); - - const load = async () => { - if (!enabled) { - return; - } - setLoading(true); - try { - setData(await fetchPage(url)); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (enabled) { - void load(); - } - }, [url, enabled]); - - return { data, loading, reload: load }; -} - -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>('/api/admin/v1/auth/login', values); - localStorage.setItem(TOKEN_KEY, response.data.data.accessToken); - Message.success('登录成功'); - navigate('/dashboard', { replace: true }); - } finally { - setLoading(false); - } - }; - - return ( -
-
-
- CT - cth-tk-admin -
-
- TikTok 短剧运营后台 -

CTH TikTok 短剧管理后台

-

集中处理短剧内容、剧集发布、订单支付、播放日志和系统权限,帮助运营人员稳定管理短剧业务。

-
-
-
内容短剧与剧集管理
-
权限角色化后台授权
-
日志请求与播放追踪
-
-
-
-
-
- CT -
-

后台登录

-

使用管理员账号进入控制台。

-
-
-
- - } placeholder="admin" /> - - - } placeholder="admin123" /> - - -
-
API 服务:{API_BASE_URL}
-
-
-
- ); -} - -function ProtectedRoute({ children }: { children: ReactElement }) { - if (!localStorage.getItem(TOKEN_KEY)) { - return ; - } - return children; -} - -type MenuConfig = { - key: string; - label: string; - icon?: ReactNode; - requiredPermissions?: string[]; - children?: MenuConfig[]; -}; - -const SYSTEM_MENU_KEY = '/system'; - -const menuItems: MenuConfig[] = [ - { key: '/dashboard', label: '工作台', icon: }, - { key: '/dramas', label: '短剧管理', icon: , requiredPermissions: ['drama:read'] }, - { key: '/users', label: '用户管理', icon: , requiredPermissions: ['user:read'] }, - { - key: SYSTEM_MENU_KEY, - label: '系统管理', - icon: , - children: [ - { key: '/permissions', label: '权限管理', icon: , requiredPermissions: ['permission:read'] }, - { key: '/roles', label: '角色管理', icon: , requiredPermissions: ['role:read'] }, - { key: '/personnel', label: '人员管理', icon: , requiredPermissions: ['admin-user:read'] }, - { key: '/profile', label: '个人中心', icon: }, - { key: '/logs', label: '日志管理', icon: , requiredPermissions: ['log:read'] }, - ], - }, -]; - -function getPermissionCodes(profile: AdminProfile | null) { - if (!profile) { - return []; - } - if (profile.isSuperAdmin || profile.permissions[0] === '*') { - return ['*']; - } - return (profile.permissions as Permission[]).map((permission) => permission.code); -} - -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)); -} - -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)); -} - -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; -} - -function renderMenuItem(item: MenuConfig) { - if (item.children) { - return ( - {item.icon}{item.label}}> - {item.children.map((child) => renderMenuItem(child))} - - ); - } - - return {item.icon}{item.label}; -} - -function NoAccessPage() { - return ( - - 当前账号没有访问该功能的权限,请联系系统管理员分配角色。 - - ); -} - -function PermissionRoute({ - profile, - requiredPermissions, - children, -}: { - profile: AdminProfile | null; - requiredPermissions?: string[]; - children: ReactElement; -}) { - if (!profile) { - return ; - } - if (!hasPermissions(profile, requiredPermissions)) { - return ; - } - return children; -} - -function AdminLayout() { - const navigate = useNavigate(); - const location = useLocation(); - const [collapsed, setCollapsed] = useState(false); - const [profile, setProfile] = useState(null); - const current = useMemo(() => findCurrentMenu(location.pathname), [location.pathname]); - const visibleMenuItems = useMemo(() => filterMenuItems(menuItems, profile), [profile]); - const [openMenuKeys, setOpenMenuKeys] = useState(current?.parent ? [current.parent.key] : []); - - useEffect(() => { - api.get>('/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 ( - - -
- TK - {!collapsed && TK短剧管理后台} -
- navigate(key)} - onClickSubMenu={(_, openKeys) => setOpenMenuKeys(openKeys)} - > - {visibleMenuItems.map((item) => renderMenuItem(item))} - -
- -
- - - -
- - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - -
-
- ); -} - -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('/api/admin/v1/dramas', canReadDramas); - const users = usePageData('/api/admin/v1/users', canReadUsers); - const logs = usePageData('/api/admin/v1/logs/requests', canReadLogs); - const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs; - - return ( - <> -
- {canReadDramas && } - {canReadUsers && } - {canReadLogs && } - -
- {canReadLogs ? ( - - - - ) : null} - {!hasAnyDataModule ? : null} - - ); -} - -function MetricCard({ label, value }: { label: string; value: number | string }) { - return
{label}
{value}
; -} - -function DramasPage() { - const { data, loading, reload } = usePageData('/api/admin/v1/dramas'); - const [visible, setVisible] = useState(false); - const [creating, setCreating] = useState(false); - - const createDrama = async (values: Partial) => { - 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 ( - } onClick={() => setVisible(true)}>新增短剧}> - {status} }, - { title: '推荐', dataIndex: 'isRecommended', width: 140, render: (value) => value ? : }, - ]} /> - setVisible(false)} footer={null}> -
- - - - - - -
- - ); -} - -function UsersPage() { - const { data, loading, reload } = usePageData('/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 ( - -
{status} }, - { title: '操作', width: 160, render: (_, record: AppUser) => }, - ]} /> - - ); -} - -function PermissionsPage() { - const { data, loading } = usePageData('/api/admin/v1/permissions'); - return
; -} - -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 = { - 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; -} - -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 ( -
- { - onChange( - keys - .map((key) => permissionIdFromKey(key)) - .filter((id): id is number => id !== null), - ); - }} - /> -
- ); -} - -function RolesPage() { - const { data, loading, reload } = usePageData('/api/admin/v1/roles'); - const permissions = usePageData('/api/admin/v1/permissions'); - const [createForm] = Form.useForm(); - const [createVisible, setCreateVisible] = useState(false); - const [creatingRole, setCreatingRole] = useState(false); - const [createPermissionIds, setCreatePermissionIds] = useState([]); - const [assignmentVisible, setAssignmentVisible] = useState(false); - const [selectedRole, setSelectedRole] = useState(null); - const [selectedPermissionIds, setSelectedPermissionIds] = useState([]); - 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 ( - } onClick={openCreateRole}>新增角色}> -
{record.permissions.map((item) => {item.name})} }, - { title: '操作', width: 140, render: (_, record: Role) => }, - ]} /> - setCreateVisible(false)} footer={null}> -
- - - -
角色的唯一标识,创建后不可修改
- - - - - - -
权限设置
- -
为角色分配系统权限,决定拥有此角色的用户可以进行的操作
-
- - -
- -
- setAssignmentVisible(false)} - > - - - - ); -} - -function PersonnelPage() { - const { data, loading, reload } = usePageData('/api/admin/v1/admin-users'); - const roles = usePageData('/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(null); - const [selectedRoleIds, setSelectedRoleIds] = useState([]); - - 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) => ( - {role.name} - )); - - return ( - } onClick={openCreate}>新增人员}> -
{record.isSuperAdmin ? 超级管理员 : record.roles.map((role) => {role.name})} }, - { title: '状态', dataIndex: 'status', width: 120, render: (status) => {status === 'active' ? '启用' : '禁用'} }, - { title: '创建时间', dataIndex: 'createdAt', width: 190 }, - { - title: '操作', - width: 220, - render: (_, record: AdminUser) => ( - - - - - ), - }, - ]} /> - setCreateVisible(false)} footer={null}> -
- - - - - - - - - - - - - - - -
- - -
- -
- setAssignVisible(false)} - > - - - - ); -} - -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(null); - - const loadProfile = async () => { - setLoading(true); - try { - const response = await api.get>('/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>('/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 ( -
- -
- {profile?.username?.slice(0, 1).toUpperCase() || 'A'} -
-

{profile?.nickname || profile?.username || '管理员'}

-

{profile?.email || '未设置邮箱'}

-
-
-
-
用户名{profile?.username || '-'}
-
状态{profile?.status === 'active' ? '启用' : '禁用'}
-
角色{profile?.isSuperAdmin ? '超级管理员' : profile?.roles.map((role) => role.name).join('、') || '-'}
-
创建时间{profile?.createdAt || '-'}
-
-
- -
- - - - - - - - -
- -
- - - - - - - - - - - -
-
- ); -} - -function LogsPage() { - const { data, loading } = usePageData('/api/admin/v1/logs/requests'); - return ; -} - -function RequestLogTable({ data, loading }: { data: RequestLog[]; loading: boolean }) { - return ( -
{value} }, - { title: '路径', dataIndex: 'path', ellipsis: true }, - { title: '状态', dataIndex: 'statusCode', width: 100, render: (value) => {value} }, - { title: '耗时', dataIndex: 'costMs', width: 100, render: (value) => String(value) + 'ms' }, - { title: '消息', dataIndex: 'message', width: 180 }, - ]} /> - ); -} +import LoginPage from './Login'; +import { ProtectedRoute } from './layout/ProtectedRoute'; +import { AdminLayout } from './layout'; function App() { return ( - } /> - } /> + } /> + } /> diff --git a/src/Dashboard/index.css b/src/Dashboard/index.css new file mode 100644 index 0000000..f3d364a --- /dev/null +++ b/src/Dashboard/index.css @@ -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; + } +} diff --git a/src/Dashboard/index.tsx b/src/Dashboard/index.tsx new file mode 100644 index 0000000..d64238b --- /dev/null +++ b/src/Dashboard/index.tsx @@ -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
{label}
{value}
; +} + +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('/api/admin/v1/dramas', canReadDramas); + const users = usePageData('/api/admin/v1/users', canReadUsers); + const logs = usePageData('/api/admin/v1/logs/requests', canReadLogs); + const hasAnyDataModule = canReadDramas || canReadUsers || canReadLogs; + + return ( + <> +
+ {canReadDramas && } + {canReadUsers && } + {canReadLogs && } + +
+ {canReadLogs ? ( + + + + ) : null} + {!hasAnyDataModule ? : null} + + ); +} + +export default DashboardPage; diff --git a/src/Drama/index.tsx b/src/Drama/index.tsx new file mode 100644 index 0000000..1357bbd --- /dev/null +++ b/src/Drama/index.tsx @@ -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('/api/admin/v1/dramas'); + const [visible, setVisible] = useState(false); + const [creating, setCreating] = useState(false); + + const createDrama = async (values: Partial) => { + 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 ( + } onClick={() => setVisible(true)}>新增短剧}> +
{status} }, + { title: '推荐', dataIndex: 'isRecommended', width: 140, render: (value) => value ? : }, + ]} /> + setVisible(false)} footer={null}> +
+ + + + + + +
+ + ); +} + +export default DramasPage; diff --git a/src/Log/index.tsx b/src/Log/index.tsx new file mode 100644 index 0000000..8d027eb --- /dev/null +++ b/src/Log/index.tsx @@ -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('/api/admin/v1/logs/requests'); + return ; +} + +export default LogsPage; diff --git a/src/App.css b/src/Login/index.css similarity index 50% rename from src/App.css rename to src/Login/index.css index 89a6896..d1310cf 100644 --- a/src/App.css +++ b/src/Login/index.css @@ -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; - } } diff --git a/src/Login/index.tsx b/src/Login/index.tsx new file mode 100644 index 0000000..fcaa75a --- /dev/null +++ b/src/Login/index.tsx @@ -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>('/api/admin/v1/auth/login', values); + localStorage.setItem(TOKEN_KEY, response.data.data.accessToken); + Message.success('登录成功'); + navigate('/dashboard', { replace: true }); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ CT + cth-tk-admin +
+
+ TikTok 短剧运营后台 +

CTH TikTok 短剧管理后台

+

集中处理短剧内容、剧集发布、订单支付、播放日志和系统权限,帮助运营人员稳定管理短剧业务。

+
+
+
内容短剧与剧集管理
+
权限角色化后台授权
+
日志请求与播放追踪
+
+
+
+
+
+ CT +
+

后台登录

+

使用管理员账号进入控制台。

+
+
+
+ + } placeholder="admin" /> + + + } placeholder="admin123" /> + + + +
API 服务:{API_BASE_URL}
+
+
+
+ ); +} + +export default LoginPage; diff --git a/src/Personnel/index.tsx b/src/Personnel/index.tsx new file mode 100644 index 0000000..57187a9 --- /dev/null +++ b/src/Personnel/index.tsx @@ -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('/api/admin/v1/admin-users'); + const roles = usePageData('/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(null); + const [selectedRoleIds, setSelectedRoleIds] = useState([]); + + 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) => ( + {role.name} + )); + + return ( + } onClick={openCreate}>新增人员}> +
{record.isSuperAdmin ? 超级管理员 : record.roles.map((role) => {role.name})} }, + { title: '状态', dataIndex: 'status', width: 120, render: (status) => {status === 'active' ? '启用' : '禁用'} }, + { title: '创建时间', dataIndex: 'createdAt', width: 190 }, + { + title: '操作', + width: 220, + render: (_, record: AdminUser) => ( + + + + + ), + }, + ]} /> + setCreateVisible(false)} footer={null}> +
+ + + + + + + + + + + + + + + +
+ + +
+ +
+ setAssignVisible(false)} + > + + + + ); +} + +export default PersonnelPage; diff --git a/src/Profile/index.css b/src/Profile/index.css new file mode 100644 index 0000000..2867186 --- /dev/null +++ b/src/Profile/index.css @@ -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; + } +} diff --git a/src/Profile/index.tsx b/src/Profile/index.tsx new file mode 100644 index 0000000..b971688 --- /dev/null +++ b/src/Profile/index.tsx @@ -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(null); + + const loadProfile = async () => { + setLoading(true); + try { + const response = await api.get>('/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>('/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 ( +
+ +
+ {profile?.username?.slice(0, 1).toUpperCase() || 'A'} +
+

{profile?.nickname || profile?.username || '管理员'}

+

{profile?.email || '未设置邮箱'}

+
+
+
+
用户名{profile?.username || '-'}
+
状态{profile?.status === 'active' ? '启用' : '禁用'}
+
角色{profile?.isSuperAdmin ? '超级管理员' : profile?.roles.map((role) => role.name).join('、') || '-'}
+
创建时间{profile?.createdAt || '-'}
+
+
+ +
+ + + + + + + + +
+ +
+ + + + + + + + + + + +
+
+ ); +} + +export default ProfilePage; diff --git a/src/Role/PermissionTreePicker.tsx b/src/Role/PermissionTreePicker.tsx new file mode 100644 index 0000000..35782b6 --- /dev/null +++ b/src/Role/PermissionTreePicker.tsx @@ -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 = { + 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 ( +
+ { + onChange( + keys + .map((key) => permissionIdFromKey(key)) + .filter((id): id is number => id !== null), + ); + }} + /> +
+ ); +} diff --git a/src/Role/index.css b/src/Role/index.css new file mode 100644 index 0000000..464fa45 --- /dev/null +++ b/src/Role/index.css @@ -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; +} diff --git a/src/Role/index.tsx b/src/Role/index.tsx new file mode 100644 index 0000000..a941bc2 --- /dev/null +++ b/src/Role/index.tsx @@ -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('/api/admin/v1/roles'); + const permissions = usePageData('/api/admin/v1/permissions'); + const [createForm] = Form.useForm(); + const [createVisible, setCreateVisible] = useState(false); + const [creatingRole, setCreatingRole] = useState(false); + const [createPermissionIds, setCreatePermissionIds] = useState([]); + const [assignmentVisible, setAssignmentVisible] = useState(false); + const [selectedRole, setSelectedRole] = useState(null); + const [selectedPermissionIds, setSelectedPermissionIds] = useState([]); + 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 ( + } onClick={openCreateRole}>新增角色}> +
{record.permissions.map((item) => {item.name})} }, + { title: '操作', width: 140, render: (_, record: Role) => }, + ]} /> + setCreateVisible(false)} footer={null}> +
+ + + +
角色的唯一标识,创建后不可修改
+ + + + + + +
权限设置
+ +
为角色分配系统权限,决定拥有此角色的用户可以进行的操作
+
+ + +
+ +
+ setAssignmentVisible(false)} + > + + + + ); +} + +export default RolesPage; diff --git a/src/User/index.tsx b/src/User/index.tsx new file mode 100644 index 0000000..f3a0340 --- /dev/null +++ b/src/User/index.tsx @@ -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('/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 ( + +
{status} }, + { title: '操作', width: 160, render: (_, record: AppUser) => }, + ]} /> + + ); +} + +export default UsersPage; diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..0fdacea --- /dev/null +++ b/src/api/client.ts @@ -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>) => { + const message = error.response?.data?.message || error.message || '请求失败'; + Message.error(message); + return Promise.reject(error); + }, +); + +export async function fetchPage(url: string): Promise> { + const response = await api.get>>(url); + return response.data.data; +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..ce0621d --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,86 @@ +export type ApiResponse = { + code: number; + data: T; + message: string; + timestamp: string; + requestId: string; + cost: string; +}; + +export type PageData = { + 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; +}; diff --git a/src/components/NoAccessPage.tsx b/src/components/NoAccessPage.tsx new file mode 100644 index 0000000..0bd9489 --- /dev/null +++ b/src/components/NoAccessPage.tsx @@ -0,0 +1,11 @@ +import { Card, Typography } from '@arco-design/web-react'; + +const { Text } = Typography; + +export function NoAccessPage() { + return ( + + 当前账号没有访问该功能的权限,请联系系统管理员分配角色。 + + ); +} diff --git a/src/components/RequestLogTable.tsx b/src/components/RequestLogTable.tsx new file mode 100644 index 0000000..7fd7fea --- /dev/null +++ b/src/components/RequestLogTable.tsx @@ -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 ( +
{value} }, + { title: '路径', dataIndex: 'path', ellipsis: true }, + { title: '状态', dataIndex: 'statusCode', width: 100, render: (value) => {value} }, + { title: '耗时', dataIndex: 'costMs', width: 100, render: (value) => String(value) + 'ms' }, + { title: '消息', dataIndex: 'message', width: 180 }, + ]} /> + ); +} diff --git a/src/hooks/usePageData.ts b/src/hooks/usePageData.ts new file mode 100644 index 0000000..284893c --- /dev/null +++ b/src/hooks/usePageData.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; +import { fetchPage } from '../api/client'; +import type { PageData } from '../api/types'; + +export function usePageData(url: string, enabled = true) { + const [loading, setLoading] = useState(false); + const [data, setData] = useState>({ + list: [], + pagination: { page: 1, pageSize: 20, total: 0 }, + }); + + const load = async () => { + if (!enabled) { + return; + } + setLoading(true); + try { + setData(await fetchPage(url)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (enabled) { + void load(); + } + }, [url, enabled]); + + return { data, loading, reload: load }; +} diff --git a/src/index.css b/src/index.css index 0ffbe38..b36ead9 100644 --- a/src/index.css +++ b/src/index.css @@ -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; +} diff --git a/src/layout/PermissionRoute.tsx b/src/layout/PermissionRoute.tsx new file mode 100644 index 0000000..305cdfe --- /dev/null +++ b/src/layout/PermissionRoute.tsx @@ -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 ; + } + if (!hasPermissions(profile, requiredPermissions)) { + return ; + } + return children; +} diff --git a/src/layout/ProtectedRoute.tsx b/src/layout/ProtectedRoute.tsx new file mode 100644 index 0000000..400a702 --- /dev/null +++ b/src/layout/ProtectedRoute.tsx @@ -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 ; + } + return children; +} diff --git a/src/layout/index.tsx b/src/layout/index.tsx new file mode 100644 index 0000000..4c39721 --- /dev/null +++ b/src/layout/index.tsx @@ -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(null); + const current = useMemo(() => findCurrentMenu(location.pathname), [location.pathname]); + const visibleMenuItems = useMemo(() => filterMenuItems(menuItems, profile), [profile]); + const [openMenuKeys, setOpenMenuKeys] = useState(current?.parent ? [current.parent.key] : []); + + useEffect(() => { + api.get>('/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 ( + + +
+ TK + {!collapsed && TK短剧管理后台} +
+ navigate(key)} + onClickSubMenu={(_, openKeys) => setOpenMenuKeys(openKeys)} + > + {visibleMenuItems.map((item) => renderMenuItem(item))} + +
+ +
+ + + +
+ + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
+
+ ); +} diff --git a/src/layout/layout.css b/src/layout/layout.css new file mode 100644 index 0000000..09ec7e8 --- /dev/null +++ b/src/layout/layout.css @@ -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; + } +} diff --git a/src/layout/menu.tsx b/src/layout/menu.tsx new file mode 100644 index 0000000..6dfeccb --- /dev/null +++ b/src/layout/menu.tsx @@ -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: }, + { key: '/dramas', label: '短剧管理', icon: , requiredPermissions: ['drama:read'] }, + { key: '/users', label: '用户管理', icon: , requiredPermissions: ['user:read'] }, + { + key: SYSTEM_MENU_KEY, + label: '系统管理', + icon: , + children: [ + { key: '/roles', label: '角色管理', icon: , requiredPermissions: ['role:read'] }, + { key: '/personnel', label: '人员管理', icon: , requiredPermissions: ['admin-user:read'] }, + { key: '/profile', label: '个人中心', icon: }, + { key: '/logs', label: '日志管理', icon: , 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 ( + {item.icon}{item.label}}> + {item.children.map((child) => renderMenuItem(child))} + + ); + } + + return {item.icon}{item.label}; +} diff --git a/src/utils/permission.ts b/src/utils/permission.ts new file mode 100644 index 0000000..7d4c84f --- /dev/null +++ b/src/utils/permission.ts @@ -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)); +}