mp-ss-components.js 97 KB

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