build: 引入tailwindcss并迁移原有样式到原子类

1. 新增postcss、autoprefixer、tailwindcss依赖并初始化配置
2. 移除全局及页面级CSS文件,将样式替换为tailwind原子类
3. 重构API调用逻辑,拆分接口请求函数
4. 统一项目样式规范,优化布局与组件样式
This commit is contained in:
2026-07-02 13:38:03 +08:00
parent 526052d633
commit e160476ecf
27 changed files with 947 additions and 499 deletions

View File

@@ -1,28 +0,0 @@
.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;
}
}

View File

@@ -4,10 +4,9 @@ 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>;
return <Card><div className="mb-2 text-muted">{label}</div><div className="text-[28px] font-bold">{value}</div></Card>;
}
function DashboardPage({ profile }: { profile: AdminProfile | null }) {
@@ -21,7 +20,7 @@ function DashboardPage({ profile }: { profile: AdminProfile | null }) {
return (
<>
<div className="page-grid">
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2 min-[901px]:grid-cols-4">
{canReadDramas && <MetricCard label="短剧管理" value={dramas.data.pagination.total} />}
{canReadUsers && <MetricCard label="用户管理" value={users.data.pagination.total} />}
{canReadLogs && <MetricCard label="请求日志" value={logs.data.pagination.total} />}

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { Button, Card, Grid, Message, Statistic, Table } from '@arco-design/web-react';
import { IconRefresh } from '@arco-design/web-react/icon';
import type { ApiResponse, DataOverview, DramaMetric, EpisodeMetric, SyncJob } from '../api/types';
import { api } from '../api/client';
import type { DataOverview, DramaMetric, EpisodeMetric, SyncJob } from '../api/types';
import { getDataOverview, syncDataMetrics } from '../api/data';
import { usePageData } from '../hooks/usePageData';
const { Row, Col } = Grid;
@@ -22,8 +22,7 @@ function DataPage() {
const jobs = usePageData<SyncJob>('/api/admin/v1/data/sync-jobs');
const loadOverview = async () => {
const response = await api.get<ApiResponse<DataOverview>>('/api/admin/v1/data/overview');
setOverview(response.data.data);
setOverview(await getDataOverview());
};
useEffect(() => {
@@ -33,7 +32,7 @@ function DataPage() {
const syncData = async () => {
setSyncing(true);
try {
await api.post('/api/admin/v1/data/sync', {});
await syncDataMetrics();
Message.success('数据同步完成');
await Promise.all([loadOverview(), dramas.reload(), episodes.reload(), jobs.reload()]);
} finally {

View File

@@ -18,8 +18,16 @@ import {
Tag,
} from "@arco-design/web-react";
import { IconArrowLeft, IconPlus, IconRefresh } from "@arco-design/web-react/icon";
import type { ApiResponse, Drama, Episode, UploadTask } from "../api/types";
import { api } from "../api/client";
import type { Drama, Episode } from "../api/types";
import {
configureDramaEpisodes,
createDrama as createDramaApi,
getDrama,
getDramaEpisodes,
updateDrama,
updateEpisode,
} from "../api/dramas";
import { completeUploadTask, createUploadTask, syncReviewStatus } from "../api/media";
import { usePageData } from "../hooks/usePageData";
const { Row, Col } = Grid;
@@ -65,7 +73,7 @@ function DramaListPage() {
const createDrama = async (values: Partial<Drama>) => {
setCreating(true);
try {
await api.post("/api/admin/v1/dramas", values);
await createDramaApi(values);
Message.success("短剧创建成功");
setVisible(false);
await reload();
@@ -218,13 +226,10 @@ function DramaDetailPage({ dramaId }: { dramaId: number }) {
const loadDetail = async () => {
setLoading(true);
try {
const [dramaResponse, episodeResponse] = await Promise.all([
api.get<ApiResponse<Drama>>(`/api/admin/v1/dramas/${dramaId}`),
api.get<ApiResponse<{ list: Episode[] }>>(`/api/admin/v1/dramas/${dramaId}/episodes?pageSize=200`),
]);
setDrama(dramaResponse.data.data);
setEpisodeCount(dramaResponse.data.data.totalEpisodes || 1);
setEpisodes(episodeResponse.data.data.list);
const [dramaData, episodeList] = await Promise.all([getDrama(dramaId), getDramaEpisodes(dramaId)]);
setDrama(dramaData);
setEpisodeCount(dramaData.totalEpisodes || 1);
setEpisodes(episodeList);
} finally {
setLoading(false);
}
@@ -235,15 +240,13 @@ function DramaDetailPage({ dramaId }: { dramaId: number }) {
}, [dramaId]);
const configureEpisodes = async () => {
await api.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, {
episodeCount,
});
await configureDramaEpisodes(dramaId, episodeCount);
Message.success("剧集数量已更新");
await loadDetail();
};
const updateDramaStatus = async (status: string) => {
await api.patch(`/api/admin/v1/dramas/${dramaId}`, { status });
await updateDrama(dramaId, { status });
Message.success("短剧状态已更新");
await loadDetail();
};
@@ -251,14 +254,14 @@ function DramaDetailPage({ dramaId }: { dramaId: number }) {
const uploadEpisodeVideo = async (episode: Episode, file: File) => {
setUploadingEpisodeId(episode.id);
try {
const task = await api.post<ApiResponse<UploadTask>>("/api/admin/v1/media/upload-tasks", {
const task = await createUploadTask({
episodeId: episode.id,
fileName: file.name,
contentType: file.type || "video/mp4",
fileSize: file.size,
});
await api.patch(`/api/admin/v1/media/upload-tasks/${task.data.data.jobId}/complete`, {});
await api.post("/api/admin/v1/media/review-status/sync", {});
await completeUploadTask(task.jobId);
await syncReviewStatus();
Message.success("视频上传并审核通过");
await loadDetail();
} finally {
@@ -267,7 +270,7 @@ function DramaDetailPage({ dramaId }: { dramaId: number }) {
};
const publishEpisode = async (episode: Episode) => {
await api.patch(`/api/admin/v1/episodes/${episode.id}`, {
await updateEpisode(episode.id, {
publishStatus: "published",
status: "published",
});

View File

@@ -1,162 +0,0 @@
.login-page {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(520px, 0.95fr) minmax(420px, 1fr);
background: #f7f8fa;
}
.login-visual {
position: relative;
overflow: hidden;
padding: 44px 56px 48px;
color: #ffffff;
background:
linear-gradient(135deg, rgba(15, 23, 42, 0.98) 0%, rgba(22, 93, 255, 0.94) 54%, rgba(15, 118, 110, 0.96) 100%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.18), transparent 28%),
radial-gradient(circle at 82% 72%, rgba(255, 255, 255, 0.12), transparent 32%);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.login-visual::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0));
pointer-events: none;
}
.login-logo {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
letter-spacing: 0;
}
.login-brand {
position: relative;
z-index: 1;
max-width: 640px;
}
.login-eyebrow {
display: inline-flex;
margin-bottom: 18px;
padding: 6px 12px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 999px;
color: rgba(255, 255, 255, 0.82);
font-size: 13px;
}
.login-brand h1 {
margin: 0 0 16px;
font-size: 42px;
line-height: 1.16;
letter-spacing: 0;
}
.login-brand p {
margin: 0;
max-width: 560px;
color: rgba(255, 255, 255, 0.78);
font-size: 16px;
line-height: 1.8;
}
.login-metrics {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
max-width: 640px;
}
.login-metric {
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.09);
backdrop-filter: blur(10px);
}
.login-metric strong {
display: block;
font-size: 20px;
margin-bottom: 6px;
}
.login-metric span {
color: rgba(255, 255, 255, 0.72);
}
.login-panel {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(247, 248, 250, 0.94)),
#f7f8fa;
}
.login-card {
width: 100%;
max-width: 420px;
padding: 36px;
border: 1px solid #e5e6eb;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
}
.login-card-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 30px;
}
.login-card-mark {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 8px;
color: #ffffff;
background: #165dff;
font-weight: 700;
}
.login-card h2 {
margin: 0 0 8px;
font-size: 24px;
}
.login-card .subtitle {
margin: 0;
color: #86909c;
}
.login-card-footer {
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid #f2f3f5;
color: #86909c;
font-size: 12px;
}
@media (max-width: 900px) {
.login-page {
grid-template-columns: 1fr;
}
.login-visual {
display: none;
}
}

View File

@@ -2,9 +2,8 @@ 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';
import { login } from '../api/auth';
import { API_BASE_URL, TOKEN_KEY } from '../api/client';
function LoginPage() {
const navigate = useNavigate();
@@ -13,8 +12,8 @@ function LoginPage() {
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);
const data = await login(values);
localStorage.setItem(TOKEN_KEY, data.accessToken);
Message.success('登录成功');
navigate('/dashboard', { replace: true });
} finally {
@@ -23,30 +22,30 @@ function LoginPage() {
};
return (
<div className="login-page">
<div className="login-visual">
<div className="login-logo">
<span className="logo-mark">CT</span>
<div className="grid min-h-screen grid-cols-1 bg-[#f7f8fa] min-[901px]:grid-cols-[minmax(520px,0.95fr)_minmax(420px,1fr)]">
<div className="relative hidden flex-col justify-between overflow-hidden px-14 pb-12 pt-11 text-white after:pointer-events-none after:absolute after:inset-0 after:bg-[linear-gradient(180deg,rgba(255,255,255,0.08),rgba(255,255,255,0))] min-[901px]:flex bg-[linear-gradient(135deg,rgba(15,23,42,0.98)_0%,rgba(22,93,255,0.94)_54%,rgba(15,118,110,0.96)_100%),radial-gradient(circle_at_18%_18%,rgba(255,255,255,0.18),transparent_28%),radial-gradient(circle_at_82%_72%,rgba(255,255,255,0.12),transparent_32%)]">
<div className="relative z-[1] flex items-center gap-2.5 font-bold">
<span className="grid h-7 w-7 place-items-center rounded-md bg-brand text-sm text-white">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 className="relative z-[1] max-w-[640px]">
<span className="mb-[18px] inline-flex rounded-full border border-white/20 px-3 py-1.5 text-[13px] text-white/80">TikTok </span>
<h1 className="mb-4 mt-0 text-[42px] leading-[1.16]">CTH TikTok </h1>
<p className="m-0 max-w-[560px] text-base leading-[1.8] text-white/75"></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 className="relative z-[1] grid max-w-[640px] grid-cols-3 gap-3">
<div className="rounded-lg border border-white/20 bg-white/10 p-4 backdrop-blur-[10px]"><strong className="mb-1.5 block text-xl"></strong><span className="text-white/70"></span></div>
<div className="rounded-lg border border-white/20 bg-white/10 p-4 backdrop-blur-[10px]"><strong className="mb-1.5 block text-xl"></strong><span className="text-white/70"></span></div>
<div className="rounded-lg border border-white/20 bg-white/10 p-4 backdrop-blur-[10px]"><strong className="mb-1.5 block text-xl"></strong><span className="text-white/70"></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 className="flex items-center justify-center bg-[linear-gradient(180deg,rgba(255,255,255,0.86),rgba(247,248,250,0.94)),#f7f8fa] p-12">
<div className="w-full max-w-[420px] rounded-lg border border-line bg-white p-9 shadow-login">
<div className="mb-[30px] flex items-center gap-3.5">
<span className="grid h-11 w-11 place-items-center rounded-lg bg-brand font-bold text-white">CT</span>
<div>
<h2></h2>
<p className="subtitle">使</p>
<h2 className="mb-2 mt-0 text-2xl"></h2>
<p className="m-0 text-muted">使</p>
</div>
</div>
<Form layout="vertical" onSubmit={onSubmit} initialValues={{ username: 'admin', password: 'admin123' }}>
@@ -58,7 +57,7 @@ function LoginPage() {
</Form.Item>
<Button type="primary" htmlType="submit" loading={loading} long></Button>
</Form>
<div className="login-card-footer">API {API_BASE_URL}</div>
<div className="mt-[22px] border-t border-canvas pt-[18px] text-xs text-muted">API {API_BASE_URL}</div>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@ 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 { createPersonnel as createPersonnelApi, updateAdminUserRoles, updateAdminUserStatus } from '../api/personnel';
import { usePageData } from '../hooks/usePageData';
function PersonnelPage() {
@@ -30,10 +30,7 @@ function PersonnelPage() {
}) => {
setCreating(true);
try {
await api.post('/api/admin/v1/admin-users', {
...values,
roleIds: values.roleIds ?? [],
});
await createPersonnelApi(values);
Message.success('人员创建成功');
setCreateVisible(false);
await reload();
@@ -54,9 +51,7 @@ function PersonnelPage() {
}
setAssigning(true);
try {
await api.patch(`/api/admin/v1/admin-users/${selectedAdmin.id}/roles`, {
roleIds: selectedRoleIds,
});
await updateAdminUserRoles(selectedAdmin.id, selectedRoleIds);
Message.success('角色分配已更新');
setAssignVisible(false);
await reload();
@@ -66,7 +61,7 @@ function PersonnelPage() {
};
const updateStatus = async (id: number, status: 'active' | 'disabled') => {
await api.patch(`/api/admin/v1/admin-users/${id}/status`, { status });
await updateAdminUserStatus(id, status);
Message.success('人员状态已更新');
await reload();
};

View File

@@ -1,65 +0,0 @@
.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;
}
}

View File

@@ -1,9 +1,9 @@
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';
import type { AdminProfile } from '../api/types';
import { changePassword as changePasswordApi, getProfile, updateProfile as updateProfileApi } from '../api/auth';
import { TOKEN_KEY } from '../api/client';
function ProfilePage() {
const navigate = useNavigate();
@@ -17,11 +17,11 @@ function ProfilePage() {
const loadProfile = async () => {
setLoading(true);
try {
const response = await api.get<ApiResponse<AdminProfile>>('/api/admin/v1/auth/profile');
setProfile(response.data.data);
const data = await getProfile();
setProfile(data);
form.setFieldsValue({
nickname: response.data.data.nickname,
email: response.data.data.email,
nickname: data.nickname,
email: data.email,
});
} finally {
setLoading(false);
@@ -35,8 +35,8 @@ function ProfilePage() {
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);
const data = await updateProfileApi(values);
setProfile(data);
Message.success('个人信息已更新');
} finally {
setSaving(false);
@@ -50,7 +50,7 @@ function ProfilePage() {
}
setChangingPassword(true);
try {
await api.patch('/api/admin/v1/auth/password', {
await changePasswordApi({
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
@@ -64,20 +64,20 @@ function ProfilePage() {
};
return (
<div className="profile-grid">
<Card className="section-card" title="个人中心" loading={loading}>
<div className="profile-summary">
<div className="grid items-start gap-4 min-[901px]:grid-cols-[minmax(0,1fr)_420px]">
<Card className="section-card min-[901px]:row-span-2" title="个人中心" loading={loading}>
<div className="mb-6 flex items-center gap-4">
<Avatar size={56}>{profile?.username?.slice(0, 1).toUpperCase() || 'A'}</Avatar>
<div>
<h2>{profile?.nickname || profile?.username || '管理员'}</h2>
<p>{profile?.email || '未设置邮箱'}</p>
<h2 className="mb-1.5 mt-0 text-[22px]">{profile?.nickname || profile?.username || '管理员'}</h2>
<p className="m-0 text-muted">{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 className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-line bg-panel p-3.5"><span className="mb-2 block text-[13px] text-muted"></span><strong className="break-words font-medium text-ink">{profile?.username || '-'}</strong></div>
<div className="rounded-lg border border-line bg-panel p-3.5"><span className="mb-2 block text-[13px] text-muted"></span><strong className="break-words font-medium text-ink">{profile?.status === 'active' ? '启用' : '禁用'}</strong></div>
<div className="rounded-lg border border-line bg-panel p-3.5"><span className="mb-2 block text-[13px] text-muted"></span><strong className="break-words font-medium text-ink">{profile?.isSuperAdmin ? '超级管理员' : profile?.roles.map((role) => role.name).join('、') || '-'}</strong></div>
<div className="rounded-lg border border-line bg-panel p-3.5"><span className="mb-2 block text-[13px] text-muted"></span><strong className="break-words font-medium text-ink">{profile?.createdAt || '-'}</strong></div>
</div>
</Card>
<Card className="section-card" title="修改个人信息">

View File

@@ -137,7 +137,7 @@ export function PermissionTreePicker({
const checkedKeys = value.map((id) => permissionKey(id));
return (
<div className="permission-tree-panel">
<div className="mb-[15px] h-[360px] overflow-auto rounded-md border border-line bg-panel px-4 py-3">
<Tree
blockNode
checkable

View File

@@ -1,23 +0,0 @@
.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;
margin-bottom: 15px;
padding: 12px 16px;
border: 1px solid #e5e6eb;
border-radius: 6px;
background: #fbfcfd;
}

View File

@@ -2,10 +2,9 @@ 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 { createRole as createRoleApi, updateRolePermissions } from "../api/roles";
import { usePageData } from "../hooks/usePageData";
import { PermissionTreePicker } from "./PermissionTreePicker";
import "./index.css";
function RolesPage() {
const { data, loading, reload, changePage } = usePageData<Role>("/api/admin/v1/roles");
@@ -28,7 +27,7 @@ function RolesPage() {
const createRole = async (values: { code: string; name: string; description?: string }) => {
setCreatingRole(true);
try {
await api.post("/api/admin/v1/roles", {
await createRoleApi({
...values,
permissionIds: createPermissionIds,
});
@@ -52,9 +51,7 @@ function RolesPage() {
}
setSavingPermissions(true);
try {
await api.patch(`/api/admin/v1/roles/${selectedRole.id}/permissions`, {
permissionIds: selectedPermissionIds,
});
await updateRolePermissions(selectedRole.id, selectedPermissionIds);
Message.success("角色权限已更新");
setAssignmentVisible(false);
await reload();
@@ -122,7 +119,7 @@ function RolesPage() {
</Form.Item>
<div className="form-label"></div>
<PermissionTreePicker permissions={permissions.data.list} value={createPermissionIds} onChange={setCreatePermissionIds} />
<div className="form-help"></div>
<div className="form-help mt-5"></div>
<div className="modal-actions">
<Button onClick={() => setCreateVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={creatingRole}>

View File

@@ -1,12 +1,12 @@
import { Button, Card, Message, Table, Tag } from '@arco-design/web-react';
import type { AppUser } from '../api/types';
import { api } from '../api/client';
import { updateUserStatus } from '../api/users';
import { usePageData } from '../hooks/usePageData';
function UsersPage() {
const { data, loading, reload, changePage } = usePageData<AppUser>('/api/admin/v1/users');
const update状态 = async (id: number, status: 'active' | 'disabled') => {
await api.patch(`/api/admin/v1/users/${id}/status`, { status });
await updateUserStatus(id, status);
Message.success('用户状态已更新');
await reload();
};

44
src/api/auth.ts Normal file
View File

@@ -0,0 +1,44 @@
import { api } from './client';
import type { AdminProfile, ApiResponse } from './types';
export type LoginPayload = {
username: string;
password: string;
};
export type UpdateProfilePayload = {
nickname?: string;
email?: string;
};
export type ChangePasswordPayload = {
oldPassword: string;
newPassword: string;
};
export async function login(payload: LoginPayload) {
const response = await api.post<ApiResponse<{ accessToken: string }>>(
'/api/admin/v1/auth/login',
payload,
);
return response.data.data;
}
export async function getProfile() {
const response = await api.get<ApiResponse<AdminProfile>>(
'/api/admin/v1/auth/profile',
);
return response.data.data;
}
export async function updateProfile(payload: UpdateProfilePayload) {
const response = await api.patch<ApiResponse<AdminProfile>>(
'/api/admin/v1/auth/profile',
payload,
);
return response.data.data;
}
export async function changePassword(payload: ChangePasswordPayload) {
await api.patch('/api/admin/v1/auth/password', payload);
}

13
src/api/data.ts Normal file
View File

@@ -0,0 +1,13 @@
import { api } from './client';
import type { ApiResponse, DataOverview } from './types';
export async function getDataOverview() {
const response = await api.get<ApiResponse<DataOverview>>(
'/api/admin/v1/data/overview',
);
return response.data.data;
}
export async function syncDataMetrics() {
await api.post('/api/admin/v1/data/sync', {});
}

44
src/api/dramas.ts Normal file
View File

@@ -0,0 +1,44 @@
import { api } from './client';
import type { ApiResponse, Drama, Episode } from './types';
export async function createDrama(payload: Partial<Drama>) {
await api.post('/api/admin/v1/dramas', payload);
}
export async function getDrama(dramaId: number) {
const response = await api.get<ApiResponse<Drama>>(
`/api/admin/v1/dramas/${dramaId}`,
);
return response.data.data;
}
export async function getDramaEpisodes(dramaId: number) {
const response = await api.get<ApiResponse<{ list: Episode[] }>>(
`/api/admin/v1/dramas/${dramaId}/episodes`,
{ params: { pageSize: 200 } },
);
return response.data.data.list;
}
export async function configureDramaEpisodes(
dramaId: number,
episodeCount: number,
) {
await api.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, {
episodeCount,
});
}
export async function updateDrama(
dramaId: number,
payload: Partial<Pick<Drama, 'status'>>,
) {
await api.patch(`/api/admin/v1/dramas/${dramaId}`, payload);
}
export async function updateEpisode(
episodeId: number,
payload: Partial<Pick<Episode, 'publishStatus' | 'status'>>,
) {
await api.patch(`/api/admin/v1/episodes/${episodeId}`, payload);
}

25
src/api/media.ts Normal file
View File

@@ -0,0 +1,25 @@
import { api } from './client';
import type { ApiResponse, UploadTask } from './types';
export type CreateUploadTaskPayload = {
episodeId: number;
fileName: string;
contentType: string;
fileSize: number;
};
export async function createUploadTask(payload: CreateUploadTaskPayload) {
const response = await api.post<ApiResponse<UploadTask>>(
'/api/admin/v1/media/upload-tasks',
payload,
);
return response.data.data;
}
export async function completeUploadTask(jobId: string) {
await api.patch(`/api/admin/v1/media/upload-tasks/${jobId}/complete`, {});
}
export async function syncReviewStatus() {
await api.post('/api/admin/v1/media/review-status/sync', {});
}

27
src/api/personnel.ts Normal file
View File

@@ -0,0 +1,27 @@
import { api } from './client';
export type CreatePersonnelPayload = {
username: string;
password: string;
nickname?: string;
email?: string;
roleIds?: number[];
};
export async function createPersonnel(payload: CreatePersonnelPayload) {
await api.post('/api/admin/v1/admin-users', {
...payload,
roleIds: payload.roleIds ?? [],
});
}
export async function updateAdminUserRoles(id: number, roleIds: number[]) {
await api.patch(`/api/admin/v1/admin-users/${id}/roles`, { roleIds });
}
export async function updateAdminUserStatus(
id: number,
status: 'active' | 'disabled',
) {
await api.patch(`/api/admin/v1/admin-users/${id}/status`, { status });
}

21
src/api/roles.ts Normal file
View File

@@ -0,0 +1,21 @@
import { api } from './client';
export type CreateRolePayload = {
code: string;
name: string;
description?: string;
permissionIds: number[];
};
export async function createRole(payload: CreateRolePayload) {
await api.post('/api/admin/v1/roles', payload);
}
export async function updateRolePermissions(
roleId: number,
permissionIds: number[],
) {
await api.patch(`/api/admin/v1/roles/${roleId}/permissions`, {
permissionIds,
});
}

8
src/api/users.ts Normal file
View File

@@ -0,0 +1,8 @@
import { api } from './client';
export async function updateUserStatus(
id: number,
status: 'active' | 'disabled',
) {
await api.patch(`/api/admin/v1/users/${id}/status`, { status });
}

View File

@@ -1,42 +1,48 @@
* {
box-sizing: border-box;
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply box-border;
}
html,
body,
#root {
@apply m-0 min-h-full w-full min-w-80;
}
body {
@apply bg-canvas font-sans text-ink;
}
button,
input,
textarea,
select {
font: inherit;
}
}
html,
body,
#root {
width: 100%;
min-width: 320px;
min-height: 100%;
margin: 0;
}
@layer components {
.section-card {
@apply rounded-lg;
}
body {
color: #1d2129;
background: #f2f3f5;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.page-stack {
@apply flex flex-col gap-4;
}
button,
input,
textarea,
select {
font: inherit;
}
.modal-actions {
@apply mt-6 flex justify-end gap-3;
}
.section-card {
border-radius: 8px;
}
.form-label {
@apply mb-2 text-sm font-medium text-[#4e5969];
}
.page-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
.form-help {
@apply -mt-3 mb-[18px] text-[13px] leading-[1.6] text-muted;
}
}

View File

@@ -2,8 +2,9 @@ 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 { TOKEN_KEY, api } from "../api/client";
import type { AdminProfile } from "../api/types";
import { getProfile } from "../api/auth";
import { TOKEN_KEY } from "../api/client";
import { filterMenuItems, findCurrentMenu, menuItems, renderMenuItem } from "./menu";
import { PermissionRoute } from "./PermissionRoute";
import DashboardPage from "../Dashboard";
@@ -14,7 +15,6 @@ 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;
@@ -29,8 +29,8 @@ export function AdminLayout() {
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))
getProfile()
.then((data) => setProfile(data))
.catch(() => {
localStorage.removeItem(TOKEN_KEY);
navigate("/login", { replace: true });
@@ -49,10 +49,10 @@ export function AdminLayout() {
};
return (
<Layout className="layout">
<Sider className="sidebar" width={220} collapsed={collapsed} collapsible trigger={null}>
<div className="logo-row">
<span className="logo-mark">TK</span>
<Layout className="min-h-screen">
<Sider className="shadow-[1px_0_0_#e5e6eb]" width={220} collapsed={collapsed} collapsible trigger={null}>
<div className="flex h-14 items-center gap-2.5 border-b border-line px-[18px] font-bold">
<span className="grid h-7 w-7 place-items-center rounded-md bg-brand text-sm text-white">TK</span>
{!collapsed && <span>TK短剧管理后台</span>}
</div>
<Menu
@@ -65,10 +65,10 @@ export function AdminLayout() {
</Menu>
</Sider>
<Layout>
<Header className="header">
<Header className="flex h-14 items-center justify-between border-b border-line bg-white px-5">
<Space>
<Button icon={collapsed ? <IconMenuUnfold /> : <IconMenuFold />} onClick={() => setCollapsed(!collapsed)} />
<div className="header-title">
<div className="flex flex-col gap-0.5">
<strong>{current?.item.label || "工作台"}</strong>
</div>
</Space>
@@ -86,13 +86,13 @@ export function AdminLayout() {
</Menu>
}
>
<button className="user-menu-trigger" type="button">
<button className="inline-flex cursor-pointer items-center gap-2 rounded-md border-0 bg-transparent px-2 py-1 text-ink hover:bg-canvas" 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">
<Content className="p-3 sm:p-5">
<Routes>
<Route path="/dashboard" element={<DashboardPage profile={profile} />} />
<Route

View File

@@ -1,79 +0,0 @@
.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;
}
}