mp-ss-components.js 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325
  1. // H5版本的小程序组件库
  2. // 参考 alf/ss-components.js 的组件形式
  3. (function () {
  4. const { ref, createApp, watch, inject, onMounted, 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. },
  627. emits: ['update:modelValue', 'change', 'search', 'clear'],
  628. setup(props, { emit }) {
  629. // 响应式数据
  630. const isOpen = ref(false);
  631. const selectedValue = ref(props.modelValue);
  632. const searchKeyword = ref('');
  633. // 计算属性
  634. const optionsList = computed(() => props.options || []);
  635. const displayText = computed(() => {
  636. if (!selectedValue.value) return props.placeholder;
  637. const selectedOption = optionsList.value.find(
  638. (option) =>
  639. option[props.mapping.value] === selectedValue.value
  640. );
  641. return selectedOption
  642. ? selectedOption[props.mapping.text]
  643. : props.placeholder;
  644. });
  645. // 监听 modelValue 变化
  646. watch(
  647. () => props.modelValue,
  648. (newValue) => {
  649. selectedValue.value = newValue;
  650. }
  651. );
  652. // 切换下拉框
  653. const toggleDropdown = () => {
  654. if (props.disabled) return;
  655. isOpen.value = !isOpen.value;
  656. };
  657. // 选择选项
  658. const selectOption = (option) => {
  659. const value = option[props.mapping.value];
  660. selectedValue.value = value;
  661. isOpen.value = false;
  662. emit('update:modelValue', value);
  663. emit('change', value);
  664. };
  665. // 点击外部关闭
  666. const handleClickOutside = (event) => {
  667. if (!event.target.closest('.ss-select-container')) {
  668. isOpen.value = false;
  669. }
  670. };
  671. onMounted(() => {
  672. document.addEventListener('click', handleClickOutside);
  673. });
  674. return {
  675. isOpen,
  676. selectedValue,
  677. searchKeyword,
  678. optionsList,
  679. displayText,
  680. toggleDropdown,
  681. selectOption,
  682. };
  683. },
  684. template: `
  685. <div class="ss-select-container" :class="{ open: isOpen }" @click.stop="toggleDropdown">
  686. <!-- 显示区域 -->
  687. <div class="ss-select" :class="{ disabled: disabled }">
  688. <span class="select-text" :class="{ placeholder: !selectedValue }">{{ displayText }}</span>
  689. <div class="select-arrow" :class="{ rotate: isOpen }">
  690. <Icon name="icon-xiangxiajiantou" size="32" :color="disabled ? '#ccc' : '#999'"/>
  691. </div>
  692. </div>
  693. <!-- 选项列表 -->
  694. <div class="ss-options" v-show="isOpen">
  695. <!-- 加载状态 -->
  696. <div
  697. v-if="loading"
  698. class="option-item loading-item"
  699. >
  700. <span class="loading-text">加载中...</span>
  701. </div>
  702. <!-- 无选项 -->
  703. <div
  704. v-else-if="optionsList.length === 0"
  705. class="option-item no-options"
  706. >
  707. 无选项
  708. </div>
  709. <!-- 选项列表 -->
  710. <div
  711. v-else
  712. v-for="(option, index) in optionsList"
  713. :key="index"
  714. class="option-item"
  715. :class="{ selected: option[mapping.value] === selectedValue }"
  716. @click.stop="selectOption(option)"
  717. >
  718. {{ option[mapping.text] }}
  719. </div>
  720. </div>
  721. </div>
  722. `,
  723. };
  724. // ss-bottom 底部按钮组件
  725. const SsBottom = {
  726. name: 'SsBottom',
  727. props: {
  728. // 是否显示审核意见
  729. showShyj: {
  730. type: Boolean,
  731. default: false,
  732. },
  733. // 审核意见标题
  734. shyjTitle: {
  735. type: String,
  736. default: '审核意见',
  737. },
  738. // 审核意见占位符
  739. shyjPlaceholder: {
  740. type: String,
  741. default: '请输入审核意见',
  742. },
  743. divider: {
  744. type: Boolean,
  745. default: true,
  746. },
  747. // 按钮配置
  748. buttons: {
  749. type: Array,
  750. default: () => [
  751. { text: '取消', action: 'cancel' },
  752. { text: '保存并提交', action: 'submit' },
  753. ],
  754. },
  755. },
  756. emits: ['button-click', 'update:shyjValue'],
  757. setup(props, { emit }) {
  758. const reason = ref('');
  759. const activeButtonIndex = ref(-1);
  760. // 处理按钮点击
  761. const handleButtonClick = (button, index) => {
  762. emit('button-click', {
  763. action: button.action,
  764. button: button,
  765. index: index,
  766. shyjValue: reason.value, // 传递审核意见
  767. });
  768. };
  769. // 监听审核意见变化
  770. const handleShyjInput = (event) => {
  771. const value = event.target.value;
  772. reason.value = value;
  773. emit('update:shyjValue', value);
  774. };
  775. // 处理按钮按下
  776. const handleButtonMouseDown = (index) => {
  777. activeButtonIndex.value = index;
  778. };
  779. // 处理按钮释放
  780. const handleButtonMouseUp = () => {
  781. activeButtonIndex.value = -1;
  782. };
  783. // 处理鼠标离开
  784. const handleMouseLeave = () => {
  785. activeButtonIndex.value = -1;
  786. };
  787. // 计算按钮样式
  788. const getButtonStyle = (button) => {
  789. const styles = {};
  790. // 如果有背景颜色配置
  791. if (button.backgroundColor) {
  792. styles.backgroundColor = button.backgroundColor;
  793. }
  794. // 如果有字体颜色配置
  795. if (button.color) {
  796. styles.color = button.color;
  797. }
  798. return styles;
  799. };
  800. // 计算按钮点击样式
  801. const getButtonActiveStyle = (button) => {
  802. const styles = {};
  803. // 如果有点击背景色配置,使用点击背景色
  804. if (button.clickBgColor) {
  805. styles.backgroundColor = button.clickBgColor;
  806. } else if (button.backgroundColor) {
  807. // 如果没有点击背景色,但有背景色,点击时使用背景色
  808. styles.backgroundColor = button.backgroundColor;
  809. }
  810. // 如果有点击字体色配置,使用点击字体色
  811. if (button.clickColor) {
  812. styles.color = button.clickColor;
  813. } else if (button.color) {
  814. // 如果没有点击字体色,但有字体色,点击时使用字体色
  815. styles.color = button.color;
  816. }
  817. return styles;
  818. };
  819. return {
  820. reason,
  821. activeButtonIndex,
  822. handleButtonClick,
  823. handleShyjInput,
  824. handleButtonMouseDown,
  825. handleButtonMouseUp,
  826. handleMouseLeave,
  827. getButtonStyle,
  828. getButtonActiveStyle,
  829. };
  830. },
  831. template: `
  832. <div class="ss-bottom">
  833. <!-- 审核意见区域 -->
  834. <div v-if="showShyj" class="ss-bottom__opinion">
  835. <table class="ss-bottom__opinion-table">
  836. <tr>
  837. <th class="ss-bottom__opinion-label">{{ shyjTitle }}</th>
  838. <td class="ss-bottom__opinion-input">
  839. <ss-input
  840. :placeholder="shyjPlaceholder"
  841. v-model="reason"
  842. @input="handleShyjInput"
  843. />
  844. </td>
  845. </tr>
  846. </table>
  847. </div>
  848. <!-- 按钮区域 -->
  849. <div class="ss-bottom__buttons" :class="{ 'ss-bottom__buttons--with-border': !showShyj }">
  850. <template v-for="(button, index) in buttons" :key="index">
  851. <div
  852. class="ss-bottom__button"
  853. :class="{
  854. 'ss-bottom__button--custom': button.backgroundColor || button.color || button.clickBgColor || button.clickColor,
  855. 'ss-bottom__button--active': activeButtonIndex === index
  856. }"
  857. :style="activeButtonIndex === index ? getButtonActiveStyle(button) : getButtonStyle(button)"
  858. @click="handleButtonClick(button, index)"
  859. @mousedown="handleButtonMouseDown(index)"
  860. @mouseup="handleButtonMouseUp"
  861. @mouseleave="handleMouseLeave"
  862. @touchstart="handleButtonMouseDown(index)"
  863. @touchend="handleButtonMouseUp"
  864. >
  865. {{ button.text }}
  866. </div>
  867. <!-- 分割线,最后一个按钮不显示 -->
  868. <div v-if="index < buttons.length - 1 && divider" class="ss-bottom__divider"></div>
  869. </template>
  870. </div>
  871. </div>
  872. `,
  873. };
  874. // ===== SsVerify 审核节点链组件 =====
  875. const SsVerify = {
  876. name: "SsVerify",
  877. props: {
  878. verifyList: {
  879. type: Array,
  880. required: true,
  881. },
  882. },
  883. setup(props) {
  884. const toggleOpen = (item) => {
  885. item.open = !item.open;
  886. // 切换后重新计算连线高度
  887. setTimeout(() => {
  888. calculateLineHeight();
  889. }, 50);
  890. };
  891. // 计算连线高度的函数
  892. const calculateLineHeight = () => {
  893. const lastOpenGroup = document.querySelector(".group-item-last-open");
  894. console.log("lastOpenGroup", lastOpenGroup);
  895. if (lastOpenGroup) {
  896. // 使用原生JavaScript代替jQuery
  897. const nodes = lastOpenGroup.querySelectorAll(".verify-node-container");
  898. if (nodes.length) {
  899. let totalHeight = 0;
  900. if (nodes.length === 1) {
  901. // 只有一个节点时,连线伸到节点的中间位置
  902. const nodeHeight = nodes[0].offsetHeight;
  903. const nodeTop = nodes[0].offsetTop;
  904. totalHeight = nodeTop + (nodeHeight / 2) - 15; // 减去圆点半径5px
  905. } else {
  906. // 多个节点时,连线延伸到最后一个节点的中间位置
  907. const lastNode = nodes[nodes.length - 1];
  908. const lastNodeTop = lastNode.offsetTop;
  909. const lastNodeHeight = lastNode.offsetHeight;
  910. totalHeight = lastNodeTop + (lastNodeHeight / 2) - 15; // 减去圆点半径5px
  911. }
  912. console.log("节点信息:", {
  913. 节点总数: nodes.length,
  914. 计算后的高度: totalHeight,
  915. 最后节点top: nodes[nodes.length - 1]?.offsetTop,
  916. 最后节点高度: nodes[nodes.length - 1]?.offsetHeight,
  917. });
  918. lastOpenGroup.style.setProperty(
  919. "--group-line-height",
  920. `${totalHeight}px`
  921. );
  922. }
  923. }
  924. };
  925. onMounted(() => {
  926. setTimeout(() => {
  927. calculateLineHeight();
  928. }, 100);
  929. });
  930. return {
  931. toggleOpen,
  932. };
  933. },
  934. render() {
  935. const { h } = Vue;
  936. return h(
  937. "div",
  938. { class: "verify-nodes" },
  939. this.verifyList.map((item, i) =>
  940. h(
  941. "div",
  942. {
  943. key: i,
  944. class: {
  945. "group-item": true,
  946. "group-item-last-open":
  947. i === this.verifyList.length - 1 && item.open,
  948. },
  949. },
  950. [
  951. h(
  952. "div",
  953. {
  954. class: "group-item-title",
  955. onClick: () => this.toggleOpen(item),
  956. },
  957. [
  958. h("div", { class: "icon" }, [
  959. h("i", {
  960. class: item.open ? "common-icon-folder-open common-icon" : "common-icon-folder-close common-icon"
  961. }),
  962. h(
  963. "div",
  964. {
  965. class: "num",
  966. style: { top: item.open ? "60%" : "55%" },
  967. },
  968. item.children?.length || 0
  969. ),
  970. ]),
  971. h("div", { class: "name" }, item.groupName),
  972. ]
  973. ),
  974. item.open && item.children?.length > 0
  975. ? h(
  976. "div",
  977. { class: "group-item-children" },
  978. item.children.map((citem, j) =>
  979. h(SsVerifyNode, {
  980. key: j,
  981. item: citem,
  982. // isGroup: i + 1 !== this.verifyList.length,
  983. isGroup: true,
  984. })
  985. )
  986. )
  987. : null,
  988. ]
  989. )
  990. )
  991. );
  992. },
  993. };
  994. // ===== SsVerifyNode 审核节点组件 =====
  995. const SsVerifyNode = {
  996. name: "SsVerifyNode",
  997. props: {
  998. item: {
  999. type: Object,
  1000. required: true,
  1001. },
  1002. isGroup: {
  1003. type: Boolean,
  1004. default: false,
  1005. },
  1006. },
  1007. render() {
  1008. const { h } = Vue;
  1009. return h("div", { class: "verify-node-container" }, [
  1010. h("div", { class: "info" }, [
  1011. h("div", { class: "avatar" }, [
  1012. h("img", {
  1013. src: this.item.thumb,
  1014. style: {
  1015. width: "50px",
  1016. height: "50px",
  1017. borderRadius: "50%",
  1018. },
  1019. }),
  1020. ]),
  1021. h("div", { class: "desc" }, [
  1022. h("div", this.item.name),
  1023. h("div", this.item.role),
  1024. ]),
  1025. h("div", { class: "link" }, [
  1026. h("div", [
  1027. this.item.video
  1028. ? h("i", { class: "common-icon-video common-icon" })
  1029. : null,
  1030. this.item.link
  1031. ? h("i", { class: "common-icon-paper-clip common-icon" })
  1032. : null,
  1033. ]),
  1034. ]),
  1035. ]),
  1036. h(
  1037. "div",
  1038. {
  1039. class: {
  1040. description: true,
  1041. link: this.isGroup,
  1042. },
  1043. attrs: { "data-num": "3" },
  1044. },
  1045. [h("div", this.item.description)]
  1046. ),
  1047. h("div", { class: "time" }, this.item.time),
  1048. ]);
  1049. },
  1050. };
  1051. // ===== SsOnoffButton 开关按钮 =====
  1052. const SsOnoffButton = {
  1053. name: 'SsOnoffButton',
  1054. props: {
  1055. // 字段名称,用于表单校验
  1056. name: { type: String, required: true },
  1057. // 显示标签
  1058. label: { type: String, required: true },
  1059. // 按钮的值
  1060. value: { type: [String, Number], required: true },
  1061. // 宽度设置
  1062. width: { type: String, default: '' },
  1063. // v-model 绑定的值
  1064. modelValue: { type: [String, Number, Array], default: '' },
  1065. // 是否多选模式
  1066. multiple: { type: Boolean, default: false },
  1067. // 是否禁用
  1068. disabled: { type: Boolean, default: false }
  1069. },
  1070. emits: ['update:modelValue', 'change'],
  1071. setup(props, { emit }) {
  1072. // 解析 modelValue,支持逗号分隔的字符串和数组
  1073. const parseModelValue = (val) => {
  1074. if (!val) return [];
  1075. // 如果是数组,直接返回字符串数组
  1076. if (Array.isArray(val)) {
  1077. return val.map(v => v.toString());
  1078. }
  1079. // 如果是字符串,按逗号分割
  1080. const cleanValue = val.toString().replace(/^,+/, ''); // 去掉开头的逗号
  1081. if (cleanValue.includes('|')) {
  1082. return cleanValue.split('|');
  1083. }
  1084. if (cleanValue.includes(',')) {
  1085. return cleanValue.split(',');
  1086. }
  1087. return cleanValue ? [cleanValue] : [];
  1088. };
  1089. // 判断当前按钮是否选中
  1090. const isChecked = computed(() => {
  1091. if (props.multiple) {
  1092. const currentValue = parseModelValue(props.modelValue);
  1093. return currentValue.includes(props.value.toString());
  1094. }
  1095. return props.modelValue === props.value;
  1096. });
  1097. // 切换选中状态
  1098. const toggleSelect = () => {
  1099. // 如果禁用,不执行任何操作
  1100. if (props.disabled) return;
  1101. if (props.multiple) {
  1102. // 多选模式
  1103. const currentValue = parseModelValue(props.modelValue);
  1104. const index = currentValue.indexOf(props.value.toString());
  1105. let newValue;
  1106. if (index === -1) {
  1107. // 添加选项
  1108. newValue = [...currentValue, props.value.toString()];
  1109. } else {
  1110. // 移除选项
  1111. newValue = currentValue.filter(v => v !== props.value.toString());
  1112. }
  1113. // 发送更新事件,使用逗号分隔的字符串格式
  1114. const emitValue = newValue.join(',');
  1115. emit('update:modelValue', emitValue);
  1116. emit('change', emitValue, newValue);
  1117. } else {
  1118. // 单选模式
  1119. emit('update:modelValue', props.value);
  1120. emit('change', props.value);
  1121. }
  1122. };
  1123. return { isChecked, toggleSelect };
  1124. },
  1125. template: `
  1126. <div class="ss-onoff-button" :class="{ checked: isChecked, disabled: disabled }" @click="toggleSelect">
  1127. <span class="button-label">{{ label }}</span>
  1128. <div class="button-mark">
  1129. <span class="form-icon" :class="isChecked ? 'form-icon-onoffbutton-checked' : 'form-icon-onoffbutton-unchecked'"></span>
  1130. </div>
  1131. </div>
  1132. `,
  1133. };
  1134. // ===== SsDatetimePicker 日期时间选择(使用 Vant 4) =====
  1135. const SsDatetimePicker = {
  1136. name: 'SsDatetimePicker',
  1137. props: {
  1138. mode: { type: String, default: 'date' }, // date | time | datetime
  1139. placeholder: { type: String, default: '请选择日期' },
  1140. modelValue: { type: String, default: '' },
  1141. minDate: { type: String, default: '' },
  1142. maxDate: { type: String, default: '' },
  1143. // 字段名称 - 用于ssVm校验
  1144. name: { type: String, default: '' },
  1145. // 是否禁用
  1146. disabled: { type: Boolean, default: false },
  1147. },
  1148. emits: ['update:modelValue', 'change', 'confirm', 'cancel'],
  1149. setup(props, { emit }) {
  1150. const showPicker = ref(false);
  1151. const showTimePicker = ref(false);
  1152. const currentStep = ref('date'); // 'date' | 'time'
  1153. // Vant DatePicker 需要数组格式 [year, month, day]
  1154. const currentDateArray = ref([]);
  1155. const currentTimeArray = ref(['12', '00']); // [hour, minute]
  1156. const tempDateStr = ref(''); // 临时存储选择的日期
  1157. // 格式化显示文本
  1158. const displayText = computed(() => {
  1159. if (!props.modelValue) return props.placeholder;
  1160. try {
  1161. const d = new Date(props.modelValue);
  1162. if (isNaN(d.getTime())) return props.modelValue;
  1163. const year = d.getFullYear();
  1164. const month = String(d.getMonth() + 1).padStart(2, '0');
  1165. const day = String(d.getDate()).padStart(2, '0');
  1166. const hours = String(d.getHours()).padStart(2, '0');
  1167. const minutes = String(d.getMinutes()).padStart(2, '0');
  1168. if (props.mode === 'time') {
  1169. return `${hours}:${minutes}`;
  1170. } else if (props.mode === 'datetime') {
  1171. return `${year}-${month}-${day} ${hours}:${minutes}`;
  1172. }
  1173. return `${year}-${month}-${day}`;
  1174. } catch (e) { return props.modelValue; }
  1175. });
  1176. // 监听 modelValue 变化,转换为数组格式
  1177. watch(() => props.modelValue, (newVal) => {
  1178. console.log('📅 modelValue 变化:', newVal);
  1179. if (newVal) {
  1180. try {
  1181. const d = new Date(newVal);
  1182. if (!isNaN(d.getTime())) {
  1183. currentDateArray.value = [d.getFullYear(), d.getMonth() + 1, d.getDate()];
  1184. console.log('📅 转换为数组:', currentDateArray.value);
  1185. }
  1186. } catch (e) {
  1187. console.warn('Invalid date:', newVal);
  1188. }
  1189. } else {
  1190. const today = new Date();
  1191. currentDateArray.value = [today.getFullYear(), today.getMonth() + 1, today.getDate()];
  1192. }
  1193. }, { immediate: true });
  1194. // 确认选择 - 根据模式处理不同的数据
  1195. const onConfirm = (value) => {
  1196. console.log('📅 Vant Picker confirm 原始值:', value, 'mode:', props.mode);
  1197. try {
  1198. // Vant 返回的是对象,包含 selectedValues 数组
  1199. const selectedValues = value.selectedValues || value;
  1200. console.log('📅 selectedValues:', selectedValues);
  1201. if (props.mode === 'time') {
  1202. // 时间模式:处理时分
  1203. if (Array.isArray(selectedValues) && selectedValues.length >= 2) {
  1204. const [hour, minute] = selectedValues;
  1205. const timeStr = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
  1206. console.log('🕐 转换后的时间字符串:', timeStr);
  1207. emit('update:modelValue', timeStr);
  1208. emit('change', timeStr);
  1209. emit('confirm', timeStr);
  1210. showPicker.value = false;
  1211. }
  1212. } else if (Array.isArray(selectedValues) && selectedValues.length >= 3) {
  1213. // 日期模式:处理年月日
  1214. const [year, month, day] = selectedValues;
  1215. const dateStr = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
  1216. if (props.mode === 'datetime') {
  1217. // datetime 模式:先存储日期,然后打开时间选择器
  1218. tempDateStr.value = dateStr;
  1219. showPicker.value = false;
  1220. showTimePicker.value = true;
  1221. currentStep.value = 'time';
  1222. } else {
  1223. // date 模式:直接完成
  1224. emit('update:modelValue', dateStr);
  1225. emit('change', dateStr);
  1226. emit('confirm', dateStr);
  1227. showPicker.value = false;
  1228. }
  1229. }
  1230. } catch (e) {
  1231. console.error('Picker conversion error:', e);
  1232. }
  1233. };
  1234. // 时间选择确认
  1235. const onTimeConfirm = (value) => {
  1236. try {
  1237. const selectedValues = value.selectedValues || value;
  1238. if (Array.isArray(selectedValues) && selectedValues.length >= 2) {
  1239. const [hour, minute] = selectedValues;
  1240. const datetimeStr = `${tempDateStr.value} ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
  1241. emit('update:modelValue', datetimeStr);
  1242. emit('change', datetimeStr);
  1243. emit('confirm', datetimeStr);
  1244. }
  1245. } catch (e) {
  1246. console.error('Time conversion error:', e);
  1247. }
  1248. showTimePicker.value = false;
  1249. currentStep.value = 'date';
  1250. };
  1251. // 取消选择
  1252. const onCancel = () => {
  1253. emit('cancel');
  1254. showPicker.value = false;
  1255. };
  1256. // 打开选择器
  1257. const openPicker = () => {
  1258. // 如果禁用,不打开选择器
  1259. if (props.disabled) return;
  1260. showPicker.value = true;
  1261. };
  1262. // 计算最小最大日期
  1263. const minDateObj = computed(() => {
  1264. return props.minDate ? new Date(props.minDate) : undefined;
  1265. });
  1266. const maxDateObj = computed(() => {
  1267. return props.maxDate ? new Date(props.maxDate) : undefined;
  1268. });
  1269. return {
  1270. showPicker,
  1271. showTimePicker,
  1272. currentDateArray,
  1273. currentTimeArray,
  1274. displayText,
  1275. openPicker,
  1276. onConfirm,
  1277. onTimeConfirm,
  1278. onCancel,
  1279. minDateObj,
  1280. maxDateObj
  1281. };
  1282. },
  1283. template: `
  1284. <div class="ss-datetime-picker ss-mobile-component" :class="{ disabled: disabled }">
  1285. <!-- 隐藏的input用于ssVm校验 -->
  1286. <input type="hidden" :name="name" :value="modelValue" />
  1287. <div class="datetime-picker-display" @click="openPicker">
  1288. <span class="datetime-picker-value" :class="{ placeholder: !modelValue }">{{ displayText }}</span>
  1289. </div>
  1290. <!-- 日期选择器 -->
  1291. <van-popup v-model:show="showPicker" position="bottom" :style="{ zIndex: 10000 }">
  1292. <van-date-picker
  1293. v-if="mode === 'date' || mode === 'datetime'"
  1294. v-model="currentDateArray"
  1295. :min-date="minDateObj"
  1296. :max-date="maxDateObj"
  1297. @confirm="onConfirm"
  1298. @cancel="onCancel"
  1299. title="选择日期"
  1300. />
  1301. <van-time-picker
  1302. v-if="mode === 'time'"
  1303. v-model="currentTimeArray"
  1304. @confirm="onConfirm"
  1305. @cancel="onCancel"
  1306. title="选择时间"
  1307. />
  1308. </van-popup>
  1309. <!-- 时间选择器(datetime 模式的第二步) -->
  1310. <van-popup v-model:show="showTimePicker" position="bottom" :style="{ zIndex: 10000 }">
  1311. <van-time-picker
  1312. v-model="currentTimeArray"
  1313. @confirm="onTimeConfirm"
  1314. @cancel="() => { showTimePicker = false; }"
  1315. title="选择时间"
  1316. />
  1317. </van-popup>
  1318. </div>
  1319. `,
  1320. };
  1321. const SsConfirm = {
  1322. name: 'SsConfirm',
  1323. props: {
  1324. modelValue: { type: Boolean, default: false },
  1325. title: { type: String, default: '确认' },
  1326. content: { type: String, default: '' },
  1327. maskClosable: { type: Boolean, default: true },
  1328. },
  1329. emits: ['update:modelValue', 'confirm', 'cancel', 'close'],
  1330. setup(props, { emit }) {
  1331. // 监听弹窗显示状态,控制 body 滚动
  1332. watch(() => props.modelValue, (newVal) => {
  1333. console.log('🔔 SsConfirm modelValue 变化:', newVal);
  1334. if (newVal) {
  1335. document.body.classList.add('modal-open');
  1336. } else {
  1337. document.body.classList.remove('modal-open');
  1338. }
  1339. }, { immediate: true }); // 添加 immediate 选项
  1340. const close = () => {
  1341. console.log('🚪 关闭确认弹窗');
  1342. emit('update:modelValue', false);
  1343. emit('close');
  1344. };
  1345. const onMask = () => {
  1346. console.log('👆 点击了遮罩层');
  1347. if (props.maskClosable) close();
  1348. };
  1349. const onCancel = () => {
  1350. console.log('❌ 点击了取消按钮');
  1351. emit('cancel');
  1352. close();
  1353. };
  1354. const onConfirm = () => {
  1355. console.log('✅ 点击了确认按钮,触发 confirm 事件');
  1356. emit('confirm');
  1357. close();
  1358. };
  1359. return { onMask, onCancel, onConfirm };
  1360. },
  1361. template: `
  1362. <div v-if="modelValue" class="ss-confirm">
  1363. <div class="confirm-mask" @click="onMask"></div>
  1364. <div class="confirm-content">
  1365. <div class="confirm-header" v-if="title">
  1366. <div class="header-title">{{ title }}</div>
  1367. </div>
  1368. <div class="header-line" v-if="title"></div>
  1369. <div class="confirm-body">
  1370. <div v-if="content" class="confirm-content-text" v-html="content"></div>
  1371. <div class="confirm-slot-content"><slot /></div>
  1372. </div>
  1373. <div class="confirm-bottom">
  1374. <button class="confirm-btn confirm-btn-cancel" @click.stop="onCancel">取消</button>
  1375. <button class="confirm-btn confirm-btn-confirm" @click.stop="onConfirm">确认</button>
  1376. </div>
  1377. </div>
  1378. </div>
  1379. `,
  1380. };
  1381. // ===== SsImageCropper 纯裁剪组件 =====
  1382. const SsImageCropper = {
  1383. name: 'SsImageCropper',
  1384. props: {
  1385. // 是否显示裁剪器
  1386. show: { type: Boolean, default: false },
  1387. // 图片源(base64 或 URL)
  1388. src: { type: String, required: true },
  1389. // 图片形状:circle圆形 | square方形
  1390. shape: { type: String, required: true },
  1391. // 裁剪比例(宽/高)
  1392. aspectRatio: { type: Number, default: 1 },
  1393. // 输出图片宽度
  1394. outputWidth: { type: Number, default: 300 },
  1395. // 输出图片高度
  1396. outputHeight: { type: Number, default: 300 },
  1397. },
  1398. emits: ['update:show', 'confirm', 'cancel'],
  1399. setup(props, { emit }) {
  1400. const cropperInstance = ref(null);
  1401. // 监听 show 变化,初始化或销毁 Cropper
  1402. watch(() => props.show, (newVal) => {
  1403. if (newVal) {
  1404. // 等待 DOM 更新后初始化
  1405. Vue.nextTick(() => {
  1406. initCropper();
  1407. });
  1408. } else {
  1409. destroyCropper();
  1410. }
  1411. });
  1412. // 监听 src 变化,重新初始化 Cropper
  1413. watch(() => props.src, () => {
  1414. if (props.show) {
  1415. Vue.nextTick(() => {
  1416. initCropper();
  1417. });
  1418. }
  1419. });
  1420. // 初始化 Cropper
  1421. const initCropper = () => {
  1422. const imageElement = document.getElementById('ss-image-cropper-img');
  1423. if (!imageElement || !window.Cropper) return;
  1424. // 销毁旧实例
  1425. destroyCropper();
  1426. // 根据 shape 属性添加类名
  1427. const container = document.querySelector('.ss-image-cropper-container');
  1428. if (container) {
  1429. if (props.shape === 'circle') {
  1430. container.classList.add('crop-shape-circle');
  1431. container.classList.remove('crop-shape-square');
  1432. } else {
  1433. container.classList.add('crop-shape-square');
  1434. container.classList.remove('crop-shape-circle');
  1435. }
  1436. }
  1437. cropperInstance.value = new window.Cropper(imageElement, {
  1438. aspectRatio: props.aspectRatio,
  1439. viewMode: 1,
  1440. dragMode: 'move',
  1441. autoCropArea: 0.8,
  1442. restore: false,
  1443. guides: false, // 关闭辅助线
  1444. center: false, // 关闭中心指示器
  1445. highlight: false,
  1446. cropBoxMovable: true,
  1447. cropBoxResizable: true,
  1448. toggleDragModeOnDblclick: false,
  1449. minContainerWidth: window.innerWidth,
  1450. minContainerHeight: window.innerHeight - 50,
  1451. });
  1452. };
  1453. // 销毁 Cropper
  1454. const destroyCropper = () => {
  1455. if (cropperInstance.value) {
  1456. cropperInstance.value.destroy();
  1457. cropperInstance.value = null;
  1458. }
  1459. };
  1460. // 取消裁剪
  1461. const handleCancel = () => {
  1462. emit('update:show', false);
  1463. emit('cancel');
  1464. };
  1465. // 确认裁剪
  1466. const handleConfirm = () => {
  1467. if (!cropperInstance.value) return;
  1468. const canvas = cropperInstance.value.getCroppedCanvas({
  1469. width: props.outputWidth,
  1470. height: props.outputHeight,
  1471. imageSmoothingEnabled: true,
  1472. imageSmoothingQuality: 'high',
  1473. fillColor: '#fff'
  1474. });
  1475. canvas.toBlob((blob) => {
  1476. emit('update:show', false);
  1477. emit('confirm', blob);
  1478. }, 'image/jpeg', 0.9);
  1479. };
  1480. // 处理底部按钮事件
  1481. const handleCropAction = (data) => {
  1482. if (data.action === 'cancel') {
  1483. handleCancel();
  1484. } else if (data.action === 'confirm') {
  1485. handleConfirm();
  1486. }
  1487. };
  1488. return {
  1489. handleCropAction,
  1490. };
  1491. },
  1492. template: `
  1493. <div v-if="show" class="ss-image-cropper-container">
  1494. <!-- 左上角尺寸显示 -->
  1495. <div class="crop-size-display">
  1496. 长: {{ outputWidth }}px<br>宽: {{ outputHeight }}px
  1497. </div>
  1498. <div class="ss-crop-image-container">
  1499. <img id="ss-image-cropper-img" :src="src" />
  1500. </div>
  1501. <!-- 使用 ss-bottom 组件 -->
  1502. <ss-bottom
  1503. :show-shyj="false"
  1504. :buttons="[
  1505. { text: '取消', action: 'cancel' },
  1506. { text: '保存并提交', action: 'confirm' }
  1507. ]"
  1508. @button-click="handleCropAction"
  1509. />
  1510. </div>
  1511. `,
  1512. };
  1513. // ===== SsUploadImage 图片上传裁剪组件(支持单图/多图) =====
  1514. const SsUploadImage = {
  1515. name: 'SsUploadImage',
  1516. props: {
  1517. // v-model 绑定的值(单图:String,多图:Array)
  1518. modelValue: { type: [String, Array], default: '' },
  1519. // 最大上传数量(默认1张,多图时设置大于1)
  1520. max: { type: Number, default: 1 },
  1521. // 是否禁用
  1522. disabled: { type: Boolean, default: false },
  1523. // 图片宽度(像素) - 必填
  1524. width: { type: [Number, String], required: true },
  1525. // 图片高度(像素) - 必填
  1526. height: { type: [Number, String], required: true },
  1527. // 图片形状:circle圆形 | square方形 - 必填
  1528. shape: { type: String, required: true },
  1529. // 裁剪比例(宽/高)
  1530. aspectRatio: { type: Number, default: undefined },
  1531. // 输出图片宽度
  1532. outputWidth: { type: Number, default: 300 },
  1533. // 输出图片高度
  1534. outputHeight: { type: Number, default: 300 },
  1535. },
  1536. emits: ['update:modelValue', 'updated'],
  1537. setup(props, { emit }) {
  1538. const showCropper = ref(false);
  1539. const tempImageSrc = ref('');
  1540. // 图片列表(统一用数组管理)
  1541. const imageList = ref([]);
  1542. // 监听 modelValue 变化,同步到 imageList
  1543. watch(() => props.modelValue, (newVal) => {
  1544. if (props.max === 1) {
  1545. // 单图模式
  1546. imageList.value = newVal ? [newVal] : [];
  1547. } else {
  1548. // 多图模式
  1549. imageList.value = Array.isArray(newVal) ? [...newVal] : (newVal ? [newVal] : []);
  1550. }
  1551. }, { immediate: true });
  1552. // 是否可以继续添加图片
  1553. const canAddMore = computed(() => {
  1554. return imageList.value.length < props.max;
  1555. });
  1556. // 容器样式
  1557. const itemStyle = computed(() => ({
  1558. width: typeof props.width === 'number' ? `${props.width}px` : props.width,
  1559. height: typeof props.height === 'number' ? `${props.height}px` : props.height,
  1560. borderRadius: props.shape === 'circle' ? '50%' : '8px',
  1561. }));
  1562. // 获取图片 URL(用于显示)
  1563. const getImageUrl = (path) => {
  1564. if (!path) return '/static/images/yishuzhao_nv.svg';
  1565. if (path.startsWith('http') || path.startsWith('blob:')) {
  1566. return path;
  1567. }
  1568. return window.SS.utils?.getImageUrl?.(path) || path;
  1569. };
  1570. // 选择图片
  1571. const selectImage = () => {
  1572. if (props.disabled || !canAddMore.value) return;
  1573. const input = document.createElement('input');
  1574. input.type = 'file';
  1575. input.accept = 'image/*';
  1576. input.onchange = (e) => {
  1577. const file = e.target.files[0];
  1578. if (file) {
  1579. const reader = new FileReader();
  1580. reader.onload = (event) => {
  1581. tempImageSrc.value = event.target.result;
  1582. showCropper.value = true;
  1583. };
  1584. reader.readAsDataURL(file);
  1585. }
  1586. };
  1587. input.click();
  1588. };
  1589. // 删除图片
  1590. const deleteImage = (index) => {
  1591. const newList = imageList.value.filter((_, i) => i !== index);
  1592. updateModelValue(newList);
  1593. };
  1594. // 确认裁剪
  1595. const handleCropConfirm = async (blob) => {
  1596. try {
  1597. const serverPath = await uploadFile(blob, 'image', 'image.jpg');
  1598. const newList = [...imageList.value, serverPath];
  1599. updateModelValue(newList);
  1600. emit('updated', serverPath);
  1601. } catch (error) {
  1602. console.error('上传失败:', error);
  1603. }
  1604. };
  1605. // 取消裁剪
  1606. const handleCropCancel = () => {
  1607. tempImageSrc.value = '';
  1608. };
  1609. // 更新 modelValue
  1610. const updateModelValue = (list) => {
  1611. if (props.max === 1) {
  1612. // 单图模式:返回 String
  1613. emit('update:modelValue', list[0] || '');
  1614. } else {
  1615. // 多图模式:返回 Array
  1616. emit('update:modelValue', list);
  1617. }
  1618. };
  1619. return {
  1620. imageList,
  1621. canAddMore,
  1622. itemStyle,
  1623. showCropper,
  1624. tempImageSrc,
  1625. getImageUrl,
  1626. selectImage,
  1627. deleteImage,
  1628. handleCropConfirm,
  1629. handleCropCancel,
  1630. };
  1631. },
  1632. template: `
  1633. <div class="ss-upload-image-multi">
  1634. <!-- 图片列表 -->
  1635. <div class="image-list">
  1636. <!-- 已上传的图片 -->
  1637. <div
  1638. v-for="(img, index) in imageList"
  1639. :key="index"
  1640. class="image-item"
  1641. :style="itemStyle"
  1642. >
  1643. <img :src="getImageUrl(img)" class="image-display" />
  1644. <!-- 删除按钮 -->
  1645. <div v-if="!disabled" class="image-delete" @click.stop="deleteImage(index)">
  1646. <Icon name="icon-guanbi" size="32" color="#fff" />
  1647. </div>
  1648. </div>
  1649. <!-- 添加按钮 -->
  1650. <div
  1651. v-if="canAddMore && !disabled"
  1652. class="image-item image-add"
  1653. :style="itemStyle"
  1654. @click="selectImage"
  1655. >
  1656. <Icon name="icon-xiangji" size="48" color="#ccc" />
  1657. <div class="add-text">{{ imageList.length }}/{{ max }}</div>
  1658. </div>
  1659. </div>
  1660. <!-- 裁剪组件 -->
  1661. <ss-image-cropper
  1662. v-model:show="showCropper"
  1663. :src="tempImageSrc"
  1664. :shape="shape"
  1665. :aspect-ratio="aspectRatio"
  1666. :output-width="outputWidth"
  1667. :output-height="outputHeight"
  1668. @confirm="handleCropConfirm"
  1669. @cancel="handleCropCancel"
  1670. />
  1671. </div>
  1672. `,
  1673. };
  1674. // ===== SsCarCard 车辆卡片组件 =====
  1675. const SsCarCard = {
  1676. name: 'SsCarCard',
  1677. props: {
  1678. // 车辆数据
  1679. carData: {
  1680. type: Object,
  1681. default: () => ({})
  1682. },
  1683. // 车辆状态:'available' | 'reserved' | 'disabled'
  1684. status: {
  1685. type: String,
  1686. default: 'available',
  1687. validator: (value) => ['available', 'reserved', 'disabled'].includes(value)
  1688. }
  1689. },
  1690. emits: ['click', 'select'],
  1691. setup(props, { emit }) {
  1692. // 计算状态样式类
  1693. const statusClass = computed(() => {
  1694. return `status-${props.status}`;
  1695. });
  1696. // 获取图片URL
  1697. const getImageUrl = (path) => {
  1698. if (!path) return '/static/images/default-car.png';
  1699. if (path.startsWith('http') || path.startsWith('blob:')) {
  1700. return path;
  1701. }
  1702. return window.SS.utils?.getImageUrl?.(path) || path;
  1703. };
  1704. // 处理卡片点击
  1705. const handleCardClick = () => {
  1706. if (props.status === 'disabled') {
  1707. return; // 禁用状态不响应点击
  1708. }
  1709. emit('click', props.carData);
  1710. emit('select', props.carData);
  1711. };
  1712. return {
  1713. statusClass,
  1714. getImageUrl,
  1715. handleCardClick,
  1716. };
  1717. },
  1718. template: `
  1719. <div class="car-card" :class="statusClass" @click="handleCardClick">
  1720. <!-- 第一行:车辆名称 -->
  1721. <div class="car-title">
  1722. {{ carData.name || '别克GL8' }}
  1723. </div>
  1724. <!-- 第二行:左右结构 -->
  1725. <div class="car-info">
  1726. <!-- 左边:车辆图片 -->
  1727. <div class="car-image-container">
  1728. <img class="car-image" :src="getImageUrl(carData.image)" />
  1729. </div>
  1730. <!-- 右边:车辆信息 -->
  1731. <div class="car-details">
  1732. <div class="detail-item car-name" v-if="carData.wph">
  1733. {{ carData.wph }}
  1734. </div>
  1735. <div class="detail-item seats" v-for="(wp, index) in carData.wpcsList" :key="index">
  1736. {{ wp.mc }} : {{ wp.sz || wp.zf }}
  1737. </div>
  1738. <div class="detail-item car-type">
  1739. {{ carData.type || '商务车' }}
  1740. </div>
  1741. </div>
  1742. </div>
  1743. </div>
  1744. `,
  1745. };
  1746. // ===== SsSubTab 移动端Tab组件 =====
  1747. const SsSubTab = {
  1748. name: 'SsSubTab',
  1749. props: {
  1750. // Tab列表数据
  1751. tabList: {
  1752. type: Array,
  1753. required: true,
  1754. },
  1755. // 当前激活的Tab索引
  1756. activeIndex: {
  1757. type: Number,
  1758. default: 0,
  1759. },
  1760. // 基础URL参数(会传递给每个iframe)
  1761. baseParams: {
  1762. type: Object,
  1763. default: () => ({}),
  1764. },
  1765. },
  1766. emits: ['tab-change'],
  1767. setup(props, { emit }) {
  1768. const currentTab = ref(props.activeIndex);
  1769. const currentTabUrl = ref('');
  1770. // 加载Tab对应的URL
  1771. const loadTabUrl = (index) => {
  1772. const tab = props.tabList[index];
  1773. if (!tab || !tab.dest) return;
  1774. // 构建iframe URL:mp_ + dest + .html
  1775. const fileName = `mp_${tab.dest}.html`;
  1776. // 构建完整URL,包含所有参数
  1777. const params = new URLSearchParams({
  1778. ...props.baseParams,
  1779. service: tab.service || '',
  1780. param: tab.param || '',
  1781. });
  1782. currentTabUrl.value = `/page/${fileName}?${params.toString()}`;
  1783. console.log('🔄 切换到Tab:', tab.desc || tab.title, '加载页面:', currentTabUrl.value);
  1784. };
  1785. // 监听 activeIndex 变化
  1786. watch(() => props.activeIndex, (newIndex) => {
  1787. currentTab.value = newIndex;
  1788. loadTabUrl(newIndex);
  1789. }, { immediate: true });
  1790. // 监听 tabList 变化
  1791. watch(() => props.tabList, () => {
  1792. if (props.tabList.length > 0 && currentTab.value === 0) {
  1793. loadTabUrl(0);
  1794. }
  1795. }, { immediate: true });
  1796. // 切换Tab
  1797. const handleTabClick = (index) => {
  1798. if (currentTab.value === index) return;
  1799. currentTab.value = index;
  1800. loadTabUrl(index);
  1801. emit('tab-change', { index, tab: props.tabList[index] });
  1802. };
  1803. return {
  1804. currentTab,
  1805. currentTabUrl,
  1806. handleTabClick,
  1807. };
  1808. },
  1809. template: `
  1810. <div class="ss-sub-tab">
  1811. <!-- Tab 栏 -->
  1812. <div class="ss-sub-tab__bar" v-if="tabList.length > 0">
  1813. <div
  1814. v-for="(tab, index) in tabList"
  1815. :key="index"
  1816. class="ss-sub-tab__item"
  1817. :class="{ 'ss-sub-tab__item--active': currentTab === index }"
  1818. @click="handleTabClick(index)"
  1819. >
  1820. {{ tab.desc || tab.title }}
  1821. </div>
  1822. </div>
  1823. <!-- 内容区域 -->
  1824. <div class="ss-sub-tab__content" v-if="currentTabUrl">
  1825. <iframe :src="currentTabUrl" frameborder="0"></iframe>
  1826. </div>
  1827. </div>
  1828. `,
  1829. };
  1830. // ===== SsUploadFile 文件上传组件(支持单文件/多文件) =====
  1831. const SsUploadFile = {
  1832. name: 'SsUploadFile',
  1833. props: {
  1834. // v-model 绑定的值(单文件:String,多文件:Array)
  1835. modelValue: { type: [String, Array], default: '' },
  1836. // 最大上传数量(默认1个)
  1837. max: { type: Number, default: 1 },
  1838. // 是否禁用
  1839. disabled: { type: Boolean, default: false },
  1840. // 允许的文件类型(默认常见文件类型)
  1841. accept: { type: String, default: '.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar' },
  1842. // 最大文件大小(MB,默认5MB)
  1843. maxSize: { type: Number, default: 5 },
  1844. },
  1845. emits: ['update:modelValue', 'uploaded'],
  1846. setup(props, { emit }) {
  1847. // 文件列表(统一用数组管理)
  1848. const fileList = ref([]);
  1849. // 监听 modelValue 变化,同步到 fileList
  1850. watch(() => props.modelValue, (newVal) => {
  1851. if (props.max === 1) {
  1852. // 单文件模式
  1853. fileList.value = newVal ? [{ path: newVal, name: getFileName(newVal) }] : [];
  1854. } else {
  1855. // 多文件模式
  1856. if (Array.isArray(newVal)) {
  1857. fileList.value = newVal.map(path => ({ path, name: getFileName(path) }));
  1858. } else {
  1859. fileList.value = newVal ? [{ path: newVal, name: getFileName(newVal) }] : [];
  1860. }
  1861. }
  1862. }, { immediate: true });
  1863. // 是否可以继续添加文件
  1864. const canAddMore = computed(() => {
  1865. return fileList.value.length < props.max;
  1866. });
  1867. // 从路径中提取文件名
  1868. const getFileName = (path) => {
  1869. if (!path) return '';
  1870. const parts = path.split('/');
  1871. return parts[parts.length - 1];
  1872. };
  1873. // 获取文件图标
  1874. const getFileIcon = (fileName) => {
  1875. const ext = fileName.split('.').pop().toLowerCase();
  1876. const iconMap = {
  1877. pdf: 'icon-pdf',
  1878. doc: 'icon-word',
  1879. docx: 'icon-word',
  1880. xls: 'icon-excel',
  1881. xlsx: 'icon-excel',
  1882. txt: 'icon-txt',
  1883. zip: 'icon-zip',
  1884. rar: 'icon-zip',
  1885. };
  1886. return iconMap[ext] || 'icon-wenjian';
  1887. };
  1888. // 选择文件
  1889. const selectFile = () => {
  1890. if (props.disabled || !canAddMore.value) return;
  1891. const input = document.createElement('input');
  1892. input.type = 'file';
  1893. input.accept = props.accept;
  1894. input.onchange = async (e) => {
  1895. const file = e.target.files[0];
  1896. if (!file) return;
  1897. // 检查文件大小
  1898. const fileSizeMB = file.size / 1024 / 1024;
  1899. if (fileSizeMB > props.maxSize) {
  1900. window.showToast?.(`文件大小不能超过${props.maxSize}MB`, 'error');
  1901. return;
  1902. }
  1903. // 上传文件
  1904. try {
  1905. const serverPath = await uploadFile(file, 'file', file.name);
  1906. const newList = [...fileList.value, { path: serverPath, name: file.name }];
  1907. updateModelValue(newList);
  1908. emit('uploaded', serverPath);
  1909. } catch (error) {
  1910. console.error('文件上传失败:', error);
  1911. }
  1912. };
  1913. input.click();
  1914. };
  1915. // 删除文件
  1916. const deleteFile = (index) => {
  1917. const newList = fileList.value.filter((_, i) => i !== index);
  1918. updateModelValue(newList);
  1919. };
  1920. // 下载文件
  1921. const downloadFile = (file) => {
  1922. const url = window.SS.utils?.getFileUrl?.(file.path) || window.getFileUrl?.(file.path) || file.path;
  1923. window.open(url, '_blank');
  1924. };
  1925. // 更新 modelValue
  1926. const updateModelValue = (list) => {
  1927. const paths = list.map(f => f.path);
  1928. if (props.max === 1) {
  1929. // 单文件模式:返回 String
  1930. emit('update:modelValue', paths[0] || '');
  1931. } else {
  1932. // 多文件模式:返回 Array
  1933. emit('update:modelValue', paths);
  1934. }
  1935. };
  1936. return {
  1937. fileList,
  1938. canAddMore,
  1939. getFileIcon,
  1940. selectFile,
  1941. deleteFile,
  1942. downloadFile,
  1943. };
  1944. },
  1945. template: `
  1946. <div class="ss-upload-file">
  1947. <!-- 文件列表 -->
  1948. <div v-if="fileList.length > 0" class="file-list">
  1949. <!-- 已上传的文件 -->
  1950. <div
  1951. v-for="(file, index) in fileList"
  1952. :key="index"
  1953. class="file-item"
  1954. >
  1955. <div class="file-icon">
  1956. <Icon :name="getFileIcon(file.name)" size="40" color="#40ac6d" />
  1957. </div>
  1958. <div class="file-info" @click="downloadFile(file)">
  1959. <span class="file-name">{{ file.name }}</span>
  1960. </div>
  1961. <!-- 文件操作按钮 -->
  1962. <div class="file-actions">
  1963. <div class="file-download" @click="downloadFile(file)">
  1964. <Icon name="icon-xiazai" size="32" color="#40ac6d" />
  1965. </div>
  1966. <div v-if="!disabled" class="file-delete" @click.stop="deleteFile(index)">
  1967. <Icon name="icon-guanbi" size="32" color="#ff3b30" />
  1968. </div>
  1969. </div>
  1970. </div>
  1971. </div>
  1972. <!-- 添加按钮 -->
  1973. <div
  1974. v-if="canAddMore && !disabled"
  1975. class="file-add-button"
  1976. @click="selectFile"
  1977. >
  1978. <div class="add-icon">
  1979. <Icon name="icon-tianjia" size="64" color="#999" />
  1980. </div>
  1981. <span class="add-text">上传文件 ({{ fileList.length }}/{{ max }})</span>
  1982. <span class="file-tip">支持格式: {{ accept }},最大{{ maxSize }}MB</span>
  1983. </div>
  1984. </div>
  1985. `,
  1986. };
  1987. window.SS.dom.initializeFormApp = function (config) {
  1988. const { el, ...vueOptions } = config;
  1989. const app = createApp({
  1990. ...vueOptions,
  1991. });
  1992. // 注册组件
  1993. // app.component("SsLoginIcon", SsLoginIcon);
  1994. // app.component("SsMark", SsMark);
  1995. // app.component("SsFullStyleHeader", SsFullStyleHeader);
  1996. // app.component("SsDialog", SsDialog);
  1997. app.component('SsInput', SsInput);
  1998. app.component('SsBottom', SsBottom);
  1999. app.component('SsCard', SsCard);
  2000. app.component('SsSearchButton', SsSearchButton);
  2001. app.component('SsSelect', SsSelect);
  2002. app.component('Icon', Icon);
  2003. // 注册 Vant 组件
  2004. const vantLib = window.vant || window.Vant;
  2005. console.log('🔍 检查 Vant:', {
  2006. hasVant: !!window.vant,
  2007. hasVantCap: !!window.Vant,
  2008. vantLib: !!vantLib,
  2009. vantKeys: vantLib ? Object.keys(vantLib).slice(0, 20) : [],
  2010. hasPopup: vantLib?.Popup,
  2011. hasDatetimePicker: vantLib?.DatetimePicker,
  2012. hasDatePicker: vantLib?.DatePicker,
  2013. hasTimePicker: vantLib?.TimePicker,
  2014. allKeys: vantLib ? Object.keys(vantLib) : []
  2015. });
  2016. if (vantLib) {
  2017. try {
  2018. // 使用 Vant 的 use 方法注册所有组件
  2019. app.use(vantLib);
  2020. console.log('✅ Vant 全部组件注册成功');
  2021. } catch (error) {
  2022. console.error('❌ Vant 组件注册失败:', error);
  2023. // 降级方案:手动注册具体组件
  2024. try {
  2025. app.component('van-popup', vantLib.Popup);
  2026. app.component('van-datetime-picker', vantLib.DatetimePicker);
  2027. console.log('✅ Vant 手动注册成功');
  2028. } catch (e) {
  2029. console.error('❌ Vant 手动注册也失败:', e);
  2030. }
  2031. }
  2032. } else {
  2033. console.warn('⚠️ Vant 未加载');
  2034. }
  2035. // 注册组件 - 统一使用 kebab-case
  2036. app.component('ss-verify', SsVerify);
  2037. app.component('ss-verify-node', SsVerifyNode);
  2038. app.component('ss-common-icon', SsCommonIcon);
  2039. app.component('ss-onoff-button', SsOnoffButton);
  2040. app.component('ss-datetime-picker', SsDatetimePicker);
  2041. app.component('ss-confirm', SsConfirm);
  2042. app.component('ss-image-cropper', SsImageCropper);
  2043. app.component('ss-upload-image', SsUploadImage);
  2044. app.component('ss-upload-file', SsUploadFile);
  2045. app.component('ss-car-card', SsCarCard);
  2046. app.component('ss-sub-tab', SsSubTab);
  2047. // app.component("SsObjp", SsObjp);
  2048. // app.component("SsHidden", SsHidden);
  2049. // app.component("SsCcp", SsCcp);
  2050. // app.component("SsDatePicker", SsDatePicker);
  2051. // app.component("SsIcon", SsIcon);
  2052. // app.component("SsCommonIcon", SsCommonIcon);
  2053. // app.component("SsBreadcrumb", SsBreadcrumb);
  2054. // app.component("SsEditor", SsEditor);
  2055. // app.component("SsDialogIcon", SsDialogIcon);
  2056. // app.component("SsBottomButton", SsBottomButton);
  2057. // app.component("SsNavIcon", SsNavIcon);
  2058. // app.component("SsHeaderIcon", SsHeaderIcon);
  2059. // app.component("SsGolbalMenuIcon", SsGolbalMenuIcon);
  2060. // app.component("SsCartListIcon", SsCartListIcon);
  2061. // app.component("SsQuickIcon", SsQuickIcon);
  2062. // app.component("SsFormIcon", SsFormIcon);
  2063. // app.component("SsBottomDivIcon", SsBottomDivIcon);
  2064. // app.component("SsEditorIcon", SsEditorIcon);
  2065. // app.component("SsValidate", SsValidate);
  2066. // app.component("SsOnoffbutton", SsOnoffbutton);
  2067. // app.component("SsOnoffbuttonArray", SsOnoffbuttonArray);
  2068. // app.component("SsTextarea", SsTextarea);
  2069. // app.component("SsLoginInput", SsLoginInput);
  2070. // app.component("SsLoginButton", SsLoginButton);
  2071. // app.component("SsSearch", SsSearch);
  2072. // app.component("SsCartItem", SsCartItem);
  2073. // app.component("SsCartItem2", SsCartItem2);
  2074. // app.component("SsListCard", SsListCard);
  2075. // app.component("SsFolderCard", SsFolderCard);
  2076. // app.component("SsFolderCartView", SsFolderCartView);
  2077. // app.component("SsPage", SsPage);
  2078. // app.component("SsRightInfo", SSRightInfo);
  2079. // app.component("SsSuccessPopup", SsSuccessPopup);
  2080. // app.component("SsErrorDialog", SsErrorDialog);
  2081. // app.component("SsVerify", SsVerify);
  2082. // app.component("SsVerifyNode", SsVerifyNode);
  2083. // app.component("SsOrcImgBox", SsOrcImgBox);
  2084. // app.component("ss-search-input", SsSearchInput);
  2085. // app.component("ss-search-date-picker", SsSearchDatePicker);
  2086. // app.component("ss-search-button", SsSearchButton);
  2087. // app.component("ss-drop-button", SsDropButton);
  2088. // app.component("ss-sub-tab", SsSubTab);
  2089. // app.component("ss-img", SsImgUpload);
  2090. // 设置为中文
  2091. // app.use(ElementPlus, {
  2092. // locale: ElementPlusLocaleZhCn,
  2093. // });
  2094. // console.log(ElementPlus);
  2095. // 确保 ElementPlusIconsVue
  2096. // if (window.ElementPlusIconsVue) {
  2097. // // 注册 Element Plus 图标组件
  2098. // for (const [key, component] of Object.entries(
  2099. // window.ElementPlusIconsVue
  2100. // )) {
  2101. // console.log(key, component);
  2102. // app.component(key, component);
  2103. // }
  2104. // }
  2105. // 挂载首页的组件
  2106. // for (const componentName in IndexComponents) {
  2107. // app.component(componentName, IndexComponents[componentName]);
  2108. // }
  2109. // 挂载echarts的组件
  2110. // for (const componentName in EchartComponents) {
  2111. // app.component(componentName, EchartComponents[componentName]);
  2112. // }
  2113. // 挂载 Vue 应用
  2114. const vm = app.mount(el);
  2115. vm.data = vueOptions.data();
  2116. return vm;
  2117. };
  2118. })();