Files
coursehomework-front/src/components/ReviewWorkbench.vue
T
wanghu 44ace7c697 feat(M5-P4): mock V2 化 + 伪接口清理 + vite 代理 + 走查修复
- mock 整体重写为「V2 单一存储 + V1 适配层」:教师 19 / 学生 6 / 通知 3 全端点,
  V1 冻结 7 端点由同一存储派生(与后端同一张 homework 表一致);
  提交内核对齐后端 doSubmit(版本翻转/逾期分流/附件 dataURL 拒绝/
  仅查阅拒绝),submit/action 返回 {submissionId} 契约对齐
- 删除伪接口 mock 与客户端方法:Submissions/SubmissionDetail/Grade/Remind/
  MyHomeworks/MySubmission/Submit/Create/Edit/InitializeRed/UserPull/
  SetTopmost/Expedite/ReceiveUsers/List/GetClassTree(真实环境必 404)
- types/homework.ts 收敛为 V1 冻结契约(删 11 个孤儿类型)
- 删除被切换孤儿化的组件:HomeworkUpload/ClassCheckInput/UserCheckModal
- vite 代理指向本地 8102(M5 联调目标,远程测试环境保留注释)
- 走查修复:TeacherHomeworks 操作列改模板插槽(render 函数 Dropdown 插槽
  在 view-ui-plus 下失效导致菜单内联);ReviewModal :value/@input 改
  modelValue(Vue2 API 残留导致弹窗打不开);StudentHomeworks 重新提交
  按钮补 canSubmit 门控(已截止且不允许补交时后端必拒)
- 验收:dev:mock 冒烟 58/58 PASS;构建 EXIT=0;tsc 47 条均为存量(基线 65)
  且无引用已删符号;浏览器走查 9 页面 + 批阅保存闭环 + 3 秒阅读上报通过
2026-09-16 03:18:38 +08:00

323 lines
11 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.
<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";
import config from "@/assets/config/host.config";
/** 附件地址前缀:本地 dev/mock 走同源代理(空),线上为基础平台域名(含后端 contextPath) */
const serviceBase = config.isMock ? "" : config.serviceHost;
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= 相对路径(需拼 serviceHost),绝对地址原样 */
function attUrl(a: AttachmentItem): string {
const u = a.fileUrl || "";
if (/^(https?:)?\/\//.test(u) || u.startsWith("data:")) return u;
return `${serviceBase}${u}`;
}
function attDownUrl(a: AttachmentItem): string {
return a.fileId ? `${serviceBase}/api/file/Down?id=${a.fileId}` : attUrl(a);
}
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>