| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747 |
- // 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<String>} 服务器路径
- */
- 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: `
- <!-- 移动端简化的错误提示 - 显示在输入框右下角 -->
- <div v-if="validationState.hasError && validationState.hasInteracted" class="ss-input__error">
- {{ validationState.errorMessage }}
- </div>
- <div class="ss-input">
- <input
- class="ss-input__field"
- :name="name"
- type="text"
- :value="inputValue"
- :placeholder="placeholder"
- @input="handleInput"
- @blur="handleBlur"
- @focus="handleFocus"
- autocomplete="off"
- v-bind="$attrs"
- />
- </div>
- `,
- };
- // 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: `
- <span
- :class="['iconfont', iconClass]"
- :style="iconStyle"
- @click="handleClick"
- ></span>
- `,
- };
- // ss-card 卡片组件 - 从小程序转换
- const SsCard = {
- name: 'SsCard',
- props: {
- item: {
- type: Object,
- default: () => ({}),
- },
- },
- emits: ['click', 'buttonClick'],
- setup(props, { emit }) {
- // 状态
- const showButtonMenu = ref(false);
- // 计算属性
- 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 handleCardClick = () => {
- // 如果菜单打开,先关闭菜单
- 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 handleButtonClick = (btn, index) => {
- showButtonMenu.value = false;
- // 执行按钮的回调
- if (btn.onclick && typeof btn.onclick === 'function') {
- btn.onclick();
- }
- // 触发组件事件
- emit('buttonClick', { button: btn, index, item: props.item });
- };
- // 监听全局关闭事件
- onMounted(() => {
- document.addEventListener('closeAllCardMenus', closeMenu);
- });
- // H5环境下的清理逻辑
- // 注意:Vue 3的beforeUnmount在某些H5环境下可能不可用
- // 这里暂时省略,依赖页面刷新时的自动清理
- return {
- showButtonMenu,
- hasButtons,
- isMultipleButtons,
- isSingleButton,
- handleCardClick,
- handleSettingClick,
- handleButtonClick,
- };
- },
- template: `
- <div class="ss-card" @click="handleCardClick">
- <!-- 右上角设置按钮 - 只有buttons且长度>0时显示 -->
- <div
- v-if="hasButtons"
- class="card-setting-header"
- @click.stop="handleSettingClick"
- >
- <div class="setting-icon">
- <Icon name="icon-chilun" size="32" color="#999"/>
- </div>
- <!-- 按钮弹窗菜单 - 只有多个按钮时显示 -->
- <div
- v-if="showButtonMenu && isMultipleButtons"
- class="button-menu"
- @click.stop
- >
- <div
- v-for="(btn, index) in item.buttons"
- :key="index"
- class="menu-item"
- @click="handleButtonClick(btn, index)"
- >
- <Icon v-if="btn.icon" :name="btn.icon" size="28" color="inherit"/>
- <span class="menu-text">{{ btn.title }}</span>
- </div>
- </div>
- </div>
- <!-- 卡片内容 -->
- <slot></slot>
- </div>
- `,
- };
- // 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: `
- <div class="ss-search-button-container" :class="{ open: showOptionsMenu }">
- <button
- class="ss-search-button"
- :style="buttonStyle"
- @click="handleClick"
- :disabled="disabled"
- >
- <!-- 前置图标插槽 -->
- <div v-if="preIcon" class="ss-search-button__pre-icon">
- <Icon :name="preIcon" :size="iconSize" :color="iconColor"/>
- </div>
- <!-- 按钮文本 -->
- <span class="ss-search-button__text">{{ text }}</span>
- <!-- 后置图标插槽 -->
- <div v-if="suffixIcon" class="ss-search-button__suffix-icon">
- <Icon :name="suffixIcon" :size="iconSize" :color="iconColor"/>
- </div>
- </button>
- <!-- 选项弹窗菜单 -->
- <div
- v-if="showOptionsMenu && hasMultipleOptions"
- class="options-menu"
- @click.stop
- >
- <div
- v-for="(option, index) in options"
- :key="index"
- class="option-item"
- @click="handleOptionClick(option, index)"
- >
- <Icon v-if="option.icon" :name="option.icon" size="28" color="inherit"/>
- <span class="option-text">{{ option.text }}</span>
- </div>
- </div>
- </div>
- `,
- };
- // 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 selectedValue = ref(props.modelValue);
- const searchKeyword = ref('');
- const remoteOptions = ref([]);
- const remoteLoading = ref(false);
- 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 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);
- }
- });
- 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 : []);
- }
- );
- return {
- isOpen,
- selectedValue,
- searchKeyword,
- optionsList,
- finalLoading,
- displayText,
- toggleDropdown,
- selectOption,
- };
- },
- template: `
- <div class="ss-select-container" :class="{ open: isOpen }" @click.stop="toggleDropdown">
- <!-- 显示区域 -->
- <div class="ss-select" :class="{ disabled: disabled }">
- <span class="select-text" :class="{ placeholder: !selectedValue }">{{ displayText }}</span>
- <div class="select-arrow" :class="{ rotate: isOpen }">
- <Icon name="icon-xiangxiajiantou" size="32" :color="disabled ? '#ccc' : '#999'"/>
- </div>
- </div>
- <!-- 选项列表 -->
- <div class="ss-options" v-show="isOpen">
- <!-- 加载状态 -->
- <div
- v-if="finalLoading"
- class="option-item loading-item"
- >
- <span class="loading-text">加载中...</span>
- </div>
- <!-- 无选项 -->
- <div
- v-else-if="optionsList.length === 0"
- class="option-item no-options"
- >
- 无选项
- </div>
- <!-- 选项列表 -->
- <div
- v-else
- v-for="(option, index) in optionsList"
- :key="index"
- class="option-item"
- :class="{ selected: option[mapping.value] === selectedValue }"
- @click.stop="selectOption(option)"
- >
- {{ option[mapping.text] }}
- </div>
- </div>
- </div>
- `,
- };
- // 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: `
- <div class="ss-bottom">
- <!-- 审核意见区域 -->
- <div v-if="showShyj" class="ss-bottom__opinion">
- <table class="ss-bottom__opinion-table">
- <tr>
- <th class="ss-bottom__opinion-label">{{ shyjTitle }}</th>
- <td class="ss-bottom__opinion-input">
- <ss-input
- :placeholder="shyjPlaceholder"
- v-model="reason"
- @input="handleShyjInput"
- />
- </td>
- </tr>
- </table>
- </div>
- <!-- 按钮区域 -->
- <div class="ss-bottom__buttons" :class="{ 'ss-bottom__buttons--with-border': !showShyj }">
- <template v-for="(button, index) in buttons" :key="index">
- <div
- class="ss-bottom__button"
- :class="{
- 'ss-bottom__button--custom': button.backgroundColor || button.color || button.clickBgColor || button.clickColor,
- 'ss-bottom__button--active': activeButtonIndex === index
- }"
- :style="activeButtonIndex === index ? getButtonActiveStyle(button) : getButtonStyle(button)"
- @click="handleButtonClick(button, index)"
- @mousedown="handleButtonMouseDown(index)"
- @mouseup="handleButtonMouseUp"
- @mouseleave="handleMouseLeave"
- @touchstart="handleButtonMouseDown(index)"
- @touchend="handleButtonMouseUp"
- >
- {{ button.text }}
- </div>
- <!-- 分割线,最后一个按钮不显示 -->
- <div v-if="index < buttons.length - 1 && divider" class="ss-bottom__divider"></div>
- </template>
- </div>
- </div>
- `,
- };
- // ===== 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: `
- <div class="ss-onoff-button" :class="{ checked: isChecked, disabled: disabled }" @click="toggleSelect">
- <span class="button-label">{{ label }}</span>
- <div class="button-mark">
- <span class="form-icon" :class="isChecked ? 'form-icon-onoffbutton-checked' : 'form-icon-onoffbutton-unchecked'"></span>
- </div>
- </div>
- `,
- };
- // ===== 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: `
- <div class="ss-datetime-picker ss-mobile-component" :class="{ disabled: disabled }">
- <!-- 隐藏的input用于ssVm校验 -->
- <input type="hidden" :name="name" :value="modelValue" />
- <div class="datetime-picker-display" @click="openPicker">
- <span class="datetime-picker-value" :class="{ placeholder: !modelValue }">{{ displayText }}</span>
- </div>
- <!-- 日期选择器 -->
- <van-popup v-model:show="showPicker" position="bottom" :style="{ zIndex: 10000 }">
- <van-date-picker
- v-if="mode === 'date' || mode === 'datetime'"
- v-model="currentDateArray"
- :min-date="minDateObj"
- :max-date="maxDateObj"
- @confirm="onConfirm"
- @cancel="onCancel"
- title="选择日期"
- />
- <van-time-picker
- v-if="mode === 'time'"
- v-model="currentTimeArray"
- @confirm="onConfirm"
- @cancel="onCancel"
- title="选择时间"
- />
- </van-popup>
- <!-- 时间选择器(datetime 模式的第二步) -->
- <van-popup v-model:show="showTimePicker" position="bottom" :style="{ zIndex: 10000 }">
- <van-time-picker
- v-model="currentTimeArray"
- @confirm="onTimeConfirm"
- @cancel="onTimeCancel"
- title="选择时间"
- />
- </van-popup>
- </div>
- `,
- };
- 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: `
- <div v-if="modelValue" class="ss-confirm">
- <div class="confirm-mask" @click="onMask"></div>
- <div class="confirm-content">
- <div class="confirm-header" v-if="title">
- <div class="header-title">{{ title }}</div>
- </div>
- <div class="header-line" v-if="title"></div>
- <div class="confirm-body">
- <div v-if="content" class="confirm-content-text" v-html="content"></div>
- <div class="confirm-slot-content"><slot /></div>
- </div>
- <div class="confirm-bottom">
- <button class="confirm-btn confirm-btn-cancel" @click.stop="onCancel">取消</button>
- <button class="confirm-btn confirm-btn-confirm" @click.stop="onConfirm">确认</button>
- </div>
- </div>
- </div>
- `,
- };
- // ===== 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: `
- <div v-if="show" class="ss-image-cropper-container">
- <!-- 左上角尺寸显示 -->
- <div class="crop-size-display">
- 长: {{ outputWidth }}px<br>宽: {{ outputHeight }}px
- </div>
- <div class="ss-crop-image-container">
- <img id="ss-image-cropper-img" :src="src" />
- </div>
- <!-- 使用 ss-bottom 组件 -->
- <ss-bottom
- :show-shyj="false"
- :buttons="[
- { text: '取消', action: 'cancel' },
- { text: '保存并提交', action: 'confirm' }
- ]"
- @button-click="handleCropAction"
- />
- </div>
- `,
- };
- // ===== 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: `
- <div class="ss-upload-image-multi">
- <!-- 图片列表 -->
- <div class="image-list">
- <!-- 已上传的图片 -->
- <div
- v-for="(img, index) in imageList"
- :key="index"
- class="image-item"
- :style="itemStyle"
- >
- <img :src="getImageUrl(img)" class="image-display" />
- <!-- 删除按钮 -->
- <div v-if="!disabled" class="image-delete" @click.stop="deleteImage(index)">
- <Icon name="icon-guanbi" size="32" color="#fff" />
- </div>
- </div>
- <!-- 添加按钮 -->
- <div
- v-if="canAddMore && !disabled"
- class="image-item image-add"
- :style="itemStyle"
- @click="selectImage"
- >
- <Icon name="icon-xiangji" size="48" color="#ccc" />
- <div class="add-text">{{ imageList.length }}/{{ max }}</div>
- </div>
- </div>
- <!-- 裁剪组件 -->
- <ss-image-cropper
- v-model:show="showCropper"
- :src="tempImageSrc"
- :shape="shape"
- :aspect-ratio="aspectRatio"
- :output-width="outputWidth"
- :output-height="outputHeight"
- @confirm="handleCropConfirm"
- @cancel="handleCropCancel"
- />
- </div>
- `,
- };
- // ===== 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: `
- <div class="car-card" :class="statusClass" @click="handleCardClick">
- <!-- 第一行:车辆名称 -->
- <div class="car-title">
- {{ carData.name || '别克GL8' }}
- </div>
- <!-- 第二行:左右结构 -->
- <div class="car-info">
- <!-- 左边:车辆图片 -->
- <div class="car-image-container">
- <img class="car-image" :src="getImageUrl(carData.image)" />
- </div>
- <!-- 右边:车辆信息 -->
- <div class="car-details">
- <div class="detail-item car-name" v-if="carData.wph">
- {{ carData.wph }}
- </div>
- <div class="detail-item seats" v-for="(wp, index) in carData.wpcsList" :key="index">
- {{ wp.mc }} : {{ wp.sz || wp.zf }}
- </div>
- <div class="detail-item car-type">
- {{ carData.type || '商务车' }}
- </div>
- </div>
- </div>
- </div>
- `,
- };
- // ===== 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 params = new URLSearchParams({
- ...props.baseParams,
- service: tab.service || '',
- param: tab.param || '',
- });
- 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: `
- <div class="ss-sub-tab">
- <!-- Tab 栏 -->
- <div class="ss-sub-tab__bar" v-if="tabList.length > 0">
- <div
- v-for="(tab, index) in tabList"
- :key="index"
- class="ss-sub-tab__item"
- :class="{ 'ss-sub-tab__item--active': currentTab === index }"
- @click="handleTabClick(index)"
- >
- {{ tab.desc || tab.title }}
- </div>
- </div>
- <!-- 内容区域 -->
- <div class="ss-sub-tab__content" v-if="currentTabUrl">
- <iframe :src="currentTabUrl" frameborder="0"></iframe>
- </div>
- </div>
- `,
- };
- // ===== 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: `
- <div class="ss-upload-file">
- <!-- 文件列表 -->
- <div v-if="fileList.length > 0" class="file-list">
- <!-- 已上传的文件 -->
- <div
- v-for="(file, index) in fileList"
- :key="index"
- class="file-item"
- >
- <div class="file-icon">
- <Icon :name="getFileIcon(file.name)" size="40" color="#40ac6d" />
- </div>
- <div class="file-info" @click="downloadFile(file)">
- <span class="file-name">{{ file.name }}</span>
- </div>
- <!-- 文件操作按钮 -->
- <div class="file-actions">
- <div class="file-download" @click="downloadFile(file)">
- <Icon name="icon-xiazai" size="32" color="#40ac6d" />
- </div>
- <div v-if="!disabled" class="file-delete" @click.stop="deleteFile(index)">
- <Icon name="icon-guanbi" size="32" color="#ff3b30" />
- </div>
- </div>
- </div>
- </div>
- <!-- 添加按钮 -->
- <div
- v-if="canAddMore && !disabled"
- class="file-add-button"
- @click="selectFile"
- >
- <div class="add-icon">
- <Icon name="icon-tianjia" size="64" color="#999" />
- </div>
- <span class="add-text">上传文件 ({{ fileList.length }}/{{ max }})</span>
- <span class="file-tip">支持格式: {{ accept }},最大{{ maxSize }}MB</span>
- </div>
- </div>
- `,
- };
- /**
- * 功能说明: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<string>} 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<void>}
- */
- 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<void>}
- */
- 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: `
- <div class="ss-editor-container">
- <input v-if="fjid" type="hidden" name="fjid" :value="fjid" />
- <input type="hidden" :name="name.replace(/wj$/, '') + 'Edit'" :value="editorContent" />
- <input type="hidden" :name="name.replace(/wj$/, '') + 'wj'" :value="modelValue" />
- <input type="hidden" name="ueditorpath" value="mswj" />
- <textarea :id="editorElementId"></textarea>
- </div>
- `,
- };
- 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;
- };
- })();
|