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,217 @@
|
||||
<template>
|
||||
<div class="flow-editor">
|
||||
<Checkbox v-model="enabled" @on-change="emitChange">选择审批人后走审批流程(不勾选=直接发布)</Checkbox>
|
||||
|
||||
<div v-if="enabled" class="steps">
|
||||
<div class="step" v-for="(st, i) in steps" :key="i">
|
||||
<div class="step-head">
|
||||
<span class="step-no">{{ i + 1 }}</span>
|
||||
<span class="step-tag">第 {{ i + 1 }} 步</span>
|
||||
<Select v-model="st.vote" size="small" class="vote-sel" @on-change="emitChange">
|
||||
<Option value="any">或签(任一通过即可)</Option>
|
||||
<Option value="all">会签(全部通过)</Option>
|
||||
</Select>
|
||||
<span class="step-ops">
|
||||
<a v-if="steps.length > 1 && i > 0" @click="move(i, -1)">上移</a>
|
||||
<a v-if="steps.length > 1 && i < steps.length - 1" @click="move(i, 1)">下移</a>
|
||||
<a v-if="steps.length > 1" class="danger" @click="removeStep(i)">删除</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="step-body">
|
||||
<span v-if="st.approvers.length" class="chips">
|
||||
<span class="chip" v-for="acc in st.approvers" :key="acc">
|
||||
{{ nameOf(acc) }}<i @click="dropApprover(i, acc)">×</i>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="ph">未选择审批人</span>
|
||||
<Button size="small" icon="md-add" @click="openPicker(i)">选择审批人</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="add-step"><a @click="addStep">+ 再加一步(多步依次审批)</a></div>
|
||||
<div class="tip">按顺序流转:上一步通过后进入下一步;任一步驳回则退回发布人。</div>
|
||||
</div>
|
||||
|
||||
<!-- 审批人选择弹窗(复用通用树选弹窗,数据源=审批人候选池) -->
|
||||
<UserCheckModal ref="picker" :sources="pickerSources" :userids="pickerIds" @on-submit="onPick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||
import { approvalSettingApi } from "@/api/module/homework";
|
||||
|
||||
type Step = { vote: "all" | "any"; approvers: string[] };
|
||||
export type FlowModel = { enabled: boolean; flow: { vote: "all" | "any"; approvers: string[] }[] };
|
||||
|
||||
const props = defineProps<{ modelValue: FlowModel }>();
|
||||
const emits = defineEmits<{ (e: "update:modelValue", v: FlowModel): void }>();
|
||||
|
||||
const enabled = ref(false);
|
||||
const steps = ref<Step[]>([{ vote: "any", approvers: [] }]);
|
||||
|
||||
/** account ↔ 数字 id(UserCheckModal 用数字 id) */
|
||||
type Pool = { id: number; name: string; account: string };
|
||||
const pool = ref<Pool[]>([]);
|
||||
const nameOf = (acc: string) => pool.value.find((p) => p.account === acc)?.name || acc;
|
||||
const idOf = (acc: string) => pool.value.find((p) => p.account === acc)?.id ?? 0;
|
||||
const accOf = (id: number) => pool.value.find((p) => p.id === id)?.account || "";
|
||||
|
||||
const picker = ref();
|
||||
const pickerIds = ref<number[]>([]);
|
||||
let editingStep = 0;
|
||||
const pickerSources = [{ name: "审批人", loader: () => approvalSettingApi.approverPool().send() as Promise<any[]> }];
|
||||
|
||||
function openPicker(i: number) {
|
||||
editingStep = i;
|
||||
pickerIds.value = steps.value[i].approvers.map(idOf);
|
||||
picker.value.show();
|
||||
}
|
||||
function onPick(list: any[]) {
|
||||
steps.value[editingStep].approvers = (list || []).map((x) => accOf(Number(x.id))).filter(Boolean);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function addStep() {
|
||||
steps.value.push({ vote: "any", approvers: [] });
|
||||
emitChange();
|
||||
}
|
||||
function removeStep(i: number) {
|
||||
steps.value.splice(i, 1);
|
||||
emitChange();
|
||||
}
|
||||
function move(i: number, dir: number) {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= steps.value.length) return;
|
||||
const a = steps.value;
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
emitChange();
|
||||
}
|
||||
function dropApprover(i: number, acc: string) {
|
||||
steps.value[i].approvers = steps.value[i].approvers.filter((x) => x !== acc);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const flow = enabled.value ? steps.value.filter((s) => s.approvers.length).map((s) => ({ vote: s.vote, approvers: [...s.approvers] })) : [];
|
||||
emits("update:modelValue", { enabled: enabled.value && flow.length > 0, flow });
|
||||
}
|
||||
|
||||
/** 有效步数(选了人的步) */
|
||||
function validFlow() {
|
||||
return enabled.value ? steps.value.filter((s) => s.approvers.length).map((s) => ({ vote: s.vote, approvers: [...s.approvers] })) : [];
|
||||
}
|
||||
/** 编辑态回显:由父组件在详情加载后调用 */
|
||||
function setFlow(mv: FlowModel | null) {
|
||||
if (mv && mv.enabled && mv.flow?.length) {
|
||||
enabled.value = true;
|
||||
steps.value = mv.flow.map((f) => ({ vote: f.vote === "all" ? "all" : "any", approvers: [...(f.approvers || [])] }));
|
||||
} else {
|
||||
enabled.value = false;
|
||||
steps.value = [{ vote: "any", approvers: [] }];
|
||||
}
|
||||
emitChange();
|
||||
}
|
||||
defineExpose({ validFlow, setFlow, validate: () => !enabled.value || validFlow().length > 0 });
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
pool.value = (await approvalSettingApi.approverPool().send()) || [];
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "加载审批人失败");
|
||||
}
|
||||
// 初始值(若父组件已同步传入)
|
||||
if (props.modelValue && props.modelValue.enabled && props.modelValue.flow?.length) {
|
||||
setFlow(props.modelValue);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flow-editor {
|
||||
.steps {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.step {
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
.step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.step-no {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: #2d8cf0;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
.step-tag {
|
||||
font-size: 13px;
|
||||
color: #17233d;
|
||||
}
|
||||
.vote-sel {
|
||||
width: 190px;
|
||||
}
|
||||
.step-ops {
|
||||
margin-left: auto;
|
||||
a {
|
||||
color: #2d8cf0;
|
||||
font-size: 13px;
|
||||
margin-left: 12px;
|
||||
cursor: pointer;
|
||||
&.danger {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
}
|
||||
.step-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
background: #eaf3ff;
|
||||
color: #2d8cf0;
|
||||
border-radius: 3px;
|
||||
padding: 2px 8px;
|
||||
font-size: 13px;
|
||||
i {
|
||||
font-style: normal;
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: #9bbfe8;
|
||||
}
|
||||
}
|
||||
.ph {
|
||||
color: #bbb;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
.add-step a {
|
||||
color: #2d8cf0;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tip {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="improt-wrap">
|
||||
<div class="form-item">
|
||||
<label>案例下载:</label>
|
||||
<a @click="downTempFile" href="javascript:;">点击下载模板.xls</a>
|
||||
</div>
|
||||
<div class="upload-panel">
|
||||
<Upload type="drag" :on-success="onSuccess" :on-error="onError" :data="uploadData" :headers="uploadHeader"
|
||||
:with-credentials="true" :action="uploadUrl"
|
||||
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel">
|
||||
<div class="upload-box">
|
||||
<Icon type="ios-paper" size="52" style="color: #6281eb"></Icon>
|
||||
<h4>点击或将文件拖拽到这里上传</h4>
|
||||
<div class="info">文件大小不超过50M 支持扩展名: xls,xlsx</div>
|
||||
</div>
|
||||
</Upload>
|
||||
</div>
|
||||
<Alert v-if="!!errorMsg" type="error" show-icon><span v-html="errorMsg"></span></Alert>
|
||||
<Alert v-else-if="!!successMsg" type="success" show-icon>{{ successMsg }}</Alert>
|
||||
<Alert v-else style="margin-bottom: 0" type="warning" show-icon>
|
||||
请按照案例模板内容进行编辑,否则将无法导入。
|
||||
</Alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Utils from "@/utils";
|
||||
export default {
|
||||
props: {
|
||||
tempFileUrl: String,
|
||||
uploadUrl: String,
|
||||
uploadData: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
errorMsg: null,
|
||||
successMsg: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
uploadHeader() {
|
||||
const roleInfo = Utils.getCurrentRoleInfo();
|
||||
roleInfo.schoolId = roleInfo.schId;
|
||||
roleInfo.yschapirequest = 1;
|
||||
// roleInfo.userType = 2;
|
||||
return roleInfo;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
downTempFile() {
|
||||
window.top.location.href = this.tempFileUrl;
|
||||
},
|
||||
onSuccess(res, file) {
|
||||
if (res.code == 0) {
|
||||
this.errorMsg = null;
|
||||
this.successMsg = "操作成功!";
|
||||
this.$emit("on-success");
|
||||
} else {
|
||||
this.errorMsg = res.msg;
|
||||
}
|
||||
},
|
||||
onError(err, file) {
|
||||
this.errorMsg = JSON.stringify(err);
|
||||
console.log(err, file);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.improt-wrap {
|
||||
padding: 16px;
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
.upload-box {
|
||||
background: #f7f8fc;
|
||||
padding: 20px 0;
|
||||
|
||||
h4 {
|
||||
color: #505c7e;
|
||||
margin: 3px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<Select v-model="innerId" @on-change="handleChange" v-bind="$attrs">
|
||||
<Option v-for="item in stageSubjects" :key="item.k" :value="item.k">
|
||||
{{ item.v }}
|
||||
</Option>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useDefaultStore } from '@/store'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'on-change': [stage: number, subject: string]
|
||||
}>()
|
||||
|
||||
const store = useDefaultStore()
|
||||
const stageSubjects = computed(() => store.getStageSubjects)
|
||||
|
||||
const innerId = ref<number | null>(null)
|
||||
|
||||
const cacheKey = 'bsch.learnmanage.cur_subject_id';
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (!stageSubjects.value.length) return
|
||||
const cached = sessionStorage.getItem(cacheKey)
|
||||
const id = cached ? Number(cached) : stageSubjects.value[0]?.k;
|
||||
if (id != null) innerId.value = id
|
||||
handleChange(id)
|
||||
})
|
||||
|
||||
function handleChange(val: number) {
|
||||
const find = stageSubjects.value.find((i: any) => i.k === val)
|
||||
if (!find) return
|
||||
sessionStorage.setItem(cacheKey, String(val))
|
||||
emit('on-change', find.stage, find.code)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<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>
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 附件预览弹窗(open-file-viewer,参考党建学习 PartyLearn 实现)。
|
||||
* 支持 图片/docx/xlsx/pptx/pdf/txt 在线预览;mp4 用 <video> 播放(走 Preview 内联流);
|
||||
* doc/ppt/xls/rtf/zip 等旧二进制格式不支持,弹窗内给「下载查看」兜底。
|
||||
* 关键点:组件自拉 URL 带不上平台认证,必须先 fetch( credentials: include ) 取 Blob 再传 :file。
|
||||
*/
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { OpenFileViewer } from "@open-file-viewer/vue";
|
||||
import { imagePlugin, pdfPlugin, officePlugin, textPlugin } from "@open-file-viewer/core";
|
||||
import "@open-file-viewer/core/style.css";
|
||||
// pdf worker:以 ?raw 内嵌源码(worker 自包含无 import),运行时生成 Blob URL,
|
||||
// 不依赖部署环境对 .mjs 的 MIME 配置/文件完整性(模块 worker 对 MIME 校验严格,nginx 旧默认不含 mjs)
|
||||
// @ts-ignore
|
||||
import pdfWorkerRaw from "pdfjs-dist/build/pdf.worker.mjs?raw";
|
||||
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
fileId: number | null;
|
||||
/** 预览文件名(用于类型判断与标题展示,需带扩展名) */
|
||||
fileName?: string;
|
||||
}>(),
|
||||
{ fileName: "" }
|
||||
);
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
}>();
|
||||
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const previewName = ref("");
|
||||
const fileType = ref("");
|
||||
const blob = ref<Blob | null>(null);
|
||||
|
||||
const fileServiceBase = config.isMock ? "" : config.serviceHost;
|
||||
/** 内联流地址(<video> 等场景用;Down 是 attachment 头,视频流不可用) */
|
||||
const previewUrl = computed(() => (props.fileId ? `${fileServiceBase}/api/file/Preview?id=${props.fileId}` : ""));
|
||||
|
||||
const isVideo = computed(() => ["mp4", "webm", "ogg"].includes(fileType.value));
|
||||
const isUnsupported = computed(
|
||||
() => !isVideo.value && ["doc", "ppt", "xls", "rtf", "zip", "rar", "7z", "wps"].includes(fileType.value)
|
||||
);
|
||||
|
||||
const pdfWorkerSrc = URL.createObjectURL(new Blob([pdfWorkerRaw as unknown as BlobPart], { type: "text/javascript" }));
|
||||
|
||||
const plugins = [
|
||||
imagePlugin(),
|
||||
pdfPlugin({
|
||||
workerSrc: pdfWorkerSrc,
|
||||
// cmaps/标准字体自托管(public 下随构建发布),避免运行时依赖 cdn.jsdelivr.net(国内网络不稳定)
|
||||
cMapUrl: `${import.meta.env.BASE_URL}cmaps/`,
|
||||
standardFontDataUrl: `${import.meta.env.BASE_URL}standard_fonts/`,
|
||||
}),
|
||||
officePlugin(),
|
||||
textPlugin(),
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
if (!props.fileId) return;
|
||||
loading.value = true;
|
||||
blob.value = null;
|
||||
previewName.value = props.fileName || "文件预览";
|
||||
fileType.value = (previewName.value.split(".").pop() || "").toLowerCase();
|
||||
try {
|
||||
// Down/Preview 走 Cookie 认证(location.href 能下载即证明),同源带 cookie 即可
|
||||
const res = await fetch(`${fileServiceBase}/api/file/Preview?id=${props.fileId}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error(`文件加载失败(${res.status})`);
|
||||
blob.value = await res.blob();
|
||||
} catch (error: any) {
|
||||
Message.error(error?.message || "文件加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
if (props.fileId) window.location.href = `${fileServiceBase}/api/file/Down?id=${props.fileId}`;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
value => {
|
||||
if (value) load();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model="show" :title="previewName || '文件预览'" :width="960" :mask-closable="false">
|
||||
<div class="preview-body">
|
||||
<Spin fix v-if="loading" size="large"></Spin>
|
||||
<template v-if="blob && !loading">
|
||||
<video v-if="isVideo" :src="previewUrl" controls autoplay class="video-player"></video>
|
||||
<div v-else-if="isUnsupported" class="unsupported">
|
||||
<Icon type="ios-alert-outline" size="64" />
|
||||
<p>该格式({{ fileType }})暂不支持在线预览</p>
|
||||
<Button type="primary" icon="ios-download-outline" @click="download">下载查看</Button>
|
||||
</div>
|
||||
<OpenFileViewer v-else :file="blob" :file-name="previewName" height="100%" toolbar :plugins="plugins" />
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button @click="show = false">关闭</Button>
|
||||
<Button type="primary" @click="download">下载</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.preview-body {
|
||||
position: relative;
|
||||
height: 72vh;
|
||||
overflow: hidden;
|
||||
.video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
}
|
||||
.unsupported {
|
||||
text-align: center;
|
||||
padding-top: 120px;
|
||||
color: #999;
|
||||
p {
|
||||
margin: 12px 0 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<Select
|
||||
class="search-wrap_sel"
|
||||
v-model="currentGradeId"
|
||||
:placeholder="isGradeAllCheck ? '全部年级' : '请选择年级'"
|
||||
:clearable="isGradeAllCheck"
|
||||
@on-change="onGradeChange"
|
||||
>
|
||||
<Option v-for="item in gradeList" :value="item.k">{{ item.v }}</Option>
|
||||
</Select>
|
||||
<Select
|
||||
ref="refClassSel"
|
||||
class="search-wrap_sel ml-12"
|
||||
v-model="currentClassId"
|
||||
:placeholder="isClassAllCheck ? '全部班级' : '请选择班级'"
|
||||
:clearable="isClassAllCheck"
|
||||
@on-change="onClassChange"
|
||||
>
|
||||
<Option v-for="item in classList" :value="item.k">{{ item.v }}</Option>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onBeforeMount, computed } from "vue";
|
||||
import { useDefaultStore } from "@/store/index.ts";
|
||||
import { GradeClassInfo } from "@/types/index";
|
||||
import { BaseDict } from "@/types/Basics";
|
||||
import { storeToRefs } from "pinia";
|
||||
interface GradeClassSel {
|
||||
isGradeAllCheck?: boolean;
|
||||
isClassAllCheck?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<GradeClassSel>(), {
|
||||
isGradeAllCheck: false,
|
||||
isClassAllCheck: true,
|
||||
});
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "on-change", info: GradeClassInfo): void;
|
||||
}>();
|
||||
const store = useDefaultStore();
|
||||
|
||||
const refClassSel = ref();
|
||||
const gradeClassList = ref<any[]>([]);
|
||||
const { gradeId: currentGradeId, classId: currentClassId } = storeToRefs(store);
|
||||
|
||||
onBeforeMount(() => {
|
||||
gradeClassList.value = store.$state.gradeClassList;
|
||||
if (!props.isGradeAllCheck && currentGradeId.value == null)
|
||||
currentGradeId.value = _.get(gradeList.value, "[0].k", null);
|
||||
});
|
||||
|
||||
const gradeList = computed<BaseDict[]>(() => {
|
||||
let arr = gradeClassList.value.map((element: GradeClassInfo) => {
|
||||
return { k: element.gradeId, v: element.gradeName };
|
||||
});
|
||||
return _.uniqBy(arr, "k");
|
||||
});
|
||||
|
||||
const classList = computed<BaseDict[]>(() => {
|
||||
let arr = gradeClassList.value
|
||||
.filter((x: GradeClassInfo) => x.gradeId == currentGradeId.value)
|
||||
.map((element: GradeClassInfo) => {
|
||||
return { k: element.classId, v: element.className };
|
||||
});
|
||||
return arr;
|
||||
});
|
||||
|
||||
function onGradeChange() {
|
||||
setTimeout(() => {
|
||||
refClassSel.value.focusIndex = 0;
|
||||
}, 200);
|
||||
currentClassId.value = !props.isClassAllCheck ? _.get(classList.value, "[0].k", null) : null;
|
||||
let info = getCurrentGradeInfo();
|
||||
emits("on-change", info);
|
||||
}
|
||||
|
||||
function onClassChange() {
|
||||
let info = getCurrentGradeInfo();
|
||||
emits("on-change", info);
|
||||
}
|
||||
|
||||
function getCurrentGradeInfo() {
|
||||
return store.findGradeClassInfo(currentGradeId.value, currentClassId.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<div class="group-tree-search-panel">
|
||||
<div v-if="searchVisible" class="search-bar mt-8 mb-8">
|
||||
<Input v-model="searchKeyword" :placeholder="searchPlaceholder" suffix="ios-search" search clearable
|
||||
class="search-input" @on-search="onSearch" @on-clear="onClear" />
|
||||
<Button v-if="showCloseButton" icon="md-close" @click="closeSearch" />
|
||||
</div>
|
||||
|
||||
<div class="tree-body">
|
||||
<MyTree v-if="!treeLoading" ref="refMyTree" :autoClick="autoClick" :treeData="treeData"
|
||||
:setting="treeSetting" @on-tree-click="treeLink" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, nextTick, useTemplateRef } from "vue";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { Message } from "view-ui-plus";
|
||||
import MyTree from '@/components/MyTree.vue'
|
||||
|
||||
const GROUP_TYPE = {
|
||||
NORMAL_GROUP: 1,
|
||||
SCHOOL_GROUP: 2,
|
||||
GRADE_GROUP: 3,
|
||||
CLASS_GROUP: 4,
|
||||
AREA_GROUP: 5,
|
||||
STAGE_GROUP: 6,
|
||||
TEACHER_GROUP: 7,
|
||||
STUDENT_GROUP: 8,
|
||||
} as const
|
||||
|
||||
const DEFAULT_CACHE_KEY = 'bsch.group-tree.selected-node'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
treeApi: () => Promise<any[]>;
|
||||
autoClick?: boolean;
|
||||
defaultSearchVisible?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
cacheKey?: string;
|
||||
}>(), {
|
||||
autoClick: false,
|
||||
defaultSearchVisible: true,
|
||||
showCloseButton: false,
|
||||
searchPlaceholder: '输入组名称搜索',
|
||||
cacheKey: DEFAULT_CACHE_KEY,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'on-change': [treeNode: any]
|
||||
'on-tree-data': [tree: any[]]
|
||||
}>()
|
||||
|
||||
const curTreeNode = defineModel<any>('curTreeNode', { default: {} })
|
||||
|
||||
const refMyTree = useTemplateRef('refMyTree')
|
||||
const treeLoading = ref(false)
|
||||
const treeData = ref<any[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const searchVisible = ref(props.defaultSearchVisible)
|
||||
|
||||
const treeSetting = ref({
|
||||
view: { dblClickExpand: false, showIcon: false },
|
||||
edit: { enable: false, showRemoveBtn: false, showRenameBtn: false, drag: false },
|
||||
check: { enable: false },
|
||||
callback: {},
|
||||
})
|
||||
|
||||
function formatTree(list: any[], depth = 1): any[] {
|
||||
return (list || []).map((node) => {
|
||||
const children = node.children;
|
||||
const hasChildren = Array.isArray(children) && children.length > 0;
|
||||
const formatted: any = { ...node, isParent: hasChildren };
|
||||
if (depth === 1) formatted.open = true;
|
||||
if (hasChildren) {
|
||||
formatted.children = formatTree(children, depth + 1);
|
||||
} else {
|
||||
delete formatted.children;
|
||||
}
|
||||
return formatted;
|
||||
});
|
||||
}
|
||||
|
||||
function findNodeById(list: any[], id?: number | null): any {
|
||||
if (id == null) return null;
|
||||
for (const node of list || []) {
|
||||
if (Number(node.id) === Number(id)) return node;
|
||||
const child = findNodeById(node.children || [], id);
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFirstNodeByType(list: any[], type: number): any {
|
||||
for (const node of list || []) {
|
||||
if (Number(node.type) === type) return node;
|
||||
const child = findFirstNodeByType(node.children || [], type);
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCachedSelectedNodeId() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(props.cacheKey);
|
||||
if (!raw) return null;
|
||||
const cached = JSON.parse(raw);
|
||||
const id = Number(cached?.id);
|
||||
return Number.isFinite(id) ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheSelectedNode(treeNode: any) {
|
||||
if (!treeNode?.id) return;
|
||||
sessionStorage.setItem(props.cacheKey, JSON.stringify({
|
||||
id: Number(treeNode.id),
|
||||
name: treeNode.name,
|
||||
type: treeNode.type,
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveInitialSelectId(list: any[], preferredId?: number | null) {
|
||||
const preferred = findNodeById(list, preferredId);
|
||||
if (preferred) return preferred.id;
|
||||
|
||||
const cached = findNodeById(list, getCachedSelectedNodeId());
|
||||
if (cached) return cached.id;
|
||||
|
||||
const schoolNode = findFirstNodeByType(list, GROUP_TYPE.SCHOOL_GROUP);
|
||||
if (schoolNode) return schoolNode.id;
|
||||
|
||||
if (props.autoClick && list.length) return list[0].id;
|
||||
return list[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async function loadTree(preferredId?: number | null) {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
const tree = await props.treeApi();
|
||||
treeData.value = formatTree(tree || []);
|
||||
emit('on-tree-data', cloneDeep(treeData.value));
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '加载组织树失败');
|
||||
} finally {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
|
||||
if (!treeData.value.length) return;
|
||||
|
||||
await nextTick();
|
||||
const selectId = resolveInitialSelectId(treeData.value, preferredId);
|
||||
if (selectId != null) {
|
||||
refMyTree.value?.reloadTree(selectId);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTree(selectId?: number) {
|
||||
await loadTree(selectId);
|
||||
}
|
||||
|
||||
function treeLink(treeNode: any) {
|
||||
const copyNode = cloneDeep(treeNode)
|
||||
copyNode.children = [];
|
||||
curTreeNode.value = copyNode;
|
||||
cacheSelectedNode(copyNode);
|
||||
emit('on-change', copyNode)
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
refMyTree.value?.search(searchKeyword.value);
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
searchKeyword.value = '';
|
||||
refMyTree.value?.search('');
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
searchVisible.value = true;
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
searchVisible.value = false;
|
||||
onClear();
|
||||
}
|
||||
|
||||
function reloadTree(id?: number) {
|
||||
refMyTree.value?.reloadTree(id);
|
||||
}
|
||||
|
||||
function selectNode(id: number, isClick = true) {
|
||||
refMyTree.value?.selectNode(id, isClick);
|
||||
}
|
||||
|
||||
function removeNode(id: number) {
|
||||
refMyTree.value?.removeNode(id);
|
||||
}
|
||||
|
||||
function renameNode(id: number, name: string) {
|
||||
refMyTree.value?.renameNode(id, name);
|
||||
}
|
||||
|
||||
function getNodeById(id: number) {
|
||||
return refMyTree.value?.getNodeById(id);
|
||||
}
|
||||
|
||||
function addNote(pid: number, id: number, name: string, isParent?: boolean, children?: any[]) {
|
||||
refMyTree.value?.addNote(pid, id, name, isParent, children);
|
||||
}
|
||||
|
||||
function openTree(open: boolean) {
|
||||
refMyTree.value?.openTree(open);
|
||||
}
|
||||
|
||||
loadTree()
|
||||
|
||||
defineExpose({
|
||||
openSearch,
|
||||
closeSearch,
|
||||
onSearch,
|
||||
onClear,
|
||||
loadTree,
|
||||
refreshTree,
|
||||
reloadTree,
|
||||
selectNode,
|
||||
removeNode,
|
||||
renameNode,
|
||||
getNodeById,
|
||||
addNote,
|
||||
openTree,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.group-tree-search-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tree-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tree-body :deep(.ztree) {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<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>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Modal v-model="isModal" @on-visible-change="showChange" :title="title">
|
||||
<Alert type="warning" class="mb-1" show-icon>
|
||||
<template #icon>
|
||||
<Icon type="ios-bulb-outline" :class="{ 'mt-5': !info }" slot="icon" :size="28"></Icon>
|
||||
</template>
|
||||
<div v-if="msg" :class="['message-box', info ? '' : 'mt-10 mb-10 ml-10']">{{ msg }}</div>
|
||||
<template #desc v-if="info" slot="desc">{{ info }}</template>
|
||||
</Alert>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="okLoading" @click="onOkClick">{{ confirmText }}</Button>
|
||||
<Button :loading="cancleLoading" @click="onCancleClick">{{ closeText }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Modal, Button, Alert, Icon } from "view-ui-plus";
|
||||
|
||||
const isModal = ref(false),
|
||||
okLoading = ref(false),
|
||||
cancleLoading = ref(false);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
msg: string;
|
||||
info?: string;
|
||||
isLoading?: boolean;
|
||||
confirmText?: string;
|
||||
closeText?: string;
|
||||
confirmFn: any;
|
||||
closeFn: any;
|
||||
destroy: Function;
|
||||
}>(),
|
||||
{
|
||||
title: "提示",
|
||||
isLoading: true,
|
||||
confirmText: "确认",
|
||||
closeText: "取消",
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(function () {
|
||||
setTimeout(() => {
|
||||
isModal.value = true;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const onOkClick = async () => {
|
||||
if (!!props.isLoading) okLoading.value = true;
|
||||
const { onOk, reslove } = props.confirmFn;
|
||||
await Promise.resolve(onOk());
|
||||
isModal.value = false;
|
||||
okLoading.value = false;
|
||||
reslove(true);
|
||||
};
|
||||
|
||||
const onCancleClick = async () => {
|
||||
if (!!props.isLoading) cancleLoading.value = true;
|
||||
const { onCancel, reslove } = props.closeFn;
|
||||
if (typeof onCancel !== "undefined") await Promise.resolve(onCancel());
|
||||
cancleLoading.value = false;
|
||||
isModal.value = false;
|
||||
reslove(false);
|
||||
};
|
||||
|
||||
const showChange = (state: boolean) => {
|
||||
if (!state) {
|
||||
props.destroy();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MyConfirm } from "@/types/Basics";
|
||||
import confirm from "./Confirm.vue";
|
||||
|
||||
import { createApp } from "vue";
|
||||
export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onCancel }: MyConfirm) => {
|
||||
return new Promise<boolean>((reslove, reject) => {
|
||||
const instance = createApp(confirm, {
|
||||
title,
|
||||
msg,
|
||||
info,
|
||||
confirmText,
|
||||
closeText,
|
||||
isLoading,
|
||||
confirmFn: {
|
||||
onOk,
|
||||
reslove,
|
||||
},
|
||||
closeFn: {
|
||||
onCancel,
|
||||
reslove,
|
||||
},
|
||||
destroy: () => {
|
||||
closeUnmount();
|
||||
},
|
||||
});
|
||||
|
||||
// 创建一个节点
|
||||
const vnode = document.createElement("div");
|
||||
// 添加到body元素上
|
||||
document.body.appendChild(vnode);
|
||||
// 实例挂载到节点上
|
||||
instance.mount(vnode);
|
||||
// 关闭状态
|
||||
// 注册实例并移除节点
|
||||
function closeUnmount() {
|
||||
setTimeout(() => {
|
||||
instance.unmount();
|
||||
document.body.removeChild(vnode);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :id="domId" :ref="domId" class="ztree"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
treeData: {
|
||||
type: Object,
|
||||
},
|
||||
setting: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
autoClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isCheck: {
|
||||
typeof: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
domId: "",
|
||||
treeSetting: _.cloneDeep(this.setting),
|
||||
treeObj: null,
|
||||
highlightNodes: [],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.$watch("treeData", val => {
|
||||
if (val) {
|
||||
this.$nextTick(() => {
|
||||
this.initTree(this.treeData, this.domId);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
this.domId = this.uuid();
|
||||
if (this.treeData) {
|
||||
this.$nextTick(() => {
|
||||
this.initTree(this.treeData, this.domId);
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
uuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
var r = (Math.random() * 16) | 0,
|
||||
v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
},
|
||||
reloadTree(nodeId) {
|
||||
this.initTree(this.treeData, this.domId);
|
||||
if (nodeId != null) {
|
||||
this.$nextTick(() => {
|
||||
this.selectNode(nodeId, true); // 第二个参数 true 会 emit on-tree-click
|
||||
});
|
||||
}
|
||||
},
|
||||
initTree(_data, _domId) {
|
||||
const _this = this;
|
||||
_this.treeSetting.callback.onClick = (event, treeId, treeNode, clickFlag) => {
|
||||
// console.log(event, treeId, treeNode, clickFlag);
|
||||
if (_this.isCheck) {
|
||||
var treeObj = $.fn.zTree.getZTreeObj(treeId);
|
||||
treeObj.checkNode(treeNode, !treeNode.checked, true);
|
||||
}
|
||||
_this.$emit("on-tree-click", treeNode);
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.beforeDrop = (treeId, treeNodes, targetNode, moveType) => {
|
||||
if (moveType == "inner") {
|
||||
// 移除限制,允许叶子节点接受子节点
|
||||
} else {
|
||||
if (targetNode.pid === 0) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.beforeDrag = (treeId, treeNodes) => {
|
||||
return treeNodes[0].drag !== false;
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.beforeClick = (treeId, treeNode, clickFlag) => {
|
||||
return !(treeNode.click === false);
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.onDrop = (event, treeId, treeNodes, targetNode, moveType) => {
|
||||
_this.$emit("on-tree-drag", { treeNodes, targetNode, moveType });
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.onCheck = (event, treeId, treeNode) => {
|
||||
_this.$emit("on-tree-check", treeNode);
|
||||
};
|
||||
|
||||
_this.treeObj = $.fn.zTree.init($("#" + _domId), _this.treeSetting, _data);
|
||||
if (_this.autoClick) {
|
||||
if (Array.isArray(this.treeData)) {
|
||||
let id = this.treeData[0].id;
|
||||
_this.selectNode(id);
|
||||
} else {
|
||||
let id = this.treeData.id;
|
||||
_this.selectNode(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
selectNode(id, isClick = true) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
if (node == null) return false;
|
||||
treeObj.selectNode(node);
|
||||
treeObj.expandNode(node, true, false, false);
|
||||
treeObj.selectNode(node);
|
||||
if (isClick) this.$emit("on-tree-click", node);
|
||||
},
|
||||
openTree(open) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
treeObj.expandAll(open);
|
||||
},
|
||||
removeNode(id) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
var pnode = node.getParentNode();
|
||||
treeObj.removeNode(node);
|
||||
pnode.isParent = true;
|
||||
treeObj.updateNode(pnode);
|
||||
this.selectNode(pnode.id);
|
||||
},
|
||||
getNodeById(id) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
return node;
|
||||
},
|
||||
renameNode(id, name) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
node.name = name;
|
||||
treeObj.updateNode(node);
|
||||
},
|
||||
addNote(pid, id, name, isParent, children) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var pnode = treeObj.getNodesByFilter(node => node.id == pid, true);
|
||||
var newNode = { pid, id, name, isParent };
|
||||
if (children && children.length) newNode.children = children;
|
||||
treeObj.addNodes(pnode, -1, newNode);
|
||||
},
|
||||
setHighlight(node, on = true) {
|
||||
const a = $('#' + node.tId + '_a');
|
||||
if (on) a.addClass('ztree-search-highlight');
|
||||
else a.removeClass('ztree-search-highlight');
|
||||
},
|
||||
clearHighlight() {
|
||||
(this.highlightNodes || []).forEach(n => this.setHighlight(n, false));
|
||||
this.highlightNodes = [];
|
||||
},
|
||||
search(keyword) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
if (!treeObj) return;
|
||||
this.clearHighlight();
|
||||
const kw = (keyword || '').trim().toLowerCase();
|
||||
if (!kw) return;
|
||||
const matchNodes = treeObj.getNodesByFilter(
|
||||
(node) => node.name && node.name.toLowerCase().includes(kw),
|
||||
false
|
||||
) || [];
|
||||
if (!matchNodes.length) {
|
||||
if (this.$Message && this.$Message.info) this.$Message.info('未搜索到匹配组');
|
||||
return;
|
||||
}
|
||||
matchNodes.forEach(n => {
|
||||
let p = n;
|
||||
while (p) {
|
||||
treeObj.expandNode(p, true, false, false);
|
||||
p = p.getParentNode();
|
||||
}
|
||||
});
|
||||
this.highlightNodes = matchNodes;
|
||||
matchNodes.forEach(n => this.setHighlight(n, true));
|
||||
this.selectNode(matchNodes[0].id, true);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ztree-search-highlight {
|
||||
background-color: #ff6600 !important;
|
||||
}
|
||||
|
||||
.ztree li a.curSelectedNode {
|
||||
background-color: #ffe4bb !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div style="height: 100%;">
|
||||
<div class="tree-search-bar">
|
||||
<Input v-model="keyword" placeholder="输入学校,回车查找" suffix="ios-search" clearable @on-enter="onSearch"
|
||||
@on-clear="onSearch" style="width:100%;margin-bottom: 10px;" />
|
||||
</div>
|
||||
<div :id="domId" :ref="domId" class="ztree"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
treeData: {
|
||||
type: Object,
|
||||
},
|
||||
setting: {
|
||||
type: Object,
|
||||
default: {
|
||||
view: {
|
||||
dblClickExpand: false,
|
||||
showIcon: false,
|
||||
},
|
||||
edit: {
|
||||
enable: true,
|
||||
showRemoveBtn: false,
|
||||
showRenameBtn: false,
|
||||
drag: false,
|
||||
},
|
||||
check: {
|
||||
enable: false,
|
||||
},
|
||||
callback: {},
|
||||
},
|
||||
},
|
||||
autoClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isCheck: {
|
||||
typeof: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
domId: "",
|
||||
treeSetting: _.cloneDeep(this.setting),
|
||||
treeObj: null,
|
||||
keyword: '',
|
||||
highlightNodes: []
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.$watch("treeData", val => {
|
||||
console.log(val);
|
||||
if (val) {
|
||||
console.log(val);
|
||||
this.$nextTick(() => {
|
||||
this.initTree(this.treeData, this.domId);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
this.domId = this.uuid();
|
||||
},
|
||||
methods: {
|
||||
uuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
var r = (Math.random() * 16) | 0,
|
||||
v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
},
|
||||
reloadTree() {
|
||||
this.treeData.open = true;
|
||||
this.initTree(this.treeData, this.domId);
|
||||
},
|
||||
initTree(_data, _domId) {
|
||||
const _this = this;
|
||||
_this.treeSetting.callback.onClick = (event, treeId, treeNode, clickFlag) => {
|
||||
// console.log(event, treeId, treeNode, clickFlag);
|
||||
if (_this.isCheck) {
|
||||
var treeObj = $.fn.zTree.getZTreeObj(treeId);
|
||||
treeObj.checkNode(treeNode, !treeNode.checked, true);
|
||||
}
|
||||
_this.$emit("on-tree-click", treeNode);
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.beforeDrop = (treeId, treeNodes, targetNode, moveType) => {
|
||||
if (moveType == "inner") {
|
||||
if (!targetNode.isParent) return false;
|
||||
} else {
|
||||
if (targetNode.pid === 0) return false;
|
||||
}
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.beforeClick = (treeId, treeNode, clickFlag) => {
|
||||
return !(treeNode.click === false);
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.onDrop = (event, treeId, treeNodes, targetNode, moveType) => {
|
||||
_this.$emit("on-tree-drag", { treeNodes, targetNode, moveType });
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.onCheck = (event, treeId, treeNode) => {
|
||||
_this.$emit("on-tree-check", treeNode);
|
||||
};
|
||||
|
||||
_this.treeObj = $.fn.zTree.init($("#" + _domId), _this.treeSetting, _data);
|
||||
if (_this.autoClick) {
|
||||
if (Array.isArray(this.treeData)) {
|
||||
let id = this.treeData[0].id;
|
||||
_this.selectNode(id);
|
||||
} else {
|
||||
let id = this.treeData.id;
|
||||
_this.selectNode(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
selectNode(id, isClick = true) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
if (node == null) return false;
|
||||
treeObj.selectNode(node);
|
||||
treeObj.expandNode(node, true, false, false);
|
||||
treeObj.selectNode(node);
|
||||
if (isClick) this.$emit("on-tree-click", node);
|
||||
},
|
||||
openTree(open) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
treeObj.expandAll(open);
|
||||
},
|
||||
removeNode(id) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
var pnode = node.getParentNode();
|
||||
treeObj.removeNode(node);
|
||||
pnode.isParent = true;
|
||||
treeObj.updateNode(pnode);
|
||||
this.selectNode(pnode.id);
|
||||
},
|
||||
getNodeById(id) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
return node;
|
||||
},
|
||||
renameNode(id, name) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
node.name = name;
|
||||
treeObj.updateNode(node);
|
||||
},
|
||||
addNote(pid, id, name, isParent) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var pnode = treeObj.getNodesByFilter(node => node.id == pid, true);
|
||||
var newNode = { pid, id, name, isParent };
|
||||
treeObj.addNodes(pnode, -1, newNode);
|
||||
},
|
||||
onSearch() {
|
||||
this.clearHighlight();
|
||||
if (!this.keyword) return;
|
||||
this.doSearch(this.keyword);
|
||||
},
|
||||
doSearch(key) {
|
||||
const treeObj = this.treeObj;
|
||||
// 1. 模糊匹配
|
||||
const matchNodes = treeObj.getNodesByFilter(node =>
|
||||
node.type == 2 &&
|
||||
node.name && node.name.toLowerCase().includes(key.toLowerCase())
|
||||
);
|
||||
if (!matchNodes.length) {
|
||||
this.$Message.info('未搜索到匹配学校')
|
||||
return;
|
||||
}
|
||||
// 2. 展开所有命中节点的父节点
|
||||
matchNodes.forEach(n => {
|
||||
let p = n.getParentNode();
|
||||
while (p) {
|
||||
treeObj.expandNode(p, true, false, false);
|
||||
p = p.getParentNode();
|
||||
}
|
||||
});
|
||||
// 3. 高亮
|
||||
this.highlightNodes = matchNodes;
|
||||
matchNodes.forEach(n => this.setHighlight(n, true));
|
||||
|
||||
// const box = document.getElementById('treeBox');
|
||||
// const nodeEl = document.getElementById(matchNodes[0].tId);
|
||||
// box.scrollTop = nodeEl.offsetTop - box.clientHeight / 2; // 居中
|
||||
|
||||
if (matchNodes.length) {
|
||||
const treeObj = this.treeObj;
|
||||
console.log(matchNodes[0]);
|
||||
this.selectNode(matchNodes[0].id);
|
||||
|
||||
// treeObj.selectNode(matchNodes[0]); // 先选中,会自动滚入可视区
|
||||
//this.$nextTick(() => treeObj.cancelSelectedNode()); // 取消选中,保持纯高亮
|
||||
}
|
||||
|
||||
},
|
||||
setHighlight(node, on = true) {
|
||||
const a = $('#' + node.tId + '_a');
|
||||
if (on) a.addClass('ztree-search-highlight');
|
||||
else a.removeClass('ztree-search-highlight');
|
||||
},
|
||||
clearHighlight() {
|
||||
this.highlightNodes.forEach(n => this.setHighlight(n, false));
|
||||
this.highlightNodes = [];
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ztree {
|
||||
height: calc(100% - 60px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ztree-search-highlight {
|
||||
background-color: #ff6600 !important;
|
||||
}
|
||||
|
||||
.ztree li a.curSelectedNode {
|
||||
background-color: #ffe4bb !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, withDefaults } from "vue";
|
||||
|
||||
interface Props {
|
||||
prefix?: string;
|
||||
name?: string;
|
||||
color?: string;
|
||||
hoverColor?: string;
|
||||
size?: string | number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
prefix: "icon",
|
||||
name: "",
|
||||
color: "#000",
|
||||
});
|
||||
|
||||
const isHover = ref(false);
|
||||
const symbolId = computed(() => `#${props.prefix}-${props.name}`);
|
||||
const svgStyle = computed(() => {
|
||||
if (!!props.size) return { width: props.size + "px", height: props.size + "px" };
|
||||
else return null;
|
||||
});
|
||||
|
||||
const compColor = computed(() => {
|
||||
if (!props.hoverColor) return props.color;
|
||||
else {
|
||||
return !!isHover.value ? props.hoverColor : props.color;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
@mouseover="isHover = true"
|
||||
@mouseout="isHover = false"
|
||||
aria-hidden="true"
|
||||
:color="compColor"
|
||||
:style="svgStyle"
|
||||
>
|
||||
<use :xlink:href="symbolId" :fill="compColor" :color="compColor" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts" setup>
|
||||
import { OperatorEnum } from "@/types";
|
||||
import { PageData, PageParams } from "@/types/Basics";
|
||||
import Utils from "@/utils";
|
||||
import { Message } from "view-ui-plus";
|
||||
|
||||
/* 表格插件参数类型 */
|
||||
interface TablePageProps {
|
||||
dataApi: (params: PageParams) => Promise<PageData>;
|
||||
columns: object[];
|
||||
tableHeight: number | undefined;
|
||||
pageParams: PageParams | undefined;
|
||||
dataFilter?: ((resp: object) => object[]) | undefined;
|
||||
autoLoad?: boolean | undefined;
|
||||
}
|
||||
import { ref, onBeforeMount, onMounted } from "vue";
|
||||
const props = withDefaults(defineProps<TablePageProps>(), {
|
||||
autoLoad: true,
|
||||
});
|
||||
const list = ref<any[]>([]),
|
||||
selectList = ref<number[]>([]),
|
||||
total = ref(0),
|
||||
tableH = ref(0),
|
||||
tableLoading = ref(true);
|
||||
const params = ref<PageParams>({ page: 1, count: 10 });
|
||||
onBeforeMount(async () => {
|
||||
if (props.tableHeight) tableH.value = props.tableHeight;
|
||||
else tableH.value = Utils.useTableHeight();
|
||||
if (!!props.pageParams) params.value = props.pageParams;
|
||||
// 页长记忆(bsch 规范 §3:customPageCount localStorage)
|
||||
params.value.count = parseInt(localStorage.getItem("customPageCount") || String(params.value.count));
|
||||
if (props.autoLoad) await getData();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// window.onresize = _.debounce(() => {
|
||||
// tableH.value = Utils.useTableHeight();
|
||||
// }, 1000);
|
||||
});
|
||||
|
||||
const getData = async () => {
|
||||
tableLoading.value = true;
|
||||
try {
|
||||
const tempParams: any = _.cloneDeep(params.value);
|
||||
tempParams.size = tempParams.count;
|
||||
delete tempParams.count;
|
||||
|
||||
tempParams.page -= 1;
|
||||
|
||||
var data = await props.dataApi(tempParams);
|
||||
total.value = data.count;
|
||||
|
||||
if (typeof props.dataFilter != "undefined") list.value = props.dataFilter(data);
|
||||
else list.value = data.list;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
Message.error(error);
|
||||
} finally {
|
||||
tableLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
function selectChange(selection: any) {
|
||||
selectList.value = selection;
|
||||
}
|
||||
async function pageUpdate(page: number) {
|
||||
params.value.page = page;
|
||||
await getData();
|
||||
}
|
||||
async function pageSizeUpdate(size: number) {
|
||||
params.value.page = 1;
|
||||
params.value.count = size;
|
||||
localStorage.setItem("customPageCount", String(size));
|
||||
await getData();
|
||||
}
|
||||
|
||||
/** 刷新数据-更新删除用 */
|
||||
// "add" | "del" | "edit" | "sel"
|
||||
async function refreshData() {
|
||||
params.value.page = 1;
|
||||
await getData();
|
||||
}
|
||||
|
||||
/** 重置数据 */
|
||||
// "add" | "del" | "edit" | "sel"
|
||||
async function reloadData(type: OperatorEnum = OperatorEnum.sel) {
|
||||
let { page, count } = params.value;
|
||||
if (type == OperatorEnum.add) {
|
||||
console.log(page, count, total.value);
|
||||
|
||||
if (page * count == total.value) page += 1;
|
||||
} else if (type == OperatorEnum.del) {
|
||||
if ((page - 1) * count + 1 == total.value) page -= 1;
|
||||
if (page < 1) page = 1;
|
||||
} else if (type == OperatorEnum.edit) {
|
||||
//page=page
|
||||
} else if (type == OperatorEnum.sel) page = 1;
|
||||
params.value.page = page;
|
||||
await getData();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
list,
|
||||
refreshData,
|
||||
reloadData,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
@on-selection-change="selectChange"
|
||||
class="link-table table-bottom-box"
|
||||
:height="tableH"
|
||||
:loading="tableLoading"
|
||||
no-data-text="暂无数据"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle">
|
||||
<slot :checkList="selectList"></slot>
|
||||
</div>
|
||||
|
||||
<Page
|
||||
class="page-box fr"
|
||||
:total="total"
|
||||
:model-value="params.page"
|
||||
:page-size="params.count"
|
||||
:page-size-opts="[10, 20, 30, 40]"
|
||||
@on-page-size-change="pageSizeUpdate"
|
||||
@on-change="pageUpdate"
|
||||
placement="top"
|
||||
show-total
|
||||
show-sizer
|
||||
></Page>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<!-- 表格 -->
|
||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<!-- 分页 / 插槽 -->
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle">
|
||||
<slot :checkList="selectList" />
|
||||
</div>
|
||||
|
||||
<Page class="page-box fr" :total="total" :model-value="page" :page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]" show-total show-sizer placement="top" @on-change="onPageChange"
|
||||
@on-page-size-change="onSizeChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { Message } from 'view-ui-plus'
|
||||
import Utils from '@/utils'
|
||||
import { OperatorEnum } from '@/types'
|
||||
|
||||
interface Props {
|
||||
/** 必填:alova-gen 生成的 selectTextbook 方法(或任意同形方法) */
|
||||
dataApi: any
|
||||
columns: any[]
|
||||
dataParams: any
|
||||
tableHeight?: number
|
||||
autoLoad?: boolean
|
||||
dataFilter?: (resp: any) => any[],
|
||||
formatResponse?: (res: any) => { total: number; list: any[] }
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
autoLoad: true,
|
||||
formatResponse: (res: any) => ({ total: res.total, list: res.content })
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'selection-change', list: number[]): void
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
})
|
||||
|
||||
/* ---------------- 表格高度 ---------------- */
|
||||
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
|
||||
|
||||
/* ---------------- 选中行 ---------------- */
|
||||
const selectList = ref<number[]>([])
|
||||
function selectChange(rows: any[]) {
|
||||
selectList.value = rows.map((r: any) => r.id)
|
||||
emit('selection-change', selectList.value)
|
||||
}
|
||||
|
||||
/* ---------------- 分页 + 请求 ---------------- */
|
||||
const {
|
||||
loading,
|
||||
data: resp,
|
||||
send: sendLoad,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
reload
|
||||
} = usePagination(
|
||||
(current: number, size: number) =>
|
||||
props.dataApi({
|
||||
...props.dataParams, // 合并外部条件
|
||||
pageIndex: current,
|
||||
pageSize: size,
|
||||
}),
|
||||
{
|
||||
// total: res => res.total,
|
||||
// data: res => res.list,
|
||||
total: res => props.formatResponse(res).total,
|
||||
data: res => props.formatResponse(res).list,
|
||||
// 默认分页参数
|
||||
initialPage: 1,
|
||||
initialPageSize: 10,
|
||||
immediate: props.autoLoad,
|
||||
watchingStates: [() => props.dataParams]
|
||||
}
|
||||
)
|
||||
/* 列表数据 */
|
||||
const list = computed(() => {
|
||||
if (!resp.value) return []
|
||||
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
||||
})
|
||||
|
||||
/* ---------------- 翻页事件 ---------------- */
|
||||
const onPageChange = (p: number) => (page.value = p)
|
||||
const onSizeChange = (s: number) => (pageSize.value = s)
|
||||
|
||||
/* ---------------- 外部可调用 ---------------- */
|
||||
function refreshData() {
|
||||
page.value = 1
|
||||
reload()
|
||||
}
|
||||
function reloadData(type: OperatorEnum = OperatorEnum.sel) {
|
||||
let p = page.value
|
||||
const s = pageSize.value
|
||||
|
||||
if (type === OperatorEnum.add && p * s === total.value) p += 1
|
||||
if (type === OperatorEnum.del && (p - 1) * s + 1 === total.value) p -= 1
|
||||
if (p < 1) p = 1
|
||||
page.value = p
|
||||
sendLoad()
|
||||
}
|
||||
|
||||
defineExpose({ list, refreshData, reloadData })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 沿用旧样式即可 */
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||
<template #loading>
|
||||
<div class="table-spin-load">加载中</div>
|
||||
</template>
|
||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||
<slot :name="slotName" v-bind="slotProps" />
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<div class="page-panel">
|
||||
<div class="fl ml-15 align-middle">
|
||||
<slot :checkList="selectList" />
|
||||
</div>
|
||||
|
||||
<Page class="page-box fr" :total="total" :model-value="page" :page-size="pageSize"
|
||||
:page-size-opts="[10, 20, 30, 40]" show-total show-sizer placement="top" @on-change="onPageChange"
|
||||
@on-page-size-change="onSizeChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useSlots } from 'vue'
|
||||
import Utils from '@/utils'
|
||||
import { OperatorEnum } from '@/types'
|
||||
|
||||
interface Props {
|
||||
dataApi: any
|
||||
columns: any[]
|
||||
tableHeight?: number
|
||||
autoLoad?: boolean
|
||||
dataFilter?: (resp: any) => any[]
|
||||
formatResponse?: (res: any) => { total: number; list: any[] }
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
autoLoad: true,
|
||||
formatResponse: (res: any) => ({ total: res.total, list: res.content })
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'selection-change', list: number[]): void
|
||||
}>()
|
||||
const slots = useSlots()
|
||||
|
||||
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
|
||||
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
||||
|
||||
const selectList = ref<number[]>([])
|
||||
function selectChange(rows: any[]) {
|
||||
selectList.value = rows.map((r: any) => r.id)
|
||||
emit('selection-change', selectList.value)
|
||||
}
|
||||
|
||||
const {
|
||||
loading,
|
||||
data: resp,
|
||||
send: sendLoad,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
reload
|
||||
} = usePagination(
|
||||
(current: number, size: number) =>
|
||||
props.dataApi({ pageIndex: current, pageSize: size }),
|
||||
{
|
||||
total: res => props.formatResponse(res).total,
|
||||
data: res => props.formatResponse(res).list,
|
||||
initialPage: 1,
|
||||
initialPageSize: 10,
|
||||
immediate: props.autoLoad,
|
||||
}
|
||||
)
|
||||
|
||||
const list = computed(() => {
|
||||
if (!resp.value) return []
|
||||
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
||||
})
|
||||
|
||||
const onPageChange = (p: number) => (page.value = p)
|
||||
const onSizeChange = (s: number) => (pageSize.value = s)
|
||||
|
||||
function refreshData() {
|
||||
page.value = 1
|
||||
reload()
|
||||
}
|
||||
|
||||
function reloadData(type: OperatorEnum = OperatorEnum.sel) {
|
||||
let p = page.value
|
||||
const s = pageSize.value
|
||||
|
||||
if (type === OperatorEnum.add && p * s === total.value) p += 1
|
||||
if (type === OperatorEnum.del && (p - 1) * s + 1 === total.value) p -= 1
|
||||
if (type === OperatorEnum.sel) p = 1
|
||||
if (p < 1) p = 1
|
||||
|
||||
page.value = p
|
||||
sendLoad()
|
||||
}
|
||||
|
||||
defineExpose({ list, refreshData, reloadData })
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div>
|
||||
<Select class="mt-8" v-model="curSubJectId" placeholder="请选择学科">
|
||||
<Option v-for="item in subjectComp" :value="item.k">{{ item.v }}</Option>
|
||||
</Select>
|
||||
<MyTree v-if="!treeLoading" ref="refMyTree" :autoClick="false" :treeData="treeData" :setting="treeSetting"
|
||||
@on-tree-click="treeLink" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MyTree from '@/components/MyTree.vue'
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { cloneDeep, isEmpty } from "lodash";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import { get, last } from "lodash";
|
||||
import { requireImg } from "@/utils/hook/useImg";
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
}>()
|
||||
|
||||
const curTreeNode = defineModel<any>('curTreeNode', { default: {} })
|
||||
//学科学段
|
||||
const curSubjectInfo = defineModel<any>('curSubjectInfo', {
|
||||
default: {
|
||||
stage: null,
|
||||
subject: null
|
||||
}
|
||||
})
|
||||
|
||||
const store = useDefaultStore();
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const refMyTree = useTemplateRef('refMyTree');
|
||||
function showIconForTree(treeId: any, treeNode: any) {
|
||||
return treeNode.level == 0;
|
||||
};
|
||||
const treeSetting = ref({
|
||||
view: {
|
||||
dblClickExpand: false,
|
||||
// showIcon: true,
|
||||
showIcon: showIconForTree
|
||||
},
|
||||
edit: {
|
||||
enable: true,
|
||||
showRemoveBtn: false,
|
||||
showRenameBtn: false,
|
||||
drag: false,
|
||||
},
|
||||
check: {
|
||||
enable: false,
|
||||
},
|
||||
callback: {},
|
||||
})
|
||||
|
||||
|
||||
const curSubJectId = ref<number | null>(null);
|
||||
|
||||
const subjectComp = computed(() => {
|
||||
return store.getStageSubjects;
|
||||
})
|
||||
|
||||
const paramsComp = computed(() => {
|
||||
const find = subjectComp.value.find((item: any) => item.k == curSubJectId.value)
|
||||
if (!find) return null;
|
||||
else
|
||||
return {
|
||||
stage: find.stage,
|
||||
subject: find.code
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const curNodePath = computed(() => {
|
||||
if (!curTreeNode.value) return [];
|
||||
let arr: any[] = curTreeNode.value.getPath().map((x: any) => x.name)
|
||||
return arr;
|
||||
})
|
||||
|
||||
|
||||
|
||||
const { loading: treeLoading, data: treeData, send: sendReloadTree, onSuccess: onNodeTreeSuccess } = useRequest(
|
||||
() => Apis.NodeTree.getKnowledge({ params: paramsComp.value }),
|
||||
{
|
||||
initialData: [],
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
watch(() => curSubJectId.value, async (val) => {
|
||||
await nextTick();
|
||||
curTreeNode.value = null;
|
||||
curSubjectInfo.value.stage = paramsComp.value?.stage;
|
||||
curSubjectInfo.value.subject = paramsComp.value?.subject;
|
||||
if (!!val)
|
||||
sessionStorage.setItem('bsch.learnmanage.cur_subject_id', val.toString())
|
||||
sendReloadTree(-1);
|
||||
})
|
||||
|
||||
onNodeTreeSuccess(() => reloadTree(-1))
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
console.log(subjectComp.value);
|
||||
|
||||
if (subjectComp.value.length > 0) {
|
||||
|
||||
let cacheStr = sessionStorage.getItem('bsch.learnmanage.cur_subject_id');
|
||||
if (!!cacheStr) {
|
||||
curSubJectId.value = Number(cacheStr);
|
||||
}
|
||||
else
|
||||
curSubJectId.value = subjectComp.value[0].k;
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
async function reloadTree(id?: number) {
|
||||
await nextTick();
|
||||
function recursion(tree: any[], level: number): any[] {
|
||||
tree.forEach(element => {
|
||||
if (level == 1) {
|
||||
element.open = true;
|
||||
element.parent = true;
|
||||
element.isParent = true;
|
||||
element.icon = requireImg(`tree-icon-item.png`, "image");
|
||||
element.iconSkin = requireImg(`tree-icon-item.png`, "image");
|
||||
}
|
||||
else {
|
||||
element.icon = '';
|
||||
element.iconSkin = '';
|
||||
}
|
||||
|
||||
// element.icon = requireImg(`tree-icon-${element.isParent ? "group" : "item"}.png`, "image");
|
||||
|
||||
element.level = level;
|
||||
|
||||
if (!!element.children && element.children.length > 0)
|
||||
element.children = recursion(element.children, level + 1);
|
||||
else if (!element.isParent) {
|
||||
delete element.children;
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
treeData.value = recursion(treeData.value, 1);
|
||||
|
||||
|
||||
// treeData.value.forEach((n: any) => {
|
||||
// n.open = true;
|
||||
// n.icon = requireImg(`tree-icon-item.png`, "image");
|
||||
// if (!n.children || n.children.length == 0) {
|
||||
// n.parent = true;
|
||||
// n.children = [];
|
||||
// }
|
||||
// });
|
||||
|
||||
if (!!curTreeNode.value && !!curTreeNode.value.id)
|
||||
refMyTree.value?.reloadTree(curTreeNode.value.id)
|
||||
else
|
||||
refMyTree.value?.reloadTree(id)
|
||||
}
|
||||
|
||||
async function refreshMoveTree() {
|
||||
// const id = selectedNode.value.id;
|
||||
// await Apis.NodeTree.refreshSortTree({ params: { nodeId: id } })
|
||||
// Message.success('刷新成功!');
|
||||
// refTreeNode.value?.reloadTree()
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function treeLink(treeNode: any) {
|
||||
const copyNode = cloneDeep(treeNode)
|
||||
copyNode.children = [];
|
||||
curTreeNode.value = copyNode;
|
||||
console.log('click', treeNode.compositeId);
|
||||
console.log(copyNode.getPath().map((x: any) => x.name));
|
||||
}
|
||||
|
||||
async function onChangeSubject() { }
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.opera-ul {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
padding-bottom: 10px;
|
||||
|
||||
li {
|
||||
padding: 10px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
li.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#modal-sortTreeNode-id {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
height: 380px;
|
||||
|
||||
.tree-div {
|
||||
z-index: 1001;
|
||||
border: 1px solid #cacaca;
|
||||
border-radius: 5px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.ztree {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,487 @@
|
||||
<template>
|
||||
<div>
|
||||
<ul class="opera-ul">
|
||||
<Tooltip content="新增节点" placement="top" transfer>
|
||||
<li @click="showAddNode" :class="{ disabled: !nodePermissions.canCreate }">
|
||||
<SvgIcon name="tree-icon-1" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="编辑节点" placement="top" transfer>
|
||||
<li @click="showEditNode" :class="{ disabled: !nodePermissions.canUpdate }">
|
||||
<SvgIcon name="tree-icon-2" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="移动节点" placement="top" transfer>
|
||||
<li @click="showMoveNode" :class="{ disabled: !nodePermissions.canMove }">
|
||||
<SvgIcon name="tree-icon-3" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="删除节点" placement="top" transfer>
|
||||
<li @click="showDeleteNode" :class="{ disabled: !nodePermissions.canDelete }">
|
||||
<SvgIcon name="tree-icon-4" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
</ul>
|
||||
<Select class="mt-8" v-model="curSubJectId" placeholder="请选择学科">
|
||||
<Option v-for="item in subjectComp" :value="item.k">{{ item.v }}</Option>
|
||||
</Select>
|
||||
<MyTree v-if="!treeLoading" ref="refMyTree" :autoClick="false" :treeData="treeData" :setting="treeSetting"
|
||||
@on-tree-click="treeLink" />
|
||||
|
||||
<div class="modal-wrap">
|
||||
<Modal v-model="editNodeModal.modal" :title="editNodeModal.title" width="450px" :mask-closable="false">
|
||||
<Form ref="refFormData" :label-width="100" :model="editNodeModal" class="myform mb-20 mr-20">
|
||||
<Form-Item label="当前位置:">
|
||||
{{ curNodePath.join(' > ') }}
|
||||
</Form-Item>
|
||||
<Form-Item label="节点名称:" prop="name"
|
||||
:rules="{ required: true, message: '请输入节点名称', trigger: 'blur' }">
|
||||
<Input ref="refNodeNameInput" maxlength="30" v-model="editNodeModal.name" />
|
||||
</Form-Item>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="editNodeModal.modal = false">取消</Button>
|
||||
<Button type="primary" :loading="editNodeModal.loading" @click="saveEditNode">确认</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal id="modal-sortTreeNode-id" class-name="up-dowm-components" v-model="showMoveModalStatus" title="移动节点"
|
||||
:closable="false" :mask-closable="false" width="600">
|
||||
<div class="fix">
|
||||
<Alert class="fl">拖拽到目标节点时,确保节点是展开状态,以保证节点数据一致</Alert>
|
||||
<Button class="fr" @click="sendReloadTree()">刷新节点树</Button>
|
||||
</div>
|
||||
<MyTree class="tree-div" ref="refSortMyTree" :autoClick="false" :treeData="moveTreeData"
|
||||
:setting="moveTreeSetting" @on-tree-drag="onTreeDragMove" />
|
||||
|
||||
<template #footer>
|
||||
<Button @click="showMoveModalStatus = false" style="margin-left: 20px">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import MyTree from '@/components/MyTree.vue'
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { RootNode } from "@/types";
|
||||
import { cloneDeep, isEmpty } from "lodash";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import { get, last } from "lodash";
|
||||
|
||||
const props = defineProps<{
|
||||
rootType: RootNode;
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'on-change': [stage: number, subject: string, treeNode: any]
|
||||
}>()
|
||||
|
||||
|
||||
const curTreeNode = defineModel<any>('curTreeNode', { default: {} })
|
||||
//学科学段
|
||||
const curSubjectInfo = defineModel<any>('curSubjectInfo', {
|
||||
default: {
|
||||
stage: null,
|
||||
subject: null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const store = useDefaultStore();
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const refMyTree = useTemplateRef('refMyTree');
|
||||
const refFormData = useTemplateRef('refFormData')
|
||||
const refNodeNameInput = useTemplateRef('refNodeNameInput')
|
||||
|
||||
|
||||
const treeSetting = ref({
|
||||
view: {
|
||||
dblClickExpand: false,
|
||||
showIcon: false,
|
||||
},
|
||||
edit: {
|
||||
enable: true,
|
||||
showRemoveBtn: false,
|
||||
showRenameBtn: false,
|
||||
drag: false,
|
||||
},
|
||||
check: {
|
||||
enable: false,
|
||||
},
|
||||
callback: {},
|
||||
})
|
||||
|
||||
const moveTreeSetting = ref({
|
||||
view: { dblClickExpand: false, showIcon: true },
|
||||
edit: {
|
||||
enable: true,
|
||||
showRemoveBtn: false,
|
||||
showRenameBtn: false,
|
||||
drag: {
|
||||
isMove: true,
|
||||
isCopy: false,
|
||||
showArrow: true,
|
||||
prev: true,
|
||||
next: true,
|
||||
inner: true,
|
||||
autoExpandTrigger: true
|
||||
}
|
||||
},
|
||||
check: {
|
||||
enable: false
|
||||
},
|
||||
callback: {}
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
const showMoveModalStatus = ref(false)
|
||||
const editNodeModal = reactive({
|
||||
modal: false,
|
||||
title: '',
|
||||
loading: false,
|
||||
name: null,
|
||||
id: null,
|
||||
})
|
||||
|
||||
|
||||
const curSubJectId = ref<number | null>(null);
|
||||
|
||||
const subjectComp = computed(() => {
|
||||
return store.getStageSubjects;
|
||||
})
|
||||
|
||||
const paramsComp = computed(() => {
|
||||
const find = subjectComp.value.find((item: any) => item.k == curSubJectId.value)
|
||||
if (!find) return null;
|
||||
else
|
||||
return {
|
||||
rootType: props.rootType,
|
||||
stage: find.stage,
|
||||
subject: find.code
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const curNodePath = computed(() => {
|
||||
if (!curTreeNode.value) return [];
|
||||
let arr: any[] = curTreeNode.value.getPath().map((x: any) => x.name)
|
||||
return arr;
|
||||
})
|
||||
|
||||
const moveTreeData = computed(() => {
|
||||
function formatTree(list: any[]): any[] {
|
||||
return list
|
||||
.map(node => {
|
||||
const [, , canMove] = $utils.unpackPerms(node.permissions)
|
||||
if (!canMove) return null
|
||||
|
||||
const copy = cloneDeep(node)
|
||||
copy.drag = true
|
||||
copy.children = copy.children?.length
|
||||
? formatTree(copy.children)
|
||||
: null
|
||||
return copy
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
return formatTree(treeData.value)
|
||||
})
|
||||
|
||||
const { loading: treeLoading, data: treeData, send: sendReloadTree, onSuccess: onNodeTreeSuccess } = useRequest(
|
||||
() => Apis.NodeTree.getNodeTree({ params: paramsComp.value }),
|
||||
{
|
||||
initialData: [],
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
watch(() => curSubJectId.value, async (val) => {
|
||||
await nextTick();
|
||||
curTreeNode.value = null;
|
||||
curSubjectInfo.value.stage = paramsComp.value?.stage;
|
||||
curSubjectInfo.value.subject = paramsComp.value?.subject;
|
||||
if (!!val)
|
||||
sessionStorage.setItem('bsch.learnmanage.cur_subject_id', val.toString())
|
||||
sendReloadTree(-1);
|
||||
})
|
||||
|
||||
onNodeTreeSuccess(() => reloadTree(-1))
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
if (subjectComp.value.length > 0) {
|
||||
|
||||
let cacheStr = sessionStorage.getItem('bsch.learnmanage.cur_subject_id');
|
||||
if (!!cacheStr) {
|
||||
curSubJectId.value = Number(cacheStr);
|
||||
}
|
||||
else
|
||||
curSubJectId.value = subjectComp.value[0].k;
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
async function reloadTree(id?: number) {
|
||||
await nextTick();
|
||||
console.log(id);
|
||||
|
||||
treeData.value.forEach((n: any) => (n.open = true));
|
||||
if (!!curTreeNode.value && !!curTreeNode.value.id)
|
||||
refMyTree.value?.reloadTree(curTreeNode.value.id)
|
||||
else
|
||||
refMyTree.value?.reloadTree(id)
|
||||
}
|
||||
|
||||
|
||||
function showAddNode() {
|
||||
if (!nodePermissions.value.canCreate) return;
|
||||
editNodeModal.title = '新增节点';
|
||||
editNodeModal.modal = true;
|
||||
editNodeModal.id = null;
|
||||
editNodeModal.name = null;
|
||||
nextTick().then(() => {
|
||||
refNodeNameInput.value?.focus()
|
||||
})
|
||||
}
|
||||
function showEditNode() {
|
||||
if (!nodePermissions.value.canUpdate) return;
|
||||
editNodeModal.title = '编辑节点';
|
||||
editNodeModal.modal = true;
|
||||
|
||||
editNodeModal.id = curTreeNode.value.id;
|
||||
editNodeModal.name = curTreeNode.value.name;
|
||||
nextTick().then(() => {
|
||||
refNodeNameInput.value.focus()
|
||||
})
|
||||
}
|
||||
function showMoveNode() {
|
||||
if (!nodePermissions.value.canMove) return;
|
||||
showMoveModalStatus.value = true;
|
||||
|
||||
}
|
||||
async function showDeleteNode() {
|
||||
if (!nodePermissions.value.canDelete) return;
|
||||
const temp = cloneDeep(curTreeNode.value);
|
||||
const parentNode = temp.getParentNode();
|
||||
Modal.confirm({
|
||||
title: '删除知识树节点',
|
||||
content: `确定删除名为【${curTreeNode.value.name}】的知识树节点记录?`,
|
||||
onOk: async () => {
|
||||
const id = curTreeNode.value.id;
|
||||
await Apis.NodeTree.deleteNode({ params: { nodeId: id } })
|
||||
Message.success('删除成功!');
|
||||
curTreeNode.value = parentNode;
|
||||
sendReloadTree()
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async function saveEditNode() {
|
||||
let isAdd = !!editNodeModal.id ? false : true;
|
||||
const validate = await refFormData.value.validate();
|
||||
if (!validate) {
|
||||
Message.error('请填写红色星号内容!');
|
||||
return;
|
||||
}
|
||||
const { id, name } = editNodeModal;
|
||||
editNodeModal.loading = true;
|
||||
if (isAdd) {
|
||||
const parentId = get(curTreeNode.value, 'id', -1)
|
||||
const brotherId = get(last(get(curTreeNode.value, 'children')), 'id', 0);
|
||||
const params: any = {
|
||||
rootType: props.rootType,
|
||||
name, parentId, brotherId,
|
||||
stage: paramsComp.value?.stage,
|
||||
subject: paramsComp.value?.subject
|
||||
};
|
||||
await Apis.NodeTree.addNode({ params })
|
||||
sendReloadTree()
|
||||
}
|
||||
else {
|
||||
await Apis.NodeTree.updateNodeName({ params: { name, nodeId: id } })
|
||||
|
||||
sendReloadTree();
|
||||
}
|
||||
|
||||
editNodeModal.loading = false;
|
||||
editNodeModal.modal = false;
|
||||
}
|
||||
|
||||
async function refreshMoveTree() {
|
||||
// const id = selectedNode.value.id;
|
||||
// await Apis.NodeTree.refreshSortTree({ params: { nodeId: id } })
|
||||
// Message.success('刷新成功!');
|
||||
// refTreeNode.value?.reloadTree()
|
||||
}
|
||||
//@ts-ignore
|
||||
async function onTreeDragMove({ treeNodes, targetNode, moveType }) {
|
||||
let mType = moveType == "inner" ? 3 : moveType == "prev" ? 1 : moveType == "next" ? 2 : 0;
|
||||
let currentNode = treeNodes[0];
|
||||
let nativeNode = currentNode;
|
||||
const params = formatMoveTree(treeNodes[0], targetNode, mType);
|
||||
await Apis.NodeTree.move({
|
||||
params: params,
|
||||
})
|
||||
sendReloadTree();
|
||||
}
|
||||
|
||||
function formatMoveTree(currentNode: any, targetNode: any, moveType: any) {
|
||||
|
||||
let nativeNode = currentNode;
|
||||
let treeNode = targetNode;
|
||||
let pathArr = treeNode.path.split(",");
|
||||
let index2 = pathArr.findIndex((ele: any) => {
|
||||
return ele == nativeNode.id;
|
||||
});
|
||||
if (index2 != -1) {
|
||||
Message.warning("父节点无法移动到子节点下方");
|
||||
return;
|
||||
}
|
||||
let newBrother = ""; //新兄长节点 【接口参数】
|
||||
let newParent = ""; //新父节点 【接口参数】
|
||||
|
||||
console.log('treeNode', treeNode);
|
||||
|
||||
var parentNode = treeNode.getParentNode();
|
||||
|
||||
let chilList = !!parentNode ? parentNode.children : treeNode.children;
|
||||
let index = chilList.findIndex((ele: any) => {
|
||||
return ele.id == treeNode.id;
|
||||
});
|
||||
if (treeNode.id == nativeNode.id || (treeNode.id == nativeNode.parentId && moveType == 3)) {
|
||||
//选中同一节点,无效移动
|
||||
Message.warning("目标节点与选中节点冲突,请选择其他目标节点");
|
||||
return;
|
||||
} else if ((moveType == 1 || moveType == 2) && nativeNode.parentId == treeNode.parentId) {
|
||||
if (moveType == 1) {
|
||||
console.log('idnex:' + index);
|
||||
index--;
|
||||
//前面
|
||||
newBrother = index == 0 ? 0 : chilList[index - 1].id;
|
||||
} else {
|
||||
//后面
|
||||
newBrother = chilList[index].id;
|
||||
}
|
||||
newParent = "";
|
||||
} else {
|
||||
//跨父节点移动
|
||||
if (moveType == 1) {
|
||||
//选择的是前面
|
||||
//前面
|
||||
newBrother = index == 0 ? index : chilList[index - 1].id;
|
||||
newParent = treeNode.parentId;
|
||||
} else if (moveType == 2) {
|
||||
//选择的是后面
|
||||
//后面
|
||||
newBrother = chilList[index].id;
|
||||
newParent = treeNode.parentId;
|
||||
} else {
|
||||
//选择的是里面
|
||||
//里面 选中的children里面的最后一个
|
||||
newBrother = isEmpty(treeNode.children)
|
||||
? 0
|
||||
: treeNode.children[treeNode.children.length - 1].id;
|
||||
newParent = treeNode.id;
|
||||
}
|
||||
}
|
||||
return {
|
||||
targetId: nativeNode.id,
|
||||
newParent: newParent,
|
||||
newBrother: newBrother,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function treeLink(treeNode: any) {
|
||||
const copyNode = cloneDeep(treeNode)
|
||||
copyNode.children = [];
|
||||
curTreeNode.value = copyNode;
|
||||
// console.log('click', treeNode);
|
||||
// console.log(copyNode.getPath().map((x: any) => x.name));
|
||||
|
||||
curSubjectInfo.value.stage = paramsComp.value?.stage;
|
||||
curSubjectInfo.value.subject = paramsComp.value?.subject;
|
||||
const { stage, subject } = curSubjectInfo.value;
|
||||
emit('on-change', stage, subject, copyNode)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const nodePermissions = computed(() => {
|
||||
if (!curTreeNode.value) return { canCreate: false, canUpdate: false, canMove: false, canDelete: false };
|
||||
const [canCreate, canUpdate, canMove, canDelete] = $utils.unpackPerms(curTreeNode.value.permissions)
|
||||
return {
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canMove,
|
||||
canDelete,
|
||||
}
|
||||
})
|
||||
|
||||
async function onChangeSubject() { }
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.opera-ul {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
padding-bottom: 10px;
|
||||
|
||||
li {
|
||||
padding: 10px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
li.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#modal-sortTreeNode-id {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
height: 380px;
|
||||
|
||||
.tree-div {
|
||||
z-index: 1001;
|
||||
border: 1px solid #cacaca;
|
||||
border-radius: 5px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.ztree {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<div>
|
||||
<ul class="opera-ul">
|
||||
<Tooltip content="新增节点" placement="top" transfer>
|
||||
<li @click="showAddNode" :class="{ disabled: !nodePermissions.canCreate }">
|
||||
<SvgIcon name="tree-icon-1" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="编辑节点" placement="top" transfer>
|
||||
<li @click="showEditNode" :class="{ disabled: !nodePermissions.canUpdate }">
|
||||
<SvgIcon name="tree-icon-2" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
<Tooltip content="移动节点" placement="top" transfer>
|
||||
<li @click="showMoveNode" :class="{ disabled: !nodePermissions.canMove }">
|
||||
<SvgIcon name="tree-icon-3" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="删除节点" placement="top" transfer>
|
||||
<li @click="showDeleteNode" :class="{ disabled: !nodePermissions.canDelete }">
|
||||
<SvgIcon name="tree-icon-4" />
|
||||
</li>
|
||||
</Tooltip>
|
||||
</ul>
|
||||
<BschSubjectSelect class="mt-8" @on-change="handleSubjectChange" />
|
||||
|
||||
<MyTree v-if="!treeLoading" ref="refMyTree" :autoClick="false" :treeData="treeData" :setting="treeSetting"
|
||||
@on-tree-click="treeLink" />
|
||||
|
||||
<div class="modal-wrap">
|
||||
<Modal v-model="editNodeModal.modal" :title="editNodeModal.title" width="450px" :mask-closable="false">
|
||||
<Form ref="refFormData" :label-width="100" :model="editNodeModal" class="myform mb-20 mr-20">
|
||||
<Form-Item label="当前位置:">
|
||||
{{ curNodePath.join(' > ') }}
|
||||
</Form-Item>
|
||||
<Form-Item label="节点名称:" prop="name"
|
||||
:rules="{ required: true, message: '请输入节点名称', trigger: 'blur' }">
|
||||
<Input ref="refNodeNameInput" maxlength="30" v-model="editNodeModal.name" />
|
||||
</Form-Item>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="editNodeModal.modal = false">取消</Button>
|
||||
<Button type="primary" :loading="editNodeModal.loading" @click="saveEditNode">确认</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal id="modal-sortTreeNode-id" class-name="up-dowm-components" v-model="showMoveModalStatus" title="移动节点"
|
||||
:closable="false" :mask-closable="false" width="600">
|
||||
<div class="fix">
|
||||
<Alert class="fl">拖拽到目标节点时,确保节点是展开状态,以保证节点数据一致</Alert>
|
||||
<Button class="fr" @click="sendReloadTree()">刷新节点树</Button>
|
||||
</div>
|
||||
<MyTree class="tree-div" ref="refSortMyTree" :autoClick="false" :treeData="moveTreeData"
|
||||
:setting="moveTreeSetting" @on-tree-drag="onTreeDragMove" />
|
||||
|
||||
<template #footer>
|
||||
<Button @click="showMoveModalStatus = false" style="margin-left: 20px">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import MyTree from '@/components/MyTree.vue'
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { RootNode } from "@/types";
|
||||
import { cloneDeep, isEmpty } from "lodash";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import BschSubjectSelect from '@/components/BschSubjectSelect.vue'
|
||||
import { get, last } from "lodash";
|
||||
|
||||
const props = defineProps<{
|
||||
rootType: RootNode;
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'on-change': [stage: number, subject: string, treeNode: any]
|
||||
}>()
|
||||
|
||||
|
||||
const curTreeNode = defineModel<any>('curTreeNode', { default: {} })
|
||||
//学科学段
|
||||
const curSubjectInfo = defineModel<any>('curSubjectInfo', {
|
||||
default: {
|
||||
stage: null,
|
||||
subject: null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const store = useDefaultStore();
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const refMyTree = useTemplateRef('refMyTree');
|
||||
const refFormData = useTemplateRef('refFormData')
|
||||
const refNodeNameInput = useTemplateRef('refNodeNameInput')
|
||||
|
||||
|
||||
const treeSetting = ref({
|
||||
view: {
|
||||
dblClickExpand: false,
|
||||
showIcon: false,
|
||||
},
|
||||
edit: {
|
||||
enable: true,
|
||||
showRemoveBtn: false,
|
||||
showRenameBtn: false,
|
||||
drag: false,
|
||||
},
|
||||
check: {
|
||||
enable: false,
|
||||
},
|
||||
callback: {},
|
||||
})
|
||||
|
||||
const moveTreeSetting = ref({
|
||||
view: { dblClickExpand: false, showIcon: true },
|
||||
edit: {
|
||||
enable: true,
|
||||
showRemoveBtn: false,
|
||||
showRenameBtn: false,
|
||||
drag: {
|
||||
isMove: true,
|
||||
isCopy: false,
|
||||
showArrow: true,
|
||||
prev: true,
|
||||
next: true,
|
||||
inner: true,
|
||||
autoExpandTrigger: true
|
||||
}
|
||||
},
|
||||
check: {
|
||||
enable: false
|
||||
},
|
||||
callback: {}
|
||||
})
|
||||
|
||||
|
||||
const showMoveModalStatus = ref(false)
|
||||
const editNodeModal = reactive({
|
||||
modal: false,
|
||||
title: '',
|
||||
loading: false,
|
||||
name: null,
|
||||
id: null,
|
||||
})
|
||||
|
||||
const curSubject = reactive({
|
||||
subject: '',
|
||||
stage: 0,
|
||||
})
|
||||
|
||||
|
||||
const paramsComp = computed(() => {
|
||||
return {
|
||||
rootType: props.rootType,
|
||||
stage: curSubject.stage,
|
||||
subject: curSubject.subject
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const curNodePath = computed(() => {
|
||||
if (!curTreeNode.value) return [];
|
||||
let arr: any[] = curTreeNode.value.getPath().map((x: any) => x.name)
|
||||
return arr;
|
||||
})
|
||||
|
||||
const moveTreeData = computed(() => {
|
||||
function formatTree(list: any[]): any[] {
|
||||
return list
|
||||
.map(node => {
|
||||
const [, , canMove] = $utils.unpackPerms(node.permissions)
|
||||
if (!canMove) return null
|
||||
|
||||
const copy = cloneDeep(node)
|
||||
copy.drag = true
|
||||
copy.children = copy.children?.length
|
||||
? formatTree(copy.children)
|
||||
: null
|
||||
return copy
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
return formatTree(treeData.value)
|
||||
})
|
||||
|
||||
const { loading: treeLoading, data: treeData, send: sendReloadTree, onSuccess: onNodeTreeSuccess } = useRequest(
|
||||
() => Apis.NodeTree.getNodeTree({ params: paramsComp.value }),
|
||||
{
|
||||
initialData: [],
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
|
||||
onNodeTreeSuccess(() => reloadTree(-props.rootType))
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
async function reloadTree(id?: number) {
|
||||
await nextTick();
|
||||
console.log(id);
|
||||
refMyTree.value?.reloadTree(id)
|
||||
// treeData.value.forEach((n: any) => (n.open = true));
|
||||
// if (!!curTreeNode.value && !!curTreeNode.value.id)
|
||||
// refMyTree.value?.reloadTree(curTreeNode.value.id)
|
||||
// else
|
||||
// refMyTree.value?.reloadTree(id)
|
||||
}
|
||||
|
||||
|
||||
function showAddNode() {
|
||||
if (!nodePermissions.value.canCreate) return;
|
||||
editNodeModal.title = '新增节点';
|
||||
editNodeModal.modal = true;
|
||||
editNodeModal.id = null;
|
||||
editNodeModal.name = null;
|
||||
nextTick().then(() => {
|
||||
refNodeNameInput.value?.focus()
|
||||
})
|
||||
}
|
||||
function showEditNode() {
|
||||
if (!nodePermissions.value.canUpdate) return;
|
||||
editNodeModal.title = '编辑节点';
|
||||
editNodeModal.modal = true;
|
||||
|
||||
editNodeModal.id = curTreeNode.value.id;
|
||||
editNodeModal.name = curTreeNode.value.name;
|
||||
nextTick().then(() => {
|
||||
refNodeNameInput.value.focus()
|
||||
})
|
||||
}
|
||||
function showMoveNode() {
|
||||
if (!nodePermissions.value.canMove) return;
|
||||
showMoveModalStatus.value = true;
|
||||
|
||||
}
|
||||
async function showDeleteNode() {
|
||||
if (!nodePermissions.value.canDelete) return;
|
||||
const temp = cloneDeep(curTreeNode.value);
|
||||
const parentNode = temp.getParentNode();
|
||||
Modal.confirm({
|
||||
title: '删除知识树节点',
|
||||
content: `确定删除名为【${curTreeNode.value.name}】的知识树节点记录?`,
|
||||
onOk: async () => {
|
||||
const id = curTreeNode.value.id;
|
||||
await Apis.NodeTree.deleteNode({ params: { nodeId: id } })
|
||||
Message.success('删除成功!');
|
||||
curTreeNode.value = parentNode;
|
||||
sendReloadTree()
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async function saveEditNode() {
|
||||
let isAdd = !!editNodeModal.id ? false : true;
|
||||
const validate = await refFormData.value.validate();
|
||||
if (!validate) {
|
||||
Message.error('请填写红色星号内容!');
|
||||
return;
|
||||
}
|
||||
const { id, name } = editNodeModal;
|
||||
editNodeModal.loading = true;
|
||||
if (isAdd) {
|
||||
const parentId = get(curTreeNode.value, 'id', -1)
|
||||
const brotherId = get(last(get(curTreeNode.value, 'children')), 'id', 0);
|
||||
const params: any = {
|
||||
rootType: props.rootType,
|
||||
name, parentId, brotherId,
|
||||
stage: paramsComp.value?.stage,
|
||||
subject: paramsComp.value?.subject
|
||||
};
|
||||
await Apis.NodeTree.addNode({ params })
|
||||
sendReloadTree()
|
||||
}
|
||||
else {
|
||||
await Apis.NodeTree.updateNodeName({ params: { name, nodeId: id } })
|
||||
|
||||
sendReloadTree();
|
||||
}
|
||||
|
||||
editNodeModal.loading = false;
|
||||
editNodeModal.modal = false;
|
||||
}
|
||||
|
||||
async function refreshMoveTree() {
|
||||
// const id = selectedNode.value.id;
|
||||
// await Apis.NodeTree.refreshSortTree({ params: { nodeId: id } })
|
||||
// Message.success('刷新成功!');
|
||||
// refTreeNode.value?.reloadTree()
|
||||
}
|
||||
//@ts-ignore
|
||||
async function onTreeDragMove({ treeNodes, targetNode, moveType }) {
|
||||
let mType = moveType == "inner" ? 3 : moveType == "prev" ? 1 : moveType == "next" ? 2 : 0;
|
||||
let currentNode = treeNodes[0];
|
||||
let nativeNode = currentNode;
|
||||
const params = formatMoveTree(treeNodes[0], targetNode, mType);
|
||||
await Apis.NodeTree.move({
|
||||
params: params,
|
||||
})
|
||||
sendReloadTree();
|
||||
}
|
||||
|
||||
function formatMoveTree(currentNode: any, targetNode: any, moveType: any) {
|
||||
|
||||
let nativeNode = currentNode;
|
||||
let treeNode = targetNode;
|
||||
let pathArr = treeNode.path.split(",");
|
||||
let index2 = pathArr.findIndex((ele: any) => {
|
||||
return ele == nativeNode.id;
|
||||
});
|
||||
if (index2 != -1) {
|
||||
Message.warning("父节点无法移动到子节点下方");
|
||||
return;
|
||||
}
|
||||
let newBrother = ""; //新兄长节点 【接口参数】
|
||||
let newParent = ""; //新父节点 【接口参数】
|
||||
|
||||
console.log('treeNode', treeNode);
|
||||
|
||||
var parentNode = treeNode.getParentNode();
|
||||
|
||||
let chilList = !!parentNode ? parentNode.children : treeNode.children;
|
||||
let index = chilList.findIndex((ele: any) => {
|
||||
return ele.id == treeNode.id;
|
||||
});
|
||||
if (treeNode.id == nativeNode.id || (treeNode.id == nativeNode.parentId && moveType == 3)) {
|
||||
//选中同一节点,无效移动
|
||||
Message.warning("目标节点与选中节点冲突,请选择其他目标节点");
|
||||
return;
|
||||
} else if ((moveType == 1 || moveType == 2) && nativeNode.parentId == treeNode.parentId) {
|
||||
if (moveType == 1) {
|
||||
console.log('idnex:' + index);
|
||||
index--;
|
||||
//前面
|
||||
newBrother = index == 0 ? 0 : chilList[index - 1].id;
|
||||
} else {
|
||||
//后面
|
||||
newBrother = chilList[index].id;
|
||||
}
|
||||
newParent = "";
|
||||
} else {
|
||||
//跨父节点移动
|
||||
if (moveType == 1) {
|
||||
//选择的是前面
|
||||
//前面
|
||||
newBrother = index == 0 ? index : chilList[index - 1].id;
|
||||
newParent = treeNode.parentId;
|
||||
} else if (moveType == 2) {
|
||||
//选择的是后面
|
||||
//后面
|
||||
newBrother = chilList[index].id;
|
||||
newParent = treeNode.parentId;
|
||||
} else {
|
||||
//选择的是里面
|
||||
//里面 选中的children里面的最后一个
|
||||
newBrother = isEmpty(treeNode.children)
|
||||
? 0
|
||||
: treeNode.children[treeNode.children.length - 1].id;
|
||||
newParent = treeNode.id;
|
||||
}
|
||||
}
|
||||
return {
|
||||
targetId: nativeNode.id,
|
||||
newParent: newParent,
|
||||
newBrother: newBrother,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function treeLink(treeNode: any) {
|
||||
const copyNode = cloneDeep(treeNode)
|
||||
copyNode.children = [];
|
||||
curTreeNode.value = copyNode;
|
||||
// console.log('click', treeNode);
|
||||
// console.log(copyNode.getPath().map((x: any) => x.name));
|
||||
|
||||
curSubjectInfo.value.stage = paramsComp.value?.stage;
|
||||
curSubjectInfo.value.subject = paramsComp.value?.subject;
|
||||
const { stage, subject } = curSubjectInfo.value;
|
||||
emit('on-change', stage, subject, copyNode)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const nodePermissions = computed(() => {
|
||||
if (!curTreeNode.value) return { canCreate: false, canUpdate: false, canMove: false, canDelete: false };
|
||||
const [canCreate, canUpdate, canMove, canDelete] = $utils.unpackPerms(curTreeNode.value.permissions)
|
||||
return {
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canMove,
|
||||
canDelete,
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubjectChange(stage: number, subject: string) {
|
||||
curSubject.stage = stage;
|
||||
curSubject.subject = subject;
|
||||
await nextTick();
|
||||
sendReloadTree();
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.opera-ul {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
padding-bottom: 10px;
|
||||
|
||||
li {
|
||||
padding: 10px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
li.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#modal-sortTreeNode-id {
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
height: 380px;
|
||||
|
||||
.tree-div {
|
||||
z-index: 1001;
|
||||
border: 1px solid #cacaca;
|
||||
border-radius: 5px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.ztree {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="type_set-box">
|
||||
<div class="type_name">
|
||||
<i class="iconfont icon-stop fl mr-10" v-if="!isEdit && !active" style="color: #ff5959"></i>
|
||||
<Input v-if="isEdit" v-model="type.name" ref="type_input" @on-blur="update" @on-enter="update" />
|
||||
<span v-else class="fl">{{ type.name }}</span>
|
||||
</div>
|
||||
<div class="type_action flex">
|
||||
<div class="action_item" @click="open">
|
||||
<span v-if="active">停用</span>
|
||||
<span v-else>启用</span>
|
||||
</div>
|
||||
<div class="action_edit" @click="edit">
|
||||
<span>编辑</span>
|
||||
</div>
|
||||
<div class="action_delete" @click="remove">
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="divider2"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "TypeSetBox",
|
||||
props: {
|
||||
type: {
|
||||
name: { type: String, default: "" },
|
||||
id: { type: String, default: "" },
|
||||
},
|
||||
active: { type: Boolean, default: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isEdit: false,
|
||||
eventType: "",
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
type: {
|
||||
deep: true,
|
||||
handler(newVal) {
|
||||
if (this.isEdit) {
|
||||
this.isEdit = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
"type.name"(newVal) {
|
||||
if (this.isEdit) {
|
||||
this.isEdit = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.$emit("active", this.type.id);
|
||||
},
|
||||
edit() {
|
||||
this.isEdit = !this.isEdit;
|
||||
if (this.isEdit) this.$nextTick(() => this.$refs["type_input"].focus());
|
||||
},
|
||||
update(e) {
|
||||
if (!this.type.name) {
|
||||
this.$Message.error("值不得为空!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.eventType == "") {
|
||||
this.eventType = e.type;
|
||||
this.isEdit = false;
|
||||
this.$emit("edit", this.type.id, this.type.name);
|
||||
setTimeout(() => {
|
||||
this.eventType = "";
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
remove() {
|
||||
this.$emit("remove", this.type.id);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.type_set-box {
|
||||
width: 222px;
|
||||
background: #ffffff;
|
||||
margin-right: 15px;
|
||||
margin-bottom: 15px;
|
||||
.type_name {
|
||||
height: 66px;
|
||||
border: 1px solid #e9e9e9;
|
||||
border-bottom: none;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
line-height: 66px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
span {
|
||||
height: 24px;
|
||||
font-size: 15px;
|
||||
font-family: PingFangSC-Semibold, PingFang SC;
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 222px;
|
||||
display: inline-block;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
.type_action {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 222px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
background: #f7f7f7;
|
||||
border-radius: 0px 0px 2px 2px;
|
||||
border-top: none;
|
||||
border: 1px solid #e9e9e9;
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
div {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
div:hover {
|
||||
color: #6c91f0;
|
||||
}
|
||||
}
|
||||
.divider {
|
||||
// position: relative;
|
||||
height: 14px;
|
||||
width: 1px;
|
||||
background: #e8e8e8;
|
||||
// left: 73px;
|
||||
// top: -23px;
|
||||
float: left;
|
||||
margin-left: 71px;
|
||||
margin-top: -22px;
|
||||
}
|
||||
.divider2 {
|
||||
// position: relative;
|
||||
// left: 146px;
|
||||
// top: -37px;
|
||||
height: 14px;
|
||||
width: 1px;
|
||||
background: #e8e8e8;
|
||||
float: left;
|
||||
margin-left: 147px;
|
||||
margin-top: -22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="improt-wrap">
|
||||
<slot></slot>
|
||||
<div class="upload-panel">
|
||||
<Upload type="drag" :on-success="onSuccess" :on-remove="onRemove" :on-error="onError"
|
||||
:before-upload="handleUploadBefore" :headers="headers" :data="uploadData" :action="uploadUrl"
|
||||
:with-credentials="true" :max-size="maxSize" :multiple="multiple">
|
||||
<div class="upload-box">
|
||||
<Icon type="ios-paper" size="52" style="color: #6281eb"></Icon>
|
||||
<h4>点击或将文件拖拽到这里上传</h4>
|
||||
<div class="info">文件大小不超过{{ $utils.getFileSize(maxSize) }}</div>
|
||||
</div>
|
||||
<Alert v-if="!!errorMsg" type="error" show-icon><span v-html="errorMsg"></span></Alert>
|
||||
</Upload>
|
||||
</div>
|
||||
<!-- <slot name="footer"></slot> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
model: {
|
||||
event: "update:fileList",
|
||||
prop: "fileList",
|
||||
},
|
||||
props: {
|
||||
fileList: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
uploadUrl: String,
|
||||
uploadData: Object,
|
||||
headers: Object,
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
maxSize: 200 * 1024 * 1024,
|
||||
errorMsg: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleUploadBefore(file) {
|
||||
var _this = this;
|
||||
if (file.size > _this.maxSize) {
|
||||
_this.$Message.error({
|
||||
content: "请上传小于" + _this.$utils.getFileSize(_this.maxSize) + "的文件!",
|
||||
duration: 3,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess(res, file) {
|
||||
if (res.code == 0) {
|
||||
this.errorMsg = null;
|
||||
const { uid } = file;
|
||||
this.fileList.push({ ...res.data, uid });
|
||||
this.$emit("update:fileList", this.fileList);
|
||||
} else {
|
||||
let msg = res.data.error;
|
||||
this.errorMsg = msg;
|
||||
}
|
||||
},
|
||||
onRemove(file, fileList) {
|
||||
const { uid } = file;
|
||||
const findIndex = this.fileList.findIndex(item => item.uid === uid);
|
||||
this.fileList.splice(findIndex, 1);
|
||||
this.$emit("update:fileList", this.fileList);
|
||||
console.log(file, fileList);
|
||||
},
|
||||
onError(err, file) {
|
||||
this.errorMsg = JSON.stringify(err);
|
||||
console.log(err, file);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.improt-wrap {
|
||||
padding: 0 12px;
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
.upload-box {
|
||||
background: #f7f8fc;
|
||||
padding: 30px 0;
|
||||
|
||||
h4 {
|
||||
color: #505c7e;
|
||||
margin: 3px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div>
|
||||
<ul class="public-upload-img fix">
|
||||
<li v-for="(item, index) in uploadList">
|
||||
<template v-if="item.status === 'finished'">
|
||||
<img :src="item.url" alt="" />
|
||||
<div class="upload-list-cover">
|
||||
<Icon
|
||||
type="ios-eye-outline"
|
||||
size="24"
|
||||
title="预览图片"
|
||||
@click.native="handleView(item.url)"
|
||||
></Icon>
|
||||
<Icon
|
||||
size="24"
|
||||
class="ml-10"
|
||||
title="删除图片"
|
||||
type="ios-trash-outline"
|
||||
@click.native="handleRemove(index)"
|
||||
></Icon>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Progress v-if="item.showProgress" :percent="item.percentage" class="mt-24" hide-info></Progress>
|
||||
</template>
|
||||
</li>
|
||||
<li class="add-li" v-show="uploadList.length < uploadCount">
|
||||
<Upload
|
||||
ref="upload"
|
||||
:show-upload-list="false"
|
||||
:on-success="handleSuccess"
|
||||
:format="['jpg', 'jpeg', 'png', 'gif']"
|
||||
accept="image/gif,image/jpeg,image/jpg,image/png"
|
||||
:max-size="20480"
|
||||
:on-format-error="handleFormatError"
|
||||
:on-exceeded-size="handleMaxSize"
|
||||
:default-file-list="defaultList"
|
||||
:headers="uploadHeader"
|
||||
type="drag"
|
||||
:action="uploadUrl"
|
||||
:data="data"
|
||||
>
|
||||
<div class="add-box">
|
||||
<Icon type="ios-add" size="50"></Icon>
|
||||
</div>
|
||||
</Upload>
|
||||
</li>
|
||||
</ul>
|
||||
<Modal title="图片预览" v-model="visible" footer-hide>
|
||||
<img :src="imgName" v-if="visible" style="width: 100%" />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
imgList: { type: Array, default: [] },
|
||||
uploadUrl: { type: String, default: "" },
|
||||
uploadCount: { type: Number, default: 1 },
|
||||
data: { type: Object, default: null },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imgName: "",
|
||||
visible: false,
|
||||
uploadList: [],
|
||||
defaultList: [],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.setDefaultList();
|
||||
this.$watch("imgList", val => {
|
||||
this.setDefaultList();
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
console.log(this.$refs.upload.fileList);
|
||||
this.uploadList = this.$refs.upload.fileList;
|
||||
},
|
||||
computed: {
|
||||
uploadHeader() {
|
||||
const bschUtil = BschLoadUtil.getInstance();
|
||||
if (!bschUtil.getIsSelf()) {
|
||||
return {
|
||||
ticket: sessionStorage.getItem("bschTicket"),
|
||||
platform: "grzx",
|
||||
};
|
||||
} else return null;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleView(name) {
|
||||
this.imgName = name;
|
||||
this.visible = true;
|
||||
},
|
||||
handleRemove(index) {
|
||||
this.$refs.upload.fileList.splice(index, 1);
|
||||
this.imgList.splice(index, 1);
|
||||
},
|
||||
handleSuccess(res, file) {
|
||||
console.log(res, file);
|
||||
file.url = res.data.fileurl;
|
||||
this.imgList.push(res.data.fileurl);
|
||||
},
|
||||
handleFormatError(file) {
|
||||
this.$Homework.info({
|
||||
title: "不支持的文件类型!",
|
||||
desc: "请上传指定的文件类型",
|
||||
});
|
||||
},
|
||||
handleMaxSize(file) {
|
||||
this.$Homework.warning({
|
||||
title: "图片过大",
|
||||
desc: "图片最大上传大小为20M.",
|
||||
});
|
||||
},
|
||||
setDefaultList() {
|
||||
let arr = [];
|
||||
this.imgList.forEach(element => {
|
||||
arr.push({
|
||||
name: "",
|
||||
url: element,
|
||||
});
|
||||
});
|
||||
this.defaultList = arr;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<!-- 作业对象选择:全部用户 / 指定用户(自旧前端 user_check_input.vue 适配) -->
|
||||
<div class="check-user-wrap">
|
||||
<div class="check-user-box">
|
||||
<RadioGroup v-model="isCheckUserAll" class="radio" @on-change="userAllCheck">
|
||||
<Radio :disabled="editDisabled" :label="0">{{ isEdit ? "现有" : "全部" }}用户</Radio>
|
||||
<Radio :label="1">指定用户</Radio>
|
||||
</RadioGroup>
|
||||
<Input
|
||||
type="text"
|
||||
v-model="userNameStr"
|
||||
readonly
|
||||
:disabled="!isCheckUserAll"
|
||||
placeholder="选择指定的用户"
|
||||
class="input"
|
||||
></Input>
|
||||
<Button icon="md-add" @click="checkUserShow" :disabled="!isCheckUserAll"></Button>
|
||||
<Button icon="md-trash" @click="resetUser" :disabled="!isCheckUserAll"></Button>
|
||||
</div>
|
||||
<UserCheckModal
|
||||
ref="userCheckModal"
|
||||
:sources="sources"
|
||||
:userids="tempUserIds"
|
||||
@on-submit="submitUser"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
/*
|
||||
* 作业对象选择输入(自旧前端 oldfront/src/components/user_check_input.vue 适配 Vue3)。
|
||||
* v-model 值为 "all" 或用户 id 逗号串;切换到「指定用户」直接弹出选择用户弹窗。
|
||||
*/
|
||||
import UserCheckModal from "@/components/UserCheckModal.vue";
|
||||
import { baseApi, settingApi } from "@/api/module/homework";
|
||||
|
||||
export default {
|
||||
name: "UserCheckInput",
|
||||
components: { UserCheckModal },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "all",
|
||||
},
|
||||
/** 编辑态:全部用户选项禁用为「现有用户」 */
|
||||
isEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
return {
|
||||
isCheckUserAll: 0 as number,
|
||||
tempUserIds: [] as any[],
|
||||
userNameStr: "",
|
||||
editDisabled: false,
|
||||
sources: [
|
||||
{
|
||||
name: "基础平台",
|
||||
loader: () => baseApi.getSchoolAll().send() as Promise<any[]>,
|
||||
},
|
||||
{
|
||||
name: "自定义组",
|
||||
loader: () => settingApi.getGroupTree().send() as Promise<any[]>,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
if (this.modelValue === "all") {
|
||||
this.isCheckUserAll = 0;
|
||||
this.userNameStr = this.isEdit ? "现有用户" : "全部用户";
|
||||
} else {
|
||||
this.isCheckUserAll = 1;
|
||||
this.tempUserIds = this.modelValue.split(",").map((x) => parseInt(x)).filter((x) => !isNaN(x));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkUserShow() {
|
||||
(this.$refs.userCheckModal as any).show();
|
||||
},
|
||||
resetUser() {
|
||||
//清除用户
|
||||
this.tempUserIds = [];
|
||||
this.userNameStr = "";
|
||||
this.$emit("update:modelValue", "");
|
||||
},
|
||||
userAllCheck(type: number) {
|
||||
let tempIds: any = String(this.modelValue || "")
|
||||
.split(",")
|
||||
.map((x) => parseInt(x))
|
||||
.filter((x) => !isNaN(x));
|
||||
if (type == 0) {
|
||||
if (this.isEdit) {
|
||||
this.tempUserIds = tempIds;
|
||||
this.userNameStr = "现有用户";
|
||||
this.$emit("update:modelValue", String(tempIds));
|
||||
} else {
|
||||
this.tempUserIds = [];
|
||||
this.userNameStr = "全部用户";
|
||||
this.$emit("update:modelValue", "all");
|
||||
}
|
||||
} else {
|
||||
if (this.isEdit) {
|
||||
this.editDisabled = true;
|
||||
}
|
||||
this.tempUserIds = tempIds;
|
||||
this.userNameStr = "";
|
||||
this.checkUserShow();
|
||||
}
|
||||
},
|
||||
submitUser(arr: any[]) {
|
||||
//确认用户
|
||||
this.userNameStr = arr.map((x) => x.name).join("、");
|
||||
const ids = arr.map((x) => x.id).join(",");
|
||||
this.$emit("update:modelValue", ids || "all");
|
||||
if (!arr.length) {
|
||||
// 清空后回到全部用户
|
||||
this.isCheckUserAll = 0;
|
||||
this.userNameStr = "全部用户";
|
||||
this.$emit("update:modelValue", "all");
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue(val: string) {
|
||||
if (val === "all") {
|
||||
this.isCheckUserAll = 0;
|
||||
this.userNameStr = this.isEdit ? "现有用户" : "全部用户";
|
||||
} else if (val === "") {
|
||||
this.userNameStr = "";
|
||||
} else {
|
||||
// 编辑回显:指定用户 id 串
|
||||
this.isCheckUserAll = 1;
|
||||
this.tempUserIds = val
|
||||
.split(",")
|
||||
.map((x) => parseInt(x))
|
||||
.filter((x) => !isNaN(x));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</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>
|
||||
@@ -0,0 +1,446 @@
|
||||
<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>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeMount, watch, computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
|
||||
const props = defineProps<{ layoutData: Record<string, any> }>();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isPanel = ref(false);
|
||||
const currPath = ref<string>("");
|
||||
const navPath = ref<any[]>([]);
|
||||
|
||||
const baseHost = computed(() => props.layoutData?.baseHost || "");
|
||||
const ssoHost = computed(() => props.layoutData?.ssoHost || "");
|
||||
const appName = computed(() => props.layoutData?.appName || "课程作业");
|
||||
const appList = computed(() => props.layoutData?.appList || []);
|
||||
const userInfo = computed(() => props.layoutData?.userInfo || { name: "管理员", avatar: "" });
|
||||
const isAlone = computed(() => props.layoutData?.isAlone ?? false);
|
||||
const modList = computed(() => props.layoutData?.modList || []);
|
||||
|
||||
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
|
||||
|
||||
onBeforeMount(() => {
|
||||
routeChange(route);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => routeChange(route)
|
||||
);
|
||||
|
||||
const flaten = (tree: any[], pid = 0): any[] => {
|
||||
const arr: any[] = [];
|
||||
tree.forEach((element: any) => {
|
||||
const { id, name, modId } = element;
|
||||
// 平台配置的 path 格式不可控,统一解析为内部路由 path(名称/modId 兜底)
|
||||
const resolved = resolveMenuPage(element.page, element.modId ?? element.id, element.name);
|
||||
arr.push({ id, name, modId, page: resolved.path, external: resolved.external, pid });
|
||||
if (element.children && element.children.length) {
|
||||
arr.push(...flaten(element.children, id));
|
||||
}
|
||||
});
|
||||
return arr;
|
||||
};
|
||||
|
||||
const menuArr = computed(() => flaten(modList.value));
|
||||
|
||||
const routeChange = (to: any) => {
|
||||
const meta: any = to.meta || {};
|
||||
const { modId, title, parentId } = meta;
|
||||
const navArr: any[] = [];
|
||||
|
||||
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
|
||||
// 匹配不到再退回 modId 对照
|
||||
const find: any =
|
||||
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
|
||||
menuArr.value.find((x: any) => x.modId === modId);
|
||||
if (find) {
|
||||
currPath.value = find.page || "";
|
||||
if (find.pid) {
|
||||
const pfind = menuArr.value.find((x: any) => x.id === find.pid);
|
||||
if (pfind) navArr.push({ name: pfind.name });
|
||||
}
|
||||
navArr.push({ name: find.name || title, page: find.page });
|
||||
navPath.value = navArr;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const parent = menuArr.value.find((x: any) => x.modId === parentId);
|
||||
if (parent) {
|
||||
currPath.value = parent.page || "";
|
||||
if (parent.pid) {
|
||||
const gp = menuArr.value.find((x: any) => x.id === parent.pid);
|
||||
if (gp) navArr.push({ name: gp.name });
|
||||
}
|
||||
navArr.push({ name: parent.name, page: parent.page });
|
||||
navArr.push({ name: title });
|
||||
navPath.value = navArr;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
|
||||
if (r) {
|
||||
currPath.value = r.path;
|
||||
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
|
||||
return;
|
||||
}
|
||||
|
||||
currPath.value = "";
|
||||
navPath.value = [];
|
||||
};
|
||||
|
||||
const linkMenu = (item: any) => {
|
||||
const page: string = item?.page || "";
|
||||
if (!page) {
|
||||
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
|
||||
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
|
||||
return;
|
||||
}
|
||||
// 平台配置的外部链接模块:整页跳转
|
||||
if (item?.external) {
|
||||
window.location.href = page;
|
||||
return;
|
||||
}
|
||||
if (page === route.path) {
|
||||
router.go(0);
|
||||
} else {
|
||||
router.push(page);
|
||||
}
|
||||
};
|
||||
|
||||
const linkUserInfo = () => {
|
||||
window.location.href = baseHost.value + "/v/userinfo";
|
||||
};
|
||||
const logout = () => {
|
||||
let url = window.location.href;
|
||||
url = "/logout?service=" + url;
|
||||
window.location.href = ssoHost.value + url;
|
||||
};
|
||||
const childChange = (index: number) => {
|
||||
const arr = modList.value || [];
|
||||
if (arr[index]) {
|
||||
arr[index].childState = !arr[index].childState;
|
||||
}
|
||||
};
|
||||
|
||||
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
|
||||
* 兜底保留旧版 modId 对照。图标为本地 bsch-icons(iconfont 掩码类,见 styles/icons.css) */
|
||||
const getMenuIcon = (item?: any): string => {
|
||||
const page: string = item?.page || "";
|
||||
if (page === "/list/new") return "icon-xiugaijl";
|
||||
if (page === "/list") return "icon-fasong";
|
||||
if (page === "/" || page === "/accept") return "icon-sjx";
|
||||
if (page === "/homeworklist") return "icon-liebiao";
|
||||
if (page === "/approval") return "icon-shenpi";
|
||||
if (page === "/approval-setting") return "icon-quanxian";
|
||||
if (page === "/stats") return "icon-tongji";
|
||||
if (page === "/setting") return "icon-shezhi";
|
||||
if (page === "/stu/today" || page === "/par/kids") return "icon-sjx";
|
||||
if (page === "/stu/redo") return "icon-xiugaijl";
|
||||
switch (item?.modId ?? item?.id) {
|
||||
case 1: return "icon-xiugaijl";
|
||||
case 2: return "icon-fasong";
|
||||
case 3: return "icon-sjx";
|
||||
case 4: return "icon-liebiao";
|
||||
case 5: return "icon-shenpi";
|
||||
case 6: return "icon-quanxian";
|
||||
case 21: return "icon-shezhi";
|
||||
case 22: return "icon-xiugaijl";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
function linkHomePage() {
|
||||
if (baseHost.value) {
|
||||
window.location.href = baseHost.value + "/BschHome/front/index.html";
|
||||
} else {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-wrap">
|
||||
<header class="header-container fix">
|
||||
<div class="fl align-middle icon-box">
|
||||
<div title="常用应用" :class="['icon-btn', isPanel ? 'active' : '']" @click="isPanel = !isPanel">
|
||||
<i class="iconfont icon-apps"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||
<div class="school-bg"></div>
|
||||
<div class="header-aside fr">
|
||||
<img :src="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
|
||||
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
||||
{{ userInfo.name }}
|
||||
</a>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<a href="javascript:;" @click="logout" class="logout fl align-middle" title="退出登陆">
|
||||
<i class="iconfont icon-logout"></i>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="left-menu" v-if="!isAlone">
|
||||
<div class="menu-top-box"></div>
|
||||
<ul>
|
||||
<template v-for="(item, index) in modList" :key="'menu_li_' + index">
|
||||
<template v-if="item.children && item.children.length > 0">
|
||||
<li>
|
||||
<a href="javascript:;" @click="childChange(index)">
|
||||
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
|
||||
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
<transition name="fade_child_ul">
|
||||
<div class="child-item-wrap" v-show="item.childState">
|
||||
<li
|
||||
:class="citem.page == currPath ? 'active child-item' : 'child-item'"
|
||||
v-for="citem in item.children"
|
||||
:key="citem.id"
|
||||
>
|
||||
<a href="javascript:;" @click="linkMenu(citem)">
|
||||
{{ citem.name }}
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
<li v-else :class="item.page == currPath ? 'active' : ''">
|
||||
<a href="javascript:;" @click="linkMenu(item)">
|
||||
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="clear-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="path-nav" :style="pathNavStyle">
|
||||
<Breadcrumb separator=">">
|
||||
<a href="javascript:;" @click="linkHomePage">
|
||||
<BreadcrumbItem>
|
||||
<i class="iconfont icon-category"></i>
|
||||
首页
|
||||
</BreadcrumbItem>
|
||||
</a>
|
||||
<BreadcrumbItem v-if="!isAlone" to="/">{{ appName }}</BreadcrumbItem>
|
||||
<template v-for="(item, index) in navPath" :key="index">
|
||||
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
|
||||
{{ item.name }}
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem v-else>{{ item.name }}</BreadcrumbItem>
|
||||
</template>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<transition name="fade_apps">
|
||||
<div class="app-panel" v-if="isPanel">
|
||||
<div class="tips-box"></div>
|
||||
<ul class="fix">
|
||||
<li class="fl" v-for="(item, index) in appList" :key="index">
|
||||
<a :href="item.url" target="_blank" rel="noopener noreferrer">
|
||||
<div class="icon-box">
|
||||
<img :src="item.icon" alt="" />
|
||||
</div>
|
||||
<p class="text-ellipsis">{{ item.name }}</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="link-panel">
|
||||
<a :href="baseHost + '/BschHome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
|
||||
全部应用
|
||||
<i>→</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 仅覆盖/补充必要的动态样式,其余样式由全局 layout.css 提供 */
|
||||
/* 菜单图标(本地 bsch-icons 掩码类)与文字间距 */
|
||||
.left-menu a .iconfont {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div :id="domId" :ref="domId" class="ztree"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: {
|
||||
treeData: {
|
||||
type: Object
|
||||
},
|
||||
setting: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
view: {
|
||||
dblClickExpand: false,
|
||||
showIcon: false
|
||||
},
|
||||
check: {
|
||||
enable: false
|
||||
},
|
||||
callback: {}
|
||||
};
|
||||
}
|
||||
},
|
||||
autoClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
domId: ""
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.$watch("treeData", val => {
|
||||
if (val) {
|
||||
this.$nextTick(() => {
|
||||
this.initTree(this.treeData, this.domId);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
this.domId = this.uuid();
|
||||
},
|
||||
methods: {
|
||||
uuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
var r = (Math.random() * 16) | 0,
|
||||
v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
},
|
||||
reloadTree(nodeId) {
|
||||
this.initTree(this.treeData, this.domId);
|
||||
if (nodeId != null) {
|
||||
this.$nextTick(() => {
|
||||
this.selectNode(nodeId, true); // 第二个参数 true 会 emit on-tree-click
|
||||
});
|
||||
}
|
||||
},
|
||||
initTree(_data, _domId) {
|
||||
const _this = this;
|
||||
_this.setting.callback.onClick = (event, treeId, treeNode, clickFlag) => {
|
||||
// console.log(event, treeId, treeNode, clickFlag);
|
||||
_this.$emit("on-tree-click", treeNode);
|
||||
};
|
||||
_this.setting.callback.onExpand = (event, treeId, treeNode) => {
|
||||
if (treeNode.isParent && !treeNode.children) {
|
||||
_this.$emit("on-tree-expand", treeNode);//触发展开事件
|
||||
}
|
||||
};
|
||||
|
||||
_this.setting.callback.beforeClick = (treeId, treeNode, clickFlag) => {
|
||||
return treeNode.click != false;
|
||||
};
|
||||
var zNodes = _this.openFistNode(_data);
|
||||
$.fn.zTree.init($("#" + _domId), _this.setting, zNodes);
|
||||
|
||||
if (_this.autoClick) {
|
||||
if (Array.isArray(this.treeData)) {
|
||||
let id = this.treeData[0].id;
|
||||
_this.selectNode(id);
|
||||
} else {
|
||||
let id = this.treeData.id;
|
||||
_this.selectNode(id);
|
||||
}
|
||||
}
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
setTimeout(() => {
|
||||
this.selectNode(0);
|
||||
}, 0);
|
||||
},
|
||||
selectNode(id, isClick = true) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
if (node == null) return false;
|
||||
treeObj.selectNode(node);
|
||||
treeObj.expandNode(node, true, false, false);
|
||||
treeObj.selectNode(node);
|
||||
if (isClick) this.$emit("on-tree-click", node);
|
||||
},
|
||||
|
||||
openFistNode(_zNodes) {
|
||||
//展开一级节点
|
||||
if (_zNodes && _zNodes.length > 0) {
|
||||
for (var key in _zNodes) {
|
||||
if (_zNodes.hasOwnProperty(key)) {
|
||||
var element = _zNodes[key];
|
||||
element.open = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_zNodes.open = true;
|
||||
}
|
||||
return _zNodes;
|
||||
},
|
||||
selectNode(id, isClick = true) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
if (node == null) return false;
|
||||
treeObj.selectNode(node);
|
||||
treeObj.expandNode(node, true, false, false);
|
||||
treeObj.selectNode(node);
|
||||
if (isClick) this.$emit("on-tree-click", node);
|
||||
},
|
||||
addNode(pid, id, name, isParent) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var pnode = treeObj.getNodesByFilter(node => node.id == pid, true);
|
||||
var newNode = { pid, id, name, isParent };
|
||||
treeObj.addNodes(pnode, -1, newNode);
|
||||
},
|
||||
addNodes(treeNode, children) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
const parentZNode = treeObj.getNodeByParam("id", treeNode.id, null);
|
||||
treeObj.addNodes(parentZNode, children, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
Reference in New Issue
Block a user