feat(nav-category): support dynamic home grid categories in admin and uniapp

这个提交包含在:
xcbyy
2026-10-10 09:08:18 +08:00
父节点 f010cfb7ab
当前提交 51682fffea
共有 11 个文件被更改,包括 1077 次插入 和 6 次删除

查看文件

@@ -48,6 +48,7 @@ export default {
'menu.safety.auditLog': 'Risk Intercept Log',
'menu.banner': 'Banner & Operations',
'menu.banner.manage': 'Banner Management',
'menu.banner.navCategory': 'Category Navigation',
'menu.server.dashboard': 'Dashboard-Server',
'menu.server.workplace': 'Workplace-Server',
'menu.server.monitor': 'Monitor-Server',

查看文件

@@ -48,6 +48,7 @@ export default {
'menu.safety.auditLog': '场所与内容拦截日志',
'menu.banner': '首页轮播与运营',
'menu.banner.manage': '轮播图管理',
'menu.banner.navCategory': '金刚区导航品类',
'menu.server.dashboard': '仪表盘-服务端',
'menu.server.workplace': '工作台-服务端',
'menu.server.monitor': '实时监控-服务端',

查看文件

@@ -22,6 +22,16 @@ const BANNER: AppRouteRecordRaw = {
roles: ['*'],
},
},
{
path: 'nav-category',
name: 'NavCategoryManage',
component: () => import('@/views/banner/nav-category/index.vue'),
meta: {
locale: 'menu.banner.navCategory',
requiresAuth: true,
roles: ['*'],
},
},
],
};

查看文件

@@ -0,0 +1,405 @@
<template>
<div class="container">
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item>首页轮播与运营</a-breadcrumb-item>
<a-breadcrumb-item>金刚区导航品类</a-breadcrumb-item>
</a-breadcrumb>
<a-card class="general-card" title="首页金刚区活动品类导航控制台">
<template #extra>
<a-space>
<a-button type="primary" @click="openCreateModal">
<template #icon><icon-plus /></template>
新增品类导航
</a-button>
<a-button @click="fetchData">
<template #icon><icon-refresh /></template>
刷新
</a-button>
</a-space>
</template>
<!-- 提示栏 -->
<a-alert style="margin-bottom: 16px" type="info">
金刚区为小程序首页核心导流宫格网格。支持自由配置品类名称、SVG图标标识或上传自定图标、微光背景底色、跳转链接路径、右上角特惠高亮角标与显示排序。
</a-alert>
<!-- 数据表格 -->
<a-table
:data="categoryList"
:loading="loading"
:pagination="{ pageSize: 12, showTotal: true }"
row-key="id"
>
<template #columns>
<a-table-column title="ID" data-index="id" :width="70" />
<a-table-column title="图标标识" :width="110">
<template #cell="{ record }">
<div class="icon-avatar-box" :class="record.bgClass">
<span class="icon-code-text">{{ record.icon }}</span>
</div>
</template>
</a-table-column>
<a-table-column title="品类名称" :width="160">
<template #cell="{ record }">
<div class="cat-name-text">
{{ record.name }}
<a-tag v-if="record.badgeText" color="red" size="small" style="margin-left: 6px">
{{ record.badgeText }}
</a-tag>
</div>
<div class="cat-code-sub">标识码: {{ record.code }}</div>
</template>
</a-table-column>
<a-table-column title="品类分类与底色" :width="160">
<template #cell="{ record }">
<div style="margin-bottom: 4px">
<a-tag color="blue" size="small">{{ record.cat }}</a-tag>
</div>
<div>
<a-tag color="gray" size="small">{{ record.bgClass }}</a-tag>
</div>
</template>
</a-table-column>
<a-table-column title="跳转路径" :width="240">
<template #cell="{ record }">
<span class="url-text">{{ record.targetUrl }}</span>
</template>
</a-table-column>
<a-table-column title="显示排序" data-index="sortOrder" :width="100">
<template #cell="{ record }">
<a-badge :count="record.sortOrder" :max-count="999" color="#165dff" />
</template>
</a-table-column>
<a-table-column title="状态" :width="110">
<template #cell="{ record }">
<a-switch
:model-value="record.status === 1"
:loading="statusLoadingId === record.id"
@change="(val) => handleToggleStatus(record, val)"
>
<template #checked>上架</template>
<template #unchecked>下线</template>
</a-switch>
</template>
</a-table-column>
<a-table-column title="操作" :width="150" fixed="right">
<template #cell="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEditModal(record)">
<template #icon><icon-edit /></template>
编辑
</a-button>
<a-popconfirm
content="确定彻底删除此金刚区品类吗?删除后小程序将不再呈现。"
@ok="handleDelete(record.id)"
>
<a-button type="text" status="danger" size="small">
<template #icon><icon-delete /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<!-- 新增 / 编辑 弹窗 -->
<a-modal
v-model:visible="modalVisible"
:title="isEdit ? '编辑金刚区导航品类' : '新增金刚区导航品类'"
:width="600"
@ok="handleModalSubmit"
@cancel="modalVisible = false"
>
<a-form :model="formData" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="品类名称" required>
<a-input v-model="formData.name" placeholder="如:美食探店 / 剧本桌游" maxlength="20" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="唯一编码 (code)">
<a-input v-model="formData.code" placeholder="如:meal / sing / esports" :disabled="isEdit" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="14">
<a-form-item label="矢量图标标识 (SVG key / 图片URL)" required>
<a-input
v-model="formData.icon"
placeholder="如: utensils, micVocal, billiards 或 http开头的图片URL"
/>
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item label="常用图标预设">
<a-select v-model="formData.icon" placeholder="快捷选择">
<a-option value="gameAll">电竞游戏 (gameAll)</a-option>
<a-option value="utensils">美食餐饮 (utensils)</a-option>
<a-option value="micVocal">KTV嗨唱 (micVocal)</a-option>
<a-option value="billiards">同城台球 (billiards)</a-option>
<a-option value="mountain">户外登山 (mountain)</a-option>
<a-option value="coffee">咖啡茶歇 (coffee)</a-option>
<a-option value="dice">桌游骰子 (dice)</a-option>
<a-option value="fitness">运动健身 (fitness)</a-option>
<a-option value="warmFlame">火热微醺 (warmFlame)</a-option>
<a-option value="gridAll">全部活动 (gridAll)</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="背景微光色 (bg_class)">
<a-select v-model="formData.bgClass" placeholder="选择卡片底色">
<a-option value="bg-indigo">靛蓝 (bg-indigo)</a-option>
<a-option value="bg-amber">琥珀 (bg-amber)</a-option>
<a-option value="bg-rose">玫瑰 (bg-rose)</a-option>
<a-option value="bg-sky">天蓝 (bg-sky)</a-option>
<a-option value="bg-emerald">翡翠 (bg-emerald)</a-option>
<a-option value="bg-teal">青碧 (bg-teal)</a-option>
<a-option value="bg-purple">罗兰 (bg-purple)</a-option>
<a-option value="bg-orange">暖橙 (bg-orange)</a-option>
<a-option value="bg-blue">经典蓝 (bg-blue)</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="分类筛选标识 (cat)">
<a-input v-model="formData.cat" placeholder="如:offline_meal / offline_sing / all" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="跳转目标页面路径" required>
<a-input v-model="formData.targetUrl" placeholder="如:/pages/activity/list?category=MEAL" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="右上角角标文案 (如: HOT / AA制 / 周末)">
<a-input v-model="formData.badgeText" placeholder="留空则不显示角标" maxlength="6" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序优先级 (越小越靠前)">
<a-input-number v-model="formData.sortOrder" :min="0" :max="999" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="初始发布状态">
<a-radio-group v-model="formData.status">
<a-radio :value="1">立即上架(小程序端立即可见)</a-radio>
<a-radio :value="0">暂存草稿(下线隐藏)</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import { Message } from '@arco-design/web-vue';
interface NavCategoryItem {
id: number;
code: string;
name: string;
icon: string;
bgClass: string;
cat: string;
targetUrl: string;
badgeText: string;
sortOrder: number;
status: number;
}
const loading = ref(false);
const categoryList = ref<NavCategoryItem[]>([]);
const statusLoadingId = ref<number | null>(null);
const modalVisible = ref(false);
const isEdit = ref(false);
const editingId = ref<number | null>(null);
const defaultFormData = {
code: '',
name: '',
icon: 'utensils',
bgClass: 'bg-indigo',
cat: 'all',
targetUrl: '/pages/activity/list?category=ALL',
badgeText: '',
sortOrder: 1,
status: 1,
};
const formData = ref({ ...defaultFormData });
const fetchData = async () => {
loading.value = true;
try {
const res: any = await axios.get('/api/admin/nav-categories');
if (res && res.data && res.data.code === 20000) {
categoryList.value = res.data.data;
} else if (Array.isArray(res.data)) {
categoryList.value = res.data;
}
} catch (err: any) {
Message.error(err.response?.data?.message || '获取金刚区品类列表失败');
} finally {
loading.value = false;
}
};
const openCreateModal = () => {
isEdit.value = false;
editingId.value = null;
formData.value = {
...defaultFormData,
code: `cat_${Date.now()}`,
};
modalVisible.value = true;
};
const openEditModal = (record: NavCategoryItem) => {
isEdit.value = true;
editingId.value = record.id;
formData.value = {
code: record.code,
name: record.name,
icon: record.icon,
bgClass: record.bgClass || 'bg-indigo',
cat: record.cat || 'all',
targetUrl: record.targetUrl || '/pages/activity/list?category=ALL',
badgeText: record.badgeText || '',
sortOrder: record.sortOrder || 0,
status: record.status,
};
modalVisible.value = true;
};
const handleModalSubmit = async () => {
if (!formData.value.name || !formData.value.name.trim()) {
Message.warning('品类名称不能为空');
return;
}
if (!formData.value.icon || !formData.value.icon.trim()) {
Message.warning('图标标识不能为空');
return;
}
try {
if (isEdit.value && editingId.value) {
const res: any = await axios.put(`/api/admin/nav-categories/${editingId.value}`, formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
Message.success('品类导航修改成功');
modalVisible.value = false;
fetchData();
}
} else {
const res: any = await axios.post('/api/admin/nav-categories', formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
Message.success('品类导航新增成功,已同步至小程序首页');
modalVisible.value = false;
fetchData();
}
}
} catch (err: any) {
Message.error(err.response?.data?.message || '保存失败,请稍后重试');
}
};
const handleToggleStatus = async (record: NavCategoryItem, checked: any) => {
const newStatus = checked ? 1 : 0;
statusLoadingId.value = record.id;
try {
const res: any = await axios.post(`/api/admin/nav-categories/${record.id}/status`, {
status: newStatus,
});
if (res && (res.data.code === 20000 || res.status === 200)) {
record.status = newStatus;
Message.success(newStatus === 1 ? '品类已上架至小程序' : '品类已下线隐藏');
}
} catch (err: any) {
Message.error(err.response?.data?.message || '切换状态失败');
} finally {
statusLoadingId.value = null;
}
};
const handleDelete = async (id: number) => {
try {
const res: any = await axios.delete(`/api/admin/nav-categories/${id}`);
if (res && (res.data.code === 20000 || res.status === 200)) {
Message.success('品类导航已彻底删除');
fetchData();
}
} catch (err: any) {
Message.error(err.response?.data?.message || '删除失败');
}
};
onMounted(() => {
fetchData();
});
</script>
<style scoped>
.container {
padding: 16px 20px;
}
.breadcrumb {
margin-bottom: 16px;
}
.icon-avatar-box {
width: 68px;
height: 38px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(99, 102, 241, 0.12);
border: 1px solid rgba(99, 102, 241, 0.25);
}
.icon-code-text {
font-size: 11px;
font-weight: 600;
color: #165dff;
}
.cat-name-text {
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
color: var(--color-text-1);
}
.cat-code-sub {
font-size: 11px;
color: var(--color-text-3);
margin-top: 2px;
}
.url-text {
font-family: monospace;
font-size: 11px;
color: #165dff;
word-break: break-all;
}
</style>