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

chore: update

上级 9268c71c
# Settings sensitive migration
- 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>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<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";
import { Spin } from "antd";
import { Editor, Toolbar } from "@wangeditor/editor-for-react";
import "@wangeditor/editor/dist/css/style.css"; // 引入 css
import AliOSS from "ali-oss";
import { getAliOSSSTSToken } from "@/pages/setting/help/addedit/requet";
import add from "@/assets/images/icon/add.png";
import dayjs from "dayjs";
import { uploadFile } from "@/utils/oss";
import "./index.less";
const EditorReader = (props) => {
const { form, fieldName, content, loadLoading } = props;
// oss
const [ossClient, setOssClient] = useState(null); // oss 操作
const [STSToken, setSTSToken] = useState(null); // oss 过期设置
const [file, setFile] = useState({}); // oss 过期设置
const [uploading, setUploading] = useState(false);
// editor 实例
const [editor, setEditor] = useState(null);
......@@ -23,32 +16,6 @@ const EditorReader = (props) => {
const [html, setHtml] = useState(content);
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 = {
......@@ -114,17 +81,8 @@ const EditorReader = (props) => {
base64LimitSize: 5 * 1024, // 5kb 以下插入 base64 // 用户自定义上传图片
customUpload: async (file, insertFn) => {
setLoading(true);
if (!ossClient) {
console.error("ossClient还未初始化", ossClient);
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 uploadFile(file, { filenamePrefix: "banner" });
insertFn(url, "题库图片");
// const { url } = await uploadFiles({ file, file_type: 'question' });
// insertFn(url, '题库图片');
......@@ -145,14 +103,8 @@ const EditorReader = (props) => {
base64LimitSize: 5 * 1024, // 5kb 以下插入 base64 // 用户自定义上传图片
customUpload: async (file, insertFn) => {
setLoading(true);
if (!ossClient) {
console.error("ossClient还未初始化", ossClient);
return false;
}
const result = await ossClient.put(file.name, file);
console.log("result", result);
insertFn(result.url, "题库视频");
const url = await uploadFile(file, { filenamePrefix: "banner" });
insertFn(url, "题库视频");
// const { url } = await uploadFiles({ file, file_type: 'question' });
// insertFn(url, '题库视频');
setLoading(false);
......@@ -161,10 +113,6 @@ const EditorReader = (props) => {
},
};
useEffect(() => {
getStsAuthToken();
}, []);
// 及时销毁 editor ,重要!
useEffect(() => {
return () => {
......@@ -175,10 +123,10 @@ const EditorReader = (props) => {
}, [editor]);
useEffect(() => {
if (editor && ossClient) {
if (editor) {
editor.setHtml(content);
}
}, [editor, content, ossClient]);
}, [editor, content]);
return (
<div className="editor_customer_simple">
......@@ -190,7 +138,6 @@ const EditorReader = (props) => {
style={{ borderBottom: "1px solid #ccc" }}
/>
<Spin spinning={loading}>
{ossClient && (
<Editor
defaultConfig={editorConfig}
value={html}
......@@ -203,7 +150,6 @@ const EditorReader = (props) => {
className="w-e-text"
style={{ height: "400px", overflowY: "hidden" }}
/>
)}
</Spin>
</div>
</div>
......
......@@ -99,7 +99,7 @@ const AudioModal = (props, ref) => {
open={visible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title="插入音频"
classNames={{
header: "editor-header-customer",
......
......@@ -23,7 +23,6 @@ import { addExpandRead, expandReadInfo } from "../utils/request";
const ExpandModal = (props, ref) => {
const {
editor,
ossClient,
bookId,
chapterId,
setExpandVisible,
......
......@@ -56,7 +56,7 @@ const FormulaModal = (props, ref) => {
open={visible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title="公式"
classNames={{
header: "editor-header-customer",
......
import React, { useEffect, useState, useRef, useImperativeHandle, forwardRef } from "react";
import { Modal, Drawer, Tree, Button, Spin, Space, Input, Image } from "antd";
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 $, { event } from "jquery";
import baike from "@/assets/images/editor/icon-baike.png";
......
......@@ -106,7 +106,7 @@ const VideoModal = (props, ref) => {
open={visible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title="插入视频"
classNames={{
header: "editor-header-customer",
......
......@@ -853,7 +853,7 @@ const WangEditorCustomer = (props, ref) => {
open={imageVisible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title="插入图片"
classNames={{
header: "editor-header-customer",
......@@ -877,7 +877,7 @@ const WangEditorCustomer = (props, ref) => {
open={galleryVisible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title="插入画廊"
classNames={{
header: "editor-header-customer",
......@@ -905,7 +905,7 @@ const WangEditorCustomer = (props, ref) => {
open={titleVisible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title="插入章头"
classNames={{
header: "editor-header-customer",
......@@ -928,7 +928,7 @@ const WangEditorCustomer = (props, ref) => {
open={sectionVisible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title="插入节头"
classNames={{
header: "editor-header-customer",
......@@ -952,7 +952,7 @@ const WangEditorCustomer = (props, ref) => {
open={practiceOpenVisible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title="插入交互练习"
classNames={{
header: "editor-header-customer",
......@@ -976,7 +976,7 @@ const WangEditorCustomer = (props, ref) => {
open={tooltipVisible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title="气泡"
classNames={{
header: "editor-header-customer",
......@@ -1001,7 +1001,7 @@ const WangEditorCustomer = (props, ref) => {
open={linkVisible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title="链接"
classNames={{
header: "editor-header-customer",
......@@ -1029,7 +1029,7 @@ const WangEditorCustomer = (props, ref) => {
open={expandVisible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title="扩展阅读"
classNames={{
header: "editor-header-customer",
......@@ -1056,7 +1056,7 @@ const WangEditorCustomer = (props, ref) => {
open={practiceVisible}
footer={null}
centered
destroyOnClose
destroyOnHidden
title={null}
classNames={{
header: "practice-topic-header",
......@@ -1085,7 +1085,7 @@ const WangEditorCustomer = (props, ref) => {
open={priviewVisible}
header={null}
footer={null}
destroyOnClose
destroyOnHidden
centered
keyboard={false}
closeIcon={<CloseOutlined style={{ fontSize: 20, color: "#fff" }} />}
......@@ -1110,7 +1110,7 @@ const WangEditorCustomer = (props, ref) => {
open={historyVisible}
header={null}
footer={null}
destroyOnClose
destroyOnHidden
centered
keyboard={false}
closeIcon={false}
......
......@@ -47,7 +47,7 @@ const AIChatDrawer = (props) => {
open={open}
width="600px"
title="AI对话"
destroyOnClose
destroyOnHidden
onClose={() => setOpen(false)}
rootClassName="ai-drawer-wrapper"
className="ai-drawer-container"
......
......@@ -124,7 +124,7 @@ export default function AIDigitalHumanModal(props) {
footer={null}
width={1000}
centered
destroyOnClose
destroyOnHidden
>
<div className="ai-digital-human">
<Row gutter={[24, 16]}>
......
......@@ -164,7 +164,7 @@ export default function IconModal(props) {
cancelText="取消"
width={700}
centered
destroyOnClose
destroyOnHidden
>
<Tabs
activeKey={activeTab}
......
......@@ -7,11 +7,8 @@ import { Editor, Toolbar } from "@wangeditor/editor-for-react";
import { DomEditor } from "@wangeditor/editor";
import { questionAdd, typeAllList, questionAddType, bookNameList } from "../request";
import AliOSS from "ali-oss";
import dayjs from "dayjs";
import { getAliOSSSTSToken } from "@/pages/setting/help/addedit/requet";
import { uploadFiles } from "@/utils/upload";
import QuestionEditr from "@/pages/books/question-bank/questionEditr";
import { uploadFile } from "@/utils/oss";
import QuestionEditr from "@/modules/books/question-bank/components/QuestionEditr";
import { values } from "lodash-es";
const CustomerTopic = (props, ref) => {
......@@ -24,8 +21,6 @@ const CustomerTopic = (props, ref) => {
});
// oss
const [ossClient, setOssClient] = useState(null); // oss 操作
const [STSToken, setSTSToken] = useState(null); // oss 过期设置
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const questionType = Form.useWatch("question_style", form);
......@@ -48,32 +43,6 @@ const CustomerTopic = (props, ref) => {
setSelectedBookId(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("");
......@@ -95,7 +64,6 @@ const CustomerTopic = (props, ref) => {
useEffect(() => {
(async () => {
await getTypeList();
await getStsAuthToken();
})();
}, []);
......@@ -138,19 +106,8 @@ const CustomerTopic = (props, ref) => {
base64LimitSize: 5 * 1024, // 5kb 以下插入 base64
customUpload: async (file, insertFn) => {
if (!ossClient) {
console.error("ossClient还未初始化", ossClient);
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 uploadFile(file, { filenamePrefix: "practice" });
insertFn(url, "题库图片");
// const { url } = await uploadFiles({ file, file_type: 'question' });
// insertFn(url, '题库图片');
......@@ -390,7 +347,6 @@ const CustomerTopic = (props, ref) => {
mode="default"
style={{ borderBottom: "1px solid #ccc" }}
/>
{ossClient && (
<Editor
defaultConfig={editorConfig}
value={initialValues.titles}
......@@ -406,7 +362,6 @@ const CustomerTopic = (props, ref) => {
mode="default"
style={{ height: "200px", overflowY: "hidden" }}
/>
)}
</div>
</Spin>
</Form.Item>
......
......@@ -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 res = await axios.post("book/teacher/getListAll", qs.stringify(obj));
......@@ -87,4 +70,4 @@ const bookNameList = async (obj) => {
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) => {
}
};
// 获取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) => {
try {
......@@ -285,7 +267,6 @@ export {
expandReadInfo,
addGallery,
delGallery,
getAliOSSSTSToken,
getRecordInfo,
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 type { TablePaginationConfig } from "antd/es/table";
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import zhCN from "antd/locale/zh_CN";
import { Alert, Button, Empty, Space, Table } from 'antd'
import type { TablePaginationConfig } from 'antd/es/table'
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
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>>({
rowKey,
......@@ -20,8 +19,8 @@ export function AppList<Row extends object, Filters extends object = Record<stri
onRetry,
tableProps,
}: AppListProps<Row, Filters>) {
const hasBlockingError = !data.loading && data.error && data.rows.length === 0;
const hasRefreshError = !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 tablePagination: false | TablePaginationConfig = pagination
? {
......@@ -32,27 +31,25 @@ export function AppList<Row extends object, Filters extends object = Record<stri
showQuickJumper: true,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
locale: zhCN.Pagination,
onChange: (page, pageSize) => {
if (pageSize !== pagination.pageSize) {
pagination.onPageSizeChange(pageSize);
return;
pagination.onPageSizeChange(pageSize)
return
}
pagination.onChange(page);
pagination.onChange(page)
},
}
: false;
: false
const updateFilter = <Name extends keyof Filters>(
item: AppListFilterItem<Filters> & { name: Name },
value: Filters[Name],
) => {
filters?.onChange({ ...filters.value, [item.name]: value });
};
filters?.onChange({ ...filters.value, [item.name]: value })
}
return (
<ConfigProvider locale={zhCN}>
<div className="app-list">
{filters ? (
<section className="app-list__filters" aria-label="筛选条件">
......@@ -119,6 +116,5 @@ export function AppList<Row extends object, Filters extends object = Record<stri
/>
)}
</div>
</ConfigProvider>
);
)
}
......@@ -42,7 +42,7 @@ function MessageDraw({ onRead, ...rest }) {
return (
<Drawer
{...rest}
destroyOnClose
destroyOnHidden
closeIcon={<MenuUnfoldOutlined />}
extra={
<span
......
......@@ -9,7 +9,7 @@ import store, { persistor } from "@/store";
import { ConfigProvider, App } from "antd";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import zhCN from "antd/locale/zh_CN";
import zhCN from "antd/es/locale/zh_CN";
import MyApp from "./App";
......
......@@ -4,7 +4,7 @@ import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList";
import { AppList, type AppListFilterItem } from "@/components/AppList";
import {
addAdmin,
......
......@@ -4,7 +4,7 @@ import type { Key } from "react";
import { useEffect, useState } from "react";
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 {
......
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 = () => {
maskClosable={false}
footer={null}
centered
destroyOnClose
destroyOnHidden
keyboard={false}
>
<Form labelCol={{ span: 5 }} form={form2} onFinish={changePasswrd}>
......
......@@ -3,7 +3,7 @@ import type { ColumnsType } from 'antd/es/table'
import { useMemo } from 'react'
import { useSelector } from 'react-redux'
import { AppList } from '@/shared/components/AppList'
import { AppList } from '@/components/AppList'
import type { AdsenseRow } from '../api'
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 type { UploadProps } from "antd";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { getAliOSSSTSToken } from "@/modules/settings/help/request";
interface OssClient {
put: (path: string, file: File) => Promise<{ url: string }>;
}
import { uploadFile } from "@/utils/oss";
interface AdvertImageUploadProps {
value?: string;
......@@ -22,37 +13,6 @@ export default function AdvertImageUpload({
onChange,
onUploadingChange,
}: 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 suffixes = ["jpg", "png"];
const suffix = file.name.substring(file.name.lastIndexOf(".") + 1);
......@@ -64,20 +24,12 @@ export default function AdvertImageUpload({
};
const customRequest: UploadProps["customRequest"] = async (options) => {
if (!ossClient) {
message.error("ossClient还未初始化");
options.onError?.(new Error("ossClient还未初始化"));
return;
}
onUploadingChange(true);
try {
const uploadFile = options.file as File;
const fileExt = uploadFile.name.substring(uploadFile.name.lastIndexOf("."));
const fileName = `ad-${dayjs().valueOf()}-${Math.random().toString(36).substring(2)}${fileExt}`;
const filePath = `${dayjs().format("YYYY/MM/DD")}/${fileName}`;
const result = await ossClient.put(filePath, uploadFile);
onChange?.(result.url);
options.onSuccess?.(result);
const fileToUpload = options.file as File;
const url = await uploadFile(fileToUpload, { bucket: "zxts-ad-file", filenamePrefix: "ad" });
onChange?.(url);
options.onSuccess?.({ url });
} catch (reason) {
console.error("上传文件失败:", reason);
message.error("上传文件失败,请重试");
......
......@@ -4,7 +4,7 @@ import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList";
import { AppList, type AppListFilterItem } from "@/components/AppList";
import {
addAdvert,
......
......@@ -4,7 +4,7 @@ import { CloseOutlined } from "@ant-design/icons";
import { useState } from "react";
import { useNavigate } from "react-router";
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 type { AuditFilters } from "../query";
import { useAuditList } from "../useAuditList";
......@@ -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 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) }) }} />
<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>;
}
......@@ -2,7 +2,7 @@ import { App, Button, DatePicker, Drawer, Form, Input, Space } from "antd";
import type { ColumnsType } from "antd/es/table";
import { useState } from "react";
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 type { ClassifyFilters } from "../query";
import { useClassifyList } from "../useClassifyList";
......
......@@ -14,7 +14,7 @@ import {
import type { ColumnsType } from "antd/es/table";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList";
import { AppList, type AppListFilterItem } from "@/components/AppList";
import {
changeCommentStatus,
deleteComment,
......
import { message } from "antd";
import qs from "qs";
import request from "@/utils/request";
import type { LabelQuery } from "./query";
async function post(path: string, data: Record<string, unknown>, notify = false) {
try {
const response = (await request.post(
path,
new URLSearchParams(data as Record<string, string>).toString(),
)) as { data?: { code?: number; data?: any; message?: string } };
const response = (await request.post(path, qs.stringify(data))) as {
data?: { code?: number; data?: any; message?: string };
};
if (response.data?.code === 200) {
if (notify) message.success(response.data.message);
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 { useEffect, useState } from "react";
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 type { LabelFilters } from "../query";
import { useLabelList } from "../useLabelList";
......@@ -159,14 +159,15 @@ export default function LabelView() {
<br />
<Row gutter={20}>
{books.map((book) => (
<Col key={book.id} flex="0 0 70px" style={{ marginBottom: 15 }}>
<Button
key={book.id}
type={bookIds.includes(book.id) ? "primary" : "default"}
ghost={bookIds.includes(book.id)}
onClick={() => toggle(book.id)}
>
{book.name}
</Button>
</Col>
))}
</Row>
</Form.Item>
......
......@@ -566,7 +566,7 @@ const Examine = () => {
open={editKey}
footer={null}
centered
destroyOnClose
destroyOnHidden
title={editKey > 0 ? "修改章节" : "新增章节"}
maskClosable={false}
mask={false}
......
.examine {
height: 100%;
// 统一目录、正文和工具栏的可视区高度;目录标题占 85px 后,树区域仍沿用 100vh - 285px。
height: calc(100vh - 190px);
min-height: 0;
.content-box {
height: 100%;
box-sizing: border-box;
......
import { Button, Drawer, Form, message, Row, Select, Space, Spin, Upload } from "antd";
import { useState } from "react";
import { downloadFile } from "@/utils/common";
import type { ImportBookValues } from "../api";
interface ImportBookFormValues {
file: unknown;
catalog_level: number;
}
......@@ -14,18 +11,22 @@ interface ImportBookDrawerProps {
open: boolean;
onClose: () => void;
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 [uploading, setUploading] = useState(false);
const bookTemplateUrl = `${import.meta.env.BASE_URL}templates/书籍导入示例.docx`;
const handleSubmit = async (values: ImportBookFormValues) => {
if (!bookFile) return;
setUploading(true);
const success = await onSubmit({ catalog_level: values.catalog_level, file: bookFile });
if (success) window.location.replace(window.location.href);
if (success) {
onSuccess();
onClose();
}
setUploading(false);
};
......@@ -34,9 +35,9 @@ export default function ImportBookDrawer({ open, onClose, onSubmit }: ImportBook
<Spin spinning={uploading}>
<Form<ImportBookFormValues> labelCol={{ span: 8 }} onFinish={handleSubmit}>
<Form.Item label="下载模板" style={{ marginBottom: 10 }}>
<Button ghost type="primary" onClick={() => downloadFile("书籍导入示例.docx")}>
下载模板
</Button>
<a href={bookTemplateUrl} download="书籍导入示例.docx">
<Button ghost type="primary">下载模板</Button>
</a>
</Form.Item>
<p
className="linetext"
......@@ -51,7 +52,6 @@ export default function ImportBookDrawer({ open, onClose, onSubmit }: ImportBook
目录需设置为word中的标题样式或按照示例编写,防止导入失败
</p>
<Form.Item label="选择书籍" required>
<Form.Item name="file" rules={[{ required: true, message: "请上传书籍" }]} noStyle>
<Upload
name="file"
accept=".doc , .docx"
......@@ -69,7 +69,6 @@ export default function ImportBookDrawer({ open, onClose, onSubmit }: ImportBook
>
<Button>点击上传</Button>
</Upload>
</Form.Item>
<p style={{ color: "#999999" }}>{bookFile?.name}</p>
</Form.Item>
<Form.Item
......
......@@ -4,7 +4,7 @@ import { useState } from "react";
import { useNavigate } from "react-router";
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 {
......@@ -328,6 +328,7 @@ export default function ManagementListView() {
open={importOpen}
onClose={() => setImportOpen(false)}
onSubmit={handleImport}
onSuccess={list.refresh}
/>
<ReleaseBookDrawer
open={releaseOpen}
......
......@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table";
import { useState } from "react";
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 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 { useEffect, useState } from "react";
import { addQuestion, addQuestionType, editQuestion } from "../api";
......@@ -8,6 +8,7 @@ const styles = ["单选题", "多选题", "判断题", "填空题", "简答题"]
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 questionStyle = Form.useWatch("question_style", form);
const typeId = Form.useWatch("type_id", form);
useEffect(() => {
if (!open) return;
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
<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="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="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>}
<Form.Item wrapperCol={{ offset: 5 }}><Space><Button onClick={onClose}>取消</Button><Button type="primary" htmlType="submit">提交</Button></Space></Form.Item>
</Form>
......
import "@wangeditor/editor/dist/css/style.css";
import { Editor, Toolbar } from "@wangeditor/editor-for-react";
import AliOSS from "ali-oss";
import dayjs from "dayjs";
import { Spin } from "antd";
import { useEffect, useMemo, useRef, useState } from "react";
import { getAliOssToken } from "../api";
import { useEffect, useMemo, useState } from "react";
import { uploadFile } from "@/utils/oss";
type Props = { value?: string; onChange?: (value: string) => void; height?: number };
export default function RichTextEditor({ value = "", onChange, height = 140 }: Props) {
const [editor, setEditor] = useState<any>();
const [client, setClient] = useState<any>();
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]);
const config = useMemo(() => ({
placeholder: "请输入内容...", hoverbarKeys: { text: { menuKeys: [] } },
......@@ -38,12 +15,8 @@ export default function RichTextEditor({ value = "", onChange, height = 140 }: P
timeout: 5000, fieldName: "image", maxFileSize: 10 * 1024 * 1024, base64LimitSize: 5 * 1024,
headers: { "Content-Type": "multipart/form-data", Authorization: `Bearer ${localStorage.getItem("token") || ""}` },
customUpload: async (file: File, insertFn: (url: string, alt: string) => void) => {
const oss = clientRef.current;
if (!oss) return;
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, "题库图片");
const url = await uploadFile(file, { filenamePrefix: "practice" });
insertFn(url, "题库图片");
},
} },
customPaste: (instance: any, event: ClipboardEvent) => {
......@@ -54,7 +27,7 @@ export default function RichTextEditor({ value = "", onChange, height = 140 }: P
}), []);
return <div style={{ border: "1px solid #ccc", zIndex: 100 }}>
<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" }} />
</Spin>
</div>;
......
......@@ -2,7 +2,7 @@ import { App, Button, DatePicker, Drawer, Form, Input, Popover, Result, Select,
import type { ColumnsType } from "antd/es/table";
import { useEffect, useState } from "react";
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 { deleteQuestion, getBookNames, getQuestion, getQuestionTypes, importQuestions } from "../api";
import type { QuestionBankFilters } from "../query";
......@@ -34,8 +34,9 @@ export default function QuestionBankView() {
];
const upload = async () => { if (!file) return; const result = await importQuestions(file); if (result) setImportResult(result); };
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} />
<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>;
}
import { DatePicker, Input, Button, Space } from "antd";
import type { ColumnsType } from "antd/es/table";
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 { useSaleDiscussionList } from "../useSaleDiscussionList";
const { RangePicker } = DatePicker;
......
......@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table";
import { useState } from "react";
import { useNavigate } from "react-router";
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 type { SaleFilters } from "../query";
import { useSaleList } from "../useSaleList";
......
......@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table";
import { useCallback, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList";
import { AppList, type AppListFilterItem } from "@/components/AppList";
import {
addMemberLevel,
......
......@@ -3,7 +3,7 @@ import type { ColumnsType } from "antd/es/table";
import { useState } from "react";
import { useNavigate } from "react-router";
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 MemberDrawer from "../components/MemberDrawer";
import type { MemberFilters } from "../query";
......
......@@ -14,14 +14,13 @@ import {
Spin,
} from "antd";
const { RangePicker } = DatePicker;
import { AppList } from "@/shared/components/AppList";
import { AppList } from "@/components/AppList";
import { getList, addVersion, editVersion, infoVersion, delVersion } from "../api";
const { TextArea } = Input;
import dayjs from "dayjs";
import Add from "@/assets/images/icon/add.png";
import { useSelector } from "react-redux";
import AliOSS from "ali-oss";
import { getAliOSSSTSToken } from "../../help/request";
import { uploadFile } from "@/utils/oss";
const AppVersion = () => {
const [data, setData] = useState([]);
const [page_size, setpage_size] = useState(10);
......@@ -46,48 +45,9 @@ const AppVersion = () => {
dateRange: [],
});
const [ossClient, setOssClient] = useState(null); // oss 操作
const [STSToken, setSTSToken] = useState(null); // oss 过期设置
const [file, setFile] = useState({});
const [img, setImg] = useState("");
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 = {
name: "file",
accept: ".apk",
......@@ -99,36 +59,23 @@ const AppVersion = () => {
message.error(`只能上传以.apk格式的包,当前文件格式错误`);
return false;
}
setFile(file);
},
customRequest: async () => {
if (!ossClient) {
message.error("ossClient还未初始化", ossClient);
return false;
}
customRequest: async ({ file }) => {
try {
setUploading(true);
const fileExt = file.name.substring(file.name.lastIndexOf("."));
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;
const imageUrl = await uploadFile(file, { bucket: "zxts-common-file", filenamePrefix: "app" });
// 更新图片 URL 状态
setImg(imageUrl);
form.setFieldsValue({ url: imageUrl });
setUploading(false);
// 上传成功,返回 true 表示可以继续上传
return true;
} catch (error) {
// 上传失败,处理错误
console.error("上传文件失败:", error);
message.error("上传文件失败,请重试");
return false;
} finally {
setUploading(false);
}
},
};
......@@ -203,6 +150,9 @@ const AppVersion = () => {
setData(list);
setLoading(false);
};
useEffect(() => {
void init();
}, []);
// 删除
const handleDel = async () => {
const bool = await delVersion({ id: delid });
......
......@@ -15,7 +15,7 @@ import {
} from "antd";
const { TextArea } = Input;
const { RangePicker } = DatePicker;
import { AppList } from "@/shared/components/AppList";
import { AppList } from "@/components/AppList";
import { getAppList, addApp, editApp, delApp, changeStatus, getAppInfo } from "../api";
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import Add from "@/assets/images/icon/add.png";
......
......@@ -6,7 +6,7 @@ import { useSelector } from "react-redux";
import Add from "@/assets/images/icon/add.png";
import coupon from "@/assets/images/icon/coupon.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 FormDrawer from "../components/FormDrawer";
......
import { DatePicker, Input, Popover } from "antd";
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 { FeedbackFilterValue, FeedbackFilters } from "../query";
......
......@@ -3,7 +3,7 @@ import qs from "qs";
import { message } from "antd";
import { get } from "lodash-es";
// oss
// 仅为尚未删除的旧 pages 代码保留的兼容接口;现行模块统一使用 utils/oss。
const getAliOSSSTSToken = async (obj) => {
try {
const res = await axios.post("/common/OssUpload/getUploadToken", qs.stringify(obj));
......@@ -12,12 +12,12 @@ const getAliOSSSTSToken = async (obj) => {
const msg = get(res, "data.message", "获取OSS令牌失败!");
if (code === 200) {
return data;
} else {
}
message.error(msg);
return false;
}
} catch (error) {
} catch {
return false;
}
};
export { getAliOSSSTSToken };
......@@ -7,11 +7,8 @@ import { DomEditor } from "@wangeditor/editor";
import { useLocation, useNavigate } from "react-router";
import { CloseOutlined } from "@ant-design/icons";
import { addHelp, HelpInfo, editHelp, editPreview } from "../api";
import { uploadFiles } from "@/utils/upload";
import AliOSS from "ali-oss";
import dayjs from "dayjs";
import { getAliOSSSTSToken } from "../request";
import { uploadFile } from "@/utils/oss";
import "./HelpAddEditView.less";
......@@ -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 实例
const [editor, setEditor] = useState(null);
......@@ -151,18 +113,8 @@ const HelpAddEdit = () => {
base64LimitSize: 5 * 1024, // 5kb 以下插入 base64 // 用户自定义上传图片
customUpload: async (file, insertFn) => {
setLoading(true);
if (!ossClient) {
console.error("ossClient还未初始化", ossClient);
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 uploadFile(file, { filenamePrefix: "help" });
insertFn(url, "题库图片");
// const { url } = await uploadFiles({ file, file_type: 'question' });
// insertFn(url, '题库图片');
......@@ -183,10 +135,6 @@ const HelpAddEdit = () => {
customUpload: async (file, insertFn) => {
setLoading(true);
if (!ossClient) {
console.error("ossClient还未初始化", ossClient);
return false;
}
// const multipartOptions = {
// partSize: 1024 * 1024, // 分片大小
......@@ -201,14 +149,9 @@ const HelpAddEdit = () => {
// message.success(`视频已上传:${uploadProgress}% , 玩命加载中~~`);
message.success(`视频已上传:99% , 正玩命加载中~~`);
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);
const url = await uploadFile(file, { filenamePrefix: "help" });
// insertFn(result.res.requestUrls[0], '题库视频');
insertFn(result.url, "题库视频");
insertFn(url, "题库视频");
setLoading(false);
} catch (error) {
console.error("上传视频失败:", error);
......@@ -219,9 +162,6 @@ const HelpAddEdit = () => {
},
};
useEffect(() => {
getStsAuthToken();
}, []);
//添加、修改
const onFinish = async (obj) => {
......@@ -336,7 +276,6 @@ const HelpAddEdit = () => {
mode="default"
style={{ borderBottom: "1px solid #ccc" }}
/>
{ossClient && (
<Editor
defaultConfig={editorConfig}
value={html} // 使用 html 作为 value 属性的值
......@@ -348,7 +287,6 @@ const HelpAddEdit = () => {
mode="default"
style={{ height: "500px", overflowY: "hidden" }}
/>
)}
</div>
</Form.Item>
<Form.Item style={{ textAlign: "center" }}>
......
......@@ -15,7 +15,7 @@ import { useNavigate } from "react-router";
import Add from "@/assets/images/icon/add.png";
import dayjs from "dayjs";
import { useSelector } from "react-redux";
import { AppList } from "@/shared/components/AppList";
import { AppList } from "@/components/AppList";
import { useHelpList } from "../useHelpList";
const HelpTable = () => {
const navigate = useNavigate();
......
import { App, Button, Drawer, Form, Input, Result, Space, Upload, message } from "antd";
import { useEffect, useState } from "react";
import { AppList } from "@/shared/components/AppList";
import { AppList } from "@/components/AppList";
import { addWords, delWords, editWords, importWords } from "../api";
import { useSensitiveList } from "../useSensitiveList";
......
......@@ -17,7 +17,7 @@ import {
import dayjs from 'dayjs'
import { useSelector } from 'react-redux'
import { AppList } from '@/shared/components/AppList'
import { AppList } from '@/components/AppList'
import { getTeacherRoles } from '@/modules/access/role/api'
import add from '@/assets/images/icon/add.png'
......
......@@ -372,7 +372,7 @@ const Audit = () => {
open={priviewVisible}
header={null}
footer={null}
destroyOnClose
destroyOnHidden
keyboard={false}
closeIcon={<CloseOutlined style={{ fontSize: 20, color: "#fff" }} />}
maskClosable={false}
......
......@@ -672,7 +672,7 @@ const Audit = () => {
/>
<Drawer
placement="right"
destroyOnClose
destroyOnHidden
onClose={() => setShowModal(false)}
open={showModal}
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 OSS from "ali-oss";
import dayjs from "dayjs";
import { getSTSToken } from "@/api/base";
let ossInstance = null;
const clients = new Map();
// 获取STS Token的包装函数
async function getOSSConfig() {
try {
const { data } = await getSTSToken();
return {
...data,
......@@ -14,82 +14,46 @@ async function getOSSConfig() {
accessKeySecret: data.AccessKeySecret,
stsToken: data.SecurityToken,
};
} catch (error) {
handleError(error, "Failed to retrieve STS token");
}
}
// 创建OSS实例
async function createOSSInstance() {
if (ossInstance) {
return ossInstance;
}
try {
export async function getOSSClient(bucket = "zxts-book-file") {
if (clients.has(bucket)) return clients.get(bucket);
const config = await getOSSConfig();
ossInstance = new OSS({
bucket: "zxts-book-file",
const client = new OSS({
bucket,
...config,
refreshSTSToken: async () => {
return await getOSSConfig();
},
timeout: 180000,
refreshSTSToken: () => getOSSConfig(),
refreshSTSTokenInterval: 14 * 60 * 1000,
});
return ossInstance;
} catch (error) {
handleError(error, "Failed to create OSS instance");
}
clients.set(bucket, client);
return client;
}
// 上传文件
export async function uploadFile(file) {
try {
const oss = await createOSSInstance();
const fileName = typeof file.name === "string" ? file.name : "";
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",
};
function getExtension(file) {
const name = typeof file.name === "string" ? file.name : "";
const fromName = name.includes(".") ? `.${name.split(".").pop().toLowerCase()}` : "";
if (fromName) return fromName;
return { "image/jpeg": ".jpg", "image/png": ".png", "video/mp4": ".mp4" }[(file.type || "").toLowerCase()] || ".bin";
}
const fileExt = extFromName || mimeExtMap[mimeType] || ".bin";
const generatedFileName = `${new Date().getTime()}-${Math.random() * 1000}${fileExt}`;
const result = await oss.put(generatedFileName, file);
return result.url;
} catch (error) {
handleError(error, "Failed to upload file");
}
function buildObjectName(
file,
{ pathPrefix = dayjs().format("YYYY/MM/DD"), filenamePrefix = "file" } = {},
) {
const filename = `${filenamePrefix}-${Date.now()}-${Math.random().toString(36).slice(2)}${getExtension(file)}`;
return pathPrefix ? `${pathPrefix.replace(/\/$/, "")}/${filename}` : filename;
}
// 上传通过URL获取的文件
export async function uploadFileByUrl(url) {
try {
url = url.replace(
"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 uploadFile(file, options = {}) {
const oss = await getOSSClient(options.bucket);
const result = await oss.put(buildObjectName(file, options), file);
return result.url;
}
// 通用错误处理函数
function handleError(error, message) {
console.error(message, error);
throw new Error(`${message}: ${error.message}`);
export async function uploadFileByUrl(url, options = {}) {
const proxyUrl = url.replace("https://ark-content-generation-cn-beijing.tos-cn-beijing.volces.com", "/api/volcano_file");
const response = await axios.get(proxyUrl, { responseType: "blob" });
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 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。
# 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 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论