Files
coursehomework-front/src/views/homework/Submissions.vue
T
wanghu e6639f7925 feat(M5): P0 契约层 + 审批/应用设置下线
- types/homeworkV2.ts:V2 全量类型契约(五态/枚举/矩阵/版本/通知)
- teacherHomework/studentHomework/notification 三客户端(28 端点)
- §6 审批功能下线:删 ApprovalList/ApprovalSetting/ApprovalFlowEditor,
  types/api/menu/menuRoute/App/mock 同步清理,DROPPED_MOD_IDS={5,6}
- §7 应用设置下线:删 HomeworkSetting/TypeSetBox/UserCheckInput,
  SettingApi 与 dict/contacts 死代码清理,DROPPED_MOD_IDS 扩 {21}
- 新增 V2 组件:TeacherHomeworks(已发布+草稿箱)、ReviewModal(批阅弹窗)、
  AttachmentUpload(附件上传)——尚未挂路由,页面切换随后续阶段
2026-09-16 02:29:32 +08:00

360 lines
13 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.
<script setup lang="ts">
import { ref, reactive, computed, h, onMounted, watch } from "vue";
import { useRoute } from "vue-router";
import { Message } from "view-ui-plus";
import { homeworkApi } from "@/api/module/homework";
import type { SubmissionsResultVo, SubmissionBrief, HomeworkVo } from "@/types/homework";
import config from "@/assets/config/host.config";
import Utils from "@/utils";
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 serid = ref<number>(Number(route.query.serid) || 0);
/* 未指定作业时,先在“我布置的”里选一份 */
const myHomeworks = ref<HomeworkVo[]>([]);
const loadingList = ref(false);
async function loadMyHomeworks() {
loadingList.value = true;
try {
const r = await homeworkApi.sendList({ scope: "user", pageSize: 100, pageIndex: 1 }).send();
myHomeworks.value = r.content || [];
} catch (e: any) {
Message.error(e.message || "加载作业失败");
} finally {
loadingList.value = false;
}
}
/* ============ 提交情况数据 ============ */
const data = ref<SubmissionsResultVo | null>(null);
const loading = ref(false);
const submitStatus = ref("all"); // all/submitted/unsubmitted/graded/needRedo
async function load() {
if (!serid.value) {
data.value = null;
return;
}
loading.value = true;
try {
data.value = await homeworkApi.submissions(serid.value, submitStatus.value).send();
} catch (e: any) {
Message.error(e.message || "加载提交情况失败");
data.value = null;
} finally {
loading.value = false;
}
}
watch(submitStatus, load);
watch(
() => route.query.serid,
(v) => {
serid.value = Number(v) || 0;
submitStatus.value = "all";
load();
}
);
onMounted(async () => {
await loadMyHomeworks();
load();
});
const summary = computed(() => data.value?.summary || null);
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" };
/* 学生表格列(纯 render,避免 iView 具名 slot 差异) */
const stuColumns = [
{ title: "学生", key: "studentName", minWidth: 120 },
{
title: "状态",
width: 110,
align: "center",
render: (h: any, p: any) => h("Tag", { color: STATUS_COLOR[p.row.submitStatus] || "default" }, () => STATUS_TEXT[p.row.submitStatus] || p.row.submitStatus),
},
{ 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: "操作",
width: 110,
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)),
},
];
/* ============ 内联批改(走 Grade 接口,核心闭环即时批改) ============ */
const gradeModal = reactive({
open: false,
subId: 0,
studentName: "",
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>
<template>
<div class="page-wrapper">
<div class="main-container">
<!-- 顶部:作业选择 + 状态过滤 -->
<div class="fix mb-12">
<div class="fl" style="display: flex; align-items: center; gap: 8px">
<span style="color: #515a6e">作业:</span>
<Select v-model="serid" style="width: 360px" placeholder="选择一份我布置的作业" filterable :loading="loadingList" @on-change="load">
<Option v-for="hw in myHomeworks" :key="hw.id" :value="hw.id">{{ hw.subject ? hw.subject + " · " : "" }}{{ hw.title }}</Option>
</Select>
</div>
<div class="fr" style="display: flex; align-items: center; gap: 8px">
<span style="color: #515a6e">状态:</span>
<Select v-model="submitStatus" style="width: 140px">
<Option value="all">全部</Option>
<Option value="submitted">已交待批</Option>
<Option value="graded">已批改</Option>
<Option value="needRedo">待订正</Option>
<Option value="unsubmitted">未交</Option>
</Select>
</div>
</div>
<p v-if="!serid" class="tip">请先在上方选择一份作业查看提交情况。</p>
<template v-else>
<div v-if="summary" class="sum-bar mb-12">
<div class="sum-title">
<b>{{ data?.title }}</b>
<span class="sub">{{ data?.subject }} · {{ data?.hwStatusDesc }} · 截止 {{ data?.deadline ? String(data.deadline).slice(0, 16) : "—" }}</span>
</div>
<div class="sum-cards">
<div class="card"><div class="n">{{ summary.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 warn">{{ summary.pendingGrade }}</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 err">{{ summary.unsubmitted }}</div><div class="l">未交</div></div>
<div class="card"><div class="n">{{ summary.submitRate }}%</div><div class="l">提交率</div></div>
</div>
</div>
<div class="groups" v-if="data">
<div v-for="(g, gi) in data.groups" :key="gi" class="grp">
<div class="grp-h">{{ g.className }}({{ g.students.length }} 人)</div>
<Table :columns="stuColumns" :data="g.students" size="small"></Table>
</div>
<p v-if="!data.groups.length" class="tip">该状态下暂无学生。</p>
</div>
</template>
<!-- 批改弹窗 -->
<Modal v-model="gradeModal.open" :title="`批改 · ${gradeModal.studentName}`" width="520px">
<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">
<div class="zoom-wrap"><img :src="imgPreview.url" /></div>
</Modal>
</div>
</div>
</template>
<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 {
color: #808695;
padding: 20px 0;
}
.sum-bar {
border: 1px solid #e8eaec;
border-radius: 4px;
padding: 14px 16px;
background: #fafcff;
.sub {
color: #808695;
margin-left: 12px;
font-size: 13px;
}
.sum-cards {
display: flex;
gap: 28px;
margin-top: 12px;
.card {
text-align: center;
.n {
font-size: 22px;
font-weight: 600;
color: #17233d;
}
.l {
font-size: 12px;
color: #808695;
}
.warn {
color: #ff9900;
}
.ok {
color: #19be6b;
}
.err {
color: #ed4014;
}
}
}
}
.groups {
margin-top: 4px;
.grp {
margin-bottom: 14px;
.grp-h {
font-weight: 600;
padding: 8px 4px;
color: #17233d;
}
}
}
</style>