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

chore: update

上级 9268c71c
# Settings sensitive migration # Settings sensitive migration
- Moved the sensitive-word list, filters, edit/delete/import flows, permissions, requests, and route into `modules/settings/sensitive`. - Moved the sensitive-word list, filters, edit/delete/import flows, permissions, requests, and route into `modules/settings/sensitive`.
- AppList now owns the filter, pagination, refresh, retry and table states; `pnpm test` passed 11/11 and `pnpm build` succeeded.
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="https://zws-imgs-pub.ezijing.com/pc/base/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="https://zws-imgs-pub.ezijing.com/pc/base/favicon.ico" />
......
...@@ -2,20 +2,13 @@ import React, { useState, useEffect } from "react"; ...@@ -2,20 +2,13 @@ import React, { useState, useEffect } from "react";
import { Spin } from "antd"; import { Spin } from "antd";
import { Editor, Toolbar } from "@wangeditor/editor-for-react"; import { Editor, Toolbar } from "@wangeditor/editor-for-react";
import "@wangeditor/editor/dist/css/style.css"; // 引入 css import "@wangeditor/editor/dist/css/style.css"; // 引入 css
import AliOSS from "ali-oss"; import { uploadFile } from "@/utils/oss";
import { getAliOSSSTSToken } from "@/pages/setting/help/addedit/requet";
import add from "@/assets/images/icon/add.png";
import dayjs from "dayjs";
import "./index.less"; import "./index.less";
const EditorReader = (props) => { const EditorReader = (props) => {
const { form, fieldName, content, loadLoading } = props; const { form, fieldName, content, loadLoading } = props;
// oss // oss
const [ossClient, setOssClient] = useState(null); // oss 操作
const [STSToken, setSTSToken] = useState(null); // oss 过期设置
const [file, setFile] = useState({}); // oss 过期设置
const [uploading, setUploading] = useState(false);
// editor 实例 // editor 实例
const [editor, setEditor] = useState(null); const [editor, setEditor] = useState(null);
...@@ -23,32 +16,6 @@ const EditorReader = (props) => { ...@@ -23,32 +16,6 @@ const EditorReader = (props) => {
const [html, setHtml] = useState(content); const [html, setHtml] = useState(content);
const [loading, setLoading] = useState(loadLoading || false); const [loading, setLoading] = useState(loadLoading || false);
const getStsAuthToken = async () => {
const data = await getAliOSSSTSToken();
if (data) {
window.sessionStorage.setItem("sts", JSON.stringify(data));
setSTSToken(data);
const ossClientTemp = await new AliOSS({
accessKeyId: data.AccessKeyId,
accessKeySecret: data.AccessKeySecret,
stsToken: data.SecurityToken,
endpoint: data.endpoint,
bucket: "zxts-book-file",
timeout: 180000,
refreshSTSToken: async () => {
const info = await getAliOSSSTSToken();
return {
AccessKeyId: info.AccessKeyId,
AccessKeySecret: info.AccessKeySecret,
SecurityToken: info.SecurityToken,
};
},
refreshSTSTokenInterval: 14 * 60 * 1000,
});
setOssClient(ossClientTemp);
}
};
// 工具栏配置 // 工具栏配置
const toolbarConfig = { const toolbarConfig = {
...@@ -114,17 +81,8 @@ const EditorReader = (props) => { ...@@ -114,17 +81,8 @@ const EditorReader = (props) => {
base64LimitSize: 5 * 1024, // 5kb 以下插入 base64 // 用户自定义上传图片 base64LimitSize: 5 * 1024, // 5kb 以下插入 base64 // 用户自定义上传图片
customUpload: async (file, insertFn) => { customUpload: async (file, insertFn) => {
setLoading(true); setLoading(true);
if (!ossClient) { const url = await uploadFile(file, { filenamePrefix: "banner" });
console.error("ossClient还未初始化", ossClient); insertFn(url, "题库图片");
return false;
}
const fileExt = file.name.substring(file.name.lastIndexOf("."));
const fileName = `banner-${dayjs().valueOf()}-${Math.random().toString(36).substring(2)}${fileExt}`;
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`;
const result = await ossClient.put(filePath, file);
console.log("result", result);
insertFn(result.url, "题库图片");
// const { url } = await uploadFiles({ file, file_type: 'question' }); // const { url } = await uploadFiles({ file, file_type: 'question' });
// insertFn(url, '题库图片'); // insertFn(url, '题库图片');
...@@ -145,14 +103,8 @@ const EditorReader = (props) => { ...@@ -145,14 +103,8 @@ const EditorReader = (props) => {
base64LimitSize: 5 * 1024, // 5kb 以下插入 base64 // 用户自定义上传图片 base64LimitSize: 5 * 1024, // 5kb 以下插入 base64 // 用户自定义上传图片
customUpload: async (file, insertFn) => { customUpload: async (file, insertFn) => {
setLoading(true); setLoading(true);
if (!ossClient) { const url = await uploadFile(file, { filenamePrefix: "banner" });
console.error("ossClient还未初始化", ossClient); insertFn(url, "题库视频");
return false;
}
const result = await ossClient.put(file.name, file);
console.log("result", result);
insertFn(result.url, "题库视频");
// const { url } = await uploadFiles({ file, file_type: 'question' }); // const { url } = await uploadFiles({ file, file_type: 'question' });
// insertFn(url, '题库视频'); // insertFn(url, '题库视频');
setLoading(false); setLoading(false);
...@@ -161,10 +113,6 @@ const EditorReader = (props) => { ...@@ -161,10 +113,6 @@ const EditorReader = (props) => {
}, },
}; };
useEffect(() => {
getStsAuthToken();
}, []);
// 及时销毁 editor ,重要! // 及时销毁 editor ,重要!
useEffect(() => { useEffect(() => {
return () => { return () => {
...@@ -175,10 +123,10 @@ const EditorReader = (props) => { ...@@ -175,10 +123,10 @@ const EditorReader = (props) => {
}, [editor]); }, [editor]);
useEffect(() => { useEffect(() => {
if (editor && ossClient) { if (editor) {
editor.setHtml(content); editor.setHtml(content);
} }
}, [editor, content, ossClient]); }, [editor, content]);
return ( return (
<div className="editor_customer_simple"> <div className="editor_customer_simple">
...@@ -190,8 +138,7 @@ const EditorReader = (props) => { ...@@ -190,8 +138,7 @@ const EditorReader = (props) => {
style={{ borderBottom: "1px solid #ccc" }} style={{ borderBottom: "1px solid #ccc" }}
/> />
<Spin spinning={loading}> <Spin spinning={loading}>
{ossClient && ( <Editor
<Editor
defaultConfig={editorConfig} defaultConfig={editorConfig}
value={html} value={html}
onCreated={setEditor} onCreated={setEditor}
...@@ -203,7 +150,6 @@ const EditorReader = (props) => { ...@@ -203,7 +150,6 @@ const EditorReader = (props) => {
className="w-e-text" className="w-e-text"
style={{ height: "400px", overflowY: "hidden" }} style={{ height: "400px", overflowY: "hidden" }}
/> />
)}
</Spin> </Spin>
</div> </div>
</div> </div>
......
...@@ -99,7 +99,7 @@ const AudioModal = (props, ref) => { ...@@ -99,7 +99,7 @@ const AudioModal = (props, ref) => {
open={visible} open={visible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title="插入音频" title="插入音频"
classNames={{ classNames={{
header: "editor-header-customer", header: "editor-header-customer",
......
...@@ -23,7 +23,6 @@ import { addExpandRead, expandReadInfo } from "../utils/request"; ...@@ -23,7 +23,6 @@ import { addExpandRead, expandReadInfo } from "../utils/request";
const ExpandModal = (props, ref) => { const ExpandModal = (props, ref) => {
const { const {
editor, editor,
ossClient,
bookId, bookId,
chapterId, chapterId,
setExpandVisible, setExpandVisible,
......
...@@ -56,7 +56,7 @@ const FormulaModal = (props, ref) => { ...@@ -56,7 +56,7 @@ const FormulaModal = (props, ref) => {
open={visible} open={visible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title="公式" title="公式"
classNames={{ classNames={{
header: "editor-header-customer", header: "editor-header-customer",
......
import React, { useEffect, useState, useRef, useImperativeHandle, forwardRef } from "react"; import React, { useEffect, useState, useRef, useImperativeHandle, forwardRef } from "react";
import { Modal, Drawer, Tree, Button, Spin, Space, Input, Image } from "antd"; import { Modal, Drawer, Tree, Button, Spin, Space, Input, Image } from "antd";
import { LeftOutlined, RightOutlined } from "@ant-design/icons"; import { LeftOutlined, RightOutlined } from "@ant-design/icons";
import { getInfoByChapterId } from "@/pages/books/section/request"; import { getInfoByChapterId } from "@/modules/books/management/chapters/request";
import { get } from "lodash-es"; import { get } from "lodash-es";
import $, { event } from "jquery"; import $, { event } from "jquery";
import baike from "@/assets/images/editor/icon-baike.png"; import baike from "@/assets/images/editor/icon-baike.png";
......
...@@ -106,7 +106,7 @@ const VideoModal = (props, ref) => { ...@@ -106,7 +106,7 @@ const VideoModal = (props, ref) => {
open={visible} open={visible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title="插入视频" title="插入视频"
classNames={{ classNames={{
header: "editor-header-customer", header: "editor-header-customer",
......
...@@ -853,7 +853,7 @@ const WangEditorCustomer = (props, ref) => { ...@@ -853,7 +853,7 @@ const WangEditorCustomer = (props, ref) => {
open={imageVisible} open={imageVisible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title="插入图片" title="插入图片"
classNames={{ classNames={{
header: "editor-header-customer", header: "editor-header-customer",
...@@ -877,7 +877,7 @@ const WangEditorCustomer = (props, ref) => { ...@@ -877,7 +877,7 @@ const WangEditorCustomer = (props, ref) => {
open={galleryVisible} open={galleryVisible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title="插入画廊" title="插入画廊"
classNames={{ classNames={{
header: "editor-header-customer", header: "editor-header-customer",
...@@ -905,7 +905,7 @@ const WangEditorCustomer = (props, ref) => { ...@@ -905,7 +905,7 @@ const WangEditorCustomer = (props, ref) => {
open={titleVisible} open={titleVisible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title="插入章头" title="插入章头"
classNames={{ classNames={{
header: "editor-header-customer", header: "editor-header-customer",
...@@ -928,7 +928,7 @@ const WangEditorCustomer = (props, ref) => { ...@@ -928,7 +928,7 @@ const WangEditorCustomer = (props, ref) => {
open={sectionVisible} open={sectionVisible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title="插入节头" title="插入节头"
classNames={{ classNames={{
header: "editor-header-customer", header: "editor-header-customer",
...@@ -952,7 +952,7 @@ const WangEditorCustomer = (props, ref) => { ...@@ -952,7 +952,7 @@ const WangEditorCustomer = (props, ref) => {
open={practiceOpenVisible} open={practiceOpenVisible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title="插入交互练习" title="插入交互练习"
classNames={{ classNames={{
header: "editor-header-customer", header: "editor-header-customer",
...@@ -976,7 +976,7 @@ const WangEditorCustomer = (props, ref) => { ...@@ -976,7 +976,7 @@ const WangEditorCustomer = (props, ref) => {
open={tooltipVisible} open={tooltipVisible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title="气泡" title="气泡"
classNames={{ classNames={{
header: "editor-header-customer", header: "editor-header-customer",
...@@ -1001,7 +1001,7 @@ const WangEditorCustomer = (props, ref) => { ...@@ -1001,7 +1001,7 @@ const WangEditorCustomer = (props, ref) => {
open={linkVisible} open={linkVisible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title="链接" title="链接"
classNames={{ classNames={{
header: "editor-header-customer", header: "editor-header-customer",
...@@ -1029,7 +1029,7 @@ const WangEditorCustomer = (props, ref) => { ...@@ -1029,7 +1029,7 @@ const WangEditorCustomer = (props, ref) => {
open={expandVisible} open={expandVisible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title="扩展阅读" title="扩展阅读"
classNames={{ classNames={{
header: "editor-header-customer", header: "editor-header-customer",
...@@ -1056,7 +1056,7 @@ const WangEditorCustomer = (props, ref) => { ...@@ -1056,7 +1056,7 @@ const WangEditorCustomer = (props, ref) => {
open={practiceVisible} open={practiceVisible}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title={null} title={null}
classNames={{ classNames={{
header: "practice-topic-header", header: "practice-topic-header",
...@@ -1085,7 +1085,7 @@ const WangEditorCustomer = (props, ref) => { ...@@ -1085,7 +1085,7 @@ const WangEditorCustomer = (props, ref) => {
open={priviewVisible} open={priviewVisible}
header={null} header={null}
footer={null} footer={null}
destroyOnClose destroyOnHidden
centered centered
keyboard={false} keyboard={false}
closeIcon={<CloseOutlined style={{ fontSize: 20, color: "#fff" }} />} closeIcon={<CloseOutlined style={{ fontSize: 20, color: "#fff" }} />}
...@@ -1110,7 +1110,7 @@ const WangEditorCustomer = (props, ref) => { ...@@ -1110,7 +1110,7 @@ const WangEditorCustomer = (props, ref) => {
open={historyVisible} open={historyVisible}
header={null} header={null}
footer={null} footer={null}
destroyOnClose destroyOnHidden
centered centered
keyboard={false} keyboard={false}
closeIcon={false} closeIcon={false}
......
...@@ -47,7 +47,7 @@ const AIChatDrawer = (props) => { ...@@ -47,7 +47,7 @@ const AIChatDrawer = (props) => {
open={open} open={open}
width="600px" width="600px"
title="AI对话" title="AI对话"
destroyOnClose destroyOnHidden
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
rootClassName="ai-drawer-wrapper" rootClassName="ai-drawer-wrapper"
className="ai-drawer-container" className="ai-drawer-container"
......
...@@ -124,7 +124,7 @@ export default function AIDigitalHumanModal(props) { ...@@ -124,7 +124,7 @@ export default function AIDigitalHumanModal(props) {
footer={null} footer={null}
width={1000} width={1000}
centered centered
destroyOnClose destroyOnHidden
> >
<div className="ai-digital-human"> <div className="ai-digital-human">
<Row gutter={[24, 16]}> <Row gutter={[24, 16]}>
......
...@@ -164,7 +164,7 @@ export default function IconModal(props) { ...@@ -164,7 +164,7 @@ export default function IconModal(props) {
cancelText="取消" cancelText="取消"
width={700} width={700}
centered centered
destroyOnClose destroyOnHidden
> >
<Tabs <Tabs
activeKey={activeTab} activeKey={activeTab}
......
...@@ -7,11 +7,8 @@ import { Editor, Toolbar } from "@wangeditor/editor-for-react"; ...@@ -7,11 +7,8 @@ import { Editor, Toolbar } from "@wangeditor/editor-for-react";
import { DomEditor } from "@wangeditor/editor"; import { DomEditor } from "@wangeditor/editor";
import { questionAdd, typeAllList, questionAddType, bookNameList } from "../request"; import { questionAdd, typeAllList, questionAddType, bookNameList } from "../request";
import AliOSS from "ali-oss"; import { uploadFile } from "@/utils/oss";
import dayjs from "dayjs"; import QuestionEditr from "@/modules/books/question-bank/components/QuestionEditr";
import { getAliOSSSTSToken } from "@/pages/setting/help/addedit/requet";
import { uploadFiles } from "@/utils/upload";
import QuestionEditr from "@/pages/books/question-bank/questionEditr";
import { values } from "lodash-es"; import { values } from "lodash-es";
const CustomerTopic = (props, ref) => { const CustomerTopic = (props, ref) => {
...@@ -24,8 +21,6 @@ const CustomerTopic = (props, ref) => { ...@@ -24,8 +21,6 @@ const CustomerTopic = (props, ref) => {
}); });
// oss // oss
const [ossClient, setOssClient] = useState(null); // oss 操作
const [STSToken, setSTSToken] = useState(null); // oss 过期设置
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const questionType = Form.useWatch("question_style", form); const questionType = Form.useWatch("question_style", form);
...@@ -48,32 +43,6 @@ const CustomerTopic = (props, ref) => { ...@@ -48,32 +43,6 @@ const CustomerTopic = (props, ref) => {
setSelectedBookId(bookId); setSelectedBookId(bookId);
}, [bookId]); }, [bookId]);
const getStsAuthToken = async () => {
const data = await getAliOSSSTSToken();
if (data) {
window.sessionStorage.setItem("sts", JSON.stringify(data));
setSTSToken(data);
const ossClientTemp = await new AliOSS({
accessKeyId: data.AccessKeyId,
accessKeySecret: data.AccessKeySecret,
stsToken: data.SecurityToken,
endpoint: data.endpoint,
bucket: "zxts-book-file",
timeout: 180000,
refreshSTSToken: async () => {
const info = await getAliOSSSTSToken();
return {
AccessKeyId: info.AccessKeyId,
AccessKeySecret: info.AccessKeySecret,
SecurityToken: info.SecurityToken,
};
},
refreshSTSTokenInterval: 14 * 60 * 1000,
});
setOssClient(ossClientTemp);
}
};
// 显示不同类型的选项函数 // 显示不同类型的选项函数
const [edierVal, setedierVal] = useState(""); const [edierVal, setedierVal] = useState("");
...@@ -95,7 +64,6 @@ const CustomerTopic = (props, ref) => { ...@@ -95,7 +64,6 @@ const CustomerTopic = (props, ref) => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await getTypeList(); await getTypeList();
await getStsAuthToken();
})(); })();
}, []); }, []);
...@@ -138,19 +106,8 @@ const CustomerTopic = (props, ref) => { ...@@ -138,19 +106,8 @@ const CustomerTopic = (props, ref) => {
base64LimitSize: 5 * 1024, // 5kb 以下插入 base64 base64LimitSize: 5 * 1024, // 5kb 以下插入 base64
customUpload: async (file, insertFn) => { customUpload: async (file, insertFn) => {
if (!ossClient) { const url = await uploadFile(file, { filenamePrefix: "practice" });
console.error("ossClient还未初始化", ossClient); insertFn(url, "题库图片");
return false;
}
const fileExt = file.name.substring(file.name.lastIndexOf("."));
const fileName = `practice-${dayjs().valueOf()}-${Math.random().toString(36).substring(2)}${fileExt}`;
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`;
const result = await ossClient.put(filePath, file);
console.log("result", result);
insertFn(result.url, "题库图片");
// const { url } = await uploadFiles({ file, file_type: 'question' }); // const { url } = await uploadFiles({ file, file_type: 'question' });
// insertFn(url, '题库图片'); // insertFn(url, '题库图片');
...@@ -390,23 +347,21 @@ const CustomerTopic = (props, ref) => { ...@@ -390,23 +347,21 @@ const CustomerTopic = (props, ref) => {
mode="default" mode="default"
style={{ borderBottom: "1px solid #ccc" }} style={{ borderBottom: "1px solid #ccc" }}
/> />
{ossClient && ( <Editor
<Editor defaultConfig={editorConfig}
defaultConfig={editorConfig} value={initialValues.titles}
value={initialValues.titles} onCreated={setTitlesEditor}
onCreated={setTitlesEditor} onChange={(editor) => {
onChange={(editor) => { form.setFieldValue("titles", editor.getHtml());
form.setFieldValue("titles", editor.getHtml()); // setHtml(editor.getHtml())
// setHtml(editor.getHtml()) }}
}} config={{
config={{ uploadImgServer: "your_upload_server_address",
uploadImgServer: "your_upload_server_address", // other config options
// other config options }}
}} mode="default"
mode="default" style={{ height: "200px", overflowY: "hidden" }}
style={{ height: "200px", overflowY: "hidden" }} />
/>
)}
</div> </div>
</Spin> </Spin>
</Form.Item> </Form.Item>
......
...@@ -57,23 +57,6 @@ const typeAllList = async (obj) => { ...@@ -57,23 +57,6 @@ const typeAllList = async (obj) => {
} }
}; };
const getAliOSSSTSToken = async (obj) => {
try {
const res = await axios.post("/common/OssUpload/getUploadToken", qs.stringify(obj));
const code = get(res, "data.code", 0);
const data = get(res, "data.data", []);
const msg = get(res, "data.message", "获取OSS令牌失败!");
if (code === 200) {
return data;
} else {
message.error(msg);
return false;
}
} catch (error) {
return false;
}
};
// 书籍名称下拉列表 // 书籍名称下拉列表
const bookNameList = async (obj) => { const bookNameList = async (obj) => {
const res = await axios.post("book/teacher/getListAll", qs.stringify(obj)); const res = await axios.post("book/teacher/getListAll", qs.stringify(obj));
...@@ -87,4 +70,4 @@ const bookNameList = async (obj) => { ...@@ -87,4 +70,4 @@ const bookNameList = async (obj) => {
return false; return false;
} }
}; };
export { getList, questionAdd, questionAddType, typeAllList, getAliOSSSTSToken, bookNameList }; export { getList, questionAdd, questionAddType, typeAllList, bookNameList };
import dayjs from "dayjs";
import { message } from "antd";
// 根据文件大小判断使用普通上传还是分片上传
const fileSizeInMB = file.size / (1024 * 1024); // 将文件大小转换为 MB
if (fileSizeInMB <= 10) {
// 文件大小小于等于 10 MB,使用普通上传
normalUploader(file, type, ossClient);
} else {
// 文件大小大于 10 MB,使用分片上传
multipartUploader(file, type, ossClient, progress, callback);
}
export const partSize = 1024 * 1024; // 每个分片大小(byte)
const parallel = 3; // 同时上传的分片数
// 普通上传
export const normalUploader = async (file, type, ossClient) => {
if (!ossClient) {
return;
}
try {
const fileExt = file.name.substring(file.name.lastIndexOf("."));
const fileName = `${type}-${dayjs().valueOf()}-${Math.random()
.toString(36)
.substring(2)}${fileExt}`;
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`;
console.log(filePath);
return new Promise((resolve, reject) => {
ossClient
.put(filePath, file)
.then((res) => {
console.log("普通上传result", res);
const status = res?.res?.status;
const statusCode = res?.res?.statusCode;
const url = res?.url;
const name = res?.name;
resolve({ status, statusCode, url, name });
})
.catch((err) => {
console.log(`普通上传 ${file.name} failed === `, err);
message.error("上传失败!");
reject(err);
});
}).catch((err) => {
console.error(`文件上传失败`, err);
message.error("上传失败!");
reject(err);
});
} catch (e) {
message.error("上传失败!");
return new Promise.reject(false);
}
};
// 分片上传
export const multipartUploader = async (file, type, ossClient, progress, callback) => {
if (!ossClient) {
return;
}
try {
const fileExt = file.name.substring(file.name.lastIndexOf("."));
const fileName = `${type}-${dayjs().valueOf()}-${Math.random()
.toString(36)
.substring(2)}${fileExt}`;
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`;
return new Promise((resolve, reject) => {
ossClient
.multipartUpload(filePath, file, {
progress,
parallel,
partSize,
})
.then((res) => {
console.log("分片上传result", res);
const status = res?.res?.status;
const statusCode = res?.res?.statusCode;
const url = res?.url;
const name = res?.name;
resolve({ status, statusCode, url, name });
})
.catch((err) => {
console.error(`文件上传失败`, err);
message.error("上传失败!");
reject(err);
});
}).catch((err) => {
console.error(`文件上传失败`, err);
message.error("上传失败!");
reject(err);
});
} catch (e) {
// 捕获超时异常。
if (e.code === "ConnectionTimeoutError") {
console.log("TimeoutError");
}
message.error("上传失败!");
}
};
// 断点续传
...@@ -218,24 +218,6 @@ const delGallery = async (obj) => { ...@@ -218,24 +218,6 @@ const delGallery = async (obj) => {
} }
}; };
// 获取OSS ststoekn
const getAliOSSSTSToken = async (obj) => {
try {
const res = await axios.post("/common/OssUpload/getUploadToken", qs.stringify(obj));
const code = get(res, "data.code", 0);
const data = get(res, "data.data", []);
const msg = get(res, "data.message", "获取OSS令牌失败!");
if (code === 200) {
return data;
} else {
message.error(msg);
return false;
}
} catch (error) {
return false;
}
};
// 保存历史详情 // 保存历史详情
const getRecordInfo = async (obj) => { const getRecordInfo = async (obj) => {
try { try {
...@@ -285,7 +267,6 @@ export { ...@@ -285,7 +267,6 @@ export {
expandReadInfo, expandReadInfo,
addGallery, addGallery,
delGallery, delGallery,
getAliOSSSTSToken,
getRecordInfo, getRecordInfo,
getRecordList, getRecordList,
}; };
import dayjs from "dayjs";
import { message } from "antd";
export const partSize = 1024 * 1024 * 1; // 每个分片大小(byte)
export const parallel = 3; // 同时上传的分片数
// 普通上传
export const normalUploader = async (file, type, ossClient) => {
if (!ossClient) {
return;
}
try {
const fileExt = file.name.substring(file.name.lastIndexOf("."));
const fileName = `${type}-${dayjs().valueOf()}-${Math.random().toString(36).substring(2)}${fileExt}`;
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`;
return new Promise((resolve, reject) => {
ossClient
.put(filePath, file)
.then((res) => {
console.log("普通上传result", res);
const status = res?.res?.status;
const statusCode = res?.res?.statusCode;
const url = res?.url;
const name = res?.name;
resolve({ status, statusCode, url, name });
})
.catch((err) => {
console.log(`普通上传 ${file.name} failed === `, err);
message.error("上传失败!");
reject(err);
});
}).catch((err) => {
console.error(`文件上传失败`, err);
message.error("上传失败!");
return new Promise.reject(err);
});
} catch (e) {
message.error("上传失败!");
return new Promise.reject(false);
}
};
// 分片上传
export const multipartUploader = async (file, type, ossClient, progress, callback) => {
if (!ossClient) {
return;
}
try {
const fileExt = file.name.substring(file.name.lastIndexOf("."));
const fileName = `${type}-${dayjs().valueOf()}-${Math.random().toString(36).substring(2)}${fileExt}`;
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`;
return new Promise((resolve, reject) => {
ossClient
.multipartUpload(filePath, file, {
progress,
parallel,
partSize,
})
.then((res) => {
console.log("分片上传result", res);
const status = res?.res?.status;
const statusCode = res?.res?.statusCode;
const url = res?.res?.requestUrls[0].split("?")[0];
const name = res?.name;
resolve({ status, statusCode, url, name });
})
.catch((err) => {
console.error(`文件上传失败`, err);
message.error("上传失败!");
reject(err);
});
}).catch((err) => {
console.error(`文件上传失败`, err);
message.error("上传失败!");
return new Promise.reject(err);
});
} catch (e) {
// 捕获超时异常。
if (e.code === "ConnectionTimeoutError") {
console.log("TimeoutError");
}
message.error("上传失败!");
}
};
// 断点续传
import { Alert, Button, ConfigProvider, Empty, Space, Table } from "antd"; import { Alert, Button, Empty, Space, Table } from 'antd'
import type { TablePaginationConfig } from "antd/es/table"; import type { TablePaginationConfig } from 'antd/es/table'
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons"; import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import zhCN from "antd/locale/zh_CN";
import type { AppListFilterItem, AppListProps } from "./types"; import type { AppListFilterItem, AppListProps } from './types'
import "./AppList.less"; import './AppList.less'
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 30, 40]; const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 30, 40]
export function AppList<Row extends object, Filters extends object = Record<string, never>>({ export function AppList<Row extends object, Filters extends object = Record<string, never>>({
rowKey, rowKey,
...@@ -20,8 +19,8 @@ export function AppList<Row extends object, Filters extends object = Record<stri ...@@ -20,8 +19,8 @@ export function AppList<Row extends object, Filters extends object = Record<stri
onRetry, onRetry,
tableProps, tableProps,
}: AppListProps<Row, Filters>) { }: AppListProps<Row, Filters>) {
const hasBlockingError = !data.loading && data.error && data.rows.length === 0; const hasBlockingError = !data.loading && data.error && data.rows.length === 0
const hasRefreshError = !data.loading && data.error && data.rows.length > 0; const hasRefreshError = !data.loading && data.error && data.rows.length > 0
const tablePagination: false | TablePaginationConfig = pagination const tablePagination: false | TablePaginationConfig = pagination
? { ? {
...@@ -32,27 +31,25 @@ export function AppList<Row extends object, Filters extends object = Record<stri ...@@ -32,27 +31,25 @@ export function AppList<Row extends object, Filters extends object = Record<stri
showQuickJumper: true, showQuickJumper: true,
showSizeChanger: true, showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`, showTotal: (total) => `共 ${total} 条`,
locale: zhCN.Pagination,
onChange: (page, pageSize) => { onChange: (page, pageSize) => {
if (pageSize !== pagination.pageSize) { if (pageSize !== pagination.pageSize) {
pagination.onPageSizeChange(pageSize); pagination.onPageSizeChange(pageSize)
return; return
} }
pagination.onChange(page); pagination.onChange(page)
}, },
} }
: false; : false
const updateFilter = <Name extends keyof Filters>( const updateFilter = <Name extends keyof Filters>(
item: AppListFilterItem<Filters> & { name: Name }, item: AppListFilterItem<Filters> & { name: Name },
value: Filters[Name], value: Filters[Name],
) => { ) => {
filters?.onChange({ ...filters.value, [item.name]: value }); filters?.onChange({ ...filters.value, [item.name]: value })
}; }
return ( return (
<ConfigProvider locale={zhCN}>
<div className="app-list"> <div className="app-list">
{filters ? ( {filters ? (
<section className="app-list__filters" aria-label="筛选条件"> <section className="app-list__filters" aria-label="筛选条件">
...@@ -119,6 +116,5 @@ export function AppList<Row extends object, Filters extends object = Record<stri ...@@ -119,6 +116,5 @@ export function AppList<Row extends object, Filters extends object = Record<stri
/> />
)} )}
</div> </div>
</ConfigProvider> )
);
} }
...@@ -42,7 +42,7 @@ function MessageDraw({ onRead, ...rest }) { ...@@ -42,7 +42,7 @@ function MessageDraw({ onRead, ...rest }) {
return ( return (
<Drawer <Drawer
{...rest} {...rest}
destroyOnClose destroyOnHidden
closeIcon={<MenuUnfoldOutlined />} closeIcon={<MenuUnfoldOutlined />}
extra={ extra={
<span <span
......
...@@ -9,7 +9,7 @@ import store, { persistor } from "@/store"; ...@@ -9,7 +9,7 @@ import store, { persistor } from "@/store";
import { ConfigProvider, App } from "antd"; import { ConfigProvider, App } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import "dayjs/locale/zh-cn"; import "dayjs/locale/zh-cn";
import zhCN from "antd/locale/zh_CN"; import zhCN from "antd/es/locale/zh_CN";
import MyApp from "./App"; import MyApp from "./App";
......
...@@ -4,7 +4,7 @@ import dayjs from "dayjs"; ...@@ -4,7 +4,7 @@ import dayjs from "dayjs";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { import {
addAdmin, addAdmin,
......
...@@ -4,7 +4,7 @@ import type { Key } from "react"; ...@@ -4,7 +4,7 @@ import type { Key } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { convertToAntdTreeData } from "@/utils/common.js"; import { convertToAntdTreeData } from "@/utils/common.js";
import { import {
......
import axios from "@/utils/request.js";
import qs from "qs";
import { message } from "antd";
import { get } from "lodash-es";
// oss
const getAliOSSSTSToken = async (obj) => {
try {
const res = await axios.post("/common/OssUpload/getUploadToken", qs.stringify(obj));
const code = get(res, "data.code", 0);
const data = get(res, "data.data", []);
const msg = get(res, "data.message", "获取OSS令牌失败!");
if (code === 200) {
return data;
} else {
message.error(msg);
return false;
}
} catch (error) {
return false;
}
};
export { getAliOSSSTSToken };
...@@ -87,7 +87,7 @@ const UserInfo = () => { ...@@ -87,7 +87,7 @@ const UserInfo = () => {
maskClosable={false} maskClosable={false}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
keyboard={false} keyboard={false}
> >
<Form labelCol={{ span: 5 }} form={form2} onFinish={changePasswrd}> <Form labelCol={{ span: 5 }} form={form2} onFinish={changePasswrd}>
......
...@@ -3,7 +3,7 @@ import type { ColumnsType } from 'antd/es/table' ...@@ -3,7 +3,7 @@ import type { ColumnsType } from 'antd/es/table'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { AppList } from '@/shared/components/AppList' import { AppList } from '@/components/AppList'
import type { AdsenseRow } from '../api' import type { AdsenseRow } from '../api'
import { useAdsenseList } from '../useAdsenseList' import { useAdsenseList } from '../useAdsenseList'
......
// @ts-expect-error The legacy workspace does not install the ali-oss declaration package.
import AliOSS from "ali-oss";
import { Button, Image, message, Upload } from "antd"; import { Button, Image, message, Upload } from "antd";
import type { UploadProps } from "antd"; import type { UploadProps } from "antd";
import dayjs from "dayjs"; import { uploadFile } from "@/utils/oss";
import { useEffect, useState } from "react";
import { getAliOSSSTSToken } from "@/modules/settings/help/request";
interface OssClient {
put: (path: string, file: File) => Promise<{ url: string }>;
}
interface AdvertImageUploadProps { interface AdvertImageUploadProps {
value?: string; value?: string;
...@@ -22,37 +13,6 @@ export default function AdvertImageUpload({ ...@@ -22,37 +13,6 @@ export default function AdvertImageUpload({
onChange, onChange,
onUploadingChange, onUploadingChange,
}: AdvertImageUploadProps) { }: AdvertImageUploadProps) {
const [ossClient, setOssClient] = useState<OssClient | null>(null);
useEffect(() => {
let active = true;
void getAliOSSSTSToken().then((data: any) => {
if (!active || !data) return;
window.sessionStorage.setItem("sts", JSON.stringify(data));
const client = new AliOSS({
accessKeyId: data.AccessKeyId,
accessKeySecret: data.AccessKeySecret,
stsToken: data.SecurityToken,
endpoint: data.endpoint,
bucket: "zxts-ad-file",
timeout: 180000,
refreshSTSToken: async () => {
const info = await getAliOSSSTSToken();
return {
AccessKeyId: info.AccessKeyId,
AccessKeySecret: info.AccessKeySecret,
SecurityToken: info.SecurityToken,
};
},
refreshSTSTokenInterval: 14 * 60 * 1000,
}) as OssClient;
setOssClient(client);
});
return () => {
active = false;
};
}, []);
const beforeUpload: UploadProps["beforeUpload"] = (file) => { const beforeUpload: UploadProps["beforeUpload"] = (file) => {
const suffixes = ["jpg", "png"]; const suffixes = ["jpg", "png"];
const suffix = file.name.substring(file.name.lastIndexOf(".") + 1); const suffix = file.name.substring(file.name.lastIndexOf(".") + 1);
...@@ -64,20 +24,12 @@ export default function AdvertImageUpload({ ...@@ -64,20 +24,12 @@ export default function AdvertImageUpload({
}; };
const customRequest: UploadProps["customRequest"] = async (options) => { const customRequest: UploadProps["customRequest"] = async (options) => {
if (!ossClient) {
message.error("ossClient还未初始化");
options.onError?.(new Error("ossClient还未初始化"));
return;
}
onUploadingChange(true); onUploadingChange(true);
try { try {
const uploadFile = options.file as File; const fileToUpload = options.file as File;
const fileExt = uploadFile.name.substring(uploadFile.name.lastIndexOf(".")); const url = await uploadFile(fileToUpload, { bucket: "zxts-ad-file", filenamePrefix: "ad" });
const fileName = `ad-${dayjs().valueOf()}-${Math.random().toString(36).substring(2)}${fileExt}`; onChange?.(url);
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`; options.onSuccess?.({ url });
const result = await ossClient.put(filePath, uploadFile);
onChange?.(result.url);
options.onSuccess?.(result);
} catch (reason) { } catch (reason) {
console.error("上传文件失败:", reason); console.error("上传文件失败:", reason);
message.error("上传文件失败,请重试"); message.error("上传文件失败,请重试");
......
...@@ -4,7 +4,7 @@ import dayjs from "dayjs"; ...@@ -4,7 +4,7 @@ import dayjs from "dayjs";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { import {
addAdvert, addAdvert,
......
...@@ -4,7 +4,7 @@ import { CloseOutlined } from "@ant-design/icons"; ...@@ -4,7 +4,7 @@ import { CloseOutlined } from "@ant-design/icons";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import PreviewModal from "../components/PreviewModal"; import PreviewModal from "../components/PreviewModal";
import type { AuditFilters } from "../query"; import type { AuditFilters } from "../query";
import { useAuditList } from "../useAuditList"; import { useAuditList } from "../useAuditList";
...@@ -14,6 +14,6 @@ export default function AuditListView() { ...@@ -14,6 +14,6 @@ export default function AuditListView() {
const filters: AppListFilterItem<AuditFilters>[] = [{ name: "name", label: "书籍名称", render: (field) => <Input allowClear autoComplete="off" value={field.value || ""} onChange={(event) => field.onChange(event.target.value)} placeholder="请输入书籍名称" /> }, { name: "authors", label: "作者", render: (field) => <Input allowClear autoComplete="off" value={field.value || ""} onChange={(event) => field.onChange(event.target.value)} placeholder="请输入作者" /> }, { name: "time", label: "创建时间", render: (field) => <RangePicker value={field.value} onChange={(value) => field.onChange(value)} /> }]; const filters: AppListFilterItem<AuditFilters>[] = [{ name: "name", label: "书籍名称", render: (field) => <Input allowClear autoComplete="off" value={field.value || ""} onChange={(event) => field.onChange(event.target.value)} placeholder="请输入书籍名称" /> }, { name: "authors", label: "作者", render: (field) => <Input allowClear autoComplete="off" value={field.value || ""} onChange={(event) => field.onChange(event.target.value)} placeholder="请输入作者" /> }, { name: "time", label: "创建时间", render: (field) => <RangePicker value={field.value} onChange={(value) => field.onChange(value)} /> }];
const columns: ColumnsType<any> = [{ title: "ID", dataIndex: "id", align: "center" }, { title: "书籍图片", dataIndex: "img", align: "center", render: (src) => <Image width={80} height={80} src={src} /> }, { title: "书籍名称", dataIndex: "name", align: "center" }, { title: "作者", dataIndex: "authors", align: "center" }, { title: "创建时间", dataIndex: "create_time", align: "center" }, { title: "更新时间", dataIndex: "update_time", align: "center" }, { title: "操作", render: (_, row) => <Space>{operationPermissionsList.includes("/books/audit/getInfoByChapterId") && <Button onClick={(event) => { event.stopPropagation(); setPreviewId(row.id); }}>预览</Button>}{operationPermissionsList.includes("/books/audit/add") && <Button onClick={(event) => { event.stopPropagation(); navigate("detail", { state: { id: row.id } }); }}>审核</Button>}</Space> }]; const columns: ColumnsType<any> = [{ title: "ID", dataIndex: "id", align: "center" }, { title: "书籍图片", dataIndex: "img", align: "center", render: (src) => <Image width={80} height={80} src={src} /> }, { title: "书籍名称", dataIndex: "name", align: "center" }, { title: "作者", dataIndex: "authors", align: "center" }, { title: "创建时间", dataIndex: "create_time", align: "center" }, { title: "更新时间", dataIndex: "update_time", align: "center" }, { title: "操作", render: (_, row) => <Space>{operationPermissionsList.includes("/books/audit/getInfoByChapterId") && <Button onClick={(event) => { event.stopPropagation(); setPreviewId(row.id); }}>预览</Button>}{operationPermissionsList.includes("/books/audit/add") && <Button onClick={(event) => { event.stopPropagation(); navigate("detail", { state: { id: row.id } }); }}>审核</Button>}</Space> }];
return <div><AppList rowKey="id" columns={columns} data={{ rows: list.rows, total: list.total, loading: list.loading, error: list.error }} filters={{ value: list.filters, items: filters, onChange: list.setFilters, onSearch: list.search, onReset: list.reset, onRefresh: list.refresh }} pagination={{ page: list.page, pageSize: list.pageSize, pageSizeOptions: [10, 20, 30, 40], onChange: list.changePage, onPageSizeChange: list.changePageSize }} onRetry={list.retry} tableProps={{ onRow: (row) => ({ onClick: () => setPreviewId(row.id) }) }} /> return <div><AppList rowKey="id" columns={columns} data={{ rows: list.rows, total: list.total, loading: list.loading, error: list.error }} filters={{ value: list.filters, items: filters, onChange: list.setFilters, onSearch: list.search, onReset: list.reset, onRefresh: list.refresh }} pagination={{ page: list.page, pageSize: list.pageSize, pageSizeOptions: [10, 20, 30, 40], onChange: list.changePage, onPageSizeChange: list.changePageSize }} onRetry={list.retry} tableProps={{ onRow: (row) => ({ onClick: () => setPreviewId(row.id) }) }} />
<Modal open={Boolean(previewId)} footer={null} destroyOnClose keyboard={false} closeIcon={<CloseOutlined style={{ fontSize: 20, color: "#fff" }} />} maskClosable={false} classNames={{ body: "phone-body", wrapper: "phone-wrapper" }} wrapClassName="wrap-phone-privew" width="494px" centered onCancel={() => setPreviewId(undefined)}><PreviewModal bookId={previewId} /></Modal> <Modal open={Boolean(previewId)} footer={null} destroyOnHidden keyboard={false} closeIcon={<CloseOutlined style={{ fontSize: 20, color: "#fff" }} />} maskClosable={false} classNames={{ body: "phone-body", wrapper: "phone-wrapper" }} wrapClassName="wrap-phone-privew" width="494px" centered onCancel={() => setPreviewId(undefined)}><PreviewModal bookId={previewId} /></Modal>
</div>; </div>;
} }
...@@ -2,7 +2,7 @@ import { App, Button, DatePicker, Drawer, Form, Input, Space } from "antd"; ...@@ -2,7 +2,7 @@ import { App, Button, DatePicker, Drawer, Form, Input, Space } from "antd";
import type { ColumnsType } from "antd/es/table"; import type { ColumnsType } from "antd/es/table";
import { useState } from "react"; import { useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { addCategory, deleteCategory, editCategory, getCategory } from "../api"; import { addCategory, deleteCategory, editCategory, getCategory } from "../api";
import type { ClassifyFilters } from "../query"; import type { ClassifyFilters } from "../query";
import { useClassifyList } from "../useClassifyList"; import { useClassifyList } from "../useClassifyList";
......
...@@ -14,7 +14,7 @@ import { ...@@ -14,7 +14,7 @@ import {
import type { ColumnsType } from "antd/es/table"; import type { ColumnsType } from "antd/es/table";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { import {
changeCommentStatus, changeCommentStatus,
deleteComment, deleteComment,
......
import { message } from "antd"; import { message } from "antd";
import qs from "qs";
import request from "@/utils/request"; import request from "@/utils/request";
import type { LabelQuery } from "./query"; import type { LabelQuery } from "./query";
async function post(path: string, data: Record<string, unknown>, notify = false) { async function post(path: string, data: Record<string, unknown>, notify = false) {
try { try {
const response = (await request.post( const response = (await request.post(path, qs.stringify(data))) as {
path, data?: { code?: number; data?: any; message?: string };
new URLSearchParams(data as Record<string, string>).toString(), };
)) as { data?: { code?: number; data?: any; message?: string } };
if (response.data?.code === 200) { if (response.data?.code === 200) {
if (notify) message.success(response.data.message); if (notify) message.success(response.data.message);
return response.data.data; return response.data.data;
......
import { App, Button, DatePicker, Drawer, Form, Input, Row, Space } from "antd"; import { App, Button, Col, DatePicker, Drawer, Form, Input, Row, Space } from "antd";
import type { ColumnsType } from "antd/es/table"; import type { ColumnsType } from "antd/es/table";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { addLabel, deleteLabel, editLabel, getAllBooks, getLabel } from "../api"; import { addLabel, deleteLabel, editLabel, getAllBooks, getLabel } from "../api";
import type { LabelFilters } from "../query"; import type { LabelFilters } from "../query";
import { useLabelList } from "../useLabelList"; import { useLabelList } from "../useLabelList";
...@@ -159,14 +159,15 @@ export default function LabelView() { ...@@ -159,14 +159,15 @@ export default function LabelView() {
<br /> <br />
<Row gutter={20}> <Row gutter={20}>
{books.map((book) => ( {books.map((book) => (
<Button <Col key={book.id} flex="0 0 70px" style={{ marginBottom: 15 }}>
key={book.id} <Button
type={bookIds.includes(book.id) ? "primary" : "default"} type={bookIds.includes(book.id) ? "primary" : "default"}
ghost={bookIds.includes(book.id)} ghost={bookIds.includes(book.id)}
onClick={() => toggle(book.id)} onClick={() => toggle(book.id)}
> >
{book.name} {book.name}
</Button> </Button>
</Col>
))} ))}
</Row> </Row>
</Form.Item> </Form.Item>
......
...@@ -566,7 +566,7 @@ const Examine = () => { ...@@ -566,7 +566,7 @@ const Examine = () => {
open={editKey} open={editKey}
footer={null} footer={null}
centered centered
destroyOnClose destroyOnHidden
title={editKey > 0 ? "修改章节" : "新增章节"} title={editKey > 0 ? "修改章节" : "新增章节"}
maskClosable={false} maskClosable={false}
mask={false} mask={false}
......
.examine { .examine {
height: 100%; // 统一目录、正文和工具栏的可视区高度;目录标题占 85px 后,树区域仍沿用 100vh - 285px。
height: calc(100vh - 190px);
min-height: 0;
.content-box { .content-box {
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
......
import { Button, Drawer, Form, message, Row, Select, Space, Spin, Upload } from "antd"; import { Button, Drawer, Form, message, Row, Select, Space, Spin, Upload } from "antd";
import { useState } from "react"; import { useState } from "react";
import { downloadFile } from "@/utils/common";
import type { ImportBookValues } from "../api"; import type { ImportBookValues } from "../api";
interface ImportBookFormValues { interface ImportBookFormValues {
file: unknown;
catalog_level: number; catalog_level: number;
} }
...@@ -14,18 +11,22 @@ interface ImportBookDrawerProps { ...@@ -14,18 +11,22 @@ interface ImportBookDrawerProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (values: ImportBookValues) => Promise<boolean>; onSubmit: (values: ImportBookValues) => Promise<boolean>;
onSuccess: () => void;
} }
export default function ImportBookDrawer({ open, onClose, onSubmit }: ImportBookDrawerProps) { export default function ImportBookDrawer({ open, onClose, onSubmit, onSuccess }: ImportBookDrawerProps) {
const [bookFile, setBookFile] = useState<File | null>(null); const [bookFile, setBookFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const bookTemplateUrl = `${import.meta.env.BASE_URL}templates/书籍导入示例.docx`;
const handleSubmit = async (values: ImportBookFormValues) => { const handleSubmit = async (values: ImportBookFormValues) => {
if (!bookFile) return; if (!bookFile) return;
setUploading(true); setUploading(true);
const success = await onSubmit({ catalog_level: values.catalog_level, file: bookFile }); const success = await onSubmit({ catalog_level: values.catalog_level, file: bookFile });
if (success) window.location.replace(window.location.href); if (success) {
onClose(); onSuccess();
onClose();
}
setUploading(false); setUploading(false);
}; };
...@@ -34,9 +35,9 @@ export default function ImportBookDrawer({ open, onClose, onSubmit }: ImportBook ...@@ -34,9 +35,9 @@ export default function ImportBookDrawer({ open, onClose, onSubmit }: ImportBook
<Spin spinning={uploading}> <Spin spinning={uploading}>
<Form<ImportBookFormValues> labelCol={{ span: 8 }} onFinish={handleSubmit}> <Form<ImportBookFormValues> labelCol={{ span: 8 }} onFinish={handleSubmit}>
<Form.Item label="下载模板" style={{ marginBottom: 10 }}> <Form.Item label="下载模板" style={{ marginBottom: 10 }}>
<Button ghost type="primary" onClick={() => downloadFile("书籍导入示例.docx")}> <a href={bookTemplateUrl} download="书籍导入示例.docx">
下载模板 <Button ghost type="primary">下载模板</Button>
</Button> </a>
</Form.Item> </Form.Item>
<p <p
className="linetext" className="linetext"
...@@ -51,25 +52,23 @@ export default function ImportBookDrawer({ open, onClose, onSubmit }: ImportBook ...@@ -51,25 +52,23 @@ export default function ImportBookDrawer({ open, onClose, onSubmit }: ImportBook
目录需设置为word中的标题样式或按照示例编写,防止导入失败 目录需设置为word中的标题样式或按照示例编写,防止导入失败
</p> </p>
<Form.Item label="选择书籍" required> <Form.Item label="选择书籍" required>
<Form.Item name="file" rules={[{ required: true, message: "请上传书籍" }]} noStyle> <Upload
<Upload name="file"
name="file" accept=".doc , .docx"
accept=".doc , .docx" showUploadList={false}
showUploadList={false} beforeUpload={(file) => {
beforeUpload={(file) => { const suffixes = ["doc", "docx"];
const suffixes = ["doc", "docx"]; const suffix = file.name.substring(file.name.lastIndexOf(".") + 1);
const suffix = file.name.substring(file.name.lastIndexOf(".") + 1); if (!suffixes.includes(suffix)) {
if (!suffixes.includes(suffix)) { message.error(`请上传以${suffixes.join("、")}格式的书籍`);
message.error(`请上传以${suffixes.join("、")}格式的书籍`);
return false;
}
setBookFile(file);
return false; return false;
}} }
> setBookFile(file);
<Button>点击上传</Button> return false;
</Upload> }}
</Form.Item> >
<Button>点击上传</Button>
</Upload>
<p style={{ color: "#999999" }}>{bookFile?.name}</p> <p style={{ color: "#999999" }}>{bookFile?.name}</p>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
......
...@@ -4,7 +4,7 @@ import { useState } from "react"; ...@@ -4,7 +4,7 @@ import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { exportHtmlToWord } from "@/utils/exportWordHtmlDocx"; import { exportHtmlToWord } from "@/utils/exportWordHtmlDocx";
import { import {
...@@ -328,6 +328,7 @@ export default function ManagementListView() { ...@@ -328,6 +328,7 @@ export default function ManagementListView() {
open={importOpen} open={importOpen}
onClose={() => setImportOpen(false)} onClose={() => setImportOpen(false)}
onSubmit={handleImport} onSubmit={handleImport}
onSuccess={list.refresh}
/> />
<ReleaseBookDrawer <ReleaseBookDrawer
open={releaseOpen} open={releaseOpen}
......
...@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table"; ...@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table";
import { useState } from "react"; import { useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { refundOrder, type OrderRow } from "../api"; import { refundOrder, type OrderRow } from "../api";
import RefundDrawer from "../components/RefundDrawer"; import RefundDrawer from "../components/RefundDrawer";
......
import { useState, useEffect, useRef } from "react";
import { Spin } from "antd";
import "@wangeditor/editor/dist/css/style.css";
import { Editor, Toolbar } from "@wangeditor/editor-for-react";
import { uploadFile } from "@/utils/oss";
const QuestionEditr = (props) => {
const {
editorValue = "",
quesList = [],
setQuesList,
index,
setedierVal,
form = null,
name = "",
} = props;
const timeout = useRef();
const [titlesEditor, setTitlesEditor] = useState(null);
const toolbarConfig = {};
toolbarConfig.toolbarKeys = ["uploadImage"];
const [loading, setLoading] = useState(true);
const [tempQuestion, setTempQuestion] = useState(quesList);
const editorConfig = {
placeholder: "请输入内容...",
hoverbarKeys: {
text: {
menuKeys: [],
},
},
MENU_CONF: {
uploadImage: {
timeout: 5 * 1000,
fieldName: "image",
headers: {
"Content-Type": "multipart/form-data",
Authorization: "Bearer " + localStorage.getItem("token") || "",
},
maxFileSize: 10 * 1024 * 1024,
base64LimitSize: 5 * 1024,
customUpload: async (file, insertFn) => {
setLoading(true);
try {
const url = await uploadFile(file, { filenamePrefix: "practice" });
insertFn(url, "题库图片");
} finally {
setLoading(false);
}
},
},
customPaste: (editor, event) => {
const text = event.clipboardData.getData("text/plain");
setTimeout(() => {
editor.insertText(text);
}, 500);
event.preventDefault();
return false;
},
},
customPaste: (editor, event) => {
const text = event.clipboardData.getData("text/plain");
setTimeout(() => {
editor.insertText(text);
}, 500);
event.preventDefault();
return false;
},
};
useEffect(() => {
if (titlesEditor) {
clearTimeout(timeout.current);
setLoading(false);
}
return () => {
if (titlesEditor === null) return;
titlesEditor.destroy();
setTitlesEditor(null);
};
}, [titlesEditor]);
return (
<div style={{ border: "1px solid #ccc", zIndex: 100 }}>
<Toolbar
editor={titlesEditor}
defaultConfig={toolbarConfig}
mode="default"
style={{ borderBottom: "1px solid #ccc" }}
/>
<Spin spinning={loading}>
<Editor
defaultConfig={editorConfig}
value={editorValue}
onCreated={setTitlesEditor}
onChange={(editor) => {
if (name) {
form.setFieldsValue({ [name]: editor.getHtml() });
setedierVal(editor.getHtml());
} else {
clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
let obj = { option: "", correct: false };
if (editor.getHtml() === "<p><br></p>") {
tempQuestion[index] = obj;
} else {
obj.option = editor.getHtml();
tempQuestion[index] = obj;
}
setQuesList(tempQuestion);
setTempQuestion(tempQuestion);
form.setFieldsValue({ question_list: tempQuestion });
}, 100);
}
}}
config={{
uploadImgServer: "your_upload_server_address",
}}
mode="default"
style={{ height: "100px", overflowY: "hidden" }}
/>
</Spin>
</div>
);
};
export default QuestionEditr;
import { Button, Checkbox, Drawer, Form, Input, Select, Space } from "antd"; import { Button, Checkbox, Col, Drawer, Form, Input, Row, Select, Space } from "antd";
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"; import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { addQuestion, addQuestionType, editQuestion } from "../api"; import { addQuestion, addQuestionType, editQuestion } from "../api";
...@@ -8,6 +8,7 @@ const styles = ["单选题", "多选题", "判断题", "填空题", "简答题"] ...@@ -8,6 +8,7 @@ const styles = ["单选题", "多选题", "判断题", "填空题", "简答题"]
export default function QuestionForm({ open, onClose, question, types, books, onTypesChange, onSaved }: Props) { export default function QuestionForm({ open, onClose, question, types, books, onTypesChange, onSaved }: Props) {
const [form] = Form.useForm(); const [addingType, setAddingType] = useState(false); const [typeName, setTypeName] = useState(""); const [form] = Form.useForm(); const [addingType, setAddingType] = useState(false); const [typeName, setTypeName] = useState("");
const questionStyle = Form.useWatch("question_style", form); const questionStyle = Form.useWatch("question_style", form);
const typeId = Form.useWatch("type_id", form);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
let values: any = question || { titles: "", type_id: undefined, question_style: undefined, analysis: "", answers: "", question_list: [], book_id: undefined }; let values: any = question || { titles: "", type_id: undefined, question_style: undefined, analysis: "", answers: "", question_list: [], book_id: undefined };
...@@ -31,10 +32,10 @@ export default function QuestionForm({ open, onClose, question, types, books, on ...@@ -31,10 +32,10 @@ export default function QuestionForm({ open, onClose, question, types, books, on
<Form form={form} onFinish={submit} labelCol={{ span: 5 }} wrapperCol={{ span: 19 }}> <Form form={form} onFinish={submit} labelCol={{ span: 5 }} wrapperCol={{ span: 19 }}>
<Form.Item name="book_id" label="书籍名称" rules={[{ required: true, message: "请选择书籍名称" }]}><Select showSearch optionFilterProp="children" placeholder="请选择书籍名称">{books.map((book) => <Select.Option key={book.book_id} value={book.book_id}>{book.name}</Select.Option>)}</Select></Form.Item> <Form.Item name="book_id" label="书籍名称" rules={[{ required: true, message: "请选择书籍名称" }]}><Select showSearch optionFilterProp="children" placeholder="请选择书籍名称">{books.map((book) => <Select.Option key={book.book_id} value={book.book_id}>{book.name}</Select.Option>)}</Select></Form.Item>
<Form.Item name="question_style" label="题目类型" rules={[{ required: true, message: "请选择题目类型" }]}><Select placeholder="请选择题目类型">{styles.map((name, index) => <Select.Option key={index + 1} value={index + 1}>{name}</Select.Option>)}</Select></Form.Item> <Form.Item name="question_style" label="题目类型" rules={[{ required: true, message: "请选择题目类型" }]}><Select placeholder="请选择题目类型">{styles.map((name, index) => <Select.Option key={index + 1} value={index + 1}>{name}</Select.Option>)}</Select></Form.Item>
<Form.Item name="type_id" label="题目分类" rules={[{ required: true, message: "请选择题目分类" }]}>{addingType ? <Space><Input value={typeName} onChange={(e) => setTypeName(e.target.value)} placeholder="请输入题目分类"/><Button type="link" onClick={addType}>添加</Button><Button type="link" danger onClick={() => setAddingType(false)}>取消</Button></Space> : <Space.Compact><Select style={{ minWidth: 220 }} placeholder="请选择题目分类">{types.map((type) => <Select.Option key={type.id} value={type.id}>{type.typename}</Select.Option>)}</Select><Button type="link" onClick={() => setAddingType(true)}>添加分类</Button></Space.Compact>}</Form.Item> <Form.Item name="type_id" label="题目分类" rules={[{ required: true, message: "请选择题目分类" }]}>{addingType ? <Space><Input value={typeName} onChange={(e) => setTypeName(e.target.value)} placeholder="请输入题目分类"/><Button type="link" onClick={addType}>添加</Button><Button type="link" danger onClick={() => setAddingType(false)}>取消</Button></Space> : <Space.Compact><Select style={{ minWidth: 220 }} placeholder="请选择题目分类" value={typeId} onChange={(value) => form.setFieldValue("type_id", value)}>{types.map((type) => <Select.Option key={type.id} value={type.id}>{type.typename}</Select.Option>)}</Select><Button type="link" onClick={() => setAddingType(true)}>添加分类</Button></Space.Compact>}</Form.Item>
<Form.Item name="titles" label="题干名称" rules={[{ required: true, message: "请输入题干名称" }]} extra={questionStyle === 4 ? "请在需要填空的位置使用2个或以上的下划线进行区分!" : ""}><RichTextEditor height={200} /></Form.Item> <Form.Item name="titles" label="题干名称" rules={[{ required: true, message: "请输入题干名称" }]} extra={questionStyle === 4 ? "请在需要填空的位置使用2个或以上的下划线进行区分!" : ""}><RichTextEditor height={200} /></Form.Item>
<Form.Item name="analysis" label="题目解析"><Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="请输入题目解析" /></Form.Item> <Form.Item name="analysis" label="题目解析"><Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="请输入题目解析" /></Form.Item>
{needsOptions && <Form.List name="question_list">{(fields, { add, remove }) => <Form.Item label="题目选项">{fields.map((field) => <Space key={field.key} align="start" style={{ display: "flex", marginBottom: 8 }}><Form.Item {...field} name={[field.name, "option"]} rules={[{ required: true, message: "请输入内容" }]}><RichTextEditor height={100} /></Form.Item><Form.Item {...field} name={[field.name, "correct"]} valuePropName="checked"><Checkbox>正确答案</Checkbox></Form.Item><Button icon={<MinusOutlined />} onClick={() => remove(field.name)} /></Space>)}{(questionStyle !== 3 || fields.length < 2) && <Button type="dashed" icon={<PlusOutlined />} onClick={() => add({ option: "", correct: false })} />}</Form.Item>}</Form.List>} {needsOptions && <Form.List name="question_list">{(fields, { add, remove }) => <Form.Item label="题目选项">{fields.map(({ key, name, ...restField }) => <Row gutter={4} key={key}><Col span={14}><Form.Item {...restField} name={[name, "option"]} rules={[{ required: true, message: "请输入内容" }]}><RichTextEditor height={100} /></Form.Item></Col><Col span={7}><Form.Item {...restField} name={[name, "correct"]} valuePropName="checked"><Checkbox>正确答案</Checkbox></Form.Item></Col><Col span={3}><Button icon={<MinusOutlined />} onClick={() => remove(name)} /></Col></Row>)}{(questionStyle !== 3 || fields.length < 2) && <Button type="dashed" icon={<PlusOutlined />} onClick={() => add({ option: "", correct: false })} />}</Form.Item>}</Form.List>}
{[4, 5].includes(questionStyle) && <Form.Item name="answers" label="正确答案"><RichTextEditor height={100} /></Form.Item>} {[4, 5].includes(questionStyle) && <Form.Item name="answers" label="正确答案"><RichTextEditor height={100} /></Form.Item>}
<Form.Item wrapperCol={{ offset: 5 }}><Space><Button onClick={onClose}>取消</Button><Button type="primary" htmlType="submit">提交</Button></Space></Form.Item> <Form.Item wrapperCol={{ offset: 5 }}><Space><Button onClick={onClose}>取消</Button><Button type="primary" htmlType="submit">提交</Button></Space></Form.Item>
</Form> </Form>
......
import "@wangeditor/editor/dist/css/style.css"; import "@wangeditor/editor/dist/css/style.css";
import { Editor, Toolbar } from "@wangeditor/editor-for-react"; import { Editor, Toolbar } from "@wangeditor/editor-for-react";
import AliOSS from "ali-oss";
import dayjs from "dayjs";
import { Spin } from "antd"; import { Spin } from "antd";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { getAliOssToken } from "../api"; import { uploadFile } from "@/utils/oss";
type Props = { value?: string; onChange?: (value: string) => void; height?: number }; type Props = { value?: string; onChange?: (value: string) => void; height?: number };
export default function RichTextEditor({ value = "", onChange, height = 140 }: Props) { export default function RichTextEditor({ value = "", onChange, height = 140 }: Props) {
const [editor, setEditor] = useState<any>(); const [editor, setEditor] = useState<any>();
const [client, setClient] = useState<any>();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const clientRef = useRef<any>();
useEffect(() => {
let active = true;
const createClient = async () => {
const token: any = await getAliOssToken();
if (!active || !token) return;
window.sessionStorage.setItem("sts", JSON.stringify(token));
const oss = new AliOSS({
accessKeyId: token.AccessKeyId, accessKeySecret: token.AccessKeySecret, stsToken: token.SecurityToken,
endpoint: token.endpoint, bucket: "zxts-book-file", timeout: 180000,
refreshSTSToken: async () => {
const next: any = await getAliOssToken();
return { AccessKeyId: next.AccessKeyId, AccessKeySecret: next.AccessKeySecret, SecurityToken: next.SecurityToken };
}, refreshSTSTokenInterval: 14 * 60 * 1000,
});
clientRef.current = oss; setClient(oss);
};
void createClient();
return () => { active = false; };
}, []);
useEffect(() => () => { editor?.destroy(); }, [editor]); useEffect(() => () => { editor?.destroy(); }, [editor]);
const config = useMemo(() => ({ const config = useMemo(() => ({
placeholder: "请输入内容...", hoverbarKeys: { text: { menuKeys: [] } }, placeholder: "请输入内容...", hoverbarKeys: { text: { menuKeys: [] } },
...@@ -38,12 +15,8 @@ export default function RichTextEditor({ value = "", onChange, height = 140 }: P ...@@ -38,12 +15,8 @@ export default function RichTextEditor({ value = "", onChange, height = 140 }: P
timeout: 5000, fieldName: "image", maxFileSize: 10 * 1024 * 1024, base64LimitSize: 5 * 1024, timeout: 5000, fieldName: "image", maxFileSize: 10 * 1024 * 1024, base64LimitSize: 5 * 1024,
headers: { "Content-Type": "multipart/form-data", Authorization: `Bearer ${localStorage.getItem("token") || ""}` }, headers: { "Content-Type": "multipart/form-data", Authorization: `Bearer ${localStorage.getItem("token") || ""}` },
customUpload: async (file: File, insertFn: (url: string, alt: string) => void) => { customUpload: async (file: File, insertFn: (url: string, alt: string) => void) => {
const oss = clientRef.current; const url = await uploadFile(file, { filenamePrefix: "practice" });
if (!oss) return; insertFn(url, "题库图片");
const ext = file.name.substring(file.name.lastIndexOf("."));
const name = `practice-${dayjs().valueOf()}-${Math.random().toString(36).substring(2)}${ext}`;
const result = await oss.put(`${dayjs().format("YYYY/MM/DD")}/${name}`, file);
insertFn(result.url, "题库图片");
}, },
} }, } },
customPaste: (instance: any, event: ClipboardEvent) => { customPaste: (instance: any, event: ClipboardEvent) => {
...@@ -54,7 +27,7 @@ export default function RichTextEditor({ value = "", onChange, height = 140 }: P ...@@ -54,7 +27,7 @@ export default function RichTextEditor({ value = "", onChange, height = 140 }: P
}), []); }), []);
return <div style={{ border: "1px solid #ccc", zIndex: 100 }}> return <div style={{ border: "1px solid #ccc", zIndex: 100 }}>
<Toolbar editor={editor} defaultConfig={{ toolbarKeys: ["uploadImage"] }} mode="default" style={{ borderBottom: "1px solid #ccc" }} /> <Toolbar editor={editor} defaultConfig={{ toolbarKeys: ["uploadImage"] }} mode="default" style={{ borderBottom: "1px solid #ccc" }} />
<Spin spinning={loading || !client}> <Spin spinning={loading}>
<Editor defaultConfig={config} value={value} onCreated={(instance) => { setEditor(instance); setLoading(false); }} onChange={(instance) => onChange?.(instance.getHtml())} mode="default" style={{ height, overflowY: "hidden" }} /> <Editor defaultConfig={config} value={value} onCreated={(instance) => { setEditor(instance); setLoading(false); }} onChange={(instance) => onChange?.(instance.getHtml())} mode="default" style={{ height, overflowY: "hidden" }} />
</Spin> </Spin>
</div>; </div>;
......
...@@ -2,7 +2,7 @@ import { App, Button, DatePicker, Drawer, Form, Input, Popover, Result, Select, ...@@ -2,7 +2,7 @@ import { App, Button, DatePicker, Drawer, Form, Input, Popover, Result, Select,
import type { ColumnsType } from "antd/es/table"; import type { ColumnsType } from "antd/es/table";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { downloadFile } from "@/utils/common"; import { downloadFile } from "@/utils/common";
import { deleteQuestion, getBookNames, getQuestion, getQuestionTypes, importQuestions } from "../api"; import { deleteQuestion, getBookNames, getQuestion, getQuestionTypes, importQuestions } from "../api";
import type { QuestionBankFilters } from "../query"; import type { QuestionBankFilters } from "../query";
...@@ -34,8 +34,9 @@ export default function QuestionBankView() { ...@@ -34,8 +34,9 @@ export default function QuestionBankView() {
]; ];
const upload = async () => { if (!file) return; const result = await importQuestions(file); if (result) setImportResult(result); }; const upload = async () => { if (!file) return; const result = await importQuestions(file); if (result) setImportResult(result); };
const closeImport = () => { setImportOpen(false); setFile(undefined); setImportResult(undefined); }; const closeImport = () => { setImportOpen(false); setFile(undefined); setImportResult(undefined); };
const questionTemplateUrl = `${import.meta.env.BASE_URL}templates/题库导入示例.xlsx`;
return <div className="question-bank"><AppList rowKey="id" columns={columns} data={{ rows: list.rows, total: list.total, loading: list.loading, error: list.error }} filters={{ value: list.filters, items: filters, onChange: list.setFilters, onSearch: list.search, onReset: list.reset, onRefresh: list.refresh }} pagination={{ page: list.page, pageSize: list.pageSize, pageSizeOptions: [10, 20, 30, 40], onChange: list.changePage, onPageSizeChange: list.changePageSize }} toolbar={<Space>{operationPermissionsList.includes("/books/question-bank/add") && <Button type="primary" ghost onClick={() => { setQuestion(undefined); setFormOpen(true); }}>添加</Button>}{operationPermissionsList.includes("/books/question-bank/import") && <Button type="primary" ghost onClick={() => setImportOpen(true)}>导入</Button>}</Space>} onRetry={list.retry} /> return <div className="question-bank"><AppList rowKey="id" columns={columns} data={{ rows: list.rows, total: list.total, loading: list.loading, error: list.error }} filters={{ value: list.filters, items: filters, onChange: list.setFilters, onSearch: list.search, onReset: list.reset, onRefresh: list.refresh }} pagination={{ page: list.page, pageSize: list.pageSize, pageSizeOptions: [10, 20, 30, 40], onChange: list.changePage, onPageSizeChange: list.changePageSize }} toolbar={<Space>{operationPermissionsList.includes("/books/question-bank/add") && <Button type="primary" ghost onClick={() => { setQuestion(undefined); setFormOpen(true); }}>添加</Button>}{operationPermissionsList.includes("/books/question-bank/import") && <Button type="primary" ghost onClick={() => setImportOpen(true)}>导入</Button>}</Space>} onRetry={list.retry} />
<QuestionForm open={formOpen} onClose={() => setFormOpen(false)} question={question} types={types} books={books} onTypesChange={loadTypes} onSaved={list.reloadFirst} /> <QuestionForm open={formOpen} onClose={() => setFormOpen(false)} question={question} types={types} books={books} onTypesChange={loadTypes} onSaved={list.reloadFirst} />
<Drawer open={importOpen} mask={false} footer={null} onClose={closeImport}>{importResult ? <Result status={importResult.data?.err === 0 ? "success" : "error"} subTitle={importResult.msg} extra={importResult.data?.err !== 0 ? <Button type="primary" ghost onClick={() => downloadFile(`temp/${importResult.data?.name}`)}>下载错误文件</Button> : null} /> : <Form onFinish={upload}><Form.Item label="下载模板"><Button type="primary" ghost onClick={() => downloadFile("题库导入示例.xlsx")}>下载模板</Button></Form.Item><p>1.请勿修改模板表头,防止导入失败;2.按照示例填写</p><Form.Item label="导入模板" required><Upload accept=".xlsx" showUploadList={false} beforeUpload={(value) => { if (!value.name.endsWith(".xlsx")) return Upload.LIST_IGNORE; setFile(value); return false; }}><Button type="primary" ghost>选择文件</Button></Upload><p>{file?.name}</p></Form.Item><Space><Button onClick={closeImport}>取消</Button><Button type="primary" htmlType="submit" disabled={!file}>确认</Button></Space></Form>}</Drawer> <Drawer open={importOpen} mask={false} footer={null} onClose={closeImport}>{importResult ? <Result status={importResult.data?.err === 0 ? "success" : "error"} subTitle={importResult.msg} extra={importResult.data?.err !== 0 ? <Button type="primary" ghost onClick={() => downloadFile(`temp/${importResult.data?.name}`)}>下载错误文件</Button> : null} /> : <Form onFinish={upload}><Form.Item label="下载模板"><a href={questionTemplateUrl} download="题库导入示例.xlsx"><Button type="primary" ghost>下载模板</Button></a></Form.Item><p>1.请勿修改模板表头,防止导入失败;2.按照示例填写</p><Form.Item label="导入模板" required><Upload accept=".xlsx" showUploadList={false} beforeUpload={(value) => { if (!value.name.endsWith(".xlsx")) return Upload.LIST_IGNORE; setFile(value); return false; }}><Button type="primary" ghost>选择文件</Button></Upload><p>{file?.name}</p></Form.Item><Space><Button onClick={closeImport}>取消</Button><Button type="primary" htmlType="submit" disabled={!file}>确认</Button></Space></Form>}</Drawer>
</div>; </div>;
} }
import { DatePicker, Input, Button, Space } from "antd"; import { DatePicker, Input, Button, Space } from "antd";
import type { ColumnsType } from "antd/es/table"; import type { ColumnsType } from "antd/es/table";
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import type { SaleDiscussionFilters } from "../query"; import type { SaleDiscussionFilters } from "../query";
import { useSaleDiscussionList } from "../useSaleDiscussionList"; import { useSaleDiscussionList } from "../useSaleDiscussionList";
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
......
...@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table"; ...@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { changeSaleStatus } from "../api"; import { changeSaleStatus } from "../api";
import type { SaleFilters } from "../query"; import type { SaleFilters } from "../query";
import { useSaleList } from "../useSaleList"; import { useSaleList } from "../useSaleList";
......
...@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table"; ...@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { import {
addMemberLevel, addMemberLevel,
......
...@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table"; ...@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { deleteMember } from "../api"; import { deleteMember } from "../api";
import MemberDrawer from "../components/MemberDrawer"; import MemberDrawer from "../components/MemberDrawer";
import type { MemberFilters } from "../query"; import type { MemberFilters } from "../query";
......
...@@ -14,14 +14,13 @@ import { ...@@ -14,14 +14,13 @@ import {
Spin, Spin,
} from "antd"; } from "antd";
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
import { AppList } from "@/shared/components/AppList"; import { AppList } from "@/components/AppList";
import { getList, addVersion, editVersion, infoVersion, delVersion } from "../api"; import { getList, addVersion, editVersion, infoVersion, delVersion } from "../api";
const { TextArea } = Input; const { TextArea } = Input;
import dayjs from "dayjs"; import dayjs from "dayjs";
import Add from "@/assets/images/icon/add.png"; import Add from "@/assets/images/icon/add.png";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import AliOSS from "ali-oss"; import { uploadFile } from "@/utils/oss";
import { getAliOSSSTSToken } from "../../help/request";
const AppVersion = () => { const AppVersion = () => {
const [data, setData] = useState([]); const [data, setData] = useState([]);
const [page_size, setpage_size] = useState(10); const [page_size, setpage_size] = useState(10);
...@@ -46,48 +45,9 @@ const AppVersion = () => { ...@@ -46,48 +45,9 @@ const AppVersion = () => {
dateRange: [], dateRange: [],
}); });
const [ossClient, setOssClient] = useState(null); // oss 操作
const [STSToken, setSTSToken] = useState(null); // oss 过期设置
const [file, setFile] = useState({});
const [img, setImg] = useState(""); const [img, setImg] = useState("");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
let ossClientTemp;
const getStsAuthToken = () => {
return new Promise(async (resolve) => {
const data = await getAliOSSSTSToken();
if (data) {
window.sessionStorage.setItem("sts", JSON.stringify(data));
setSTSToken(data);
ossClientTemp = await new AliOSS({
accessKeyId: data.AccessKeyId,
accessKeySecret: data.AccessKeySecret,
stsToken: data.SecurityToken,
endpoint: data.endpoint,
bucket: "zxts-common-file",
timeout: 180000,
refreshSTSToken: async () => {
const info = await getAliOSSSTSToken();
return {
AccessKeyId: info.AccessKeyId,
AccessKeySecret: info.AccessKeySecret,
SecurityToken: info.SecurityToken,
};
},
refreshSTSTokenInterval: 14 * 60 * 1000,
});
setOssClient(ossClientTemp);
console.log("AliOSS", ossClientTemp);
}
resolve();
});
};
useEffect(() => {
getStsAuthToken();
}, []);
const props = { const props = {
name: "file", name: "file",
accept: ".apk", accept: ".apk",
...@@ -99,36 +59,23 @@ const AppVersion = () => { ...@@ -99,36 +59,23 @@ const AppVersion = () => {
message.error(`只能上传以.apk格式的包,当前文件格式错误`); message.error(`只能上传以.apk格式的包,当前文件格式错误`);
return false; return false;
} }
setFile(file);
}, },
customRequest: async () => { customRequest: async ({ file }) => {
if (!ossClient) {
message.error("ossClient还未初始化", ossClient);
return false;
}
try { try {
setUploading(true); setUploading(true);
const fileExt = file.name.substring(file.name.lastIndexOf(".")); const imageUrl = await uploadFile(file, { bucket: "zxts-common-file", filenamePrefix: "app" });
const fileName = `app-${dayjs().valueOf()}-${Math.random().toString(36).substring(2)}${fileExt}`;
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`;
const result = await ossClient.put(filePath, file);
console.log("result", result);
// 假设 result 中包含了文件的 URL
const imageUrl = result.url;
// 更新图片 URL 状态 // 更新图片 URL 状态
setImg(imageUrl); setImg(imageUrl);
form.setFieldsValue({ url: imageUrl }); form.setFieldsValue({ url: imageUrl });
setUploading(false);
// 上传成功,返回 true 表示可以继续上传
return true; return true;
} catch (error) { } catch (error) {
// 上传失败,处理错误 // 上传失败,处理错误
console.error("上传文件失败:", error); console.error("上传文件失败:", error);
message.error("上传文件失败,请重试"); message.error("上传文件失败,请重试");
return false; return false;
} finally {
setUploading(false);
} }
}, },
}; };
...@@ -203,6 +150,9 @@ const AppVersion = () => { ...@@ -203,6 +150,9 @@ const AppVersion = () => {
setData(list); setData(list);
setLoading(false); setLoading(false);
}; };
useEffect(() => {
void init();
}, []);
// 删除 // 删除
const handleDel = async () => { const handleDel = async () => {
const bool = await delVersion({ id: delid }); const bool = await delVersion({ id: delid });
......
...@@ -15,7 +15,7 @@ import { ...@@ -15,7 +15,7 @@ import {
} from "antd"; } from "antd";
const { TextArea } = Input; const { TextArea } = Input;
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
import { AppList } from "@/shared/components/AppList"; import { AppList } from "@/components/AppList";
import { getAppList, addApp, editApp, delApp, changeStatus, getAppInfo } from "../api"; import { getAppList, addApp, editApp, delApp, changeStatus, getAppInfo } from "../api";
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"; import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import Add from "@/assets/images/icon/add.png"; import Add from "@/assets/images/icon/add.png";
......
...@@ -6,7 +6,7 @@ import { useSelector } from "react-redux"; ...@@ -6,7 +6,7 @@ import { useSelector } from "react-redux";
import Add from "@/assets/images/icon/add.png"; import Add from "@/assets/images/icon/add.png";
import coupon from "@/assets/images/icon/coupon.png"; import coupon from "@/assets/images/icon/coupon.png";
import coupon2 from "@/assets/images/icon/coupon2.png"; import coupon2 from "@/assets/images/icon/coupon2.png";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import { deleteCoupon, getCouponConfig, setCouponConfig, type CouponRow } from "../api"; import { deleteCoupon, getCouponConfig, setCouponConfig, type CouponRow } from "../api";
import FormDrawer from "../components/FormDrawer"; import FormDrawer from "../components/FormDrawer";
......
import { DatePicker, Input, Popover } from "antd"; import { DatePicker, Input, Popover } from "antd";
import type { ColumnsType } from "antd/es/table"; import type { ColumnsType } from "antd/es/table";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList"; import { AppList, type AppListFilterItem } from "@/components/AppList";
import type { FeedbackRow } from "../api"; import type { FeedbackRow } from "../api";
import type { FeedbackFilterValue, FeedbackFilters } from "../query"; import type { FeedbackFilterValue, FeedbackFilters } from "../query";
......
...@@ -3,7 +3,7 @@ import qs from "qs"; ...@@ -3,7 +3,7 @@ import qs from "qs";
import { message } from "antd"; import { message } from "antd";
import { get } from "lodash-es"; import { get } from "lodash-es";
// oss // 仅为尚未删除的旧 pages 代码保留的兼容接口;现行模块统一使用 utils/oss。
const getAliOSSSTSToken = async (obj) => { const getAliOSSSTSToken = async (obj) => {
try { try {
const res = await axios.post("/common/OssUpload/getUploadToken", qs.stringify(obj)); const res = await axios.post("/common/OssUpload/getUploadToken", qs.stringify(obj));
...@@ -12,12 +12,12 @@ const getAliOSSSTSToken = async (obj) => { ...@@ -12,12 +12,12 @@ const getAliOSSSTSToken = async (obj) => {
const msg = get(res, "data.message", "获取OSS令牌失败!"); const msg = get(res, "data.message", "获取OSS令牌失败!");
if (code === 200) { if (code === 200) {
return data; return data;
} else {
message.error(msg);
return false;
} }
} catch (error) { message.error(msg);
return false;
} catch {
return false; return false;
} }
}; };
export { getAliOSSSTSToken }; export { getAliOSSSTSToken };
...@@ -7,11 +7,8 @@ import { DomEditor } from "@wangeditor/editor"; ...@@ -7,11 +7,8 @@ import { DomEditor } from "@wangeditor/editor";
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
import { CloseOutlined } from "@ant-design/icons"; import { CloseOutlined } from "@ant-design/icons";
import { addHelp, HelpInfo, editHelp, editPreview } from "../api"; import { addHelp, HelpInfo, editHelp, editPreview } from "../api";
import { uploadFiles } from "@/utils/upload";
import AliOSS from "ali-oss";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { getAliOSSSTSToken } from "../request"; import { uploadFile } from "@/utils/oss";
import "./HelpAddEditView.less"; import "./HelpAddEditView.less";
...@@ -45,41 +42,6 @@ const HelpAddEdit = () => { ...@@ -45,41 +42,6 @@ const HelpAddEdit = () => {
} }
}, []); }, []);
const [ossClient, setOssClient] = useState(null); // oss 操作
const [STSToken, setSTSToken] = useState(null); // oss 过期设置
let ossClientTemp;
const getStsAuthToken = () => {
return new Promise(async (resolve) => {
const data = await getAliOSSSTSToken();
if (data) {
window.sessionStorage.setItem("sts", JSON.stringify(data));
setSTSToken(data);
ossClientTemp = await new AliOSS({
accessKeyId: data.AccessKeyId,
accessKeySecret: data.AccessKeySecret,
stsToken: data.SecurityToken,
endpoint: data.endpoint,
bucket: "zxts-book-file",
timeout: 180000,
refreshSTSToken: async () => {
const info = await getAliOSSSTSToken();
return {
AccessKeyId: info.AccessKeyId,
AccessKeySecret: info.AccessKeySecret,
SecurityToken: info.SecurityToken,
};
},
refreshSTSTokenInterval: 14 * 60 * 1000,
});
setOssClient(ossClientTemp);
console.log("AliOSS", ossClientTemp);
}
resolve();
});
};
// editor 实例 // editor 实例
const [editor, setEditor] = useState(null); const [editor, setEditor] = useState(null);
...@@ -151,18 +113,8 @@ const HelpAddEdit = () => { ...@@ -151,18 +113,8 @@ const HelpAddEdit = () => {
base64LimitSize: 5 * 1024, // 5kb 以下插入 base64 // 用户自定义上传图片 base64LimitSize: 5 * 1024, // 5kb 以下插入 base64 // 用户自定义上传图片
customUpload: async (file, insertFn) => { customUpload: async (file, insertFn) => {
setLoading(true); setLoading(true);
if (!ossClient) { const url = await uploadFile(file, { filenamePrefix: "help" });
console.error("ossClient还未初始化", ossClient); insertFn(url, "题库图片");
return false;
}
const fileExt = file.name.substring(file.name.lastIndexOf("."));
const fileName = `help-${dayjs().valueOf()}-${Math.random().toString(36).substring(2)}${fileExt}`;
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`;
const result = await ossClient.put(filePath, file);
console.log("result", result);
insertFn(result.url, "题库图片");
// const { url } = await uploadFiles({ file, file_type: 'question' }); // const { url } = await uploadFiles({ file, file_type: 'question' });
// insertFn(url, '题库图片'); // insertFn(url, '题库图片');
...@@ -183,10 +135,6 @@ const HelpAddEdit = () => { ...@@ -183,10 +135,6 @@ const HelpAddEdit = () => {
customUpload: async (file, insertFn) => { customUpload: async (file, insertFn) => {
setLoading(true); setLoading(true);
if (!ossClient) {
console.error("ossClient还未初始化", ossClient);
return false;
}
// const multipartOptions = { // const multipartOptions = {
// partSize: 1024 * 1024, // 分片大小 // partSize: 1024 * 1024, // 分片大小
...@@ -201,14 +149,9 @@ const HelpAddEdit = () => { ...@@ -201,14 +149,9 @@ const HelpAddEdit = () => {
// message.success(`视频已上传:${uploadProgress}% , 玩命加载中~~`); // message.success(`视频已上传:${uploadProgress}% , 玩命加载中~~`);
message.success(`视频已上传:99% , 正玩命加载中~~`); message.success(`视频已上传:99% , 正玩命加载中~~`);
const fileExt = file.name.substring(file.name.lastIndexOf(".")); const url = await uploadFile(file, { filenamePrefix: "help" });
const fileName = `help-${dayjs().valueOf()}-${Math.random().toString(36).substring(2)}${fileExt}`;
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`;
const result = await ossClient.put(filePath, file);
console.log("result", result);
// insertFn(result.res.requestUrls[0], '题库视频'); // insertFn(result.res.requestUrls[0], '题库视频');
insertFn(result.url, "题库视频"); insertFn(url, "题库视频");
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
console.error("上传视频失败:", error); console.error("上传视频失败:", error);
...@@ -219,9 +162,6 @@ const HelpAddEdit = () => { ...@@ -219,9 +162,6 @@ const HelpAddEdit = () => {
}, },
}; };
useEffect(() => {
getStsAuthToken();
}, []);
//添加、修改 //添加、修改
const onFinish = async (obj) => { const onFinish = async (obj) => {
...@@ -336,8 +276,7 @@ const HelpAddEdit = () => { ...@@ -336,8 +276,7 @@ const HelpAddEdit = () => {
mode="default" mode="default"
style={{ borderBottom: "1px solid #ccc" }} style={{ borderBottom: "1px solid #ccc" }}
/> />
{ossClient && ( <Editor
<Editor
defaultConfig={editorConfig} defaultConfig={editorConfig}
value={html} // 使用 html 作为 value 属性的值 value={html} // 使用 html 作为 value 属性的值
onCreated={setEditor} onCreated={setEditor}
...@@ -348,7 +287,6 @@ const HelpAddEdit = () => { ...@@ -348,7 +287,6 @@ const HelpAddEdit = () => {
mode="default" mode="default"
style={{ height: "500px", overflowY: "hidden" }} style={{ height: "500px", overflowY: "hidden" }}
/> />
)}
</div> </div>
</Form.Item> </Form.Item>
<Form.Item style={{ textAlign: "center" }}> <Form.Item style={{ textAlign: "center" }}>
......
...@@ -15,7 +15,7 @@ import { useNavigate } from "react-router"; ...@@ -15,7 +15,7 @@ import { useNavigate } from "react-router";
import Add from "@/assets/images/icon/add.png"; import Add from "@/assets/images/icon/add.png";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { AppList } from "@/shared/components/AppList"; import { AppList } from "@/components/AppList";
import { useHelpList } from "../useHelpList"; import { useHelpList } from "../useHelpList";
const HelpTable = () => { const HelpTable = () => {
const navigate = useNavigate(); const navigate = useNavigate();
......
import { App, Button, Drawer, Form, Input, Result, Space, Upload, message } from "antd"; import { App, Button, Drawer, Form, Input, Result, Space, Upload, message } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AppList } from "@/shared/components/AppList"; import { AppList } from "@/components/AppList";
import { addWords, delWords, editWords, importWords } from "../api"; import { addWords, delWords, editWords, importWords } from "../api";
import { useSensitiveList } from "../useSensitiveList"; import { useSensitiveList } from "../useSensitiveList";
......
...@@ -17,7 +17,7 @@ import { ...@@ -17,7 +17,7 @@ import {
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { AppList } from '@/shared/components/AppList' import { AppList } from '@/components/AppList'
import { getTeacherRoles } from '@/modules/access/role/api' import { getTeacherRoles } from '@/modules/access/role/api'
import add from '@/assets/images/icon/add.png' import add from '@/assets/images/icon/add.png'
......
...@@ -372,7 +372,7 @@ const Audit = () => { ...@@ -372,7 +372,7 @@ const Audit = () => {
open={priviewVisible} open={priviewVisible}
header={null} header={null}
footer={null} footer={null}
destroyOnClose destroyOnHidden
keyboard={false} keyboard={false}
closeIcon={<CloseOutlined style={{ fontSize: 20, color: "#fff" }} />} closeIcon={<CloseOutlined style={{ fontSize: 20, color: "#fff" }} />}
maskClosable={false} maskClosable={false}
......
...@@ -672,7 +672,7 @@ const Audit = () => { ...@@ -672,7 +672,7 @@ const Audit = () => {
/> />
<Drawer <Drawer
placement="right" placement="right"
destroyOnClose destroyOnHidden
onClose={() => setShowModal(false)} onClose={() => setShowModal(false)}
open={showModal} open={showModal}
mask={false} mask={false}
......
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import AppVersionListView from "@/modules/settings/app-version/views/AppVersionListView";
const { getList } = vi.hoisted(() => ({
getList: vi.fn().mockResolvedValue({ total: 0, list: [] }),
}));
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
vi.mock("@/modules/settings/app-version/api", () => ({
addVersion: vi.fn(),
delVersion: vi.fn(),
editVersion: vi.fn(),
getList,
infoVersion: vi.fn(),
}));
vi.mock("react-redux", () => ({
useSelector: (selector: (state: { user: { operationPermissionsList: string[] } }) => unknown) =>
selector({ user: { operationPermissionsList: [] } }),
}));
describe("AppVersionListView", () => {
it("挂载时请求首个版本列表页面", async () => {
render(<AppVersionListView />);
await waitFor(() => {
expect(getList).toHaveBeenCalledWith({ page: 1, page_size: 10 });
});
});
});
import axios from "axios"; import axios from "axios";
import OSS from "ali-oss"; import OSS from "ali-oss";
import dayjs from "dayjs";
import { getSTSToken } from "@/api/base"; import { getSTSToken } from "@/api/base";
let ossInstance = null; const clients = new Map();
// 获取STS Token的包装函数
async function getOSSConfig() { async function getOSSConfig() {
try { const { data } = await getSTSToken();
const { data } = await getSTSToken(); return {
return { ...data,
...data, accessKeyId: data.AccessKeyId,
accessKeyId: data.AccessKeyId, accessKeySecret: data.AccessKeySecret,
accessKeySecret: data.AccessKeySecret, stsToken: data.SecurityToken,
stsToken: data.SecurityToken, };
};
} catch (error) {
handleError(error, "Failed to retrieve STS token");
}
} }
// 创建OSS实例 export async function getOSSClient(bucket = "zxts-book-file") {
async function createOSSInstance() { if (clients.has(bucket)) return clients.get(bucket);
if (ossInstance) {
return ossInstance; const config = await getOSSConfig();
} const client = new OSS({
try { bucket,
const config = await getOSSConfig(); ...config,
ossInstance = new OSS({ timeout: 180000,
bucket: "zxts-book-file", refreshSTSToken: () => getOSSConfig(),
...config, refreshSTSTokenInterval: 14 * 60 * 1000,
refreshSTSToken: async () => { });
return await getOSSConfig(); clients.set(bucket, client);
}, return client;
refreshSTSTokenInterval: 14 * 60 * 1000,
});
return ossInstance;
} catch (error) {
handleError(error, "Failed to create OSS instance");
}
} }
// 上传文件 function getExtension(file) {
export async function uploadFile(file) { const name = typeof file.name === "string" ? file.name : "";
try { const fromName = name.includes(".") ? `.${name.split(".").pop().toLowerCase()}` : "";
const oss = await createOSSInstance(); if (fromName) return fromName;
const fileName = typeof file.name === "string" ? file.name : ""; return { "image/jpeg": ".jpg", "image/png": ".png", "video/mp4": ".mp4" }[(file.type || "").toLowerCase()] || ".bin";
const lastDotIndex = fileName.lastIndexOf("."); }
const extFromName = lastDotIndex > -1 ? fileName.slice(lastDotIndex).toLowerCase() : "";
const mimeType = (file.type || "").toLowerCase();
const mimeExtMap = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"audio/mpeg": ".mp3",
"audio/wav": ".wav",
"audio/ogg": ".ogg",
"audio/mp4": ".m4a",
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/ogg": ".ogv",
"video/quicktime": ".mov",
};
const fileExt = extFromName || mimeExtMap[mimeType] || ".bin"; function buildObjectName(
const generatedFileName = `${new Date().getTime()}-${Math.random() * 1000}${fileExt}`; file,
const result = await oss.put(generatedFileName, file); { pathPrefix = dayjs().format("YYYY/MM/DD"), filenamePrefix = "file" } = {},
return result.url; ) {
} catch (error) { const filename = `${filenamePrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}${getExtension(file)}`;
handleError(error, "Failed to upload file"); return pathPrefix ? `${pathPrefix.replace(/\/$/, "")}/${filename}` : filename;
}
} }
// 上传通过URL获取的文件 export async function uploadFile(file, options = {}) {
export async function uploadFileByUrl(url) { const oss = await getOSSClient(options.bucket);
try { const result = await oss.put(buildObjectName(file, options), file);
url = url.replace( return result.url;
"https://ark-content-generation-cn-beijing.tos-cn-beijing.volces.com",
"/api/volcano_file",
);
const res = await axios.get(url, { responseType: "blob" });
return await uploadFile(res.data);
} catch (error) {
handleError(error, "Failed to upload file from URL");
}
} }
// 通用错误处理函数 export async function uploadFileByUrl(url, options = {}) {
function handleError(error, message) { const proxyUrl = url.replace("https://ark-content-generation-cn-beijing.tos-cn-beijing.volces.com", "/api/volcano_file");
console.error(message, error); const response = await axios.get(proxyUrl, { responseType: "blob" });
throw new Error(`${message}: ${error.message}`); return uploadFile(response.data, options);
} }
# Admin AppList 操作统一实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
**Goal:** 只保留一个受控 AppList,并将公共筛选操作统一为带图标的“搜索 / 重置”。
**Architecture:** shared/components/AppList 是唯一公共列表实现。旧 coupon 页面改由现有 modules/settings/coupon/CouponListView 承接,然后删除旧 components/list。现有自绘筛选条只修改按钮文案和图标,不变更请求回调。
**Tech Stack:** React 18、Ant Design 5、@ant-design/icons、Vitest、Vite。
## Global Constraints
- 公共筛选区只显示搜索和重置。
- 搜索使用 SearchOutlined;重置使用 ReloadOutlined。
- 不删除 filters.onRefresh,业务内部刷新、mutation 后刷新和错误重试保持原状。
- 不修改搜索参数、请求时机、分页和业务工具栏。
- common/Pagination 不在本次删除范围。
---
### Task 1: 统一公共 AppList 操作
**Files:**
- Modify: apps/admin/src/shared/components/AppList/AppList.tsx
- Modify: apps/admin/src/shared/components/AppList/AppList.test.tsx
- [ ] 将 Ant Design 图标导入为 SearchOutlined 与 ReloadOutlined。
- [ ] 将公共筛选操作改为:
~~~tsx
<Button type="primary" icon={<SearchOutlined />} onClick={filters.onSearch}>
搜索
</Button>
<Button icon={<ReloadOutlined />} onClick={filters.onReset}>
重置
</Button>
~~~
- [ ] 删除公共刷新按钮;不删除 filters.onRefresh 类型字段。
- [ ] 将现有组件测试更新为只断言搜索与重置回调,并断言不存在刷新按钮。
- [ ] 运行:
~~~bash
pnpm --filter @ebook/admin test:unit --run src/shared/components/AppList/AppList.test.tsx
~~~
预期:AppList 测试通过。
### Task 2: 统一遗留自绘筛选条并删除旧 AppList
**Files:**
- Modify: apps/admin/src/modules/teachers/list/views/TeacherListView.jsx
- Modify: apps/admin/src/modules/settings/help/views/HelpListView.jsx
- Modify: apps/admin/src/modules/settings/applied/views/AppliedListView.jsx
- Modify: apps/admin/src/modules/settings/app-version/views/AppVersionListView.jsx
- Modify: apps/admin/src/app/routes.tsx or configuration compatibility entry that resolves old coupon
- Delete: apps/admin/src/components/list/index.jsx
- [ ] 为四个自绘操作条的搜索、重置按钮添加同样的 Ant Design 图标;删除刷新按钮,不改搜索和重置回调。
- [ ] 将旧 coupon 路径解析到 modules/settings/coupon/CouponListView,确保不再运行 pages/setting/coupon/index.jsx。
- [ ] 用 rg 确认 src 内没有 components/list 的运行时导入。
- [ ] 删除旧 components/list/index.jsx。
- [ ] 运行:
~~~bash
pnpm --filter @ebook/admin test
pnpm --filter @ebook/admin build
~~~
预期:11 个 Node 测试通过,production build 成功。
# Admin Modular Architecture and AppList Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
**Goal:** 在不改变 URL、权限、接口协议和页面行为的前提下,把 Admin 重构为顶级业务域聚合路由、二级业务模块拥有代码的结构,并用受控 AppList 统一标准业务表格。
**Architecture:** app/routes.tsx 只懒加载 books、members、settings 等顶级业务域;业务域 routes.tsx 聚合二级模块;management、audit、sale 等模块拥有 api.ts、query.ts、routes.tsx、状态、components 和 views。AppList 仅负责受控展示,请求与业务状态留在模块内。
**Tech Stack:** React 18.3、React Router 7.18、TypeScript 7、Vite 8、Ant Design 5、Vitest、Testing Library、pnpm。
## Global Constraints
- 唯一设计基线:docs/superpowers/specs/2026-07-20-admin-modular-app-list-design.md。
- 不创建 api、query、routes 文件夹;使用 api.ts、query.ts、routes.tsx 文件。
- 不改变公开 URL、路由 state、query 参数、权限判断和菜单行为。
- 不改变后端接口、请求方法、content type、签名方式和返回值语义。
- 不在本计划中统一 utils/axios.js 与 utils/request.js。
- 迁移页面时先原样移动,再逐层抽取;禁止简化或重写业务功能。
- AppList 不调用 API,不读取业务 Store,不拥有筛选和分页状态。
- point-management、编辑器、树和非列表详情不接入 AppList。
- 旧页面通过验证前不得删除。
- 每个任务只提交当前任务文件,禁止全仓格式化。
- 全局历史 lint 和 TypeScript 问题以基线为准;变更文件必须通过检查,错误总数不得增加。
- 每完成一个任务,更新本计划对应复选框,并在继续前检查 git diff。
---
## Task 1:冻结现状与建立迁移清单
**Files:**
- Create: docs/superpowers/migrations/admin-route-contract.md
- Create: docs/superpowers/migrations/admin-page-inventory.md
- Create: apps/admin/src/app/route-manifest.ts
- Create: apps/admin/src/app/route-manifest.test.mjs
- Modify: apps/admin/package.json
- [x] 1. 从当前 app/routes.tsx 提取公开 URL 清单。
route-manifest.ts 只导出静态字符串,不导入 React 页面:
~~~ts
export const adminPublicRoutes = [
"/books/management",
"/books/management/add-edit",
"/books/management/chapter",
"/books/audit",
"/books/audit/detail",
"/books/classify",
"/books/label",
"/books/sale",
"/books/sale/detail",
"/books/sale/discussP",
"/books/sale/discuss-detail",
"/books/question-bank",
"/books/discussion",
"/books/order-management",
"/books/order-management/sale-statistics",
"/books/design",
"/member/list",
"/member/list/query",
"/member/list/detail",
"/member/userlevel",
"/advertisement/advert",
"/advertisement/adsense",
"/setting/help",
"/setting/help/addedit",
"/setting/applied",
"/setting/personalized-setting",
"/setting/point-management",
"/setting/app-version",
"/setting/coupon",
"/setting/sensitive",
"/setting/feedback",
"/jurisdiction/admin",
"/jurisdiction/role",
"/teacher/list",
"/userinfo",
"/editor",
"/login",
"/student/book"
] as const;
~~~
- [x] 2. 用 Node 测试断言路由清单不重复、全部以斜杠开头,并锁定数量。
- [x] 3. admin-page-inventory.md 为每个页面记录:旧路径、目标模块、API 文件、表格类型、权限点、弹窗/抽屉、上传/预览、mutation、路由 state。
- [x] 4. admin-route-contract.md 记录每个 URL 的当前组件和目标模块。
- [x] 5. package.json 的 test 脚本加入 route-manifest.test.mjs。
- [x] 6. 运行基线命令并把结果写入 inventory 文末。
~~~bash
cd apps/admin
pnpm test
pnpm build
pnpm exec tsc --noEmit
pnpm exec oxlint src --deny-warnings
~~~
预期:现有 Node 测试和 build 通过;TypeScript 和 oxlint 的历史失败须原样记录,不在本任务修复。
- [x] 7. 提交。
~~~bash
git add apps/admin/src/app/route-manifest.ts apps/admin/src/app/route-manifest.test.mjs apps/admin/package.json docs/superpowers/migrations
git commit -m "test(admin): freeze route and page migration contracts"
~~~
---
## Task 2:建立组件测试环境
**Files:**
- Modify: apps/admin/package.json
- Create: apps/admin/vitest.config.ts
- Create: apps/admin/src/test/setup.ts
- Create: apps/admin/src/test/smoke.test.tsx
- [x] 1. 安装测试依赖。
~~~bash
pnpm --filter @ebook/admin add -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
~~~
- [x] 2. 在 package.json 增加 test:unit 和 test:unit:watch,保留现有 test。
- [x] 3. 配置 jsdom、setupFiles、CSS 处理和项目 alias。
- [x] 4. 先写一个渲染按钮并点击的 smoke test。
- [x] 5. 运行测试。
~~~bash
cd apps/admin
pnpm test
pnpm test:unit --run
~~~
预期:两套测试均为 0 exit code。
- [x] 6. 提交。
~~~bash
git add apps/admin/package.json apps/admin/vitest.config.ts apps/admin/src/test pnpm-lock.yaml
git commit -m "test(admin): add component test harness"
~~~
---
## Task 3:实现受控 AppList
**Files:**
- Create: apps/admin/src/shared/components/AppList/types.ts
- Create: apps/admin/src/shared/components/AppList/AppList.tsx
- Create: apps/admin/src/shared/components/AppList/AppList.less
- Create: apps/admin/src/shared/components/AppList/AppList.test.tsx
- Create: apps/admin/src/shared/components/AppList/index.ts
- [x] 1. 先写失败测试,覆盖:
- rows、columns 与显式 rowKey。
- 受控筛选值。
- 搜索、重置、刷新。
- page、pageSize 与 total。
- loading、empty、error、retry。
- toolbar 与 rowSelection。
- 0、false 不被清理。
- [x] 2. 运行测试确认失败原因是组件尚不存在。
~~~bash
cd apps/admin
pnpm test:unit --run src/shared/components/AppList/AppList.test.tsx
~~~
- [x] 3. 按设计文档的 AppListProps 实现受控组件。
- [x] 4. 筛选项使用 render 回调支持 Input、Select、DatePicker 和自定义控件;AppList 不解释业务字段。
- [x] 5. 搜索、重置、刷新只能调用回调,不能自动发请求。
- [x] 6. 分页 current、pageSize、total 全部来自 props。
- [x] 7. 错误态提供重试按钮;loading 和 error 的优先级写入测试。
- [x] 8. 不导入 utils/axios、utils/request、Redux、Zustand 或 react-router。
- [x] 9. 运行单测和变更文件检查。
~~~bash
pnpm test:unit --run src/shared/components/AppList/AppList.test.tsx
pnpm exec oxlint src/shared/components/AppList
pnpm exec oxfmt --check src/shared/components/AppList
~~~
- [x] 10. 提交。
~~~bash
git add apps/admin/src/shared/components/AppList
git commit -m "feat(admin): add controlled AppList component"
~~~
---
## Task 4:建立两级路由边界
**Files:**
- Modify: apps/admin/src/app/routes.tsx
- Create: apps/admin/src/app/ModuleBoundary.tsx
- Create: apps/admin/src/app/RouteErrorBoundary.tsx
- Create: apps/admin/src/modules/books/routes.tsx
- Create: apps/admin/src/modules/members/routes.tsx
- Create: apps/admin/src/modules/advertisements/routes.tsx
- Create: apps/admin/src/modules/settings/routes.tsx
- Create: apps/admin/src/modules/access/routes.tsx
- Create: apps/admin/src/modules/teachers/routes.tsx
- Create: apps/admin/src/modules/auth/routes.tsx
- Create: apps/admin/src/modules/account/routes.tsx
- Create: apps/admin/src/modules/editor/routes.tsx
- Create: apps/admin/src/modules/student-book/routes.tsx
- Create: apps/admin/src/app/routes.boundary.test.mjs
- [x] 1. 写静态边界测试:
- app/routes.tsx 可以导入 modules/books/routes,但不能包含 modules/books/management/views。
- 每个顶级模块必须存在 routes.tsx。
- 根路由只包含顶级路径入口。
- [x] 2. app/routes.tsx 使用 React.lazy 导入顶级 routes。
- [x] 3. 各顶级 routes.tsx 暂时懒加载原 pages 页面,不移动业务代码。
- [x] 4. 顶级 routes 使用相对路径,保持完整 URL 不变。
- [x] 5. 页面需要 props 时增加无参数路由适配组件,不能用类型断言掩盖问题。
- [x] 6. ModuleBoundary 提供局部 Suspense;RouteErrorBoundary 提供重试或返回入口。
- [x] 7. 验证路由清单与 production build。
~~~bash
cd apps/admin
pnpm test
pnpm build
~~~
- [x] 8. 检查构建产物中 books、settings 等顶级域和页面仍有独立 chunk。
- [x] 9. 提交。
~~~bash
git add apps/admin/src/app apps/admin/src/modules
git commit -m "refactor(admin): add domain route boundaries"
~~~
---
## Task 5:试点迁移 settings/feedback
**Files:**
- Move: apps/admin/src/pages/setting/feedback 对应实现
- Create: apps/admin/src/modules/settings/feedback/api.ts
- Create: apps/admin/src/modules/settings/feedback/query.ts
- Create: apps/admin/src/modules/settings/feedback/useFeedbackList.ts
- Create: apps/admin/src/modules/settings/feedback/views/FeedbackListView.tsx
- Create: apps/admin/src/modules/settings/feedback/routes.tsx
- Create: apps/admin/src/modules/settings/feedback/query.test.ts
- Create: apps/admin/src/modules/settings/feedback/useFeedbackList.test.tsx
- Modify: apps/admin/src/modules/settings/routes.tsx
- [ ] 1. 在 inventory 中补全旧 feedback 的接口、参数、列、操作和权限。
- [ ] 2. 为 query.ts 先写测试,逐字段锁定旧参数;明确保留 0 和 false。
- [ ] 3. 为列表 Hook 先写测试:首次加载、搜索回第一页、翻页、失败结束 loading、重试、竞态。
- [ ] 4. 原样移动旧页面代码到 feedback 模块,先只修复导入路径。
- [ ] 5. 让 settings/routes.tsx 指向新 View,运行页面冒烟。
- [ ] 6. 把请求原样提取到 api.ts;await 请求后再读取返回值。
- [ ] 7. 把查询映射提取到 query.ts。
- [ ] 8. 使用 useFeedbackList 拥有请求和状态。
- [ ] 9. 用新受控 AppList 替换旧请求型 AppList;保留所有列、权限和操作。
- [ ] 10. 验证搜索、重置、刷新、分页、错误和空态。
~~~bash
cd apps/admin
pnpm test:unit --run src/modules/settings/feedback
pnpm test
pnpm build
~~~
- [ ] 11. 确认通过后删除 feedback 的旧页面文件。
- [ ] 12. 提交移动与改造,确保 git 能识别 rename。
~~~bash
git add apps/admin/src/modules/settings/feedback apps/admin/src/modules/settings/routes.tsx apps/admin/src/pages/setting/feedback
git commit -m "refactor(admin): migrate settings feedback module"
~~~
---
## Task 6:第二试点 advertisements/adsense
**Files:**
- Move: apps/admin/src/pages/advertisement/adsense 对应实现
- Create: apps/admin/src/modules/advertisements/adsense/api.ts
- Create: apps/admin/src/modules/advertisements/adsense/query.ts
- Create: apps/admin/src/modules/advertisements/adsense/useAdsenseList.ts
- Create: apps/admin/src/modules/advertisements/adsense/views/AdsenseListView.tsx
- Create: apps/admin/src/modules/advertisements/adsense/routes.tsx
- Create: apps/admin/src/modules/advertisements/adsense/query.test.ts
- Create: apps/admin/src/modules/advertisements/adsense/useAdsenseList.test.tsx
- Modify: apps/admin/src/modules/advertisements/routes.tsx
- [ ] 1. 先写参数与状态测试,再移动页面。
- [ ] 2. 保留原表格列、状态展示、操作按钮、权限和刷新逻辑。
- [ ] 3. 抽取 api.ts、query.ts 和 useAdsenseList。
- [ ] 4. 接入受控 AppList。
- [ ] 5. 验证 109 行旧实现中的每项行为均在新页面存在。
- [ ] 6. 测试和构建通过后删除旧文件并提交。
~~~bash
cd apps/admin
pnpm test:unit --run src/modules/advertisements/adsense
pnpm test
pnpm build
git add src/modules/advertisements src/pages/advertisement/adsense
git commit -m "refactor(admin): migrate advertisements adsense module"
~~~
---
## Task 7:第三试点 settings/coupon 并冻结 AppList v1
**Files:**
- Move: apps/admin/src/pages/setting/coupon 对应实现
- Create: apps/admin/src/modules/settings/coupon/api.ts
- Create: apps/admin/src/modules/settings/coupon/query.ts
- Create: apps/admin/src/modules/settings/coupon/useCouponList.ts
- Create: apps/admin/src/modules/settings/coupon/components/
- Create: apps/admin/src/modules/settings/coupon/views/CouponListView.tsx
- Create: apps/admin/src/modules/settings/coupon/routes.tsx
- Create: apps/admin/src/modules/settings/coupon/query.test.ts
- Create: apps/admin/src/modules/settings/coupon/useCouponList.test.tsx
- Modify: apps/admin/src/modules/settings/routes.tsx
- Modify if evidence requires: apps/admin/src/shared/components/AppList/
- Create: docs/superpowers/migrations/app-list-v1-review.md
- [ ] 1. 锁定优惠券筛选、分页、状态、操作、权限和异常行为。
- [ ] 2. 先写 query 和 Hook 失败测试。
- [ ] 3. 原样移动,再分离 API、query 和状态。
- [ ] 4. 接入 AppList,业务按钮和 mutation 留在 coupon。
- [ ] 5. 对三个试点做复盘,只记录真实重复点。
- [ ] 6. 如三处确实需要相同能力,才调整 AppList;不得抽取业务字段。
- [ ] 7. app-list-v1-review.md 记录保留、修改和拒绝加入的能力。
- [ ] 8. 测试、构建、冒烟后删除旧文件并提交。
~~~bash
cd apps/admin
pnpm test:unit --run src/modules/settings/coupon src/shared/components/AppList
pnpm test
pnpm build
git add src/modules/settings/coupon src/modules/settings/routes.tsx src/shared/components/AppList src/pages/setting/coupon ../../docs/superpowers/migrations/app-list-v1-review.md
git commit -m "refactor(admin): migrate coupon and stabilize AppList"
~~~
---
## Task 8:迁移 settings 剩余模块
**Files:**
- Move into modules/settings: help、applied、personalized、point-management、app-version、sensitive
- Modify: apps/admin/src/modules/settings/routes.tsx
- Create per list module as needed: api.ts、query.ts、useFeatureList.ts、routes.tsx、components、views、tests
- [ ] 1. 每个二级模块单独建立 inventory 行和单独提交。
- [ ] 2. 迁移顺序:help、applied、app-version、sensitive、personalized、point-management。
- [ ] 3. help 同时迁移列表和 addedit 路由,保留路由 state。
- [ ] 4. applied、app-version、sensitive 的标准表格接入 AppList。
- [ ] 5. personalized 按真实页面类型决定;不是标准表格则保留专用布局。
- [ ] 6. point-management 保持表单,不接入 AppList。
- [ ] 7. 每个模块执行:失败测试、原样移动、切路由、抽 API/query、接 AppList、验证、删旧文件、提交。
每个模块验证:
~~~bash
cd apps/admin
pnpm test:unit --run src/modules/settings/模块名
pnpm test
pnpm build
~~~
提交格式:
~~~bash
git commit -m "refactor(admin): migrate settings 模块名 module"
~~~
---
## Task 9:迁移 advertisements 与 access
**Files:**
- Move: advertisement/advert -> modules/advertisements/advert
- Move: jurisdiction/admin -> modules/access/admin
- Move: jurisdiction/role -> modules/access/role
- Modify: modules/advertisements/routes.tsx
- Modify: modules/access/routes.tsx
- Create each module's api.ts、query.ts、list Hook、components、views、tests as needed
- [ ] 1. 先迁移 advert,保留上传、预览、编辑、状态切换和权限。
- [ ] 2. 再迁移 access/admin,保留账号操作、弹窗、权限和状态。
- [ ] 3. 最后迁移 access/role,保留权限树、授权和所有 mutation。
- [ ] 4. access 的权限树是业务组件,不放入 AppList。
- [ ] 5. 三个模块分别测试、构建、人工冒烟和提交。
- [ ] 6. 不共享 admin 与 role 的私有实现;只有无业务语义的重复才能进入 shared。
---
## Task 10:迁移 members
**Files:**
- Move: member/list -> modules/members/list
- Move: member/userlevel -> modules/members/level
- Modify: modules/members/routes.tsx
- Create: modules/members/list/api.ts
- Create: modules/members/list/query.ts
- Create: modules/members/list/useMemberList.ts
- Create: modules/members/list/components/
- Create: modules/members/list/views/
- Create equivalent level files and tests
- [ ] 1. list 模块同时拥有列表、query、detail 和详情内记录组件。
- [ ] 2. 保留列表到详情的路由 state、返回行为和筛选状态。
- [ ] 3. 主列表接入 AppList。
- [ ] 4. CouponRecord、IntegralRecord、orderRecord 先原样迁移;只有确实需要完整列表交互时才接入 AppList。
- [ ] 5. level 保留所有编辑、删除、排序或状态行为。
- [ ] 6. 分别建立 query、Hook 和 mutation 测试。
- [ ] 7. list 与 level 分两次提交。
---
## Task 11:迁移 teachers
**Files:**
- Move: teacher/list -> modules/teachers/list
- Modify: modules/teachers/routes.tsx
- Create: modules/teachers/list/api.ts
- Create: modules/teachers/list/query.ts
- Create: modules/teachers/list/useTeacherList.ts
- Create: modules/teachers/list/components/
- Create: modules/teachers/list/views/
- Create tests
- [ ] 1. 以原 700 行以上页面为行为事实来源。
- [ ] 2. 列出并保留所有权限、操作、弹窗、上传、预览和 mutation。
- [ ] 3. 原样移动并切换路由后,再抽取 API 与查询。
- [ ] 4. 仅替换列表框架,不改业务组件。
- [ ] 5. 测试、构建和人工冒烟通过后删除旧文件并提交。
---
## Task 12:迁移 books 低风险模块
**Files:**
- Move into modules/books: classify、label、discussion
- Modify: modules/books/routes.tsx
- Create each module's api.ts、query.ts、list Hook、components、views、tests as needed
- [ ] 1. 迁移顺序:classify、label、discussion。
- [ ] 2. classify 若包含树形编辑,树本身保留专用组件;只有右侧标准表格使用 AppList。
- [ ] 3. label 保留全部编辑、删除、排序和状态动作。
- [ ] 4. discussion 保留内容预览、筛选、审核或删除动作。
- [ ] 5. 一个模块一个提交,禁止三者混在同一 diff。
---
## Task 13:迁移 books/management
**Files:**
- Move: books management 列表、新增编辑、章节页面及私有组件
- Create: modules/books/management/api.ts
- Create: modules/books/management/query.ts
- Create: modules/books/management/useManagementList.ts
- Create: modules/books/management/routes.tsx
- Create: modules/books/management/components/
- Create: modules/books/management/views/
- Create tests
- Modify: modules/books/routes.tsx
- [ ] 1. 在 inventory 中逐项核对原列表约 641 行代码的行为。
- [ ] 2. management/routes.tsx 拥有列表、新增编辑、章节三个相对路由,不再创建 chapter 三级业务模块。
- [ ] 3. 原样移动页面和私有组件,先让所有路由可用。
- [ ] 4. 为列表查询、详情读取、保存、删除、状态动作分别建立 API 契约。
- [ ] 5. query.ts 只实现 management 的真实参数映射。
- [ ] 6. 主列表接入 AppList;新增编辑和章节页保持专用布局。
- [ ] 7. 验证权限、按钮、导航 state、上传、封面、章节操作、错误提示和返回。
- [ ] 8. 测试、构建和人工冒烟通过后删旧文件并提交。
---
## Task 14:迁移 books/audit
**Files:**
- Move: books audit 列表、详情和预览
- Create: modules/books/audit/api.ts
- Create: modules/books/audit/query.ts
- Create: modules/books/audit/useAuditList.ts
- Create: modules/books/audit/routes.tsx
- Create: modules/books/audit/components/
- Create: modules/books/audit/views/
- Create tests
- Modify: modules/books/routes.tsx
- [ ] 1. 锁定审核状态、权限、详情、通过、拒绝和预览行为。
- [ ] 2. 原样移动并验证路由 state。
- [ ] 3. API 测试覆盖通过、拒绝、失败和刷新。
- [ ] 4. 列表接入 AppList;详情和预览不强制使用。
- [ ] 5. 防止重复提交审核动作。
- [ ] 6. 完整验证后删除旧文件并单独提交。
---
## Task 15:迁移 books/sale
**Files:**
- Move: sale 列表、编辑、评论列表、评论详情及私有组件
- Create: modules/books/sale/api.ts
- Create: modules/books/sale/query.ts
- Create: modules/books/sale/useSaleList.ts
- Create: modules/books/sale/useSaleDiscussionList.ts
- Create: modules/books/sale/routes.tsx
- Create: modules/books/sale/components/
- Create: modules/books/sale/views/
- Create tests
- Modify: modules/books/routes.tsx
- [ ] 1. sale/routes.tsx 拥有 sale 全部子流程,不创建独立 discussion 业务域。
- [ ] 2. 分别锁定销售列表和评论列表的参数,不复用错误的通用日期转换。
- [ ] 3. 原样迁移编辑、评论详情、上下架和其他 mutation。
- [ ] 4. 两个标准列表分别接入 AppList,但使用各自 Hook。
- [ ] 5. 验证列表到详情、评论详情的 state 与返回行为。
- [ ] 6. 测试、构建和人工冒烟通过后删除旧文件并提交。
---
## Task 16:迁移 books/question-bank 与 orders
**Files:**
- Move into modules/books: question-bank、orders
- Create each module's api.ts、query.ts、routes.tsx、Hook、components、views、tests
- Modify: modules/books/routes.tsx
- [ ] 1. question-bank 保留题型、批量动作、导入导出、编辑和权限。
- [ ] 2. orders 同时拥有订单列表和销售统计路由。
- [ ] 3. orders/query.ts 精确使用现有 start_pay、end_pay 等字段,不套用 start_time、end_time。
- [ ] 4. 订单列表接入 AppList;销售统计图表或汇总使用原专用组件。
- [ ] 5. 两个模块分别测试和提交。
---
## Task 17:迁移 books/design 与剩余独立模块
**Files:**
- Move: books/design -> modules/books/design
- Move: login -> modules/auth
- Move: userinfo -> modules/account
- Move: editor -> modules/editor
- Move: student/book -> modules/student-book
- Modify each top-level routes.tsx
- Create api.ts、routes.tsx、components、views only where actually needed
- [ ] 1. 这些页面以原样移动和路由归属为主,不为满足目录模板创建空 query.ts。
- [ ] 2. design 与 editor 不接入 AppList。
- [ ] 3. login 保留认证、重定向和错误处理。
- [ ] 4. account 保留资料读取、保存和上传。
- [ ] 5. student-book 保留当前 URL、参数和访问边界。
- [ ] 6. 每个顶级模块独立验证和提交。
---
## Task 18:删除旧列表基础设施
**Files:**
- Delete after zero references: apps/admin/src/components/list/
- Delete after zero references: apps/admin/src/common/TableCom/
- Delete after zero references: apps/admin/src/common/Pagination/
- Delete migrated files under apps/admin/src/pages/
- Modify imports discovered by search
- [ ] 1. 搜索所有旧组件引用。
~~~bash
cd apps/admin
rg "components/list|common/TableCom|common/Pagination" src
~~~
- [ ] 2. 只有搜索结果为零时才删除对应组件。
- [ ] 3. 搜索所有 pages 导入和 app/routes.tsx 中的二级 View 导入。
~~~bash
rg "from.*pages|import.*pages|modules/.*/.*/views" src/app src/modules
~~~
- [ ] 4. 确认所有已迁移旧页面都没有入口和引用。
- [ ] 5. 删除旧文件后运行完整测试与构建。
- [ ] 6. 单独提交清理,不与功能迁移混合。
~~~bash
git add apps/admin/src
git commit -m "refactor(admin): remove migrated legacy list infrastructure"
~~~
---
## Task 19:架构守卫
**Files:**
- Create: apps/admin/src/architecture/module-boundaries.test.mjs
- Modify: apps/admin/package.json
- Modify: docs/superpowers/migrations/admin-page-inventory.md
- [ ] 1. 测试禁止 app 直接导入二级业务 View。
- [ ] 2. 测试禁止 shared 导入 modules。
- [ ] 3. 测试禁止跨顶级业务域导入内部文件。
- [ ] 4. 测试禁止目录名 api、query、routes 出现在 modules 内。
- [ ] 5. 测试每个顶级业务域存在 routes.tsx。
- [ ] 6. 测试所有 route manifest 路径仍被路由结构覆盖。
- [ ] 7. 将守卫加入 pnpm test。
- [ ] 8. 运行并提交。
~~~bash
cd apps/admin
pnpm test
git add src/architecture package.json ../../docs/superpowers/migrations/admin-page-inventory.md
git commit -m "test(admin): enforce module architecture boundaries"
~~~
---
## Task 20:最终验证与交付
**Files:**
- Modify: docs/superpowers/migrations/admin-page-inventory.md
- Create: docs/superpowers/migrations/admin-modular-final-verification.md
- [ ] 1. 运行完整自动验证。
~~~bash
cd apps/admin
pnpm test
pnpm test:unit --run
pnpm build
pnpm exec tsc --noEmit
pnpm exec oxlint src
pnpm exec oxfmt --check src
~~~
- [ ] 2. 对比迁移前基线:不得新增类型和 lint 错误。
- [ ] 3. 检查构建 chunk,确认顶级域和最终页面仍按需加载。
- [ ] 4. 人工冒烟所有 route-manifest 路径。
- [ ] 5. 高风险业务重点验证:
- books management:列表、新增编辑、章节。
- books audit:列表、详情、通过、拒绝、预览。
- books sale:列表、编辑、评论列表和详情。
- question-bank:编辑、批量、导入导出。
- orders:列表、日期参数、销售统计。
- members:列表、详情和记录。
- access:admin、role、权限树。
- teachers:全部操作、上传和预览。
- [ ] 6. 检查最终目录:
- books 聚合二级模块路由。
- management、audit、sale 各自拥有代码。
- 不存在 api、query、routes 文件夹。
- point-management 未接入 AppList。
- app/routes.tsx 不感知二级页面。
- shared 不依赖 modules。
- [ ] 7. final-verification.md 记录命令、结果、历史基线差异、人工冒烟结果和仍存在的非本次问题。
- [ ] 8. 最终提交仅包含验证文档。
~~~bash
git add docs/superpowers/migrations/admin-page-inventory.md docs/superpowers/migrations/admin-modular-final-verification.md
git commit -m "docs(admin): record modular migration verification"
~~~
## 执行停止条件
遇到以下任一情况,执行模型必须停止当前模块,不得继续批量迁移:
- 新页面缺少旧页面的按钮、列、弹窗、抽屉、上传、预览或 mutation。
- 公开 URL、query、route state 或返回行为变化。
- API 请求方法、参数名、content type 或响应解包方式无法确认。
- 测试或 build 出现迁移前没有的新失败。
- 需要修改 AppList 才能承载单个业务模块的特殊规则。
- 需要跨业务域导入另一个模块的内部实现。
- 无法判断某个旧文件是否仍有入口。
停止后应回到 inventory 和旧页面,以现有行为为证据补齐测试,再继续当前模块。不得通过删除测试、类型断言、吞异常或返回空数组绕过问题。
## 完成定义
只有同时满足以下条件,才可宣布本计划完成:
- 设计文档的全部验收标准满足。
- route manifest 中所有 URL 均通过自动或人工验证。
- 所有标准业务表格使用受控 AppList。
- 所有非表格页面保留专用实现。
- 所有模块都遵守顶级路由聚合和二级业务归属。
- 旧 pages 与旧列表组件已在零引用后删除。
- 完整测试和 production build 通过。
- TypeScript 与 lint 未新增基线问题。
- 最终验证文档包含可复核的命令结果。
# Admin OSS 上传统一 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将管理端所有浏览器直传 OSS 的实现收口到 `utils/oss.js`,同时维持原有 bucket 与对象路径。
**Architecture:** `utils/oss.js` 负责获取及刷新 STS、按 bucket 缓存 OSS 客户端、生成对象键并上传。页面仅保留文件校验、上传状态和 URL 回填;每个页面通过 `bucket` 与 `pathPrefix` 描述其存储目标。服务端代理上传 `utils/upload.js` 不改动。
**Tech Stack:** React 18、Ant Design 5、ali-oss、Vite、Vitest。
## Global Constraints
- 不修改后端代理上传 `apps/admin/src/utils/upload.js`。
- 不改变任一页面当前 bucket:书籍、题库、编辑器与帮助中心为 `zxts-book-file`,APP 包为 `zxts-common-file`,广告为 `zxts-ad-file`。
- 不自动提交。
---
### Task 1: 建立可配置的 OSS 上传工具
**Files:**
- Modify: `apps/admin/src/utils/oss.js`
- Create: `apps/admin/src/test/oss.test.ts`
**Interfaces:**
- Produces: `uploadFile(file, { bucket, pathPrefix, filenamePrefix }) => Promise<string>`。
- Produces: `uploadFileByUrl(url, options) => Promise<string>`。
- Produces: `getOSSClient(bucket) => Promise<AliOSSClient>`,仅供需要分片上传进度的通用编辑器工具调用。
- [ ] **Step 1: 编写失败测试**
模拟 `getSTSToken` 与 `ali-oss`,断言相同 bucket 只创建一个客户端、不同 bucket 创建独立客户端,并断言 `uploadFile` 返回 SDK 的 `result.url`。
- [ ] **Step 2: 运行测试确认失败**
Run: `pnpm --filter @ebook/admin test:unit --run src/test/oss.test.ts`
Expected: FAIL,因为当前 `uploadFile` 不接受 options 且只缓存单一客户端。
- [ ] **Step 3: 实现按 bucket 缓存的上传工具**
```js
export async function uploadFile(file, {
bucket = "zxts-book-file",
pathPrefix = "",
filenamePrefix = "file",
} = {}) {
const oss = await getOSSClient(bucket)
const objectName = buildObjectName(file, { pathPrefix, filenamePrefix })
return (await oss.put(objectName, file)).url
}
```
`getOSSClient(bucket)` 使用 `Map` 以 bucket 为键;`refreshSTSToken` 为当前 bucket 重新请求 token。`buildObjectName` 保留原扩展名、加入唯一随机名,并在传入 pathPrefix 时生成 `${pathPrefix}/${filename}`。
- [ ] **Step 4: 运行测试确认通过**
Run: `pnpm --filter @ebook/admin test:unit --run src/test/oss.test.ts`
Expected: PASS。
### Task 2: 迁移广告、帮助与 APP 安装包上传
**Files:**
- Modify: `apps/admin/src/modules/advertisements/advert/components/AdvertImageUpload.tsx`
- Modify: `apps/admin/src/modules/settings/help/views/HelpAddEditView.jsx`
- Modify: `apps/admin/src/modules/settings/app-version/views/AppVersionListView.jsx`
**Interfaces:**
- Consumes: Task 1 的 `uploadFile`。
- Uses: 广告 `{ bucket: "zxts-ad-file", pathPrefix: YYYY/MM/DD, filenamePrefix: "ad" }`;帮助 `{ bucket: "zxts-book-file", pathPrefix: YYYY/MM/DD, filenamePrefix: "help" }`;APP `{ bucket: "zxts-common-file", pathPrefix: YYYY/MM/DD, filenamePrefix: "app" }`。
- [ ] **Step 1: 清理本地 AliOSS 初始化状态**
删除各页面的 `AliOSS`、STS Token、`ossClient` state、初始化 effect 及 `getAliOSSSTSToken` 引入;保留页面自己的 loading 状态。
- [ ] **Step 2: 改为调用统一工具**
```js
const url = await uploadFile(file, {
bucket: "zxts-common-file",
pathPrefix: dayjs().format("YYYY/MM/DD"),
filenamePrefix: "app",
})
```
用返回 URL 更新各自表单字段;上传失败保持 `message.error` 和 Ant Upload 的 `onError` 回调。
- [ ] **Step 3: 构建校验**
Run: `pnpm --filter @ebook/admin build`
Expected: PASS。
### Task 3: 迁移书籍与题库编辑器直传
**Files:**
- Modify: `apps/admin/src/common/editor-simple/index.jsx`
- Modify: `apps/admin/src/common/wangeditor-customer/practice/components/customer.jsx`
- Modify: `apps/admin/src/modules/books/question-bank/components/QuestionEditr.jsx`
- Modify: `apps/admin/src/modules/books/question-bank/components/RichTextEditor.tsx`
**Interfaces:**
- Consumes: Task 1 的 `uploadFile`。
- Uses: `{ bucket: "zxts-book-file", pathPrefix: YYYY/MM/DD, filenamePrefix }`。
- [ ] **Step 1: 删除编辑器的 AliOSS 客户端初始化**
移除 `AliOSS`、STS 请求、`ossClient` 与 `clientRef`。编辑器可直接渲染,上传时调用统一工具,不再等待客户端初始化。
- [ ] **Step 2: 替换图片与视频 customUpload**
```js
const url = await uploadFile(file, {
bucket: "zxts-book-file",
pathPrefix: dayjs().format("YYYY/MM/DD"),
filenamePrefix: "question",
})
insertFn(url, "题库图片")
```
帮助、练习、横幅等原有 filenamePrefix 保持不变;视频同样返回 URL 后传给现有 `insertFn`。
- [ ] **Step 3: 校验编辑器上传失败路径**
每个 customUpload 用 `try/finally` 结束 loading,并在 catch 中显示原有失败提示;不吞掉工具抛出的异常。
- [ ] **Step 4: 构建校验**
Run: `pnpm --filter @ebook/admin build`
Expected: PASS。
### Task 4: 清理其他遗留直传入口并完成验证
**Files:**
- Modify: `apps/admin/src/pages/advertisement/advert/index.jsx`
- Modify: `apps/admin/src/pages/books/question-bank/components/form-drawer.jsx`
- Modify: `apps/admin/src/pages/books/question-bank/questionEditr/index.jsx`
- Modify: `apps/admin/src/pages/books/question-bank/index.jsx`
- Modify: `apps/admin/src/common/wangeditor-customer/upload.js`
- Modify: `apps/admin/src/common/wangeditor-customer/utils/upload.js`
**Interfaces:**
- Consumes: Task 1 的 `uploadFile`。
- Preserves: 日期路径、文件前缀、分片上传的进度回调;超大文件需要继续使用同一工具暴露的客户端能力或保留为显式例外。
- [ ] **Step 1: 区分未路由遗留页面与运行中的通用编辑器工具**
路由未引用的页面也改为调用 `uploadFile`,避免未来重新接入后绕过统一入口。通用编辑器工具的 `normalUploader` 与 `multipartUploader` 的 `ossClient` 参数改为 `bucket`,内部调用 `getOSSClient(bucket)`;因此保留分片上传进度回调,但不再自行请求 STS。
- [ ] **Step 2: 运行静态排查**
Run: `rg -n 'import AliOSS from "ali-oss"|new AliOSS\(|getAliOSSSTSToken' apps/admin/src -g '*.{js,jsx,ts,tsx}'`
Expected: 仅保留 `utils/oss.js` 及不在本次范围的服务端代理接口声明。
- [ ] **Step 3: 运行完整验证**
Run: `pnpm --filter @ebook/admin test && pnpm --filter @ebook/admin test:unit --run src/test/oss.test.ts && pnpm --filter @ebook/admin build`
Expected: tests PASS,build PASS。
# Admin 模块化架构与 AppList 设计
## 状态
- 日期:2026-07-20
- 状态:已确认方向,待实施
- 范围:apps/admin
- 核心决定:books 聚合路由;management、audit、sale 等二级业务模块各自拥有业务代码;不创建 api、query、routes 目录
## 1. 背景
当前 Admin 的页面、接口、查询参数转换和路由主要集中在 pages、common、components 与 app/routes.tsx 中。现有页面已经具备完整业务能力,但存在以下结构问题:
1. app/routes.tsx 直接感知所有页面,新增或调整业务时需要修改全局路由。
2. 页面、接口、查询参数、弹窗和表格逻辑分散,业务边界不清晰。
3. 列表页同时使用 AppList、TableCom、Pagination 等多套方案。
4. 现有 AppList 自己发请求、维护查询状态,难以支持复杂列表,也不利于测试。
5. 项目同时存在 utils/axios.js、utils/request.js 等不同请求语义,贸然统一会引入回归。
6. 以前的模块化尝试把数百行业务页面改写成几十行通用页面,丢失了权限、操作列、弹窗、上传、预览和跳转等功能。
本次重构以“调整代码归属,不改变产品行为”为原则。模块化和 AppList 统一必须分步进行,每一步都能独立验证和回滚。
## 2. 目标
### 2.1 模块目标
- 顶级业务域负责聚合本域路由。
- 二级业务模块拥有自己的页面、接口、查询转换、状态和组件。
- 根路由只感知顶级业务域,不直接导入二级业务页面。
- 顶级业务域和最终页面都按需加载。
- 删除对 pages 的依赖后,业务代码能够从目录位置直接判断归属。
### 2.2 AppList 目标
- 所有标准业务表格使用同一个受控 AppList。
- 请求、筛选状态、分页状态和业务动作由业务模块拥有。
- AppList 只负责列表展示与交互编排,不调用业务 API。
- 支持复杂筛选、自定义单元格、批量操作、权限控制、空态、错误态和现有视觉行为。
- 树、表单编辑器、卡片集合等非表格页面不强制使用 AppList。
### 2.3 交付目标
- URL、菜单、权限、请求参数、返回值处理、交互和视觉行为保持不变。
- 每次只迁移一个可验证的业务模块。
- 旧页面只有在对应新模块通过验证后才能删除。
- 实施步骤足够明确,可由后续成本更低的模型逐项执行。
## 3. 非目标
本次不包含:
- 重做 Admin 视觉设计。
- 修改后端接口或协议。
- 同时统一两套 HTTP 客户端。
- 引入新的全局状态库。
- 为所有列表强行抽取一个万能数据 Hook。
- 借模块化重构顺便修改业务规则。
- 一次性格式化全仓库。
- 为 api、query、routes 分别建立目录。
## 4. 已确认的目录原则
### 4.1 顶级业务域聚合路由
books、members、settings、access、advertisements、teachers 等顶级业务域只负责:
- 声明本域路由。
- 提供本域边界和加载态。
- 把相对路径分发给二级业务模块。
顶级业务域不承载二级模块的接口和页面实现。
### 4.2 二级业务模块拥有业务代码
以 books 为例:
~~~text
src/modules/books/
routes.tsx
management/
api.ts
query.ts
routes.tsx
useManagementList.ts
components/
views/
audit/
api.ts
query.ts
routes.tsx
useAuditList.ts
components/
views/
sale/
api.ts
query.ts
routes.tsx
useSaleList.ts
components/
views/
~~~
明确禁止:
~~~text
management/api/
management/query/
management/routes/
~~~
api.ts、query.ts、routes.tsx 是文件,不是文件夹。components 和 views 可以是文件夹,因为它们天然可能包含多个实现文件。
### 4.3 不为形式创建空文件
- 只有存在接口调用时才创建 api.ts。
- 只有存在查询参数转换时才创建 query.ts。
- 只有包含多个子路由或需要独立路由边界时才创建二级 routes.tsx。
- 简单模块可以由顶级 routes.tsx 直接懒加载其唯一 View。
- 不创建只做无意义转发的 index.ts。
## 5. 目标目录
~~~text
apps/admin/src/
app/
routes.tsx
ModuleBoundary.tsx
RouteErrorBoundary.tsx
modules/
books/
routes.tsx
management/
api.ts
query.ts
routes.tsx
useManagementList.ts
components/
views/
audit/
api.ts
query.ts
routes.tsx
useAuditList.ts
components/
views/
classify/
label/
sale/
question-bank/
discussion/
orders/
design/
members/
routes.tsx
list/
level/
advertisements/
routes.tsx
advert/
adsense/
settings/
routes.tsx
help/
applied/
personalized/
point-management/
app-version/
coupon/
sensitive/
feedback/
access/
routes.tsx
admin/
role/
teachers/
routes.tsx
list/
auth/
routes.tsx
api.ts
views/
account/
routes.tsx
api.ts
views/
editor/
routes.tsx
views/
student-book/
routes.tsx
api.ts
views/
shared/
components/
AppList/
AppList.tsx
AppList.test.tsx
types.ts
index.ts
~~~
目录中列出的 api.ts、query.ts、routes.tsx 是允许出现的位置,不代表每个模块必须全部创建。
## 6. 业务域归属
### 6.1 books
- management:图书列表、新增编辑、章节管理。
- audit:审核列表、审核详情、预览。
- classify:图书分类。
- label:图书标签。
- sale:销售列表、销售编辑、评论列表、评论详情。
- question-bank:题库。
- discussion:讨论管理。
- orders:订单列表、销售统计。
- design:图书设计相关页面。
management 的新增编辑和章节页面仍属于 management,不再为它们额外创建三级业务模块。sale 的评论子流程仍由 sale 拥有。这样可以保持“二级模块拥有完整业务闭环”。
### 6.2 members
- list:会员列表、会员详情,以及详情内的订单、积分、优惠券记录。
- level:会员等级。
### 6.3 settings
- help、applied、personalized、point-management、app-version、coupon、sensitive、feedback 各自拥有自己的业务实现。
- point-management 是表单页面,不使用 AppList。
### 6.4 其他业务域
- advertisements:advert、adsense。
- access:admin、role。
- teachers:list。
- auth、account、editor、student-book 作为独立顶级模块。
## 7. 依赖规则
允许的依赖方向:
~~~text
app -> 顶级业务域 routes
顶级业务域 routes -> 本域二级模块 routes 或 views
二级业务模块 -> shared、utils、公共类型
shared -> utils、无业务语义的基础能力
~~~
禁止的依赖:
- app/routes.tsx 直接导入 books/management/views 等二级页面。
- books 模块导入 settings 或 members 的内部文件。
- shared 导入任何 modules 下的业务代码。
- 一个二级模块直接导入另一个二级模块的私有组件或状态。
- 使用聚合导出导致所有页面被提前打入同一个包。
跨模块确实需要共享的能力,先确认其不包含业务语义,再移动到 shared。仅被一个模块使用的代码不得提前抽到 shared。
## 8. 路由设计
### 8.1 两级懒加载
第一层由 app/routes.tsx 懒加载顶级业务域:
~~~tsx
const BooksRoutes = lazy(() => import("../modules/books/routes"));
const SettingsRoutes = lazy(() => import("../modules/settings/routes"));
~~~
根路由只保留:
~~~text
books/*
member/*
advertisement/*
setting/*
jurisdiction/*
teacher/*
login
userinfo
editor
student/book/*
*
~~~
第二层由业务域 routes.tsx 懒加载最终页面或二级路由:
~~~tsx
const ManagementRoutes = lazy(
() => import("./management/routes")
);
const AuditRoutes = lazy(() => import("./audit/routes"));
const SaleRoutes = lazy(() => import("./sale/routes"));
~~~
业务域使用相对路径聚合:
~~~text
management/*
audit/*
sale/*
classify
label
~~~
### 8.2 URL 保持不变
重构前后的公开 URL 必须完全一致。例如:
- /books/management
- /books/management/add-edit
- /books/management/chapter
- /books/audit
- /books/audit/detail
- /books/sale
- /books/sale/discussP
- /member/list
- /setting/help
- /jurisdiction/admin
路由迁移测试应对完整路径清单做契约断言,不能只验证页面能否渲染。
### 8.3 路由组件约束
- routes.tsx 只负责路由匹配、懒加载和边界。
- routes.tsx 不发请求,不维护业务筛选状态。
- 页面需要必填 props 时,不允许直接作为路由组件;必须提供无参数 Route View 适配层。
- 每个懒加载模块必须有局部加载态和错误边界。
- 404 行为和现有跳转逻辑保持不变。
## 9. 二级模块职责
### 9.1 api.ts
职责:
- 封装本模块使用的后端端点。
- 保持当前请求方法、content type、签名方式、取消机制和响应语义。
- 为 View 和 Hook 提供明确的方法名与类型。
约束:
- 迁移阶段不替换底层 HTTP 客户端。
- 如果旧接口返回 Promise,必须 await 后再做响应归一化。
- 不得把 Promise 当作响应对象读取。
- 不得吞掉异常后伪造空列表;错误必须进入页面现有错误路径。
### 9.2 query.ts
职责:
- 定义筛选表单值。
- 把 UI 查询值转换为当前接口所需的精确参数。
- 保留 0、false、空数组等有业务意义的值。
约束:
- 不使用统一的“删除所有 falsy 值”逻辑。
- 每个模块依据原页面单独实现映射。
- 日期字段按实际接口处理。例如订单可能使用 start_pay、end_pay,不能套用通用 start_time、end_time。
- query.ts 不发请求,不操作路由。
### 9.3 useFeatureList.ts
职责:
- 拥有筛选值、页码、每页数量、数据、总数、加载和错误状态。
- 处理竞态:只允许最后一次请求更新当前页面状态。
- 搜索和重置回到第一页。
- 翻页保留当前筛选条件。
- 失败时结束 loading,并保留可重试信息。
- 执行删除、上下架、审核等动作后按原行为刷新。
初期每个业务模块独立维护 Hook。至少完成三个复杂程度不同的模块后,才能基于重复证据抽取公共 Hook。
### 9.4 views
职责:
- 组合业务组件和 shared 组件。
- 读取权限并决定按钮、列和动作是否显示。
- 处理导航、弹窗、抽屉、上传、预览和业务动作。
- 不把业务规则下沉到 AppList。
### 9.5 components
存放仅在当前二级模块内复用的筛选项、编辑弹窗、详情抽屉、预览、上传控件等。
## 10. AppList 设计
### 10.1 定位
AppList 是受控的列表展示组件,不是请求框架,也不是业务状态容器。
AppList 负责:
- 渲染筛选区、工具栏、表格和分页。
- 把用户操作通过回调上报。
- 统一加载、空数据、错误和重试表现。
- 统一分页布局与基础可访问性。
AppList 不负责:
- 调用 API。
- 构造业务查询参数。
- 读取 Redux、Zustand 或路由状态。
- 判断业务权限。
- 执行删除、审核、上下架等业务动作。
- 擅自清理筛选值。
### 10.2 建议契约
~~~tsx
export interface AppListProps<Row, Filters> {
rowKey: string | ((row: Row) => React.Key);
columns: ColumnsType<Row>;
data: {
rows: Row[];
total: number;
loading: boolean;
error?: Error | null;
};
filters?: {
value: Filters;
items: AppListFilterItem<Filters>[];
onChange: (next: Filters) => void;
onSearch: () => void;
onReset: () => void;
onRefresh: () => void;
};
pagination?: false | {
page: number;
pageSize: number;
pageSizeOptions?: number[];
onChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
};
selection?: TableRowSelection<Row>;
toolbar?: React.ReactNode;
onRetry?: () => void;
tableProps?: Omit<TableProps<Row>, "columns" | "dataSource" | "loading">;
}
~~~
使用方式:
~~~tsx
<AppList
rowKey="id"
columns={columns}
data={{ rows, total, loading, error }}
filters={{
value: filters,
items: filterItems,
onChange: setFilters,
onSearch: search,
onReset: reset,
onRefresh: refresh
}}
pagination={{
page,
pageSize,
onChange: changePage,
onPageSizeChange: changePageSize
}}
toolbar={toolbar}
/>
~~~
### 10.3 AppList 行为
- page、pageSize、total 全部受控。
- onChange 只更新筛选值,不自动猜测是否发请求。
- 搜索由 onSearch 明确触发。
- 清空数字 0 或布尔 false 时不能被误判为无效值。
- 请求失败后 loading 必须结束。
- error 存在时显示可重试状态;已有数据可按产品现状决定是否继续展示。
- rowKey 必须显式提供,不能默认假设所有数据都有 id。
- columns、toolbar、selection 由业务模块构造,以保留权限和业务动作。
- tableProps 仅作为 Ant Design Table 的受控扩展口,不能覆盖核心数据与加载状态。
### 10.4 不使用 AppList 的场景
- point-management 等纯表单页。
- 分类树、目录树。
- 富文本或图书设计编辑器。
- 纯卡片、看板或时间线。
- 详情页内不需要分页、筛选和列表工具栏的简单静态表格。
会员详情中的订单、积分、优惠券记录是否使用 AppList,应依据现有交互决定,不能只因使用了 Table 就强制迁移。
## 11. 状态与请求规则
### 11.1 状态归属
- 表单草稿、筛选条件、分页和弹窗状态默认属于二级业务模块。
- 只有跨路由、跨模块且需要持久化的状态才进入全局 Store。
- 不因为 Zustand 已安装就为每个列表创建 Store。
### 11.2 请求竞态
每个列表控制器必须用请求序号、AbortController 或当前项目已有的取消机制,避免慢请求覆盖新请求。
### 11.3 响应归一化
项目现有 HTTP 客户端返回值不同。迁移时采用“模块内适配,底层不改”的方式:
1. 保持旧页面使用的客户端。
2. await 实际请求。
3. 根据该接口真实返回结构提取 rows 和 total。
4. 用契约测试锁定响应结构。
5. 只有所有调用方完成迁移后,才另立项目统一 HTTP 客户端。
## 12. 行为等价迁移
每个页面迁移前必须建立行为清单,至少包含:
- 完整 URL 与查询参数。
- 首次加载请求。
- 搜索、重置、翻页和修改 pageSize。
- 权限控制。
- 操作列中的所有动作。
- 新增、编辑、删除、审核、上下架等 mutation。
- 弹窗、抽屉、预览、上传和导出。
- 成功与失败反馈。
- 操作完成后的刷新策略。
- 返回列表时的状态恢复。
迁移方式:
1. 先原样移动旧页面和其私有代码到目标模块。
2. 修复导入路径并让新路由指向新位置。
3. 验证行为与旧页面一致。
4. 再把接口和查询转换抽到 api.ts、query.ts。
5. 最后把标准表格接入受控 AppList。
禁止直接根据旧页面“重新想象”一个简化版本。
## 13. 实施顺序
### 阶段一:建立护栏
- 固化当前全部 URL。
- 记录当前构建、测试、类型检查和 lint 基线。
- 为 AppList 建立组件测试环境。
- 为路由建立契约测试。
### 阶段二:建立路由边界
- app/routes.tsx 改为只懒加载顶级业务域。
- 各顶级业务域先路由到旧 pages,保持行为不变。
- 验证所有公开 URL。
### 阶段三:实现受控 AppList
- 新建 shared/components/AppList。
- 覆盖筛选、分页、加载、错误、选择和工具栏测试。
- 暂不删除旧 AppList、TableCom、Pagination。
### 阶段四:小模块试点
推荐顺序:
1. settings/feedback:已有 AppList,功能较小。
2. advertisements/adsense:标准列表。
3. settings/coupon:验证复杂筛选和业务动作。
试点结束后复盘 AppList 契约,只修正有实际证据的问题。
### 阶段五:按业务域迁移
1. settings。
2. advertisements。
3. access。
4. members。
5. teachers。
6. books。
7. auth、account、editor、student-book。
books 放在后期,因为 management、audit、sale、question-bank 和 orders 功能复杂、回归风险最高。
### 阶段六:清理
- 确认所有路由已脱离 pages。
- 确认没有旧 AppList、TableCom、Pagination 的有效引用。
- 删除已被替代的旧实现。
- 检查循环依赖和错误的跨模块引用。
- 生成最终构建并进行关键路径冒烟测试。
## 14. 测试策略
### 14.1 AppList 单元测试
必须覆盖:
- 受控筛选值渲染。
- 搜索、重置、刷新回调。
- page 与 pageSize 回调。
- loading、empty、error、retry。
- row selection。
- toolbar 和权限过滤后的 columns。
- 0 与 false 不被丢弃。
### 14.2 模块测试
每个列表模块至少覆盖:
- query.ts 精确参数转换。
- API 响应成功和失败。
- 首次加载。
- 搜索后回第一页。
- 翻页保留筛选。
- 慢请求不能覆盖新请求。
- mutation 后的刷新。
### 14.3 路由测试
- 完整 URL 清单保持不变。
- 根路由不直接导入二级业务 View。
- 顶级业务域和页面均为懒加载。
- 路由组件不要求外部传入业务 props。
- 未知路径仍进入原有 404 行为。
### 14.4 回归验证
- 现有 Node 测试继续通过。
- production build 继续通过。
- TypeScript 与 lint 以记录的迁移前基线为参照,禁止新增错误。
- 对高风险页面做人工冒烟:management、audit、sale、question-bank、orders、members、access。
全仓库当前已有历史类型和 lint 问题,不得把“修复所有历史问题”混入本次重构。每个任务必须保证变更文件通过检查,并且全局错误数不增加。
## 15. 提交与回滚策略
- 一个提交只处理一个基础设施能力或一个业务模块。
- 路由迁移、API 抽取和 AppList 改造尽量分提交。
- 禁止把全仓格式化与业务迁移放在同一提交。
- 新模块通过验证前保留旧页面。
- 删除旧代码单独提交,便于回滚。
- 每个提交信息写明模块和阶段。
## 16. 验收标准
### 架构
- app/routes.tsx 只感知顶级业务域。
- books/routes.tsx 聚合 management、audit、sale 等路由。
- 二级业务模块拥有自己的业务代码。
- 没有 api、query、routes 文件夹。
- shared 不依赖 modules。
- 不存在跨业务域导入内部实现。
### 行为
- 所有既有 URL 保持不变。
- 权限、操作列、弹窗、上传、预览、跳转和请求参数保持一致。
- 所有 mutation 的成功、失败和刷新行为保持一致。
### AppList
- 标准业务表格使用受控 AppList。
- AppList 不发业务请求,不拥有业务状态。
- 非表格页面未被强制接入 AppList。
- 旧列表组件仅在全部调用方迁移完成后删除。
### 质量
- 测试和生产构建通过。
- 不新增 TypeScript、lint 和循环依赖问题。
- 每个模块可独立回滚。
- 路由和页面仍具有实际的按需加载边界。
## 17. 明确禁止
- 不允许再次把完整页面压缩成缺少业务功能的通用列表。
- 不允许创建 modules/books/api 或 management/api 等目录。
- 不允许把所有业务 API 集中到顶级 books/api.ts。
- 不允许复制一套通用 query 映射到所有模块。
- 不允许先删除 pages 再补功能。
- 不允许在 AppList 内请求接口或读取业务 Store。
- 不允许以修复架构为名修改 URL、权限或后端参数。
- 不允许把格式化、依赖升级、HTTP 客户端统一和模块迁移混成一次大改动。
## 18. 最终结论
Admin 的目标结构不是“每种技术文件一个目录”,而是“按业务闭环组织代码”:
- books 负责聚合路由。
- management、audit、sale 等二级模块拥有完整业务代码。
- api.ts、query.ts、routes.tsx 保持为模块根部的文件。
- components、views 根据实际复杂度使用目录。
- AppList 是受控展示组件,业务模块拥有请求和状态。
- 迁移必须以旧页面行为为事实来源,分模块验证,最后再清理旧代码。
该方向在目录简洁度、业务归属、懒加载能力和迁移安全性之间取得平衡,可作为后续实施计划的唯一架构基线。
# Learning Route Lazy Loading Design
## Goal
Change `apps/learning` from eager route-view imports to React Router's native
`RouteObject.lazy` API. Each route page must be loaded on demand while the
existing URLs, authentication rules, layout hierarchy, and module-owned route
files remain unchanged.
## Scope
This change applies only to `apps/learning` route code, route-focused tests,
and any small shared loading utility required by those routes. It must not
modify `apps/admin`, business APIs, React Query state, Zustand state, or page
behavior.
The currently registered route set remains the same. The existing search route
definition will also become lazy so that it is ready if it is registered later,
but this change will not register `/search`. The documented `/home` and
`/download` domains do not currently have implemented route files, so this
change will not invent them.
## Architecture
Use `RouteObject.lazy` for every business leaf page. Route metadata stays in
each domain's existing `routes.tsx`; only the page implementation is loaded by
dynamic `import()`.
The following infrastructure remains synchronously available:
- `LearningLayout` and the root route definition.
- `MyLayout`, because it is shared by every `/my` child navigation.
- `RequireSession`, so protected navigation is rejected before protected page
content renders.
- Redirect-only routes and route path metadata.
Create a small, type-safe helper in `src/router/lazy-route.ts` that accepts a
module importer and named component export and returns a React Router lazy
function. A route will have this shape:
```ts
{
path: 'library',
lazy: lazyRoute(() => import('./views/LibraryView'), 'LibraryView'),
}
```
The helper must not invoke the importer until React Router calls the returned
lazy function. It must return `{ Component }`, not a pre-created React element.
## Route Boundaries
Each of these page exports gets its own dynamic import boundary:
- Authentication: `LoginView`.
- Library: `LibraryView`.
- Book: `BookDetailView`.
- Reader: `ReaderView`.
- Search definition: `SearchView`.
- My root children: overview, books, bookshelf, history, favorites, notes,
note detail, discussions, and wrong questions.
- My subdomains: order list and detail, coupons, wallet, profile, security,
password change, phone change, messages, feedback, help list, help detail,
and about.
The `/my` parent continues to render `RequireSession` around `MyLayout`, with
lazy leaf children below it. The standalone reader route needs a protected
parent route whose index child lazily loads `ReaderView`; this preserves the
same `/reader/:bookId/:chapterId` URL while keeping the guard synchronous.
## Loading and Failure Behavior
Initial direct navigation to a lazy route must render a small application-level
loading fallback through `RouterProvider` while its route module is fetched.
The fallback must be accessible with `role="status"` and must not introduce a
new state store.
Dynamic import failures remain router errors. This change does not add retry or
error-swallowing behavior; React Router's error handling must receive the
failure rather than silently rendering the wrong page.
## Testing
Follow test-driven development:
1. Add a unit test proving the lazy helper does not import eagerly and returns
the requested named component when invoked.
2. Add a route-structure test proving every business leaf route uses `lazy`
instead of a synchronous `element`.
3. Preserve focused render coverage for representative public, nested My, and
standalone reader routes.
4. Run the complete learning test suite and compare it with the established
baseline of 15 failures and 34 passes; the failure set must not grow.
Production verification must run learning lint, format check, TypeScript 7
typecheck, and the Vite build. The build output must contain multiple JavaScript
files with distinct lazy route entry chunks, rather than the current single
application JavaScript bundle.
## Non-Goals
- Changing route URLs or registering currently unused routes.
- Lazy-loading shared layouts, guards, or every non-route component.
- Adding preload-on-hover, service workers, custom chunk retry logic, or manual
Rollup chunk configuration.
- Fixing unrelated pre-existing tests or business behavior.
# Admin OSS 上传统一方案
## 目标
所有前端直传 OSS 的入口统一调用 `apps/admin/src/utils/oss.js`,页面不再自行创建 `AliOSS` 客户端或请求 STS Token。
## 接口
`uploadFile(file, options)` 接受:
- `bucket`:目标 bucket;默认保留书籍上传当前使用的 `zxts-book-file`。
- `pathPrefix`:可选的对象路径前缀;不传时沿用当前扁平文件名。
工具内部负责:STS Token 获取与刷新、按 bucket 缓存客户端、生成唯一文件名、上传,以及向调用方抛出可处理的异常。返回 OSS 文件 URL。
## 迁移范围
- 书籍、题库、编辑器与帮助中心上传使用 `zxts-book-file`。
- 广告上传使用 `zxts-ad-file`,APP 安装包上传使用 `zxts-common-file`;均保留现有日期路径前缀。
- 清理页面中的 `AliOSS`、STS Token 初始化状态和重复上传实现。
后端代理上传 `utils/upload.js` 不在本次范围内:它是另一条服务端上传链路,不能在未确认接口语义前替换为浏览器直传。
## 行为与校验
页面保留原有文件类型、大小校验、上传中状态与成功后的表单赋值。上传失败继续由页面展示原有错误提示;工具只提供统一的异常来源。
为避免跨 bucket 复用错误客户端,客户端缓存以 bucket 为键;STS 失效时由 SDK 回调重新获取同一 bucket 的配置。
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论