- 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 秒阅读上报通过
323 lines
11 KiB
Vue
323 lines
11 KiB
Vue
<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>
|