feat: 新建课程作业模块(基于校园公告模板),实现 v1 教师端与 v2 学生/家长端

以 CampusAnnouncement 为模板建立课程作业模块,重命名标识符与文案(notice→homework、公告→作业),
并保留其顶栏/左栏外壳,按课程作业业务重搭数据模型与页面。

- mock(src/assets/mock/homeworkapi.ts):作业化数据模型(学科/截止/提交方式/流程状态),
  班级花名册→学生个体提交记录与批改(确定性分布),端点 List/SendList/Submissions/
  SubmissionDetail/Grade/Remind/Statis 等;左栏菜单按角色返回。
- v1 教师端:布置作业(新增学科/截止/提交方式)、我布置的(状态标签+提交/批改进度)、
  提交情况(Submissions,含内联批改)、批改中心(Grade 逐份批改)、作业统计(成绩分布+进度)。
- v2 学生/家长端(端分离):SetRole + viewerRole 切换身份;StudentHomeworks.vue 承担
  今日作业/待订正(学生可提交/重交)与孩子作业(家长只读+提醒);新增 MyHomeworks/Submit/
  MySubmission/ParentNudge 接口与类型。
- 类型与 api 模块同步;dev:mock 三角色 curl 端到端验证通过。
This commit is contained in:
lijing
2026-09-14 18:40:20 +08:00
commit d75aab8d07
344 changed files with 35558 additions and 0 deletions
+256
View File
@@ -0,0 +1,256 @@
<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";
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) => h("Button", { type: "text", size: "small", onClick: () => openGrade(p.row as SubmissionBrief) }, () => (p.row.hasGrade ? "再批" : "批改")),
},
];
/* ============ 内联批改(走 Grade 接口,核心闭环即时批改) ============ */
const gradeModal = reactive({ open: false, subId: 0, studentName: "", mode: "grade", action: "grade", score: "" as string | number, level: "A", comment: "" });
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 = "";
}
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="440px">
<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" style="margin-left: 8px" @click="doGrade('excellent')">标为优秀</Button>
<Button type="primary" :loading="loading" @click="onSave">保存</Button>
</template>
</Modal>
</div>
</div>
</template>
<style lang="scss" scoped>
.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>