refactor: 重构分页逻辑并统一代码风格
1. 新增分页数据处理hook和工具函数,实现分页状态统一管理 2. 替换所有页面的表格分页配置为标准current/page模式 3. 统一项目内字符串引号风格为双引号 4. 调整权限树面板样式和布局代码格式
This commit is contained in:
9
.prettierrc
Normal file
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 390,
|
||||||
|
"eslintIntegration": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"stylelintIntegration": true
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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
20
src/hooks/pageData.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
14
tests/pageData.test.ts
Normal 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 },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user