| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325 |
- // H5版本的小程序组件库
- // 参考 alf/ss-components.js 的组件形式
- (function () {
- const { ref, createApp, watch, inject, onMounted, 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 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 handleCardClick = () => {
- // 如果菜单打开,先关闭菜单
- 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 handleButtonClick = (btn, index) => {
- showButtonMenu.value = false;
- // 执行按钮的回调
- if (btn.onclick && typeof btn.onclick === 'function') {
- btn.onclick();
- }
- // 触发组件事件
- emit('buttonClick', { button: btn, index, item: props.item });
- };
- // 监听全局关闭事件
- onMounted(() => {
- document.addEventListener('closeAllCardMenus', closeMenu);
- });
- // H5环境下的清理逻辑
- // 注意:Vue 3的beforeUnmount在某些H5环境下可能不可用
- // 这里暂时省略,依赖页面刷新时的自动清理
- return {
- showButtonMenu,
- hasButtons,
- isMultipleButtons,
- isSingleButton,
- handleCardClick,
- handleSettingClick,
- handleButtonClick,
- };
- },
- template: `
- <div class="ss-card" @click="handleCardClick">
- <!-- 右上角设置按钮 - 只有buttons且长度>0时显示 -->
- <div
- v-if="hasButtons"
- 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>
- `,
- };
- // 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',
- },
- },
- emits: ['update:modelValue', 'change', 'search', 'clear'],
- setup(props, { emit }) {
- // 响应式数据
- const isOpen = ref(false);
- const selectedValue = ref(props.modelValue);
- const searchKeyword = ref('');
- // 计算属性
- const optionsList = computed(() => props.options || []);
- 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);
- });
- return {
- isOpen,
- selectedValue,
- searchKeyword,
- optionsList,
- displayText,
- toggleDropdown,
- selectOption,
- };
- },
- template: `
- <div class="ss-select-container" :class="{ open: isOpen }" @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="loading"
- 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'],
- setup(props, { emit }) {
- const showPicker = ref(false);
- const showTimePicker = ref(false);
- const currentStep = ref('date'); // 'date' | 'time'
- // 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);
- 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);
- 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);
- }
- } catch (e) {
- console.error('Time conversion error:', e);
- }
- showTimePicker.value = false;
- currentStep.value = 'date';
- };
- // 取消选择
- const onCancel = () => {
- emit('cancel');
- showPicker.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,
- 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="() => { showTimePicker = false; }"
- 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 params = new URLSearchParams({
- ...props.baseParams,
- service: tab.service || '',
- param: tab.param || '',
- });
- 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>
- `,
- };
- 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("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;
- };
- })();
|