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:
@@ -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>
|
||||
Reference in New Issue
Block a user