!171 refactor(web-ele): 移除未使用的工具函数

Merge pull request !171 from 痴货/master
pull/172/MERGE
xingyu 2025-07-13 11:04:29 +00:00 committed by Gitee
commit cedefa5d16
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
129 changed files with 17206 additions and 217 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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 = <T extends Component>(
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,

View File

@ -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,
});
},
});

View File

@ -49,3 +49,10 @@ export function getCategoryList(params: any) {
},
);
}
// 获得商品分类列表
export function getCategorySimpleList() {
return requestClient.get<MallCategoryApi.Category[]>(
'/product/category/list-all-simple',
);
}

View File

@ -0,0 +1,2 @@
export { default as SummaryCard } from './summary-card.vue';
export type { SummaryCardProps } from './typing';

View File

@ -0,0 +1,57 @@
<script lang="ts" setup>
import type { SummaryCardProps } from './typing';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { ElTooltip } from 'element-plus';
/** 统计卡片 */
defineOptions({ name: 'SummaryCard' });
defineProps<SummaryCardProps>();
</script>
<template>
<div
class="flex flex-row items-center gap-3 rounded bg-[var(--el-bg-color-overlay)] p-4"
>
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded"
:class="`${iconColor} ${iconBgColor}`"
>
<IconifyIcon v-if="icon" :icon="icon" class="!text-6" />
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-1">
<span class="text-base">{{ title }}</span>
<ElTooltip :content="tooltip" placement="topLeft" v-if="tooltip">
<IconifyIcon
icon="lucide:circle-alert"
class="item-center !text-3 flex"
/>
</ElTooltip>
</div>
<div class="flex flex-row items-baseline gap-2">
<div class="text-lg">
<CountTo
:prefix="prefix"
:end-val="value ?? 0"
:decimals="decimals ?? 0"
/>
</div>
<span
v-if="percent !== undefined"
:class="Number(percent) > 0 ? 'text-red-500' : 'text-green-500'"
>
<span class="text-sm">{{ Math.abs(Number(percent)) }}%</span>
<IconifyIcon
:icon="
Number(percent) > 0 ? 'lucide:chevron-up' : 'lucide:chevron-down'
"
class="ml-0.5 !text-sm"
/>
</span>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,11 @@
export interface SummaryCardProps {
title: string;
tooltip?: string;
icon?: string;
iconColor?: string;
iconBgColor?: string;
prefix?: string;
value?: number;
decimals?: number;
percent?: number | string;
}

View File

@ -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<boolean>(false);
const { getStringAccept } = useUploadType({
acceptRef: accept,
@ -82,7 +88,7 @@ const isActMsg = ref<boolean>(true); // 文件类型错误提示
const isFirstRender = ref<boolean>(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' : ''"
>
<div
v-if="fileList && fileList.length < maxNumber"
class="flex flex-col items-center justify-center"
class="upload-content flex flex-col items-center justify-center"
:style="{ width: width || '', height: height || '' }"
>
<CloudUpload />
<div class="mt-2">{{ $t('ui.upload.imgUpload') }}</div>
@ -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;
}
</style>

View File

@ -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;

View File

@ -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);
};

View File

@ -5,3 +5,4 @@ export * from './formCreate';
export * from './rangePickerProps';
export * from './routerHelper';
export * from './validator';
export * from './bean';

View File

@ -0,0 +1,177 @@
<script lang="ts" setup>
import type {
AnalysisOverviewItem,
WorkbenchProjectItem,
WorkbenchQuickNavItem,
} from '@vben/common-ui';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
AnalysisOverview,
DocAlert,
Page,
WorkbenchQuickNav,
} from '@vben/common-ui';
import {
SvgBellIcon,
SvgCakeIcon,
SvgCardIcon,
SvgDownloadIcon,
} from '@vben/icons';
import { isString, openWindow } from '@vben/utils';
import { getUserCountComparison } from '#/api/mall/statistics/member';
import { getOrderComparison } from '#/api/mall/statistics/trade';
/** 商城首页 */
defineOptions({ name: 'MallHome' });
const loading = ref(true); //
const orderComparison = ref(); //
const userComparison = ref(); //
/** 查询交易对照卡片数据 */
const getOrder = async () => {
orderComparison.value = await getOrderComparison();
};
/** 查询会员用户数量对照卡片数据 */
const getUserCount = async () => {
userComparison.value = await getUserCountComparison();
};
/** 初始化 */
onMounted(async () => {
loading.value = true;
await Promise.all([getOrder(), getUserCount()]);
loading.value = false;
});
const overviewItems: AnalysisOverviewItem[] = [
{
icon: SvgCardIcon,
title: '今日销售额',
totalTitle: '昨日数据',
totalValue: orderComparison.value?.reference?.orderPayPrice || 0,
value: orderComparison.value?.orderPayPrice || 0,
},
{
icon: SvgCakeIcon,
title: '今日用户访问量',
totalTitle: '总访问量',
totalValue: userComparison.value?.reference?.visitUserCount || 0,
value: userComparison.value?.visitUserCount || 0,
},
{
icon: SvgDownloadIcon,
title: '今日订单量',
totalTitle: '总订单量',
totalValue: orderComparison.value?.orderPayCount || 0,
value: orderComparison.value?.reference?.orderPayCount || 0,
},
{
icon: SvgBellIcon,
title: '今日会员注册量',
totalTitle: '总会员注册量',
totalValue: userComparison.value?.registerUserCount || 0,
value: userComparison.value?.reference?.registerUserCount || 0,
},
];
// url 使 http
const quickNavItems: WorkbenchQuickNavItem[] = [
{
color: '#1fdaca',
icon: 'ep:user-filled',
title: '用户管理',
url: 'MemberUser',
},
{
color: '#ff6b6b',
icon: 'fluent-mdl2:product',
title: '商品管理',
url: 'ProductSpu',
},
{
color: '#7c3aed',
icon: 'ep:list',
title: '订单管理',
url: 'TradeOrder',
},
{
color: '#3fb27f',
icon: 'ri:refund-2-line',
title: '售后管理',
url: 'TradeAfterSale',
},
{
color: '#4daf1bc9',
icon: 'fa-solid:project-diagram',
title: '分销管理',
url: 'TradeBrokerageUser',
},
{
color: '#1a73e8',
icon: 'ep:ticket',
title: '优惠券',
url: 'PromotionCoupon',
},
{
color: '#4daf1bc9',
icon: 'fa:group',
title: '拼团活动',
url: 'PromotionBargainActivity',
},
{
color: '#1a73e8',
icon: 'vaadin:money-withdraw',
title: '佣金提现',
url: 'TradeBrokerageWithdraw',
},
{
color: '#1a73e8',
icon: 'vaadin:money-withdraw',
title: '数据统计',
url: 'TradeBrokerageWithdraw',
},
];
const router = useRouter();
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
if (nav.url?.startsWith('http')) {
openWindow(nav.url);
return;
}
if (nav.url?.startsWith('/')) {
router.push(nav.url).catch((error) => {
console.error('Navigation failed:', error);
});
} else if (isString(nav.url)) {
router.push({ name: nav.url });
} else {
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
}
}
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="商城手册(功能开启)"
url="https://doc.iocoder.cn/mall/build/"
/>
</template>
<AnalysisOverview :items="overviewItems" />
<div class="mt-5 w-full lg:w-2/5">
<WorkbenchQuickNav
:items="quickNavItems"
class="mt-5 lg:mt-0"
title="快捷导航"
@click="navTo"
/>
</div>
</Page>
</template>

View File

@ -0,0 +1,135 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
import { z } from '#/adapter/form';
import {
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
getRangePickerDefaultProps,
} from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '品牌名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'picUrl',
label: '品牌图片',
component: 'ImageUpload',
rules: 'required',
},
{
fieldName: 'sort',
label: '品牌排序',
component: 'InputNumber',
componentProps: {
min: 0,
controlsPosition: 'right',
placeholder: '请输入品牌排序',
},
rules: z.number().min(0).default(1),
},
{
fieldName: 'status',
label: '品牌状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'description',
label: '品牌描述',
component: 'Textarea',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '品牌名称',
component: 'Input',
},
{
fieldName: 'status',
label: '品牌状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 表格列配置 */
export function useGridColumns(): VxeGridPropTypes.Columns {
return [
{
field: 'name',
title: '分类名称',
fixed: 'left',
},
{
field: 'picUrl',
title: '品牌图片',
cellRender: {
name: 'CellImage',
props: {
class: 'w-10 h-10',
},
},
},
{
field: 'sort',
title: '品牌排序',
},
{
field: 'status',
title: '开启状态',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallBrandApi } from '#/api/mall/product/brand';
import { Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteBrand, getBrandPage } from '#/api/mall/product/brand';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建品牌 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑品牌 */
function handleEdit(row: MallBrandApi.Brand) {
formModalApi.setData(row).open();
}
/** 删除品牌 */
async function handleDelete(row: MallBrandApi.Brand) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteBrand(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getBrandPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallBrandApi.Brand>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['品牌']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['product:brand:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['product:brand:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['product:brand:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,83 @@
<script lang="ts" setup>
import type { MallBrandApi } from '#/api/mall/product/brand';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { createBrand, getBrand, updateBrand } from '#/api/mall/product/brand';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallBrandApi.Brand>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['品牌'])
: $t('ui.actionTitle.create', ['品牌']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as MallBrandApi.Brand;
try {
await (formData.value?.id ? updateBrand(data) : createBrand(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallBrandApi.Brand>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getBrand(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,139 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallCategoryApi } from '#/api/mall/product/category';
import { handleTree } from '@vben/utils';
import { z } from '#/adapter/form';
import { getCategoryList } from '#/api/mall/product/category';
import { CommonStatusEnum, DICT_TYPE, getDictOptions } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'parentId',
label: '上级分类',
component: 'ApiTreeSelect',
componentProps: {
allowClear: true,
api: async () => {
const data = await getCategoryList({ parentId: 0 });
data.unshift({
id: 0,
name: '顶级分类',
picUrl: '',
sort: 0,
status: 0,
});
return handleTree(data);
},
labelField: 'name',
valueField: 'id',
childrenField: 'children',
placeholder: '请选择上级分类',
treeDefaultExpandAll: true,
},
rules: 'selectRequired',
},
{
fieldName: 'name',
label: '分类名称',
component: 'Input',
componentProps: {
placeholder: '请输入分类名称',
},
rules: 'required',
},
{
fieldName: 'picUrl',
label: '移动端分类图',
component: 'ImageUpload',
rules: 'required',
},
{
fieldName: 'sort',
label: '分类排序',
component: 'InputNumber',
componentProps: {
min: 0,
controlsPosition: 'right',
placeholder: '请输入分类排序',
},
rules: z.number().min(0).default(1),
},
{
fieldName: 'status',
label: '开启状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '分类名称',
component: 'Input',
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions<MallCategoryApi.Category>['columns'] {
return [
{
field: 'name',
title: '分类名称',
align: 'left',
fixed: 'left',
treeNode: true,
},
{
field: 'picUrl',
title: '移动端分类图',
cellRender: {
name: 'CellImage',
},
},
{
field: 'sort',
title: '分类排序',
},
{
field: 'status',
title: '开启状态',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 300,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,187 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallCategoryApi } from '#/api/mall/product/category';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteCategory, getCategoryList } from '#/api/mall/product/category';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建分类 */
function handleCreate() {
formModalApi.setData({}).open();
}
/** 添加下级分类 */
function handleAppend(row: MallCategoryApi.Category) {
formModalApi.setData({ parentId: row.id }).open();
}
/** 编辑分类 */
function handleEdit(row: MallCategoryApi.Category) {
formModalApi.setData(row).open();
}
/** 查看商品操作 */
const router = useRouter(); //
const handleViewSpu = (id: number) => {
router.push({
name: 'ProductSpu',
query: { categoryId: id },
});
};
/** 删除分类 */
async function handleDelete(row: MallCategoryApi.Category) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteCategory(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
/** 切换树形展开/收缩状态 */
const isExpanded = ref(false);
function toggleExpand() {
isExpanded.value = !isExpanded.value;
gridApi.grid.setAllTreeExpand(isExpanded.value);
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_, formValues) => {
return await getCategoryList(formValues);
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
},
treeConfig: {
parentField: 'parentId',
rowField: 'id',
transform: true,
reserve: true,
},
} as VxeTableGridOptions<MallCategoryApi.Category>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【产品】产品管理、产品分类"
url="https://doc.iocoder.cn/crm/product/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['分类']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['product:category:create'],
onClick: handleCreate,
},
{
label: isExpanded ? '收缩' : '展开',
type: 'primary',
onClick: toggleExpand,
},
]"
/>
</template>
<template #name="{ row }">
<div class="flex w-full items-center gap-1">
<span class="flex-auto">{{ row.name }}</span>
</div>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '新增下级',
type: 'primary',
link: true,
icon: ACTION_ICON.ADD,
auth: ['product:category:create'],
onClick: handleAppend.bind(null, row),
},
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['product:category:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '查看商品',
type: 'primary',
link: true,
icon: ACTION_ICON.VIEW,
auth: ['product:category:update'],
ifShow: row.parentId > 0,
onClick: handleViewSpu.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['product:category:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { MallCategoryApi } from '#/api/mall/product/category';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createCategory,
getCategory,
updateCategory,
} from '#/api/mall/product/category';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallCategoryApi.Category>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['产品分类'])
: $t('ui.actionTitle.create', ['产品分类']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as MallCategoryApi.Category;
try {
await (formData.value?.id ? updateCategory(data) : createCategory(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
let data = modalApi.getData<MallCategoryApi.Category>();
if (!data) {
return;
}
modalApi.lock();
try {
if (data.id) {
data = await getCategory(data.id as number);
}
// values
formData.value = data;
await formApi.setValues(data);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,202 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
import type { MallCommentApi } from '#/api/mall/product/comment';
import { getSpuSimpleList } from '#/api/mall/product/spu';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'spuId',
label: '商品',
component: 'ApiSelect',
componentProps: {
api: getSpuSimpleList,
labelField: 'name',
valueField: 'id',
},
rules: 'required',
},
{
fieldName: 'userAvatar',
label: '用户头像',
component: 'ImageUpload',
rules: 'required',
},
{
fieldName: 'userNickname',
label: '用户名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'content',
label: '评论内容',
component: 'Textarea',
rules: 'required',
},
{
fieldName: 'descriptionScores',
label: '描述星级',
component: 'Rate',
rules: 'required',
},
{
fieldName: 'benefitScores',
label: '服务星级',
component: 'Rate',
rules: 'required',
},
{
fieldName: 'picUrls',
label: '评论图片',
component: 'ImageUpload',
componentProps: {
maxNumber: 9,
},
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'replyStatus',
label: '回复状态',
component: 'Select',
componentProps: {
options: [
{ label: '已回复', value: true },
{ label: '未回复', value: false },
],
},
},
{
fieldName: 'spuName',
label: '商品名称',
component: 'Input',
},
{
fieldName: 'userNickname',
label: '用户名称',
component: 'Input',
},
{
fieldName: 'orderId',
label: '订单编号',
component: 'Input',
},
{
fieldName: 'createTime',
label: '评论时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 表格列配置 */
export function useGridColumns<T = MallCommentApi.Comment>(
onStatusChange?: (
newStatus: boolean,
row: T,
) => PromiseLike<boolean | undefined>,
): VxeGridPropTypes.Columns {
return [
{
field: 'id',
title: '评论编号',
fixed: 'left',
},
{
field: 'skuPicUrl',
title: '商品图片',
cellRender: {
name: 'CellImage',
},
},
{
field: 'spuName',
title: '商品名称',
minWidth: 200,
},
{
field: 'skuProperties',
title: '商品属性',
minWidth: 200,
formatter: ({ cellValue }) => {
return cellValue && cellValue.length > 0
? cellValue
.map((item: any) => `${item.propertyName} : ${item.valueName}`)
.join('\n')
: '-';
},
},
{
field: 'userNickname',
title: '用户名称',
},
{
field: 'descriptionScores',
title: '商品评分',
},
{
field: 'benefitScores',
title: '服务评分',
},
{
field: 'content',
title: '评论内容',
},
{
field: 'picUrls',
title: '评论图片',
cellRender: {
name: 'CellImages',
},
},
{
field: 'replyContent',
title: '回复内容',
},
{
field: 'createTime',
title: '评论时间',
formatter: 'formatDateTime',
},
{
field: 'visible',
title: '是否展示',
align: 'center',
cellRender: {
attrs: { beforeChange: onStatusChange },
name: 'CellSwitch',
props: {
checkedValue: true,
unCheckedValue: false,
},
},
},
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,159 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallCommentApi } from '#/api/mall/product/comment';
import { h } from 'vue';
import { confirm, DocAlert, Page, prompt, useVbenModal } from '@vben/common-ui';
import { ElInput, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getCommentPage,
replyComment,
updateCommentVisible,
} from '#/api/mall/product/comment';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建评价 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 回复评价 */
function handleReply(row: MallCommentApi.Comment) {
prompt({
component: () => {
return h(ElInput, {
type: 'textarea',
});
},
content: row.content
? `用户评论:${row.content}\n请输入回复内容`
: '请输入回复内容:',
title: '回复评论',
modelPropName: 'value',
}).then(async (val) => {
if (val) {
await replyComment({
id: row.id as number,
replyContent: val,
});
onRefresh();
}
});
}
/** 更新状态 */
async function handleStatusChange(
newStatus: boolean,
row: MallCommentApi.Comment,
): Promise<boolean | undefined> {
return new Promise((resolve, reject) => {
const text = newStatus ? '展示' : '隐藏';
confirm({
content: `确认要${text + row.id}评论吗?`,
})
.then(async () => {
//
const res = await updateCommentVisible({
id: row.id as number,
visible: newStatus,
});
if (res) {
//
ElMessage.success(`${text}成功`);
resolve(true);
} else {
reject(new Error('操作失败'));
}
})
.catch(() => {
reject(new Error('取消操作'));
});
});
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(handleStatusChange),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCommentPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallCommentApi.Comment>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【商品】商品评价"
url="https://doc.iocoder.cn/mall/product-comment/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['虚拟评论']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['product:comment:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '回复',
type: 'primary',
link: true,
auth: ['product:comment:update'],
onClick: handleReply.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,83 @@
<script lang="ts" setup>
import type { MallCommentApi } from '#/api/mall/product/comment';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { createComment, getComment } from '#/api/mall/product/comment';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallCommentApi.Comment>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['品牌'])
: $t('ui.actionTitle.create', ['品牌']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as MallCommentApi.Comment;
try {
await createComment(data);
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallCommentApi.Comment>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getComment(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,176 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { getPropertySimpleList } from '#/api/mall/product/property';
// ============================== 属性 ==============================
/** 类型新增/修改的表单 */
export function usePropertyFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '名称',
component: 'Input',
componentProps: {
placeholder: '请输入名称',
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 类型列表的搜索表单 */
export function usePropertyGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '名称',
component: 'Input',
componentProps: {
placeholder: '请输入名称',
clearable: true,
},
},
];
}
/** 类型列表的字段 */
export function usePropertyGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
},
{
field: 'name',
title: '名称',
},
{
field: 'remark',
title: '备注',
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
// ============================== 值数据 ==============================
/** 数据新增/修改的表单 */
export function useValueFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'propertyId',
label: '属性编号',
component: 'ApiSelect',
componentProps: (values) => {
return {
api: getPropertySimpleList,
labelField: 'name',
valueField: 'id',
disabled: !!values.id,
};
},
rules: 'required',
dependencies: {
triggerFields: [''],
},
},
{
fieldName: 'name',
label: '名称',
component: 'Input',
componentProps: {
placeholder: '请输入名称',
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
},
},
];
}
/** 字典数据列表搜索表单 */
export function useValueGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '名称',
component: 'Input',
componentProps: {
clearable: true,
},
},
];
}
/**
*
*/
export function useValueGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
},
{
field: 'name',
title: '属性值名称',
},
{
field: 'remark',
title: '备注',
},
{
title: '创建时间',
field: 'createTime',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,35 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { DocAlert, Page } from '@vben/common-ui';
import PropertyGrid from './modules/property-grid.vue';
import ValueGrid from './modules/value-grid.vue';
const searchPropertyId = ref<number>(); // ID
function handlePropertyIdSelect(propertyId: number) {
searchPropertyId.value = propertyId;
}
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【商品】商品属性"
url="https://doc.iocoder.cn/mall/product-property/"
/>
</template>
<div class="flex h-full">
<!-- 左侧属性列表 -->
<div class="w-1/2 pr-3">
<PropertyGrid @select="handlePropertyIdSelect" />
</div>
<!-- 右侧属性数据列表 -->
<div class="w-1/2">
<ValueGrid :property-id="searchPropertyId" />
</div>
</div>
</Page>
</template>

View File

@ -0,0 +1,88 @@
<script lang="ts" setup>
import type { MallPropertyApi } from '#/api/mall/product/property';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createProperty,
getProperty,
updateProperty,
} from '#/api/mall/product/property';
import { $t } from '#/locales';
import { usePropertyFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallPropertyApi.Property>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['属性'])
: $t('ui.actionTitle.create', ['属性']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: usePropertyFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as MallPropertyApi.Property;
try {
await (formData.value?.id ? updateProperty(data) : createProperty(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallPropertyApi.Property>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getProperty(data.id as number);
// values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,140 @@
<script lang="ts" setup>
import type {
VxeGridListeners,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { MallPropertyApi } from '#/api/mall/product/property';
import { useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteProperty, getPropertyPage } from '#/api/mall/product/property';
import { $t } from '#/locales';
import { usePropertyGridColumns, usePropertyGridFormSchema } from '../data';
import PropertyForm from './property-form.vue';
const emit = defineEmits(['select']);
const [PropertyFormModal, propertyFormModalApi] = useVbenModal({
connectedComponent: PropertyForm,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建属性 */
function handleCreate() {
propertyFormModalApi.setData(null).open();
}
/** 编辑属性 */
function handleEdit(row: any) {
propertyFormModalApi.setData(row).open();
}
/** 删除属性 */
async function handleDelete(row: MallPropertyApi.Property) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteProperty(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
/** 表格事件 */
const gridEvents: VxeGridListeners<MallPropertyApi.Property> = {
cellClick: ({ row }) => {
emit('select', row.id);
},
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: usePropertyGridFormSchema(),
},
gridOptions: {
columns: usePropertyGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPropertyPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isCurrent: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallPropertyApi.Property>,
gridEvents,
});
</script>
<template>
<div class="h-full">
<PropertyFormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['属性']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['product:property:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['product:property:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['product:property:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</div>
</template>

View File

@ -0,0 +1,100 @@
<script lang="ts" setup>
import type { MallPropertyApi } from '#/api/mall/product/property';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createPropertyValue,
getPropertyValue,
updatePropertyValue,
} from '#/api/mall/product/property';
import { $t } from '#/locales';
import { useValueFormSchema } from '../data';
defineOptions({ name: 'MallPropertyValueForm' });
const emit = defineEmits(['success']);
const formData = ref<MallPropertyApi.PropertyValue>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['属性值'])
: $t('ui.actionTitle.create', ['属性值']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useValueFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as MallPropertyApi.PropertyValue;
try {
await (formData.value?.id
? updatePropertyValue(data)
: createPropertyValue(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<
MallPropertyApi.PropertyValue | { propertyId?: string }
>();
// ID
if (data && 'id' in data && data.id) {
modalApi.lock();
try {
formData.value = await getPropertyValue(data.id as number);
// values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
} else if (data && 'propertyId' in data && data.propertyId) {
// propertyId
await formApi.setValues({
propertyId: data.propertyId,
});
}
},
});
</script>
<template>
<Modal :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,149 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallPropertyApi } from '#/api/mall/product/property';
import { watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deletePropertyValue,
getPropertyValuePage,
} from '#/api/mall/product/property';
import { $t } from '#/locales';
import { useValueGridColumns, useValueGridFormSchema } from '../data';
import ValueForm from './value-form.vue';
const props = defineProps({
propertyId: {
type: Number,
default: undefined,
},
});
const [ValueFormModal, valueFormModalApi] = useVbenModal({
connectedComponent: ValueForm,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建字典数据 */
function handleCreate() {
valueFormModalApi.setData({ propertyId: props.propertyId }).open();
}
/** 编辑字典数据 */
function handleEdit(row: MallPropertyApi.PropertyValue) {
valueFormModalApi.setData(row).open();
}
/** 删除字典数据 */
async function handleDelete(row: MallPropertyApi.PropertyValue) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deletePropertyValue(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useValueGridFormSchema(),
},
gridOptions: {
columns: useValueGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPropertyValuePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
propertyId: props.propertyId,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallPropertyApi.PropertyValue>,
});
/** 监听 dictType 变化,重新查询 */
watch(
() => props.propertyId,
() => {
if (props.propertyId) {
onRefresh();
}
},
);
</script>
<template>
<div class="flex h-full flex-col">
<ValueFormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['属性值']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['product:property:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['product:property:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['product:property:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</div>
</template>

View File

@ -0,0 +1,42 @@
import type { MallSpuApi } from '#/api/mall/product/spu';
import type { PropertyAndValues } from './model';
/**
* -
*
* @param spu
* @return PropertyAndValues
*/
const getPropertyList = (spu: MallSpuApi.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 (
index !== undefined &&
index >= 0 &&
!properties[index]!.values?.some((value) => value.id === valueId)
) {
properties[index]!.values?.push({ id: valueId!, name: valueName! });
}
},
);
});
}
return properties;
};
export { getPropertyList };

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,138 @@
<script lang="ts" setup>
import { useVbenForm } from '#/adapter/form';
import { handleTree } from '@vben/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>

View File

@ -0,0 +1,25 @@
import SkuList from './SkuList.vue';
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;
}
export { SkuList, PropertyAndValues, RuleConfig, getPropertyList };

View File

@ -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>

View File

@ -0,0 +1,196 @@
<!-- 商品发布 - 库存价格 - 属性列表 -->
<template>
<ElCol v-for="(item, index) in attributeList" :key="index">
<div>
<ElText class="mx-1">属性名</ElText>
<ElTag class="mx-1" type="success" @close="handleCloseProperty(index)">
{{ item.name }}
</ElTag>
</div>
<div>
<ElSpace :size="1">
<ElText class="mx-1">属性值</ElText>
<ElTag
v-for="(value, valueIndex) in item.values"
:key="value.id"
class="mx-1"
@close="handleCloseValue(index, valueIndex)"
>
{{ value.name }}
</ElTag>
<ElSelect
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"
/>
</ElSelect>
<ElButton
v-show="!inputVisible(index)"
class="button-new-tag ml-1"
size="small"
@click="showInput(index)"
>
+ 添加
</ElButton>
</ElSpace>
</div>
<ElDivider class="my-10px" />
</ElCol>
</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,
ElCol,
ElTag,
ElText,
ElButton,
ElDivider,
ElSpace,
} from 'element-plus';
import { $t } from '#/locales';
import type { PropertyAndValues } from './model';
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) {
const values = attributeList.value[index]!.values as any[];
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.
const values = attributeList.value[index]!.values as any[];
if (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 = '';
const values = attributeList.value[index]!.values as any[];
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,
});
const values = attributeList.value[index]!.values as any[];
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>

View File

@ -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';
// Propertyvalues
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>

View File

@ -0,0 +1,243 @@
<script lang="ts" setup>
import { useVbenForm } from '#/adapter/form';
import { watch, ref } from 'vue';
import { ElMessage, ElSpace } from 'element-plus';
import SkuList from './sku-list.vue';
import { Page, useVbenModal } from '@vben/common-ui';
import ProductPropertyAddForm from './product-property-add-form.vue';
import { getPropertyList } from './data';
import ProductAttributes from './product-attributes.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,
},
},
{
fieldName: 'batchSettings',
label: '批量设置',
component: 'Input',
dependencies: {
triggerFields: ['specType'],
show: (values) => values.specType && propertyList.value.length > 0,
},
},
{
fieldName: 'specTypeItemList',
label: '规格列表',
component: 'Input',
dependencies: {
triggerFields: ['specType'],
show: (values) => values.specType && propertyList.value.length > 0,
},
},
],
showDefaultActions: false,
});
const [ProductPropertyAddFormModal, productPropertyAddFormApi] = useVbenModal({
connectedComponent: ProductPropertyAddForm,
destroyOnClose: true,
});
/** 调用 SkuList generateTableData 方法*/
const skuListRef = ref();
const generateSkus = (propertyList: any[]) => {
skuListRef.value.generateTableData(propertyList);
};
/** 将传进来的值赋值给 formData */
watch(
() => props.propFormData,
(data) => {
if (!data) {
return;
}
// SKU PropertyAndValues
propertyList.value = getPropertyList(data);
},
{
immediate: true,
},
);
</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>
<ElSpace direction="vertical" alignment="flex-start">
<ElButton type="primary" @click="productPropertyAddFormApi.open()"
>添加属性</ElButton
>
<ProductAttributes
:property-list="propertyList"
@success="generateSkus"
/>
</ElSpace>
</template>
<template #batchSettings>
<SkuList
:is-batch="true"
:prop-form-data="props.propFormData"
:property-list="propertyList"
/>
</template>
<template #specTypeItemList>
<SkuList
:prop-form-data="props.propFormData"
:property-list="propertyList"
:rule-config="ruleConfig"
/>
</template>
</Form>
<ProductPropertyAddFormModal :propertyList="propertyList" />
</Page>
</template>

View File

@ -0,0 +1,527 @@
<template>
<!-- 情况一添加/修改 -->
<el-table
v-if="!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="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 } from '#/utils';
import { formatToFraction, isEmpty } from '@vben/utils';
import type { PropertyAndValues, RuleConfig } from './model';
import UploadImg from '#/components/upload/image-upload.vue';
import { ElTable, ElInput, ElMessage } from 'element-plus';
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,
}, //
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) {
// restItemundefined
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) => {
// nameindex
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>

View File

@ -0,0 +1,117 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { handleTree } from '@vben/utils';
import { getCategoryList } from '#/api/mall/product/category';
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '商品名称',
component: 'Input',
},
{
fieldName: 'categoryId',
label: '商品分类',
component: 'ApiTreeSelect',
componentProps: {
api: async () => {
const res = await getCategoryList({});
return handleTree(res, 'id', 'parentId', 'children');
},
fieldNames: { label: 'name', value: 'id', children: 'children' },
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns<T = MallSpuApi.Spu>(
onStatusChange?: (
newStatus: number,
row: T,
) => PromiseLike<boolean | undefined>,
): VxeTableGridOptions['columns'] {
return [
{
type: 'expand',
width: 80,
slots: { content: 'expand_content' },
fixed: 'left',
},
{
field: 'id',
title: '商品编号',
fixed: 'left',
},
{
field: 'name',
title: '商品名称',
fixed: 'left',
minWidth: 200,
},
{
field: 'picUrl',
title: '商品图片',
cellRender: {
name: 'CellImage',
},
},
{
field: 'price',
title: '价格',
formatter: 'formatAmount2',
},
{
field: 'salesCount',
title: '销量',
},
{
field: 'stock',
title: '库存',
},
{
field: 'sort',
title: '排序',
},
{
field: 'status',
title: '销售状态',
cellRender: {
attrs: { beforeChange: onStatusChange },
name: 'CellSwitch',
props: {
checkedValue: 1,
checkedChildren: '上架',
unCheckedValue: 0,
unCheckedChildren: '下架',
},
},
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 300,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,349 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { confirm, DocAlert, Page } from '@vben/common-ui';
import {
downloadFileFromBlobPart,
fenToYuan,
handleTree,
treeToString,
} from '@vben/utils';
import { ElDescriptions, ElLoading, ElMessage, ElTabs } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCategoryList } from '#/api/mall/product/category';
import {
deleteSpu,
exportSpu,
getSpuPage,
getTabsCount,
updateStatus,
} from '#/api/mall/product/spu';
import { $t } from '#/locales';
import { ProductSpuStatusEnum } from '#/utils';
import { useGridColumns, useGridFormSchema } from './data';
const { push } = useRouter();
const tabType = ref(0);
const categoryList = ref();
// tabs
const tabsData = ref([
{
name: '出售中',
type: 0,
count: 0,
},
{
name: '仓库中',
type: 1,
count: 0,
},
{
name: '已售罄',
type: 2,
count: 0,
},
{
name: '警戒库存',
type: 3,
count: 0,
},
{
name: '回收站',
type: 4,
count: 0,
},
]);
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 获得每个 Tab 的数量 */
async function getTabCount() {
const res = await getTabsCount();
for (const objName in res) {
const index = Number(objName);
if (tabsData.value[index]) {
tabsData.value[index].count = res[objName] as number;
}
}
}
/** 创建商品 */
function handleCreate() {
push({ name: 'ProductSpuAdd' });
}
/** 导出表格 */
async function handleExport() {
const data = await exportSpu(await gridApi.formApi.getValues());
downloadFileFromBlobPart({ fileName: '商品.xls', source: data });
}
/** 编辑商品 */
function handleEdit(row: MallSpuApi.Spu) {
push({ name: 'ProductSpuEdit', params: { id: row.id } });
}
/** 删除商品 */
async function handleDelete(row: MallSpuApi.Spu) {
const hideLoading = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteSpu(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
hideLoading.close();
}
}
/** 添加到仓库 / 回收站的状态 */
async function handleStatus02Change(row: MallSpuApi.Spu, newStatus: number) {
//
const text =
newStatus === ProductSpuStatusEnum.RECYCLE.status
? '加入到回收站'
: '恢复到仓库';
confirm(`确认要"${row.name}"${text}吗?`)
.then(async () => {
await updateStatus({ id: row.id as number, status: newStatus });
ElMessage.success(`${text}成功`);
onRefresh();
})
.catch(() => {
ElMessage.error(`${text}失败`);
});
}
/** 更新状态 */
async function handleStatusChange(
newStatus: number,
row: MallSpuApi.Spu,
): Promise<boolean | undefined> {
return new Promise((resolve, reject) => {
//
const text = row.status ? '上架' : '下架';
confirm({
content: `确认要${text + row.name}吗?`,
})
.then(async () => {
//
const res = await updateStatus({
id: row.id as number,
status: newStatus,
});
if (res) {
//
ElMessage.success(`${text}成功`);
resolve(true);
} else {
reject(new Error('操作失败'));
}
})
.catch(() => {
reject(new Error('取消操作'));
});
});
}
/** 查看商品详情 */
function handleDetail(row: MallSpuApi.Spu) {
push({ name: 'ProductSpuDetail', params: { id: row.id } });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(handleStatusChange),
height: 'auto',
cellConfig: {
height: 80,
},
expandConfig: {
height: 100,
},
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getSpuPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
tabType: tabType.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
resizable: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallSpuApi.Spu>,
});
function onChangeTab(key: any) {
tabType.value = Number(key);
gridApi.query();
}
onMounted(() => {
getTabCount();
getCategoryList({}).then((res) => {
categoryList.value = handleTree(res, 'id', 'parentId', 'children');
});
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【商品】商品 SPU 与 SKU"
url="https://doc.iocoder.cn/mall/product-spu-sku/"
/>
</template>
<Grid>
<template #top>
<ElTabs class="border-none" @change="onChangeTab">
<ElTabs.TabPane
v-for="item in tabsData"
:key="item.type"
:tab="`${item.name} (${item.count})`"
/>
</ElTabs>
</template>
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['商品']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['product:spu:create'],
onClick: handleCreate,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['product:spu:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #expand_content="{ row }">
<ElDescriptions
:column="4"
class="mt-4"
:label-style="{
width: '100px',
fontWeight: 'bold',
fontSize: '14px',
}"
:content-style="{ width: '100px', fontSize: '14px' }"
>
<ElDescriptions.Item label="商品分类">
{{ treeToString(categoryList, row.categoryId) }}
</ElDescriptions.Item>
<ElDescriptions.Item label="商品名称">
{{ row.name }}
</ElDescriptions.Item>
<ElDescriptions.Item label="市场价">
{{ fenToYuan(row.marketPrice) }}
</ElDescriptions.Item>
<ElDescriptions.Item label="成本价">
{{ fenToYuan(row.costPrice) }}
</ElDescriptions.Item>
<ElDescriptions.Item label="浏览量">
{{ row.browseCount }}
</ElDescriptions.Item>
<ElDescriptions.Item label="虚拟销量">
{{ row.virtualSalesCount }}
</ElDescriptions.Item>
</ElDescriptions>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['product:spu:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.detail'),
type: 'primary',
link: true,
icon: ACTION_ICON.VIEW,
onClick: handleDetail.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['product:spu:delete'],
ifShow: () => row.type === 4,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
{
label: '恢复',
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['product:spu:update'],
ifShow: () => row.type === 4,
onClick: handleStatus02Change.bind(
null,
row,
ProductSpuStatusEnum.DISABLE.status,
),
},
{
label: '回收',
type: 'danger',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['product:spu:update'],
ifShow: () => row.type !== 4,
onClick: handleStatus02Change.bind(
null,
row,
ProductSpuStatusEnum.RECYCLE.status,
),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,499 @@
<script lang="ts" setup>
import { useRouter, useRoute } from 'vue-router';
import { ref, onMounted } from 'vue';
import { floatToFixed2 } from '@vben/utils';
import * as ProductSpuApi from '#/api/mall/product/spu';
import type { MallSpuApi } from '#/api/mall/product/spu';
import * as ProductCategoryApi from '#/api/mall/product/category';
import * as ProductBrandApi from '#/api/mall/product/brand';
import { Page } from '@vben/common-ui';
import { getIntDictOptions, DICT_TYPE } from '#/utils/dict';
import {
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElCarousel,
ElCarouselItem,
ElImage,
ElDivider,
ElButton,
ElTabs,
ElTabPane,
ElTag,
ElBadge,
ElEmpty,
} from 'element-plus';
import { IconifyIcon } from '@vben/icons';
interface Category {
id: number;
name: string;
children?: Category[];
}
interface Brand {
id: number;
name: string;
}
interface DictData {
value: number | string;
label: string;
colorType?: string;
cssClass?: string;
}
const { push } = useRouter(); //
const { params } = useRoute(); //
const formLoading = ref(false); // 12
const activeTab = ref('basic'); //
const categoryList = ref<Category[]>([]); //
const brandList = ref<Brand[]>([]); //
const deliveryTypeDict = ref<DictData[]>([]); //
// 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 getDeliveryTypeDict = async () => {
try {
deliveryTypeDict.value = await getIntDictOptions(
DICT_TYPE.TRADE_DELIVERY_TYPE,
);
} catch (error) {
console.error('获取配送方式字典失败', error);
}
};
/** 获取商品分类列表 */
const getCategoryList = async () => {
try {
const data = await ProductCategoryApi.getCategorySimpleList();
categoryList.value = data as Category[];
} catch (error) {
console.error('获取商品分类失败', error);
}
};
/** 获取商品品牌列表 */
const getBrandList = async () => {
try {
const data = await ProductBrandApi.getSimpleBrandList();
brandList.value = data as Brand[];
} catch (error) {
console.error('获取商品品牌失败', error);
}
};
/** 根据ID获取分类名称 */
const getCategoryNameById = (id: number | undefined) => {
if (!id || !categoryList.value || categoryList.value.length === 0)
return '未知分类';
const category = categoryList.value.find((item) => item.id === id);
return category ? category.name : '未知分类';
};
/** 根据ID获取品牌名称 */
const getBrandNameById = (id: number | undefined) => {
if (!id || !brandList.value || brandList.value.length === 0)
return '未知品牌';
const brand = brandList.value.find((item) => item.id === id);
return brand ? brand.name : '未知品牌';
};
/** 根据值获取配送方式名称 */
const getDeliveryTypeName = (value: number) => {
if (!deliveryTypeDict.value || deliveryTypeDict.value.length === 0)
return `${value}`;
const dict = deliveryTypeDict.value.find((item) => item.value === value);
return dict ? dict.label : `${value}`;
};
/** 获得详情 */
const getDetail = async () => {
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) => {
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);
});
formData.value = res;
} finally {
formLoading.value = false;
}
}
};
/** 返回列表 */
const back = () => {
push({ name: 'ProductSpu' });
};
/** 编辑商品 */
const editProduct = () => {
push({ name: 'ProductSpuForm', params: { id: params.id } });
};
/** 初始化 */
onMounted(async () => {
await Promise.all([getCategoryList(), getBrandList(), getDeliveryTypeDict()]);
await getDetail();
});
</script>
<template>
<Page auto-content-height :loading="formLoading">
<template #title>
<span class="text-lg font-bold">商品详情</span>
</template>
<template #extra>
<div class="flex gap-2">
<ElButton type="primary" @click="editProduct">
<IconifyIcon icon="ep:edit" class="mr-1" />
编辑商品
</ElButton>
<ElButton @click="back">
<IconifyIcon icon="ep:back" class="mr-1" />
返回列表
</ElButton>
</div>
</template>
<ElCard shadow="hover" class="mb-4">
<div class="mb-4 flex flex-col gap-4 md:flex-row md:items-center">
<ElImage
:src="formData.picUrl"
fit="contain"
style="width: 120px; height: 120px"
class="rounded border"
/>
<div class="flex-grow">
<h1 class="mb-2 text-xl font-bold">{{ formData.name }}</h1>
<div class="mb-2 text-gray-500">
{{ formData.introduction || '暂无简介' }}
</div>
<div class="flex flex-wrap gap-2">
<ElTag v-if="formData.specType" type="success"></ElTag>
<ElTag v-else type="info">单规格</ElTag>
<ElTag v-if="formData.subCommissionType" type="warning"></ElTag>
<ElTag type="danger"
>库存:
{{
formData.skus?.reduce(
(sum, sku) => sum + (sku.stock || 0),
0,
) || 0
}}</ElTag
>
<ElTag type="info"
>分类: {{ getCategoryNameById(formData.categoryId) }}</ElTag
>
</div>
</div>
</div>
<ElTabs v-model="activeTab" type="border-card">
<ElTabPane name="basic" label="基本信息">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- 基本信息 -->
<ElCard shadow="never" header="商品信息" class="h-full">
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="商品名称">{{
formData.name
}}</ElDescriptionsItem>
<ElDescriptionsItem label="商品分类">
<ElTag type="success">{{
getCategoryNameById(formData.categoryId)
}}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="商品品牌">
<ElTag type="primary">{{
getBrandNameById(formData.brandId)
}}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="关键字">
<ElTag type="danger"></ElTag
>{{ formData.keyword || '无' }}</ElDescriptionsItem
>
<ElDescriptionsItem label="赠送积分">{{
formData.giveIntegral
}}</ElDescriptionsItem>
<ElDescriptionsItem label="虚拟销量">{{
formData.virtualSalesCount
}}</ElDescriptionsItem>
<ElDescriptionsItem label="排序">{{
formData.sort
}}</ElDescriptionsItem>
<ElDescriptionsItem label="规格类型">
<ElTag :type="formData.specType ? 'success' : 'info'">
{{ formData.specType ? '多规格' : '单规格' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="分销类型">
<ElTag
:type="formData.subCommissionType ? 'warning' : 'info'"
>
{{ formData.subCommissionType ? '单独设置' : '默认设置' }}
</ElTag>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<!-- 配送信息 -->
<ElCard shadow="never" header="配送信息" class="h-full">
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="配送方式">
<div class="flex flex-wrap gap-2">
<ElTag
v-for="(type, index) in formData.deliveryTypes"
:key="index"
:type="
(deliveryTypeDict.find((dict) => dict.value === type)
?.colorType as
| 'success'
| 'warning'
| 'info'
| 'danger'
| 'primary'
| undefined) || undefined
"
>
{{ getDeliveryTypeName(type) }}
</ElTag>
<span
v-if="
!formData.deliveryTypes ||
formData.deliveryTypes.length === 0
"
class="text-gray-400"
>
暂无配送方式
</span>
</div>
</ElDescriptionsItem>
<ElDescriptionsItem label="运费模板">{{
formData.deliveryTemplateId || '未设置'
}}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
</ElTabPane>
<ElTabPane name="images" label="商品图片">
<ElCard shadow="never" header="商品轮播图">
<div
v-if="formData.sliderPicUrls && formData.sliderPicUrls.length > 0"
>
<ElCarousel
height="400px"
:interval="4000"
indicator-position="outside"
arrow="always"
>
<ElCarouselItem
v-for="(item, index) in formData.sliderPicUrls"
:key="index"
>
<div class="flex h-full items-center justify-center">
<ElImage
:src="item"
fit="contain"
class="max-h-full"
:preview-src-list="formData.sliderPicUrls"
:initial-index="index"
/>
</div>
</ElCarouselItem>
</ElCarousel>
<div class="mt-6 flex flex-wrap justify-center gap-3">
<div
v-for="(item, index) in formData.sliderPicUrls"
:key="index"
class="cursor-pointer rounded border p-1"
>
<ElImage
:src="item"
fit="cover"
style="width: 80px; height: 80px"
/>
</div>
</div>
</div>
<ElEmpty v-else description="暂无轮播图" />
</ElCard>
</ElTabPane>
<ElTabPane name="sku" label="SKU信息">
<div v-if="formData.skus && formData.skus.length > 0">
<div
v-for="(sku, index) in formData.skus"
:key="index"
class="mb-6"
>
<ElCard
shadow="hover"
:header="`规格 ${index + 1}${sku.properties && sku.properties.length > 0 ? ' - ' + sku.properties.map((p) => p.valueName).join('/') : ''}`"
>
<div class="flex flex-col gap-4 md:flex-row">
<ElImage
:src="sku.picUrl || formData.picUrl"
fit="contain"
style="width: 120px; height: 120px"
class="flex-shrink-0 rounded border"
/>
<div class="grid flex-grow grid-cols-1 gap-6 md:grid-cols-3">
<!-- 价格信息 -->
<div class="rounded bg-gray-50 p-4">
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
价格信息
</h3>
<div class="grid grid-cols-2 gap-2">
<div class="text-gray-500">销售价:</div>
<div class="font-bold text-red-500">
¥{{ sku.price }}
</div>
<div class="text-gray-500">市场价:</div>
<div>¥{{ sku.marketPrice }}</div>
<div class="text-gray-500">成本价:</div>
<div>¥{{ sku.costPrice }}</div>
</div>
</div>
<!-- 库存信息 -->
<div class="rounded bg-gray-50 p-4">
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
库存信息
</h3>
<div class="grid grid-cols-2 gap-2">
<div class="text-gray-500">库存:</div>
<div class="font-bold">{{ sku.stock }} </div>
<div class="text-gray-500">条码:</div>
<div>{{ sku.barCode || '未设置' }}</div>
</div>
</div>
<!-- 物流信息 -->
<div class="rounded bg-gray-50 p-4">
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
物流信息
</h3>
<div class="grid grid-cols-2 gap-2">
<div class="text-gray-500">重量:</div>
<div>{{ sku.weight }} kg</div>
<div class="text-gray-500">体积:</div>
<div>{{ sku.volume }} </div>
</div>
</div>
</div>
</div>
<!-- 分销佣金 -->
<div
v-if="formData.subCommissionType"
class="mt-4 rounded bg-yellow-50 p-4"
>
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
分销佣金
</h3>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
<div class="text-gray-500">一级佣金:</div>
<div class="font-bold">¥{{ sku.firstBrokeragePrice }}</div>
<div class="text-gray-500">二级佣金:</div>
<div class="font-bold">¥{{ sku.secondBrokeragePrice }}</div>
</div>
</div>
<!-- 规格属性 -->
<div
v-if="sku.properties && sku.properties.length > 0"
class="mt-4"
>
<h3 class="mb-2 font-bold text-gray-700">规格属性</h3>
<div class="flex flex-wrap gap-2">
<ElTag
v-for="(prop, propIndex) in sku.properties"
:key="propIndex"
effect="dark"
class="text-sm"
>
{{ prop.propertyName }}: {{ prop.valueName }}
</ElTag>
</div>
</div>
</ElCard>
</div>
</div>
<ElEmpty v-else description="暂无SKU信息" />
</ElTabPane>
<ElTabPane name="detail" label="商品详情">
<ElCard shadow="never" body-style="padding: 0;">
<div v-if="formData.description" class="product-description">
<div v-html="formData.description"></div>
</div>
<ElEmpty v-else description="暂无商品详情" />
</ElCard>
</ElTabPane>
</ElTabs>
</ElCard>
</Page>
</template>
<style scoped>
.product-description {
padding: 20px;
background-color: #fff;
border-radius: 4px;
}
.product-description :deep(img) {
max-width: 100%;
height: auto;
}
.product-description :deep(table) {
width: 100%;
border-collapse: collapse;
}
.product-description :deep(table td) {
padding: 8px;
border: 1px solid #eee;
}
</style>

View File

@ -0,0 +1,186 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { onMounted, ref, unref } from 'vue';
import { cloneDeep } from '@vben/utils';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { useRouter, useRoute } from 'vue-router';
import { formatToFraction, convertToInteger } from '@vben/utils';
import * as ProductSpuApi from '#/api/mall/product/spu';
import { ElMessage } from 'element-plus';
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); // 12
const { push } = useRouter(); //
const { params } = useRoute(); //
/** 获得详情 */
const getDetail = async () => {
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) => {
//
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 infoRef = ref<InstanceType<typeof InfoForm>>();
const skuRef = ref<InstanceType<typeof SkuForm>>();
const deliveryRef = ref<InstanceType<typeof DeliveryForm>>();
const descriptionRef = ref<InstanceType<typeof DescriptionForm>>();
const otherRef = ref<InstanceType<typeof OtherForm>>();
const submitForm = async () => {
//
formLoading.value = true;
try {
//
await unref(infoRef)?.validate();
await unref(skuRef)?.validate();
await unref(deliveryRef)?.validate();
await unref(descriptionRef)?.validate();
await unref(otherRef)?.validate();
// , server
const deepCopyFormData = cloneDeep(unref(formData.value)) as MallSpuApi.Spu;
deepCopyFormData.skus!.forEach((item) => {
// sku name
item.name = deepCopyFormData.name;
// sku
item.price = convertToInteger(item.price);
item.marketPrice = convertToInteger(item.marketPrice);
item.costPrice = convertToInteger(item.costPrice);
item.firstBrokeragePrice = convertToInteger(item.firstBrokeragePrice);
item.secondBrokeragePrice = convertToInteger(item.secondBrokeragePrice);
});
//
const newSliderPicUrls: any[] = [];
deepCopyFormData.sliderPicUrls!.forEach((item: any) => {
//
typeof item === 'object'
? newSliderPicUrls.push(item.url)
: newSliderPicUrls.push(item);
});
deepCopyFormData.sliderPicUrls = newSliderPicUrls;
//
const data = deepCopyFormData as MallSpuApi.Spu;
const id = params.id as unknown as number;
if (!id) {
await ProductSpuApi.createSpu(data);
ElMessage.success('创建成功');
} else {
await ProductSpuApi.updateSpu(data);
ElMessage.success('更新成功');
}
close();
} 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"
ref="infoRef"
/>
</ElTabPane>
<ElTabPane label="价格库存" name="sku">
<SkuForm
:propFormData="formData"
v-model:activeName="activeName"
ref="skuRef"
/>
</ElTabPane>
<ElTabPane label="物流设置" name="delivery">
<DeliveryForm
:propFormData="formData"
v-model:activeName="activeName"
ref="deliveryRef"
/>
</ElTabPane>
<ElTabPane label="商品详情" name="description">
<DescriptionForm
:propFormData="formData"
v-model:activeName="activeName"
ref="descriptionRef"
/>
</ElTabPane>
<ElTabPane label="其它设置" name="other">
<OtherForm
:propFormData="formData"
v-model:activeName="activeName"
ref="otherRef"
/>
</ElTabPane>
</ElTabs>
<ElButton type="primary" :loading="formLoading" @click="submitForm"
>保存</ElButton
>
<ElButton @click="close"></ElButton>
</Page>
</template>

View File

@ -0,0 +1,135 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '分类名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'picUrl',
label: '图标地址',
component: 'ImageUpload',
},
{
fieldName: 'sort',
label: '排序',
component: 'InputNumber',
componentProps: {
min: 0,
controlsPosition: 'right',
placeholder: '请输入排序',
},
rules: 'required',
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '分类名称',
component: 'Input',
componentProps: {
placeholder: '请输入分类名称',
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
placeholder: '请选择状态',
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 表格列配置 */
export function useGridColumns(): VxeGridPropTypes.Columns {
return [
{
title: '编号',
field: 'id',
width: 100,
},
{
title: '分类名称',
field: 'name',
minWidth: 240,
},
{
title: '分类图片',
field: 'picUrl',
width: 80,
cellRender: {
name: 'CellImage',
},
},
{
title: '状态',
field: 'status',
width: 150,
cellRender: {
name: 'CellDictTag',
props: {
dictType: DICT_TYPE.COMMON_STATUS,
},
},
},
{
title: '排序',
field: 'sort',
width: 150,
},
{
title: '创建时间',
field: 'createTime',
width: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,128 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallArticleCategoryApi } from '#/api/mall/promotion/articleCategory';
import { Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteArticleCategory,
getArticleCategoryPage,
} from '#/api/mall/promotion/articleCategory';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建分类 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑分类 */
function handleEdit(row: MallArticleCategoryApi.ArticleCategory) {
formModalApi.setData(row).open();
}
/** 删除分类 */
async function handleDelete(row: MallArticleCategoryApi.ArticleCategory) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteArticleCategory(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getArticleCategoryPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallArticleCategoryApi.ArticleCategory>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['文章分类']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:article-category:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:article-category:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:article-category:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { MallArticleCategoryApi } from '#/api/mall/promotion/articleCategory';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createArticleCategory,
getArticleCategory,
updateArticleCategory,
} from '#/api/mall/promotion/articleCategory';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallArticleCategoryApi.ArticleCategory>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['文章分类'])
: $t('ui.actionTitle.create', ['文章分类']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as MallArticleCategoryApi.ArticleCategory;
try {
await (formData.value?.id
? updateArticleCategory(data)
: createArticleCategory(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallArticleCategoryApi.ArticleCategory>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getArticleCategory(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,210 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
import { z } from '#/adapter/form';
import { getSimpleArticleCategoryList } from '#/api/mall/promotion/articleCategory';
import {
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
getRangePickerDefaultProps,
} from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'title',
label: '文章标题',
component: 'Input',
rules: 'required',
},
{
fieldName: 'categoryId',
label: '文章分类',
component: 'ApiSelect',
componentProps: {
api: getSimpleArticleCategoryList,
labelField: 'name',
valueField: 'id',
},
rules: 'required',
},
{
fieldName: 'author',
label: '文章作者',
component: 'Input',
},
{
fieldName: 'introduction',
label: '文章简介',
component: 'Input',
},
{
fieldName: 'picUrl',
label: '文章封面',
component: 'ImageUpload',
rules: 'required',
},
{
fieldName: 'recommendHot',
label: '是否热门',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
buttonStyle: 'solid',
optionType: 'button',
},
},
{
fieldName: 'recommendBanner',
label: '是否轮播图',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
buttonStyle: 'solid',
optionType: 'button',
},
},
{
// TODO: 商品关联
fieldName: 'spuId',
label: '商品关联',
component: 'Input',
},
{
fieldName: 'sort',
label: '排序',
component: 'InputNumber',
componentProps: {
min: 0,
controlsPosition: 'right',
placeholder: '请输入品牌排序',
},
rules: z.number().min(0).default(1),
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
{
fieldName: 'description',
label: '文章内容',
component: 'RichTextarea',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '文章分类',
component: 'ApiSelect',
componentProps: {
api: getSimpleArticleCategoryList,
labelField: 'name',
valueField: 'id',
},
},
{
fieldName: 'title',
label: '文章标题',
component: 'Input',
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 表格列配置 */
export function useGridColumns(): VxeGridPropTypes.Columns {
return [
{
field: 'id',
title: '编号',
fixed: 'left',
},
{
field: 'title',
title: '标题',
},
{
field: 'picUrl',
title: '封面',
cellRender: {
name: 'CellImage',
},
},
{
field: 'categoryId',
title: '分类',
},
{
field: 'browseCount',
title: '浏览量',
},
{
field: 'author',
title: '作者',
},
{
field: 'introduction',
title: '文章简介',
},
{
field: 'sort',
title: '排序',
},
{
field: 'status',
title: '状态',
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
formatter: 'formatDateTime',
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallArticleApi } from '#/api/mall/promotion/article';
import { Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteArticle, getArticlePage } from '#/api/mall/promotion/article';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建品牌 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑品牌 */
function handleEdit(row: MallArticleApi.Article) {
formModalApi.setData(row).open();
}
/** 删除品牌 */
async function handleDelete(row: MallArticleApi.Article) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.title]),
fullscreen: true,
});
try {
await deleteArticle(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.title]));
onRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getArticlePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallArticleApi.Article>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['文章']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:article:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:article:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:article:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.title]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,87 @@
<script lang="ts" setup>
import type { MallArticleApi } from '#/api/mall/promotion/article';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createArticle,
getArticle,
updateArticle,
} from '#/api/mall/promotion/article';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallArticleApi.Article>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['文章'])
: $t('ui.actionTitle.create', ['文章']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as MallArticleApi.Article;
try {
await (formData.value?.id ? updateArticle(data) : createArticle(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallArticleApi.Article>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getArticle(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,175 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'title',
label: 'Banner标题',
component: 'Input',
rules: 'required',
},
{
fieldName: 'picUrl',
label: '图片地址',
component: 'ImageUpload',
rules: 'required',
},
{
fieldName: 'position',
label: '定位',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.PROMOTION_BANNER_POSITION, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
},
{
fieldName: 'url',
label: '跳转地址',
component: 'Input',
rules: 'required',
},
{
fieldName: 'sort',
label: '排序',
component: 'InputNumber',
componentProps: {
min: 0,
controlsPosition: 'right',
placeholder: '请输入排序',
},
rules: 'required',
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
},
{
fieldName: 'memo',
label: '描述',
component: 'Textarea',
componentProps: {
rows: 4,
placeholder: '请输入描述',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'title',
label: 'Banner标题',
component: 'Input',
componentProps: {
placeholder: '请输入Banner标题',
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
placeholder: '请选择状态',
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 表格列配置 */
export function useGridColumns(): VxeGridPropTypes.Columns {
return [
{
title: 'Banner标题',
field: 'title',
},
{
title: '图片',
field: 'picUrl',
width: 80,
cellRender: {
name: 'CellImage',
},
},
{
title: '状态',
field: 'status',
width: 150,
cellRender: {
name: 'CellDictTag',
props: {
dictType: DICT_TYPE.COMMON_STATUS,
},
},
},
{
title: '定位',
field: 'position',
width: 150,
cellRender: {
name: 'CellDictTag',
props: {
dictType: DICT_TYPE.PROMOTION_BANNER_POSITION,
},
},
},
{
title: '跳转地址',
field: 'url',
},
{
title: '创建时间',
field: 'createTime',
width: 180,
formatter: 'formatDateTime',
},
{
title: '排序',
field: 'sort',
width: 100,
},
{
title: '描述',
field: 'memo',
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallBannerApi } from '#/api/mall/market/banner';
import { Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteBanner, getBannerPage } from '#/api/mall/market/banner';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建Banner */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑Banner */
function handleEdit(row: MallBannerApi.Banner) {
formModalApi.setData(row).open();
}
/** 删除Banner */
async function handleDelete(row: MallBannerApi.Banner) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.title]),
fullscreen: true,
});
try {
await deleteBanner(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.title]));
onRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getBannerPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallBannerApi.Banner>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="Banner">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['Banner']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:banner:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:banner:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:banner:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.title]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,87 @@
<script lang="ts" setup>
import type { MallBannerApi } from '#/api/mall/market/banner';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createBanner,
getBanner,
updateBanner,
} from '#/api/mall/market/banner';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallBannerApi.Banner>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['Banner'])
: $t('ui.actionTitle.create', ['Banner']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as MallBannerApi.Banner;
try {
await (formData.value?.id ? updateBanner(data) : createBanner(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallBannerApi.Banner>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getBanner(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,262 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { formatDate } from '@vben/utils';
import { DICT_TYPE, getDictOptions } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
},
rules: 'required',
},
{
fieldName: 'startTime',
label: '开始时间',
component: 'DatePicker',
componentProps: {
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
placeholder: '请选择开始时间',
},
rules: 'required',
},
{
fieldName: 'endTime',
label: '结束时间',
component: 'DatePicker',
componentProps: {
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
placeholder: '请选择结束时间',
},
rules: 'required',
},
{
fieldName: 'bargainFirstPrice',
label: '砍价起始价格(元)',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
step: 0.01,
placeholder: '请输入砍价起始价格',
},
rules: 'required',
},
{
fieldName: 'bargainMinPrice',
label: '砍价底价(元)',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
step: 0.01,
placeholder: '请输入砍价底价',
},
rules: 'required',
},
{
fieldName: 'stock',
label: '活动库存',
component: 'InputNumber',
componentProps: {
min: 1,
placeholder: '请输入活动库存',
},
rules: 'required',
},
{
fieldName: 'helpMaxCount',
label: '助力人数',
component: 'InputNumber',
componentProps: {
min: 1,
placeholder: '请输入助力人数',
},
rules: 'required',
},
{
fieldName: 'bargainCount',
label: '砍价次数',
component: 'InputNumber',
componentProps: {
min: 1,
placeholder: '请输入砍价次数',
},
rules: 'required',
},
{
fieldName: 'totalLimitCount',
label: '购买限制',
component: 'InputNumber',
componentProps: {
min: 1,
placeholder: '请输入购买限制',
},
rules: 'required',
},
{
fieldName: 'randomMinPrice',
label: '最小砍价金额(元)',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
step: 0.01,
placeholder: '请输入最小砍价金额',
},
},
{
fieldName: 'randomMaxPrice',
label: '最大砍价金额(元)',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
step: 0.01,
placeholder: '请输入最大砍价金额',
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
clearable: true,
},
},
{
fieldName: 'status',
label: '活动状态',
component: 'Select',
componentProps: {
placeholder: '请选择活动状态',
clearable: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '活动编号',
minWidth: 80,
},
{
field: 'name',
title: '活动名称',
minWidth: 140,
},
{
field: 'activityTime',
title: '活动时间',
minWidth: 210,
formatter: ({ row }) => {
if (!row.startTime || !row.endTime) return '';
return `${formatDate(row.startTime, 'YYYY-MM-DD')} ~ ${formatDate(row.endTime, 'YYYY-MM-DD')}`;
},
},
{
field: 'picUrl',
title: '商品图片',
minWidth: 80,
cellRender: {
name: 'CellImage',
props: {
height: 40,
width: 40,
},
},
},
{
field: 'spuName',
title: '商品标题',
minWidth: 300,
},
{
field: 'bargainFirstPrice',
title: '起始价格',
minWidth: 100,
formatter: 'formatAmount2',
},
{
field: 'bargainMinPrice',
title: '砍价底价',
minWidth: 100,
formatter: 'formatAmount2',
},
{
field: 'recordUserCount',
title: '总砍价人数',
minWidth: 100,
},
{
field: 'recordSuccessUserCount',
title: '成功砍价人数',
minWidth: 110,
},
{
field: 'helpUserCount',
title: '助力人数',
minWidth: 100,
},
{
field: 'status',
title: '活动状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'stock',
title: '库存',
minWidth: 80,
},
{
field: 'totalStock',
title: '总库存',
minWidth: 80,
},
{
field: 'createTime',
title: '创建时间',
width: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,173 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallBargainActivityApi } from '#/api/mall/promotion/bargain/bargainActivity';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
closeBargainActivity,
deleteBargainActivity,
getBargainActivityPage,
} from '#/api/mall/promotion/bargain/bargainActivity';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'PromotionBargainActivity' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建砍价活动 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑砍价活动 */
function handleEdit(row: MallBargainActivityApi.BargainActivity) {
formModalApi.setData(row).open();
}
/** 关闭砍价活动 */
async function handleClose(row: MallBargainActivityApi.BargainActivity) {
try {
await confirm({
content: '确认关闭该砍价活动吗?',
});
} catch {
return;
}
const loadingInstance = ElLoading.service({
text: '确认关闭该砍价活动吗?',
fullscreen: true,
});
try {
await closeBargainActivity(row.id as number);
ElMessage.success('关闭成功');
onRefresh();
} finally {
loadingInstance.close();
}
}
/** 删除砍价活动 */
async function handleDelete(row: MallBargainActivityApi.BargainActivity) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteBargainActivity(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getBargainActivityPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallBargainActivityApi.BargainActivity>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】砍价活动"
url="https://doc.iocoder.cn/mall/promotion-bargain/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['砍价活动']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:bargain-activity:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:bargain-activity:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '关闭',
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:bargain-activity:close'],
ifShow: row.status === 0,
onClick: handleClose.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:bargain-activity:delete'],
ifShow: row.status !== 0,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,92 @@
<script lang="ts" setup>
import type { MallBargainActivityApi } from '#/api/mall/promotion/bargain/bargainActivity';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createBargainActivity,
getBargainActivity,
updateBargainActivity,
} from '#/api/mall/promotion/bargain/bargainActivity';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
defineOptions({ name: 'PromotionBargainActivityForm' });
const emit = defineEmits(['success']);
const formData = ref<MallBargainActivityApi.BargainActivity>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['砍价活动'])
: $t('ui.actionTitle.create', ['砍价活动']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as MallBargainActivityApi.BargainActivity;
try {
await (formData.value?.id
? updateBargainActivity(data)
: createBargainActivity(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallBargainActivityApi.BargainActivity>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getBargainActivity(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,161 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'status',
label: '砍价状态',
component: 'Select',
componentProps: {
placeholder: '请选择砍价状态',
clearable: true,
options: getDictOptions(
DICT_TYPE.PROMOTION_BARGAIN_RECORD_STATUS,
'number',
),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 50,
},
{
field: 'avatar',
title: '用户头像',
minWidth: 120,
cellRender: {
name: 'CellImage',
props: {
height: 40,
width: 40,
shape: 'circle',
},
},
},
{
field: 'nickname',
title: '用户昵称',
minWidth: 100,
},
{
field: 'createTime',
title: '发起时间',
width: 180,
formatter: 'formatDateTime',
},
{
field: 'activity.name',
title: '砍价活动',
minWidth: 150,
},
{
field: 'activity.bargainMinPrice',
title: '最低价',
minWidth: 100,
formatter: 'formatAmount2',
},
{
field: 'bargainPrice',
title: '当前价',
minWidth: 100,
formatter: 'formatAmount2',
},
{
field: 'activity.helpMaxCount',
title: '总砍价次数',
minWidth: 100,
},
{
field: 'helpCount',
title: '剩余砍价次数',
minWidth: 100,
},
{
field: 'status',
title: '砍价状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.PROMOTION_BARGAIN_RECORD_STATUS },
},
},
{
field: 'endTime',
title: '结束时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'orderId',
title: '订单编号',
minWidth: 100,
},
{
title: '操作',
width: 100,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 助力列表表格列配置 */
export function useHelpGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'userId',
title: '用户编号',
minWidth: 80,
},
{
field: 'avatar',
title: '用户头像',
minWidth: 80,
cellRender: {
name: 'CellImage',
props: {
height: 40,
width: 40,
shape: 'circle',
},
},
},
{
field: 'nickname',
title: '用户昵称',
minWidth: 100,
},
{
field: 'reducePrice',
title: '砍价金额',
minWidth: 100,
formatter: 'formatAmount2',
},
{
field: 'createTime',
title: '助力时间',
width: 180,
formatter: 'formatDateTime',
},
];
}

View File

@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallBargainRecordApi } from '#/api/mall/promotion/bargain/bargainRecord';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getBargainRecordPage } from '#/api/mall/promotion/bargain/bargainRecord';
import { useGridColumns, useGridFormSchema } from './data';
import HelpListModal from './modules/list.vue';
defineOptions({ name: 'PromotionBargainRecord' });
const [HelpListModalApi, helpListModalApi] = useVbenModal({
connectedComponent: HelpListModal,
destroyOnClose: true,
});
/** 查看助力详情 */
function handleViewHelp(row: MallBargainRecordApi.BargainRecord) {
helpListModalApi.setData({ recordId: row.id }).open();
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getBargainRecordPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallBargainRecordApi.BargainRecord>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】砍价活动"
url="https://doc.iocoder.cn/mall/promotion-bargain/"
/>
</template>
<HelpListModalApi />
<Grid table-title="">
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '助力',
type: 'primary',
link: true,
icon: ACTION_ICON.VIEW,
auth: ['promotion:bargain-help:query'],
onClick: handleViewHelp.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,67 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallBargainHelpApi } from '#/api/mall/promotion/bargain/bargainHelp';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getBargainHelpPage } from '#/api/mall/promotion/bargain/bargainHelp';
import { useHelpGridColumns } from '../data';
/** 助力列表 */
defineOptions({ name: 'BargainRecordListDialog' });
const recordId = ref<number>();
const getTitle = computed(() => {
return `助力列表 - 记录${recordId.value || ''}`;
});
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
recordId.value = undefined;
return;
}
// ID
const data = modalApi.getData<{ recordId: number }>();
if (data?.recordId) {
recordId.value = data.recordId;
}
},
});
const [Grid] = useVbenVxeGrid({
gridOptions: {
columns: useHelpGridColumns(),
height: 600,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }) => {
return await getBargainHelpPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
recordId: recordId.value,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
},
} as VxeTableGridOptions<MallBargainHelpApi.BargainHelp>,
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Grid class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,238 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { formatDate } from '@vben/utils';
import { DICT_TYPE, getDictOptions } from '#/utils';
/** 表单配置 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
},
rules: 'required',
},
{
fieldName: 'status',
label: '活动状态',
component: 'Select',
componentProps: {
placeholder: '请选择活动状态',
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
rules: 'required',
},
{
fieldName: 'startTime',
label: '开始时间',
component: 'DatePicker',
componentProps: {
placeholder: '请选择开始时间',
showTime: false,
valueFormat: 'x',
format: 'YYYY-MM-DD',
},
rules: 'required',
},
{
fieldName: 'endTime',
label: '结束时间',
component: 'DatePicker',
componentProps: {
placeholder: '请选择结束时间',
showTime: false,
valueFormat: 'x',
format: 'YYYY-MM-DD',
},
rules: 'required',
},
{
fieldName: 'userSize',
label: '用户数量',
component: 'InputNumber',
componentProps: {
placeholder: '请输入用户数量',
min: 2,
},
rules: 'required',
},
{
fieldName: 'limitDuration',
label: '限制时长',
component: 'InputNumber',
componentProps: {
placeholder: '请输入限制时长(小时)',
min: 0,
},
rules: 'required',
},
{
fieldName: 'totalLimitCount',
label: '总限购数量',
component: 'InputNumber',
componentProps: {
placeholder: '请输入总限购数量',
min: 0,
},
},
{
fieldName: 'singleLimitCount',
label: '单次限购数量',
component: 'InputNumber',
componentProps: {
placeholder: '请输入单次限购数量',
min: 0,
},
},
{
fieldName: 'virtualGroup',
label: '虚拟成团',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING, 'boolean'),
},
},
{
// TODO
fieldName: 'spuId',
label: '拼团商品',
component: 'Input',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
clearable: true,
},
},
{
fieldName: 'status',
label: '活动状态',
component: 'Select',
componentProps: {
placeholder: '请选择活动状态',
clearable: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '活动编号',
minWidth: 80,
},
{
field: 'name',
title: '活动名称',
minWidth: 140,
},
{
field: 'activityTime',
title: '活动时间',
minWidth: 210,
formatter: ({ row }) => {
if (!row.startTime || !row.endTime) return '';
return `${formatDate(row.startTime, 'YYYY-MM-DD')} ~ ${formatDate(row.endTime, 'YYYY-MM-DD')}`;
},
},
{
field: 'picUrl',
title: '商品图片',
minWidth: 80,
cellRender: {
name: 'CellImage',
props: {
height: 40,
width: 40,
},
},
},
{
field: 'spuName',
title: '商品标题',
minWidth: 300,
},
{
field: 'marketPrice',
title: '原价',
minWidth: 100,
formatter: ({ cellValue }) => {
return `¥${(cellValue / 100).toFixed(2)}`;
},
},
{
field: 'combinationPrice',
title: '拼团价',
minWidth: 100,
formatter: ({ row }) => {
if (!row.products || row.products.length === 0) return '';
const combinationPrice = Math.min(
...row.products.map((item: any) => item.combinationPrice),
);
return `¥${(combinationPrice / 100).toFixed(2)}`;
},
},
{
field: 'groupCount',
title: '开团组数',
minWidth: 100,
},
{
field: 'groupSuccessCount',
title: '成团组数',
minWidth: 100,
},
{
field: 'recordCount',
title: '购买次数',
minWidth: 100,
},
{
field: 'status',
title: '活动状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 200,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,177 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallCombinationActivityApi } from '#/api/mall/promotion/combination/combinationActivity';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
closeCombinationActivity,
deleteCombinationActivity,
getCombinationActivityPage,
} from '#/api/mall/promotion/combination/combinationActivity';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import CombinationActivityForm from './modules/form.vue';
defineOptions({ name: 'PromotionCombinationActivity' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: CombinationActivityForm,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建拼团活动 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑拼团活动 */
function handleEdit(row: MallCombinationActivityApi.CombinationActivity) {
formModalApi.setData(row).open();
}
/** 关闭拼团活动 */
async function handleClose(
row: MallCombinationActivityApi.CombinationActivity,
) {
try {
await confirm({
content: '确认关闭该拼团活动吗?',
});
} catch {
return;
}
const loadingInstance = ElLoading.service({
text: '关闭中...',
fullscreen: true,
});
try {
await closeCombinationActivity(row.id as number);
ElMessage.success('关闭成功');
onRefresh();
} finally {
loadingInstance.close();
}
}
/** 删除拼团活动 */
async function handleDelete(
row: MallCombinationActivityApi.CombinationActivity,
) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteCombinationActivity(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCombinationActivityPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallCombinationActivityApi.CombinationActivity>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】拼团活动"
url="https://doc.iocoder.cn/mall/promotion-combination/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['拼团活动']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:combination-activity:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:combination-activity:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '关闭',
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:combination-activity:close'],
ifShow: row.status === 0,
onClick: handleClose.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:combination-activity:delete'],
ifShow: row.status !== 0,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,93 @@
<script lang="ts" setup>
import type { MallCombinationActivityApi } from '#/api/mall/promotion/combination/combinationActivity';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import {
createCombinationActivity,
getCombinationActivity,
updateCombinationActivity,
} from '#/api/mall/promotion/combination/combinationActivity';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
defineOptions({ name: 'CombinationActivityForm' });
const emit = defineEmits(['success']);
const formData = ref<MallCombinationActivityApi.CombinationActivity>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['拼团活动'])
: $t('ui.actionTitle.create', ['拼团活动']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as MallCombinationActivityApi.CombinationActivity;
try {
await (formData.value?.id
? updateCombinationActivity(data)
: createCombinationActivity(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data =
modalApi.getData<MallCombinationActivityApi.CombinationActivity>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getCombinationActivity(data.id as number);
// values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-3/5" :title="getTitle">
<Form />
</Modal>
</template>

View File

@ -0,0 +1,177 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE, getDictOptions } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'status',
label: '拼团状态',
component: 'Select',
componentProps: {
placeholder: '请选择拼团状态',
clearable: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
clearable: true,
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '拼团编号',
minWidth: 80,
},
{
field: 'avatar',
title: '头像',
minWidth: 80,
cellRender: {
name: 'CellImage',
props: {
height: 40,
width: 40,
shape: 'circle',
},
},
},
{
field: 'nickname',
title: '昵称',
minWidth: 100,
},
{
field: 'headId',
title: '开团团长',
minWidth: 100,
},
{
field: 'picUrl',
title: '拼团商品图',
minWidth: 80,
cellRender: {
name: 'CellImage',
},
},
{
field: 'spuName',
title: '拼团商品',
minWidth: 120,
},
{
field: 'activityName',
title: '拼团活动',
minWidth: 140,
},
{
field: 'userSize',
title: '几人团',
minWidth: 80,
},
{
field: 'userCount',
title: '参与人数',
minWidth: 80,
},
{
field: 'createTime',
title: '参团时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'endTime',
title: '结束时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'status',
title: '拼团状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
title: '操作',
width: 100,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 用户列表表格列配置 */
export function useUserGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 80,
},
{
field: 'avatar',
title: '用户头像',
minWidth: 80,
cellRender: {
name: 'CellImage',
props: {
height: 40,
width: 40,
shape: 'circle',
},
},
},
{
field: 'nickname',
title: '用户昵称',
minWidth: 100,
},
{
field: 'headId',
title: '开团团长',
minWidth: 100,
formatter: ({ cellValue }) => {
return cellValue === 0 ? '团长' : '团员';
},
},
{
field: 'createTime',
title: '参团时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'endTime',
title: '结束时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'status',
title: '拼团状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
];
}

View File

@ -0,0 +1,82 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCombinationRecordPage } from '#/api/mall/promotion/combination/combinationRecord';
import { useGridColumns, useGridFormSchema } from './data';
import CombinationUserList from './modules/list.vue';
defineOptions({ name: 'PromotionCombinationRecord' });
const [UserListModal, userListModalApi] = useVbenModal({
connectedComponent: CombinationUserList,
destroyOnClose: true,
});
/** 查看拼团用户 */
function handleViewUsers(row: any) {
userListModalApi.setData({ recordId: row.id }).open();
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCombinationRecordPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】拼团活动"
url="https://doc.iocoder.cn/mall/promotion-combination/"
/>
</template>
<UserListModal />
<Grid table-title="">
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '查看成员',
type: 'primary',
link: true,
icon: ACTION_ICON.VIEW,
onClick: handleViewUsers.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,63 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCombinationRecordPage } from '#/api/mall/promotion/combination/combinationRecord';
import { useUserGridColumns } from '../data';
defineOptions({ name: 'CombinationUserList' });
const headId = ref<number>();
const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
headId.value = undefined;
return;
}
const data = modalApi.getData<{ headId: number }>();
if (data?.headId) {
headId.value = data.headId;
}
},
});
const [Grid] = useVbenVxeGrid({
gridOptions: {
columns: useUserGridColumns(),
height: 600,
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }) => {
// API
return await getCombinationRecordPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
headId: headId.value,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
} as VxeTableGridOptions,
});
const getTitle = computed(() => {
return `拼团成员列表 (拼团ID: ${headId.value || ''})`;
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Grid class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,129 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
import { discountFormat } from './formatter';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'nickname',
label: '会员昵称',
component: 'Input',
componentProps: {
placeholder: '请输入会员昵称',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '领取时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'nickname',
title: '会员昵称',
minWidth: 100,
},
{
field: 'name',
title: '优惠券名称',
minWidth: 140,
},
{
field: 'productScope',
title: '类型',
minWidth: 110,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.PROMOTION_PRODUCT_SCOPE },
},
},
{
field: 'discountType',
title: '优惠',
minWidth: 110,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.PROMOTION_DISCOUNT_TYPE },
},
},
{
field: 'discountPrice',
title: '优惠力度',
minWidth: 110,
formatter: ({ row }) => {
return discountFormat(row);
},
},
{
field: 'takeType',
title: '领取方式',
minWidth: 110,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.PROMOTION_COUPON_TAKE_TYPE },
},
},
{
field: 'status',
title: '状态',
minWidth: 110,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.PROMOTION_COUPON_STATUS },
},
},
{
field: 'createTime',
title: '领取时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'useTime',
title: '使用时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 100,
fixed: 'right',
slots: { default: 'actions' },
},
];
}
/** 获取状态选项卡配置 */
export function getStatusTabs() {
const tabs = [
{
label: '全部',
value: 'all',
},
];
// 添加字典状态选项
const statusOptions = getDictOptions(DICT_TYPE.PROMOTION_COUPON_STATUS);
for (const option of statusOptions) {
tabs.push({
label: option.label,
value: String(option.value),
});
}
return tabs;
}

View File

@ -0,0 +1,65 @@
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { floatToFixed2, formatDate } from '@vben/utils';
import {
CouponTemplateValidityTypeEnum,
PromotionDiscountTypeEnum,
} from '#/utils';
// 格式化【优惠金额/折扣】
export function discountFormat(row: MallCouponTemplateApi.CouponTemplate) {
if (row.discountType === PromotionDiscountTypeEnum.PRICE.type) {
return `¥${floatToFixed2(row.discountPrice)}`;
}
if (row.discountType === PromotionDiscountTypeEnum.PERCENT.type) {
return `${row.discountPercent}%`;
}
return `未知【${row.discountType}`;
}
// 格式化【领取上限】
export function takeLimitCountFormat(
row: MallCouponTemplateApi.CouponTemplate,
) {
if (row.takeLimitCount) {
if (row.takeLimitCount === -1) {
return '无领取限制';
}
return `${row.takeLimitCount} 张/人`;
} else {
return ' ';
}
}
// 格式化【有效期限】
export function validityTypeFormat(row: MallCouponTemplateApi.CouponTemplate) {
if (row.validityType === CouponTemplateValidityTypeEnum.DATE.type) {
return `${formatDate(row.validStartTime)}${formatDate(row.validEndTime)}`;
}
if (row.validityType === CouponTemplateValidityTypeEnum.TERM.type) {
return `领取后第 ${row.fixedStartTerm} - ${row.fixedEndTerm} 天内可用`;
}
return `未知【${row.validityType}`;
}
// 格式化【totalCount】
export function totalCountFormat(row: MallCouponTemplateApi.CouponTemplate) {
if (row.totalCount === -1) {
return '不限制';
}
return row.totalCount;
}
// 格式化【剩余数量】
export function remainedCountFormat(row: MallCouponTemplateApi.CouponTemplate) {
if (row.totalCount === -1) {
return '不限制';
}
return row.totalCount - row.takeCount;
}
// 格式化【最低消费】
export function usePriceFormat(row: MallCouponTemplateApi.CouponTemplate) {
return `¥${floatToFixed2(row.usePrice)}`;
}

View File

@ -0,0 +1,129 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallCouponApi } from '#/api/mall/promotion/coupon/coupon';
import { ref } from 'vue';
import { DocAlert, Page } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteCoupon,
getCouponPage,
} from '#/api/mall/promotion/coupon/coupon';
import { getStatusTabs, useGridColumns, useGridFormSchema } from './data';
defineOptions({ name: 'PromotionCoupon' });
const activeTab = ref('all');
const statusTabs = ref(getStatusTabs());
/** 删除优惠券 */
async function handleDelete(row: MallCouponApi.Coupon) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteCoupon(row.id as number);
ElMessage.success('回收成功');
onRefresh();
} finally {
loadingInstance.close();
}
}
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** Tab切换 */
function onTabChange(tabName: string) {
activeTab.value = tabName;
//
const formValues = gridApi.formApi.getValues();
const status = tabName === 'all' ? undefined : Number(tabName);
gridApi.formApi.setValues({ ...formValues, status });
gridApi.query();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const params = {
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
// Tab
status:
activeTab.value === 'all' ? undefined : Number(activeTab.value),
};
return await getCouponPage(params);
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallCouponApi.Coupon>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】优惠劵"
url="https://doc.iocoder.cn/mall/promotion-coupon/"
/>
</template>
<Grid table-title="">
<template #top>
<Tabs v-model:active-key="activeTab" type="card" @change="onTabChange">
<TabPane
v-for="tab in statusTabs"
:key="tab.value"
:tab="tab.label"
/>
</Tabs>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '回收',
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:coupon:delete'],
popConfirm: {
title:
'回收将会收回会员领取的待使用的优惠券,已使用的将无法回收,确定要回收所选优惠券吗?',
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,252 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
// 格式化函数移到组件内部实现
import { z } from '#/adapter/form';
import {
CommonStatusEnum,
DICT_TYPE,
getDictOptions,
getRangePickerDefaultProps,
} from '#/utils';
import {
discountFormat,
remainedCountFormat,
takeLimitCountFormat,
totalCountFormat,
validityTypeFormat,
} from '../formatter';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '优惠券名称',
component: 'Input',
componentProps: {
placeholder: '请输入优惠券名称',
},
rules: 'required',
},
{
fieldName: 'description',
label: '优惠券描述',
component: 'Textarea',
},
// TODO
{
fieldName: 'productScope',
label: '优惠类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.PROMOTION_PRODUCT_SCOPE, 'number'),
},
rules: 'required',
},
{
fieldName: 'takeType',
label: '领取方式',
component: 'Select',
componentProps: {
placeholder: '请选择领取方式',
options: getDictOptions(DICT_TYPE.PROMOTION_COUPON_TAKE_TYPE, 'number'),
},
rules: 'required',
},
{
fieldName: 'validityType',
label: '有效期类型',
component: 'Select',
componentProps: {
placeholder: '请选择有效期类型',
options: getDictOptions(
DICT_TYPE.PROMOTION_COUPON_TEMPLATE_VALIDITY_TYPE,
'number',
),
},
rules: 'required',
},
{
fieldName: 'totalCount',
label: '发放数量',
component: 'InputNumber',
componentProps: {
min: 0,
placeholder: '请输入发放数量',
},
rules: 'required',
},
{
fieldName: 'takeLimitCount',
label: '领取上限',
component: 'InputNumber',
componentProps: {
min: 0,
placeholder: '请输入领取上限',
},
rules: 'required',
},
{
fieldName: 'status',
label: '优惠券状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '优惠券名称',
component: 'Input',
componentProps: {
placeholder: '请输入优惠券名称',
clearable: true,
},
},
{
fieldName: 'discountType',
label: '优惠类型',
component: 'Select',
componentProps: {
placeholder: '请选择优惠类型',
clearable: true,
options: getDictOptions(DICT_TYPE.PROMOTION_DISCOUNT_TYPE, 'number'),
},
},
{
fieldName: 'status',
label: '优惠券状态',
component: 'Select',
componentProps: {
placeholder: '请选择优惠券状态',
clearable: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{ type: 'checkbox', width: 40 },
{
field: 'name',
title: '优惠券名称',
minWidth: 140,
},
{
field: 'productScope',
title: '类型',
minWidth: 130,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.PROMOTION_PRODUCT_SCOPE },
},
},
{
field: 'discountType',
title: '优惠',
minWidth: 110,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.PROMOTION_DISCOUNT_TYPE },
},
},
{
field: 'discountPrice',
title: '优惠力度',
minWidth: 110,
formatter: ({ row }) => {
return discountFormat(row);
},
},
{
field: 'takeType',
title: '领取方式',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.PROMOTION_COUPON_TAKE_TYPE },
},
},
{
field: 'validityType',
title: '使用时间',
minWidth: 180,
formatter: ({ row }) => {
return validityTypeFormat(row);
},
},
{
field: 'totalCount',
title: '发放数量',
minWidth: 100,
formatter: ({ row }) => {
return totalCountFormat(row);
},
},
{
field: 'remainedCount',
title: '剩余数量',
minWidth: 100,
formatter: ({ row }) => {
return remainedCountFormat(row);
},
},
{
field: 'takeLimitCount',
title: '领取上限',
minWidth: 100,
formatter: ({ row }) => {
return takeLimitCountFormat(row);
},
},
{
field: 'status',
title: '状态',
minWidth: 100,
slots: { default: 'status' },
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 120,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,185 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { ref } from 'vue';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { ElLoading, ElMessage, ElSwitch } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteCouponTemplate,
getCouponTemplatePage,
updateCouponTemplateStatus,
} from '#/api/mall/promotion/coupon/couponTemplate';
import { CommonStatusEnum } from '#/utils';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'PromotionCouponTemplate' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 编辑优惠券模板 */
function handleEdit(row: MallCouponTemplateApi.CouponTemplate) {
formModalApi.setData(row).open();
}
/** 创建优惠券模板 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 删除优惠券模板 */
async function handleDelete(row: MallCouponTemplateApi.CouponTemplate) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteCouponTemplate(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
records: MallCouponTemplateApi.CouponTemplate[];
}) {
checkedIds.value = records.map((item) => item.id as number);
}
/** 优惠券模板状态修改 */
async function handleStatusChange(row: MallCouponTemplateApi.CouponTemplate) {
const text = row.status === CommonStatusEnum.ENABLE ? '启用' : '停用';
const loadingInstance = ElLoading.service({
text: `正在${text}优惠券模板...`,
fullscreen: true,
});
try {
await updateCouponTemplateStatus(row.id as number, row.status as 0 | 1);
ElMessage.success(`${text}成功`);
} catch {
// row.status
row.status =
row.status === CommonStatusEnum.ENABLE
? CommonStatusEnum.DISABLE
: CommonStatusEnum.ENABLE;
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getCouponTemplatePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallCouponTemplateApi.CouponTemplate>,
gridEvents: {
checkboxAll: handleRowCheckboxChange,
checkboxChange: handleRowCheckboxChange,
},
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】优惠劵"
url="https://doc.iocoder.cn/mall/promotion-coupon/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['优惠券模板']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:coupon-template:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #status="{ row }">
<ElSwitch
v-model:checked="row.status"
:checked-value="CommonStatusEnum.ENABLE"
:un-checked-value="CommonStatusEnum.DISABLE"
@change="handleStatusChange(row)"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:coupon-template:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:coupon-template:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,89 @@
<script lang="ts" setup>
import type { MallCouponTemplateApi } from '#/api/mall/promotion/coupon/couponTemplate';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createCouponTemplate,
getCouponTemplate,
updateCouponTemplate,
} from '#/api/mall/promotion/coupon/couponTemplate';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallCouponTemplateApi.CouponTemplate>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['优惠券模板'])
: $t('ui.actionTitle.create', ['优惠券模板']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as MallCouponTemplateApi.CouponTemplate;
try {
await (formData.value?.id
? updateCouponTemplate(data)
: createCouponTemplate(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallCouponTemplateApi.CouponTemplate>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getCouponTemplate(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,159 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { formatDate } from '@vben/utils';
import { DICT_TYPE, getDictOptions } from '#/utils';
/** 表单配置 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
},
rules: 'required',
},
{
fieldName: 'status',
label: '活动状态',
component: 'Select',
componentProps: {
placeholder: '请选择活动状态',
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
rules: 'required',
},
{
fieldName: 'startTime',
label: '开始时间',
component: 'DatePicker',
componentProps: {
placeholder: '请选择开始时间',
showTime: false,
valueFormat: 'x',
format: 'YYYY-MM-DD',
},
rules: 'required',
},
{
fieldName: 'endTime',
label: '结束时间',
component: 'DatePicker',
componentProps: {
placeholder: '请选择结束时间',
showTime: false,
valueFormat: 'x',
format: 'YYYY-MM-DD',
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
},
},
// TODO
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
clearable: true,
},
},
{
fieldName: 'status',
label: '活动状态',
component: 'Select',
componentProps: {
placeholder: '请选择活动状态',
clearable: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'activeTime',
label: '活动时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
clearable: true,
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '活动编号',
minWidth: 80,
},
{
field: 'name',
title: '活动名称',
minWidth: 140,
},
{
field: 'activityTime',
title: '活动时间',
minWidth: 210,
formatter: ({ row }) => {
if (!row.startTime || !row.endTime) return '';
return `${formatDate(row.startTime, 'YYYY-MM-DD')} ~ ${formatDate(row.endTime, 'YYYY-MM-DD')}`;
},
},
{
field: 'status',
title: '活动状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,173 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallDiscountActivityApi } from '#/api/mall/promotion/discount/discountActivity';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
closeDiscountActivity,
deleteDiscountActivity,
getDiscountActivityPage,
} from '#/api/mall/promotion/discount/discountActivity';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import DiscountActivityForm from './modules/form.vue';
defineOptions({ name: 'PromotionDiscountActivity' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: DiscountActivityForm,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建满减活动 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑满减活动 */
function handleEdit(row: MallDiscountActivityApi.DiscountActivity) {
formModalApi.setData(row).open();
}
/** 关闭满减活动 */
async function handleClose(row: MallDiscountActivityApi.DiscountActivity) {
try {
await confirm({
content: '确认关闭该限时折扣活动吗?',
});
} catch {
return;
}
const loadingInstance = ElLoading.service({
text: '正在关闭中',
fullscreen: true,
});
try {
await closeDiscountActivity(row.id as number);
ElMessage.success('关闭成功');
onRefresh();
} finally {
loadingInstance.close();
}
}
/** 删除满减活动 */
async function handleDelete(row: MallDiscountActivityApi.DiscountActivity) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteDiscountActivity(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDiscountActivityPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallDiscountActivityApi.DiscountActivity>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】限时折扣"
url="https://doc.iocoder.cn/mall/promotion-discount/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['限时折扣活动']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:discount-activity:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:discount-activity:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '关闭',
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:discount-activity:close'],
ifShow: row.status === 0,
onClick: handleClose.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:discount-activity:delete'],
ifShow: row.status !== 0,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { MallDiscountActivityApi } from '#/api/mall/promotion/discount/discountActivity';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import {
createDiscountActivity,
getDiscountActivity,
updateDiscountActivity,
} from '#/api/mall/promotion/discount/discountActivity';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
defineOptions({ name: 'DiscountActivityForm' });
const emit = defineEmits(['success']);
const formData = ref<MallDiscountActivityApi.DiscountActivity>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['限时折扣活动'])
: $t('ui.actionTitle.create', ['限时折扣活动']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as MallDiscountActivityApi.DiscountActivity;
//
if (!data.products) {
data.products = [];
}
try {
await (formData.value?.id
? updateDiscountActivity(data)
: createDiscountActivity(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallDiscountActivityApi.DiscountActivity>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDiscountActivity(data.id as number);
// values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-3/5" :title="getTitle">
<Form />
</Modal>
</template>

View File

@ -0,0 +1,109 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
/** 表单配置 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '页面名称',
component: 'Input',
componentProps: {
placeholder: '请输入页面名称',
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
},
},
{
fieldName: 'previewPicUrls',
component: 'ImageUpload',
label: '预览图',
componentProps: {
maxNumber: 10,
multiple: true,
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '页面名称',
component: 'Input',
componentProps: {
placeholder: '请输入页面名称',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
clearable: true,
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 80,
},
{
field: 'previewPicUrls',
title: '预览图',
minWidth: 120,
cellRender: {
name: 'CellImages',
},
},
{
field: 'name',
title: '页面名称',
minWidth: 150,
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 200,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,143 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallDiyPageApi } from '#/api/mall/promotion/diy/page';
import { useRouter } from 'vue-router';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteDiyPage, getDiyPagePage } from '#/api/mall/promotion/diy/page';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import DiyPageForm from './modules/form.vue';
defineOptions({ name: 'PromotionDiyPage' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: DiyPageForm,
destroyOnClose: true,
});
const { push } = useRouter();
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建DIY页面 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑DIY页面 */
function handleEdit(row: MallDiyPageApi.DiyPage) {
formModalApi.setData(row).open();
}
/** 装修页面 */
function handleDecorate(row: MallDiyPageApi.DiyPage) {
//
push({ name: 'DiyPageDecorate', params: { id: row.id } });
}
/** 删除DIY页面 */
async function handleDelete(row: MallDiyPageApi.DiyPage) {
await deleteDiyPage(row.id as number);
onRefresh();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDiyPagePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallDiyPageApi.DiyPage>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】商城装修"
url="https://doc.iocoder.cn/mall/diy/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['装修页面']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:diy-page:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '装修',
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:diy-page:update'],
onClick: handleDecorate.bind(null, row),
},
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:diy-page:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:diy-page:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,92 @@
<script lang="ts" setup>
import type { MallDiyPageApi } from '#/api/mall/promotion/diy/page';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import {
createDiyPage,
getDiyPage,
updateDiyPage,
} from '#/api/mall/promotion/diy/page';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallDiyPageApi.DiyPage>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['装修页面'])
: $t('ui.actionTitle.create', ['装修页面']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as MallDiyPageApi.DiyPage;
//
if (!data.previewPicUrls) {
data.previewPicUrls = [];
}
try {
await (formData.value?.id ? updateDiyPage(data) : createDiyPage(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallDiyPageApi.DiyPage>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDiyPage(data.id as number);
// values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form />
</Modal>
</template>

View File

@ -0,0 +1,120 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '#/utils/dict';
/** 表单配置 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
placeholder: '请输入模板名称',
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
},
},
{
fieldName: 'previewPicUrls',
component: 'ImageUpload',
label: '预览图',
componentProps: {
maxNumber: 10,
multiple: true,
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '模板名称',
component: 'Input',
componentProps: {
placeholder: '请输入模板名称',
clearable: true,
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
placeholder: ['开始时间', '结束时间'],
clearable: true,
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 80,
},
{
field: 'previewPicUrls',
title: '预览图',
minWidth: 120,
cellRender: {
name: 'CellImages',
},
},
{
field: 'name',
title: '模板名称',
minWidth: 150,
},
{
field: 'used',
title: '是否使用',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
{
field: 'remark',
title: '备注',
minWidth: 200,
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 250,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,170 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallDiyTemplateApi } from '#/api/mall/promotion/diy/template';
import { useRouter } from 'vue-router';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteDiyTemplate,
getDiyTemplatePage,
useDiyTemplate,
} from '#/api/mall/promotion/diy/template';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import DiyTemplateForm from './modules/form.vue';
defineOptions({ name: 'PromotionDiyTemplate' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: DiyTemplateForm,
destroyOnClose: true,
});
const router = useRouter();
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建DIY模板 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑DIY模板 */
function handleEdit(row: MallDiyTemplateApi.DiyTemplate) {
formModalApi.setData(row).open();
}
/** 装修模板 */
function handleDecorate(row: MallDiyTemplateApi.DiyTemplate) {
//
router.push({ name: 'DiyTemplateDecorate', params: { id: row.id } });
}
/** 使用模板 */
async function handleUse(row: MallDiyTemplateApi.DiyTemplate) {
confirm({
content: `是否使用模板"${row.name}"?`,
}).then(async () => {
//
await useDiyTemplate(row.id as number);
ElMessage.success('使用成功');
onRefresh();
});
}
/** 删除DIY模板 */
async function handleDelete(row: MallDiyTemplateApi.DiyTemplate) {
await deleteDiyTemplate(row.id as number);
onRefresh();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDiyTemplatePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallDiyTemplateApi.DiyTemplate>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】商城装修"
url="https://doc.iocoder.cn/mall/diy/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['装修模板']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:diy-template:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '装修',
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:diy-template:update'],
onClick: handleDecorate.bind(null, row),
},
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:diy-template:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '使用',
type: 'primary',
link: true,
auth: ['promotion:diy-template:use'],
ifShow: !row.used,
onClick: handleUse.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:diy-template:delete'],
ifShow: !row.used,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,99 @@
<script lang="ts" setup>
import type { MallDiyTemplateApi } from '#/api/mall/promotion/diy/template';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import {
createDiyTemplate,
getDiyTemplate,
updateDiyTemplate,
} from '#/api/mall/promotion/diy/template';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
/** 提交表单 */
const emit = defineEmits(['success']);
const formData = ref<MallDiyTemplateApi.DiyTemplate>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['装修模板'])
: $t('ui.actionTitle.create', ['装修模板']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data = (await formApi.getValues()) as MallDiyTemplateApi.DiyTemplate;
//
if (!data.previewPicUrls) {
data.previewPicUrls = [];
}
if (data.used === undefined) {
data.used = false;
}
try {
await (formData.value?.id
? updateDiyTemplate(data)
: createDiyTemplate(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallDiyTemplateApi.DiyTemplate>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getDiyTemplate(data.id as number);
// values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form />
</Modal>
</template>

View File

@ -0,0 +1,26 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/promotion/kefu/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/promotion/kefu/index
代码pull request 贡献给我们
</Button>
</Page>
</template>

View File

@ -0,0 +1,140 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE } from '#/utils/dict';
/** 表单配置 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'spuId',
label: '积分商城活动商品',
component: 'Input',
componentProps: {
placeholder: '请选择商品',
},
rules: 'required',
},
{
fieldName: 'sort',
label: '排序',
component: 'InputNumber',
componentProps: {
placeholder: '请输入排序',
min: 0,
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'status',
label: '活动状态',
component: 'Select',
componentProps: {
placeholder: '请选择活动状态',
clearable: true,
dictType: DICT_TYPE.COMMON_STATUS,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '活动编号',
minWidth: 80,
},
{
field: 'picUrl',
title: '商品图片',
minWidth: 80,
cellRender: {
name: 'CellImage',
},
},
{
field: 'spuName',
title: '商品标题',
minWidth: 300,
},
{
field: 'marketPrice',
title: '原价',
minWidth: 100,
formatter: 'formatAmount2',
},
{
field: 'point',
title: '兑换积分',
minWidth: 100,
},
{
field: 'price',
title: '兑换金额',
minWidth: 100,
formatter: 'formatAmount2',
},
{
field: 'status',
title: '活动状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'stock',
title: '库存',
minWidth: 80,
},
{
field: 'totalStock',
title: '总库存',
minWidth: 80,
},
{
field: 'redeemedQuantity',
title: '已兑换数量',
minWidth: 100,
slots: { default: 'redeemedQuantity' },
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,162 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
import { computed } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
closePointActivity,
deletePointActivity,
getPointActivityPage,
} from '#/api/mall/promotion/point';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import PointActivityForm from './modules/form.vue';
defineOptions({ name: 'PromotionPointActivity' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: PointActivityForm,
destroyOnClose: true,
});
/** 获得商品已兑换数量 */
const getRedeemedQuantity = computed(
() => (row: MallPointActivityApi.PointActivity) =>
(row.totalStock || 0) - (row.stock || 0),
);
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建积分活动 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑积分活动 */
function handleEdit(row: MallPointActivityApi.PointActivity) {
formModalApi.setData(row).open();
}
/** 关闭积分活动 */
function handleClose(row: MallPointActivityApi.PointActivity) {
confirm({
content: '确认关闭该积分商城活动吗?',
}).then(async () => {
await closePointActivity(row.id);
ElMessage.success('关闭成功');
onRefresh();
});
}
/** 删除积分活动 */
async function handleDelete(row: MallPointActivityApi.PointActivity) {
await deletePointActivity(row.id);
onRefresh();
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getPointActivityPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallPointActivityApi.PointActivity>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】积分商城活动"
url="https://doc.iocoder.cn/mall/promotion-point/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['积分活动']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:point-activity:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #redeemedQuantity="{ row }">
{{ getRedeemedQuantity(row) }}
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:point-activity:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '关闭',
type: 'danger',
link: true,
auth: ['promotion:point-activity:close'],
ifShow: row.status === 0,
onClick: handleClose.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:point-activity:delete'],
ifShow: row.status !== 0,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.spuName]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,107 @@
<script lang="ts" setup>
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import {
createPointActivity,
getPointActivity,
updatePointActivity,
} from '#/api/mall/promotion/point';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallPointActivityApi.PointActivity>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['积分活动'])
: $t('ui.actionTitle.create', ['积分活动']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 120,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as MallPointActivityApi.PointActivity;
//
if (!data.products) {
data.products = [];
}
if (!data.sort) {
data.sort = 0;
}
try {
await (formData.value?.id
? updatePointActivity(data)
: createPointActivity(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallPointActivityApi.PointActivity>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getPointActivity(data.id);
// values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-3/5" :title="getTitle">
<div class="p-4">
<div class="mb-4 rounded border border-yellow-200 bg-yellow-50 p-4">
<p class="text-yellow-800">
<strong>注意</strong>
积分活动涉及复杂的商品选择和SKU配置当前为简化版本
完整的商品选择和积分配置功能需要在后续版本中完善
</p>
</div>
<Form />
</div>
</Modal>
</template>

View File

@ -0,0 +1,166 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE, getDictOptions } from '#/utils';
/** 表单配置 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
},
rules: 'required',
},
{
fieldName: 'startTime',
label: '开始时间',
component: 'DatePicker',
componentProps: {
placeholder: '请选择开始时间',
showTime: true,
valueFormat: 'x',
format: 'YYYY-MM-DD HH:mm:ss',
},
rules: 'required',
},
{
fieldName: 'endTime',
label: '结束时间',
component: 'DatePicker',
componentProps: {
placeholder: '请选择结束时间',
showTime: true,
valueFormat: 'x',
format: 'YYYY-MM-DD HH:mm:ss',
},
rules: 'required',
},
{
fieldName: 'conditionType',
label: '条件类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.PROMOTION_CONDITION_TYPE, 'number'),
},
rules: 'required',
},
{
fieldName: 'productScope',
label: '商品范围',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.PROMOTION_PRODUCT_SCOPE, 'number'),
},
rules: 'required',
},
{
fieldName: 'remark',
label: '备注',
component: 'Textarea',
componentProps: {
placeholder: '请输入备注',
rows: 4,
},
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
clearable: true,
},
},
{
fieldName: 'status',
label: '活动状态',
component: 'Select',
componentProps: {
placeholder: '请选择活动状态',
clearable: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '活动时间',
component: 'RangePicker',
componentProps: {
placeholder: ['活动开始日期', '活动结束日期'],
clearable: true,
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'name',
title: '活动名称',
minWidth: 140,
},
{
field: 'productScope',
title: '活动范围',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.PROMOTION_PRODUCT_SCOPE },
},
},
{
field: 'startTime',
title: '活动开始时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'endTime',
title: '活动结束时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'status',
title: '状态',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,173 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallRewardActivityApi } from '#/api/mall/promotion/reward/rewardActivity';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
closeRewardActivity,
deleteRewardActivity,
getRewardActivityPage,
} from '#/api/mall/promotion/reward/rewardActivity';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import RewardActivityForm from './modules/form.vue';
defineOptions({ name: 'PromotionRewardActivity' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: RewardActivityForm,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建满减送活动 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑满减送活动 */
function handleEdit(row: MallRewardActivityApi.RewardActivity) {
formModalApi.setData(row).open();
}
/** 关闭活动 */
async function handleClose(row: MallRewardActivityApi.RewardActivity) {
try {
await confirm({
content: '确认关闭该满减送活动吗?',
});
} catch {
return;
}
const loadingInstance = ElLoading.service({
text: '正在关闭中',
fullscreen: true,
});
try {
await closeRewardActivity(row.id as number);
ElMessage.success('关闭成功');
onRefresh();
} finally {
loadingInstance.close();
}
}
/** 删除活动 */
async function handleDelete(row: MallRewardActivityApi.RewardActivity) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteRewardActivity(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getRewardActivityPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallRewardActivityApi.RewardActivity>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】满减送"
url="https://doc.iocoder.cn/mall/promotion-record/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['满减送活动']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:reward-activity:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:reward-activity:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '关闭',
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:reward-activity:close'],
ifShow: row.status === 0,
onClick: handleClose.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:reward-activity:delete'],
ifShow: row.status !== 0,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,105 @@
<script lang="ts" setup>
import type { MallRewardActivityApi } from '#/api/mall/promotion/reward/rewardActivity';
import { computed, ref } from 'vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import {
createRewardActivity,
getReward,
updateRewardActivity,
} from '#/api/mall/promotion/reward/rewardActivity';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallRewardActivityApi.RewardActivity>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['满减送活动'])
: $t('ui.actionTitle.create', ['满减送活动']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
labelWidth: 100,
},
wrapperClass: 'grid-cols-2',
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as MallRewardActivityApi.RewardActivity;
//
if (!data.rules) {
data.rules = [];
}
try {
await (formData.value?.id
? updateRewardActivity(data)
: createRewardActivity(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallRewardActivityApi.RewardActivity>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getReward(data.id as number);
// values
if (formData.value) {
await formApi.setValues(formData.value);
}
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-4/5" :title="getTitle">
<Form />
<!-- 简化说明 -->
<div class="mt-4 rounded bg-blue-50 p-4">
<p class="text-sm text-blue-600">
<strong>说明</strong> 当前为简化版本的满减送活动表单
复杂的商品选择优惠规则配置等功能已简化仅保留基础字段配置
如需完整功能请参考原始 Element UI 版本的实现
</p>
</div>
</Modal>
</template>

View File

@ -0,0 +1,126 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DICT_TYPE, getDictOptions } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
clearable: true,
},
},
{
fieldName: 'status',
label: '活动状态',
component: 'Select',
componentProps: {
placeholder: '请选择活动状态',
clearable: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '活动编号',
minWidth: 80,
},
{
field: 'name',
title: '活动名称',
minWidth: 140,
},
{
field: 'configIds',
title: '秒杀时段',
minWidth: 220,
slots: { default: 'configIds' },
},
{
field: 'startTime',
title: '活动时间',
minWidth: 210,
slots: { default: 'timeRange' },
},
{
field: 'picUrl',
title: '商品图片',
minWidth: 80,
cellRender: {
name: 'CellImage',
},
},
{
field: 'spuName',
title: '商品标题',
minWidth: 300,
},
{
field: 'marketPrice',
title: '原价',
minWidth: 100,
formatter: ({ row }) => `${(row.marketPrice / 100).toFixed(2)}`,
},
{
field: 'seckillPrice',
title: '秒杀价',
minWidth: 100,
formatter: ({ row }) => {
if (!(row.products || row.products.length === 0)) {
return '¥0.00';
}
const seckillPrice = Math.min(
...row.products.map((item: any) => item.seckillPrice),
);
return `${(seckillPrice / 100).toFixed(2)}`;
},
},
{
field: 'status',
title: '活动状态',
align: 'center',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'stock',
title: '库存',
align: 'center',
minWidth: 80,
},
{
field: 'totalStock',
title: '总库存',
align: 'center',
minWidth: 80,
},
{
field: 'createTime',
title: '创建时间',
align: 'center',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
align: 'center',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,34 @@
import { formatDate } from '@vben/utils';
// 全局变量,用于存储配置列表
let configList: any[] = [];
/** 设置配置列表 */
export function setConfigList(list: any[]) {
configList = list;
}
/** 格式化配置名称 */
export function formatConfigNames(configId: number): string {
const config = configList.find((item) => item.id === configId);
return config === null || config === undefined
? ''
: `${config.name}[${config.startTime} ~ ${config.endTime}]`;
}
/** 格式化秒杀价格 */
export function formatSeckillPrice(products: any[]): string {
if (!products || products.length === 0) {
return '¥0.00';
}
const seckillPrice = Math.min(...products.map((item) => item.seckillPrice));
return `${(seckillPrice / 100).toFixed(2)}`;
}
/** 格式化活动时间范围 */
export function formatTimeRange(
startTime: Date | string,
endTime: Date | string,
): string {
return `${formatDate(startTime, 'YYYY-MM-DD')} ~ ${formatDate(endTime, 'YYYY-MM-DD')}`;
}

View File

@ -0,0 +1,193 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallSeckillActivityApi } from '#/api/mall/promotion/seckill/seckillActivity';
import { onMounted } from 'vue';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { ElLoading, ElMessage, ElTag } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
closeSeckillActivity,
deleteSeckillActivity,
getSeckillActivityPage,
} from '#/api/mall/promotion/seckill/seckillActivity';
import { getSimpleSeckillConfigList } from '#/api/mall/promotion/seckill/seckillConfig';
import { useGridColumns, useGridFormSchema } from './data';
import { formatConfigNames, formatTimeRange, setConfigList } from './formatter';
import Form from './modules/form.vue';
defineOptions({ name: 'SeckillActivity' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 编辑活动 */
function handleEdit(row: MallSeckillActivityApi.SeckillActivity) {
formModalApi.setData(row).open();
}
/** 创建活动 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 关闭活动 */
async function handleClose(row: MallSeckillActivityApi.SeckillActivity) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.closing', [row.name]),
fullscreen: true,
});
try {
await closeSeckillActivity(row.id as number);
ElMessage.success('关闭成功');
onRefresh();
} finally {
loadingInstance.close();
}
}
/** 删除活动 */
async function handleDelete(row: MallSeckillActivityApi.SeckillActivity) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteSeckillActivity(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getSeckillActivityPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallSeckillActivityApi.SeckillActivity>,
});
/** 初始化 */
onMounted(async () => {
//
const configList = await getSimpleSeckillConfigList();
setConfigList(configList);
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【营销】秒杀活动"
url="https://doc.iocoder.cn/mall/promotion-seckill/"
/>
</template>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['秒杀活动']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:seckill-activity:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #configIds="{ row }">
<div class="flex flex-wrap gap-1">
<ElTag
v-for="(configId, index) in row.configIds"
:key="index"
class="mr-1"
>
{{ formatConfigNames(configId) }}
</ElTag>
</div>
</template>
<template #timeRange="{ row }">
{{ formatTimeRange(row.startTime, row.endTime) }}
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:seckill-activity:update'],
onClick: handleEdit.bind(null, row),
},
{
label: '关闭',
type: 'danger',
link: true,
auth: ['promotion:seckill-activity:close'],
ifShow: row.status === 0,
popConfirm: {
title: '确认关闭该秒杀活动吗?',
confirm: handleClose.bind(null, row),
},
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
auth: ['promotion:seckill-activity:delete'],
ifShow: row.status !== 0,
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,121 @@
<script lang="ts" setup>
import type { MallSeckillActivityApi } from '#/api/mall/promotion/seckill/seckillActivity';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createSeckillActivity,
getSeckillActivity,
updateSeckillActivity,
} from '#/api/mall/promotion/seckill/seckillActivity';
import { $t } from '#/locales';
const emit = defineEmits(['success']);
const formData = ref<MallSeckillActivityApi.SeckillActivity>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['秒杀活动'])
: $t('ui.actionTitle.create', ['秒杀活动']);
});
//
const formSchema = [
{
fieldName: 'id',
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '活动名称',
component: 'Input',
componentProps: {
placeholder: '请输入活动名称',
},
rules: 'required',
},
{
fieldName: 'status',
label: '活动状态',
component: 'Select',
componentProps: {
placeholder: '请选择活动状态',
options: [
{ label: '开启', value: 0 },
{ label: '关闭', value: 1 },
],
},
rules: 'required',
},
];
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 80,
},
layout: 'horizontal',
schema: formSchema,
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as MallSeckillActivityApi.SeckillActivity;
try {
await (formData.value?.id
? updateSeckillActivity(data)
: createSeckillActivity(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallSeckillActivityApi.SeckillActivity>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getSeckillActivity(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,151 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallSeckillConfigApi } from '#/api/mall/promotion/seckill/seckillConfig';
import { DICT_TYPE, getDictOptions } from '#/utils';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'name',
label: '秒杀时段名称',
component: 'Input',
rules: 'required',
},
{
fieldName: 'startTime',
label: '开始时间点',
component: 'TimePicker',
componentProps: {
format: 'HH:mm',
valueFormat: 'HH:mm',
placeholder: '请选择开始时间点',
},
rules: 'required',
},
{
fieldName: 'endTime',
label: '结束时间点',
component: 'TimePicker',
componentProps: {
format: 'HH:mm',
valueFormat: 'HH:mm',
placeholder: '请选择结束时间点',
},
rules: 'required',
},
{
fieldName: 'sliderPicUrls',
label: '秒杀轮播图',
component: 'ImageUpload',
componentProps: {
multiple: true,
maxNumber: 5,
},
},
{
fieldName: 'status',
label: '状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
},
];
}
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '秒杀时段名称',
component: 'Input',
componentProps: {
placeholder: '请输入秒杀时段名称',
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
placeholder: '请选择状态',
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
},
},
];
}
/** 表格列配置 */
export function useGridColumns<T = MallSeckillConfigApi.SeckillConfig>(
onStatusChange?: (
newStatus: number,
row: T,
) => PromiseLike<boolean | undefined>,
): VxeTableGridOptions['columns'] {
return [
{
title: '秒杀时段名称',
field: 'name',
minWidth: 200,
},
{
title: '开始时间点',
field: 'startTime',
minWidth: 120,
},
{
title: '结束时间点',
field: 'endTime',
minWidth: 120,
},
{
title: '秒杀轮播图',
field: 'sliderPicUrls',
minWidth: 100,
cellRender: {
name: 'CellImages',
},
},
{
title: '活动状态',
field: 'status',
minWidth: 100,
cellRender: {
attrs: { beforeChange: onStatusChange },
name: 'CellSwitch',
props: {
checkedValue: 1,
checkedChildren: '启用',
unCheckedValue: 0,
unCheckedChildren: '禁用',
},
},
},
{
title: '创建时间',
field: 'createTime',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 180,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,157 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallSeckillConfigApi } from '#/api/mall/promotion/seckill/seckillConfig';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { ElLoading, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteSeckillConfig,
getSeckillConfigPage,
updateSeckillConfigStatus,
} from '#/api/mall/promotion/seckill/seckillConfig';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true,
});
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
/** 创建秒杀时段 */
function handleCreate() {
formModalApi.setData(null).open();
}
/** 编辑秒杀时段 */
function handleEdit(row: MallSeckillConfigApi.SeckillConfig) {
formModalApi.setData(row).open();
}
/** 删除秒杀时段 */
async function handleDelete(row: MallSeckillConfigApi.SeckillConfig) {
const loadingInstance = ElLoading.service({
text: $t('ui.actionMessage.deleting', [row.name]),
fullscreen: true,
});
try {
await deleteSeckillConfig(row.id as number);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onRefresh();
} finally {
loadingInstance.close();
}
}
/** 修改状态 */
async function handleStatusChange(
newStatus: number,
row: MallSeckillConfigApi.SeckillConfig,
): Promise<boolean | undefined> {
return new Promise((resolve, reject) => {
//
const text = row.status === 0 ? '启用' : '停用';
confirm({
content: `确认要${text + row.name}吗?`,
})
.then(async () => {
//
const res = await updateSeckillConfigStatus(row.id, newStatus);
if (res) {
//
ElMessage.success(`${text}成功`);
resolve(true);
} else {
reject(new Error('操作失败'));
}
})
.catch(() => {
reject(new Error('取消操作'));
});
});
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(handleStatusChange),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getSeckillConfigPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallSeckillConfigApi.SeckillConfig>,
});
</script>
<template>
<Page auto-content-height>
<FormModal @success="onRefresh" />
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['秒杀时段']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['promotion:seckill-config:create'],
onClick: handleCreate,
},
]"
/>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: $t('common.edit'),
type: 'primary',
link: true,
icon: ACTION_ICON.EDIT,
auth: ['promotion:seckill-config:update'],
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
type: 'danger',
link: true,
icon: ACTION_ICON.DELETE,
auth: ['promotion:seckill-config:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { MallSeckillConfigApi } from '#/api/mall/promotion/seckill/seckillConfig';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import {
createSeckillConfig,
getSeckillConfig,
updateSeckillConfig,
} from '#/api/mall/promotion/seckill/seckillConfig';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallSeckillConfigApi.SeckillConfig>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['秒杀时段'])
: $t('ui.actionTitle.create', ['秒杀时段']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
modalApi.lock();
//
const data =
(await formApi.getValues()) as MallSeckillConfigApi.SeckillConfig;
try {
await (formData.value?.id
? updateSeckillConfig(data)
: createSeckillConfig(data));
//
await modalApi.close();
emit('success');
ElMessage.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
//
const data = modalApi.getData<MallSeckillConfigApi.SeckillConfig>();
if (!data || !data.id) {
return;
}
modalApi.lock();
try {
formData.value = await getSeckillConfig(data.id as number);
// values
await formApi.setValues(formData.value);
} finally {
modalApi.unlock();
}
},
});
</script>
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
</Modal>
</template>

View File

@ -0,0 +1,34 @@
<script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui';
import { ElButton } from 'element-plus';
</script>
<template>
<Page>
<DocAlert
title="【统计】会员、商品、交易统计"
url="https://doc.iocoder.cn/mall/statistics/"
/>
<ElButton
danger
type="primary"
link
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</ElButton>
<br />
<ElButton
type="primary"
link
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/statistics/member/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/statistics/member/index
代码pull request 贡献给我们
</ElButton>
</Page>
</template>

View File

@ -0,0 +1,34 @@
<script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui';
import { ElButton } from 'element-plus';
</script>
<template>
<Page>
<DocAlert
title="【统计】会员、商品、交易统计"
url="https://doc.iocoder.cn/mall/statistics/"
/>
<ElButton
danger
type="primary"
link
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</ElButton>
<br />
<ElButton
type="primary"
link
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/statistics/product/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/statistics/product/index
代码pull request 贡献给我们
</ElButton>
</Page>
</template>

View File

@ -0,0 +1,34 @@
<script lang="ts" setup>
import { DocAlert, Page } from '@vben/common-ui';
import { ElButton } from 'element-plus';
</script>
<template>
<Page>
<DocAlert
title="【统计】会员、商品、交易统计"
url="https://doc.iocoder.cn/mall/statistics/"
/>
<ElButton
danger
type="primary"
link
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</ElButton>
<br />
<ElButton
type="primary"
link
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/statistics/trade/index"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/statistics/trade/index
代码pull request 贡献给我们
</ElButton>
</Page>
</template>

View File

@ -0,0 +1,140 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeGridPropTypes } from '#/adapter/vxe-table';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'spuName',
label: '商品名称',
component: 'Input',
},
{
fieldName: 'no',
label: '退款编号',
component: 'Input',
},
{
fieldName: 'orderNo',
label: '订单编号',
component: 'Input',
},
{
fieldName: 'status',
label: '售后状态',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.TRADE_AFTER_SALE_STATUS, 'number'),
},
},
{
fieldName: 'status',
label: '售后方式',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.TRADE_AFTER_SALE_WAY, 'number'),
},
},
{
fieldName: 'type',
label: '售后类型',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.TRADE_AFTER_SALE_TYPE, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/** 表格列配置 */
export function useGridColumns(): VxeGridPropTypes.Columns {
return [
{
field: 'no',
title: '退款编号',
fixed: 'left',
},
{
field: 'orderNo',
title: '订单编号',
fixed: 'left',
slots: { default: 'orderNo' },
},
{
field: 'spuName',
title: '商品名称',
align: 'left',
minWidth: 200,
},
{
field: 'picUrl',
title: '商品图片',
cellRender: {
name: 'CellImage',
},
},
{
field: 'properties',
title: '商品属性',
minWidth: 200,
formatter: ({ cellValue }) => {
return cellValue && cellValue.length > 0
? cellValue
.map((item: any) => `${item.propertyName} : ${item.valueName}`)
.join('\n')
: '-';
},
},
{
field: 'refundPrice',
title: '订单金额',
formatter: 'formatAmount2',
},
{
field: 'user.nickname',
title: '买家',
},
{
field: 'createTime',
title: '申请时间',
formatter: 'formatDateTime',
},
{
field: 'content',
title: '售后状态',
cellRender: {
name: 'CellDictTag',
props: {
dictType: DICT_TYPE.TRADE_AFTER_SALE_STATUS,
},
},
},
{
field: 'way',
title: '售后方式',
cellRender: {
name: 'CellDictTag',
props: {
dictType: DICT_TYPE.TRADE_AFTER_SALE_WAY,
},
},
},
{
title: '操作',
width: 80,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,120 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallAfterSaleApi } from '#/api/mall/trade/afterSale';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { DocAlert, Page } from '@vben/common-ui';
import { ElButton, ElTabs } from 'element-plus';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getAfterSalePage } from '#/api/mall/trade/afterSale';
import { DICT_TYPE, getDictOptions } from '#/utils';
import { useGridColumns, useGridFormSchema } from './data';
const { push } = useRouter();
const status = ref('0');
const statusTabs = ref([
{
label: '全部',
value: '0',
},
]);
/** 处理退款 */
function openAfterSaleDetail(row: MallAfterSaleApi.AfterSale) {
push({ name: 'TradeAfterSaleDetail', params: { id: row.id } });
}
// TODO @xingyu
/** 查看订单详情 */
function openOrderDetail(row: MallAfterSaleApi.AfterSale) {
push({ name: 'TradeOrderDetail', params: { id: row.id } });
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getAfterSalePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
status: status.value === '0' ? undefined : status.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallAfterSaleApi.AfterSale>,
});
function onChangeStatus(key: number | string) {
status.value = key.toString();
gridApi.query();
}
onMounted(() => {
for (const dict of getDictOptions(DICT_TYPE.TRADE_AFTER_SALE_STATUS)) {
statusTabs.value.push({
label: dict.label,
value: dict.value.toString(),
});
}
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【交易】交易订单"
url="https://doc.iocoder.cn/mall/trade-order/"
/>
</template>
<Grid table-title="退">
<template #top>
<ElTabs class="border-none" @change="onChangeStatus">
<ElTabs.TabPane
v-for="tab in statusTabs"
:key="tab.value"
:tab="tab.label"
/>
</ElTabs>
</template>
<template #orderNo="{ row }">
<ElButton type="primary" link @click="openOrderDetail(row)">
{{ row.orderNo }}
</ElButton>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '处理退款',
type: 'primary',
link: true,
onClick: openAfterSaleDetail.bind(null, row),
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@ -0,0 +1,135 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { fenToYuan } from '@vben/utils';
import { DICT_TYPE, getDictOptions, getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'userId',
label: '用户编号',
component: 'Input',
componentProps: {
placeholder: '请输入用户编号',
clearable: true,
},
},
{
fieldName: 'bizType',
label: '业务类型',
component: 'Select',
componentProps: {
placeholder: '请选择业务类型',
clearable: true,
options: getDictOptions(DICT_TYPE.BROKERAGE_RECORD_BIZ_TYPE, 'number'),
},
},
{
fieldName: 'status',
label: '状态',
component: 'Select',
componentProps: {
placeholder: '请选择状态',
clearable: true,
options: getDictOptions(DICT_TYPE.BROKERAGE_RECORD_STATUS, 'number'),
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '编号',
minWidth: 60,
},
{
field: 'userId',
title: '用户编号',
minWidth: 80,
},
{
field: 'userAvatar',
title: '头像',
minWidth: 70,
cellRender: {
name: 'CellImage',
props: {
height: 40,
width: 40,
shape: 'circle',
},
},
},
{
field: 'userNickname',
title: '昵称',
minWidth: 80,
},
{
field: 'bizType',
title: '业务类型',
minWidth: 85,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.BROKERAGE_RECORD_BIZ_TYPE },
},
},
{
field: 'bizId',
title: '业务编号',
minWidth: 80,
},
{
field: 'title',
title: '标题',
minWidth: 110,
},
{
field: 'price',
title: '金额',
minWidth: 60,
formatter: ({ row }) => `${fenToYuan(row.price)}`,
},
{
field: 'description',
title: '说明',
minWidth: 120,
},
{
field: 'status',
title: '状态',
minWidth: 85,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.BROKERAGE_RECORD_STATUS },
},
},
{
field: 'unfreezeTime',
title: '解冻时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'createTime',
title: '创建时间',
width: 180,
formatter: 'formatDateTime',
},
];
}

View File

@ -0,0 +1,57 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallBrokerageRecordApi } from '#/api/mall/trade/brokerage/record';
import { DocAlert, Page } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getBrokerageRecordPage } from '#/api/mall/trade/brokerage/record';
import { useGridColumns, useGridFormSchema } from './data';
defineOptions({ name: 'TradeBrokerageRecord' });
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
showOverflow: 'tooltip',
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getBrokerageRecordPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallBrokerageRecordApi.BrokerageRecord>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【交易】分销返佣"
url="https://doc.iocoder.cn/mall/trade-brokerage/"
/>
</template>
<Grid table-title="" />
</Page>
</template>

View File

@ -0,0 +1,140 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { fenToYuan } from '@vben/utils';
import { getRangePickerDefaultProps } from '#/utils';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'bindUserId',
label: '推广员编号',
component: 'Input',
componentProps: {
placeholder: '请输入推广员编号',
clearable: true,
},
},
{
fieldName: 'brokerageEnabled',
label: '推广资格',
component: 'Select',
componentProps: {
placeholder: '请选择推广资格',
clearable: true,
options: [
{ label: '有', value: true },
{ label: '无', value: false },
],
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
clearable: true,
},
},
];
}
/** 列表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
title: '用户编号',
minWidth: 80,
},
{
field: 'avatar',
title: '头像',
minWidth: 70,
cellRender: {
name: 'CellImage',
props: {
width: 24,
height: 24,
shape: 'circle',
},
},
},
{
field: 'nickname',
title: '昵称',
minWidth: 80,
},
{
field: 'brokerageUserCount',
title: '推广人数',
minWidth: 80,
},
{
field: 'brokerageOrderCount',
title: '推广订单数量',
minWidth: 110,
},
{
field: 'brokerageOrderPrice',
title: '推广订单金额',
minWidth: 110,
formatter: ({ row }) => `${fenToYuan(row.brokerageOrderPrice)}`,
},
{
field: 'withdrawPrice',
title: '已提现金额',
minWidth: 100,
formatter: ({ row }) => `${fenToYuan(row.withdrawPrice)}`,
},
{
field: 'withdrawCount',
title: '已提现次数',
minWidth: 100,
},
{
field: 'price',
title: '未提现金额',
minWidth: 100,
formatter: ({ row }) => `${fenToYuan(row.price)}`,
},
{
field: 'frozenPrice',
title: '冻结中佣金',
minWidth: 100,
formatter: ({ row }) => `${fenToYuan(row.frozenPrice)}`,
},
{
field: 'brokerageEnabled',
title: '推广资格',
minWidth: 80,
slots: { default: 'brokerageEnabled' },
},
{
field: 'brokerageTime',
title: '成为推广员时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
field: 'bindUserId',
title: '上级推广员编号',
minWidth: 150,
},
{
field: 'bindUserTime',
title: '推广员绑定时间',
minWidth: 180,
formatter: 'formatDateTime',
},
{
title: '操作',
width: 150,
fixed: 'right',
slots: { default: 'actions' },
},
];
}

View File

@ -0,0 +1,221 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallBrokerageUserApi } from '#/api/mall/trade/brokerage/user';
import { useAccess } from '@vben/access';
import { DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { ElLoading, ElMessage, ElSwitch } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
clearBindUser,
getBrokerageUserPage,
updateBrokerageEnabled,
} from '#/api/mall/trade/brokerage/user';
import { useGridColumns, useGridFormSchema } from './data';
import BrokerageOrderListModal from './modules/order-list-modal.vue';
import BrokerageUserCreateForm from './modules/user-create-form.vue';
import BrokerageUserListModal from './modules/user-list-modal.vue';
import BrokerageUserUpdateForm from './modules/user-update-form.vue';
defineOptions({ name: 'TradeBrokerageUser' });
const { hasAccessByCodes } = useAccess();
/** 刷新表格 */
function onRefresh() {
gridApi.query();
}
const [OrderListModal, OrderListModalApi] = useVbenModal({
connectedComponent: BrokerageOrderListModal,
});
const [UserCreateModal, UserCreateModalApi] = useVbenModal({
connectedComponent: BrokerageUserCreateForm,
});
const [UserListModal, UserListModalApi] = useVbenModal({
connectedComponent: BrokerageUserListModal,
});
const [UserUpdateModal, UserUpdateModalApi] = useVbenModal({
connectedComponent: BrokerageUserUpdateForm,
});
/** 打开推广人列表 */
function openBrokerageUserTable(row: MallBrokerageUserApi.BrokerageUser) {
UserListModalApi.setData(row).open();
}
/** 打开推广订单列表 */
function openBrokerageOrderTable(row: MallBrokerageUserApi.BrokerageUser) {
OrderListModalApi.setData(row).open();
}
/** 打开表单:修改上级推广人 */
function openUpdateBindUserForm(row: MallBrokerageUserApi.BrokerageUser) {
UserUpdateModalApi.setData(row).open();
}
/** 创建分销员 */
function openCreateUserForm() {
UserCreateModalApi.open();
}
/** 清除上级推广人 */
async function handleClearBindUser(row: MallBrokerageUserApi.BrokerageUser) {
const loadingInstance = ElLoading.service({
text: `正在清除"${row.nickname}"的上级推广人...`,
fullscreen: true,
});
try {
await clearBindUser({ id: row.id as number });
ElMessage.success('清除成功');
onRefresh();
} finally {
loadingInstance.close();
}
}
/** 推广资格:开通/关闭 */
async function handleBrokerageEnabledChange(
row: MallBrokerageUserApi.BrokerageUser,
) {
const text = row.brokerageEnabled ? '开通' : '关闭';
const loadingInstance = ElLoading.service({
text: `正在${text}"${row.nickname}"的推广资格...`,
fullscreen: true,
});
try {
await updateBrokerageEnabled({
id: row.id as number,
enabled: row.brokerageEnabled as boolean,
});
ElMessage.success(`${text}成功`);
onRefresh();
} catch {
//
row.brokerageEnabled = !row.brokerageEnabled;
} finally {
loadingInstance.close();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
showOverflow: 'tooltip',
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getBrokerageUserPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: { code: 'query' },
search: true,
},
} as VxeTableGridOptions<MallBrokerageUserApi.BrokerageUser>,
});
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【交易】分销返佣"
url="https://doc.iocoder.cn/mall/trade-brokerage/"
/>
</template>
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['分销员']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['trade:brokerage-user:create'],
onClick: openCreateUserForm,
},
]"
/>
</template>
<template #brokerageEnabled="{ row }">
<ElSwitch
v-model:checked="row.brokerageEnabled"
:disabled="
!hasAccessByCodes(['trade:brokerage-user:update-bind-user'])
"
checked-children="有"
un-checked-children="无"
@change="handleBrokerageEnabledChange(row)"
/>
</template>
<template #actions="{ row }">
<TableAction
:drop-down-actions="[
{
label: '推广人',
type: 'primary',
link: true,
auth: ['trade:brokerage-user:user-query'],
onClick: openBrokerageUserTable.bind(null, row),
},
{
label: '推广订单',
type: 'primary',
link: true,
auth: ['trade:brokerage-user:order-query'],
onClick: openBrokerageOrderTable.bind(null, row),
},
{
label: '修改上级推广人',
type: 'primary',
link: true,
auth: ['trade:brokerage-user:update-bind-user'],
onClick: openUpdateBindUserForm.bind(null, row),
},
{
label: '清除上级推广人',
type: 'primary',
link: true,
auth: ['trade:brokerage-user:clear-bind-user'],
onClick: handleClearBindUser.bind(null, row),
},
]"
/>
</template>
</Grid>
<!-- 修改上级推广人表单 -->
<UserUpdateModal @success="onRefresh" />
<!-- 推广人列表 -->
<UserListModal />
<!-- 推广订单列表 -->
<OrderListModal />
<!-- 创建分销员 -->
<UserCreateModal @success="onRefresh" />
</Page>
</template>

Some files were not shown because too many files have changed in this diff Show More