增加产品分类

This commit is contained in:
李志强 2026-03-21 14:12:44 +08:00
parent 0ad1dc31a1
commit f9db706769
8 changed files with 708 additions and 58 deletions

View File

@ -51,3 +51,55 @@ export function deleteProducts(id) {
method: 'delete' method: 'delete'
}) })
} }
/**
* 获取产品分类列表
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function getProductsTypesList(params) {
return request({
url: '/admin/productsTypesList',
method: 'get',
params
})
}
/**
* 添加产品分类
* @param {Object} data - 分类数据
* @returns {Promise}
*/
export function addProductsTypes(data) {
return request({
url: '/admin/addProductsTypes',
method: 'post',
data
})
}
/**
* 更新产品分类
* @param {number} id - 分类ID
* @param {Object} data - 分类数据
* @returns {Promise}
*/
export function updateProductsTypes(id, data) {
return request({
url: `/admin/editProductsTypes/${id}`,
method: 'put',
data
})
}
/**
* 删除产品分类
* @param {number} id - 分类ID
* @returns {Promise}
*/
export function deleteProductsTypes(id) {
return request({
url: `/admin/deleteProductsTypes/${id}`,
method: 'delete'
})
}

View File

@ -27,7 +27,7 @@
:default-active="route.path" :default-active="route.path"
> >
<!-- 菜单标题 --> <!-- 菜单标题 -->
<h3>{{ isCollapse ? "管理" : currentModule?.title || "子菜单" }}</h3> <h3>{{ isCollapse ? "管理" : asideTitle }}</h3>
<!-- 无模块时显示提示 --> <!-- 无模块时显示提示 -->
<el-menu-item v-if="!currentModule" index="/home"> <el-menu-item v-if="!currentModule" index="/home">
@ -248,12 +248,30 @@ const displayMenus = computed(() => {
if (!currentModule.value) { if (!currentModule.value) {
return []; return [];
} }
const currentPath = route.path;
// 访 index children
// /apps/cms/products children /apps/cms/products/types
if (currentPath === currentModule.value.path) {
return [];
}
return currentModule.value.children || []; return currentModule.value.children || [];
}); });
const asideTitle = computed(() => {
if (isCollapse.value) return "管理";
if (!currentModule.value) return "子菜单";
return displayMenus.value.length > 0 ? (currentModule.value.title || "子菜单") : "子菜单";
});
const processMenus = (menus) => { const processMenus = (menus) => {
return menus return menus
.filter((menu) => { .filter((menu) => {
// is_visible
if (menu.is_visible !== undefined && Number(menu.is_visible) === 0) {
return false;
}
if (menu.path && menu.path.trim() !== "") return true; if (menu.path && menu.path.trim() !== "") return true;
if (menu.children && menu.children.length > 0) return true; if (menu.children && menu.children.length > 0) return true;
return false; return false;

View File

@ -1,54 +1,174 @@
import { createComponentLoader } from '@/utils/pathResolver'; import { createComponentLoader } from '@/utils/pathResolver';
function computeFullPath(menuPath, parentPath) {
if (!menuPath) return parentPath || '';
if (menuPath.startsWith('/')) {
return menuPath.replace(/\/+/g, '/');
}
const base = (parentPath || '').replace(/\/$/, '');
return `${base}/${menuPath}`.replace(/\/+/g, '/');
}
/** 将子路由的绝对路径转为相对父布局的路径,供 Vue Router 嵌套使用 */
function toRelativeChildPath(parentAbs, childAbs) {
const base = (parentAbs || '').replace(/\/$/, '');
const target = (childAbs || '').replace(/\/$/, '');
if (!target) return '';
if (target === base) return '';
const prefix = `${base}/`;
if (target.startsWith(prefix)) {
return target.slice(prefix.length);
}
// 兜底:取最后一段(菜单 path 配置异常时)
const parts = target.split('/').filter(Boolean);
return parts.length ? parts[parts.length - 1] : '';
}
function hasPageComponent(menu) {
return menu.type === 4 || (menu.component_path && String(menu.component_path).trim() !== '');
}
function resolvePageComponent(menu) {
if (menu.type === 4) {
return () => import('@/views/onepage/index.vue');
}
if (menu.component_path && String(menu.component_path).trim() !== '') {
return createComponentLoader(menu.component_path);
}
return () => import('@/views/404/404.vue');
}
/**
* 菜单子节点 -> 嵌套路由path 相对 layoutAbsPath
*/
function convertNestedMenuChildren(children, layoutAbsPath) {
if (!children || children.length === 0) return [];
return children.map((child) => nestedMenuToRoute(child, layoutAbsPath));
}
function nestedMenuToRoute(menu, layoutAbsPath) {
const childAbs = computeFullPath(menu.path, layoutAbsPath);
const relPath = toRelativeChildPath(layoutAbsPath, childAbs);
const hasChildren = menu.children && menu.children.length > 0;
const ownPage = hasPageComponent(menu);
const meta = {
title: menu.title,
icon: menu.icon,
id: menu.id,
componentPath: menu.component_path,
};
// 既有自己的页面又有子菜单:套一层 EmptyLayout避免父页面组件里没有 <router-view> 导致子路由无法渲染
if (hasChildren && ownPage) {
return {
path: relPath,
name: `menu_${menu.id}`,
meta,
component: () => import('@/views/layouts/EmptyLayout.vue'),
children: [
{
path: '',
name: `menu_${menu.id}_index`,
meta: { ...meta },
component: resolvePageComponent(menu),
},
...convertNestedMenuChildren(menu.children, childAbs),
],
};
}
// 纯目录 + 子节点
if (hasChildren && !ownPage) {
const route = {
path: relPath,
name: `menu_${menu.id}`,
meta,
component: () => import('@/views/layouts/EmptyLayout.vue'),
children: convertNestedMenuChildren(menu.children, childAbs),
};
const firstChild = menu.children[0];
if (firstChild && firstChild.path) {
const firstAbs = computeFullPath(firstChild.path, childAbs);
const firstRel = toRelativeChildPath(childAbs, firstAbs);
if (firstRel) {
route.redirect = firstRel;
}
}
return route;
}
// 叶子页面
return {
path: relPath,
name: `menu_${menu.id}`,
meta,
component: resolvePageComponent(menu),
};
}
// 递归转换嵌套菜单为嵌套路由 // 递归转换嵌套菜单为嵌套路由
export function convertMenusToRoutes(menus, parentPath = '') { export function convertMenusToRoutes(menus, parentPath = '') {
if (!menus || menus.length === 0) return []; if (!menus || menus.length === 0) return [];
return menus.map(menu => { return menus.map((menu) => {
// 拼接完整的路由路径(处理相对路径) const fullPath = menu.path
const fullPath = menu.path ? ? menu.path.startsWith('/')
(menu.path.startsWith('/') ? menu.path : `${parentPath}/${menu.path}`) ? menu.path.replace(/\/+/g, '/')
: `${(parentPath || '').replace(/\/$/, '')}/${menu.path}`.replace(/\/+/g, '/')
: ''; : '';
const hasChildren = menu.children && menu.children.length > 0;
const ownPage = hasPageComponent(menu);
const meta = {
title: menu.title,
icon: menu.icon,
id: menu.id,
componentPath: menu.component_path,
};
// 顶层:有页面 + 有子菜单 -> EmptyLayout + 默认子路由 + 相对 path 子路由
if (hasChildren && ownPage) {
return {
path: fullPath || menu.path || '',
name: `menu_${menu.id}`,
meta,
component: () => import('@/views/layouts/EmptyLayout.vue'),
children: [
{
path: '',
name: `menu_${menu.id}_index`,
meta: { ...meta },
component: resolvePageComponent(menu),
},
...convertNestedMenuChildren(menu.children, fullPath),
],
};
}
const route = { const route = {
path: fullPath || menu.path || '', path: fullPath || menu.path || '',
name: `menu_${menu.id}`, name: `menu_${menu.id}`,
meta: { meta,
title: menu.title,
icon: menu.icon,
id: menu.id,
componentPath: menu.component_path
}
}; };
// 1. 处理组件加载
if (menu.type === 4) { if (menu.type === 4) {
// 单页类型
route.component = () => import('@/views/onepage/index.vue'); route.component = () => import('@/views/onepage/index.vue');
} else if (menu.component_path && menu.component_path.trim() !== '') { } else if (menu.component_path && menu.component_path.trim() !== '') {
// 正常页面
route.component = createComponentLoader(menu.component_path); route.component = createComponentLoader(menu.component_path);
} else if (menu.children && menu.children.length > 0) { } else if (hasChildren) {
// 目录节点:必须给组件,否则父级无法渲染子级
route.component = () => import('@/views/layouts/EmptyLayout.vue'); route.component = () => import('@/views/layouts/EmptyLayout.vue');
} else {
// 异常:既没路径也没子菜单
route.component = () => import('@/views/404/404.vue');
}
// 2. 递归子路由(传递当前完整路径作为父路径)
if (menu.children && menu.children.length > 0) {
route.children = convertMenusToRoutes(menu.children, fullPath); route.children = convertMenusToRoutes(menu.children, fullPath);
// 目录节点添加重定向,防止点击父菜单页面空白
const firstChild = menu.children[0]; const firstChild = menu.children[0];
if (firstChild && firstChild.path) { if (firstChild && firstChild.path) {
// 计算第一个子路由的完整路径 const childFullPath = firstChild.path.startsWith('/')
const childFullPath = firstChild.path.startsWith('/') ? ? firstChild.path
firstChild.path : : `${fullPath}/${firstChild.path}`;
`${fullPath}/${firstChild.path}`;
route.redirect = childFullPath; route.redirect = childFullPath;
} }
} else {
route.component = () => import('@/views/404/404.vue');
} }
return route; return route;

View File

@ -3,16 +3,16 @@
<div class="header-bar"> <div class="header-bar">
<h2>企业产品管理</h2> <h2>企业产品管理</h2>
<div class="header-actions"> <div class="header-actions">
<el-button type="primary" @click="handleTypes">
<i class="fa-solid fa-layer-group"></i>
分类管理
</el-button>
<el-button type="primary" @click="handleAdd"> <el-button type="primary" @click="handleAdd">
<el-icon> <i class="fa-solid fa-plus"></i>
<Plus />
</el-icon>
添加产品 添加产品
</el-button> </el-button>
<el-button @click="refresh" :loading="loading"> <el-button @click="refresh" :loading="loading">
<el-icon> <i class="fa-solid fa-refresh"></i>
<Refresh />
</el-icon>
刷新 刷新
</el-button> </el-button>
</div> </div>
@ -96,6 +96,7 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue' import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { import {
getProductsList, getProductsList,
updateProducts, updateProducts,
@ -106,6 +107,8 @@ import EditDialog from './components/edit.vue'
// @ts-ignore // @ts-ignore
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const router = useRouter()
// URL // URL
// //
// 1) http(s)://... // 1) http(s)://...
@ -244,28 +247,16 @@ const handleDelete = async (row) => {
} }
} }
//
const handleStatusChange = async (row, val) => {
try {
const res = await updateProducts(row.id, { status: val })
if (res.code === 200) {
ElMessage.success('状态更新成功')
} else {
ElMessage.error(res.msg || '状态更新失败')
row.status = val === 1 ? 0 : 1
}
} catch (error) {
console.error('更新企业产品状态失败:', error)
ElMessage.error('更新企业产品状态失败')
row.status = val === 1 ? 0 : 1
}
}
// //
const handleSuccess = () => { const handleSuccess = () => {
fetchList() fetchList()
} }
//
const handleTypes = () => {
router.push('/apps/cms/products/types')
}
// //
onMounted(() => { onMounted(() => {
fetchList() fetchList()

View File

@ -0,0 +1,211 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
destroy-on-close
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="分类名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="父级分类" prop="pid">
<el-select
v-model="formData.pid"
placeholder="请选择父级分类"
clearable
style="width: 100%"
>
<el-option label="顶级分类" :value="0" />
<el-option
v-for="item in typeOptions"
:key="item.id"
:label="item.title"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="分类描述" prop="desc">
<el-input
v-model="formData.desc"
type="textarea"
:rows="4"
placeholder="请输入分类描述(可选)"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
addProductsTypes,
updateProductsTypes,
getProductsTypesList
} from '@/api/products'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: '添加分类'
},
isEdit: {
type: Boolean,
default: false
},
rowData: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const visible = ref(false)
const submitLoading = ref(false)
const formRef = ref(null)
const typeOptions = ref([])
const formData = reactive({
title: '',
pid: 0,
desc: '',
sort: 0
})
const formRules = {
title: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ max: 100, message: '长度不超过100个字符', trigger: 'blur' }
]
}
const resetForm = () => {
formData.title = ''
formData.pid = 0
formData.desc = ''
formData.sort = 0
}
const loadTypeOptions = async () => {
try {
const res = await getProductsTypesList({ page: 1, limit: 1000, keyword: '' })
if (res.code === 200 && Array.isArray(res.data?.list)) {
const curId = props.isEdit ? Number(props.rowData?.id ?? 0) : 0
typeOptions.value = curId
? res.data.list.filter((item) => Number(item.id) !== curId)
: res.data.list
} else {
typeOptions.value = []
}
} catch (e) {
//
console.error('加载父级分类失败:', e)
typeOptions.value = []
}
}
// /
watch(
() => props.modelValue,
async (val) => {
visible.value = val
if (val) {
resetForm()
if (props.isEdit && props.rowData) {
Object.assign(formData, props.rowData)
formData.pid = Number(props.rowData?.pid ?? 0)
formData.sort = Number(props.rowData?.sort ?? 0)
}
await loadTypeOptions()
}
}
)
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleClose = () => {
visible.value = false
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
let res
const payload = {
title: formData.title,
pid: Number(formData.pid ?? 0),
desc: formData.desc ?? '',
sort: Number(formData.sort ?? 0)
}
if (props.isEdit) {
res = await updateProductsTypes(Number(props.rowData?.id), payload)
} else {
res = await addProductsTypes(payload)
}
if (res.code === 200) {
ElMessage.success(props.isEdit ? '更新分类成功' : '添加分类成功')
handleClose()
emit('success')
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (e) {
console.error('提交分类失败:', e)
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
})
}
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@ -0,0 +1,240 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>企业产品分类管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<i class="fa-solid fa-plus"></i>
添加分类
</el-button>
<el-button @click="refresh" :loading="loading">
<i class="fa-solid fa-refresh"></i>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchForm.keyword"
placeholder="请输入分类名称搜索"
clearable
style="width: 200px; margin-right: 10px"
@keyup.enter="handleSearch" />
<el-button type="primary" @click="handleSearch">
<el-icon>
<Search />
</el-icon>
搜索
</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="typesList" style="width: 100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="title" label="分类名称" min-width="180" align="center" />
<el-table-column prop="desc" label="分类描述" min-width="200" align="center" />
<el-table-column prop="pid" label="父级ID" width="110" align="center" />
<el-table-column prop="sort" label="排序" width="90" align="center" />
<el-table-column prop="create_time" label="创建时间" min-width="180" align="center" />
<el-table-column label="操作" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
<el-icon>
<Edit />
</el-icon>
编辑
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination v-model:current-page="pagination.page" v-model:page-size="pagination.limit"
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handlePageChange" />
</div>
<!-- 添加/编辑组件 -->
<EditDialog v-model="dialogVisible" :title="dialogTitle" :is-edit="isEdit" :row-data="currentRow"
@success="handleSuccess" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue'
import {
getProductsTypesList,
deleteProductsTypes
} from '@/api/products'
import EditDialog from './components/edit.vue'
//
const loading = ref(false)
//
const searchForm = reactive({
keyword: ''
})
//
const pagination = reactive({
page: 1,
limit: 10,
total: 0
})
//
const typesList = ref([])
//
const dialogVisible = ref(false)
const dialogTitle = ref('')
const isEdit = ref(false)
const currentRow = ref({})
//
const fetchList = async () => {
loading.value = true
try {
const res = await getProductsTypesList({
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword
})
if (res.code === 200) {
typesList.value = res.data.list
pagination.total = res.data.total
}
} catch (error) {
console.error('获取企业产品分类列表失败:', error)
ElMessage.error('获取企业产品分类列表失败')
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.page = 1
fetchList()
}
//
const resetSearch = () => {
searchForm.keyword = ''
pagination.page = 1
fetchList()
}
//
const refresh = () => {
fetchList()
}
//
const handleSizeChange = (val) => {
pagination.limit = val
fetchList()
}
const handlePageChange = (val) => {
pagination.page = val
fetchList()
}
//
const handleAdd = () => {
isEdit.value = false
dialogTitle.value = '添加企业产品分类'
currentRow.value = {}
dialogVisible.value = true
}
//
const handleEdit = (row) => {
isEdit.value = true
dialogTitle.value = '编辑企业产品分类'
currentRow.value = row
dialogVisible.value = true
}
//
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该企业产品分类吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await deleteProductsTypes(row.id)
if (res.code === 200) {
ElMessage.success('删除分类成功')
fetchList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除企业产品分类失败:', error)
ElMessage.error('删除企业产品分类失败')
}
}
}
//
const handleSuccess = () => {
fetchList()
}
//
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-bar h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.search-bar {
margin-bottom: 20px;
display: flex;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -78,6 +78,14 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="是否显示" prop="is_visible">
<el-switch
v-model="currentMenu.is_visible"
:active-value="1"
:inactive-value="0"
/>
</el-form-item>
<el-form-item label="权限标识" prop="permission"> <el-form-item label="权限标识" prop="permission">
<el-input <el-input
v-model="currentMenu.permission" v-model="currentMenu.permission"
@ -109,6 +117,7 @@ interface Menu {
icon: string; icon: string;
sort: number; sort: number;
status: 0 | 1; status: 0 | 1;
is_visible: 0 | 1;
type: 1 | 2 | 3; type: 1 | 2 | 3;
permission: string; permission: string;
children?: Menu[]; children?: Menu[];
@ -152,6 +161,7 @@ const currentMenu = ref<Partial<Menu>>({
icon: '', icon: '',
sort: 0, sort: 0,
status: 1, status: 1,
is_visible: 1,
type: 1, type: 1,
permission: '', permission: '',
}); });
@ -220,6 +230,7 @@ watch(() => props.menu, (newMenu) => {
icon: '', icon: '',
sort: 0, sort: 0,
status: 1, status: 1,
is_visible: 1,
type: 1, type: 1,
permission: '', permission: '',
}; };
@ -256,6 +267,7 @@ watch(() => props.visible, (newVisible) => {
icon: '', icon: '',
sort: 0, sort: 0,
status: 1, status: 1,
is_visible: 1,
type: 1, type: 1,
permission: '', permission: '',
}; };

View File

@ -89,6 +89,12 @@
align="center" align="center"
></el-table-column> ></el-table-column>
<el-table-column prop="is_visible" label="是否显示" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.is_visible === 1 ? 'success' : 'danger'">{{ scope.row.is_visible === 1 ? '显示' : '隐藏' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center"> <el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope"> <template #default="scope">
<el-switch <el-switch