| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151 |
- // H5版本的小程序组件库
- // 参考 alf/ss-components.js 的组件形式
- (function () {
- const {
- ref,
- createApp,
- watch,
- inject,
- onMounted,
- onBeforeUnmount,
- computed,
- } = Vue;
- // ===== 公共上传函数 =====
- /**
- * 统一文件上传函数
- * @param {File|Blob} file - 文件或Blob对象
- * @param {String} type - 'image' | 'file'
- * @param {String} fileName - 文件名(可选)
- * @returns {Promise<String>} 服务器路径
- */
- async function uploadFile(file, type = "image", fileName) {
- try {
- window.showToast?.("上传中...", "loading");
- const formData = new FormData();
- const name = fileName || (file instanceof Blob ? "file" : file.name);
- formData.append("fileEdit", file, name);
- formData.append("application", "");
- // 根据类型选择接口参数
- const apiType = type === "image" ? "img" : "file";
- const result = await window.request.post(
- `/service?ssServ=ulByHttp&type=${apiType}`,
- formData,
- { loading: false }
- );
- if (result?.data?.fileList?.[0]?.path) {
- const serverPath = result.data.fileList[0].path;
- window.showToast?.("上传成功", "success");
- return serverPath;
- } else {
- throw new Error("上传返回数据格式错误");
- }
- } catch (error) {
- console.error("上传失败:", error);
- window.showToast?.("上传失败,请重试", "error");
- throw error;
- }
- }
- const SsCommonIcon = {
- name: "SsCommonIcon",
- props: {
- class: {
- type: String,
- required: true,
- },
- },
- setup(props) {
- const { h } = Vue;
- return () =>
- h("i", {
- class: props.class + " common-icon",
- });
- },
- };
- // ss-input 智能输入组件
- const SsInput = {
- name: "SsInput",
- inheritAttrs: false, // 不直接继承属性到组件根元素
- props: {
- // v-model 绑定的值
- modelValue: {
- type: [String, Number],
- default: "",
- },
- // 字段名称
- name: {
- type: String,
- default: "",
- },
- // 占位符
- placeholder: {
- type: String,
- default: "请输入",
- },
- // 错误提示
- errTip: {
- type: String,
- default: "",
- },
- },
- emits: ["update:modelValue", "input", "blur", "change", "focus"],
- setup(props, { emit }) {
- const inputValue = ref(props.modelValue || "");
- const validationState = ref({
- hasError: false,
- errorMessage: "",
- isRequired: false,
- isEmpty: true,
- hasInteracted: false, // 是否已经交互过
- isSubmitMode: false, // 是否处于提交模式
- });
- // 从ValidatedTd注入事件处理函数(兼容小程序方式)
- const onInputInject = inject("onInput", null);
- const onBlurInject = inject("onBlur", null);
- // 检查是否为必填字段
- const checkRequired = () => {
- if (window.ssVm && props.name) {
- validationState.value.isRequired = window.ssVm.isRequired(props.name);
- }
- };
- // 更新父级td的class
- const updateTdClass = () => {
- // 找到父级td元素
- const inputElement = document.querySelector(
- `input[name="${props.name}"]`
- );
- if (inputElement) {
- const tdElement = inputElement.closest("td");
- if (tdElement) {
- // 移除所有校验相关的class
- tdElement.classList.remove("td-required", "td-error");
- // 逻辑分离:
- // 1. 初始状态:必填且为空 → 只有左侧红线 (td-required)
- // 2. 实时校验失败 → 左侧红线 + 底部红线 + 错误文字 (td-error)
- if (
- validationState.value.hasError &&
- validationState.value.hasInteracted
- ) {
- // 用户交互后校验失败:左侧红线 + 底部红线 + 错误文字
- tdElement.classList.add("td-error");
- } else if (
- validationState.value.isRequired &&
- validationState.value.isEmpty
- ) {
- // 必填且为空:只有左侧红线
- tdElement.classList.add("td-required");
- }
- }
- }
- };
- // 校验字段
- const validateField = (value) => {
- if (window.ssVm && props.name) {
- const result = window.ssVm.validateField(props.name);
- validationState.value.hasError = !result.valid;
- validationState.value.errorMessage = result.message || "";
- validationState.value.isEmpty = !value || value.trim() === "";
- // 更新td的class
- setTimeout(updateTdClass, 0);
- return result;
- }
- return { valid: true, message: "" };
- };
- // 监听props变化
- watch(
- () => props.modelValue,
- (newVal) => {
- inputValue.value = newVal;
- validateField(newVal);
- }
- );
- // 挂载时初始化
- onMounted(() => {
- checkRequired();
- validateField(inputValue.value);
- // 监听ssVm规则更新事件
- const inputElement = document.querySelector(
- `input[name="${props.name}"]`
- );
- if (inputElement) {
- inputElement.addEventListener("ssvm-rules-updated", () => {
- console.log(`收到规则更新通知: ${props.name}`);
- checkRequired();
- validateField(inputValue.value);
- updateTdClass();
- });
- }
- // 确保初始状态正确显示,延迟更长时间确保DOM完全渲染
- setTimeout(() => {
- updateTdClass();
- }, 200);
- });
- // 事件处理函数
- const handleInput = (event) => {
- const value = event.target.value;
- inputValue.value = value;
- // 标记已交互
- validationState.value.hasInteracted = true;
- // 1. 支持v-model
- emit("update:modelValue", value);
- emit("input", event);
- // 2. 内部校验(实时校验)
- validateField(value);
- // 3. 兼容小程序inject方式
- if (onInputInject) {
- onInputInject(event);
- }
- };
- const handleBlur = (event) => {
- // 标记已交互
- validationState.value.hasInteracted = true;
- emit("blur", event);
- // 失焦时再次校验
- validateField(inputValue.value);
- // 兼容小程序inject方式
- if (onBlurInject) {
- onBlurInject(event);
- }
- };
- const handleFocus = (event) => {
- emit("focus", event);
- };
- return {
- inputValue,
- validationState,
- handleInput,
- handleBlur,
- handleFocus,
- };
- },
- template: `
- <!-- 移动端简化的错误提示 - 显示在输入框右下角 -->
- <div v-if="validationState.hasError && validationState.hasInteracted" class="ss-input__error">
- {{ validationState.errorMessage }}
- </div>
- <div class="ss-input">
- <input
- class="ss-input__field"
- :name="name"
- type="text"
- :value="inputValue"
- :placeholder="placeholder"
- @input="handleInput"
- @blur="handleBlur"
- @focus="handleFocus"
- autocomplete="off"
- v-bind="$attrs"
- />
- </div>
- `,
- };
- // icon 图标组件 - 从小程序转换
- const Icon = {
- name: "Icon",
- props: {
- // 图标名称,对应iconfont中的类名,如'icon-home'
- name: {
- type: String,
- required: true,
- },
- // 图标颜色
- color: {
- type: String,
- default: "#000",
- },
- // 图标大小,单位rpx (H5中转换为px)
- size: {
- type: [Number, String],
- default: 32,
- },
- },
- emits: ["click"],
- setup(props, { emit }) {
- // 计算完整的图标类名
- const iconClass = computed(() => {
- return props.name;
- });
- // 计算样式
- const iconStyle = computed(() => ({
- color: props.color,
- fontSize: parseInt(props.size) / 2 + "px", // rpx转px,除以2
- verticalAlign: "middle",
- }));
- // 点击事件处理
- const handleClick = (event) => {
- emit("click", event);
- };
- return {
- iconClass,
- iconStyle,
- handleClick,
- };
- },
- template: `
- <span
- :class="['iconfont', iconClass]"
- :style="iconStyle"
- @click="handleClick"
- ></span>
- `,
- };
- // ss-card 卡片组件 - 从小程序转换
- const SsCard = {
- name: "SsCard",
- props: {
- item: {
- type: Object,
- default: () => ({}),
- },
- },
- emits: ["click", "buttonClick"],
- setup(props, { emit }) {
- // 状态
- const showButtonMenu = ref(false);
- const swipeOffset = ref(0);
- const touchStartX = ref(0);
- const touchStartY = ref(0);
- const swipeOpening = ref(false);
- const isSwiping = ref(false);
- const swipeActions = computed(() => {
- return Array.isArray(props.item?.swipeActions)
- ? props.item.swipeActions.filter(Boolean)
- : [];
- });
- const hasSwipeActions = computed(() => swipeActions.value.length > 0);
- const swipeActionPalette = [
- { backgroundColor: "#1d3388", color: "#fff" },
- { backgroundColor: "#2b4cd1", color: "#fff" },
- { backgroundColor: "#3c77ec", color: "#fff" },
- { backgroundColor: "#96b5fb", color: "#1d3388" },
- { backgroundColor: "#dfe9fd", color: "#1d3388" },
- ];
- const actionPaneWidth = computed(() => {
- if (!hasSwipeActions.value) return 0;
- return 66 * swipeActions.value.length;
- });
- // 计算属性
- const hasButtons = computed(() => {
- return (
- props.item?.buttons &&
- Array.isArray(props.item.buttons) &&
- props.item.buttons.length > 0
- );
- });
- const isMultipleButtons = computed(() => {
- return props.item?.buttons && props.item.buttons.length > 1;
- });
- const isSingleButton = computed(() => {
- return props.item?.buttons && props.item.buttons.length === 1;
- });
- const cardContentStyle = computed(() => ({
- transform: `translateX(-${swipeOffset.value}px)`,
- }));
- const actionPaneStyle = computed(() => ({
- width: `${actionPaneWidth.value}px`,
- }));
- const swipeIndicatorStyle = computed(() => {
- const palette = getSwipeActionStyle(0) || {};
- return {
- backgroundColor: palette.backgroundColor || "#1d3388",
- opacity: swipeOffset.value > 0 ? 0 : 1,
- };
- });
- const getSwipeActionStyle = (index) => {
- const paletteIndex = Math.min(
- Number(index || 0),
- swipeActionPalette.length - 1
- );
- return swipeActionPalette[paletteIndex] || swipeActionPalette[0];
- };
- const closeSwipe = () => {
- swipeOffset.value = 0;
- swipeOpening.value = false;
- };
- const openSwipe = () => {
- if (!hasSwipeActions.value) return;
- swipeOffset.value = actionPaneWidth.value;
- swipeOpening.value = true;
- };
- // 处理卡片点击
- const handleCardClick = () => {
- if (isSwiping.value) {
- isSwiping.value = false;
- return;
- }
- if (swipeOpening.value) {
- closeSwipe();
- return;
- }
- // 如果菜单打开,先关闭菜单
- if (showButtonMenu.value) {
- showButtonMenu.value = false;
- return;
- }
- emit("click");
- };
- // 处理设置按钮点击
- const handleSettingClick = () => {
- if (isSingleButton.value) {
- // 只有一个按钮,直接执行
- handleButtonClick(props.item.buttons[0], 0);
- } else if (isMultipleButtons.value) {
- // 先记录当前状态
- const wasOpen = showButtonMenu.value;
- // 关闭其他卡片的菜单
- document.dispatchEvent(new CustomEvent("closeAllCardMenus"));
- // 切换当前菜单状态
- showButtonMenu.value = !wasOpen;
- }
- };
- // 关闭菜单
- const closeMenu = () => {
- showButtonMenu.value = false;
- };
- const handleTouchStart = (event) => {
- if (!hasSwipeActions.value) return;
- const touch = event.touches && event.touches[0];
- if (!touch) return;
- touchStartX.value = touch.clientX;
- touchStartY.value = touch.clientY;
- isSwiping.value = false;
- document.dispatchEvent(new CustomEvent("closeAllCardSwipes"));
- };
- const handleTouchMove = (event) => {
- if (!hasSwipeActions.value) return;
- const touch = event.touches && event.touches[0];
- if (!touch) return;
- const deltaX = touch.clientX - touchStartX.value;
- const deltaY = touch.clientY - touchStartY.value;
- if (Math.abs(deltaY) > Math.abs(deltaX) || Math.abs(deltaX) < 8) {
- return;
- }
- isSwiping.value = true;
- const nextOffset = swipeOpening.value
- ? actionPaneWidth.value - deltaX
- : -deltaX;
- swipeOffset.value = Math.max(0, Math.min(actionPaneWidth.value, nextOffset));
- };
- const handleTouchEnd = () => {
- if (!hasSwipeActions.value) return;
- if (swipeOffset.value > actionPaneWidth.value / 2) {
- openSwipe();
- } else {
- closeSwipe();
- }
- setTimeout(() => {
- isSwiping.value = false;
- }, 0);
- };
- const handleCloseAllSwipes = () => {
- closeSwipe();
- };
- // 处理按钮点击
- const handleButtonClick = (btn, index) => {
- showButtonMenu.value = false;
- closeSwipe();
- // 执行按钮的回调
- if (btn.onclick && typeof btn.onclick === "function") {
- btn.onclick();
- }
- // 触发组件事件
- emit("buttonClick", { button: btn, index, item: props.item });
- };
- // 监听全局关闭事件
- onMounted(() => {
- document.addEventListener("closeAllCardMenus", closeMenu);
- document.addEventListener("closeAllCardSwipes", handleCloseAllSwipes);
- });
- onBeforeUnmount(() => {
- document.removeEventListener("closeAllCardMenus", closeMenu);
- document.removeEventListener("closeAllCardSwipes", handleCloseAllSwipes);
- });
- // H5环境下的清理逻辑
- // 注意:Vue 3的beforeUnmount在某些H5环境下可能不可用
- // 这里暂时省略,依赖页面刷新时的自动清理
- return {
- showButtonMenu,
- swipeActions,
- hasSwipeActions,
- hasButtons,
- isMultipleButtons,
- isSingleButton,
- cardContentStyle,
- actionPaneStyle,
- swipeIndicatorStyle,
- getSwipeActionStyle,
- handleCardClick,
- handleSettingClick,
- handleTouchStart,
- handleTouchMove,
- handleTouchEnd,
- handleButtonClick,
- };
- },
- template: `
- <div class="ss-card" :class="{ 'ss-card--swipeable': hasSwipeActions }">
- <div
- v-if="hasSwipeActions"
- class="ss-card-swipe__indicator"
- :style="swipeIndicatorStyle"
- ></div>
- <div v-if="hasSwipeActions" class="ss-card-swipe__actions" :style="actionPaneStyle">
- <!-- 功能说明:卡片左滑露出记录操作按钮(如“变动”),替代齿轮菜单入口 by xu 2026-03-06 -->
- <button
- v-for="(btn, index) in swipeActions"
- :key="index"
- class="ss-card-swipe__action"
- :style="getSwipeActionStyle(index)"
- @click.stop="handleButtonClick(btn, index)"
- >
- {{ btn.title || btn.text || btn.desc }}
- </button>
- </div>
- <div
- class="ss-card-swipe__content"
- :style="cardContentStyle"
- @touchstart="handleTouchStart"
- @touchmove="handleTouchMove"
- @touchend="handleTouchEnd"
- >
- <div class="ss-card-main" @click="handleCardClick">
- <!-- 右上角设置按钮 - 只有buttons且长度>0时显示 -->
- <div
- v-if="hasButtons && !hasSwipeActions"
- class="card-setting-header"
- @click.stop="handleSettingClick"
- >
- <div class="setting-icon">
- <Icon name="icon-chilun" size="32" color="#999"/>
- </div>
- <!-- 按钮弹窗菜单 - 只有多个按钮时显示 -->
- <div
- v-if="showButtonMenu && isMultipleButtons"
- class="button-menu"
- @click.stop
- >
- <div
- v-for="(btn, index) in item.buttons"
- :key="index"
- class="menu-item"
- @click="handleButtonClick(btn, index)"
- >
- <Icon v-if="btn.icon" :name="btn.icon" size="28" color="inherit"/>
- <span class="menu-text">{{ btn.title }}</span>
- </div>
- </div>
- </div>
- <!-- 卡片内容 -->
- <slot></slot>
- </div>
- </div>
- </div>
- `,
- };
- // ss-search-button 搜索按钮组件 - 从小程序转换
- const SsSearchButton = {
- name: "SsSearchButton",
- props: {
- // 按钮文本
- text: {
- type: String,
- default: "增加",
- },
- // 是否禁用
- disabled: {
- type: Boolean,
- default: false,
- },
- // 按钮高度
- height: {
- type: [String, Number],
- default: "36px",
- },
- // 前置图标名称
- preIcon: {
- type: String,
- default: "",
- },
- // 后置图标名称
- suffixIcon: {
- type: String,
- default: "",
- },
- // 图标大小
- iconSize: {
- type: [String, Number],
- default: "32",
- },
- // 图标颜色
- iconColor: {
- type: String,
- default: "#585d6e",
- },
- // 自定义按钮样式
- customStyle: {
- type: Object,
- default: () => ({}),
- },
- // 跳转链接(兼容原JSP用法)
- href: {
- type: String,
- default: "",
- },
- // 选项列表
- options: {
- type: Array,
- default: () => [],
- },
- },
- emits: ["click", "optionClick"],
- setup(props, { emit }) {
- // 状态
- const showOptionsMenu = ref(false);
- // 计算属性
- const hasOptions = computed(() => {
- return (
- props.options &&
- Array.isArray(props.options) &&
- props.options.length > 0
- );
- });
- const hasMultipleOptions = computed(() => {
- return props.options && props.options.length > 1;
- });
- const isSingleOption = computed(() => {
- return props.options && props.options.length === 1;
- });
- // 按钮样式
- const buttonStyle = computed(() => ({
- height:
- typeof props.height === "number" ? `${props.height}px` : props.height,
- ...props.customStyle,
- }));
- // 处理按钮点击
- const handleClick = () => {
- if (!hasOptions.value) {
- // 没有选项,直接触发点击事件
- emit("click");
- } else if (isSingleOption.value) {
- // 单个选项,直接执行
- handleOptionClick(props.options[0], 0);
- } else if (hasMultipleOptions.value) {
- // 先记录当前状态
- const wasOpen = showOptionsMenu.value;
- // 关闭其他按钮的菜单
- document.dispatchEvent(new CustomEvent("closeAllButtonMenus"));
- // 切换当前菜单状态
- showOptionsMenu.value = !wasOpen;
- }
- };
- // 处理选项点击
- const handleOptionClick = (option, index) => {
- showOptionsMenu.value = false;
- // 执行选项的回调
- if (option.onclick && typeof option.onclick === "function") {
- option.onclick();
- }
- // 触发组件事件
- emit("optionClick", { option, index });
- };
- // 关闭菜单
- const closeMenu = () => {
- showOptionsMenu.value = false;
- };
- // 监听全局关闭事件
- onMounted(() => {
- document.addEventListener("closeAllButtonMenus", closeMenu);
- });
- return {
- showOptionsMenu,
- hasOptions,
- hasMultipleOptions,
- isSingleOption,
- buttonStyle,
- handleClick,
- handleOptionClick,
- };
- },
- template: `
- <div class="ss-search-button-container" :class="{ open: showOptionsMenu }">
- <button
- class="ss-search-button"
- :style="buttonStyle"
- @click="handleClick"
- :disabled="disabled"
- >
- <!-- 前置图标插槽 -->
- <div v-if="preIcon" class="ss-search-button__pre-icon">
- <Icon :name="preIcon" :size="iconSize" :color="iconColor"/>
- </div>
- <!-- 按钮文本 -->
- <span class="ss-search-button__text">{{ text }}</span>
- <!-- 后置图标插槽 -->
- <div v-if="suffixIcon" class="ss-search-button__suffix-icon">
- <Icon :name="suffixIcon" :size="iconSize" :color="iconColor"/>
- </div>
- </button>
- <!-- 选项弹窗菜单 -->
- <div
- v-if="showOptionsMenu && hasMultipleOptions"
- class="options-menu"
- @click.stop
- >
- <div
- v-for="(option, index) in options"
- :key="index"
- class="option-item"
- @click="handleOptionClick(option, index)"
- >
- <Icon v-if="option.icon" :name="option.icon" size="28" color="inherit"/>
- <span class="option-text">{{ option.text }}</span>
- </div>
- </div>
- </div>
- `,
- };
- // ss-select 下拉选择组件 - 从小程序转换
- const SsSelect = {
- name: "SsSelect",
- props: {
- // 选项数组
- options: {
- type: Array,
- default: () => [],
- },
- // 字段映射
- mapping: {
- type: Object,
- default: () => ({ text: "n", value: "v" }),
- },
- // 默认值
- modelValue: {
- type: [String, Number],
- default: "",
- },
- // 占位符
- placeholder: {
- type: String,
- default: "请选择",
- },
- // 校验配置
- validation: {
- type: Object,
- default: () => ({ enable: false, message: "" }),
- },
- // 是否禁用
- disabled: {
- type: Boolean,
- default: false,
- },
- // 是否支持搜索
- searchable: {
- type: Boolean,
- default: false,
- },
- // 是否支持清空
- clearable: {
- type: Boolean,
- default: false,
- },
- // 加载状态
- loading: {
- type: Boolean,
- default: false,
- },
- // 宽度设置
- width: {
- type: String,
- default: "100%",
- },
- minWidth: {
- type: String,
- default: "unset",
- },
- // 功能说明:对齐PC端 ss-objp,用 codebook 在组件内部拉取下拉选项 by xu 2026-02-28
- cb: {
- type: String,
- default: "",
- },
- url: {
- type: String,
- default: "/service?ssServ=loadObjpOpt&objectpickerdropdown1=1",
- },
- inp: {
- type: [Boolean, String],
- default: false,
- },
- filter: {
- type: [Object, String],
- default: null,
- },
- autoSelectFirst: {
- type: Boolean,
- default: false,
- },
- },
- emits: ["update:modelValue", "change", "search", "clear", "loaded"],
- setup(props, { emit }) {
- // 响应式数据
- const isOpen = ref(false);
- const containerRef = ref(null);
- const selectedValue = ref(props.modelValue);
- const searchKeyword = ref("");
- const remoteOptions = ref([]);
- const remoteLoading = ref(false);
- const autoMinWidth = ref("");
- // 功能说明:ss-select 未显式传宽度时,按 placeholder/选项文本计算稳定最小宽度,避免回显和下拉宽度随选中项抖动 by xu 2026-03-06
- const normalizeCssSize = (value) => {
- if (value === undefined || value === null) return "";
- if (typeof value === "number") return `${value}px`;
- const text = String(value).trim();
- return text;
- };
- const hasExplicitWidth = computed(() => {
- const widthText = normalizeCssSize(props.width);
- if (!widthText) return false;
- return widthText !== "100%" && widthText !== "auto";
- });
- const measureStableMinWidth = () => {
- if (hasExplicitWidth.value) {
- autoMinWidth.value = "";
- return;
- }
- const texts = [props.placeholder]
- .concat(
- (Array.isArray(optionsList.value) ? optionsList.value : []).map(
- (option) => option?.[props.mapping.text]
- )
- )
- .map((item) => (item === undefined || item === null ? "" : String(item).trim()))
- .filter(Boolean);
- if (!texts.length || typeof document === "undefined") {
- autoMinWidth.value = "";
- return;
- }
- const measureNode =
- containerRef.value?.querySelector?.(".select-text") || containerRef.value;
- const computedStyle = measureNode
- ? window.getComputedStyle(measureNode)
- : null;
- const fontSize = computedStyle?.fontSize || "16px";
- const fontWeight = computedStyle?.fontWeight || "400";
- const fontFamily = computedStyle?.fontFamily || "sans-serif";
- const canvas = document.createElement("canvas");
- const context = canvas.getContext("2d");
- if (!context) return;
- context.font = `${fontWeight} ${fontSize} ${fontFamily}`;
- const widestText = texts.reduce((maxWidth, text) => {
- return Math.max(maxWidth, context.measureText(text).width);
- }, 0);
- const reservedWidth = 52;
- autoMinWidth.value = `${Math.max(88, Math.ceil(widestText + reservedWidth))}px`;
- };
- const parseFilterObj = () => {
- if (!props.filter) return {};
- if (typeof props.filter === "object") return props.filter;
- if (typeof props.filter === "string") {
- try {
- const obj = JSON.parse(props.filter);
- return obj && typeof obj === "object" ? obj : {};
- } catch (_) {
- return {};
- }
- }
- return {};
- };
- const normalizeResultToOptions = (respData) => {
- const raw = respData || {};
- if (Array.isArray(raw.resultList)) {
- return raw.resultList.map((it) => {
- if (it && typeof it === "object") return it;
- return { n: String(it || ""), v: String(it || "") };
- });
- }
- if (raw.result && typeof raw.result === "object") {
- return Object.keys(raw.result).map((k) => ({
- n: raw.result[k],
- v: k,
- }));
- }
- if (Array.isArray(raw.objectList)) {
- return raw.objectList.map((it) => {
- if (it && typeof it === "object") return it;
- return { n: String(it || ""), v: String(it || "") };
- });
- }
- return [];
- };
- const maybeAutoSelectFirst = (opts) => {
- if (!props.autoSelectFirst) return;
- if (!Array.isArray(opts) || opts.length === 0) return;
- if (
- selectedValue.value !== undefined &&
- selectedValue.value !== null &&
- selectedValue.value !== ""
- )
- return;
- const first = opts[0];
- if (!first || typeof first !== "object") return;
- const value = first[props.mapping.value];
- if (value === undefined || value === null || value === "") return;
- selectedValue.value = value;
- emit("update:modelValue", value);
- emit("change", value);
- };
- const needRemoteData = computed(() => !!String(props.cb || "").trim());
- const loadRemoteOptions = async () => {
- if (!needRemoteData.value) return;
- if (!window.request || typeof window.request.post !== "function")
- return;
- remoteLoading.value = true;
- try {
- const objpParam = {
- input: String(props.inp === true || props.inp === "true"),
- codebook: String(props.cb || ""),
- ...parseFilterObj(),
- };
- const postData = {
- objectpickerparam: JSON.stringify(objpParam),
- objectpickertype: 1,
- objectpickersearchAll: 1,
- };
- const resp = await window.request.post(
- props.url || "/service?ssServ=loadObjpOpt&objectpickerdropdown1=1",
- postData,
- { loading: false, formData: true }
- );
- const opts = normalizeResultToOptions(
- resp && resp.data ? resp.data : null
- );
- remoteOptions.value = opts;
- emit("loaded", opts);
- maybeAutoSelectFirst(opts);
- } catch (e) {
- remoteOptions.value = [];
- emit("loaded", []);
- console.error("[ss-select] loadRemoteOptions failed", props.cb, e);
- } finally {
- remoteLoading.value = false;
- }
- };
- // 计算属性
- const optionsList = computed(() => {
- if (Array.isArray(props.options) && props.options.length > 0)
- return props.options;
- if (needRemoteData.value) return remoteOptions.value;
- return [];
- });
- const finalLoading = computed(
- () => !!props.loading || remoteLoading.value
- );
- const selectContainerStyle = computed(() => {
- const style = {};
- const widthText = normalizeCssSize(props.width);
- const minWidthText = normalizeCssSize(props.minWidth);
- // 功能说明:ss-select 在 td 等窄容器中限制最大宽度,避免自动 minWidth 把父布局撑开 by xu 2026-03-07
- style.maxWidth = "100%";
- if (hasExplicitWidth.value) {
- style.width = widthText;
- }
- if (minWidthText && minWidthText !== "unset") {
- style.minWidth = `min(${minWidthText}, 100%)`;
- } else if (autoMinWidth.value) {
- style.minWidth = `min(${autoMinWidth.value}, 100%)`;
- }
- return style;
- });
- const displayText = computed(() => {
- if (!selectedValue.value) return props.placeholder;
- const selectedOption = optionsList.value.find(
- (option) => option[props.mapping.value] === selectedValue.value
- );
- return selectedOption
- ? selectedOption[props.mapping.text]
- : props.placeholder;
- });
- // 监听 modelValue 变化
- watch(
- () => props.modelValue,
- (newValue) => {
- selectedValue.value = newValue;
- }
- );
- // 切换下拉框
- const toggleDropdown = () => {
- if (props.disabled) return;
- isOpen.value = !isOpen.value;
- };
- // 选择选项
- const selectOption = (option) => {
- const value = option[props.mapping.value];
- selectedValue.value = value;
- isOpen.value = false;
- emit("update:modelValue", value);
- emit("change", value);
- };
- // 点击外部关闭
- const handleClickOutside = (event) => {
- if (!event.target.closest(".ss-select-container")) {
- isOpen.value = false;
- }
- };
- onMounted(() => {
- document.addEventListener("click", handleClickOutside);
- loadRemoteOptions();
- if (!needRemoteData.value && Array.isArray(props.options)) {
- emit("loaded", props.options);
- }
- measureStableMinWidth();
- });
- watch(
- () => [props.cb, props.url, props.filter],
- () => {
- if (!needRemoteData.value) return;
- loadRemoteOptions();
- }
- );
- watch(
- () => props.options,
- (newVal) => {
- if (needRemoteData.value) return;
- emit("loaded", Array.isArray(newVal) ? newVal : []);
- }
- );
- watch(
- () => [
- props.placeholder,
- props.width,
- props.minWidth,
- optionsList.value
- .map((option) => option?.[props.mapping.text])
- .join("||"),
- ],
- () => {
- measureStableMinWidth();
- },
- { immediate: true }
- );
- return {
- containerRef,
- isOpen,
- selectedValue,
- searchKeyword,
- optionsList,
- finalLoading,
- displayText,
- selectContainerStyle,
- toggleDropdown,
- selectOption,
- };
- },
- template: `
- <div ref="containerRef" class="ss-select-container" :class="{ open: isOpen }" :style="selectContainerStyle" @click.stop="toggleDropdown">
- <!-- 显示区域 -->
- <div class="ss-select" :class="{ disabled: disabled }">
- <span class="select-text" :class="{ placeholder: !selectedValue }">{{ displayText }}</span>
- <div class="select-arrow" :class="{ rotate: isOpen }">
- <Icon name="icon-xiangxiajiantou" size="32" :color="disabled ? '#ccc' : '#999'"/>
- </div>
- </div>
- <!-- 选项列表 -->
- <div class="ss-options" v-show="isOpen">
- <!-- 加载状态 -->
- <div
- v-if="finalLoading"
- class="option-item loading-item"
- >
- <span class="loading-text">加载中...</span>
- </div>
- <!-- 无选项 -->
- <div
- v-else-if="optionsList.length === 0"
- class="option-item no-options"
- >
- 无选项
- </div>
- <!-- 选项列表 -->
- <div
- v-else
- v-for="(option, index) in optionsList"
- :key="index"
- class="option-item"
- :class="{ selected: option[mapping.value] === selectedValue }"
- @click.stop="selectOption(option)"
- >
- {{ option[mapping.text] }}
- </div>
- </div>
- </div>
- `,
- };
- // ss-bottom 底部按钮组件
- const SsBottom = {
- name: "SsBottom",
- props: {
- // 是否显示审核意见
- showShyj: {
- type: Boolean,
- default: false,
- },
- // 审核意见标题
- shyjTitle: {
- type: String,
- default: "审核意见",
- },
- // 审核意见占位符
- shyjPlaceholder: {
- type: String,
- default: "请输入审核意见",
- },
- divider: {
- type: Boolean,
- default: true,
- },
- // 按钮配置
- buttons: {
- type: Array,
- default: () => [
- { text: "取消", action: "cancel" },
- { text: "保存并提交", action: "submit" },
- ],
- },
- },
- emits: ["button-click", "update:shyjValue"],
- setup(props, { emit }) {
- const reason = ref("");
- const activeButtonIndex = ref(-1);
- // 处理按钮点击
- const handleButtonClick = (button, index) => {
- emit("button-click", {
- action: button.action,
- button: button,
- index: index,
- shyjValue: reason.value, // 传递审核意见
- });
- };
- // 监听审核意见变化
- const handleShyjInput = (event) => {
- const value = event.target.value;
- reason.value = value;
- emit("update:shyjValue", value);
- };
- // 处理按钮按下
- const handleButtonMouseDown = (index) => {
- activeButtonIndex.value = index;
- };
- // 处理按钮释放
- const handleButtonMouseUp = () => {
- activeButtonIndex.value = -1;
- };
- // 处理鼠标离开
- const handleMouseLeave = () => {
- activeButtonIndex.value = -1;
- };
- // 计算按钮样式
- const getButtonStyle = (button) => {
- const styles = {};
- // 如果有背景颜色配置
- if (button.backgroundColor) {
- styles.backgroundColor = button.backgroundColor;
- }
- // 如果有字体颜色配置
- if (button.color) {
- styles.color = button.color;
- }
- return styles;
- };
- // 计算按钮点击样式
- const getButtonActiveStyle = (button) => {
- const styles = {};
- // 如果有点击背景色配置,使用点击背景色
- if (button.clickBgColor) {
- styles.backgroundColor = button.clickBgColor;
- } else if (button.backgroundColor) {
- // 如果没有点击背景色,但有背景色,点击时使用背景色
- styles.backgroundColor = button.backgroundColor;
- }
- // 如果有点击字体色配置,使用点击字体色
- if (button.clickColor) {
- styles.color = button.clickColor;
- } else if (button.color) {
- // 如果没有点击字体色,但有字体色,点击时使用字体色
- styles.color = button.color;
- }
- return styles;
- };
- return {
- reason,
- activeButtonIndex,
- handleButtonClick,
- handleShyjInput,
- handleButtonMouseDown,
- handleButtonMouseUp,
- handleMouseLeave,
- getButtonStyle,
- getButtonActiveStyle,
- };
- },
- template: `
- <div class="ss-bottom">
- <!-- 审核意见区域 -->
- <div v-if="showShyj" class="ss-bottom__opinion">
- <table class="ss-bottom__opinion-table">
- <tr>
- <th class="ss-bottom__opinion-label">{{ shyjTitle }}</th>
- <td class="ss-bottom__opinion-input">
- <ss-input
- :placeholder="shyjPlaceholder"
- v-model="reason"
- @input="handleShyjInput"
- />
- </td>
- </tr>
- </table>
- </div>
- <!-- 按钮区域 -->
- <div class="ss-bottom__buttons" :class="{ 'ss-bottom__buttons--with-border': !showShyj }">
- <template v-for="(button, index) in buttons" :key="index">
- <div
- class="ss-bottom__button"
- :class="{
- 'ss-bottom__button--custom': button.backgroundColor || button.color || button.clickBgColor || button.clickColor,
- 'ss-bottom__button--active': activeButtonIndex === index
- }"
- :style="activeButtonIndex === index ? getButtonActiveStyle(button) : getButtonStyle(button)"
- @click="handleButtonClick(button, index)"
- @mousedown="handleButtonMouseDown(index)"
- @mouseup="handleButtonMouseUp"
- @mouseleave="handleMouseLeave"
- @touchstart="handleButtonMouseDown(index)"
- @touchend="handleButtonMouseUp"
- >
- {{ button.text }}
- </div>
- <!-- 分割线,最后一个按钮不显示 -->
- <div v-if="index < buttons.length - 1 && divider" class="ss-bottom__divider"></div>
- </template>
- </div>
- </div>
- `,
- };
- // ===== SsVerify 审核节点链组件 =====
- const SsVerify = {
- name: "SsVerify",
- props: {
- verifyList: {
- type: Array,
- required: true,
- },
- },
- setup(props) {
- const toggleOpen = (item) => {
- item.open = !item.open;
- // 切换后重新计算连线高度
- setTimeout(() => {
- calculateLineHeight();
- }, 50);
- };
- // 计算连线高度的函数
- const calculateLineHeight = () => {
- const lastOpenGroup = document.querySelector(".group-item-last-open");
- console.log("lastOpenGroup", lastOpenGroup);
- if (lastOpenGroup) {
- // 使用原生JavaScript代替jQuery
- const nodes = lastOpenGroup.querySelectorAll(
- ".verify-node-container"
- );
- if (nodes.length) {
- let totalHeight = 0;
- if (nodes.length === 1) {
- // 只有一个节点时,连线伸到节点的中间位置
- const nodeHeight = nodes[0].offsetHeight;
- const nodeTop = nodes[0].offsetTop;
- totalHeight = nodeTop + nodeHeight / 2 - 15; // 减去圆点半径5px
- } else {
- // 多个节点时,连线延伸到最后一个节点的中间位置
- const lastNode = nodes[nodes.length - 1];
- const lastNodeTop = lastNode.offsetTop;
- const lastNodeHeight = lastNode.offsetHeight;
- totalHeight = lastNodeTop + lastNodeHeight / 2 - 15; // 减去圆点半径5px
- }
- console.log("节点信息:", {
- 节点总数: nodes.length,
- 计算后的高度: totalHeight,
- 最后节点top: nodes[nodes.length - 1]?.offsetTop,
- 最后节点高度: nodes[nodes.length - 1]?.offsetHeight,
- });
- lastOpenGroup.style.setProperty(
- "--group-line-height",
- `${totalHeight}px`
- );
- }
- }
- };
- onMounted(() => {
- setTimeout(() => {
- calculateLineHeight();
- }, 100);
- });
- return {
- toggleOpen,
- };
- },
- render() {
- const { h } = Vue;
- return h(
- "div",
- { class: "verify-nodes" },
- this.verifyList.map((item, i) =>
- h(
- "div",
- {
- key: i,
- class: {
- "group-item": true,
- "group-item-last-open":
- i === this.verifyList.length - 1 && item.open,
- },
- },
- [
- h(
- "div",
- {
- class: "group-item-title",
- onClick: () => this.toggleOpen(item),
- },
- [
- h("div", { class: "icon" }, [
- h("i", {
- class: item.open
- ? "common-icon-folder-open common-icon"
- : "common-icon-folder-close common-icon",
- }),
- h(
- "div",
- {
- class: "num",
- style: { top: item.open ? "60%" : "55%" },
- },
- item.children?.length || 0
- ),
- ]),
- h("div", { class: "name" }, item.groupName),
- ]
- ),
- item.open && item.children?.length > 0
- ? h(
- "div",
- { class: "group-item-children" },
- item.children.map((citem, j) =>
- h(SsVerifyNode, {
- key: j,
- item: citem,
- // isGroup: i + 1 !== this.verifyList.length,
- isGroup: true,
- })
- )
- )
- : null,
- ]
- )
- )
- );
- },
- };
- // ===== SsVerifyNode 审核节点组件 =====
- const SsVerifyNode = {
- name: "SsVerifyNode",
- props: {
- item: {
- type: Object,
- required: true,
- },
- isGroup: {
- type: Boolean,
- default: false,
- },
- },
- render() {
- const { h } = Vue;
- return h("div", { class: "verify-node-container" }, [
- h("div", { class: "info" }, [
- h("div", { class: "avatar" }, [
- h("img", {
- src: this.item.thumb,
- style: {
- width: "50px",
- height: "50px",
- borderRadius: "50%",
- },
- }),
- ]),
- h("div", { class: "desc" }, [
- h("div", this.item.name),
- h("div", this.item.role),
- ]),
- h("div", { class: "link" }, [
- h("div", [
- this.item.video
- ? h("i", { class: "common-icon-video common-icon" })
- : null,
- this.item.link
- ? h("i", { class: "common-icon-paper-clip common-icon" })
- : null,
- ]),
- ]),
- ]),
- h(
- "div",
- {
- class: {
- description: true,
- link: this.isGroup,
- },
- attrs: { "data-num": "3" },
- },
- [h("div", this.item.description)]
- ),
- h("div", { class: "time" }, this.item.time),
- ]);
- },
- };
- // ===== SsOnoffButton 开关按钮 =====
- const SsOnoffButton = {
- name: "SsOnoffButton",
- props: {
- // 字段名称,用于表单校验
- name: { type: String, required: true },
- // 显示标签
- label: { type: String, required: true },
- // 按钮的值
- value: { type: [String, Number], required: true },
- // 宽度设置
- width: { type: String, default: "" },
- // v-model 绑定的值
- modelValue: { type: [String, Number, Array], default: "" },
- // 是否多选模式
- multiple: { type: Boolean, default: false },
- // 是否禁用
- disabled: { type: Boolean, default: false },
- },
- emits: ["update:modelValue", "change"],
- setup(props, { emit }) {
- // 解析 modelValue,支持逗号分隔的字符串和数组
- const parseModelValue = (val) => {
- if (!val) return [];
- // 如果是数组,直接返回字符串数组
- if (Array.isArray(val)) {
- return val.map((v) => v.toString());
- }
- // 如果是字符串,按逗号分割
- const cleanValue = val.toString().replace(/^,+/, ""); // 去掉开头的逗号
- if (cleanValue.includes("|")) {
- return cleanValue.split("|");
- }
- if (cleanValue.includes(",")) {
- return cleanValue.split(",");
- }
- return cleanValue ? [cleanValue] : [];
- };
- // 判断当前按钮是否选中
- const isChecked = computed(() => {
- if (props.multiple) {
- const currentValue = parseModelValue(props.modelValue);
- return currentValue.includes(props.value.toString());
- }
- return props.modelValue === props.value;
- });
- // 切换选中状态
- const toggleSelect = () => {
- // 如果禁用,不执行任何操作
- if (props.disabled) return;
- if (props.multiple) {
- // 多选模式
- const currentValue = parseModelValue(props.modelValue);
- const index = currentValue.indexOf(props.value.toString());
- let newValue;
- if (index === -1) {
- // 添加选项
- newValue = [...currentValue, props.value.toString()];
- } else {
- // 移除选项
- newValue = currentValue.filter((v) => v !== props.value.toString());
- }
- // 发送更新事件,使用逗号分隔的字符串格式
- const emitValue = newValue.join(",");
- emit("update:modelValue", emitValue);
- emit("change", emitValue, newValue);
- } else {
- // 单选模式
- emit("update:modelValue", props.value);
- emit("change", props.value);
- }
- };
- return { isChecked, toggleSelect };
- },
- template: `
- <div class="ss-onoff-button" :class="{ checked: isChecked, disabled: disabled }" @click="toggleSelect">
- <span class="button-label">{{ label }}</span>
- <div class="button-mark">
- <span class="form-icon" :class="isChecked ? 'form-icon-onoffbutton-checked' : 'form-icon-onoffbutton-unchecked'"></span>
- </div>
- </div>
- `,
- };
- // ===== SsDatetimePicker 日期时间选择(使用 Vant 4) =====
- const SsDatetimePicker = {
- name: "SsDatetimePicker",
- props: {
- mode: { type: String, default: "date" }, // date | time | datetime
- placeholder: { type: String, default: "请选择日期" },
- modelValue: { type: String, default: "" },
- minDate: { type: String, default: "" },
- maxDate: { type: String, default: "" },
- // 字段名称 - 用于ssVm校验
- name: { type: String, default: "" },
- // 是否禁用
- disabled: { type: Boolean, default: false },
- },
- emits: [
- "update:modelValue",
- "change",
- "confirm",
- "cancel",
- "open",
- "close",
- ],
- setup(props, { emit }) {
- const showPicker = ref(false);
- const showTimePicker = ref(false);
- const currentStep = ref("date"); // 'date' | 'time'
- // 功能说明:统一在组件层处理 iframe 场景底部按钮显隐,避免每个页面重复绑定 by xu 2026-02-28
- const notifyParentBottomVisible = (visible) => {
- try {
- const fn = window.parent && window.parent.__mpObjInpSetBottomVisible;
- if (typeof fn === "function") fn(visible !== false);
- } catch (_) {}
- };
- // 功能说明:监听弹层显隐,点遮罩/取消关闭时也恢复父层底部按钮 by xu 2026-03-01
- watch(
- [showPicker, showTimePicker],
- ([dateOpen, timeOpen], [prevDateOpen, prevTimeOpen]) => {
- const hasOpen = !!(dateOpen || timeOpen);
- const hadOpen = !!(prevDateOpen || prevTimeOpen);
- if (!hadOpen && hasOpen) {
- emit("open");
- notifyParentBottomVisible(false);
- return;
- }
- if (hadOpen && !hasOpen) {
- emit("close");
- notifyParentBottomVisible(true);
- }
- }
- );
- // Vant DatePicker 需要数组格式 [year, month, day]
- const currentDateArray = ref([]);
- const currentTimeArray = ref(["12", "00"]); // [hour, minute]
- const tempDateStr = ref(""); // 临时存储选择的日期
- // 格式化显示文本
- const displayText = computed(() => {
- if (!props.modelValue) return props.placeholder;
- try {
- const d = new Date(props.modelValue);
- if (isNaN(d.getTime())) return props.modelValue;
- const year = d.getFullYear();
- const month = String(d.getMonth() + 1).padStart(2, "0");
- const day = String(d.getDate()).padStart(2, "0");
- const hours = String(d.getHours()).padStart(2, "0");
- const minutes = String(d.getMinutes()).padStart(2, "0");
- if (props.mode === "time") {
- return `${hours}:${minutes}`;
- } else if (props.mode === "datetime") {
- return `${year}-${month}-${day} ${hours}:${minutes}`;
- }
- return `${year}-${month}-${day}`;
- } catch (e) {
- return props.modelValue;
- }
- });
- // 监听 modelValue 变化,转换为数组格式
- watch(
- () => props.modelValue,
- (newVal) => {
- console.log("📅 modelValue 变化:", newVal);
- if (newVal) {
- try {
- const d = new Date(newVal);
- if (!isNaN(d.getTime())) {
- currentDateArray.value = [
- d.getFullYear(),
- d.getMonth() + 1,
- d.getDate(),
- ];
- console.log("📅 转换为数组:", currentDateArray.value);
- }
- } catch (e) {
- console.warn("Invalid date:", newVal);
- }
- } else {
- const today = new Date();
- currentDateArray.value = [
- today.getFullYear(),
- today.getMonth() + 1,
- today.getDate(),
- ];
- }
- },
- { immediate: true }
- );
- // 确认选择 - 根据模式处理不同的数据
- const onConfirm = (value) => {
- console.log(
- "📅 Vant Picker confirm 原始值:",
- value,
- "mode:",
- props.mode
- );
- try {
- // Vant 返回的是对象,包含 selectedValues 数组
- const selectedValues = value.selectedValues || value;
- console.log("📅 selectedValues:", selectedValues);
- if (props.mode === "time") {
- // 时间模式:处理时分
- if (Array.isArray(selectedValues) && selectedValues.length >= 2) {
- const [hour, minute] = selectedValues;
- const timeStr = `${hour.padStart(2, "0")}:${minute.padStart(
- 2,
- "0"
- )}`;
- console.log("🕐 转换后的时间字符串:", timeStr);
- emit("update:modelValue", timeStr);
- emit("change", timeStr);
- emit("confirm", timeStr);
- emit("close");
- notifyParentBottomVisible(true);
- showPicker.value = false;
- }
- } else if (
- Array.isArray(selectedValues) &&
- selectedValues.length >= 3
- ) {
- // 日期模式:处理年月日
- const [year, month, day] = selectedValues;
- const dateStr = `${year}-${month.padStart(2, "0")}-${day.padStart(
- 2,
- "0"
- )}`;
- if (props.mode === "datetime") {
- // datetime 模式:先存储日期,然后打开时间选择器
- tempDateStr.value = dateStr;
- showPicker.value = false;
- showTimePicker.value = true;
- currentStep.value = "time";
- } else {
- // date 模式:直接完成
- emit("update:modelValue", dateStr);
- emit("change", dateStr);
- emit("confirm", dateStr);
- emit("close");
- notifyParentBottomVisible(true);
- showPicker.value = false;
- }
- }
- } catch (e) {
- console.error("Picker conversion error:", e);
- }
- };
- // 时间选择确认
- const onTimeConfirm = (value) => {
- try {
- const selectedValues = value.selectedValues || value;
- if (Array.isArray(selectedValues) && selectedValues.length >= 2) {
- const [hour, minute] = selectedValues;
- const datetimeStr = `${tempDateStr.value} ${hour.padStart(
- 2,
- "0"
- )}:${minute.padStart(2, "0")}`;
- emit("update:modelValue", datetimeStr);
- emit("change", datetimeStr);
- emit("confirm", datetimeStr);
- emit("close");
- notifyParentBottomVisible(true);
- }
- } catch (e) {
- console.error("Time conversion error:", e);
- }
- showTimePicker.value = false;
- currentStep.value = "date";
- };
- // 取消选择
- const onCancel = () => {
- emit("cancel");
- showPicker.value = false;
- };
- const onTimeCancel = () => {
- emit("cancel");
- showTimePicker.value = false;
- };
- // 打开选择器
- const openPicker = () => {
- // 如果禁用,不打开选择器
- if (props.disabled) return;
- showPicker.value = true;
- };
- // 计算最小最大日期
- const minDateObj = computed(() => {
- return props.minDate ? new Date(props.minDate) : undefined;
- });
- const maxDateObj = computed(() => {
- return props.maxDate ? new Date(props.maxDate) : undefined;
- });
- return {
- showPicker,
- showTimePicker,
- currentDateArray,
- currentTimeArray,
- displayText,
- openPicker,
- onConfirm,
- onTimeConfirm,
- onTimeCancel,
- onCancel,
- minDateObj,
- maxDateObj,
- };
- },
- template: `
- <div class="ss-datetime-picker ss-mobile-component" :class="{ disabled: disabled }">
- <!-- 隐藏的input用于ssVm校验 -->
- <input type="hidden" :name="name" :value="modelValue" />
- <div class="datetime-picker-display" @click="openPicker">
- <span class="datetime-picker-value" :class="{ placeholder: !modelValue }">{{ displayText }}</span>
- </div>
- <!-- 日期选择器 -->
- <van-popup v-model:show="showPicker" position="bottom" :style="{ zIndex: 10000 }">
- <van-date-picker
- v-if="mode === 'date' || mode === 'datetime'"
- v-model="currentDateArray"
- :min-date="minDateObj"
- :max-date="maxDateObj"
- @confirm="onConfirm"
- @cancel="onCancel"
- title="选择日期"
- />
- <van-time-picker
- v-if="mode === 'time'"
- v-model="currentTimeArray"
- @confirm="onConfirm"
- @cancel="onCancel"
- title="选择时间"
- />
- </van-popup>
- <!-- 时间选择器(datetime 模式的第二步) -->
- <van-popup v-model:show="showTimePicker" position="bottom" :style="{ zIndex: 10000 }">
- <van-time-picker
- v-model="currentTimeArray"
- @confirm="onTimeConfirm"
- @cancel="onTimeCancel"
- title="选择时间"
- />
- </van-popup>
- </div>
- `,
- };
- const SsConfirm = {
- name: "SsConfirm",
- props: {
- modelValue: { type: Boolean, default: false },
- title: { type: String, default: "确认" },
- content: { type: String, default: "" },
- maskClosable: { type: Boolean, default: true },
- },
- emits: ["update:modelValue", "confirm", "cancel", "close"],
- setup(props, { emit }) {
- // 监听弹窗显示状态,控制 body 滚动
- watch(
- () => props.modelValue,
- (newVal) => {
- console.log("🔔 SsConfirm modelValue 变化:", newVal);
- if (newVal) {
- document.body.classList.add("modal-open");
- } else {
- document.body.classList.remove("modal-open");
- }
- },
- { immediate: true }
- ); // 添加 immediate 选项
- const close = () => {
- console.log("🚪 关闭确认弹窗");
- emit("update:modelValue", false);
- emit("close");
- };
- const onMask = () => {
- console.log("👆 点击了遮罩层");
- if (props.maskClosable) close();
- };
- const onCancel = () => {
- console.log("❌ 点击了取消按钮");
- emit("cancel");
- close();
- };
- const onConfirm = () => {
- console.log("✅ 点击了确认按钮,触发 confirm 事件");
- emit("confirm");
- close();
- };
- return { onMask, onCancel, onConfirm };
- },
- template: `
- <div v-if="modelValue" class="ss-confirm">
- <div class="confirm-mask" @click="onMask"></div>
- <div class="confirm-content">
- <div class="confirm-header" v-if="title">
- <div class="header-title">{{ title }}</div>
- </div>
- <div class="header-line" v-if="title"></div>
- <div class="confirm-body">
- <div v-if="content" class="confirm-content-text" v-html="content"></div>
- <div class="confirm-slot-content"><slot /></div>
- </div>
- <div class="confirm-bottom">
- <button class="confirm-btn confirm-btn-cancel" @click.stop="onCancel">取消</button>
- <button class="confirm-btn confirm-btn-confirm" @click.stop="onConfirm">确认</button>
- </div>
- </div>
- </div>
- `,
- };
- // ===== SsImageCropper 纯裁剪组件 =====
- const SsImageCropper = {
- name: "SsImageCropper",
- props: {
- // 是否显示裁剪器
- show: { type: Boolean, default: false },
- // 图片源(base64 或 URL)
- src: { type: String, required: true },
- // 图片形状:circle圆形 | square方形
- shape: { type: String, required: true },
- // 裁剪比例(宽/高)
- aspectRatio: { type: Number, default: 1 },
- // 输出图片宽度
- outputWidth: { type: Number, default: 300 },
- // 输出图片高度
- outputHeight: { type: Number, default: 300 },
- },
- emits: ["update:show", "confirm", "cancel"],
- setup(props, { emit }) {
- const cropperInstance = ref(null);
- // 监听 show 变化,初始化或销毁 Cropper
- watch(
- () => props.show,
- (newVal) => {
- if (newVal) {
- // 等待 DOM 更新后初始化
- Vue.nextTick(() => {
- initCropper();
- });
- } else {
- destroyCropper();
- }
- }
- );
- // 监听 src 变化,重新初始化 Cropper
- watch(
- () => props.src,
- () => {
- if (props.show) {
- Vue.nextTick(() => {
- initCropper();
- });
- }
- }
- );
- // 初始化 Cropper
- const initCropper = () => {
- const imageElement = document.getElementById("ss-image-cropper-img");
- if (!imageElement || !window.Cropper) return;
- // 销毁旧实例
- destroyCropper();
- // 根据 shape 属性添加类名
- const container = document.querySelector(".ss-image-cropper-container");
- if (container) {
- if (props.shape === "circle") {
- container.classList.add("crop-shape-circle");
- container.classList.remove("crop-shape-square");
- } else {
- container.classList.add("crop-shape-square");
- container.classList.remove("crop-shape-circle");
- }
- }
- cropperInstance.value = new window.Cropper(imageElement, {
- aspectRatio: props.aspectRatio,
- viewMode: 1,
- dragMode: "move",
- autoCropArea: 0.8,
- restore: false,
- guides: false, // 关闭辅助线
- center: false, // 关闭中心指示器
- highlight: false,
- cropBoxMovable: true,
- cropBoxResizable: true,
- toggleDragModeOnDblclick: false,
- minContainerWidth: window.innerWidth,
- minContainerHeight: window.innerHeight - 50,
- });
- };
- // 销毁 Cropper
- const destroyCropper = () => {
- if (cropperInstance.value) {
- cropperInstance.value.destroy();
- cropperInstance.value = null;
- }
- };
- // 取消裁剪
- const handleCancel = () => {
- emit("update:show", false);
- emit("cancel");
- };
- // 确认裁剪
- const handleConfirm = () => {
- if (!cropperInstance.value) return;
- const canvas = cropperInstance.value.getCroppedCanvas({
- width: props.outputWidth,
- height: props.outputHeight,
- imageSmoothingEnabled: true,
- imageSmoothingQuality: "high",
- fillColor: "#fff",
- });
- canvas.toBlob(
- (blob) => {
- emit("update:show", false);
- emit("confirm", blob);
- },
- "image/jpeg",
- 0.9
- );
- };
- // 处理底部按钮事件
- const handleCropAction = (data) => {
- if (data.action === "cancel") {
- handleCancel();
- } else if (data.action === "confirm") {
- handleConfirm();
- }
- };
- return {
- handleCropAction,
- };
- },
- template: `
- <div v-if="show" class="ss-image-cropper-container">
- <!-- 左上角尺寸显示 -->
- <div class="crop-size-display">
- 长: {{ outputWidth }}px<br>宽: {{ outputHeight }}px
- </div>
- <div class="ss-crop-image-container">
- <img id="ss-image-cropper-img" :src="src" />
- </div>
- <!-- 使用 ss-bottom 组件 -->
- <ss-bottom
- :show-shyj="false"
- :buttons="[
- { text: '取消', action: 'cancel' },
- { text: '保存并提交', action: 'confirm' }
- ]"
- @button-click="handleCropAction"
- />
- </div>
- `,
- };
- // ===== SsUploadImage 图片上传裁剪组件(支持单图/多图) =====
- const SsUploadImage = {
- name: "SsUploadImage",
- props: {
- // v-model 绑定的值(单图:String,多图:Array)
- modelValue: { type: [String, Array], default: "" },
- // 最大上传数量(默认1张,多图时设置大于1)
- max: { type: Number, default: 1 },
- // 是否禁用
- disabled: { type: Boolean, default: false },
- // 图片宽度(像素) - 必填
- width: { type: [Number, String], required: true },
- // 图片高度(像素) - 必填
- height: { type: [Number, String], required: true },
- // 图片形状:circle圆形 | square方形 - 必填
- shape: { type: String, required: true },
- // 裁剪比例(宽/高)
- aspectRatio: { type: Number, default: undefined },
- // 输出图片宽度
- outputWidth: { type: Number, default: 300 },
- // 输出图片高度
- outputHeight: { type: Number, default: 300 },
- },
- emits: ["update:modelValue", "updated"],
- setup(props, { emit }) {
- const showCropper = ref(false);
- const tempImageSrc = ref("");
- // 图片列表(统一用数组管理)
- const imageList = ref([]);
- // 监听 modelValue 变化,同步到 imageList
- watch(
- () => props.modelValue,
- (newVal) => {
- if (props.max === 1) {
- // 单图模式
- imageList.value = newVal ? [newVal] : [];
- } else {
- // 多图模式
- imageList.value = Array.isArray(newVal)
- ? [...newVal]
- : newVal
- ? [newVal]
- : [];
- }
- },
- { immediate: true }
- );
- // 是否可以继续添加图片
- const canAddMore = computed(() => {
- return imageList.value.length < props.max;
- });
- // 容器样式
- const itemStyle = computed(() => ({
- width:
- typeof props.width === "number" ? `${props.width}px` : props.width,
- height:
- typeof props.height === "number" ? `${props.height}px` : props.height,
- borderRadius: props.shape === "circle" ? "50%" : "8px",
- }));
- // 获取图片 URL(用于显示)
- const getImageUrl = (path) => {
- if (!path) return "/static/images/yishuzhao_nv.svg";
- if (path.startsWith("http") || path.startsWith("blob:")) {
- return path;
- }
- return window.SS.utils?.getImageUrl?.(path) || path;
- };
- // 选择图片
- const selectImage = () => {
- if (props.disabled || !canAddMore.value) return;
- const input = document.createElement("input");
- input.type = "file";
- input.accept = "image/*";
- input.onchange = (e) => {
- const file = e.target.files[0];
- if (file) {
- const reader = new FileReader();
- reader.onload = (event) => {
- tempImageSrc.value = event.target.result;
- showCropper.value = true;
- };
- reader.readAsDataURL(file);
- }
- };
- input.click();
- };
- // 删除图片
- const deleteImage = (index) => {
- const newList = imageList.value.filter((_, i) => i !== index);
- updateModelValue(newList);
- };
- // 确认裁剪
- const handleCropConfirm = async (blob) => {
- try {
- const serverPath = await uploadFile(blob, "image", "image.jpg");
- const newList = [...imageList.value, serverPath];
- updateModelValue(newList);
- emit("updated", serverPath);
- } catch (error) {
- console.error("上传失败:", error);
- }
- };
- // 取消裁剪
- const handleCropCancel = () => {
- tempImageSrc.value = "";
- };
- // 更新 modelValue
- const updateModelValue = (list) => {
- if (props.max === 1) {
- // 单图模式:返回 String
- emit("update:modelValue", list[0] || "");
- } else {
- // 多图模式:返回 Array
- emit("update:modelValue", list);
- }
- };
- return {
- imageList,
- canAddMore,
- itemStyle,
- showCropper,
- tempImageSrc,
- getImageUrl,
- selectImage,
- deleteImage,
- handleCropConfirm,
- handleCropCancel,
- };
- },
- template: `
- <div class="ss-upload-image-multi">
- <!-- 图片列表 -->
- <div class="image-list">
- <!-- 已上传的图片 -->
- <div
- v-for="(img, index) in imageList"
- :key="index"
- class="image-item"
- :style="itemStyle"
- >
- <img :src="getImageUrl(img)" class="image-display" />
- <!-- 删除按钮 -->
- <div v-if="!disabled" class="image-delete" @click.stop="deleteImage(index)">
- <Icon name="icon-guanbi" size="32" color="#fff" />
- </div>
- </div>
- <!-- 添加按钮 -->
- <div
- v-if="canAddMore && !disabled"
- class="image-item image-add"
- :style="itemStyle"
- @click="selectImage"
- >
- <Icon name="icon-xiangji" size="48" color="#ccc" />
- <div class="add-text">{{ imageList.length }}/{{ max }}</div>
- </div>
- </div>
- <!-- 裁剪组件 -->
- <ss-image-cropper
- v-model:show="showCropper"
- :src="tempImageSrc"
- :shape="shape"
- :aspect-ratio="aspectRatio"
- :output-width="outputWidth"
- :output-height="outputHeight"
- @confirm="handleCropConfirm"
- @cancel="handleCropCancel"
- />
- </div>
- `,
- };
- // ===== SsCarCard 车辆卡片组件 =====
- const SsCarCard = {
- name: "SsCarCard",
- props: {
- // 车辆数据
- carData: {
- type: Object,
- default: () => ({}),
- },
- // 车辆状态:'available' | 'reserved' | 'disabled'
- status: {
- type: String,
- default: "available",
- validator: (value) =>
- ["available", "reserved", "disabled"].includes(value),
- },
- },
- emits: ["click", "select"],
- setup(props, { emit }) {
- // 计算状态样式类
- const statusClass = computed(() => {
- return `status-${props.status}`;
- });
- // 获取图片URL
- const getImageUrl = (path) => {
- if (!path) return "/static/images/default-car.png";
- if (path.startsWith("http") || path.startsWith("blob:")) {
- return path;
- }
- return window.SS.utils?.getImageUrl?.(path) || path;
- };
- // 处理卡片点击
- const handleCardClick = () => {
- if (props.status === "disabled") {
- return; // 禁用状态不响应点击
- }
- emit("click", props.carData);
- emit("select", props.carData);
- };
- return {
- statusClass,
- getImageUrl,
- handleCardClick,
- };
- },
- template: `
- <div class="car-card" :class="statusClass" @click="handleCardClick">
- <!-- 第一行:车辆名称 -->
- <div class="car-title">
- {{ carData.name || '别克GL8' }}
- </div>
- <!-- 第二行:左右结构 -->
- <div class="car-info">
- <!-- 左边:车辆图片 -->
- <div class="car-image-container">
- <img class="car-image" :src="getImageUrl(carData.image)" />
- </div>
- <!-- 右边:车辆信息 -->
- <div class="car-details">
- <div class="detail-item car-name" v-if="carData.wph">
- {{ carData.wph }}
- </div>
- <div class="detail-item seats" v-for="(wp, index) in carData.wpcsList" :key="index">
- {{ wp.mc }} : {{ wp.sz || wp.zf }}
- </div>
- <div class="detail-item car-type">
- {{ carData.type || '商务车' }}
- </div>
- </div>
- </div>
- </div>
- `,
- };
- // ===== SsSubTab 移动端Tab组件 =====
- const SsSubTab = {
- name: "SsSubTab",
- props: {
- // Tab列表数据
- tabList: {
- type: Array,
- required: true,
- },
- // 当前激活的Tab索引
- activeIndex: {
- type: Number,
- default: 0,
- },
- // 基础URL参数(会传递给每个iframe)
- baseParams: {
- type: Object,
- default: () => ({}),
- },
- },
- emits: ["tab-change"],
- setup(props, { emit }) {
- const currentTab = ref(props.activeIndex);
- const currentTabUrl = ref("");
- // 加载Tab对应的URL
- const loadTabUrl = (index) => {
- const tab = props.tabList[index];
- if (!tab || !tab.dest) return;
- // 构建iframe URL:mp_ + dest + .html
- const fileName = `mp_${tab.dest}.html`;
- // 构建完整URL,包含所有参数
- const tabService =
- tab.service ||
- tab.servName ||
- props.baseParams.service ||
- props.baseParams.ssServ ||
- "";
- const tabDest =
- tab.dest || props.baseParams.dest || props.baseParams.ssDest || "";
- const tabParam = tab.param || props.baseParams.param || "";
- const params = new URLSearchParams({
- ...props.baseParams,
- service: tabService,
- dest: tabDest,
- ssServ: tabService,
- ssDest: tabDest,
- param: tabParam,
- });
- currentTabUrl.value = `/page/${fileName}?${params.toString()}`;
- console.log(
- "🔄 切换到Tab:",
- tab.desc || tab.title,
- "加载页面:",
- currentTabUrl.value
- );
- };
- // 监听 activeIndex 变化
- watch(
- () => props.activeIndex,
- (newIndex) => {
- currentTab.value = newIndex;
- loadTabUrl(newIndex);
- },
- { immediate: true }
- );
- // 监听 tabList 变化
- watch(
- () => props.tabList,
- () => {
- if (props.tabList.length > 0 && currentTab.value === 0) {
- loadTabUrl(0);
- }
- },
- { immediate: true }
- );
- // 切换Tab
- const handleTabClick = (index) => {
- if (currentTab.value === index) return;
- currentTab.value = index;
- loadTabUrl(index);
- emit("tab-change", { index, tab: props.tabList[index] });
- };
- return {
- currentTab,
- currentTabUrl,
- handleTabClick,
- };
- },
- template: `
- <div class="ss-sub-tab">
- <!-- Tab 栏 -->
- <div class="ss-sub-tab__bar" v-if="tabList.length > 0">
- <div
- v-for="(tab, index) in tabList"
- :key="index"
- class="ss-sub-tab__item"
- :class="{ 'ss-sub-tab__item--active': currentTab === index }"
- @click="handleTabClick(index)"
- >
- {{ tab.desc || tab.title }}
- </div>
- </div>
- <!-- 内容区域 -->
- <div class="ss-sub-tab__content" v-if="currentTabUrl">
- <iframe :src="currentTabUrl" frameborder="0"></iframe>
- </div>
- </div>
- `,
- };
- // ===== SsUploadFile 文件上传组件(支持单文件/多文件) =====
- const SsUploadFile = {
- name: "SsUploadFile",
- props: {
- // v-model 绑定的值(单文件:String,多文件:Array)
- modelValue: { type: [String, Array], default: "" },
- // 最大上传数量(默认1个)
- max: { type: Number, default: 1 },
- // 是否禁用
- disabled: { type: Boolean, default: false },
- // 允许的文件类型(默认常见文件类型)
- accept: {
- type: String,
- default: ".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar",
- },
- // 最大文件大小(MB,默认5MB)
- maxSize: { type: Number, default: 5 },
- },
- emits: ["update:modelValue", "uploaded"],
- setup(props, { emit }) {
- // 文件列表(统一用数组管理)
- const fileList = ref([]);
- // 监听 modelValue 变化,同步到 fileList
- watch(
- () => props.modelValue,
- (newVal) => {
- if (props.max === 1) {
- // 单文件模式
- fileList.value = newVal
- ? [{ path: newVal, name: getFileName(newVal) }]
- : [];
- } else {
- // 多文件模式
- if (Array.isArray(newVal)) {
- fileList.value = newVal.map((path) => ({
- path,
- name: getFileName(path),
- }));
- } else {
- fileList.value = newVal
- ? [{ path: newVal, name: getFileName(newVal) }]
- : [];
- }
- }
- },
- { immediate: true }
- );
- // 是否可以继续添加文件
- const canAddMore = computed(() => {
- return fileList.value.length < props.max;
- });
- // 从路径中提取文件名
- const getFileName = (path) => {
- if (!path) return "";
- const parts = path.split("/");
- return parts[parts.length - 1];
- };
- // 获取文件图标
- const getFileIcon = (fileName) => {
- const ext = fileName.split(".").pop().toLowerCase();
- const iconMap = {
- pdf: "icon-pdf",
- doc: "icon-word",
- docx: "icon-word",
- xls: "icon-excel",
- xlsx: "icon-excel",
- txt: "icon-txt",
- zip: "icon-zip",
- rar: "icon-zip",
- };
- return iconMap[ext] || "icon-wenjian";
- };
- // 选择文件
- const selectFile = () => {
- if (props.disabled || !canAddMore.value) return;
- const input = document.createElement("input");
- input.type = "file";
- input.accept = props.accept;
- input.onchange = async (e) => {
- const file = e.target.files[0];
- if (!file) return;
- // 检查文件大小
- const fileSizeMB = file.size / 1024 / 1024;
- if (fileSizeMB > props.maxSize) {
- window.showToast?.(`文件大小不能超过${props.maxSize}MB`, "error");
- return;
- }
- // 上传文件
- try {
- const serverPath = await uploadFile(file, "file", file.name);
- const newList = [
- ...fileList.value,
- { path: serverPath, name: file.name },
- ];
- updateModelValue(newList);
- emit("uploaded", serverPath);
- } catch (error) {
- console.error("文件上传失败:", error);
- }
- };
- input.click();
- };
- // 删除文件
- const deleteFile = (index) => {
- const newList = fileList.value.filter((_, i) => i !== index);
- updateModelValue(newList);
- };
- // 下载文件
- const downloadFile = (file) => {
- const url =
- window.SS.utils?.getFileUrl?.(file.path) ||
- window.getFileUrl?.(file.path) ||
- file.path;
- window.open(url, "_blank");
- };
- // 更新 modelValue
- const updateModelValue = (list) => {
- const paths = list.map((f) => f.path);
- if (props.max === 1) {
- // 单文件模式:返回 String
- emit("update:modelValue", paths[0] || "");
- } else {
- // 多文件模式:返回 Array
- emit("update:modelValue", paths);
- }
- };
- return {
- fileList,
- canAddMore,
- getFileIcon,
- selectFile,
- deleteFile,
- downloadFile,
- };
- },
- template: `
- <div class="ss-upload-file">
- <!-- 文件列表 -->
- <div v-if="fileList.length > 0" class="file-list">
- <!-- 已上传的文件 -->
- <div
- v-for="(file, index) in fileList"
- :key="index"
- class="file-item"
- >
- <div class="file-icon">
- <Icon :name="getFileIcon(file.name)" size="40" color="#40ac6d" />
- </div>
- <div class="file-info" @click="downloadFile(file)">
- <span class="file-name">{{ file.name }}</span>
- </div>
- <!-- 文件操作按钮 -->
- <div class="file-actions">
- <div class="file-download" @click="downloadFile(file)">
- <Icon name="icon-xiazai" size="32" color="#40ac6d" />
- </div>
- <div v-if="!disabled" class="file-delete" @click.stop="deleteFile(index)">
- <Icon name="icon-guanbi" size="32" color="#ff3b30" />
- </div>
- </div>
- </div>
- </div>
- <!-- 添加按钮 -->
- <div
- v-if="canAddMore && !disabled"
- class="file-add-button"
- @click="selectFile"
- >
- <div class="add-icon">
- <Icon name="icon-tianjia" size="64" color="#999" />
- </div>
- <span class="add-text">上传文件 ({{ fileList.length }}/{{ max }})</span>
- <span class="file-tip">支持格式: {{ accept }},最大{{ maxSize }}MB</span>
- </div>
- </div>
- `,
- };
- /**
- * 功能说明:H5移动端富文本组件(对齐PC字段协议 mswj/fjid/path 回显) by xu 2026-03-01
- *
- * 约定:
- * - v-model 绑定文件路径字段(如 mswj)
- * - 组件内部维护编辑内容,并输出隐藏字段:xxEdit / xxwj / ueditorpath / fjid
- * - 回显通过 url + path 拉取 HTML 内容,不直接依赖 modelValue 的 HTML 字符串
- *
- * @component SsEditor
- * @prop {String} modelValue 文件路径(如 mswj)
- * @prop {String} name 字段名(默认 mswj)
- * @prop {String} url 回显读取接口地址
- * @prop {Number|String} height 编辑器高度
- * @prop {String} placeholder 占位文案
- * @prop {Boolean} readonly 是否只读
- * @prop {String} uploadUrl 上传接口地址
- * @prop {Object} param 附件参数(button.cmsAddUrl / button.cmsUpdUrl / mode)
- * @emits update:modelValue 更新文件路径
- * @emits ready 编辑器就绪
- * @emits change 内容变化
- */
- const SsEditor = {
- name: "SsEditor",
- props: {
- modelValue: { type: String, default: "" },
- name: { type: String, default: "mswj" },
- url: { type: String, default: "" },
- height: { type: [Number, String], default: 280 },
- placeholder: { type: String, default: "请输入内容" },
- readonly: { type: Boolean, default: false },
- uploadUrl: { type: String, default: "/service?ssServ=ulByHttp" },
- param: { type: Object, default: () => ({}) },
- },
- emits: ["update:modelValue", "ready", "change"],
- setup(props, { emit }) {
- const editorElementId = `ss-editor-${Date.now()}-${Math.floor(
- Math.random() * 10000
- )}`;
- const editorContent = ref("");
- const editorInstance = ref(null);
- const fjid = ref(props.param?.button?.val || "");
- const fjName = props.param?.button?.desc || "附件";
- const mode = props.param?.mode;
- /**
- * 功能说明:确保附件 fjid 存在,不存在时先通过 cmsAddUrl 创建 by xu 2026-03-01
- * @returns {Promise<string>} fjid
- */
- const ensureFjid = async () => {
- if (fjid.value) return fjid.value;
- if (!props.param?.button?.cmsAddUrl) return "";
- return new Promise((resolve) => {
- $.ajax({
- type: "post",
- url: props.param.button.cmsAddUrl,
- async: false,
- data: {
- name: "fjid",
- ssNrObjName: "sh",
- ssNrObjId: "",
- },
- success: (_fjid) => {
- fjid.value = _fjid || "";
- resolve(fjid.value);
- },
- error: () => resolve(""),
- });
- });
- };
- /**
- * 功能说明:打开附件管理弹窗(与PC端行为一致) by xu 2026-03-01
- * @returns {Promise<void>}
- */
- const openAttachmentDialog = async () => {
- if (!props.param?.button?.cmsUpdUrl) {
- console.warn("未配置附件编辑地址 cmsUpdUrl");
- return;
- }
- const currentFjid = await ensureFjid();
- if (!currentFjid) return;
- const query =
- `&nrid=T-${currentFjid}` +
- `&objectId=${currentFjid}` +
- `&objectName=${encodeURIComponent(fjName)}` +
- `&callback=${window["fjidCallbackName"] || ""}`;
- if (window.SS && typeof window.SS.openDialog === "function") {
- window.SS.openDialog({
- src: props.param.button.cmsUpdUrl + query,
- headerTitle: "编辑",
- width: 900,
- high: 664,
- zIndex: 51,
- });
- }
- };
- /**
- * 功能说明:初始化 Jodit 编辑器并绑定工具栏/上传/change 事件 by xu 2026-03-01
- * @returns {void}
- */
- const buildEditor = () => {
- if (!window.Jodit || !window.Jodit.make) {
- console.error("Jodit 未加载,无法初始化 ss-editor");
- return;
- }
- const editorUploadUrl = props.uploadUrl.includes("?")
- ? `${props.uploadUrl}&type=img`
- : `${props.uploadUrl}?type=img`;
- const instance = window.Jodit.make(`#${editorElementId}`, {
- height: props.height,
- placeholder: props.placeholder,
- readonly: props.readonly,
- language: "zh_cn",
- showXPathInStatusbar: false,
- showCharsCounter: false,
- showWordsCounter: false,
- allowResizeY: false,
- toolbarSticky: false,
- statusbar: false,
- uploader: {
- url: editorUploadUrl,
- format: "json",
- method: "POST",
- filesVariableName: (i) => `imgs[${i}]`,
- isSuccess: (resp) => resp?.code === 0 || !!resp?.data,
- getMessage: (resp) => resp?.msg || "上传失败",
- process: (resp) => resp?.data?.url || resp?.data?.path || "",
- contentType: () => false,
- },
- controls: {
- customLinkButton: {
- name: "link",
- tooltip: "附件",
- exec: () => {
- openAttachmentDialog();
- },
- },
- },
- buttons: [
- "fullsize",
- "bold",
- "italic",
- "underline",
- "|",
- "font",
- "fontsize",
- "|",
- "left",
- "center",
- "right",
- "|",
- "ul",
- "ol",
- "|",
- "image",
- "table",
- "customLinkButton",
- "|",
- "undo",
- "redo",
- ],
- buttonsMD: [
- "bold",
- "italic",
- "underline",
- "|",
- "image",
- "customLinkButton",
- "|",
- "dots",
- ],
- buttonsSM: [
- "bold",
- "italic",
- "|",
- "image",
- "customLinkButton",
- "|",
- "dots",
- ],
- buttonsXS: ["bold", "|", "dots"],
- });
- instance.value = editorContent.value || "";
- instance.events.on("change", () => {
- editorContent.value = instance.value || "";
- emit("change", editorContent.value);
- });
- editorInstance.value = instance;
- emit("ready", instance);
- };
- /**
- * 功能说明:按路径加载富文本HTML内容并回填编辑器 by xu 2026-03-01
- * @returns {Promise<void>}
- */
- const loadContentByPath = async () => {
- if (!props.url || !props.modelValue) return;
- try {
- const params = new URLSearchParams();
- if (mode) params.append("mode", mode);
- params.append("path", props.modelValue);
- const response = await window.axios.post(props.url, params, {
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- });
- const content = response?.data?.content || "";
- if (content) {
- editorContent.value = content;
- if (editorInstance.value) {
- editorInstance.value.value = content;
- }
- }
- const filePath = response?.data?.path;
- if (filePath) {
- emit("update:modelValue", filePath);
- }
- } catch (error) {
- console.error("ss-editor 回显内容加载失败:", error);
- }
- };
- onMounted(async () => {
- buildEditor();
- await loadContentByPath();
- });
- watch(
- () => props.readonly,
- (newVal) => {
- if (
- editorInstance.value &&
- typeof editorInstance.value.setReadOnly === "function"
- ) {
- editorInstance.value.setReadOnly(newVal);
- }
- }
- );
- onBeforeUnmount(() => {
- if (
- editorInstance.value &&
- typeof editorInstance.value.destruct === "function"
- ) {
- editorInstance.value.destruct();
- }
- });
- return {
- editorElementId,
- editorContent,
- fjid,
- };
- },
- template: `
- <div class="ss-editor-container">
- <input v-if="fjid" type="hidden" name="fjid" :value="fjid" />
- <input type="hidden" :name="name.replace(/wj$/, '') + 'Edit'" :value="editorContent" />
- <input type="hidden" :name="name.replace(/wj$/, '') + 'wj'" :value="modelValue" />
- <input type="hidden" name="ueditorpath" value="mswj" />
- <textarea :id="editorElementId"></textarea>
- </div>
- `,
- };
- window.SS.dom.initializeFormApp = function (config) {
- const { el, ...vueOptions } = config;
- const app = createApp({
- ...vueOptions,
- });
- // 注册组件
- // app.component("SsLoginIcon", SsLoginIcon);
- // app.component("SsMark", SsMark);
- // app.component("SsFullStyleHeader", SsFullStyleHeader);
- // app.component("SsDialog", SsDialog);
- app.component("SsInput", SsInput);
- app.component("SsBottom", SsBottom);
- app.component("SsCard", SsCard);
- app.component("SsSearchButton", SsSearchButton);
- app.component("SsSelect", SsSelect);
- app.component("Icon", Icon);
- // 注册 Vant 组件
- const vantLib = window.vant || window.Vant;
- console.log("🔍 检查 Vant:", {
- hasVant: !!window.vant,
- hasVantCap: !!window.Vant,
- vantLib: !!vantLib,
- vantKeys: vantLib ? Object.keys(vantLib).slice(0, 20) : [],
- hasPopup: vantLib?.Popup,
- hasDatetimePicker: vantLib?.DatetimePicker,
- hasDatePicker: vantLib?.DatePicker,
- hasTimePicker: vantLib?.TimePicker,
- allKeys: vantLib ? Object.keys(vantLib) : [],
- });
- if (vantLib) {
- try {
- // 使用 Vant 的 use 方法注册所有组件
- app.use(vantLib);
- console.log("✅ Vant 全部组件注册成功");
- } catch (error) {
- console.error("❌ Vant 组件注册失败:", error);
- // 降级方案:手动注册具体组件
- try {
- app.component("van-popup", vantLib.Popup);
- app.component("van-datetime-picker", vantLib.DatetimePicker);
- console.log("✅ Vant 手动注册成功");
- } catch (e) {
- console.error("❌ Vant 手动注册也失败:", e);
- }
- }
- } else {
- console.warn("⚠️ Vant 未加载");
- }
- // 注册组件 - 统一使用 kebab-case
- app.component("ss-verify", SsVerify);
- app.component("ss-verify-node", SsVerifyNode);
- app.component("ss-common-icon", SsCommonIcon);
- app.component("ss-onoff-button", SsOnoffButton);
- app.component("ss-datetime-picker", SsDatetimePicker);
- app.component("ss-confirm", SsConfirm);
- app.component("ss-image-cropper", SsImageCropper);
- app.component("ss-upload-image", SsUploadImage);
- app.component("ss-upload-file", SsUploadFile);
- app.component("ss-car-card", SsCarCard);
- app.component("ss-sub-tab", SsSubTab);
- app.component("ss-editor", SsEditor);
- app.component("SsEditor", SsEditor);
- // app.component("SsObjp", SsObjp);
- // app.component("SsHidden", SsHidden);
- // app.component("SsCcp", SsCcp);
- // app.component("SsDatePicker", SsDatePicker);
- // app.component("SsIcon", SsIcon);
- // app.component("SsCommonIcon", SsCommonIcon);
- // app.component("SsBreadcrumb", SsBreadcrumb);
- // app.component("SsEditor", SsEditor);
- // app.component("SsDialogIcon", SsDialogIcon);
- // app.component("SsBottomButton", SsBottomButton);
- // app.component("SsNavIcon", SsNavIcon);
- // app.component("SsHeaderIcon", SsHeaderIcon);
- // app.component("SsGolbalMenuIcon", SsGolbalMenuIcon);
- // app.component("SsCartListIcon", SsCartListIcon);
- // app.component("SsQuickIcon", SsQuickIcon);
- // app.component("SsFormIcon", SsFormIcon);
- // app.component("SsBottomDivIcon", SsBottomDivIcon);
- // app.component("SsEditorIcon", SsEditorIcon);
- // app.component("SsValidate", SsValidate);
- // app.component("SsOnoffbutton", SsOnoffbutton);
- // app.component("SsOnoffbuttonArray", SsOnoffbuttonArray);
- // app.component("SsTextarea", SsTextarea);
- // app.component("SsLoginInput", SsLoginInput);
- // app.component("SsLoginButton", SsLoginButton);
- // app.component("SsSearch", SsSearch);
- // app.component("SsCartItem", SsCartItem);
- // app.component("SsCartItem2", SsCartItem2);
- // app.component("SsListCard", SsListCard);
- // app.component("SsFolderCard", SsFolderCard);
- // app.component("SsFolderCartView", SsFolderCartView);
- // app.component("SsPage", SsPage);
- // app.component("SsRightInfo", SSRightInfo);
- // app.component("SsSuccessPopup", SsSuccessPopup);
- // app.component("SsErrorDialog", SsErrorDialog);
- // app.component("SsVerify", SsVerify);
- // app.component("SsVerifyNode", SsVerifyNode);
- // app.component("SsOrcImgBox", SsOrcImgBox);
- // app.component("ss-search-input", SsSearchInput);
- // app.component("ss-search-date-picker", SsSearchDatePicker);
- // app.component("ss-search-button", SsSearchButton);
- // app.component("ss-drop-button", SsDropButton);
- // app.component("ss-sub-tab", SsSubTab);
- // app.component("ss-img", SsImgUpload);
- // 设置为中文
- // app.use(ElementPlus, {
- // locale: ElementPlusLocaleZhCn,
- // });
- // console.log(ElementPlus);
- // 确保 ElementPlusIconsVue
- // if (window.ElementPlusIconsVue) {
- // // 注册 Element Plus 图标组件
- // for (const [key, component] of Object.entries(
- // window.ElementPlusIconsVue
- // )) {
- // console.log(key, component);
- // app.component(key, component);
- // }
- // }
- // 挂载首页的组件
- // for (const componentName in IndexComponents) {
- // app.component(componentName, IndexComponents[componentName]);
- // }
- // 挂载echarts的组件
- // for (const componentName in EchartComponents) {
- // app.component(componentName, EchartComponents[componentName]);
- // }
- // 挂载 Vue 应用
- const vm = app.mount(el);
- vm.data = vueOptions.data();
- return vm;
- };
- })();
|