This commit is contained in:
2026-05-31 12:09:14 +08:00
commit e4f9eedb80
1121 changed files with 209380 additions and 0 deletions
@@ -0,0 +1,72 @@
<!-- 搜索表单 -->
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="where.type"
placeholder="选择类型"
allow-clear
style="width: 120px"
@change="search"
>
<a-select-option :value="0">商城分类</a-select-option>
<a-select-option :value="1">外卖分类</a-select-option>
</a-select>
<a-input-search
v-model:value="where.keywords"
placeholder="搜索分类名称"
allow-clear
style="width: 200px"
@search="search"
@pressEnter="search"
/>
<a-button type="primary" class="ele-btn-icon" @click="add">
<template #icon><PlusOutlined /></template>
<span>添加分类</span>
</a-button>
<a-button
danger
class="ele-btn-icon"
:disabled="!selection?.length"
@click="remove"
>
<template #icon><DeleteOutlined /></template>
<span>批量删除</span>
</a-button>
</a-space>
</template>
<script lang="ts" setup>
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import type { ShopGoodsCategoryParam } from '@/api/shop/shopGoodsCategory/model';
import useSearch from '@/utils/use-search';
const props = withDefaults(
defineProps<{
selection?: any[];
}>(),
{}
);
const emit = defineEmits<{
(e: 'search', where?: ShopGoodsCategoryParam): void;
(e: 'add'): void;
(e: 'remove'): void;
}>();
const { where } = useSearch<ShopGoodsCategoryParam>({
type: undefined,
keywords: ''
});
const search = () => {
emit('search', where);
};
const add = () => {
emit('add');
};
const remove = () => {
emit('remove');
};
</script>
@@ -0,0 +1,266 @@
<!-- 商品分类编辑弹窗 -->
<template>
<ele-modal
:width="560"
:visible="visible"
:maskClosable="false"
:title="isUpdate ? '编辑分类' : '添加分类'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ flex: '90px' }"
:wrapper-col="{ flex: '1' }"
>
<a-form-item label="分类名称" name="title">
<a-input
allow-clear
placeholder="请输入分类名称"
v-model:value="form.title"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="上级分类" name="parentId">
<a-tree-select
allow-clear
:tree-data="parentTreeData"
tree-default-expand-all
style="width: 100%"
placeholder="顶级分类(不选则为顶级)"
:value="form.parentId || undefined"
@update:value="(val?: number) => (form.parentId = val)"
/>
</a-form-item>
<a-form-item label="分类类型" name="type">
<a-radio-group v-model:value="form.type">
<a-radio :value="0">商城分类</a-radio>
<a-radio :value="1">外卖分类</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="分类图片" name="image">
<a-upload
:before-upload="handleImageUpload"
:show-upload-list="false"
accept="image/*"
>
<a-button v-if="!form.image">
<template #icon><UploadOutlined /></template>
上传图片
</a-button>
<div v-else class="flex items-center gap-2">
<img :src="form.image" style="width: 60px; height: 60px; object-fit: cover; border-radius: 4px; border: 1px solid #d9d9d9;" />
<a-button type="text" danger size="small" @click.stop="form.image = undefined">删除</a-button>
</div>
</a-upload>
</a-form-item>
<a-form-item label="排序号" name="sortNumber">
<a-input-number
:min="0"
:max="9999"
style="width: 100%"
placeholder="数值越小越靠前"
v-model:value="form.sortNumber"
/>
</a-form-item>
<a-form-item label="分类标识" name="categoryCode">
<a-input
allow-clear
placeholder="可选,如:shoes、clothing"
v-model:value="form.categoryCode"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
<a-radio :value="0">正常</a-radio>
<a-radio :value="1">禁用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="备注" name="comments">
<a-textarea
:rows="3"
:maxlength="200"
placeholder="可选备注说明"
v-model:value="form.comments"
/>
</a-form-item>
</a-form>
</ele-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch, computed } from 'vue';
import { message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { assignObject, toTreeData } from 'ele-admin-pro';
import { addShopGoodsCategory, updateShopGoodsCategory } from '@/api/shop/shopGoodsCategory';
import { uploadFile } from '@/api/system/file';
import type { ShopGoodsCategory } from '@/api/shop/shopGoodsCategory/model';
import type { FormInstance } from 'ant-design-vue/es/form';
const isUpdate = ref(false);
const props = defineProps<{
visible: boolean;
data?: ShopGoodsCategory | null;
categoryList?: ShopGoodsCategory[];
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
const loading = ref(false);
const formRef = ref<FormInstance | null>(null);
const form = reactive<ShopGoodsCategory>({
categoryId: undefined,
categoryCode: undefined,
title: undefined,
type: 0,
image: undefined,
parentId: undefined,
path: undefined,
component: undefined,
pageId: undefined,
userId: undefined,
count: undefined,
sortNumber: 100,
comments: undefined,
hide: 0,
recommend: 0,
showIndex: 0,
merchantId: undefined,
status: 0,
deleted: 0,
tenantId: undefined,
createTime: undefined,
updateTime: undefined
});
// 父级分类树数据
const parentTreeData = computed(() => {
if (!props.categoryList) return [];
// 排除自己和自己的子孙节点,防止循环引用
const excludeIds = new Set<number>();
const collectIds = (id?: number) => {
if (!id) return;
excludeIds.add(id);
const children = props.categoryList?.filter((c) => c.parentId === id) || [];
children.forEach((c) => collectIds(c.categoryId));
};
if (props.data?.categoryId) {
collectIds(props.data.categoryId);
}
const filtered = props.categoryList?.filter((c) => !excludeIds.has(c.categoryId || 0)) || [];
return toTreeData({
data: filtered.map((c) => ({
...c,
value: c.categoryId,
label: c.title,
title: c.title,
key: c.categoryId
})),
idField: 'categoryId',
parentIdField: 'parentId'
});
});
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules: Record<string, any> = {
title: [
{
required: true,
type: 'string',
message: '请填写分类名称',
trigger: 'blur'
}
],
type: [
{
required: true,
type: 'number',
message: '请选择分类类型',
trigger: 'change'
}
]
};
const save = () => {
if (!formRef.value) return;
formRef.value
.validate()
.then(() => {
loading.value = true;
const saveOrUpdate = isUpdate.value ? updateShopGoodsCategory : addShopGoodsCategory;
saveOrUpdate({ ...form })
.then((msg) => {
loading.value = false;
message.success(msg);
updateVisible(false);
emit('done');
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {});
};
// 图片上传
const handleImageUpload = async (file: File) => {
const isImage = file.type?.startsWith('image/');
if (!isImage) {
message.error('只能上传图片文件');
return false;
}
try {
const res = await uploadFile(file);
if (res?.path) {
form.image = res.path;
}
} catch (e: any) {
message.error(e.message || '上传失败');
}
return false; // 阻止默认上传
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
assignObject(form, props.data);
isUpdate.value = true;
} else {
// 重置为默认值
Object.assign(form, {
categoryId: undefined,
categoryCode: undefined,
title: undefined,
type: 0,
image: undefined,
parentId: undefined,
sortNumber: 100,
comments: undefined,
status: 0
});
isUpdate.value = false;
}
} else {
formRef.value?.resetFields();
}
},
{ immediate: true }
);
</script>
+275
View File
@@ -0,0 +1,275 @@
<!-- 商品分类管理 -->
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="categoryId"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
v-model:selection="selection"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<a-space :style="{ paddingLeft: ((depthMap.get(record.categoryId || 0) || 0) * 20) + 'px' }">
<img v-if="record.image" :src="record.image" style="width: 24px; height: 24px; object-fit: cover; border-radius: 4px;" />
<span class="font-bold">{{ record.title }}</span>
</a-space>
</template>
<template v-if="column.key === 'type'">
<a-tag :color="record.type === 0 ? 'blue' : 'orange'">
{{ record.type === 0 ? '商城分类' : '外卖分类' }}
</a-tag>
</template>
<template v-if="column.key === 'parentId'">
{{ getParentName(record.parentId) }}
</template>
<template v-if="column.key === 'status'">
<a-switch
:checked="record.status === 0"
checked-children="正常"
un-checked-children="禁用"
size="small"
@change="(val: boolean) => toggleStatus(record, val)"
/>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a @click="openEdit(record)">编辑</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要删除此分类吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<ShopGoodsCategoryEdit
v-model:visible="showEdit"
:data="current"
:categoryList="flatCategoryList"
@done="reload"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } 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 ShopGoodsCategoryEdit from './components/shopGoodsCategoryEdit.vue';
import {
listShopGoodsCategory,
removeShopGoodsCategory,
removeBatchShopGoodsCategory,
updateShopGoodsCategory
} from '@/api/shop/shopGoodsCategory';
import type { ShopGoodsCategory, ShopGoodsCategoryParam } from '@/api/shop/shopGoodsCategory/model';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<ShopGoodsCategory[]>([]);
// 当前编辑数据
const current = ref<ShopGoodsCategory | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 分类列表(扁平,用于父级选择)
const flatCategoryList = ref<ShopGoodsCategory[]>([]);
// 分类层级深度映射
const depthMap = ref<Map<number, number>>(new Map());
// 计算每个分类的层级深度
const computeDepth = (list: ShopGoodsCategory[]) => {
const map = new Map<number, number>();
const getDepth = (id?: number): number => {
if (!id || id === 0) return 0;
if (map.has(id)) return map.get(id)!;
const parent = list.find((c) => c.categoryId === id);
const d = parent ? getDepth(parent.parentId) + 1 : 0;
map.set(id, d);
return d;
};
list.forEach((c) => {
if (c.categoryId) {
map.set(c.categoryId, getDepth(c.categoryId));
}
});
depthMap.value = map;
};
// 表格数据源(不分页,全量展示便于查看层级)
const datasource: DatasourceFunction = ({ where, orders }) => {
return listShopGoodsCategory({ ...where, ...orders }).then((res) => {
const list = res || [];
flatCategoryList.value = list;
computeDepth(list);
return { list, count: list.length };
});
};
// 获取父级分类名称
const getParentName = (parentId?: number) => {
if (!parentId || parentId === 0) return '顶级分类';
const parent = flatCategoryList.value.find((c) => c.categoryId === parentId);
return parent?.title || '顶级分类';
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'categoryId',
key: 'categoryId',
align: 'center',
width: 70
},
{
title: '分类名称',
dataIndex: 'title',
key: 'title',
align: 'left',
minWidth: 160
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
align: 'center',
width: 100
},
{
title: '上级分类',
dataIndex: 'parentId',
key: 'parentId',
align: 'center',
width: 120
},
{
title: '排序',
dataIndex: 'sortNumber',
key: 'sortNumber',
align: 'center',
width: 80,
sorter: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 90
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 120,
sorter: true,
customRender: ({ text }) => toDateString(text, 'yyyy-MM-dd')
},
{
title: '操作',
key: 'action',
width: 140,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: ShopGoodsCategoryParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
/* 打开编辑弹窗 */
const openEdit = (row?: ShopGoodsCategory) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 切换状态 */
const toggleStatus = (row: ShopGoodsCategory, val: boolean) => {
const newStatus = val ? 0 : 1;
updateShopGoodsCategory({ ...row, status: newStatus })
.then((msg) => {
message.success(msg);
row.status = newStatus;
})
.catch((e) => {
message.error(e.message);
});
};
/* 删除单个 */
const remove = (row: ShopGoodsCategory) => {
const hide = message.loading('请求中..', 0);
removeShopGoodsCategory(row.categoryId)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) return;
const ids = selection.value.map((item) => item.categoryId);
const hide = message.loading('请求中..', 0);
removeBatchShopGoodsCategory(ids)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 行属性:双击编辑 */
const customRow = (record: ShopGoodsCategory) => {
return {
onDblclick: () => {
openEdit(record);
}
};
};
// 初始化由 ele-pro-table 自动触发 datasource 加载
</script>