diff --git a/src/components/ReviewModal.vue b/src/components/ReviewModal.vue index 67ba02e..1f0d9bc 100644 --- a/src/components/ReviewModal.vue +++ b/src/components/ReviewModal.vue @@ -1,315 +1,32 @@ - - diff --git a/src/components/ReviewWorkbench.vue b/src/components/ReviewWorkbench.vue new file mode 100644 index 0000000..f5f3312 --- /dev/null +++ b/src/components/ReviewWorkbench.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/src/views/homework/Grade.vue b/src/views/homework/Grade.vue index b3f1773..69b5d30 100644 --- a/src/views/homework/Grade.vue +++ b/src/views/homework/Grade.vue @@ -1,36 +1,29 @@ + diff --git a/src/views/homework/Submissions.vue b/src/views/homework/Submissions.vue index a0a3754..750fbf9 100644 --- a/src/views/homework/Submissions.vue +++ b/src/views/homework/Submissions.vue @@ -2,33 +2,30 @@ import { ref, reactive, computed, h, onMounted, watch } from "vue"; import { useRoute } from "vue-router"; import { Message } from "view-ui-plus"; -import { homeworkApi } from "@/api/module/homework"; -import type { SubmissionsResultVo, SubmissionBrief, HomeworkVo } from "@/types/homework"; +import { teacherApi } from "@/api/module/teacherHomework"; +import type { + BasePageResult, + HomeworkStatsVo, + HomeworkV2Vo, + StudentMatrixVo, + SubmitStatusV2, +} from "@/types/homeworkV2"; +import ReviewModal from "@/components/ReviewModal.vue"; +import MyConfirm from "@/components/MyConfirm"; import config from "@/assets/config/host.config"; import Utils from "@/utils"; const serviceBase = config.isMock ? "" : config.serviceHost; -function previewUrl(v: number | string) { - const s = String(v); - if (s.startsWith("data:") || s.startsWith("http")) return s; - return `${serviceBase}/api/file/Preview?id=${s}`; -} -const imgPreview = reactive({ open: false, url: "" }); -function zoom(url: string) { - imgPreview.url = url; - imgPreview.open = true; -} - const route = useRoute(); const serid = ref(Number(route.query.serid) || 0); -/* 未指定作业时,先在“我布置的”里选一份 */ -const myHomeworks = ref([]); +/* ============ 未指定作业时:从我布置的已发布作业里选一份 ============ */ +const myHomeworks = ref([]); const loadingList = ref(false); async function loadMyHomeworks() { loadingList.value = true; try { - const r = await homeworkApi.sendList({ scope: "user", pageSize: 100, pageIndex: 1 }).send(); + const r = await teacherApi.published({ pageIndex: 1, pageSize: 100 }).send(); myHomeworks.value = r.content || []; } catch (e: any) { Message.error(e.message || "加载作业失败"); @@ -37,218 +34,324 @@ async function loadMyHomeworks() { } } -/* ============ 提交情况数据 ============ */ -const data = ref(null); +/* ============ 汇总统计卡片(stats:总进度) ============ */ +const stats = ref(null); + +/* ============ 矩阵查询 ============ */ +const SUBMIT_TABS: { name: string; label: string }[] = [ + { name: "", label: "全部" }, + { name: "NOT_SUBMITTED", label: "未交" }, + { name: "SUBMITTED", label: "已交待批" }, + { name: "LATE_SUBMITTED", label: "逾期补交" }, + { name: "RETURNED", label: "待订正" }, + { name: "REVIEWED", label: "已批阅" }, +]; +const submitStatus = ref(""); +const classId = ref(""); +const pendingOnly = ref(false); +const pageNum = ref(1); +const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10")); + +const EMPTY_PAGE: BasePageResult = { content: [], total: 0, pageIndex: 1, pageSize: 10, totalPages: 0, hasNext: false }; +const page = ref>(EMPTY_PAGE); const loading = ref(false); -const submitStatus = ref("all"); // all/submitted/unsubmitted/graded/needRedo async function load() { if (!serid.value) { - data.value = null; + page.value = EMPTY_PAGE; + stats.value = null; return; } loading.value = true; try { - data.value = await homeworkApi.submissions(serid.value, submitStatus.value).send(); + page.value = await teacherApi + .students(serid.value, { + pageIndex: pageNum.value, + pageSize: pageSize.value, + classId: classId.value || undefined, + submitStatus: (submitStatus.value || undefined) as SubmitStatusV2 | undefined, + pendingGrade: pendingOnly.value ? true : undefined, + }) + .send(); } catch (e: any) { Message.error(e.message || "加载提交情况失败"); - data.value = null; + page.value = EMPTY_PAGE; } finally { loading.value = false; } } -watch(submitStatus, load); +async function loadStats() { + if (!serid.value) return; + try { + stats.value = await teacherApi.stats(serid.value).send(); + } catch { + stats.value = null; + } +} +function doSearch() { + pageNum.value = 1; + load(); +} +function pageUpdate(p: number) { + pageNum.value = p; + load(); +} +function pageSizeUpdate(size: number) { + pageNum.value = 1; + pageSize.value = size; + localStorage.setItem("customPageCount", String(size)); + load(); +} +function pickHw(v: number) { + serid.value = v; + submitStatus.value = ""; + classId.value = ""; + pendingOnly.value = false; + pageNum.value = 1; + load(); + loadStats(); +} + watch( () => route.query.serid, (v) => { serid.value = Number(v) || 0; - submitStatus.value = "all"; - load(); + pickHw(serid.value); } ); onMounted(async () => { await loadMyHomeworks(); load(); + loadStats(); }); -const summary = computed(() => data.value?.summary || null); +const total = computed(() => stats.value?.total || null); +const summaryTitle = computed(() => { + const hw = myHomeworks.value.find((x) => x.id === serid.value); + return hw ? hw.title : stats.value?.title || ""; +}); -const STATUS_TEXT: Record = { unsubmitted: "未交", submitted: "已交待批", graded: "已批改", needRedo: "待订正", redone: "已订正" }; -const STATUS_COLOR: Record = { unsubmitted: "default", submitted: "warning", graded: "success", needRedo: "error", redone: "primary" }; +/* ============ 状态展示 ============ */ +const STATUS_TEXT: Record = { + NOT_SUBMITTED: "未交", + SUBMITTED: "已交待批", + LATE_SUBMITTED: "逾期补交", + RETURNED: "待订正", + REVIEWED: "已批阅", +}; +const STATUS_COLOR: Record = { + NOT_SUBMITTED: "default", + SUBMITTED: "blue", + LATE_SUBMITTED: "warning", + RETURNED: "error", + REVIEWED: "success", +}; +const REVIEW_TEXT: Record = { PENDING: "待批阅", REVIEWED: "已批阅", RETURNED: "已打回" }; +const REVIEW_COLOR: Record = { PENDING: "warning", REVIEWED: "success", RETURNED: "error" }; -/* 学生表格列(纯 render,避免 iView 具名 slot 差异) */ -const stuColumns = [ - { title: "学生", key: "studentName", minWidth: 120 }, +function fmtTime(v?: string | null) { + return v ? String(v).slice(5, 16) : "—"; +} +function previewUrl(u: string) { + if (/^(https?:)?\/\//.test(u) || u.startsWith("data:")) return u; + return `${serviceBase}${u}`; +} + +/* ============ 批阅弹窗 / 定向提醒 ============ */ +const reviewModal = reactive({ open: false, row: null as StudentMatrixVo | null }); +function openReview(row: StudentMatrixVo) { + reviewModal.row = row; + reviewModal.open = true; +} +function onReviewed() { + load(); + loadStats(); + loadMyHomeworks(); +} +function remindOne(row: StudentMatrixVo) { + const label = row.isRead ? "未交" : "未读未交"; + MyConfirm({ + title: "提醒", + msg: `向 ${row.studentName}(${row.className})发送${label}提醒?`, + async onOk() { + try { + const r = await teacherApi.remind(serid.value, row.isRead ? "NOT_SUBMITTED" : "NOT_READ", [row.studentId]).send(); + Message.success(`已提醒 ${r?.reminded ?? 0} 位学生`); + } catch (e: any) { + Message.error(e.message || "提醒失败"); + } + }, + }); +} + +/* ============ 矩阵列 ============ */ +const columns = computed(() => [ + { title: "学生", key: "studentName", minWidth: 110, align: "left" }, + { title: "班级", key: "className", width: 110, align: "left" }, { - title: "状态", + title: "阅读", + width: 90, + align: "center", + render: (h: any, p: any) => { + const r = p.row as StudentMatrixVo; + if (!r.isRead) return h("span", { style: "color:#ed4014;" }, "未读"); + return h( + "Tooltip", + { transfer: true, props: { content: `首次 ${fmtTime(r.firstReadAt)} / 最近 ${fmtTime(r.lastReadAt)}`, placement: "top" } }, + () => h("span", { style: "color:#19be6b;" }, "已读") + ); + }, + }, + { + title: "提交状态", width: 110, align: "center", - render: (h: any, p: any) => h("Tag", { color: STATUS_COLOR[p.row.submitStatus] || "default" }, () => STATUS_TEXT[p.row.submitStatus] || p.row.submitStatus), + render: (h: any, p: any) => { + const r = p.row as StudentMatrixVo; + const tag = h("Tag", { color: STATUS_COLOR[r.submitStatus] || "default" }, () => STATUS_TEXT[r.submitStatus] || r.submitStatus); + if (r.isLate && r.submitStatus !== "NOT_SUBMITTED") { + return h("span", { style: "display:inline-flex;align-items:center;gap:4px;" }, [tag, h("span", { style: "color:#ed4014;font-size:12px;" }, "逾期")]); + } + return tag; + }, + }, + { title: "提交时间", width: 130, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.submittedAt)) }, + { + title: "评分", + width: 70, + align: "center", + render: (h: any, p: any) => h("span", { style: (p.row.score != null ? "color:#17233d;font-weight:600;" : "color:#c5c8ce;") }, p.row.score == null ? "—" : String(p.row.score)), + }, + { + title: "批阅状态", + width: 95, + align: "center", + render: (h: any, p: any) => h("Tag", { color: REVIEW_COLOR[p.row.reviewStatus] || "default" }, () => REVIEW_TEXT[p.row.reviewStatus] || p.row.reviewStatus), + }, + { + title: "附件", + width: 110, + align: "left", + render: (h: any, p: any) => { + const th: string[] = p.row.thumbnails || []; + if (!th.length) return h("span", { style: "color:#c5c8ce;" }, "—"); + return h( + "div", + { style: "display:flex;gap:4px;" }, + th.slice(0, 3).map((u) => + h("img", { + src: previewUrl(u), + style: "width:32px;height:32px;object-fit:cover;border-radius:3px;border:1px solid #e8eaec;cursor:zoom-in;", + onClick: () => zoom(previewUrl(u)), + }) + ) + ); + }, }, - { title: "提交时间", width: 150, render: (h: any, p: any) => h("span", {}, p.row.submittedAt ? String(p.row.submittedAt).slice(5, 16) : "—") }, - { title: "分数", width: 80, align: "center", render: (h: any, p: any) => h("span", {}, p.row.score == null ? "—" : p.row.score) }, - { title: "等级", width: 80, align: "center", render: (h: any, p: any) => h("span", {}, p.row.level || "—") }, { title: "操作", - width: 110, + width: 130, align: "center", - render: (h: any, p: any) => Utils.operatingHandle(h, p.row.hasGrade ? "再批" : "批改", p.row.hasGrade ? "icon-edit" : "icon-chakan", () => openGrade(p.row as SubmissionBrief)), + fixed: "right", + render: (h: any, p: any) => { + const r = p.row as StudentMatrixVo; + const nodes: any[] = []; + if (r.submitStatus !== "NOT_SUBMITTED") { + nodes.push(Utils.operatingHandle(h, r.reviewStatus === "PENDING" ? "批阅" : "查看", r.reviewStatus === "PENDING" ? "icon-edit" : "icon-chakan", () => openReview(r))); + } else { + nodes.push(Utils.operatingHandle(h, "提醒", "icon-fasong", () => remindOne(r))); + } + return nodes; + }, }, -]; +]); +const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true })); -/* ============ 内联批改(走 Grade 接口,核心闭环即时批改) ============ */ -const gradeModal = reactive({ - open: false, - subId: 0, - studentName: "", - mode: "grade", - action: "grade", - score: "" as string | number, - level: "A", - comment: "", - answerText: "", - answerImages: [] as string[], - loadingAnswer: false, -}); -async function openGrade(stu: SubmissionBrief) { - gradeModal.open = true; - gradeModal.subId = stu.subId; - gradeModal.studentName = stu.studentName; - gradeModal.mode = "grade"; - gradeModal.action = "grade"; - gradeModal.score = stu.score ?? ""; - gradeModal.level = stu.level || "A"; - gradeModal.comment = ""; - gradeModal.answerText = ""; - gradeModal.answerImages = []; - gradeModal.loadingAnswer = true; - try { - const d = await homeworkApi.submissionDetail(stu.subId).send(); - gradeModal.answerText = d.text || ""; - gradeModal.answerImages = (d.images || []).map((v: any) => String(v)).filter(Boolean); - } catch (e: any) { - Message.error(e.message || "加载作答失败"); - } finally { - gradeModal.loadingAnswer = false; - } -} -async function doGrade(action: "grade" | "redo" | "excellent") { - const payload: any = { subId: gradeModal.subId, action }; - if (action !== "redo") { - if (gradeModal.score !== "" && gradeModal.score !== null) payload.score = Number(gradeModal.score); - payload.level = gradeModal.level; - } - payload.comment = gradeModal.comment; - try { - await homeworkApi.grade(payload).send(); - Message.success(action === "redo" ? "已要求订正" : action === "excellent" ? "已标为优秀" : "批改已保存"); - gradeModal.open = false; - load(); - } catch (e: any) { - Message.error(e.message || "批改失败"); - } -} -function onSave() { - doGrade(gradeModal.mode === "redo" ? "redo" : "grade"); +/* 图片放大 */ +const imgPreview = reactive({ open: false, url: "" }); +function zoom(url: string) { + imgPreview.url = url; + imgPreview.open = true; }