From f0516fa857090834aae59e226109fcfc0681b8e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=83=E8=B4=A7?= <252048765@qq.com> Date: Sun, 6 Jul 2025 21:27:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=95=86=E5=93=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=8C=85=E5=90=AB?= =?UTF-8?q?=E5=95=86=E5=93=81=E5=88=86=E7=B1=BB=E3=80=81=E5=93=81=E7=89=8C?= =?UTF-8?q?=E3=80=81SPU=E7=AE=A1=E7=90=86=E5=8F=8A=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-ele/src/adapter/component/index.ts | 24 + apps/web-ele/src/adapter/vxe-table.ts | 10 +- apps/web-ele/src/api/mall/product/category.ts | 7 + .../src/components/upload/image-upload.vue | 52 +- .../web-ele/src/router/routes/modules/mall.ts | 76 +++ apps/web-ele/src/utils/bean.ts | 17 + apps/web-ele/src/utils/formatNum.ts | 38 ++ apps/web-ele/src/utils/index.ts | 4 + apps/web-ele/src/utils/is.ts | 117 ++++ apps/web-ele/src/utils/tree.ts | 440 +++++++++++++ .../src/views/mall/product/brand/data.ts | 3 + .../product/spu/components/delivery-form.vue | 84 +++ .../spu/components/description-form.vue | 66 ++ .../mall/product/spu/components/info-form.vue | 138 ++++ .../mall/product/spu/components/model.d.ts | 62 ++ .../product/spu/components/other-form.vue | 86 +++ .../spu/components/product-fttributes.vue | 195 ++++++ .../components/product-property-add-form.vue | 130 ++++ .../mall/product/spu/components/sku-form.vue | 192 ++++++ .../mall/product/spu/components/sku-list.vue | 613 ++++++++++++++++++ .../views/mall/product/spu/modules/form.vue | 128 +++- 21 files changed, 2465 insertions(+), 17 deletions(-) create mode 100644 apps/web-ele/src/router/routes/modules/mall.ts create mode 100644 apps/web-ele/src/utils/bean.ts create mode 100644 apps/web-ele/src/utils/formatNum.ts create mode 100644 apps/web-ele/src/utils/is.ts create mode 100644 apps/web-ele/src/utils/tree.ts create mode 100644 apps/web-ele/src/views/mall/product/spu/components/delivery-form.vue create mode 100644 apps/web-ele/src/views/mall/product/spu/components/description-form.vue create mode 100644 apps/web-ele/src/views/mall/product/spu/components/info-form.vue create mode 100644 apps/web-ele/src/views/mall/product/spu/components/model.d.ts create mode 100644 apps/web-ele/src/views/mall/product/spu/components/other-form.vue create mode 100644 apps/web-ele/src/views/mall/product/spu/components/product-fttributes.vue create mode 100644 apps/web-ele/src/views/mall/product/spu/components/product-property-add-form.vue create mode 100644 apps/web-ele/src/views/mall/product/spu/components/sku-form.vue create mode 100644 apps/web-ele/src/views/mall/product/spu/components/sku-list.vue diff --git a/apps/web-ele/src/adapter/component/index.ts b/apps/web-ele/src/adapter/component/index.ts index 411cf50f5..aca6d3181 100644 --- a/apps/web-ele/src/adapter/component/index.ts +++ b/apps/web-ele/src/adapter/component/index.ts @@ -126,6 +126,12 @@ const ElUpload = defineAsyncComponent(() => import('element-plus/es/components/upload/style/css'), ]).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 = ( component: T, @@ -185,6 +191,7 @@ export type ComponentType = | 'TimePicker' | 'TreeSelect' | 'Upload' + | 'ApiCascader' | BaseFormComponentType; async function initComponentAdapter() { @@ -204,6 +211,23 @@ async function initComponentAdapter() { visibleEvent: 'onVisibleChange', }, ), + ApiCascader: withDefaultPlaceholder( + { + ...ApiComponent, + name: 'ApiCascader', + }, + 'select', + { + component: ElCascader, + props: { + props: { + label: 'label', + value: 'value', + children: 'children', + }, + }, + }, + ), ApiTreeSelect: withDefaultPlaceholder( { ...ApiComponent, diff --git a/apps/web-ele/src/adapter/vxe-table.ts b/apps/web-ele/src/adapter/vxe-table.ts index 5bf8b3137..a2938b977 100644 --- a/apps/web-ele/src/adapter/vxe-table.ts +++ b/apps/web-ele/src/adapter/vxe-table.ts @@ -75,10 +75,16 @@ setupVbenVxeTable({ // 表格配置项可以用 cellRender: { name: 'CellImage' }, vxeUI.renderer.add('CellImage', { - renderTableDefault(_renderOpts, params) { + renderTableDefault(renderOpts, params) { + const { props } = renderOpts; const { column, row } = params; const src = row[column.field]; - return h(ElImage, { src, previewSrcList: [src] }); + return h(ElImage, { + src, + previewSrcList: [src], + class: props?.class, + previewTeleported: true, + }); }, }); diff --git a/apps/web-ele/src/api/mall/product/category.ts b/apps/web-ele/src/api/mall/product/category.ts index f30d6c6b7..3ca05b0f9 100644 --- a/apps/web-ele/src/api/mall/product/category.ts +++ b/apps/web-ele/src/api/mall/product/category.ts @@ -49,3 +49,10 @@ export function getCategoryList(params: any) { }, ); } + +// 获得商品分类列表 +export function getCategorySimpleList() { + return requestClient.get( + '/product/category/list-all-simple', + ); +} diff --git a/apps/web-ele/src/components/upload/image-upload.vue b/apps/web-ele/src/components/upload/image-upload.vue index 174df55fc..edb3fc9c0 100644 --- a/apps/web-ele/src/components/upload/image-upload.vue +++ b/apps/web-ele/src/components/upload/image-upload.vue @@ -48,10 +48,14 @@ const props = withDefaults( resultField?: string; // 是否显示下面的描述 showDescription?: boolean; - value?: string | string[]; + modelValue?: string | string[]; + // 上传框宽度 + width?: string | number; + // 上传框高度 + height?: string | number; }>(), { - value: () => [], + modelValue: () => [], directory: undefined, disabled: false, listType: 'picture-card', @@ -63,11 +67,13 @@ const props = withDefaults( api: undefined, resultField: '', showDescription: true, + width: '', + height: '', }, ); -const emit = defineEmits(['change', 'update:value', 'delete']); -const { accept, helpText, maxNumber, maxSize } = toRefs(props); +const emit = defineEmits(['change', 'update:modelValue', 'delete']); +const { accept, helpText, maxNumber, maxSize, width, height } = toRefs(props); const isInnerOperate = ref(false); const { getStringAccept } = useUploadType({ acceptRef: accept, @@ -82,7 +88,7 @@ const isActMsg = ref(true); // 文件类型错误提示 const isFirstRender = ref(true); // 是否第一次渲染 watch( - () => props.value, + () => props.modelValue, async (v) => { if (isInnerOperate.value) { isInnerOperate.value = false; @@ -101,7 +107,7 @@ watch( return { uid: -i, name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)), - status: UploadResultStatus.DONE, + status: UploadResultStatus.SUCCESS, url: item, } as UploadFile; } else if (item && isObject(item)) { @@ -109,7 +115,7 @@ watch( return { uid: file.uid || -i, name: file.name || '', - status: UploadResultStatus.DONE, + status: UploadResultStatus.SUCCESS, url: file.url, } as UploadFile; } @@ -154,7 +160,7 @@ const handleRemove = async (file: UploadFile) => { index !== -1 && fileList.value.splice(index, 1); const value = getValue(); isInnerOperate.value = true; - emit('update:value', value); + emit('update:modelValue', value); emit('change', value); emit('delete', file); } @@ -204,7 +210,7 @@ async function customRequest(options: UploadRequestOptions) { // 更新文件 const value = getValue(); isInnerOperate.value = true; - emit('update:value', value); + emit('update:modelValue', value); emit('change', value); } catch (error: any) { console.error(error); @@ -213,13 +219,14 @@ async function customRequest(options: UploadRequestOptions) { } function getValue() { + console.log(fileList.value); const list = (fileList.value || []) - .filter((item) => item?.status === UploadResultStatus.DONE) + .filter((item) => item?.status === UploadResultStatus.SUCCESS) .map((item: any) => { if (item?.response && props?.resultField) { return item?.response; } - return item?.url || item?.response?.url || item?.response; + return item?.response?.url || item?.response; }); // add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型 if (props.maxNumber === 1) { @@ -243,10 +250,11 @@ function getValue() { :multiple="multiple" :on-preview="handlePreview" :on-remove="handleRemove" + :class="width || height ? 'custom-upload' : ''" >
{{ $t('ui.upload.imgUpload') }}
@@ -262,4 +270,22 @@ function getValue() { .ant-upload-select-picture-card { @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; +} diff --git a/apps/web-ele/src/router/routes/modules/mall.ts b/apps/web-ele/src/router/routes/modules/mall.ts new file mode 100644 index 000000000..80416ba65 --- /dev/null +++ b/apps/web-ele/src/router/routes/modules/mall.ts @@ -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; diff --git a/apps/web-ele/src/utils/bean.ts b/apps/web-ele/src/utils/bean.ts new file mode 100644 index 000000000..fa4e3c028 --- /dev/null +++ b/apps/web-ele/src/utils/bean.ts @@ -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); +}; diff --git a/apps/web-ele/src/utils/formatNum.ts b/apps/web-ele/src/utils/formatNum.ts new file mode 100644 index 000000000..fd8fbb1e3 --- /dev/null +++ b/apps/web-ele/src/utils/formatNum.ts @@ -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; +}; diff --git a/apps/web-ele/src/utils/index.ts b/apps/web-ele/src/utils/index.ts index 022e6441d..f07b644bd 100644 --- a/apps/web-ele/src/utils/index.ts +++ b/apps/web-ele/src/utils/index.ts @@ -5,3 +5,7 @@ export * from './formCreate'; export * from './rangePickerProps'; export * from './routerHelper'; export * from './validator'; +export * from './tree'; +export * from './formatNum'; +export * from './is'; +export * from './bean'; diff --git a/apps/web-ele/src/utils/is.ts b/apps/web-ele/src/utils/is.ts new file mode 100644 index 000000000..cd2dcc376 --- /dev/null +++ b/apps/web-ele/src/utils/is.ts @@ -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 = (val?: T): val is T => { + return typeof val !== 'undefined' +} + +export const isUnDef = (val?: T): val is T => { + return !isDef(val) +} + +export const isObject = (val: any): val is Record => { + 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 = (val: unknown): val is Promise => { + 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 => { + 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 => { + 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 +} diff --git a/apps/web-ele/src/utils/tree.ts b/apps/web-ele/src/utils/tree.ts new file mode 100644 index 000000000..1d6adf2a8 --- /dev/null +++ b/apps/web-ele/src/utils/tree.ts @@ -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 { + (...arg: T[]): T; +} + +const getConfig = (config: Partial) => + Object.assign({}, DEFAULT_CONFIG, config); + +// tree from list +export const listToTree = ( + list: any[], + config: Partial = {}, +): 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 = ( + tree: any, + config: Partial = {}, +): 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 = ( + tree: any, + func: Fn, + config: Partial = {}, +): 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 = ( + tree: any, + func: Fn, + config: Partial = {}, +): 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 = ( + tree: any, + func: Fn, + config: Partial = {}, +): 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 = {}, +) => { + 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 = ( + tree: T[], + func: (n: T) => boolean, + config: Partial = {}, +): 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 = ( + tree: T[], + func: (n: T) => any, + config: Partial = {}, +): 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 = ( + 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 = {}; + const nodeIds: Record = {}; + 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; +}; diff --git a/apps/web-ele/src/views/mall/product/brand/data.ts b/apps/web-ele/src/views/mall/product/brand/data.ts index 654809ac9..ac0271e67 100644 --- a/apps/web-ele/src/views/mall/product/brand/data.ts +++ b/apps/web-ele/src/views/mall/product/brand/data.ts @@ -103,6 +103,9 @@ export function useGridColumns(): VxeGridPropTypes.Columns { title: '品牌图片', cellRender: { name: 'CellImage', + props: { + class: 'w-10 h-10', + }, }, }, { diff --git a/apps/web-ele/src/views/mall/product/spu/components/delivery-form.vue b/apps/web-ele/src/views/mall/product/spu/components/delivery-form.vue new file mode 100644 index 000000000..c99de0f77 --- /dev/null +++ b/apps/web-ele/src/views/mall/product/spu/components/delivery-form.vue @@ -0,0 +1,84 @@ + + diff --git a/apps/web-ele/src/views/mall/product/spu/components/description-form.vue b/apps/web-ele/src/views/mall/product/spu/components/description-form.vue new file mode 100644 index 000000000..53b1c0741 --- /dev/null +++ b/apps/web-ele/src/views/mall/product/spu/components/description-form.vue @@ -0,0 +1,66 @@ + + diff --git a/apps/web-ele/src/views/mall/product/spu/components/info-form.vue b/apps/web-ele/src/views/mall/product/spu/components/info-form.vue new file mode 100644 index 000000000..c5bd3d446 --- /dev/null +++ b/apps/web-ele/src/views/mall/product/spu/components/info-form.vue @@ -0,0 +1,138 @@ + + diff --git a/apps/web-ele/src/views/mall/product/spu/components/model.d.ts b/apps/web-ele/src/views/mall/product/spu/components/model.d.ts new file mode 100644 index 000000000..01e5ef181 --- /dev/null +++ b/apps/web-ele/src/views/mall/product/spu/components/model.d.ts @@ -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 }; diff --git a/apps/web-ele/src/views/mall/product/spu/components/other-form.vue b/apps/web-ele/src/views/mall/product/spu/components/other-form.vue new file mode 100644 index 000000000..dcb9e1bd5 --- /dev/null +++ b/apps/web-ele/src/views/mall/product/spu/components/other-form.vue @@ -0,0 +1,86 @@ + + diff --git a/apps/web-ele/src/views/mall/product/spu/components/product-fttributes.vue b/apps/web-ele/src/views/mall/product/spu/components/product-fttributes.vue new file mode 100644 index 000000000..5a488b584 --- /dev/null +++ b/apps/web-ele/src/views/mall/product/spu/components/product-fttributes.vue @@ -0,0 +1,195 @@ + + + + diff --git a/apps/web-ele/src/views/mall/product/spu/components/product-property-add-form.vue b/apps/web-ele/src/views/mall/product/spu/components/product-property-add-form.vue new file mode 100644 index 000000000..e74826645 --- /dev/null +++ b/apps/web-ele/src/views/mall/product/spu/components/product-property-add-form.vue @@ -0,0 +1,130 @@ + + + diff --git a/apps/web-ele/src/views/mall/product/spu/components/sku-form.vue b/apps/web-ele/src/views/mall/product/spu/components/sku-form.vue new file mode 100644 index 000000000..8032c619e --- /dev/null +++ b/apps/web-ele/src/views/mall/product/spu/components/sku-form.vue @@ -0,0 +1,192 @@ + + diff --git a/apps/web-ele/src/views/mall/product/spu/components/sku-list.vue b/apps/web-ele/src/views/mall/product/spu/components/sku-list.vue new file mode 100644 index 000000000..96ce2770c --- /dev/null +++ b/apps/web-ele/src/views/mall/product/spu/components/sku-list.vue @@ -0,0 +1,613 @@ + + diff --git a/apps/web-ele/src/views/mall/product/spu/modules/form.vue b/apps/web-ele/src/views/mall/product/spu/modules/form.vue index 5b6413575..9011d2c5e 100644 --- a/apps/web-ele/src/views/mall/product/spu/modules/form.vue +++ b/apps/web-ele/src/views/mall/product/spu/modules/form.vue @@ -1,3 +1,127 @@ - + + +