- 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(附件上传)——尚未挂路由,页面切换随后续阶段
360 lines
13 KiB
Vue
360 lines
13 KiB
Vue
<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>
|