mp-ss-components.js 97 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349
  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 filterObj = parseFilterObj();
  964. const objpParam = {
  965. input: String(canInput.value),
  966. codebook: String(props.cb || ""),
  967. ...filterObj,
  968. };
  969. const postData = {
  970. objectpickerparam: JSON.stringify(objpParam),
  971. objectpickertype: 1,
  972. objectpickersearchAll: 1,
  973. };
  974. // 功能说明:xyByBj 人员码本按班级级联时,补充固定 cascadingLevel 和上级班级值 by xu 2026-03-18
  975. if (String(props.cb || "") === "xyByBj" && filterObj.bjid) {
  976. objpParam.cascadingLevel = "bjid,ryid";
  977. postData.objectpickerparam = JSON.stringify(objpParam);
  978. postData.upperValue = filterObj.bjid;
  979. }
  980. const resp = await window.request.post(
  981. props.url || "/service?ssServ=loadObjpOpt&objectpickerdropdown1=1",
  982. postData,
  983. { loading: false, formData: true }
  984. );
  985. const opts = normalizeResultToOptions(
  986. resp && resp.data ? resp.data : null
  987. );
  988. remoteOptions.value = opts;
  989. emit("loaded", opts);
  990. maybeAutoSelectFirst(opts);
  991. if (!isOpen.value) {
  992. syncInputFromSelection();
  993. }
  994. } catch (e) {
  995. remoteOptions.value = [];
  996. emit("loaded", []);
  997. console.error("[ss-select] loadRemoteOptions failed", props.cb, e);
  998. } finally {
  999. remoteLoading.value = false;
  1000. }
  1001. };
  1002. const finalLoading = computed(() => {
  1003. return !!props.loading || remoteLoading.value;
  1004. });
  1005. const selectContainerStyle = computed(() => {
  1006. const style = {};
  1007. const widthText = normalizeCssSize(props.width);
  1008. const minWidthText = normalizeCssSize(props.minWidth);
  1009. // 功能说明:ss-select 在 td 等窄容器中限制最大宽度,避免自动 minWidth 把父布局撑开 by xu 2026-03-07
  1010. style.maxWidth = "100%";
  1011. if (hasExplicitWidth.value) {
  1012. style.width = widthText;
  1013. }
  1014. if (minWidthText && minWidthText !== "unset") {
  1015. style.minWidth = `min(${minWidthText}, 100%)`;
  1016. } else if (autoMinWidth.value) {
  1017. style.minWidth = `min(${autoMinWidth.value}, 100%)`;
  1018. }
  1019. return style;
  1020. });
  1021. const displayText = computed(() => {
  1022. if (!selectedValue.value) return props.placeholder;
  1023. const selectedOption = findSelectedOption();
  1024. return selectedOption
  1025. ? getOptionText(selectedOption)
  1026. : props.placeholder;
  1027. });
  1028. const enterInputFilterMode = () => {
  1029. if (!canInput.value) return;
  1030. const selectedOption = findSelectedOption();
  1031. const selectedText = selectedOption
  1032. ? getOptionText(selectedOption)
  1033. : "";
  1034. if (!filterKeyword.value && searchKeyword.value === selectedText) {
  1035. searchKeyword.value = "";
  1036. }
  1037. filterKeyword.value = searchKeyword.value;
  1038. forceShowAll.value = false;
  1039. };
  1040. const openDropdown = (showAll = false) => {
  1041. if (props.disabled) return;
  1042. if (activeSsSelectCloser && activeSsSelectCloser !== closeDropdown) {
  1043. activeSsSelectCloser({ restoreInput: true });
  1044. }
  1045. forceShowAll.value = showAll;
  1046. isOpen.value = true;
  1047. activeSsSelectCloser = closeDropdown;
  1048. };
  1049. const closeDropdown = ({ restoreInput = true } = {}) => {
  1050. isOpen.value = false;
  1051. forceShowAll.value = false;
  1052. if (activeSsSelectCloser === closeDropdown) {
  1053. activeSsSelectCloser = null;
  1054. }
  1055. if (restoreInput) {
  1056. syncInputFromSelection();
  1057. }
  1058. };
  1059. const toggleDropdown = () => {
  1060. if (props.disabled) return;
  1061. if (isOpen.value) {
  1062. closeDropdown();
  1063. } else {
  1064. openDropdown(false);
  1065. }
  1066. };
  1067. const handleContainerClick = () => {
  1068. if (props.disabled || canInput.value) return;
  1069. toggleDropdown();
  1070. };
  1071. const handleTextClick = () => {
  1072. if (props.disabled) return;
  1073. if (!canInput.value) {
  1074. toggleDropdown();
  1075. return;
  1076. }
  1077. enterInputFilterMode();
  1078. openDropdown(false);
  1079. if (inputRef.value) {
  1080. inputRef.value.focus();
  1081. }
  1082. };
  1083. const handleInputFocus = () => {
  1084. if (props.disabled || !canInput.value) return;
  1085. enterInputFilterMode();
  1086. openDropdown(false);
  1087. };
  1088. const handleInput = (event) => {
  1089. const value = event?.target?.value || "";
  1090. searchKeyword.value = value;
  1091. filterKeyword.value = value;
  1092. forceShowAll.value = false;
  1093. isOpen.value = true;
  1094. emit("search", value);
  1095. };
  1096. const handleArrowClick = () => {
  1097. if (props.disabled) return;
  1098. if (!canInput.value) {
  1099. toggleDropdown();
  1100. return;
  1101. }
  1102. filterKeyword.value = "";
  1103. forceShowAll.value = true;
  1104. isOpen.value = true;
  1105. };
  1106. const selectOption = (option) => {
  1107. const value = option[props.mapping.value];
  1108. selectedValue.value = value;
  1109. searchKeyword.value = getOptionText(option);
  1110. filterKeyword.value = "";
  1111. forceShowAll.value = false;
  1112. isOpen.value = false;
  1113. emit("update:modelValue", value);
  1114. emit("change", value);
  1115. };
  1116. const handleClickOutside = (event) => {
  1117. if (!containerRef.value?.contains(event.target)) {
  1118. closeDropdown();
  1119. }
  1120. };
  1121. watch(
  1122. () => props.modelValue,
  1123. (newValue) => {
  1124. selectedValue.value = newValue;
  1125. if (!isOpen.value) {
  1126. syncInputFromSelection();
  1127. }
  1128. },
  1129. { immediate: true }
  1130. );
  1131. watch(
  1132. () => props.options,
  1133. (newVal) => {
  1134. if (needRemoteData.value) return;
  1135. emit("loaded", Array.isArray(newVal) ? newVal : []);
  1136. if (!isOpen.value) {
  1137. syncInputFromSelection();
  1138. }
  1139. }
  1140. );
  1141. watch(
  1142. () => [props.cb, props.url, props.filter],
  1143. () => {
  1144. if (!needRemoteData.value) return;
  1145. loadRemoteOptions();
  1146. }
  1147. );
  1148. watch(
  1149. () => [
  1150. props.placeholder,
  1151. props.width,
  1152. props.minWidth,
  1153. optionsList.value
  1154. .map((option) => option?.[props.mapping.text])
  1155. .join("||"),
  1156. ],
  1157. () => {
  1158. measureStableMinWidth();
  1159. },
  1160. { immediate: true }
  1161. );
  1162. watch(
  1163. () => optionsList.value,
  1164. () => {
  1165. if (!isOpen.value) {
  1166. syncInputFromSelection();
  1167. }
  1168. },
  1169. { deep: true }
  1170. );
  1171. onMounted(() => {
  1172. document.addEventListener("click", handleClickOutside);
  1173. loadRemoteOptions();
  1174. if (!needRemoteData.value && Array.isArray(props.options)) {
  1175. emit("loaded", props.options);
  1176. }
  1177. syncInputFromSelection();
  1178. measureStableMinWidth();
  1179. });
  1180. onBeforeUnmount(() => {
  1181. if (activeSsSelectCloser === closeDropdown) {
  1182. activeSsSelectCloser = null;
  1183. }
  1184. document.removeEventListener("click", handleClickOutside);
  1185. });
  1186. return {
  1187. containerRef,
  1188. inputRef,
  1189. isOpen,
  1190. selectedValue,
  1191. searchKeyword,
  1192. canInput,
  1193. filteredOptions,
  1194. finalLoading,
  1195. displayText,
  1196. selectContainerStyle,
  1197. handleContainerClick,
  1198. handleTextClick,
  1199. handleInputFocus,
  1200. handleInput,
  1201. handleArrowClick,
  1202. selectOption,
  1203. };
  1204. },
  1205. template: `
  1206. <div
  1207. ref="containerRef"
  1208. class="ss-select-container"
  1209. :class="{ open: isOpen }"
  1210. :style="selectContainerStyle"
  1211. @click.stop="handleContainerClick"
  1212. >
  1213. <div class="ss-select" :class="{ disabled: disabled }">
  1214. <div class="select-text" @click.stop="handleTextClick">
  1215. <input
  1216. v-if="canInput"
  1217. ref="inputRef"
  1218. :type="'text'"
  1219. :value="searchKeyword"
  1220. :placeholder="placeholder"
  1221. :disabled="disabled"
  1222. @focus.stop="handleInputFocus"
  1223. @click.stop="handleTextClick"
  1224. @input="handleInput"
  1225. style="width: 100%; border: none; outline: none; background: transparent; color: inherit; font-size: inherit; padding: 0; margin: 0;"
  1226. />
  1227. <span v-else :class="{ placeholder: !selectedValue }">{{ displayText }}</span>
  1228. </div>
  1229. <div class="select-arrow" :class="{ rotate: isOpen }" @click.stop="handleArrowClick">
  1230. <Icon name="icon-xiangxiajiantou" size="32" :color="disabled ? '#ccc' : '#999'"/>
  1231. </div>
  1232. </div>
  1233. <div class="ss-options" v-show="isOpen">
  1234. <div
  1235. v-if="finalLoading"
  1236. class="option-item loading-item"
  1237. >
  1238. <span class="loading-text">加载中...</span>
  1239. </div>
  1240. <div
  1241. v-else-if="filteredOptions.length === 0"
  1242. class="option-item no-options"
  1243. >
  1244. 无选项
  1245. </div>
  1246. <div
  1247. v-else
  1248. v-for="(option, index) in filteredOptions"
  1249. :key="index"
  1250. class="option-item"
  1251. :class="{ selected: option[mapping.value] === selectedValue }"
  1252. @click.stop="selectOption(option)"
  1253. >
  1254. {{ option[mapping.text] }}
  1255. </div>
  1256. </div>
  1257. </div>
  1258. `,
  1259. };
  1260. // ss-bottom 底部按钮组件
  1261. const SsBottom = {
  1262. name: "SsBottom",
  1263. props: {
  1264. // 是否显示审核意见
  1265. showShyj: {
  1266. type: Boolean,
  1267. default: false,
  1268. },
  1269. // 审核意见标题
  1270. shyjTitle: {
  1271. type: String,
  1272. default: "审核意见",
  1273. },
  1274. // 审核意见占位符
  1275. shyjPlaceholder: {
  1276. type: String,
  1277. default: "请输入审核意见",
  1278. },
  1279. divider: {
  1280. type: Boolean,
  1281. default: true,
  1282. },
  1283. // 按钮配置
  1284. buttons: {
  1285. type: Array,
  1286. default: () => [
  1287. { text: "取消", action: "cancel" },
  1288. { text: "保存并提交", action: "submit" },
  1289. ],
  1290. },
  1291. },
  1292. emits: ["button-click", "update:shyjValue"],
  1293. setup(props, { emit }) {
  1294. const reason = ref("");
  1295. const activeButtonIndex = ref(-1);
  1296. // 处理按钮点击
  1297. const handleButtonClick = (button, index) => {
  1298. emit("button-click", {
  1299. action: button.action,
  1300. button: button,
  1301. index: index,
  1302. shyjValue: reason.value, // 传递审核意见
  1303. });
  1304. };
  1305. // 监听审核意见变化
  1306. const handleShyjInput = (event) => {
  1307. const value = event.target.value;
  1308. reason.value = value;
  1309. emit("update:shyjValue", value);
  1310. };
  1311. // 处理按钮按下
  1312. const handleButtonMouseDown = (index) => {
  1313. activeButtonIndex.value = index;
  1314. };
  1315. // 处理按钮释放
  1316. const handleButtonMouseUp = () => {
  1317. activeButtonIndex.value = -1;
  1318. };
  1319. // 处理鼠标离开
  1320. const handleMouseLeave = () => {
  1321. activeButtonIndex.value = -1;
  1322. };
  1323. // 计算按钮样式
  1324. const getButtonStyle = (button) => {
  1325. const styles = {};
  1326. // 如果有背景颜色配置
  1327. if (button.backgroundColor) {
  1328. styles.backgroundColor = button.backgroundColor;
  1329. }
  1330. // 如果有字体颜色配置
  1331. if (button.color) {
  1332. styles.color = button.color;
  1333. }
  1334. return styles;
  1335. };
  1336. // 计算按钮点击样式
  1337. const getButtonActiveStyle = (button) => {
  1338. const styles = {};
  1339. // 如果有点击背景色配置,使用点击背景色
  1340. if (button.clickBgColor) {
  1341. styles.backgroundColor = button.clickBgColor;
  1342. } else if (button.backgroundColor) {
  1343. // 如果没有点击背景色,但有背景色,点击时使用背景色
  1344. styles.backgroundColor = button.backgroundColor;
  1345. }
  1346. // 如果有点击字体色配置,使用点击字体色
  1347. if (button.clickColor) {
  1348. styles.color = button.clickColor;
  1349. } else if (button.color) {
  1350. // 如果没有点击字体色,但有字体色,点击时使用字体色
  1351. styles.color = button.color;
  1352. }
  1353. return styles;
  1354. };
  1355. return {
  1356. reason,
  1357. activeButtonIndex,
  1358. handleButtonClick,
  1359. handleShyjInput,
  1360. handleButtonMouseDown,
  1361. handleButtonMouseUp,
  1362. handleMouseLeave,
  1363. getButtonStyle,
  1364. getButtonActiveStyle,
  1365. };
  1366. },
  1367. template: `
  1368. <div class="ss-bottom">
  1369. <!-- 审核意见区域 -->
  1370. <div v-if="showShyj" class="ss-bottom__opinion">
  1371. <table class="ss-bottom__opinion-table">
  1372. <tr>
  1373. <th class="ss-bottom__opinion-label">{{ shyjTitle }}</th>
  1374. <td class="ss-bottom__opinion-input">
  1375. <ss-input
  1376. :placeholder="shyjPlaceholder"
  1377. v-model="reason"
  1378. @input="handleShyjInput"
  1379. />
  1380. </td>
  1381. </tr>
  1382. </table>
  1383. </div>
  1384. <!-- 按钮区域 -->
  1385. <div class="ss-bottom__buttons" :class="{ 'ss-bottom__buttons--with-border': !showShyj }">
  1386. <template v-for="(button, index) in buttons" :key="index">
  1387. <div
  1388. class="ss-bottom__button"
  1389. :class="{
  1390. 'ss-bottom__button--custom': button.backgroundColor || button.color || button.clickBgColor || button.clickColor,
  1391. 'ss-bottom__button--active': activeButtonIndex === index
  1392. }"
  1393. :style="activeButtonIndex === index ? getButtonActiveStyle(button) : getButtonStyle(button)"
  1394. @click="handleButtonClick(button, index)"
  1395. @mousedown="handleButtonMouseDown(index)"
  1396. @mouseup="handleButtonMouseUp"
  1397. @mouseleave="handleMouseLeave"
  1398. @touchstart="handleButtonMouseDown(index)"
  1399. @touchend="handleButtonMouseUp"
  1400. >
  1401. {{ button.text }}
  1402. </div>
  1403. <!-- 分割线,最后一个按钮不显示 -->
  1404. <div v-if="index < buttons.length - 1 && divider" class="ss-bottom__divider"></div>
  1405. </template>
  1406. </div>
  1407. </div>
  1408. `,
  1409. };
  1410. // ===== SsVerify 审核节点链组件 =====
  1411. const SsVerify = {
  1412. name: "SsVerify",
  1413. props: {
  1414. verifyList: {
  1415. type: Array,
  1416. required: true,
  1417. },
  1418. },
  1419. setup(props) {
  1420. const toggleOpen = (item) => {
  1421. item.open = !item.open;
  1422. // 切换后重新计算连线高度
  1423. setTimeout(() => {
  1424. calculateLineHeight();
  1425. }, 50);
  1426. };
  1427. // 计算连线高度的函数
  1428. const calculateLineHeight = () => {
  1429. const lastOpenGroup = document.querySelector(".group-item-last-open");
  1430. console.log("lastOpenGroup", lastOpenGroup);
  1431. if (lastOpenGroup) {
  1432. // 使用原生JavaScript代替jQuery
  1433. const nodes = lastOpenGroup.querySelectorAll(
  1434. ".verify-node-container"
  1435. );
  1436. if (nodes.length) {
  1437. let totalHeight = 0;
  1438. if (nodes.length === 1) {
  1439. // 只有一个节点时,连线伸到节点的中间位置
  1440. const nodeHeight = nodes[0].offsetHeight;
  1441. const nodeTop = nodes[0].offsetTop;
  1442. totalHeight = nodeTop + nodeHeight / 2 - 15; // 减去圆点半径5px
  1443. } else {
  1444. // 多个节点时,连线延伸到最后一个节点的中间位置
  1445. const lastNode = nodes[nodes.length - 1];
  1446. const lastNodeTop = lastNode.offsetTop;
  1447. const lastNodeHeight = lastNode.offsetHeight;
  1448. totalHeight = lastNodeTop + lastNodeHeight / 2 - 15; // 减去圆点半径5px
  1449. }
  1450. console.log("节点信息:", {
  1451. 节点总数: nodes.length,
  1452. 计算后的高度: totalHeight,
  1453. 最后节点top: nodes[nodes.length - 1]?.offsetTop,
  1454. 最后节点高度: nodes[nodes.length - 1]?.offsetHeight,
  1455. });
  1456. lastOpenGroup.style.setProperty(
  1457. "--group-line-height",
  1458. `${totalHeight}px`
  1459. );
  1460. }
  1461. }
  1462. };
  1463. onMounted(() => {
  1464. setTimeout(() => {
  1465. calculateLineHeight();
  1466. }, 100);
  1467. });
  1468. return {
  1469. toggleOpen,
  1470. };
  1471. },
  1472. render() {
  1473. const { h } = Vue;
  1474. return h(
  1475. "div",
  1476. { class: "verify-nodes" },
  1477. this.verifyList.map((item, i) =>
  1478. h(
  1479. "div",
  1480. {
  1481. key: i,
  1482. class: {
  1483. "group-item": true,
  1484. "group-item-last-open":
  1485. i === this.verifyList.length - 1 && item.open,
  1486. },
  1487. },
  1488. [
  1489. h(
  1490. "div",
  1491. {
  1492. class: "group-item-title",
  1493. onClick: () => this.toggleOpen(item),
  1494. },
  1495. [
  1496. h("div", { class: "icon" }, [
  1497. h("i", {
  1498. class: item.open
  1499. ? "common-icon-folder-open common-icon"
  1500. : "common-icon-folder-close common-icon",
  1501. }),
  1502. h(
  1503. "div",
  1504. {
  1505. class: "num",
  1506. style: { top: item.open ? "60%" : "55%" },
  1507. },
  1508. item.children?.length || 0
  1509. ),
  1510. ]),
  1511. h("div", { class: "name" }, item.groupName),
  1512. ]
  1513. ),
  1514. item.open && item.children?.length > 0
  1515. ? h(
  1516. "div",
  1517. { class: "group-item-children" },
  1518. item.children.map((citem, j) =>
  1519. h(SsVerifyNode, {
  1520. key: j,
  1521. item: citem,
  1522. // isGroup: i + 1 !== this.verifyList.length,
  1523. isGroup: true,
  1524. })
  1525. )
  1526. )
  1527. : null,
  1528. ]
  1529. )
  1530. )
  1531. );
  1532. },
  1533. };
  1534. // ===== SsVerifyNode 审核节点组件 =====
  1535. const SsVerifyNode = {
  1536. name: "SsVerifyNode",
  1537. props: {
  1538. item: {
  1539. type: Object,
  1540. required: true,
  1541. },
  1542. isGroup: {
  1543. type: Boolean,
  1544. default: false,
  1545. },
  1546. },
  1547. render() {
  1548. const { h } = Vue;
  1549. return h("div", { class: "verify-node-container" }, [
  1550. h("div", { class: "info" }, [
  1551. h("div", { class: "avatar" }, [
  1552. h("img", {
  1553. src: this.item.thumb,
  1554. style: {
  1555. width: "50px",
  1556. height: "50px",
  1557. borderRadius: "50%",
  1558. },
  1559. }),
  1560. ]),
  1561. h("div", { class: "desc" }, [
  1562. h("div", this.item.name),
  1563. h("div", this.item.role),
  1564. ]),
  1565. h("div", { class: "link" }, [
  1566. h("div", [
  1567. this.item.video
  1568. ? h("i", { class: "common-icon-video common-icon" })
  1569. : null,
  1570. this.item.link
  1571. ? h("i", { class: "common-icon-paper-clip common-icon" })
  1572. : null,
  1573. ]),
  1574. ]),
  1575. ]),
  1576. h(
  1577. "div",
  1578. {
  1579. class: {
  1580. description: true,
  1581. link: this.isGroup,
  1582. },
  1583. attrs: { "data-num": "3" },
  1584. },
  1585. [h("div", this.item.description)]
  1586. ),
  1587. h("div", { class: "time" }, this.item.time),
  1588. ]);
  1589. },
  1590. };
  1591. // ===== SsOnoffButton 开关按钮 =====
  1592. const SsOnoffButton = {
  1593. name: "SsOnoffButton",
  1594. props: {
  1595. // 字段名称,用于表单校验
  1596. name: { type: String, required: true },
  1597. // 显示标签
  1598. label: { type: String, required: true },
  1599. // 按钮的值
  1600. value: { type: [String, Number], required: true },
  1601. // 宽度设置
  1602. width: { type: String, default: "" },
  1603. // v-model 绑定的值
  1604. modelValue: { type: [String, Number, Array], default: "" },
  1605. // 是否多选模式
  1606. multiple: { type: Boolean, default: false },
  1607. // 是否禁用
  1608. disabled: { type: Boolean, default: false },
  1609. },
  1610. emits: ["update:modelValue", "change"],
  1611. setup(props, { emit }) {
  1612. // 解析 modelValue,支持逗号分隔的字符串和数组
  1613. const parseModelValue = (val) => {
  1614. if (!val) return [];
  1615. // 如果是数组,直接返回字符串数组
  1616. if (Array.isArray(val)) {
  1617. return val.map((v) => v.toString());
  1618. }
  1619. // 如果是字符串,按逗号分割
  1620. const cleanValue = val.toString().replace(/^,+/, ""); // 去掉开头的逗号
  1621. if (cleanValue.includes("|")) {
  1622. return cleanValue.split("|");
  1623. }
  1624. if (cleanValue.includes(",")) {
  1625. return cleanValue.split(",");
  1626. }
  1627. return cleanValue ? [cleanValue] : [];
  1628. };
  1629. // 判断当前按钮是否选中
  1630. const isChecked = computed(() => {
  1631. if (props.multiple) {
  1632. const currentValue = parseModelValue(props.modelValue);
  1633. return currentValue.includes(props.value.toString());
  1634. }
  1635. return props.modelValue === props.value;
  1636. });
  1637. // 切换选中状态
  1638. const toggleSelect = () => {
  1639. // 如果禁用,不执行任何操作
  1640. if (props.disabled) return;
  1641. if (props.multiple) {
  1642. // 多选模式
  1643. const currentValue = parseModelValue(props.modelValue);
  1644. const index = currentValue.indexOf(props.value.toString());
  1645. let newValue;
  1646. if (index === -1) {
  1647. // 添加选项
  1648. newValue = [...currentValue, props.value.toString()];
  1649. } else {
  1650. // 移除选项
  1651. newValue = currentValue.filter((v) => v !== props.value.toString());
  1652. }
  1653. // 发送更新事件,使用逗号分隔的字符串格式
  1654. const emitValue = newValue.join(",");
  1655. emit("update:modelValue", emitValue);
  1656. emit("change", emitValue, newValue);
  1657. } else {
  1658. // 单选模式
  1659. emit("update:modelValue", props.value);
  1660. emit("change", props.value);
  1661. }
  1662. };
  1663. return { isChecked, toggleSelect };
  1664. },
  1665. template: `
  1666. <div class="ss-onoff-button" :class="{ checked: isChecked, disabled: disabled }" @click="toggleSelect">
  1667. <span class="button-label">{{ label }}</span>
  1668. <div class="button-mark">
  1669. <span class="form-icon" :class="isChecked ? 'form-icon-onoffbutton-checked' : 'form-icon-onoffbutton-unchecked'"></span>
  1670. </div>
  1671. </div>
  1672. `,
  1673. };
  1674. // ===== SsDatetimePicker 日期时间选择(使用 Vant 4) =====
  1675. const SsDatetimePicker = {
  1676. name: "SsDatetimePicker",
  1677. props: {
  1678. mode: { type: String, default: "date" }, // date | time | datetime
  1679. placeholder: { type: String, default: "请选择日期" },
  1680. modelValue: { type: String, default: "" },
  1681. minDate: { type: String, default: "" },
  1682. maxDate: { type: String, default: "" },
  1683. // 字段名称 - 用于ssVm校验
  1684. name: { type: String, default: "" },
  1685. // 是否禁用
  1686. disabled: { type: Boolean, default: false },
  1687. },
  1688. emits: [
  1689. "update:modelValue",
  1690. "change",
  1691. "confirm",
  1692. "cancel",
  1693. "open",
  1694. "close",
  1695. ],
  1696. setup(props, { emit }) {
  1697. const showPicker = ref(false);
  1698. const showTimePicker = ref(false);
  1699. const currentStep = ref("date"); // 'date' | 'time'
  1700. // 功能说明:统一在组件层处理 iframe 场景底部按钮显隐,避免每个页面重复绑定 by xu 2026-02-28
  1701. const notifyParentBottomVisible = (visible) => {
  1702. try {
  1703. const fn = window.parent && window.parent.__mpObjInpSetBottomVisible;
  1704. if (typeof fn === "function") fn(visible !== false);
  1705. } catch (_) {}
  1706. };
  1707. // 功能说明:监听弹层显隐,点遮罩/取消关闭时也恢复父层底部按钮 by xu 2026-03-01
  1708. watch(
  1709. [showPicker, showTimePicker],
  1710. ([dateOpen, timeOpen], [prevDateOpen, prevTimeOpen]) => {
  1711. const hasOpen = !!(dateOpen || timeOpen);
  1712. const hadOpen = !!(prevDateOpen || prevTimeOpen);
  1713. if (!hadOpen && hasOpen) {
  1714. emit("open");
  1715. notifyParentBottomVisible(false);
  1716. return;
  1717. }
  1718. if (hadOpen && !hasOpen) {
  1719. emit("close");
  1720. notifyParentBottomVisible(true);
  1721. }
  1722. }
  1723. );
  1724. // Vant DatePicker 需要数组格式 [year, month, day]
  1725. const currentDateArray = ref([]);
  1726. const currentTimeArray = ref(["12", "00"]); // [hour, minute]
  1727. const tempDateStr = ref(""); // 临时存储选择的日期
  1728. // 格式化显示文本
  1729. const displayText = computed(() => {
  1730. if (!props.modelValue) return props.placeholder;
  1731. try {
  1732. const d = new Date(props.modelValue);
  1733. if (isNaN(d.getTime())) return props.modelValue;
  1734. const year = d.getFullYear();
  1735. const month = String(d.getMonth() + 1).padStart(2, "0");
  1736. const day = String(d.getDate()).padStart(2, "0");
  1737. const hours = String(d.getHours()).padStart(2, "0");
  1738. const minutes = String(d.getMinutes()).padStart(2, "0");
  1739. if (props.mode === "time") {
  1740. return `${hours}:${minutes}`;
  1741. } else if (props.mode === "datetime") {
  1742. return `${year}-${month}-${day} ${hours}:${minutes}`;
  1743. }
  1744. return `${year}-${month}-${day}`;
  1745. } catch (e) {
  1746. return props.modelValue;
  1747. }
  1748. });
  1749. // 监听 modelValue 变化,转换为数组格式
  1750. watch(
  1751. () => props.modelValue,
  1752. (newVal) => {
  1753. console.log("📅 modelValue 变化:", newVal);
  1754. if (newVal) {
  1755. try {
  1756. const d = new Date(newVal);
  1757. if (!isNaN(d.getTime())) {
  1758. currentDateArray.value = [
  1759. d.getFullYear(),
  1760. d.getMonth() + 1,
  1761. d.getDate(),
  1762. ];
  1763. console.log("📅 转换为数组:", currentDateArray.value);
  1764. }
  1765. } catch (e) {
  1766. console.warn("Invalid date:", newVal);
  1767. }
  1768. } else {
  1769. const today = new Date();
  1770. currentDateArray.value = [
  1771. today.getFullYear(),
  1772. today.getMonth() + 1,
  1773. today.getDate(),
  1774. ];
  1775. }
  1776. },
  1777. { immediate: true }
  1778. );
  1779. // 确认选择 - 根据模式处理不同的数据
  1780. const onConfirm = (value) => {
  1781. console.log(
  1782. "📅 Vant Picker confirm 原始值:",
  1783. value,
  1784. "mode:",
  1785. props.mode
  1786. );
  1787. try {
  1788. // Vant 返回的是对象,包含 selectedValues 数组
  1789. const selectedValues = value.selectedValues || value;
  1790. console.log("📅 selectedValues:", selectedValues);
  1791. if (props.mode === "time") {
  1792. // 时间模式:处理时分
  1793. if (Array.isArray(selectedValues) && selectedValues.length >= 2) {
  1794. const [hour, minute] = selectedValues;
  1795. const timeStr = `${hour.padStart(2, "0")}:${minute.padStart(
  1796. 2,
  1797. "0"
  1798. )}`;
  1799. console.log("🕐 转换后的时间字符串:", timeStr);
  1800. emit("update:modelValue", timeStr);
  1801. emit("change", timeStr);
  1802. emit("confirm", timeStr);
  1803. emit("close");
  1804. notifyParentBottomVisible(true);
  1805. showPicker.value = false;
  1806. }
  1807. } else if (
  1808. Array.isArray(selectedValues) &&
  1809. selectedValues.length >= 3
  1810. ) {
  1811. // 日期模式:处理年月日
  1812. const [year, month, day] = selectedValues;
  1813. const dateStr = `${year}-${month.padStart(2, "0")}-${day.padStart(
  1814. 2,
  1815. "0"
  1816. )}`;
  1817. if (props.mode === "datetime") {
  1818. // datetime 模式:先存储日期,然后打开时间选择器
  1819. tempDateStr.value = dateStr;
  1820. showPicker.value = false;
  1821. showTimePicker.value = true;
  1822. currentStep.value = "time";
  1823. } else {
  1824. // date 模式:直接完成
  1825. emit("update:modelValue", dateStr);
  1826. emit("change", dateStr);
  1827. emit("confirm", dateStr);
  1828. emit("close");
  1829. notifyParentBottomVisible(true);
  1830. showPicker.value = false;
  1831. }
  1832. }
  1833. } catch (e) {
  1834. console.error("Picker conversion error:", e);
  1835. }
  1836. };
  1837. // 时间选择确认
  1838. const onTimeConfirm = (value) => {
  1839. try {
  1840. const selectedValues = value.selectedValues || value;
  1841. if (Array.isArray(selectedValues) && selectedValues.length >= 2) {
  1842. const [hour, minute] = selectedValues;
  1843. const datetimeStr = `${tempDateStr.value} ${hour.padStart(
  1844. 2,
  1845. "0"
  1846. )}:${minute.padStart(2, "0")}`;
  1847. emit("update:modelValue", datetimeStr);
  1848. emit("change", datetimeStr);
  1849. emit("confirm", datetimeStr);
  1850. emit("close");
  1851. notifyParentBottomVisible(true);
  1852. }
  1853. } catch (e) {
  1854. console.error("Time conversion error:", e);
  1855. }
  1856. showTimePicker.value = false;
  1857. currentStep.value = "date";
  1858. };
  1859. // 取消选择
  1860. const onCancel = () => {
  1861. emit("cancel");
  1862. showPicker.value = false;
  1863. };
  1864. const onTimeCancel = () => {
  1865. emit("cancel");
  1866. showTimePicker.value = false;
  1867. };
  1868. // 打开选择器
  1869. const openPicker = () => {
  1870. // 如果禁用,不打开选择器
  1871. if (props.disabled) return;
  1872. showPicker.value = true;
  1873. };
  1874. // 计算最小最大日期
  1875. const minDateObj = computed(() => {
  1876. return props.minDate ? new Date(props.minDate) : undefined;
  1877. });
  1878. const maxDateObj = computed(() => {
  1879. return props.maxDate ? new Date(props.maxDate) : undefined;
  1880. });
  1881. return {
  1882. showPicker,
  1883. showTimePicker,
  1884. currentDateArray,
  1885. currentTimeArray,
  1886. displayText,
  1887. openPicker,
  1888. onConfirm,
  1889. onTimeConfirm,
  1890. onTimeCancel,
  1891. onCancel,
  1892. minDateObj,
  1893. maxDateObj,
  1894. };
  1895. },
  1896. template: `
  1897. <div class="ss-datetime-picker ss-mobile-component" :class="{ disabled: disabled }">
  1898. <!-- 隐藏的input用于ssVm校验 -->
  1899. <input type="hidden" :name="name" :value="modelValue" />
  1900. <div class="datetime-picker-display" @click="openPicker">
  1901. <span class="datetime-picker-value" :class="{ placeholder: !modelValue }">{{ displayText }}</span>
  1902. </div>
  1903. <!-- 日期选择器 -->
  1904. <van-popup v-model:show="showPicker" position="bottom" :style="{ zIndex: 10000 }">
  1905. <van-date-picker
  1906. v-if="mode === 'date' || mode === 'datetime'"
  1907. v-model="currentDateArray"
  1908. :min-date="minDateObj"
  1909. :max-date="maxDateObj"
  1910. @confirm="onConfirm"
  1911. @cancel="onCancel"
  1912. title="选择日期"
  1913. />
  1914. <van-time-picker
  1915. v-if="mode === 'time'"
  1916. v-model="currentTimeArray"
  1917. @confirm="onConfirm"
  1918. @cancel="onCancel"
  1919. title="选择时间"
  1920. />
  1921. </van-popup>
  1922. <!-- 时间选择器(datetime 模式的第二步) -->
  1923. <van-popup v-model:show="showTimePicker" position="bottom" :style="{ zIndex: 10000 }">
  1924. <van-time-picker
  1925. v-model="currentTimeArray"
  1926. @confirm="onTimeConfirm"
  1927. @cancel="onTimeCancel"
  1928. title="选择时间"
  1929. />
  1930. </van-popup>
  1931. </div>
  1932. `,
  1933. };
  1934. const SsConfirm = {
  1935. name: "SsConfirm",
  1936. props: {
  1937. modelValue: { type: Boolean, default: false },
  1938. title: { type: String, default: "确认" },
  1939. content: { type: String, default: "" },
  1940. maskClosable: { type: Boolean, default: true },
  1941. },
  1942. emits: ["update:modelValue", "confirm", "cancel", "close"],
  1943. setup(props, { emit }) {
  1944. // 监听弹窗显示状态,控制 body 滚动
  1945. watch(
  1946. () => props.modelValue,
  1947. (newVal) => {
  1948. console.log("🔔 SsConfirm modelValue 变化:", newVal);
  1949. if (newVal) {
  1950. document.body.classList.add("modal-open");
  1951. } else {
  1952. document.body.classList.remove("modal-open");
  1953. }
  1954. },
  1955. { immediate: true }
  1956. ); // 添加 immediate 选项
  1957. const close = () => {
  1958. console.log("🚪 关闭确认弹窗");
  1959. emit("update:modelValue", false);
  1960. emit("close");
  1961. };
  1962. const onMask = () => {
  1963. console.log("👆 点击了遮罩层");
  1964. if (props.maskClosable) close();
  1965. };
  1966. const onCancel = () => {
  1967. console.log("❌ 点击了取消按钮");
  1968. emit("cancel");
  1969. close();
  1970. };
  1971. const onConfirm = () => {
  1972. console.log("✅ 点击了确认按钮,触发 confirm 事件");
  1973. emit("confirm");
  1974. close();
  1975. };
  1976. return { onMask, onCancel, onConfirm };
  1977. },
  1978. template: `
  1979. <div v-if="modelValue" class="ss-confirm">
  1980. <div class="confirm-mask" @click="onMask"></div>
  1981. <div class="confirm-content">
  1982. <div class="confirm-header" v-if="title">
  1983. <div class="header-title">{{ title }}</div>
  1984. </div>
  1985. <div class="header-line" v-if="title"></div>
  1986. <div class="confirm-body">
  1987. <div v-if="content" class="confirm-content-text" v-html="content"></div>
  1988. <div class="confirm-slot-content"><slot /></div>
  1989. </div>
  1990. <div class="confirm-bottom">
  1991. <button class="confirm-btn confirm-btn-cancel" @click.stop="onCancel">取消</button>
  1992. <button class="confirm-btn confirm-btn-confirm" @click.stop="onConfirm">确认</button>
  1993. </div>
  1994. </div>
  1995. </div>
  1996. `,
  1997. };
  1998. // ===== SsImageCropper 纯裁剪组件 =====
  1999. const SsImageCropper = {
  2000. name: "SsImageCropper",
  2001. props: {
  2002. // 是否显示裁剪器
  2003. show: { type: Boolean, default: false },
  2004. // 图片源(base64 或 URL)
  2005. src: { type: String, required: true },
  2006. // 图片形状:circle圆形 | square方形
  2007. shape: { type: String, required: true },
  2008. // 裁剪比例(宽/高)
  2009. aspectRatio: { type: Number, default: 1 },
  2010. // 输出图片宽度
  2011. outputWidth: { type: Number, default: 300 },
  2012. // 输出图片高度
  2013. outputHeight: { type: Number, default: 300 },
  2014. },
  2015. emits: ["update:show", "confirm", "cancel"],
  2016. setup(props, { emit }) {
  2017. const cropperInstance = ref(null);
  2018. // 监听 show 变化,初始化或销毁 Cropper
  2019. watch(
  2020. () => props.show,
  2021. (newVal) => {
  2022. if (newVal) {
  2023. // 等待 DOM 更新后初始化
  2024. Vue.nextTick(() => {
  2025. initCropper();
  2026. });
  2027. } else {
  2028. destroyCropper();
  2029. }
  2030. }
  2031. );
  2032. // 监听 src 变化,重新初始化 Cropper
  2033. watch(
  2034. () => props.src,
  2035. () => {
  2036. if (props.show) {
  2037. Vue.nextTick(() => {
  2038. initCropper();
  2039. });
  2040. }
  2041. }
  2042. );
  2043. // 初始化 Cropper
  2044. const initCropper = () => {
  2045. const imageElement = document.getElementById("ss-image-cropper-img");
  2046. if (!imageElement || !window.Cropper) return;
  2047. // 销毁旧实例
  2048. destroyCropper();
  2049. // 根据 shape 属性添加类名
  2050. const container = document.querySelector(".ss-image-cropper-container");
  2051. if (container) {
  2052. if (props.shape === "circle") {
  2053. container.classList.add("crop-shape-circle");
  2054. container.classList.remove("crop-shape-square");
  2055. } else {
  2056. container.classList.add("crop-shape-square");
  2057. container.classList.remove("crop-shape-circle");
  2058. }
  2059. }
  2060. cropperInstance.value = new window.Cropper(imageElement, {
  2061. aspectRatio: props.aspectRatio,
  2062. viewMode: 1,
  2063. dragMode: "move",
  2064. autoCropArea: 0.8,
  2065. restore: false,
  2066. guides: false, // 关闭辅助线
  2067. center: false, // 关闭中心指示器
  2068. highlight: false,
  2069. cropBoxMovable: true,
  2070. cropBoxResizable: true,
  2071. toggleDragModeOnDblclick: false,
  2072. minContainerWidth: window.innerWidth,
  2073. minContainerHeight: window.innerHeight - 50,
  2074. });
  2075. };
  2076. // 销毁 Cropper
  2077. const destroyCropper = () => {
  2078. if (cropperInstance.value) {
  2079. cropperInstance.value.destroy();
  2080. cropperInstance.value = null;
  2081. }
  2082. };
  2083. // 取消裁剪
  2084. const handleCancel = () => {
  2085. emit("update:show", false);
  2086. emit("cancel");
  2087. };
  2088. // 确认裁剪
  2089. const handleConfirm = () => {
  2090. if (!cropperInstance.value) return;
  2091. const canvas = cropperInstance.value.getCroppedCanvas({
  2092. width: props.outputWidth,
  2093. height: props.outputHeight,
  2094. imageSmoothingEnabled: true,
  2095. imageSmoothingQuality: "high",
  2096. fillColor: "#fff",
  2097. });
  2098. canvas.toBlob(
  2099. (blob) => {
  2100. emit("update:show", false);
  2101. emit("confirm", blob);
  2102. },
  2103. "image/jpeg",
  2104. 0.9
  2105. );
  2106. };
  2107. // 处理底部按钮事件
  2108. const handleCropAction = (data) => {
  2109. if (data.action === "cancel") {
  2110. handleCancel();
  2111. } else if (data.action === "confirm") {
  2112. handleConfirm();
  2113. }
  2114. };
  2115. return {
  2116. handleCropAction,
  2117. };
  2118. },
  2119. template: `
  2120. <div v-if="show" class="ss-image-cropper-container">
  2121. <!-- 左上角尺寸显示 -->
  2122. <div class="crop-size-display">
  2123. 长: {{ outputWidth }}px<br>宽: {{ outputHeight }}px
  2124. </div>
  2125. <div class="ss-crop-image-container">
  2126. <img id="ss-image-cropper-img" :src="src" />
  2127. </div>
  2128. <!-- 使用 ss-bottom 组件 -->
  2129. <ss-bottom
  2130. :show-shyj="false"
  2131. :buttons="[
  2132. { text: '取消', action: 'cancel' },
  2133. { text: '保存并提交', action: 'confirm' }
  2134. ]"
  2135. @button-click="handleCropAction"
  2136. />
  2137. </div>
  2138. `,
  2139. };
  2140. // ===== SsUploadImage 图片上传裁剪组件(支持单图/多图) =====
  2141. const SsUploadImage = {
  2142. name: "SsUploadImage",
  2143. props: {
  2144. // v-model 绑定的值(单图:String,多图:Array)
  2145. modelValue: { type: [String, Array], default: "" },
  2146. // 最大上传数量(默认1张,多图时设置大于1)
  2147. max: { type: Number, default: 1 },
  2148. // 是否禁用
  2149. disabled: { type: Boolean, default: false },
  2150. // 图片宽度(像素) - 必填
  2151. width: { type: [Number, String], required: true },
  2152. // 图片高度(像素) - 必填
  2153. height: { type: [Number, String], required: true },
  2154. // 图片形状:circle圆形 | square方形 - 必填
  2155. shape: { type: String, required: true },
  2156. // 裁剪比例(宽/高)
  2157. aspectRatio: { type: Number, default: undefined },
  2158. // 输出图片宽度
  2159. outputWidth: { type: Number, default: 300 },
  2160. // 输出图片高度
  2161. outputHeight: { type: Number, default: 300 },
  2162. },
  2163. emits: ["update:modelValue", "updated"],
  2164. setup(props, { emit }) {
  2165. const showCropper = ref(false);
  2166. const tempImageSrc = ref("");
  2167. // 图片列表(统一用数组管理)
  2168. const imageList = ref([]);
  2169. // 监听 modelValue 变化,同步到 imageList
  2170. watch(
  2171. () => props.modelValue,
  2172. (newVal) => {
  2173. if (props.max === 1) {
  2174. // 单图模式
  2175. imageList.value = newVal ? [newVal] : [];
  2176. } else {
  2177. // 多图模式
  2178. imageList.value = Array.isArray(newVal)
  2179. ? [...newVal]
  2180. : newVal
  2181. ? [newVal]
  2182. : [];
  2183. }
  2184. },
  2185. { immediate: true }
  2186. );
  2187. // 是否可以继续添加图片
  2188. const canAddMore = computed(() => {
  2189. return imageList.value.length < props.max;
  2190. });
  2191. // 容器样式
  2192. const itemStyle = computed(() => ({
  2193. width:
  2194. typeof props.width === "number" ? `${props.width}px` : props.width,
  2195. height:
  2196. typeof props.height === "number" ? `${props.height}px` : props.height,
  2197. borderRadius: props.shape === "circle" ? "50%" : "8px",
  2198. }));
  2199. // 获取图片 URL(用于显示)
  2200. const getImageUrl = (path) => {
  2201. if (!path) return "/static/images/yishuzhao_nv.svg";
  2202. if (path.startsWith("http") || path.startsWith("blob:")) {
  2203. return path;
  2204. }
  2205. return window.SS.utils?.getImageUrl?.(path) || path;
  2206. };
  2207. // 选择图片
  2208. const selectImage = () => {
  2209. if (props.disabled || !canAddMore.value) return;
  2210. const input = document.createElement("input");
  2211. input.type = "file";
  2212. input.accept = "image/*";
  2213. input.onchange = (e) => {
  2214. const file = e.target.files[0];
  2215. if (file) {
  2216. const reader = new FileReader();
  2217. reader.onload = (event) => {
  2218. tempImageSrc.value = event.target.result;
  2219. showCropper.value = true;
  2220. };
  2221. reader.readAsDataURL(file);
  2222. }
  2223. };
  2224. input.click();
  2225. };
  2226. // 删除图片
  2227. const deleteImage = (index) => {
  2228. const newList = imageList.value.filter((_, i) => i !== index);
  2229. updateModelValue(newList);
  2230. };
  2231. // 确认裁剪
  2232. const handleCropConfirm = async (blob) => {
  2233. try {
  2234. const serverPath = await uploadFile(blob, "image", "image.jpg");
  2235. const newList = [...imageList.value, serverPath];
  2236. updateModelValue(newList);
  2237. emit("updated", serverPath);
  2238. } catch (error) {
  2239. console.error("上传失败:", error);
  2240. }
  2241. };
  2242. // 取消裁剪
  2243. const handleCropCancel = () => {
  2244. tempImageSrc.value = "";
  2245. };
  2246. // 更新 modelValue
  2247. const updateModelValue = (list) => {
  2248. if (props.max === 1) {
  2249. // 单图模式:返回 String
  2250. emit("update:modelValue", list[0] || "");
  2251. } else {
  2252. // 多图模式:返回 Array
  2253. emit("update:modelValue", list);
  2254. }
  2255. };
  2256. return {
  2257. imageList,
  2258. canAddMore,
  2259. itemStyle,
  2260. showCropper,
  2261. tempImageSrc,
  2262. getImageUrl,
  2263. selectImage,
  2264. deleteImage,
  2265. handleCropConfirm,
  2266. handleCropCancel,
  2267. };
  2268. },
  2269. template: `
  2270. <div class="ss-upload-image-multi">
  2271. <!-- 图片列表 -->
  2272. <div class="image-list">
  2273. <!-- 已上传的图片 -->
  2274. <div
  2275. v-for="(img, index) in imageList"
  2276. :key="index"
  2277. class="image-item"
  2278. :style="itemStyle"
  2279. >
  2280. <img :src="getImageUrl(img)" class="image-display" />
  2281. <!-- 删除按钮 -->
  2282. <div v-if="!disabled" class="image-delete" @click.stop="deleteImage(index)">
  2283. <Icon name="icon-guanbi" size="32" color="#fff" />
  2284. </div>
  2285. </div>
  2286. <!-- 添加按钮 -->
  2287. <div
  2288. v-if="canAddMore && !disabled"
  2289. class="image-item image-add"
  2290. :style="itemStyle"
  2291. @click="selectImage"
  2292. >
  2293. <Icon name="icon-xiangji" size="48" color="#ccc" />
  2294. <div class="add-text">{{ imageList.length }}/{{ max }}</div>
  2295. </div>
  2296. </div>
  2297. <!-- 裁剪组件 -->
  2298. <ss-image-cropper
  2299. v-model:show="showCropper"
  2300. :src="tempImageSrc"
  2301. :shape="shape"
  2302. :aspect-ratio="aspectRatio"
  2303. :output-width="outputWidth"
  2304. :output-height="outputHeight"
  2305. @confirm="handleCropConfirm"
  2306. @cancel="handleCropCancel"
  2307. />
  2308. </div>
  2309. `,
  2310. };
  2311. // ===== SsCarCard 车辆卡片组件 =====
  2312. const SsCarCard = {
  2313. name: "SsCarCard",
  2314. props: {
  2315. // 车辆数据
  2316. carData: {
  2317. type: Object,
  2318. default: () => ({}),
  2319. },
  2320. // 车辆状态:'available' | 'reserved' | 'disabled'
  2321. status: {
  2322. type: String,
  2323. default: "available",
  2324. validator: (value) =>
  2325. ["available", "reserved", "disabled"].includes(value),
  2326. },
  2327. },
  2328. emits: ["click", "select"],
  2329. setup(props, { emit }) {
  2330. // 计算状态样式类
  2331. const statusClass = computed(() => {
  2332. return `status-${props.status}`;
  2333. });
  2334. // 获取图片URL
  2335. const getImageUrl = (path) => {
  2336. if (!path) return "/static/images/default-car.png";
  2337. if (path.startsWith("http") || path.startsWith("blob:")) {
  2338. return path;
  2339. }
  2340. return window.SS.utils?.getImageUrl?.(path) || path;
  2341. };
  2342. // 处理卡片点击
  2343. const handleCardClick = () => {
  2344. if (props.status === "disabled") {
  2345. return; // 禁用状态不响应点击
  2346. }
  2347. emit("click", props.carData);
  2348. emit("select", props.carData);
  2349. };
  2350. return {
  2351. statusClass,
  2352. getImageUrl,
  2353. handleCardClick,
  2354. };
  2355. },
  2356. template: `
  2357. <div class="car-card" :class="statusClass" @click="handleCardClick">
  2358. <!-- 第一行:车辆名称 -->
  2359. <div class="car-title">
  2360. {{ carData.name || '别克GL8' }}
  2361. </div>
  2362. <!-- 第二行:左右结构 -->
  2363. <div class="car-info">
  2364. <!-- 左边:车辆图片 -->
  2365. <div class="car-image-container">
  2366. <img class="car-image" :src="getImageUrl(carData.image)" />
  2367. </div>
  2368. <!-- 右边:车辆信息 -->
  2369. <div class="car-details">
  2370. <div class="detail-item car-name" v-if="carData.wph">
  2371. {{ carData.wph }}
  2372. </div>
  2373. <div class="detail-item seats" v-for="(wp, index) in carData.wpcsList" :key="index">
  2374. {{ wp.mc }} : {{ wp.sz || wp.zf }}
  2375. </div>
  2376. <div class="detail-item car-type">
  2377. {{ carData.type || '商务车' }}
  2378. </div>
  2379. </div>
  2380. </div>
  2381. </div>
  2382. `,
  2383. };
  2384. // ===== SsSubTab 移动端Tab组件 =====
  2385. const SsSubTab = {
  2386. name: "SsSubTab",
  2387. props: {
  2388. // Tab列表数据
  2389. tabList: {
  2390. type: Array,
  2391. required: true,
  2392. },
  2393. // 当前激活的Tab索引
  2394. activeIndex: {
  2395. type: Number,
  2396. default: 0,
  2397. },
  2398. // 基础URL参数(会传递给每个iframe)
  2399. baseParams: {
  2400. type: Object,
  2401. default: () => ({}),
  2402. },
  2403. },
  2404. emits: ["tab-change"],
  2405. setup(props, { emit }) {
  2406. const currentTab = ref(props.activeIndex);
  2407. const currentTabUrl = ref("");
  2408. // 加载Tab对应的URL
  2409. const loadTabUrl = (index) => {
  2410. const tab = props.tabList[index];
  2411. if (!tab || !tab.dest) return;
  2412. // 构建iframe URL:mp_ + dest + .html
  2413. const fileName = `mp_${tab.dest}.html`;
  2414. // 构建完整URL,包含所有参数
  2415. const tabService =
  2416. tab.service ||
  2417. tab.servName ||
  2418. props.baseParams.service ||
  2419. props.baseParams.ssServ ||
  2420. "";
  2421. const tabDest =
  2422. tab.dest || props.baseParams.dest || props.baseParams.ssDest || "";
  2423. const tabParam = tab.param || props.baseParams.param || "";
  2424. const params = new URLSearchParams({
  2425. ...props.baseParams,
  2426. service: tabService,
  2427. dest: tabDest,
  2428. ssServ: tabService,
  2429. ssDest: tabDest,
  2430. param: tabParam,
  2431. });
  2432. currentTabUrl.value = `/page/${fileName}?${params.toString()}`;
  2433. console.log(
  2434. "🔄 切换到Tab:",
  2435. tab.desc || tab.title,
  2436. "加载页面:",
  2437. currentTabUrl.value
  2438. );
  2439. };
  2440. // 监听 activeIndex 变化
  2441. watch(
  2442. () => props.activeIndex,
  2443. (newIndex) => {
  2444. currentTab.value = newIndex;
  2445. loadTabUrl(newIndex);
  2446. },
  2447. { immediate: true }
  2448. );
  2449. // 监听 tabList 变化
  2450. watch(
  2451. () => props.tabList,
  2452. () => {
  2453. if (props.tabList.length > 0 && currentTab.value === 0) {
  2454. loadTabUrl(0);
  2455. }
  2456. },
  2457. { immediate: true }
  2458. );
  2459. // 切换Tab
  2460. const handleTabClick = (index) => {
  2461. if (currentTab.value === index) return;
  2462. currentTab.value = index;
  2463. loadTabUrl(index);
  2464. emit("tab-change", { index, tab: props.tabList[index] });
  2465. };
  2466. return {
  2467. currentTab,
  2468. currentTabUrl,
  2469. handleTabClick,
  2470. };
  2471. },
  2472. template: `
  2473. <div class="ss-sub-tab">
  2474. <!-- Tab 栏 -->
  2475. <div class="ss-sub-tab__bar" v-if="tabList.length > 0">
  2476. <div
  2477. v-for="(tab, index) in tabList"
  2478. :key="index"
  2479. class="ss-sub-tab__item"
  2480. :class="{ 'ss-sub-tab__item--active': currentTab === index }"
  2481. @click="handleTabClick(index)"
  2482. >
  2483. {{ tab.desc || tab.title }}
  2484. </div>
  2485. </div>
  2486. <!-- 内容区域 -->
  2487. <div class="ss-sub-tab__content" v-if="currentTabUrl">
  2488. <iframe :src="currentTabUrl" frameborder="0"></iframe>
  2489. </div>
  2490. </div>
  2491. `,
  2492. };
  2493. // ===== SsUploadFile 文件上传组件(支持单文件/多文件) =====
  2494. const SsUploadFile = {
  2495. name: "SsUploadFile",
  2496. props: {
  2497. // v-model 绑定的值(单文件:String,多文件:Array)
  2498. modelValue: { type: [String, Array], default: "" },
  2499. // 最大上传数量(默认1个)
  2500. max: { type: Number, default: 1 },
  2501. // 是否禁用
  2502. disabled: { type: Boolean, default: false },
  2503. // 允许的文件类型(默认常见文件类型)
  2504. accept: {
  2505. type: String,
  2506. default: ".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar",
  2507. },
  2508. // 最大文件大小(MB,默认5MB)
  2509. maxSize: { type: Number, default: 5 },
  2510. },
  2511. emits: ["update:modelValue", "uploaded"],
  2512. setup(props, { emit }) {
  2513. // 文件列表(统一用数组管理)
  2514. const fileList = ref([]);
  2515. // 监听 modelValue 变化,同步到 fileList
  2516. watch(
  2517. () => props.modelValue,
  2518. (newVal) => {
  2519. if (props.max === 1) {
  2520. // 单文件模式
  2521. fileList.value = newVal
  2522. ? [{ path: newVal, name: getFileName(newVal) }]
  2523. : [];
  2524. } else {
  2525. // 多文件模式
  2526. if (Array.isArray(newVal)) {
  2527. fileList.value = newVal.map((path) => ({
  2528. path,
  2529. name: getFileName(path),
  2530. }));
  2531. } else {
  2532. fileList.value = newVal
  2533. ? [{ path: newVal, name: getFileName(newVal) }]
  2534. : [];
  2535. }
  2536. }
  2537. },
  2538. { immediate: true }
  2539. );
  2540. // 是否可以继续添加文件
  2541. const canAddMore = computed(() => {
  2542. return fileList.value.length < props.max;
  2543. });
  2544. // 从路径中提取文件名
  2545. const getFileName = (path) => {
  2546. if (!path) return "";
  2547. const parts = path.split("/");
  2548. return parts[parts.length - 1];
  2549. };
  2550. // 获取文件图标
  2551. const getFileIcon = (fileName) => {
  2552. const ext = fileName.split(".").pop().toLowerCase();
  2553. const iconMap = {
  2554. pdf: "icon-pdf",
  2555. doc: "icon-word",
  2556. docx: "icon-word",
  2557. xls: "icon-excel",
  2558. xlsx: "icon-excel",
  2559. txt: "icon-txt",
  2560. zip: "icon-zip",
  2561. rar: "icon-zip",
  2562. };
  2563. return iconMap[ext] || "icon-wenjian";
  2564. };
  2565. // 选择文件
  2566. const selectFile = () => {
  2567. if (props.disabled || !canAddMore.value) return;
  2568. const input = document.createElement("input");
  2569. input.type = "file";
  2570. input.accept = props.accept;
  2571. input.onchange = async (e) => {
  2572. const file = e.target.files[0];
  2573. if (!file) return;
  2574. // 检查文件大小
  2575. const fileSizeMB = file.size / 1024 / 1024;
  2576. if (fileSizeMB > props.maxSize) {
  2577. window.showToast?.(`文件大小不能超过${props.maxSize}MB`, "error");
  2578. return;
  2579. }
  2580. // 上传文件
  2581. try {
  2582. const serverPath = await uploadFile(file, "file", file.name);
  2583. const newList = [
  2584. ...fileList.value,
  2585. { path: serverPath, name: file.name },
  2586. ];
  2587. updateModelValue(newList);
  2588. emit("uploaded", serverPath);
  2589. } catch (error) {
  2590. console.error("文件上传失败:", error);
  2591. }
  2592. };
  2593. input.click();
  2594. };
  2595. // 删除文件
  2596. const deleteFile = (index) => {
  2597. const newList = fileList.value.filter((_, i) => i !== index);
  2598. updateModelValue(newList);
  2599. };
  2600. // 下载文件
  2601. const downloadFile = (file) => {
  2602. const url =
  2603. window.SS.utils?.getFileUrl?.(file.path) ||
  2604. window.getFileUrl?.(file.path) ||
  2605. file.path;
  2606. window.open(url, "_blank");
  2607. };
  2608. // 更新 modelValue
  2609. const updateModelValue = (list) => {
  2610. const paths = list.map((f) => f.path);
  2611. if (props.max === 1) {
  2612. // 单文件模式:返回 String
  2613. emit("update:modelValue", paths[0] || "");
  2614. } else {
  2615. // 多文件模式:返回 Array
  2616. emit("update:modelValue", paths);
  2617. }
  2618. };
  2619. return {
  2620. fileList,
  2621. canAddMore,
  2622. getFileIcon,
  2623. selectFile,
  2624. deleteFile,
  2625. downloadFile,
  2626. };
  2627. },
  2628. template: `
  2629. <div class="ss-upload-file">
  2630. <!-- 文件列表 -->
  2631. <div v-if="fileList.length > 0" class="file-list">
  2632. <!-- 已上传的文件 -->
  2633. <div
  2634. v-for="(file, index) in fileList"
  2635. :key="index"
  2636. class="file-item"
  2637. >
  2638. <div class="file-icon">
  2639. <Icon :name="getFileIcon(file.name)" size="40" color="#40ac6d" />
  2640. </div>
  2641. <div class="file-info" @click="downloadFile(file)">
  2642. <span class="file-name">{{ file.name }}</span>
  2643. </div>
  2644. <!-- 文件操作按钮 -->
  2645. <div class="file-actions">
  2646. <div class="file-download" @click="downloadFile(file)">
  2647. <Icon name="icon-xiazai" size="32" color="#40ac6d" />
  2648. </div>
  2649. <div v-if="!disabled" class="file-delete" @click.stop="deleteFile(index)">
  2650. <Icon name="icon-guanbi" size="32" color="#ff3b30" />
  2651. </div>
  2652. </div>
  2653. </div>
  2654. </div>
  2655. <!-- 添加按钮 -->
  2656. <div
  2657. v-if="canAddMore && !disabled"
  2658. class="file-add-button"
  2659. @click="selectFile"
  2660. >
  2661. <div class="add-icon">
  2662. <Icon name="icon-tianjia" size="64" color="#999" />
  2663. </div>
  2664. <span class="add-text">上传文件 ({{ fileList.length }}/{{ max }})</span>
  2665. <span class="file-tip">支持格式: {{ accept }},最大{{ maxSize }}MB</span>
  2666. </div>
  2667. </div>
  2668. `,
  2669. };
  2670. /**
  2671. * 功能说明:H5移动端富文本组件(对齐PC字段协议 mswj/fjid/path 回显) by xu 2026-03-01
  2672. *
  2673. * 约定:
  2674. * - v-model 绑定文件路径字段(如 mswj)
  2675. * - 组件内部维护编辑内容,并输出隐藏字段:xxEdit / xxwj / ueditorpath / fjid
  2676. * - 回显通过 url + path 拉取 HTML 内容,不直接依赖 modelValue 的 HTML 字符串
  2677. *
  2678. * @component SsEditor
  2679. * @prop {String} modelValue 文件路径(如 mswj)
  2680. * @prop {String} name 字段名(默认 mswj)
  2681. * @prop {String} url 回显读取接口地址
  2682. * @prop {Number|String} height 编辑器高度
  2683. * @prop {String} placeholder 占位文案
  2684. * @prop {Boolean} readonly 是否只读
  2685. * @prop {String} uploadUrl 上传接口地址
  2686. * @prop {Object} param 附件参数(button.cmsAddUrl / button.cmsUpdUrl / mode)
  2687. * @emits update:modelValue 更新文件路径
  2688. * @emits ready 编辑器就绪
  2689. * @emits change 内容变化
  2690. */
  2691. const SsEditor = {
  2692. name: "SsEditor",
  2693. props: {
  2694. modelValue: { type: String, default: "" },
  2695. name: { type: String, default: "mswj" },
  2696. url: { type: String, default: "" },
  2697. height: { type: [Number, String], default: 280 },
  2698. placeholder: { type: String, default: "请输入内容" },
  2699. readonly: { type: Boolean, default: false },
  2700. uploadUrl: { type: String, default: "/service?ssServ=ulByHttp" },
  2701. param: { type: Object, default: () => ({}) },
  2702. },
  2703. emits: ["update:modelValue", "ready", "change"],
  2704. setup(props, { emit }) {
  2705. const editorElementId = `ss-editor-${Date.now()}-${Math.floor(
  2706. Math.random() * 10000
  2707. )}`;
  2708. const editorContent = ref("");
  2709. const editorInstance = ref(null);
  2710. const fjid = ref(props.param?.button?.val || "");
  2711. const fjName = props.param?.button?.desc || "附件";
  2712. const mode = props.param?.mode;
  2713. /**
  2714. * 功能说明:确保附件 fjid 存在,不存在时先通过 cmsAddUrl 创建 by xu 2026-03-01
  2715. * @returns {Promise<string>} fjid
  2716. */
  2717. const ensureFjid = async () => {
  2718. if (fjid.value) return fjid.value;
  2719. if (!props.param?.button?.cmsAddUrl) return "";
  2720. return new Promise((resolve) => {
  2721. $.ajax({
  2722. type: "post",
  2723. url: props.param.button.cmsAddUrl,
  2724. async: false,
  2725. data: {
  2726. name: "fjid",
  2727. ssNrObjName: "sh",
  2728. ssNrObjId: "",
  2729. },
  2730. success: (_fjid) => {
  2731. fjid.value = _fjid || "";
  2732. resolve(fjid.value);
  2733. },
  2734. error: () => resolve(""),
  2735. });
  2736. });
  2737. };
  2738. /**
  2739. * 功能说明:打开附件管理弹窗(与PC端行为一致) by xu 2026-03-01
  2740. * @returns {Promise<void>}
  2741. */
  2742. const openAttachmentDialog = async () => {
  2743. if (!props.param?.button?.cmsUpdUrl) {
  2744. console.warn("未配置附件编辑地址 cmsUpdUrl");
  2745. return;
  2746. }
  2747. const currentFjid = await ensureFjid();
  2748. if (!currentFjid) return;
  2749. const query =
  2750. `&nrid=T-${currentFjid}` +
  2751. `&objectId=${currentFjid}` +
  2752. `&objectName=${encodeURIComponent(fjName)}` +
  2753. `&callback=${window["fjidCallbackName"] || ""}`;
  2754. if (window.SS && typeof window.SS.openDialog === "function") {
  2755. window.SS.openDialog({
  2756. src: props.param.button.cmsUpdUrl + query,
  2757. headerTitle: "编辑",
  2758. width: 900,
  2759. high: 664,
  2760. zIndex: 51,
  2761. });
  2762. }
  2763. };
  2764. /**
  2765. * 功能说明:初始化 Jodit 编辑器并绑定工具栏/上传/change 事件 by xu 2026-03-01
  2766. * @returns {void}
  2767. */
  2768. const buildEditor = () => {
  2769. if (!window.Jodit || !window.Jodit.make) {
  2770. console.error("Jodit 未加载,无法初始化 ss-editor");
  2771. return;
  2772. }
  2773. const editorUploadUrl = props.uploadUrl.includes("?")
  2774. ? `${props.uploadUrl}&type=img`
  2775. : `${props.uploadUrl}?type=img`;
  2776. const instance = window.Jodit.make(`#${editorElementId}`, {
  2777. height: props.height,
  2778. placeholder: props.placeholder,
  2779. readonly: props.readonly,
  2780. language: "zh_cn",
  2781. showXPathInStatusbar: false,
  2782. showCharsCounter: false,
  2783. showWordsCounter: false,
  2784. allowResizeY: false,
  2785. toolbarSticky: false,
  2786. statusbar: false,
  2787. uploader: {
  2788. url: editorUploadUrl,
  2789. format: "json",
  2790. method: "POST",
  2791. filesVariableName: (i) => `imgs[${i}]`,
  2792. isSuccess: (resp) => resp?.code === 0 || !!resp?.data,
  2793. getMessage: (resp) => resp?.msg || "上传失败",
  2794. process: (resp) => resp?.data?.url || resp?.data?.path || "",
  2795. contentType: () => false,
  2796. },
  2797. controls: {
  2798. customLinkButton: {
  2799. name: "link",
  2800. tooltip: "附件",
  2801. exec: () => {
  2802. openAttachmentDialog();
  2803. },
  2804. },
  2805. },
  2806. buttons: [
  2807. "fullsize",
  2808. "bold",
  2809. "italic",
  2810. "underline",
  2811. "|",
  2812. "font",
  2813. "fontsize",
  2814. "|",
  2815. "left",
  2816. "center",
  2817. "right",
  2818. "|",
  2819. "ul",
  2820. "ol",
  2821. "|",
  2822. "image",
  2823. "table",
  2824. "customLinkButton",
  2825. "|",
  2826. "undo",
  2827. "redo",
  2828. ],
  2829. buttonsMD: [
  2830. "bold",
  2831. "italic",
  2832. "underline",
  2833. "|",
  2834. "image",
  2835. "customLinkButton",
  2836. "|",
  2837. "dots",
  2838. ],
  2839. buttonsSM: [
  2840. "bold",
  2841. "italic",
  2842. "|",
  2843. "image",
  2844. "customLinkButton",
  2845. "|",
  2846. "dots",
  2847. ],
  2848. buttonsXS: ["bold", "|", "dots"],
  2849. });
  2850. instance.value = editorContent.value || "";
  2851. instance.events.on("change", () => {
  2852. editorContent.value = instance.value || "";
  2853. emit("change", editorContent.value);
  2854. });
  2855. editorInstance.value = instance;
  2856. emit("ready", instance);
  2857. };
  2858. /**
  2859. * 功能说明:按路径加载富文本HTML内容并回填编辑器 by xu 2026-03-01
  2860. * @returns {Promise<void>}
  2861. */
  2862. const loadContentByPath = async () => {
  2863. if (!props.url || !props.modelValue) return;
  2864. try {
  2865. const params = new URLSearchParams();
  2866. if (mode) params.append("mode", mode);
  2867. params.append("path", props.modelValue);
  2868. const response = await window.axios.post(props.url, params, {
  2869. headers: { "Content-Type": "application/x-www-form-urlencoded" },
  2870. });
  2871. const content = response?.data?.content || "";
  2872. if (content) {
  2873. editorContent.value = content;
  2874. if (editorInstance.value) {
  2875. editorInstance.value.value = content;
  2876. }
  2877. }
  2878. const filePath = response?.data?.path;
  2879. if (filePath) {
  2880. emit("update:modelValue", filePath);
  2881. }
  2882. } catch (error) {
  2883. console.error("ss-editor 回显内容加载失败:", error);
  2884. }
  2885. };
  2886. onMounted(async () => {
  2887. buildEditor();
  2888. await loadContentByPath();
  2889. });
  2890. watch(
  2891. () => props.readonly,
  2892. (newVal) => {
  2893. if (
  2894. editorInstance.value &&
  2895. typeof editorInstance.value.setReadOnly === "function"
  2896. ) {
  2897. editorInstance.value.setReadOnly(newVal);
  2898. }
  2899. }
  2900. );
  2901. onBeforeUnmount(() => {
  2902. if (
  2903. editorInstance.value &&
  2904. typeof editorInstance.value.destruct === "function"
  2905. ) {
  2906. editorInstance.value.destruct();
  2907. }
  2908. });
  2909. return {
  2910. editorElementId,
  2911. editorContent,
  2912. fjid,
  2913. };
  2914. },
  2915. template: `
  2916. <div class="ss-editor-container">
  2917. <input v-if="fjid" type="hidden" name="fjid" :value="fjid" />
  2918. <input type="hidden" :name="name.replace(/wj$/, '') + 'Edit'" :value="editorContent" />
  2919. <input type="hidden" :name="name.replace(/wj$/, '') + 'wj'" :value="modelValue" />
  2920. <input type="hidden" name="ueditorpath" value="mswj" />
  2921. <textarea :id="editorElementId"></textarea>
  2922. </div>
  2923. `,
  2924. };
  2925. window.SS.dom.initializeFormApp = function (config) {
  2926. const { el, ...vueOptions } = config;
  2927. const app = createApp({
  2928. ...vueOptions,
  2929. });
  2930. // 注册组件
  2931. // app.component("SsLoginIcon", SsLoginIcon);
  2932. // app.component("SsMark", SsMark);
  2933. // app.component("SsFullStyleHeader", SsFullStyleHeader);
  2934. // app.component("SsDialog", SsDialog);
  2935. app.component("SsInput", SsInput);
  2936. app.component("SsBottom", SsBottom);
  2937. app.component("SsCard", SsCard);
  2938. app.component("SsSearchButton", SsSearchButton);
  2939. app.component("SsSelect", SsSelect);
  2940. app.component("Icon", Icon);
  2941. // 注册 Vant 组件
  2942. const vantLib = window.vant || window.Vant;
  2943. console.log("🔍 检查 Vant:", {
  2944. hasVant: !!window.vant,
  2945. hasVantCap: !!window.Vant,
  2946. vantLib: !!vantLib,
  2947. vantKeys: vantLib ? Object.keys(vantLib).slice(0, 20) : [],
  2948. hasPopup: vantLib?.Popup,
  2949. hasDatetimePicker: vantLib?.DatetimePicker,
  2950. hasDatePicker: vantLib?.DatePicker,
  2951. hasTimePicker: vantLib?.TimePicker,
  2952. allKeys: vantLib ? Object.keys(vantLib) : [],
  2953. });
  2954. if (vantLib) {
  2955. try {
  2956. // 使用 Vant 的 use 方法注册所有组件
  2957. app.use(vantLib);
  2958. console.log("✅ Vant 全部组件注册成功");
  2959. } catch (error) {
  2960. console.error("❌ Vant 组件注册失败:", error);
  2961. // 降级方案:手动注册具体组件
  2962. try {
  2963. app.component("van-popup", vantLib.Popup);
  2964. app.component("van-datetime-picker", vantLib.DatetimePicker);
  2965. console.log("✅ Vant 手动注册成功");
  2966. } catch (e) {
  2967. console.error("❌ Vant 手动注册也失败:", e);
  2968. }
  2969. }
  2970. } else {
  2971. console.warn("⚠️ Vant 未加载");
  2972. }
  2973. // 注册组件 - 统一使用 kebab-case
  2974. app.component("ss-verify", SsVerify);
  2975. app.component("ss-verify-node", SsVerifyNode);
  2976. app.component("ss-common-icon", SsCommonIcon);
  2977. app.component("ss-onoff-button", SsOnoffButton);
  2978. app.component("ss-datetime-picker", SsDatetimePicker);
  2979. app.component("ss-confirm", SsConfirm);
  2980. app.component("ss-image-cropper", SsImageCropper);
  2981. app.component("ss-upload-image", SsUploadImage);
  2982. app.component("ss-upload-file", SsUploadFile);
  2983. app.component("ss-car-card", SsCarCard);
  2984. app.component("ss-sub-tab", SsSubTab);
  2985. app.component("ss-editor", SsEditor);
  2986. app.component("SsEditor", SsEditor);
  2987. // app.component("SsObjp", SsObjp);
  2988. // app.component("SsHidden", SsHidden);
  2989. // app.component("SsCcp", SsCcp);
  2990. // app.component("SsDatePicker", SsDatePicker);
  2991. // app.component("SsIcon", SsIcon);
  2992. // app.component("SsCommonIcon", SsCommonIcon);
  2993. // app.component("SsBreadcrumb", SsBreadcrumb);
  2994. // app.component("SsEditor", SsEditor);
  2995. // app.component("SsDialogIcon", SsDialogIcon);
  2996. // app.component("SsBottomButton", SsBottomButton);
  2997. // app.component("SsNavIcon", SsNavIcon);
  2998. // app.component("SsHeaderIcon", SsHeaderIcon);
  2999. // app.component("SsGolbalMenuIcon", SsGolbalMenuIcon);
  3000. // app.component("SsCartListIcon", SsCartListIcon);
  3001. // app.component("SsQuickIcon", SsQuickIcon);
  3002. // app.component("SsFormIcon", SsFormIcon);
  3003. // app.component("SsBottomDivIcon", SsBottomDivIcon);
  3004. // app.component("SsEditorIcon", SsEditorIcon);
  3005. // app.component("SsValidate", SsValidate);
  3006. // app.component("SsOnoffbutton", SsOnoffbutton);
  3007. // app.component("SsOnoffbuttonArray", SsOnoffbuttonArray);
  3008. // app.component("SsTextarea", SsTextarea);
  3009. // app.component("SsLoginInput", SsLoginInput);
  3010. // app.component("SsLoginButton", SsLoginButton);
  3011. // app.component("SsSearch", SsSearch);
  3012. // app.component("SsCartItem", SsCartItem);
  3013. // app.component("SsCartItem2", SsCartItem2);
  3014. // app.component("SsListCard", SsListCard);
  3015. // app.component("SsFolderCard", SsFolderCard);
  3016. // app.component("SsFolderCartView", SsFolderCartView);
  3017. // app.component("SsPage", SsPage);
  3018. // app.component("SsRightInfo", SSRightInfo);
  3019. // app.component("SsSuccessPopup", SsSuccessPopup);
  3020. // app.component("SsErrorDialog", SsErrorDialog);
  3021. // app.component("SsVerify", SsVerify);
  3022. // app.component("SsVerifyNode", SsVerifyNode);
  3023. // app.component("SsOrcImgBox", SsOrcImgBox);
  3024. // app.component("ss-search-input", SsSearchInput);
  3025. // app.component("ss-search-date-picker", SsSearchDatePicker);
  3026. // app.component("ss-search-button", SsSearchButton);
  3027. // app.component("ss-drop-button", SsDropButton);
  3028. // app.component("ss-sub-tab", SsSubTab);
  3029. // app.component("ss-img", SsImgUpload);
  3030. // 设置为中文
  3031. // app.use(ElementPlus, {
  3032. // locale: ElementPlusLocaleZhCn,
  3033. // });
  3034. // console.log(ElementPlus);
  3035. // 确保 ElementPlusIconsVue
  3036. // if (window.ElementPlusIconsVue) {
  3037. // // 注册 Element Plus 图标组件
  3038. // for (const [key, component] of Object.entries(
  3039. // window.ElementPlusIconsVue
  3040. // )) {
  3041. // console.log(key, component);
  3042. // app.component(key, component);
  3043. // }
  3044. // }
  3045. // 挂载首页的组件
  3046. // for (const componentName in IndexComponents) {
  3047. // app.component(componentName, IndexComponents[componentName]);
  3048. // }
  3049. // 挂载echarts的组件
  3050. // for (const componentName in EchartComponents) {
  3051. // app.component(componentName, EchartComponents[componentName]);
  3052. // }
  3053. // 挂载 Vue 应用
  3054. const vm = app.mount(el);
  3055. vm.data = vueOptions.data();
  3056. return vm;
  3057. };
  3058. })();