// H5版本的小程序组件库 // 参考 alf/ss-components.js 的组件形式 (function () { const { ref, createApp, watch, inject, onMounted, onBeforeUnmount, computed, } = Vue; // ===== 公共上传函数 ===== /** * 统一文件上传函数 * @param {File|Blob} file - 文件或Blob对象 * @param {String} type - 'image' | 'file' * @param {String} fileName - 文件名(可选) * @returns {Promise} 服务器路径 */ async function uploadFile(file, type = "image", fileName) { try { window.showToast?.("上传中...", "loading"); const formData = new FormData(); const name = fileName || (file instanceof Blob ? "file" : file.name); formData.append("fileEdit", file, name); formData.append("application", ""); // 根据类型选择接口参数 const apiType = type === "image" ? "img" : "file"; const result = await window.request.post( `/service?ssServ=ulByHttp&type=${apiType}`, formData, { loading: false } ); if (result?.data?.fileList?.[0]?.path) { const serverPath = result.data.fileList[0].path; window.showToast?.("上传成功", "success"); return serverPath; } else { throw new Error("上传返回数据格式错误"); } } catch (error) { console.error("上传失败:", error); window.showToast?.("上传失败,请重试", "error"); throw error; } } const SsCommonIcon = { name: "SsCommonIcon", props: { class: { type: String, required: true, }, }, setup(props) { const { h } = Vue; return () => h("i", { class: props.class + " common-icon", }); }, }; // ss-input 智能输入组件 const SsInput = { name: "SsInput", inheritAttrs: false, // 不直接继承属性到组件根元素 props: { // v-model 绑定的值 modelValue: { type: [String, Number], default: "", }, // 字段名称 name: { type: String, default: "", }, // 占位符 placeholder: { type: String, default: "请输入", }, // 错误提示 errTip: { type: String, default: "", }, }, emits: ["update:modelValue", "input", "blur", "change", "focus"], setup(props, { emit }) { const inputValue = ref(props.modelValue || ""); const validationState = ref({ hasError: false, errorMessage: "", isRequired: false, isEmpty: true, hasInteracted: false, // 是否已经交互过 isSubmitMode: false, // 是否处于提交模式 }); // 从ValidatedTd注入事件处理函数(兼容小程序方式) const onInputInject = inject("onInput", null); const onBlurInject = inject("onBlur", null); // 检查是否为必填字段 const checkRequired = () => { if (window.ssVm && props.name) { validationState.value.isRequired = window.ssVm.isRequired(props.name); } }; // 更新父级td的class const updateTdClass = () => { // 找到父级td元素 const inputElement = document.querySelector( `input[name="${props.name}"]` ); if (inputElement) { const tdElement = inputElement.closest("td"); if (tdElement) { // 移除所有校验相关的class tdElement.classList.remove("td-required", "td-error"); // 逻辑分离: // 1. 初始状态:必填且为空 → 只有左侧红线 (td-required) // 2. 实时校验失败 → 左侧红线 + 底部红线 + 错误文字 (td-error) if ( validationState.value.hasError && validationState.value.hasInteracted ) { // 用户交互后校验失败:左侧红线 + 底部红线 + 错误文字 tdElement.classList.add("td-error"); } else if ( validationState.value.isRequired && validationState.value.isEmpty ) { // 必填且为空:只有左侧红线 tdElement.classList.add("td-required"); } } } }; // 校验字段 const validateField = (value) => { if (window.ssVm && props.name) { const result = window.ssVm.validateField(props.name); validationState.value.hasError = !result.valid; validationState.value.errorMessage = result.message || ""; validationState.value.isEmpty = !value || value.trim() === ""; // 更新td的class setTimeout(updateTdClass, 0); return result; } return { valid: true, message: "" }; }; // 监听props变化 watch( () => props.modelValue, (newVal) => { inputValue.value = newVal; validateField(newVal); } ); // 挂载时初始化 onMounted(() => { checkRequired(); validateField(inputValue.value); // 监听ssVm规则更新事件 const inputElement = document.querySelector( `input[name="${props.name}"]` ); if (inputElement) { inputElement.addEventListener("ssvm-rules-updated", () => { console.log(`收到规则更新通知: ${props.name}`); checkRequired(); validateField(inputValue.value); updateTdClass(); }); } // 确保初始状态正确显示,延迟更长时间确保DOM完全渲染 setTimeout(() => { updateTdClass(); }, 200); }); // 事件处理函数 const handleInput = (event) => { const value = event.target.value; inputValue.value = value; // 标记已交互 validationState.value.hasInteracted = true; // 1. 支持v-model emit("update:modelValue", value); emit("input", event); // 2. 内部校验(实时校验) validateField(value); // 3. 兼容小程序inject方式 if (onInputInject) { onInputInject(event); } }; const handleBlur = (event) => { // 标记已交互 validationState.value.hasInteracted = true; emit("blur", event); // 失焦时再次校验 validateField(inputValue.value); // 兼容小程序inject方式 if (onBlurInject) { onBlurInject(event); } }; const handleFocus = (event) => { emit("focus", event); }; return { inputValue, validationState, handleInput, handleBlur, handleFocus, }; }, template: `
{{ validationState.errorMessage }}
`, }; // icon 图标组件 - 从小程序转换 const Icon = { name: "Icon", props: { // 图标名称,对应iconfont中的类名,如'icon-home' name: { type: String, required: true, }, // 图标颜色 color: { type: String, default: "#000", }, // 图标大小,单位rpx (H5中转换为px) size: { type: [Number, String], default: 32, }, }, emits: ["click"], setup(props, { emit }) { // 计算完整的图标类名 const iconClass = computed(() => { return props.name; }); // 计算样式 const iconStyle = computed(() => ({ color: props.color, fontSize: parseInt(props.size) / 2 + "px", // rpx转px,除以2 verticalAlign: "middle", })); // 点击事件处理 const handleClick = (event) => { emit("click", event); }; return { iconClass, iconStyle, handleClick, }; }, template: ` `, }; // ss-card 卡片组件 - 从小程序转换 const SsCard = { name: "SsCard", props: { item: { type: Object, default: () => ({}), }, }, emits: ["click", "buttonClick"], setup(props, { emit }) { // 状态 const showButtonMenu = ref(false); const swipeOffset = ref(0); const touchStartX = ref(0); const touchStartY = ref(0); const swipeOpening = ref(false); const isSwiping = ref(false); const swipeActions = computed(() => { return Array.isArray(props.item?.swipeActions) ? props.item.swipeActions.filter(Boolean) : []; }); const hasSwipeActions = computed(() => swipeActions.value.length > 0); const swipeActionPalette = [ { backgroundColor: "#1d3388", color: "#fff" }, { backgroundColor: "#2b4cd1", color: "#fff" }, { backgroundColor: "#3c77ec", color: "#fff" }, { backgroundColor: "#96b5fb", color: "#1d3388" }, { backgroundColor: "#dfe9fd", color: "#1d3388" }, ]; const actionPaneWidth = computed(() => { if (!hasSwipeActions.value) return 0; return 66 * swipeActions.value.length; }); // 计算属性 const hasButtons = computed(() => { return ( props.item?.buttons && Array.isArray(props.item.buttons) && props.item.buttons.length > 0 ); }); const isMultipleButtons = computed(() => { return props.item?.buttons && props.item.buttons.length > 1; }); const isSingleButton = computed(() => { return props.item?.buttons && props.item.buttons.length === 1; }); const cardContentStyle = computed(() => ({ transform: `translateX(-${swipeOffset.value}px)`, })); const actionPaneStyle = computed(() => ({ width: `${actionPaneWidth.value}px`, })); const swipeIndicatorStyle = computed(() => { const palette = getSwipeActionStyle(0) || {}; return { backgroundColor: palette.backgroundColor || "#1d3388", opacity: swipeOffset.value > 0 ? 0 : 1, }; }); const getSwipeActionStyle = (index) => { const paletteIndex = Math.min( Number(index || 0), swipeActionPalette.length - 1 ); return swipeActionPalette[paletteIndex] || swipeActionPalette[0]; }; const closeSwipe = () => { swipeOffset.value = 0; swipeOpening.value = false; }; const openSwipe = () => { if (!hasSwipeActions.value) return; swipeOffset.value = actionPaneWidth.value; swipeOpening.value = true; }; // 处理卡片点击 const handleCardClick = () => { if (isSwiping.value) { isSwiping.value = false; return; } if (swipeOpening.value) { closeSwipe(); return; } // 如果菜单打开,先关闭菜单 if (showButtonMenu.value) { showButtonMenu.value = false; return; } emit("click"); }; // 处理设置按钮点击 const handleSettingClick = () => { if (isSingleButton.value) { // 只有一个按钮,直接执行 handleButtonClick(props.item.buttons[0], 0); } else if (isMultipleButtons.value) { // 先记录当前状态 const wasOpen = showButtonMenu.value; // 关闭其他卡片的菜单 document.dispatchEvent(new CustomEvent("closeAllCardMenus")); // 切换当前菜单状态 showButtonMenu.value = !wasOpen; } }; // 关闭菜单 const closeMenu = () => { showButtonMenu.value = false; }; const handleTouchStart = (event) => { if (!hasSwipeActions.value) return; const touch = event.touches && event.touches[0]; if (!touch) return; touchStartX.value = touch.clientX; touchStartY.value = touch.clientY; isSwiping.value = false; document.dispatchEvent(new CustomEvent("closeAllCardSwipes")); }; const handleTouchMove = (event) => { if (!hasSwipeActions.value) return; const touch = event.touches && event.touches[0]; if (!touch) return; const deltaX = touch.clientX - touchStartX.value; const deltaY = touch.clientY - touchStartY.value; if (Math.abs(deltaY) > Math.abs(deltaX) || Math.abs(deltaX) < 8) { return; } isSwiping.value = true; const nextOffset = swipeOpening.value ? actionPaneWidth.value - deltaX : -deltaX; swipeOffset.value = Math.max(0, Math.min(actionPaneWidth.value, nextOffset)); }; const handleTouchEnd = () => { if (!hasSwipeActions.value) return; if (swipeOffset.value > actionPaneWidth.value / 2) { openSwipe(); } else { closeSwipe(); } setTimeout(() => { isSwiping.value = false; }, 0); }; const handleCloseAllSwipes = () => { closeSwipe(); }; // 处理按钮点击 const handleButtonClick = (btn, index) => { showButtonMenu.value = false; closeSwipe(); // 执行按钮的回调 if (btn.onclick && typeof btn.onclick === "function") { btn.onclick(); } // 触发组件事件 emit("buttonClick", { button: btn, index, item: props.item }); }; // 监听全局关闭事件 onMounted(() => { document.addEventListener("closeAllCardMenus", closeMenu); document.addEventListener("closeAllCardSwipes", handleCloseAllSwipes); }); onBeforeUnmount(() => { document.removeEventListener("closeAllCardMenus", closeMenu); document.removeEventListener("closeAllCardSwipes", handleCloseAllSwipes); }); // H5环境下的清理逻辑 // 注意:Vue 3的beforeUnmount在某些H5环境下可能不可用 // 这里暂时省略,依赖页面刷新时的自动清理 return { showButtonMenu, swipeActions, hasSwipeActions, hasButtons, isMultipleButtons, isSingleButton, cardContentStyle, actionPaneStyle, swipeIndicatorStyle, getSwipeActionStyle, handleCardClick, handleSettingClick, handleTouchStart, handleTouchMove, handleTouchEnd, handleButtonClick, }; }, template: `
`, }; // ss-search-button 搜索按钮组件 - 从小程序转换 const SsSearchButton = { name: "SsSearchButton", props: { // 按钮文本 text: { type: String, default: "增加", }, // 是否禁用 disabled: { type: Boolean, default: false, }, // 按钮高度 height: { type: [String, Number], default: "36px", }, // 前置图标名称 preIcon: { type: String, default: "", }, // 后置图标名称 suffixIcon: { type: String, default: "", }, // 图标大小 iconSize: { type: [String, Number], default: "32", }, // 图标颜色 iconColor: { type: String, default: "#585d6e", }, // 自定义按钮样式 customStyle: { type: Object, default: () => ({}), }, // 跳转链接(兼容原JSP用法) href: { type: String, default: "", }, // 选项列表 options: { type: Array, default: () => [], }, }, emits: ["click", "optionClick"], setup(props, { emit }) { // 状态 const showOptionsMenu = ref(false); // 计算属性 const hasOptions = computed(() => { return ( props.options && Array.isArray(props.options) && props.options.length > 0 ); }); const hasMultipleOptions = computed(() => { return props.options && props.options.length > 1; }); const isSingleOption = computed(() => { return props.options && props.options.length === 1; }); // 按钮样式 const buttonStyle = computed(() => ({ height: typeof props.height === "number" ? `${props.height}px` : props.height, ...props.customStyle, })); // 处理按钮点击 const handleClick = () => { if (!hasOptions.value) { // 没有选项,直接触发点击事件 emit("click"); } else if (isSingleOption.value) { // 单个选项,直接执行 handleOptionClick(props.options[0], 0); } else if (hasMultipleOptions.value) { // 先记录当前状态 const wasOpen = showOptionsMenu.value; // 关闭其他按钮的菜单 document.dispatchEvent(new CustomEvent("closeAllButtonMenus")); // 切换当前菜单状态 showOptionsMenu.value = !wasOpen; } }; // 处理选项点击 const handleOptionClick = (option, index) => { showOptionsMenu.value = false; // 执行选项的回调 if (option.onclick && typeof option.onclick === "function") { option.onclick(); } // 触发组件事件 emit("optionClick", { option, index }); }; // 关闭菜单 const closeMenu = () => { showOptionsMenu.value = false; }; // 监听全局关闭事件 onMounted(() => { document.addEventListener("closeAllButtonMenus", closeMenu); }); return { showOptionsMenu, hasOptions, hasMultipleOptions, isSingleOption, buttonStyle, handleClick, handleOptionClick, }; }, template: `
{{ option.text }}
`, }; // ss-select 下拉选择组件 - 从小程序转换 const SsSelect = { name: "SsSelect", props: { // 选项数组 options: { type: Array, default: () => [], }, // 字段映射 mapping: { type: Object, default: () => ({ text: "n", value: "v" }), }, // 默认值 modelValue: { type: [String, Number], default: "", }, // 占位符 placeholder: { type: String, default: "请选择", }, // 校验配置 validation: { type: Object, default: () => ({ enable: false, message: "" }), }, // 是否禁用 disabled: { type: Boolean, default: false, }, // 是否支持搜索 searchable: { type: Boolean, default: false, }, // 是否支持清空 clearable: { type: Boolean, default: false, }, // 加载状态 loading: { type: Boolean, default: false, }, // 宽度设置 width: { type: String, default: "100%", }, minWidth: { type: String, default: "unset", }, // 功能说明:对齐PC端 ss-objp,用 codebook 在组件内部拉取下拉选项 by xu 2026-02-28 cb: { type: String, default: "", }, url: { type: String, default: "/service?ssServ=loadObjpOpt&objectpickerdropdown1=1", }, inp: { type: [Boolean, String], default: false, }, filter: { type: [Object, String], default: null, }, autoSelectFirst: { type: Boolean, default: false, }, }, emits: ["update:modelValue", "change", "search", "clear", "loaded"], setup(props, { emit }) { // 响应式数据 const isOpen = ref(false); const containerRef = ref(null); const selectedValue = ref(props.modelValue); const searchKeyword = ref(""); const remoteOptions = ref([]); const remoteLoading = ref(false); const autoMinWidth = ref(""); // 功能说明:ss-select 未显式传宽度时,按 placeholder/选项文本计算稳定最小宽度,避免回显和下拉宽度随选中项抖动 by xu 2026-03-06 const normalizeCssSize = (value) => { if (value === undefined || value === null) return ""; if (typeof value === "number") return `${value}px`; const text = String(value).trim(); return text; }; const hasExplicitWidth = computed(() => { const widthText = normalizeCssSize(props.width); if (!widthText) return false; return widthText !== "100%" && widthText !== "auto"; }); const measureStableMinWidth = () => { if (hasExplicitWidth.value) { autoMinWidth.value = ""; return; } const texts = [props.placeholder] .concat( (Array.isArray(optionsList.value) ? optionsList.value : []).map( (option) => option?.[props.mapping.text] ) ) .map((item) => (item === undefined || item === null ? "" : String(item).trim())) .filter(Boolean); if (!texts.length || typeof document === "undefined") { autoMinWidth.value = ""; return; } const measureNode = containerRef.value?.querySelector?.(".select-text") || containerRef.value; const computedStyle = measureNode ? window.getComputedStyle(measureNode) : null; const fontSize = computedStyle?.fontSize || "16px"; const fontWeight = computedStyle?.fontWeight || "400"; const fontFamily = computedStyle?.fontFamily || "sans-serif"; const canvas = document.createElement("canvas"); const context = canvas.getContext("2d"); if (!context) return; context.font = `${fontWeight} ${fontSize} ${fontFamily}`; const widestText = texts.reduce((maxWidth, text) => { return Math.max(maxWidth, context.measureText(text).width); }, 0); const reservedWidth = 52; autoMinWidth.value = `${Math.max(88, Math.ceil(widestText + reservedWidth))}px`; }; const parseFilterObj = () => { if (!props.filter) return {}; if (typeof props.filter === "object") return props.filter; if (typeof props.filter === "string") { try { const obj = JSON.parse(props.filter); return obj && typeof obj === "object" ? obj : {}; } catch (_) { return {}; } } return {}; }; const normalizeResultToOptions = (respData) => { const raw = respData || {}; if (Array.isArray(raw.resultList)) { return raw.resultList.map((it) => { if (it && typeof it === "object") return it; return { n: String(it || ""), v: String(it || "") }; }); } if (raw.result && typeof raw.result === "object") { return Object.keys(raw.result).map((k) => ({ n: raw.result[k], v: k, })); } if (Array.isArray(raw.objectList)) { return raw.objectList.map((it) => { if (it && typeof it === "object") return it; return { n: String(it || ""), v: String(it || "") }; }); } return []; }; const maybeAutoSelectFirst = (opts) => { if (!props.autoSelectFirst) return; if (!Array.isArray(opts) || opts.length === 0) return; if ( selectedValue.value !== undefined && selectedValue.value !== null && selectedValue.value !== "" ) return; const first = opts[0]; if (!first || typeof first !== "object") return; const value = first[props.mapping.value]; if (value === undefined || value === null || value === "") return; selectedValue.value = value; emit("update:modelValue", value); emit("change", value); }; const needRemoteData = computed(() => !!String(props.cb || "").trim()); const loadRemoteOptions = async () => { if (!needRemoteData.value) return; if (!window.request || typeof window.request.post !== "function") return; remoteLoading.value = true; try { const objpParam = { input: String(props.inp === true || props.inp === "true"), codebook: String(props.cb || ""), ...parseFilterObj(), }; const postData = { objectpickerparam: JSON.stringify(objpParam), objectpickertype: 1, objectpickersearchAll: 1, }; const resp = await window.request.post( props.url || "/service?ssServ=loadObjpOpt&objectpickerdropdown1=1", postData, { loading: false, formData: true } ); const opts = normalizeResultToOptions( resp && resp.data ? resp.data : null ); remoteOptions.value = opts; emit("loaded", opts); maybeAutoSelectFirst(opts); } catch (e) { remoteOptions.value = []; emit("loaded", []); console.error("[ss-select] loadRemoteOptions failed", props.cb, e); } finally { remoteLoading.value = false; } }; // 计算属性 const optionsList = computed(() => { if (Array.isArray(props.options) && props.options.length > 0) return props.options; if (needRemoteData.value) return remoteOptions.value; return []; }); const finalLoading = computed( () => !!props.loading || remoteLoading.value ); const selectContainerStyle = computed(() => { const style = {}; const widthText = normalizeCssSize(props.width); const minWidthText = normalizeCssSize(props.minWidth); // 功能说明:ss-select 在 td 等窄容器中限制最大宽度,避免自动 minWidth 把父布局撑开 by xu 2026-03-07 style.maxWidth = "100%"; if (hasExplicitWidth.value) { style.width = widthText; } if (minWidthText && minWidthText !== "unset") { style.minWidth = `min(${minWidthText}, 100%)`; } else if (autoMinWidth.value) { style.minWidth = `min(${autoMinWidth.value}, 100%)`; } return style; }); const displayText = computed(() => { if (!selectedValue.value) return props.placeholder; const selectedOption = optionsList.value.find( (option) => option[props.mapping.value] === selectedValue.value ); return selectedOption ? selectedOption[props.mapping.text] : props.placeholder; }); // 监听 modelValue 变化 watch( () => props.modelValue, (newValue) => { selectedValue.value = newValue; } ); // 切换下拉框 const toggleDropdown = () => { if (props.disabled) return; isOpen.value = !isOpen.value; }; // 选择选项 const selectOption = (option) => { const value = option[props.mapping.value]; selectedValue.value = value; isOpen.value = false; emit("update:modelValue", value); emit("change", value); }; // 点击外部关闭 const handleClickOutside = (event) => { if (!event.target.closest(".ss-select-container")) { isOpen.value = false; } }; onMounted(() => { document.addEventListener("click", handleClickOutside); loadRemoteOptions(); if (!needRemoteData.value && Array.isArray(props.options)) { emit("loaded", props.options); } measureStableMinWidth(); }); watch( () => [props.cb, props.url, props.filter], () => { if (!needRemoteData.value) return; loadRemoteOptions(); } ); watch( () => props.options, (newVal) => { if (needRemoteData.value) return; emit("loaded", Array.isArray(newVal) ? newVal : []); } ); watch( () => [ props.placeholder, props.width, props.minWidth, optionsList.value .map((option) => option?.[props.mapping.text]) .join("||"), ], () => { measureStableMinWidth(); }, { immediate: true } ); return { containerRef, isOpen, selectedValue, searchKeyword, optionsList, finalLoading, displayText, selectContainerStyle, toggleDropdown, selectOption, }; }, template: `
{{ displayText }}
加载中...
无选项
{{ option[mapping.text] }}
`, }; // ss-bottom 底部按钮组件 const SsBottom = { name: "SsBottom", props: { // 是否显示审核意见 showShyj: { type: Boolean, default: false, }, // 审核意见标题 shyjTitle: { type: String, default: "审核意见", }, // 审核意见占位符 shyjPlaceholder: { type: String, default: "请输入审核意见", }, divider: { type: Boolean, default: true, }, // 按钮配置 buttons: { type: Array, default: () => [ { text: "取消", action: "cancel" }, { text: "保存并提交", action: "submit" }, ], }, }, emits: ["button-click", "update:shyjValue"], setup(props, { emit }) { const reason = ref(""); const activeButtonIndex = ref(-1); // 处理按钮点击 const handleButtonClick = (button, index) => { emit("button-click", { action: button.action, button: button, index: index, shyjValue: reason.value, // 传递审核意见 }); }; // 监听审核意见变化 const handleShyjInput = (event) => { const value = event.target.value; reason.value = value; emit("update:shyjValue", value); }; // 处理按钮按下 const handleButtonMouseDown = (index) => { activeButtonIndex.value = index; }; // 处理按钮释放 const handleButtonMouseUp = () => { activeButtonIndex.value = -1; }; // 处理鼠标离开 const handleMouseLeave = () => { activeButtonIndex.value = -1; }; // 计算按钮样式 const getButtonStyle = (button) => { const styles = {}; // 如果有背景颜色配置 if (button.backgroundColor) { styles.backgroundColor = button.backgroundColor; } // 如果有字体颜色配置 if (button.color) { styles.color = button.color; } return styles; }; // 计算按钮点击样式 const getButtonActiveStyle = (button) => { const styles = {}; // 如果有点击背景色配置,使用点击背景色 if (button.clickBgColor) { styles.backgroundColor = button.clickBgColor; } else if (button.backgroundColor) { // 如果没有点击背景色,但有背景色,点击时使用背景色 styles.backgroundColor = button.backgroundColor; } // 如果有点击字体色配置,使用点击字体色 if (button.clickColor) { styles.color = button.clickColor; } else if (button.color) { // 如果没有点击字体色,但有字体色,点击时使用字体色 styles.color = button.color; } return styles; }; return { reason, activeButtonIndex, handleButtonClick, handleShyjInput, handleButtonMouseDown, handleButtonMouseUp, handleMouseLeave, getButtonStyle, getButtonActiveStyle, }; }, template: `
{{ shyjTitle }}
`, }; // ===== SsVerify 审核节点链组件 ===== const SsVerify = { name: "SsVerify", props: { verifyList: { type: Array, required: true, }, }, setup(props) { const toggleOpen = (item) => { item.open = !item.open; // 切换后重新计算连线高度 setTimeout(() => { calculateLineHeight(); }, 50); }; // 计算连线高度的函数 const calculateLineHeight = () => { const lastOpenGroup = document.querySelector(".group-item-last-open"); console.log("lastOpenGroup", lastOpenGroup); if (lastOpenGroup) { // 使用原生JavaScript代替jQuery const nodes = lastOpenGroup.querySelectorAll( ".verify-node-container" ); if (nodes.length) { let totalHeight = 0; if (nodes.length === 1) { // 只有一个节点时,连线伸到节点的中间位置 const nodeHeight = nodes[0].offsetHeight; const nodeTop = nodes[0].offsetTop; totalHeight = nodeTop + nodeHeight / 2 - 15; // 减去圆点半径5px } else { // 多个节点时,连线延伸到最后一个节点的中间位置 const lastNode = nodes[nodes.length - 1]; const lastNodeTop = lastNode.offsetTop; const lastNodeHeight = lastNode.offsetHeight; totalHeight = lastNodeTop + lastNodeHeight / 2 - 15; // 减去圆点半径5px } console.log("节点信息:", { 节点总数: nodes.length, 计算后的高度: totalHeight, 最后节点top: nodes[nodes.length - 1]?.offsetTop, 最后节点高度: nodes[nodes.length - 1]?.offsetHeight, }); lastOpenGroup.style.setProperty( "--group-line-height", `${totalHeight}px` ); } } }; onMounted(() => { setTimeout(() => { calculateLineHeight(); }, 100); }); return { toggleOpen, }; }, render() { const { h } = Vue; 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" }, [ h("i", { class: item.open ? "common-icon-folder-open common-icon" : "common-icon-folder-close common-icon", }), 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, ] ) ) ); }, }; // ===== SsVerifyNode 审核节点组件 ===== const SsVerifyNode = { name: "SsVerifyNode", props: { item: { type: Object, required: true, }, isGroup: { type: Boolean, default: false, }, }, render() { const { h } = Vue; return h("div", { class: "verify-node-container" }, [ h("div", { class: "info" }, [ h("div", { class: "avatar" }, [ h("img", { src: this.item.thumb, style: { width: "50px", height: "50px", borderRadius: "50%", }, }), ]), h("div", { class: "desc" }, [ h("div", this.item.name), h("div", this.item.role), ]), h("div", { class: "link" }, [ h("div", [ this.item.video ? h("i", { class: "common-icon-video common-icon" }) : null, this.item.link ? h("i", { class: "common-icon-paper-clip common-icon" }) : null, ]), ]), ]), h( "div", { class: { description: true, link: this.isGroup, }, attrs: { "data-num": "3" }, }, [h("div", this.item.description)] ), h("div", { class: "time" }, this.item.time), ]); }, }; // ===== SsOnoffButton 开关按钮 ===== const SsOnoffButton = { name: "SsOnoffButton", props: { // 字段名称,用于表单校验 name: { type: String, required: true }, // 显示标签 label: { type: String, required: true }, // 按钮的值 value: { type: [String, Number], required: true }, // 宽度设置 width: { type: String, default: "" }, // v-model 绑定的值 modelValue: { type: [String, Number, Array], default: "" }, // 是否多选模式 multiple: { type: Boolean, default: false }, // 是否禁用 disabled: { type: Boolean, default: false }, }, emits: ["update:modelValue", "change"], setup(props, { emit }) { // 解析 modelValue,支持逗号分隔的字符串和数组 const parseModelValue = (val) => { if (!val) return []; // 如果是数组,直接返回字符串数组 if (Array.isArray(val)) { return val.map((v) => v.toString()); } // 如果是字符串,按逗号分割 const cleanValue = val.toString().replace(/^,+/, ""); // 去掉开头的逗号 if (cleanValue.includes("|")) { return cleanValue.split("|"); } if (cleanValue.includes(",")) { return cleanValue.split(","); } return cleanValue ? [cleanValue] : []; }; // 判断当前按钮是否选中 const isChecked = computed(() => { if (props.multiple) { const currentValue = parseModelValue(props.modelValue); return currentValue.includes(props.value.toString()); } return props.modelValue === props.value; }); // 切换选中状态 const toggleSelect = () => { // 如果禁用,不执行任何操作 if (props.disabled) return; if (props.multiple) { // 多选模式 const currentValue = parseModelValue(props.modelValue); const index = currentValue.indexOf(props.value.toString()); let newValue; if (index === -1) { // 添加选项 newValue = [...currentValue, props.value.toString()]; } else { // 移除选项 newValue = currentValue.filter((v) => v !== props.value.toString()); } // 发送更新事件,使用逗号分隔的字符串格式 const emitValue = newValue.join(","); emit("update:modelValue", emitValue); emit("change", emitValue, newValue); } else { // 单选模式 emit("update:modelValue", props.value); emit("change", props.value); } }; return { isChecked, toggleSelect }; }, template: `
{{ label }}
`, }; // ===== SsDatetimePicker 日期时间选择(使用 Vant 4) ===== const SsDatetimePicker = { name: "SsDatetimePicker", props: { mode: { type: String, default: "date" }, // date | time | datetime placeholder: { type: String, default: "请选择日期" }, modelValue: { type: String, default: "" }, minDate: { type: String, default: "" }, maxDate: { type: String, default: "" }, // 字段名称 - 用于ssVm校验 name: { type: String, default: "" }, // 是否禁用 disabled: { type: Boolean, default: false }, }, emits: [ "update:modelValue", "change", "confirm", "cancel", "open", "close", ], setup(props, { emit }) { const showPicker = ref(false); const showTimePicker = ref(false); const currentStep = ref("date"); // 'date' | 'time' // 功能说明:统一在组件层处理 iframe 场景底部按钮显隐,避免每个页面重复绑定 by xu 2026-02-28 const notifyParentBottomVisible = (visible) => { try { const fn = window.parent && window.parent.__mpObjInpSetBottomVisible; if (typeof fn === "function") fn(visible !== false); } catch (_) {} }; // 功能说明:监听弹层显隐,点遮罩/取消关闭时也恢复父层底部按钮 by xu 2026-03-01 watch( [showPicker, showTimePicker], ([dateOpen, timeOpen], [prevDateOpen, prevTimeOpen]) => { const hasOpen = !!(dateOpen || timeOpen); const hadOpen = !!(prevDateOpen || prevTimeOpen); if (!hadOpen && hasOpen) { emit("open"); notifyParentBottomVisible(false); return; } if (hadOpen && !hasOpen) { emit("close"); notifyParentBottomVisible(true); } } ); // Vant DatePicker 需要数组格式 [year, month, day] const currentDateArray = ref([]); const currentTimeArray = ref(["12", "00"]); // [hour, minute] const tempDateStr = ref(""); // 临时存储选择的日期 // 格式化显示文本 const displayText = computed(() => { if (!props.modelValue) return props.placeholder; try { const d = new Date(props.modelValue); if (isNaN(d.getTime())) return props.modelValue; const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); const hours = String(d.getHours()).padStart(2, "0"); const minutes = String(d.getMinutes()).padStart(2, "0"); if (props.mode === "time") { return `${hours}:${minutes}`; } else if (props.mode === "datetime") { return `${year}-${month}-${day} ${hours}:${minutes}`; } return `${year}-${month}-${day}`; } catch (e) { return props.modelValue; } }); // 监听 modelValue 变化,转换为数组格式 watch( () => props.modelValue, (newVal) => { console.log("📅 modelValue 变化:", newVal); if (newVal) { try { const d = new Date(newVal); if (!isNaN(d.getTime())) { currentDateArray.value = [ d.getFullYear(), d.getMonth() + 1, d.getDate(), ]; console.log("📅 转换为数组:", currentDateArray.value); } } catch (e) { console.warn("Invalid date:", newVal); } } else { const today = new Date(); currentDateArray.value = [ today.getFullYear(), today.getMonth() + 1, today.getDate(), ]; } }, { immediate: true } ); // 确认选择 - 根据模式处理不同的数据 const onConfirm = (value) => { console.log( "📅 Vant Picker confirm 原始值:", value, "mode:", props.mode ); try { // Vant 返回的是对象,包含 selectedValues 数组 const selectedValues = value.selectedValues || value; console.log("📅 selectedValues:", selectedValues); if (props.mode === "time") { // 时间模式:处理时分 if (Array.isArray(selectedValues) && selectedValues.length >= 2) { const [hour, minute] = selectedValues; const timeStr = `${hour.padStart(2, "0")}:${minute.padStart( 2, "0" )}`; console.log("🕐 转换后的时间字符串:", timeStr); emit("update:modelValue", timeStr); emit("change", timeStr); emit("confirm", timeStr); emit("close"); notifyParentBottomVisible(true); showPicker.value = false; } } else if ( Array.isArray(selectedValues) && selectedValues.length >= 3 ) { // 日期模式:处理年月日 const [year, month, day] = selectedValues; const dateStr = `${year}-${month.padStart(2, "0")}-${day.padStart( 2, "0" )}`; if (props.mode === "datetime") { // datetime 模式:先存储日期,然后打开时间选择器 tempDateStr.value = dateStr; showPicker.value = false; showTimePicker.value = true; currentStep.value = "time"; } else { // date 模式:直接完成 emit("update:modelValue", dateStr); emit("change", dateStr); emit("confirm", dateStr); emit("close"); notifyParentBottomVisible(true); showPicker.value = false; } } } catch (e) { console.error("Picker conversion error:", e); } }; // 时间选择确认 const onTimeConfirm = (value) => { try { const selectedValues = value.selectedValues || value; if (Array.isArray(selectedValues) && selectedValues.length >= 2) { const [hour, minute] = selectedValues; const datetimeStr = `${tempDateStr.value} ${hour.padStart( 2, "0" )}:${minute.padStart(2, "0")}`; emit("update:modelValue", datetimeStr); emit("change", datetimeStr); emit("confirm", datetimeStr); emit("close"); notifyParentBottomVisible(true); } } catch (e) { console.error("Time conversion error:", e); } showTimePicker.value = false; currentStep.value = "date"; }; // 取消选择 const onCancel = () => { emit("cancel"); showPicker.value = false; }; const onTimeCancel = () => { emit("cancel"); showTimePicker.value = false; }; // 打开选择器 const openPicker = () => { // 如果禁用,不打开选择器 if (props.disabled) return; showPicker.value = true; }; // 计算最小最大日期 const minDateObj = computed(() => { return props.minDate ? new Date(props.minDate) : undefined; }); const maxDateObj = computed(() => { return props.maxDate ? new Date(props.maxDate) : undefined; }); return { showPicker, showTimePicker, currentDateArray, currentTimeArray, displayText, openPicker, onConfirm, onTimeConfirm, onTimeCancel, onCancel, minDateObj, maxDateObj, }; }, template: `
{{ displayText }}
`, }; const SsConfirm = { name: "SsConfirm", props: { modelValue: { type: Boolean, default: false }, title: { type: String, default: "确认" }, content: { type: String, default: "" }, maskClosable: { type: Boolean, default: true }, }, emits: ["update:modelValue", "confirm", "cancel", "close"], setup(props, { emit }) { // 监听弹窗显示状态,控制 body 滚动 watch( () => props.modelValue, (newVal) => { console.log("🔔 SsConfirm modelValue 变化:", newVal); if (newVal) { document.body.classList.add("modal-open"); } else { document.body.classList.remove("modal-open"); } }, { immediate: true } ); // 添加 immediate 选项 const close = () => { console.log("🚪 关闭确认弹窗"); emit("update:modelValue", false); emit("close"); }; const onMask = () => { console.log("👆 点击了遮罩层"); if (props.maskClosable) close(); }; const onCancel = () => { console.log("❌ 点击了取消按钮"); emit("cancel"); close(); }; const onConfirm = () => { console.log("✅ 点击了确认按钮,触发 confirm 事件"); emit("confirm"); close(); }; return { onMask, onCancel, onConfirm }; }, template: `
{{ title }}
`, }; // ===== SsImageCropper 纯裁剪组件 ===== const SsImageCropper = { name: "SsImageCropper", props: { // 是否显示裁剪器 show: { type: Boolean, default: false }, // 图片源(base64 或 URL) src: { type: String, required: true }, // 图片形状:circle圆形 | square方形 shape: { type: String, required: true }, // 裁剪比例(宽/高) aspectRatio: { type: Number, default: 1 }, // 输出图片宽度 outputWidth: { type: Number, default: 300 }, // 输出图片高度 outputHeight: { type: Number, default: 300 }, }, emits: ["update:show", "confirm", "cancel"], setup(props, { emit }) { const cropperInstance = ref(null); // 监听 show 变化,初始化或销毁 Cropper watch( () => props.show, (newVal) => { if (newVal) { // 等待 DOM 更新后初始化 Vue.nextTick(() => { initCropper(); }); } else { destroyCropper(); } } ); // 监听 src 变化,重新初始化 Cropper watch( () => props.src, () => { if (props.show) { Vue.nextTick(() => { initCropper(); }); } } ); // 初始化 Cropper const initCropper = () => { const imageElement = document.getElementById("ss-image-cropper-img"); if (!imageElement || !window.Cropper) return; // 销毁旧实例 destroyCropper(); // 根据 shape 属性添加类名 const container = document.querySelector(".ss-image-cropper-container"); if (container) { if (props.shape === "circle") { container.classList.add("crop-shape-circle"); container.classList.remove("crop-shape-square"); } else { container.classList.add("crop-shape-square"); container.classList.remove("crop-shape-circle"); } } cropperInstance.value = new window.Cropper(imageElement, { aspectRatio: props.aspectRatio, viewMode: 1, dragMode: "move", autoCropArea: 0.8, restore: false, guides: false, // 关闭辅助线 center: false, // 关闭中心指示器 highlight: false, cropBoxMovable: true, cropBoxResizable: true, toggleDragModeOnDblclick: false, minContainerWidth: window.innerWidth, minContainerHeight: window.innerHeight - 50, }); }; // 销毁 Cropper const destroyCropper = () => { if (cropperInstance.value) { cropperInstance.value.destroy(); cropperInstance.value = null; } }; // 取消裁剪 const handleCancel = () => { emit("update:show", false); emit("cancel"); }; // 确认裁剪 const handleConfirm = () => { if (!cropperInstance.value) return; const canvas = cropperInstance.value.getCroppedCanvas({ width: props.outputWidth, height: props.outputHeight, imageSmoothingEnabled: true, imageSmoothingQuality: "high", fillColor: "#fff", }); canvas.toBlob( (blob) => { emit("update:show", false); emit("confirm", blob); }, "image/jpeg", 0.9 ); }; // 处理底部按钮事件 const handleCropAction = (data) => { if (data.action === "cancel") { handleCancel(); } else if (data.action === "confirm") { handleConfirm(); } }; return { handleCropAction, }; }, template: `
长: {{ outputWidth }}px
宽: {{ outputHeight }}px
`, }; // ===== SsUploadImage 图片上传裁剪组件(支持单图/多图) ===== const SsUploadImage = { name: "SsUploadImage", props: { // v-model 绑定的值(单图:String,多图:Array) modelValue: { type: [String, Array], default: "" }, // 最大上传数量(默认1张,多图时设置大于1) max: { type: Number, default: 1 }, // 是否禁用 disabled: { type: Boolean, default: false }, // 图片宽度(像素) - 必填 width: { type: [Number, String], required: true }, // 图片高度(像素) - 必填 height: { type: [Number, String], required: true }, // 图片形状:circle圆形 | square方形 - 必填 shape: { type: String, required: true }, // 裁剪比例(宽/高) aspectRatio: { type: Number, default: undefined }, // 输出图片宽度 outputWidth: { type: Number, default: 300 }, // 输出图片高度 outputHeight: { type: Number, default: 300 }, }, emits: ["update:modelValue", "updated"], setup(props, { emit }) { const showCropper = ref(false); const tempImageSrc = ref(""); // 图片列表(统一用数组管理) const imageList = ref([]); // 监听 modelValue 变化,同步到 imageList watch( () => props.modelValue, (newVal) => { if (props.max === 1) { // 单图模式 imageList.value = newVal ? [newVal] : []; } else { // 多图模式 imageList.value = Array.isArray(newVal) ? [...newVal] : newVal ? [newVal] : []; } }, { immediate: true } ); // 是否可以继续添加图片 const canAddMore = computed(() => { return imageList.value.length < props.max; }); // 容器样式 const itemStyle = computed(() => ({ width: typeof props.width === "number" ? `${props.width}px` : props.width, height: typeof props.height === "number" ? `${props.height}px` : props.height, borderRadius: props.shape === "circle" ? "50%" : "8px", })); // 获取图片 URL(用于显示) const getImageUrl = (path) => { if (!path) return "/static/images/yishuzhao_nv.svg"; if (path.startsWith("http") || path.startsWith("blob:")) { return path; } return window.SS.utils?.getImageUrl?.(path) || path; }; // 选择图片 const selectImage = () => { if (props.disabled || !canAddMore.value) return; const input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.onchange = (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { tempImageSrc.value = event.target.result; showCropper.value = true; }; reader.readAsDataURL(file); } }; input.click(); }; // 删除图片 const deleteImage = (index) => { const newList = imageList.value.filter((_, i) => i !== index); updateModelValue(newList); }; // 确认裁剪 const handleCropConfirm = async (blob) => { try { const serverPath = await uploadFile(blob, "image", "image.jpg"); const newList = [...imageList.value, serverPath]; updateModelValue(newList); emit("updated", serverPath); } catch (error) { console.error("上传失败:", error); } }; // 取消裁剪 const handleCropCancel = () => { tempImageSrc.value = ""; }; // 更新 modelValue const updateModelValue = (list) => { if (props.max === 1) { // 单图模式:返回 String emit("update:modelValue", list[0] || ""); } else { // 多图模式:返回 Array emit("update:modelValue", list); } }; return { imageList, canAddMore, itemStyle, showCropper, tempImageSrc, getImageUrl, selectImage, deleteImage, handleCropConfirm, handleCropCancel, }; }, template: `
{{ imageList.length }}/{{ max }}
`, }; // ===== SsCarCard 车辆卡片组件 ===== const SsCarCard = { name: "SsCarCard", props: { // 车辆数据 carData: { type: Object, default: () => ({}), }, // 车辆状态:'available' | 'reserved' | 'disabled' status: { type: String, default: "available", validator: (value) => ["available", "reserved", "disabled"].includes(value), }, }, emits: ["click", "select"], setup(props, { emit }) { // 计算状态样式类 const statusClass = computed(() => { return `status-${props.status}`; }); // 获取图片URL const getImageUrl = (path) => { if (!path) return "/static/images/default-car.png"; if (path.startsWith("http") || path.startsWith("blob:")) { return path; } return window.SS.utils?.getImageUrl?.(path) || path; }; // 处理卡片点击 const handleCardClick = () => { if (props.status === "disabled") { return; // 禁用状态不响应点击 } emit("click", props.carData); emit("select", props.carData); }; return { statusClass, getImageUrl, handleCardClick, }; }, template: `
{{ carData.name || '别克GL8' }}
{{ carData.wph }}
{{ wp.mc }} : {{ wp.sz || wp.zf }}
{{ carData.type || '商务车' }}
`, }; // ===== SsSubTab 移动端Tab组件 ===== const SsSubTab = { name: "SsSubTab", props: { // Tab列表数据 tabList: { type: Array, required: true, }, // 当前激活的Tab索引 activeIndex: { type: Number, default: 0, }, // 基础URL参数(会传递给每个iframe) baseParams: { type: Object, default: () => ({}), }, }, emits: ["tab-change"], setup(props, { emit }) { const currentTab = ref(props.activeIndex); const currentTabUrl = ref(""); // 加载Tab对应的URL const loadTabUrl = (index) => { const tab = props.tabList[index]; if (!tab || !tab.dest) return; // 构建iframe URL:mp_ + dest + .html const fileName = `mp_${tab.dest}.html`; // 构建完整URL,包含所有参数 const tabService = tab.service || tab.servName || props.baseParams.service || props.baseParams.ssServ || ""; const tabDest = tab.dest || props.baseParams.dest || props.baseParams.ssDest || ""; const tabParam = tab.param || props.baseParams.param || ""; const params = new URLSearchParams({ ...props.baseParams, service: tabService, dest: tabDest, ssServ: tabService, ssDest: tabDest, param: tabParam, }); currentTabUrl.value = `/page/${fileName}?${params.toString()}`; console.log( "🔄 切换到Tab:", tab.desc || tab.title, "加载页面:", currentTabUrl.value ); }; // 监听 activeIndex 变化 watch( () => props.activeIndex, (newIndex) => { currentTab.value = newIndex; loadTabUrl(newIndex); }, { immediate: true } ); // 监听 tabList 变化 watch( () => props.tabList, () => { if (props.tabList.length > 0 && currentTab.value === 0) { loadTabUrl(0); } }, { immediate: true } ); // 切换Tab const handleTabClick = (index) => { if (currentTab.value === index) return; currentTab.value = index; loadTabUrl(index); emit("tab-change", { index, tab: props.tabList[index] }); }; return { currentTab, currentTabUrl, handleTabClick, }; }, template: `
{{ tab.desc || tab.title }}
`, }; // ===== SsUploadFile 文件上传组件(支持单文件/多文件) ===== const SsUploadFile = { name: "SsUploadFile", props: { // v-model 绑定的值(单文件:String,多文件:Array) modelValue: { type: [String, Array], default: "" }, // 最大上传数量(默认1个) max: { type: Number, default: 1 }, // 是否禁用 disabled: { type: Boolean, default: false }, // 允许的文件类型(默认常见文件类型) accept: { type: String, default: ".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar", }, // 最大文件大小(MB,默认5MB) maxSize: { type: Number, default: 5 }, }, emits: ["update:modelValue", "uploaded"], setup(props, { emit }) { // 文件列表(统一用数组管理) const fileList = ref([]); // 监听 modelValue 变化,同步到 fileList watch( () => props.modelValue, (newVal) => { if (props.max === 1) { // 单文件模式 fileList.value = newVal ? [{ path: newVal, name: getFileName(newVal) }] : []; } else { // 多文件模式 if (Array.isArray(newVal)) { fileList.value = newVal.map((path) => ({ path, name: getFileName(path), })); } else { fileList.value = newVal ? [{ path: newVal, name: getFileName(newVal) }] : []; } } }, { immediate: true } ); // 是否可以继续添加文件 const canAddMore = computed(() => { return fileList.value.length < props.max; }); // 从路径中提取文件名 const getFileName = (path) => { if (!path) return ""; const parts = path.split("/"); return parts[parts.length - 1]; }; // 获取文件图标 const getFileIcon = (fileName) => { const ext = fileName.split(".").pop().toLowerCase(); const iconMap = { pdf: "icon-pdf", doc: "icon-word", docx: "icon-word", xls: "icon-excel", xlsx: "icon-excel", txt: "icon-txt", zip: "icon-zip", rar: "icon-zip", }; return iconMap[ext] || "icon-wenjian"; }; // 选择文件 const selectFile = () => { if (props.disabled || !canAddMore.value) return; const input = document.createElement("input"); input.type = "file"; input.accept = props.accept; input.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; // 检查文件大小 const fileSizeMB = file.size / 1024 / 1024; if (fileSizeMB > props.maxSize) { window.showToast?.(`文件大小不能超过${props.maxSize}MB`, "error"); return; } // 上传文件 try { const serverPath = await uploadFile(file, "file", file.name); const newList = [ ...fileList.value, { path: serverPath, name: file.name }, ]; updateModelValue(newList); emit("uploaded", serverPath); } catch (error) { console.error("文件上传失败:", error); } }; input.click(); }; // 删除文件 const deleteFile = (index) => { const newList = fileList.value.filter((_, i) => i !== index); updateModelValue(newList); }; // 下载文件 const downloadFile = (file) => { const url = window.SS.utils?.getFileUrl?.(file.path) || window.getFileUrl?.(file.path) || file.path; window.open(url, "_blank"); }; // 更新 modelValue const updateModelValue = (list) => { const paths = list.map((f) => f.path); if (props.max === 1) { // 单文件模式:返回 String emit("update:modelValue", paths[0] || ""); } else { // 多文件模式:返回 Array emit("update:modelValue", paths); } }; return { fileList, canAddMore, getFileIcon, selectFile, deleteFile, downloadFile, }; }, template: `
{{ file.name }}
上传文件 ({{ fileList.length }}/{{ max }}) 支持格式: {{ accept }},最大{{ maxSize }}MB
`, }; /** * 功能说明:H5移动端富文本组件(对齐PC字段协议 mswj/fjid/path 回显) by xu 2026-03-01 * * 约定: * - v-model 绑定文件路径字段(如 mswj) * - 组件内部维护编辑内容,并输出隐藏字段:xxEdit / xxwj / ueditorpath / fjid * - 回显通过 url + path 拉取 HTML 内容,不直接依赖 modelValue 的 HTML 字符串 * * @component SsEditor * @prop {String} modelValue 文件路径(如 mswj) * @prop {String} name 字段名(默认 mswj) * @prop {String} url 回显读取接口地址 * @prop {Number|String} height 编辑器高度 * @prop {String} placeholder 占位文案 * @prop {Boolean} readonly 是否只读 * @prop {String} uploadUrl 上传接口地址 * @prop {Object} param 附件参数(button.cmsAddUrl / button.cmsUpdUrl / mode) * @emits update:modelValue 更新文件路径 * @emits ready 编辑器就绪 * @emits change 内容变化 */ const SsEditor = { name: "SsEditor", props: { modelValue: { type: String, default: "" }, name: { type: String, default: "mswj" }, url: { type: String, default: "" }, height: { type: [Number, String], default: 280 }, placeholder: { type: String, default: "请输入内容" }, readonly: { type: Boolean, default: false }, uploadUrl: { type: String, default: "/service?ssServ=ulByHttp" }, param: { type: Object, default: () => ({}) }, }, emits: ["update:modelValue", "ready", "change"], setup(props, { emit }) { const editorElementId = `ss-editor-${Date.now()}-${Math.floor( Math.random() * 10000 )}`; const editorContent = ref(""); const editorInstance = ref(null); const fjid = ref(props.param?.button?.val || ""); const fjName = props.param?.button?.desc || "附件"; const mode = props.param?.mode; /** * 功能说明:确保附件 fjid 存在,不存在时先通过 cmsAddUrl 创建 by xu 2026-03-01 * @returns {Promise} fjid */ const ensureFjid = async () => { if (fjid.value) return fjid.value; if (!props.param?.button?.cmsAddUrl) return ""; return new Promise((resolve) => { $.ajax({ type: "post", url: props.param.button.cmsAddUrl, async: false, data: { name: "fjid", ssNrObjName: "sh", ssNrObjId: "", }, success: (_fjid) => { fjid.value = _fjid || ""; resolve(fjid.value); }, error: () => resolve(""), }); }); }; /** * 功能说明:打开附件管理弹窗(与PC端行为一致) by xu 2026-03-01 * @returns {Promise} */ const openAttachmentDialog = async () => { if (!props.param?.button?.cmsUpdUrl) { console.warn("未配置附件编辑地址 cmsUpdUrl"); return; } const currentFjid = await ensureFjid(); if (!currentFjid) return; const query = `&nrid=T-${currentFjid}` + `&objectId=${currentFjid}` + `&objectName=${encodeURIComponent(fjName)}` + `&callback=${window["fjidCallbackName"] || ""}`; if (window.SS && typeof window.SS.openDialog === "function") { window.SS.openDialog({ src: props.param.button.cmsUpdUrl + query, headerTitle: "编辑", width: 900, high: 664, zIndex: 51, }); } }; /** * 功能说明:初始化 Jodit 编辑器并绑定工具栏/上传/change 事件 by xu 2026-03-01 * @returns {void} */ const buildEditor = () => { if (!window.Jodit || !window.Jodit.make) { console.error("Jodit 未加载,无法初始化 ss-editor"); return; } const editorUploadUrl = props.uploadUrl.includes("?") ? `${props.uploadUrl}&type=img` : `${props.uploadUrl}?type=img`; const instance = window.Jodit.make(`#${editorElementId}`, { height: props.height, placeholder: props.placeholder, readonly: props.readonly, language: "zh_cn", showXPathInStatusbar: false, showCharsCounter: false, showWordsCounter: false, allowResizeY: false, toolbarSticky: false, statusbar: false, uploader: { url: editorUploadUrl, format: "json", method: "POST", filesVariableName: (i) => `imgs[${i}]`, isSuccess: (resp) => resp?.code === 0 || !!resp?.data, getMessage: (resp) => resp?.msg || "上传失败", process: (resp) => resp?.data?.url || resp?.data?.path || "", contentType: () => false, }, controls: { customLinkButton: { name: "link", tooltip: "附件", exec: () => { openAttachmentDialog(); }, }, }, buttons: [ "fullsize", "bold", "italic", "underline", "|", "font", "fontsize", "|", "left", "center", "right", "|", "ul", "ol", "|", "image", "table", "customLinkButton", "|", "undo", "redo", ], buttonsMD: [ "bold", "italic", "underline", "|", "image", "customLinkButton", "|", "dots", ], buttonsSM: [ "bold", "italic", "|", "image", "customLinkButton", "|", "dots", ], buttonsXS: ["bold", "|", "dots"], }); instance.value = editorContent.value || ""; instance.events.on("change", () => { editorContent.value = instance.value || ""; emit("change", editorContent.value); }); editorInstance.value = instance; emit("ready", instance); }; /** * 功能说明:按路径加载富文本HTML内容并回填编辑器 by xu 2026-03-01 * @returns {Promise} */ const loadContentByPath = async () => { if (!props.url || !props.modelValue) return; try { const params = new URLSearchParams(); if (mode) params.append("mode", mode); params.append("path", props.modelValue); const response = await window.axios.post(props.url, params, { headers: { "Content-Type": "application/x-www-form-urlencoded" }, }); const content = response?.data?.content || ""; if (content) { editorContent.value = content; if (editorInstance.value) { editorInstance.value.value = content; } } const filePath = response?.data?.path; if (filePath) { emit("update:modelValue", filePath); } } catch (error) { console.error("ss-editor 回显内容加载失败:", error); } }; onMounted(async () => { buildEditor(); await loadContentByPath(); }); watch( () => props.readonly, (newVal) => { if ( editorInstance.value && typeof editorInstance.value.setReadOnly === "function" ) { editorInstance.value.setReadOnly(newVal); } } ); onBeforeUnmount(() => { if ( editorInstance.value && typeof editorInstance.value.destruct === "function" ) { editorInstance.value.destruct(); } }); return { editorElementId, editorContent, fjid, }; }, template: `
`, }; window.SS.dom.initializeFormApp = function (config) { const { el, ...vueOptions } = config; const app = createApp({ ...vueOptions, }); // 注册组件 // app.component("SsLoginIcon", SsLoginIcon); // app.component("SsMark", SsMark); // app.component("SsFullStyleHeader", SsFullStyleHeader); // app.component("SsDialog", SsDialog); app.component("SsInput", SsInput); app.component("SsBottom", SsBottom); app.component("SsCard", SsCard); app.component("SsSearchButton", SsSearchButton); app.component("SsSelect", SsSelect); app.component("Icon", Icon); // 注册 Vant 组件 const vantLib = window.vant || window.Vant; console.log("🔍 检查 Vant:", { hasVant: !!window.vant, hasVantCap: !!window.Vant, vantLib: !!vantLib, vantKeys: vantLib ? Object.keys(vantLib).slice(0, 20) : [], hasPopup: vantLib?.Popup, hasDatetimePicker: vantLib?.DatetimePicker, hasDatePicker: vantLib?.DatePicker, hasTimePicker: vantLib?.TimePicker, allKeys: vantLib ? Object.keys(vantLib) : [], }); if (vantLib) { try { // 使用 Vant 的 use 方法注册所有组件 app.use(vantLib); console.log("✅ Vant 全部组件注册成功"); } catch (error) { console.error("❌ Vant 组件注册失败:", error); // 降级方案:手动注册具体组件 try { app.component("van-popup", vantLib.Popup); app.component("van-datetime-picker", vantLib.DatetimePicker); console.log("✅ Vant 手动注册成功"); } catch (e) { console.error("❌ Vant 手动注册也失败:", e); } } } else { console.warn("⚠️ Vant 未加载"); } // 注册组件 - 统一使用 kebab-case app.component("ss-verify", SsVerify); app.component("ss-verify-node", SsVerifyNode); app.component("ss-common-icon", SsCommonIcon); app.component("ss-onoff-button", SsOnoffButton); app.component("ss-datetime-picker", SsDatetimePicker); app.component("ss-confirm", SsConfirm); app.component("ss-image-cropper", SsImageCropper); app.component("ss-upload-image", SsUploadImage); app.component("ss-upload-file", SsUploadFile); app.component("ss-car-card", SsCarCard); app.component("ss-sub-tab", SsSubTab); app.component("ss-editor", SsEditor); app.component("SsEditor", SsEditor); // app.component("SsObjp", SsObjp); // app.component("SsHidden", SsHidden); // app.component("SsCcp", SsCcp); // app.component("SsDatePicker", SsDatePicker); // app.component("SsIcon", SsIcon); // app.component("SsCommonIcon", SsCommonIcon); // app.component("SsBreadcrumb", SsBreadcrumb); // app.component("SsEditor", SsEditor); // app.component("SsDialogIcon", SsDialogIcon); // app.component("SsBottomButton", SsBottomButton); // app.component("SsNavIcon", SsNavIcon); // app.component("SsHeaderIcon", SsHeaderIcon); // app.component("SsGolbalMenuIcon", SsGolbalMenuIcon); // app.component("SsCartListIcon", SsCartListIcon); // app.component("SsQuickIcon", SsQuickIcon); // app.component("SsFormIcon", SsFormIcon); // app.component("SsBottomDivIcon", SsBottomDivIcon); // app.component("SsEditorIcon", SsEditorIcon); // app.component("SsValidate", SsValidate); // app.component("SsOnoffbutton", SsOnoffbutton); // app.component("SsOnoffbuttonArray", SsOnoffbuttonArray); // app.component("SsTextarea", SsTextarea); // app.component("SsLoginInput", SsLoginInput); // app.component("SsLoginButton", SsLoginButton); // app.component("SsSearch", SsSearch); // app.component("SsCartItem", SsCartItem); // app.component("SsCartItem2", SsCartItem2); // app.component("SsListCard", SsListCard); // app.component("SsFolderCard", SsFolderCard); // app.component("SsFolderCartView", SsFolderCartView); // app.component("SsPage", SsPage); // app.component("SsRightInfo", SSRightInfo); // app.component("SsSuccessPopup", SsSuccessPopup); // app.component("SsErrorDialog", SsErrorDialog); // app.component("SsVerify", SsVerify); // app.component("SsVerifyNode", SsVerifyNode); // app.component("SsOrcImgBox", SsOrcImgBox); // app.component("ss-search-input", SsSearchInput); // app.component("ss-search-date-picker", SsSearchDatePicker); // app.component("ss-search-button", SsSearchButton); // app.component("ss-drop-button", SsDropButton); // app.component("ss-sub-tab", SsSubTab); // app.component("ss-img", SsImgUpload); // 设置为中文 // app.use(ElementPlus, { // locale: ElementPlusLocaleZhCn, // }); // console.log(ElementPlus); // 确保 ElementPlusIconsVue // if (window.ElementPlusIconsVue) { // // 注册 Element Plus 图标组件 // for (const [key, component] of Object.entries( // window.ElementPlusIconsVue // )) { // console.log(key, component); // app.component(key, component); // } // } // 挂载首页的组件 // for (const componentName in IndexComponents) { // app.component(componentName, IndexComponents[componentName]); // } // 挂载echarts的组件 // for (const componentName in EchartComponents) { // app.component(componentName, EchartComponents[componentName]); // } // 挂载 Vue 应用 const vm = app.mount(el); vm.data = vueOptions.data(); return vm; }; })();