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(附件上传)——尚未挂路由,页面切换随后续阶段
This commit is contained in:
wanghu
2026-09-16 02:29:32 +08:00
parent d75aab8d07
commit e6639f7925
40 changed files with 7734 additions and 1535 deletions
+522
View File
@@ -0,0 +1,522 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, h } from "vue";
import { useRouter } from "vue-router";
import { useRequest } from "alova/client";
import { Message } from "view-ui-plus";
import { teacherApi } from "@/api/module/teacherHomework";
import type { HomeworkV2Vo, TeacherHomeworkQuery } from "@/types/homeworkV2";
import type { BasePageResult } from "@/types/homeworkV2";
import MyConfirm from "@/components/MyConfirm";
import Utils from "@/utils";
const router = useRouter();
const SUBJECTS = ["语文", "数学", "英语", "物理", "化学", "生物", "道德与法治", "历史", "地理", "科学", "综合实践"];
/* ============ 顶级视图:已发布 / 草稿箱 ============ */
type ViewMode = "published" | "drafts";
const viewMode = ref<ViewMode>("published");
/* 已发布状态筛选(空 = 全部) */
const PUB_TABS = [
{ name: "", label: "全部" },
{ name: "PUBLISHED", label: "进行中" },
{ name: "CLOSED", label: "已截止" },
{ name: "ARCHIVED", label: "已归档" },
];
const pubStatus = ref("");
/* ============ 搜索条件 ============ */
const keyword = ref("");
const subject = ref("");
/* ============ 分页 ============ */
const pageNum = ref(1);
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
const EMPTY_PAGE: BasePageResult<HomeworkV2Vo> = { content: [], total: 0, pageIndex: 1, pageSize: 10, totalPages: 0, hasNext: false };
const { data: page, loading, send: reload } = useRequest((params: TeacherHomeworkQuery, mode: ViewMode) =>
mode === "drafts" ? teacherApi.drafts(params) : teacherApi.published(params),
{ immediate: false, initialData: EMPTY_PAGE });
async function run() {
const params: TeacherHomeworkQuery = {
keyword: keyword.value || undefined,
subject: subject.value || undefined,
pageIndex: pageNum.value,
pageSize: pageSize.value,
};
if (viewMode.value === "published" && pubStatus.value) params.hwStatus = pubStatus.value;
try {
await reload(params, viewMode.value);
} catch (e: any) {
Message.error(e.message || "获取作业列表失败");
}
}
function doSearch() {
pageNum.value = 1;
run();
}
function switchView(name: string) {
viewMode.value = name as ViewMode;
pageNum.value = 1;
run();
}
function onPubTab(name: string) {
pubStatus.value = name;
doSearch();
}
function pageUpdate(p: number) {
pageNum.value = p;
run();
}
function pageSizeUpdate(size: number) {
pageNum.value = 1;
pageSize.value = size;
localStorage.setItem("customPageCount", String(size));
run();
}
run();
watch(
() => router.currentRoute.value.path,
() => {
pageNum.value = 1;
keyword.value = "";
subject.value = "";
pubStatus.value = "";
viewMode.value = "published";
run();
}
);
/* ============ 状态色 ============ */
const STATUS_COLOR: Record<string, string> = {
DRAFT_UNPUBLISHED: "default",
DRAFT_SCHEDULED: "blue",
PUBLISHED: "primary",
CLOSED: "warning",
ARCHIVED: "success",
};
function fmtTime(v?: string | null): string {
return v ? String(v).slice(0, 16) : "—";
}
/* ============ 行操作 ============ */
function viewDetail(row: HomeworkV2Vo) {
router.push({ path: "/list/detail", query: { serid: String(row.id) } });
}
function viewSubmissions(row: HomeworkV2Vo) {
router.push({ path: "/submissions", query: { serid: String(row.id) } });
}
function toGrade(row: HomeworkV2Vo) {
router.push({ path: "/grade", query: { serid: String(row.id) } });
}
function edit(row: HomeworkV2Vo) {
router.push({ path: "/list/new", query: { serid: String(row.id) } });
}
function remind(row: HomeworkV2Vo, type: "NOT_READ" | "NOT_SUBMITTED") {
const label = type === "NOT_READ" ? "未读" : "未交";
MyConfirm({
title: "提醒",
msg: `向“ ${row.title} ”的${label}学生发送提醒?`,
async onOk() {
try {
const r = await teacherApi.remind(row.id, type).send();
Message.success(`已提醒 ${r?.reminded ?? 0} 位${label}学生`);
} catch (e: any) {
Message.error(e.message || "提醒失败");
}
},
});
}
/* 延长截止(仅进行中;新截止须晚于 now 与原截止) */
const extendModal = reactive({ open: false, row: null as HomeworkV2Vo | null, time: null as Date | null, saving: false });
function openExtend(row: HomeworkV2Vo) {
extendModal.row = row;
extendModal.time = null;
extendModal.open = true;
}
async function doExtend() {
const row = extendModal.row;
if (!row) return;
if (!extendModal.time) {
Message.info("请选择新的截止时间");
return;
}
const d = extendModal.time;
const pad = (n: number) => String(n).padStart(2, "0");
const at = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
if (row.deadlineAt && at <= String(row.deadlineAt)) {
Message.info("新截止时间必须晚于原截止时间");
return;
}
extendModal.saving = true;
try {
await teacherApi.extend(row.id, at).send();
Message.success("截止时间已延长");
extendModal.open = false;
run();
} catch (e: any) {
Message.error(e.message || "延长截止失败");
} finally {
extendModal.saving = false;
}
}
function copyRow(row: HomeworkV2Vo) {
MyConfirm({
title: "复制",
msg: `复制“ ${row.title} ”为新草稿?`,
info: "复制作业内容与班级设置,不含提交与阅读数据",
async onOk() {
try {
const newId = await teacherApi.copy(row.id).send();
Message.success("已复制为新草稿");
router.push({ path: "/list/new", query: { serid: String(newId) } });
} catch (e: any) {
Message.error(e.message || "复制失败");
}
},
});
}
function archive(row: HomeworkV2Vo) {
MyConfirm({
title: "归档",
msg: `归档“ ${row.title} ”?`,
info: "归档后作业只读,不再触发任何提醒",
async onOk() {
try {
await teacherApi.archive(row.id).send();
Message.success("已归档");
run();
} catch (e: any) {
Message.error(e.message || "归档失败");
}
},
});
}
function doExport(row: HomeworkV2Vo) {
window.location.href = teacherApi.exportUrl(row.id);
}
/* 草稿箱操作 */
function publishDraft(row: HomeworkV2Vo) {
MyConfirm({
title: "发布",
msg: `立即发布“ ${row.title} ”?`,
info: "发布后参与班级学生将收到作业通知",
async onOk() {
try {
await teacherApi.publish(row.id).send();
Message.success("发布成功");
switchView("published");
} catch (e: any) {
Message.error(e.message || "发布失败");
}
},
});
}
function cancelSchedule(row: HomeworkV2Vo) {
MyConfirm({
title: "取消定时",
msg: `取消“ ${row.title} ”的定时发布?`,
info: "取消后作业回到草稿状态",
async onOk() {
try {
await teacherApi.schedule(row.id, { cancel: true }).send();
Message.success("已取消定时");
run();
} catch (e: any) {
Message.error(e.message || "取消定时失败");
}
},
});
}
function del(row: HomeworkV2Vo) {
MyConfirm({
title: "删除",
msg: `确定删除“ ${row.title} ”这条作业吗?`,
info: "删除后不可恢复",
async onOk() {
try {
await teacherApi.remove(row.id).send();
Message.success("删除成功");
if ((page.value?.content?.length || 0) <= 1 && pageNum.value > 1) pageNum.value -= 1;
run();
} catch (e: any) {
Message.error(e.message || "删除失败");
}
},
});
}
/* 文本按钮 */
function btn(text: string, onClick: () => void) {
return h("Button", { type: "text", size: "small", onClick }, () => text);
}
/* ============ 已发布列表列 ============ */
const pubColumns = computed(() => [
{ title: "序号", type: "index", width: 60, align: "center" },
{
title: "标题",
key: "title",
minWidth: 200,
align: "left",
tooltip: true,
render: (h: any, p: any) =>
h("a", { style: "color:#2d8cf0;", onClick: () => viewDetail(p.row) }, () => p.row.title),
},
{ title: "学科", key: "subject", width: 80, align: "center" },
{
title: "班级",
width: 130,
align: "left",
tooltip: true,
render: (h: any, p: any) => h("span", {}, (p.row.classes || []).map((c: any) => c.className).join("、") || "—"),
},
{ title: "发布时间", width: 130, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.publishedAt)) },
{ title: "截止时间", width: 130, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.deadlineAt)) },
{
title: "应交",
width: 65,
align: "center",
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.should ?? "—")),
},
{
title: "已读",
width: 65,
align: "center",
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.read ?? "—")),
},
{
title: "已交",
width: 65,
align: "center",
render: (h: any, p: any) => h("span", {}, String(p.row.progress?.submitted ?? "—")),
},
{
title: "未读未交",
width: 85,
align: "center",
render: (h: any, p: any) => {
const g = p.row.progress;
if (!g) return h("span", { style: "color:#999;" }, "—");
return h("span", { style: g.unreadUndone > 0 ? "color:#ed4014;font-weight:600;" : "color:#999;" }, String(g.unreadUndone));
},
},
{
title: "提交率",
width: 80,
align: "center",
render: (h: any, p: any) => {
const g = p.row.progress;
if (!g) return h("span", { style: "color:#999;" }, "—");
return h("span", {}, `${g.submitRate}%`);
},
},
{
title: "待批阅",
width: 75,
align: "center",
render: (h: any, p: any) => {
const g = p.row.progress;
if (!g) return h("span", { style: "color:#999;" }, "—");
return h("span", { style: g.pendingGrade > 0 ? "color:#f90;font-weight:600;" : "color:#999;" }, String(g.pendingGrade));
},
},
{
title: "状态",
width: 90,
align: "center",
render: (h: any, p: any) => h("Tag", { color: STATUS_COLOR[p.row.hwStatus] || "default" }, () => p.row.hwStatusDesc || p.row.hwStatus),
},
{
title: "操作",
width: 250,
align: "center",
fixed: "right",
render: (h: any, p: any) => {
const row = p.row as HomeworkV2Vo;
const nodes: any[] = [];
nodes.push(btn("提交情况", () => viewSubmissions(row)));
if ((row.progress?.pendingGrade ?? 0) > 0) nodes.push(btn("批阅", () => toGrade(row)));
const more: any[] = [];
if (row.hwStatus === "PUBLISHED") {
if ((row.progress?.unread ?? 0) > 0) more.push(mItem("提醒未读", () => remind(row, "NOT_READ")));
if ((row.progress?.unsubmitted ?? 0) > 0) more.push(mItem("催交", () => remind(row, "NOT_SUBMITTED")));
more.push(mItem("延长截止", () => openExtend(row)));
}
more.push(mItem("复制", () => copyRow(row)));
if (row.hwStatus === "PUBLISHED" || row.hwStatus === "CLOSED") more.push(mItem("归档", () => archive(row)));
more.push(mItem("导出报表", () => doExport(row)));
nodes.push(
h("Dropdown", { transfer: true, onOnClick: (name: string) => onMore(name) }, () => [
h("Button", { type: "text", size: "small" }, () => "更多∨"),
h("DropdownMenu", { slot: "list" }, () => more),
])
);
return h("div", {}, nodes);
},
},
]);
/* 更多菜单回调(闭包 name→action) */
const moreActions = new Map<string, () => void>();
let moreSeq = 0;
function mItem(text: string, action: () => void) {
const key = `m${++moreSeq}`;
moreActions.set(key, action);
return h("DropdownItem", { name: key }, () => text);
}
function onMore(name: string) {
moreActions.get(name)?.();
}
/* ============ 草稿箱列 ============ */
const draftColumns = computed(() => [
{ title: "序号", type: "index", width: 60, align: "center" },
{ title: "标题", key: "title", minWidth: 220, align: "left", tooltip: true },
{ title: "学科", key: "subject", width: 90, align: "center" },
{
title: "班级",
width: 150,
align: "left",
tooltip: true,
render: (h: any, p: any) => h("span", {}, (p.row.classes || []).map((c: any) => c.className).join("、") || "—"),
},
{ title: "更新时间", width: 140, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.updateTime)) },
{ title: "截止时间", width: 140, align: "left", render: (h: any, p: any) => h("span", {}, fmtTime(p.row.deadlineAt)) },
{
title: "状态",
width: 110,
align: "center",
render: (h: any, p: any) => {
const row = p.row as HomeworkV2Vo;
const st = row.hwStatus === "DRAFT_SCHEDULED" ? `定时 ${fmtTime(row.scheduledAt)}` : "未发布";
return h("Tag", { color: STATUS_COLOR[row.hwStatus] || "default" }, () => st);
},
},
{
title: "操作",
width: 250,
align: "center",
fixed: "right",
render: (h: any, p: any) => {
const row = p.row as HomeworkV2Vo;
const nodes: any[] = [];
nodes.push(btn("编辑", () => edit(row)));
nodes.push(btn("发布", () => publishDraft(row)));
if (row.hwStatus === "DRAFT_SCHEDULED") nodes.push(btn("取消定时", () => cancelSchedule(row)));
nodes.push(btn("删除", () => del(row)));
return nodes;
},
},
]);
const columns = computed(() => (viewMode.value === "drafts" ? draftColumns.value : pubColumns.value));
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<!-- 1. 操作 + 视图切换 -->
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="router.push('/list/new')">布置作业</Button>
</div>
<Tabs :value="viewMode" @on-click="switchView" class="mb-12">
<TabPane name="published" label="已发布"></TabPane>
<TabPane name="drafts" label="草稿箱"></TabPane>
</Tabs>
<!-- 2. 已发布状态筛选 + 搜索区 -->
<div class="search-wrap fix">
<div v-if="viewMode === 'published'" class="fl">
<Tabs :value="pubStatus" @on-click="onPubTab" size="small" class="inner-tabs">
<TabPane v-for="t in PUB_TABS" :key="t.name" :name="t.name" :label="t.label"></TabPane>
</Tabs>
</div>
<Select v-model="subject" class="search-wrap_sel ml-12 fl" clearable placeholder="学科" style="width: 120px" @on-change="doSearch">
<Option v-for="s in SUBJECTS" :key="s" :value="s">{{ s }}</Option>
</Select>
<Input
class="fr"
style="width: 200px"
search
suffix="ios-search"
placeholder="输入关键字搜索"
v-model="keyword"
@on-search="doSearch"
@on-clear="doSearch"
/>
</div>
<!-- 3. 表格 + 分页 -->
<Table
:columns="columns"
:data="page.content"
class="link-table table-bottom-box"
:height="tableHeight"
:loading="loading"
no-data-text="暂无数据"
></Table>
<div class="page-panel">
<div class="fl ml-15 align-middle"></div>
<Page
class="page-box fr"
:total="page.total"
:current="pageNum"
:page-size="pageSize"
:page-size-opts="[10, 20, 30, 40]"
@on-page-size-change="pageSizeUpdate"
@on-change="pageUpdate"
placement="top"
show-total
show-sizer
></Page>
</div>
</div>
<!-- 延长截止 -->
<Modal v-model="extendModal.open" title="延长截止时间" width="420px">
<Form :label-width="100">
<FormItem label="新截止时间:">
<DatePicker
type="datetime"
transfer
v-model="extendModal.time"
format="yyyy-MM-dd HH:mm"
placeholder="选择新的截止时间"
style="width: 240px"
></DatePicker>
<div class="extend-tip" v-if="extendModal.row?.deadlineAt">
原截止时间:{{ extendModal.row.deadlineAt }}
</div>
</FormItem>
</Form>
<template #footer>
<Button type="primary" :loading="extendModal.saving" @click="doExtend">保存</Button>
<Button @click="extendModal.open = false" style="margin-left: 8px">取消</Button>
</template>
</Modal>
</div>
</template>
<style scoped lang="scss">
.search-wrap {
.inner-tabs {
margin-bottom: -8px;
:deep(.ivu-tabs-bar) {
margin-bottom: 0;
}
}
}
.extend-tip {
color: #808695;
font-size: 12px;
margin-top: 4px;
}
</style>