feat(M5-P4): mock V2 化 + 伪接口清理 + vite 代理 + 走查修复

- mock 整体重写为「V2 单一存储 + V1 适配层」:教师 19 / 学生 6 / 通知 3 全端点,
  V1 冻结 7 端点由同一存储派生(与后端同一张 homework 表一致);
  提交内核对齐后端 doSubmit(版本翻转/逾期分流/附件 dataURL 拒绝/
  仅查阅拒绝),submit/action 返回 {submissionId} 契约对齐
- 删除伪接口 mock 与客户端方法:Submissions/SubmissionDetail/Grade/Remind/
  MyHomeworks/MySubmission/Submit/Create/Edit/InitializeRed/UserPull/
  SetTopmost/Expedite/ReceiveUsers/List/GetClassTree(真实环境必 404)
- types/homework.ts 收敛为 V1 冻结契约(删 11 个孤儿类型)
- 删除被切换孤儿化的组件:HomeworkUpload/ClassCheckInput/UserCheckModal
- vite 代理指向本地 8102(M5 联调目标,远程测试环境保留注释)
- 走查修复:TeacherHomeworks 操作列改模板插槽(render 函数 Dropdown 插槽
  在 view-ui-plus 下失效导致菜单内联);ReviewModal :value/@input 改
  modelValue(Vue2 API 残留导致弹窗打不开);StudentHomeworks 重新提交
  按钮补 canSubmit 门控(已截止且不允许补交时后端必拒)
- 验收:dev:mock 冒烟 58/58 PASS;构建 EXIT=0;tsc 47 条均为存量(基线 65)
  且无引用已删符号;浏览器走查 9 页面 + 批阅保存闭环 + 3 秒阅读上报通过
This commit is contained in:
wanghu
2026-09-16 03:18:38 +08:00
parent 6258ebb173
commit 44ace7c697
18 changed files with 1587 additions and 1916 deletions
-132
View File
@@ -1,132 +0,0 @@
<template>
<!-- 作业对象选择:全部班级 / 指定班级(学生场景,复用 UserCheckModal 树选弹窗) -->
<div class="check-user-wrap">
<div class="check-user-box">
<RadioGroup v-model="isAllClass" class="radio" @on-change="onScopeChange">
<Radio :disabled="editDisabled" :label="0">{{ isEdit ? "现有班级" : "全部班级" }}</Radio>
<Radio :label="1">指定班级</Radio>
</RadioGroup>
<Input type="text" v-model="classStr" readonly :disabled="!isAllClass" placeholder="选择通知的班级" class="input"></Input>
<Button icon="md-add" @click="openPicker" :disabled="!isAllClass"></Button>
<Button icon="md-trash" @click="reset" :disabled="!isAllClass"></Button>
</div>
<UserCheckModal ref="picker" :sources="sources" :userids="tempIds" @on-submit="submit" />
</div>
</template>
<script lang="ts">
/*
* 班级选择输入(学生场景)。v-model = "all" 或 班级 id 逗号串。
* 数据源为「年级 → 班级」树(base/GetClassTree),叶子为班级,弹窗仅收集叶子 id。
*/
import UserCheckModal from "@/components/UserCheckModal.vue";
import { baseApi } from "@/api/module/homework";
export default {
name: "ClassCheckInput",
components: { UserCheckModal },
props: {
modelValue: { type: String, default: "all" },
/** 编辑态:全部班级选项禁用为「现有班级」 */
isEdit: { type: Boolean, default: false },
},
emits: ["update:modelValue"],
data() {
return {
isAllClass: 0 as number,
tempIds: [] as number[],
classStr: "",
editDisabled: false,
sources: [{ name: "班级", loader: () => baseApi.getClassTree().send() as Promise<any[]> }],
};
},
created() {
this.syncFromValue(this.modelValue);
},
methods: {
idsFromVal(val: string): number[] {
return String(val || "")
.split(",")
.map((x) => parseInt(x))
.filter((x) => !isNaN(x));
},
syncFromValue(val: string) {
if (val === "all") {
this.isAllClass = 0;
this.classStr = this.isEdit ? "现有班级" : "全部班级";
} else if (val === "") {
this.isAllClass = 1;
this.classStr = "";
this.tempIds = [];
} else {
this.isAllClass = 1;
this.tempIds = this.idsFromVal(val);
this.classStr = "";
}
},
onScopeChange(type: number) {
const tempIds = this.idsFromVal(String(this.modelValue || ""));
if (type === 0) {
if (this.isEdit) {
this.tempIds = tempIds;
this.classStr = "现有班级";
this.$emit("update:modelValue", String(tempIds));
} else {
this.tempIds = [];
this.classStr = "全部班级";
this.$emit("update:modelValue", "all");
}
} else {
if (this.isEdit) this.editDisabled = true;
this.tempIds = tempIds;
this.classStr = "";
this.openPicker();
}
},
openPicker() {
(this.$refs.picker as any).show();
},
reset() {
this.tempIds = [];
this.classStr = "";
this.$emit("update:modelValue", "");
},
submit(arr: any[]) {
this.classStr = arr.map((x) => x.name).join("、");
const ids = arr.map((x) => x.id).join(",");
this.$emit("update:modelValue", ids || "all");
if (!arr.length) {
this.isAllClass = 0;
this.classStr = "全部班级";
this.$emit("update:modelValue", "all");
}
},
},
watch: {
modelValue(val: string) {
this.syncFromValue(val);
},
},
};
</script>
<style lang="scss" scoped>
.check-user-wrap {
width: 100%;
.check-user-box {
display: flex;
align-items: center;
gap: 8px;
.radio {
width: 180px;
flex: none;
font-size: 12px;
vertical-align: middle;
line-height: 32px;
}
.input {
flex: 1;
}
}
}
</style>
-116
View File
@@ -1,116 +0,0 @@
<template>
<div class="homework-upload">
<!-- 自定义上传附件(自旧前端 oldfront/src/components/uploadFile.vue 适配) -->
<div class="upload-row">
<Upload
:on-success="encloSuccess"
:on-remove="encloRemove"
:default-file-list="defaultList"
:action="uploadFileUrl"
:accept="fileType"
:multiple="isMultiple"
:before-upload="handleUpload"
:format="formatArr"
:max-size="maxSize"
:on-format-error="handleFormatError"
:on-exceeded-size="handleExceeded"
>
<Button><Icon type="ios-cloud-upload-outline"></Icon>选择文件</Button>
</Upload>
<span class="upload-tip">提示:附件大小不超过{{ maxFileSize }}</span>
</div>
</div>
</template>
<script lang="ts">
/*
* 作业附件上传(自旧前端 oldfront/src/components/uploadFile.vue 适配 Vue3)。
* v-model 为已上传附件 fileid 数组;编辑时经 defaultList 回显已有附件。
*/
import { Message, Homework } from "view-ui-plus";
import config from "@/assets/config/host.config";
export default {
name: "HomeworkUpload",
props: {
defaultList: { type: Array as () => any[], default: () => [] },
fileids: { type: Array as () => number[], default: () => [] },
fileType: { type: String, default: "" },
multiple: { type: Boolean, default: false },
},
emits: ["update:fileids"],
data() {
return {
isMultiple: !this.multiple || false,
uploadFileUrl: (config.isMock ? "" : config.serviceHost) + "/api/base/FileUpload",
formatArr: ["jpg", "gif", "png", "bmp", "xls", "doc", "txt", "docx", "xlsx", "zip", "rar", "pdf", "ppt", "pptx", "wps"],
maxFileSize: "200MB",
maxSize: 204800,
};
},
methods: {
encloSuccess(resp: any, file: any) {
if (resp.code !== 0) {
Message.error(resp.msg || "上传失败");
return;
}
this.fileids.push(resp.data.fileid);
this.$emit("update:fileids", this.fileids);
},
encloRemove(file: any) {
//删除文件:默认列表项带 fileid,刚上传的取 response
const list = file.fileid ? file : file.response;
let fileid = list?.fileid || list?.data?.fileid;
const arry = this.fileids.filter((element: number) => element != fileid);
this.$emit("update:fileids", arry);
},
handleUpload() {
if (!this.isMultiple && this.fileids.length > 0) {
Message.info("仅允许上传一个附件!");
return false;
}
return true;
},
handleFormatError() {
Homework.info({
title: "不支持的上传类型!",
desc: "请上传指定的文件类型",
});
},
handleExceeded() {
Homework.warning({
title: "超出大小限制",
desc: "最大文件大小为" + this.maxFileSize + "!",
});
},
},
};
</script>
<style lang="scss" scoped>
.homework-upload {
display: block;
.upload-row {
display: flex;
align-items: center;
}
:deep(.ivu-upload) {
display: inline-flex;
align-items: center;
}
/* 文件列表整体另起一行,每项独占一行,不再与按钮挤在一起 */
:deep(.ivu-upload-list) {
display: block;
margin-top: 8px;
}
:deep(.ivu-upload-list-file) {
padding: 4px 0;
}
.upload-tip {
margin-left: 10px;
color: #f90;
font-size: 12px;
line-height: 1;
}
}
</style>
+2 -2
View File
@@ -2,8 +2,8 @@
<!-- V2 批阅弹窗:ReviewWorkbench 的弹窗形态(学生矩阵行内批阅用)。
批改中心(Grade.vue)直接内嵌 ReviewWorkbench,不经过本组件。 -->
<Modal
:value="open"
@input="$emit('update:open', $event)"
:model-value="open"
@update:model-value="$emit('update:open', $event)"
:title="`批阅 · ${student?.studentName || ''}`"
width="640px"
:mask-closable="false"
+9 -4
View File
@@ -91,6 +91,10 @@ import { ref, reactive, computed, watch } from "vue";
import { Message } from "view-ui-plus";
import { teacherApi } from "@/api/module/teacherHomework";
import type { AttachmentItem, SubmissionVersionVo, StudentMatrixVo } from "@/types/homeworkV2";
import config from "@/assets/config/host.config";
/** 附件地址前缀:本地 dev/mock 走同源代理(空),线上为基础平台域名(含后端 contextPath) */
const serviceBase = config.isMock ? "" : config.serviceHost;
const props = defineProps<{
homeworkId: number;
@@ -152,13 +156,14 @@ async function load() {
const imageAtts = computed(() => (cur.value?.attachments || []).filter((a) => (a.fileType || "").toLowerCase() === "image"));
const fileAtts = computed(() => (cur.value?.attachments || []).filter((a) => (a.fileType || "").toLowerCase() !== "image"));
/** 附件展示地址:fileUrl 已是 /api/file/Preview?id= 相对路径;绝对地址原样 */
/** 附件展示地址:fileUrl 可能是 /api/file/Preview?id= 相对路径(需拼 serviceHost),绝对地址原样 */
function attUrl(a: AttachmentItem): string {
if (/^(https?:)?\/\//.test(a.fileUrl) || a.fileUrl.startsWith("data:")) return a.fileUrl;
return a.fileUrl;
const u = a.fileUrl || "";
if (/^(https?:)?\/\//.test(u) || u.startsWith("data:")) return u;
return `${serviceBase}${u}`;
}
function attDownUrl(a: AttachmentItem): string {
return a.fileId ? `/api/file/Down?id=${a.fileId}` : a.fileUrl;
return a.fileId ? `${serviceBase}/api/file/Down?id=${a.fileId}` : attUrl(a);
}
const imgZoom = reactive({ open: false, url: "" });
-446
View File
@@ -1,446 +0,0 @@
<template>
<Modal
v-model="modal"
title="选择用户"
width="720px"
:mask-closable="false"
:closable="false"
>
<Tabs
v-if="sources.length > 1"
:key="tabKey"
type="card"
:value="String(tabIndex)"
@on-click="tabChange"
>
<TabPane v-for="(item, index) in sources" :key="index" :label="item.name" :name="index + ''"></TabPane>
</Tabs>
<AutoComplete
@on-search="handleSearch"
@on-select="handleSelect"
placeholder="输入名字搜索"
v-model="searchTreeName"
:clearable="true"
class="auto-box"
>
<Option v-for="item in searchResult" :key="item.id" :value="item.name">
<span>{{ item.name }}</span>
<span class="fr info">{{ item.group }}</span>
</Option>
</AutoComplete>
<div class="tree-wrap fix">
<div class="tree-panel fl">
<div class="tree-box">
<div :id="ztreeDomId" class="ztree"></div>
</div>
</div>
<div class="arrow-box fl">
<svg
t="1626916477711"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="36"
height="36"
>
<path
d="M138.935 256.078v511.844h63.42V256.078h-63.42z m126.842 0v511.844h127.215V256.078H265.777zM885.064 512L583.254 64.323v191.755H456.414v511.844h126.842v191.755L885.064 512z"
fill="#2d8cf0"
></path>
</svg>
</div>
<div class="user-panel fl">
<div class="title-box fix">
<Checkbox @on-change="checkAllUser" v-model="isCheckUserAll">全选</Checkbox>
<div class="fr">{{ tempUserList.length }}</div>
<a
href="javascript:;"
@click="delCheckUser"
v-if="tempUserList.findIndex((x: any) => x.isCheck) > -1"
>删除选中</a
>
</div>
<ul class="user-box">
<li
v-for="item in tempUserList"
:key="item.id"
:class="{ active: item.isCheck }"
@click="checkUserOne(item)"
>
{{ item.name }}
<span class="info">{{ item.group }}</span>
</li>
</ul>
</div>
</div>
<template #footer>
<Button type="primary" @click="submit">提交</Button>
<Button @click="modal = false" style="margin-left: 8px">取消</Button>
</template>
</Modal>
</template>
<script lang="ts">
/*
* 选择用户弹窗(自旧前端 oldfront/src/components/userCheck.vue 适配 Vue3)。
* 左侧为带复选框的组织树(部门→人员,叶子=人员),勾选/取消按父子级联;
* 右侧为已选人员列表,支持全选/删除选中;提交返回去重后的用户 id 数组与用户对象数组。
*/
import { Message } from "view-ui-plus";
import { requireImg } from "@/utils/hook/useImg";
interface SelectSource {
name: string;
loader: () => Promise<any[]>;
}
export default {
name: "UserCheckModal",
props: {
/** 数据源 tab:{ name, loader },loader 返回 ztree 树数据(children 嵌套,叶子=人员) */
sources: {
type: Array as () => SelectSource[],
default: () => [],
},
/** 已选用户 id(打开时自动勾选) */
userids: {
type: Array as () => number[],
default: () => [],
},
isDisabled: {
type: Boolean,
default: false,
},
},
emits: ["update:userids", "on-submit"],
data() {
return {
ztreeDomId: "user_check_tree_" + Math.floor(Math.random() * 1e8),
modal: false,
tabIndex: 0,
/** 每次打开弹窗自增,强制重建 Tabs,避免上次 tab 高亮残留与数据错位 */
tabKey: 0,
/** 树加载序号:tab 快速切换时只渲染最后一次请求的结果 */
treeSeq: 0,
isCheckUserAll: false,
cacheMap: new Map(),
currentTree: [] as any[],
searchTreeName: "",
searchResult: [] as any[],
tempUserList: [] as any[],
};
},
computed: {
/** 当前树扁平化(含父级名称,供搜索展示) */
currentTreeFlatten(): any[] {
const flatten = (data: any[], pid: number, pname: string): any[] =>
data.reduce(
(arr: any[], { id, name, children = [], type }: any) =>
arr.concat([{ id, name, pid, type, group: pname }], flatten(children, id, name)),
[]
);
return flatten(this.currentTree, 0, "");
},
},
methods: {
show() {
this.modal = true;
// 重置到第一个 tab:显式赋值 + key 重建 Tabs,保证高亮与数据一致
this.tabIndex = 0;
this.tabKey++;
this.changeEmpty();
this.initTree();
},
tabChange(index: string) {
const idx = Number(index);
// 严格比较:v-model 场景下 iview 会把值写成字符串 name,
// 宽松相等("1" == 1)会误判“未切换”导致 tab 永远不生效
if (idx !== 0 && this.tabIndex === idx) return;
this.tabIndex = idx;
this.changeEmpty();
this.initTree();
},
handleSearch(query: string) {
let arr: any[] = [];
const searchArr = this.currentTreeFlatten.filter(
(x: any) => x.type == 2 && x.name.indexOf(query) != -1
);
for (const item of searchArr) {
!arr
.map((x) => this.getSplitId(x.id))
.includes(this.getSplitId(item.id)) && arr.push(item);
}
this.searchResult = arr;
},
handleSelect(query: string) {
if (query.indexOf(",") != -1) query = query.split(",")[0];
var treeObj = ($.fn as any).zTree.getZTreeObj(this.ztreeDomId);
var node = treeObj.getNodeByParam("name", query, null);
if (!!node) treeObj.checkNode(node, true, true, true);
},
/**
* ztree 数据预处理:拼接 pid 保证同人多组时 id 可区分;叶子标记 type=2。
* 组/用户判定优先用接口 isParent(组=文件夹),缺失时按有无子节点兜底;
* 并设置组/用户区分图标。
*/
formatTree(tree: any[], level = 0, pid: number = 0) {
tree.forEach((element: any) => {
element.open = level == 0;
element.pid = pid;
element.id = element.id + "," + pid;
element.checked = false;
if (typeof element.isParent !== "undefined") {
element.type = element.isParent ? 1 : 2;
} else {
element.type = element.children && element.children.length > 0 ? 1 : 2;
}
element.icon = requireImg(
element.type == 1 ? "tree-icon-group.png" : "tree-icon-item.png",
"image"
);
if (typeof element.children !== "undefined" && element.children.length > 0) {
element.children = this.formatTree(element.children, level + 1, this.getSplitId(element.id));
}
});
return tree;
},
initTree() {
const _this = this;
// 竞态守卫:慢请求返回时若已切换 tab,丢弃结果
const seq = ++this.treeSeq;
const cacheKey = this.tabIndex;
const source = this.sources[this.tabIndex];
if (!source) return;
const cached = this.cacheMap.get(cacheKey);
if (cached) {
this.createTree(cached);
return;
}
source
.loader()
.then((data: any[]) => {
if (seq !== _this.treeSeq) return;
_this.cacheMap.set(cacheKey, data || []);
_this.createTree(data || []);
})
.catch((e: any) => {
if (seq === _this.treeSeq) Message.error(e?.message || "加载组织数据失败");
});
},
createTree(rawData: any[]) {
const _this = this;
var jtree = JSON.parse(JSON.stringify(rawData));
var ftree = _this.formatTree(jtree);
_this.currentTree = ftree;
let treeSetting: any = {
view: {
dblClickExpand: false,
showIcon: true,
},
check: {
enable: true,
chkboxType: { Y: "ps", N: "ps" },
},
callback: {},
};
/** 勾选变化后同步右侧已选列表:收集勾选节点子树内的所有叶子 */
function formatUser(treeNode: any, checked: boolean) {
const recursionCheckIds = (tree: any): any[] => {
let arrIds: any[] = [];
for (const key in tree) {
const element = tree[key];
if (element.type == 2 && !element.chkDisabled) {
arrIds.push({ id: element.id, tid: element.tId });
}
if (typeof element.children !== "undefined" && element.children) {
arrIds = [...arrIds, ...recursionCheckIds(element.children)];
}
}
return arrIds;
};
const checkIds = recursionCheckIds([treeNode]);
const checkUserList = _this.currentTreeFlatten.filter((x: any) =>
checkIds.map((x) => x.id).includes(x.id)
);
const checkUserArr: any[] = [];
for (const element of checkUserList) {
var findObj = checkIds.find((x) => x.id == element.id);
if (
!!findObj &&
checkUserArr.findIndex((x) => x.id == element.id) === -1 &&
_this.tempUserList.findIndex((x) => x.id == element.id) === -1
) {
checkUserArr.push({ ...element, isCheck: false, tid: findObj.tid });
}
}
if (checked) {
_this.tempUserList = _this.tempUserList.concat(checkUserArr);
} else {
var ids = checkUserList.map((x) => x.id);
_this.tempUserList = _this.tempUserList.filter((x) => !ids.includes(x.id));
}
}
treeSetting.callback.onClick = (event: any, treeId: string, treeNode: any) => {
let check = treeNode.checked;
let treeObj = ($.fn as any).zTree.getZTreeObj(treeId);
treeObj.checkNode(treeNode, !check, true);
formatUser(treeNode, treeNode.checked);
};
treeSetting.callback.onCheck = (event: any, treeId: string, treeNode: any) => {
formatUser(treeNode, treeNode.checked);
};
var treeObj = ($.fn as any).zTree.init($("#" + _this.ztreeDomId), treeSetting, ftree);
// 回显:已选用户打勾(callbackFlag=true 会触发 onCheck,同步右侧列表)
var nodes = treeObj.getNodesByFilter((x: any) =>
_this.userids.includes(_this.getSplitId(x.id))
);
var arr: any[] = [];
nodes.forEach((element: any) => {
var splitId = _this.getSplitId(element.id); //重复id只选择第一个
if (!arr.includes(splitId)) treeObj.checkNode(element, true, true, true);
if (_this.isDisabled) treeObj.setChkDisabled(element, true);
arr.push(splitId);
});
},
checkAllUser(check: boolean) {
this.tempUserList.forEach((element: any) => {
element.isCheck = check;
});
},
checkUserOne(item: any) {
item.isCheck = !item.isCheck;
const tlist = this.tempUserList;
this.isCheckUserAll =
tlist.length > 0 && tlist.filter((x) => x.isCheck).length == tlist.length;
},
delCheckUser() {
const _this = this;
const tlist = _this.tempUserList;
const delArr = tlist.filter((item: any) => item.isCheck);
var treeObj = ($.fn as any).zTree.getZTreeObj(_this.ztreeDomId);
delArr.forEach((element: any) => {
var node = treeObj.getNodeByTId(element.tid);
treeObj.checkNode(node, false, true, true);
});
_this.isCheckUserAll = false;
},
changeEmpty() {
this.tempUserList = [];
this.searchTreeName = "";
this.searchResult = [];
},
submit() {
const _this = this;
let idsArr = this.tempUserList.map((x: any) => _this.getSplitId(x.id));
idsArr = Array.from(new Set(idsArr));
this.$emit("update:userids", idsArr);
function delKey(obj: any, ...args: string[]) {
args.forEach((v) => delete obj[v]);
return obj;
}
const objArr: any[] = [];
idsArr.forEach((element) => {
let findObj = _this.tempUserList.find((x: any) => _this.getSplitId(x.id) == element);
findObj = delKey({ ...findObj }, "id", "isCheck", "tid", "pid", "type");
objArr.push({ ...findObj, id: element });
});
this.$emit("on-submit", objArr);
this.modal = false;
},
/** 树节点 id 形如 "1,301"(原id,父id),取真实 id */
getSplitId(strId: any) {
if (typeof strId === "string" && strId.indexOf(",") != -1) return parseInt(strId.split(",")[0]);
return strId;
},
},
};
</script>
<style lang="scss" scoped>
.tree-wrap {
height: 430px;
position: relative;
}
.tree-panel {
border: 1px solid #eee;
width: 280px;
height: 430px;
overflow: hidden;
}
.tree-box {
padding: 4px 0;
height: 428px;
width: auto;
overflow-y: auto;
border: none;
}
.user-panel {
border: 1px solid #eee;
width: 280px;
.title-box {
background: #f7f7f7;
height: 34px;
line-height: 34px;
padding: 0 12px;
a {
text-decoration: none;
float: right;
margin-right: 20px;
outline: none;
}
}
.user-box {
height: 396px;
overflow-y: auto;
li {
line-height: 32px;
padding-left: 12px;
cursor: pointer;
.info {
float: right;
margin-right: 12px;
font-size: 12px;
color: gray;
}
}
li:hover {
background: #f3f3f3;
}
li.active {
background: #e4edf6;
}
}
}
.arrow-box {
width: 128px;
text-align: center;
position: relative;
top: 50%;
margin-top: -17px;
}
.auto-box {
width: 200px;
margin-bottom: 10px;
:deep(.ivu-select-item .info) {
font-size: 12px;
color: gray;
position: relative;
top: 2px;
}
}
:deep(.ztree li) {
overflow: hidden;
}
:deep(.ztree li a) {
width: 100%;
}
:deep(.ztree li a:hover) {
text-decoration: none;
}
:deep(.ivu-auto-complete .ivu-select-dropdown-list) {
max-height: 310px;
overflow-y: auto;
}
</style>