feat: 提交流程中心前端代码
build / build (push) Successful in 3m21s

Vue3 + Vite + view-ui-plus 前端源码,含流程工作台、实例详情、
任务处理、管理端(定义/实例/映射/回调)等页面。
保留远程 .gitea/workflows/build.yml CI 配置。
This commit is contained in:
chenxing
2026-09-15 10:42:45 +08:00
parent ca30ff12cc
commit 90db00cf16
328 changed files with 25868 additions and 2 deletions
+95
View File
@@ -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>
+37
View File
@@ -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>
+208
View File
@@ -0,0 +1,208 @@
<script setup lang="ts">
/**
* 动态表单渲染器(V2.0 Part B.3:schema → 控件,一期 7 类)。
* schemaJson = {"groups":[{title, fields:[{key,label,type,required,pattern,options,...}]}]}。
* v-model(formData) 为 {key:value} 对象;upload 字段值为 [{attachmentId,fileUrl,fileName}]。
*/
import { computed } from "vue";
import { Message } from "view-ui-plus";
import { fileApi } from "@/api/module/svc";
const props = defineProps<{ schemaJson?: string; formData: Record<string, any> }>();
const emit = defineEmits<{ (e: "update:formData", v: Record<string, any>): void }>();
interface Field {
key: string;
label: string;
type: string;
required?: boolean;
pattern?: string;
min?: number;
max?: number;
maxLength?: number;
tip?: string;
placeholder?: string;
options?: string[];
rows?: number;
defaultValue?: any;
accept?: string;
maxSize?: number;
maxFiles?: number;
}
const groups = computed<{ title: string; fields: Field[] }[]>(() => {
try {
const parsed = JSON.parse(props.schemaJson || "{}");
return (parsed.groups || []).map((g: any) => ({ title: g.title || "", fields: g.fields || [] }));
} catch {
return [];
}
});
function setValue(field: Field, value: any) {
emit("update:formData", { ...props.formData, [field.key]: value });
}
function ensureArray(field: Field): any[] {
const v = props.formData[field.key];
return Array.isArray(v) ? v : [];
}
/** upload 字段上传(maxSize MB/maxFiles 上限;返回 {attachmentId,fileUrl,fileName}) */
async function uploadFile(field: Field, ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
if (field.maxSize && file.size > field.maxSize * 1024 * 1024) {
Message.error(`单个文件不得超过 ${field.maxSize}MB`);
return;
}
const list = ensureArray(field);
if (field.maxFiles && list.length >= field.maxFiles) {
Message.error(`附件数超过上限 ${field.maxFiles}`);
return;
}
try {
const res: any = await fileApi.upload(file, "file").send();
setValue(field, [...list, res]);
} catch (e: any) {
Message.error(e?.message || "上传失败");
}
}
</script>
<template>
<div class="dyn-form">
<div v-for="(group, gi) in groups" :key="gi" class="dyn-group">
<div v-if="group.title" class="dyn-group-title">{{ group.title }}</div>
<div v-for="field in group.fields" :key="field.key" class="dyn-field">
<label class="dyn-label">
<span v-if="field.required" class="dyn-required">*</span>
{{ field.label }}
</label>
<template v-if="field.type === 'text' || field.type === 'phone'">
<Input
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
:placeholder="field.placeholder || ''"
:maxlength="field.maxLength"
@update:model-value="(v: string) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'textarea'">
<Input
type="textarea"
:rows="field.rows || 3"
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
:placeholder="field.placeholder || ''"
:maxlength="field.maxLength"
@update:model-value="(v: string) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'number'">
<InputNumber
:model-value="formData[field.key] ?? field.defaultValue ?? null"
:min="field.min"
:max="field.max"
:placeholder="field.placeholder || ''"
style="width: 220px"
@update:model-value="(v: number) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'date'">
<DatePicker
type="date"
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
placeholder="选择日期"
style="width: 220px"
@update:model-value="(v: string) => setValue(field, v)"
/>
</template>
<template v-else-if="field.type === 'select'">
<Select
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
:placeholder="field.placeholder || '请选择'"
style="width: 260px"
@update:model-value="(v: string) => setValue(field, v)"
>
<Option v-for="opt in field.options || []" :key="opt" :value="opt">{{ opt }}</Option>
</Select>
</template>
<template v-else-if="field.type === 'radio'">
<RadioGroup
:model-value="formData[field.key] ?? field.defaultValue ?? ''"
@update:model-value="(v: string) => setValue(field, v)"
>
<Radio v-for="opt in field.options || []" :key="opt" :label="opt">{{ opt }}</Radio>
</RadioGroup>
</template>
<template v-else-if="field.type === 'upload' || field.type === 'image'">
<div class="dyn-upload">
<div v-for="(f, fi) in ensureArray(field)" :key="fi" class="dyn-file">
<a :href="f.fileUrl" target="_blank" rel="noopener">{{ f.fileName }}</a>
<a class="dyn-file-remove" @click.prevent="
setValue(
field,
ensureArray(field).filter((_, i) => i !== fi)
)
">移除</a>
</div>
<label class="dyn-file-btn">
<input type="file" style="display: none" :accept="field.accept || ''" @change="uploadFile(field, $event)" />
<Button size="small" icon="ios-cloud-upload-outline">上传附件</Button>
</label>
</div>
</template>
<div v-if="field.tip" class="dyn-tip">{{ field.tip }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.dyn-group {
margin-bottom: 8px;
}
.dyn-group-title {
font-weight: 600;
padding: 8px 0;
border-bottom: 1px solid #eee;
margin-bottom: 12px;
}
.dyn-field {
margin-bottom: 14px;
}
.dyn-label {
display: block;
margin-bottom: 6px;
color: #515a6e;
}
.dyn-required {
color: #ed4014;
margin-right: 2px;
}
.dyn-tip {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.dyn-file {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 4px;
}
.dyn-file-remove {
color: #ed4014;
cursor: pointer;
font-size: 12px;
}
</style>
+138
View File
@@ -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>
+89
View File
@@ -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>
+264
View File
@@ -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>
+76
View File
@@ -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>
+42
View File
@@ -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);
}
});
};
+206
View File
@@ -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>
+234
View File
@@ -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>
+116
View File
@@ -0,0 +1,116 @@
<template>
<div class="notice-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, Notice } from "view-ui-plus";
import config from "@/assets/config/host.config";
export default {
name: "NoticeUpload",
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() {
Notice.info({
title: "不支持的上传类型!",
desc: "请上传指定的文件类型",
});
},
handleExceeded() {
Notice.warning({
title: "超出大小限制",
desc: "最大文件大小为" + this.maxFileSize + "!",
});
},
},
};
</script>
<style lang="scss" scoped>
.notice-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>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
/**
* 富文本编辑器(wangEditor 5 封装;图片走 /api/file/upload type=image)。
* v-model 为 HTML 字符串;图片上传返回完整 URL 直接内嵌(Notice 上传通道契约)。
*/
import { onBeforeUnmount, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import "@wangeditor/editor/dist/css/style.css";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import { fileApi } from "@/api/module/svc";
import { useSite } from "@/utils/site";
const props = defineProps<{ modelValue?: string; placeholder?: string }>();
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
const { currentSiteCode } = useSite();
const editorRef = ref();
const mode = ref<"simple" | "default">("default");
const html = ref(props.modelValue || "");
watch(
() => props.modelValue,
v => {
if (v !== html.value) html.value = v || "";
}
);
watch(html, v => emit("update:modelValue", v));
const toolbarConfig = { excludeKeys: ["group-video"] };
const editorConfig = {
placeholder: props.placeholder || "",
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string) => void) {
try {
const res: any = await fileApi.upload(file, "image", currentSiteCode.value).send();
if (res?.fileUrl) {
insertFn(res.fileUrl);
}
} catch (e: any) {
Message.error(e?.message || "图片上传失败");
}
},
},
},
};
onBeforeUnmount(() => {
editorRef.value?.destroy?.();
});
</script>
<template>
<div class="rich-editor">
<Toolbar class="rich-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" :mode="mode" />
<Editor
class="rich-content"
v-model="html"
:defaultConfig="editorConfig"
:mode="mode"
@onCreated="(ed: any) => (editorRef = ed)"
/>
</div>
</template>
<style scoped>
.rich-editor {
border: 1px solid #dcdee2;
border-radius: 4px;
z-index: 100;
background: #fff;
}
.rich-toolbar {
border-bottom: 1px solid #e8eaec;
}
.rich-content {
height: 260px;
overflow-y: hidden;
}
</style>
+43
View File
@@ -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>
+142
View File
@@ -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>
+119
View File
@@ -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>
+105
View File
@@ -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>
+238
View File
@@ -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>
+487
View File
@@ -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>
+469
View File
@@ -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>
+169
View File
@@ -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>
+106
View File
@@ -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>
+132
View File
@@ -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.$Notice.info({
title: "不支持的文件类型!",
desc: "请上传指定的文件类型",
});
},
handleMaxSize(file) {
this.$Notice.warning({
title: "图片过大",
desc: "图片最大上传大小为20M.",
});
},
setDefaultList() {
let arr = [];
this.imgList.forEach(element => {
arr.push({
name: "",
url: element,
});
});
this.defaultList = arr;
},
},
};
</script>
<style></style>
+165
View File
@@ -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/notice";
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>
+446
View File
@@ -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>
+284
View File
@@ -0,0 +1,284 @@
<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 === "/noticelist") return "icon-liebiao";
if (page === "/stats") return "icon-tongji";
if (page === "/setting") return "icon-shezhi";
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-wenzhang";
case 21: return "icon-shezhi";
case 22: return "icon-xiugaijl";
default: return "";
}
};
function linkHomePage() {
if (baseHost.value) {
// 旧 BschHome 已下线(线上 404),首页统一指向用户中心 userhome
window.location.href = baseHost.value + "/userhome/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" @click="linkHomePage">
<i class="iconfont icon-shouye"></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>
<span class="crumb-home">
<i class="iconfont icon-category"></i>
首页
</span>
</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 + '/userhome/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;
}
/* 面包屑「首页」垂直居中:layout.css 遗留的 .path-nav .icon-category{position:relative;top:1px}
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
这里用 inline-flex 真居中并关掉该补偿(不动压缩产物 layout.css,覆盖收在本组件内) */
.crumb-home {
display: inline-flex;
align-items: center;
gap: 4px;
}
.crumb-home .iconfont {
position: static;
top: 0;
}
</style>
+149
View File
@@ -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>