mp-ss-components.js 76 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747
  1. // H5版本的小程序组件库
  2. // 参考 alf/ss-components.js 的组件形式
  3. (function () {
  4. const { ref, createApp, watch, inject, onMounted, onBeforeUnmount, computed } = Vue;
  5. // ===== 公共上传函数 =====
  6. /**
  7. * 统一文件上传函数
  8. * @param {File|Blob} file - 文件或Blob对象
  9. * @param {String} type - 'image' | 'file'
  10. * @param {String} fileName - 文件名(可选)
  11. * @returns {Promise<String>} 服务器路径
  12. */
  13. async function uploadFile(file, type = 'image', fileName) {
  14. try {
  15. window.showToast?.('上传中...', 'loading');
  16. const formData = new FormData();
  17. const name = fileName || (file instanceof Blob ? 'file' : file.name);
  18. formData.append('fileEdit', file, name);
  19. formData.append('application', '');
  20. // 根据类型选择接口参数
  21. const apiType = type === 'image' ? 'img' : 'file';
  22. const result = await window.request.post(
  23. `/service?ssServ=ulByHttp&type=${apiType}`,
  24. formData,
  25. { loading: false }
  26. );
  27. if (result?.data?.fileList?.[0]?.path) {
  28. const serverPath = result.data.fileList[0].path;
  29. window.showToast?.('上传成功', 'success');
  30. return serverPath;
  31. } else {
  32. throw new Error('上传返回数据格式错误');
  33. }
  34. } catch (error) {
  35. console.error('上传失败:', error);
  36. window.showToast?.('上传失败,请重试', 'error');
  37. throw error;
  38. }
  39. }
  40. const SsCommonIcon = {
  41. name: "SsCommonIcon",
  42. props: {
  43. class: {
  44. type: String,
  45. required: true,
  46. },
  47. },
  48. setup(props) {
  49. const { h } = Vue;
  50. return () =>
  51. h("i", {
  52. class: props.class + " common-icon",
  53. });
  54. },
  55. };
  56. // ss-input 智能输入组件
  57. const SsInput = {
  58. name: 'SsInput',
  59. inheritAttrs: false, // 不直接继承属性到组件根元素
  60. props: {
  61. // v-model 绑定的值
  62. modelValue: {
  63. type: [String, Number],
  64. default: '',
  65. },
  66. // 字段名称
  67. name: {
  68. type: String,
  69. default: '',
  70. },
  71. // 占位符
  72. placeholder: {
  73. type: String,
  74. default: '请输入',
  75. },
  76. // 错误提示
  77. errTip: {
  78. type: String,
  79. default: '',
  80. },
  81. },
  82. emits: ['update:modelValue', 'input', 'blur', 'change', 'focus'],
  83. setup(props, { emit }) {
  84. const inputValue = ref(props.modelValue || '');
  85. const validationState = ref({
  86. hasError: false,
  87. errorMessage: '',
  88. isRequired: false,
  89. isEmpty: true,
  90. hasInteracted: false, // 是否已经交互过
  91. isSubmitMode: false, // 是否处于提交模式
  92. });
  93. // 从ValidatedTd注入事件处理函数(兼容小程序方式)
  94. const onInputInject = inject('onInput', null);
  95. const onBlurInject = inject('onBlur', null);
  96. // 检查是否为必填字段
  97. const checkRequired = () => {
  98. if (window.ssVm && props.name) {
  99. validationState.value.isRequired = window.ssVm.isRequired(
  100. props.name
  101. );
  102. }
  103. };
  104. // 更新父级td的class
  105. const updateTdClass = () => {
  106. // 找到父级td元素
  107. const inputElement = document.querySelector(
  108. `input[name="${props.name}"]`
  109. );
  110. if (inputElement) {
  111. const tdElement = inputElement.closest('td');
  112. if (tdElement) {
  113. // 移除所有校验相关的class
  114. tdElement.classList.remove('td-required', 'td-error');
  115. // 逻辑分离:
  116. // 1. 初始状态:必填且为空 → 只有左侧红线 (td-required)
  117. // 2. 实时校验失败 → 左侧红线 + 底部红线 + 错误文字 (td-error)
  118. if (
  119. validationState.value.hasError &&
  120. validationState.value.hasInteracted
  121. ) {
  122. // 用户交互后校验失败:左侧红线 + 底部红线 + 错误文字
  123. tdElement.classList.add('td-error');
  124. } else if (
  125. validationState.value.isRequired &&
  126. validationState.value.isEmpty
  127. ) {
  128. // 必填且为空:只有左侧红线
  129. tdElement.classList.add('td-required');
  130. }
  131. }
  132. }
  133. };
  134. // 校验字段
  135. const validateField = (value) => {
  136. if (window.ssVm && props.name) {
  137. const result = window.ssVm.validateField(props.name);
  138. validationState.value.hasError = !result.valid;
  139. validationState.value.errorMessage = result.message || '';
  140. validationState.value.isEmpty =
  141. !value || value.trim() === '';
  142. // 更新td的class
  143. setTimeout(updateTdClass, 0);
  144. return result;
  145. }
  146. return { valid: true, message: '' };
  147. };
  148. // 监听props变化
  149. watch(
  150. () => props.modelValue,
  151. (newVal) => {
  152. inputValue.value = newVal;
  153. validateField(newVal);
  154. }
  155. );
  156. // 挂载时初始化
  157. onMounted(() => {
  158. checkRequired();
  159. validateField(inputValue.value);
  160. // 监听ssVm规则更新事件
  161. const inputElement = document.querySelector(
  162. `input[name="${props.name}"]`
  163. );
  164. if (inputElement) {
  165. inputElement.addEventListener('ssvm-rules-updated', () => {
  166. console.log(`收到规则更新通知: ${props.name}`);
  167. checkRequired();
  168. validateField(inputValue.value);
  169. updateTdClass();
  170. });
  171. }
  172. // 确保初始状态正确显示,延迟更长时间确保DOM完全渲染
  173. setTimeout(() => {
  174. updateTdClass();
  175. }, 200);
  176. });
  177. // 事件处理函数
  178. const handleInput = (event) => {
  179. const value = event.target.value;
  180. inputValue.value = value;
  181. // 标记已交互
  182. validationState.value.hasInteracted = true;
  183. // 1. 支持v-model
  184. emit('update:modelValue', value);
  185. emit('input', event);
  186. // 2. 内部校验(实时校验)
  187. validateField(value);
  188. // 3. 兼容小程序inject方式
  189. if (onInputInject) {
  190. onInputInject(event);
  191. }
  192. };
  193. const handleBlur = (event) => {
  194. // 标记已交互
  195. validationState.value.hasInteracted = true;
  196. emit('blur', event);
  197. // 失焦时再次校验
  198. validateField(inputValue.value);
  199. // 兼容小程序inject方式
  200. if (onBlurInject) {
  201. onBlurInject(event);
  202. }
  203. };
  204. const handleFocus = (event) => {
  205. emit('focus', event);
  206. };
  207. return {
  208. inputValue,
  209. validationState,
  210. handleInput,
  211. handleBlur,
  212. handleFocus,
  213. };
  214. },
  215. template: `
  216. <!-- 移动端简化的错误提示 - 显示在输入框右下角 -->
  217. <div v-if="validationState.hasError && validationState.hasInteracted" class="ss-input__error">
  218. {{ validationState.errorMessage }}
  219. </div>
  220. <div class="ss-input">
  221. <input
  222. class="ss-input__field"
  223. :name="name"
  224. type="text"
  225. :value="inputValue"
  226. :placeholder="placeholder"
  227. @input="handleInput"
  228. @blur="handleBlur"
  229. @focus="handleFocus"
  230. autocomplete="off"
  231. v-bind="$attrs"
  232. />
  233. </div>
  234. `,
  235. };
  236. // icon 图标组件 - 从小程序转换
  237. const Icon = {
  238. name: 'Icon',
  239. props: {
  240. // 图标名称,对应iconfont中的类名,如'icon-home'
  241. name: {
  242. type: String,
  243. required: true,
  244. },
  245. // 图标颜色
  246. color: {
  247. type: String,
  248. default: '#000',
  249. },
  250. // 图标大小,单位rpx (H5中转换为px)
  251. size: {
  252. type: [Number, String],
  253. default: 32,
  254. },
  255. },
  256. emits: ['click'],
  257. setup(props, { emit }) {
  258. // 计算完整的图标类名
  259. const iconClass = computed(() => {
  260. return props.name;
  261. });
  262. // 计算样式
  263. const iconStyle = computed(() => ({
  264. color: props.color,
  265. fontSize: parseInt(props.size) / 2 + 'px', // rpx转px,除以2
  266. verticalAlign: 'middle',
  267. }));
  268. // 点击事件处理
  269. const handleClick = (event) => {
  270. emit('click', event);
  271. };
  272. return {
  273. iconClass,
  274. iconStyle,
  275. handleClick,
  276. };
  277. },
  278. template: `
  279. <span
  280. :class="['iconfont', iconClass]"
  281. :style="iconStyle"
  282. @click="handleClick"
  283. ></span>
  284. `,
  285. };
  286. // ss-card 卡片组件 - 从小程序转换
  287. const SsCard = {
  288. name: 'SsCard',
  289. props: {
  290. item: {
  291. type: Object,
  292. default: () => ({}),
  293. },
  294. },
  295. emits: ['click', 'buttonClick'],
  296. setup(props, { emit }) {
  297. // 状态
  298. const showButtonMenu = ref(false);
  299. // 计算属性
  300. const hasButtons = computed(() => {
  301. return (
  302. props.item?.buttons &&
  303. Array.isArray(props.item.buttons) &&
  304. props.item.buttons.length > 0
  305. );
  306. });
  307. const isMultipleButtons = computed(() => {
  308. return props.item?.buttons && props.item.buttons.length > 1;
  309. });
  310. const isSingleButton = computed(() => {
  311. return props.item?.buttons && props.item.buttons.length === 1;
  312. });
  313. // 处理卡片点击
  314. const handleCardClick = () => {
  315. // 如果菜单打开,先关闭菜单
  316. if (showButtonMenu.value) {
  317. showButtonMenu.value = false;
  318. return;
  319. }
  320. emit('click');
  321. };
  322. // 处理设置按钮点击
  323. const handleSettingClick = () => {
  324. if (isSingleButton.value) {
  325. // 只有一个按钮,直接执行
  326. handleButtonClick(props.item.buttons[0], 0);
  327. } else if (isMultipleButtons.value) {
  328. // 先记录当前状态
  329. const wasOpen = showButtonMenu.value;
  330. // 关闭其他卡片的菜单
  331. document.dispatchEvent(
  332. new CustomEvent('closeAllCardMenus')
  333. );
  334. // 切换当前菜单状态
  335. showButtonMenu.value = !wasOpen;
  336. }
  337. };
  338. // 关闭菜单
  339. const closeMenu = () => {
  340. showButtonMenu.value = false;
  341. };
  342. // 处理按钮点击
  343. const handleButtonClick = (btn, index) => {
  344. showButtonMenu.value = false;
  345. // 执行按钮的回调
  346. if (btn.onclick && typeof btn.onclick === 'function') {
  347. btn.onclick();
  348. }
  349. // 触发组件事件
  350. emit('buttonClick', { button: btn, index, item: props.item });
  351. };
  352. // 监听全局关闭事件
  353. onMounted(() => {
  354. document.addEventListener('closeAllCardMenus', closeMenu);
  355. });
  356. // H5环境下的清理逻辑
  357. // 注意:Vue 3的beforeUnmount在某些H5环境下可能不可用
  358. // 这里暂时省略,依赖页面刷新时的自动清理
  359. return {
  360. showButtonMenu,
  361. hasButtons,
  362. isMultipleButtons,
  363. isSingleButton,
  364. handleCardClick,
  365. handleSettingClick,
  366. handleButtonClick,
  367. };
  368. },
  369. template: `
  370. <div class="ss-card" @click="handleCardClick">
  371. <!-- 右上角设置按钮 - 只有buttons且长度>0时显示 -->
  372. <div
  373. v-if="hasButtons"
  374. class="card-setting-header"
  375. @click.stop="handleSettingClick"
  376. >
  377. <div class="setting-icon">
  378. <Icon name="icon-chilun" size="32" color="#999"/>
  379. </div>
  380. <!-- 按钮弹窗菜单 - 只有多个按钮时显示 -->
  381. <div
  382. v-if="showButtonMenu && isMultipleButtons"
  383. class="button-menu"
  384. @click.stop
  385. >
  386. <div
  387. v-for="(btn, index) in item.buttons"
  388. :key="index"
  389. class="menu-item"
  390. @click="handleButtonClick(btn, index)"
  391. >
  392. <Icon v-if="btn.icon" :name="btn.icon" size="28" color="inherit"/>
  393. <span class="menu-text">{{ btn.title }}</span>
  394. </div>
  395. </div>
  396. </div>
  397. <!-- 卡片内容 -->
  398. <slot></slot>
  399. </div>
  400. `,
  401. };
  402. // ss-search-button 搜索按钮组件 - 从小程序转换
  403. const SsSearchButton = {
  404. name: 'SsSearchButton',
  405. props: {
  406. // 按钮文本
  407. text: {
  408. type: String,
  409. default: '增加',
  410. },
  411. // 是否禁用
  412. disabled: {
  413. type: Boolean,
  414. default: false,
  415. },
  416. // 按钮高度
  417. height: {
  418. type: [String, Number],
  419. default: '36px',
  420. },
  421. // 前置图标名称
  422. preIcon: {
  423. type: String,
  424. default: '',
  425. },
  426. // 后置图标名称
  427. suffixIcon: {
  428. type: String,
  429. default: '',
  430. },
  431. // 图标大小
  432. iconSize: {
  433. type: [String, Number],
  434. default: '32',
  435. },
  436. // 图标颜色
  437. iconColor: {
  438. type: String,
  439. default: '#585d6e',
  440. },
  441. // 自定义按钮样式
  442. customStyle: {
  443. type: Object,
  444. default: () => ({}),
  445. },
  446. // 跳转链接(兼容原JSP用法)
  447. href: {
  448. type: String,
  449. default: '',
  450. },
  451. // 选项列表
  452. options: {
  453. type: Array,
  454. default: () => [],
  455. },
  456. },
  457. emits: ['click', 'optionClick'],
  458. setup(props, { emit }) {
  459. // 状态
  460. const showOptionsMenu = ref(false);
  461. // 计算属性
  462. const hasOptions = computed(() => {
  463. return (
  464. props.options &&
  465. Array.isArray(props.options) &&
  466. props.options.length > 0
  467. );
  468. });
  469. const hasMultipleOptions = computed(() => {
  470. return props.options && props.options.length > 1;
  471. });
  472. const isSingleOption = computed(() => {
  473. return props.options && props.options.length === 1;
  474. });
  475. // 按钮样式
  476. const buttonStyle = computed(() => ({
  477. height:
  478. typeof props.height === 'number'
  479. ? `${props.height}px`
  480. : props.height,
  481. ...props.customStyle,
  482. }));
  483. // 处理按钮点击
  484. const handleClick = () => {
  485. if (!hasOptions.value) {
  486. // 没有选项,直接触发点击事件
  487. emit('click');
  488. } else if (isSingleOption.value) {
  489. // 单个选项,直接执行
  490. handleOptionClick(props.options[0], 0);
  491. } else if (hasMultipleOptions.value) {
  492. // 先记录当前状态
  493. const wasOpen = showOptionsMenu.value;
  494. // 关闭其他按钮的菜单
  495. document.dispatchEvent(
  496. new CustomEvent('closeAllButtonMenus')
  497. );
  498. // 切换当前菜单状态
  499. showOptionsMenu.value = !wasOpen;
  500. }
  501. };
  502. // 处理选项点击
  503. const handleOptionClick = (option, index) => {
  504. showOptionsMenu.value = false;
  505. // 执行选项的回调
  506. if (option.onclick && typeof option.onclick === 'function') {
  507. option.onclick();
  508. }
  509. // 触发组件事件
  510. emit('optionClick', { option, index });
  511. };
  512. // 关闭菜单
  513. const closeMenu = () => {
  514. showOptionsMenu.value = false;
  515. };
  516. // 监听全局关闭事件
  517. onMounted(() => {
  518. document.addEventListener('closeAllButtonMenus', closeMenu);
  519. });
  520. return {
  521. showOptionsMenu,
  522. hasOptions,
  523. hasMultipleOptions,
  524. isSingleOption,
  525. buttonStyle,
  526. handleClick,
  527. handleOptionClick,
  528. };
  529. },
  530. template: `
  531. <div class="ss-search-button-container" :class="{ open: showOptionsMenu }">
  532. <button
  533. class="ss-search-button"
  534. :style="buttonStyle"
  535. @click="handleClick"
  536. :disabled="disabled"
  537. >
  538. <!-- 前置图标插槽 -->
  539. <div v-if="preIcon" class="ss-search-button__pre-icon">
  540. <Icon :name="preIcon" :size="iconSize" :color="iconColor"/>
  541. </div>
  542. <!-- 按钮文本 -->
  543. <span class="ss-search-button__text">{{ text }}</span>
  544. <!-- 后置图标插槽 -->
  545. <div v-if="suffixIcon" class="ss-search-button__suffix-icon">
  546. <Icon :name="suffixIcon" :size="iconSize" :color="iconColor"/>
  547. </div>
  548. </button>
  549. <!-- 选项弹窗菜单 -->
  550. <div
  551. v-if="showOptionsMenu && hasMultipleOptions"
  552. class="options-menu"
  553. @click.stop
  554. >
  555. <div
  556. v-for="(option, index) in options"
  557. :key="index"
  558. class="option-item"
  559. @click="handleOptionClick(option, index)"
  560. >
  561. <Icon v-if="option.icon" :name="option.icon" size="28" color="inherit"/>
  562. <span class="option-text">{{ option.text }}</span>
  563. </div>
  564. </div>
  565. </div>
  566. `,
  567. };
  568. // ss-select 下拉选择组件 - 从小程序转换
  569. const SsSelect = {
  570. name: 'SsSelect',
  571. props: {
  572. // 选项数组
  573. options: {
  574. type: Array,
  575. default: () => [],
  576. },
  577. // 字段映射
  578. mapping: {
  579. type: Object,
  580. default: () => ({ text: 'n', value: 'v' }),
  581. },
  582. // 默认值
  583. modelValue: {
  584. type: [String, Number],
  585. default: '',
  586. },
  587. // 占位符
  588. placeholder: {
  589. type: String,
  590. default: '请选择',
  591. },
  592. // 校验配置
  593. validation: {
  594. type: Object,
  595. default: () => ({ enable: false, message: '' }),
  596. },
  597. // 是否禁用
  598. disabled: {
  599. type: Boolean,
  600. default: false,
  601. },
  602. // 是否支持搜索
  603. searchable: {
  604. type: Boolean,
  605. default: false,
  606. },
  607. // 是否支持清空
  608. clearable: {
  609. type: Boolean,
  610. default: false,
  611. },
  612. // 加载状态
  613. loading: {
  614. type: Boolean,
  615. default: false,
  616. },
  617. // 宽度设置
  618. width: {
  619. type: String,
  620. default: '100%',
  621. },
  622. minWidth: {
  623. type: String,
  624. default: 'unset',
  625. },
  626. // 功能说明:对齐PC端 ss-objp,用 codebook 在组件内部拉取下拉选项 by xu 2026-02-28
  627. cb: {
  628. type: String,
  629. default: '',
  630. },
  631. url: {
  632. type: String,
  633. default: '/service?ssServ=loadObjpOpt&objectpickerdropdown1=1',
  634. },
  635. inp: {
  636. type: [Boolean, String],
  637. default: false,
  638. },
  639. filter: {
  640. type: [Object, String],
  641. default: null,
  642. },
  643. autoSelectFirst: {
  644. type: Boolean,
  645. default: false,
  646. },
  647. },
  648. emits: ['update:modelValue', 'change', 'search', 'clear', 'loaded'],
  649. setup(props, { emit }) {
  650. // 响应式数据
  651. const isOpen = ref(false);
  652. const selectedValue = ref(props.modelValue);
  653. const searchKeyword = ref('');
  654. const remoteOptions = ref([]);
  655. const remoteLoading = ref(false);
  656. const parseFilterObj = () => {
  657. if (!props.filter) return {};
  658. if (typeof props.filter === 'object') return props.filter;
  659. if (typeof props.filter === 'string') {
  660. try {
  661. const obj = JSON.parse(props.filter);
  662. return obj && typeof obj === 'object' ? obj : {};
  663. } catch (_) {
  664. return {};
  665. }
  666. }
  667. return {};
  668. };
  669. const normalizeResultToOptions = (respData) => {
  670. const raw = respData || {};
  671. if (Array.isArray(raw.resultList)) {
  672. return raw.resultList.map((it) => {
  673. if (it && typeof it === 'object') return it;
  674. return { n: String(it || ''), v: String(it || '') };
  675. });
  676. }
  677. if (raw.result && typeof raw.result === 'object') {
  678. return Object.keys(raw.result).map((k) => ({
  679. n: raw.result[k],
  680. v: k,
  681. }));
  682. }
  683. if (Array.isArray(raw.objectList)) {
  684. return raw.objectList.map((it) => {
  685. if (it && typeof it === 'object') return it;
  686. return { n: String(it || ''), v: String(it || '') };
  687. });
  688. }
  689. return [];
  690. };
  691. const maybeAutoSelectFirst = (opts) => {
  692. if (!props.autoSelectFirst) return;
  693. if (!Array.isArray(opts) || opts.length === 0) return;
  694. if (selectedValue.value !== undefined && selectedValue.value !== null && selectedValue.value !== '') return;
  695. const first = opts[0];
  696. if (!first || typeof first !== 'object') return;
  697. const value = first[props.mapping.value];
  698. if (value === undefined || value === null || value === '') return;
  699. selectedValue.value = value;
  700. emit('update:modelValue', value);
  701. emit('change', value);
  702. };
  703. const needRemoteData = computed(() => !!String(props.cb || '').trim());
  704. const loadRemoteOptions = async () => {
  705. if (!needRemoteData.value) return;
  706. if (!window.request || typeof window.request.post !== 'function') return;
  707. remoteLoading.value = true;
  708. try {
  709. const objpParam = {
  710. input: String(props.inp === true || props.inp === 'true'),
  711. codebook: String(props.cb || ''),
  712. ...parseFilterObj(),
  713. };
  714. const postData = {
  715. objectpickerparam: JSON.stringify(objpParam),
  716. objectpickertype: 1,
  717. objectpickersearchAll: 1,
  718. };
  719. const resp = await window.request.post(
  720. props.url || '/service?ssServ=loadObjpOpt&objectpickerdropdown1=1',
  721. postData,
  722. { loading: false, formData: true }
  723. );
  724. const opts = normalizeResultToOptions(resp && resp.data ? resp.data : null);
  725. remoteOptions.value = opts;
  726. emit('loaded', opts);
  727. maybeAutoSelectFirst(opts);
  728. } catch (e) {
  729. remoteOptions.value = [];
  730. emit('loaded', []);
  731. console.error('[ss-select] loadRemoteOptions failed', props.cb, e);
  732. } finally {
  733. remoteLoading.value = false;
  734. }
  735. };
  736. // 计算属性
  737. const optionsList = computed(() => {
  738. if (Array.isArray(props.options) && props.options.length > 0) return props.options;
  739. if (needRemoteData.value) return remoteOptions.value;
  740. return [];
  741. });
  742. const finalLoading = computed(() => !!props.loading || remoteLoading.value);
  743. const displayText = computed(() => {
  744. if (!selectedValue.value) return props.placeholder;
  745. const selectedOption = optionsList.value.find(
  746. (option) =>
  747. option[props.mapping.value] === selectedValue.value
  748. );
  749. return selectedOption
  750. ? selectedOption[props.mapping.text]
  751. : props.placeholder;
  752. });
  753. // 监听 modelValue 变化
  754. watch(
  755. () => props.modelValue,
  756. (newValue) => {
  757. selectedValue.value = newValue;
  758. }
  759. );
  760. // 切换下拉框
  761. const toggleDropdown = () => {
  762. if (props.disabled) return;
  763. isOpen.value = !isOpen.value;
  764. };
  765. // 选择选项
  766. const selectOption = (option) => {
  767. const value = option[props.mapping.value];
  768. selectedValue.value = value;
  769. isOpen.value = false;
  770. emit('update:modelValue', value);
  771. emit('change', value);
  772. };
  773. // 点击外部关闭
  774. const handleClickOutside = (event) => {
  775. if (!event.target.closest('.ss-select-container')) {
  776. isOpen.value = false;
  777. }
  778. };
  779. onMounted(() => {
  780. document.addEventListener('click', handleClickOutside);
  781. loadRemoteOptions();
  782. if (!needRemoteData.value && Array.isArray(props.options)) {
  783. emit('loaded', props.options);
  784. }
  785. });
  786. watch(
  787. () => [props.cb, props.url, props.filter],
  788. () => {
  789. if (!needRemoteData.value) return;
  790. loadRemoteOptions();
  791. }
  792. );
  793. watch(
  794. () => props.options,
  795. (newVal) => {
  796. if (needRemoteData.value) return;
  797. emit('loaded', Array.isArray(newVal) ? newVal : []);
  798. }
  799. );
  800. return {
  801. isOpen,
  802. selectedValue,
  803. searchKeyword,
  804. optionsList,
  805. finalLoading,
  806. displayText,
  807. toggleDropdown,
  808. selectOption,
  809. };
  810. },
  811. template: `
  812. <div class="ss-select-container" :class="{ open: isOpen }" @click.stop="toggleDropdown">
  813. <!-- 显示区域 -->
  814. <div class="ss-select" :class="{ disabled: disabled }">
  815. <span class="select-text" :class="{ placeholder: !selectedValue }">{{ displayText }}</span>
  816. <div class="select-arrow" :class="{ rotate: isOpen }">
  817. <Icon name="icon-xiangxiajiantou" size="32" :color="disabled ? '#ccc' : '#999'"/>
  818. </div>
  819. </div>
  820. <!-- 选项列表 -->
  821. <div class="ss-options" v-show="isOpen">
  822. <!-- 加载状态 -->
  823. <div
  824. v-if="finalLoading"
  825. class="option-item loading-item"
  826. >
  827. <span class="loading-text">加载中...</span>
  828. </div>
  829. <!-- 无选项 -->
  830. <div
  831. v-else-if="optionsList.length === 0"
  832. class="option-item no-options"
  833. >
  834. 无选项
  835. </div>
  836. <!-- 选项列表 -->
  837. <div
  838. v-else
  839. v-for="(option, index) in optionsList"
  840. :key="index"
  841. class="option-item"
  842. :class="{ selected: option[mapping.value] === selectedValue }"
  843. @click.stop="selectOption(option)"
  844. >
  845. {{ option[mapping.text] }}
  846. </div>
  847. </div>
  848. </div>
  849. `,
  850. };
  851. // ss-bottom 底部按钮组件
  852. const SsBottom = {
  853. name: 'SsBottom',
  854. props: {
  855. // 是否显示审核意见
  856. showShyj: {
  857. type: Boolean,
  858. default: false,
  859. },
  860. // 审核意见标题
  861. shyjTitle: {
  862. type: String,
  863. default: '审核意见',
  864. },
  865. // 审核意见占位符
  866. shyjPlaceholder: {
  867. type: String,
  868. default: '请输入审核意见',
  869. },
  870. divider: {
  871. type: Boolean,
  872. default: true,
  873. },
  874. // 按钮配置
  875. buttons: {
  876. type: Array,
  877. default: () => [
  878. { text: '取消', action: 'cancel' },
  879. { text: '保存并提交', action: 'submit' },
  880. ],
  881. },
  882. },
  883. emits: ['button-click', 'update:shyjValue'],
  884. setup(props, { emit }) {
  885. const reason = ref('');
  886. const activeButtonIndex = ref(-1);
  887. // 处理按钮点击
  888. const handleButtonClick = (button, index) => {
  889. emit('button-click', {
  890. action: button.action,
  891. button: button,
  892. index: index,
  893. shyjValue: reason.value, // 传递审核意见
  894. });
  895. };
  896. // 监听审核意见变化
  897. const handleShyjInput = (event) => {
  898. const value = event.target.value;
  899. reason.value = value;
  900. emit('update:shyjValue', value);
  901. };
  902. // 处理按钮按下
  903. const handleButtonMouseDown = (index) => {
  904. activeButtonIndex.value = index;
  905. };
  906. // 处理按钮释放
  907. const handleButtonMouseUp = () => {
  908. activeButtonIndex.value = -1;
  909. };
  910. // 处理鼠标离开
  911. const handleMouseLeave = () => {
  912. activeButtonIndex.value = -1;
  913. };
  914. // 计算按钮样式
  915. const getButtonStyle = (button) => {
  916. const styles = {};
  917. // 如果有背景颜色配置
  918. if (button.backgroundColor) {
  919. styles.backgroundColor = button.backgroundColor;
  920. }
  921. // 如果有字体颜色配置
  922. if (button.color) {
  923. styles.color = button.color;
  924. }
  925. return styles;
  926. };
  927. // 计算按钮点击样式
  928. const getButtonActiveStyle = (button) => {
  929. const styles = {};
  930. // 如果有点击背景色配置,使用点击背景色
  931. if (button.clickBgColor) {
  932. styles.backgroundColor = button.clickBgColor;
  933. } else if (button.backgroundColor) {
  934. // 如果没有点击背景色,但有背景色,点击时使用背景色
  935. styles.backgroundColor = button.backgroundColor;
  936. }
  937. // 如果有点击字体色配置,使用点击字体色
  938. if (button.clickColor) {
  939. styles.color = button.clickColor;
  940. } else if (button.color) {
  941. // 如果没有点击字体色,但有字体色,点击时使用字体色
  942. styles.color = button.color;
  943. }
  944. return styles;
  945. };
  946. return {
  947. reason,
  948. activeButtonIndex,
  949. handleButtonClick,
  950. handleShyjInput,
  951. handleButtonMouseDown,
  952. handleButtonMouseUp,
  953. handleMouseLeave,
  954. getButtonStyle,
  955. getButtonActiveStyle,
  956. };
  957. },
  958. template: `
  959. <div class="ss-bottom">
  960. <!-- 审核意见区域 -->
  961. <div v-if="showShyj" class="ss-bottom__opinion">
  962. <table class="ss-bottom__opinion-table">
  963. <tr>
  964. <th class="ss-bottom__opinion-label">{{ shyjTitle }}</th>
  965. <td class="ss-bottom__opinion-input">
  966. <ss-input
  967. :placeholder="shyjPlaceholder"
  968. v-model="reason"
  969. @input="handleShyjInput"
  970. />
  971. </td>
  972. </tr>
  973. </table>
  974. </div>
  975. <!-- 按钮区域 -->
  976. <div class="ss-bottom__buttons" :class="{ 'ss-bottom__buttons--with-border': !showShyj }">
  977. <template v-for="(button, index) in buttons" :key="index">
  978. <div
  979. class="ss-bottom__button"
  980. :class="{
  981. 'ss-bottom__button--custom': button.backgroundColor || button.color || button.clickBgColor || button.clickColor,
  982. 'ss-bottom__button--active': activeButtonIndex === index
  983. }"
  984. :style="activeButtonIndex === index ? getButtonActiveStyle(button) : getButtonStyle(button)"
  985. @click="handleButtonClick(button, index)"
  986. @mousedown="handleButtonMouseDown(index)"
  987. @mouseup="handleButtonMouseUp"
  988. @mouseleave="handleMouseLeave"
  989. @touchstart="handleButtonMouseDown(index)"
  990. @touchend="handleButtonMouseUp"
  991. >
  992. {{ button.text }}
  993. </div>
  994. <!-- 分割线,最后一个按钮不显示 -->
  995. <div v-if="index < buttons.length - 1 && divider" class="ss-bottom__divider"></div>
  996. </template>
  997. </div>
  998. </div>
  999. `,
  1000. };
  1001. // ===== SsVerify 审核节点链组件 =====
  1002. const SsVerify = {
  1003. name: "SsVerify",
  1004. props: {
  1005. verifyList: {
  1006. type: Array,
  1007. required: true,
  1008. },
  1009. },
  1010. setup(props) {
  1011. const toggleOpen = (item) => {
  1012. item.open = !item.open;
  1013. // 切换后重新计算连线高度
  1014. setTimeout(() => {
  1015. calculateLineHeight();
  1016. }, 50);
  1017. };
  1018. // 计算连线高度的函数
  1019. const calculateLineHeight = () => {
  1020. const lastOpenGroup = document.querySelector(".group-item-last-open");
  1021. console.log("lastOpenGroup", lastOpenGroup);
  1022. if (lastOpenGroup) {
  1023. // 使用原生JavaScript代替jQuery
  1024. const nodes = lastOpenGroup.querySelectorAll(".verify-node-container");
  1025. if (nodes.length) {
  1026. let totalHeight = 0;
  1027. if (nodes.length === 1) {
  1028. // 只有一个节点时,连线伸到节点的中间位置
  1029. const nodeHeight = nodes[0].offsetHeight;
  1030. const nodeTop = nodes[0].offsetTop;
  1031. totalHeight = nodeTop + (nodeHeight / 2) - 15; // 减去圆点半径5px
  1032. } else {
  1033. // 多个节点时,连线延伸到最后一个节点的中间位置
  1034. const lastNode = nodes[nodes.length - 1];
  1035. const lastNodeTop = lastNode.offsetTop;
  1036. const lastNodeHeight = lastNode.offsetHeight;
  1037. totalHeight = lastNodeTop + (lastNodeHeight / 2) - 15; // 减去圆点半径5px
  1038. }
  1039. console.log("节点信息:", {
  1040. 节点总数: nodes.length,
  1041. 计算后的高度: totalHeight,
  1042. 最后节点top: nodes[nodes.length - 1]?.offsetTop,
  1043. 最后节点高度: nodes[nodes.length - 1]?.offsetHeight,
  1044. });
  1045. lastOpenGroup.style.setProperty(
  1046. "--group-line-height",
  1047. `${totalHeight}px`
  1048. );
  1049. }
  1050. }
  1051. };
  1052. onMounted(() => {
  1053. setTimeout(() => {
  1054. calculateLineHeight();
  1055. }, 100);
  1056. });
  1057. return {
  1058. toggleOpen,
  1059. };
  1060. },
  1061. render() {
  1062. const { h } = Vue;
  1063. return h(
  1064. "div",
  1065. { class: "verify-nodes" },
  1066. this.verifyList.map((item, i) =>
  1067. h(
  1068. "div",
  1069. {
  1070. key: i,
  1071. class: {
  1072. "group-item": true,
  1073. "group-item-last-open":
  1074. i === this.verifyList.length - 1 && item.open,
  1075. },
  1076. },
  1077. [
  1078. h(
  1079. "div",
  1080. {
  1081. class: "group-item-title",
  1082. onClick: () => this.toggleOpen(item),
  1083. },
  1084. [
  1085. h("div", { class: "icon" }, [
  1086. h("i", {
  1087. class: item.open ? "common-icon-folder-open common-icon" : "common-icon-folder-close common-icon"
  1088. }),
  1089. h(
  1090. "div",
  1091. {
  1092. class: "num",
  1093. style: { top: item.open ? "60%" : "55%" },
  1094. },
  1095. item.children?.length || 0
  1096. ),
  1097. ]),
  1098. h("div", { class: "name" }, item.groupName),
  1099. ]
  1100. ),
  1101. item.open && item.children?.length > 0
  1102. ? h(
  1103. "div",
  1104. { class: "group-item-children" },
  1105. item.children.map((citem, j) =>
  1106. h(SsVerifyNode, {
  1107. key: j,
  1108. item: citem,
  1109. // isGroup: i + 1 !== this.verifyList.length,
  1110. isGroup: true,
  1111. })
  1112. )
  1113. )
  1114. : null,
  1115. ]
  1116. )
  1117. )
  1118. );
  1119. },
  1120. };
  1121. // ===== SsVerifyNode 审核节点组件 =====
  1122. const SsVerifyNode = {
  1123. name: "SsVerifyNode",
  1124. props: {
  1125. item: {
  1126. type: Object,
  1127. required: true,
  1128. },
  1129. isGroup: {
  1130. type: Boolean,
  1131. default: false,
  1132. },
  1133. },
  1134. render() {
  1135. const { h } = Vue;
  1136. return h("div", { class: "verify-node-container" }, [
  1137. h("div", { class: "info" }, [
  1138. h("div", { class: "avatar" }, [
  1139. h("img", {
  1140. src: this.item.thumb,
  1141. style: {
  1142. width: "50px",
  1143. height: "50px",
  1144. borderRadius: "50%",
  1145. },
  1146. }),
  1147. ]),
  1148. h("div", { class: "desc" }, [
  1149. h("div", this.item.name),
  1150. h("div", this.item.role),
  1151. ]),
  1152. h("div", { class: "link" }, [
  1153. h("div", [
  1154. this.item.video
  1155. ? h("i", { class: "common-icon-video common-icon" })
  1156. : null,
  1157. this.item.link
  1158. ? h("i", { class: "common-icon-paper-clip common-icon" })
  1159. : null,
  1160. ]),
  1161. ]),
  1162. ]),
  1163. h(
  1164. "div",
  1165. {
  1166. class: {
  1167. description: true,
  1168. link: this.isGroup,
  1169. },
  1170. attrs: { "data-num": "3" },
  1171. },
  1172. [h("div", this.item.description)]
  1173. ),
  1174. h("div", { class: "time" }, this.item.time),
  1175. ]);
  1176. },
  1177. };
  1178. // ===== SsOnoffButton 开关按钮 =====
  1179. const SsOnoffButton = {
  1180. name: 'SsOnoffButton',
  1181. props: {
  1182. // 字段名称,用于表单校验
  1183. name: { type: String, required: true },
  1184. // 显示标签
  1185. label: { type: String, required: true },
  1186. // 按钮的值
  1187. value: { type: [String, Number], required: true },
  1188. // 宽度设置
  1189. width: { type: String, default: '' },
  1190. // v-model 绑定的值
  1191. modelValue: { type: [String, Number, Array], default: '' },
  1192. // 是否多选模式
  1193. multiple: { type: Boolean, default: false },
  1194. // 是否禁用
  1195. disabled: { type: Boolean, default: false }
  1196. },
  1197. emits: ['update:modelValue', 'change'],
  1198. setup(props, { emit }) {
  1199. // 解析 modelValue,支持逗号分隔的字符串和数组
  1200. const parseModelValue = (val) => {
  1201. if (!val) return [];
  1202. // 如果是数组,直接返回字符串数组
  1203. if (Array.isArray(val)) {
  1204. return val.map(v => v.toString());
  1205. }
  1206. // 如果是字符串,按逗号分割
  1207. const cleanValue = val.toString().replace(/^,+/, ''); // 去掉开头的逗号
  1208. if (cleanValue.includes('|')) {
  1209. return cleanValue.split('|');
  1210. }
  1211. if (cleanValue.includes(',')) {
  1212. return cleanValue.split(',');
  1213. }
  1214. return cleanValue ? [cleanValue] : [];
  1215. };
  1216. // 判断当前按钮是否选中
  1217. const isChecked = computed(() => {
  1218. if (props.multiple) {
  1219. const currentValue = parseModelValue(props.modelValue);
  1220. return currentValue.includes(props.value.toString());
  1221. }
  1222. return props.modelValue === props.value;
  1223. });
  1224. // 切换选中状态
  1225. const toggleSelect = () => {
  1226. // 如果禁用,不执行任何操作
  1227. if (props.disabled) return;
  1228. if (props.multiple) {
  1229. // 多选模式
  1230. const currentValue = parseModelValue(props.modelValue);
  1231. const index = currentValue.indexOf(props.value.toString());
  1232. let newValue;
  1233. if (index === -1) {
  1234. // 添加选项
  1235. newValue = [...currentValue, props.value.toString()];
  1236. } else {
  1237. // 移除选项
  1238. newValue = currentValue.filter(v => v !== props.value.toString());
  1239. }
  1240. // 发送更新事件,使用逗号分隔的字符串格式
  1241. const emitValue = newValue.join(',');
  1242. emit('update:modelValue', emitValue);
  1243. emit('change', emitValue, newValue);
  1244. } else {
  1245. // 单选模式
  1246. emit('update:modelValue', props.value);
  1247. emit('change', props.value);
  1248. }
  1249. };
  1250. return { isChecked, toggleSelect };
  1251. },
  1252. template: `
  1253. <div class="ss-onoff-button" :class="{ checked: isChecked, disabled: disabled }" @click="toggleSelect">
  1254. <span class="button-label">{{ label }}</span>
  1255. <div class="button-mark">
  1256. <span class="form-icon" :class="isChecked ? 'form-icon-onoffbutton-checked' : 'form-icon-onoffbutton-unchecked'"></span>
  1257. </div>
  1258. </div>
  1259. `,
  1260. };
  1261. // ===== SsDatetimePicker 日期时间选择(使用 Vant 4) =====
  1262. const SsDatetimePicker = {
  1263. name: 'SsDatetimePicker',
  1264. props: {
  1265. mode: { type: String, default: 'date' }, // date | time | datetime
  1266. placeholder: { type: String, default: '请选择日期' },
  1267. modelValue: { type: String, default: '' },
  1268. minDate: { type: String, default: '' },
  1269. maxDate: { type: String, default: '' },
  1270. // 字段名称 - 用于ssVm校验
  1271. name: { type: String, default: '' },
  1272. // 是否禁用
  1273. disabled: { type: Boolean, default: false },
  1274. },
  1275. emits: ['update:modelValue', 'change', 'confirm', 'cancel', 'open', 'close'],
  1276. setup(props, { emit }) {
  1277. const showPicker = ref(false);
  1278. const showTimePicker = ref(false);
  1279. const currentStep = ref('date'); // 'date' | 'time'
  1280. // 功能说明:统一在组件层处理 iframe 场景底部按钮显隐,避免每个页面重复绑定 by xu 2026-02-28
  1281. const notifyParentBottomVisible = (visible) => {
  1282. try {
  1283. const fn = window.parent && window.parent.__mpObjInpSetBottomVisible;
  1284. if (typeof fn === 'function') fn(visible !== false);
  1285. } catch (_) {}
  1286. };
  1287. // 功能说明:监听弹层显隐,点遮罩/取消关闭时也恢复父层底部按钮 by xu 2026-03-01
  1288. watch([showPicker, showTimePicker], ([dateOpen, timeOpen], [prevDateOpen, prevTimeOpen]) => {
  1289. const hasOpen = !!(dateOpen || timeOpen);
  1290. const hadOpen = !!(prevDateOpen || prevTimeOpen);
  1291. if (!hadOpen && hasOpen) {
  1292. emit('open');
  1293. notifyParentBottomVisible(false);
  1294. return;
  1295. }
  1296. if (hadOpen && !hasOpen) {
  1297. emit('close');
  1298. notifyParentBottomVisible(true);
  1299. }
  1300. });
  1301. // Vant DatePicker 需要数组格式 [year, month, day]
  1302. const currentDateArray = ref([]);
  1303. const currentTimeArray = ref(['12', '00']); // [hour, minute]
  1304. const tempDateStr = ref(''); // 临时存储选择的日期
  1305. // 格式化显示文本
  1306. const displayText = computed(() => {
  1307. if (!props.modelValue) return props.placeholder;
  1308. try {
  1309. const d = new Date(props.modelValue);
  1310. if (isNaN(d.getTime())) return props.modelValue;
  1311. const year = d.getFullYear();
  1312. const month = String(d.getMonth() + 1).padStart(2, '0');
  1313. const day = String(d.getDate()).padStart(2, '0');
  1314. const hours = String(d.getHours()).padStart(2, '0');
  1315. const minutes = String(d.getMinutes()).padStart(2, '0');
  1316. if (props.mode === 'time') {
  1317. return `${hours}:${minutes}`;
  1318. } else if (props.mode === 'datetime') {
  1319. return `${year}-${month}-${day} ${hours}:${minutes}`;
  1320. }
  1321. return `${year}-${month}-${day}`;
  1322. } catch (e) { return props.modelValue; }
  1323. });
  1324. // 监听 modelValue 变化,转换为数组格式
  1325. watch(() => props.modelValue, (newVal) => {
  1326. console.log('📅 modelValue 变化:', newVal);
  1327. if (newVal) {
  1328. try {
  1329. const d = new Date(newVal);
  1330. if (!isNaN(d.getTime())) {
  1331. currentDateArray.value = [d.getFullYear(), d.getMonth() + 1, d.getDate()];
  1332. console.log('📅 转换为数组:', currentDateArray.value);
  1333. }
  1334. } catch (e) {
  1335. console.warn('Invalid date:', newVal);
  1336. }
  1337. } else {
  1338. const today = new Date();
  1339. currentDateArray.value = [today.getFullYear(), today.getMonth() + 1, today.getDate()];
  1340. }
  1341. }, { immediate: true });
  1342. // 确认选择 - 根据模式处理不同的数据
  1343. const onConfirm = (value) => {
  1344. console.log('📅 Vant Picker confirm 原始值:', value, 'mode:', props.mode);
  1345. try {
  1346. // Vant 返回的是对象,包含 selectedValues 数组
  1347. const selectedValues = value.selectedValues || value;
  1348. console.log('📅 selectedValues:', selectedValues);
  1349. if (props.mode === 'time') {
  1350. // 时间模式:处理时分
  1351. if (Array.isArray(selectedValues) && selectedValues.length >= 2) {
  1352. const [hour, minute] = selectedValues;
  1353. const timeStr = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
  1354. console.log('🕐 转换后的时间字符串:', timeStr);
  1355. emit('update:modelValue', timeStr);
  1356. emit('change', timeStr);
  1357. emit('confirm', timeStr);
  1358. emit('close');
  1359. notifyParentBottomVisible(true);
  1360. showPicker.value = false;
  1361. }
  1362. } else if (Array.isArray(selectedValues) && selectedValues.length >= 3) {
  1363. // 日期模式:处理年月日
  1364. const [year, month, day] = selectedValues;
  1365. const dateStr = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
  1366. if (props.mode === 'datetime') {
  1367. // datetime 模式:先存储日期,然后打开时间选择器
  1368. tempDateStr.value = dateStr;
  1369. showPicker.value = false;
  1370. showTimePicker.value = true;
  1371. currentStep.value = 'time';
  1372. } else {
  1373. // date 模式:直接完成
  1374. emit('update:modelValue', dateStr);
  1375. emit('change', dateStr);
  1376. emit('confirm', dateStr);
  1377. emit('close');
  1378. notifyParentBottomVisible(true);
  1379. showPicker.value = false;
  1380. }
  1381. }
  1382. } catch (e) {
  1383. console.error('Picker conversion error:', e);
  1384. }
  1385. };
  1386. // 时间选择确认
  1387. const onTimeConfirm = (value) => {
  1388. try {
  1389. const selectedValues = value.selectedValues || value;
  1390. if (Array.isArray(selectedValues) && selectedValues.length >= 2) {
  1391. const [hour, minute] = selectedValues;
  1392. const datetimeStr = `${tempDateStr.value} ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
  1393. emit('update:modelValue', datetimeStr);
  1394. emit('change', datetimeStr);
  1395. emit('confirm', datetimeStr);
  1396. emit('close');
  1397. notifyParentBottomVisible(true);
  1398. }
  1399. } catch (e) {
  1400. console.error('Time conversion error:', e);
  1401. }
  1402. showTimePicker.value = false;
  1403. currentStep.value = 'date';
  1404. };
  1405. // 取消选择
  1406. const onCancel = () => {
  1407. emit('cancel');
  1408. showPicker.value = false;
  1409. };
  1410. const onTimeCancel = () => {
  1411. emit('cancel');
  1412. showTimePicker.value = false;
  1413. };
  1414. // 打开选择器
  1415. const openPicker = () => {
  1416. // 如果禁用,不打开选择器
  1417. if (props.disabled) return;
  1418. showPicker.value = true;
  1419. };
  1420. // 计算最小最大日期
  1421. const minDateObj = computed(() => {
  1422. return props.minDate ? new Date(props.minDate) : undefined;
  1423. });
  1424. const maxDateObj = computed(() => {
  1425. return props.maxDate ? new Date(props.maxDate) : undefined;
  1426. });
  1427. return {
  1428. showPicker,
  1429. showTimePicker,
  1430. currentDateArray,
  1431. currentTimeArray,
  1432. displayText,
  1433. openPicker,
  1434. onConfirm,
  1435. onTimeConfirm,
  1436. onTimeCancel,
  1437. onCancel,
  1438. minDateObj,
  1439. maxDateObj
  1440. };
  1441. },
  1442. template: `
  1443. <div class="ss-datetime-picker ss-mobile-component" :class="{ disabled: disabled }">
  1444. <!-- 隐藏的input用于ssVm校验 -->
  1445. <input type="hidden" :name="name" :value="modelValue" />
  1446. <div class="datetime-picker-display" @click="openPicker">
  1447. <span class="datetime-picker-value" :class="{ placeholder: !modelValue }">{{ displayText }}</span>
  1448. </div>
  1449. <!-- 日期选择器 -->
  1450. <van-popup v-model:show="showPicker" position="bottom" :style="{ zIndex: 10000 }">
  1451. <van-date-picker
  1452. v-if="mode === 'date' || mode === 'datetime'"
  1453. v-model="currentDateArray"
  1454. :min-date="minDateObj"
  1455. :max-date="maxDateObj"
  1456. @confirm="onConfirm"
  1457. @cancel="onCancel"
  1458. title="选择日期"
  1459. />
  1460. <van-time-picker
  1461. v-if="mode === 'time'"
  1462. v-model="currentTimeArray"
  1463. @confirm="onConfirm"
  1464. @cancel="onCancel"
  1465. title="选择时间"
  1466. />
  1467. </van-popup>
  1468. <!-- 时间选择器(datetime 模式的第二步) -->
  1469. <van-popup v-model:show="showTimePicker" position="bottom" :style="{ zIndex: 10000 }">
  1470. <van-time-picker
  1471. v-model="currentTimeArray"
  1472. @confirm="onTimeConfirm"
  1473. @cancel="onTimeCancel"
  1474. title="选择时间"
  1475. />
  1476. </van-popup>
  1477. </div>
  1478. `,
  1479. };
  1480. const SsConfirm = {
  1481. name: 'SsConfirm',
  1482. props: {
  1483. modelValue: { type: Boolean, default: false },
  1484. title: { type: String, default: '确认' },
  1485. content: { type: String, default: '' },
  1486. maskClosable: { type: Boolean, default: true },
  1487. },
  1488. emits: ['update:modelValue', 'confirm', 'cancel', 'close'],
  1489. setup(props, { emit }) {
  1490. // 监听弹窗显示状态,控制 body 滚动
  1491. watch(() => props.modelValue, (newVal) => {
  1492. console.log('🔔 SsConfirm modelValue 变化:', newVal);
  1493. if (newVal) {
  1494. document.body.classList.add('modal-open');
  1495. } else {
  1496. document.body.classList.remove('modal-open');
  1497. }
  1498. }, { immediate: true }); // 添加 immediate 选项
  1499. const close = () => {
  1500. console.log('🚪 关闭确认弹窗');
  1501. emit('update:modelValue', false);
  1502. emit('close');
  1503. };
  1504. const onMask = () => {
  1505. console.log('👆 点击了遮罩层');
  1506. if (props.maskClosable) close();
  1507. };
  1508. const onCancel = () => {
  1509. console.log('❌ 点击了取消按钮');
  1510. emit('cancel');
  1511. close();
  1512. };
  1513. const onConfirm = () => {
  1514. console.log('✅ 点击了确认按钮,触发 confirm 事件');
  1515. emit('confirm');
  1516. close();
  1517. };
  1518. return { onMask, onCancel, onConfirm };
  1519. },
  1520. template: `
  1521. <div v-if="modelValue" class="ss-confirm">
  1522. <div class="confirm-mask" @click="onMask"></div>
  1523. <div class="confirm-content">
  1524. <div class="confirm-header" v-if="title">
  1525. <div class="header-title">{{ title }}</div>
  1526. </div>
  1527. <div class="header-line" v-if="title"></div>
  1528. <div class="confirm-body">
  1529. <div v-if="content" class="confirm-content-text" v-html="content"></div>
  1530. <div class="confirm-slot-content"><slot /></div>
  1531. </div>
  1532. <div class="confirm-bottom">
  1533. <button class="confirm-btn confirm-btn-cancel" @click.stop="onCancel">取消</button>
  1534. <button class="confirm-btn confirm-btn-confirm" @click.stop="onConfirm">确认</button>
  1535. </div>
  1536. </div>
  1537. </div>
  1538. `,
  1539. };
  1540. // ===== SsImageCropper 纯裁剪组件 =====
  1541. const SsImageCropper = {
  1542. name: 'SsImageCropper',
  1543. props: {
  1544. // 是否显示裁剪器
  1545. show: { type: Boolean, default: false },
  1546. // 图片源(base64 或 URL)
  1547. src: { type: String, required: true },
  1548. // 图片形状:circle圆形 | square方形
  1549. shape: { type: String, required: true },
  1550. // 裁剪比例(宽/高)
  1551. aspectRatio: { type: Number, default: 1 },
  1552. // 输出图片宽度
  1553. outputWidth: { type: Number, default: 300 },
  1554. // 输出图片高度
  1555. outputHeight: { type: Number, default: 300 },
  1556. },
  1557. emits: ['update:show', 'confirm', 'cancel'],
  1558. setup(props, { emit }) {
  1559. const cropperInstance = ref(null);
  1560. // 监听 show 变化,初始化或销毁 Cropper
  1561. watch(() => props.show, (newVal) => {
  1562. if (newVal) {
  1563. // 等待 DOM 更新后初始化
  1564. Vue.nextTick(() => {
  1565. initCropper();
  1566. });
  1567. } else {
  1568. destroyCropper();
  1569. }
  1570. });
  1571. // 监听 src 变化,重新初始化 Cropper
  1572. watch(() => props.src, () => {
  1573. if (props.show) {
  1574. Vue.nextTick(() => {
  1575. initCropper();
  1576. });
  1577. }
  1578. });
  1579. // 初始化 Cropper
  1580. const initCropper = () => {
  1581. const imageElement = document.getElementById('ss-image-cropper-img');
  1582. if (!imageElement || !window.Cropper) return;
  1583. // 销毁旧实例
  1584. destroyCropper();
  1585. // 根据 shape 属性添加类名
  1586. const container = document.querySelector('.ss-image-cropper-container');
  1587. if (container) {
  1588. if (props.shape === 'circle') {
  1589. container.classList.add('crop-shape-circle');
  1590. container.classList.remove('crop-shape-square');
  1591. } else {
  1592. container.classList.add('crop-shape-square');
  1593. container.classList.remove('crop-shape-circle');
  1594. }
  1595. }
  1596. cropperInstance.value = new window.Cropper(imageElement, {
  1597. aspectRatio: props.aspectRatio,
  1598. viewMode: 1,
  1599. dragMode: 'move',
  1600. autoCropArea: 0.8,
  1601. restore: false,
  1602. guides: false, // 关闭辅助线
  1603. center: false, // 关闭中心指示器
  1604. highlight: false,
  1605. cropBoxMovable: true,
  1606. cropBoxResizable: true,
  1607. toggleDragModeOnDblclick: false,
  1608. minContainerWidth: window.innerWidth,
  1609. minContainerHeight: window.innerHeight - 50,
  1610. });
  1611. };
  1612. // 销毁 Cropper
  1613. const destroyCropper = () => {
  1614. if (cropperInstance.value) {
  1615. cropperInstance.value.destroy();
  1616. cropperInstance.value = null;
  1617. }
  1618. };
  1619. // 取消裁剪
  1620. const handleCancel = () => {
  1621. emit('update:show', false);
  1622. emit('cancel');
  1623. };
  1624. // 确认裁剪
  1625. const handleConfirm = () => {
  1626. if (!cropperInstance.value) return;
  1627. const canvas = cropperInstance.value.getCroppedCanvas({
  1628. width: props.outputWidth,
  1629. height: props.outputHeight,
  1630. imageSmoothingEnabled: true,
  1631. imageSmoothingQuality: 'high',
  1632. fillColor: '#fff'
  1633. });
  1634. canvas.toBlob((blob) => {
  1635. emit('update:show', false);
  1636. emit('confirm', blob);
  1637. }, 'image/jpeg', 0.9);
  1638. };
  1639. // 处理底部按钮事件
  1640. const handleCropAction = (data) => {
  1641. if (data.action === 'cancel') {
  1642. handleCancel();
  1643. } else if (data.action === 'confirm') {
  1644. handleConfirm();
  1645. }
  1646. };
  1647. return {
  1648. handleCropAction,
  1649. };
  1650. },
  1651. template: `
  1652. <div v-if="show" class="ss-image-cropper-container">
  1653. <!-- 左上角尺寸显示 -->
  1654. <div class="crop-size-display">
  1655. 长: {{ outputWidth }}px<br>宽: {{ outputHeight }}px
  1656. </div>
  1657. <div class="ss-crop-image-container">
  1658. <img id="ss-image-cropper-img" :src="src" />
  1659. </div>
  1660. <!-- 使用 ss-bottom 组件 -->
  1661. <ss-bottom
  1662. :show-shyj="false"
  1663. :buttons="[
  1664. { text: '取消', action: 'cancel' },
  1665. { text: '保存并提交', action: 'confirm' }
  1666. ]"
  1667. @button-click="handleCropAction"
  1668. />
  1669. </div>
  1670. `,
  1671. };
  1672. // ===== SsUploadImage 图片上传裁剪组件(支持单图/多图) =====
  1673. const SsUploadImage = {
  1674. name: 'SsUploadImage',
  1675. props: {
  1676. // v-model 绑定的值(单图:String,多图:Array)
  1677. modelValue: { type: [String, Array], default: '' },
  1678. // 最大上传数量(默认1张,多图时设置大于1)
  1679. max: { type: Number, default: 1 },
  1680. // 是否禁用
  1681. disabled: { type: Boolean, default: false },
  1682. // 图片宽度(像素) - 必填
  1683. width: { type: [Number, String], required: true },
  1684. // 图片高度(像素) - 必填
  1685. height: { type: [Number, String], required: true },
  1686. // 图片形状:circle圆形 | square方形 - 必填
  1687. shape: { type: String, required: true },
  1688. // 裁剪比例(宽/高)
  1689. aspectRatio: { type: Number, default: undefined },
  1690. // 输出图片宽度
  1691. outputWidth: { type: Number, default: 300 },
  1692. // 输出图片高度
  1693. outputHeight: { type: Number, default: 300 },
  1694. },
  1695. emits: ['update:modelValue', 'updated'],
  1696. setup(props, { emit }) {
  1697. const showCropper = ref(false);
  1698. const tempImageSrc = ref('');
  1699. // 图片列表(统一用数组管理)
  1700. const imageList = ref([]);
  1701. // 监听 modelValue 变化,同步到 imageList
  1702. watch(() => props.modelValue, (newVal) => {
  1703. if (props.max === 1) {
  1704. // 单图模式
  1705. imageList.value = newVal ? [newVal] : [];
  1706. } else {
  1707. // 多图模式
  1708. imageList.value = Array.isArray(newVal) ? [...newVal] : (newVal ? [newVal] : []);
  1709. }
  1710. }, { immediate: true });
  1711. // 是否可以继续添加图片
  1712. const canAddMore = computed(() => {
  1713. return imageList.value.length < props.max;
  1714. });
  1715. // 容器样式
  1716. const itemStyle = computed(() => ({
  1717. width: typeof props.width === 'number' ? `${props.width}px` : props.width,
  1718. height: typeof props.height === 'number' ? `${props.height}px` : props.height,
  1719. borderRadius: props.shape === 'circle' ? '50%' : '8px',
  1720. }));
  1721. // 获取图片 URL(用于显示)
  1722. const getImageUrl = (path) => {
  1723. if (!path) return '/static/images/yishuzhao_nv.svg';
  1724. if (path.startsWith('http') || path.startsWith('blob:')) {
  1725. return path;
  1726. }
  1727. return window.SS.utils?.getImageUrl?.(path) || path;
  1728. };
  1729. // 选择图片
  1730. const selectImage = () => {
  1731. if (props.disabled || !canAddMore.value) return;
  1732. const input = document.createElement('input');
  1733. input.type = 'file';
  1734. input.accept = 'image/*';
  1735. input.onchange = (e) => {
  1736. const file = e.target.files[0];
  1737. if (file) {
  1738. const reader = new FileReader();
  1739. reader.onload = (event) => {
  1740. tempImageSrc.value = event.target.result;
  1741. showCropper.value = true;
  1742. };
  1743. reader.readAsDataURL(file);
  1744. }
  1745. };
  1746. input.click();
  1747. };
  1748. // 删除图片
  1749. const deleteImage = (index) => {
  1750. const newList = imageList.value.filter((_, i) => i !== index);
  1751. updateModelValue(newList);
  1752. };
  1753. // 确认裁剪
  1754. const handleCropConfirm = async (blob) => {
  1755. try {
  1756. const serverPath = await uploadFile(blob, 'image', 'image.jpg');
  1757. const newList = [...imageList.value, serverPath];
  1758. updateModelValue(newList);
  1759. emit('updated', serverPath);
  1760. } catch (error) {
  1761. console.error('上传失败:', error);
  1762. }
  1763. };
  1764. // 取消裁剪
  1765. const handleCropCancel = () => {
  1766. tempImageSrc.value = '';
  1767. };
  1768. // 更新 modelValue
  1769. const updateModelValue = (list) => {
  1770. if (props.max === 1) {
  1771. // 单图模式:返回 String
  1772. emit('update:modelValue', list[0] || '');
  1773. } else {
  1774. // 多图模式:返回 Array
  1775. emit('update:modelValue', list);
  1776. }
  1777. };
  1778. return {
  1779. imageList,
  1780. canAddMore,
  1781. itemStyle,
  1782. showCropper,
  1783. tempImageSrc,
  1784. getImageUrl,
  1785. selectImage,
  1786. deleteImage,
  1787. handleCropConfirm,
  1788. handleCropCancel,
  1789. };
  1790. },
  1791. template: `
  1792. <div class="ss-upload-image-multi">
  1793. <!-- 图片列表 -->
  1794. <div class="image-list">
  1795. <!-- 已上传的图片 -->
  1796. <div
  1797. v-for="(img, index) in imageList"
  1798. :key="index"
  1799. class="image-item"
  1800. :style="itemStyle"
  1801. >
  1802. <img :src="getImageUrl(img)" class="image-display" />
  1803. <!-- 删除按钮 -->
  1804. <div v-if="!disabled" class="image-delete" @click.stop="deleteImage(index)">
  1805. <Icon name="icon-guanbi" size="32" color="#fff" />
  1806. </div>
  1807. </div>
  1808. <!-- 添加按钮 -->
  1809. <div
  1810. v-if="canAddMore && !disabled"
  1811. class="image-item image-add"
  1812. :style="itemStyle"
  1813. @click="selectImage"
  1814. >
  1815. <Icon name="icon-xiangji" size="48" color="#ccc" />
  1816. <div class="add-text">{{ imageList.length }}/{{ max }}</div>
  1817. </div>
  1818. </div>
  1819. <!-- 裁剪组件 -->
  1820. <ss-image-cropper
  1821. v-model:show="showCropper"
  1822. :src="tempImageSrc"
  1823. :shape="shape"
  1824. :aspect-ratio="aspectRatio"
  1825. :output-width="outputWidth"
  1826. :output-height="outputHeight"
  1827. @confirm="handleCropConfirm"
  1828. @cancel="handleCropCancel"
  1829. />
  1830. </div>
  1831. `,
  1832. };
  1833. // ===== SsCarCard 车辆卡片组件 =====
  1834. const SsCarCard = {
  1835. name: 'SsCarCard',
  1836. props: {
  1837. // 车辆数据
  1838. carData: {
  1839. type: Object,
  1840. default: () => ({})
  1841. },
  1842. // 车辆状态:'available' | 'reserved' | 'disabled'
  1843. status: {
  1844. type: String,
  1845. default: 'available',
  1846. validator: (value) => ['available', 'reserved', 'disabled'].includes(value)
  1847. }
  1848. },
  1849. emits: ['click', 'select'],
  1850. setup(props, { emit }) {
  1851. // 计算状态样式类
  1852. const statusClass = computed(() => {
  1853. return `status-${props.status}`;
  1854. });
  1855. // 获取图片URL
  1856. const getImageUrl = (path) => {
  1857. if (!path) return '/static/images/default-car.png';
  1858. if (path.startsWith('http') || path.startsWith('blob:')) {
  1859. return path;
  1860. }
  1861. return window.SS.utils?.getImageUrl?.(path) || path;
  1862. };
  1863. // 处理卡片点击
  1864. const handleCardClick = () => {
  1865. if (props.status === 'disabled') {
  1866. return; // 禁用状态不响应点击
  1867. }
  1868. emit('click', props.carData);
  1869. emit('select', props.carData);
  1870. };
  1871. return {
  1872. statusClass,
  1873. getImageUrl,
  1874. handleCardClick,
  1875. };
  1876. },
  1877. template: `
  1878. <div class="car-card" :class="statusClass" @click="handleCardClick">
  1879. <!-- 第一行:车辆名称 -->
  1880. <div class="car-title">
  1881. {{ carData.name || '别克GL8' }}
  1882. </div>
  1883. <!-- 第二行:左右结构 -->
  1884. <div class="car-info">
  1885. <!-- 左边:车辆图片 -->
  1886. <div class="car-image-container">
  1887. <img class="car-image" :src="getImageUrl(carData.image)" />
  1888. </div>
  1889. <!-- 右边:车辆信息 -->
  1890. <div class="car-details">
  1891. <div class="detail-item car-name" v-if="carData.wph">
  1892. {{ carData.wph }}
  1893. </div>
  1894. <div class="detail-item seats" v-for="(wp, index) in carData.wpcsList" :key="index">
  1895. {{ wp.mc }} : {{ wp.sz || wp.zf }}
  1896. </div>
  1897. <div class="detail-item car-type">
  1898. {{ carData.type || '商务车' }}
  1899. </div>
  1900. </div>
  1901. </div>
  1902. </div>
  1903. `,
  1904. };
  1905. // ===== SsSubTab 移动端Tab组件 =====
  1906. const SsSubTab = {
  1907. name: 'SsSubTab',
  1908. props: {
  1909. // Tab列表数据
  1910. tabList: {
  1911. type: Array,
  1912. required: true,
  1913. },
  1914. // 当前激活的Tab索引
  1915. activeIndex: {
  1916. type: Number,
  1917. default: 0,
  1918. },
  1919. // 基础URL参数(会传递给每个iframe)
  1920. baseParams: {
  1921. type: Object,
  1922. default: () => ({}),
  1923. },
  1924. },
  1925. emits: ['tab-change'],
  1926. setup(props, { emit }) {
  1927. const currentTab = ref(props.activeIndex);
  1928. const currentTabUrl = ref('');
  1929. // 加载Tab对应的URL
  1930. const loadTabUrl = (index) => {
  1931. const tab = props.tabList[index];
  1932. if (!tab || !tab.dest) return;
  1933. // 构建iframe URL:mp_ + dest + .html
  1934. const fileName = `mp_${tab.dest}.html`;
  1935. // 构建完整URL,包含所有参数
  1936. const params = new URLSearchParams({
  1937. ...props.baseParams,
  1938. service: tab.service || '',
  1939. param: tab.param || '',
  1940. });
  1941. currentTabUrl.value = `/page/${fileName}?${params.toString()}`;
  1942. console.log('🔄 切换到Tab:', tab.desc || tab.title, '加载页面:', currentTabUrl.value);
  1943. };
  1944. // 监听 activeIndex 变化
  1945. watch(() => props.activeIndex, (newIndex) => {
  1946. currentTab.value = newIndex;
  1947. loadTabUrl(newIndex);
  1948. }, { immediate: true });
  1949. // 监听 tabList 变化
  1950. watch(() => props.tabList, () => {
  1951. if (props.tabList.length > 0 && currentTab.value === 0) {
  1952. loadTabUrl(0);
  1953. }
  1954. }, { immediate: true });
  1955. // 切换Tab
  1956. const handleTabClick = (index) => {
  1957. if (currentTab.value === index) return;
  1958. currentTab.value = index;
  1959. loadTabUrl(index);
  1960. emit('tab-change', { index, tab: props.tabList[index] });
  1961. };
  1962. return {
  1963. currentTab,
  1964. currentTabUrl,
  1965. handleTabClick,
  1966. };
  1967. },
  1968. template: `
  1969. <div class="ss-sub-tab">
  1970. <!-- Tab 栏 -->
  1971. <div class="ss-sub-tab__bar" v-if="tabList.length > 0">
  1972. <div
  1973. v-for="(tab, index) in tabList"
  1974. :key="index"
  1975. class="ss-sub-tab__item"
  1976. :class="{ 'ss-sub-tab__item--active': currentTab === index }"
  1977. @click="handleTabClick(index)"
  1978. >
  1979. {{ tab.desc || tab.title }}
  1980. </div>
  1981. </div>
  1982. <!-- 内容区域 -->
  1983. <div class="ss-sub-tab__content" v-if="currentTabUrl">
  1984. <iframe :src="currentTabUrl" frameborder="0"></iframe>
  1985. </div>
  1986. </div>
  1987. `,
  1988. };
  1989. // ===== SsUploadFile 文件上传组件(支持单文件/多文件) =====
  1990. const SsUploadFile = {
  1991. name: 'SsUploadFile',
  1992. props: {
  1993. // v-model 绑定的值(单文件:String,多文件:Array)
  1994. modelValue: { type: [String, Array], default: '' },
  1995. // 最大上传数量(默认1个)
  1996. max: { type: Number, default: 1 },
  1997. // 是否禁用
  1998. disabled: { type: Boolean, default: false },
  1999. // 允许的文件类型(默认常见文件类型)
  2000. accept: { type: String, default: '.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar' },
  2001. // 最大文件大小(MB,默认5MB)
  2002. maxSize: { type: Number, default: 5 },
  2003. },
  2004. emits: ['update:modelValue', 'uploaded'],
  2005. setup(props, { emit }) {
  2006. // 文件列表(统一用数组管理)
  2007. const fileList = ref([]);
  2008. // 监听 modelValue 变化,同步到 fileList
  2009. watch(() => props.modelValue, (newVal) => {
  2010. if (props.max === 1) {
  2011. // 单文件模式
  2012. fileList.value = newVal ? [{ path: newVal, name: getFileName(newVal) }] : [];
  2013. } else {
  2014. // 多文件模式
  2015. if (Array.isArray(newVal)) {
  2016. fileList.value = newVal.map(path => ({ path, name: getFileName(path) }));
  2017. } else {
  2018. fileList.value = newVal ? [{ path: newVal, name: getFileName(newVal) }] : [];
  2019. }
  2020. }
  2021. }, { immediate: true });
  2022. // 是否可以继续添加文件
  2023. const canAddMore = computed(() => {
  2024. return fileList.value.length < props.max;
  2025. });
  2026. // 从路径中提取文件名
  2027. const getFileName = (path) => {
  2028. if (!path) return '';
  2029. const parts = path.split('/');
  2030. return parts[parts.length - 1];
  2031. };
  2032. // 获取文件图标
  2033. const getFileIcon = (fileName) => {
  2034. const ext = fileName.split('.').pop().toLowerCase();
  2035. const iconMap = {
  2036. pdf: 'icon-pdf',
  2037. doc: 'icon-word',
  2038. docx: 'icon-word',
  2039. xls: 'icon-excel',
  2040. xlsx: 'icon-excel',
  2041. txt: 'icon-txt',
  2042. zip: 'icon-zip',
  2043. rar: 'icon-zip',
  2044. };
  2045. return iconMap[ext] || 'icon-wenjian';
  2046. };
  2047. // 选择文件
  2048. const selectFile = () => {
  2049. if (props.disabled || !canAddMore.value) return;
  2050. const input = document.createElement('input');
  2051. input.type = 'file';
  2052. input.accept = props.accept;
  2053. input.onchange = async (e) => {
  2054. const file = e.target.files[0];
  2055. if (!file) return;
  2056. // 检查文件大小
  2057. const fileSizeMB = file.size / 1024 / 1024;
  2058. if (fileSizeMB > props.maxSize) {
  2059. window.showToast?.(`文件大小不能超过${props.maxSize}MB`, 'error');
  2060. return;
  2061. }
  2062. // 上传文件
  2063. try {
  2064. const serverPath = await uploadFile(file, 'file', file.name);
  2065. const newList = [...fileList.value, { path: serverPath, name: file.name }];
  2066. updateModelValue(newList);
  2067. emit('uploaded', serverPath);
  2068. } catch (error) {
  2069. console.error('文件上传失败:', error);
  2070. }
  2071. };
  2072. input.click();
  2073. };
  2074. // 删除文件
  2075. const deleteFile = (index) => {
  2076. const newList = fileList.value.filter((_, i) => i !== index);
  2077. updateModelValue(newList);
  2078. };
  2079. // 下载文件
  2080. const downloadFile = (file) => {
  2081. const url = window.SS.utils?.getFileUrl?.(file.path) || window.getFileUrl?.(file.path) || file.path;
  2082. window.open(url, '_blank');
  2083. };
  2084. // 更新 modelValue
  2085. const updateModelValue = (list) => {
  2086. const paths = list.map(f => f.path);
  2087. if (props.max === 1) {
  2088. // 单文件模式:返回 String
  2089. emit('update:modelValue', paths[0] || '');
  2090. } else {
  2091. // 多文件模式:返回 Array
  2092. emit('update:modelValue', paths);
  2093. }
  2094. };
  2095. return {
  2096. fileList,
  2097. canAddMore,
  2098. getFileIcon,
  2099. selectFile,
  2100. deleteFile,
  2101. downloadFile,
  2102. };
  2103. },
  2104. template: `
  2105. <div class="ss-upload-file">
  2106. <!-- 文件列表 -->
  2107. <div v-if="fileList.length > 0" class="file-list">
  2108. <!-- 已上传的文件 -->
  2109. <div
  2110. v-for="(file, index) in fileList"
  2111. :key="index"
  2112. class="file-item"
  2113. >
  2114. <div class="file-icon">
  2115. <Icon :name="getFileIcon(file.name)" size="40" color="#40ac6d" />
  2116. </div>
  2117. <div class="file-info" @click="downloadFile(file)">
  2118. <span class="file-name">{{ file.name }}</span>
  2119. </div>
  2120. <!-- 文件操作按钮 -->
  2121. <div class="file-actions">
  2122. <div class="file-download" @click="downloadFile(file)">
  2123. <Icon name="icon-xiazai" size="32" color="#40ac6d" />
  2124. </div>
  2125. <div v-if="!disabled" class="file-delete" @click.stop="deleteFile(index)">
  2126. <Icon name="icon-guanbi" size="32" color="#ff3b30" />
  2127. </div>
  2128. </div>
  2129. </div>
  2130. </div>
  2131. <!-- 添加按钮 -->
  2132. <div
  2133. v-if="canAddMore && !disabled"
  2134. class="file-add-button"
  2135. @click="selectFile"
  2136. >
  2137. <div class="add-icon">
  2138. <Icon name="icon-tianjia" size="64" color="#999" />
  2139. </div>
  2140. <span class="add-text">上传文件 ({{ fileList.length }}/{{ max }})</span>
  2141. <span class="file-tip">支持格式: {{ accept }},最大{{ maxSize }}MB</span>
  2142. </div>
  2143. </div>
  2144. `,
  2145. };
  2146. /**
  2147. * 功能说明:H5移动端富文本组件(对齐PC字段协议 mswj/fjid/path 回显) by xu 2026-03-01
  2148. *
  2149. * 约定:
  2150. * - v-model 绑定文件路径字段(如 mswj)
  2151. * - 组件内部维护编辑内容,并输出隐藏字段:xxEdit / xxwj / ueditorpath / fjid
  2152. * - 回显通过 url + path 拉取 HTML 内容,不直接依赖 modelValue 的 HTML 字符串
  2153. *
  2154. * @component SsEditor
  2155. * @prop {String} modelValue 文件路径(如 mswj)
  2156. * @prop {String} name 字段名(默认 mswj)
  2157. * @prop {String} url 回显读取接口地址
  2158. * @prop {Number|String} height 编辑器高度
  2159. * @prop {String} placeholder 占位文案
  2160. * @prop {Boolean} readonly 是否只读
  2161. * @prop {String} uploadUrl 上传接口地址
  2162. * @prop {Object} param 附件参数(button.cmsAddUrl / button.cmsUpdUrl / mode)
  2163. * @emits update:modelValue 更新文件路径
  2164. * @emits ready 编辑器就绪
  2165. * @emits change 内容变化
  2166. */
  2167. const SsEditor = {
  2168. name: 'SsEditor',
  2169. props: {
  2170. modelValue: { type: String, default: '' },
  2171. name: { type: String, default: 'mswj' },
  2172. url: { type: String, default: '' },
  2173. height: { type: [Number, String], default: 280 },
  2174. placeholder: { type: String, default: '请输入内容' },
  2175. readonly: { type: Boolean, default: false },
  2176. uploadUrl: { type: String, default: '/service?ssServ=ulByHttp' },
  2177. param: { type: Object, default: () => ({}) },
  2178. },
  2179. emits: ['update:modelValue', 'ready', 'change'],
  2180. setup(props, { emit }) {
  2181. const editorElementId = `ss-editor-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
  2182. const editorContent = ref('');
  2183. const editorInstance = ref(null);
  2184. const fjid = ref(props.param?.button?.val || '');
  2185. const fjName = props.param?.button?.desc || '附件';
  2186. const mode = props.param?.mode;
  2187. /**
  2188. * 功能说明:确保附件 fjid 存在,不存在时先通过 cmsAddUrl 创建 by xu 2026-03-01
  2189. * @returns {Promise<string>} fjid
  2190. */
  2191. const ensureFjid = async () => {
  2192. if (fjid.value) return fjid.value;
  2193. if (!props.param?.button?.cmsAddUrl) return '';
  2194. return new Promise((resolve) => {
  2195. $.ajax({
  2196. type: 'post',
  2197. url: props.param.button.cmsAddUrl,
  2198. async: false,
  2199. data: {
  2200. name: 'fjid',
  2201. ssNrObjName: 'sh',
  2202. ssNrObjId: '',
  2203. },
  2204. success: (_fjid) => {
  2205. fjid.value = _fjid || '';
  2206. resolve(fjid.value);
  2207. },
  2208. error: () => resolve(''),
  2209. });
  2210. });
  2211. };
  2212. /**
  2213. * 功能说明:打开附件管理弹窗(与PC端行为一致) by xu 2026-03-01
  2214. * @returns {Promise<void>}
  2215. */
  2216. const openAttachmentDialog = async () => {
  2217. if (!props.param?.button?.cmsUpdUrl) {
  2218. console.warn('未配置附件编辑地址 cmsUpdUrl');
  2219. return;
  2220. }
  2221. const currentFjid = await ensureFjid();
  2222. if (!currentFjid) return;
  2223. const query =
  2224. `&nrid=T-${currentFjid}` +
  2225. `&objectId=${currentFjid}` +
  2226. `&objectName=${encodeURIComponent(fjName)}` +
  2227. `&callback=${window['fjidCallbackName'] || ''}`;
  2228. if (window.SS && typeof window.SS.openDialog === 'function') {
  2229. window.SS.openDialog({
  2230. src: props.param.button.cmsUpdUrl + query,
  2231. headerTitle: '编辑',
  2232. width: 900,
  2233. high: 664,
  2234. zIndex: 51,
  2235. });
  2236. }
  2237. };
  2238. /**
  2239. * 功能说明:初始化 Jodit 编辑器并绑定工具栏/上传/change 事件 by xu 2026-03-01
  2240. * @returns {void}
  2241. */
  2242. const buildEditor = () => {
  2243. if (!window.Jodit || !window.Jodit.make) {
  2244. console.error('Jodit 未加载,无法初始化 ss-editor');
  2245. return;
  2246. }
  2247. const editorUploadUrl = props.uploadUrl.includes('?')
  2248. ? `${props.uploadUrl}&type=img`
  2249. : `${props.uploadUrl}?type=img`;
  2250. const instance = window.Jodit.make(`#${editorElementId}`, {
  2251. height: props.height,
  2252. placeholder: props.placeholder,
  2253. readonly: props.readonly,
  2254. language: 'zh_cn',
  2255. showXPathInStatusbar: false,
  2256. showCharsCounter: false,
  2257. showWordsCounter: false,
  2258. allowResizeY: false,
  2259. toolbarSticky: false,
  2260. statusbar: false,
  2261. uploader: {
  2262. url: editorUploadUrl,
  2263. format: 'json',
  2264. method: 'POST',
  2265. filesVariableName: (i) => `imgs[${i}]`,
  2266. isSuccess: (resp) => resp?.code === 0 || !!resp?.data,
  2267. getMessage: (resp) => resp?.msg || '上传失败',
  2268. process: (resp) => resp?.data?.url || resp?.data?.path || '',
  2269. contentType: () => false,
  2270. },
  2271. controls: {
  2272. customLinkButton: {
  2273. name: 'link',
  2274. tooltip: '附件',
  2275. exec: () => {
  2276. openAttachmentDialog();
  2277. },
  2278. },
  2279. },
  2280. buttons: [
  2281. 'fullsize',
  2282. 'bold',
  2283. 'italic',
  2284. 'underline',
  2285. '|',
  2286. 'font',
  2287. 'fontsize',
  2288. '|',
  2289. 'left',
  2290. 'center',
  2291. 'right',
  2292. '|',
  2293. 'ul',
  2294. 'ol',
  2295. '|',
  2296. 'image',
  2297. 'table',
  2298. 'customLinkButton',
  2299. '|',
  2300. 'undo',
  2301. 'redo',
  2302. ],
  2303. buttonsMD: ['bold', 'italic', 'underline', '|', 'image', 'customLinkButton', '|', 'dots'],
  2304. buttonsSM: ['bold', 'italic', '|', 'image', 'customLinkButton', '|', 'dots'],
  2305. buttonsXS: ['bold', '|', 'dots'],
  2306. });
  2307. instance.value = editorContent.value || '';
  2308. instance.events.on('change', () => {
  2309. editorContent.value = instance.value || '';
  2310. emit('change', editorContent.value);
  2311. });
  2312. editorInstance.value = instance;
  2313. emit('ready', instance);
  2314. };
  2315. /**
  2316. * 功能说明:按路径加载富文本HTML内容并回填编辑器 by xu 2026-03-01
  2317. * @returns {Promise<void>}
  2318. */
  2319. const loadContentByPath = async () => {
  2320. if (!props.url || !props.modelValue) return;
  2321. try {
  2322. const params = new URLSearchParams();
  2323. if (mode) params.append('mode', mode);
  2324. params.append('path', props.modelValue);
  2325. const response = await window.axios.post(props.url, params, {
  2326. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  2327. });
  2328. const content = response?.data?.content || '';
  2329. if (content) {
  2330. editorContent.value = content;
  2331. if (editorInstance.value) {
  2332. editorInstance.value.value = content;
  2333. }
  2334. }
  2335. const filePath = response?.data?.path;
  2336. if (filePath) {
  2337. emit('update:modelValue', filePath);
  2338. }
  2339. } catch (error) {
  2340. console.error('ss-editor 回显内容加载失败:', error);
  2341. }
  2342. };
  2343. onMounted(async () => {
  2344. buildEditor();
  2345. await loadContentByPath();
  2346. });
  2347. watch(
  2348. () => props.readonly,
  2349. (newVal) => {
  2350. if (editorInstance.value && typeof editorInstance.value.setReadOnly === 'function') {
  2351. editorInstance.value.setReadOnly(newVal);
  2352. }
  2353. }
  2354. );
  2355. onBeforeUnmount(() => {
  2356. if (editorInstance.value && typeof editorInstance.value.destruct === 'function') {
  2357. editorInstance.value.destruct();
  2358. }
  2359. });
  2360. return {
  2361. editorElementId,
  2362. editorContent,
  2363. fjid,
  2364. };
  2365. },
  2366. template: `
  2367. <div class="ss-editor-container">
  2368. <input v-if="fjid" type="hidden" name="fjid" :value="fjid" />
  2369. <input type="hidden" :name="name.replace(/wj$/, '') + 'Edit'" :value="editorContent" />
  2370. <input type="hidden" :name="name.replace(/wj$/, '') + 'wj'" :value="modelValue" />
  2371. <input type="hidden" name="ueditorpath" value="mswj" />
  2372. <textarea :id="editorElementId"></textarea>
  2373. </div>
  2374. `,
  2375. };
  2376. window.SS.dom.initializeFormApp = function (config) {
  2377. const { el, ...vueOptions } = config;
  2378. const app = createApp({
  2379. ...vueOptions,
  2380. });
  2381. // 注册组件
  2382. // app.component("SsLoginIcon", SsLoginIcon);
  2383. // app.component("SsMark", SsMark);
  2384. // app.component("SsFullStyleHeader", SsFullStyleHeader);
  2385. // app.component("SsDialog", SsDialog);
  2386. app.component('SsInput', SsInput);
  2387. app.component('SsBottom', SsBottom);
  2388. app.component('SsCard', SsCard);
  2389. app.component('SsSearchButton', SsSearchButton);
  2390. app.component('SsSelect', SsSelect);
  2391. app.component('Icon', Icon);
  2392. // 注册 Vant 组件
  2393. const vantLib = window.vant || window.Vant;
  2394. console.log('🔍 检查 Vant:', {
  2395. hasVant: !!window.vant,
  2396. hasVantCap: !!window.Vant,
  2397. vantLib: !!vantLib,
  2398. vantKeys: vantLib ? Object.keys(vantLib).slice(0, 20) : [],
  2399. hasPopup: vantLib?.Popup,
  2400. hasDatetimePicker: vantLib?.DatetimePicker,
  2401. hasDatePicker: vantLib?.DatePicker,
  2402. hasTimePicker: vantLib?.TimePicker,
  2403. allKeys: vantLib ? Object.keys(vantLib) : []
  2404. });
  2405. if (vantLib) {
  2406. try {
  2407. // 使用 Vant 的 use 方法注册所有组件
  2408. app.use(vantLib);
  2409. console.log('✅ Vant 全部组件注册成功');
  2410. } catch (error) {
  2411. console.error('❌ Vant 组件注册失败:', error);
  2412. // 降级方案:手动注册具体组件
  2413. try {
  2414. app.component('van-popup', vantLib.Popup);
  2415. app.component('van-datetime-picker', vantLib.DatetimePicker);
  2416. console.log('✅ Vant 手动注册成功');
  2417. } catch (e) {
  2418. console.error('❌ Vant 手动注册也失败:', e);
  2419. }
  2420. }
  2421. } else {
  2422. console.warn('⚠️ Vant 未加载');
  2423. }
  2424. // 注册组件 - 统一使用 kebab-case
  2425. app.component('ss-verify', SsVerify);
  2426. app.component('ss-verify-node', SsVerifyNode);
  2427. app.component('ss-common-icon', SsCommonIcon);
  2428. app.component('ss-onoff-button', SsOnoffButton);
  2429. app.component('ss-datetime-picker', SsDatetimePicker);
  2430. app.component('ss-confirm', SsConfirm);
  2431. app.component('ss-image-cropper', SsImageCropper);
  2432. app.component('ss-upload-image', SsUploadImage);
  2433. app.component('ss-upload-file', SsUploadFile);
  2434. app.component('ss-car-card', SsCarCard);
  2435. app.component('ss-sub-tab', SsSubTab);
  2436. app.component('ss-editor', SsEditor);
  2437. app.component('SsEditor', SsEditor);
  2438. // app.component("SsObjp", SsObjp);
  2439. // app.component("SsHidden", SsHidden);
  2440. // app.component("SsCcp", SsCcp);
  2441. // app.component("SsDatePicker", SsDatePicker);
  2442. // app.component("SsIcon", SsIcon);
  2443. // app.component("SsCommonIcon", SsCommonIcon);
  2444. // app.component("SsBreadcrumb", SsBreadcrumb);
  2445. // app.component("SsEditor", SsEditor);
  2446. // app.component("SsDialogIcon", SsDialogIcon);
  2447. // app.component("SsBottomButton", SsBottomButton);
  2448. // app.component("SsNavIcon", SsNavIcon);
  2449. // app.component("SsHeaderIcon", SsHeaderIcon);
  2450. // app.component("SsGolbalMenuIcon", SsGolbalMenuIcon);
  2451. // app.component("SsCartListIcon", SsCartListIcon);
  2452. // app.component("SsQuickIcon", SsQuickIcon);
  2453. // app.component("SsFormIcon", SsFormIcon);
  2454. // app.component("SsBottomDivIcon", SsBottomDivIcon);
  2455. // app.component("SsEditorIcon", SsEditorIcon);
  2456. // app.component("SsValidate", SsValidate);
  2457. // app.component("SsOnoffbutton", SsOnoffbutton);
  2458. // app.component("SsOnoffbuttonArray", SsOnoffbuttonArray);
  2459. // app.component("SsTextarea", SsTextarea);
  2460. // app.component("SsLoginInput", SsLoginInput);
  2461. // app.component("SsLoginButton", SsLoginButton);
  2462. // app.component("SsSearch", SsSearch);
  2463. // app.component("SsCartItem", SsCartItem);
  2464. // app.component("SsCartItem2", SsCartItem2);
  2465. // app.component("SsListCard", SsListCard);
  2466. // app.component("SsFolderCard", SsFolderCard);
  2467. // app.component("SsFolderCartView", SsFolderCartView);
  2468. // app.component("SsPage", SsPage);
  2469. // app.component("SsRightInfo", SSRightInfo);
  2470. // app.component("SsSuccessPopup", SsSuccessPopup);
  2471. // app.component("SsErrorDialog", SsErrorDialog);
  2472. // app.component("SsVerify", SsVerify);
  2473. // app.component("SsVerifyNode", SsVerifyNode);
  2474. // app.component("SsOrcImgBox", SsOrcImgBox);
  2475. // app.component("ss-search-input", SsSearchInput);
  2476. // app.component("ss-search-date-picker", SsSearchDatePicker);
  2477. // app.component("ss-search-button", SsSearchButton);
  2478. // app.component("ss-drop-button", SsDropButton);
  2479. // app.component("ss-sub-tab", SsSubTab);
  2480. // app.component("ss-img", SsImgUpload);
  2481. // 设置为中文
  2482. // app.use(ElementPlus, {
  2483. // locale: ElementPlusLocaleZhCn,
  2484. // });
  2485. // console.log(ElementPlus);
  2486. // 确保 ElementPlusIconsVue
  2487. // if (window.ElementPlusIconsVue) {
  2488. // // 注册 Element Plus 图标组件
  2489. // for (const [key, component] of Object.entries(
  2490. // window.ElementPlusIconsVue
  2491. // )) {
  2492. // console.log(key, component);
  2493. // app.component(key, component);
  2494. // }
  2495. // }
  2496. // 挂载首页的组件
  2497. // for (const componentName in IndexComponents) {
  2498. // app.component(componentName, IndexComponents[componentName]);
  2499. // }
  2500. // 挂载echarts的组件
  2501. // for (const componentName in EchartComponents) {
  2502. // app.component(componentName, EchartComponents[componentName]);
  2503. // }
  2504. // 挂载 Vue 应用
  2505. const vm = app.mount(el);
  2506. vm.data = vueOptions.data();
  2507. return vm;
  2508. };
  2509. })();