mp-ss-components.js 92 KB

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