Files
coursehomework-front/src/components/UserCheckModal.vue
T
lijing d75aab8d07 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 端到端验证通过。
2026-09-14 18:40:20 +08:00

447 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>