build / build (push) Successful in 6m16s
契约与健壮性(明细见 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)
437 lines
17 KiB
Vue
437 lines
17 KiB
Vue
<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>
|