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

refactor(admin): migrate books question bank module

上级 594f41d3
import { message } from "antd";
import request from "@/utils/request";
import type { QuestionBankQuery } 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 } };
if (response.data?.code === 200) {
if (notify) message.success(response.data.message);
return response.data.data;
}
message.error(response.data?.message || "数据获取失败!");
} catch {}
return false;
}
export const getQuestionList = (query: QuestionBankQuery) => post("/book/question/getList", query);
export const getQuestion = (id: string | number) => post("/book/question/getInfoById", { id });
export const addQuestion = (data: Record<string, unknown>) => post("/book/question/add", data, true);
export const editQuestion = (data: Record<string, unknown>) => post("/book/question/edit", data, true);
export const deleteQuestion = (id: string | number) => post("/book/question/del", { id }, true);
export const addQuestionType = (typename: string) => post("/book/question/type/add", { typename }, true);
export const getQuestionTypes = () => post("/book/question/type/getList", { page: 1, page_size: 999 });
export const getBookNames = () => post("book/teacher/getListAll");
export async function importQuestions(file: File) {
try {
const form = new FormData();
form.append("file", file);
const response = (await request.post("/book/question/import", form, {
headers: { "Content-Type": "multipart/form-data" },
})) as { data?: { code?: number; data?: any; message?: string } };
if (response.data?.code === 200) return { msg: response.data.message, data: response.data.data };
message.error(response.data?.message || "数据获取失败!");
} catch {}
return false;
}
import { Button, Checkbox, Drawer, Form, Input, Select, Space } from "antd";
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react";
import { addQuestion, addQuestionType, editQuestion } from "../api";
type Props = { open: boolean; onClose: () => void; question: any; types: any[]; books: any[]; onTypesChange: () => void; onSaved: () => void };
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);
useEffect(() => {
if (!open) return;
let values: any = question || { titles: "", type_id: undefined, question_style: undefined, analysis: "", answers: "", question_list: [], book_id: undefined };
if (typeof values.question_list === "string") { try { values = { ...values, question_list: JSON.parse(values.question_list) }; } catch { values = { ...values, question_list: [] }; } }
form.setFieldsValue(values);
}, [form, open, question]);
const addType = async () => { const result: any = await addQuestionType(typeName); if (result) { await onTypesChange(); form.setFieldValue("type_id", result.type_id); setTypeName(""); setAddingType(false); } };
const submit = async (values: any) => {
if ([1, 2, 3].includes(values.question_style)) {
const answers = values.question_list || []; const correct = answers.filter((item: any) => item.correct).length;
if (([1, 3].includes(values.question_style) && correct !== 1) || (values.question_style === 2 && correct < 2)) {
form.setFields([{ name: ["question_list", Math.max(0, answers.length - 1), "correct"], errors: [values.question_style === 2 ? "多选题需要2个及以上正确答案!" : `${values.question_style === 3 ? "判断题" : "单选题"}有且仅有一个正确答案!`] }]); return;
}
values.question_list = JSON.stringify(answers);
}
const ok = question?.id ? await editQuestion({ ...values, id: question.id }) : await addQuestion(values);
if (ok) { onClose(); onSaved(); }
};
const needsOptions = [1, 2, 3].includes(questionStyle);
return <Drawer open={open} mask={false} footer={null} onClose={onClose} width={720}>
<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="titles" label="题干名称" rules={[{ required: true, message: "请输入题干名称" }]} extra={questionStyle === 4 ? "请在需要填空的位置使用2个或以上的下划线进行区分!" : ""}><Input.TextArea autoSize={{ minRows: 4, maxRows: 8 }} 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: "请输入内容" }]}><Input.TextArea placeholder="请输入选项" /></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>}
{[4, 5].includes(questionStyle) && <Form.Item name="answers" label="正确答案"><Input.TextArea autoSize={{ minRows: 4, maxRows: 8 }} placeholder="请输入正确答案" /></Form.Item>}
<Form.Item wrapperCol={{ offset: 5 }}><Space><Button onClick={onClose}>取消</Button><Button type="primary" htmlType="submit">提交</Button></Space></Form.Item>
</Form>
</Drawer>;
}
import type { Dayjs } from "dayjs";
export interface QuestionBankFilters {
book_id?: string | number;
titles?: string;
type_id?: string | number;
question_style?: number;
time?: [Dayjs | null, Dayjs | null] | null;
}
export interface QuestionBankQuery {
page: number;
page_size: number;
book_id?: string | number;
titles?: string;
type_id?: string | number;
question_style?: number;
start_time?: number;
end_time?: number;
}
export function buildQuestionBankQuery(page: number, pageSize: number, filters: QuestionBankFilters): QuestionBankQuery {
const query: QuestionBankQuery = { page, page_size: pageSize };
(["book_id", "titles", "type_id", "question_style"] as const).forEach((key) => {
if (filters[key]) query[key] = filters[key] as never;
});
if (filters.time?.[0] && filters.time[1]) {
query.start_time = filters.time[0].startOf("day").unix();
query.end_time = filters.time[1].endOf("day").unix();
}
return query;
}
import { Navigate, useRoutes } from "react-router";
import QuestionBankView from "./views/QuestionBankView";
export default function QuestionBankRoutes() {
return useRoutes([{ index: true, element: <QuestionBankView /> }, { path: "*", element: <Navigate to="." replace /> }]);
}
import { useCallback, useEffect, useRef, useState } from "react";
import { getQuestionList } from "./api";
import { buildQuestionBankQuery, type QuestionBankFilters, type QuestionBankQuery } from "./query";
export function useQuestionBankList() {
const [filters, setFilters] = useState<QuestionBankFilters>({});
const [applied, setApplied] = useState<QuestionBankFilters>({});
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [rows, setRows] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const last = useRef<QuestionBankQuery>(buildQuestionBankQuery(1, 10, {}));
const load = useCallback(async (query: QuestionBankQuery) => {
last.current = query; setLoading(true); setError(null);
try { const result: any = await getQuestionList(query); if (!result) throw new Error("请求失败"); setRows(result.list || []); setTotal(result.total || 0); }
catch (e) { setError(e instanceof Error ? e : new Error("请求失败")); }
finally { setLoading(false); }
}, []);
useEffect(() => { void load(last.current); }, [load]);
const run = (p: number, s: number, f: QuestionBankFilters) => void load(buildQuestionBankQuery(p, s, f));
const search = () => { setApplied(filters); setPage(1); run(1, pageSize, filters); };
return { filters, setFilters, page, pageSize, rows, total, loading, error, search,
reset: () => { setFilters({}); setApplied({}); setPage(1); run(1, pageSize, {}); },
refresh: () => { setApplied(filters); run(page, pageSize, filters); }, retry: () => void load(last.current), reloadFirst: search,
changePage: (p: number) => { setPage(p); run(p, pageSize, applied); },
changePageSize: (s: number) => { setPage(1); setPageSize(s); run(1, s, applied); },
};
}
import { App, Button, DatePicker, Drawer, Form, Input, Popover, Result, Select, Space, Upload } 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 { downloadFile } from "@/utils/common";
import { deleteQuestion, getBookNames, getQuestion, getQuestionTypes, importQuestions } from "../api";
import type { QuestionBankFilters } from "../query";
import { useQuestionBankList } from "../useQuestionBankList";
import QuestionForm from "../components/QuestionForm";
const { RangePicker } = DatePicker;
const questionStyles = ["-", "单选题", "多选题", "判断题", "填空题", "简答题"];
export default function QuestionBankView() {
const list = useQuestionBankList(); const { modal } = App.useApp(); const { operationPermissionsList = [] } = useSelector((state: any) => state.user);
const [types, setTypes] = useState<any[]>([]); const [books, setBooks] = useState<any[]>([]); const [formOpen, setFormOpen] = useState(false); const [question, setQuestion] = useState<any>();
const [importOpen, setImportOpen] = useState(false); const [file, setFile] = useState<File>(); const [importResult, setImportResult] = useState<any>();
const loadTypes = async () => { const value: any = await getQuestionTypes(); if (value) setTypes(value.list || []); };
useEffect(() => { void loadTypes(); void getBookNames().then((value: any) => value && setBooks(value)); }, []);
const filters: AppListFilterItem<QuestionBankFilters>[] = [
{ name: "book_id", label: "书籍名称", render: (f) => <Select allowClear showSearch optionFilterProp="children" value={f.value} onChange={f.onChange} style={{ width: 200 }} placeholder="请输入书籍名称">{books.map((book) => <Select.Option key={book.book_id} value={book.book_id}>{book.name}</Select.Option>)}</Select> },
{ name: "titles", label: "题干名称", render: (f) => <Input allowClear value={f.value || ""} onChange={(e) => f.onChange(e.target.value)} placeholder="请输入题干名称" /> },
{ name: "type_id", label: "题目分类", render: (f) => <Select allowClear value={f.value} onChange={f.onChange} style={{ width: 180 }} placeholder="请选择题目分类">{types.map((type) => <Select.Option key={type.id} value={type.id}>{type.typename}</Select.Option>)}</Select> },
{ name: "question_style", label: "题目类型", render: (f) => <Select allowClear value={f.value} onChange={f.onChange} style={{ width: 160 }} placeholder="请选择题目类型">{questionStyles.slice(1).map((name, index) => <Select.Option key={index + 1} value={index + 1}>{name.replace("题", "")}</Select.Option>)}</Select> },
{ name: "time", label: "创建时间", render: (f) => <RangePicker value={f.value} onChange={(value) => f.onChange(value)} /> },
];
const edit = async (id: number) => { const value = await getQuestion(id); if (value) { setQuestion(value); setFormOpen(true); } };
const columns: ColumnsType<any> = [
{ title: "ID", dataIndex: "id", align: "center" },
...["书籍名称:book_name", "题目名称:titles"].map((text) => { const [title, dataIndex] = text.split(":"); return { title, dataIndex, align: "center" as const, width: 250, render: (html: string) => <Popover overlayStyle={{ maxWidth: 500 }} content={<div dangerouslySetInnerHTML={{ __html: html }} />}><div style={{ display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", overflow: "hidden" }} dangerouslySetInnerHTML={{ __html: html }} /></Popover> }; }),
{ title: "题目分类", dataIndex: "type_id", align: "center", render: (id) => types.find((type) => type.id === id)?.typename },
{ title: "题目类型", dataIndex: "question_style", align: "center", render: (style) => questionStyles[style] || "-" },
{ title: "创建时间", dataIndex: "create_time", align: "center" }, { title: "更新时间", dataIndex: "update_time", align: "center" },
{ title: "操作", render: (_, row) => <Space>{operationPermissionsList.includes("/books/question-bank/getInfoById") && <Button onClick={() => edit(row.id)}>编辑</Button>}{operationPermissionsList.includes("/books/question-bank/del") && <Button onClick={() => modal.confirm({ title: "提示", content: "题目将永久删除,是否继续?", onOk: async () => { if (await deleteQuestion(row.id)) list.refresh(); } })}>删除</Button>}</Space> },
];
const upload = async () => { if (!file) return; const result = await importQuestions(file); if (result) setImportResult(result); };
const closeImport = () => { setImportOpen(false); setFile(undefined); setImportResult(undefined); };
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>
</div>;
}
......@@ -17,7 +17,7 @@ const SaleTable = lazy(() => import("@/pages/books/sale/table"));
const SaleEdit = lazy(() => import("@/pages/books/sale/edit"));
const DiscussList = lazy(() => import("@/pages/books/sale/discussList/index"));
const DiscussDetail = lazy(() => import("@/pages/books/sale/discussDetail/index"));
const QuestionBank = lazy(() => import("@/pages/books/question-bank/index"));
const QuestionBankRoutes = lazy(() => import("./question-bank/routes"));
const DiscussionRoutes = lazy(() => import("./discussion/routes"));
const OrderManagementLayout = lazy(() => import("@/pages/books/order-management/index"));
const OrderTable = lazy(() => import("@/pages/books/order-management/tab/table"));
......@@ -62,7 +62,7 @@ export default function BooksRoutes() {
{ path: "discuss-detail", Component: DiscussDetail },
],
},
{ path: "question-bank", Component: QuestionBank },
{ path: "question-bank/*", Component: QuestionBankRoutes },
{ path: "discussion/*", Component: DiscussionRoutes },
{
path: "order-management",
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论