feat(M5-P2): 教师端批阅链路切换 V2

- ReviewWorkbench:从 ReviewModal 抽出可内嵌批阅工作台(版本切换/批阅/打回),
  ReviewModal 保留为其弹窗形态
- Submissions 切 V2 学生矩阵(PRD 页 5):GET /students 分页 + classId/
  submitStatus/pendingGrade 筛选,未交占位行可定向提醒(remind studentIds),
  批阅走 ReviewModal,汇总卡片走 GET /stats
- Grade 切 V2 批改中心(PRD 页 6):待批作业列表按 progress.pendingGrade 排序,
  队列取矩阵 pendingGrade=true,右侧内嵌 ReviewWorkbench,批完自动前移
- HomeworkDetails 双模式:/list/detail 走 V2 详情+统计卡片+分班进度表(PRD 页 4),
  /homeworklist/detail 保留 V1 冻结 Detail(管理端看他人作业,V2 有归属校验)
This commit is contained in:
wanghu
2026-09-16 02:37:08 +08:00
parent 39824fbb3d
commit 23d67161e4
5 changed files with 956 additions and 920 deletions
+317
View File
@@ -0,0 +1,317 @@
<template>
<!-- V2 批阅工作台:历史版本切换 + 保存批阅(review)/ 打回(return)。
Submissions(弹窗形态,见 ReviewModal)与 Grade(批改中心右侧面板)共用。 -->
<div class="rv-body">
<!-- 历史版本切换 -->
<div class="rv-versions" v-if="versions.length">
<span class="rv-label">提交版本:</span>
<RadioGroup :value="curIdx" type="button" size="small" @on-change="(i: number) => (curIdx = i)">
<Radio v-for="(v, i) in versions" :key="v.submissionId" :label="i">
v{{ v.version }}{{ v.isLatest ? "(当前)" : "" }}
</Radio>
</RadioGroup>
</div>
<Spin v-if="loading" fix style="position: relative; padding: 40px 0"></Spin>
<template v-else-if="cur">
<div class="rv-meta">
<Tag size="small" :color="SUBMIT_COLOR[cur.submitStatus]">{{ cur.submitStatusDesc || cur.submitStatus }}</Tag>
<span v-if="cur.isLate" class="late">逾期</span>
<span class="time">提交时间:{{ cur.submittedAt ? String(cur.submittedAt).slice(0, 16) : "—" }}</span>
<template v-if="cur.reviewStatus !== 'PENDING'">
<span class="time">批阅:{{ cur.reviewedBy || "—" }} {{ cur.reviewedAt ? String(cur.reviewedAt).slice(0, 16) : "" }}</span>
</template>
</div>
<div class="rv-text" v-if="cur.text">{{ cur.text }}</div>
<div class="rv-text empty" v-else>(无文字作答)</div>
<!-- 附件:图片缩略图 + 文件条目 -->
<div class="rv-atts" v-if="cur.attachments && cur.attachments.length">
<div class="rv-imgs" v-if="imageAtts.length">
<div class="rv-img" v-for="(a, i) in imageAtts" :key="i" @click="zoom(attUrl(a))">
<img :src="attUrl(a)" />
</div>
</div>
<div class="rv-files" v-if="fileAtts.length">
<span class="rv-file" v-for="(a, i) in fileAtts" :key="i">
{{ a.fileName || "附件" + (i + 1) }}
<a :href="attDownUrl(a)" target="_blank">下载</a>
</span>
</div>
</div>
<!-- 上一版批阅结果(历史版本展示) -->
<Alert v-if="cur.reviewStatus !== 'PENDING' && (cur.score != null || cur.comment)" type="success" show-icon style="margin-top: 8px">
已批阅:{{ cur.score != null ? `${cur.score} 分 · ` : "" }}{{ cur.comment || "无评语" }}
</Alert>
</template>
<div v-else class="rv-empty">该学生尚未提交</div>
<Divider style="margin: 10px 0" />
<!-- 批阅输入(仅当前版本待批时) -->
<Form :label-width="72" v-if="canReview">
<FormItem label="分数">
<Input v-model="score" placeholder="可留空只用评语" style="width: 160px"></Input>
</FormItem>
<FormItem label="评语">
<Input v-model="comment" type="textarea" :rows="3" placeholder="输入批阅评语(分数与评语至少填一项)"></Input>
</FormItem>
</Form>
<Alert v-else-if="cur" type="warning" show-icon>该版本已批阅;如需重新批阅或退回订正,请使用打回让学生重交。</Alert>
<!-- 操作行:打回 / 保存批阅 -->
<div class="rv-actions">
<Button type="error" :loading="returning" @click="openReturn">打回订正</Button>
<Button type="primary" :loading="saving" :disabled="!canReview" @click="doReview">保存批阅</Button>
</div>
</div>
<!-- 打回原因 -->
<Modal v-model="returnModal.open" title="打回订正" width="460px">
<Form :label-width="80">
<FormItem label="订正原因" required>
<Input v-model="returnModal.comment" type="textarea" :rows="3" placeholder="打回须填写订正原因(学生可见)"></Input>
</FormItem>
</Form>
<template #footer>
<Button @click="returnModal.open = false">取消</Button>
<Button type="error" :loading="returning" style="margin-left: 8px" @click="doReturn">确认打回</Button>
</template>
</Modal>
<!-- 图片放大 -->
<Modal v-model="imgZoom.open" footer-hide title="作答图片" width="680px">
<div class="zoom-wrap"><img :src="imgZoom.url" /></div>
</Modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from "vue";
import { Message } from "view-ui-plus";
import { teacherApi } from "@/api/module/teacherHomework";
import type { AttachmentItem, SubmissionVersionVo, StudentMatrixVo } from "@/types/homeworkV2";
const props = defineProps<{
homeworkId: number;
/** 学生(studentId/studentName 必有;矩阵行或其子集) */
student: StudentMatrixVo | null;
}>();
const emit = defineEmits<{ (e: "saved"): void }>();
const versions = ref<SubmissionVersionVo[]>([]);
const curIdx = ref(0);
const loading = ref(false);
const saving = ref(false);
const returning = ref(false);
const score = ref<string | number>("");
const comment = ref("");
const SUBMIT_COLOR: Record<string, string> = {
NOT_SUBMITTED: "default",
SUBMITTED: "blue",
LATE_SUBMITTED: "warning",
RETURNED: "error",
REVIEWED: "success",
};
const cur = computed(() => versions.value[curIdx.value] || null);
/** 当前展示版本可批:最新版本且批阅状态 PENDING(未交/已批不可批) */
const canReview = computed(() => {
const v = cur.value;
return !!v && v.isLatest === 1 && v.reviewStatus === "PENDING" && v.submitStatus !== "NOT_SUBMITTED";
});
watch(
() => props.student,
(s) => {
if (s) load();
},
{ immediate: true }
);
async function load() {
if (!props.student) return;
loading.value = true;
score.value = "";
comment.value = "";
curIdx.value = 0;
try {
versions.value = await teacherApi.versions(props.homeworkId, props.student.studentId).send();
// 已批版本回填上一次评分(教师微调场景)
const reviewed = versions.value.find((v) => v.reviewStatus === "REVIEWED" && v.isLatest === 1);
if (reviewed?.score != null) score.value = reviewed.score;
if (reviewed?.comment) comment.value = reviewed.comment;
} catch (e: any) {
Message.error(e.message || "加载提交版本失败");
} finally {
loading.value = false;
}
}
const imageAtts = computed(() => (cur.value?.attachments || []).filter((a) => (a.fileType || "").toLowerCase() === "image"));
const fileAtts = computed(() => (cur.value?.attachments || []).filter((a) => (a.fileType || "").toLowerCase() !== "image"));
/** 附件展示地址:fileUrl 已是 /api/file/Preview?id= 相对路径;绝对地址原样 */
function attUrl(a: AttachmentItem): string {
if (/^(https?:)?\/\//.test(a.fileUrl) || a.fileUrl.startsWith("data:")) return a.fileUrl;
return a.fileUrl;
}
function attDownUrl(a: AttachmentItem): string {
return a.fileId ? `/api/file/Down?id=${a.fileId}` : a.fileUrl;
}
const imgZoom = reactive({ open: false, url: "" });
function zoom(url: string) {
imgZoom.url = url;
imgZoom.open = true;
}
async function doReview() {
if (!props.student) return;
const s = String(score.value).trim();
const c = comment.value.trim();
if (!s && !c) {
Message.info("分数与评语至少填写一项");
return;
}
saving.value = true;
try {
await teacherApi
.review(props.homeworkId, props.student.studentId, {
score: s ? Number(s) : undefined,
comment: c || undefined,
})
.send();
Message.success("批阅已保存");
emit("saved");
} catch (e: any) {
Message.error(e.message || "批阅失败");
} finally {
saving.value = false;
}
}
const returnModal = reactive({ open: false, comment: "" });
function openReturn() {
returnModal.comment = "";
returnModal.open = true;
}
async function doReturn() {
if (!props.student) return;
if (!returnModal.comment.trim()) {
Message.info("请填写订正原因");
return;
}
returning.value = true;
try {
await teacherApi.returnSubmission(props.homeworkId, props.student.studentId, { comment: returnModal.comment.trim() }).send();
Message.success("已打回,等待学生订正重交");
returnModal.open = false;
emit("saved");
} catch (e: any) {
Message.error(e.message || "打回失败");
} finally {
returning.value = false;
}
}
</script>
<style lang="scss" scoped>
.rv-body {
position: relative;
.rv-versions {
margin-bottom: 10px;
.rv-label {
color: #808695;
font-size: 13px;
margin-right: 6px;
}
}
.rv-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
font-size: 12px;
color: #808695;
.late {
color: #ed4014;
font-weight: 600;
}
}
.rv-text {
color: #515a6e;
line-height: 1.6;
background: #fafcff;
border: 1px solid #e8eaec;
border-radius: 4px;
padding: 8px 10px;
max-height: 160px;
overflow-y: auto;
white-space: pre-wrap;
&.empty {
color: #c5c8ce;
}
}
.rv-atts {
margin-top: 8px;
}
.rv-imgs {
display: flex;
flex-wrap: wrap;
gap: 8px;
.rv-img {
width: 88px;
height: 88px;
border: 1px solid #e8eaec;
border-radius: 4px;
overflow: hidden;
cursor: zoom-in;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
&:hover {
border-color: #2d8cf0;
}
}
}
.rv-files {
margin-top: 6px;
.rv-file {
display: inline-block;
margin: 0 8px 4px 0;
padding: 2px 8px;
background: #f0f2f5;
border-radius: 3px;
font-size: 12px;
color: #515a6e;
a {
margin-left: 6px;
color: #2d8cf0;
}
}
}
.rv-empty {
color: #c5c8ce;
text-align: center;
padding: 30px 0;
}
.rv-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
}
.zoom-wrap {
text-align: center;
img {
max-width: 100%;
max-height: 62vh;
}
}
</style>