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