import { ssIcon, commonIcon } from "./icon-config.js"; import * as IndexComponents from "./ss-index-components.js"; import * as EchartComponents from "./ss-echarts-compnents.js"; import { isNum, toStyleStr } from "./tools.js"; import { EVEN_VAR } from "./EventBus.js"; // import * as elements from "../lib/element-plus.js"; (function () { const { createApp, ref, reactive, watch, onMounted, onBeforeUnmount, h, computed, resolveComponent, watchEffect, nextTick, onVnodeMounted, Teleport, inject, provide, } = Vue; // 弹窗默认遮罩z-index let currentZIndex = 100; // 目前已存在的弹窗 const topWindow = window.top; topWindow.dialogInstances = topWindow.dialogInstances || []; // 新建弹窗 function createSsDialogInstance(setting, callbackEvent) { currentZIndex += 10; // 动态提升 z-index const container = document.createElement("div"); document.body.appendChild(container); const app = Vue.createApp({ render() { return h(SsDialog, { ...setting, zIndex: currentZIndex, onClose() { document.body.removeChild(container); // 仅移除弹窗容器 const index = topWindow.dialogInstances.indexOf(app); if (index > -1) { topWindow.dialogInstances.splice(index, 1); // 移除实例 } // 关闭后的回调 if (callbackEvent && typeof callbackEvent === "function") { callbackEvent(); } app.unmount(); // 仅卸载弹窗实例 if (container.parentNode) { container.parentNode.removeChild(container); // 确保移除容器 } }, }); }, }); topWindow.dialogInstances.push({ app, callbackEvent, container }); app.component("ss-mark", SsMark); // 注册 ss-mark 组件 app.component("ss-icon", SsIcon); app.component("ss-full-style-header", SsFullStyleHeader); // 注册 ss-full-style-header 组件 app.mount(container); } // ss-breadcrumb 一级菜单页面面包屑 const SsBreadcrumb = { name: "SsBreadcrumb", props: { level: { type: Object, default: null, }, }, setup(props) { const currentMenu = ref(null); const folderPath = ref([]); const eventBus = window.parent.sharedEventBus; // 监听页面变化 onMounted(() => { // 获取初始页面 currentMenu.value = eventBus.getState(EVEN_VAR.currentPage); folderPath.value = eventBus.getState("folderPath") || []; // 订阅页面变化 eventBus.subscribe(EVEN_VAR.currentPage, (page) => { currentMenu.value = page; }); eventBus.subscribe("folderPath", (path) => { folderPath.value = path || []; }); }); // 修改点击处理函数 const handlePathClick = (index) => { if (props.level?.onBack) { // 截取到点击的位置,后面的路径会被销毁 const newPath = folderPath.value.slice(0, index + 1); eventBus.publish("folderPath", newPath); // 返回到对应层级 const targetFolder = newPath[newPath.length - 1]?.folder || null; props.level.onBack(targetFolder); } }; const SsCommonIcon = resolveComponent("SsCommonIcon"); return () => h("div", { class: "bread-crumb" }, [ currentMenu.value && h( "div", { onClick: () => { if (props.level?.onBack) { eventBus.publish("folderPath", []); props.level.onBack(null); // 返回到根目录 } else { eventBus.publish(EVEN_VAR.currentPage, currentMenu.value); } }, }, currentMenu.value.label || currentMenu.value.name ), ...(folderPath.value || []) .map((folder, index) => [ h(SsCommonIcon, { class: "common-icon-arrow-right" }), h( "div", { class: "bread-crumb-item", onClick: () => handlePathClick(index), style: { cursor: "pointer" }, }, folder.title ), ]) .flat(), ]); }, }; // ss-input form表单的输入 const SsInput = { name: "SsInp",//把SsInput改为SsInp Ben(20251225) inheritAttrs: false, // 不直接继承属性到组件根元素 props: { name: { type: String, required: true, default: "", }, // 接收 v-model 绑定的值 errTip: { type: String, }, required: { type: Boolean, default: false, }, placeholder: { type: String, default: "请输入", }, defaultValue: [String, Number], modelValue: [String, Number], // 新增:附件配置 fj: { type: Object, default: null, }, // 新增:param 配置(用于附件功能) param: { type: Object, default: null, }, // 新增:高度配置 height: { type: String, default: "", }, // 新增:是否允许回车换行,默认false禁止换行 by xu 20251212 multiline: { type: Boolean, default: false, }, }, emits: ["update:modelValue", "input", "blur", "change"], // 允许更新 v-model 绑定的值 setup(props, { emit }) { const errMsg = ref(""); const inputRef = ref(null); const textareaRef = ref(null); const inputValue = ref(props.modelValue || props.defaultValue || ""); const contentFloatingDiv = ref(false); // 控制浮动 DIV 的显示 const floatingDivPosition = ref("bottom"); // 'bottom' 或 'top' const isFocused = ref(false); // 跟踪焦点状态 // 附件相关变量(仅在传入 param 时初始化) let fjid = ref(null); let fjName = null; let mode = null; if (props.param && props.param.button) { fjid = ref(props.param.button.val); fjName = props.param.button.desc; mode = props.param.mode; } const showRequired = computed(() => { // 检查是否有验证规则(通过 window.ssVm 判断) const hasValidationRule = window.ssVm?.validations?.has(props.name); if (!hasValidationRule) return false; if (errMsg.value) return true; if (!inputValue.value) return true; return false; }); // 计算floatdiv应该向上还是向下展开 const calculateFloatingDivPosition = () => { nextTick(() => { const textarea = inputRef.value; if (!textarea) return; const rect = textarea.getBoundingClientRect(); const viewportHeight = window.innerHeight; // 预估floatdiv的高度(最多5行 * 20px + 上下padding + border) const estimatedFloatDivHeight = 20 * 5 + 10 + 2; // 5行 + padding + border = 112px // 检查下方空间 const spaceBelow = viewportHeight - rect.bottom; // 如果下方空间不足,且上方空间足够,则向上展开 if (spaceBelow < estimatedFloatDivHeight && rect.top > estimatedFloatDivHeight) { floatingDivPosition.value = "top"; } else { floatingDivPosition.value = "bottom"; } }); }; // 计算floatdiv的top偏移量 const getFloatingDivTop = computed(() => { if (props.height) { // 有height时,padding是5px return '5px'; } else { // 没有height时是单行居中,需要计算居中位置 // 假设input-container高度是32px(或者从CSS读取),单行20px // 居中偏移 = (容器高度 - 行高) / 2 = (32 - 20) / 2 = 6px return '6px'; } }); const validate = () => { if (window.ssVm) { const result = window.ssVm.validateField(props.name); console.log(result); errMsg.value = result.valid ? "" : result.message; } }; // 使用 watch 监听 props.errTip 和 props.modelValue 的变化 watch( () => props.errTip, (newVal) => { errMsg.value = newVal; }, { immediate: true } ); watch( () => props.modelValue, (newVal) => { inputValue.value = newVal; } ); // 挂载时的逻辑 onMounted(() => { errMsg.value = props.errTip; inputValue.value = props.modelValue || props.defaultValue || ""; }); // 计算并调整textarea的高度 const adjustHeight = () => { nextTick(() => { const textarea = textareaRef.value; if (!textarea) return; // floatDiv的textarea始终自动计算高度,不受props.height影响 // 重置高度以获得正确的scrollHeight textarea.style.height = "auto"; // 计算新高度 - 统一限制为5行 const lineHeight = parseInt(getComputedStyle(textarea).lineHeight, 10); const maxHeight = lineHeight * 5; // 统一为5行 const newHeight = Math.min(textarea.scrollHeight, maxHeight); textarea.style.height = `${newHeight}px`; }); }; // 检查是否应该显示浮动窗口(需要同时满足:有焦点 + 内容超出) // 修复新增页面点击就出现floatdiv的问题 by xu 20251212 const checkShouldShowFloatingDiv = () => { const textarea = inputRef.value; if (!textarea) return false; // 首先检查是否有内容,没有内容时不显示floatdiv by xu 20251212 if (!inputValue.value || inputValue.value.toString().trim() === '') { console.log('[floatdiv] 内容为空,不显示floatdiv'); return false; } // 判断内容是否超出 by xu 20251212 // 同时检查横向和纵向溢出,任一方向溢出都应显示floatdiv // 纵向溢出需要加容差值,避免padding/border导致的误判 by xu 20251212 const verticalTolerance = 5; // 容差值5px const isHorizontalOverflow = textarea.scrollWidth > textarea.clientWidth; const isVerticalOverflow = textarea.scrollHeight > textarea.clientHeight + verticalTolerance; const isOverflow = isHorizontalOverflow || isVerticalOverflow; console.log('[floatdiv] 溢出检测 - scrollWidth:', textarea.scrollWidth, 'clientWidth:', textarea.clientWidth, 'horizontalOverflow:', isHorizontalOverflow); console.log('[floatdiv] 溢出检测 - scrollHeight:', textarea.scrollHeight, 'clientHeight:', textarea.clientHeight, 'tolerance:', verticalTolerance, 'verticalOverflow:', isVerticalOverflow); const shouldShow = isFocused.value && isOverflow; console.log('[floatdiv] 最终判断 - isFocused:', isFocused.value, 'isOverflow:', isOverflow, 'shouldShow:', shouldShow); // 需要同时满足:有焦点 + 内容超出 return shouldShow; }; // 定义事件处理函数 const onInput = (event) => { const newValue = event.target.value; inputValue.value = newValue; emit("update:modelValue", newValue); validate(); // 输入时验证 nextTick(() => { // 检查是否需要显示浮动div contentFloatingDiv.value = checkShouldShowFloatingDiv(); // 如果需要显示floatdiv,计算其位置 if (contentFloatingDiv.value) { calculateFloatingDivPosition(); } }); adjustHeight(); }; const onFocus = (event) => { // 设置焦点状态为true isFocused.value = true; adjustHeight(); // 检查是否应该显示浮动窗口 nextTick(() => { contentFloatingDiv.value = checkShouldShowFloatingDiv(); if (contentFloatingDiv.value) { calculateFloatingDivPosition(); } }); }; // 失去焦点时进行验证 const onBlur = (event) => { emit("blur", event.target); validate(); // 失焦时验证 nextTick(() => { // 如果焦点不在 textarea 上,则隐藏浮动 div if (!document.activeElement.classList.contains("input-control")) { isFocused.value = false; contentFloatingDiv.value = false; } }); }; const onChange = (event) => { inputValue.value = event.target.value || ""; emit("change", inputValue.value); }; const onMouseover = (event) => { nextTick(() => { // setTimeout(contentFloatingDiv.value = true, 500) }); }; const onMouseleave = (event) => { // contentFloatingDiv.value = false }; // 处理键盘按下事件,禁止回车换行 by xu 20251212 const onKeydown = (event) => { // 如果不允许多行且按下的是回车键,阻止默认行为 if (!props.multiline && event.key === 'Enter') { event.preventDefault(); } }; // 附件按钮点击处理(从 SsEditor 搬运) const onAttachmentClick = (e) => { e.preventDefault(); if (!props.param || !props.param.button) { console.warn("未配置 param 参数"); return; } console.log("附件点击了"); console.log("param", props.param); console.log("cmsAddUrl", props.param.button.cmsAddUrl); // 如果 fjid 为空,先调用 cmsAddUrl 创建 if (fjid.value == null || fjid.value == "") { $.ajax({ type: "post", url: props.param.button.cmsAddUrl, async: false, data: { name: "fjid", ssNrObjName: "sh", ssNrObjId: "", }, success: function (_fjid) { console.log("cmsAddUrl success", _fjid); fjid.value = _fjid; }, }); } // 构建参数字符串 var str = "&nrid=T-" + fjid.value + "&objectId=" + fjid.value + "&objectName=" + fjName + "&callback=" + (window["fjidCallbackName"] || ""); console.log("str", str); // 打开附件编辑对话框 SS.openDialog({ src: props.param.button.cmsUpdUrl + str, headerTitle: "编辑", width: 900, high: 664, zIndex: 51, }); }; return { errMsg, inputValue, showRequired, onInput, onBlur, onChange, onMouseover, onMouseleave, onKeydown, // 新增:键盘事件处理 by xu 20251212 contentFloatingDiv, floatingDivPosition, getFloatingDivTop, inputRef, textareaRef, onFocus, onAttachmentClick, fjid, // 附件 ID,用于隐藏字段 }; }, render() { const { resolveComponent, h } = Vue; const SsIcon = resolveComponent("ss-icon"); const SsEditorIcon = resolveComponent("SsEditorIcon"); // 构建主textarea的样式 const mainTextareaStyle = {}; if (this.height) { mainTextareaStyle.height = "auto" // mainTextareaStyle.paddingTop = '5px'; // 有高度时加上padding-top // mainTextareaStyle.paddingBottom = '5px'; // 有高度时加上padding-bottom } else { // 没有指定height时,固定为单行高度 mainTextareaStyle.height = '20px'; // 行高20px mainTextareaStyle.lineHeight = '20px'; // 确保单行垂直居中 mainTextareaStyle.display = 'flex'; mainTextareaStyle.marginBottom = '5px'; } // 如果有附件按钮,为按钮留出空间 if (this.fj || (this.param && this.param.button)) {//加上&&this.param.button条件 Ben(20251221) mainTextareaStyle.paddingRight = '75px'; } const mainTextareaRows = this.height ? Math.floor(parseFloat('80px') / 20) : 1 return h("div", { class: "input" }, [ h("div", { class: "input-container", }, [ h("div", { class: "input", style: "padding:5px 0" }, [ h("textarea", { ref: "inputRef", class: "input-control", name: this.name, value: this.inputValue, onInput: this.onInput, onFocus: this.onFocus, onBlur: this.onBlur, onChange: this.onChange, onKeydown: this.onKeydown, // 新增:禁止回车换行 by xu 20251212 placeholder: this.placeholder, onMouseover: this.onMouseover, // 监听鼠标悬停 onMouseleave: this.onMouseleave, // 监听鼠标离开 rows: mainTextareaRows, ...this.$attrs, style: mainTextareaStyle, autocomplete: "off", }), // 附件按钮(优先使用 param,兼容旧的 fj) this.fj || (this.param && this.param.button)//加上&&this.param.button条件 Ben(20251221) ? h( "button", { type: "button", class: "fj-button", onClick: this.param ? this.onAttachmentClick : (e) => { e.preventDefault(); console.log("附件配置:", this.fj); }, }, [ h(SsEditorIcon, { class: "editor-icon-link", }), h("span", { class: "fj-button-text" }, "附件") ] ) : null, // this.showRequired ? h("div", { class: "required" }) : null, ]), this.contentFloatingDiv || "" ? h("div", { class: "floating-div", style: this.floatingDivPosition === "bottom" ? { // 向下展开: 覆盖原输入框,top对齐首行 top: this.getFloatingDivTop, bottom: 'auto', } : { // 向上展开: 同样覆盖原输入框,但从底部开始计算 top: 'auto', bottom: this.height ? '5px' : '6px', // 对齐到原textarea的底部padding位置 } }, [ h("textarea", { ref: "textareaRef", class: "input-control", value: this.inputValue, onInput: this.onInput, onBlur: this.onBlur, onFocus: this.onFocus, onKeydown: this.onKeydown, // 新增:禁止回车换行 by xu 20251212 onMouseover: this.onMouseover, // 监听鼠标悬停 onMouseleave: this.onMouseleave, // 监听鼠标离开 autocomplete: "off", onVnodeMounted: (vnode) => { vnode.el.focus(); }, }), ]) : null, // this.errMsg ? h(SsValidate, { errMsg: this.errMsg }) : null, ]), // 附件相关的隐藏字段(仅在有 param 时才渲染) this.param && [ // fjid 隐藏字段(只有当 fjid 有值时才渲染) this.fjid && this.fjid.value && h("input", { type: "hidden", name: "fjid", value: this.fjid.value, }), // 其他隐藏字段根据 name 生成 /* 去掉。文本框不需要(这是富文本才有的) Ben 20251205 h("input", { type: "hidden", name: this.name.replace(/wj$/, "") + "Edit", value: this.inputValue }), */ // h("input", { // type: "hidden", // name: this.name.replace(/wj$/, "") + "wj", // value: this.inputValue // }), /* 去掉。文本框不需要(这是富文本才有的) Ben 20251205 h("input", { type: "hidden", name: "ueditorpath", value: this.name }), */ ], ]); }, }; // ss-normal-input 登录输入 const SsLoginInput = { name: "SsLoginInput", inheritAttrs: false, props: { errTip: { type: String, }, type: { type: String, default: "text", }, required: { type: Boolean, default: false, }, placeholder: { type: String, default: "请输入", }, name: { type: String, default: "", }, defaultValue: [String, Number], modelValue: [String, Number], }, emits: ["update:modelValue", "input", "blur", "change"], // 允许更新 v-model 绑定的值 setup(props, { emit }) { const errMsg = ref(""); const inputRef = ref(null); const textareaRef = ref(null); const inputValue = ref(props.modelValue || props.defaultValue || ""); // 使用 watch 监听 props.errTip 和 props.modelValue 的变化 watch( () => props.errTip, (newVal) => { errMsg.value = newVal; }, { immediate: true } ); watch( () => props.modelValue, (newVal) => { inputValue.value = newVal; } ); // 挂载时的逻辑 onMounted(() => { errMsg.value = props.errTip; inputValue.value = props.modelValue || props.defaultValue || ""; }); // 定义事件处理函数 const onInput = (event) => { const newValue = event.target.value; inputValue.value = newValue; emit("update:modelValue", newValue); }; return { inputValue, onInput, inputRef, textareaRef }; }, render() { return h("div", { class: "input" }, [ h("div", { class: "input-container" }, [ h("div", { class: "input" }, [ h("input", { ref: "inputRef", class: "input-control", name: this.name, value: this.inputValue, onInput: this.onInput, type: this.type, placeholder: this.placeholder, required: this.required, ...this.$attrs, autocomplete: "off", }), this.required ? h("div", { class: "required" }) : null, ]), ]), ]); }, }; // ss-login-button const SsLoginButton = { name: "SsLoginButton", inheritAttrs: false, props: { class: { type: String, default: "", }, text: { type: String, default: "", }, type: { type: String, default: "button", }, }, emits: ["click"], setup(props, { emit }) { // 定义事件处理函数 const onClick = (event) => { // 发射一个 'click' 事件,你可以传递所需的参数 emit("click", event); }; return { props, onClick }; }, render() { const SsIcon = resolveComponent("ss-icon"); const SsLoginIcon = resolveComponent("ss-login-icon"); return h( "button", { class: "login-button", type: this.type, onClick: this.onClick }, [ h("span", [h(SsLoginIcon, { class: this.class })]), h("span", {}, this.text), ] ); }, }; // ss-objp 下拉选择 const SsObjp = { name: "SsObjp", inheritAttrs: false, props: { onchange: { //在此属性传入onChange的window全局回调函数,函数唯一参数是当前选中值 Ben(20251217) type: String, required: false, }, filter: { type: String, required: false, }, cb: { type: String, required: true, }, url: { type: String, required: true, }, name: { type: String, required: true, }, width: { type: String, default: "100%", }, placeholder: { type: String, default: "请选择", }, inp: { type: Boolean, default: false, }, opt: { type: Array, default: () => [], }, errTip: String, defaultValue: [String, Number], modelValue: [String, Number], direction: { type: String, default: "bottom", }, }, emits: ["update:modelValue", "input", "blur", "change"], setup(props, { emit }) { const canInput = props.inp; const errMsg = Vue.ref(props.errTip); const selectItem = Vue.ref({}); let inputText = Vue.ref(""); // 用于存储输入框的文本 const popupWinVisible = Vue.ref(false); const filteredOptions = Vue.ref(props.opt); const popupDirection = Vue.ref("bottom"); const popupMaxHeight = Vue.ref("none"); // popup最大高度,用于空间不足时限制高度并出滚动条 by xu 20251212 // const showRequired = Vue.computed(() => { // const hasValidationRule = window.ssVm?.validations?.has(props.name); // if (!hasValidationRule) return false; // if (errMsg.value) return true; // if (!selectItem.value?.value) return true; // return false; // }); const validate = () => { if (window.ssVm) { const result = window.ssVm.validateField(props.name); // console.log("validate", window.ssVm.validateField(props.name)); errMsg.value = result.valid ? "" : result.message; } }; //在objPicker界面,选中value对应的项 const updateSelectItem = () => { // console.log(props.opt); const item = props.opt.find((it) => it.value === props.modelValue); if (item) { selectItem.value = item; inputText.value = item.label; } else { selectItem.value = { label: "", value: "" }; inputText.value = ""; } // validate(); }; Vue.watch( () => props.errTip, (newVal) => { errMsg.value = newVal; } ); Vue.watch(() => props.modelValue, updateSelectItem, { immediate: true }); Vue.watch( () => props.opt, (newVal) => { updateSelectItem(); filteredOptions.value = [...newVal]; // console.log("filteredOptions", filteredOptions.value); } ); //初始化objPicker在页面刚打开时的默认值 async function initDefaultValue() { try { if (props.url && props.cb && props.modelValue) { let objectPickerParam; let url = props.url; //如果有定义过滤器 if (props.filter) { //包含HTML实体的JSON字符串转为JSON对象,如原字符串是{"dwid":"88"},注意key也必需用单引号包着 // const decodedString = props.filter.replace(/"/g, '"'); // 转换为: {"dwid":"88"} // objectPickerParam = JSON.parse(decodedString); // 转为json对象 const filterObj = props.filter; // 转为json对象 for (let k in filterObj) { let v = filterObj[k]; url += "&" + k + "=" + v; } objectPickerParam = props.filter; // 转为json对象 objectPickerParam["input"] = props.inp; objectPickerParam["codebook"] = props.cb; // alert(url); } else { objectPickerParam = { input: props.inp, codebook: props.cb }; } const objectPickerParamStr = JSON.stringify(objectPickerParam); const params = new URLSearchParams(); params.append("objectpickerparam", objectPickerParamStr); params.append("objectpickertype", "2"); params.append("objectpickervalue", props.modelValue); //需回显的值 // alert("1params:"+JSON.stringify(params)); axios .post(props.url, params, { headers: { "Content-Type": "application/x-www-form-urlencoded", // 必须手动设置! }, }) .then((response) => { // alert(JSON.stringify(response.data)); if ("timeout" == response.data.statusText) { alert("网络超时!"); return; } if (response.data.result) { const keys = Object.keys(response.data.result); if (keys.length === 1) { let code = keys[0]; let desc = response.data.result[keys[0]]; if (props.opt) props.opt.length = 0; //通过修改数组的length属性,直接清空数组元素,内存会被自动释放。这是性能最优的方式 else { props.opt = []; } props.opt.push({ label: desc, value: code }); updateSelectItem(); // alert('props.opt:'+JSON.stringify(props.opt)); } } }); } } catch (error) { // callback(null, error.message); // 失败回调,传递错误 } } // Vue.onMounted(updateSelectItem); const doSelectItem = (item) => { emit("update:modelValue", item.value); selectItem.value = item; inputText.value = item.label; hidePopup(); nextTick(() => { console.log(item.value + "@@@props.modelValue:" + props.modelValue); validate(); if (window.ssVm) { // 遍历所有验证规则,找到依赖当前字段的规则 for (const [field, rules] of window.ssVm.validations.entries()) { for (const rule of rules) { if (rule.opt?.relField === props.name) { // console.log("Found dependent field:", field); // 调试日志 window.ssVm.validateField(field); } } } } }); callGlobalOnchg(item.value, item.label); // 值变化时尝试调用全局onchange回调函数 Ben(20251217) }; // 用于调用全局onchange回调函数 Ben(20251217) const callGlobalOnchg = (value, desc) => { // 检查 onchange 属性是否提供了有效的函数名 if (props.onchange && typeof props.onchange === 'string') { // 检查 window 对象上是否存在该函数 if (typeof window !== 'undefined' && window[props.onchange] && typeof window[props.onchange] === 'function') { try { window[props.onchange](value, desc) // 调用全局函数,并传入当前选中值 } catch (error) { console.error(`调用全局函数 ${props.onchange} 时出错:`, error) } } else { console.warn(`全局函数 ${props.onchange} 未定义或不是一个函数。`) } } } //可录入的objPicker,更新下拉菜单选项 async function updateOptionBYInputText(inpTxt) { try { let objectPickerParam; let url = props.url; if (props.url && props.cb) { //如果有定义过滤器 if (props.filter) { const filterObj = props.filter; // 转为json对象 for (let k in filterObj) { let v = filterObj[k]; url += "&" + k + "=" + v; } //包含HTML实体的JSON字符串转为JSON对象,如原字符串是{"dwid":"88"},注意key也必需用单引号包着 // const decodedString = props.filter.replace(/"/g, '"'); // 转换为: {"dwid":"88"} // objectPickerParam = JSON.parse(decodedString); // 转为json对象 objectPickerParam = props.filter; objectPickerParam["input"] = props.inp; objectPickerParam["codebook"] = props.cb; // alert(url); } else { objectPickerParam = { input: props.inp, codebook: props.cb }; } const objectPickerParamStr = JSON.stringify(objectPickerParam); const params = new URLSearchParams(); params.append("objectpickerparam", objectPickerParamStr); params.append("objectpickertype", "1"); if (props.inp && props.inp === true) {//把"true"改为true Ben(20251209) params.append("objectpickersearchAll", 0); //只查录入的值 params.append("objectpickerinput", inpTxt); //录入的值 } else { params.append("objectpickersearchAll", 1); } axios .post(url, params, { headers: { "Content-Type": "application/x-www-form-urlencoded", // 必须手动设置! }, }) .then((response) => { if ("timeout" == response.data.statusText) { alert("网络超时!"); return; } // 先清空选项 by xu 20251212 if (props.opt) { props.opt.length = 0; } else { props.opt = []; } if (response.data.result) { const keys = Object.keys(response.data.result); // console.log("params:"+params+"@@response.data:"+JSON.stringify(response.data)); if (keys.length > 0) { for (let k in response.data.result) { props.opt.push({ label: response.data.result[k], value: k, }); } // console.log('###inpTxt:'+inpTxt+';'); if ( props.inp && props.inp === true && //把"true"改为true Ben(20251209) inpTxt.length > 0 ) { //对于可录入的,用已录入的值作过滤 filteredOptions.value = props.opt.filter((option) => option.label .toLowerCase() .includes(inputText.value.toLowerCase()) ); // 可录入的objPicker,当搜索结果只有一项时,自动选中这一项 by xu 20251212 if (filteredOptions.value.length === 1) { const autoSelectItem = filteredOptions.value[0]; console.log("[objp] 搜索结果只有一项,自动选中:", autoSelectItem); doSelectItem(autoSelectItem); return; // 自动选中后直接返回,不需要显示popup } filteredOptions.value.unshift({ label: "", value: "" }); // console.log('###做了过滤:'+inputText.value.toLowerCase()+';'); } else { filteredOptions.value = props.opt; filteredOptions.value.unshift({ label: "", value: "" }); } console.log("props.opt11:" + JSON.stringify(props.opt)); } else { // 没有数据时,清空过滤选项 by xu 20251212 filteredOptions.value = []; console.log("[objp] 接口返回空数据"); } } else { // result不存在时,清空过滤选项 by xu 20251212 filteredOptions.value = []; console.log("[objp] 接口返回无result"); } // 无论是否有数据,都显示popup by xu 20251212 if (!popupWinVisible.value) { popupWinVisible.value = true; } }); } } catch (error) { // callback(null, error.message); // 失败回调,传递错误 } } // 计算弹出方向和最大高度的方法 by xu 20251212 // 当空间不足时限制popup高度并显示滚动条 const calculatePopupDirection = () => { // 1. 获取select容器元素 const selectEl = document .querySelector(`[name="${props.name}"]`) ?.closest(".select-container"); if (!selectEl) return; // 2. 获取位置信息 const selectRect = selectEl.getBoundingClientRect(); const viewportHeight = window.innerHeight; // 3. 计算上下可用空间 const spaceBelow = viewportHeight - selectRect.bottom - 10; // 减10px留边距 const spaceAbove = selectRect.top - 10; // 减10px留边距 // 4. popup预估高度(假设每项36px,最多显示8项 + padding) const estimatedPopupHeight = 300; const minPopupHeight = 100; // 最小高度 console.log('[popup] 空间计算 - spaceAbove:', spaceAbove, 'spaceBelow:', spaceBelow, 'estimatedHeight:', estimatedPopupHeight); // 5. 判断方向和最大高度 by xu 20251212 if (spaceBelow >= estimatedPopupHeight) { // 下方空间足够,向下展开,不限制高度 popupDirection.value = "bottom"; popupMaxHeight.value = "none"; console.log('[popup] 向下展开,空间充足'); } else if (spaceAbove >= estimatedPopupHeight) { // 上方空间足够,向上展开,不限制高度 popupDirection.value = "top"; popupMaxHeight.value = "none"; console.log('[popup] 向上展开,空间充足'); } else { // 上下空间都不足,选择空间大的方向,并限制高度出滚动条 if (spaceBelow >= spaceAbove) { popupDirection.value = "bottom"; popupMaxHeight.value = Math.max(spaceBelow, minPopupHeight) + "px"; console.log('[popup] 向下展开,空间不足,限制高度:', popupMaxHeight.value); } else { popupDirection.value = "top"; popupMaxHeight.value = Math.max(spaceAbove, minPopupHeight) + "px"; console.log('[popup] 向上展开,空间不足,限制高度:', popupMaxHeight.value); } } }; //点击下拉菜单的文本区域时,会触发的方法 function togglePopup() { // 可录入的 objPicker,更新下拉菜单选项 updateOptionBYInputText(inputText.value); // popupWinVisible.value = !popupWinVisible.value; Vue.nextTick(() => { calculatePopupDirection(); }); } const hidePopup = () => { popupWinVisible.value = false; }; //点击下拉菜单的三角形时,会触发的方法 // 添加toggle逻辑,点击时切换显示/隐藏 by xu 20251212 const suffixClick = () => { // 如果popup已显示,则关闭 by xu 20251212 if (popupWinVisible.value) { hidePopup(); console.log("[objp] 点三角关闭popup"); return; } //可录入的objPicker,更新下拉菜单选项 updateOptionBYInputText(""); Vue.nextTick(() => { calculatePopupDirection(); }); console.log("[objp] 点三角打开popup"); }; //可录入的objPicker,录入项变化时,会触发 async function handleInputChange(event) { inputText.value = event.target.value; if (!inputText.value) { inputText.value = ""; } //可录入的objPicker,更新下拉菜单选项 updateOptionBYInputText(inputText.value); // filteredOptions.value = props.opt.filter((option) => // option.label.toLowerCase().includes(inputText.value.toLowerCase()) // ); // if (!popupWinVisible.value) { // popupWinVisible.value = true; // 确保下拉框在输入时打开 // } } Vue.onMounted(() => { initDefaultValue(); window.addEventListener("resize", calculatePopupDirection); }); Vue.onUnmounted(() => { window.removeEventListener("resize", calculatePopupDirection); }); return { errMsg, selectItem, inputText, canInput, filteredOptions, popupWinVisible, popupDirection, popupMaxHeight, // 添加popup最大高度 by xu 20251212 suffixClick, togglePopup, hidePopup, doSelectItem, handleInputChange, }; }, template: `
`, }; // ss-hidden 隐藏字段组件 const SsHidden = { name: "SsHidden", props: { modelValue: String, name: { type: String, required: true, }, rule: { type: String, required: true, }, param: { type: String, required: true, }, url: { type: String, required: true, }, }, emits: ["update:modelValue"], setup(props, { emit }) { const errMsg = Vue.ref(""); const validate = () => { if (window.ssVm) { const result = window.ssVm.validateField(props.name); console.log("validate", window.ssVm.validateField(props.name)); errMsg.value = result.valid ? "" : result.message; } }; Vue.onMounted(() => { /** * 初始化级联菜单值初始值思路: * 1. 带隐藏字段(即带编码规则)的级联菜单 * 在隐藏字段这,可以取到要回显的值和编码规则,从而计算出各级下拉菜单要回显的值。 * 然后通过ajax取各级级联菜单的值回显。 * 2. 不带隐藏字段的级联,只能在各个下拉菜单的setup事件中通过ajax取回显值回显 */ // 当同组级联下拉菜单选中值变化时,会调用本隐藏字段下面这方法设置隐藏字段值 window.addEventListener( "cascader-setHiddenVal-" + props.name, (event) => { const { value } = event.detail; emit("update:modelValue", value); console.log(value); setTimeout(() => { validate(); }, 50); } ); // 如果有初始值,触发回显过程 if (props.modelValue) { console.log("级联隐藏字段,开始回显,初始值:", props.modelValue); triggerCascaderEcho(props.modelValue); validate(); } }); // 触发级联回显 const triggerCascaderEcho = (code) => { /** * 开始回显,初始值: 440304 * 解析后的所有值: Array(3)0: "440000"1: "440300"2: "440304"length: 3[[Prototype]]: Array(0) */ const values = parseHiddenCodeForAll(code, props.rule); console.log("解析后的所有值:", values); // 转换为 JSON 对象 // const paramObj = JSON.parse(props.param); const paramObj = props.param; let selectArr = paramObj.fieldOrd; //保存本组级联菜单项的数组,如:['hksheng','hkshi','hkxian'] if (selectArr.length != values.length) { // alert('属性'+props.name+'的值'+code+'与级联菜单中下拉菜单的数目不匹配!'); return; } // 按顺序触发回显,并增加延迟确保数据加载 /** * 通过隐藏字段的setup事件, * 循环遍历各级下拉菜单,并触发定义在下拉菜单中的'cascader-echo'事件, * 在此事件中完成每个下拉菜单回显值操作(只取当前要回显的键值对显示, * 下拉菜单所有的值,在点击下拉菜单时,才通过ajax取)。 */ values.forEach((value, index) => { if (value) { setTimeout(() => { let upperVal = undefined; if (index != 0) { upperVal = values[index - 1]; } const echoEvent = new CustomEvent( "cascader-echo-" + selectArr[index], { detail: { name: props.name, value: value, // level: index + 1, isAuto: true, // 标记为自动回显 upperVal: upperVal, }, } ); console.log(props.name + "--upperValue:" + upperVal); window.dispatchEvent(echoEvent); }, index * 500); // 每级增加500ms延迟 } }); }; // 解析所有级别的代码 const parseHiddenCodeForAll = (code, rule) => { if (!code || !rule) return []; // 获取规则中每段的长度 const segments = []; let currentChar = rule[0]; let currentLength = 1; for (let i = 1; i < rule.length; i++) { if (rule[i] === currentChar) { currentLength++; } else { segments.push(currentLength); currentChar = rule[i]; currentLength = 1; } } segments.push(currentLength); // 解析每一级的值 const values = []; let position = 0; segments.forEach((length, index) => { const value = code .substring(0, position + length) .padEnd(rule.length, "0"); values.push(value); position += length; }); return values; }; watchEffect(() => { }); return {}; }, template: ``, }; // ss-cascader 级联选择器 const SsCcp = { name: "SsCcp", inheritAttrs: false, props: { modelValue: String, name: { type: String, required: true, }, level: { type: Number, required: true, }, opt: { type: Array, default: () => [], }, placeholder: { type: String, default: "请选择", }, width: { type: String, default: "150px", }, direction: { type: String, default: "bottom", }, mode: { type: String, default: "1", }, //级联菜单配置参数,如果是数组,则代表本下拉菜单是多套级联菜单共用的第一级菜单。如果是对象,则只有一套级联菜单用此下拉菜单。 param: { type: String, required: true, }, //向后台拿数据的url url: { type: String, required: true, }, }, emits: ["update:modelValue", "change"], setup(props, { emit }) { // alert('级联菜单初始化:'+props.name+':--:'+props.modelValue); const selectItem = Vue.ref({ label: props.placeholder, value: "" }); const popupWinVisible = Vue.ref(false); const isAutoEcho = Vue.ref(false); // 用于标记是否是自动回显 const upperValue = Vue.ref(""); //上级下拉菜单当前值,在初始化下拉菜单默认值时,和上级下拉菜单的值变化时,修改此upperValue变量 const popupDirection = Vue.ref("bottom"); const popupMaxHeight = Vue.ref("none"); // popup最大高度,用于空间不足时限制高度并出滚动条 by xu 20251212 //有隐藏字段的下拉菜单,加载菜单项并展开事件 // 被上级下拉菜单选中值后,触发本下拉菜单刷新菜单项并弹出显示 window.addEventListener("cascader-open-" + props.name, async (event) => { const { upperVal } = event.detail; upperValue.value = upperVal; console.log( "22props.name:" + props.name + ",22props.upperValue:" + upperValue.value ); selectItem.value = ""; //清除本下拉菜单当前选中的值 emit("update:modelValue", ""); //通知父级 //清空下拉菜单,并设置第一项的值为placeholder clearAndInit1stOpt(); //下个下拉菜单名 let nextSelName = getNextSel(props.name, props.param.fieldOrd); if (nextSelName) { //清下个下拉菜单选中值和选项 event = new CustomEvent("cascader-cleanOpt-" + nextSelName, { detail: {}, }); window.dispatchEvent(event); } showPopup(); }); //设置mode2的下级下拉菜单的上级菜单当前值 function setNextSelectUpperValue() { //设置下级菜单的上级菜单当前值upperValue let paramArr = undefined; if (Array.isArray(props.param)) { paramArr = props.param; } else { paramArr = []; paramArr.push(props.param); } for (const oneParam of paramArr) { //下个下拉菜单名 const nextSelName = getNextSel(props.name, oneParam.fieldOrd); if (nextSelName) { setTimeout(() => { const openNextEvent = new CustomEvent( "cascade-setUpperVal-" + nextSelName, { detail: { upperVal: props.modelValue, }, } ); window.dispatchEvent(openNextEvent); }, 100); } } // end for } // 把上级 级联下拉菜单的值,设置进本组件的事件 window.addEventListener("cascade-setUpperVal-" + props.name, (event) => { // alert('props.name:'+props.name+',props.upperValue:'+event.detail.upperVal); const { upperVal } = event.detail; upperValue.value = upperVal; // console.log('props.name:'+props.name+',props.upperValue:'+upperValue.value); }); //清空下拉菜单,并设置第一项的值为空 function clearAndInit1stOpt() { if (props.opt) props.opt.length = 0; //通过修改数组的length属性,直接清空数组元素,内存会被自动释放。这是性能最优的方式 else { props.opt = []; } props.opt.push({ label: "", value: "" }); } //获取下一级下拉菜单,如果下一级下拉菜单不存在,则返回undefined function getNextSel(selName, selNameArr) { // 检查参数有效性 if (!Array.isArray(selNameArr) || selNameArr.length === 0) { return undefined; } // 查找当前元素的索引 const currentIndex = selNameArr.indexOf(selName); // 如果元素不存在或已经是最后一个元素,返回undefined if (currentIndex === -1 || currentIndex === selNameArr.length - 1) { return undefined; } // 返回下一个元素 return selNameArr[currentIndex + 1]; } // 处理选择事件 const doSelectItem = (item) => { selectItem.value = item; emit("update:modelValue", item.value); //修改本下拉菜单在vue中保存的值 // alert('item.value:'+item.value); if (props.mode === "1") { // mode 1 模式:修改隐藏字段值 let event = new CustomEvent( "cascader-setHiddenVal-" + props.param.combField, { detail: { value: item.value, }, } ); window.dispatchEvent(event); } emit("change", item.value); //触发配置的change方法 //设置下级菜单的上级菜单当前值upperValue let paramArr = undefined; if (Array.isArray(props.param)) { paramArr = props.param; } else { paramArr = []; paramArr.push(props.param); } for (const oneParam of paramArr) { //下个下拉菜单名 const nextSelName = getNextSel(props.name, oneParam.fieldOrd); if (nextSelName) { setTimeout(() => { const openNextEvent = new CustomEvent( "cascader-open-" + nextSelName, { detail: { upperVal: item.value, }, } ); window.dispatchEvent(openNextEvent); }, 100); } } // end for hidePopup(); //下个下拉菜单名 // let nextSelName = getNextSel(props.name, props.param.fieldOrd); // if(nextSelName){ // // //设置下一级下拉菜单中保存的本下拉菜单值(upperValue) // // event = new CustomEvent('cascade-setUpperVal-'+nextSelName, { // // detail: { // // value: item.value // // } // // }); // // window.dispatchEvent(event); // // //触发下一级下拉菜单,重新初始化下拉菜单项并弹出显示 // event = new CustomEvent('cascader-open-' +nextSelName, { // detail: { // upperVal: item.value // } // }); // window.dispatchEvent(event); // } // 只在手动选择时自动展开下一级 // if (!isAutoEcho.value) { // const nextLevel = props.level + 1; // setTimeout(() => { // const openNextEvent = new CustomEvent('open-next-cascader', { // detail: { // name: props.name, // level: nextLevel // } // }); // window.dispatchEvent(openNextEvent); // }, 100); // } }; // 监听下一级展开事件 (仅 mode 2) window.addEventListener("cascade-open", (event) => { if (props.mode === "2") { const { level } = event.detail; if (level === props.level) { popupWinVisible.value = true; } } }); if (props.mode === "1") { //如果是有隐藏字段的下拉菜单 // 监听回显事件 window.addEventListener( "cascader-echo-" + props.name, async (event) => { const { name, value, isAuto, upperVal } = event.detail; // level, if (upperVal) { upperValue.value = upperVal; console.log( "value:" + value + ",upperValue:" + upperValue + ",初始化级联组件时props.name:" + props.name ); } // if (name === props.name && level === props.level) { // 设置自动回显标记 isAutoEcho.value = true; // if (props.opt.length === 0) { // const loadDataEvent = new CustomEvent('cascader-load-data', { // detail: { // name: props.name, // level: props.level, // value: value // } // }); // window.dispatchEvent(loadDataEvent); //下面的代码只用于页面刚打开时,初始化级联菜单的回显值。 //Vue.watch用于监听数据的变化,并在数据变化时执行特定的回调函数。 //这段代码使用了 Vue.js 的 watch API 来监听 props.opt 的变化,如果props.opt有变化,则自动 // const unwatch = Vue.watch( // () => props.opt, // 监听的数据源(props.opt) // (newOptions) => { // 回调函数 // if (newOptions.length > 0) { // 条件判断 // matchAndSelect(value); // 执行逻辑 // unwatch(); // 停止监听 // } // }, // { immediate: true } // 配置:立即触发一次 // ); // } else { // matchAndSelect(value); // } // 初始化级联菜单在页面刚打开时的默认值 async function initDefaultValue(value) { try { // alert(1); if (props.url && props.param // && props.modelValue 对于有rule编码规则的级联菜单(即mode=1),modelValue一定是空的,所以注释掉,修复mode=1的级联菜单无法回显问题。Ben(20251124) ) { // alert(2); /** * let objectPickerParam= * {"objectpickerparam":"{\"input\":\"false\",\"cascadingLevel\":\"hksheng,hkshi,hkxian\"," + * "\"name\":\"hksheng\"," + * "\"cascadingName\":\"dq\",\"cascadingInputsName\":\"hkdqm\"," + * "\"codebook\":\"sheng\"}", * "objectpickertype":2, * "objectpickervalue":"440000" * }; */ const objectPickerParam = { input: "false", cascadingLevel: props.param.fieldOrd.join(","), //如:"hksheng,hkshi,hkxian" name: props.name, //本下拉菜单名 cascadingName: props.param.name, //级联菜单名 cascadingInputsName: props.param.combField, //对象属性,即隐藏字段名,如:hkdqm codebook: props.param.codebook, }; const objectPickerParamStr = JSON.stringify(objectPickerParam); const params = new URLSearchParams(); params.append("objectpickerparam", objectPickerParamStr); params.append("objectpickertype", "2"); params.append("objectpickervalue", value); //需回显的值 axios .post(props.url, params, { headers: { "Content-Type": "application/x-www-form-urlencoded", // 必须手动设置! }, }) .then((response) => { // alert(JSON.stringify(response.data)); if ("timeout" == response.data.statusText) { alert("网络超时!"); return; } if (response.data.result) { const keys = Object.keys(response.data.result); if (keys.length === 1) { let code = keys[0]; let desc = response.data.result[keys[0]]; clearAndInit1stOpt(); props.opt.push({ label: desc, value: code }); if (value) matchAndSelect(value); // updateSelectItem(); // alert('props.opt:'+JSON.stringify(props.opt)); } } }); } } catch (error) { alert(error); // callback(null, error.message); // 失败回调,传递错误 } } //下面的代码只用于页面刚打开时,初始化级联菜单的回显值。 initDefaultValue(value); // 延迟重置自动回显标记 setTimeout(() => { isAutoEcho.value = false; }, 500); } ); // 被上级下拉菜单触发的,清除选中值和下拉菜单选项 window.addEventListener( "cascader-cleanOpt-" + props.name, async (event) => { upperValue.value = ""; selectItem.value = ""; //清除本下拉菜单当前选中的值 emit("update:modelValue", ""); //通知父级 //清空所有下拉菜单项 if (props.opt) { props.opt.length = 0; } else { props.opt = []; } //下个下拉菜单名 let nextSelName = getNextSel(props.name, props.param.fieldOrd); // alert('nextSelName:'+nextSelName+'--,props.name:'+props.name); if (nextSelName) { //清下个下拉菜单选中值和选项 event = new CustomEvent("cascader-cleanOpt-" + nextSelName, { detail: {}, }); window.dispatchEvent(event); } } ); } else if (props.mode === "2") { //没隐藏字段的下拉菜单,在这初始化默认值 let needInitParam = undefined; if (Array.isArray(props.param)) { needInitParam = props.param[props.param.length - 1]; //只初始化数组最后一项 console.log("needInitParam最后一项:" + JSON.stringify(needInitParam)); } else { needInitParam = props.param; } // 初始化级联菜单在页面刚打开时的默认值 async function initDefaultValue(value, param) { try { // alert(1); if (props.url && param && props.modelValue) { // alert(2); /** * let param= * {"objectpickerparam":"{\"input\":\"false\",\"cascadingLevel\":\"rylbm,gwid\"," + * "\"name\":\"gwid\",\"cascadingName\":\"rylb_gw\"," + * "\"codebook\":\"gwByRylb\"}", * "objectpickertype":2, * "objectpickervalue":"102121"}; */ // alert('props.name:'+props.name+',props.param.fieldOrd:'+props.param.fieldOrd); const objectPickerParam = { input: "false", cascadingLevel: param.fieldOrd.join(","), //如:"hksheng,hkshi,hkxian" name: props.name, //本下拉菜单名 cascadingName: param.name, //级联菜单名 codebook: param.codebook, }; const objectPickerParamStr = JSON.stringify(objectPickerParam); const sendParams = new URLSearchParams(); sendParams.append("objectpickerparam", objectPickerParamStr); sendParams.append("objectpickertype", "2"); sendParams.append("objectpickervalue", value); //需回显的值 axios .post(props.url, sendParams, { headers: { "Content-Type": "application/x-www-form-urlencoded", // 必须手动设置! }, }) .then((response) => { // alert(JSON.stringify(response.data)); if ("timeout" == response.data.statusText) { alert("网络超时!"); return; } if (response.data.result) { const keys = Object.keys(response.data.result); console.log( "name:" + props.name + ",@@级联初始化默认值value:" + value + "--param:" + JSON.stringify(param) + "--objectPickerParamStr:" + objectPickerParamStr + "--response.data:" + JSON.stringify(response.data) ); if (keys.length === 1) { let code = keys[0]; let desc = response.data.result[keys[0]]; if (props.opt) props.opt.length = 0; //通过修改数组的length属性,直接清空数组元素,内存会被自动释放。这是性能最优的方式 else { props.opt = []; } props.opt.push({ label: desc, value: code }); if (value) matchAndSelect(value); console.log( "GOOD mode2回显的默认值:" + JSON.stringify({ label: desc, value: code }) + "--props.param:" + JSON.stringify(param) ); // updateSelectItem(); // alert('props.opt:'+JSON.stringify(props.opt)); } } }); } } catch (error) { alert(error); // callback(null, error.message); // 失败回调,传递错误 } // 重置自动回显标记 isAutoEcho.value = false; } // 初始化级联菜单在页面刚打开时的默认值 initDefaultValue(props.modelValue, needInitParam); //设置mode2的下级下拉菜单的上级菜单当前值 setNextSelectUpperValue(); } //选中要回显的默认值 const matchAndSelect = (value) => { const matchedOption = props.opt.find((opt) => opt.value === value); if (matchedOption) { selectItem.value = matchedOption; emit("update:modelValue", value); emit("change", value); } }; // 计算弹出方向和最大高度的方法 by xu 20251212 // 当空间不足时限制popup高度并显示滚动条 const calculatePopupDirection = () => { // 1. 获取select容器元素 const selectEl = document.querySelector( `[name="${props.name}"]` )?.nextElementSibling; console.log("selectEl:" + selectEl, props.name); if (!selectEl) return; // 2. 获取位置信息 const selectRect = selectEl.getBoundingClientRect(); const viewportHeight = window.innerHeight; // 3. 计算上下可用空间 by xu 20251212 const spaceBelow = viewportHeight - selectRect.bottom - 10; // 减10px留边距 const spaceAbove = selectRect.top - 10; // 减10px留边距 // 4. popup预估高度(假设每项36px,最多显示8项 + padding) const estimatedPopupHeight = 300; const minPopupHeight = 100; // 最小高度 console.log('[popup] 空间计算 - spaceAbove:', spaceAbove, 'spaceBelow:', spaceBelow, 'estimatedHeight:', estimatedPopupHeight); // 5. 判断方向和最大高度 by xu 20251212 if (spaceBelow >= estimatedPopupHeight) { // 下方空间足够,向下展开,不限制高度 popupDirection.value = "bottom"; popupMaxHeight.value = "none"; console.log('[popup] 向下展开,空间充足'); } else if (spaceAbove >= estimatedPopupHeight) { // 上方空间足够,向上展开,不限制高度 popupDirection.value = "top"; popupMaxHeight.value = "none"; console.log('[popup] 向上展开,空间充足'); } else { // 上下空间都不足,选择空间大的方向,并限制高度出滚动条 if (spaceBelow >= spaceAbove) { popupDirection.value = "bottom"; popupMaxHeight.value = Math.max(spaceBelow, minPopupHeight) + "px"; console.log('[popup] 向下展开,空间不足,限制高度:', popupMaxHeight.value); } else { popupDirection.value = "top"; popupMaxHeight.value = Math.max(spaceAbove, minPopupHeight) + "px"; console.log('[popup] 向上展开,空间不足,限制高度:', popupMaxHeight.value); } } }; //级联菜单点击事件 const togglePopup = () => { if (!popupWinVisible.value) { //如果当前下拉菜单是隐藏的,先ajax重新加载下拉菜单项,再显示。 showPopup(); } else { hidePopup(); } }; //显示下拉菜单,在此之前先清除下拉菜单项 const showPopup = () => { //清空下拉菜单,并设置第一项的值为空 clearAndInit1stOpt(); Vue.nextTick(() => { calculatePopupDirection(); }); let url = props.url; let filterObj = props.param.filter; if (filterObj) { for (let k in filterObj) { let v = filterObj[k]; url += "&" + k + "=" + v; } } if (props.mode === "1") { //如果是有隐藏字段的下拉菜单 console.log("666url:" + url); // alert('url:'+url); // 获取级联菜单所有下拉菜单项 async function getSelectItems(value) { try { // alert(1); if (props.url && props.param) { // alert(2); /** * param={"objectpickerparam":"{\"input\":\"false\",\"cascadingLevel\":\"hksheng,hkshi,hkxian\"," + * "\"name\":\"hksheng\",\"cascadingName\":\"dq\"," + * "\"cascadingInputsName\":\"hkdqm\",\"codebook\":\"sheng\"}", * "objectpickertype":1,//2表示获取要回显的一项,1表示获取所有下拉菜单项 * "upperValue":"440000" * }; */ const objectPickerParam = { input: "false", cascadingLevel: props.param.fieldOrd.join(","), //如:"hksheng,hkshi,hkxian" name: props.name, //本下拉菜单名 cascadingName: props.param.name, //级联菜单名 cascadingInputsName: props.param.combField, //对象属性,即隐藏字段名,如:hkdqm codebook: props.param.codebook, }; console.log("mode1 upperValue.value:" + upperValue.value); const objectPickerParamStr = JSON.stringify(objectPickerParam); const params = new URLSearchParams(); params.append("objectpickerparam", objectPickerParamStr); params.append("objectpickertype", "1"); if (upperValue.value) { params.append("upperValue", upperValue.value); } // params.append('objectpickervalue', value); //需回显的值 axios .post(url, params, { headers: { "Content-Type": "application/x-www-form-urlencoded", // 必须手动设置! }, }) .then((response) => { if ("timeout" == response.data.statusText) { alert("网络超时!"); return; } if (response.data.result) { const keys = Object.keys(response.data.result); console.log( "params:" + params + "@@response.data:" + JSON.stringify(response.data) ); if (keys.length > 0) { for (let k in response.data.result) { props.opt.push({ label: response.data.result[k], value: k, }); } console.log("props.opt11:" + JSON.stringify(props.opt)); } else { // 没有数据时打印日志 by xu 20251212 console.log("[ccp mode1] 接口返回空数据"); } } else { // result不存在时打印日志 by xu 20251212 console.log("[ccp mode1] 接口返回无result"); } // 无论是否有数据,都显示popup by xu 20251212 if (!popupWinVisible.value) { popupWinVisible.value = true; } }); } } catch (error) { alert(error); // callback(null, error.message); // 失败回调,传递错误 } } getSelectItems(props.modelValue); } else if (props.mode === "2") { //没隐藏字段的下拉菜单 let needInitParam = undefined; if (Array.isArray(props.param)) { needInitParam = props.param[props.param.length - 1]; //只初始化数组最后一项 console.log( "needInitParam最后一项:" + JSON.stringify(needInitParam) ); } else { needInitParam = props.param; } // 获取级联菜单所有下拉菜单项 async function getSelectItems(value, sendParam) { try { // alert(1); if (props.url && sendParam) { // alert(2); /** * param="{\"input\":\"false\",\"cascadingLevel\":\"dwid,sjryid\", * \"ryid\":\"111121\",\"name\":\"sjryid\", * \"cascadingName\":\"dw_sjry\",\"codebook\":\"sjryByDw\"}" */ const objectPickerParam = { input: "false", cascadingLevel: sendParam.fieldOrd.join(","), //如:"hksheng,hkshi,hkxian" name: props.name, //本下拉菜单名 cascadingName: sendParam.name, //级联菜单名 codebook: sendParam.codebook, }; console.log("mode2 upperValue.value:" + upperValue.value); const objectPickerParamStr = JSON.stringify(objectPickerParam); const params = new URLSearchParams(); params.append("objectpickerparam", objectPickerParamStr); params.append("objectpickertype", "1"); if (upperValue.value) { params.append("upperValue", upperValue.value); } // params.append('objectpickervalue', value); //需回显的值 axios .post(url, params, { headers: { "Content-Type": "application/x-www-form-urlencoded", // 必须手动设置! }, }) .then((response) => { if ("timeout" == response.data.statusText) { alert("网络超时!"); return; } if (response.data.result) { const keys = Object.keys(response.data.result); console.log( "params:" + params + "@@response.data:" + JSON.stringify(response.data) ); if (keys.length > 0) { for (let k in response.data.result) { props.opt.push({ label: response.data.result[k], value: k, }); } console.log("props.opt11:" + JSON.stringify(props.opt)); } else { // 没有数据时打印日志 by xu 20251212 console.log("[ccp mode2] 接口返回空数据"); } } else { // result不存在时打印日志 by xu 20251212 console.log("[ccp mode2] 接口返回无result"); } // 无论是否有数据,都显示popup by xu 20251212 if (!popupWinVisible.value) { popupWinVisible.value = true; } }); } } catch (error) { alert(error); // callback(null, error.message); // 失败回调,传递错误 } } getSelectItems(props.modelValue, needInitParam); // popupWinVisible.value = !popupWinVisible.value; } }; const hidePopup = () => { popupWinVisible.value = false; }; // 合并所有的 onMounted 逻辑 Vue.onMounted(() => { window.addEventListener("resize", calculatePopupDirection); // 1. 监听展开下一级事件 window.addEventListener("open-next-cascader", (event) => { const { name, level } = event.detail; if (name === props.name && level === props.level) { popupWinVisible.value = true; } }); // 2. 监听级联事件 window.addEventListener("cascader-change", (event) => { const { name, level, value } = event.detail; if (name === props.name && level < props.level) { selectItem.value = { label: "", value: "" }; emit("update:modelValue", ""); if (ssHidden) { ssHidden.updateValue(value); } } }); }); Vue.onUnmounted(() => { window.removeEventListener("resize", calculatePopupDirection); }); // 监听值变化,处理回显 (mode 2) Vue.watch( () => props.modelValue, (newVal) => { if (props.mode === "2" && newVal) { // 使用 watchEffect 替代嵌套的 watch Vue.watchEffect(() => { if (props.opt.length > 0) { const matchedOption = props.opt.find( (opt) => opt.value === newVal ); if (matchedOption) { selectItem.value = matchedOption; } } }); } else { // 原有的值变化处理 const item = props.opt.find((it) => it.value === newVal); if (item) { selectItem.value = item; } else { selectItem.value = { label: "", value: "" }; } } }, { immediate: true } ); // 监听选项变化,当数据加载完成时进行匹配 Vue.watch( () => props.opt, (newOptions) => { if (newOptions.length > 0) { const matchedOption = newOptions.find( (opt) => opt.value === selectItem.value.value ); if (matchedOption) { selectItem.value = matchedOption; emit("update:modelValue", matchedOption.value); emit("change", matchedOption.value); } } } ); return { selectItem, popupWinVisible, popupDirection, popupMaxHeight, // 添加popup最大高度 by xu 20251212 togglePopup, hidePopup, doSelectItem, }; }, template: `
`, }; // ss-date-picker 日期时间选择器组件 const SsDatePicker = { name: "SsDatePicker", props: { modelValue: { type: [String, Number, Date], default: "", }, name: { type: String, required: true, }, type: { type: String, default: "date", validator: (value) => ["date", "datetime", "time"].includes(value), }, fmt: { type: String, default: null, }, placeholder: { type: String, default: "", }, width: { type: String, default: "100%", }, }, emits: ["update:modelValue"], setup(props, { emit }) { const errMsg = ref(""); const validate = () => { if (window.ssVm) { const result = window.ssVm.validateField(props.name); console.log("validate", window.ssVm.validateField(props.name)); errMsg.value = result.valid ? "" : result.message; } }; // 根据type确定默认格式 const defaultFormat = computed(() => { switch (props.type) { case "datetime": return "YYYY-MM-DD HH:mm:ss"; case "date": return "YYYY-MM-DD"; case "time": return "HH:mm:ss"; } }); const convertJavaFormatToElement = (javaFormat) => { if (!javaFormat) return null; return javaFormat .replace("yyyy", "YYYY") .replace("MM", "MM") .replace("dd", "DD") .replace("HH", "HH") .replace("mm", "mm") .replace("ss", "ss"); }; const finalFormat = computed(() => { if (props.fmt) { return convertJavaFormatToElement(props.fmt); } return defaultFormat.value; }); // 使用 resolveComponent 获取组件 const ElDatePicker = resolveComponent("ElDatePicker"); const ElTimePicker = resolveComponent("ElTimePicker"); const SsFormIcon = resolveComponent("SsFormIcon"); const ElIcon = resolveComponent("ElIcon"); const handleValueUpdate = (val) => { emit("update:modelValue", val); emit("change", val); // 同时触发 change 事件 setTimeout(() => { validate(); }, 50); }; const dateType = computed(() => { const fmt = props.fmt || ""; if (fmt.includes("HH:mm:ss")) { return "datetime"; } else if (fmt.includes("HH:mm")) { return "datetime"; } else if (fmt.includes("mm:ss")) { return "time"; } return "date"; }); let useTimePicker = true; //"yyyy-MM-dd HH:mm:ss"; "日期字符串格式在java的写法",传到本组件fmt属性也是按这个格式 if (props.fmt) { //有fmt属性,则以fmt属性优先判断类型 if (/[dMy]/.test(props.fmt)) { //如果有传入日期格式,且含年月日 useTimePicker = false; } else { useTimePicker = true; } } else if (props.type !== "time") { useTimePicker = false; } return () => h("div", { class: "ss-date-picker", style: { width: props.width } }, [ h("input", { type: "hidden", name: props.name, value: props.modelValue, }), // 选择组件 h(useTimePicker ? ElTimePicker : ElDatePicker, { modelValue: props.modelValue, "onUpdate:modelValue": handleValueUpdate, type: dateType.value, format: finalFormat.value, "value-format": finalFormat.value, clearable: true, placeholder: props.placeholder, class: "custom-date-picker", // 用于自定义样式 "time-arrow-control": props.type === "datetime", // 修改这里 size: "large", // 添加这一行,改为 large 尺寸 style: { width: "100%" }, "prefix-icon": h(SsFormIcon, { class: "form-icon-time" }), }), ]); }, }; // ss-icon 图标 // v3.0 增加 class 属性分支:有 class 走新逻辑,否则走 v1.0 逻辑 by xu 20251212 // v3.0 用法: // v1.0 用法: const SsIcon = { name: "SsIcon", // v3.0 禁用 class 透传,手动处理 by xu 20251215 inheritAttrs: false, props: { // v1.0: 以下为旧属性 name: { type: String }, size: { type: [Number, String], default: 16 }, unit: { type: String, default: "px" }, color: String, type: { type: String, default: ssIcon.name, validator: function (value) { return [ssIcon, commonIcon].some((icon) => icon.name === value); }, }, }, emits: ["update:modelValue", "input", "blur", "change"], setup(props, { emit, attrs }) { // v3.0 分支:有 class 属性时直接渲染(从 attrs 获取) by xu 20251215 if (attrs.class) { return () => h("i", { ...attrs, class: attrs.class + ' icon-container' }); } // v1.0 分支:原有逻辑 const useIconType = computed(() => { return [ssIcon, commonIcon].find( (iconConfig) => iconConfig.name === props.type ); }); const iconName = computed(() => { const iconConfig = useIconType.value; // 注意:使用 .value 来访问响应式引用的值 if (!iconConfig) { console.error(`Icon type "${props.type}" not found.`); return ""; } const iconType = iconConfig.types[props.name]; if (!iconType) { console.error( `Icon name "${props.name}" not found in type "${props.type}".` ); return ""; } return `${iconConfig.prefix}${iconType}`; }); // 类似地,你可以计算 fontFamily 和 style const fontFamily = computed(() => { return useIconType.value ? useIconType.value.family : ""; }); // console.log(iconName.value,fontFamily.value) const style = computed(() => { const sizeStyle = isNum(props.size) ? `${props.size}${props.unit}` : props.size; const styleObj = { fontSize: sizeStyle, color: props.color || "", }; return toStyleStr(styleObj); }); // 使用渲染函数定义模板逻辑 return () => h("i", { class: ["icon-container", iconName.value, fontFamily.value], style: style.value, }); }, }; // 通用icon const SsCommonIcon = { name: "SsCommonIcon", props: { class: { type: String, required: true, }, }, setup(props) { return () => h("i", { class: props.class + " common-icon", }); }, }; // 登录页icon const SsLoginIcon = { name: "SsLoginIcon", props: { class: { type: String, required: true, }, }, setup(props) { return () => h("div", { class: props.class + " login-icon", }); }, }; // 弹窗icon const SsDialogIcon = { name: "SsDialogIcon", props: { class: { type: String, required: true, }, }, setup(props) { return () => h("i", { class: props.class + " dialog-icon", }); }, }; // 全局左侧导航图标组件 const SsNavIcon = { name: "SsNavIcon", props: { class: { type: String, required: true, }, }, setup(props) { return () => h("div", { class: props.class + " nav-icon", }); }, }; // 顶部工具栏图标组件 const SsHeaderIcon = { name: "SsHeaderIcon", props: { class: { type: String, required: true, }, }, setup(props) { return () => h("div", { class: props.class + " header-icon", }); }, }; // 全局菜单图标组件 const SsGolbalMenuIcon = { name: "SsGolbalMenuIcon", props: { class: { type: String, required: true, }, }, setup(props) { return () => h("div", { class: props.class + " global-menu-icon", }); }, }; // 全局查询列表卡片图标 const SsCartListIcon = { name: "SsCartListIcon", props: { class: { type: String, required: true, }, }, setup(props) { return () => h("div", { class: props.class + " cart-list-icon", }); }, }; // 全局底部工具栏图标组件 const SsQuickIcon = { name: "SsQuickIcon", props: { class: { type: String, required: true, }, }, setup(props) { return () => h("div", { class: props.class + " quick-icon", }); }, }; // 表单组件icon const SsFormIcon = { name: "SsFormIcon", props: { class: { type: String, required: true, }, }, setup(props) { return () => h("div", { class: props.class + " form-icon", }); }, }; // 弹窗底部按钮icon const SsBottomDivIcon = { name: "SsBottomDivIcon", props: { class: { type: String, required: true, }, }, setup(props) { return () => h("div", { class: props.class + " bottom-div-icon", }); }, }; // editor组件icon const SsEditorIcon = { name: "SsEditorIcon", props: { class: { type: String, required: true, }, }, setup(props) { return () => h("i", { class: props.class + " editor-icon", }); }, }; // ss-validate校验器 const SsValidate = { name: "SsValidate", props: { errMsg: { type: String }, textAlign: { type: String, default: "left" }, style: { type: Object, default: () => ({}) }, }, template: `
{{ errMsg }}
{{ errMsg }}
`, }; // ss-onoff-array 多选按钮 数组形式 const SsonoffArray = { name: "SsonoffArray", props: { name: { type: String, required: true, }, opt: { type: Array, default: () => [], }, defaultValue: [String, Number, Array], modelValue: [String, Number, Array], multiple: { // 新增多选模式属性 type: Boolean, default: false, }, // 是否允许一项都不选,默认true允许 by xu 20251212 null: { type: Boolean, default: true, }, }, emits: ["update:modelValue"], // 允许更新 v-model 绑定的值 setup(props, { emit }) { console.log("多选按钮", props.opt); // 使用数组来存储选中值 const checkedValue = ref( props.multiple ? Array.isArray(props.defaultValue) ? props.defaultValue : [] : props.defaultValue ); const errMsg = ref(props.errTip); // 生成icon名字 const genIconName = (itemValue) => { if (props.multiple) { return checkedValue.value.includes(itemValue) ? "form-icon-onoff-checked" : "form-icon-onoff-unchecked"; } return checkedValue.value === itemValue ? "form-icon-onoff-checked" : "form-icon-onoff-unchecked"; }; // 选中项 const selectItem = (value) => { if (props.multiple) { // 多选模式 const index = checkedValue.value.indexOf(value); if (index === -1) { checkedValue.value = [...checkedValue.value, value]; } else { // 取消选中当前项 const newValue = checkedValue.value.filter((v) => v !== value); // 如果不允许为空且取消后为空,则阻止取消操作 by xu 20251212 if (!props.null && newValue.length === 0) { return; // 阻止取消最后一项 } checkedValue.value = newValue; } } else { // 单选模式 // 如果点击的是当前已选中的项,判断是否允许取消 by xu 20251212 if (checkedValue.value === value) { if (!props.null) { return; // 不允许为空时,阻止取消 } checkedValue.value = ""; // 允许为空时,取消选中 } else { checkedValue.value = value; } } emit("update:modelValue", checkedValue.value); nextTick(() => { // 触发验证 if (window.ssVm) { window.ssVm.validateField(props.name); } }); }; return { checkedValue, genIconName, selectItem }; }, // 使用渲染函数定义模板逻辑 render() { const SsFormIcon = resolveComponent("ss-form-icon"); return h("div", { class: "radio-container" }, [ // 根据情况创建 input this.multiple ? this.checkedValue.length ? // 多选且有选中值:为选中项创建 input this.checkedValue.map((value) => h("input", { type: "checkbox", name: this.name, value: value, checked: true, style: { display: "none" }, }) ) : // 多选但没有选中值:创建一个空值 input h("input", { type: "hidden", name: this.name, value: "", }) : // 单选模式:创建一个 input h("input", { type: "hidden", name: this.name, value: this.checkedValue || "", }), this.opt.map((item, i) => h( "div", { key: i, class: { checked: this.multiple ? this.checkedValue.includes(item.value) : this.checkedValue === item.value, }, style: { width: item.width }, onClick: () => this.selectItem(item.value), }, [ h("span", null, item.label), h("div", { class: "mark" }, [ h(SsFormIcon, { class: this.genIconName(item.value), }), ]), ] ) ), ]); }, }; // ss-onoff 一个按钮 const Ssonoff = { name: "Ssonoff", props: { name: { type: String, required: true, }, label: { type: String, required: true, }, value: { type: [String, Number], required: true, }, width: { type: String, default: "", }, modelValue: [String, Number, Array], multiple: { type: Boolean, default: false, }, null: { type: Boolean, default: true, }, }, emits: ["update:modelValue"], setup(props, { emit }) { const parseModelValue = (val) => { if (!val) return []; // 如果以逗号开头,去掉开头的逗号 const cleanValue = val.toString().replace(/^,+/, ""); if (cleanValue.includes("|")) { return cleanValue.split("|"); } if (cleanValue.includes(",")) { return cleanValue.split(","); } return [cleanValue]; }; // 判断当前按钮是否选中 const isChecked = computed(() => { if (props.multiple) { const currentValue = parseModelValue(props.modelValue); return currentValue.includes(props.value.toString()); // return Array.isArray(props.modelValue) && props.modelValue.includes(props.value); } return (props.modelValue + '') === (props.value + '');//强转为字符串类型再比较(改之前是数字类型和字符串类型作比较,永远为false) Ben 20251206 }); // 切换选中状态 const toggleSelect = () => { if (props.multiple) { const currentValue = parseModelValue(props.modelValue); const index = currentValue.indexOf(props.value.toString()); let newValue; if (index === -1) { // 选中当前项 newValue = [...currentValue, props.value]; } else { // 取消选中当前项 const filteredValue = currentValue.filter((v) => v !== props.value.toString()); // 如果不允许为空且取消后为空,则阻止取消操作 if (!props.null && filteredValue.length === 0) { return; // 阻止取消最后一项 } newValue = filteredValue; } emit("update:modelValue", newValue.join(",")); } else { // 单选模式 const currentValue = parseModelValue(props.modelValue); const isCurrentlySelected = currentValue.includes(props.value.toString()); if (!isCurrentlySelected) { // 选中当前项 emit("update:modelValue", props.value); } else { // 取消选中当前项 // 如果不允许为空且当前只有这一项被选中,则阻止取消操作 if (!props.null && currentValue.length === 1) { return; // 阻止取消唯一选中项 } emit("update:modelValue", ""); } } nextTick(() => { // 触发验证 if (window.ssVm) { window.ssVm.validateField(props.name); } }); }; return { isChecked, toggleSelect }; }, render() { const SsFormIcon = resolveComponent("ss-form-icon"); return h("div", { class: "radio-container2" }, [ // 隐藏的表单元素 this.multiple ? h("input", { type: "hidden", name: `${this.name}`, // 多选模式下使用数组形式的 name value: this.isChecked ? this.value : "", }) : this.isChecked && h("input", { // 只有当前按钮被选中时才创建 input type: "hidden", name: this.name, value: this.value, }), // 按钮显示 h( "div", { class: { checked: this.isChecked }, style: { width: this.width }, onClick: this.toggleSelect, }, [ h("span", null, this.label), h("div", { class: "mark" }, [ h(SsFormIcon, { class: this.isChecked ? "form-icon-onoff-checked" : "form-icon-onoff-unchecked", }), ]), ] ), ]); }, }; // ss-textarea const SsTextarea = { name: "SsTextarea", props: { name: { type: String, required: true, }, placeholder: { type: String, default: "请输入", }, defaultValue: [String, Number], modelValue: [String, Number], }, emits: ["update:modelValue"], setup(props, { emit }) { const inputValue = ref(props.modelValue || props.defaultValue || ""); // 监听 modelValue 变化 watch( () => props.modelValue, (newVal) => { inputValue.value = newVal; } ); // 输入事件处理 const onInput = (event) => { const newValue = event.target.value; inputValue.value = newValue; emit("update:modelValue", newValue); // 触发验证 if (window.ssVm) { window.ssVm.validateField(props.name); } }; // 失焦时验证 const onBlur = () => { if (window.ssVm) { window.ssVm.validateField(props.name); } }; return { inputValue, onInput, onBlur }; }, render() { return h("div", { class: "textarea-container" }, [ h("div", { class: "textarea" }, [ // 添加隐藏的 input 用于验证 h("input", { type: "hidden", name: this.name, value: this.inputValue || "", }), h("textarea", { placeholder: this.placeholder, value: this.inputValue, onInput: this.onInput, onBlur: this.onBlur, }), ]), ]); }, }; // ss-editor 富文本编辑器 基于Jodit const SsEditor = { name: "SsEditor", props: { modelValue: { type: String, default: "", }, name: { type: String, default: "", }, url: { type: String, default: "", }, height: { type: [Number, String], default: 400, }, placeholder: { type: String, default: "请输入内容", }, readonly: { type: Boolean, default: false, }, uploadUrl: { type: String, default: "/ulByHttp",//原值为“upload” Ben(20251205) }, param: { type: Object, default: () => ({}), }, }, emits: ["update:modelValue", "ready", "change"], setup(props, { emit }) { const SsEditorIcon = resolveComponent("SsEditorIcon"); const editorRef = ref(null); let editorContent = '';//保存富文本编辑器里面的富文本内容 const uniqueId = "editor-" + Date.now(); const errMsg = Vue.ref(""); let fjid = ref(props.param.button.val); let fjName = props.param.button.desc; let mode = props.param.mode; const validate = () => { if (window.ssVm) { const result = window.ssVm.validateField(props.name); console.log("validate", window.ssVm.validateField(props.name)); errMsg.value = result.valid ? "" : result.message; } }; onMounted(() => { validate(); const editor = Jodit.make(`#${uniqueId}`, { height: props.height, placeholder: props.placeholder, readonly: props.readonly, language: "zh_cn", i18n: { zh_cn: { Link: "链接", URL: "链接", "No follow": "无跟踪", "Class name": "类名", Image: "图片", File: "文件", "Line height": "行高", Alternative: "描述", "Alternative text": "描述", "Lower Alpha": "小写字母", "Upper Alpha": "大写字母", "Upper Roman": "大写罗马数字", "Lower Roman": "小写罗马数字", "Lower Greek": "小写希腊字母", "Lower Letter": "小写字母", "Upper Letter": "大写字母", }, }, showXPathInStatusbar: false, showCharsCounter: false, showWordsCounter: false, allowResizeY: false, toolbarSticky: false, statusbar: false, uploader: { url: props.uploadUrl, format: "json", method: "POST", filesVariableName: function (i) { return "imgs[" + i + "]"; }, headers: { Accept: "application/json", }, prepareData: function (formData) { // 这里可以在发送前处理表单数据 return formData; }, isSuccess: function (resp) { console.log("isSuccess resp:", resp); return resp.code === 0; }, getMessage: function (resp) { console.log("getMessage resp:", resp); return resp.msg || "上传失败"; }, process: function (resp) { console.log("process resp:", resp); return resp.data.url; }, error: function (e) { console.error("上传失败:", e.message); }, defaultHandlerSuccess: function (resp) { console.log("上传成功:", resp); }, defaultHandlerError: function (err) { console.error("上传错误:", err); }, contentType: function (requestData) { // 可以根据需要修改 Content-Type return false; // 让浏览器自动设置 }, }, // 自定义字体列表 controls: { font: { list: { Arial: "Arial", SimSun: "宋体", SimHei: "黑体", "Microsoft YaHei": "微软雅黑", KaiTi: "楷体", FangSong: "仿宋", "Times New Roman": "Times New Roman", "Courier New": "Courier New", }, }, customLinkButton: { name: "link", tooltip: "附件", exec: function (editor) { // 按钮点击时的处理函数 console.log("附件点击了"); console.log("param", props.param); console.log("cmsAddUrl", props.param.button.cmsAddUrl); if (fjid.value == null || fjid.value == "") { $.ajax({ type: "post", url: props.param.button.cmsAddUrl, async: false, data: { name: "fjid", ssNrObjName: "sh", ssNrObjId: "", }, success: function (_fjid) { console.log("cmsAddUrl success", _fjid); fjid.value = _fjid; }, }); } var str = "&nrid=T-" + fjid.value + "&objectId=" + fjid.value + "&objectName=" + fjName + "&callback=" + (window["fjidCallbackName"] || ""); console.log("str", str); SS.openDialog({ src: props.param.button.cmsUpdUrl + str, headerTitle: "编辑", width: 900, high: 664, zIndex: 51 }); // ss.display.showComponent({ // show: ["wdDialog"], // url: props.param.button.cmsUpdUrl + str, // title: "编辑", // width: 900, // high: 664, // }); }, }, }, toolbarAdaptive: true, buttons: [ "fullsize", "bold", "italic", "underline", "strikethrough", "eraser", "|", "font", "fontsize", "brush", "paragraph", "|", "left", "center", "right", "justify", "|", "ul", "ol", "indent", "outdent", "|", "image", "table", "customLinkButton", "print", "|", "undo", "redo", "find", ], // 中等宽度时显示的按钮 buttonsMD: [ "fullsize", "bold", "italic", "underline", "strikethrough", "eraser", "|", "font", "fontsize", "brush", "paragraph", "|", "font", "fontsize", "|", "left", "center", "right", "justify", "|", "image", "customLinkButton", "|", "dots", // 其余按钮会自动进入 dots 菜单 ], // 小屏幕时显示的按钮 buttonsSM: ["fullsize", "bold", "italic", "|", "image", "|", "dots"], // 超小屏幕时显示的按钮 buttonsXS: ["fullsize", "bold", "|", "dots"], // 设置响应式断点 sizeLG: 1024, // 大屏幕 sizeMD: 768, // 中等屏幕 sizeSM: 576, // 小屏幕 // 自定义图标 getIcon: function (name, clearName) { // 定义图标映射 const iconMap = { bold: "editor-icon-bold", italic: "editor-icon-italic", underline: "editor-icon-underline", strikethrough: "editor-icon-strikethrough", eraser: "editor-icon-eraser", copyformat: "editor-icon-copyformat", font: "editor-icon-font", fontsize: "editor-icon-fontsize", brush: "editor-icon-brush", paragraph: "editor-icon-paragraph", left: "editor-icon-align-left", center: "editor-icon-align-center", right: "editor-icon-align-right", justify: "editor-icon-align-justify", ul: "editor-icon-ul", ol: "editor-icon-ol", indent: "editor-icon-indent", outdent: "editor-icon-outdent", image: "editor-icon-image", file: "editor-icon-file", video: "editor-icon-video", table: "editor-icon-table", link: "editor-icon-link", source: "editor-icon-source", eye: "editor-icon-preview", fullsize: "editor-icon-fullsize", shrink: "editor-icon-fullsize-exit", // 添加退出全屏图标 print: "editor-icon-print", undo: "editor-icon-undo", redo: "editor-icon-redo", search: "editor-icon-find", selectall: "editor-icon-selectall", }; // 获取对应的图标类名 const iconClass = iconMap[clearName] || iconMap[name]; if (iconClass) { // 返回带有我们自定义图标类的 span 元素 return ``; } return null; }, }); // 设置初始值 editor.value = editorContent; // editor.value = props.modelValue; // 监听变化 editor.events.on("change", () => { // emit("update:modelValue", editor.value); editorContent = editor.value; // alert('editorContent:'+editorContent); let contentElements = document.getElementsByName(props.name.replace(/wj$/, "") + "Edit"); if (contentElements.length > 0) { contentElements[0].value = editorContent; } emit("change", editor.value); setTimeout(() => { validate(); }, 50); }); // 保存编辑器实例 editorRef.value = editor; emit("ready", editor); //回显编辑器富文本文件 if (props.url) { const params = new URLSearchParams(); if (mode) params.append("mode", mode); if (props.modelValue) params.append("path", props.modelValue); // alert('props.url:'+props.url+',props.modelValue:'+props.modelValue); axios .post(props.url, params, { headers: { "Content-Type": "application/x-www-form-urlencoded", // 必须手动设置! }, }) .then((response) => { // alert(JSON.stringify(response.data)); if ("timeout" == response.data.statusText) { alert("网络超时!"); return; } let content = response.data.content; if (content) { // editor.value = content; editorRef.value.value = content; editorContent = content; // alert('editor.value:'+editor.value) } let filePath = response.data.path; // alert('response.data:'+JSON.stringify(response.data)); if (filePath) { props.modelValue = filePath; emit("update:modelValue", filePath); } }); } }); // 监听值变化 watch( // () => props.modelValue, () => editorContent, (newValue) => { if (editorRef.value && newValue !== editorRef.value.value) { editorRef.value.value = newValue || ""; } } ); // 监听只读状态变化 watch( () => props.readonly, (newValue) => { if (editorRef.value) { editorRef.value.setReadOnly(newValue); } } ); // 组件销毁时清理 onBeforeUnmount(() => { if (editorRef.value) { editorRef.value.destruct(); } }); return () => h("div", { class: "ss-editor-container" }, [ fjid.value && h("input", { type: "hidden", name: "fjid", value: fjid.value, }), h("input", { type: "hidden", name: props.name.replace(/wj$/, "") + "Edit", value: editorContent // value: props.modelValue, }), h("input", { type: "hidden", name: props.name.replace(/wj$/, "") + "wj", value: props.modelValue // value: props.url }), h("input", { type: "hidden", name: "ueditorpath", value: "mswj" }), h("textarea", { id: uniqueId }), ]); }, }; // 弹窗右边图标 const SsFullStyleHeader = { name: "SsFullStyleHeader", props: { title: { type: String, default: "标题", }, }, emits: ["close"], setup(props, { emit }) { // console.log(props.title) const onClose = () => { emit("close"); }; const SsIcon = resolveComponent("ss-icon"); return () => h("div", { class: "header-container" }, [ h("div", { class: "title" }, props.title), h("div", { class: "handle-bar" }, [ h("div", { class: "left-bar" }, [ h(SsDialogIcon, { class: "dialog-icon-download" }), h(SsDialogIcon, { class: "dialog-icon-print" }), h(SsDialogIcon, { class: "dialog-icon-setting" }), h(SsDialogIcon, { class: "dialog-icon-collect" }), h(SsDialogIcon, { class: "dialog-icon-help" }), h(SsDialogIcon, { class: "dialog-icon-full-screen" }), h(SsDialogIcon, { class: "dialog-icon-lock" }), ]), h("div", { class: "close-bar", onClick: onClose }, [ h(SsDialogIcon, { class: "dialog-icon-close" }), ]), ]), ]); }, }; // ss-dialog弹窗 const SsDialog = { name: "SsDialog", props: { src: { type: String, }, headerTitle: { type: String, // required: true, default: "弹窗", }, width: { type: String, default: "1400", }, height: { type: String, default: "600", }, params: { type: Object, default: () => ({}), }, zIndex: { type: Number, default: 1000, }, }, emits: ["close"], setup(props, { slots, emit }) { // 关闭窗口方法 const onClose = () => { emit("close"); }; const showHeader = ref(true); const headerVisible = ref(false); const popupHieght = ref(props.height); // 状态:存储位置信息 const position = reactive({ // 页面居中 x: (window.innerWidth - props.width) / 2, y: (window.innerHeight - popupHieght.value) / 2, isDragging: false, offsetX: 0, offsetY: 0, }); // 鼠标按下时设置起始坐标并开始拖拽 const startDrag = (event) => { position.isDragging = true; position.offsetX = event.clientX - position.x; position.offsetY = event.clientY - position.y; }; // 鼠标移动时更新位置 const onDrag = (event) => { if (position.isDragging) { position.x = event.clientX - position.offsetX; position.y = event.clientY - position.offsetY; } }; // 鼠标放开时结束拖拽 const endDrag = () => { position.isDragging = false; }; // 监听来自 iframe 的消息 const handleMessage = (event) => { // 顶天立地 if (event.data && typeof event.data.hasScrollBar !== "undefined") { if (event.data.hasScrollBar) { // console.log(event); position.y = 10; showHeader.value = false; headerVisible.value = true; popupHieght.value = window.innerHeight - 20; // console.log(popupHieght.value); document.querySelector(".body").style.height = "100%"; document.querySelector(".body").style.paddingTop = "30px"; document.querySelector(".header-container ").style.position = "absolute"; document.querySelector(".header-container ").style.zIndex = "10"; } } }; // 鼠标移入关闭按钮区域时显示头部 const onMouseEnterCloseButton = () => { headerVisible.value = false; }; // 鼠标移出关闭按钮区域时隐藏头部 const onMouseLeaveCloseButton = () => { headerVisible.value = true; }; // 在组件挂载时添加全局事件监听器 Vue.onMounted(() => { // 如果传过来的高度大于窗口高度,则设置为窗口高度减去20 否则保持传过来的高度 popupHieght.value = popupHieght.value > window.innerHeight ? window.innerHeight - 20 : popupHieght.value; const container = document.querySelector(".header-container"); if (container) { container.addEventListener("mousedown", startDrag); } document.addEventListener("mousemove", onDrag); document.addEventListener("mouseup", endDrag); window.addEventListener("message", handleMessage); }); // 在组件卸载时移除全局事件监听器 Vue.onUnmounted(() => { document.removeEventListener("mousemove", onDrag); document.removeEventListener("mouseup", endDrag); window.removeEventListener("message", handleMessage); }); const SsMark = resolveComponent("ss-mark"); const SsFullStyleHeader = resolveComponent("ss-full-style-header"); // render函数定义组件结构 return () => h( Teleport, { to: "body" }, // 使用 Teleport 将弹窗内容挂载到 body h(SsMark, {}, [ h( "div", { class: "popup-container", style: { position: "absolute", left: `${position.x}px`, top: `${position.y}px`, width: props.width + "px", height: popupHieght.value + "px", zIndex: props.zIndex, // 确保弹窗在最上层 }, }, [ h(SsFullStyleHeader, { class: "header", title: props.headerTitle, onClose: onClose, onMousedown: startDrag, // 绑定拖动事件 onMouseUp: endDrag, ...(!showHeader.value && { onMouseenter: onMouseEnterCloseButton, onMouseleave: onMouseLeaveCloseButton, }), style: { cursor: position.isDragging ? "grabbing" : "grab", visibility: headerVisible.value ? "hidden" : "visible", }, }), h( "div", { class: "body", style: {}, }, [ h("iframe", { src: props.src, frameborder: 0, style: { width: "100%", height: "100%" }, }), ] ), headerVisible.value && h("div", { class: "close-button", onMouseenter: onMouseEnterCloseButton, onMouseleave: onMouseLeaveCloseButton, style: { position: "absolute", top: "0", right: "0", // background: 'black', width: "60px", height: "60px", cursor: "pointer", }, }), ] ), ]) ); }, }; // ss-mark遮罩层 const SsMark = { name: "SsMark", setup(props, { slots, emit }) { return () => h("div", { class: "dialog-container" }, [ h("div", { class: "mark-content" }, [ h("div", { class: "dialog-contianer" }, [ slots.default ? slots.default() : "", ]), ]), ]); }, }; // ss-bottom-button 底部按钮 // 修改支持更多按钮 by xu 20251211 const SsBottomButton = { name: "SsBottomButton", props: { text: { type: String, required: false, }, type: { type: String, default: "button", }, iconClass: { type: String, }, class: { type: String, default: "", }, onclick: { type: [Function, String], default: null, }, // 修改支持更多按钮 by xu 20251211 more: { type: [Boolean, String], default: false, }, }, setup(props, { emit }) { const SsBottomDivIcon = Vue.resolveComponent("ss-bottom-div-icon"); const showDropdown = Vue.ref(false); // 修改支持更多按钮 by xu 20251211 const moreKey = Vue.computed(() => { const val = props.more; if (val === false || val === null || typeof val === "undefined") { return null; } if (val === true || val === "" || val === "true") { return "moreChg"; } return val; }); // 从配置中读取按钮信息和下拉选项 const config = Vue.computed(() => { if (moreKey.value && window.ss && window.ss.dom && window.ss.dom.btnElemConfig) { return window.ss.dom.btnElemConfig[moreKey.value] || {}; } return {}; }); const buttonText = Vue.computed(() => { return props.text || config.value.desc || ''; }); const dropOptions = Vue.computed(() => { return config.value.dropOptions || []; }); const hasDropdown = Vue.computed(() => { return dropOptions.value.length > 0; }); const handleMouseEnter = () => { if (hasDropdown.value) { showDropdown.value = true; } }; const handleMouseLeave = () => { showDropdown.value = false; }; const handleDropItemClick = (option) => { if (option.callback && typeof option.callback === 'function') { option.callback(); } showDropdown.value = false; }; return () => h( "div", { class: "ss-bottom-button-wrapper", onMouseenter: handleMouseEnter, onMouseleave: handleMouseLeave, }, [ h( "button", { class: props.class, onClick: (e) => { e.stopPropagation(); if (props.onclick) { // 如果是函数直接调用 if (typeof props.onclick === "function") { props.onclick(e); } else if (typeof props.onclick === "string") { // 如果是字符串,使用直接的方法执行 // 临时存储按钮元素到全局变量 window.__ss_current_button = e.currentTarget; // 直接执行代码,使用eval以保留原始上下文 try { eval(props.onclick); } finally { // 清理全局变量 delete window.__ss_current_button; } } } }, type: props.type, }, [ h("span", null, [ h(SsBottomDivIcon, { class: props.iconClass, }), ]), h("span", null, buttonText.value), ] ), // 渲染下拉菜单 hasDropdown.value && showDropdown.value ? h( "div", { class: "ss-bottom-button-dropdown", }, dropOptions.value.map((option) => h( "div", { class: "ss-bottom-button-dropdown-item", onClick: (e) => { e.stopPropagation(); handleDropItemClick(option); }, }, option.desc ) ) ) : null, ] ); }, }; // ss-search搜索框 const SsSearch = { name: "SsSearch", props: { theme: { type: String, default: "light", validator: function (value) { return ["dark", "light"].includes(value); }, }, placeholder: { type: String, default: "请输入搜索条件", }, }, setup(props, { emit }) { const onClick = () => { console.log("Search clicked"); emit("click"); }; const SsIcon = Vue.resolveComponent("ss-icon"); return () => Vue.h( "div", { class: ["search-container", props.theme], onClick: onClick, }, [ Vue.h("input", { placeholder: props.placeholder, disabled: true, }), Vue.h(SsIcon, { name: "search-result", size: "20px", }), ] ); }, }; // ss-cart-item 菜单页面的卡片 左右结构 const SsCartItem = { name: "SsCartItem", props: { active: Boolean, item: { type: Object, default: () => ({ thumb: "images/example/project-img.png", title: "广州(国际)科技成果转化天河基地专", description: "佳能中国广州分公司", all: 50, finish: 5, }), }, }, setup(props, { emit }) { const item = props.item; const itemWidth = Vue.computed(() => { const containerWidth = document.body.clientWidth || document.body.scrollWidth - 520; const halfWidth = containerWidth / 2; if (halfWidth < 480) { return Math.min(containerWidth, 702) + "px"; } else { return Math.min(halfWidth, 702) + "px"; } }); const onItemClick = (e) => { emit("click", e); }; return { item, itemWidth, onItemClick, }; }, render() { const SsIcon = Vue.resolveComponent("ss-icon"); return Vue.h( "div", { class: { "item-container": true, active: this.active }, onClick: this.onItemClick, style: { width: this.itemWidth }, }, [ Vue.h("div", { class: "header" }, [ Vue.h(SsIcon, { name: "setting", size: "20px" }), ]), Vue.h("div", { class: "body" }, [ Vue.h("div", { class: "left" }, [ Vue.h("img", { src: this.item.thumb, alt: "Thumbnail", class: "imgUnHandle", style: { "object-fit": "cover", width: "100%", height: "100%" }, }), ]), Vue.h("div", { class: "right" }, [ Vue.h("div", { class: "title" }, this.item.title), Vue.h("div", { class: "desc" }, this.item.description), Vue.h("div", { class: "progress" }, [ Vue.h( "div", { style: { width: `${(this.item.finish / this.item.all) * 100}%`, }, }, [Vue.h("div", `${this.item.finish}/${this.item.all}`)] ), ]), ]), ]), ] ); }, }; // ss-cart-item2 菜单页面的卡片 上下结构 const SsCartItem2 = { name: "SsCartItem2", props: { active: Boolean, item: { type: Object, default: () => ({ thumb: "images/example/project-img.png", title: "广州(国际)科技成果转化天河基地专", description: "佳能中国广州分公司", all: 50, finish: 5, }), }, }, setup(props, { emit }) { const item = props.item; const itemWidth = Vue.computed(() => { const containerWidth = document.body.clientWidth || document.body.scrollWidth - 520; const halfWidth = containerWidth / 2; if (halfWidth < 480) { return Math.min(containerWidth, 702) + "px"; } else { return Math.min(halfWidth, 702) + "px"; } }); const onItemClick = (e) => { emit("click", e); }; return { item, itemWidth, onItemClick, }; }, render() { const SsIcon = Vue.resolveComponent("ss-icon"); return Vue.h( "div", { class: { "item-container2": true, active: this.active }, onClick: this.onItemClick, style: { width: this.itemWidth }, }, [ Vue.h("div", { class: "action-bar" }, [ Vue.h(SsIcon, { name: "setting", size: "20px" }), ]), Vue.h("div", { class: "header" }, [ Vue.h("div", { class: "title" }, `${this.item.title}`), ]), Vue.h("div", { class: "body" }, [ Vue.h("div", { class: "left" }, [ Vue.h("img", { src: this.item.thumb, alt: "Thumbnail", class: "imgUnHandle", style: { "object-fit": "cover", width: "100%", height: "100%" }, }), ]), Vue.h("div", { class: "right" }, [ Vue.h("div", { class: "content" }, this.item.description), Vue.h("div", { class: "tip" }, [ Vue.h("div", { class: "progress" }, [ Vue.h( "div", { style: { width: `${(this.item.finish / this.item.all) * 100}%`, }, }, [Vue.h("div", `${this.item.finish}/${this.item.all}`)] ), ]), ]), ]), ]), ] ); }, }; /** * SsListCard - 列表卡片组件 * * @description 用于显示列表项的卡片组件,支持缩略图、标签、状态、操作按钮和选择功能 * * @prop {Object} item - 卡片数据对象 * @prop {String} item.title - 卡片标题 * @prop {String} [item.thumb] - 缩略图 URL(可选) * @prop {String} [item.thumbType] - 缩略图类型:'thumbnail'(缩略图)或默认(证件照) * @prop {String} [item.status] - 卡片状态:'available'(可用-绿色)、'unavailable'(不可用-黄色)、'disabled'(禁用-红色) * @prop {Array} item.tags - 标签数组,格式:[{键: 值}, ...] * @prop {Function} item.onclick - 点击卡片的回调函数 * @prop {Array} [item.buttons] - 操作按钮数组(可选),显示在右上角齿轮 * @prop {Array} [item.statusIcons] - 状态图标数组(可选),显示在右上角,格式:[{class: '图标类名', title: '提示文字'}, ...] * * @example * // 基础用法 * const item = { * title: "卡片标题", * tags: [ * { 类型: '文档' }, * { 状态: '进行中' } * ], * onclick: () => console.log('点击了卡片') * }; * * @example * // 带缩略图和状态 * const item = { * title: "场地预定", * thumbType: 'thumbnail', * thumb: "https://example.com/image.jpg", * status: "available", // 绿色背景 * tags: [{ 容量: '50人' }], * onclick: () => {} * }; * * @example * // 带操作按钮和状态图标 * const item = { * title: "会议室A", * tags: [{ 楼层: '3F' }], * onclick: () => {}, * // 右上角操作按钮(齿轮) * buttons: [{ * class: 'cart-list-setting', * title: '编辑', * onclick: () => console.log('编辑') * }], * // 右上角状态图标(在齿轮右边) * statusIcons: [{ * class: 'icon-emoji', * title: '清洁中' * }] * }; * * @features * - 卡片选择:鼠标悬停右下角显示选择角标,点击切换选中状态,选中后显示底部深灰色线条 * - 状态颜色:根据 status 字段显示不同背景色(可用/不可用/禁用) * - 图片类型:支持证件照(73×100px)和缩略图(180×100px)两种尺寸 * - 操作按钮:右上角齿轮,hover 显示,支持多个按钮下拉菜单 * - 状态图标:右上角显示状态图标,齿轮会根据图标数量自动左移 * * @author xu * @date 20260105 */ // 组件文档补全(JSDoc) by xu 20260108 /** * SsListCard(左侧对象卡片) * * 用途: * - 渲染左侧卡片(标题 + tags) * - 右下角“角标”用于选中/取消选中(不会触发卡片 click) * * 调用示例: * ```html * * ``` * * Props: * - `item`:卡片数据对象(建议含 `id/title/tags[]`;内部会读写 `item._ssSelected` 作为选中态) * * Emits: * - `toggle-select`:点击角标触发,参数 `{ item, selected }` * - `click`:点击卡片主体触发(用于打开详情等) */ const SsListCard = { name: "SsListCard", props: { ssObjName: { type: String, default: "" }, // 功能:业务对象名(用于默认缩略图 icon) by xu 20260109 cardClickAction: { type: String, default: "view" }, // 功能:卡片主体点击动作(view=查看;single=单选互斥) by xu 20260109 item: { type: Object, required: true, }, }, emits: ["click", "change", "toggle-select"], setup(props, { emit }) { const item = props.item; // 移除 itemWidth 计算属性,不再需要 by xu 20260105 // 判断卡片类型 by xu 20260105 const cardType = Vue.computed(() => { // 支持“无图但保留缩略图占位”(同一业务列表图片形态一致) by xu 20260109 if (!item.thumb && !item.thumbType) return ''; // 业务列表“无图”形态 // 根据 thumbType 字段判断,如果没有则默认为证件照 return item.thumbType === 'thumbnail' ? 'card-thumbnail' : 'card-photo'; }); // 判断状态类型 - 场地预定状态 by xu 20260105 const statusClass = Vue.computed(() => { if (!item.status) return ''; // 无状态,默认白色 // 映射状态值到 CSS 类名 const statusMap = { 'available': 'status-available', 'unavailable': 'status-unavailable', 'disabled': 'status-disabled' }; return statusMap[item.status] || ''; }); const onItemClick = (e) => { // 清除所有类型卡片的 active 状态(卡片主体点击仅做 active 高亮) by xu 20260109 const allListCards = document.querySelectorAll( ".knowledge-item-container" ); const allFolderCards = document.querySelectorAll(".ss-folder-list"); allListCards.forEach((card) => card.classList.remove("active")); allFolderCards.forEach((card) => card.classList.remove("active")); // 设置当前项的 active 状态 e.currentTarget.classList.add("active"); // 卡片主体点击动作由页面级配置控制 by xu 20260109 if (props.cardClickAction === "view") { props.item.onclick?.(); // 通知父级:卡片点击(查看) by xu 20260109 emit("click", props.item); } }; const onItemChange = (e, icon, index) => { e.stopPropagation(); // 阻止事件冒泡到卡片 props.item.buttons[0].onclick(); // emit("change", { item: props.item, icon, index }); }; return { item, cardType, statusClass, onItemClick, onItemChange, }; }, data() { return { showButtons: false, selected: false, // 选择状态 by xu 20260105 showTextPopover: false, // 功能:右侧文字区 hover 展示全量 by xu 20260108 textPopoverType: "", // second-summary / second-tags / third / third-full by xu 20260108 textPopoverBottom: 0, // 功能:popover 从当前省略行位置向上展开 by xu 20260108 hideTextPopoverTimer: null, // 功能:鼠标从省略行移到浮层的缓冲 by xu 20260108 textPopoverPayload: null, // { kind, text?, lines? } by xu 20260108 ellipsisVisible: { // 功能:只在真实出现 ... 时才显示命中区/允许 goheight by xu 20260109 secondSummary: false, secondTags: false, third: false, thirdFull: false, }, }; }, methods: { // 切换选择状态(对外 emit,支持方案A父级 state 中转) by xu 20260106 toggleSelect(e) { e.stopPropagation(); // 使用 item 上的状态,便于父级/右侧边栏反向同步 by xu 20260106 this.item._ssSelected = !this.item?._ssSelected; this.$emit("toggle-select", { item: this.item, selected: !!this.item?._ssSelected }); }, // 卡片主体点击=单选互斥:只有“本次切到选中”才清理其他选中 by xu 20260109 toggleSelectExclusive(e) { e?.stopPropagation?.(); this.item._ssSelected = !this.item?._ssSelected; const selected = !!this.item?._ssSelected; this.$emit("toggle-select", { item: this.item, selected, exclusive: true }); }, // 功能:无缩略图时,用业务对象 icon 做默认图(ss-icon + icon-obj-xx) by xu 20260109 getBizThumbIconClass() { const name = String( this.ssObjName || this.item?.ssObjName || this.$root?.ssObjName || window?.ss?.dom?.ssObjName || "" ).trim(); if (!name) return ""; return `icon-obj-${name}`; }, // 功能:构造右侧文字区 4 行(摘要/类目或标签/对象号) by xu 20260108 buildRightTextLines() { const item = this.item || {}; const summary = String(item?.desc ?? "").trim(); // 后端字段后续映射 by xu 20260108 const objNum = String(item?.objNum ?? "").trim(); // 后端字段后续映射 by xu 20260108 const categoryArr = Array.isArray(item?.category) ? item.category : []; const tagsArr = Array.isArray(item?.tags) ? item.tags : []; const hasTags = tagsArr.length > 0; const hasCategory = categoryArr.length > 0; // 第二部分(L1-L3):摘要优先,其次物品参数 by xu 20260108 const secondKind = summary ? "summary" : hasTags ? "tags" : ""; const hasSecond = !!secondKind; // 第三部分(L4):对象号优先,其次类目 by xu 20260108 const thirdKind = objNum ? "objNum" : hasCategory ? "category" : ""; const hasThird = !!thirdKind; const thirdFull = !hasSecond && hasThird; // 第二部分为空则第三部分占满 4 行 by xu 20260108 const secondFull = hasSecond && !hasThird && secondKind === "tags"; // 仅标签时占满 4 行(不留空行) by xu 20260109 const toValueText = (obj) => { // 功能说明:类目/物品参数回显展示 key: value(否则只显示值看不懂) by xu 20260114 const [k, v] = Object.entries(obj || {})[0] || ["", ""]; const key = k !== undefined && k !== null ? String(k).trim() : ""; const val = v !== undefined && v !== null ? String(v).trim() : ""; if (key && val) return `${key}:${val}`; if (val) return val; if (key) return key; return ""; }; const toLineList = (arr) => (arr || []) .map(toValueText) .map((s) => String(s ?? "").trim()) .filter(Boolean); const flat = (arr) => toLineList(arr).join(" "); // 第二部分 tags:默认 3 行;仅标签时占满 4 行(避免底部空一行) by xu 20260109 const secondTagsMaxLines = secondFull ? 4 : 3; const secondTagsLinesFull = toLineList(tagsArr); const secondTagsHead = secondTagsLinesFull.slice(0, Math.max(0, secondTagsMaxLines - 1)); const secondTagsTail = secondTagsLinesFull.slice(Math.max(0, secondTagsMaxLines - 1)); const secondTagsLast = secondTagsTail.length <= 1 ? (secondTagsTail[0] || "") : secondTagsTail.join(" "); // 功能:最后一行平铺剩余 by xu 20260108 // 第三部分类目:卡片上串成一行;goheight 展开时一条一行 by xu 20260109 const categoryLinesFull = toLineList(categoryArr); const categoryLine = categoryLinesFull.join(" "); const thirdText = thirdKind === "objNum" ? objNum : thirdKind === "category" ? categoryLine : ""; return { secondKind, thirdKind, hasSecond, hasThird, thirdFull, secondFull, summary, secondTagsMaxLines, secondTagsHead, secondTagsLast, secondTagsLinesFull, objNum, categoryLine, categoryLinesFull, thirdText, }; }, measureTextOverflowByLines(text, maxLines, width) { const w = Number(width) || 0; if (!w || !text) return false; const probe = document.createElement("div"); probe.style.position = "fixed"; probe.style.left = "-99999px"; probe.style.top = "0"; probe.style.width = w + "px"; probe.style.fontSize = "18px"; probe.style.lineHeight = "24px"; probe.style.whiteSpace = "normal"; probe.style.wordBreak = "break-word"; probe.style.visibility = "hidden"; probe.textContent = text; document.body.appendChild(probe); const h = probe.getBoundingClientRect().height || 0; document.body.removeChild(probe); return h > maxLines * 24 + 1; }, measureSingleLineOverflow(text, width) { const w = Number(width) || 0; if (!w || !text) return false; const probe = document.createElement("span"); probe.style.position = "fixed"; probe.style.left = "-99999px"; probe.style.top = "0"; probe.style.display = "inline-block"; probe.style.maxWidth = w + "px"; probe.style.fontSize = "18px"; probe.style.lineHeight = "24px"; probe.style.whiteSpace = "nowrap"; probe.style.visibility = "hidden"; probe.textContent = text; document.body.appendChild(probe); const overflow = (probe.scrollWidth || 0) > (probe.clientWidth || w) + 1; document.body.removeChild(probe); return overflow; }, // 功能:根据当前卡片宽度刷新「是否出现 ...」状态(用于控制命中区显示) by xu 20260109 refreshEllipsisVisible() { try { const right = this.$el?.querySelector?.(".right"); const rawWidth = right?.getBoundingClientRect?.().width || right?.clientWidth || 0; const width = Math.max(0, Math.round(rawWidth)); // 修复:内容区不再使用 padding-right 预留,测量按真实宽度 by xu 20260109 const model = this.buildRightTextLines(); const next = { secondSummary: false, secondTags: false, third: false, thirdFull: false, }; if (model.secondKind === "summary" && model.summary) { next.secondSummary = this.measureTextOverflowByLines(model.summary, 3, width); } if (model.secondKind === "tags") { // 功能说明:tags 采用「最后一行平铺剩余」策略,是否出现 ... 仅取决于最后一行是否溢出(数量多但平铺放得下不算溢出) by xu 20260114 next.secondTags = this.measureSingleLineOverflow(model.secondTagsLast, width); } if (model.hasThird && !model.thirdFull) { next.third = this.measureSingleLineOverflow(model.thirdText, width); } if (model.hasThird && model.thirdFull) { next.thirdFull = this.measureTextOverflowByLines(model.thirdText, 4, width); } const prev = this.ellipsisVisible || {}; const changed = prev.secondSummary !== next.secondSummary || prev.secondTags !== next.secondTags || prev.third !== next.third || prev.thirdFull !== next.thirdFull; if (changed) this.ellipsisVisible = next; } catch (e) { // ignore by xu 20260109 } }, showTextPopoverFor(el, kind) { // 调试开关:window.__SS_LISTCARD_DEBUG__ = true 时打印 hover/溢出判断日志 by xu 20260108 const debug = typeof window !== "undefined" && !!window.__SS_LISTCARD_DEBUG__; if (debug) { console.log("[SsListCard] ellipsis hover", { kind, el: el?.className, }); } const model = this.buildRightTextLines(); const lineEl = el?.closest?.(".ss-card-text__line") || el; const right = lineEl?.closest?.(".right") || el?.closest?.(".right"); const rawWidth = right?.getBoundingClientRect?.().width || right?.clientWidth || 0; const width = Math.max(0, Math.round(rawWidth)); // 修复:内容区不再预留 padding-right,测量按真实宽度 by xu 20260109 const textEl = kind === "second-summary" ? lineEl?.querySelector?.(".ss-card-text__secondSummary") : kind === "second-tags" ? lineEl?.querySelector?.(".ss-card-text__tagLineLast") : kind === "third" ? lineEl?.querySelector?.(".ss-card-text__thirdLine") : lineEl?.querySelector?.(".ss-card-text__thirdFull"); let payload = null; // 仅当真实会出现 ... 时才允许 goheight(避免“没超出也能出 goheight”) by xu 20260109 const overflowed = kind === "second-summary" ? this.measureTextOverflowByLines(model.summary, 3, width) : kind === "second-tags" ? this.measureSingleLineOverflow(model.secondTagsLast, width) // 功能说明:同 refreshEllipsisVisible,tags 仅看最后一行是否溢出 by xu 20260114 : kind === "third" ? this.measureSingleLineOverflow(model.thirdText, width) : this.measureTextOverflowByLines(model.thirdText, 4, width); if (!overflowed) return; if (kind === "second-summary") { if (model.summary) payload = { kind, text: model.summary }; } else if (kind === "second-tags") { if (Array.isArray(model.secondTagsLinesFull) && model.secondTagsLinesFull.length) { payload = { kind, lines: model.secondTagsLinesFull }; } } else if (kind === "third") { if (model.thirdKind === "category" && Array.isArray(model.categoryLinesFull) && model.categoryLinesFull.length) { payload = { kind, lines: model.categoryLinesFull }; // 功能:类目展开一条一行 by xu 20260109 } else if (model.thirdText) { payload = { kind, text: model.thirdText }; } } else if (kind === "third-full") { if (model.thirdKind === "category" && Array.isArray(model.categoryLinesFull) && model.categoryLinesFull.length) { payload = { kind, lines: model.categoryLinesFull }; // 功能:类目占满模式展开一条一行 by xu 20260109 } else if (model.thirdText) { payload = { kind, text: model.thirdText }; } } if (debug) { console.log("[SsListCard] ellipsis decide", { kind, rawWidth: Math.round(rawWidth), width, hasPayload: !!payload, textEl: textEl?.className, textClient: textEl ? { cw: textEl.clientWidth, ch: textEl.clientHeight, sw: textEl.scrollWidth, sh: textEl.scrollHeight } : null, }); } if (!payload) return; this.clearHideTextPopoverTimer(); const container = lineEl?.closest?.(".right"); const containerRect = container?.getBoundingClientRect?.(); const lineRect = lineEl?.getBoundingClientRect?.(); if (containerRect && lineRect) { const bottom = Math.max(0, Math.round(containerRect.bottom - lineRect.bottom)); this.textPopoverBottom = bottom; } else { this.textPopoverBottom = 0; } this.textPopoverPayload = payload; this.textPopoverType = kind; this.showTextPopover = true; if (debug) console.log("[SsListCard] goheight show", payload); }, isOverflowing(el) { if (!el) return false; // 单行/多行省略统一判断:scroll 尺寸大于 client 尺寸即认为有 ... by xu 20260108 return (el.scrollWidth && el.clientWidth && el.scrollWidth > el.clientWidth + 1) || (el.scrollHeight && el.clientHeight && el.scrollHeight > el.clientHeight + 1); }, isSummaryOverflowing(el) { if (!el) return false; // -webkit-line-clamp 场景下 scrollHeight 不稳定,改用“无 clamp 的离屏测量”判断是否超过 2 行 by xu 20260108 const text = String(this.item?.desc ?? "").trim(); if (!text) return false; const rect = el.getBoundingClientRect?.(); const width = rect?.width || el.clientWidth || 0; if (!width) return false; const probe = document.createElement("div"); probe.style.position = "fixed"; probe.style.left = "-99999px"; probe.style.top = "0"; probe.style.width = width + "px"; probe.style.fontSize = "18px"; probe.style.lineHeight = "24px"; probe.style.whiteSpace = "normal"; probe.style.wordBreak = "break-word"; probe.style.visibility = "hidden"; probe.textContent = text; document.body.appendChild(probe); const h = probe.getBoundingClientRect().height || 0; document.body.removeChild(probe); return h > 48 + 1; }, clearHideTextPopoverTimer() { if (this.hideTextPopoverTimer) { clearTimeout(this.hideTextPopoverTimer); this.hideTextPopoverTimer = null; } }, // 修复 goheight hover 无响应:移除重复方法覆盖,统一使用上面的 showTextPopoverFor(el, kind) by xu 20260109 hideTextPopoverLater() { this.clearHideTextPopoverTimer(); this.hideTextPopoverTimer = setTimeout(() => { this.showTextPopover = false; this.textPopoverType = ""; this.textPopoverPayload = null; }, 120); }, hideTextPopover() { this.clearHideTextPopoverTimer(); this.showTextPopover = false; this.textPopoverType = ""; this.textPopoverPayload = null; }, // 功能:新需求下不在 updated 内做测量,避免死循环 by xu 20260108 }, mounted() { // 无需在 mounted/updated 里做 overflow 测量(避免死循环),只在 hover 触发时判断 by xu 20260108 // 仅用于控制“...命中区是否显示”,不会触发循环更新 by xu 20260109 this.$nextTick?.(() => { requestAnimationFrame(() => this.refreshEllipsisVisible?.()); }); this.__ssListCardResizeHandler = () => this.refreshEllipsisVisible?.(); // 功能:窗口变化时刷新 ... 显示 by xu 20260109 window.addEventListener?.("resize", this.__ssListCardResizeHandler); }, updated() { // 卡片数据更新后刷新一次 ... 显示状态(避免“宽度/内容变了但命中区不变”) by xu 20260109 this.$nextTick?.(() => { requestAnimationFrame(() => this.refreshEllipsisVisible?.()); }); }, beforeUnmount() { // 清理 timer,避免残留导致异常 by xu 20260108 this.clearHideTextPopoverTimer?.(); if (this.__ssListCardResizeHandler) { window.removeEventListener?.("resize", this.__ssListCardResizeHandler); this.__ssListCardResizeHandler = null; } }, render() { const SsCartListIcon = Vue.resolveComponent("ss-cart-list-icon"); const SsIcon = Vue.resolveComponent("ss-icon"); const hasThumbArea = !!(this.item?.thumb || this.item?.thumbType); // 功能:无图但有 thumbType 时仍保留占位 by xu 20260109 return Vue.h( "div", { class: { "knowledge-item-container": true, active: this.item.active, [this.cardType]: !!this.cardType, // 动态添加卡片类型类名 by xu 20260105 [this.statusClass]: !!this.statusClass }, onClick: (e) => { this.onItemClick?.(e); if (this.cardClickAction === "single") { this.toggleSelectExclusive?.(e); } }, // 功能:卡片主体点击动作(view/single) by xu 20260109 // 移除固定宽度,由 CSS min-width 控制 by xu 20260105 }, [ // 右上角状态图标区域 by xu 20260105 this.item?.statusIcons?.length > 0 && Vue.h( "div", { class: "card-status-icons" }, this.item.statusIcons.map((icon) => Vue.h(SsIcon, { class: `status-icon ${icon.class}`, title: icon.title, }) ) ), this.item?.buttons?.length > 0 && Vue.h( "div", { class: "header", style: this.item?.statusIcons?.length > 0 ? { right: `${this.item.statusIcons.length * 48}px`, borderTopRightRadius: '0' } : {}, onMouseenter: () => (this.showButtons = true), onMouseleave: () => (this.showButtons = false), onClick: (e) => this.onItemChange(e, this.item.buttons[0], 0), }, [ // 只在有按钮时渲染设置图标 // this.item?.buttons?.length > 0 && Vue.h("div", { class: "cart-list-setting cart-list-icon", title: this.item?.buttons?.[0]?.title, }), // 鼠标移入时显示按钮列表,与图标同级 // this.item?.buttons?.length > 0 && this.showButtons && this.item?.buttons?.length > 1 && Vue.h( "div", { class: "cart-list-button-popup", }, this.item.buttons.map((btn) => Vue.h( "div", { onClick: (e) => { e.stopPropagation(); btn.onclick?.(); }, }, [ // 如果有 class,显示对应的图标 btn.class && Vue.h(SsCartListIcon, { class: [btn.class], }), // 显示按钮文本 Vue.h("span", null, btn.title), ] ) ) ), ] ), Vue.h("div", { class: "body" }, [ Vue.h("div", { class: "box-header" }, [ Vue.h("div", `${this.item.title}`), ]), Vue.h( "div", { class: !hasThumbArea ? "no-thumb box-body" : "box-body", }, [ hasThumbArea ? this.item.thumb ? Vue.h("div", { class: "left" }, [ Vue.h("img", { src: this.item.thumb, alt: "Thumbnail", class: "imgUnHandle", style: { "object-fit": "cover", width: "100%", height: "100%", }, }), ]) : Vue.h( // 功能:无图占位(ss-icon + biz icon,居中) by xu 20260109 "div", { class: "left ss-objlist-thumbPlaceholder" }, [ Vue.h(SsIcon, { class: `${this.getBizThumbIconClass()} ss-objlist-thumbIcon`, }), ] ) : null, Vue.h( "div", { class: "right", }, (() => { const model = this.buildRightTextLines(); // 功能:右侧文字区新规则(第二部分/第三部分优先级) by xu 20260108 const hasAny = !!(model?.hasSecond || model?.hasThird); if (!hasAny) return []; const children = []; // 第二部分:L1-L3(摘要优先,其次 tags;不足留空;超出 L3 ...) by xu 20260108 if (model.hasSecond) { if (model.secondKind === "summary") { children.push( Vue.h("div", { class: "ss-card-text__line ss-card-text__secondBlock" }, [ Vue.h("div", { class: "ss-card-text__secondSummary", title: model.summary }, model.summary), Vue.h("span", { class: ["ss-card-text__ellipsisHit", "ss-card-text__ellipsisHit--second", this.ellipsisVisible?.secondSummary ? "is-on" : ""], title: "查看完整摘要", onMouseenter: (e) => this.showTextPopoverFor(e?.currentTarget, "second-summary"), onClick: (e) => { e?.stopPropagation?.(); this.showTextPopoverFor(e?.currentTarget, "second-summary"); }, // 功能:点击 ... 也可展开(避免 hover 受遮挡) by xu 20260109 onMouseleave: () => this.hideTextPopoverLater(), }), ]) ); } else if (model.secondKind === "tags") { children.push( Vue.h("div", { class: ["ss-card-text__line", model.secondFull ? "ss-card-text__secondFullBlock" : "ss-card-text__secondBlock"] }, [ // 功能:仅标签时占满 4 行 by xu 20260109 Vue.h( "div", { class: "ss-card-text__secondTags" }, [ ...model.secondTagsHead.map((t) => Vue.h("div", { class: "ss-card-text__tagLine", title: t }, t) ), // 第三行:平铺剩余(可能为空) by xu 20260108 Vue.h("div", { class: "ss-card-text__tagLine is-last ss-card-text__tagLineLast", title: model.secondTagsLast }, model.secondTagsLast), ].filter(Boolean) ), // 只在最后一行出现 ... 时才触发 goheight by xu 20260108 Vue.h("span", { class: ["ss-card-text__ellipsisHit", "ss-card-text__ellipsisHit--second", this.ellipsisVisible?.secondTags ? "is-on" : ""], title: "查看完整物品参数", onMouseenter: (e) => this.showTextPopoverFor(e?.currentTarget, "second-tags"), onClick: (e) => { e?.stopPropagation?.(); this.showTextPopoverFor(e?.currentTarget, "second-tags"); }, // 功能:点击 ... 也可展开(避免 hover 受遮挡) by xu 20260109 onMouseleave: () => this.hideTextPopoverLater(), }), ]) ); } } // 第三部分:默认 L4;第二部分为空则占满 L1-L4 by xu 20260108 if (model.hasThird) { if (model.thirdFull) { children.push( Vue.h("div", { class: "ss-card-text__line ss-card-text__thirdFullBlock" }, [ Vue.h("div", { class: "ss-card-text__thirdFull", title: model.thirdText }, model.thirdText), Vue.h("span", { class: ["ss-card-text__ellipsisHit", "ss-card-text__ellipsisHit--third", this.ellipsisVisible?.thirdFull ? "is-on" : ""], title: "查看完整信息", onMouseenter: (e) => this.showTextPopoverFor(e?.currentTarget, "third-full"), onClick: (e) => { e?.stopPropagation?.(); this.showTextPopoverFor(e?.currentTarget, "third-full"); }, // 功能:点击 ... 也可展开(避免 hover 受遮挡) by xu 20260109 onMouseleave: () => this.hideTextPopoverLater(), }), ]) ); } else { children.push( Vue.h("div", { class: "ss-card-text__line ss-card-text__thirdLineWrap" }, [ Vue.h("div", { class: "ss-card-text__thirdLine", title: model.thirdText }, model.thirdText), Vue.h("span", { class: ["ss-card-text__ellipsisHit", "ss-card-text__ellipsisHit--third", this.ellipsisVisible?.third ? "is-on" : ""], title: "查看完整信息", onMouseenter: (e) => this.showTextPopoverFor(e?.currentTarget, "third"), onClick: (e) => { e?.stopPropagation?.(); this.showTextPopoverFor(e?.currentTarget, "third"); }, // 功能:点击 ... 也可展开(避免 hover 受遮挡) by xu 20260109 onMouseleave: () => this.hideTextPopoverLater(), }), ]) ); } } // hover 展开浮层:宽度=右侧文字区,底对齐向上展开,带阴影 by xu 20260108 // popover 作为 `.right` 的 sibling 渲染,避免被 `.ss-card-text{overflow:hidden}` 裁剪 by xu 20260108 const popover = this.showTextPopover && Vue.h( "div", { class: "ss-card-text-popover", style: { bottom: this.textPopoverBottom + "px" }, onMouseenter: () => { this.clearHideTextPopoverTimer(); this.showTextPopover = true; }, onMouseleave: () => this.hideTextPopoverLater(), }, (() => { const p = this.textPopoverPayload || {}; if (p.kind === "second-summary" && p.text) { return [Vue.h("div", { class: "ss-card-text-popover__summary" }, p.text)]; } if (Array.isArray(p.lines)) { return [ Vue.h( "div", { class: "ss-card-text-popover__kvlist" }, p.lines.map((t) => Vue.h("div", { class: "ss-card-text-popover__kv" }, t)) ), ]; } if ((p.kind === "third" || p.kind === "third-full") && p.text) { return [Vue.h("div", { class: "ss-card-text-popover__objno" }, p.text)]; } return []; })() ); return [ Vue.h("div", { class: "ss-card-text" }, children), popover, ]; })() ), ] ), ]), // 右下角卡片选择图标 by xu 20260105 Vue.h(SsIcon, { class: this.item?._ssSelected ? "card-icon icon-cardChk-on" : "card-icon icon-cardChk", onClick: this.toggleSelect, }), // 选中后底部线条 by xu 20260105 this.item?._ssSelected && Vue.h("div", { class: "select-bottom-line" }), ] ); }, }; // 二级对象卡片:复用一级对象新卡片布局/省略浮层,但去掉勾选与 single 选中,仅支持点击查看 by xu 20260115 const SsCObjCardList = { name: "SsCObjCardList", props: { ssObjName: { type: String, default: "" }, // 功能说明:业务对象名(用于默认缩略图 icon) by xu 20260115 item: { type: Object, required: true, }, }, emits: ["click", "change"], setup(props, { emit }) { const item = props.item; const cardType = Vue.computed(() => { if (!item.thumb && !item.thumbType) return ""; return item.thumbType === "thumbnail" ? "card-thumbnail" : "card-photo"; }); const statusClass = Vue.computed(() => { if (!item.status) return ""; const statusMap = { available: "status-available", unavailable: "status-unavailable", disabled: "status-disabled", }; return statusMap[item.status] || ""; }); const onItemClick = (e) => { // 清除所有类型卡片的 active 状态(保持与一级对象一致) by xu 20260115 const allListCards = document.querySelectorAll(".knowledge-item-container"); const allFolderCards = document.querySelectorAll(".ss-folder-list"); allListCards.forEach((card) => card.classList.remove("active")); allFolderCards.forEach((card) => card.classList.remove("active")); e.currentTarget.classList.add("active"); // 二级对象卡片:点击仅查看(调用 item.onclick) by xu 20260115 props.item.onclick?.(); emit("click", props.item); }; const onItemChange = (e) => { e.stopPropagation(); props.item.buttons?.[0]?.onclick?.(); }; return { item, cardType, statusClass, onItemClick, onItemChange }; }, data() { return { showButtons: false, showTextPopover: false, // 功能:右侧文字区 hover 展示全量 by xu 20260115 textPopoverType: "", // second-summary / second-tags / third / third-full by xu 20260115 textPopoverBottom: 0, // 功能:popover 从当前省略行位置向上展开 by xu 20260115 hideTextPopoverTimer: null, // 功能:鼠标从省略行移到浮层的缓冲 by xu 20260115 textPopoverPayload: null, // { kind, text?, lines? } by xu 20260115 ellipsisVisible: { secondSummary: false, secondTags: false, third: false, thirdFull: false, }, // 功能:只在真实出现 ... 时才显示命中区/允许 goheight by xu 20260115 }; }, methods: { getBizThumbIconClass() { // 功能:无缩略图时,用业务对象 icon 做默认图(ss-icon + icon-obj-xx) by xu 20260115 const name = String( this.ssObjName || this.item?.ssObjName || this.$root?.ssObjName || window?.ss?.dom?.ssObjName || "" ).trim(); if (!name) return ""; return "icon-obj-" + name; }, buildRightTextLines() { // 功能:沿用一级对象卡片右侧文字区规则 by xu 20260115 const item = this.item || {}; const summary = String(item?.desc ?? "").trim(); const objNum = String(item?.objNum ?? "").trim(); const categoryArr = Array.isArray(item?.category) ? item.category : []; const tagsArr = Array.isArray(item?.tags) ? item.tags : []; const hasTags = tagsArr.length > 0; const hasCategory = categoryArr.length > 0; const secondKind = summary ? "summary" : hasTags ? "tags" : ""; const hasSecond = !!secondKind; const thirdKind = objNum ? "objNum" : hasCategory ? "category" : ""; const hasThird = !!thirdKind; const thirdFull = !hasSecond && hasThird; const secondFull = hasSecond && !hasThird && secondKind === "tags"; const toValueText = (obj) => { // 功能说明:类目/物品参数回显展示 key: value(否则只显示值看不懂) by xu 20260115 const [k, v] = Object.entries(obj || {})[0] || ["", ""]; const key = k !== undefined && k !== null ? String(k).trim() : ""; const val = v !== undefined && v !== null ? String(v).trim() : ""; if (key && val) return key + ":" + val; if (val) return val; if (key) return key; return ""; }; const toLineList = (arr) => (arr || []) .map(toValueText) .map((s) => String(s ?? "").trim()) .filter(Boolean); const secondTagsMaxLines = secondFull ? 4 : 3; const secondTagsLinesFull = toLineList(tagsArr); const secondTagsHead = secondTagsLinesFull.slice(0, Math.max(0, secondTagsMaxLines - 1)); const secondTagsTail = secondTagsLinesFull.slice(Math.max(0, secondTagsMaxLines - 1)); const secondTagsLast = secondTagsTail.length <= 1 ? (secondTagsTail[0] || "") : secondTagsTail.join(" "); const categoryLinesFull = toLineList(categoryArr); const categoryLine = categoryLinesFull.join(" "); const thirdText = thirdKind === "objNum" ? objNum : thirdKind === "category" ? categoryLine : ""; return { secondKind, thirdKind, hasSecond, hasThird, thirdFull, secondFull, summary, secondTagsMaxLines, secondTagsHead, secondTagsLast, secondTagsLinesFull, objNum, categoryLine, categoryLinesFull, thirdText, }; }, measureTextOverflowByLines(text, maxLines, width) { const w = Number(width) || 0; if (!w || !text) return false; const probe = document.createElement("div"); probe.style.position = "fixed"; probe.style.left = "-99999px"; probe.style.top = "0"; probe.style.width = w + "px"; probe.style.fontSize = "18px"; probe.style.lineHeight = "24px"; probe.style.whiteSpace = "normal"; probe.style.wordBreak = "break-word"; probe.style.visibility = "hidden"; probe.textContent = text; document.body.appendChild(probe); const h = probe.getBoundingClientRect().height || 0; document.body.removeChild(probe); return h > maxLines * 24 + 1; }, measureSingleLineOverflow(text, width) { const w = Number(width) || 0; if (!w || !text) return false; const probe = document.createElement("span"); probe.style.position = "fixed"; probe.style.left = "-99999px"; probe.style.top = "0"; probe.style.display = "inline-block"; probe.style.maxWidth = w + "px"; probe.style.fontSize = "18px"; probe.style.lineHeight = "24px"; probe.style.whiteSpace = "nowrap"; probe.style.visibility = "hidden"; probe.textContent = text; document.body.appendChild(probe); const overflow = (probe.scrollWidth || 0) > (probe.clientWidth || w) + 1; document.body.removeChild(probe); return overflow; }, refreshEllipsisVisible() { // 功能:刷新「是否出现 ...」状态(用于控制命中区显示) by xu 20260115 try { const right = this.$el?.querySelector?.(".right"); const rawWidth = right?.getBoundingClientRect?.().width || right?.clientWidth || 0; const width = Math.max(0, Math.round(rawWidth)); const model = this.buildRightTextLines(); const next = { secondSummary: false, secondTags: false, third: false, thirdFull: false }; if (model.secondKind === "summary" && model.summary) { next.secondSummary = this.measureTextOverflowByLines(model.summary, 3, width); } if (model.secondKind === "tags") { next.secondTags = this.measureSingleLineOverflow(model.secondTagsLast, width); // 功能说明:tags 仅看最后一行是否溢出 by xu 20260115 } if (model.hasThird && !model.thirdFull) { next.third = this.measureSingleLineOverflow(model.thirdText, width); } if (model.hasThird && model.thirdFull) { next.thirdFull = this.measureTextOverflowByLines(model.thirdText, 4, width); } const prev = this.ellipsisVisible || {}; const changed = prev.secondSummary !== next.secondSummary || prev.secondTags !== next.secondTags || prev.third !== next.third || prev.thirdFull !== next.thirdFull; if (changed) this.ellipsisVisible = next; } catch (e) { // ignore by xu 20260115 } }, clearHideTextPopoverTimer() { if (this.hideTextPopoverTimer) { clearTimeout(this.hideTextPopoverTimer); this.hideTextPopoverTimer = null; } }, hideTextPopoverLater() { this.clearHideTextPopoverTimer(); this.hideTextPopoverTimer = setTimeout(() => { this.showTextPopover = false; this.textPopoverType = ""; this.textPopoverPayload = null; }, 120); }, showTextPopoverFor(el, kind) { const model = this.buildRightTextLines(); const lineEl = el?.closest?.(".ss-card-text__line") || el; const right = lineEl?.closest?.(".right") || el?.closest?.(".right"); const rawWidth = right?.getBoundingClientRect?.().width || right?.clientWidth || 0; const width = Math.max(0, Math.round(rawWidth)); const overflowed = kind === "second-summary" ? this.measureTextOverflowByLines(model.summary, 3, width) : kind === "second-tags" ? this.measureSingleLineOverflow(model.secondTagsLast, width) : kind === "third" ? this.measureSingleLineOverflow(model.thirdText, width) : this.measureTextOverflowByLines(model.thirdText, 4, width); if (!overflowed) return; let payload = null; if (kind === "second-summary") { if (model.summary) payload = { kind, text: model.summary }; } else if (kind === "second-tags") { if (Array.isArray(model.secondTagsLinesFull) && model.secondTagsLinesFull.length) { payload = { kind, lines: model.secondTagsLinesFull }; } } else if (kind === "third") { if (model.thirdKind === "category" && Array.isArray(model.categoryLinesFull) && model.categoryLinesFull.length) { payload = { kind, lines: model.categoryLinesFull }; } else if (model.thirdText) { payload = { kind, text: model.thirdText }; } } else if (kind === "third-full") { if (model.thirdKind === "category" && Array.isArray(model.categoryLinesFull) && model.categoryLinesFull.length) { payload = { kind, lines: model.categoryLinesFull }; } else if (model.thirdText) { payload = { kind, text: model.thirdText }; } } if (!payload) return; this.clearHideTextPopoverTimer(); const container = lineEl?.closest?.(".right"); const containerRect = container?.getBoundingClientRect?.(); const lineRect = lineEl?.getBoundingClientRect?.(); if (containerRect && lineRect) { const bottom = Math.max(0, Math.round(containerRect.bottom - lineRect.bottom)); this.textPopoverBottom = bottom; } else { this.textPopoverBottom = 0; } this.textPopoverPayload = payload; this.textPopoverType = kind; this.showTextPopover = true; }, }, mounted() { this.$nextTick?.(() => { requestAnimationFrame(() => this.refreshEllipsisVisible?.()); }); this.__ssCObjCardResizeHandler = () => this.refreshEllipsisVisible?.(); // 功能说明:窗口变化时刷新 ... 显示 by xu 20260115 window.addEventListener?.("resize", this.__ssCObjCardResizeHandler); }, updated() { this.$nextTick?.(() => { requestAnimationFrame(() => this.refreshEllipsisVisible?.()); }); }, beforeUnmount() { this.clearHideTextPopoverTimer?.(); if (this.__ssCObjCardResizeHandler) { window.removeEventListener?.("resize", this.__ssCObjCardResizeHandler); this.__ssCObjCardResizeHandler = null; } }, render() { const SsCartListIcon = Vue.resolveComponent("ss-cart-list-icon"); const SsIcon = Vue.resolveComponent("ss-icon"); const hasThumbArea = !!(this.item?.thumb || this.item?.thumbType); return Vue.h( "div", { class: { "knowledge-item-container": true, active: this.item.active, [this.cardType]: !!this.cardType, [this.statusClass]: !!this.statusClass, }, onClick: (e) => this.onItemClick?.(e), // 功能说明:二级对象卡片点击仅查看 by xu 20260115 }, [ this.item?.statusIcons?.length > 0 && Vue.h( "div", { class: "card-status-icons" }, this.item.statusIcons.map((icon) => Vue.h(SsIcon, { class: "status-icon " + icon.class, title: icon.title }) ) ), this.item?.buttons?.length > 0 && Vue.h( "div", { class: "header", style: this.item?.statusIcons?.length > 0 ? { right: String(this.item.statusIcons.length * 48) + "px", borderTopRightRadius: "0" } : {}, onMouseenter: () => (this.showButtons = true), onMouseleave: () => (this.showButtons = false), onClick: (e) => this.onItemChange(e), }, [ Vue.h("div", { class: "cart-list-setting cart-list-icon", title: this.item?.buttons?.[0]?.title, }), this.showButtons && this.item?.buttons?.length > 1 && Vue.h( "div", { class: "cart-list-button-popup" }, this.item.buttons.map((btn) => Vue.h( "div", { onClick: (e) => { e.stopPropagation(); btn.onclick?.(); }, }, [ btn.class && Vue.h(SsCartListIcon, { class: [btn.class] }), Vue.h("span", null, btn.title), ] ) ) ), ] ), Vue.h("div", { class: "body" }, [ Vue.h("div", { class: "box-header" }, [Vue.h("div", String(this.item.title || ""))]), Vue.h( "div", { class: !hasThumbArea ? "no-thumb box-body" : "box-body" }, [ hasThumbArea ? this.item.thumb ? Vue.h("div", { class: "left" }, [ Vue.h("img", { src: this.item.thumb, alt: "Thumbnail", class: "imgUnHandle", style: { "object-fit": "cover", width: "100%", height: "100%" }, }), ]) : Vue.h("div", { class: "left ss-objlist-thumbPlaceholder" }, [ Vue.h(SsIcon, { class: this.getBizThumbIconClass() + " ss-objlist-thumbIcon" }), ]) : null, Vue.h( "div", { class: "right" }, (() => { const model = this.buildRightTextLines(); const hasAny = !!(model?.hasSecond || model?.hasThird); if (!hasAny) return []; const children = []; if (model.hasSecond && model.secondKind === "summary") { children.push( Vue.h("div", { class: "ss-card-text__line ss-card-text__secondBlock" }, [ Vue.h("div", { class: "ss-card-text__secondSummary", title: model.summary }, model.summary), Vue.h("span", { class: [ "ss-card-text__ellipsisHit", "ss-card-text__ellipsisHit--second", this.ellipsisVisible?.secondSummary ? "is-on" : "", ], title: "查看完整信息", onMouseenter: (e) => this.showTextPopoverFor(e?.currentTarget, "second-summary"), onClick: (e) => { e?.stopPropagation?.(); this.showTextPopoverFor(e?.currentTarget, "second-summary"); }, onMouseleave: () => this.hideTextPopoverLater(), }), ]) ); } if (model.hasSecond && model.secondKind === "tags") { children.push( Vue.h( "div", { class: [ "ss-card-text__line", model.secondFull ? "ss-card-text__secondFullBlock" : "ss-card-text__secondBlock", ], }, [ Vue.h( "div", { class: "ss-card-text__secondTags" }, [ ...model.secondTagsHead.map((t) => Vue.h("div", { class: "ss-card-text__tagLine", title: t }, t) ), Vue.h( "div", { class: "ss-card-text__tagLine is-last ss-card-text__tagLineLast", title: model.secondTagsLast, }, model.secondTagsLast ), ] ), Vue.h("span", { class: [ "ss-card-text__ellipsisHit", "ss-card-text__ellipsisHit--second", this.ellipsisVisible?.secondTags ? "is-on" : "", ], title: "查看完整信息", onMouseenter: (e) => this.showTextPopoverFor(e?.currentTarget, "second-tags"), onClick: (e) => { e?.stopPropagation?.(); this.showTextPopoverFor(e?.currentTarget, "second-tags"); }, onMouseleave: () => this.hideTextPopoverLater(), }), ] ) ); } if (model.hasThird) { if (model.thirdFull) { children.push( Vue.h("div", { class: "ss-card-text__line ss-card-text__thirdFullBlock" }, [ Vue.h("div", { class: "ss-card-text__thirdFull", title: model.thirdText }, model.thirdText), Vue.h("span", { class: [ "ss-card-text__ellipsisHit", "ss-card-text__ellipsisHit--third", this.ellipsisVisible?.thirdFull ? "is-on" : "", ], title: "查看完整信息", onMouseenter: (e) => this.showTextPopoverFor(e?.currentTarget, "third-full"), onClick: (e) => { e?.stopPropagation?.(); this.showTextPopoverFor(e?.currentTarget, "third-full"); }, onMouseleave: () => this.hideTextPopoverLater(), }), ]) ); } else { children.push( Vue.h("div", { class: "ss-card-text__line ss-card-text__thirdLineWrap" }, [ Vue.h("div", { class: "ss-card-text__thirdLine", title: model.thirdText }, model.thirdText), Vue.h("span", { class: [ "ss-card-text__ellipsisHit", "ss-card-text__ellipsisHit--third", this.ellipsisVisible?.third ? "is-on" : "", ], title: "查看完整信息", onMouseenter: (e) => this.showTextPopoverFor(e?.currentTarget, "third"), onClick: (e) => { e?.stopPropagation?.(); this.showTextPopoverFor(e?.currentTarget, "third"); }, onMouseleave: () => this.hideTextPopoverLater(), }), ]) ); } } const popover = this.showTextPopover && Vue.h( "div", { class: "ss-card-text-popover", style: { bottom: this.textPopoverBottom + "px" }, onMouseenter: () => { this.clearHideTextPopoverTimer(); this.showTextPopover = true; }, onMouseleave: () => this.hideTextPopoverLater(), }, (() => { const p = this.textPopoverPayload || {}; if (p.kind === "second-summary" && p.text) { return [Vue.h("div", { class: "ss-card-text-popover__summary" }, p.text)]; } if (Array.isArray(p.lines)) { return [ Vue.h( "div", { class: "ss-card-text-popover__kvlist" }, p.lines.map((t) => Vue.h("div", { class: "ss-card-text-popover__kv" }, t)) ), ]; } if ((p.kind === "third" || p.kind === "third-full") && p.text) { return [Vue.h("div", { class: "ss-card-text-popover__objno" }, p.text)]; } return []; })() ); return [Vue.h("div", { class: "ss-card-text" }, children), popover]; })() ), ] ), ]), ] ); }, }; // ss-sidebar 右侧边栏(容器 + 子组件),用于 objList 右侧区域 by xu 20260106 // 组件文档补全(JSDoc) by xu 20260108 /** * SsSidebarButtons(右侧边栏顶部按钮栏) * * 用途: * - 渲染 objList 右侧顶部快捷操作(预定/入住/退房/清洁...) * - 内部复用 `ss-search-button`(项目现有按钮样式/交互) * * 调用示例: * ```html * * ``` * * Props: * - `items`: 按钮配置数组 * - `{ id, text, icon?, onClick? }` * * Emits: * - `click`:点击按钮时触发,参数为按钮对象 */ const SsSidebarButtons = { name: "SsSidebarButtons", props: { items: { type: Array, default: () => [] }, }, emits: ["click"], render() { const SsSearchButton = Vue.resolveComponent("ss-search-button"); const items = this.items || []; if (!items.length) return null; return Vue.h( "div", { class: "ss-sidebar-actions" }, items.map((btn) => // 顶部操作按钮复用 ss-search-button(先 mock 固定按钮) by xu 20260106 Vue.h(SsSearchButton, { text: btn?.text ?? "", iconClass: btn?.iconClass ?? "", opt: btn?.opt ?? [], checkId: btn?.checkId ?? "0", width: btn?.width, id: btn?.id, onClick: (e) => { e?.stopPropagation?.(); btn?.onClick?.(btn); this.$emit("click", btn); }, }) ) ); }, }; // 组件文档补全(JSDoc) by xu 20260108 /** * SsSidebarChart(ECharts 容器渲染) * * 用途: * - 仅负责 echarts init / setOption / resize / dispose * - 被 `ss-sidebar-chart-hover` 与图表面板复用 * * 调用示例: * ```html * * ``` * * Props: * - `options`:ECharts option(Object) * - `height`:容器高度(String) */ const SsSidebarChart = { name: "SsSidebarChart", props: { options: { type: Object, default: () => ({}) }, height: { type: String, default: "200px" }, }, setup(props) { const elRef = Vue.ref(null); let chart = null; const renderChart = () => { if (!elRef.value || !window.echarts) return; if (!chart) { chart = window.echarts.init(elRef.value); } chart.setOption(props.options || {}, true); }; const resizeChart = () => { chart?.resize?.(); }; Vue.onMounted(() => { renderChart(); window.addEventListener("resize", resizeChart); }); Vue.onBeforeUnmount(() => { window.removeEventListener("resize", resizeChart); chart?.dispose?.(); chart = null; }); Vue.watch( () => props.options, () => { renderChart(); }, { deep: true } ); return { elRef }; }, render() { return Vue.h("div", { ref: "elRef", style: { width: "100%", height: this.height, // 图表容器不加 padding/border,由外层布局控制 by xu 20260106 background: "transparent", border: "none", "border-radius": "0", }, }); }, }; // ss-sidebar-chart-hover:hover 弹出左侧大图(支持图钉/全屏) by xu 20260106 // 组件文档补全(JSDoc) by xu 20260108 /** * SsSidebarChartHover(小图 + hover 左侧大图预览 + 图钉固定 + 全屏) * * 用途: * - 右侧统计图小卡片:hover 时在左侧弹出大图预览 * - 预览头部:左侧图标+标题;右侧固定/全屏按钮(icon-base) * - 全屏:方案A(浏览器 Fullscreen API) * * 调用示例(由 ss-sidebar chart panel 内部调用): * ```html * * ``` * * Props: * - `title/iconClass/icon`:用于预览/全屏 header 显示(与面板 header 一致) * - `options`:ECharts option * - `height`:小图高度 * - `previewWidth/previewHeight`:预览建议尺寸(会按视口自适应) */ const SsSidebarChartHover = { name: "SsSidebarChartHover", props: { // hover 大图标题/图标(与小图面板 header 一致) by xu 20260108 title: { type: String, default: "" }, iconClass: { type: String, default: "" }, icon: { type: String, default: "" }, options: { type: Object, default: () => ({}) }, height: { type: String, default: "220px" }, // hover 弹窗建议尺寸:默认 1000x650(比例由逻辑统一管控),实际渲染会按比例自适应视口 by xu 20260115 previewWidth: { type: Number, default: 1000 }, previewHeight: { type: Number, default: 650 }, }, setup(props) { const triggerRef = Vue.ref(null); const fullscreenRef = Vue.ref(null); const open = Vue.ref(false); const pinned = Vue.ref(false); const fullscreen = Vue.ref(false); const hoveringTrigger = Vue.ref(false); const hoveringPreview = Vue.ref(false); const previewStyle = Vue.ref({}); let closeTimer = null; const updatePreviewPosition = () => { const el = triggerRef.value; if (!el) return; const rect = el.getBoundingClientRect(); // 功能说明:优先使用 visualViewport(避免浏览器 UI/缩放导致可视区与 innerHeight 偏差,出现预览被遮挡约 70px) by xu 20260116 const docEl = document.documentElement; const vv = window.visualViewport; let viewportLeft = Number(vv?.offsetLeft ?? 0) || 0; let viewportTop = Number(vv?.offsetTop ?? 0) || 0; let vw = Number(vv?.width ?? 0) || Number(docEl?.clientWidth || 0) || window.innerWidth; let vh = Number(vv?.height ?? 0) || Number(docEl?.clientHeight || 0) || window.innerHeight; // 功能说明:若页面在 iframe 内,父页面可能裁切 iframe 可见区域(overflow/弹窗容器),需要用“iframe可见区域”做二次约束 by xu 20260116 try { const inIframe = window.top && window.top !== window; const frameEl = window.frameElement; if (inIframe && frameEl && window.top?.document) { const topVv = window.top.visualViewport; const topDocEl = window.top.document.documentElement; const topVw = Number(topVv?.width ?? 0) || Number(topDocEl?.clientWidth || 0) || window.top.innerWidth; const topVh = Number(topVv?.height ?? 0) || Number(topDocEl?.clientHeight || 0) || window.top.innerHeight; const fr = frameEl.getBoundingClientRect?.(); if (fr && fr.width > 0 && fr.height > 0) { const visibleW = Math.max(0, Math.min(fr.right, topVw) - Math.max(fr.left, 0)); const visibleH = Math.max(0, Math.min(fr.bottom, topVh) - Math.max(fr.top, 0)); if (visibleW > 0) { vw = Math.min(vw, visibleW); viewportLeft = Math.max(0, -fr.left); // iframe内坐标系偏移(左侧被裁切时) by xu 20260116 } if (visibleH > 0) { vh = Math.min(vh, visibleH); viewportTop = Math.max(0, -fr.top); // iframe内坐标系偏移(顶部被裁切时) by xu 20260116 } } } } catch (_) {} // 预览窗尺寸:优先保证“完整可见”,其次再尽量对齐右侧 header 顶部 by xu 20260116 const viewportPaddingX = 40; const viewportPaddingTop = 20; const viewportPaddingBottom = 10; // 功能说明:hover 预览框不要覆盖右侧栏,优先放在 ss-sidebar 左侧;必要时动态缩小宽度 by xu 20260115 const sidebarEl = el.closest ? el.closest(".ss-sidebar") : null; const sidebarRect = sidebarEl?.getBoundingClientRect?.(); const maxWidthByViewport = Math.max(240, vw - viewportPaddingX); const maxWidthByLeftSpace = sidebarRect ? Math.max(240, sidebarRect.left - viewportPaddingX - 14 /* gapFromSidebar */) : maxWidthByViewport; // 功能说明:预览框尺寸按比例(默认 5:3)缩放,并提供最大/最小值约束 by xu 20260115 const ratio = 3 / 5; const minWidth = 320; const minHeight = 240; const maxHeightByViewport = Math.max(minHeight, vh - viewportPaddingTop - viewportPaddingBottom); const maxWidthByProp = Number(props.previewWidth) || 1000; const maxHeightByProp = Number(props.previewHeight) || 650; // 优先按高度撑满可视区(保证预览完整可见),再根据左侧可用宽度回退 by xu 20260116 const maxHeight = Math.min(maxHeightByProp, maxHeightByViewport); let height = Math.max(minHeight, maxHeight); let width = Math.round(height / ratio); width = Math.min(width, maxWidthByProp, maxWidthByViewport, maxWidthByLeftSpace); width = Math.max(minWidth, width); height = Math.round(width * ratio); if (height > maxHeight) { height = maxHeight; width = Math.round(height / ratio); } // 默认贴着小图左侧弹出,右边缘与小图左边缘轻微重叠,避免 1px 缝隙导致 hover 闪断 by xu 20260108 const overlap = 2; const gapFromSidebar = 14; // 功能说明:弹窗与右侧边栏留出距离(更不贴近) by xu 20260108 // 优先:贴在 sidebar 左侧(不压住右侧栏内容) by xu 20260115 let left = sidebarRect ? (sidebarRect.left - gapFromSidebar - width + overlap) : (rect.left - width - gapFromSidebar + overlap); // 如果左侧空间不足,则贴右侧(兜底,同样重叠) by xu 20260108 if (left < 0) left = rect.right + gapFromSidebar - overlap; left = Math.max(viewportLeft, Math.min(left, viewportLeft + vw - width)); // top:优先保证完整可见,然后才贴近 header 顶部 by xu 20260116 let headerEl = el; while (headerEl && !headerEl.classList?.contains("ss-sidebar-panel")) { headerEl = headerEl.parentElement; } let headerRect = headerEl?.querySelector(".ss-sidebar-panel__header")?.getBoundingClientRect(); let top = headerRect?.top ?? rect.top; top = Math.max(viewportTop + viewportPaddingTop, Math.min(top, viewportTop + vh - height - viewportPaddingBottom)); previewStyle.value = { position: "fixed", left: `${Math.round(left)}px`, top: `${Math.round(top)}px`, width: `${width}px`, height: `${height}px`, zIndex: 2147483647, // 功能说明:提高层级到接近浏览器上限,避免仍被页面固定栏/弹窗遮挡 by xu 20260116 }; }; const clearCloseTimer = () => { if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; } }; const scheduleClose = () => { clearCloseTimer(); if (pinned.value || fullscreen.value) return; if (hoveringTrigger.value || hoveringPreview.value) return; // 功能:鼠标在小图/大图之间移动不关闭 by xu 20260108 closeTimer = setTimeout(() => { open.value = false; }, 100); }; const openPreview = () => { clearCloseTimer(); updatePreviewPosition(); open.value = true; }; const togglePin = () => { pinned.value = !pinned.value; if (pinned.value) { open.value = true; updatePreviewPosition(); } }; const toggleFullscreen = () => { // 全屏:采用浏览器 Fullscreen API(方案A),不使用遮罩弹窗 by xu 20260108 if (!fullscreen.value) { open.value = false; // 避免同时渲染预览与全屏 by xu 20260108 fullscreen.value = true; Vue.nextTick(() => { const el = fullscreenRef.value; if (el?.requestFullscreen) { el.requestFullscreen().catch(() => { // requestFullscreen 失败则回退为非全屏状态 by xu 20260108 fullscreen.value = false; }); } else { fullscreen.value = false; } }); } else { if (document?.exitFullscreen) { document.exitFullscreen().catch(() => { }); } } }; const handleFullscreenChange = () => { const isFs = !!document.fullscreenElement; fullscreen.value = isFs; // 功能说明:同步 ESC/系统退出全屏状态 by xu 20260108 if (isFs) { open.value = false; clearCloseTimer(); return; } // 退出全屏后:若固定或仍 hover,则恢复预览,否则关闭 by xu 20260108 if (pinned.value || hoveringTrigger.value || hoveringPreview.value) { open.value = true; updatePreviewPosition(); } else { open.value = false; } }; Vue.onMounted(() => { window.addEventListener("resize", updatePreviewPosition); window.addEventListener("scroll", updatePreviewPosition, true); document.addEventListener("fullscreenchange", handleFullscreenChange); // 功能说明:监听全屏状态变化 by xu 20260108 }); Vue.onBeforeUnmount(() => { clearCloseTimer(); window.removeEventListener("resize", updatePreviewPosition); window.removeEventListener("scroll", updatePreviewPosition, true); document.removeEventListener("fullscreenchange", handleFullscreenChange); }); return { triggerRef, fullscreenRef, open, pinned, fullscreen, hoveringTrigger, hoveringPreview, previewStyle, openPreview, scheduleClose, clearCloseTimer, togglePin, toggleFullscreen, }; }, render() { const SsIcon = Vue.resolveComponent("ss-icon"); const Chart = Vue.resolveComponent("ss-sidebar-chart"); const hasHeader = !!(this.title || this.iconClass || this.icon); // 功能:hover 大图显示左侧图标+标题 by xu 20260108 const previewContent = Vue.h( "div", { class: { "ss-sidebar-chart-preview": true, "is-pinned": this.pinned }, style: this.previewStyle, onMouseenter: () => { this.hoveringPreview = true; this.clearCloseTimer(); }, onMouseleave: () => { this.hoveringPreview = false; this.scheduleClose(); }, }, [ hasHeader ? Vue.h("div", { class: "ss-sidebar-panel__header ss-sidebar-chart-preview__header" }, [ Vue.h("div", { class: "ss-sidebar-panel__title" }, [ this.iconClass ? Vue.h(SsIcon, { class: this.iconClass + " ss-sidebar-panel__icon" }) : this.icon ? Vue.h(SsIcon, { name: this.icon, size: "16px", class: "ss-sidebar-panel__icon" }) : null, Vue.h("span", null, this.title || "统计图"), ]), Vue.h("div", { class: "ss-sidebar-panel__tools" }, [ Vue.h( "button", { type: "button", class: { "ss-sidebar-chart-tool": true, "is-active": this.pinned }, title: this.pinned ? "取消固定" : "固定", onClick: (e) => { e.stopPropagation(); this.togglePin(); }, }, // 固定图标:未固定 icon-fix,固定 icon-fix-bold(icon-base) by xu 20260108 [Vue.h(SsIcon, { class: this.pinned ? "menu-base-icon icon-fix-bold" : "menu-base-icon icon-fix" })] ), Vue.h( "button", { type: "button", class: "ss-sidebar-chart-tool", title: "全屏", onClick: (e) => { e.stopPropagation(); this.toggleFullscreen(); }, }, // 全屏图标:未展开 icon-chk,展开 icon-chk-on(icon-base) by xu 20260108 [Vue.h(SsIcon, { class: this.fullscreen ? "menu-base-icon icon-fs-exit" : "menu-base-icon icon-fs" })] ), ]), ]) : Vue.h("div", { class: "ss-sidebar-chart-preview__header ss-sidebar-chart-preview__header--simple" }, [ Vue.h("div", { class: "ss-sidebar-panel__tools" }, [ Vue.h( "button", { type: "button", class: { "ss-sidebar-chart-tool": true, "is-active": this.pinned }, title: this.pinned ? "取消固定" : "固定", onClick: (e) => { e.stopPropagation(); this.togglePin(); }, }, // 固定图标:未固定 icon-fix,固定 icon-fix-bold(icon-base) by xu 20260108 [Vue.h(SsIcon, { class: this.pinned ? "menu-base-icon icon-fix-bold" : "menu-base-icon icon-fix" })] ), Vue.h( "button", { type: "button", class: "ss-sidebar-chart-tool", title: "全屏", onClick: (e) => { e.stopPropagation(); this.toggleFullscreen(); }, }, // 全屏图标:未展开 icon-chk,展开 icon-chk-on(icon-base) by xu 20260108 [Vue.h(SsIcon, { class: this.fullscreen ? "menu-base-icon icon-chk-on" : "menu-base-icon icon-chk" })] ), ]), ]), Vue.h("div", { class: "ss-sidebar-chart-preview__body" }, [ Vue.h(Chart, { options: this.options, height: "100%" }), ]), ] ); const fullscreenContent = this.fullscreen && Vue.h( "div", { ref: "fullscreenRef", class: "ss-sidebar-chart-fullscreen", }, [ Vue.h("div", { class: "ss-sidebar-panel__header ss-sidebar-chart-fullscreen__header" }, [ Vue.h("div", { class: "ss-sidebar-panel__title" }, [ this.iconClass ? Vue.h(SsIcon, { class: this.iconClass + " ss-sidebar-panel__icon" }) : this.icon ? Vue.h(SsIcon, { name: this.icon, size: "16px", class: "ss-sidebar-panel__icon" }) : null, Vue.h("span", null, this.title || "统计图"), ]), Vue.h("div", { class: "ss-sidebar-panel__tools" }, [ Vue.h( "button", { type: "button", class: { "ss-sidebar-chart-tool": true, "is-active": this.pinned }, title: this.pinned ? "取消固定" : "固定", onClick: (e) => { e.stopPropagation(); this.togglePin(); }, }, [Vue.h(SsIcon, { class: this.pinned ? "menu-base-icon icon-fix-bold" : "menu-base-icon icon-fix" })] ), Vue.h( "button", { type: "button", class: "ss-sidebar-chart-tool", title: "退出全屏", onClick: (e) => { e.stopPropagation(); this.toggleFullscreen(); }, }, [Vue.h(SsIcon, { class: "menu-base-icon icon-fs-exit" })] ), ]), ]), Vue.h("div", { class: "ss-sidebar-chart-fullscreen__body" }, [ Vue.h(Chart, { options: this.options, height: "100%" }), ]), ] ); return Vue.h("div", { class: "ss-sidebar-chart-hover" }, [ Vue.h( "div", { ref: "triggerRef", class: "ss-sidebar-chart-hover__trigger", onMouseenter: () => { this.hoveringTrigger = true; this.openPreview(); }, onMouseleave: () => { this.hoveringTrigger = false; this.scheduleClose(); }, }, [Vue.h(Chart, { options: this.options, height: this.height })] ), (this.open || this.fullscreen) && Vue.h(Vue.Teleport, { to: "body" }, [ this.open ? previewContent : null, fullscreenContent || null, ]), ]); }, }; // 组件文档补全(JSDoc) by xu 20260108 /** * SsSidebarList(右侧业务面板:人员/已选/服务/预定...) * * 用途: * - 统一渲染面板 header(图标/标题/数量/右侧按钮) * - 统一渲染 list(固定行高、hover、右侧移除按钮等) * * 调用示例(由 ss-sidebar 通过 panels 配置驱动): * ```js * { type:'list', title:'已选', iconClass:'menu-icon icon-obj-xcd', mode:'selected', items:selectedItems, closable:true } * ``` * * Props(核心): * - `title`:header 标题 * - `iconClass/icon`:header 图标(优先 iconClass) * - `count`:数量回显(图表面板可不传) * - `closable`:是否显示“清空”按钮(触发 emit clear) * - `headerFilters`:header 条件数组(组件名 + props),用于联调接口搜索 * - `headerSearchButton`:是否显示搜索按钮(触发 emit search) * - `items`:列表数据 * - `mode`:`selected` 时右侧按钮语义为“移除” * - `itemLayout`:`simple` / `person`(人员号槽位) * - `itemAction`:是否显示 item 右侧操作按钮(hover 才出现) * * Emits: * - `remove(item)`:点击 item 右侧移除 * - `clear()`:点击 header 清空 * - `search({keyword, filters})`:点击 header 搜索 */ const SsSidebarList = { name: "SsSidebarList", props: { title: { type: String, default: "" }, // header 图标:优先使用 iconClass(走 ss-icon v3.0 class 分支) by xu 20260106 iconClass: { type: String, default: "" }, icon: { type: String, default: "" }, // 兼容旧写法(ss-icon name) count: { type: [Number, String], default: "" }, // 选中类分区:右侧关闭按钮=清空分区数据 by xu 20260106 closable: { type: Boolean, default: false }, searchable: { type: Boolean, default: false }, // 搜索框是否放在 header 内(人员块需要该布局) by xu 20260106 searchInHeader: { type: Boolean, default: false }, // header 搜索:下拉条件 + 搜索按钮(适合“人员”块) by xu 20260106 headerFilters: { type: Array, default: () => [] }, headerSearchButton: { type: Boolean, default: false }, searchPlaceholder: { type: String, default: "搜索" }, // 列表项布局:simple(仅标题) / person(标题+人员号槽位) by xu 20260106 itemLayout: { type: String, default: "simple" }, itemAction: { type: Boolean, default: true }, collapsible: { type: Boolean, default: true }, // 功能说明:是否允许双击 header 折叠/展开 by xu 20260116 collapsed: { type: Boolean, default: false }, // 功能说明:折叠态仅展示 header by xu 20260116 items: { type: Array, default: () => [] }, mode: { type: String, default: "search" }, // search / selected }, emits: ["select", "remove", "clear", "search", "toggle-collapse"], data() { return { keyword: "", filterValues: {}, }; // 功能说明:折叠状态完全由 props.collapsed 驱动,避免多面板复用导致状态不同步 by xu 20260116 }, created() { // header 下拉条件默认值初始化 by xu 20260106 (this.headerFilters || []).forEach((f) => { if (!f || !f.key) return; if (this.filterValues[f.key] !== undefined) return; const first = f?.options?.[0]?.value ?? ""; this.filterValues[f.key] = f.value !== undefined ? f.value : first; }); }, methods: { __shouldIgnoreHeaderToggle(e) { // 功能说明:忽略工具区/输入区触发折叠,避免误触 by xu 20260116 const t = e?.target; if (!t || !t.closest) return false; if (t.closest(".ss-sidebar-panel__tools")) return true; if (t.closest(".ss-sidebar-panel__filters")) return true; if (t.closest("input,textarea,select,button")) return true; return false; }, __toggleCollapseInternal(e, source) { if (!this.collapsible) return; if (this.__shouldIgnoreHeaderToggle(e)) return; const nextCollapsed = !this.collapsed; console.log("[SsSidebarList] toggle emit", { title: this.title, source, to: nextCollapsed }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 // 功能说明:由父组件(SsSidebar.toggleSectionCollapse)统一控制 section 高度与折叠数组 by xu 20260116 this.$emit("toggle-collapse"); }, }, render() { const items = this.items || []; const SsIcon = Vue.resolveComponent("ss-icon"); const isSelectedMode = this.mode === "selected"; const activeKeyword = this.filterValues?.keyword ?? this.keyword; // 功能:keyword 优先取 headerFilters.keyword by xu 20260106 const hasHeaderKeyword = (this.headerFilters || []).some((f) => f?.key === "keyword"); // 功能:header 内 keyword 过滤 by xu 20260106 const renderHeaderFilter = (f) => { if (!f) return null; const key = f.key; const componentName = f.component; if (!key || !componentName) return null; const Comp = Vue.resolveComponent(componentName); if (!Comp) return null; const modelValue = this.filterValues[key]; const props = f.props || {}; return Vue.h(Comp, { ...props, modelValue, "onUpdate:modelValue": (v) => { this.filterValues[key] = v; }, }); }; const filteredItems = this.searchable && activeKeyword ? items.filter((it) => String(it?.title ?? "") .toLowerCase() .includes(String(activeKeyword).toLowerCase()) ) : items; if (!filteredItems.length && !this.title) return null; if (this.collapsed) { return Vue.h("div", { class: "ss-sidebar-panel" }, [ this.title ? Vue.h( "div", { class: "ss-sidebar-panel__header", // 功能说明:折叠触发绑定到整个 header(dblclick + click.detail==2 兜底) by xu 20260116 onDblclick: (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); console.log("[SsSidebarList] header dblclick", { title: this.title, collapsed: true }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 this.__toggleCollapseInternal(e, "dblclick"); }, // 功能说明:移除 click.detail==2 兜底,避免双击同时触发 click+dblclick 导致“折叠又立刻展开” by xu 20260116 }, [ Vue.h("div", { class: "ss-sidebar-panel__title" }, [ this.iconClass ? Vue.h(SsIcon, { class: this.iconClass + " ss-sidebar-panel__icon" }) : this.icon ? Vue.h(SsIcon, { name: this.icon, size: "16px", class: "ss-sidebar-panel__icon" }) : null, Vue.h("span", null, this.title), this.count !== "" ? Vue.h("span", { class: "ss-sidebar-panel__count" }, `(${this.count})`) : null, ]), Vue.h("div", { class: "ss-sidebar-panel__tools" }, [ this.closable ? Vue.h( "button", { type: "button", class: "ss-sidebar-icon-btn ss-sidebar-header-btn", title: "清空", onClick: (e) => { e.stopPropagation(); this.$emit("clear"); }, }, [Vue.h(SsIcon, { class: "menu-base-icon icon-cl" })] ) : null, ]), ] ) : null, ]); } return Vue.h("div", { class: "ss-sidebar-panel" }, [ this.title ? Vue.h( "div", { class: "ss-sidebar-panel__header", // 功能说明:折叠触发绑定到整个 header(dblclick + click.detail==2 兜底) by xu 20260116 onDblclick: (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); console.log("[SsSidebarList] header dblclick", { title: this.title, collapsed: false }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 this.__toggleCollapseInternal(e, "dblclick"); }, // 功能说明:移除 click.detail==2 兜底,避免双击同时触发 click+dblclick 导致“折叠又立刻展开” by xu 20260116 }, [ Vue.h("div", { class: "ss-sidebar-panel__title" }, [ // 图标 + 标题(每个分区都有) by xu 20260106 this.iconClass ? Vue.h(SsIcon, { class: this.iconClass + " ss-sidebar-panel__icon" }) : this.icon ? Vue.h(SsIcon, { name: this.icon, size: "16px", class: "ss-sidebar-panel__icon" }) : null, Vue.h("span", null, this.title), // 数量回显:图表分区可不传 count by xu 20260106 this.count !== "" ? Vue.h("span", { class: "ss-sidebar-panel__count" }, `(${this.count})`) : null, ]), Vue.h("div", { class: "ss-sidebar-panel__tools" }, [ // header 条件(例如下拉框)+ 右侧搜索按钮 by xu 20260106 this.headerFilters?.length ? Vue.h( "div", { class: "ss-sidebar-panel__filters" }, this.headerFilters.map(renderHeaderFilter).filter(Boolean) ) : null, this.headerSearchButton ? Vue.h( "button", { type: "button", class: "ss-sidebar-icon-btn", title: "搜索", onClick: (e) => { e.stopPropagation(); this.$emit("search", { // headerFilters 内也可能包含 keyword by xu 20260106 keyword: activeKeyword, filters: { ...(this.filterValues || {}) }, }); }, }, [Vue.h(SsIcon, { name: "search", size: "14px" })] ) : null, // 人员块:搜索框在 header 内 by xu 20260106 this.searchable && this.searchInHeader && !this.headerSearchButton ? Vue.h("div", { class: "ss-sidebar-panel__searchInline" }, [ Vue.h("div", { class: "ss-sidebar-search is-inline" }, [ Vue.h(SsIcon, { name: "search", size: "14px", class: "ss-sidebar-search__prefix" }), Vue.h("input", { class: "ss-sidebar-search__input", value: this.keyword, placeholder: this.searchPlaceholder, onInput: (e) => { this.keyword = e?.target?.value ?? ""; }, }), ]), ]) : null, this.closable ? Vue.h( "button", { type: "button", class: "ss-sidebar-icon-btn ss-sidebar-header-btn", title: "清空", onClick: (e) => { e.stopPropagation(); this.$emit("clear"); }, }, // 清空按钮使用 icon-base 的 icon-cl by xu 20260106 [Vue.h(SsIcon, { class: "menu-base-icon icon-cl" })] ) : null, ]), ]) : null, // 非 header 内搜索:独立一行 by xu 20260106 // headerSearchButton/headerFilters 已覆盖搜索能力时,不再额外渲染独立搜索行 by xu 20260106 this.searchable && !this.searchInHeader && !this.headerSearchButton && !hasHeaderKeyword ? Vue.h("div", { class: "ss-sidebar-panel__search" }, [ Vue.h("div", { class: "ss-sidebar-search" }, [ Vue.h(SsIcon, { name: "search", size: "14px", class: "ss-sidebar-search__prefix" }), Vue.h("input", { class: "ss-sidebar-search__input", value: this.keyword, placeholder: this.searchPlaceholder, onInput: (e) => { this.keyword = e?.target?.value ?? ""; }, }), ]), ]) : null, Vue.h( "div", { class: "ss-sidebar-list" }, filteredItems.map((item, idx) => { const title = item?.title ?? ""; const tags = item?.tags || []; const isPersonLayout = this.itemLayout === "person"; const hasTags = !isPersonLayout && (tags?.length > 0); // 列表项垂直对齐:有 tags 顶对齐 by xu 20260106 return Vue.h( "div", { class: { "ss-sidebar-list-item": true, "is-first": idx === 0, "is-person": isPersonLayout, "has-tags": hasTags, }, }, [ Vue.h("div", { class: "ss-sidebar-list-item__main" }, [ Vue.h( "div", { class: "ss-sidebar-list-item__title" }, Vue.h( "span", { style: { "white-space": "nowrap", overflow: "hidden", "text-overflow": "ellipsis", }, }, title ) ), // 非人员布局才显示 tags by xu 20260106 !isPersonLayout && tags?.length ? Vue.h( "div", { class: "ss-sidebar-list-item__tags" }, tags.map((tag) => { const [k, v] = Object.entries(tag)[0] || ["", ""]; return Vue.h("span", { class: "ss-sidebar-tag", title: `${k}: ${v}` }, `${k}: ${v}`); }) ) : null, ]), // 人员布局:中间保留“人员号”槽位 by xu 20260106 isPersonLayout ? Vue.h( "div", { class: "ss-sidebar-list-item__meta", title: String(item?.meta ?? "") }, item?.meta ?? "" ) : null, this.itemAction ? Vue.h( "button", { type: "button", class: { // item 操作按钮:默认无背景/无边框,hover 才高亮 by xu 20260106 "ss-sidebar-item-btn": true, }, title: isSelectedMode ? "移除" : "选择", onClick: (e) => { e.stopPropagation(); if (isSelectedMode) this.$emit("remove", item); else this.$emit("select", item); }, }, [ // item 移除图标使用 icon-base 的 icon-cl by xu 20260106 isSelectedMode ? Vue.h(SsIcon, { class: "menu-base-icon icon-cl" }) : Vue.h(SsIcon, { name: "check", size: "14px" }), ] ) : null, ] ); }) ), ]); }, }; // ss-sidebar-report-table:右侧“统计表/报表”面板(pstatList grtjlbm=51 聚拢渲染) by xu 20260115 const SsSidebarReportTable = { name: "SsSidebarReportTable", props: { title: { type: String, default: "" }, iconClass: { type: String, default: "" }, icon: { type: String, default: "" }, items: { type: Array, default: () => [] }, // pstatList(grtjlbm=51) 数组 onOpen: { type: Function, default: null }, // (srv, ctx) => void collapsible: { type: Boolean, default: true }, // 功能说明:是否允许双击 header 折叠/展开 by xu 20260116 collapsed: { type: Boolean, default: false }, // 功能说明:折叠态仅展示 header by xu 20260116 }, emits: ["open", "toggle-collapse"], data() { return {}; // 功能说明:折叠状态完全由 props.collapsed 驱动 by xu 20260116 }, methods: { __toggleCollapseInternal(e, source) { if (!this.collapsible) return; const next = !this.collapsed; console.log("[SsSidebarReportTable] toggle emit", { title: this.title, source, to: next }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 this.$emit("toggle-collapse"); }, }, render() { const SsIcon = Vue.resolveComponent("ss-icon"); const list = this.items || []; if (!this.title && !list.length) return null; const header = this.title ? Vue.h( "div", { class: "ss-sidebar-panel__header", // 功能说明:折叠触发绑定到整个 header(dblclick + click.detail==2 兜底) by xu 20260116 onDblclick: (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); console.log("[SsSidebarReportTable] header dblclick", { title: this.title, collapsed: this.collapsed }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 this.__toggleCollapseInternal(e, "dblclick"); }, // 功能说明:移除 click.detail==2 兜底,避免双击同时触发 click+dblclick 导致“折叠又立刻展开” by xu 20260116 }, [ Vue.h("div", { class: "ss-sidebar-panel__title" }, [ this.iconClass ? Vue.h(SsIcon, { class: this.iconClass + " ss-sidebar-panel__icon" }) : this.icon ? Vue.h(SsIcon, { name: this.icon, size: "16px", class: "ss-sidebar-panel__icon" }) : null, Vue.h("span", null, this.title), ]), Vue.h("div", { class: "ss-sidebar-panel__tools" }), ]) : null; const renderReport = (report) => { const title = String(report?.mc ?? ""); const mx = Array.isArray(report?.grtjmxList) ? report.grtjmxList : []; if (!title && !mx.length) return null; // 功能说明:每个报表对象渲染为一个 table(有边框、无圆角、表间距10px;样式由 base.css 统一控制) by xu 20260115 const cols = Math.max(1, mx.length); // 功能说明:table 外层包一层 wrap,子项过多时支持横向滚动 by xu 20260115 return Vue.h("div", { class: "ss-sidebar-report-table-wrap" }, [ Vue.h( "table", { class: "ss-sidebar-report-table" }, [ Vue.h("thead", null, [ Vue.h("tr", null, [ Vue.h( "th", { class: "ss-sidebar-report-table__title", colspan: cols }, Vue.h("div", { class: "ss-sidebar-report-table__title-content" }, [ Vue.h("span", { class: "ss-sidebar-report-table__dot" }), Vue.h("span", { class: "ss-sidebar-report-table__title-text", title }, title), ]) ), ]), ]), Vue.h("tbody", null, [ Vue.h( "tr", null, mx.map((cell) => { const text = String(cell?.mc ?? ""); const srv = { servName: cell?.fwm ?? "", dest: cell?.bjm ?? "", title: text, width: cell?.width, height: cell?.height, minHeight: cell?.height, maxHeight: cell?.height, showTitle: text, }; return Vue.h( "td", { class: "ss-sidebar-report-table__cell", title: text, onClick: (e) => { e?.stopPropagation?.(); try { this.onOpen?.(srv, { report, cell }); } catch (_) {} this.$emit("open", { report, cell, srv }); }, }, text ); }) ), ]), ] ), ]); }; // 功能说明:报表面板增加独立 class,便于 base.css 统一控制 padding/间距 by xu 20260115 return Vue.h("div", { class: "ss-sidebar-panel ss-sidebar-report-panel" }, [ header, this.collapsed ? null : Vue.h( "div", // 功能说明:报表列表滚动/高度样式下沉到 base.css,避免写在 DOM 上 by xu 20260115 { class: "ss-sidebar-report__list" }, list.map(renderReport).filter(Boolean) ), ]); }, }; // 组件文档补全(JSDoc) by xu 20260108 /** * SsSidebar(objList 右侧边栏容器) * * 用途: * - 统一渲染顶部按钮栏(buttons) * - 统一渲染中间业务面板(list panels,可拖拽调高度) * - 统一渲染底部图表(chart panels,内部用 ss-sidebar-chart-hover) * * 调用示例: * ```html * * ``` * * Props: * - `buttons`:顶部按钮配置数组 * - `panels`:分区配置数组(`type: 'list' | 'chart'`) * * Events(向外透传): * - `remove(item)`:来自 list 面板移除 * - `select(item)`:来自 list 面板选择(如后续需要) */ const SsSidebar = { name: "SsSidebar", props: { buttons: { type: Array, default: () => [] }, charts: { type: Array, default: () => [] }, list: { type: Array, default: () => [] }, // legacy listMode: { type: String, default: "search" }, // legacy panels: { type: Array, default: () => [] }, }, emits: ["select", "remove"], data() { return { // 业务面板高度(索引 -> px) by xu 20260106 sectionHeights: [], sectionCollapsed: [], // 功能说明:面板折叠状态(sectionPanels 索引) by xu 20260116 sectionHeightsExpanded: [], // 功能说明:面板展开高度缓存(用于折叠后恢复) by xu 20260116 chartCollapsed: [], // 功能说明:图表面板折叠状态(chartPanels 索引) by xu 20260116 chartHeaderTitleDownAt: [], // 功能说明:双击检测绑定到 chart 标题区 by xu 20260116 reportCollapsed: [], // 功能说明:报表面板折叠状态(reportPanels 索引) by xu 20260116 resizeTimer: null, resizing: false, resizeIndex: -1, resizeStartY: 0, resizeStartPrev: 0, resizeStartNext: 0, }; }, methods: { // 初始化默认高度(只在第一次/面板数量变化时补齐) by xu 20260106 ensureSectionHeights(sectionCount) { if (!Array.isArray(this.sectionHeights)) this.sectionHeights = []; if (this.sectionHeights.length === sectionCount) return; const next = []; for (let i = 0; i < sectionCount; i++) { next[i] = this.sectionHeights[i] ?? 190; // 默认高度 by xu 20260106 } this.sectionHeights = next; // 功能说明:面板数量变化时补齐折叠/缓存数组长度 by xu 20260116 this.sectionCollapsed = Array.from({ length: sectionCount }, (_, i) => !!this.sectionCollapsed?.[i]); this.sectionHeightsExpanded = Array.from({ length: sectionCount }, (_, i) => this.sectionHeightsExpanded?.[i] ?? null); }, toggleSectionCollapse(index) { // 功能说明:双击 header 折叠/展开 section 面板(仅控制高度与内容渲染) by xu 20260116 const i = Number(index); if (isNaN(i) || i < 0) return; // 功能说明:加更细粒度日志,定位“多面板折叠无视觉效果”的根因(高度是否真的变、DOM 是否更新) by xu 20260116 const collapsedHeight = 37; // 功能说明:header(35) + panel 边框(2),避免 flex shrink 导致 header 变 25px by xu 20260116 const cur = !!this.sectionCollapsed?.[i]; console.log("[SsSidebar] toggleSectionCollapse", { index: i, to: !cur, prevHeight: this.sectionHeights?.[i] }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 if (!cur) { this.sectionHeightsExpanded[i] = this.sectionHeights[i] ?? 190; this.sectionHeights.splice(i, 1, collapsedHeight); } else { const restore = Number(this.sectionHeightsExpanded?.[i] ?? 190) || 190; this.sectionHeights.splice(i, 1, restore); } this.sectionCollapsed.splice(i, 1, !cur); console.log("[SsSidebar] section state(after)", { index: i, height: this.sectionHeights?.[i], collapsed: this.sectionCollapsed?.[i], allHeights: Array.from(this.sectionHeights || []), allCollapsed: Array.from(this.sectionCollapsed || []), }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 Vue.nextTick(() => { try { const root = this.$el; const sections = root?.querySelectorAll?.(".ss-sidebar-section"); const el = sections?.[i]; const rectH = el?.getBoundingClientRect?.().height; console.log("[SsSidebar] section dom(beforeFix)", { index: i, styleHeight: el?.style?.height, rectH }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 // 功能说明:若渲染未把 height patch 到 DOM,则在 nextTick 强制同步一次(并打印) by xu 20260116 const targetH = (Number(this.sectionHeights?.[i] ?? 190) || 190) + "px"; if (el && el.style && el.style.height !== targetH) { el.style.height = targetH; } if (el?.classList) { el.classList.toggle("is-collapsed", !!this.sectionCollapsed?.[i]); } const rectAfter = el?.getBoundingClientRect?.().height; console.log("[SsSidebar] section dom(afterFix)", { index: i, styleHeight: el?.style?.height, rectH: rectAfter, targetH }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 } catch (err) { console.log("[SsSidebar] section dom(afterTick) error", err); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 } }); }, toggleChartCollapse(index) { // 功能说明:双击 header 折叠/展开底部 chart 面板(隐藏/显示 chart-hover) by xu 20260116 const i = Number(index); if (isNaN(i) || i < 0) return; const cur = !!this.chartCollapsed?.[i]; console.log("[SsSidebar] toggleChartCollapse", { index: i, to: !cur }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 this.chartCollapsed.splice(i, 1, !cur); console.log("[SsSidebar] chart state(after)", { index: i, collapsed: this.chartCollapsed?.[i], allCollapsed: Array.from(this.chartCollapsed || []) }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 Vue.nextTick(() => { try { const root = this.$el; const el = root?.querySelector?.(`.ss-sidebar-chart-panel[data-chart-idx="${i}"]`); console.log("[SsSidebar] chart dom(beforeFix)", { index: i, found: !!el }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 if (el?.classList) el.classList.toggle("is-collapsed", !!this.chartCollapsed?.[i]); console.log("[SsSidebar] chart dom(afterFix)", { index: i, collapsed: !!this.chartCollapsed?.[i] }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 } catch (err) { console.log("[SsSidebar] chart dom(afterTick) error", err); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 } }); }, toggleReportCollapse(index) { // 功能说明:双击 header 折叠/展开底部 report-table 面板(隐藏/显示表格) by xu 20260116 const i = Number(index); if (isNaN(i) || i < 0) return; const cur = !!this.reportCollapsed?.[i]; console.log("[SsSidebar] toggleReportCollapse", { index: i, to: !cur }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 this.reportCollapsed.splice(i, 1, !cur); console.log("[SsSidebar] report state(after)", { index: i, collapsed: this.reportCollapsed?.[i], allCollapsed: Array.from(this.reportCollapsed || []) }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 Vue.nextTick(() => { try { const root = this.$el; const el = root?.querySelector?.(`.ss-sidebar-report-panel[data-report-idx="${i}"]`); console.log("[SsSidebar] report dom(beforeFix)", { index: i, found: !!el }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 if (el?.classList) el.classList.toggle("is-collapsed", !!this.reportCollapsed?.[i]); console.log("[SsSidebar] report dom(afterFix)", { index: i, collapsed: !!this.reportCollapsed?.[i] }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 } catch (err) { console.log("[SsSidebar] report dom(afterTick) error", err); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 } }); }, startResize(index, e) { if (e?.preventDefault) e.preventDefault(); if (e?.stopPropagation) e.stopPropagation(); if (this.resizing) return; // 长按 0.5s 后才进入拖拽调高度 by xu 20260106 clearTimeout(this.resizeTimer); const startY = e?.clientY ?? 0; const gapEl = e?.currentTarget; this.resizeTimer = setTimeout(() => { this.resizing = true; this.resizeIndex = index; this.resizeStartY = startY; this.resizeStartPrev = this.sectionHeights[index] ?? 190; this.resizeStartNext = this.sectionHeights[index + 1] ?? 190; gapEl?.classList?.add("is-active"); window.addEventListener("pointermove", this.onResizeMove, { passive: false }); window.addEventListener("pointerup", this.endResize, { passive: false, once: true }); }, 500); }, onResizeMove(e) { if (!this.resizing) return; if (e?.preventDefault) e.preventDefault(); const dy = (e?.clientY ?? 0) - this.resizeStartY; const minPanelHeight = 83; // header(35) + listMin(48) by xu 20260106 const prev = Math.max(minPanelHeight, this.resizeStartPrev + dy); const next = Math.max(minPanelHeight, this.resizeStartNext - dy); // 若其中一个达到最小值,则停止继续挤压 by xu 20260106 const adjustedDy = prev - this.resizeStartPrev; const nextAdjusted = this.resizeStartNext - adjustedDy; this.sectionHeights.splice(this.resizeIndex, 1, prev); this.sectionHeights.splice(this.resizeIndex + 1, 1, Math.max(minPanelHeight, nextAdjusted)); }, endResize(e) { clearTimeout(this.resizeTimer); this.resizeTimer = null; if (!this.resizing) return; if (e?.preventDefault) e.preventDefault(); const activeGaps = document.querySelectorAll(".ss-sidebar-gap.is-active"); activeGaps.forEach((g) => g.classList.remove("is-active")); this.resizing = false; this.resizeIndex = -1; window.removeEventListener("pointermove", this.onResizeMove); }, }, mounted() { clearTimeout(this.resizeTimer); this.resizeTimer = null; // 功能说明:暂时回退为固定底部留白方案(CSS 控制),后续再定位遮挡根因 by xu 20260115 }, beforeUnmount() { clearTimeout(this.resizeTimer); this.resizeTimer = null; window.removeEventListener("pointermove", this.onResizeMove); }, render() { const SsSidebarButtonsComp = Vue.resolveComponent("ss-sidebar-buttons"); const SsSidebarChartComp = Vue.resolveComponent("ss-sidebar-chart"); const SsSidebarListComp = Vue.resolveComponent("ss-sidebar-list"); const SsSidebarReportTableComp = Vue.resolveComponent("ss-sidebar-report-table"); const SsIcon = Vue.resolveComponent("ss-icon"); // 支持 panels(多分区),list/listMode 作为 legacy 兜底 by xu 20260106 const panels = (this.panels || []).length ? this.panels : this.list?.length ? [{ type: "list", title: "已选", icon: "", mode: this.listMode, items: this.list }] : []; // 功能说明:右侧栏强制移除“对象”tab(兼容后端返回 rbarObj/rbarobj 或直接返回中文“对象”标题) by xu 20260116 const panelsNoObj = (panels || []).filter((p) => { const k = String(p?._tabKey ?? "").trim().toLowerCase(); const t = String(p?.title ?? "").trim(); if (k === "rbarobj") return false; if (t === "对象") return false; return true; }); // 功能说明:report-table 作为底部报表区(放在统计图下面),不参与可拖拽 section 面板 by xu 20260115 const sectionPanels = panelsNoObj.filter((p) => p?.type !== "chart" && p?.type !== "report-table"); const chartPanels = panelsNoObj.filter((p) => p?.type === "chart"); const reportPanels = panelsNoObj.filter((p) => p?.type === "report-table"); this.ensureSectionHeights(sectionPanels.length); // 功能说明:补齐 chart/report 折叠数组长度 by xu 20260116 this.chartCollapsed = Array.from({ length: chartPanels.length }, (_, i) => !!this.chartCollapsed?.[i]); this.chartHeaderTitleDownAt = Array.from({ length: chartPanels.length }, (_, i) => this.chartHeaderTitleDownAt?.[i] ?? 0); this.reportCollapsed = Array.from({ length: reportPanels.length }, (_, i) => !!this.reportCollapsed?.[i]); return Vue.h("div", { class: "ss-sidebar" }, [ this.buttons?.length ? Vue.h(SsSidebarButtonsComp, { items: this.buttons }) : null, Vue.h( "div", { class: "ss-sidebar__inner" }, [ ...(this.charts || []).map((c) => Vue.h(SsSidebarChartComp, { options: c?.options || {}, height: c?.height || "200px" }) ), // 可拖拽的业务面板容器 by xu 20260106 Vue.h( "div", { class: "ss-sidebar-sections", style: { flex: "0 0 auto" } }, // 功能说明:禁止 flex shrink,避免面板很多时 header 被压缩成一条线 by xu 20260116 sectionPanels.flatMap((p, idx) => { // 功能说明:section 面板支持 list / report-table 两种渲染 by xu 20260115 const panelContent = p?.type === "report-table" ? Vue.h(SsSidebarReportTableComp, { key: `ss-sidebar-report-in-section-${idx}-${p?.title ?? ""}`, // 功能说明:加 key 防止多面板时组件实例复用导致折叠态不更新 by xu 20260116 title: p?.title ?? "", icon: p?.icon ?? "", iconClass: p?.iconClass ?? "", items: p?.items || [], onOpen: (srv, ctx) => p?.onOpen?.(srv, ctx), }) : Vue.h(SsSidebarListComp, { key: `ss-sidebar-list-${idx}-${p?.title ?? ""}`, // 功能说明:加 key 防止多面板时组件实例复用导致折叠态不更新 by xu 20260116 title: p?.title ?? "", icon: p?.icon ?? "", count: p?.count ?? (p?.items?.length ?? ""), closable: !!p?.closable, searchable: !!p?.searchable, searchInHeader: !!p?.searchInHeader, headerFilters: p?.headerFilters || [], headerSearchButton: !!p?.headerSearchButton, searchPlaceholder: p?.searchPlaceholder ?? "搜索", itemLayout: p?.itemLayout ?? "simple", itemAction: p?.itemAction ?? true, // 功能说明:面板可配置是否显示 hover 操作按钮(之前未透传导致总显示) by xu 20260114 collapsible: true, // 功能说明:双击 header 可折叠/展开 by xu 20260116 collapsed: !!this.sectionCollapsed?.[idx], // 功能说明:折叠态仅展示 header by xu 20260116 onToggleCollapse: () => this.toggleSectionCollapse?.(idx), // 功能说明:折叠事件回调 by xu 20260116 iconClass: p?.iconClass ?? "", items: p?.items || [], mode: p?.mode || "search", onSelect: (item) => this.$emit("select", item), onRemove: (item) => this.$emit("remove", item), // closable = 清空分区数据 by xu 20260106 onClear: () => p?.onClear?.(), onSearch: (payload) => p?.onSearch?.(payload), }); const section = Vue.h( "div", { class: { "ss-sidebar-section": true, "is-collapsed": !!this.sectionCollapsed?.[idx] }, // 功能说明:折叠态加 class,配合 CSS 裁剪溢出(否则高度变了内容仍 overflow visible) by xu 20260116 key: `ss-sidebar-section-${idx}-${p?.type ?? "list"}-${p?.title ?? ""}`, // 功能说明:加 key 保证 section 节点稳定更新 by xu 20260116 style: { height: (this.sectionHeights[idx] ?? 190) + "px", flex: "0 0 auto" }, // 功能说明:禁止 flex shrink,避免折叠/展开后 header 高度被挤压 by xu 20260116 }, [ Vue.h("div", { class: "ss-sidebar-section__content" }, [ panelContent, ]), ] ); const gap = idx < sectionPanels.length - 1 ? Vue.h("div", { class: "ss-sidebar-gap", onPointerdown: (e) => this.startResize(idx, e), }) : null; return gap ? [section, gap] : [section]; }) ), // 图表区固定在底部(hover 弹出大图) by xu 20260106 ...chartPanels.map((p, chartIdx) => Vue.h( "div", { class: { "ss-sidebar-panel": true, "ss-sidebar-chart-panel": true, "is-collapsed": !!this.chartCollapsed?.[chartIdx], }, // 功能说明:图表折叠态加 class,DOM 兜底隐藏内容 by xu 20260116 style: { flex: "0 0 auto", minHeight: "37px" }, // 功能说明:禁止 flex shrink + 折叠态最小高度兜底,避免 header 被压扁 by xu 20260116 "data-chart-idx": chartIdx, // 功能说明:便于 toggleChartCollapse nextTick 精确定位 DOM by xu 20260116 key: `ss-sidebar-chart-${chartIdx}-${p?.title ?? ""}`, // 功能说明:加 key 防止多图表时实例复用 by xu 20260116 }, [ p?.title ? Vue.h( "div", { class: "ss-sidebar-panel__header", // 功能说明:折叠触发绑定到整个 header(仅 dblclick,避免双击触发两次) by xu 20260116 onDblclick: (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); console.log("[SsSidebar] chart header dblclick", { idx: chartIdx, title: p?.title }); // 功能说明:直接打印用于排查多面板不生效 by xu 20260116 this.toggleChartCollapse?.(chartIdx); }, // 功能说明:移除 click.detail==2 兜底,避免双击同时触发 click+dblclick 导致“折叠又立刻展开” by xu 20260116 }, [ Vue.h("div", { class: "ss-sidebar-panel__title" }, [ p?.iconClass ? Vue.h(SsIcon, { class: p.iconClass + " ss-sidebar-panel__icon" }) : p?.icon ? Vue.h(SsIcon, { name: p.icon, size: "16px", class: "ss-sidebar-panel__icon" }) : null, Vue.h("span", null, p.title), ]), Vue.h("div", { class: "ss-sidebar-panel__tools" }), ]) : null, // hover 大图也展示与 header 一致的图标/标题 by xu 20260108 (this.chartCollapsed?.[chartIdx]) ? null : Vue.h(Vue.resolveComponent("ss-sidebar-chart-hover"), { title: p?.title ?? "", iconClass: p?.iconClass ?? "", icon: p?.icon ?? "", options: p?.options || {}, height: p?.height || "240px", }), ]) ), // 功能说明:统计表(report-table)放在统计图下面,统一走 ss-sidebar-report-table 渲染 by xu 20260115 ...reportPanels.map((p, reportIdx) => // 功能说明:report-table 顶部也需要折叠态 class/定位属性,DOM 兜底隐藏内容 by xu 20260116 Vue.h("div", { class: { "ss-sidebar-report-panel-wrap": true, "ss-sidebar-report-panel": true, "is-collapsed": !!this.reportCollapsed?.[reportIdx], }, // 功能说明:报表折叠态加 class,DOM 兜底隐藏内容 by xu 20260116 style: { flex: "0 0 auto", minHeight: "37px" }, // 功能说明:禁止 flex shrink + 折叠态最小高度兜底,避免 header 被压扁 by xu 20260116 "data-report-idx": reportIdx, // 功能说明:便于 toggleReportCollapse nextTick 精确定位 DOM by xu 20260116 key: `ss-sidebar-report-wrap-${reportIdx}-${p?.title ?? ""}`, // 功能说明:加 key 防止多面板时实例复用导致折叠态不更新 by xu 20260116 }, [ // 功能说明:ss-sidebar-report-table 自带 ss-sidebar-panel 外壳,这里不重复包裹 by xu 20260115 Vue.h(SsSidebarReportTableComp, { key: `ss-sidebar-report-${reportIdx}-${p?.title ?? ""}`, // 功能说明:加 key 防止多面板时实例复用导致折叠态不更新 by xu 20260116 title: p?.title ?? "", icon: p?.icon ?? "", iconClass: p?.iconClass ?? "", items: p?.items || [], collapsible: true, // 功能说明:允许双击 header 折叠/展开 by xu 20260116 collapsed: !!this.reportCollapsed?.[reportIdx], // 功能说明:折叠态隐藏表格 by xu 20260116 onToggleCollapse: () => this.toggleReportCollapse?.(reportIdx), // 功能说明:报表折叠事件回调 by xu 20260116 onOpen: (srv, ctx) => p?.onOpen?.(srv, ctx), }), ]) ), ].filter(Boolean) ), ]); }, }; // ss-folder-card 文件夹卡片 const SsFolderCard = { name: "SsFolderCard", props: { item: { type: Object, required: true, }, }, data() { return { showButtons: false, }; }, emits: ["click", "change"], setup(props, { emit }) { const item = props.item; const showChildren = ref(false); const eventBus = window.parent.sharedEventBus; const itemWidth = Vue.computed(() => { // 功能说明:页面改为 grid 等分布局后,卡片宽度交给容器控制,这里固定 100% by xu 20260116 return "100%"; }); onMounted(() => { eventBus.subscribe("folderPath", (path) => { const currentPath = path || []; // 如果当前文件夹不在路径中,则销毁视图 if ( !currentPath.some((item) => item.folder.title === props.item.title) ) { showChildren.value = false; } }); }); const onItemClick = (e) => { if (e && e.stopPropagation) { e.stopPropagation(); } // 单击只处理 active 状态 if (e && e.currentTarget) { const allListCards = document.querySelectorAll( ".knowledge-item-container" ); const allFolderCards = document.querySelectorAll(".ss-folder-list"); allListCards.forEach((card) => card.classList.remove("active")); allFolderCards.forEach((card) => card.classList.remove("active")); e.currentTarget.classList.add("active"); } else { // 如果是数据对象,需要找到对应的 DOM 元素 const allListCards = document.querySelectorAll( ".knowledge-item-container" ); const allFolderCards = document.querySelectorAll(".ss-folder-list"); allListCards.forEach((card) => card.classList.remove("active")); allFolderCards.forEach((card) => card.classList.remove("active")); // 找到标题匹配的文件夹元素 const targetFolder = Array.from(allFolderCards).find((card) => card.textContent.includes(e.title) ); if (targetFolder) { targetFolder.classList.add("active"); } } emit("click", item); }; // 修改双击处理函数 const handleFolderDblClick = (folder, e) => { if (e) e.stopPropagation(); if (folder.children?.length) { showChildren.value = true; const pathInfo = { title: folder.title, folder: folder, }; const currentPath = eventBus.getState("folderPath") || []; if (!currentPath.some((item) => item.title === folder.title)) { eventBus.publish("folderPath", [...currentPath, pathInfo]); } } }; const onItemChange = (e, icon, index) => { e.stopPropagation(); props.item.buttons[0].onclick(); // emit("change", { item: props.item, icon, index }); }; return { item, itemWidth, showChildren, onItemClick, onItemChange, handleFolderDblClick, }; }, render() { const SsCartListIcon = Vue.resolveComponent("ss-cart-list-icon"); if (this.showChildren) { return h(SsFolderCartView, { folder: this.item, }); } return Vue.h( "div", { class: { "ss-folder-list": true, active: this.item.active }, onClick: (e) => { e.stopPropagation(); this.onItemClick(e); }, onDblclick: (e) => this.handleFolderDblClick(this.item, e), style: { width: this.itemWidth }, }, [ // 文件夹特有的装饰元素 Vue.h("div", { class: "ss-folder-list-trapezoid" }), Vue.h("div", { class: "ss-folder-list-top-transparent" }), Vue.h("div", { class: "ss-folder-list-top" }), Vue.h("div", { class: "ss-folder-list-right" }), // header 部分(按钮) this.item?.buttons?.length > 0 && Vue.h( "div", { class: "header", onMouseenter: () => (this.showButtons = true), onMouseleave: () => (this.showButtons = false), onClick: (e) => this.onItemChange(e, this.item.buttons[0], 0), }, [ // this.item?.buttons?.length > 0 && Vue.h("div", { class: "cart-list-setting cart-list-icon", title: this.item?.buttons?.[0]?.title, }), // this.item?.buttons?.length > 0 && this.showButtons && this.item?.buttons?.length > 1 && Vue.h( "div", { class: "cart-list-button-popup", }, this.item.buttons.map((btn) => Vue.h( "div", { onClick: (e) => { e.stopPropagation(); btn.onclick?.(); }, }, [ btn.class && Vue.h(SsCartListIcon, { class: [btn.class], }), Vue.h("span", null, btn.title), ] ) ) ), ] ), // body 部分 Vue.h("div", { class: "body" }, [ Vue.h("div", { class: "box-header" }, [ Vue.h("div", null, this.item.title), ]), Vue.h( "div", { class: !this.item.thumb ? "no-thumb box-body" : "box-body", }, [ this.item.thumb ? Vue.h("div", { class: "left" }, [ Vue.h("img", { src: this.item.thumb, alt: "Thumbnail", class: "imgUnHandle", style: { "object-fit": "cover", width: "100%", height: "100%", }, }), ]) : null, Vue.h("div", { class: "right" }, [ ...this.item.tags.map((tag) => { const [key, value] = Object.entries(tag)[0]; return Vue.h( "div", { class: "title", title: `${key}: ${value}`, }, `${key}: ${value}` ); }), ]), ] ), ]), ] ); }, }; // SsFolderCartView 组件 - 用于显示文件夹内容 const SsFolderCartView = { name: "SsFolderCartView", props: { folder: { type: Object, required: true, }, }, emits: ["click"], setup(props, { emit }) { const eventBus = window.parent.sharedEventBus; const currentFolder = ref(props.folder); const showChildren = ref(false); const onItemClick = (e) => { if (e && e.stopPropagation) { e.stopPropagation(); } // 单击只处理 active 状态 if (e && e.currentTarget) { const allListCards = document.querySelectorAll( ".knowledge-item-container" ); const allFolderCards = document.querySelectorAll(".ss-folder-list"); allListCards.forEach((card) => card.classList.remove("active")); allFolderCards.forEach((card) => card.classList.remove("active")); e.currentTarget.classList.add("active"); } else { // 如果是数据对象,需要找到对应的 DOM 元素 const allListCards = document.querySelectorAll( ".knowledge-item-container" ); const allFolderCards = document.querySelectorAll(".ss-folder-list"); allListCards.forEach((card) => card.classList.remove("active")); allFolderCards.forEach((card) => card.classList.remove("active")); // 找到标题匹配的文件夹元素 const targetFolder = Array.from(allFolderCards).find((card) => card.textContent.includes(e.title) ); if (targetFolder) { targetFolder.classList.add("active"); } } emit("click", props.folder); }; const handleFolderDblClick = (folder, e) => { if (e) e.stopPropagation(); if (folder.children?.length) { showChildren.value = true; const pathInfo = { title: folder.title, folder: folder, }; const currentPath = eventBus.getState("folderPath") || []; if (!currentPath.some((item) => item.title === folder.title)) { eventBus.publish("folderPath", [...currentPath, pathInfo]); currentFolder.value = folder; } } }; const goBack = (targetFolder) => { if (targetFolder === null) { // 返回根目录 eventBus.publish("folderPath", []); } else { currentFolder.value = targetFolder; } }; return { currentFolder, showChildren, onItemClick, handleFolderDblClick, goBack, }; }, render() { return h( "div", { class: "page-container", style: { position: "fixed", top: 0, left: 0, width: "100%", height: "100%", background: "var(--lightgray)", padding: "20px 0", zIndex: 1000, }, }, [ // 搜索栏 h("div", { class: "search-bar" }, [ h("div", { class: "search-bar-contaienr" }, [ h(SsBreadcrumb, { level: { onBack: this.goBack, }, }), ]), ]), // 内容区域 h( "div", { class: "content-area item-content-area", style: { gap: "20px" }, }, [ ...(this.currentFolder.children || []).map((child, index) => h(child.children ? SsFolderCard : SsListCard, { key: index, item: child, onClick: (e) => this.onItemClick(e), onDblclick: (e) => this.handleFolderDblClick(child, e), }) ), ] ), ] ); }, }; // ss-page分页 const SsPage = { name: "SsPage", props: { total: { type: Number, required: true, }, size: { type: Number, default: 10, }, page: { type: Number, default: 1, }, onChange: { type: Function, default: () => { }, }, }, setup(props) { const totalItems = ref(props.total); // 总条目数 const totalPages = ref(Math.ceil(props.total / props.size)); const currentPage = ref(props.page); // 当前页码 // 计算显示的信息 const pageInfo = ref( `共${totalItems.value}条,第 ${currentPage.value}/${totalPages.value} 页` ); // 上一页的逻辑 const goToPreviousPage = (e) => { e.preventDefault(); // 阻止默认行为 if (currentPage.value > 1) { currentPage.value -= 1; updatePageInfo(); props.onChange?.({ pageNo: currentPage.value, // 当前页码 rowNumPer: props.size, // 每页条数 rowNum: props.total, // 总记录数 }); } }; // 下一页的逻辑 const goToNextPage = (e) => { e.preventDefault(); // 阻止默认行为 if (currentPage.value < totalPages.value) { currentPage.value += 1; updatePageInfo(); props.onChange?.({ pageNo: currentPage.value, // 当前页码 rowNumPer: props.size, // 每页条数 rowNum: props.total, // 总记录数 }); } }; // 更新页码信息的函数 const updatePageInfo = () => { pageInfo.value = `共${totalItems.value}条,第 ${currentPage.value}/${totalPages.value} 页`; }; return { pageInfo, totalPages, goToPreviousPage, goToNextPage, }; }, render(props, { slots, emit }) { return Vue.h("div", { class: "pager-container" }, [ Vue.h("input", { type: "hidden", name: "rowNum", value: props.total }), Vue.h("input", { type: "hidden", name: "rowNumPer", value: props.size, }), Vue.h("input", { type: "hidden", name: "pageCount", value: this.totalPages, }), Vue.h("input", { type: "hidden", name: "pageNo", value: props.page }), Vue.h("div", { class: "pager-content" }, [ Vue.h("div", { class: "info" }, this.pageInfo), Vue.h( "div", { class: "btn" }, Vue.h( "button", { onClick: (e) => this.goToPreviousPage(e) }, "上一页" ) ), Vue.h( "div", { class: "btn" }, Vue.h("button", { onClick: (e) => this.goToNextPage(e) }, "下一页") ), ]), ]); }, }; // ss-right-info 一级页面右边栏 const SSRightInfo = { name: "SSRightInfo", setup() { // 初始化响应式数据 const item = ref({ thumb: "images/example/project-img.png", // 更换为适合你项目的实际路径 title: "工业和信息化产业高质量发展资金", }); return { item, }; }, render() { return Vue.h("div", { class: "info-container" }, [ Vue.h("div", { class: "header" }, [ Vue.h("div", [ Vue.h("img", { src: this.item.thumb, class: "imgUnHandle", style: { "object-fit": "cover", width: "100%", height: "100%" }, }), // 将 ImageViewer 替换为 img 标签 ]), Vue.h("div", [Vue.h("div", this.item.title)]), ]), Vue.h("div", { class: "section-container" }, [ Vue.h("div", { class: "section" }, [ Vue.h("div", { class: "title" }, "合同"), Vue.h("div", { class: "text" }, "合同总金额:42,399,320"), Vue.h( "div", { class: "a" }, "《工业和信息化产业高质量发展资金补助合同》" ), ]), Vue.h("div", { class: "section" }, [ Vue.h("div", { class: "title" }, "发票"), Vue.h("div", { class: "text" }, "应开发票总额:42,399,320"), Vue.h("div", { class: "text" }, "已开发票金额:17,235,345"), Vue.h("div", { class: "text" }, "未开发票金额:25,163,975"), ]), Vue.h("div", { class: "section" }, [ Vue.h("div", { class: "title" }, "项目组成员"), Vue.h("div", { class: "text" }, "我司:3人"), Vue.h("div", { class: "text" }, "对方:2人"), Vue.h("div", { class: "text" }, "项目负责人:张三"), ]), Vue.h("div", { class: "section" }, [ Vue.h("div", { class: "title" }, "采购"), Vue.h("div", { class: "text" }, "总额:999,320"), Vue.h("div", { class: "text" }, "已付金额:335,345"), Vue.h("div", { class: "text" }, "未付金额:663,975"), ]), ]), ]); }, }; // const SsSuccessPopup = { name: "SsSuccessPopup", props: { right: { type: String, default: "20px", }, bottom: { type: String, default: "calc(100% + 5px)", }, }, setup(props, { expose }) { // 响应式状态:是否可见 const visible = ref(false); // 计算样式 const style = computed(() => { return { "--message-dialog-right": props.right, "--message-dialog-bottom": props.bottom, }; }); // 显示对话框的方法 const show = () => { visible.value = true; }; // 隐藏对话框的方法 const hide = () => { visible.value = false; }; // 将方法暴露给外部使用 expose({ show, hide }); // 返回渲染函数 return () => { if (!visible.value) return null; const SsIcon = resolveComponent("ss-icon"); return h( "div", { class: "success-popup", style: style.value, onClick: (e) => e.stopPropagation(), }, [ h("div", { class: "left" }, [ h("div", { class: "icon" }, [ h(SsIcon, { name: "check", size: "36px" }), ]), ]), h("div", { class: "right" }, [ h("div", { class: "title" }, "提交成功"), h("div", { class: "desc" }, "您的信息已成功提交。"), ]), ] ); }; }, }; const SsErrorDialog = { name: "SsErrorDialog", setup(props, { emit }) { const visible = ref(false); const style = computed(() => { return {}; }); const show = () => { visible.value = true; }; const hide = () => { visible.value = false; }; const onBack = () => { emit("back"); hide(); }; return { visible, style, show, hide, onBack, }; }, render() { const SsIcon = resolveComponent("ss-icon"); return this.visible ? h( "div", { class: "errorDialog", style: this.style, onClick: (event) => event.stopPropagation(), }, [ h("div", { class: "body" }, [ h("div", { class: "left" }, [ h("div", { class: "icon" }, [ h(SsIcon, { name: "close", size: "36px" }), ]), ]), h("div", { class: "right" }, [ h("div", { class: "title" }, "操作失败"), h("div", { class: "desc" }, "请点击返回以继续。"), ]), ]), h("div", { class: "footer" }, [ h("div", { class: "left" }), h("div", { class: "right" }, [ h( "div", { class: "btn", onClick: this.onBack, }, [h(SsIcon, { name: "arrow-left-line" }), h("div", "返回")] ), ]), ]), ] ) : null; }, }; /** * 审核链条 * @name ss-verify * @param { Array } verify-list 审核节点列表 * @property { Array } verify-list 审核节点列表 * @example * verify-list [ * { * groupName: "", // 群组名称 * open: true, // 默认是否展开 * children:[ //群组里的人员 * { * thumb: "images/example/user-4.png", // 头像 * name: "李丽思 ", // 姓名 * role: "人事处处长", // 角色 * description: "同意。", // 审核意见 * time: "09:38 08/11", // 审核时间 * video: false, // false不显示/true显示 视频icon * link: false, // false不显示/true显示 链接icon 后续应该是附件 * } * ] * } * ] */ const SsVerify = { name: "SsVerify", props: { verifyList: { type: Array, required: true, }, }, setup(props) { const toggleOpen = (item) => { item.open = !item.open; }; onMounted(() => { setTimeout(() => { const lastOpenGroup = document.querySelector(".group-item-last-open"); console.log("lastOpenGroup", lastOpenGroup); if (lastOpenGroup) { const nodes = $(lastOpenGroup).find(".verify-node-container"); if (nodes.length) { let totalHeight = 0; const gudingHeight = 100; if (nodes.length === 1) { totalHeight = gudingHeight; } else { // 累加除最后一个节点外的所有节点高度 for (let i = 0; i < nodes.length - 1; i++) { totalHeight += $(nodes[i]).outerHeight(); } totalHeight += gudingHeight; } console.log("节点信息:", { 节点总数: nodes.length, 计算后的高度: totalHeight, }); lastOpenGroup.style.setProperty( "--group-line-height", `${totalHeight}px` ); } } }, 0); }); return { toggleOpen, }; }, render() { const SsIcon = resolveComponent("ss-icon"); const SsCommonIcon = resolveComponent("ss-common-icon"); const SsVerifyNode = resolveComponent("ss-verify-node"); return h( "div", { class: "verify-nodes" }, this.verifyList.map((item, i) => h( "div", { key: i, class: { "group-item": true, "group-item-last-open": i === this.verifyList.length - 1 && item.open, }, }, [ h( "div", { class: "group-item-title", onClick: () => this.toggleOpen(item), }, [ h("div", { class: "icon" }, [ item.open ? h(SsCommonIcon, { class: "common-icon-folder-open" }) : h(SsCommonIcon, { class: "common-icon-folder-close" }), h( "div", { class: "num", style: { top: item.open ? "60%" : "55%" }, }, item.children?.length || 0 ), ]), h("div", { class: "name" }, item.groupName), ] ), item.open && item.children?.length > 0 ? h( "div", { class: "group-item-children" }, item.children.map((citem, j) => h(SsVerifyNode, { key: j, item: citem, // isGroup: i + 1 !== this.verifyList.length, isGroup: true, }) ) ) : null, ] ) ) ); }, }; /** * 审核页面的审核节点 * @name ss-verify-node * @param {Object} item 审核节点信息 * @param {Boolean} isGroup 是否为分组节点 */ const SsVerifyNode = { name: "SsVerifyNode", props: { item: { type: Object, required: true, }, isGroup: { type: Boolean, default: false, }, }, render() { const SsIcon = resolveComponent("ss-icon"); const SsCommonIcon = resolveComponent("ss-common-icon"); return Vue.h("div", { class: "verify-node-container" }, [ Vue.h("div", { class: "info" }, [ Vue.h("div", { class: "avatar" }, [ Vue.h("img", { src: this.item.thumb, style: { width: "50px", height: "50px", borderRadius: "50%", }, }), ]), Vue.h("div", { class: "desc" }, [ Vue.h("div", this.item.name), Vue.h("div", this.item.role), ]), Vue.h("div", { class: "link" }, [ Vue.h("div", [ this.item.video ? Vue.h(SsCommonIcon, { class: "common-icon-video" }) : null, this.item.link ? Vue.h(SsCommonIcon, { class: "common-icon-paper-clip", }) : null, ]), ]), ]), Vue.h( "div", { class: { description: true, link: this.isGroup, }, attrs: { "data-num": "3" }, }, [Vue.h("div", this.item.description)] ), Vue.h("div", { class: "time" }, this.item.time), ]); }, }; /** * 智能识别图片的左侧图片播放 可以放大缩小旋转图片 * @name ss-orc-img-box * @param { Object } image-obj 包含图片的url, 和图片的名称 * */ const SsOrcImgBox = { name: "SsOrcImgBox", props: { imageObj: { type: Object, required: true, }, }, setup(props) { const zoom = ref(1); const rotation = ref(0); const containerWidth = ref(0); const containerHeight = ref(0); const container = ref(null); const imgPosition = ref({ x: 0, y: 0 }); const isDragging = ref(false); const lastMousePosition = ref({ x: 0, y: 0 }); const imgStyle = computed(() => ({ width: `${zoom.value * 100}%`, height: `${zoom.value * 100}%`, transform: `rotate(${rotation.value}deg) translate(${imgPosition.value.x}px, ${imgPosition.value.y}px)`, transformOrigin: "center center", cursor: isDragging.value ? "grabbing" : "grab", })); const resetZoom = () => { zoom.value = 1; rotation.value = rotation.value + 90; imgPosition.value = { x: 0, y: 0 }; }; const handleRangeChange = (event) => { const value = event.target.value / 50; // 0 到 100 映射到 0 到 2 的缩放 zoom.value = Math.max(value, 0.1); // 设置最小缩放值为 0.1 }; const updateImgBoxDimensions = () => { if (container.value) { containerWidth.value = container.value.clientWidth; containerHeight.value = container.value.clientHeight; } }; const onMouseDown = (event) => { isDragging.value = true; lastMousePosition.value = { x: event.clientX, y: event.clientY }; }; const onMouseMove = (event) => { if (isDragging.value) { const dx = event.clientX - lastMousePosition.value.x; const dy = event.clientY - lastMousePosition.value.y; // 防止旋转后拖动的x,y反转 // 首先将当前旋转角度从度数转换为弧度,因为 JavaScript 的 Math 库使用弧度 const angle = rotation.value * (Math.PI / 180); // 使用基本的二维旋转矩阵将原始位移 dx 和 dy 转换为旋转后的位移 rotatedDx 和 rotatedDy。 const rotatedDx = dx * Math.cos(angle) + dy * Math.sin(angle); const rotatedDy = dy * Math.cos(angle) - dx * Math.sin(angle); imgPosition.value = { x: imgPosition.value.x + rotatedDx, y: imgPosition.value.y + rotatedDy, }; lastMousePosition.value = { x: event.clientX, y: event.clientY }; } }; const onMouseUp = () => { isDragging.value = false; }; onMounted(() => { nextTick(() => { updateImgBoxDimensions(); window.addEventListener("resize", updateImgBoxDimensions); window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); }); }); return { zoom, rotation, container, imgStyle, resetZoom, handleRangeChange, containerWidth, containerHeight, onMouseDown, imgPosition, }; }, render() { const SsIcon = resolveComponent("ss-icon"); return h("div", { class: "ocr-img-box" }, [ h("div", { class: "img-bar" }, [ h("div", this.imageObj.name), h("div", { class: "action-bar" }, [ h("div", { class: "ocr-img-range-box" }, [ h("input", { type: "range", min: 0, max: 100, value: this.zoom * 50, // 初始位置为50 onInput: this.handleRangeChange, }), h("span", { class: "line" }), ]), h(SsIcon, { name: "reset", size: "26px", onClick: this.resetZoom, }), ]), ]), h("div", { class: "img-viewer", ref: "container" }, [ h( "div", { class: "img-box", style: { width: `${this.containerWidth}px`, height: `${this.containerHeight}px`, overflow: "hidden", position: "relative", }, }, [ h("img", { src: this.imageObj.thumb, style: this.imgStyle, class: "zoomable-img", onMousedown: this.onMouseDown, }), ] ), ]), ]); }, }; // 搜索输入框组件 const SsSearchInput = { name: "SsSearchInput", props: { name: String, placeholder: String, width: { type: String, default: "100px", }, modelValue: String, }, emits: ["update:modelValue", "search"], setup(props, { emit }) { const handleInput = (e) => { emit("update:modelValue", e.target.value); }; const handleKeyup = (e) => { if (e.key === "Enter") { emit("search"); } }; return { handleInput, handleKeyup }; }, render() { return h( "div", { class: "input", style: this.width ? { width: this.width } : undefined, }, [ h("input", { name: this.name, placeholder: this.placeholder, value: this.modelValue, onInput: this.handleInput, onKeyup: this.handleKeyup, }), ] ); }, }; // ss-search-date-picker 日期时间选择器组件 const SsSearchDatePicker = { name: "SsSearchDatePicker", props: { modelValue: { type: [String, Number, Date], default: "", }, name: { type: String, required: true, }, type: { type: String, default: "date", validator: (value) => ["date", "datetime", "time"].includes(value), }, fmt: { type: String, default: null, }, placeholder: { type: String, default: "", }, width: { type: String, default: "100%", }, }, emits: ["update:modelValue"], setup(props, { emit }) { const errMsg = ref(""); const validate = () => { if (window.ssVm) { const result = window.ssVm.validateField(props.name); console.log("validate", window.ssVm.validateField(props.name)); errMsg.value = result.valid ? "" : result.message; } }; // 根据type确定默认格式 const defaultFormat = computed(() => { switch (props.type) { case "datetime": return "YYYY-MM-DD HH:mm:ss"; case "date": return "YYYY-MM-DD"; case "time": return "HH:mm:ss"; } }); const convertJavaFormatToElement = (javaFormat) => { if (!javaFormat) return null; return javaFormat .replace("yyyy", "YYYY") .replace("MM", "MM") .replace("dd", "DD") .replace("HH", "HH") .replace("mm", "mm") .replace("ss", "ss"); }; const finalFormat = computed(() => { if (props.fmt) { return convertJavaFormatToElement(props.fmt); } return defaultFormat.value; }); // 使用 resolveComponent 获取组件 const ElDatePicker = resolveComponent("ElDatePicker"); const ElTimePicker = resolveComponent("ElTimePicker"); const SsFormIcon = resolveComponent("SsFormIcon"); const ElIcon = resolveComponent("ElIcon"); let useTimePicker = true; //"yyyy-MM-dd HH:mm:ss"; "日期字符串格式在java的写法",传到本组件fmt属性也是按这个格式 if (props.fmt) { //有fmt属性,则以fmt属性优先判断类型 if (/[dMy]/.test(props.fmt)) { //如果有传入日期格式,且含年月日 useTimePicker = false; } else { useTimePicker = true; } } else if (props.type !== "time") { useTimePicker = false; } const dateType = computed(() => { const fmt = props.fmt || ""; if (fmt.includes("HH:mm:ss")) { return "datetime"; } else if (fmt.includes("HH:mm")) { return "datetime"; } else if (fmt.includes("mm:ss")) { return "time"; } return "date"; }); const handleValueUpdate = (val) => { emit("update:modelValue", val); emit("change", val); // 同时触发 change 事件 setTimeout(() => { validate(); }, 50); }; return () => h( "div", { class: "ss-search-date-picker", style: { width: props.width } }, [ h("input", { type: "hidden", name: props.name, value: props.modelValue, }), h(useTimePicker ? ElTimePicker : ElDatePicker, { modelValue: props.modelValue, "onUpdate:modelValue": handleValueUpdate, type: dateType.value, format: finalFormat.value, "value-format": finalFormat.value, clearable: true, placeholder: props.placeholder, class: "custom-date-picker", // 用于自定义样式 "time-arrow-control": props.type === "datetime", // 修改这里 size: "large", // 添加这一行,改为 large 尺寸 style: { width: "100%" }, "prefix-icon": h(SsFormIcon, { class: "form-icon-time" }), }), ] ); }, }; // 搜索按钮组件(包含下拉按钮) const SsSearchButton = { name: "SsSearchButton", props: { text: { type: String, required: true, }, iconClass: { type: String, required: false, }, opt: { type: Array, default: () => [], }, checkId: { type: String, default: "0", }, width: { //add by Ben(20251225) type: String, required: false }, id: { //add by Ben(20251225) type: String, required: false }, }, emits: ["click"], setup(props, { emit }) { const currentId = ref(props.checkId || "0"); const showPopup = ref(false); const handleMouseEnter = () => { showPopup.value = true; }; const handleMouseLeave = () => { showPopup.value = false; }; // 添加点击事件处理,阻止默认行为 const handleClick = (e) => { e.preventDefault(); if (props.opt?.length > 0) { const selectedOption = currentId.value === "0" ? props.opt[0] : props.opt.find((opt) => opt.id === currentId.value); if (selectedOption) { selectedOption.callback?.(); } } else { emit("click", e); } }; // 获取显示文本 const getDisplayText = () => { if (!props.opt?.length) return props.text; const selectedOption = currentId.value === "0" ? props.opt[0] : props.opt.find((opt) => opt.id === currentId.value); return selectedOption ? selectedOption.desc : props.opt[0].desc; }; return () => h( "button", { class: props.opt?.length > 0 ? "ss-drop-button ss-drop-button-more" : "ss-drop-button", type: "button", // 明确指定按钮类型为 button onMouseenter: handleMouseEnter, onMouseleave: handleMouseLeave, onClick: handleClick, // 添加点击事件处理 style: { width: props.width }, //add by Ben(20251225) id: props.id //add by Ben(20251225) }, [ props.iconClass ? h("span", { class: props.iconClass, style: { fontFamily: "iconfont", marginRight: "5px" }, }) : null, h("span", getDisplayText()), props.opt.length > 0 && showPopup.value && h( "div", { class: "popup", }, props.opt.map((item) => h( "div", { onClick: (e) => { e.preventDefault(); // 选项点击也阻止默认行为 e.stopPropagation(); // 阻止事件冒泡 currentId.value = item.id; // 更新当前选中的ID item.callback(); showPopup.value = false; // 选择后关闭弹窗 }, }, item.desc ) ) ), ] ); }, }; // 下拉按钮组件 const SsDropButton = { name: "SsDropButton", props: { text: { type: String, required: true, }, iconClass: { type: String, required: true, }, opt: { type: Array, default: () => [], }, checkId: { type: String, default: "0", }, onclick: { type: Function, default: null, }, }, setup(props) { const currentId = ref(props.checkId || "0"); const showPopup = ref(false); const handleMouseEnter = () => { showPopup.value = true; }; const handleMouseLeave = () => { showPopup.value = false; }; // 添加点击事件处理,阻止默认行为 const handleClick = (e) => { e.preventDefault(); if (props.opt?.length > 0) { const selectedOption = currentId.value === "0" ? props.opt[0] : props.opt.find((opt) => opt.id === currentId.value); if (selectedOption) { selectedOption.callback?.(); } } else if (props.onclick) { props.onclick(); } }; // 获取显示文本 const getDisplayText = () => { if (!props.opt?.length) return props.text; const selectedOption = currentId.value === "0" ? props.opt[0] : props.opt.find((opt) => opt.id === currentId.value); return selectedOption ? selectedOption.desc : props.opt[0].desc; }; return () => h( "button", { class: props.opt?.length > 0 ? "ss-drop-button ss-drop-button-more" : "ss-drop-button", type: "button", // 明确指定按钮类型为 button onMouseenter: handleMouseEnter, onMouseleave: handleMouseLeave, onClick: handleClick, // 添加点击事件处理 }, [ h("span", { class: props.iconClass, style: { fontFamily: "iconfont" }, }), h("span", getDisplayText()), props.opt.length > 0 && showPopup.value && h( "div", { class: "popup", }, props.opt.map((item) => h( "div", { onClick: (e) => { e.preventDefault(); // 选项点击也阻止默认行为 e.stopPropagation(); // 阻止事件冒泡 currentId.value = item.id; // 更新当前选中的ID item.callback(); showPopup.value = false; // 选择后关闭弹窗 }, }, item.desc ) ) ), ] ); }, }; /** * 二级页面标签组件 * @name ss-sub-tab * @description 用于展示二级页面的布局组件,包含左侧垂直标签导航(支持分组)和右侧iframe内容区 * @property {String} headerImage - 左侧顶部图片地址 * @property {Array} menuList - 菜单配置列表 * @property {Object} [activeMenu] - 当前选中的菜单项,不传则自动选择第一个可选菜单 * @property {Array} footerButtons - 底部按钮配置列表 */ /** * SsSubTab 左侧菜单+iframe内容组件 * v3.0 改造:去掉顶部图片,改为图标+悬浮模式,iframe懒加载 by xu 20251216 */ const SsSubTab = { name: "SsSubTab", props: { menuList: { type: Array, required: true, }, activeMenu: { type: String, default: "", }, footerButtons: { type: Array, default: () => [], }, leftDisplay: { type: Boolean, default: true, }, // v3.0 新增:菜单模式 collapse(悬浮展开) / fixed(始终收起) by xu 20251216 initialMode: { type: String, default: 'collapse', }, }, emits: ["menu-change", "footer-click"], setup(props, { emit }) { // v3.0 新增:默认图标映射,使用icon-biz图标 by xu 20251216 const defaultIcons = [ 'icon-obj-ry', // 人员 'icon-obj-dw', // 单位 'icon-obj-gw', // 岗位 'icon-biz-rc', // 人才 'icon-biz-xc', // 巡查 'icon-biz-cl', // 材料 'icon-biz-men', // 门 'icon-obj-xy' // 协议 ]; //功能: SsSubTab 支持后端下发 iconName + pobj/cobj 两级菜单 by xu 20251222 const isTrue = (v) => v === true || v === "true" || v === 1 || v === "1"; //功能 by xu 20251222 const resolveIconClass = (iconNameOrClass, fallbackIndex) => { //功能 by xu 20251222 const fallback = `menu-icon ${defaultIcons[fallbackIndex % defaultIcons.length]}`; if (!iconNameOrClass) { return fallback; } // 已经是完整 class(可能包含 menu-icon / menu-base-icon / 多个 class) if (typeof iconNameOrClass === "string" && iconNameOrClass.indexOf(" ") > -1) { return iconNameOrClass; } const iconName = iconNameOrClass; if (iconName === "menu-icon" || iconName === "menu-base-icon") { return fallback; } // 业务图标库:icon-biz / icon-obj -> menu-icon if ( typeof iconName === "string" && (iconName.indexOf("icon-obj-") === 0 || iconName.indexOf("icon-biz-") === 0) ) { return `menu-icon ${iconName}`; } // 默认认为是 icon-base 图标 -> menu-base-icon return `menu-base-icon ${iconName}`; }; const getMenuIcon = (item, index) => { //功能 by xu 20251222 if (!item) { return resolveIconClass(null, index); } //功能: 变动图标后端暂不正确,前端先写死为 icon-chg by xu 20251223 if (item.title === "变动" || item.name === "sys_bd") { return resolveIconClass("icon-chg", index); } // 兼容旧字段 icon(优先使用) if (item.icon) return resolveIconClass(item.icon, index); // v3.0 使用后端下发 iconName if (item.iconName) return resolveIconClass(item.iconName, index); return resolveIconClass(null, index); }; //功能: SsSubTab 底部按钮支持 icon+文字(icon-base)by xu 20251224 const getFooterIcon = (button) => { //功能 by xu 20251224 if (!button) return "menu-base-icon icon-subm"; const iconNameOrClass = button.iconClass || button.iconName || button.icon; if (!iconNameOrClass) return "menu-base-icon icon-subm"; if (typeof iconNameOrClass === "string" && iconNameOrClass.indexOf(" ") > -1) { return iconNameOrClass; } return `menu-base-icon ${iconNameOrClass}`; }; //功能: pobj/cobj 扁平结构转换为 children 树结构,兼容原 children 结构 by xu 20251222 const normalizeMenuList = (rawList) => { if (!Array.isArray(rawList) || rawList.length === 0) { return []; } const hasTree = rawList.some((it) => Array.isArray(it?.children) && it.children.length > 0); if (hasTree) { return rawList.map((it) => ({ ...it, __level: 1, children: Array.isArray(it.children) ? it.children.map((c) => ({ ...c, __level: 2 })) : it.children, })); } const hasMarker = rawList.some((it) => it && ("pobj" in it || "cobj" in it)); if (!hasMarker) { return rawList.map((it) => ({ ...it, __level: 1 })); } const result = []; let currentGroup = null; for (const item of rawList) { //功能: “变动”始终按一级处理(即使后端误传 pobj/cobj)by xu 20251223 const isChgItem = item && (item.title === "变动" || item.name === "sys_bd"); if (isChgItem) { result.push({ ...item, __level: 1 }); continue; } const isParent = isTrue(item?.pobj); const isChild = isTrue(item?.cobj); if (isParent) { currentGroup = { ...item, __level: 1, children: [], }; result.push(currentGroup); continue; } if (isChild && currentGroup) { currentGroup.children.push({ ...item, __level: 2 }); continue; } //功能: 变动等无 pobj/cobj 的选项按一级展示(不挂到 children,也不打断当前分组)by xu 20251223 result.push({ ...item, __level: 1 }); } return result; }; const menuListComputed = computed(() => normalizeMenuList(props.menuList)); //功能 by xu 20251222 //功能: 分组展开状态(默认展开),避免 computed 生成对象导致 open 状态丢失 by xu 20251222 const groupOpenState = reactive({}); // { [key]: boolean } const getGroupKey = (item) => (item?.name || item?.title || ""); //功能 by xu 20251222 const isGroupOpen = (item) => { //功能 by xu 20251222 const key = getGroupKey(item); if (!key) return true; return groupOpenState[key] !== false; }; const toggleGroupOpen = (item) => { //功能 by xu 20251222 const key = getGroupKey(item); if (!key) return; groupOpenState[key] = !isGroupOpen(item); }; const getLevelClass = (item, fallbackLevel) => { //功能 by xu 20251222 //功能: “变动”始终按一级样式处理 by xu 20251223 if (item && (item.title === "变动" || item.name === "sys_bd")) { return "level-1"; } const level = item?.__level || fallbackLevel || 1; return level === 2 ? "level-2" : "level-1"; }; // v3.0 新增:菜单模式管理 by xu 20251216 const menuMode = ref(props.initialMode); const isHovering = ref(false); const toggleMenuMode = () => { menuMode.value = menuMode.value === 'collapse' ? 'fixed' : 'collapse'; }; //功能: 提供给旧UI弹窗顶部按钮调用的 API(替代点击 .menu-mode-toggle DOM)by xu 20251224 const registerSsSubTabApi = () => { //功能 by xu 20251224 try { window.SS = window.SS || {}; window.SS.dom = window.SS.dom || {}; //功能: 兼容小写 ss 命名空间(部分页面只引用 window.ss)by xu 20251224 window.ss = window.ss || window.SS; window.ss.dom = window.ss.dom || window.SS.dom; const api = { toggleMenuMode, getMenuMode: () => menuMode.value, }; window.SS.dom.ssSubTabApi = api; window.ss.dom.ssSubTabApi = api; //功能: 多层弹窗(如 objPlay -> objInfo) 场景,将 API 注册到 topWindow 供按钮跨层调用 by xu 20251224 try { const wdDialogId = window.wd && wd.display && wd.display.getwdDialogId && wd.display.getwdDialogId(); if (wdDialogId && window.top) { window.top.__ssSubTabApiMap = window.top.__ssSubTabApiMap || {}; window.top.__ssSubTabApiMap[wdDialogId] = api; } } catch (e) { } try { console.log("[SsSubTabApi] registered", window.location && window.location.pathname); } catch (e) { } } catch (e) { } }; const unregisterSsSubTabApi = () => { //功能 by xu 20251224 try { //功能: 从 topWindow 解绑(避免弹窗关闭后残留)by xu 20251224 try { const wdDialogId = window.wd && wd.display && wd.display.getwdDialogId && wd.display.getwdDialogId(); if (wdDialogId && window.top && window.top.__ssSubTabApiMap && window.top.__ssSubTabApiMap[wdDialogId]) { delete window.top.__ssSubTabApiMap[wdDialogId]; } } catch (e) { } if (window.SS?.dom?.ssSubTabApi?.toggleMenuMode === toggleMenuMode) { delete window.SS.dom.ssSubTabApi; } if (window.ss?.dom?.ssSubTabApi?.toggleMenuMode === toggleMenuMode) { delete window.ss.dom.ssSubTabApi; } } catch (e) { } }; //功能: 立即注册,避免 enable 早于 onMounted 导致“api not ready”by xu 20251224 registerSsSubTabApi(); //功能 by xu 20251224 onBeforeUnmount(unregisterSsSubTabApi); //功能 by xu 20251224 const onMouseEnter = () => { if (menuMode.value === 'collapse') { isHovering.value = true; } }; const onMouseLeave = () => { isHovering.value = false; }; const isExpanded = computed(() => { return menuMode.value === 'collapse' && isHovering.value; }); // v3.0 新增:iframe 懒加载,点击才加载 by xu 20251216 const loadedMenus = ref(new Set()); const isMenuLoaded = (menuName) => { return loadedMenus.value.has(menuName); }; // 根据标题找到对应的菜单项 const findMenuByTitle = (title) => { for (const item of menuListComputed.value) { //功能 by xu 20251222 if (item.children?.length > 0) { const child = item.children.find((c) => c.title === title); if (child) return child; } else if (item.title === title) { return item; } } return null; }; // 计算默认选中的菜单项 const defaultActiveMenu = computed(() => { if (props.activeMenu) { const menu = findMenuByTitle(props.activeMenu); if (menu) return menu; } const firstItem = menuListComputed.value[0]; //功能 by xu 20251222 if (!firstItem) return null; //功能: 默认选中第一个一级菜单(不默认跳到第一个二级)by xu 20251224 return firstItem; }); const currentMenu = ref(defaultActiveMenu.value); // 监听外部activeMenu变化 watch( () => props.activeMenu, (newTitle) => { if (newTitle) { const menu = findMenuByTitle(newTitle); if (menu) { currentMenu.value = menu; } } } ); // 初始化:默认选中项加入已加载集合 watch(currentMenu, (menu) => { if (menu?.name) { loadedMenus.value.add(menu.name); } }, { immediate: true }); // 选择菜单项时触发 menu-change 钩子 const selectItem = (item) => { currentMenu.value = item; // 标记为已加载 if (item.name) { loadedMenus.value.add(item.name); } emit("menu-change", item); }; // 处理底部按钮点击 const handleFooterClick = (button, index) => { emit("footer-click", { button, index }); }; return { menuListComputed, //功能 by xu 20251222 currentMenu, selectItem, handleFooterClick, getFooterIcon, //功能: SsSubTab 底部按钮支持 icon+文字(icon-base)by xu 20251224 menuMode, isHovering, isExpanded, toggleMenuMode, onMouseEnter, onMouseLeave, isMenuLoaded, getMenuIcon, isGroupOpen, //功能 by xu 20251222 toggleGroupOpen, //功能 by xu 20251222 getLevelClass, //功能 by xu 20251222 }; }, template: `