refactor: 重构分页逻辑并统一代码风格

1. 新增分页数据处理hook和工具函数,实现分页状态统一管理
2. 替换所有页面的表格分页配置为标准current/page模式
3. 统一项目内字符串引号风格为双引号
4. 调整权限树面板样式和布局代码格式
This commit is contained in:
2026-07-02 11:50:07 +08:00
parent 9362625b4e
commit 526052d633
13 changed files with 884 additions and 809 deletions

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"tabWidth": 4,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 390,
"eslintIntegration": true,
"bracketSpacing": true,
"stylelintIntegration": true
}

View File

@@ -55,7 +55,7 @@ function DataPage() {
</Card> </Card>
<Card className="section-card" title="短剧数据"> <Card className="section-card" title="短剧数据">
<Table rowKey="dramaId" loading={dramas.loading} data={dramas.data.list} pagination={{ total: dramas.data.pagination.total, pageSize: dramas.data.pagination.pageSize }} columns={[ <Table rowKey="dramaId" loading={dramas.loading} data={dramas.data.list} pagination={{ current: dramas.data.pagination.page, total: dramas.data.pagination.total, pageSize: dramas.data.pagination.pageSize, onChange: dramas.changePage }} columns={[
{ title: '短剧ID', dataIndex: 'dramaId', width: 100 }, { title: '短剧ID', dataIndex: 'dramaId', width: 100 },
{ title: '短剧名称', dataIndex: 'title' }, { title: '短剧名称', dataIndex: 'title' },
{ title: '播放量', dataIndex: 'plays', width: 120 }, { title: '播放量', dataIndex: 'plays', width: 120 },
@@ -68,7 +68,7 @@ function DataPage() {
</Card> </Card>
<Card className="section-card" title="剧集数据"> <Card className="section-card" title="剧集数据">
<Table rowKey="episodeId" loading={episodes.loading} data={episodes.data.list} pagination={{ total: episodes.data.pagination.total, pageSize: episodes.data.pagination.pageSize }} columns={[ <Table rowKey="episodeId" loading={episodes.loading} data={episodes.data.list} pagination={{ current: episodes.data.pagination.page, total: episodes.data.pagination.total, pageSize: episodes.data.pagination.pageSize, onChange: episodes.changePage }} columns={[
{ title: '剧集ID', dataIndex: 'episodeId', width: 100 }, { title: '剧集ID', dataIndex: 'episodeId', width: 100 },
{ title: '短剧ID', dataIndex: 'dramaId', width: 100 }, { title: '短剧ID', dataIndex: 'dramaId', width: 100 },
{ title: '集数', dataIndex: 'episodeNo', width: 90 }, { title: '集数', dataIndex: 'episodeNo', width: 90 },
@@ -81,7 +81,7 @@ function DataPage() {
</Card> </Card>
<Card className="section-card" title="同步记录"> <Card className="section-card" title="同步记录">
<Table rowKey="id" loading={jobs.loading} data={jobs.data.list} pagination={{ total: jobs.data.pagination.total, pageSize: jobs.data.pagination.pageSize }} columns={[ <Table rowKey="id" loading={jobs.loading} data={jobs.data.list} pagination={{ current: jobs.data.pagination.page, total: jobs.data.pagination.total, pageSize: jobs.data.pagination.pageSize, onChange: jobs.changePage }} columns={[
{ title: '任务ID', dataIndex: 'jobId' }, { title: '任务ID', dataIndex: 'jobId' },
{ title: '状态', dataIndex: 'status', width: 120 }, { title: '状态', dataIndex: 'status', width: 120 },
{ title: '短剧数', dataIndex: 'dramaCount', width: 120 }, { title: '短剧数', dataIndex: 'dramaCount', width: 120 },

View File

@@ -1,27 +1,23 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { import {
Button, Button,
Card, Card,
Descriptions, Descriptions,
Form, Form,
Grid, Grid,
Input, Input,
InputNumber, InputNumber,
InputTag, InputTag,
Message, Message,
Modal, Modal,
Select, Select,
Space, Space,
Switch, Switch,
Table, Table,
Tag, Tag,
} from "@arco-design/web-react"; } from "@arco-design/web-react";
import { import { IconArrowLeft, IconPlus, IconRefresh } from "@arco-design/web-react/icon";
IconArrowLeft,
IconPlus,
IconRefresh,
} from "@arco-design/web-react/icon";
import type { ApiResponse, Drama, Episode, UploadTask } from "../api/types"; import type { ApiResponse, Drama, Episode, UploadTask } from "../api/types";
import { api } from "../api/client"; import { api } from "../api/client";
import { usePageData } from "../hooks/usePageData"; import { usePageData } from "../hooks/usePageData";
@@ -29,450 +25,386 @@ import { usePageData } from "../hooks/usePageData";
const { Row, Col } = Grid; const { Row, Col } = Grid;
const STATUS_OPTIONS: { value: string; label: string }[] = [ const STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "draft", label: "草稿" }, { value: "draft", label: "草稿" },
{ value: "scheduled", label: "定时发布" }, { value: "scheduled", label: "定时发布" },
{ value: "published", label: "已发布" }, { value: "published", label: "已发布" },
{ value: "offline", label: "已下线" }, { value: "offline", label: "已下线" },
]; ];
const REVIEW_STATUS_LABEL: Record<string, string> = { const REVIEW_STATUS_LABEL: Record<string, string> = {
not_submitted: "未提交", not_submitted: "未提交",
pending: "待审核", pending: "待审核",
reviewing: "审核中", reviewing: "审核中",
approved: "已通过", approved: "已通过",
rejected: "已拒绝", rejected: "已拒绝",
}; };
const PUBLISH_STATUS_LABEL: Record<string, string> = { const PUBLISH_STATUS_LABEL: Record<string, string> = {
draft: "草稿", draft: "草稿",
scheduled: "定时发布", scheduled: "定时发布",
published: "已发布", published: "已发布",
offline: "已下线", offline: "已下线",
}; };
function DramasPage() { function DramasPage() {
const { id } = useParams(); const { id } = useParams();
if (id) { if (id) {
return <DramaDetailPage dramaId={Number(id)} />; return <DramaDetailPage dramaId={Number(id)} />;
} }
return <DramaListPage />; return <DramaListPage />;
} }
function DramaListPage() { function DramaListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data, loading, reload } = usePageData<Drama>("/api/admin/v1/dramas"); const { data, loading, reload, changePage } = usePageData<Drama>("/api/admin/v1/dramas");
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const createDrama = async (values: Partial<Drama>) => { const createDrama = async (values: Partial<Drama>) => {
setCreating(true); setCreating(true);
try { try {
await api.post("/api/admin/v1/dramas", values); await api.post("/api/admin/v1/dramas", values);
Message.success("短剧创建成功"); Message.success("短剧创建成功");
setVisible(false); setVisible(false);
await reload(); await reload();
} finally { } finally {
setCreating(false); setCreating(false);
} }
}; };
return ( return (
<Card <Card
className="section-card" className="section-card"
title="短剧管理" title="短剧管理"
extra={ extra={
<Button <Button type="primary" icon={<IconPlus />} onClick={() => setVisible(true)}>
type="primary"
icon={<IconPlus />} </Button>
onClick={() => setVisible(true)} }
> >
<Table
</Button> rowKey="id"
} loading={loading}
> data={data.list}
<Table pagination={{
rowKey="id" current: data.pagination.page,
loading={loading} total: data.pagination.total,
data={data.list} pageSize: data.pagination.pageSize,
pagination={{ onChange: (page, pageSize) => changePage(page, pageSize),
total: data.pagination.total, }}
pageSize: data.pagination.pageSize, columns={[
}} { title: "ID", dataIndex: "id", width: 80 },
columns={[ { title: "标题", dataIndex: "title" },
{ title: "ID", dataIndex: "id", width: 80 }, { title: "地区", dataIndex: "region", width: 120 },
{ title: "标题", dataIndex: "title" }, { title: "类型", dataIndex: "type", width: 140 },
{ title: "地区", dataIndex: "region", width: 120 }, { title: "总集数", dataIndex: "totalEpisodes", width: 100 },
{ title: "类型", dataIndex: "type", width: 140 }, { title: "已发布", dataIndex: "publishedEpisodes", width: 100 },
{ title: "总集数", dataIndex: "totalEpisodes", width: 100 }, {
{ title: "已发布", dataIndex: "publishedEpisodes", width: 100 }, title: "状态",
{ dataIndex: "status",
title: "状态", width: 120,
dataIndex: "status", render: (status) => (
width: 120, <Tag color={status === "published" ? "green" : "gray"}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag>
render: (status) => ( ),
<Tag color={status === "published" ? "green" : "gray"}> },
{PUBLISH_STATUS_LABEL[String(status)] || String(status)} {
</Tag> title: "推荐",
), dataIndex: "isRecommended",
}, width: 100,
{ render: (value) => (value ? <Tag color="arcoblue"></Tag> : <Tag></Tag>),
title: "推荐", },
dataIndex: "isRecommended", {
width: 100, title: "操作",
render: (value) => width: 120,
value ? <Tag color="arcoblue"></Tag> : <Tag></Tag>, render: (_, record) => (
}, <Button type="text" onClick={() => navigate(`/dramas/${record.id}`)}>
{
title: "操作", </Button>
width: 120, ),
render: (_, record) => ( },
<Button ]}
type="text"
onClick={() => navigate(`/dramas/${record.id}`)}
>
</Button>
),
},
]}
/>
<Modal
title="创建短剧"
visible={visible}
onCancel={() => setVisible(false)}
footer={null}
style={{ width: 640 }}
>
<Form
layout="vertical"
onSubmit={createDrama}
initialValues={{
status: "draft",
isRecommended: false,
sortOrder: 0,
}}
>
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
<Input placeholder="请输入短剧标题" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="类型" field="type" rules={[{ required: true }]}>
<Input placeholder="请输入类型" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="状态" field="status">
<Select placeholder="请选择状态">
{STATUS_OPTIONS.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
label="封面地址"
field="coverUrl"
rules={[{ required: true }]}
>
<Input placeholder="请输入封面图片 URL" />
</Form.Item>
<Form.Item label="简介" field="description">
<Input.TextArea
placeholder="请输入短剧简介"
autoSize={{ minRows: 2, maxRows: 4 }}
/> />
</Form.Item> <Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: 640 }}>
<Form.Item label="标签" field="tags"> <Form
<InputTag placeholder="输入后回车添加标签" allowClear /> layout="vertical"
</Form.Item> onSubmit={createDrama}
<Row gutter={16}> initialValues={{
<Col span={8}> status: "draft",
<Form.Item label="地区" field="region"> isRecommended: false,
<Input placeholder="如 中国" /> sortOrder: 0,
</Form.Item> }}
</Col> >
<Col span={8}> <Form.Item label="标题" field="title" rules={[{ required: true }]}>
<Form.Item label="语言" field="language"> <Input placeholder="请输入短剧标题" />
<Input placeholder="如 zh-CN" /> </Form.Item>
</Form.Item> <Row gutter={16}>
</Col> <Col span={12}>
<Col span={8}> <Form.Item label="类型" field="type" rules={[{ required: true }]}>
<Form.Item label="年份" field="year"> <Input placeholder="请输入类型,如复仇、爱情、动作等" />
<InputNumber </Form.Item>
placeholder="如 2026" </Col>
min={1900} <Col span={12}>
style={{ width: "100%" }} <Form.Item label="状态" field="status">
/> <Select placeholder="请选择状态">
</Form.Item> {STATUS_OPTIONS.map((option) => (
</Col> <Select.Option key={option.value} value={option.value}>
</Row> {option.label}
<Row gutter={16}> </Select.Option>
<Col span={12}> ))}
<Form.Item label="排序权重" field="sortOrder"> </Select>
<InputNumber </Form.Item>
placeholder="数值越大越靠前" </Col>
style={{ width: "100%" }} </Row>
/> <Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
</Form.Item> <Input placeholder="请输入封面图片 URL" />
</Col> </Form.Item>
<Col span={12}> <Form.Item label="简介" field="description">
<Form.Item <Input.TextArea placeholder="请输入短剧简介" autoSize={{ minRows: 2, maxRows: 4 }} />
label="推荐" </Form.Item>
field="isRecommended" <Form.Item label="标签" field="tags">
triggerPropName="checked" <InputTag placeholder="输入后回车添加标签" allowClear />
> </Form.Item>
<Switch /> <Row gutter={16}>
</Form.Item> <Col span={8}>
</Col> <Form.Item label="地区" field="region">
</Row> <Input placeholder="如 中国" />
<div className="modal-actions"> </Form.Item>
<Button onClick={() => setVisible(false)}></Button> </Col>
<Button type="primary" htmlType="submit" loading={creating}> <Col span={8}>
<Form.Item label="语言" field="language">
</Button> <Input placeholder="如 zh-CN" />
</div> </Form.Item>
</Form> </Col>
</Modal> <Col span={8}>
</Card> <Form.Item label="年份" field="year">
); <InputNumber placeholder="如 2026" min={1900} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="排序权重" field="sortOrder">
<InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
<div className="modal-actions">
<Button onClick={() => setVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={creating}>
</Button>
</div>
</Form>
</Modal>
</Card>
);
} }
function DramaDetailPage({ dramaId }: { dramaId: number }) { function DramaDetailPage({ dramaId }: { dramaId: number }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [drama, setDrama] = useState<Drama | null>(null); const [drama, setDrama] = useState<Drama | null>(null);
const [episodes, setEpisodes] = useState<Episode[]>([]); const [episodes, setEpisodes] = useState<Episode[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [episodeCount, setEpisodeCount] = useState(1); const [episodeCount, setEpisodeCount] = useState(1);
const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>( const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>(null);
null,
);
const loadDetail = async () => { const loadDetail = async () => {
setLoading(true); setLoading(true);
try { try {
const [dramaResponse, episodeResponse] = await Promise.all([ const [dramaResponse, episodeResponse] = await Promise.all([
api.get<ApiResponse<Drama>>(`/api/admin/v1/dramas/${dramaId}`), api.get<ApiResponse<Drama>>(`/api/admin/v1/dramas/${dramaId}`),
api.get<ApiResponse<{ list: Episode[] }>>( api.get<ApiResponse<{ list: Episode[] }>>(`/api/admin/v1/dramas/${dramaId}/episodes?pageSize=200`),
`/api/admin/v1/dramas/${dramaId}/episodes?pageSize=200`, ]);
), setDrama(dramaResponse.data.data);
]); setEpisodeCount(dramaResponse.data.data.totalEpisodes || 1);
setDrama(dramaResponse.data.data); setEpisodes(episodeResponse.data.data.list);
setEpisodeCount(dramaResponse.data.data.totalEpisodes || 1); } finally {
setEpisodes(episodeResponse.data.data.list); setLoading(false);
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadDetail();
}, [dramaId]);
const configureEpisodes = async () => {
await api.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, {
episodeCount,
});
Message.success("剧集数量已更新");
await loadDetail();
};
const updateDramaStatus = async (status: string) => {
await api.patch(`/api/admin/v1/dramas/${dramaId}`, { status });
Message.success("短剧状态已更新");
await loadDetail();
};
const uploadEpisodeVideo = async (episode: Episode, file: File) => {
setUploadingEpisodeId(episode.id);
try {
const task = await api.post<ApiResponse<UploadTask>>(
"/api/admin/v1/media/upload-tasks",
{
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", {});
Message.success("视频上传并审核通过");
await loadDetail();
} finally {
setUploadingEpisodeId(null);
}
};
const publishEpisode = async (episode: Episode) => {
await api.patch(`/api/admin/v1/episodes/${episode.id}`, {
publishStatus: "published",
status: "published",
});
Message.success("剧集已发布");
await loadDetail();
};
return (
<div className="page-stack">
<Card
className="section-card"
loading={loading && !drama}
title={
<Space>
<Button
icon={<IconArrowLeft />}
onClick={() => navigate("/dramas")}
/>
</Space>
} }
extra={ };
<Space>
<Button icon={<IconRefresh />} onClick={loadDetail}> useEffect(() => {
void loadDetail();
</Button> }, [dramaId]);
<Button onClick={() => updateDramaStatus("draft")}>稿</Button>
<Button const configureEpisodes = async () => {
type="primary" await api.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, {
onClick={() => updateDramaStatus("published")} episodeCount,
});
Message.success("剧集数量已更新");
await loadDetail();
};
const updateDramaStatus = async (status: string) => {
await api.patch(`/api/admin/v1/dramas/${dramaId}`, { status });
Message.success("短剧状态已更新");
await loadDetail();
};
const uploadEpisodeVideo = async (episode: Episode, file: File) => {
setUploadingEpisodeId(episode.id);
try {
const task = await api.post<ApiResponse<UploadTask>>("/api/admin/v1/media/upload-tasks", {
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", {});
Message.success("视频上传并审核通过");
await loadDetail();
} finally {
setUploadingEpisodeId(null);
}
};
const publishEpisode = async (episode: Episode) => {
await api.patch(`/api/admin/v1/episodes/${episode.id}`, {
publishStatus: "published",
status: "published",
});
Message.success("剧集已发布");
await loadDetail();
};
return (
<div className="page-stack">
<Card
className="section-card"
loading={loading && !drama}
title={
<Space>
<Button icon={<IconArrowLeft />} onClick={() => navigate("/dramas")} />
</Space>
}
extra={
<Space>
<Button icon={<IconRefresh />} onClick={loadDetail}>
</Button>
<Button onClick={() => updateDramaStatus("draft")}>稿</Button>
<Button type="primary" onClick={() => updateDramaStatus("published")}>
</Button>
</Space>
}
> >
{drama && (
</Button> <Descriptions
</Space> column={3}
} data={[
> { label: "短剧ID", value: drama.id },
{drama && ( { label: "名称", value: drama.title },
<Descriptions { label: "地区", value: drama.region || "-" },
column={3} { label: "类型", value: drama.type },
data={[ { label: "总集数", value: drama.totalEpisodes || 0 },
{ label: "短剧ID", value: drama.id }, { label: "已发布集数", value: drama.publishedEpisodes || 0 },
{ label: "名称", value: drama.title }, {
{ label: "地区", value: drama.region || "-" }, label: "状态",
{ label: "类型", value: drama.type }, value: PUBLISH_STATUS_LABEL[drama.status] || drama.status,
{ label: "总集数", value: drama.totalEpisodes || 0 }, },
{ label: "已发布集数", value: drama.publishedEpisodes || 0 }, { label: "封面", value: drama.coverUrl },
{ { label: "简介", value: drama.description || "-" },
label: "状态", ]}
value: PUBLISH_STATUS_LABEL[drama.status] || drama.status, />
}, )}
{ label: "封面", value: drama.coverUrl }, </Card>
{ label: "简介", value: drama.description || "-" },
]}
/>
)}
</Card>
<Card <Card
className="section-card" className="section-card"
title="剧集配置" title="剧集配置"
extra={ extra={
<Space> <Space>
<InputNumber <InputNumber min={1} max={500} value={episodeCount} onChange={(value) => setEpisodeCount(Number(value) || 1)} />
min={1} <Button type="primary" onClick={configureEpisodes}>
max={500}
value={episodeCount} </Button>
onChange={(value) => setEpisodeCount(Number(value) || 1)} </Space>
/> }
<Button type="primary" onClick={configureEpisodes}> >
<Table
</Button> rowKey="id"
</Space> loading={loading}
} data={episodes}
> pagination={false}
<Table columns={[
rowKey="id" { title: "集数", dataIndex: "episodeNo", width: 80 },
loading={loading} { title: "标题", dataIndex: "title" },
data={episodes} {
pagination={false} title: "BytePlus VID",
columns={[ dataIndex: "byteplusVid",
{ title: "集数", dataIndex: "episodeNo", width: 80 }, width: 180,
{ title: "标题", dataIndex: "title" }, render: (value) => value || "-",
{ },
title: "BytePlus VID", {
dataIndex: "byteplusVid", title: "尺寸",
width: 180, width: 120,
render: (value) => value || "-", render: (_, record) => (record.width && record.height ? `${record.width}x${record.height}` : "-"),
}, },
{ {
title: "尺寸", title: "时长",
width: 120, dataIndex: "durationSeconds",
render: (_, record) => width: 100,
record.width && record.height render: (value) => (value ? `${value}s` : "-"),
? `${record.width}x${record.height}` },
: "-", {
}, title: "审核状态",
{ dataIndex: "reviewStatus",
title: "时长", width: 120,
dataIndex: "durationSeconds", render: (status) => (
width: 100, <Tag color={status === "approved" ? "green" : "orange"}>{REVIEW_STATUS_LABEL[String(status)] || String(status)}</Tag>
render: (value) => (value ? `${value}s` : "-"), ),
}, },
{ {
title: "审核状态", title: "发布状态",
dataIndex: "reviewStatus", dataIndex: "publishStatus",
width: 120, width: 120,
render: (status) => ( render: (status) => (
<Tag color={status === "approved" ? "green" : "orange"}> <Tag color={status === "published" ? "green" : "gray"}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag>
{REVIEW_STATUS_LABEL[String(status)] || String(status)} ),
</Tag> },
), {
}, title: "操作",
{ width: 280,
title: "发布状态", render: (_, record) => (
dataIndex: "publishStatus", <Space>
width: 120, <Button loading={uploadingEpisodeId === record.id}>
render: (status) => ( <label>
<Tag color={status === "published" ? "green" : "gray"}>
{PUBLISH_STATUS_LABEL[String(status)] || String(status)} <input
</Tag> type="file"
), accept="video/*"
}, style={{ display: "none" }}
{ onChange={(event) => {
title: "操作", const file = event.target.files?.[0];
width: 280, if (file) {
render: (_, record) => ( void uploadEpisodeVideo(record, file);
<Space> }
<Button loading={uploadingEpisodeId === record.id}> event.target.value = "";
<label> }}
/>
<input </label>
type="file" </Button>
accept="video/*" <Button type="text" disabled={record.reviewStatus !== "approved"} onClick={() => publishEpisode(record)}>
style={{ display: "none" }}
onChange={(event) => { </Button>
const file = event.target.files?.[0]; </Space>
if (file) { ),
void uploadEpisodeVideo(record, file); },
} ]}
event.target.value = ""; />
}} </Card>
/> </div>
</label> );
</Button>
<Button
type="text"
disabled={record.reviewStatus !== "approved"}
onClick={() => publishEpisode(record)}
>
</Button>
</Space>
),
},
]}
/>
</Card>
</div>
);
} }
export default DramasPage; export default DramasPage;

View File

@@ -6,7 +6,7 @@ import { api } from '../api/client';
import { usePageData } from '../hooks/usePageData'; import { usePageData } from '../hooks/usePageData';
function PersonnelPage() { function PersonnelPage() {
const { data, loading, reload } = usePageData<AdminUser>('/api/admin/v1/admin-users'); const { data, loading, reload, changePage } = usePageData<AdminUser>('/api/admin/v1/admin-users');
const roles = usePageData<Role>('/api/admin/v1/roles'); const roles = usePageData<Role>('/api/admin/v1/roles');
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [createVisible, setCreateVisible] = useState(false); const [createVisible, setCreateVisible] = useState(false);
@@ -77,7 +77,7 @@ function PersonnelPage() {
return ( return (
<Card className="section-card" title="人员管理" extra={<Button type="primary" icon={<IconPlus />} onClick={openCreate}></Button>}> <Card className="section-card" title="人员管理" extra={<Button type="primary" icon={<IconPlus />} onClick={openCreate}></Button>}>
<Table rowKey="id" loading={loading} data={data.list} pagination={{ total: data.pagination.total, pageSize: data.pagination.pageSize }} columns={[ <Table rowKey="id" loading={loading} data={data.list} pagination={{ current: data.pagination.page, total: data.pagination.total, pageSize: data.pagination.pageSize, onChange: changePage }} columns={[
{ title: 'ID', dataIndex: 'id', width: 80 }, { title: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username' }, { title: '用户名', dataIndex: 'username' },
{ title: '姓名', dataIndex: 'nickname' }, { title: '姓名', dataIndex: 'nickname' },

View File

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

View File

@@ -15,6 +15,7 @@
.permission-tree-panel { .permission-tree-panel {
height: 360px; height: 360px;
overflow: auto; overflow: auto;
margin-bottom: 15px;
padding: 12px 16px; padding: 12px 16px;
border: 1px solid #e5e6eb; border: 1px solid #e5e6eb;
border-radius: 6px; border-radius: 6px;

View File

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

View File

@@ -4,7 +4,7 @@ import { api } from '../api/client';
import { usePageData } from '../hooks/usePageData'; import { usePageData } from '../hooks/usePageData';
function UsersPage() { function UsersPage() {
const { data, loading, reload } = usePageData<AppUser>('/api/admin/v1/users'); const { data, loading, reload, changePage } = usePageData<AppUser>('/api/admin/v1/users');
const update状态 = async (id: number, status: 'active' | 'disabled') => { const update状态 = async (id: number, status: 'active' | 'disabled') => {
await api.patch(`/api/admin/v1/users/${id}/status`, { status }); await api.patch(`/api/admin/v1/users/${id}/status`, { status });
Message.success('用户状态已更新'); Message.success('用户状态已更新');
@@ -13,7 +13,7 @@ function UsersPage() {
return ( return (
<Card className="section-card" title="小程序用户管理"> <Card className="section-card" title="小程序用户管理">
<Table rowKey="id" loading={loading} data={data.list} pagination={{ total: data.pagination.total, pageSize: data.pagination.pageSize }} columns={[ <Table rowKey="id" loading={loading} data={data.list} pagination={{ current: data.pagination.page, total: data.pagination.total, pageSize: data.pagination.pageSize, onChange: changePage }} columns={[
{ title: 'ID', dataIndex: 'id', width: 80 }, { title: 'ID', dataIndex: 'id', width: 80 },
{ title: 'TikTok Open ID', dataIndex: 'tiktokOpenId' }, { title: 'TikTok Open ID', dataIndex: 'tiktokOpenId' },
{ title: '昵称', dataIndex: 'nickname' }, { title: '昵称', dataIndex: 'nickname' },

View File

@@ -1,9 +1,10 @@
import axios, { AxiosError } from 'axios'; import axios, { AxiosError } from "axios";
import { Message } from '@arco-design/web-react'; import { Message } from "@arco-design/web-react";
import type { ApiResponse, PageData } from './types'; import type { ApiResponse, PageData } from "./types";
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; export const API_BASE_URL =
export const TOKEN_KEY = 'cth_tk_admin_token'; 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 }); export const api = axios.create({ baseURL: API_BASE_URL });
@@ -18,13 +19,17 @@ api.interceptors.request.use((config) => {
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError<ApiResponse<unknown>>) => { (error: AxiosError<ApiResponse<unknown>>) => {
const message = error.response?.data?.message || error.message || '请求失败'; const message =
error.response?.data?.message || error.message || "请求失败";
Message.error(message); Message.error(message);
return Promise.reject(error); return Promise.reject(error);
}, },
); );
export async function fetchPage<T>(url: string): Promise<PageData<T>> { export async function fetchPage<T>(
const response = await api.get<ApiResponse<PageData<T>>>(url); url: string,
params?: { page?: number; pageSize?: number },
): Promise<PageData<T>> {
const response = await api.get<ApiResponse<PageData<T>>>(url, { params });
return response.data.data; return response.data.data;
} }

20
src/hooks/pageData.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { PageData } from '../api/types';
type PageQuery = {
page: number;
pageSize: number;
};
export function resolvePageData<T>(
response: PageData<T>,
query: PageQuery,
): PageData<T> {
return {
list: response.list,
pagination: {
...response.pagination,
page: query.page,
pageSize: query.pageSize,
},
};
}

View File

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

View File

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

14
tests/pageData.test.ts Normal file
View File

@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import { resolvePageData } from '../src/hooks/pageData.js';
const response = {
list: [{ id: 21 }],
pagination: { page: 1, pageSize: 20, total: 30 },
};
const resolved = resolvePageData(response, { page: 2, pageSize: 20 });
assert.deepEqual(resolved, {
list: [{ id: 21 }],
pagination: { page: 2, pageSize: 20, total: 30 },
});