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
+307
View File
@@ -0,0 +1,307 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from "vue";
import { useRoute } from "vue-router";
import { Message } from "view-ui-plus";
import { homeworkApi } from "@/api/module/homework";
import type { StudentHomeworkVo, MyHomeworksVo } from "@/types/homework";
import HomeworkUpload from "@/components/HomeworkUpload.vue";
const route = useRoute();
const role = computed(() => (route.meta as any).role || "student");
const isParent = computed(() => role.value === "parent");
const TABS = [
{ name: "all", label: "全部" },
{ name: "todo", label: "待完成" },
{ name: "submitted", label: "已交待批" },
{ name: "graded", label: "已批改" },
{ name: "redo", label: "待订正" },
];
const tab = ref<string>((route.meta as any).initTab || "all");
const data = ref<MyHomeworksVo | null>(null);
const loading = ref(false);
async function load() {
loading.value = true;
try {
data.value = await homeworkApi.myHomeworks().send();
} catch (e: any) {
Message.error(e.message || "加载作业失败");
} finally {
loading.value = false;
}
}
onMounted(load);
watch(() => route.fullPath, load);
function bucketOf(x: StudentHomeworkVo): string {
if (x.viewOnly) return "view";
const st = x.my ? x.my.submitStatus : "unsubmitted";
if (st === "unsubmitted") return "todo";
if (st === "submitted") return "submitted";
if (st === "needRedo") return "redo";
return "graded";
}
const filtered = computed(() => {
const list = data.value?.list || [];
if (tab.value === "all") return list;
return list.filter((x) => bucketOf(x) === tab.value);
});
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" };
function dueInfo(deadline: string | null) {
if (!deadline) return { text: "无截止", overdue: false };
const d = new Date(String(deadline).replace(/-/g, "/")).getTime();
const now = Date.now();
const diffH = (d - now) / 3600000;
if (diffH < 0) return { text: "已截止", overdue: true };
if (diffH < 24) return { text: `剩 ${Math.max(1, Math.round(diffH))} 小时`, overdue: false };
return { text: `剩 ${Math.round(diffH / 24)} 天`, overdue: false };
}
/* ============ 提交 / 重交 ============ */
const sub = reactive({ open: false, serid: 0, title: "", text: "", fileids: [] as number[], isRedo: false, submitting: false });
function openSubmit(row: StudentHomeworkVo) {
sub.open = true;
sub.serid = row.id;
sub.title = row.title;
sub.text = row.my?.text || "";
sub.fileids = [];
sub.isRedo = !!row.my && row.my.submitStatus === "needRedo";
}
async function submit() {
sub.submitting = true;
try {
await homeworkApi.submit({ serid: sub.serid, text: sub.text, attachments: sub.fileids.join(",") }).send();
Message.success("提交成功");
sub.open = false;
load();
} catch (e: any) {
Message.error(e.message || "提交失败");
} finally {
sub.submitting = false;
}
}
/* ============ 批改反馈 ============ */
const fb = reactive({ open: false, row: null as StudentHomeworkVo | null });
function openFeedback(row: StudentHomeworkVo) {
fb.open = true;
fb.row = row;
}
/* ============ 家长提醒 ============ */
async function nudge(row: StudentHomeworkVo) {
try {
await homeworkApi.parentNudge(row.id).send();
Message.success("已提醒孩子");
} catch (e: any) {
Message.error(e.message || "提醒失败");
}
}
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<div class="head fix mb-12">
<span class="h-title">{{ isParent ? "孩子作业" : "我的作业" }}</span>
<span class="h-sub" v-if="data">{{ isParent ? "(只读查看 + 提醒)" : "待完成 " + data.buckets.todo + " · 待订正 " + data.buckets.redo }}</span>
</div>
<Tabs :value="tab" @on-click="(n: string) => (tab = n)" class="mb-12">
<TabPane v-for="t in TABS" :key="t.name" :name="t.name" :label="t.label + (t.name !== 'all' && data ? ' ' + (t.name==='todo'?data.buckets.todo:t.name==='submitted'?data.buckets.submitted:t.name==='graded'?data.buckets.graded:data.buckets.redo) : '')"></TabPane>
</Tabs>
<Spin v-if="loading" fix />
<p v-if="!loading && !filtered.length" class="tip">该分类下暂无作业。</p>
<div class="cards">
<div class="card" v-for="row in filtered" :key="row.id">
<div class="c-top">
<span class="c-subj">{{ row.subject }}</span>
<span class="c-title">{{ row.title }}</span>
<Tag v-if="row.viewOnly" color="default">仅查阅</Tag>
<Tag v-else :color="STATUS_COLOR[row.my ? row.my.submitStatus : 'unsubmitted']">{{ STATUS_TEXT[row.my ? row.my.submitStatus : "unsubmitted"] }}</Tag>
</div>
<div class="c-meta">
<span>{{ row.publisherName }} · {{ row.deptName }}</span>
<span :class="{ overdue: dueInfo(row.deadline).overdue && bucketOf(row) === 'todo' }">截止 {{ row.deadline ? String(row.deadline).slice(5, 16) : "—" }}({{ dueInfo(row.deadline).text }})</span>
</div>
<div class="c-body" v-html="row.content"></div>
<!-- 批改结果摘要 -->
<div class="c-grade" v-if="row.my && (row.my.submitStatus === 'graded' || row.my.submitStatus === 'redone')">
<span class="g-badge" v-if="row.my.isExcellent">优秀</span>
得分 {{ row.my.score ?? "—" }} · 等级 {{ row.my.level ?? "—" }} · {{ row.my.comment }}
</div>
<div class="c-grade warn" v-else-if="row.my && row.my.submitStatus === 'needRedo'">
老师要求订正:{{ row.my.comment }}
</div>
<div class="c-actions">
<template v-if="!isParent">
<Button v-if="bucketOf(row) === 'todo'" type="primary" size="small" @click="openSubmit(row)">提交作业</Button>
<Button v-else-if="bucketOf(row) === 'redo'" type="error" size="small" @click="openSubmit(row)">重新提交</Button>
<Button
v-if="row.my && (row.my.submitStatus === 'graded' || row.my.submitStatus === 'redone' || row.my.submitStatus === 'needRedo')"
size="small"
@click="openFeedback(row)"
>查看批改</Button
>
</template>
<template v-else>
<Button
v-if="row.my && (row.my.submitStatus === 'graded' || row.my.submitStatus === 'redone' || row.my.submitStatus === 'needRedo')"
size="small"
@click="openFeedback(row)"
>查看批改</Button
>
<Button v-if="bucketOf(row) === 'todo' || bucketOf(row) === 'redo'" type="primary" size="small" ghost @click="nudge(row)">提醒孩子</Button>
</template>
</div>
</div>
</div>
<!-- 提交弹窗 -->
<Modal v-model="sub.open" :title="(sub.isRedo ? '重新提交 · ' : '提交作业 · ') + sub.title" width="560px">
<Form :label-width="72">
<FormItem label="文字作答">
<Input v-model="sub.text" type="textarea" :rows="4" placeholder="输入作答内容"></Input>
</FormItem>
<FormItem label="图片/附件">
<HomeworkUpload v-model:fileids="sub.fileids"></HomeworkUpload>
</FormItem>
</Form>
<template #footer>
<Button @click="sub.open = false">取消</Button>
<Button type="primary" :loading="sub.submitting" @click="submit">提交</Button>
</template>
</Modal>
<!-- 批改反馈弹窗 -->
<Modal v-model="fb.open" title="批改反馈" width="480px">
<div v-if="fb.row && fb.row.my" class="fb">
<div class="fb-row"><span>学生提交</span><span>{{ fb.row.my.submittedAt ? String(fb.row.my.submittedAt).slice(0, 16) : "—" }}</span></div>
<div class="fb-row"><span>作答内容</span><span class="fb-text">{{ fb.row.my.text || "(仅附件)" }}</span></div>
<div class="fb-row"><span>得分</span><span class="fb-score">{{ fb.row.my.score ?? "—" }}</span></div>
<div class="fb-row"><span>等级</span><span>{{ fb.row.my.level || "—" }}</span></div>
<div class="fb-row"><span>评语</span><span>{{ fb.row.my.comment || "—" }}</span></div>
<div class="fb-row" v-if="fb.row.my.isExcellent"><span></span><Tag color="success">优秀作业</Tag></div>
</div>
<template #footer><Button type="primary" @click="fb.open = false">关闭</Button></template>
</Modal>
</div>
</div>
</template>
<style lang="scss" scoped>
.head {
.h-title {
font-size: 16px;
font-weight: 600;
color: #17233d;
}
.h-sub {
margin-left: 10px;
color: #808695;
font-size: 13px;
}
}
.mb-12 {
margin-bottom: 12px;
}
.tip {
color: #808695;
padding: 16px 0;
}
.cards {
.card {
border: 1px solid #e8eaec;
border-radius: 6px;
padding: 14px 16px;
margin-bottom: 12px;
background: #fff;
.c-top {
display: flex;
align-items: center;
gap: 10px;
.c-subj {
background: #e8f3ff;
color: #2d8cf0;
border-radius: 3px;
padding: 1px 8px;
font-size: 12px;
}
.c-title {
font-size: 15px;
font-weight: 600;
color: #17233d;
}
}
.c-meta {
display: flex;
justify-content: space-between;
color: #808695;
font-size: 13px;
margin-top: 6px;
.overdue {
color: #ed4014;
}
}
.c-body {
margin-top: 8px;
color: #515a6e;
line-height: 1.6;
max-height: 76px;
overflow: hidden;
}
.c-grade {
margin-top: 8px;
padding: 6px 10px;
background: #f6ffed;
border-radius: 4px;
color: #389e0d;
font-size: 13px;
&.warn {
background: #fff2f0;
color: #cf1322;
}
.g-badge {
background: #ff9900;
color: #fff;
border-radius: 3px;
padding: 0 6px;
margin-right: 6px;
}
}
.c-actions {
margin-top: 12px;
button {
margin-right: 8px;
}
}
}
}
.fb {
.fb-row {
display: flex;
padding: 8px 0;
border-bottom: 1px dashed #eee;
span:first-child {
width: 80px;
color: #808695;
}
.fb-score {
font-weight: 600;
color: #ed4014;
}
.fb-text {
color: #515a6e;
}
}
}
</style>