feat: 新增商品管理模块,包含商品分类、品牌、SPU管理及相关表单组件
							parent
							
								
									4cc5d8bf92
								
							
						
					
					
						commit
						f0516fa857
					
				|  | @ -126,6 +126,12 @@ const ElUpload = defineAsyncComponent(() => | ||||||
|     import('element-plus/es/components/upload/style/css'), |     import('element-plus/es/components/upload/style/css'), | ||||||
|   ]).then(([res]) => res.ElUpload), |   ]).then(([res]) => res.ElUpload), | ||||||
| ); | ); | ||||||
|  | const ElCascader = defineAsyncComponent(() => | ||||||
|  |   Promise.all([ | ||||||
|  |     import('element-plus/es/components/cascader/index'), | ||||||
|  |     import('element-plus/es/components/cascader/style/css'), | ||||||
|  |   ]).then(([res]) => res.ElCascader), | ||||||
|  | ); | ||||||
| 
 | 
 | ||||||
| const withDefaultPlaceholder = <T extends Component>( | const withDefaultPlaceholder = <T extends Component>( | ||||||
|   component: T, |   component: T, | ||||||
|  | @ -185,6 +191,7 @@ export type ComponentType = | ||||||
|   | 'TimePicker' |   | 'TimePicker' | ||||||
|   | 'TreeSelect' |   | 'TreeSelect' | ||||||
|   | 'Upload' |   | 'Upload' | ||||||
|  |   | 'ApiCascader' | ||||||
|   | BaseFormComponentType; |   | BaseFormComponentType; | ||||||
| 
 | 
 | ||||||
| async function initComponentAdapter() { | async function initComponentAdapter() { | ||||||
|  | @ -204,6 +211,23 @@ async function initComponentAdapter() { | ||||||
|         visibleEvent: 'onVisibleChange', |         visibleEvent: 'onVisibleChange', | ||||||
|       }, |       }, | ||||||
|     ), |     ), | ||||||
|  |     ApiCascader: withDefaultPlaceholder( | ||||||
|  |       { | ||||||
|  |         ...ApiComponent, | ||||||
|  |         name: 'ApiCascader', | ||||||
|  |       }, | ||||||
|  |       'select', | ||||||
|  |       { | ||||||
|  |         component: ElCascader, | ||||||
|  |         props: { | ||||||
|  |           props: { | ||||||
|  |             label: 'label', | ||||||
|  |             value: 'value', | ||||||
|  |             children: 'children', | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     ), | ||||||
|     ApiTreeSelect: withDefaultPlaceholder( |     ApiTreeSelect: withDefaultPlaceholder( | ||||||
|       { |       { | ||||||
|         ...ApiComponent, |         ...ApiComponent, | ||||||
|  |  | ||||||
|  | @ -75,10 +75,16 @@ setupVbenVxeTable({ | ||||||
| 
 | 
 | ||||||
|     // 表格配置项可以用 cellRender: { name: 'CellImage' },
 |     // 表格配置项可以用 cellRender: { name: 'CellImage' },
 | ||||||
|     vxeUI.renderer.add('CellImage', { |     vxeUI.renderer.add('CellImage', { | ||||||
|       renderTableDefault(_renderOpts, params) { |       renderTableDefault(renderOpts, params) { | ||||||
|  |         const { props } = renderOpts; | ||||||
|         const { column, row } = params; |         const { column, row } = params; | ||||||
|         const src = row[column.field]; |         const src = row[column.field]; | ||||||
|         return h(ElImage, { src, previewSrcList: [src] }); |         return h(ElImage, { | ||||||
|  |           src, | ||||||
|  |           previewSrcList: [src], | ||||||
|  |           class: props?.class, | ||||||
|  |           previewTeleported: true, | ||||||
|  |         }); | ||||||
|       }, |       }, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -49,3 +49,10 @@ export function getCategoryList(params: any) { | ||||||
|     }, |     }, | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // 获得商品分类列表
 | ||||||
|  | export function getCategorySimpleList() { | ||||||
|  |   return requestClient.get<MallCategoryApi.Category[]>( | ||||||
|  |     '/product/category/list-all-simple', | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -48,10 +48,14 @@ const props = withDefaults( | ||||||
|     resultField?: string; |     resultField?: string; | ||||||
|     // 是否显示下面的描述 |     // 是否显示下面的描述 | ||||||
|     showDescription?: boolean; |     showDescription?: boolean; | ||||||
|     value?: string | string[]; |     modelValue?: string | string[]; | ||||||
|  |     // 上传框宽度 | ||||||
|  |     width?: string | number; | ||||||
|  |     // 上传框高度 | ||||||
|  |     height?: string | number; | ||||||
|   }>(), |   }>(), | ||||||
|   { |   { | ||||||
|     value: () => [], |     modelValue: () => [], | ||||||
|     directory: undefined, |     directory: undefined, | ||||||
|     disabled: false, |     disabled: false, | ||||||
|     listType: 'picture-card', |     listType: 'picture-card', | ||||||
|  | @ -63,11 +67,13 @@ const props = withDefaults( | ||||||
|     api: undefined, |     api: undefined, | ||||||
|     resultField: '', |     resultField: '', | ||||||
|     showDescription: true, |     showDescription: true, | ||||||
|  |     width: '', | ||||||
|  |     height: '', | ||||||
|   }, |   }, | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
| const emit = defineEmits(['change', 'update:value', 'delete']); | const emit = defineEmits(['change', 'update:modelValue', 'delete']); | ||||||
| const { accept, helpText, maxNumber, maxSize } = toRefs(props); | const { accept, helpText, maxNumber, maxSize, width, height } = toRefs(props); | ||||||
| const isInnerOperate = ref<boolean>(false); | const isInnerOperate = ref<boolean>(false); | ||||||
| const { getStringAccept } = useUploadType({ | const { getStringAccept } = useUploadType({ | ||||||
|   acceptRef: accept, |   acceptRef: accept, | ||||||
|  | @ -82,7 +88,7 @@ const isActMsg = ref<boolean>(true); // 文件类型错误提示 | ||||||
| const isFirstRender = ref<boolean>(true); // 是否第一次渲染 | const isFirstRender = ref<boolean>(true); // 是否第一次渲染 | ||||||
| 
 | 
 | ||||||
| watch( | watch( | ||||||
|   () => props.value, |   () => props.modelValue, | ||||||
|   async (v) => { |   async (v) => { | ||||||
|     if (isInnerOperate.value) { |     if (isInnerOperate.value) { | ||||||
|       isInnerOperate.value = false; |       isInnerOperate.value = false; | ||||||
|  | @ -101,7 +107,7 @@ watch( | ||||||
|             return { |             return { | ||||||
|               uid: -i, |               uid: -i, | ||||||
|               name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)), |               name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)), | ||||||
|               status: UploadResultStatus.DONE, |               status: UploadResultStatus.SUCCESS, | ||||||
|               url: item, |               url: item, | ||||||
|             } as UploadFile; |             } as UploadFile; | ||||||
|           } else if (item && isObject(item)) { |           } else if (item && isObject(item)) { | ||||||
|  | @ -109,7 +115,7 @@ watch( | ||||||
|             return { |             return { | ||||||
|               uid: file.uid || -i, |               uid: file.uid || -i, | ||||||
|               name: file.name || '', |               name: file.name || '', | ||||||
|               status: UploadResultStatus.DONE, |               status: UploadResultStatus.SUCCESS, | ||||||
|               url: file.url, |               url: file.url, | ||||||
|             } as UploadFile; |             } as UploadFile; | ||||||
|           } |           } | ||||||
|  | @ -154,7 +160,7 @@ const handleRemove = async (file: UploadFile) => { | ||||||
|     index !== -1 && fileList.value.splice(index, 1); |     index !== -1 && fileList.value.splice(index, 1); | ||||||
|     const value = getValue(); |     const value = getValue(); | ||||||
|     isInnerOperate.value = true; |     isInnerOperate.value = true; | ||||||
|     emit('update:value', value); |     emit('update:modelValue', value); | ||||||
|     emit('change', value); |     emit('change', value); | ||||||
|     emit('delete', file); |     emit('delete', file); | ||||||
|   } |   } | ||||||
|  | @ -204,7 +210,7 @@ async function customRequest(options: UploadRequestOptions) { | ||||||
|     // 更新文件 |     // 更新文件 | ||||||
|     const value = getValue(); |     const value = getValue(); | ||||||
|     isInnerOperate.value = true; |     isInnerOperate.value = true; | ||||||
|     emit('update:value', value); |     emit('update:modelValue', value); | ||||||
|     emit('change', value); |     emit('change', value); | ||||||
|   } catch (error: any) { |   } catch (error: any) { | ||||||
|     console.error(error); |     console.error(error); | ||||||
|  | @ -213,13 +219,14 @@ async function customRequest(options: UploadRequestOptions) { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function getValue() { | function getValue() { | ||||||
|  |   console.log(fileList.value); | ||||||
|   const list = (fileList.value || []) |   const list = (fileList.value || []) | ||||||
|     .filter((item) => item?.status === UploadResultStatus.DONE) |     .filter((item) => item?.status === UploadResultStatus.SUCCESS) | ||||||
|     .map((item: any) => { |     .map((item: any) => { | ||||||
|       if (item?.response && props?.resultField) { |       if (item?.response && props?.resultField) { | ||||||
|         return item?.response; |         return item?.response; | ||||||
|       } |       } | ||||||
|       return item?.url || item?.response?.url || item?.response; |       return item?.response?.url || item?.response; | ||||||
|     }); |     }); | ||||||
|   // add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型 |   // add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型 | ||||||
|   if (props.maxNumber === 1) { |   if (props.maxNumber === 1) { | ||||||
|  | @ -243,10 +250,11 @@ function getValue() { | ||||||
|       :multiple="multiple" |       :multiple="multiple" | ||||||
|       :on-preview="handlePreview" |       :on-preview="handlePreview" | ||||||
|       :on-remove="handleRemove" |       :on-remove="handleRemove" | ||||||
|  |       :class="width || height ? 'custom-upload' : ''" | ||||||
|     > |     > | ||||||
|       <div |       <div | ||||||
|         v-if="fileList && fileList.length < maxNumber" |         class="upload-content flex flex-col items-center justify-center" | ||||||
|         class="flex flex-col items-center justify-center" |         :style="{ width: width || '', height: height || '' }" | ||||||
|       > |       > | ||||||
|         <CloudUpload /> |         <CloudUpload /> | ||||||
|         <div class="mt-2">{{ $t('ui.upload.imgUpload') }}</div> |         <div class="mt-2">{{ $t('ui.upload.imgUpload') }}</div> | ||||||
|  | @ -262,4 +270,22 @@ function getValue() { | ||||||
| .ant-upload-select-picture-card { | .ant-upload-select-picture-card { | ||||||
|   @apply flex items-center justify-center; |   @apply flex items-center justify-center; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | .custom-upload .el-upload { | ||||||
|  |   width: auto !important; | ||||||
|  |   height: auto !important; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .custom-upload .el-upload--picture-card { | ||||||
|  |   width: auto !important; | ||||||
|  |   height: auto !important; | ||||||
|  |   line-height: normal !important; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .custom-upload .upload-content { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   overflow: hidden; | ||||||
|  | } | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
|  | @ -0,0 +1,76 @@ | ||||||
|  | import type { RouteRecordRaw } from 'vue-router'; | ||||||
|  | 
 | ||||||
|  | const routes: RouteRecordRaw[] = [ | ||||||
|  |   { | ||||||
|  |     path: '/mall/product', | ||||||
|  |     name: 'ProductCenter', | ||||||
|  |     meta: { | ||||||
|  |       title: '商品中心', | ||||||
|  |       icon: 'lucide:shopping-bag', | ||||||
|  |       keepAlive: true, | ||||||
|  |       hideInMenu: true, | ||||||
|  |     }, | ||||||
|  |     children: [ | ||||||
|  |       { | ||||||
|  |         path: 'spu/add', | ||||||
|  |         name: 'ProductSpuAdd', | ||||||
|  |         meta: { | ||||||
|  |           title: '商品添加', | ||||||
|  |           activeMenu: '/mall/product/spu', | ||||||
|  |         }, | ||||||
|  |         component: () => import('#/views/mall/product/spu/modules/form.vue'), | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         path: String.raw`spu/edit/:id(\d+)`, | ||||||
|  |         name: 'ProductSpuEdit', | ||||||
|  |         meta: { | ||||||
|  |           title: '商品编辑', | ||||||
|  |           activeMenu: '/mall/product/spu', | ||||||
|  |         }, | ||||||
|  |         component: () => import('#/views/mall/product/spu/modules/form.vue'), | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         path: String.raw`spu/detail/:id(\d+)`, | ||||||
|  |         name: 'ProductSpuDetail', | ||||||
|  |         meta: { | ||||||
|  |           title: '商品详情', | ||||||
|  |           activeMenu: '/crm/business', | ||||||
|  |         }, | ||||||
|  |         component: () => import('#/views/mall/product/spu/modules/detail.vue'), | ||||||
|  |       }, | ||||||
|  |     ], | ||||||
|  |   }, | ||||||
|  |   // {
 | ||||||
|  |   //   path: '/mall/trade',
 | ||||||
|  |   //   name: 'TradeCenter',
 | ||||||
|  |   //   meta: {
 | ||||||
|  |   //     title: '交易中心',
 | ||||||
|  |   //     icon: 'lucide:shopping-cart',
 | ||||||
|  |   //     keepAlive: true,
 | ||||||
|  |   //     hideInMenu: true,
 | ||||||
|  |   //   },
 | ||||||
|  |   //   children: [
 | ||||||
|  |   //     {
 | ||||||
|  |   //       path: String.raw`order/detail/:id(\d+)`,
 | ||||||
|  |   //       name: 'TradeOrderDetail',
 | ||||||
|  |   //       meta: {
 | ||||||
|  |   //         title: '订单详情',
 | ||||||
|  |   //         activeMenu: '/mall/trade/order',
 | ||||||
|  |   //       },
 | ||||||
|  |   //       component: () => import('#/views/mall/trade/order/detail/index.vue'),
 | ||||||
|  |   //     },
 | ||||||
|  |   //     {
 | ||||||
|  |   //       path: String.raw`after-sale/detail/:id(\d+)`,
 | ||||||
|  |   //       name: 'TradeAfterSaleDetail',
 | ||||||
|  |   //       meta: {
 | ||||||
|  |   //         title: '退款详情',
 | ||||||
|  |   //         activeMenu: '/mall/trade/after-sale',
 | ||||||
|  |   //       },
 | ||||||
|  |   //       component: () =>
 | ||||||
|  |   //         import('#/views/mall/trade/afterSale/detail/index.vue'),
 | ||||||
|  |   //     },
 | ||||||
|  |   //   ],
 | ||||||
|  |   // },
 | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
|  | export default routes; | ||||||
|  | @ -0,0 +1,17 @@ | ||||||
|  | /** | ||||||
|  |  * 将值复制到目标对象,且以目标对象属性为准,例:target: {a:1} source:{a:2,b:3} 结果为:{a:2} | ||||||
|  |  * @param target 目标对象 | ||||||
|  |  * @param source 源对象 | ||||||
|  |  */ | ||||||
|  | export const copyValueToTarget = (target: any, source: any) => { | ||||||
|  |   const newObj = Object.assign({}, target, source); | ||||||
|  |   // 删除多余属性
 | ||||||
|  |   Object.keys(newObj).forEach((key) => { | ||||||
|  |     // 如果不是target中的属性则删除
 | ||||||
|  |     if (Object.keys(target).indexOf(key) === -1) { | ||||||
|  |       delete newObj[key]; | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |   // 更新目标对象值
 | ||||||
|  |   Object.assign(target, newObj); | ||||||
|  | }; | ||||||
|  | @ -0,0 +1,38 @@ | ||||||
|  | /** | ||||||
|  |  * 将一个整数转换为分数保留两位小数 | ||||||
|  |  * @param num | ||||||
|  |  */ | ||||||
|  | export const formatToFraction = (num: number | string | undefined): string => { | ||||||
|  |   if (typeof num === 'undefined') return '0.00'; | ||||||
|  |   const parsedNumber = typeof num === 'string' ? parseFloat(num) : num; | ||||||
|  |   return (parsedNumber / 100.0).toFixed(2); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 将一个数转换为 1.00 这样 | ||||||
|  |  * 数据呈现的时候使用 | ||||||
|  |  * | ||||||
|  |  * @param num 整数 | ||||||
|  |  */ | ||||||
|  | // TODO @芋艿:看看怎么融合掉
 | ||||||
|  | export const floatToFixed2 = (num: number | string | undefined): string => { | ||||||
|  |   let str = '0.00'; | ||||||
|  |   if (typeof num === 'undefined') { | ||||||
|  |     return str; | ||||||
|  |   } | ||||||
|  |   const f = formatToFraction(num); | ||||||
|  |   const decimalPart = f.toString().split('.')[1]; | ||||||
|  |   const len = decimalPart ? decimalPart.length : 0; | ||||||
|  |   switch (len) { | ||||||
|  |     case 0: | ||||||
|  |       str = f.toString() + '.00'; | ||||||
|  |       break; | ||||||
|  |     case 1: | ||||||
|  |       str = f.toString() + '0'; | ||||||
|  |       break; | ||||||
|  |     case 2: | ||||||
|  |       str = f.toString(); | ||||||
|  |       break; | ||||||
|  |   } | ||||||
|  |   return str; | ||||||
|  | }; | ||||||
|  | @ -5,3 +5,7 @@ export * from './formCreate'; | ||||||
| export * from './rangePickerProps'; | export * from './rangePickerProps'; | ||||||
| export * from './routerHelper'; | export * from './routerHelper'; | ||||||
| export * from './validator'; | export * from './validator'; | ||||||
|  | export * from './tree'; | ||||||
|  | export * from './formatNum'; | ||||||
|  | export * from './is'; | ||||||
|  | export * from './bean'; | ||||||
|  |  | ||||||
|  | @ -0,0 +1,117 @@ | ||||||
|  | // copy to vben-admin
 | ||||||
|  | 
 | ||||||
|  | const toString = Object.prototype.toString | ||||||
|  | 
 | ||||||
|  | export const is = (val: unknown, type: string) => { | ||||||
|  |   return toString.call(val) === `[object ${type}]` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isDef = <T = unknown>(val?: T): val is T => { | ||||||
|  |   return typeof val !== 'undefined' | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isUnDef = <T = unknown>(val?: T): val is T => { | ||||||
|  |   return !isDef(val) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isObject = (val: any): val is Record<any, any> => { | ||||||
|  |   return val !== null && is(val, 'Object') | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isEmpty = (val: any): boolean => { | ||||||
|  |   if (val === null || val === undefined || typeof val === 'undefined') { | ||||||
|  |     return true | ||||||
|  |   } | ||||||
|  |   if (isArray(val) || isString(val)) { | ||||||
|  |     return val.length === 0 | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   if (val instanceof Map || val instanceof Set) { | ||||||
|  |     return val.size === 0 | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   if (isObject(val)) { | ||||||
|  |     return Object.keys(val).length === 0 | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return false | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isDate = (val: unknown): val is Date => { | ||||||
|  |   return is(val, 'Date') | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isNull = (val: unknown): val is null => { | ||||||
|  |   return val === null | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isNullAndUnDef = (val: unknown): val is null | undefined => { | ||||||
|  |   return isUnDef(val) && isNull(val) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isNullOrUnDef = (val: unknown): val is null | undefined => { | ||||||
|  |   return isUnDef(val) || isNull(val) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isNumber = (val: unknown): val is number => { | ||||||
|  |   return is(val, 'Number') | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isPromise = <T = any>(val: unknown): val is Promise<T> => { | ||||||
|  |   return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isString = (val: unknown): val is string => { | ||||||
|  |   return is(val, 'String') | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isFunction = (val: unknown): val is Function => { | ||||||
|  |   return typeof val === 'function' | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isBoolean = (val: unknown): val is boolean => { | ||||||
|  |   return is(val, 'Boolean') | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isRegExp = (val: unknown): val is RegExp => { | ||||||
|  |   return is(val, 'RegExp') | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isArray = (val: any): val is Array<any> => { | ||||||
|  |   return val && Array.isArray(val) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isWindow = (val: any): val is Window => { | ||||||
|  |   return typeof window !== 'undefined' && is(val, 'Window') | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isElement = (val: unknown): val is Element => { | ||||||
|  |   return isObject(val) && !!val.tagName | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isMap = (val: unknown): val is Map<any, any> => { | ||||||
|  |   return is(val, 'Map') | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isServer = typeof window === 'undefined' | ||||||
|  | 
 | ||||||
|  | export const isClient = !isServer | ||||||
|  | 
 | ||||||
|  | export const isUrl = (path: string): boolean => { | ||||||
|  |   const reg = | ||||||
|  |     /(((^https?:(?:\/\/)?)(?:[-:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&%@.\w_]*)#?(?:[\w]*))?)$/ | ||||||
|  |   return reg.test(path) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isDark = (): boolean => { | ||||||
|  |   return window.matchMedia('(prefers-color-scheme: dark)').matches | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // 是否是图片链接
 | ||||||
|  | export const isImgPath = (path: string): boolean => { | ||||||
|  |   return /(https?:\/\/|data:image\/).*?\.(png|jpg|jpeg|gif|svg|webp|ico)/gi.test(path) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const isEmptyVal = (val: any): boolean => { | ||||||
|  |   return val === '' || val === null || val === undefined | ||||||
|  | } | ||||||
|  | @ -0,0 +1,440 @@ | ||||||
|  | interface TreeHelperConfig { | ||||||
|  |   id: string; | ||||||
|  |   children: string; | ||||||
|  |   pid: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const DEFAULT_CONFIG: TreeHelperConfig = { | ||||||
|  |   id: 'id', | ||||||
|  |   children: 'children', | ||||||
|  |   pid: 'pid', | ||||||
|  | }; | ||||||
|  | export const defaultProps = { | ||||||
|  |   children: 'children', | ||||||
|  |   label: 'name', | ||||||
|  |   value: 'id', | ||||||
|  |   isLeaf: 'leaf', | ||||||
|  |   emitPath: false, // 用于 cascader 组件:在选中节点改变时,是否返回由该节点所在的各级菜单的值所组成的数组,若设置 false,则只返回该节点的值
 | ||||||
|  | }; | ||||||
|  | interface Fn<T = any> { | ||||||
|  |   (...arg: T[]): T; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const getConfig = (config: Partial<TreeHelperConfig>) => | ||||||
|  |   Object.assign({}, DEFAULT_CONFIG, config); | ||||||
|  | 
 | ||||||
|  | // tree from list
 | ||||||
|  | export const listToTree = <T = any>( | ||||||
|  |   list: any[], | ||||||
|  |   config: Partial<TreeHelperConfig> = {}, | ||||||
|  | ): T[] => { | ||||||
|  |   const conf = getConfig(config) as TreeHelperConfig; | ||||||
|  |   const nodeMap = new Map(); | ||||||
|  |   const result: T[] = []; | ||||||
|  |   const { id, children, pid } = conf; | ||||||
|  | 
 | ||||||
|  |   for (const node of list) { | ||||||
|  |     node[children] = node[children] || []; | ||||||
|  |     nodeMap.set(node[id], node); | ||||||
|  |   } | ||||||
|  |   for (const node of list) { | ||||||
|  |     const parent = nodeMap.get(node[pid]); | ||||||
|  |     (parent ? parent.children : result).push(node); | ||||||
|  |   } | ||||||
|  |   return result; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const treeToList = <T = any>( | ||||||
|  |   tree: any, | ||||||
|  |   config: Partial<TreeHelperConfig> = {}, | ||||||
|  | ): T => { | ||||||
|  |   config = getConfig(config); | ||||||
|  |   const { children } = config; | ||||||
|  |   const result: any = [...tree]; | ||||||
|  |   for (let i = 0; i < result.length; i++) { | ||||||
|  |     if (!result[i][children!]) continue; | ||||||
|  |     result.splice(i + 1, 0, ...result[i][children!]); | ||||||
|  |   } | ||||||
|  |   return result; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const findNode = <T = any>( | ||||||
|  |   tree: any, | ||||||
|  |   func: Fn, | ||||||
|  |   config: Partial<TreeHelperConfig> = {}, | ||||||
|  | ): T | null => { | ||||||
|  |   config = getConfig(config); | ||||||
|  |   const { children } = config; | ||||||
|  |   const list = [...tree]; | ||||||
|  |   for (const node of list) { | ||||||
|  |     if (func(node)) return node; | ||||||
|  |     node[children!] && list.push(...node[children!]); | ||||||
|  |   } | ||||||
|  |   return null; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const findNodeAll = <T = any>( | ||||||
|  |   tree: any, | ||||||
|  |   func: Fn, | ||||||
|  |   config: Partial<TreeHelperConfig> = {}, | ||||||
|  | ): T[] => { | ||||||
|  |   config = getConfig(config); | ||||||
|  |   const { children } = config; | ||||||
|  |   const list = [...tree]; | ||||||
|  |   const result: T[] = []; | ||||||
|  |   for (const node of list) { | ||||||
|  |     func(node) && result.push(node); | ||||||
|  |     node[children!] && list.push(...node[children!]); | ||||||
|  |   } | ||||||
|  |   return result; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const findPath = <T = any>( | ||||||
|  |   tree: any, | ||||||
|  |   func: Fn, | ||||||
|  |   config: Partial<TreeHelperConfig> = {}, | ||||||
|  | ): T | T[] | null => { | ||||||
|  |   config = getConfig(config); | ||||||
|  |   const path: T[] = []; | ||||||
|  |   const list = [...tree]; | ||||||
|  |   const visitedSet = new Set(); | ||||||
|  |   const { children } = config; | ||||||
|  |   while (list.length) { | ||||||
|  |     const node = list[0]; | ||||||
|  |     if (visitedSet.has(node)) { | ||||||
|  |       path.pop(); | ||||||
|  |       list.shift(); | ||||||
|  |     } else { | ||||||
|  |       visitedSet.add(node); | ||||||
|  |       node[children!] && list.unshift(...node[children!]); | ||||||
|  |       path.push(node); | ||||||
|  |       if (func(node)) { | ||||||
|  |         return path; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   return null; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const findPathAll = ( | ||||||
|  |   tree: any, | ||||||
|  |   func: Fn, | ||||||
|  |   config: Partial<TreeHelperConfig> = {}, | ||||||
|  | ) => { | ||||||
|  |   config = getConfig(config); | ||||||
|  |   const path: any[] = []; | ||||||
|  |   const list = [...tree]; | ||||||
|  |   const result: any[] = []; | ||||||
|  |   const visitedSet = new Set(), | ||||||
|  |     { children } = config; | ||||||
|  |   while (list.length) { | ||||||
|  |     const node = list[0]; | ||||||
|  |     if (visitedSet.has(node)) { | ||||||
|  |       path.pop(); | ||||||
|  |       list.shift(); | ||||||
|  |     } else { | ||||||
|  |       visitedSet.add(node); | ||||||
|  |       node[children!] && list.unshift(...node[children!]); | ||||||
|  |       path.push(node); | ||||||
|  |       func(node) && result.push([...path]); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   return result; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const filter = <T = any>( | ||||||
|  |   tree: T[], | ||||||
|  |   func: (n: T) => boolean, | ||||||
|  |   config: Partial<TreeHelperConfig> = {}, | ||||||
|  | ): T[] => { | ||||||
|  |   config = getConfig(config); | ||||||
|  |   const children = config.children as string; | ||||||
|  | 
 | ||||||
|  |   function listFilter(list: T[]) { | ||||||
|  |     return list | ||||||
|  |       .map((node: any) => ({ ...node })) | ||||||
|  |       .filter((node) => { | ||||||
|  |         node[children] = node[children] && listFilter(node[children]); | ||||||
|  |         return func(node) || (node[children] && node[children].length); | ||||||
|  |       }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return listFilter(tree); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const forEach = <T = any>( | ||||||
|  |   tree: T[], | ||||||
|  |   func: (n: T) => any, | ||||||
|  |   config: Partial<TreeHelperConfig> = {}, | ||||||
|  | ): void => { | ||||||
|  |   config = getConfig(config); | ||||||
|  |   const list: any[] = [...tree]; | ||||||
|  |   const { children } = config; | ||||||
|  |   for (let i = 0; i < list.length; i++) { | ||||||
|  |     // func 返回true就终止遍历,避免大量节点场景下无意义循环,引起浏览器卡顿
 | ||||||
|  |     if (func(list[i])) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     children && | ||||||
|  |       list[i][children] && | ||||||
|  |       list.splice(i + 1, 0, ...list[i][children]); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * @description: Extract tree specified structure | ||||||
|  |  */ | ||||||
|  | export const treeMap = <T = any>( | ||||||
|  |   treeData: T[], | ||||||
|  |   opt: { children?: string; conversion: Fn }, | ||||||
|  | ): T[] => { | ||||||
|  |   return treeData.map((item) => treeMapEach(item, opt)); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * @description: Extract tree specified structure | ||||||
|  |  */ | ||||||
|  | export const treeMapEach = ( | ||||||
|  |   data: any, | ||||||
|  |   { children = 'children', conversion }: { children?: string; conversion: Fn }, | ||||||
|  | ) => { | ||||||
|  |   const haveChildren = | ||||||
|  |     Array.isArray(data[children]) && data[children].length > 0; | ||||||
|  |   const conversionData = conversion(data) || {}; | ||||||
|  |   if (haveChildren) { | ||||||
|  |     return { | ||||||
|  |       ...conversionData, | ||||||
|  |       [children]: data[children].map((i: number) => | ||||||
|  |         treeMapEach(i, { | ||||||
|  |           children, | ||||||
|  |           conversion, | ||||||
|  |         }), | ||||||
|  |       ), | ||||||
|  |     }; | ||||||
|  |   } else { | ||||||
|  |     return { | ||||||
|  |       ...conversionData, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 递归遍历树结构 | ||||||
|  |  * @param treeDatas 树 | ||||||
|  |  * @param callBack 回调 | ||||||
|  |  * @param parentNode 父节点 | ||||||
|  |  */ | ||||||
|  | export const eachTree = (treeDatas: any[], callBack: Fn, parentNode = {}) => { | ||||||
|  |   treeDatas.forEach((element) => { | ||||||
|  |     const newNode = callBack(element, parentNode) || element; | ||||||
|  |     if (element.children) { | ||||||
|  |       eachTree(element.children, callBack, newNode); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 构造树型结构数据 | ||||||
|  |  * @param {*} data 数据源 | ||||||
|  |  * @param {*} id id字段 默认 'id' | ||||||
|  |  * @param {*} parentId 父节点字段 默认 'parentId' | ||||||
|  |  * @param {*} children 孩子节点字段 默认 'children' | ||||||
|  |  */ | ||||||
|  | export const handleTree = ( | ||||||
|  |   data: any[], | ||||||
|  |   id?: string, | ||||||
|  |   parentId?: string, | ||||||
|  |   children?: string, | ||||||
|  | ) => { | ||||||
|  |   if (!Array.isArray(data)) { | ||||||
|  |     console.warn('data must be an array'); | ||||||
|  |     return []; | ||||||
|  |   } | ||||||
|  |   const config = { | ||||||
|  |     id: id || 'id', | ||||||
|  |     parentId: parentId || 'parentId', | ||||||
|  |     childrenList: children || 'children', | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const childrenListMap: Record<string, any[]> = {}; | ||||||
|  |   const nodeIds: Record<string, any> = {}; | ||||||
|  |   const tree: any[] = []; | ||||||
|  | 
 | ||||||
|  |   for (const d of data) { | ||||||
|  |     const parentId = d[config.parentId]; | ||||||
|  |     if (childrenListMap[parentId] == null) { | ||||||
|  |       childrenListMap[parentId] = []; | ||||||
|  |     } | ||||||
|  |     nodeIds[d[config.id]] = d; | ||||||
|  |     childrenListMap[parentId].push(d); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   for (const d of data) { | ||||||
|  |     const parentId = d[config.parentId]; | ||||||
|  |     if (nodeIds[parentId] == null) { | ||||||
|  |       tree.push(d); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   for (const t of tree) { | ||||||
|  |     adaptToChildrenList(t); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   function adaptToChildrenList(o: any) { | ||||||
|  |     if (childrenListMap[o[config.id]] !== null) { | ||||||
|  |       o[config.childrenList] = childrenListMap[o[config.id]]; | ||||||
|  |     } | ||||||
|  |     if (o[config.childrenList]) { | ||||||
|  |       for (const c of o[config.childrenList]) { | ||||||
|  |         adaptToChildrenList(c); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return tree; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 构造树型结构数据 | ||||||
|  |  * @param {*} data 数据源 | ||||||
|  |  * @param {*} id id字段 默认 'id' | ||||||
|  |  * @param {*} parentId 父节点字段 默认 'parentId' | ||||||
|  |  * @param {*} children 孩子节点字段 默认 'children' | ||||||
|  |  * @param {*} rootId 根Id 默认 0 | ||||||
|  |  */ | ||||||
|  | // @ts-ignore
 | ||||||
|  | export const handleTree2 = (data, id, parentId, children, rootId) => { | ||||||
|  |   id = id || 'id'; | ||||||
|  |   parentId = parentId || 'parentId'; | ||||||
|  |   // children = children || 'children'
 | ||||||
|  |   rootId = | ||||||
|  |     rootId || | ||||||
|  |     Math.min( | ||||||
|  |       ...data.map((item: any) => { | ||||||
|  |         return item[parentId]; | ||||||
|  |       }), | ||||||
|  |     ) || | ||||||
|  |     0; | ||||||
|  |   // 对源数据深度克隆
 | ||||||
|  |   const cloneData = JSON.parse(JSON.stringify(data)); | ||||||
|  |   // 循环所有项
 | ||||||
|  |   const treeData = cloneData.filter((father: any) => { | ||||||
|  |     const branchArr = cloneData.filter((child: any) => { | ||||||
|  |       // 返回每一项的子级数组
 | ||||||
|  |       return father[id] === child[parentId]; | ||||||
|  |     }); | ||||||
|  |     branchArr.length > 0 ? (father.children = branchArr) : ''; | ||||||
|  |     // 返回第一层
 | ||||||
|  |     return father[parentId] === rootId; | ||||||
|  |   }); | ||||||
|  |   return treeData !== '' ? treeData : data; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 校验选中的节点,是否为指定 level | ||||||
|  |  * | ||||||
|  |  * @param tree 要操作的树结构数据 | ||||||
|  |  * @param nodeId 需要判断在什么层级的数据 | ||||||
|  |  * @param level 检查的级别, 默认检查到二级 | ||||||
|  |  * @return true 是;false 否 | ||||||
|  |  */ | ||||||
|  | export const checkSelectedNode = ( | ||||||
|  |   tree: any[], | ||||||
|  |   nodeId: any, | ||||||
|  |   level = 2, | ||||||
|  | ): boolean => { | ||||||
|  |   if ( | ||||||
|  |     typeof tree === 'undefined' || | ||||||
|  |     !Array.isArray(tree) || | ||||||
|  |     tree.length === 0 | ||||||
|  |   ) { | ||||||
|  |     console.warn('tree must be an array'); | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // 校验是否是一级节点
 | ||||||
|  |   if (tree.some((item) => item.id === nodeId)) { | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // 递归计数
 | ||||||
|  |   let count = 1; | ||||||
|  | 
 | ||||||
|  |   // 深层次校验
 | ||||||
|  |   function performAThoroughValidation(arr: any[]): boolean { | ||||||
|  |     count += 1; | ||||||
|  |     for (const item of arr) { | ||||||
|  |       if (item.id === nodeId) { | ||||||
|  |         return true; | ||||||
|  |       } else if ( | ||||||
|  |         typeof item.children !== 'undefined' && | ||||||
|  |         item.children.length !== 0 | ||||||
|  |       ) { | ||||||
|  |         if (performAThoroughValidation(item.children)) { | ||||||
|  |           return true; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   for (const item of tree) { | ||||||
|  |     count = 1; | ||||||
|  |     if (performAThoroughValidation(item.children)) { | ||||||
|  |       // 找到后对比是否是期望的层级
 | ||||||
|  |       if (count >= level) { | ||||||
|  |         return true; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return false; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 获取节点的完整结构 | ||||||
|  |  * @param tree 树数据 | ||||||
|  |  * @param nodeId 节点 id | ||||||
|  |  */ | ||||||
|  | export const treeToString = (tree: any[], nodeId: any) => { | ||||||
|  |   if ( | ||||||
|  |     typeof tree === 'undefined' || | ||||||
|  |     !Array.isArray(tree) || | ||||||
|  |     tree.length === 0 | ||||||
|  |   ) { | ||||||
|  |     console.warn('tree must be an array'); | ||||||
|  |     return ''; | ||||||
|  |   } | ||||||
|  |   // 校验是否是一级节点
 | ||||||
|  |   const node = tree.find((item) => item.id === nodeId); | ||||||
|  |   if (typeof node !== 'undefined') { | ||||||
|  |     return node.name; | ||||||
|  |   } | ||||||
|  |   let str = ''; | ||||||
|  | 
 | ||||||
|  |   function performAThoroughValidation(arr: any[]) { | ||||||
|  |     for (const item of arr) { | ||||||
|  |       if (item.id === nodeId) { | ||||||
|  |         str += ` / ${item.name}`; | ||||||
|  |         return true; | ||||||
|  |       } else if ( | ||||||
|  |         typeof item.children !== 'undefined' && | ||||||
|  |         item.children.length !== 0 | ||||||
|  |       ) { | ||||||
|  |         str += ` / ${item.name}`; | ||||||
|  |         if (performAThoroughValidation(item.children)) { | ||||||
|  |           return true; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   for (const item of tree) { | ||||||
|  |     str = `${item.name}`; | ||||||
|  |     if (performAThoroughValidation(item.children)) { | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   return str; | ||||||
|  | }; | ||||||
|  | @ -103,6 +103,9 @@ export function useGridColumns(): VxeGridPropTypes.Columns { | ||||||
|       title: '品牌图片', |       title: '品牌图片', | ||||||
|       cellRender: { |       cellRender: { | ||||||
|         name: 'CellImage', |         name: 'CellImage', | ||||||
|  |         props: { | ||||||
|  |           class: 'w-10 h-10', | ||||||
|  |         }, | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|  |  | ||||||
|  | @ -0,0 +1,84 @@ | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { useVbenForm } from '#/adapter/form'; | ||||||
|  | import * as ExpressTemplateApi from '#/api/mall/trade/delivery/expressTemplate'; | ||||||
|  | import { watch } from 'vue'; | ||||||
|  | import { ElMessage } from 'element-plus'; | ||||||
|  | import { DICT_TYPE, getIntDictOptions, DeliveryTypeEnum } from '#/utils'; | ||||||
|  | 
 | ||||||
|  | const props = defineProps<{ | ||||||
|  |   propFormData: Object; | ||||||
|  | }>(); | ||||||
|  | 
 | ||||||
|  | /** 将传进来的值赋值给 formData */ | ||||||
|  | watch( | ||||||
|  |   () => props.propFormData, | ||||||
|  |   (data) => { | ||||||
|  |     if (!data) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     formApi.setValues(data); | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | const emit = defineEmits(['update:activeName']); | ||||||
|  | const validate = async () => { | ||||||
|  |   const { valid } = await formApi.validate(); | ||||||
|  |   if (!valid) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   try { | ||||||
|  |     // 校验通过更新数据 | ||||||
|  |     Object.assign(props.propFormData, formApi.getValues()); | ||||||
|  |   } catch (e) { | ||||||
|  |     ElMessage.error('【物流设置】不完善,请填写相关信息'); | ||||||
|  |     emit('update:activeName', 'delivery'); | ||||||
|  |     throw e; // 目的截断之后的校验 | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | defineExpose({ validate }); | ||||||
|  | 
 | ||||||
|  | const [Form, formApi] = useVbenForm({ | ||||||
|  |   commonConfig: { | ||||||
|  |     componentProps: { | ||||||
|  |       class: '!w-1/6', | ||||||
|  |     }, | ||||||
|  |     formItemClass: 'col-span-2', | ||||||
|  |     labelWidth: 120, | ||||||
|  |   }, | ||||||
|  |   layout: 'horizontal', | ||||||
|  |   schema: [ | ||||||
|  |     { | ||||||
|  |       fieldName: 'deliveryTypes', | ||||||
|  |       label: '配送方式', | ||||||
|  |       component: 'CheckboxGroup', | ||||||
|  |       componentProps: { | ||||||
|  |         options: getIntDictOptions(DICT_TYPE.TRADE_DELIVERY_TYPE), | ||||||
|  |       }, | ||||||
|  |       rules: 'required', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'deliveryTemplateId', | ||||||
|  |       label: '运费模板', | ||||||
|  |       component: 'ApiSelect', | ||||||
|  |       componentProps: { | ||||||
|  |         api: ExpressTemplateApi.getSimpleTemplateList, | ||||||
|  |         props: { | ||||||
|  |           label: 'name', | ||||||
|  |           value: 'id', | ||||||
|  |           children: 'children', | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |       rules: 'required', | ||||||
|  |       dependencies: { | ||||||
|  |         triggerFields: ['deliveryTypes'], | ||||||
|  |         show: (values) => | ||||||
|  |           values.deliveryTypes.includes(DeliveryTypeEnum.EXPRESS.type), | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  |   showDefaultActions: false, | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  | <template> | ||||||
|  |   <Form /> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,66 @@ | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { useVbenForm } from '#/adapter/form'; | ||||||
|  | import * as ExpressTemplateApi from '#/api/mall/trade/delivery/expressTemplate'; | ||||||
|  | import { watch } from 'vue'; | ||||||
|  | import { ElMessage } from 'element-plus'; | ||||||
|  | import { DICT_TYPE, getIntDictOptions, DeliveryTypeEnum } from '#/utils'; | ||||||
|  | 
 | ||||||
|  | const props = defineProps<{ | ||||||
|  |   propFormData: Object; | ||||||
|  | }>(); | ||||||
|  | 
 | ||||||
|  | /** 将传进来的值赋值给 formData */ | ||||||
|  | watch( | ||||||
|  |   () => props.propFormData, | ||||||
|  |   (data) => { | ||||||
|  |     if (!data) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     formApi.setValues(data); | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | const emit = defineEmits(['update:activeName']); | ||||||
|  | const validate = async () => { | ||||||
|  |   const { valid } = await formApi.validate(); | ||||||
|  |   if (!valid) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   try { | ||||||
|  |     // 校验通过更新数据 | ||||||
|  |     Object.assign(props.propFormData, formApi.getValues()); | ||||||
|  |   } catch (e) { | ||||||
|  |     ElMessage.error('【商品详情】不完善,请填写相关信息'); | ||||||
|  |     emit('update:activeName', 'description'); | ||||||
|  |     throw e; // 目的截断之后的校验 | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | defineExpose({ validate }); | ||||||
|  | 
 | ||||||
|  | const [Form, formApi] = useVbenForm({ | ||||||
|  |   commonConfig: { | ||||||
|  |     componentProps: { | ||||||
|  |       class: '!w-1/6', | ||||||
|  |     }, | ||||||
|  |     formItemClass: 'col-span-2', | ||||||
|  |     labelWidth: 120, | ||||||
|  |   }, | ||||||
|  |   layout: 'horizontal', | ||||||
|  |   schema: [ | ||||||
|  |     { | ||||||
|  |       fieldName: 'description', | ||||||
|  |       label: '商品详情', | ||||||
|  |       component: 'RichTextarea', | ||||||
|  |       componentProps: { | ||||||
|  |         placeholder: '请输入商品详情', | ||||||
|  |         height: 1000, | ||||||
|  |       }, | ||||||
|  |       rules: 'required', | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  |   showDefaultActions: false, | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  | <template> | ||||||
|  |   <Form /> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,138 @@ | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { useVbenForm } from '#/adapter/form'; | ||||||
|  | import { handleTree } from '#/utils'; | ||||||
|  | import * as ProductCategoryApi from '#/api/mall/product/category'; | ||||||
|  | import * as ProductBrandApi from '#/api/mall/product/brand'; | ||||||
|  | import { watch } from 'vue'; | ||||||
|  | import { ElMessage } from 'element-plus'; | ||||||
|  | 
 | ||||||
|  | const getCategoryList = async () => { | ||||||
|  |   const data = await ProductCategoryApi.getCategorySimpleList(); | ||||||
|  |   return handleTree(data, 'id'); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const props = defineProps<{ | ||||||
|  |   propFormData: Object; | ||||||
|  | }>(); | ||||||
|  | 
 | ||||||
|  | /** 将传进来的值赋值给 formData */ | ||||||
|  | watch( | ||||||
|  |   () => props.propFormData, | ||||||
|  |   (data) => { | ||||||
|  |     if (!data) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     formApi.setValues(data); | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | const emit = defineEmits(['update:activeName']); | ||||||
|  | const validate = async () => { | ||||||
|  |   const { valid } = await formApi.validate(); | ||||||
|  |   if (!valid) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   try { | ||||||
|  |     // 校验通过更新数据 | ||||||
|  |     Object.assign(props.propFormData, formApi.getValues()); | ||||||
|  |   } catch (e) { | ||||||
|  |     ElMessage.error('【基础设置】不完善,请填写相关信息'); | ||||||
|  |     emit('update:activeName', 'info'); | ||||||
|  |     throw e; // 目的截断之后的校验 | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | defineExpose({ validate }); | ||||||
|  | 
 | ||||||
|  | const [Form, formApi] = useVbenForm({ | ||||||
|  |   commonConfig: { | ||||||
|  |     componentProps: { | ||||||
|  |       class: '!w-1/6', | ||||||
|  |     }, | ||||||
|  |     formItemClass: 'col-span-2', | ||||||
|  |     labelWidth: 120, | ||||||
|  |   }, | ||||||
|  |   layout: 'horizontal', | ||||||
|  |   schema: [ | ||||||
|  |     { | ||||||
|  |       fieldName: 'name', | ||||||
|  |       label: '商品名称', | ||||||
|  |       component: 'Input', | ||||||
|  |       componentProps: { | ||||||
|  |         placeholder: '请输入商品名称', | ||||||
|  |       }, | ||||||
|  |       rules: 'required', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'categoryId', | ||||||
|  |       label: '商品分类', | ||||||
|  |       component: 'ApiCascader', | ||||||
|  |       componentProps: { | ||||||
|  |         api: getCategoryList, | ||||||
|  |         props: { | ||||||
|  |           label: 'name', | ||||||
|  |           value: 'id', | ||||||
|  |           children: 'children', | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |       rules: 'required', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'brandId', | ||||||
|  |       label: '商品品牌', | ||||||
|  |       component: 'ApiSelect', | ||||||
|  |       componentProps: { | ||||||
|  |         api: ProductBrandApi.getSimpleBrandList, | ||||||
|  |         labelField: 'name', | ||||||
|  |         valueField: 'id', | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'keyword', | ||||||
|  |       label: '商品关键字', | ||||||
|  |       component: 'Input', | ||||||
|  |       componentProps: { | ||||||
|  |         placeholder: '请输入商品关键字', | ||||||
|  |       }, | ||||||
|  |       rules: 'required', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'introduction', | ||||||
|  |       label: '商品简介', | ||||||
|  |       component: 'Input', | ||||||
|  |       componentProps: { | ||||||
|  |         type: 'textarea', | ||||||
|  |         placeholder: '请输入商品简介', | ||||||
|  |         maxlength: 128, | ||||||
|  |         showWordLimit: true, | ||||||
|  |         autosize: { | ||||||
|  |           minRows: 4, | ||||||
|  |           maxRows: 4, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |       rules: 'required', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'picUrl', | ||||||
|  |       label: '商品封面图', | ||||||
|  |       component: 'ImageUpload', | ||||||
|  |       componentProps: { | ||||||
|  |         max: 1, | ||||||
|  |         class: 'w-full', | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'sliderPicUrls', | ||||||
|  |       label: '商品轮播图', | ||||||
|  |       component: 'ImageUpload', | ||||||
|  |       componentProps: { | ||||||
|  |         max: 10, | ||||||
|  |         class: 'w-full', | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  |   showDefaultActions: false, | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  | <template> | ||||||
|  |   <Form /> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,62 @@ | ||||||
|  | import SkuList from './SkuList.vue'; | ||||||
|  | import { Spu } from '@/api/mall/product/spu'; | ||||||
|  | 
 | ||||||
|  | interface PropertyAndValues { | ||||||
|  |   id: number; | ||||||
|  |   name: string; | ||||||
|  |   values?: PropertyAndValues[]; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface RuleConfig { | ||||||
|  |   // 需要校验的字段
 | ||||||
|  |   // 例:name: 'name' 则表示校验 sku.name 的值
 | ||||||
|  |   // 例:name: 'productConfig.stock' 则表示校验 sku.productConfig.name 的值,此处 productConfig 表示我在 Sku 上扩展的属性
 | ||||||
|  |   name: string; | ||||||
|  |   // 校验规格为一个毁掉函数,其中 arg 为需要校验的字段的值。
 | ||||||
|  |   // 例:需要校验价格必须大于0.01
 | ||||||
|  |   // {
 | ||||||
|  |   //  name:'price',
 | ||||||
|  |   //  rule:(arg: number) => arg > 0.01
 | ||||||
|  |   // }
 | ||||||
|  |   rule: (arg: any) => boolean; | ||||||
|  |   // 校验不通过时的消息提示
 | ||||||
|  |   message: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 获得商品的规格列表 - 商品相关的公共函数 | ||||||
|  |  * | ||||||
|  |  * @param spu | ||||||
|  |  * @return PropertyAndValues 规格列表 | ||||||
|  |  */ | ||||||
|  | const getPropertyList = (spu: Spu): PropertyAndValues[] => { | ||||||
|  |   //  直接拿返回的 skus 属性逆向生成出 propertyList
 | ||||||
|  |   const properties: PropertyAndValues[] = []; | ||||||
|  |   // 只有是多规格才处理
 | ||||||
|  |   if (spu.specType) { | ||||||
|  |     spu.skus?.forEach((sku) => { | ||||||
|  |       sku.properties?.forEach( | ||||||
|  |         ({ propertyId, propertyName, valueId, valueName }) => { | ||||||
|  |           // 添加属性
 | ||||||
|  |           if (!properties?.some((item) => item.id === propertyId)) { | ||||||
|  |             properties.push({ | ||||||
|  |               id: propertyId!, | ||||||
|  |               name: propertyName!, | ||||||
|  |               values: [], | ||||||
|  |             }); | ||||||
|  |           } | ||||||
|  |           // 添加属性值
 | ||||||
|  |           const index = properties?.findIndex((item) => item.id === propertyId); | ||||||
|  |           if ( | ||||||
|  |             !properties[index].values?.some((value) => value.id === valueId) | ||||||
|  |           ) { | ||||||
|  |             properties[index].values?.push({ id: valueId!, name: valueName! }); | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |   return properties; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export { SkuList, PropertyAndValues, RuleConfig, getPropertyList }; | ||||||
|  | @ -0,0 +1,86 @@ | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { useVbenForm } from '#/adapter/form'; | ||||||
|  | import * as ExpressTemplateApi from '#/api/mall/trade/delivery/expressTemplate'; | ||||||
|  | import { watch } from 'vue'; | ||||||
|  | import { ElMessage } from 'element-plus'; | ||||||
|  | import { DICT_TYPE, getIntDictOptions, DeliveryTypeEnum } from '#/utils'; | ||||||
|  | 
 | ||||||
|  | const props = defineProps<{ | ||||||
|  |   propFormData: Object; | ||||||
|  | }>(); | ||||||
|  | 
 | ||||||
|  | /** 将传进来的值赋值给 formData */ | ||||||
|  | watch( | ||||||
|  |   () => props.propFormData, | ||||||
|  |   (data) => { | ||||||
|  |     if (!data) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     formApi.setValues(data); | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | const emit = defineEmits(['update:activeName']); | ||||||
|  | const validate = async () => { | ||||||
|  |   const { valid } = await formApi.validate(); | ||||||
|  |   if (!valid) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   try { | ||||||
|  |     // 校验通过更新数据 | ||||||
|  |     Object.assign(props.propFormData, formApi.getValues()); | ||||||
|  |   } catch (e) { | ||||||
|  |     ElMessage.error('【其它设置】不完善,请填写相关信息'); | ||||||
|  |     emit('update:activeName', 'other'); | ||||||
|  |     throw e; // 目的截断之后的校验 | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | defineExpose({ validate }); | ||||||
|  | 
 | ||||||
|  | const [Form, formApi] = useVbenForm({ | ||||||
|  |   commonConfig: { | ||||||
|  |     componentProps: { | ||||||
|  |       class: '!w-1/6', | ||||||
|  |     }, | ||||||
|  |     formItemClass: 'col-span-2', | ||||||
|  |     labelWidth: 120, | ||||||
|  |   }, | ||||||
|  |   layout: 'horizontal', | ||||||
|  |   schema: [ | ||||||
|  |     { | ||||||
|  |       fieldName: 'sort', | ||||||
|  |       label: '商品排序', | ||||||
|  |       component: 'InputNumber', | ||||||
|  |       componentProps: { | ||||||
|  |         min: 0, | ||||||
|  |         step: 1, | ||||||
|  |       }, | ||||||
|  |       rules: 'required', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'giveIntegral', | ||||||
|  |       label: '赠送积分', | ||||||
|  |       component: 'InputNumber', | ||||||
|  |       componentProps: { | ||||||
|  |         min: 0, | ||||||
|  |         step: 1, | ||||||
|  |       }, | ||||||
|  |       rules: 'required', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'virtualSalesCount', | ||||||
|  |       label: '虚拟销量', | ||||||
|  |       component: 'InputNumber', | ||||||
|  |       componentProps: { | ||||||
|  |         min: 0, | ||||||
|  |         step: 1, | ||||||
|  |       }, | ||||||
|  |       rules: 'required', | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  |   showDefaultActions: false, | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  | <template> | ||||||
|  |   <Form /> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,195 @@ | ||||||
|  | <!-- 商品发布 - 库存价格 - 属性列表 --> | ||||||
|  | <template> | ||||||
|  |   <el-col v-for="(item, index) in attributeList" :key="index"> | ||||||
|  |     <div> | ||||||
|  |       <el-text class="mx-1">属性名:</el-text> | ||||||
|  |       <el-tag class="mx-1" type="success" @close="handleCloseProperty(index)"> | ||||||
|  |         {{ item.name }} | ||||||
|  |       </el-tag> | ||||||
|  |     </div> | ||||||
|  |     <div> | ||||||
|  |       <el-text class="mx-1">属性值:</el-text> | ||||||
|  |       <el-tag | ||||||
|  |         v-for="(value, valueIndex) in item.values" | ||||||
|  |         :key="value.id" | ||||||
|  |         class="mx-1" | ||||||
|  |         @close="handleCloseValue(index, valueIndex)" | ||||||
|  |       > | ||||||
|  |         {{ value.name }} | ||||||
|  |       </el-tag> | ||||||
|  |       <el-select | ||||||
|  |         v-show="inputVisible(index)" | ||||||
|  |         :id="`input${index}`" | ||||||
|  |         :ref="setInputRef" | ||||||
|  |         v-model="inputValue" | ||||||
|  |         :reserve-keyword="false" | ||||||
|  |         allow-create | ||||||
|  |         class="!w-30" | ||||||
|  |         default-first-option | ||||||
|  |         filterable | ||||||
|  |         size="small" | ||||||
|  |         @blur="handleInputConfirm(index, item.id)" | ||||||
|  |         @change="handleInputConfirm(index, item.id)" | ||||||
|  |         @keyup.enter="handleInputConfirm(index, item.id)" | ||||||
|  |       > | ||||||
|  |         <el-option | ||||||
|  |           v-for="item2 in attributeOptions" | ||||||
|  |           :key="item2.id" | ||||||
|  |           :label="item2.name" | ||||||
|  |           :value="item2.name" | ||||||
|  |         /> | ||||||
|  |       </el-select> | ||||||
|  |       <el-button | ||||||
|  |         v-show="!inputVisible(index)" | ||||||
|  |         class="button-new-tag ml-1" | ||||||
|  |         size="small" | ||||||
|  |         @click="showInput(index)" | ||||||
|  |       > | ||||||
|  |         + 添加 | ||||||
|  |       </el-button> | ||||||
|  |     </div> | ||||||
|  |     <el-divider class="my-10px" /> | ||||||
|  |   </el-col> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { ref, watch, computed } from 'vue'; | ||||||
|  | import type { PropType } from 'vue'; | ||||||
|  | 
 | ||||||
|  | import * as PropertyApi from '#/api/mall/product/property'; | ||||||
|  | import type { MallPropertyApi } from '#/api/mall/product/property'; | ||||||
|  | import { ElMessage } from 'element-plus'; | ||||||
|  | import { $t } from '#/locales'; | ||||||
|  | 
 | ||||||
|  | // 定义PropertyAndValues接口 | ||||||
|  | interface PropertyAndValues { | ||||||
|  |   id: number; | ||||||
|  |   name: string; | ||||||
|  |   values: Array<{ | ||||||
|  |     id: number; | ||||||
|  |     name: string; | ||||||
|  |   }>; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | defineOptions({ name: 'ProductAttributes' }); | ||||||
|  | 
 | ||||||
|  | const inputValue = ref(''); // 输入框值 | ||||||
|  | const attributeIndex = ref<number | null>(null); // 获取焦点时记录当前属性项的index | ||||||
|  | // 输入框显隐控制 | ||||||
|  | const inputVisible = computed(() => (index: number) => { | ||||||
|  |   if (attributeIndex.value === null) return false; | ||||||
|  |   if (attributeIndex.value === index) return true; | ||||||
|  | }); | ||||||
|  | const inputRef = ref<any[]>([]); //标签输入框Ref | ||||||
|  | /** 解决 ref 在 v-for 中的获取问题*/ | ||||||
|  | const setInputRef = (el: any) => { | ||||||
|  |   if (el === null || typeof el === 'undefined') return; | ||||||
|  |   // 如果不存在 id 相同的元素才添加 | ||||||
|  |   if ( | ||||||
|  |     !inputRef.value.some( | ||||||
|  |       (item) => item.inputRef?.attributes.id === el.inputRef?.attributes.id, | ||||||
|  |     ) | ||||||
|  |   ) { | ||||||
|  |     inputRef.value.push(el); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | const attributeList = ref<PropertyAndValues[]>([]); // 商品属性列表 | ||||||
|  | const attributeOptions = ref([] as MallPropertyApi.PropertyValue[]); // 商品属性名称下拉框 | ||||||
|  | const props = defineProps({ | ||||||
|  |   propertyList: { | ||||||
|  |     type: Array as PropType<PropertyAndValues[]>, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | watch( | ||||||
|  |   () => props.propertyList, | ||||||
|  |   (data) => { | ||||||
|  |     if (!data) return; | ||||||
|  |     attributeList.value = data as any; | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     deep: true, | ||||||
|  |     immediate: true, | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | /** 删除属性值*/ | ||||||
|  | const handleCloseValue = (index: number, valueIndex: number) => { | ||||||
|  |   if (index < attributeList.value.length) { | ||||||
|  |     attributeList.value[index]!.values.splice(valueIndex, 1); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** 删除属性*/ | ||||||
|  | const handleCloseProperty = (index: number) => { | ||||||
|  |   if (index < attributeList.value.length) { | ||||||
|  |     attributeList.value.splice(index, 1); | ||||||
|  |     emit('success', attributeList.value); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** 显示输入框并获取焦点 */ | ||||||
|  | const showInput = async (index: number) => { | ||||||
|  |   if (index < attributeList.value.length) { | ||||||
|  |     attributeIndex.value = index; | ||||||
|  |     inputRef.value[index].focus(); | ||||||
|  |     // 获取属性下拉选项 | ||||||
|  |     await getAttributeOptions(attributeList.value[index]!.id); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** 输入框失去焦点或点击回车时触发 */ | ||||||
|  | const emit = defineEmits(['success']); // 定义 success 事件,用于操作成功后的回调 | ||||||
|  | const handleInputConfirm = async (index: number, propertyId: number) => { | ||||||
|  |   if (inputValue.value && index < attributeList.value.length) { | ||||||
|  |     // 1. 重复添加校验 | ||||||
|  |     if ( | ||||||
|  |       attributeList.value[index]!.values.find( | ||||||
|  |         (item) => item.name === inputValue.value, | ||||||
|  |       ) | ||||||
|  |     ) { | ||||||
|  |       ElMessage.warning('已存在相同属性值,请重试'); | ||||||
|  |       attributeIndex.value = null; | ||||||
|  |       inputValue.value = ''; | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // 2.1 情况一:属性值已存在,则直接使用并结束 | ||||||
|  |     const existValue = attributeOptions.value.find( | ||||||
|  |       (item) => item.name === inputValue.value, | ||||||
|  |     ); | ||||||
|  |     if (existValue) { | ||||||
|  |       attributeIndex.value = null; | ||||||
|  |       inputValue.value = ''; | ||||||
|  |       attributeList.value[index]!.values.push({ | ||||||
|  |         id: existValue.id!, | ||||||
|  |         name: existValue.name, | ||||||
|  |       }); | ||||||
|  |       emit('success', attributeList.value); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // 2.2 情况二:新属性值,则进行保存 | ||||||
|  |     try { | ||||||
|  |       const id = await PropertyApi.createPropertyValue({ | ||||||
|  |         propertyId, | ||||||
|  |         name: inputValue.value, | ||||||
|  |       }); | ||||||
|  |       attributeList.value[index]!.values.push({ id, name: inputValue.value }); | ||||||
|  |       ElMessage.success($t('common.createSuccess')); | ||||||
|  |       emit('success', attributeList.value); | ||||||
|  |     } catch { | ||||||
|  |       ElMessage.error('添加失败,请重试'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   attributeIndex.value = null; | ||||||
|  |   inputValue.value = ''; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** 获取商品属性下拉选项 */ | ||||||
|  | const getAttributeOptions = async (propertyId: number) => { | ||||||
|  |   attributeOptions.value = | ||||||
|  |     await PropertyApi.getPropertyValueSimpleList(propertyId); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  | @ -0,0 +1,130 @@ | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { ref, watch } from 'vue'; | ||||||
|  | import type { PropType } from 'vue'; | ||||||
|  | 
 | ||||||
|  | import { useVbenModal } from '@vben/common-ui'; | ||||||
|  | 
 | ||||||
|  | import { ElMessage } from 'element-plus'; | ||||||
|  | 
 | ||||||
|  | import { useVbenForm } from '#/adapter/form'; | ||||||
|  | import { $t } from '#/locales'; | ||||||
|  | import { getPropertySimpleList } from '#/api/mall/product/property'; | ||||||
|  | import * as PropertyApi from '#/api/mall/product/property'; | ||||||
|  | import type { MallPropertyApi } from '#/api/mall/product/property'; | ||||||
|  | 
 | ||||||
|  | // 扩展Property接口,添加values属性 | ||||||
|  | interface ExtendedProperty extends MallPropertyApi.Property { | ||||||
|  |   values?: any[]; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const emit = defineEmits(['success']); | ||||||
|  | 
 | ||||||
|  | const attributeList = ref<ExtendedProperty[]>([]); // 商品属性列表 | ||||||
|  | const attributeOptions = ref([] as MallPropertyApi.Property[]); // 商品属性名称下拉框 | ||||||
|  | 
 | ||||||
|  | const props = defineProps({ | ||||||
|  |   propertyList: { | ||||||
|  |     type: Array as PropType<ExtendedProperty[]>, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const [Form, formApi] = useVbenForm({ | ||||||
|  |   commonConfig: { | ||||||
|  |     componentProps: { | ||||||
|  |       class: 'w-full', | ||||||
|  |     }, | ||||||
|  |     formItemClass: 'col-span-2', | ||||||
|  |     labelWidth: 120, | ||||||
|  |   }, | ||||||
|  |   layout: 'horizontal', | ||||||
|  |   schema: [ | ||||||
|  |     { | ||||||
|  |       fieldName: 'name', | ||||||
|  |       label: '属性名称', | ||||||
|  |       component: 'ApiSelect', | ||||||
|  |       componentProps: { | ||||||
|  |         api: getPropertySimpleList, | ||||||
|  |         labelField: 'name', | ||||||
|  |         valueField: 'id', | ||||||
|  |         defaultFirstOption: true, | ||||||
|  |         filterable: true, | ||||||
|  |         allowCreate: true, | ||||||
|  |         placeholder: '请选择属性名称。如果不存在,可手动输入选择', | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  |   showDefaultActions: false, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const [Modal, modalApi] = useVbenModal({ | ||||||
|  |   async onConfirm() { | ||||||
|  |     modalApi.lock(); | ||||||
|  |     const { name } = await formApi.getValues(); | ||||||
|  |     // 1.1 重复添加校验 | ||||||
|  |     for (const attrItem of attributeList.value) { | ||||||
|  |       if (attrItem.name === name) { | ||||||
|  |         return ElMessage.error('该属性已存在,请勿重复添加'); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     // 1.2 校验表单 | ||||||
|  |     const { valid } = await formApi.validate(); | ||||||
|  |     if (!valid) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // 2.1 情况一:属性名已存在,则直接使用并结束 | ||||||
|  |     const existProperty = attributeOptions.value.find( | ||||||
|  |       (item) => item.name === name, | ||||||
|  |     ); | ||||||
|  |     if (existProperty) { | ||||||
|  |       // 添加到属性列表 | ||||||
|  |       attributeList.value.push({ | ||||||
|  |         id: existProperty.id, | ||||||
|  |         ...(await formApi.getValues()), | ||||||
|  |         values: [], | ||||||
|  |       }); | ||||||
|  |       // 关闭弹窗 | ||||||
|  |       modalApi.close(); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // 2.2 情况二:如果是不存在的属性,则需要执行新增 | ||||||
|  |     // 提交请求 | ||||||
|  |     modalApi.lock(); | ||||||
|  |     try { | ||||||
|  |       const data = (await formApi.getValues()) as MallPropertyApi.Property; | ||||||
|  |       const propertyId = await PropertyApi.createProperty(data); | ||||||
|  |       // 添加到属性列表 | ||||||
|  |       attributeList.value.push({ | ||||||
|  |         id: propertyId, | ||||||
|  |         ...(await formApi.getValues()), | ||||||
|  |         values: [], | ||||||
|  |       }); | ||||||
|  |       // 关闭弹窗 | ||||||
|  |       ElMessage.success($t('common.createSuccess')); | ||||||
|  |       modalApi.close(); | ||||||
|  |     } finally { | ||||||
|  |       modalApi.unlock(); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | watch( | ||||||
|  |   () => props.propertyList, // 解决 props 无法直接修改父组件的问题 | ||||||
|  |   (data) => { | ||||||
|  |     if (!data) return; | ||||||
|  |     attributeList.value = data; | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     deep: true, | ||||||
|  |     immediate: true, | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <Modal class="w-2/5" title="添加商品属性"> | ||||||
|  |     <Form class="mx-4" /> | ||||||
|  |   </Modal> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,192 @@ | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { useVbenForm } from '#/adapter/form'; | ||||||
|  | import { watch, ref } from 'vue'; | ||||||
|  | import { ElMessage } from 'element-plus'; | ||||||
|  | import SkuList from './sku-list.vue'; | ||||||
|  | import { Page, useVbenModal } from '@vben/common-ui'; | ||||||
|  | import ProductPropertyAddForm from './product-property-add-form.vue'; | ||||||
|  | 
 | ||||||
|  | const props = defineProps<{ | ||||||
|  |   propFormData: Object; | ||||||
|  | }>(); | ||||||
|  | 
 | ||||||
|  | interface PropertyAndValues { | ||||||
|  |   id: number; | ||||||
|  |   name: string; | ||||||
|  |   values?: PropertyAndValues[]; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface RuleConfig { | ||||||
|  |   // 需要校验的字段 | ||||||
|  |   // 例:name: 'name' 则表示校验 sku.name 的值 | ||||||
|  |   // 例:name: 'productConfig.stock' 则表示校验 sku.productConfig.name 的值,此处 productConfig 表示我在 Sku 上扩展的属性 | ||||||
|  |   name: string; | ||||||
|  |   // 校验规格为一个毁掉函数,其中 arg 为需要校验的字段的值。 | ||||||
|  |   // 例:需要校验价格必须大于0.01 | ||||||
|  |   // { | ||||||
|  |   //  name:'price', | ||||||
|  |   //  rule:(arg: number) => arg > 0.01 | ||||||
|  |   // } | ||||||
|  |   rule: (arg: any) => boolean; | ||||||
|  |   // 校验不通过时的消息提示 | ||||||
|  |   message: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const propertyList = ref<PropertyAndValues[]>([]); // 商品属性列表 | ||||||
|  | 
 | ||||||
|  | // sku 相关属性校验规则 | ||||||
|  | const ruleConfig: RuleConfig[] = [ | ||||||
|  |   { | ||||||
|  |     name: 'stock', | ||||||
|  |     rule: (arg) => arg >= 0, | ||||||
|  |     message: '商品库存必须大于等于 1 !!!', | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: 'price', | ||||||
|  |     rule: (arg) => arg >= 0.01, | ||||||
|  |     message: '商品销售价格必须大于等于 0.01 元!!!', | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: 'marketPrice', | ||||||
|  |     rule: (arg) => arg >= 0.01, | ||||||
|  |     message: '商品市场价格必须大于等于 0.01 元!!!', | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     name: 'costPrice', | ||||||
|  |     rule: (arg) => arg >= 0.01, | ||||||
|  |     message: '商品成本价格必须大于等于 0.00 元!!!', | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
|  | 
 | ||||||
|  | /** 将传进来的值赋值给 formData */ | ||||||
|  | watch( | ||||||
|  |   () => props.propFormData, | ||||||
|  |   (data) => { | ||||||
|  |     if (!data) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     formApi.setValues(data); | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | const emit = defineEmits(['update:activeName']); | ||||||
|  | const validate = async () => { | ||||||
|  |   const { valid } = await formApi.validate(); | ||||||
|  |   if (!valid) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   try { | ||||||
|  |     // 校验通过更新数据 | ||||||
|  |     Object.assign(props.propFormData, formApi.getValues()); | ||||||
|  |   } catch (e) { | ||||||
|  |     ElMessage.error('【库存价格】不完善,请填写相关信息'); | ||||||
|  |     emit('update:activeName', 'sku'); | ||||||
|  |     throw e; // 目的截断之后的校验 | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | defineExpose({ validate }); | ||||||
|  | 
 | ||||||
|  | const [Form, formApi] = useVbenForm({ | ||||||
|  |   commonConfig: { | ||||||
|  |     componentProps: { | ||||||
|  |       class: '!w-1/6', | ||||||
|  |     }, | ||||||
|  |     formItemClass: 'col-span-2', | ||||||
|  |     labelWidth: 120, | ||||||
|  |   }, | ||||||
|  |   layout: 'horizontal', | ||||||
|  |   schema: [ | ||||||
|  |     { | ||||||
|  |       fieldName: 'subCommissionType', | ||||||
|  |       label: '分销类型', | ||||||
|  |       component: 'RadioGroup', | ||||||
|  |       componentProps: { | ||||||
|  |         options: [ | ||||||
|  |           { | ||||||
|  |             label: '默认设置', | ||||||
|  |             value: false, | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             label: '单独设置', | ||||||
|  |             value: true, | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |       defaultValue: false, | ||||||
|  |       rules: 'required', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'specType', | ||||||
|  |       label: '商品规格', | ||||||
|  |       component: 'RadioGroup', | ||||||
|  |       componentProps: { | ||||||
|  |         options: [ | ||||||
|  |           { | ||||||
|  |             label: '单规格', | ||||||
|  |             value: false, | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             label: '多规格', | ||||||
|  |             value: true, | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |       defaultValue: false, | ||||||
|  |       rules: 'required', | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'skuList', | ||||||
|  |       component: 'Input', | ||||||
|  |       dependencies: { | ||||||
|  |         triggerFields: ['specType'], | ||||||
|  |         show: (values) => !values.specType, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       fieldName: 'specTypeItem', | ||||||
|  |       label: '商品属性', | ||||||
|  |       component: 'Input', | ||||||
|  |       dependencies: { | ||||||
|  |         triggerFields: ['specType'], | ||||||
|  |         show: (values) => values.specType, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  |   showDefaultActions: false, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const [ProductPropertyAddFormModal, productPropertyAddFormApi] = useVbenModal({ | ||||||
|  |   connectedComponent: ProductPropertyAddForm, | ||||||
|  |   destroyOnClose: true, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | /** 调用 SkuList generateTableData 方法*/ | ||||||
|  | const skuListRef = ref(); | ||||||
|  | const generateSkus = (propertyList: any[]) => { | ||||||
|  |   skuListRef.value.generateTableData(propertyList); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  | <template> | ||||||
|  |   <Page :auto-content-height="true"> | ||||||
|  |     <Form> | ||||||
|  |       <template #skuList> | ||||||
|  |         <SkuList | ||||||
|  |           ref="skuListRef" | ||||||
|  |           :prop-form-data="props.propFormData" | ||||||
|  |           :property-list="propertyList" | ||||||
|  |           :rule-config="ruleConfig" | ||||||
|  |         /> | ||||||
|  |       </template> | ||||||
|  |       <template #specTypeItem> | ||||||
|  |         <ElButton type="primary" @click="productPropertyAddFormApi.open()" | ||||||
|  |           >添加属性</ElButton | ||||||
|  |         > | ||||||
|  |         <ProductAttributes | ||||||
|  |           :property-list="propertyList" | ||||||
|  |           @success="generateSkus" | ||||||
|  |         /> | ||||||
|  |       </template> | ||||||
|  |     </Form> | ||||||
|  |     <ProductPropertyAddFormModal :propertyList="propertyList" /> | ||||||
|  |   </Page> | ||||||
|  | </template> | ||||||
|  | @ -0,0 +1,613 @@ | ||||||
|  | <template> | ||||||
|  |   <!-- 情况一:添加/修改 --> | ||||||
|  |   <el-table | ||||||
|  |     v-if="!isDetail && !isActivityComponent" | ||||||
|  |     :data="isBatch ? skuList : formData!.skus!" | ||||||
|  |     border | ||||||
|  |     class="tabNumWidth" | ||||||
|  |     max-height="500" | ||||||
|  |     size="small" | ||||||
|  |   > | ||||||
|  |     <el-table-column align="center" label="图片" min-width="120"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         <UploadImg | ||||||
|  |           v-model="row.picUrl" | ||||||
|  |           height="50px" | ||||||
|  |           width="50px" | ||||||
|  |           :show-description="false" | ||||||
|  |         /> | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <template v-if="formData!.specType && !isBatch"> | ||||||
|  |       <!--  根据商品属性动态添加 --> | ||||||
|  |       <el-table-column | ||||||
|  |         v-for="(item, index) in tableHeaders" | ||||||
|  |         :key="index" | ||||||
|  |         :label="item.label" | ||||||
|  |         align="center" | ||||||
|  |         min-width="120" | ||||||
|  |       > | ||||||
|  |         <template #default="{ row }"> | ||||||
|  |           <span style="font-weight: bold; color: #40aaff"> | ||||||
|  |             {{ row.properties?.[index]?.valueName }} | ||||||
|  |           </span> | ||||||
|  |         </template> | ||||||
|  |       </el-table-column> | ||||||
|  |     </template> | ||||||
|  |     <el-table-column align="center" label="商品条码" min-width="168"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         <el-input v-model="row.barCode" class="w-100%" /> | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="销售价" min-width="168"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         <el-input-number | ||||||
|  |           v-model="row.price" | ||||||
|  |           :min="0" | ||||||
|  |           :precision="2" | ||||||
|  |           :step="0.1" | ||||||
|  |           class="w-100%" | ||||||
|  |           controls-position="right" | ||||||
|  |         /> | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="市场价" min-width="168"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         <el-input-number | ||||||
|  |           v-model="row.marketPrice" | ||||||
|  |           :min="0" | ||||||
|  |           :precision="2" | ||||||
|  |           :step="0.1" | ||||||
|  |           class="w-100%" | ||||||
|  |           controls-position="right" | ||||||
|  |         /> | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="成本价" min-width="168"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         <el-input-number | ||||||
|  |           v-model="row.costPrice" | ||||||
|  |           :min="0" | ||||||
|  |           :precision="2" | ||||||
|  |           :step="0.1" | ||||||
|  |           class="w-100%" | ||||||
|  |           controls-position="right" | ||||||
|  |         /> | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="库存" min-width="168"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         <el-input-number | ||||||
|  |           v-model="row.stock" | ||||||
|  |           :min="0" | ||||||
|  |           class="w-100%" | ||||||
|  |           controls-position="right" | ||||||
|  |         /> | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="重量(kg)" min-width="168"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         <el-input-number | ||||||
|  |           v-model="row.weight" | ||||||
|  |           :min="0" | ||||||
|  |           :precision="2" | ||||||
|  |           :step="0.1" | ||||||
|  |           class="w-100%" | ||||||
|  |           controls-position="right" | ||||||
|  |         /> | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="体积(m^3)" min-width="168"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         <el-input-number | ||||||
|  |           v-model="row.volume" | ||||||
|  |           :min="0" | ||||||
|  |           :precision="2" | ||||||
|  |           :step="0.1" | ||||||
|  |           class="w-100%" | ||||||
|  |           controls-position="right" | ||||||
|  |         /> | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <template v-if="formData!.subCommissionType"> | ||||||
|  |       <el-table-column align="center" label="一级返佣(元)" min-width="168"> | ||||||
|  |         <template #default="{ row }"> | ||||||
|  |           <el-input-number | ||||||
|  |             v-model="row.firstBrokeragePrice" | ||||||
|  |             :min="0" | ||||||
|  |             :precision="2" | ||||||
|  |             :step="0.1" | ||||||
|  |             class="w-100%" | ||||||
|  |             controls-position="right" | ||||||
|  |           /> | ||||||
|  |         </template> | ||||||
|  |       </el-table-column> | ||||||
|  |       <el-table-column align="center" label="二级返佣(元)" min-width="168"> | ||||||
|  |         <template #default="{ row }"> | ||||||
|  |           <el-input-number | ||||||
|  |             v-model="row.secondBrokeragePrice" | ||||||
|  |             :min="0" | ||||||
|  |             :precision="2" | ||||||
|  |             :step="0.1" | ||||||
|  |             class="w-100%" | ||||||
|  |             controls-position="right" | ||||||
|  |           /> | ||||||
|  |         </template> | ||||||
|  |       </el-table-column> | ||||||
|  |     </template> | ||||||
|  |     <el-table-column | ||||||
|  |       v-if="formData?.specType" | ||||||
|  |       align="center" | ||||||
|  |       fixed="right" | ||||||
|  |       label="操作" | ||||||
|  |       width="80" | ||||||
|  |     > | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         <el-button | ||||||
|  |           v-if="isBatch" | ||||||
|  |           link | ||||||
|  |           size="small" | ||||||
|  |           type="primary" | ||||||
|  |           @click="batchAdd" | ||||||
|  |         > | ||||||
|  |           批量添加 | ||||||
|  |         </el-button> | ||||||
|  |         <el-button | ||||||
|  |           v-else | ||||||
|  |           link | ||||||
|  |           size="small" | ||||||
|  |           type="primary" | ||||||
|  |           @click="deleteSku(row)" | ||||||
|  |           >删除</el-button | ||||||
|  |         > | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |   </el-table> | ||||||
|  | 
 | ||||||
|  |   <!-- 情况二:详情 --> | ||||||
|  |   <el-table | ||||||
|  |     v-if="isDetail" | ||||||
|  |     ref="activitySkuListRef" | ||||||
|  |     :data="formData!.skus!" | ||||||
|  |     border | ||||||
|  |     max-height="500" | ||||||
|  |     size="small" | ||||||
|  |     style="width: 99%" | ||||||
|  |     @selection-change="handleSelectionChange" | ||||||
|  |   > | ||||||
|  |     <el-table-column v-if="isComponent" type="selection" width="45" /> | ||||||
|  |     <el-table-column align="center" label="图片" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         <el-image v-if="row.picUrl" :src="row.picUrl" class="h-50px w-50px" /> | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <template v-if="formData!.specType && !isBatch"> | ||||||
|  |       <!--  根据商品属性动态添加 --> | ||||||
|  |       <el-table-column | ||||||
|  |         v-for="(item, index) in tableHeaders" | ||||||
|  |         :key="index" | ||||||
|  |         :label="item.label" | ||||||
|  |         align="center" | ||||||
|  |         min-width="80" | ||||||
|  |       > | ||||||
|  |         <template #default="{ row }"> | ||||||
|  |           <span style="font-weight: bold; color: #40aaff"> | ||||||
|  |             {{ row.properties?.[index]?.valueName }} | ||||||
|  |           </span> | ||||||
|  |         </template> | ||||||
|  |       </el-table-column> | ||||||
|  |     </template> | ||||||
|  |     <el-table-column align="center" label="商品条码" min-width="100"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ row.barCode }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="销售价(元)" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ row.price }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="市场价(元)" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ row.marketPrice }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="成本价(元)" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ row.costPrice }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="库存" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ row.stock }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="重量(kg)" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ row.weight }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="体积(m^3)" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ row.volume }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <template v-if="formData!.subCommissionType"> | ||||||
|  |       <el-table-column align="center" label="一级返佣(元)" min-width="80"> | ||||||
|  |         <template #default="{ row }"> | ||||||
|  |           {{ row.firstBrokeragePrice }} | ||||||
|  |         </template> | ||||||
|  |       </el-table-column> | ||||||
|  |       <el-table-column align="center" label="二级返佣(元)" min-width="80"> | ||||||
|  |         <template #default="{ row }"> | ||||||
|  |           {{ row.secondBrokeragePrice }} | ||||||
|  |         </template> | ||||||
|  |       </el-table-column> | ||||||
|  |     </template> | ||||||
|  |   </el-table> | ||||||
|  | 
 | ||||||
|  |   <!-- 情况三:作为活动组件 --> | ||||||
|  |   <el-table | ||||||
|  |     v-if="isActivityComponent" | ||||||
|  |     :data="formData!.skus!" | ||||||
|  |     border | ||||||
|  |     max-height="500" | ||||||
|  |     size="small" | ||||||
|  |     style="width: 99%" | ||||||
|  |   > | ||||||
|  |     <el-table-column v-if="isComponent" type="selection" width="45" /> | ||||||
|  |     <el-table-column align="center" label="图片" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         <el-image :src="row.picUrl" class="h-60px w-60px" /> | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <template v-if="formData!.specType"> | ||||||
|  |       <!--  根据商品属性动态添加 --> | ||||||
|  |       <el-table-column | ||||||
|  |         v-for="(item, index) in tableHeaders" | ||||||
|  |         :key="index" | ||||||
|  |         :label="item.label" | ||||||
|  |         align="center" | ||||||
|  |         min-width="80" | ||||||
|  |       > | ||||||
|  |         <template #default="{ row }"> | ||||||
|  |           <span style="font-weight: bold; color: #40aaff"> | ||||||
|  |             {{ row.properties?.[index]?.valueName }} | ||||||
|  |           </span> | ||||||
|  |         </template> | ||||||
|  |       </el-table-column> | ||||||
|  |     </template> | ||||||
|  |     <el-table-column align="center" label="商品条码" min-width="100"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ row.barCode }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="销售价(元)" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ formatToFraction(row.price) }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="市场价(元)" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ formatToFraction(row.marketPrice) }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="成本价(元)" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ formatToFraction(row.costPrice) }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <el-table-column align="center" label="库存" min-width="80"> | ||||||
|  |       <template #default="{ row }"> | ||||||
|  |         {{ row.stock }} | ||||||
|  |       </template> | ||||||
|  |     </el-table-column> | ||||||
|  |     <!--  方便扩展每个活动配置的属性不一样  --> | ||||||
|  |     <slot name="extension"></slot> | ||||||
|  |   </el-table> | ||||||
|  | </template> | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { copyValueToTarget, formatToFraction } from '#/utils'; | ||||||
|  | import type { PropertyAndValues, RuleConfig } from './model'; | ||||||
|  | import UploadImg from '#/components/upload/image-upload.vue'; | ||||||
|  | import { ElTable, ElInput, ElMessage } from 'element-plus'; | ||||||
|  | import { isEmpty } from '#/utils/is'; | ||||||
|  | import type { MallSpuApi } from '#/api/mall/product/spu'; | ||||||
|  | import { ref, watch } from 'vue'; | ||||||
|  | import type { PropType } from 'vue'; | ||||||
|  | 
 | ||||||
|  | defineOptions({ name: 'SkuList' }); | ||||||
|  | 
 | ||||||
|  | const props = defineProps({ | ||||||
|  |   propFormData: { | ||||||
|  |     type: Object as PropType<MallSpuApi.Spu>, | ||||||
|  |     default: () => ({}), | ||||||
|  |   }, | ||||||
|  |   propertyList: { | ||||||
|  |     type: Array as PropType<PropertyAndValues[]>, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  |   ruleConfig: { | ||||||
|  |     type: Array as PropType<RuleConfig[]>, | ||||||
|  |     default: () => [], | ||||||
|  |   }, | ||||||
|  |   isBatch: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, // 是否作为批量操作组件 | ||||||
|  |   isDetail: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, // 是否作为 sku 详情组件 | ||||||
|  |   isComponent: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, // 是否作为组件 | ||||||
|  |   isActivityComponent: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, // 是否作为活动组件 | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const formData = ref<MallSpuApi.Spu>(); // 表单数据 | ||||||
|  | const skuList = ref<MallSpuApi.Sku[]>([ | ||||||
|  |   { | ||||||
|  |     price: 0, // 商品价格 | ||||||
|  |     marketPrice: 0, // 市场价 | ||||||
|  |     costPrice: 0, // 成本价 | ||||||
|  |     barCode: '', // 商品条码 | ||||||
|  |     picUrl: '', // 图片地址 | ||||||
|  |     stock: 0, // 库存 | ||||||
|  |     weight: 0, // 商品重量 | ||||||
|  |     volume: 0, // 商品体积 | ||||||
|  |     firstBrokeragePrice: 0, // 一级分销的佣金 | ||||||
|  |     secondBrokeragePrice: 0, // 二级分销的佣金 | ||||||
|  |   }, | ||||||
|  | ]); // 批量添加时的临时数据 | ||||||
|  | 
 | ||||||
|  | /** 批量添加 */ | ||||||
|  | const batchAdd = () => { | ||||||
|  |   validateProperty(); | ||||||
|  |   formData.value!.skus!.forEach((item: MallSpuApi.Sku) => { | ||||||
|  |     copyValueToTarget(item, skuList.value[0]); | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | /** 校验商品属性属性值 */ | ||||||
|  | const validateProperty = () => { | ||||||
|  |   // 校验商品属性属性值是否为空,有一个为空都不给过 | ||||||
|  |   const warningInfo = '存在属性属性值为空,请先检查完善属性值后重试!!!'; | ||||||
|  |   for (const item of props.propertyList) { | ||||||
|  |     if (!item.values || isEmpty(item.values)) { | ||||||
|  |       ElMessage.warning(warningInfo); | ||||||
|  |       throw new Error(warningInfo); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | /** 删除 sku */ | ||||||
|  | const deleteSku = (row: MallSpuApi.Sku) => { | ||||||
|  |   const index = formData.value!.skus!.findIndex( | ||||||
|  |     // 直接把列表转成字符串比较 | ||||||
|  |     (sku: MallSpuApi.Sku) => | ||||||
|  |       JSON.stringify(sku.properties) === JSON.stringify(row.properties), | ||||||
|  |   ); | ||||||
|  |   formData.value!.skus!.splice(index, 1); | ||||||
|  | }; | ||||||
|  | const tableHeaders = ref<{ prop: string; label: string }[]>([]); // 多属性表头 | ||||||
|  | /** | ||||||
|  |  * 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。 | ||||||
|  |  */ | ||||||
|  | const validateSku = () => { | ||||||
|  |   validateProperty(); | ||||||
|  |   let warningInfo = '请检查商品各行相关属性配置,'; | ||||||
|  |   let validate = true; // 默认通过 | ||||||
|  |   for (const sku of formData.value!.skus!) { | ||||||
|  |     // 作为活动组件的校验 | ||||||
|  |     for (const rule of props?.ruleConfig) { | ||||||
|  |       const arg = getValue(sku, rule.name); | ||||||
|  |       if (!rule.rule(arg)) { | ||||||
|  |         validate = false; // 只要有一个不通过则直接不通过 | ||||||
|  |         warningInfo += rule.message; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     // 只要有一个不通过则结束后续的校验 | ||||||
|  |     if (!validate) { | ||||||
|  |       ElMessage.warning(warningInfo); | ||||||
|  |       throw new Error(warningInfo); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | const getValue = (obj: any, arg: string) => { | ||||||
|  |   const keys = arg.split('.'); | ||||||
|  |   let value = obj; | ||||||
|  |   for (const key of keys) { | ||||||
|  |     if (value && typeof value === 'object' && key in value) { | ||||||
|  |       value = value[key]; | ||||||
|  |     } else { | ||||||
|  |       value = undefined; | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   return value; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const emit = defineEmits<{ | ||||||
|  |   (e: 'selectionChange', value: MallSpuApi.Sku[]): void; | ||||||
|  | }>(); | ||||||
|  | /** | ||||||
|  |  * 选择时触发 | ||||||
|  |  * @param Sku 传递过来的选中的 sku 是一个数组 | ||||||
|  |  */ | ||||||
|  | const handleSelectionChange = (val: MallSpuApi.Sku[]) => { | ||||||
|  |   emit('selectionChange', val); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 将传进来的值赋值给 skuList | ||||||
|  |  */ | ||||||
|  | watch( | ||||||
|  |   () => props.propFormData, | ||||||
|  |   (data) => { | ||||||
|  |     if (!data) return; | ||||||
|  |     formData.value = data; | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     deep: true, | ||||||
|  |     immediate: true, | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | /** 生成表数据 */ | ||||||
|  | const generateTableData = (propertyList: any[]) => { | ||||||
|  |   // 构建数据结构 | ||||||
|  |   const propertyValues = propertyList.map((item) => | ||||||
|  |     item.values.map((v: any) => ({ | ||||||
|  |       propertyId: item.id, | ||||||
|  |       propertyName: item.name, | ||||||
|  |       valueId: v.id, | ||||||
|  |       valueName: v.name, | ||||||
|  |     })), | ||||||
|  |   ); | ||||||
|  |   const buildSkuList = build(propertyValues); | ||||||
|  |   // 如果回显的 sku 属性和添加的属性不一致则重置 skus 列表 | ||||||
|  |   if (!validateData(propertyList)) { | ||||||
|  |     // 如果不一致则重置表数据,默认添加新的属性重新生成 sku 列表 | ||||||
|  |     formData.value!.skus = []; | ||||||
|  |   } | ||||||
|  |   if (buildSkuList && buildSkuList.length > 0) { | ||||||
|  |     for (const item of buildSkuList) { | ||||||
|  |       const row = { | ||||||
|  |         properties: Array.isArray(item) ? item : [item], // 如果只有一个属性的话返回的是一个 property 对象 | ||||||
|  |         price: 0, | ||||||
|  |         marketPrice: 0, | ||||||
|  |         costPrice: 0, | ||||||
|  |         barCode: '', | ||||||
|  |         picUrl: '', | ||||||
|  |         stock: 0, | ||||||
|  |         weight: 0, | ||||||
|  |         volume: 0, | ||||||
|  |         firstBrokeragePrice: 0, | ||||||
|  |         secondBrokeragePrice: 0, | ||||||
|  |       }; | ||||||
|  |       // 如果存在属性相同的 sku 则不做处理 | ||||||
|  |       const index = formData.value!.skus!.findIndex( | ||||||
|  |         (sku: MallSpuApi.Sku) => | ||||||
|  |           JSON.stringify(sku.properties) === JSON.stringify(row.properties), | ||||||
|  |       ); | ||||||
|  |       if (index !== -1) { | ||||||
|  |         continue; | ||||||
|  |       } | ||||||
|  |       formData.value!.skus!.push(row); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * 生成 skus 前置校验 | ||||||
|  |  */ | ||||||
|  | const validateData = (propertyList: any[]) => { | ||||||
|  |   const skuPropertyIds: number[] = []; | ||||||
|  |   formData.value!.skus!.forEach((sku: MallSpuApi.Sku) => | ||||||
|  |     sku.properties | ||||||
|  |       ?.map((property: any) => property.propertyId) | ||||||
|  |       ?.forEach((propertyId: number) => { | ||||||
|  |         if (skuPropertyIds.indexOf(propertyId!) === -1) { | ||||||
|  |           skuPropertyIds.push(propertyId!); | ||||||
|  |         } | ||||||
|  |       }), | ||||||
|  |   ); | ||||||
|  |   const propertyIds = propertyList.map((item) => item.id); | ||||||
|  |   return skuPropertyIds.length === propertyIds.length; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** 构建所有排列组合 */ | ||||||
|  | const build = ( | ||||||
|  |   propertyValuesList: MallSpuApi.Property[][], | ||||||
|  | ): MallSpuApi.Property[] | MallSpuApi.Property[][] => { | ||||||
|  |   if (!propertyValuesList || propertyValuesList.length === 0) { | ||||||
|  |     return []; | ||||||
|  |   } else if (propertyValuesList.length === 1) { | ||||||
|  |     return propertyValuesList[0] || []; | ||||||
|  |   } else { | ||||||
|  |     const result: MallSpuApi.Property[][] = []; | ||||||
|  |     const rest = build(propertyValuesList.slice(1)); | ||||||
|  |     if (propertyValuesList[0] && Array.isArray(rest)) { | ||||||
|  |       for (let i = 0; i < propertyValuesList[0].length; i++) { | ||||||
|  |         for (let j = 0; j < rest.length; j++) { | ||||||
|  |           const currentItem = propertyValuesList[0][i]; | ||||||
|  |           const restItem = rest[j]; | ||||||
|  |           // 第一次不是数组结构,后面的都是数组结构 | ||||||
|  |           if (Array.isArray(restItem)) { | ||||||
|  |             result.push([currentItem!, ...restItem]); | ||||||
|  |           } else if (restItem) { | ||||||
|  |             // 确保restItem不是undefined,并进行类型断言 | ||||||
|  |             result.push([currentItem!, restItem as MallSpuApi.Property]); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return result; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** 监听属性列表,生成相关参数和表头 */ | ||||||
|  | watch( | ||||||
|  |   () => props.propertyList, | ||||||
|  |   (propertyList: PropertyAndValues[]) => { | ||||||
|  |     // 如果不是多规格则结束 | ||||||
|  |     if (!formData.value!.specType) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     // 如果当前组件作为批量添加数据使用,则重置表数据 | ||||||
|  |     if (props.isBatch) { | ||||||
|  |       skuList.value = [ | ||||||
|  |         { | ||||||
|  |           price: 0, | ||||||
|  |           marketPrice: 0, | ||||||
|  |           costPrice: 0, | ||||||
|  |           barCode: '', | ||||||
|  |           picUrl: '', | ||||||
|  |           stock: 0, | ||||||
|  |           weight: 0, | ||||||
|  |           volume: 0, | ||||||
|  |           firstBrokeragePrice: 0, | ||||||
|  |           secondBrokeragePrice: 0, | ||||||
|  |         }, | ||||||
|  |       ]; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // 判断代理对象是否为空 | ||||||
|  |     if (JSON.stringify(propertyList) === '[]') { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     // 重置表头 | ||||||
|  |     tableHeaders.value = []; | ||||||
|  |     // 生成表头 | ||||||
|  |     propertyList.forEach((item, index) => { | ||||||
|  |       // name加属性项index区分属性值 | ||||||
|  |       tableHeaders.value.push({ prop: `name${index}`, label: item.name }); | ||||||
|  |     }); | ||||||
|  |     // 如果回显的 sku 属性和添加的属性一致则不处理 | ||||||
|  |     if (validateData(propertyList)) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     // 添加新属性没有属性值也不做处理 | ||||||
|  |     if (propertyList.some((item) => !item.values || isEmpty(item.values))) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     // 生成 table 数据,即 sku 列表 | ||||||
|  |     generateTableData(propertyList); | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     deep: true, | ||||||
|  |     immediate: true, | ||||||
|  |   }, | ||||||
|  | ); | ||||||
|  | const activitySkuListRef = ref<InstanceType<typeof ElTable>>(); | ||||||
|  | 
 | ||||||
|  | const getSkuTableRef = () => { | ||||||
|  |   return activitySkuListRef.value; | ||||||
|  | }; | ||||||
|  | // 暴露出生成 sku 方法,给添加属性成功时调用 | ||||||
|  | defineExpose({ generateTableData, validateSku, getSkuTableRef }); | ||||||
|  | </script> | ||||||
|  | @ -1,3 +1,127 @@ | ||||||
| <script lang="ts" setup></script> | <script lang="ts" setup> | ||||||
|  | import { Page } from '@vben/common-ui'; | ||||||
|  | import { onMounted, ref } from 'vue'; | ||||||
|  | import type { MallSpuApi } from '#/api/mall/product/spu'; | ||||||
|  | import { useRouter, useRoute } from 'vue-router'; | ||||||
|  | import { floatToFixed2, formatToFraction } from '#/utils'; | ||||||
|  | import * as ProductSpuApi from '#/api/mall/product/spu'; | ||||||
| 
 | 
 | ||||||
| <template>form</template> | import InfoForm from '../components/info-form.vue'; | ||||||
|  | import DeliveryForm from '../components/delivery-form.vue'; | ||||||
|  | import DescriptionForm from '../components/description-form.vue'; | ||||||
|  | import OtherForm from '../components/other-form.vue'; | ||||||
|  | import SkuForm from '../components/sku-form.vue'; | ||||||
|  | 
 | ||||||
|  | const activeTab = ref('info'); | ||||||
|  | const activeName = ref('info'); // Tag 激活的窗口 | ||||||
|  | 
 | ||||||
|  | // SPU 表单数据 | ||||||
|  | const formData = ref<MallSpuApi.Spu>({ | ||||||
|  |   name: '', // 商品名称 | ||||||
|  |   categoryId: undefined, // 商品分类 | ||||||
|  |   keyword: '', // 关键字 | ||||||
|  |   picUrl: '', // 商品封面图 | ||||||
|  |   sliderPicUrls: [], // 商品轮播图 | ||||||
|  |   introduction: '', // 商品简介 | ||||||
|  |   deliveryTypes: [], // 配送方式数组 | ||||||
|  |   deliveryTemplateId: undefined, // 运费模版 | ||||||
|  |   brandId: undefined, // 商品品牌 | ||||||
|  |   specType: false, // 商品规格 | ||||||
|  |   subCommissionType: false, // 分销类型 | ||||||
|  |   skus: [ | ||||||
|  |     { | ||||||
|  |       price: 0, // 商品价格 | ||||||
|  |       marketPrice: 0, // 市场价 | ||||||
|  |       costPrice: 0, // 成本价 | ||||||
|  |       barCode: '', // 商品条码 | ||||||
|  |       picUrl: '', // 图片地址 | ||||||
|  |       stock: 0, // 库存 | ||||||
|  |       weight: 0, // 商品重量 | ||||||
|  |       volume: 0, // 商品体积 | ||||||
|  |       firstBrokeragePrice: 0, // 一级分销的佣金 | ||||||
|  |       secondBrokeragePrice: 0, // 二级分销的佣金 | ||||||
|  |     }, | ||||||
|  |   ], | ||||||
|  |   description: '', // 商品详情 | ||||||
|  |   sort: 0, // 商品排序 | ||||||
|  |   giveIntegral: 0, // 赠送积分 | ||||||
|  |   virtualSalesCount: 0, // 虚拟销量 | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const formLoading = ref(false); // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 | ||||||
|  | const isDetail = ref(false); // 是否查看详情 | ||||||
|  | const { push, currentRoute } = useRouter(); // 路由 | ||||||
|  | const { params, name } = useRoute(); // 查询参数 | ||||||
|  | /** 获得详情 */ | ||||||
|  | const getDetail = async () => { | ||||||
|  |   if ('ProductSpuDetail' === name) { | ||||||
|  |     isDetail.value = true; | ||||||
|  |   } | ||||||
|  |   const id = params.id as unknown as number; | ||||||
|  |   if (id) { | ||||||
|  |     formLoading.value = true; | ||||||
|  |     try { | ||||||
|  |       const res = (await ProductSpuApi.getSpu(id)) as MallSpuApi.Spu; | ||||||
|  |       res.skus?.forEach((item: MallSpuApi.Sku) => { | ||||||
|  |         if (isDetail.value) { | ||||||
|  |           item.price = floatToFixed2(item.price); | ||||||
|  |           item.marketPrice = floatToFixed2(item.marketPrice); | ||||||
|  |           item.costPrice = floatToFixed2(item.costPrice); | ||||||
|  |           item.firstBrokeragePrice = floatToFixed2(item.firstBrokeragePrice); | ||||||
|  |           item.secondBrokeragePrice = floatToFixed2(item.secondBrokeragePrice); | ||||||
|  |         } else { | ||||||
|  |           // 回显价格分转元 | ||||||
|  |           item.price = formatToFraction(item.price); | ||||||
|  |           item.marketPrice = formatToFraction(item.marketPrice); | ||||||
|  |           item.costPrice = formatToFraction(item.costPrice); | ||||||
|  |           item.firstBrokeragePrice = formatToFraction(item.firstBrokeragePrice); | ||||||
|  |           item.secondBrokeragePrice = formatToFraction( | ||||||
|  |             item.secondBrokeragePrice, | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |       formData.value = res; | ||||||
|  |     } finally { | ||||||
|  |       formLoading.value = false; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** 关闭按钮 */ | ||||||
|  | const close = () => { | ||||||
|  |   push({ name: 'ProductSpu' }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | /** 初始化 */ | ||||||
|  | onMounted(async () => { | ||||||
|  |   await getDetail(); | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <Page :auto-content-height="true"> | ||||||
|  |     <ElTabs v-model="activeTab"> | ||||||
|  |       <ElTabPane label="基础设置" name="info"> | ||||||
|  |         <InfoForm :propFormData="formData" v-model:activeName="activeName" /> | ||||||
|  |       </ElTabPane> | ||||||
|  |       <ElTabPane label="价格库存" name="sku"> | ||||||
|  |         <SkuForm :propFormData="formData" v-model:activeName="activeName" /> | ||||||
|  |       </ElTabPane> | ||||||
|  |       <ElTabPane label="物流设置" name="delivery"> | ||||||
|  |         <DeliveryForm | ||||||
|  |           :propFormData="formData" | ||||||
|  |           v-model:activeName="activeName" | ||||||
|  |         /> | ||||||
|  |       </ElTabPane> | ||||||
|  |       <ElTabPane label="商品详情" name="description"> | ||||||
|  |         <DescriptionForm | ||||||
|  |           :propFormData="formData" | ||||||
|  |           v-model:activeName="activeName" | ||||||
|  |         /> | ||||||
|  |       </ElTabPane> | ||||||
|  |       <ElTabPane label="其它设置" name="other"> | ||||||
|  |         <OtherForm :propFormData="formData" v-model:activeName="activeName" /> | ||||||
|  |       </ElTabPane> | ||||||
|  |     </ElTabs> | ||||||
|  |   </Page> | ||||||
|  | </template> | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue
	
	 吃货
						吃货