Files
guilixu-admin/src/views/shop/shopGoods/index.vue
T
gxwebsoft 053b776d9a feat(shopGoods): 支持商品分类多选及列表树形高度优化
- 商品模型增加 categoryIds 字段支持多分类,搜索参数同步支持多分类查询
- 商品列表新增分类列,展示商品所属分类名称
- 商品编辑及搜索组件商品分类选择改为多选,保持兼容单选主分类逻辑
- 商品分类树形列表SVG连线高度调小,提升界面紧凑度
- 优化分类图片尺寸,缩小预览图大小,提升视觉效果
- 更新用户推荐接口方法参数类型声明,增加类型安全保障
2026-08-14 19:38:02 +08:00

688 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<a-page-header :title="getPageTitle()" @back="() => $router.go(-1)">
<template #extra>
<Extra />
</template>
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="goodsId"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
:init-load="false"
:scroll="{ x: 1800 }"
size="small"
v-model:selection="selection"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
:navigationList="navigationList"
@add="openEdit"
@remove="removeBatch"
@batchUp="batchUpShelf"
@batchDown="batchDownShelf"
@batchMove="openMove"
@import="openImport"
@imageImport="openImageImport"
@detailImport="openDetailImport"
@convertContent="convertSelectedContent"
@export="handleExport"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<a-space class="flex items-center cursor-pointer">
<img
v-if="record.image"
:src="getCompressedImageUrl(record.image)"
class="goods-thumb"
loading="lazy"
alt=""
width="50"
height="50"
/>
<div class="flex flex-col justify-center leading-tight">
<span class="text-gray-700 font-bold">{{ record.name }}</span>
<span
v-if="record.code"
class="text-gray-400 text-xs mt-1"
>编号:{{ record.code }}</span
>
</div>
</a-space>
</template>
<template v-if="column.key === 'priceGroup'">
<div class="flex flex-col leading-tight">
<span class="text-gray-800 font-bold"
>¥{{ record.price
}}<span
v-if="record.priceMax != null && record.priceMax !== record.price"
> ~ ¥{{ record.priceMax }}</span
></span
>
<span class="text-gray-400 text-xs"
>市场价:<template v-if="record.salePrice"
>¥{{ record.salePrice
}}<span
v-if="
record.salePriceMax != null &&
record.salePriceMax !== record.salePrice
"
> ~ ¥{{ record.salePriceMax }}</span
></template
><template v-else>-</template></span
>
<span class="text-gray-400 text-xs"
>会员价:<template v-if="record.dealerPrice"
>¥{{ record.dealerPrice
}}<span
v-if="
record.dealerPriceMax != null &&
record.dealerPriceMax !== record.dealerPrice
"
> ~ ¥{{ record.dealerPriceMax }}</span
></template
><template v-else>-</template></span
>
</div>
</template>
<template v-if="column.key === 'recommend'">
<a-space @click.stop="onRecommend(record)">
<span
v-if="record.recommend === 1"
class="ele-text-success cursor-pointer"
><CheckOutlined
/></span>
<span v-else class="ele-text-placeholder cursor-pointer"
><CloseOutlined
/></span>
</a-space>
</template>
<template v-if="column.key === 'isTop'">
<a-space @click.stop="onTop(record)">
<span
v-if="record.isTop === 1"
class="ele-text-success cursor-pointer"
><CheckOutlined
/></span>
<span v-else class="ele-text-placeholder cursor-pointer"
><CloseOutlined
/></span>
</a-space>
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 0" color="green">出售中</a-tag>
<a-tag v-if="record.status === 1" color="orange">待上架</a-tag>
<a-tag v-if="record.status === 2" color="purple">待审核</a-tag>
<a-tag v-if="record.status === 3" color="red">审核不通过</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click.stop="openEdit(record)">修改</a>
<a-divider type="vertical" />
<a
v-if="record.status === 0"
class="ele-text-warning"
@click.stop="toggleShelf(record, 1)"
>下架</a
>
<a
v-else
class="ele-text-success"
@click.stop="toggleShelf(record, 0)"
>上架</a
>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此记录吗?"
@confirm.stop="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<ShopGoodsEdit
v-model:visible="showEdit"
:navigationList="navigationList"
:data="current"
@done="reload"
/>
<!-- 导入弹窗 -->
<ShopGoodsImport
v-model:visible="showImport"
@done="reload"
/>
<ShopGoodsImageImport
v-model:visible="showImageImport"
@done="reload"
/>
<ShopGoodsDetailImport
v-model:visible="showDetailImport"
@done="reload"
/>
</a-page-header>
</template>
<script lang="ts" setup>
import { createVNode, ref, onMounted } from 'vue';
import { message, Modal } from 'ant-design-vue';
import {
ExclamationCircleOutlined,
CheckOutlined,
CloseOutlined
} from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toTreeData } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import ShopGoodsEdit from './components/shopGoodsEdit.vue';
import Extra from '@/views/shop/shopGoods/components/extra.vue';
import ShopGoodsImport from './components/shop-goods-import.vue';
import ShopGoodsImageImport from './components/shop-goods-image-import.vue';
import ShopGoodsDetailImport from './components/shop-goods-detail-import.vue';
import {
convertMarkdownImagesToHtml,
listShopGoods,
pageShopGoods,
removeShopGoods,
removeBatchShopGoods,
updateShopGoods
} from '@/api/shop/shopGoods';
import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model';
import { getPageTitle } from '@/utils/common';
import { getCompressedImageUrl } from '@/utils/image';
import { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model';
import { listShopGoodsCategory } from '@/api/shop/shopGoodsCategory';
import { utils, writeFile } from 'xlsx';
import dayjs from 'dayjs';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<ShopGoods[]>([]);
// 当前编辑数据
const current = ref<ShopGoods | null>(null);
// 分类数据
const navigationList = ref<ShopGoodsCategory[]>();
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示导入弹窗
const showImport = ref(false);
// 是否显示图片导入弹窗
const showImageImport = ref(false);
// 是否显示详情导入弹窗
const showDetailImport = ref(false);
// 是否显示批量移动弹窗
const showMove = ref(false);
// 加载状态
const loading = ref(true);
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
if (filters) {
where.status = filters.status;
}
return pageShopGoods({
...where,
...orders,
page,
limit
});
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'goodsId',
key: 'goodsId',
align: 'center',
width: 90
},
{
title: '商品',
dataIndex: 'name',
key: 'name',
width: 300
},
{
title: '分类',
dataIndex: 'categoryName',
key: 'categoryName',
align: 'center',
width: 160,
ellipsis: true,
customRender: ({ text }) => text || '--'
},
{
title: '价格',
key: 'priceGroup',
dataIndex: 'priceGroup'
},
{
title: '销量',
dataIndex: 'sales',
key: 'sales',
align: 'center'
},
{
title: '库存',
dataIndex: 'stock',
key: 'stock',
align: 'center'
},
{
title: '推荐',
dataIndex: 'recommend',
key: 'recommend',
align: 'center'
},
{
title: '置顶',
dataIndex: 'isTop',
key: 'isTop',
align: 'center'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center'
},
// {
// title: '备注',
// dataIndex: 'comments',
// key: 'comments',
// align: 'center',
// },
// {
// title: '排序号',
// dataIndex: 'sortNumber',
// key: 'sortNumber',
// align: 'center',
// width: 120,
// sorter: true
// },
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
sorter: true,
ellipsis: true,
width: 180,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: ShopGoodsParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: ShopGoods) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
/* 打开导入弹窗 */
const openImport = () => {
showImport.value = true;
};
/* 打开图片导入弹窗 */
const openImageImport = () => {
showImageImport.value = true;
};
/* 打开详情导入弹窗 */
const openDetailImport = () => {
showDetailImport.value = true;
};
/* 转换选中商品详情Markdown图片为HTML */
const convertSelectedContent = () => {
const goodsIds = selection.value
.map((goods) => goods.goodsId)
.filter((goodsId): goodsId is number => goodsId != null);
if (!goodsIds.length) {
message.error('请至少选择一条商品');
return;
}
Modal.confirm({
title: '转换商品详情内容',
content: `确定要转换选中的 ${goodsIds.length} 个商品详情内容吗?`,
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: async () => {
const hide = message.loading('正在转换商品详情..', 0);
try {
const results = await Promise.all(
goodsIds.map((goodsId) => convertMarkdownImagesToHtml({ goodsId }))
);
const total = results.reduce(
(sum, item) => sum + (item.data?.total || 0),
0
);
const success = results.reduce(
(sum, item) => sum + (item.data?.success || 0),
0
);
hide();
message.success(`转换完成,匹配${total}个商品,成功转换${success}个`);
reload();
} catch (e: any) {
hide();
message.error(e.message || '转换失败,请重试');
}
}
});
};
/* 价格区间文本:多规格且区间不相等时显示 ¥min ~ ¥max,否则显示单值 */
const fmtPrice = (min?: string | number, max?: string | number) => {
if (min == null || min === '') {
return max != null && max !== '' ? `¥${max}` : '';
}
const base = `¥${min}`;
return max != null && max !== '' && `${max}` !== `${min}`
? `${base} ~ ¥${max}`
: base;
};
/* 导出商品列表 */
const handleExport = async () => {
if (loading.value) {
return;
}
loading.value = true;
message.loading('正在准备导出数据...', 0);
try {
const array: (string | number)[][] = [
[
'商品ID',
'商品名称',
'价格',
'市场价',
'会员价',
'销量',
'库存',
'状态',
'推荐',
'创建时间'
]
];
const list = await listShopGoods({});
if (!list || list.length === 0) {
message.warning('没有数据可以导出');
loading.value = false;
return;
}
list.forEach((goods: ShopGoods) => {
const statusMap = ['已上架', '待上架', '待审核', '审核不通过'];
array.push([
`${goods.goodsId || ''}`,
`${goods.name || ''}`,
fmtPrice(goods.price, goods.priceMax),
fmtPrice(goods.salePrice, goods.salePriceMax),
fmtPrice(goods.dealerPrice, goods.dealerPriceMax),
`${goods.sales || 0}`,
`${goods.stock || 0}`,
statusMap[goods.status || 0] || '',
goods.recommend === 1 ? '是' : '否',
`${goods.createTime || ''}`
]);
});
const sheetName = `商品列表${dayjs(new Date()).format('YYYYMMDD')}`;
const workbook = {
SheetNames: [sheetName],
Sheets: {}
};
const sheet = utils.aoa_to_sheet(array);
workbook.Sheets[sheetName] = sheet;
sheet['!cols'] = [
{ wch: 10 },
{ wch: 30 },
{ wch: 12 },
{ wch: 12 },
{ wch: 12 },
{ wch: 10 },
{ wch: 10 },
{ wch: 12 },
{ wch: 8 },
{ wch: 20 }
];
message.destroy();
message.loading('正在生成Excel文件...', 0);
setTimeout(() => {
writeFile(workbook, `${sheetName}.xlsx`);
loading.value = false;
message.destroy();
message.success(`成功导出 ${list.length} 条记录`);
}, 1000);
} catch (error: any) {
loading.value = false;
message.destroy();
message.error(error.message || '导出失败,请重试');
}
};
/* 删除单个 */
const remove = (row: ShopGoods) => {
const hide = message.loading('请求中..', 0);
removeShopGoods(row.goodsId)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchShopGoods(selection.value.map((d) => d.goodsId))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 查询:分类数据延迟到列表首屏渲染完成后再加载,不阻塞首屏 */
const query = () => {
// 加载分类数据
if (!navigationList.value) {
listShopGoodsCategory({}).then((res) => {
navigationList.value = toTreeData({
data: res?.map((d) => {
d.value = d.categoryId;
d.label = d.title;
return d;
}),
idField: 'categoryId',
parentIdField: 'parentId'
});
});
}
};
const onRecommend = (row: ShopGoods) => {
updateShopGoods({
...row,
recommend: row.recommend == 1 ? 0 : 1
}).then((msg) => {
message.success(msg);
reload();
});
};
/* 单个商品置顶/取消置顶(isTop: 1 置顶 0 不置顶) */
const onTop = (row: ShopGoods) => {
const hide = message.loading('请求中..', 0);
updateShopGoods({
...row,
isTop: row.isTop == 1 ? 0 : 1
})
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 单个商品上/下架切换(status: 0 上架 1 下架) */
const toggleShelf = (row: ShopGoods, status: number) => {
const hide = message.loading('请求中..', 0);
updateShopGoods({ ...row, status })
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量上架 */
const batchUpShelf = () => {
batchSetShelf(0, '上架');
};
/* 批量下架 */
const batchDownShelf = () => {
batchSetShelf(1, '下架');
};
/* 批量设置上下架 */
const batchSetShelf = (status: number, label: string) => {
if (!selection.value.length) {
message.error('请至少选择一条商品');
return;
}
Modal.confirm({
title: '提示',
content: `确定要批量${label}选中的 ${selection.value.length} 个商品吗?`,
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
Promise.all(selection.value.map((d) => updateShopGoods({ ...d, status })))
.then(() => {
hide();
message.success(`已批量${label} ${selection.value.length} 个商品`);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 自定义行属性 */
const customRow = (record: ShopGoods) => {
return {
// 行点击事件
onClick: () => {
// openEdit(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
onMounted(query);
</script>
<script lang="ts">
export default {
name: 'ShopGoods'
};
</script>
<style lang="less" scoped>
.goods-thumb {
width: 50px;
height: 50px;
object-fit: cover;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.06);
flex-shrink: 0;
display: block;
}
</style>