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:
@@ -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>
|
||||
Reference in New Issue
Block a user