提交 e7063b73 authored 作者: 王鹏飞's avatar 王鹏飞

refactor(admin): complete books management AppList

上级 f0bbf752
# Books Management Migration Report
## Status
- The active `/books/management` and `/books/management/list` entries now render the controlled shared `AppList` directly.
- The incomplete three-column placeholder was replaced with the complete behavior of the legacy 641-line list page.
- Existing `/books/management/add-edit` and `/books/management/chapter` boundaries remain available and keep their state payloads.
## AppList integration
- `ManagementListView.tsx` renders `AppList<ManagementRow, ManagementFilters>` as the main list shell.
- Data, total, loading, error, retry, page, page size, controlled filters, search, reset, refresh, and toolbar actions are wired through `useManagementList`.
- The legacy duplicate `ManagementListView.jsx` entry was removed so extension resolution cannot bypass the AppList implementation.
- Page and page-size changes use the current draft filters, matching the legacy `PaginationCom` behavior.
- Clearing an active input/select/date filter reloads immediately; mutations retain the legacy unfiltered-current-page refresh behavior.
## Preserved columns and filters
- Columns: book name, 80×80 cover with fallback, author, audit status, release count, creation time, update time, and operations.
- Audit display preserves the four values: 待发布, 审核中, 审核未通过, 审核通过.
- Filters: book name, author, audit status, and creation date range.
- Date range conversion preserves local start-of-day and end-of-day Unix seconds.
- Page-size choices remain 10, 20, 30, and 40.
## Preserved permissions and operations
- `/books/management/getAllList`: creator/admin basic-information action.
- `/books/management/getInfoById`: chapter editing action, including `isAdminOrCreator` navigation state.
- `/books/management/export`: creator/admin Word export confirmation and original HTML removal selectors.
- `/books/management/del`: creator/admin delete confirmation with `确认删除书籍?`.
- `/books/management/release`: creator/admin publish-review action only for `audit_status === 1`.
- `/books/management/import`: import drawer action.
- `/books/management/add`: add-book navigation.
- Creator/admin calculation remains `userInfo.id === user_id || userInfo.type === 1`.
## Preserved requests and private UI
- Main-list requests are now local typed implementations:
- `POST /book/teacher/getList`
- `POST /book/teacher/del`
- `POST /book/teacher/export`
- `POST /book/teacher/release`
- multipart `POST /book/teacher/import`
- Server success/error messages and fallback `数据获取失败!` are retained.
- `DeleteBookModal`, `ExportBookModal`, `ImportBookDrawer`, and `ReleaseBookDrawer` retain their original copy, validation, and actions.
- Import retains DOC/DOCX validation, filename display, five catalog levels, template download, upload spinner, and successful full-page reload.
- Publish review retains the 255-character message limit and successful full-page reload.
## Verification
- `pnpm exec oxlint src/modules/books/management --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/books/management`: passed.
- `pnpm exec tsc --noEmit`: no diagnostics under `src/modules/books/management`; the workspace still has unrelated existing diagnostics.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed; only the existing formula-editor and large-chunk warnings were emitted.
## Concerns
- Per task direction, no API/query/Hook feature tests were added.
- Add/edit and chapter screens remain compatibility boundaries over their existing implementations; this change fully replaces only the main-list implementation requested here.
- Deprecated root route/config metadata still references the legacy management pages, so those old pages remain in place.
export { getList as getManagementList } from "@/pages/books/management/request";
import { message } from "antd";
// @ts-expect-error The legacy workspace does not install the qs declaration package.
import qs from "qs";
import request from "@/utils/request.js";
import type { ManagementQuery } from "./query";
export interface ManagementRow {
id: number;
name?: string;
img?: string;
authors?: string;
audit_status?: number;
release_num?: number;
create_time?: string;
update_time?: string;
user_id?: number;
}
export interface ManagementListResult {
rows: ManagementRow[];
total: number;
}
export interface ExportBookResult {
name?: string;
content?: string;
}
export interface ImportBookValues {
catalog_level: number;
file: File;
}
type ApiParams = Record<string, unknown>;
type ApiBody<T> = { code?: number; data?: T; message?: string };
async function getPayload<T>(url: string, params: ApiParams): Promise<T | false> {
try {
const response = await request.post(url, qs.stringify(params));
const body = response.data as ApiBody<T>;
const text = body?.message ?? "数据获取失败!";
if (body?.code === 200) return (body.data ?? []) as T;
message.error(text);
return false;
} catch {
return false;
}
}
async function mutate(url: string, params: ApiParams) {
try {
const response = await request.post(url, qs.stringify(params));
const body = response.data as ApiBody<unknown>;
const text = body?.message ?? "数据获取失败!";
if (body?.code === 200) {
message.success(text);
return true;
}
message.error(text);
return false;
} catch {
return false;
}
}
export async function getManagementList(
query: ManagementQuery,
): Promise<ManagementListResult | false> {
const data = await getPayload<{ list?: ManagementRow[]; total?: number }>(
"/book/teacher/getList",
{ ...query },
);
if (data === false) return false;
return { rows: data.list ?? [], total: data.total ?? 0 };
}
export function deleteManagementBook(id: number) {
return mutate("/book/teacher/del", { id });
}
export function exportManagementBook(id: number) {
return getPayload<ExportBookResult>("/book/teacher/export", { id });
}
export function releaseManagementBook(id: number, releaseMess?: string) {
return mutate("/book/teacher/release", { release_mess: releaseMess, id });
}
export async function importManagementBook(values: ImportBookValues) {
try {
const response = await request.post("/book/teacher/import", values, {
headers: { "Content-Type": "multipart/form-data" },
});
const body = response.data as ApiBody<unknown>;
const text = body?.message ?? "数据获取失败!";
if (body?.code === 200) {
message.success(text);
return true;
}
message.error(text);
return false;
} catch {
return false;
}
}
import { Modal, Row, Space } from "antd";
interface DeleteBookModalProps {
open: boolean;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
}
export default function DeleteBookModal({ open, onCancel, onConfirm }: DeleteBookModalProps) {
return (
<Modal
mask={false}
centered
open={open}
onOk={onConfirm}
onCancel={onCancel}
footer={(_, { OkBtn, CancelBtn }) => (
<Row justify="center" className="delModal" style={{ marginBottom: "30px" }}>
<Space size={20}>
<CancelBtn />
<OkBtn />
</Space>
</Row>
)}
okText="确认"
>
<p style={{ textAlign: "center", padding: "30px 0 18px", fontSize: 16 }}>确认删除书籍?</p>
</Modal>
);
}
import { Modal, Row, Space } from "antd";
interface ExportBookModalProps {
open: boolean;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
}
export default function ExportBookModal({ open, onCancel, onConfirm }: ExportBookModalProps) {
return (
<Modal
mask={false}
centered
open={open}
onOk={onConfirm}
onCancel={onCancel}
footer={(_, { OkBtn, CancelBtn }) => (
<Row justify="center" className="delModal" style={{ marginBottom: "30px" }}>
<Space size={20}>
<CancelBtn />
<OkBtn />
</Space>
</Row>
)}
okText="确认"
>
<p style={{ textAlign: "center", padding: "30px 0 18px", fontSize: 16 }}>
只能导出文本部分,画廊、音频、视频不支持
</p>
</Modal>
);
}
import { Button, Drawer, Form, message, Row, Select, Space, Spin, Upload } from "antd";
import { useState } from "react";
import { downloadFile } from "@/utils/common";
import type { ImportBookValues } from "../api";
interface ImportBookFormValues {
file: unknown;
catalog_level: number;
}
interface ImportBookDrawerProps {
open: boolean;
onClose: () => void;
onSubmit: (values: ImportBookValues) => Promise<boolean>;
}
export default function ImportBookDrawer({ open, onClose, onSubmit }: ImportBookDrawerProps) {
const [bookFile, setBookFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const handleSubmit = async (values: ImportBookFormValues) => {
if (!bookFile) return;
setUploading(true);
const success = await onSubmit({ catalog_level: values.catalog_level, file: bookFile });
if (success) window.location.replace(window.location.href);
onClose();
setUploading(false);
};
return (
<Drawer mask={false} open={open} onClose={onClose} footer={null}>
<Spin spinning={uploading}>
<Form<ImportBookFormValues> labelCol={{ span: 8 }} onFinish={handleSubmit}>
<Form.Item label="下载模板" style={{ marginBottom: 10 }}>
<Button ghost type="primary" onClick={() => downloadFile("书籍导入示例.docx")}>
下载模板
</Button>
</Form.Item>
<p
className="linetext"
style={{
fontWeight: 400,
color: "#999999",
fontSize: 12,
marginBottom: 30,
paddingLeft: 75,
}}
>
目录需设置为word中的标题样式或按照示例编写,防止导入失败
</p>
<Form.Item label="选择书籍" required>
<Form.Item name="file" rules={[{ required: true, message: "请上传书籍" }]} noStyle>
<Upload
name="file"
accept=".doc , .docx"
showUploadList={false}
beforeUpload={(file) => {
const suffixes = ["doc", "docx"];
const suffix = file.name.substring(file.name.lastIndexOf(".") + 1);
if (!suffixes.includes(suffix)) {
message.error(`请上传以${suffixes.join("、")}格式的书籍`);
return false;
}
setBookFile(file);
return false;
}}
>
<Button>点击上传</Button>
</Upload>
</Form.Item>
<p style={{ color: "#999999" }}>{bookFile?.name}</p>
</Form.Item>
<Form.Item
label="目录结构等级"
name="catalog_level"
rules={[{ required: true, message: "请选择目录结构等级" }]}
>
<Select
placeholder="请选择目录结构的等级"
style={{ width: 260 }}
options={[1, 2, 3, 4, 5].map((value) => ({ label: `标题${value}`, value }))}
/>
</Form.Item>
<Form.Item>
<Row
justify="center"
className="delModal"
style={{ marginBottom: "30px", marginTop: "0px" }}
>
<Space size={20}>
<Button onClick={onClose}>取消</Button>
<Button type="primary" htmlType="submit">
确认
</Button>
</Space>
</Row>
</Form.Item>
</Form>
</Spin>
</Drawer>
);
}
import { Button, Drawer, Form, Input, Space } from "antd";
const { TextArea } = Input;
export interface ReleaseBookFormValues {
release_mess?: string;
}
interface ReleaseBookDrawerProps {
open: boolean;
onClose: () => void;
onSubmit: (values: ReleaseBookFormValues) => void | Promise<void>;
}
export default function ReleaseBookDrawer({ open, onClose, onSubmit }: ReleaseBookDrawerProps) {
return (
<Drawer placement="right" onClose={onClose} open={open} mask={false}>
<Form<ReleaseBookFormValues> labelCol={{ span: 5 }} onFinish={onSubmit}>
<Form.Item
label="发布信息"
name="release_mess"
rules={[{ max: 255, message: "最大255字符" }]}
>
<TextArea placeholder="请输入发布信息" autoSize={{ minRows: 5, maxRows: 8 }} />
</Form.Item>
<Form.Item wrapperCol={{ offset: 10, span: 16 }}>
<Space size={20}>
<Button onClick={onClose}>取消</Button>
<Button type="primary" htmlType="submit">
发布
</Button>
</Space>
</Form.Item>
</Form>
</Drawer>
);
}
import type { Dayjs } from "dayjs";
export interface ManagementFilters { name?: string; authors?: string; audit_status?: string; time?: [Dayjs | null, Dayjs | null] | null; }
export function buildManagementQuery(page: number, pageSize: number, filters: ManagementFilters) { const query: any = { page, page_size: pageSize, ...filters }; delete query.time; if (filters.time?.[0] && filters.time[1]) { query.start_time = filters.time[0].startOf("day").unix(); query.end_time = filters.time[1].endOf("day").unix(); } return query; }
export interface ManagementFilters {
name?: string;
authors?: string;
audit_status?: string | null;
time?: [Dayjs | null, Dayjs | null] | null;
}
export interface ManagementQuery {
page: number;
page_size: number;
name?: string;
authors?: string;
audit_status?: string;
start_time?: number;
end_time?: number;
}
interface BuildManagementQueryInput {
page: number;
pageSize: number;
filters: ManagementFilters;
}
export function buildManagementQuery({
page,
pageSize,
filters,
}: BuildManagementQueryInput): ManagementQuery {
const query: ManagementQuery = { page, page_size: pageSize };
if (filters.name) query.name = filters.name;
if (filters.authors) query.authors = filters.authors;
if (filters.audit_status) query.audit_status = filters.audit_status;
if (filters.time?.[0] && filters.time[1]) {
query.start_time = filters.time[0].startOf("day").unix();
query.end_time = filters.time[1].endOf("day").unix();
}
return query;
}
......@@ -2,5 +2,20 @@ import { Navigate, Outlet, useRoutes } from "react-router";
import ManagementAddEdit from "./components/ManagementAddEdit";
import ManagementChapter from "./components/ManagementChapter";
import ManagementListView from "./views/ManagementListView";
function Layout() { return <Outlet />; }
export default function ManagementRoutes() { return useRoutes([{ Component: Layout, children: [{ index: true, Component: ManagementListView }, { path: "list", Component: ManagementListView }, { path: "add-edit", Component: ManagementAddEdit }, { path: "chapter", Component: ManagementChapter }, { path: "*", element: <Navigate to="." replace /> }] }]); }
function Layout() {
return <Outlet />;
}
export default function ManagementRoutes() {
return useRoutes([
{
Component: Layout,
children: [
{ index: true, Component: ManagementListView },
{ path: "list", Component: ManagementListView },
{ path: "add-edit", Component: ManagementAddEdit },
{ path: "chapter", Component: ManagementChapter },
{ path: "*", element: <Navigate to="." replace /> },
],
},
]);
}
import { useCallback, useEffect, useRef, useState } from "react";
import { getManagementList } from "./api";
import { buildManagementQuery, type ManagementFilters } from "./query";
export function useManagementList() { const [filters, setFilters] = useState<ManagementFilters>({}); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [rows, setRows] = useState<any[]>([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null); const last = useRef<any>(buildManagementQuery(1, 10, {})); const load = useCallback(async (query: any) => { last.current = query; setLoading(true); try { const data: any = await getManagementList(query); if (!data) throw new Error("请求失败"); setRows(data.list || []); setTotal(data.total || 0); setError(null); } catch (e) { setError(e instanceof Error ? e : new Error("请求失败")); } finally { setLoading(false); } }, []); useEffect(() => { void load(last.current); }, [load]); return { filters, setFilters, page, pageSize, rows, total, loading, error, search: () => { setPage(1); void load(buildManagementQuery(1, pageSize, filters)); }, reset: () => { setFilters({}); setPage(1); void load(buildManagementQuery(1, pageSize, {})); }, refresh: () => void load(buildManagementQuery(page, pageSize, filters)), retry: () => void load(last.current), changePage: (value: number) => { setPage(value); void load(buildManagementQuery(value, pageSize, filters)); }, changePageSize: (value: number) => { setPage(1); setPageSize(value); void load(buildManagementQuery(1, value, filters)); } }; }
import { getManagementList, type ManagementListResult, type ManagementRow } from "./api";
import { buildManagementQuery, type ManagementFilters, type ManagementQuery } from "./query";
const INITIAL_PAGE = 1;
const INITIAL_PAGE_SIZE = 10;
function isCleared(previous: ManagementFilters, next: ManagementFilters) {
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) as Set<
keyof ManagementFilters
>;
return [...keys].some((key) => previous[key] !== next[key] && !next[key]);
}
export function useManagementList() {
const [filters, setFilterState] = useState<ManagementFilters>({});
const [page, setPage] = useState(INITIAL_PAGE);
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE);
const [rows, setRows] = useState<ManagementRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const requestId = useRef(0);
const lastQuery = useRef<ManagementQuery>(
buildManagementQuery({ page: INITIAL_PAGE, pageSize: INITIAL_PAGE_SIZE, filters: {} }),
);
const load = useCallback(async (query: ManagementQuery) => {
const currentRequest = ++requestId.current;
lastQuery.current = query;
setLoading(true);
setError(null);
try {
const result: ManagementListResult | false = await getManagementList(query);
if (currentRequest !== requestId.current) return;
if (result === false) {
setError(new Error("数据获取失败!"));
return;
}
setRows(result.rows);
setTotal(result.total);
} catch (reason) {
if (currentRequest === requestId.current) {
setError(reason instanceof Error ? reason : new Error("数据获取失败!"));
}
} finally {
if (currentRequest === requestId.current) setLoading(false);
}
}, []);
const loadFor = useCallback(
(nextPage: number, nextPageSize: number, nextFilters: ManagementFilters) =>
load(buildManagementQuery({ page: nextPage, pageSize: nextPageSize, filters: nextFilters })),
[load],
);
useEffect(() => {
void load(lastQuery.current);
return () => {
requestId.current += 1;
};
}, [load]);
const setFilters = useCallback(
(nextFilters: ManagementFilters) => {
const shouldReload = isCleared(filters, nextFilters);
setFilterState(nextFilters);
if (shouldReload) void loadFor(page, pageSize, nextFilters);
},
[filters, loadFor, page, pageSize],
);
const search = useCallback(() => {
setPage(INITIAL_PAGE);
void loadFor(INITIAL_PAGE, pageSize, filters);
}, [filters, loadFor, pageSize]);
const reset = useCallback(() => {
const emptyFilters: ManagementFilters = {};
setFilterState(emptyFilters);
setPage(INITIAL_PAGE);
void loadFor(INITIAL_PAGE, pageSize, emptyFilters);
}, [loadFor, pageSize]);
const refresh = useCallback(() => {
void loadFor(page, pageSize, filters);
}, [filters, loadFor, page, pageSize]);
const refreshUnfiltered = useCallback(() => {
void loadFor(page, pageSize, {});
}, [loadFor, page, pageSize]);
const changePage = useCallback(
(nextPage: number) => {
setPage(nextPage);
void loadFor(nextPage, pageSize, filters);
},
[filters, loadFor, pageSize],
);
const changePageSize = useCallback(
(nextPageSize: number) => {
setPage(INITIAL_PAGE);
setPageSize(nextPageSize);
void loadFor(INITIAL_PAGE, nextPageSize, filters);
},
[filters, loadFor],
);
const retry = useCallback(() => void load(lastQuery.current), [load]);
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset,
refresh,
refreshUnfiltered,
changePage,
changePageSize,
retry,
};
}
export { default } from "@/pages/books/management/list";
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论