feat(M5-P1): 教师端发布链路切换 V2

- /list 挂载 TeacherHomeworks(已发布+草稿箱双 Tab,PRD 页 2/3),
  HomeworkList 仅保留 /homeworklist(全部作业,V1 冻结接口)
- NewHomework 切 V2:班级树改 teacherApi.classTree(),创建/编辑一律先落草稿
  再按需 publish/schedule,附件改 AttachmentUpload(AttachmentItem[]),
  「仅查阅」与其余提交方式互斥;转发场景保留 V1 GetDetail(V2 详情有归属校验)
- HomeworkList 摘除伪接口 remind 调用(后端无 /api/homework/Remind)
- 路由:/accept 重定向 /stu/today,新增 /stu/detail(学生详情页载体),
  /stu/redo initTab 改 todo(V2 无独立 redo 桶,RETURNED 归 todo)
This commit is contained in:
wanghu
2026-09-16 02:32:31 +08:00
parent e6639f7925
commit 39824fbb3d
3 changed files with 115 additions and 110 deletions
+21 -21
View File
@@ -5,30 +5,23 @@ import type { RouteRecordRaw } from "vue-router";
* 页面间参数通过 query 传递:
* - /list/new?serid= (编辑)
* - /list/new?fid= (转发)
* - /list/new?tempid=(模板引用)
* - /list/detail?serid=&type=、/homeworklist/detail?serid=&type=
* type: 0=编辑视角(不标读) 1=普通查看(标读)
* - /list/detail?serid=、/homeworklist/detail?serid=
*
* modId 是菜单 ↔ 路由的稳定契约(对照后端 GetUserMod 现行数据):
* 1=已收到 2=已发送 3=全部作业 4=作业统计
* 1=已收到(→学生今日作业) 2=已发送 3=全部作业 4=作业统计
* 发作业为「已发送」的深层子页(modId 22 不在菜单树,meta.parentId=2 关联高亮),
* 与 UserBase「应用管理>应用类型管理」范式一致。
*/
const routes: RouteRecordRaw[] = [
// v1 教师默认落地:我布置的
{ path: "/", redirect: "/list" },
// 学生收件箱(v2 保留,不在菜单)
{
path: "/accept",
name: "homework_accept",
component: () => import("@/views/homework/HomeworkAccept.vue"),
meta: { title: "已收到", modId: 1 },
},
// 我布置的(教师端作业列表)
// v1 收件箱语义已由学生端列表替代(M5 §2.3):旧地址与书签统一导向学生今日作业
{ path: "/accept", redirect: "/stu/today" },
// 我布置的(教师端 V2:已发布 + 草稿箱)
{
path: "/list",
name: "homework_list",
component: () => import("@/views/homework/HomeworkList.vue"),
component: () => import("@/views/homework/TeacherHomeworks.vue"),
meta: { title: "我布置的", modId: 2 },
},
// 提交情况(某作业的已交/未交名单;从列表行或菜单进入,无 serid 时页内选择作业)
@@ -53,15 +46,15 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/homework/NewHomework.vue"),
meta: { title: "发作业", modId: 22, parentId: 2 },
},
// 全部作业(管理端)
// 全部作业(管理端,V1 冻结接口;详情走 /homeworklist/detail 的 V1 分支)
{
path: "/homeworklist",
name: "homework_manage",
component: () => import("@/views/homework/HomeworkList.vue"),
meta: { title: "全部作业", modId: 3 },
},
// 作业详情(同一组件挂两个路由,面包屑/菜单高亮由 meta.parentId 确定父节点:
// 已发送下 /list/detail,全部作业下 /homeworklist/detail;type 区分是否标读)
// 作业详情(同一组件挂两个路由:/list/detail 走 V2 详情+统计(仅本人作业),
// /homeworklist/detail 走 V1 冻结 Detail(管理端可看他人作业;V2 有 requireOwned 校验)
{
path: "/list/detail",
name: "homework_list_detail",
@@ -83,19 +76,26 @@ const routes: RouteRecordRaw[] = [
},
/* ---- v2 学生端 ---- */
// 学生·今日作业
// 学生·今日作业(tab=all/todo/done/closed)
{
path: "/stu/today",
name: "student_today",
component: () => import("@/views/homework/StudentHomeworks.vue"),
meta: { title: "今日作业", modId: 31, role: "student" },
},
// 学生·待订正(复用同一页,默认过滤待订正)
// 学生·待订正(V2 无独立 redo 桶:RETURNED 归入 todo,页内红色强化展示)
{
path: "/stu/redo",
name: "student_redo",
component: () => import("@/views/homework/StudentHomeworks.vue"),
meta: { title: "待订正", modId: 32, role: "student", initTab: "redo" },
meta: { title: "待订正", modId: 32, role: "student", initTab: "todo" },
},
// 学生·作业详情/提交/批阅结果(PRD 学生页 2/3 载体)
{
path: "/stu/detail",
name: "student_detail",
component: () => import("@/views/homework/HomeworkAccept.vue"),
meta: { title: "作业详情", modId: 33, role: "student" },
},
/* ---- 旧版路径兼容重定向(书签、残留的平台模块配置) ---- */
@@ -103,10 +103,10 @@ const routes: RouteRecordRaw[] = [
{ path: "/newhomework", redirect: "/list/new" },
{ path: "/edit", redirect: "/list/new" },
{ path: "/manage", redirect: "/homeworklist" },
{ path: "/homework", redirect: "/accept" },
{ path: "/homework", redirect: "/stu/today" },
{ path: "/homework/newhomework", redirect: "/list/new" },
{ path: "/homework/edit", redirect: "/list/new" },
{ path: "/homework/homework_accept", redirect: "/accept" },
{ path: "/homework/homework_accept", redirect: "/stu/today" },
{ path: "/homework/homework_list", redirect: "/list" },
{ path: "/homework/manage", redirect: "/homeworklist" },
{ path: "/homework/homework_details", redirect: "/list/detail" },
-15
View File
@@ -104,20 +104,6 @@ function viewSubmissions(row: HomeworkVo) {
function edit(row: HomeworkVo) {
router.push({ path: "/list/new", query: { serid: row.id } });
}
function remind(row: HomeworkVo) {
MyConfirm({
title: "催交",
msg: `向“ ${row.title} ”的未交学生发送提交提醒?`,
async onOk() {
try {
const r = await homeworkApi.remind(row.id).send();
Message.success(`已提醒 ${(r && (r as any).reminded) ?? 0} 位未交学生`);
} catch (e: any) {
Message.error(e.message || "催交失败");
}
},
});
}
function del(row: HomeworkVo) {
MyConfirm({
title: "删除",
@@ -236,7 +222,6 @@ const columns = [
const nodes: any[] = [];
if (row.hwStatus !== "draft" && (row as any).progress) nodes.push(opBtn(h, "提交情况", "icon-chakan", () => viewSubmissions(row)));
if (row.hwStatus === "draft") nodes.push(opBtn(h, "编辑", "icon-edit", () => edit(row)));
if (row.hwStatus === "collecting" && row.progress && row.progress.unsubmitted > 0) nodes.push(opBtn(h, "催交", "icon-fasong", () => remind(row)));
nodes.push(opBtn(h, "删除", "icon-ashbin", () => del(row)));
return nodes;
},
+94 -74
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { ref, reactive, shallowRef, computed, onMounted, onBeforeUnmount } from "vue";
import { ref, reactive, shallowRef, computed, onMounted, onBeforeUnmount, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRequest } from "alova/client";
import { Message } from "view-ui-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { homeworkApi, baseApi } from "@/api/module/homework";
import type { HomeworkDetailVo, HomeworkRequest, UpFileVo } from "@/types/homework";
import HomeworkUpload from "@/components/HomeworkUpload.vue";
import { teacherApi } from "@/api/module/teacherHomework";
import { homeworkApi } from "@/api/module/homework";
import type { HomeworkV2Request, HomeworkV2Vo, AttachmentItem } from "@/types/homeworkV2";
import type { UpFileVo } from "@/types/homework";
import AttachmentUpload from "@/components/AttachmentUpload.vue";
import config from "@/assets/config/host.config";
const route = useRoute();
@@ -32,7 +33,7 @@ const REMIND_OPTS = [
const form = reactive({
title: "",
subject: "",
deadline: "",
deadline: "" as string | Date,
content: "",
});
const submitTypes = ref<string[]>(["image", "attach"]);
@@ -40,26 +41,32 @@ const allClasses = ref(false);
const selClasses = ref<string[]>([]);
const allowLate = ref(false);
const remindBefore = ref<string[]>([]);
const fileids = ref<number[]>([]);
const editFileList = ref<{ name: string; fileid: number }[]>([]);
const attachments = ref<AttachmentItem[]>([]);
/* 班级下拉多选数据(年级分组) */
/* 「仅查阅」与其余方式互斥(后端 SubmitTypes:none=不必提交) */
watch(submitTypes, (v) => {
if (v.includes("none") && v.length > 1) {
submitTypes.value = v[v.length - 1] === "none" ? ["none"] : v.filter((x) => x !== "none");
}
});
/* ============ 班级下拉多选(V2 教师班级树,叶子 id=classId) ============ */
const classGroups = ref<{ grade: string; items: { value: string; label: string }[] }[]>([]);
const allClassIds = ref<string[]>([]);
async function loadClasses() {
try {
const tree: any[] = await baseApi.getClassTree().send();
const tree = await teacherApi.classTree().send();
const groups: { grade: string; items: { value: string; label: string }[] }[] = [];
const ids: string[] = [];
(tree || []).forEach((g) => {
const items = (g.children || []).map((c: any) => ({ value: String(c.id), label: c.name }));
const items = (g.children || []).map((c) => ({ value: String(c.id), label: c.name }));
items.forEach((i) => ids.push(i.value));
groups.push({ grade: g.name, items });
});
classGroups.value = groups;
allClassIds.value = ids;
} catch {
/* 忽略 */
} catch (e: any) {
Message.error(e.message || "加载班级树失败");
}
}
function toggleAll(v: boolean) {
@@ -109,106 +116,119 @@ function handleCreated(editor: any) {
onBeforeUnmount(() => editorRef.value?.destroy());
/* ============ 定时发布 ============ */
const timeModal = reactive({ open: false, date: "" });
const timeModal = reactive({ open: false, date: "" as string | Date });
const dateOpt = { disabledDate: (d: Date) => d && d.valueOf() < Date.now() - 86400000 };
function fmt(d: any) {
function fmt(d: any): string {
if (!d) return "";
const dt = d instanceof Date ? d : new Date(String(d).replace(/-/g, "/"));
const pad = (n: number) => String(n).padStart(2, "0");
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}:00`;
}
/* ============ 编辑回显 ============ */
/* ============ 编辑回显(V2 详情) / 转发(V1 GetDetail 保留给转发场景) ============ */
function fillFromV2(d: HomeworkV2Vo) {
form.title = (d.title || "").replace("[修订稿]", "");
form.subject = d.subject || "";
form.deadline = d.deadlineAt || "";
form.content = d.description || "";
submitTypes.value = Array.isArray(d.submitType) && d.submitType.length ? [...d.submitType] : ["image"];
allowLate.value = d.allowLate === 1;
remindBefore.value = [d.remind1d === 1 ? "1d" : "", d.remind1h === 1 ? "1h" : ""].filter(Boolean);
const cls = (d.classes || []).map((c) => String(c.classId));
if (cls.length && cls.length === allClassIds.value.length && cls.every((c) => allClassIds.value.includes(c))) {
allClasses.value = true;
selClasses.value = [...allClassIds.value];
} else {
allClasses.value = false;
selClasses.value = cls;
}
attachments.value = [...(d.attachments || [])];
if (d.scheduledAt) timeModal.date = d.scheduledAt;
}
onMounted(async () => {
if (!(isEdit.value || fid.value > 0)) return;
const sourceId = isEdit.value ? serid.value : fid.value;
try {
const detail: HomeworkDetailVo = await homeworkApi.getHomeworkDetails(sourceId).send();
if (isEdit.value) {
form.title = (detail.title || "").replace("[修订稿]", "");
form.subject = (detail as any).subject || "";
form.deadline = (detail as any).deadline || "";
submitTypes.value = Array.isArray((detail as any).submitType) && (detail as any).submitType.length ? (detail as any).submitType : ["image"];
allowLate.value = !!(detail as any).allowLate;
remindBefore.value = Array.isArray((detail as any).remindBefore) ? (detail as any).remindBefore : [];
if (detail.tlist === "all" || !Array.isArray(detail.tlist)) {
allClasses.value = true;
selClasses.value = [...allClassIds.value];
} else {
allClasses.value = false;
selClasses.value = detail.tlist.map((v) => String(v));
const d = await teacherApi.detail(serid.value).send();
if (d.hwStatus !== "DRAFT_UNPUBLISHED" && d.hwStatus !== "DRAFT_SCHEDULED") {
Message.info("仅草稿状态的作业可编辑");
}
if (detail.DsfsTime) timeModal.date = detail.DsfsTime;
fillFromV2(d);
} else {
form.title = detail.title || "";
}
form.content = detail.Contents || "";
if (Array.isArray(detail.ChaoSend) && detail.ChaoSend.length) {
editFileList.value = detail.ChaoSend.map((f) => ({ name: f.filename || f.name, fileid: f.fileid || f.id }));
fileids.value = editFileList.value.map((f) => f.fileid);
// 转发:拿他人作业作底稿(V2 详情有本人归属校验,走 V1 冻结 GetDetail)
const d: any = await homeworkApi.getHomeworkDetails(fid.value).send();
form.title = d.title || "";
form.content = d.Contents || "";
attachments.value = (d.ChaoSend || []).map((f: any) => ({
fileId: f.fileid || f.id,
fileUrl: `/api/file/Preview?id=${f.fileid || f.id}`,
fileType: /\.(jpe?g|gif|png|bmp|webp)$/i.test(f.filename || f.name || "") ? "image" : "file",
fileName: f.filename || f.name,
sort: 0,
}));
}
} catch (e: any) {
Message.error(e.message || "加载作业失败");
}
});
/* ============ 提交 ============ */
/* ============ 提交(V2:创建/编辑一律先落草稿,再按需 publish / schedule) ============ */
const formRef = ref();
const isLoading = ref(false);
const { send: sendCreate } = useRequest((payload: HomeworkRequest) => homeworkApi.create(payload), { immediate: false });
const { send: sendEdit } = useRequest((payload: HomeworkRequest) => homeworkApi.editHomework(payload), { immediate: false });
async function doAdd(mode: "publish" | "draft" | "time") {
if (!(await formRef.value?.validate?.())) {
Message.info("请填写带 * 的必填项");
return;
}
if (!allClasses.value && selClasses.value.length === 0) {
const classIds = allClasses.value ? [...allClassIds.value] : selClasses.value;
if (classIds.length === 0) {
Message.info("请选择接收班级");
return;
}
const data: HomeworkRequest = {
title: form.title,
content: form.content,
subject: form.subject,
deadline: form.deadline ? fmt(form.deadline) : undefined,
submitType: submitTypes.value.length ? submitTypes.value : ["text"],
allowLate: allowLate.value,
remindBefore: remindBefore.value,
receiveList: allClasses.value ? "ALL" : selClasses.value.join(","),
receiveType: "class",
deptId: "",
fileIds: fileids.value.join(","),
draft: mode === "draft",
};
if (mode === "time" && !timeModal.date) {
timeModal.open = true;
Message.info("请选择定时发布时间");
return;
}
let scheduledAt = "";
if (mode === "time") {
if (!timeModal.date) {
Message.info("请选择定时发布时间");
return;
}
const t = new Date(String(timeModal.date).replace(/-/g, "/")).getTime();
if (t < Date.now()) {
scheduledAt = fmt(timeModal.date);
if (new Date(scheduledAt.replace(/-/g, "/")).getTime() <= Date.now()) {
Message.info("定时时间需晚于当前时间");
return;
}
data.timingSendTime = fmt(timeModal.date);
}
const types = submitTypes.value.length ? submitTypes.value : ["text"];
const data: HomeworkV2Request = {
title: form.title,
subject: form.subject,
description: form.content,
classIds,
attachments: attachments.value.map((a, i) => ({ ...a, sort: i })),
deadlineAt: form.deadline ? fmt(form.deadline) : undefined,
submitType: types as HomeworkV2Request["submitType"],
allowLate: allowLate.value ? 1 : 0,
remind1d: remindBefore.value.includes("1d") ? 1 : 0,
remind1h: remindBefore.value.includes("1h") ? 1 : 0,
};
isLoading.value = true;
try {
if (!isEdit.value) {
await sendCreate(data);
Message.success(mode === "draft" ? "已存草稿" : mode === "time" ? "已设置定时发布" : "发布成功");
timeModal.open = false;
router.push("/list");
const id = isEdit.value ? serid.value : await teacherApi.create(data).send();
if (isEdit.value) await teacherApi.edit(id, data).send();
if (mode === "publish") {
await teacherApi.publish(id).send();
Message.success("发布成功");
} else if (mode === "time") {
await teacherApi.schedule(id, { scheduledAt }).send();
Message.success("已设置定时发布");
} else {
data.sourceId = serid.value;
if (data.title && data.title.indexOf("[修订稿]") === -1) data.title = "[修订稿]" + data.title;
await sendEdit(data);
Message.success("保存成功");
timeModal.open = false;
router.push("/list");
Message.success("已存草稿");
}
timeModal.open = false;
router.push("/list");
} catch (e: any) {
Message.error(e.message || "保存失败");
} finally {
@@ -266,7 +286,7 @@ async function doAdd(mode: "publish" | "draft" | "time") {
</CheckboxGroup>
</FormItem>
<FormItem label="添加附件:">
<HomeworkUpload v-model:fileids="fileids" :defaultList="editFileList"></HomeworkUpload>
<AttachmentUpload v-model="attachments" buttonText="选择附件"></AttachmentUpload>
</FormItem>
</Form>