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
+15 -298
View File
@@ -1,315 +1,32 @@
<template> <template>
<!-- V2 批阅弹窗:历史版本切换 + 保存批阅(review)/ 打回(return)。 <!-- V2 批阅弹窗:ReviewWorkbench 的弹窗形态(学生矩阵行内批阅用)。
Submissions(学生矩阵)与 Grade(批改中心)共用。 --> 批改中心(Grade.vue)直接内嵌 ReviewWorkbench,不经过本组件。 -->
<Modal :value="open" @input="$emit('update:open', $event)" :title="`批阅 · ${student?.studentName || ''}`" width="640px" :mask-closable="false"> <Modal
<div class="rv-body"> :value="open"
<!-- 历史版本切换 --> @input="$emit('update:open', $event)"
<div class="rv-versions" v-if="versions.length"> :title="`批阅 · ${student?.studentName || ''}`"
<span class="rv-label">提交版本:</span> width="640px"
<RadioGroup :value="curIdx" type="button" size="small" @on-change="(i: number) => (curIdx = i)"> :mask-closable="false"
<Radio v-for="(v, i) in versions" :key="v.submissionId" :label="i"> footer-hide
v{{ v.version }}{{ v.isLatest ? "(当前)" : "" }} >
</Radio> <ReviewWorkbench :homeworkId="homeworkId" :student="student" @saved="onSaved" />
</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>
<template #footer>
<Button @click="$emit('update:open', false)">取消</Button>
<Button type="error" style="margin-left: 8px" :loading="returning" @click="openReturn">打回订正</Button>
<Button type="primary" :loading="saving" :disabled="!canReview" @click="doReview">保存批阅</Button>
</template>
</Modal>
<!-- 打回原因 -->
<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> </Modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, watch } from "vue"; import ReviewWorkbench from "@/components/ReviewWorkbench.vue";
import { Message } from "view-ui-plus"; import type { StudentMatrixVo } from "@/types/homeworkV2";
import { teacherApi } from "@/api/module/teacherHomework";
import type { AttachmentItem, SubmissionVersionVo, StudentMatrixVo } from "@/types/homeworkV2";
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
homeworkId: number; homeworkId: number;
/** 学生(studentId/studentName 必有;score 可选回填) */ /** 学生矩阵行(studentId/studentName 必有) */
student: StudentMatrixVo | null; student: StudentMatrixVo | null;
}>(); }>();
const emit = defineEmits<{ (e: "update:open", v: boolean): void; (e: "saved"): void }>(); const emit = defineEmits<{ (e: "update:open", v: boolean): void; (e: "saved"): void }>();
const versions = ref<SubmissionVersionVo[]>([]); function onSaved() {
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.open,
(v) => {
if (v) load();
}
);
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("update:open", false); emit("update:open", false);
emit("saved"); 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("update:open", false);
emit("saved");
} catch (e: any) {
Message.error(e.message || "打回失败");
} finally {
returning.value = false;
}
} }
</script> </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;
}
}
.zoom-wrap {
text-align: center;
img {
max-width: 100%;
max-height: 62vh;
}
}
</style>
+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>
+79 -213
View File
@@ -1,36 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { homeworkApi } from "@/api/module/homework"; import { teacherApi } from "@/api/module/teacherHomework";
import type { HomeworkVo, SubmissionBrief, SubmissionDetailVo } from "@/types/homework"; import type { HomeworkV2Vo, StudentMatrixVo } from "@/types/homeworkV2";
import config from "@/assets/config/host.config"; import ReviewWorkbench from "@/components/ReviewWorkbench.vue";
const serviceBase = config.isMock ? "" : config.serviceHost;
/** 学生提交图片:可能是可直接显示的 URL/dataURL,或 fileid(回退拼预览地址) */
function previewUrl(v: number | string) {
const s = String(v);
if (s.startsWith("data:") || s.startsWith("http")) return s;
return `${serviceBase}/api/file/Preview?id=${s}`;
}
const imgPreview = reactive({ open: false, url: "" });
function zoom(url: string) {
imgPreview.url = url;
imgPreview.open = true;
}
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const serid = ref<number>(Number(route.query.serid) || 0); const serid = ref<number>(Number(route.query.serid) || 0);
/* 待批改作业(progress.pendingGrade>0) */ /* ============ 待批作业(已发布且 pendingGrade>0,按待批数降序) ============ */
const homeworks = ref<HomeworkVo[]>([]); const homeworks = ref<HomeworkV2Vo[]>([]);
const loadingHw = ref(false); const loadingHw = ref(false);
async function loadHomeworks() { async function loadHomeworks() {
loadingHw.value = true; loadingHw.value = true;
try { try {
const r = await homeworkApi.sendList({ scope: "user", pageSize: 100, pageIndex: 1 }).send(); const r = await teacherApi.published({ pageIndex: 1, pageSize: 100 }).send();
homeworks.value = (r.content || []).filter((h: any) => h.progress && h.progress.pendingGrade > 0); homeworks.value = (r.content || [])
.filter((h) => (h.progress?.pendingGrade ?? 0) > 0)
.sort((a, b) => (b.progress?.pendingGrade ?? 0) - (a.progress?.pendingGrade ?? 0));
// 未带 serid 进入时自动选中第一份待批作业
if (!serid.value && homeworks.value.length) {
pickHw(homeworks.value[0].id);
}
} catch (e: any) { } catch (e: any) {
Message.error(e.message || "加载待批作业失败"); Message.error(e.message || "加载待批作业失败");
} finally { } finally {
@@ -38,99 +31,72 @@ async function loadHomeworks() {
} }
} }
/* 当前作业的待批队列 */ /* ============ 待批队列(矩阵 pendingGrade=true) ============ */
type QueueItem = SubmissionBrief & { className: string }; const queue = ref<StudentMatrixVo[]>([]);
const queue = ref<QueueItem[]>([]);
const idx = ref(0); const idx = ref(0);
const loadingQueue = ref(false); const loadingQueue = ref(false);
const current = computed(() => queue.value[idx.value] || null);
const homeworkTitle = ref(""); const homeworkTitle = ref("");
const current = computed(() => queue.value[idx.value] || null);
async function loadQueue() { async function loadQueue() {
if (!serid.value) { if (!serid.value) {
queue.value = []; queue.value = [];
detail.value = null; homeworkTitle.value = "";
return; return;
} }
loadingQueue.value = true; loadingQueue.value = true;
try { try {
const d = await homeworkApi.submissions(serid.value, "submitted").send(); const hw = homeworks.value.find((h) => h.id === serid.value);
homeworkTitle.value = d.title; homeworkTitle.value = hw ? hw.title : "";
const flat: QueueItem[] = []; const r = await teacherApi.students(serid.value, { pendingGrade: true, pageIndex: 1, pageSize: 200 }).send();
(d.groups || []).forEach((g) => g.students.forEach((s) => flat.push({ ...s, className: g.className }))); queue.value = r.content || [];
queue.value = flat;
idx.value = 0; idx.value = 0;
if (flat.length) await select(0);
else detail.value = null;
} catch (e: any) { } catch (e: any) {
Message.error(e.message || "加载待批名单失败"); Message.error(e.message || "加载待批名单失败");
queue.value = []; queue.value = [];
detail.value = null;
} finally { } finally {
loadingQueue.value = false; loadingQueue.value = false;
} }
} }
/* 批改表单 */ /* 批阅/打回完成:移出队列并前移;全批完则刷新作业列表 */
const detail = ref<SubmissionDetailVo | null>(null); function onSaved() {
const loadingDetail = ref(false);
const saving = ref(false);
const form = reactive({ mode: "grade" as "grade" | "redo", score: "" as string | number, level: "A", comment: "", isExcellent: false });
async function select(i: number) {
if (i < 0 || i >= queue.value.length) return;
idx.value = i;
const s = queue.value[i];
loadingDetail.value = true;
try {
detail.value = await homeworkApi.submissionDetail(s.subId).send();
const g = detail.value.grade;
form.mode = "grade";
form.score = g && g.score != null ? g.score : "";
form.level = (g && g.level) || "A";
form.comment = (g && g.comment) || "";
form.isExcellent = !!(g && g.isExcellent);
} catch (e: any) {
Message.error(e.message || "加载提交失败");
detail.value = null;
} finally {
loadingDetail.value = false;
}
}
async function submit(action: "grade" | "redo" | "excellent") {
if (!current.value) return;
const payload: any = { subId: current.value.subId, action };
if (action !== "redo") {
if (form.score !== "" && form.score !== null) payload.score = Number(form.score);
payload.level = form.level;
payload.isExcellent = action === "excellent" ? true : form.isExcellent;
}
payload.comment = form.comment;
saving.value = true;
try {
await homeworkApi.grade(payload).send();
Message.success(action === "redo" ? "已要求订正" : "批改已保存");
// 从待批队列移除并前移
const done = idx.value; const done = idx.value;
queue.value.splice(done, 1); queue.value.splice(done, 1);
if (queue.value.length === 0) { if (queue.value.length === 0) {
detail.value = null; if (homeworks.value.length <= 1) {
} else { homeworks.value = [];
await select(Math.min(done, queue.value.length - 1)); serid.value = 0;
router.replace({ path: "/grade" });
return;
} }
} catch (e: any) { loadHomeworks();
Message.error(e.message || "批改失败"); return;
} finally {
saving.value = false;
} }
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) { function pickHw(v: number) {
serid.value = v; serid.value = v;
router.replace({ path: "/grade", query: v ? { serid: v } : {} }); router.replace({ path: "/grade", query: v ? { serid: String(v) } : {} });
loadQueue();
} }
watch(serid, loadQueue); watch(
() => route.query.serid,
(v) => {
const n = Number(v) || 0;
if (n !== serid.value) {
serid.value = n;
loadQueue();
}
}
);
onMounted(async () => { onMounted(async () => {
await loadHomeworks(); await loadHomeworks();
loadQueue(); loadQueue();
@@ -140,19 +106,19 @@ onMounted(async () => {
<template> <template>
<div class="page-wrapper"> <div class="page-wrapper">
<div class="main-container"> <div class="main-container">
<!-- 顶部:作业选择 --> <!-- 顶部:待批作业选择 -->
<div class="fix mb-12"> <div class="fix mb-12">
<div class="fl" style="display: flex; align-items: center; gap: 8px"> <div class="fl" style="display: flex; align-items: center; gap: 8px">
<span style="color: #515a6e">待批作业:</span> <span style="color: #515a6e">待批作业:</span>
<Select v-model="serid" style="width: 380px" placeholder="选择一份有待批改的作业" filterable :loading="loadingHw" @on-change="pickHw"> <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"> <Option v-for="hw in homeworks" :key="hw.id" :value="hw.id">
{{ hw.subject ? hw.subject + " · " : "" }}{{ hw.title }}(待批 {{ (hw as any).progress.pendingGrade }}) {{ hw.subject ? hw.subject + " · " : "" }}{{ hw.title }}(待批 {{ hw.progress!.pendingGrade }})
</Option> </Option>
</Select> </Select>
</div> </div>
</div> </div>
<p v-if="!serid && !homeworks.length" class="tip">当前没有待批改的作业。</p> <p v-if="!homeworks.length" class="tip">🎉 当前没有待批改的作业。</p>
<p v-else-if="!serid" class="tip">请从上方选择一份待批改的作业。</p> <p v-else-if="!serid" class="tip">请从上方选择一份待批改的作业。</p>
<div v-else class="workbench fix"> <div v-else class="workbench fix">
@@ -162,88 +128,33 @@ onMounted(async () => {
<p v-if="!loadingQueue && !queue.length" class="done">🎉 该作业已全部批改完成</p> <p v-if="!loadingQueue && !queue.length" class="done">🎉 该作业已全部批改完成</p>
<Spin v-if="loadingQueue" fix /> <Spin v-if="loadingQueue" fix />
<ul class="qlist"> <ul class="qlist">
<li <li v-for="(s, i) in queue" :key="s.studentId" :class="{ act: i === idx }" @click="select(i)">
v-for="(s, i) in queue" <div class="q1">
:key="s.subId" {{ s.studentName }}
:class="{ act: i === idx }" <span v-if="s.isLate" class="q-late">逾期</span>
@click="select(i)" </div>
>
<div class="q1">{{ s.studentName }}</div>
<div class="q2">{{ s.className }} · {{ s.submittedAt ? String(s.submittedAt).slice(5, 16) : "" }}</div> <div class="q2">{{ s.className }} · {{ s.submittedAt ? String(s.submittedAt).slice(5, 16) : "" }}</div>
</li> </li>
</ul> </ul>
</div> </div>
<!-- 右:提交内容 + 批改 --> <!-- 右:提交内容 + 批阅(版本切换/批阅/打回共用工作台) -->
<div class="wb-right fr"> <div class="wb-right fr">
<template v-if="current">
<div class="wb-h"> <div class="wb-h">
{{ homeworkTitle }} {{ homeworkTitle }}
<span class="pos">第 {{ idx + 1 }} / {{ queue.length }} 份 · {{ current.studentName }}({{ current.className }})</span> <span class="pos" v-if="current">第 {{ idx + 1 }} / {{ queue.length }} 份 · {{ current.studentName }}({{ current.className }})</span>
</div> </div>
<div class="answer" v-if="detail"> <div class="wb-panel" v-if="current">
<div class="a-meta">提交时间:{{ detail.submittedAt || "—" }}</div> <ReviewWorkbench :key="workbenchKey" :homeworkId="serid" :student="current" @saved="onSaved" />
<div class="a-body" v-html="detail.text || '(无文字作答)'"></div>
<div v-if="detail.images && detail.images.length" class="ans-imgs">
<div class="ans-img" v-for="(im, ii) in detail.images" :key="ii" @click="zoom(previewUrl(im))">
<img :src="previewUrl(im)" />
</div> </div>
</div>
<div v-if="detail.attachments && detail.attachments.length" class="a-files">
附件:
<span class="att-chip" v-for="(at, ai) in detail.attachments" :key="ai">附件{{ ai + 1 }}</span>
</div>
</div>
<Spin v-if="loadingDetail" fix />
<div class="grade-form">
<RadioGroup v-model="form.mode" type="button">
<Radio label="grade">打分 / 等级</Radio>
<Radio label="redo">要求订正</Radio>
</RadioGroup>
<div class="gf-row" v-if="form.mode === 'grade'">
<span>分数</span>
<Input v-model="form.score" placeholder="0-100" style="width: 120px"></Input>
<span>等级</span>
<Select v-model="form.level" style="width: 140px">
<Option value="A">A(优)</Option>
<Option value="B">B(良)</Option>
<Option value="C">C(待改进)</Option>
</Select>
<Checkbox v-model="form.isExcellent">优秀作业</Checkbox>
</div>
<div class="gf-row">
<Input v-model="form.comment" type="textarea" :rows="2" placeholder="批改评语"></Input>
</div>
<div class="gf-actions fix">
<Button class="fl" icon="ios-arrow-back" :disabled="idx === 0" @click="select(idx - 1)">上一份</Button>
<div class="fr">
<Button type="warning" icon="md-refresh" :loading="saving" @click="submit('redo')">要求订正</Button>
<Button type="primary" icon="md-checkmark" :loading="saving" @click="submit('grade')" style="margin-left: 8px">保存并下一份</Button>
</div>
</div>
</div>
</template>
<p v-else class="tip">该作业暂无待批改提交。</p> <p v-else class="tip">该作业暂无待批改提交。</p>
</div> </div>
</div> </div>
</div> </div>
<!-- 作答图片放大预览 -->
<Modal v-model="imgPreview.open" footer-hide title="作答图片" width="680px">
<div class="zoom-wrap"><img :src="imgPreview.url" /></div>
</Modal>
</div> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.zoom-wrap {
text-align: center;
img {
max-width: 100%;
max-height: 62vh;
}
}
.tip { .tip {
color: #808695; color: #808695;
padding: 20px 0; padding: 20px 0;
@@ -270,7 +181,8 @@ onMounted(async () => {
} }
} }
.wb-left { .wb-left {
width: 260px; width: 240px;
flex: none;
border: 1px solid #e8eaec; border: 1px solid #e8eaec;
border-radius: 4px; border-radius: 4px;
padding: 0 10px 10px; padding: 0 10px 10px;
@@ -287,6 +199,16 @@ onMounted(async () => {
cursor: pointer; cursor: pointer;
.q1 { .q1 {
color: #17233d; color: #17233d;
display: flex;
align-items: center;
gap: 6px;
}
.q-late {
background: #ff9900;
color: #fff;
border-radius: 3px;
font-size: 11px;
padding: 0 4px;
} }
.q2 { .q2 {
font-size: 12px; font-size: 12px;
@@ -308,66 +230,10 @@ onMounted(async () => {
border-radius: 4px; border-radius: 4px;
padding: 0 14px 14px; padding: 0 14px 14px;
position: relative; position: relative;
.answer { .wb-panel {
padding: 12px 0; padding-top: 6px;
.a-meta { max-height: calc(100vh - 260px);
color: #808695; overflow-y: auto;
font-size: 13px;
}
.a-body {
margin-top: 8px;
line-height: 1.7;
color: #17233d;
}
.a-files {
margin-top: 8px;
color: #808695;
.att-chip {
display: inline-block;
margin: 0 6px 0 4px;
padding: 1px 8px;
background: #f0f2f5;
border-radius: 3px;
color: #515a6e;
font-size: 12px;
}
}
.ans-imgs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
.ans-img {
width: 92px;
height: 92px;
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;
}
}
}
}
.grade-form {
border-top: 1px dashed #e8eaec;
padding-top: 14px;
.gf-row {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0;
}
.gf-actions {
margin-top: 8px;
}
} }
} }
</style> </style>
+222 -135
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted, watch, h } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { teacherApi } from "@/api/module/teacherHomework";
import { homeworkApi } from "@/api/module/homework"; import { homeworkApi } from "@/api/module/homework";
import type { HomeworkDetailVo, NextPrevVo, ReadGroup } from "@/types/homework"; import type { HomeworkDetailVo, NextPrevVo, ReadGroup } from "@/types/homework";
import type { HomeworkStatsVo, HomeworkV2Vo } from "@/types/homeworkV2";
import FilePreviewModal from "@/components/FilePreviewModal.vue"; import FilePreviewModal from "@/components/FilePreviewModal.vue";
import Utils from "@/utils"; import Utils from "@/utils";
@@ -11,47 +13,103 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const serid = computed(() => Number(route.query.serid) || 0); const serid = computed(() => Number(route.query.serid) || 0);
/* type=0 管理视角(不标读) / type=1 查看(标读);注意 0 是合法值不能用 || 兜底 */ /**
const viewType = computed(() => { * 双模式:/list/detail 走 V2(本人作业:详情 + 汇总统计,PRD 页 4);
const t = Number(route.query.type); * /homeworklist/detail 走 V1 冻结 Detail(管理端可看他人作业——V2 详情有归属校验)。
return t === 0 ? 0 : 1; */
}); const isV2 = computed(() => route.path.startsWith("/list/detail"));
/* 上一/下一篇的序列类型:管理视角走已发(type=0),查看走已收(type=1) */
const navType = computed(() => (viewType.value === 0 ? "0" : "1"));
/* ============ V2:详情 + 统计 ============ */
const v2Detail = ref<HomeworkV2Vo | null>(null);
const v2Stats = ref<HomeworkStatsVo | null>(null);
async function loadV2() {
if (!serid.value) return;
try {
v2Detail.value = await teacherApi.detail(serid.value).send();
if (v2Detail.value.hwStatus !== "DRAFT_UNPUBLISHED" && v2Detail.value.hwStatus !== "DRAFT_SCHEDULED") {
v2Stats.value = await teacherApi.stats(serid.value).send();
} else {
v2Stats.value = null;
}
} catch (e: any) {
Message.error(e.message || "加载作业失败");
v2Detail.value = null;
}
}
const STATUS_COLOR: Record<string, string> = {
DRAFT_UNPUBLISHED: "default",
DRAFT_SCHEDULED: "blue",
PUBLISHED: "primary",
CLOSED: "warning",
ARCHIVED: "success",
};
const SUBMIT_LABEL: Record<string, string> = { image: "拍照/图片", attach: "上传附件", text: "文字作答", none: "仅查阅" };
const isDraft = computed(() => v2Detail.value?.hwStatus === "DRAFT_UNPUBLISHED" || v2Detail.value?.hwStatus === "DRAFT_SCHEDULED");
const total = computed(() => v2Stats.value?.total || null);
/* 分班进度表列 */
const clsColumns = [
{ title: "班级", key: "className", minWidth: 140, align: "left" },
{ title: "应交", key: "should", width: 80, align: "center", render: (hh: any, p: any) => hh("span", {}, String(p.row.progress?.should ?? "—")) },
{ title: "已读", width: 100, align: "center", render: (hh: any, p: any) => hh("span", {}, `${p.row.progress?.read ?? 0}(${p.row.progress?.readRate ?? 0}%)`) },
{ title: "已交", width: 100, align: "center", render: (hh: any, p: any) => hh("span", {}, `${p.row.progress?.submitted ?? 0}(${p.row.progress?.submitRate ?? 0}%)`) },
{ title: "待批阅", width: 80, align: "center", render: (hh: any, p: any) => hh("span", { style: (p.row.progress?.pendingGrade ?? 0) > 0 ? "color:#ff9900;font-weight:600;" : "color:#808695;" }, String(p.row.progress?.pendingGrade ?? 0)) },
{ title: "未读未交", width: 90, align: "center", render: (hh: any, p: any) => hh("span", { style: (p.row.progress?.unreadUndone ?? 0) > 0 ? "color:#ed4014;font-weight:600;" : "color:#808695;" }, String(p.row.progress?.unreadUndone ?? 0)) },
{ title: "批阅率", width: 80, align: "center", render: (hh: any, p: any) => hh("span", {}, `${p.row.progress?.gradeRate ?? 0}%`) },
];
/* V2 附件 → 复用 V1 文件条目渲染结构 */
const v2Files = computed(() =>
(v2Detail.value?.attachments || []).map((a) => ({
fileid: a.fileId,
name: a.fileName || `附件${a.fileId ?? ""}`,
size: 0,
url: a.fileUrl,
}))
);
function onEdit() {
router.push({ path: "/list/new", query: { serid: String(serid.value) } });
}
function goSubmissions() {
router.push({ path: "/submissions", query: { serid: String(serid.value) } });
}
function goGrade() {
router.push({ path: "/grade", query: { serid: String(serid.value) } });
}
function doExport() {
window.location.href = teacherApi.exportUrl(serid.value);
}
/* ============ V1(管理端冻结接口) ============ */
const detail = ref<HomeworkDetailVo | null>(null); const detail = ref<HomeworkDetailVo | null>(null);
const loading = ref(false);
const readGroups = ref<ReadGroup[]>([]); const readGroups = ref<ReadGroup[]>([]);
const showRead = ref(false); const showRead = ref(false);
const prevId = ref(0); const prevId = ref(0);
const nextId = ref(0); const nextId = ref(0);
const viewType = computed(() => 0);
onMounted(async () => { async function loadV1() {
await load();
});
async function load() {
if (!serid.value) return; if (!serid.value) return;
loading.value = true;
try { try {
detail.value = await homeworkApi.modify(serid.value, viewType.value).send(); detail.value = await homeworkApi.modify(serid.value, 0).send();
const nav: NextPrevVo = await homeworkApi.getNextPrevHomework(serid.value, navType.value).send(); const nav: NextPrevVo = await homeworkApi.getNextPrevHomework(serid.value, "0").send();
prevId.value = nav.prevId || 0; prevId.value = nav.prevId || 0;
nextId.value = nav.nextId || 0; nextId.value = nav.nextId || 0;
} catch (e: any) { } catch (e: any) {
Message.error(e.message || "加载作业失败"); Message.error(e.message || "加载作业失败");
} finally { detail.value = null;
loading.value = false;
} }
} }
function goNav(id: number) { function goNav(id: number) {
if (!id) return; if (!id) return;
router.replace({ path: route.path, query: { ...route.query, serid: id } }); router.replace({ path: route.path, query: { ...route.query, serid: id } });
load();
} }
/* 阅读进度(同旧版:已查看百分比) */ /* 阅读进度(管理视角:已查看百分比) */
const readPercent = computed(() => { const readPercent = computed(() => {
const total = Number(detail.value?.numcount) || 0; const total = Number(detail.value?.numcount) || 0;
const read = Number(detail.value?.readnumcount) || 0; const read = Number(detail.value?.readnumcount) || 0;
@@ -69,26 +127,40 @@ async function openRead() {
} }
} }
/* 编辑(管理视角) */ /* ============ 公共:加载入口 ============ */
function onEdit() { const loading = ref(false);
router.push({ path: "/list/new", query: { serid: serid.value } }); async function load() {
loading.value = true;
try {
if (isV2.value) await loadV2();
else await loadV1();
} finally {
loading.value = false;
}
} }
onMounted(load);
watch(
() => [route.path, route.query.serid],
() => load()
);
/* 打印 */ /* ============ 公共:打印 / 附件预览与下载 ============ */
function onPrint() { function onPrint() {
if (!detail.value) return; const d: any = isV2.value ? v2Detail.value : detail.value;
if (!d) return;
const meta = isV2.value
? `学科:${d.subject || "—"} 状态:${d.hwStatusDesc || ""} 截止:${d.deadlineAt ? String(d.deadlineAt).slice(0, 16) : "无"}`
: `来源:${d.DeptName || ""} 发布人:${d.TeaName || ""} 发布时间:${d.EditDate || ""}`;
const win = window.open("", "_blank"); const win = window.open("", "_blank");
if (!win) return; if (!win) return;
win.document.write(` win.document.write(`
<html> <html>
<head><title>${detail.value.title || ""}</title></head> <head><title>${d.title || ""}</title></head>
<body style="font-family:'Microsoft YaHei',sans-serif;padding:24px;"> <body style="font-family:'Microsoft YaHei',sans-serif;padding:24px;">
<h2>${detail.value.title || ""}</h2> <h2>${d.title || ""}</h2>
<p style="color:#666;font-size:13px;"> <p style="color:#666;font-size:13px;">${meta}</p>
来源:${detail.value.DeptName || ""} 发布人:${detail.value.TeaName || ""} 发布时间:${detail.value.EditDate || ""}
</p>
<hr/> <hr/>
<div>${detail.value.Contents || ""}</div> <div>${isV2.value ? d.description || "" : d.Contents || ""}</div>
</body> </body>
</html> </html>
`); `);
@@ -105,11 +177,11 @@ function downAll() {
window.location.href = homeworkApi.allFileDownUrl(serid.value); window.location.href = homeworkApi.allFileDownUrl(serid.value);
} }
/** 展示名:后端 name=原始文件名,filename=存储相对路径(homework/2026xxxx/uuid.ext) */ /** 展示名:V1 后端 name=原始文件名,filename=存储相对路径 */
function displayName(item: { name?: string; filename?: string }) { function displayName(item: { name?: string; filename?: string }) {
return item.name || item.filename || ""; return item.name || item.filename || "";
} }
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi) */ /* 附件图标(按扩展名) */
function getFileIcon(name: string) { function getFileIcon(name: string) {
const suffix = String(name || "").split(".").pop()?.toLowerCase() || ""; const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word"; if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
@@ -143,7 +215,61 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
<template> <template>
<div class="page-wrapper"> <div class="page-wrapper">
<div class="main-container" v-if="detail"> <!-- ==================== V2:我布置的作业详情 + 统计(PRD 页 4) ==================== -->
<div class="main-container" v-if="isV2 && v2Detail">
<div class="ops-top">
<div class="nav">
<Button size="small" @click="router.push('/list')">返回列表</Button>
</div>
<div class="nav">
<Button size="small" v-if="isDraft" @click="onEdit">编辑</Button>
<Button size="small" v-if="!isDraft" @click="goSubmissions">提交情况</Button>
<Button size="small" v-if="(total?.pendingGrade ?? 0) > 0" type="primary" @click="goGrade">去批阅({{ total!.pendingGrade }})</Button>
<Button size="small" v-if="!isDraft" @click="doExport">导出报表</Button>
<Button size="small" @click="onPrint">打印</Button>
</div>
</div>
<h2 class="homework-title2">{{ v2Detail.title }}</h2>
<div class="meta">
<Tag :color="STATUS_COLOR[v2Detail.hwStatus] || 'default'">{{ v2Detail.hwStatusDesc || v2Detail.hwStatus }}</Tag>
<span v-if="v2Detail.subject">学科:{{ v2Detail.subject }}</span>
<span>发布:{{ v2Detail.publishedAt ? String(v2Detail.publishedAt).slice(0, 16) : v2Detail.scheduledAt ? `定时 ${String(v2Detail.scheduledAt).slice(0, 16)}` : "—" }}</span>
<span>截止:{{ v2Detail.deadlineAt ? String(v2Detail.deadlineAt).slice(0, 16) : "无(不截止)" }}</span>
<span>提交方式:{{ (v2Detail.submitType || []).map((t) => SUBMIT_LABEL[t] || t).join(" / ") || "—" }}</span>
<span v-if="v2Detail.deadlineAt">逾期补交:{{ v2Detail.allowLate === 1 ? "允许" : "不允许" }}</span>
<span>班级:{{ (v2Detail.classes || []).map((c) => c.className).join("、") || "—" }}</span>
</div>
<!-- 统计卡片(非草稿) -->
<template v-if="total">
<div class="stats-head">总体进度</div>
<div class="stat-cards">
<div class="s-card"><b>{{ total.should }}</b><span>应交</span></div>
<div class="s-card"><b class="blue">{{ total.read }}</b><span>已读({{ total.readRate }}%)</span></div>
<div class="s-card"><b class="blue">{{ total.submitted }}</b><span>已交({{ total.submitRate }}%)</span></div>
<div class="s-card"><b class="orange">{{ total.pendingGrade }}</b><span>待批阅</span></div>
<div class="s-card"><b class="red">{{ total.unreadUndone }}</b><span>未读未交</span></div>
<div class="s-card"><b class="green">{{ total.gradeRate }}%</b><span>批阅率</span></div>
</div>
<Table v-if="v2Stats?.byClass?.length" class="cls-table" size="small" :columns="clsColumns" :data="v2Stats.byClass"></Table>
</template>
<div class="content" v-html="v2Detail.description"></div>
<div v-if="v2Files.length" class="files">
<div class="files-title">附件({{ v2Files.length }})</div>
<div v-for="f in v2Files" :key="f.fileid" class="file-item">
<i class="iconfont file-type-icon" :class="getFileIcon(f.name)"></i>
<span class="file-name" :title="f.name">{{ f.name }}</span>
<a class="file-op" v-if="getIsPreview(f.name)" @click="openPreview(f)">查看</a>
<a class="file-op" @click="downFile(f.fileid || 0)">下载</a>
</div>
</div>
</div>
<!-- ==================== V1:全部作业(管理端,冻结接口) ==================== -->
<div class="main-container" v-else-if="!isV2 && detail">
<div class="ops-top"> <div class="ops-top">
<div class="nav"> <div class="nav">
<Button size="small" :disabled="!prevId" @click="goNav(prevId)">上一篇</Button> <Button size="small" :disabled="!prevId" @click="goNav(prevId)">上一篇</Button>
@@ -151,8 +277,6 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
</div> </div>
</div> </div>
<!-- 管理视角:阅读进度统计条(同旧版已发作业详情) -->
<template v-if="viewType === 0">
<h2 class="homework-title2">{{ detail.title }}</h2> <h2 class="homework-title2">{{ detail.title }}</h2>
<div class="stat-bar"> <div class="stat-bar">
<div class="progress-box"> <div class="progress-box">
@@ -173,20 +297,6 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
</div> </div>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
</template>
<!-- 查看视角:普通标题 -->
<h2 v-else class="homework-title2">{{ detail.title }}</h2>
<div v-if="viewType !== 0" class="meta">
<span>发文单位:{{ detail.DeptName || "—" }}</span>
<span>发布人:{{ detail.TeaName || "—" }}</span>
<span>发布时间:{{ detail.EditDate }}</span>
<span>接收班级:{{ detail.receiveText || "全部班级" }}</span>
<span v-if="detail.isDsfs">定时发送:{{ detail.DsfsTime }}</span>
</div>
<!-- 查看视角:正文 -->
<div class="content" v-html="detail.Contents"></div> <div class="content" v-html="detail.Contents"></div>
@@ -201,13 +311,10 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
</div> </div>
</div> </div>
<!-- 管理视角底部操作(同旧版:编辑 / 打印) --> <div class="bottom-btns">
<div v-if="viewType === 0" class="bottom-btns"> <Button @click="onPrint">打印</Button>
<Button @click="onEdit">编辑</Button>
<Button style="margin-left: 10px" @click="onPrint">打印</Button>
</div> </div>
<!-- 查看阅读情况(同旧版:绿=已读 红=未读) -->
<Modal v-model="showRead" title="查看阅读情况" :width="620"> <Modal v-model="showRead" title="查看阅读情况" :width="620">
<div class="read-tip">提示:<span class="green">绿色</span>表示已读,<span class="red">红色</span>表示未读</div> <div class="read-tip">提示:<span class="green">绿色</span>表示已读,<span class="red">红色</span>表示未读</div>
<div class="read-list"> <div class="read-list">
@@ -237,6 +344,10 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
<FilePreviewModal v-model="previewState.show" :file-id="previewState.fileId" :file-name="previewState.fileName" /> <FilePreviewModal v-model="previewState.show" :file-id="previewState.fileId" :file-name="previewState.fileName" />
</template> </template>
<script lang="ts">
export default { name: "HomeworkDetails" };
</script>
<style scoped> <style scoped>
.ops-top { .ops-top {
display: flex; display: flex;
@@ -255,12 +366,58 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 18px; gap: 18px;
align-items: center;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px dashed #e8e8e8; border-bottom: 1px dashed #e8e8e8;
font-size: 13px; font-size: 13px;
color: #808695; color: #808695;
} }
/* 管理视角统计条 */ /* V2 统计卡片 */
.stats-head {
font-weight: 600;
color: #17233d;
margin-top: 14px;
}
.stat-cards {
display: flex;
gap: 14px;
flex-wrap: wrap;
margin: 10px 0;
.s-card {
min-width: 108px;
text-align: center;
border: 1px solid #e8eaec;
border-radius: 6px;
padding: 10px 8px;
background: #fafcff;
b {
display: block;
font-size: 22px;
color: #17233d;
line-height: 1.2;
}
span {
font-size: 12px;
color: #808695;
}
.blue {
color: #2d8cf0;
}
.orange {
color: #ff9900;
}
.red {
color: #ed4014;
}
.green {
color: #19be6b;
}
}
}
.cls-table {
margin-bottom: 6px;
}
/* V1 管理视角统计条 */
.stat-bar { .stat-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -336,24 +493,24 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
color: #515a6e; color: #515a6e;
margin: 0 16px 8px 0; margin: 0 16px 8px 0;
font-size: 13px; font-size: 13px;
.file-type-icon { }
.file-item .file-type-icon {
font-size: 16px; font-size: 16px;
color: #515a6e; color: #515a6e;
} }
.file-name { .file-item .file-name {
max-width: 320px; max-width: 320px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.file-size { .file-item .file-size {
color: #999; color: #999;
font-size: 12px; font-size: 12px;
} }
.file-op { .file-item .file-op {
color: #2d8cf0; color: #2d8cf0;
cursor: pointer; cursor: pointer;
}
} }
.bottom-btns { .bottom-btns {
border-top: 1px dashed #e8e8e8; border-top: 1px dashed #e8e8e8;
@@ -408,74 +565,4 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
color: #999; color: #999;
font-size: 13px; font-size: 13px;
} }
.flow-progress {
margin: 12px 0 4px;
padding: 12px 14px;
background: #f8faff;
border: 1px solid #e8f0fe;
border-radius: 6px;
.fp-title {
font-size: 13px;
font-weight: 600;
color: #17233d;
margin-bottom: 10px;
}
.fp-steps {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.fp-step {
display: flex;
gap: 8px;
flex: 1;
min-width: 240px;
}
.fp-dot {
width: 22px;
height: 22px;
line-height: 22px;
text-align: center;
border-radius: 50%;
background: #c5c8ce;
color: #fff;
font-size: 12px;
flex: none;
}
.is-approved .fp-dot {
background: #19be6b;
}
.is-pending .fp-dot {
background: #ff9900;
}
.is-rejected .fp-dot {
background: #ed4014;
}
.fp-body {
flex: 1;
}
.fp-name {
font-size: 13px;
font-weight: 600;
color: #17233d;
margin-bottom: 2px;
}
.fp-people {
font-size: 12px;
color: #808695;
}
.fp-wait {
color: #ff9900;
}
.fp-rec {
font-size: 12px;
margin-top: 3px;
&.approved {
color: #19be6b;
}
&.rejected {
color: #ed4014;
}
}
}
</style> </style>
+271 -222
View File
@@ -2,33 +2,30 @@
import { ref, reactive, computed, h, onMounted, watch } from "vue"; import { ref, reactive, computed, h, onMounted, watch } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { homeworkApi } from "@/api/module/homework"; import { teacherApi } from "@/api/module/teacherHomework";
import type { SubmissionsResultVo, SubmissionBrief, HomeworkVo } from "@/types/homework"; import type {
BasePageResult,
HomeworkStatsVo,
HomeworkV2Vo,
StudentMatrixVo,
SubmitStatusV2,
} from "@/types/homeworkV2";
import ReviewModal from "@/components/ReviewModal.vue";
import MyConfirm from "@/components/MyConfirm";
import config from "@/assets/config/host.config"; import config from "@/assets/config/host.config";
import Utils from "@/utils"; import Utils from "@/utils";
const serviceBase = config.isMock ? "" : config.serviceHost; const serviceBase = config.isMock ? "" : config.serviceHost;
function previewUrl(v: number | string) {
const s = String(v);
if (s.startsWith("data:") || s.startsWith("http")) return s;
return `${serviceBase}/api/file/Preview?id=${s}`;
}
const imgPreview = reactive({ open: false, url: "" });
function zoom(url: string) {
imgPreview.url = url;
imgPreview.open = true;
}
const route = useRoute(); const route = useRoute();
const serid = ref<number>(Number(route.query.serid) || 0); const serid = ref<number>(Number(route.query.serid) || 0);
/* 未指定作业时,先在“我布置的”里选一份 */ /* ============ 未指定作业时:从我布置的已发布作业里选一份 ============ */
const myHomeworks = ref<HomeworkVo[]>([]); const myHomeworks = ref<HomeworkV2Vo[]>([]);
const loadingList = ref(false); const loadingList = ref(false);
async function loadMyHomeworks() { async function loadMyHomeworks() {
loadingList.value = true; loadingList.value = true;
try { try {
const r = await homeworkApi.sendList({ scope: "user", pageSize: 100, pageIndex: 1 }).send(); const r = await teacherApi.published({ pageIndex: 1, pageSize: 100 }).send();
myHomeworks.value = r.content || []; myHomeworks.value = r.content || [];
} catch (e: any) { } catch (e: any) {
Message.error(e.message || "加载作业失败"); Message.error(e.message || "加载作业失败");
@@ -37,218 +34,324 @@ async function loadMyHomeworks() {
} }
} }
/* ============ 提交情况数据 ============ */ /* ============ 汇总统计卡片(stats:总进度) ============ */
const data = ref<SubmissionsResultVo | null>(null); const stats = ref<HomeworkStatsVo | null>(null);
/* ============ 矩阵查询 ============ */
const SUBMIT_TABS: { name: string; label: string }[] = [
{ name: "", label: "全部" },
{ name: "NOT_SUBMITTED", label: "未交" },
{ name: "SUBMITTED", label: "已交待批" },
{ name: "LATE_SUBMITTED", label: "逾期补交" },
{ name: "RETURNED", label: "待订正" },
{ name: "REVIEWED", label: "已批阅" },
];
const submitStatus = ref("");
const classId = ref("");
const pendingOnly = ref(false);
const pageNum = ref(1);
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
const EMPTY_PAGE: BasePageResult<StudentMatrixVo> = { content: [], total: 0, pageIndex: 1, pageSize: 10, totalPages: 0, hasNext: false };
const page = ref<BasePageResult<StudentMatrixVo>>(EMPTY_PAGE);
const loading = ref(false); const loading = ref(false);
const submitStatus = ref("all"); // all/submitted/unsubmitted/graded/needRedo
async function load() { async function load() {
if (!serid.value) { if (!serid.value) {
data.value = null; page.value = EMPTY_PAGE;
stats.value = null;
return; return;
} }
loading.value = true; loading.value = true;
try { try {
data.value = await homeworkApi.submissions(serid.value, submitStatus.value).send(); page.value = await teacherApi
.students(serid.value, {
pageIndex: pageNum.value,
pageSize: pageSize.value,
classId: classId.value || undefined,
submitStatus: (submitStatus.value || undefined) as SubmitStatusV2 | undefined,
pendingGrade: pendingOnly.value ? true : undefined,
})
.send();
} catch (e: any) { } catch (e: any) {
Message.error(e.message || "加载提交情况失败"); Message.error(e.message || "加载提交情况失败");
data.value = null; page.value = EMPTY_PAGE;
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
watch(submitStatus, load); async function loadStats() {
if (!serid.value) return;
try {
stats.value = await teacherApi.stats(serid.value).send();
} catch {
stats.value = null;
}
}
function doSearch() {
pageNum.value = 1;
load();
}
function pageUpdate(p: number) {
pageNum.value = p;
load();
}
function pageSizeUpdate(size: number) {
pageNum.value = 1;
pageSize.value = size;
localStorage.setItem("customPageCount", String(size));
load();
}
function pickHw(v: number) {
serid.value = v;
submitStatus.value = "";
classId.value = "";
pendingOnly.value = false;
pageNum.value = 1;
load();
loadStats();
}
watch( watch(
() => route.query.serid, () => route.query.serid,
(v) => { (v) => {
serid.value = Number(v) || 0; serid.value = Number(v) || 0;
submitStatus.value = "all"; pickHw(serid.value);
load();
} }
); );
onMounted(async () => { onMounted(async () => {
await loadMyHomeworks(); await loadMyHomeworks();
load(); load();
loadStats();
}); });
const summary = computed(() => data.value?.summary || null); const total = computed(() => stats.value?.total || null);
const summaryTitle = computed(() => {
const hw = myHomeworks.value.find((x) => x.id === serid.value);
return hw ? hw.title : stats.value?.title || "";
});
const STATUS_TEXT: Record<string, string> = { unsubmitted: "未交", submitted: "已交待批", graded: "已批改", needRedo: "待订正", redone: "已订正" }; /* ============ 状态展示 ============ */
const STATUS_COLOR: Record<string, string> = { unsubmitted: "default", submitted: "warning", graded: "success", needRedo: "error", redone: "primary" }; const STATUS_TEXT: Record<string, string> = {
NOT_SUBMITTED: "未交",
SUBMITTED: "已交待批",
LATE_SUBMITTED: "逾期补交",
RETURNED: "待订正",
REVIEWED: "已批阅",
};
const STATUS_COLOR: Record<string, string> = {
NOT_SUBMITTED: "default",
SUBMITTED: "blue",
LATE_SUBMITTED: "warning",
RETURNED: "error",
REVIEWED: "success",
};
const REVIEW_TEXT: Record<string, string> = { PENDING: "待批阅", REVIEWED: "已批阅", RETURNED: "已打回" };
const REVIEW_COLOR: Record<string, string> = { PENDING: "warning", REVIEWED: "success", RETURNED: "error" };
/* 学生表格列(纯 render,避免 iView 具名 slot 差异) */ function fmtTime(v?: string | null) {
const stuColumns = [ return v ? String(v).slice(5, 16) : "—";
{ title: "学生", key: "studentName", minWidth: 120 }, }
function previewUrl(u: string) {
if (/^(https?:)?\/\//.test(u) || u.startsWith("data:")) return u;
return `${serviceBase}${u}`;
}
/* ============ 批阅弹窗 / 定向提醒 ============ */
const reviewModal = reactive({ open: false, row: null as StudentMatrixVo | null });
function openReview(row: StudentMatrixVo) {
reviewModal.row = row;
reviewModal.open = true;
}
function onReviewed() {
load();
loadStats();
loadMyHomeworks();
}
function remindOne(row: StudentMatrixVo) {
const label = row.isRead ? "未交" : "未读未交";
MyConfirm({
title: "提醒",
msg: `向 ${row.studentName}(${row.className})发送${label}提醒?`,
async onOk() {
try {
const r = await teacherApi.remind(serid.value, row.isRead ? "NOT_SUBMITTED" : "NOT_READ", [row.studentId]).send();
Message.success(`已提醒 ${r?.reminded ?? 0} 位学生`);
} catch (e: any) {
Message.error(e.message || "提醒失败");
}
},
});
}
/* ============ 矩阵列 ============ */
const columns = computed(() => [
{ title: "学生", key: "studentName", minWidth: 110, align: "left" },
{ title: "班级", key: "className", width: 110, align: "left" },
{ {
title: "状态", title: "阅读",
width: 90,
align: "center",
render: (h: any, p: any) => {
const r = p.row as StudentMatrixVo;
if (!r.isRead) return h("span", { style: "color:#ed4014;" }, "未读");
return h(
"Tooltip",
{ transfer: true, props: { content: `首次 ${fmtTime(r.firstReadAt)} / 最近 ${fmtTime(r.lastReadAt)}`, placement: "top" } },
() => h("span", { style: "color:#19be6b;" }, "已读")
);
},
},
{
title: "提交状态",
width: 110, width: 110,
align: "center", align: "center",
render: (h: any, p: any) => h("Tag", { color: STATUS_COLOR[p.row.submitStatus] || "default" }, () => STATUS_TEXT[p.row.submitStatus] || p.row.submitStatus), render: (h: any, p: any) => {
const r = p.row as StudentMatrixVo;
const tag = h("Tag", { color: STATUS_COLOR[r.submitStatus] || "default" }, () => STATUS_TEXT[r.submitStatus] || r.submitStatus);
if (r.isLate && r.submitStatus !== "NOT_SUBMITTED") {
return h("span", { style: "display:inline-flex;align-items:center;gap:4px;" }, [tag, h("span", { style: "color:#ed4014;font-size:12px;" }, "逾期")]);
}
return tag;
},
},
{ title: "提交时间", width: 130, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.submittedAt)) },
{
title: "评分",
width: 70,
align: "center",
render: (h: any, p: any) => h("span", { style: (p.row.score != null ? "color:#17233d;font-weight:600;" : "color:#c5c8ce;") }, p.row.score == null ? "—" : String(p.row.score)),
},
{
title: "批阅状态",
width: 95,
align: "center",
render: (h: any, p: any) => h("Tag", { color: REVIEW_COLOR[p.row.reviewStatus] || "default" }, () => REVIEW_TEXT[p.row.reviewStatus] || p.row.reviewStatus),
},
{
title: "附件",
width: 110,
align: "left",
render: (h: any, p: any) => {
const th: string[] = p.row.thumbnails || [];
if (!th.length) return h("span", { style: "color:#c5c8ce;" }, "—");
return h(
"div",
{ style: "display:flex;gap:4px;" },
th.slice(0, 3).map((u) =>
h("img", {
src: previewUrl(u),
style: "width:32px;height:32px;object-fit:cover;border-radius:3px;border:1px solid #e8eaec;cursor:zoom-in;",
onClick: () => zoom(previewUrl(u)),
})
)
);
},
}, },
{ title: "提交时间", width: 150, render: (h: any, p: any) => h("span", {}, p.row.submittedAt ? String(p.row.submittedAt).slice(5, 16) : "—") },
{ title: "分数", width: 80, align: "center", render: (h: any, p: any) => h("span", {}, p.row.score == null ? "—" : p.row.score) },
{ title: "等级", width: 80, align: "center", render: (h: any, p: any) => h("span", {}, p.row.level || "—") },
{ {
title: "操作", title: "操作",
width: 110, width: 130,
align: "center", align: "center",
render: (h: any, p: any) => Utils.operatingHandle(h, p.row.hasGrade ? "再批" : "批改", p.row.hasGrade ? "icon-edit" : "icon-chakan", () => openGrade(p.row as SubmissionBrief)), fixed: "right",
render: (h: any, p: any) => {
const r = p.row as StudentMatrixVo;
const nodes: any[] = [];
if (r.submitStatus !== "NOT_SUBMITTED") {
nodes.push(Utils.operatingHandle(h, r.reviewStatus === "PENDING" ? "批阅" : "查看", r.reviewStatus === "PENDING" ? "icon-edit" : "icon-chakan", () => openReview(r)));
} else {
nodes.push(Utils.operatingHandle(h, "提醒", "icon-fasong", () => remindOne(r)));
}
return nodes;
}, },
]; },
]);
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
/* ============ 内联批改(走 Grade 接口,核心闭环即时批改) ============ */ /* 图片放大 */
const gradeModal = reactive({ const imgPreview = reactive({ open: false, url: "" });
open: false, function zoom(url: string) {
subId: 0, imgPreview.url = url;
studentName: "", imgPreview.open = true;
mode: "grade",
action: "grade",
score: "" as string | number,
level: "A",
comment: "",
answerText: "",
answerImages: [] as string[],
loadingAnswer: false,
});
async function openGrade(stu: SubmissionBrief) {
gradeModal.open = true;
gradeModal.subId = stu.subId;
gradeModal.studentName = stu.studentName;
gradeModal.mode = "grade";
gradeModal.action = "grade";
gradeModal.score = stu.score ?? "";
gradeModal.level = stu.level || "A";
gradeModal.comment = "";
gradeModal.answerText = "";
gradeModal.answerImages = [];
gradeModal.loadingAnswer = true;
try {
const d = await homeworkApi.submissionDetail(stu.subId).send();
gradeModal.answerText = d.text || "";
gradeModal.answerImages = (d.images || []).map((v: any) => String(v)).filter(Boolean);
} catch (e: any) {
Message.error(e.message || "加载作答失败");
} finally {
gradeModal.loadingAnswer = false;
}
}
async function doGrade(action: "grade" | "redo" | "excellent") {
const payload: any = { subId: gradeModal.subId, action };
if (action !== "redo") {
if (gradeModal.score !== "" && gradeModal.score !== null) payload.score = Number(gradeModal.score);
payload.level = gradeModal.level;
}
payload.comment = gradeModal.comment;
try {
await homeworkApi.grade(payload).send();
Message.success(action === "redo" ? "已要求订正" : action === "excellent" ? "已标为优秀" : "批改已保存");
gradeModal.open = false;
load();
} catch (e: any) {
Message.error(e.message || "批改失败");
}
}
function onSave() {
doGrade(gradeModal.mode === "redo" ? "redo" : "grade");
} }
</script> </script>
<template> <template>
<div class="page-wrapper"> <div class="page-wrapper">
<div class="main-container"> <div class="main-container">
<!-- 顶部:作业选择 + 状态过滤 --> <!-- 顶部:作业选择 + 班级/状态筛选 -->
<div class="fix mb-12"> <div class="fix mb-12">
<div class="fl" style="display: flex; align-items: center; gap: 8px"> <div class="fl" style="display: flex; align-items: center; gap: 8px">
<span style="color: #515a6e">作业:</span> <span style="color: #515a6e">作业:</span>
<Select v-model="serid" style="width: 360px" placeholder="选择一份我布置的作业" filterable :loading="loadingList" @on-change="load"> <Select :model-value="serid" style="width: 360px" placeholder="选择一份我布置的作业" filterable :loading="loadingList" @on-change="pickHw">
<Option v-for="hw in myHomeworks" :key="hw.id" :value="hw.id">{{ hw.subject ? hw.subject + " · " : "" }}{{ hw.title }}</Option> <Option v-for="hw in myHomeworks" :key="hw.id" :value="hw.id">
{{ hw.subject ? hw.subject + " · " : "" }}{{ hw.title }}{{ (hw.progress?.pendingGrade ?? 0) > 0 ? `(待批 ${hw.progress!.pendingGrade})` : "" }}
</Option>
</Select> </Select>
</div> </div>
<div class="fr" style="display: flex; align-items: center; gap: 8px"> <div class="fr" style="display: flex; align-items: center; gap: 8px">
<span style="color: #515a6e">状态:</span> <span style="color: #515a6e">班级:</span>
<Select v-model="submitStatus" style="width: 140px"> <Select v-model="classId" style="width: 150px" clearable placeholder="全部班级" @on-change="doSearch">
<Option value="all">全部</Option> <Option v-for="c in stats?.byClass || []" :key="c.classId" :value="c.classId">{{ c.className }}</Option>
<Option value="submitted">已交待批</Option>
<Option value="graded">已批改</Option>
<Option value="needRedo">待订正</Option>
<Option value="unsubmitted">未交</Option>
</Select> </Select>
<Checkbox v-model="pendingOnly" @on-change="doSearch">只看待批阅</Checkbox>
</div> </div>
</div> </div>
<p v-if="!serid" class="tip">请先在上方选择一份作业查看提交情况。</p> <p v-if="!serid" class="tip">请先在上方选择一份作业查看提交情况。</p>
<template v-else> <template v-else>
<div v-if="summary" class="sum-bar mb-12"> <!-- 汇总卡片 -->
<div v-if="total" class="sum-bar mb-12">
<div class="sum-title"> <div class="sum-title">
<b>{{ data?.title }}</b> <b>{{ summaryTitle }}</b>
<span class="sub">{{ data?.subject }} · {{ data?.hwStatusDesc }} · 截止 {{ data?.deadline ? String(data.deadline).slice(0, 16) : "—" }}</span> <span class="sub">
应交 {{ total.should }} 人 · 阅读率 {{ total.readRate }}% · 提交率 {{ total.submitRate }}% · 批阅率 {{ total.gradeRate }}%
</span>
</div> </div>
<div class="sum-cards"> <div class="sum-cards">
<div class="card"><div class="n">{{ summary.should }}</div><div class="l">应交</div></div> <div class="card"><div class="n">{{ total.should }}</div><div class="l">应交</div></div>
<div class="card"><div class="n">{{ summary.submitted }}</div><div class="l">已交</div></div> <div class="card"><div class="n">{{ total.read }}</div><div class="l">已读</div></div>
<div class="card"><div class="n warn">{{ summary.pendingGrade }}</div><div class="l">待批改</div></div> <div class="card"><div class="n">{{ total.submitted }}</div><div class="l">已交</div></div>
<div class="card"><div class="n ok">{{ summary.graded }}</div><div class="l">已批改</div></div> <div class="card"><div class="n warn">{{ total.pendingGrade }}</div><div class="l">待批阅</div></div>
<div class="card"><div class="n err">{{ summary.unsubmitted }}</div><div class="l">未交</div></div> <div class="card"><div class="n err">{{ total.unreadUndone }}</div><div class="l">未读未交</div></div>
<div class="card"><div class="n">{{ summary.submitRate }}%</div><div class="l">提交率</div></div> <div class="card"><div class="n">{{ total.submitRate }}%</div><div class="l">提交率</div></div>
</div> </div>
</div> </div>
<div class="groups" v-if="data"> <!-- 提交状态 Tab -->
<div v-for="(g, gi) in data.groups" :key="gi" class="grp"> <Tabs :value="submitStatus" @on-click="(n: string) => { submitStatus = n; doSearch(); }" class="mb-12">
<div class="grp-h">{{ g.className }}({{ g.students.length }} 人)</div> <TabPane v-for="t in SUBMIT_TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
<Table :columns="stuColumns" :data="g.students" size="small"></Table> </Tabs>
</div>
<p v-if="!data.groups.length" class="tip">该状态下暂无学生。</p> <Table
:columns="columns"
:data="page.content"
class="link-table table-bottom-box"
:height="tableHeight"
:loading="loading"
no-data-text="暂无数据"
></Table>
<div class="page-panel">
<div class="fl ml-15 align-middle"></div>
<Page
class="page-box fr"
:total="page.total"
:current="pageNum"
:page-size="pageSize"
:page-size-opts="[10, 20, 30, 40]"
@on-page-size-change="pageSizeUpdate"
@on-change="pageUpdate"
placement="top"
show-total
show-sizer
></Page>
</div> </div>
</template> </template>
<!-- 批改弹窗 --> <!-- 批阅弹窗(版本切换 + 批阅/打回) -->
<Modal v-model="gradeModal.open" :title="`批改 · ${gradeModal.studentName}`" width="520px"> <ReviewModal v-model:open="reviewModal.open" :homeworkId="serid" :student="reviewModal.row" @saved="onReviewed" />
<div class="ans-box">
<div class="ans-h">学生作答</div>
<Spin v-if="gradeModal.loadingAnswer" />
<template v-else>
<div class="ans-text">{{ gradeModal.answerText || "(无文字作答)" }}</div>
<div class="ans-imgs" v-if="gradeModal.answerImages.length">
<div class="ans-img" v-for="(u, ui) in gradeModal.answerImages" :key="ui" @click="zoom(previewUrl(u))">
<img :src="previewUrl(u)" />
</div>
</div>
</template>
</div>
<Divider style="margin: 8px 0" />
<Form :label-width="72">
<FormItem label="方式">
<RadioGroup v-model="gradeModal.mode">
<Radio label="grade">打分/等级</Radio>
<Radio label="redo">要求订正</Radio>
</RadioGroup>
</FormItem>
<template v-if="gradeModal.mode !== 'redo'">
<FormItem label="分数">
<Input v-model="gradeModal.score" placeholder="0-100,可留空只用等级" style="width: 160px"></Input>
</FormItem>
<FormItem label="等级">
<Select v-model="gradeModal.level" style="width: 160px">
<Option value="A">A(优)</Option>
<Option value="B">B(良)</Option>
<Option value="C">C(待改进)</Option>
</Select>
</FormItem>
</template>
<FormItem label="评语">
<Input v-model="gradeModal.comment" type="textarea" :rows="3" placeholder="输入批改评语"></Input>
</FormItem>
</Form>
<template #footer>
<Button @click="gradeModal.open = false">取消</Button>
<Button type="warning" icon="md-star" style="margin-left: 8px" @click="doGrade('excellent')">标为优秀</Button>
<Button type="primary" icon="md-checkmark" :loading="loading" @click="onSave">保存</Button>
</template>
</Modal>
<!-- 作答图片放大预览 --> <!-- 附件图片放大 -->
<Modal v-model="imgPreview.open" footer-hide title="作答图片" width="680px"> <Modal v-model="imgPreview.open" footer-hide title="作答图片" width="680px">
<div class="zoom-wrap"><img :src="imgPreview.url" /></div> <div class="zoom-wrap"><img :src="imgPreview.url" /></div>
</Modal> </Modal>
@@ -257,53 +360,6 @@ function onSave() {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.ans-box {
.ans-h {
font-weight: 600;
color: #17233d;
margin-bottom: 6px;
}
.ans-text {
color: #515a6e;
line-height: 1.6;
background: #fafcff;
border: 1px solid #e8eaec;
border-radius: 4px;
padding: 8px 10px;
max-height: 140px;
overflow-y: auto;
}
.ans-imgs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
.ans-img {
width: 80px;
height: 80px;
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;
}
}
}
}
.zoom-wrap {
text-align: center;
img {
max-width: 100%;
max-height: 62vh;
}
}
.tip { .tip {
color: #808695; color: #808695;
padding: 20px 0; padding: 20px 0;
@@ -336,24 +392,17 @@ function onSave() {
.warn { .warn {
color: #ff9900; color: #ff9900;
} }
.ok {
color: #19be6b;
}
.err { .err {
color: #ed4014; color: #ed4014;
} }
} }
} }
} }
.groups { .zoom-wrap {
margin-top: 4px; text-align: center;
.grp { img {
margin-bottom: 14px; max-width: 100%;
.grp-h { max-height: 62vh;
font-weight: 600;
padding: 8px 4px;
color: #17233d;
}
} }
} }
</style> </style>