Files
coursehomework-front/src/views/homework/Grade.vue
T
lijing d075c4da5d
build / build (push) Successful in 6m16s
fix: 作业模块契约缺陷修复(B-01~B-09)+ 列表页状态/操作列图标化统一
契约与健壮性(明细见 bug检测与修复.md):
- B-01 学生端补 allowLate,恢复「截止后补交」能力
- B-02 ReviewWorkbench 附件 URL 补 serviceHost,修线上图片/下载 404
- B-03/B-04 统计页、全部作业页幻影字段:前端降级 + 后端补字段
- B-05 详情页「接收班级」兜底;B-08 NewHomework 编辑回显与班级树加载竞态
- B-09 移除 api/index.ts 未定义的 GlobalFetch 与硬编码 baseURL

列表页界面统一(我布置的 / 全部作业):
- 状态列统一走 Utils.HW_STATUS_META + hwStatusPill(图标 + 文字 + 同色浅底胶囊)
- 操作列改图标按钮:提交情况 icon-chakan、批阅 icon-shenpi,低频操作收进「更多」下拉(功能全保留)
- 两页操作列同宽,切页不跳列;草稿箱状态列加宽以容纳「定时 …」胶囊
- Submissions 批阅图标与列表行操作对齐

构建:npm run build 通过(exit 0)
2026-09-22 12:30:00 +08:00

437 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { teacherApi } from "@/api/module/teacherHomework";
import type { HomeworkV2Vo, StudentMatrixVo, SubjectVo } from "@/types/homeworkV2";
import ReviewWorkbench from "@/components/ReviewWorkbench.vue";
const route = useRoute();
const router = useRouter();
const serid = ref<number>(Number(route.query.serid) || 0);
/* ============ 任教学科筛选(/subjects/teaching;接口失败或为空时降级内置清单) ============ */
const SUBJECTS = ["语文", "数学", "英语", "物理", "化学", "生物", "道德与法治", "历史", "地理", "科学", "综合实践"];
const subjectOpts = ref<SubjectVo[]>(SUBJECTS.map((s) => ({ subId: s, subName: s })));
const selSubject = ref("");
async function loadSubjects() {
try {
const list = await teacherApi.teachingSubjects().send();
if (Array.isArray(list) && list.length) subjectOpts.value = list;
} catch {
/* 降级用内置清单 */
}
}
function onSubject() {
loadHomeworks();
}
/** 提示文案用的学科名(subId 为存储编码,展示取字典名) */
const selSubjectName = computed(() => subjectOpts.value.find((s) => s.subId === selSubject.value)?.subName || selSubject.value);
/* ============ 待批作业(已发布且勾选了提交渠道=需批改,按待批数降序;受任教学科筛选) ============ */
const homeworks = ref<HomeworkV2Vo[]>([]);
const loadingHw = ref(false);
async function loadHomeworks() {
loadingHw.value = true;
try {
const r = await teacherApi.published({ pageIndex: 1, pageSize: 100, subject: selSubject.value || undefined }).send();
// 仅查阅(submitType 仅 none)=教师无需批改,不进入批改中心
homeworks.value = (r.content || [])
.filter((h) => (h.submitType || []).some((t) => t !== "none"))
.sort((a, b) => (b.progress?.pendingGrade ?? 0) - (a.progress?.pendingGrade ?? 0));
// 未带 serid 进入、或当前作业不在筛选结果中时,自动选中第一份待批作业
if (homeworks.value.length && (!serid.value || !homeworks.value.some((h) => h.id === serid.value))) {
pickHw(homeworks.value[0].id);
} else if (!homeworks.value.length && serid.value) {
// 学科筛选后无待批作业且当前选中被排除:清空选中
serid.value = 0;
router.replace({ path: "/grade" });
loadCounts();
loadQueue();
}
} catch (e: any) {
Message.error(e.message || "加载待批作业失败");
} finally {
loadingHw.value = false;
}
}
/* ============ 班级 / 批改状态筛选 ============ */
type GradeState = "pending" | "graded" | "unsubmitted";
interface ClassCount { classId: string; className: string; pending: number; graded: number; unsubmitted: number }
interface TabCounts { pending: number; graded: number; unsubmitted: number; byClass: ClassCount[] }
const counts = ref<TabCounts | null>(null);
const selClass = ref("");
const selState = ref<GradeState>("pending");
const isPendingRow = (r: StudentMatrixVo) =>
(r.submitStatus === "SUBMITTED" || r.submitStatus === "LATE_SUBMITTED") && r.reviewStatus === "PENDING";
const isGradedRow = (r: StudentMatrixVo) => r.reviewStatus === "REVIEWED";
const isUnsubmittedRow = (r: StudentMatrixVo) => r.submitStatus === "NOT_SUBMITTED";
async function loadCounts() {
if (!serid.value) {
counts.value = null;
return;
}
// 优先 /stats(total + byClass);接口未就绪或缺 byClass 时,兜底用 /students 全量矩阵客户端汇总
try {
const s = await teacherApi.stats(serid.value).send();
if (s && Array.isArray(s.byClass) && s.byClass.length) {
counts.value = {
pending: s.total.pendingGrade,
graded: s.total.graded,
unsubmitted: s.total.unsubmitted,
byClass: s.byClass.map((c) => ({ classId: c.classId, className: c.className, pending: c.progress.pendingGrade, graded: c.progress.graded, unsubmitted: c.progress.unsubmitted })),
};
return;
}
} catch {
/* 走兜底 */
}
try {
const r = await teacherApi.students(serid.value, { pageIndex: 1, pageSize: 1000 }).send();
const list = r.content || [];
const byClass: ClassCount[] = [];
const map = new Map<string, ClassCount>();
for (const row of list) {
let c = map.get(row.classId);
if (!c) {
c = { classId: row.classId, className: row.className, pending: 0, graded: 0, unsubmitted: 0 };
map.set(row.classId, c);
byClass.push(c);
}
if (isUnsubmittedRow(row)) c.unsubmitted++;
else if (isPendingRow(row)) c.pending++;
else if (isGradedRow(row)) c.graded++;
}
counts.value = {
pending: list.filter(isPendingRow).length,
graded: list.filter(isGradedRow).length,
unsubmitted: list.filter(isUnsubmittedRow).length,
byClass,
};
} catch {
counts.value = null;
}
}
function pickFilter(classId: string, state: GradeState) {
if (selClass.value === classId && selState.value === state) return;
selClass.value = classId;
selState.value = state;
loadQueue();
}
/* ============ 待批/已批队列(矩阵 classId + pendingGrade/reviewStatus 筛选) ============ */
const queue = ref<StudentMatrixVo[]>([]);
const idx = ref(0);
const loadingQueue = ref(false);
const homeworkTitle = ref("");
/* ============ 提交内容类型角标(按学生实际提交内容展示:图/附/文,不做筛选) ============ */
const TYPE_OPTS = [
{ v: "image", label: "拍照/图片", tag: "图" },
{ v: "attach", label: "上传附件", tag: "附" },
{ v: "text", label: "文字作答", tag: "文" },
];
const typeTag = (v: string) => TYPE_OPTS.find((t) => t.v === v)?.tag || v;
const current = computed(() => queue.value[idx.value] || null);
const queueTitle = computed(() =>
selState.value === "graded" ? "已批队列" : selState.value === "unsubmitted" ? "未提交名单" : "待批队列"
);
async function loadQueue() {
if (!serid.value) {
queue.value = [];
homeworkTitle.value = "";
return;
}
loadingQueue.value = true;
try {
const hw = homeworks.value.find((h) => h.id === serid.value);
homeworkTitle.value = hw ? hw.title : "";
const r = await teacherApi.students(serid.value, {
classId: selClass.value || undefined,
pendingGrade: selState.value === "pending" ? true : undefined,
reviewStatus: selState.value === "graded" ? "REVIEWED" : undefined,
submitStatus: selState.value === "unsubmitted" ? "NOT_SUBMITTED" : undefined,
pageIndex: 1,
pageSize: 200,
}).send();
queue.value = r.content || [];
idx.value = 0;
} catch (e: any) {
Message.error(e.message || "加载学生名单失败");
queue.value = [];
} finally {
loadingQueue.value = false;
}
}
/* 批阅/打回完成:待批视图本地移出;已批视图(打回)整队刷新;数量同步刷新 */
function onSaved() {
loadCounts();
if (selState.value === "graded") {
loadQueue();
return;
}
const done = idx.value;
queue.value.splice(done, 1);
if (queue.value.length === 0) {
if (homeworks.value.length <= 1) {
homeworks.value = [];
serid.value = 0;
router.replace({ path: "/grade" });
return;
}
loadHomeworks();
return;
}
idx.value = Math.min(done, queue.value.length - 1);
}
function select(i: number) {
if (i < 0 || i >= queue.value.length) return;
idx.value = i;
}
/** 强制工作台重新加载(同一学生打回后重交再进队列) */
const workbenchKey = computed(() => `${serid.value}-${current.value?.studentId ?? ""}-${idx.value}`);
function pickHw(v: number) {
serid.value = v;
selClass.value = "";
selState.value = "pending";
router.replace({ path: "/grade", query: v ? { serid: String(v) } : {} });
loadCounts();
loadQueue();
}
watch(
() => route.query.serid,
(v) => {
const n = Number(v) || 0;
if (n !== serid.value) {
serid.value = n;
selClass.value = "";
selState.value = "pending";
loadCounts();
loadQueue();
}
}
);
onMounted(async () => {
loadSubjects();
await loadHomeworks();
loadCounts();
loadQueue();
});
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<!-- 顶部:任教学科筛选 + 待批作业选择 -->
<div class="fix mb-12">
<div class="fl" style="display: flex; align-items: center; gap: 8px">
<span style="color: #515a6e">任教学科:</span>
<Select v-model="selSubject" clearable placeholder="全部学科" style="width: 140px" @on-change="onSubject">
<Option v-for="s in subjectOpts" :key="s.subId" :value="s.subId">{{ s.subName }}</Option>
</Select>
<span style="color: #515a6e">待批作业:</span>
<Select :model-value="serid" style="width: 380px" placeholder="选择一份有待批的作业" filterable :loading="loadingHw" @on-change="pickHw">
<Option v-for="hw in homeworks" :key="hw.id" :value="hw.id">
{{ hw.subject ? hw.subject + " · " : "" }}{{ hw.title }}(待批 {{ hw.progress!.pendingGrade }})
</Option>
</Select>
</div>
</div>
<p v-if="!homeworks.length" class="tip">🎉 {{ selSubject ? `「${selSubjectName}」学科下暂无需要批改的作业。` : "当前没有需要批改的作业。" }}</p>
<p v-else-if="!serid" class="tip">请从上方选择一份待批改的作业。</p>
<div v-else>
<!-- 班级 / 批改状态筛选(/stats 或 /students 兜底汇总) -->
<div v-if="counts" class="class-bar">
<span class="grp">
<span class="cls" :class="{ on: !selClass }" @click="pickFilter('', selState)">全部班级({{ counts.pending + counts.graded + counts.unsubmitted }}):</span>
<span class="seg" :class="{ on: !selClass && selState === 'pending' }" @click="pickFilter('', 'pending')">待批改({{ counts.pending }})</span>
<span class="seg" :class="{ on: !selClass && selState === 'graded' }" @click="pickFilter('', 'graded')">已批改({{ counts.graded }})</span>
<span class="seg" :class="{ on: !selClass && selState === 'unsubmitted' }" @click="pickFilter('', 'unsubmitted')">未提交({{ counts.unsubmitted }})</span>
</span>
<span v-for="c in counts.byClass" :key="c.classId" class="grp">
<span class="cls" :class="{ on: selClass === c.classId }" @click="pickFilter(c.classId, selState)">{{ c.className }}({{ c.pending + c.graded + c.unsubmitted }}):</span>
<span class="seg" :class="{ on: selClass === c.classId && selState === 'pending' }" @click="pickFilter(c.classId, 'pending')">待批改({{ c.pending }})</span>
<span class="seg" :class="{ on: selClass === c.classId && selState === 'graded' }" @click="pickFilter(c.classId, 'graded')">已批改({{ c.graded }})</span>
<span class="seg" :class="{ on: selClass === c.classId && selState === 'unsubmitted' }" @click="pickFilter(c.classId, 'unsubmitted')">未提交({{ c.unsubmitted }})</span>
</span>
</div>
<div class="workbench fix">
<!-- 左:批改队列 -->
<div class="wb-left fl">
<div class="wb-h">{{ queueTitle }}({{ queue.length }} 份)</div>
<p v-if="!loadingQueue && !queue.length" class="done">🎉 当前筛选下暂无学生</p>
<Spin v-if="loadingQueue" fix />
<ul class="qlist">
<li v-for="(s, i) in queue" :key="s.studentId" :class="{ act: i === idx }" @click="select(i)">
<div class="q1">
{{ s.studentName }}
<span v-if="s.isLate" class="q-late">逾期</span>
<span v-if="s.submitStatus === 'NOT_SUBMITTED'" class="q-unsub">未提交</span>
<span v-for="t in s.contentTypes || []" :key="t" class="q-tag">{{ typeTag(t) }}</span>
</div>
<div class="q2">{{ s.className }} · {{ s.submittedAt ? String(s.submittedAt).slice(5, 16) : "" }}</div>
</li>
</ul>
</div>
<!-- 右:提交内容 + 批阅(版本切换/批阅/打回共用工作台;未提交名单无批阅操作) -->
<div class="wb-right fr">
<div class="wb-h">
{{ homeworkTitle }}
<span class="pos" v-if="current">第 {{ idx + 1 }} / {{ queue.length }} 份 · {{ current.studentName }}({{ current.className }})</span>
</div>
<div class="wb-panel" v-if="current && selState !== 'unsubmitted'">
<ReviewWorkbench :key="workbenchKey" :homeworkId="serid" :student="current" @saved="onSaved" />
</div>
<p v-else-if="current" class="tip">该学生尚未提交作业,无法批阅。可在线下催交,或使用「我布置的 → 更多 → 催交」发送提醒。</p>
<p v-else class="tip">当前筛选下暂无待处理的学生提交。</p>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.tip {
color: #808695;
padding: 20px 0;
}
.class-bar {
display: flex;
flex-wrap: wrap;
gap: 4px 24px;
padding: 6px 2px 10px;
font-size: 13px;
color: #515a6e;
.grp {
display: inline-flex;
align-items: center;
gap: 8px;
}
.cls {
cursor: pointer;
font-weight: 600;
color: #17233d;
&:hover,
&.on {
color: #2d8cf0;
}
}
.seg {
cursor: pointer;
padding: 1px 8px;
border-radius: 3px;
background: #f5f7fa;
&:hover {
color: #2d8cf0;
}
&.on {
background: #2d8cf0;
color: #fff;
}
}
}
.done {
color: #19be6b;
padding: 16px 8px;
}
.workbench {
display: flex;
gap: 16px;
align-items: stretch;
}
.wb-h {
font-weight: 600;
color: #17233d;
padding: 8px 0;
border-bottom: 1px solid #e8eaec;
.pos {
font-weight: 400;
color: #808695;
font-size: 13px;
margin-left: 10px;
}
}
.wb-left {
width: 240px;
flex: none;
border: 1px solid #e8eaec;
border-radius: 4px;
padding: 0 10px 10px;
position: relative;
max-height: calc(100vh - 220px);
overflow-y: auto;
.qlist {
list-style: none;
margin: 0;
padding: 0;
li {
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
.q1 {
color: #17233d;
display: flex;
align-items: center;
gap: 6px;
}
.q-late {
background: #ff9900;
color: #fff;
border-radius: 3px;
font-size: 11px;
padding: 0 4px;
}
.q-tag {
color: #2d8cf0;
background: #f0faff;
border: 1px solid #d4eaf7;
border-radius: 3px;
font-size: 11px;
padding: 0 4px;
}
.q-unsub {
background: #c5c8ce;
color: #fff;
border-radius: 3px;
font-size: 11px;
padding: 0 4px;
}
.q2 {
font-size: 12px;
color: #808695;
}
&.act {
background: #e8f3ff;
}
&:hover {
background: #f5f7fa;
}
}
}
}
.wb-right {
flex: 1;
min-width: 0;
border: 1px solid #e8eaec;
border-radius: 4px;
padding: 0 14px 14px;
position: relative;
.wb-panel {
padding-top: 6px;
max-height: calc(100vh - 260px);
overflow-y: auto;
}
}
</style>