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>

查看文件

@@ -564,6 +564,21 @@ export async function getBanners() {
return null;
}
/**
* 获取首页金刚区活动品类导航列表 (配置化接口)
*/
export async function getNavCategories() {
try {
const res = await http.get('/nav-categories');
if (Array.isArray(res) && res.length > 0) {
return res;
}
} catch (err) {
console.warn('[API] 获取金刚区品类导航异常:', err.message);
}
return null;
}

查看文件

@@ -29,18 +29,19 @@
</swiper>
<!-- 陪玩活动项目专属金刚区 (10宫格经典网格:2行×5列,纯图标+主标题) -->
<!-- 陪玩活动项目专属金刚区 (动态活动品类导航网格:纯图标+主标题+高亮角标) -->
<view class="quick-grid grid-card">
<view
v-for="item in activityProjects"
:key="item.id"
v-for="item in navCategories"
:key="item.id || item.code"
class="quick-item btn-active"
@click="handleProjectClick(item)"
>
<view class="quick-icon-bg" :class="item.bgClass">
<image class="quick-svg" :src="item.icon" mode="aspectFit" />
<image class="quick-svg" :src="resolveIcon(item.icon)" mode="aspectFit" />
</view>
<text class="quick-title">{{ item.name }}</text>
<text v-if="item.badgeText" class="quick-badge">{{ item.badgeText }}</text>
</view>
</view>
@@ -128,7 +129,7 @@ import { onShow } from '@dcloudio/uni-app';
import CustomTabBar from '@/components/CustomTabBar.vue';
import SkeletonScreen from '@/components/SkeletonScreen.vue';
import PrivacyModal from '@/components/PrivacyModal.vue';
import { getCompanions, getBanners } from '@/common/api.js';
import { getCompanions, getBanners, getNavCategories } from '@/common/api.js';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
import { getNavBarInfo } from '@/common/nav.js';
@@ -136,6 +137,7 @@ import { getNavBarInfo } from '@/common/nav.js';
const isLoading = ref(true);
const banners = ref([]);
const experts = ref([]);
const navCategories = ref([]);
const currentCategory = ref('all');
const playingId = ref(null);
const currentThemeKey = ref(getCurrentThemeKey());
@@ -153,7 +155,8 @@ const loadInitialData = async () => {
try {
await Promise.allSettled([
fetchLiveCompanions(),
fetchLiveBanners()
fetchLiveBanners(),
fetchLiveNavCategories()
]);
} finally {
// 平滑延时关闭骨架屏,呈现优雅扫光转场
@@ -185,6 +188,23 @@ const fetchLiveBanners = async () => {
}
};
const fetchLiveNavCategories = async () => {
try {
const list = await getNavCategories();
if (Array.isArray(list) && list.length > 0) {
navCategories.value = list;
}
} catch (err) {
console.warn('[Index] 获取金刚区品类导航失败:', err);
}
};
const resolveIcon = (icon) => {
if (!icon) return ICONS.gridAll;
if (ICONS[icon]) return ICONS[icon];
return icon;
};
const onThemeChanged = (key) => {
currentThemeKey.value = key;
applyThemeToNativeUI(key);
@@ -205,6 +225,7 @@ onShow(() => {
uni.hideTabBar({ animation: false, fail: () => {} });
fetchLiveCompanions();
fetchLiveBanners();
fetchLiveNavCategories();
});
@@ -543,6 +564,22 @@ const navTo = (url) => {
line-height: 1.2;
}
.quick-badge {
position: absolute;
top: -6rpx;
right: 6rpx;
background: linear-gradient(135deg, #F43F5E 0%, #FB7185 100%);
color: #FFFFFF;
font-size: 16rpx;
padding: 2rpx 8rpx;
border-radius: 12rpx;
font-weight: 700;
box-shadow: 0 2rpx 6rpx rgba(244, 63, 94, 0.4);
transform: scale(0.9);
}
position: relative;
&:active .quick-icon-bg {
transform: scale(0.92);
}

查看文件

@@ -0,0 +1,130 @@
// 已读取AGENTS.md
import { ContentSecurityService } from '../security/content-security.service';
export interface NavCategoryItem {
id: number;
code: string;
name: string;
icon: string;
bgClass?: string;
cat?: string;
targetUrl: string;
badgeText?: string;
sortOrder: number;
status: number; // 1: 启用, 0: 停用
createdAt?: string;
updatedAt?: string;
}
export class NavCategoryService {
private categories: NavCategoryItem[] = [
{ id: 1, code: 'esports', name: '电子竞技', icon: 'gameAll', bgClass: 'bg-indigo', cat: 'all', targetUrl: '/pages/activity/list?category=ALL', badgeText: 'HOT', sortOrder: 1, status: 1 },
{ id: 2, code: 'meal', name: '美食探店', icon: 'utensils', bgClass: 'bg-amber', cat: 'offline_meal', targetUrl: '/pages/activity/list?category=MEAL', badgeText: 'AA制', sortOrder: 2, status: 1 },
{ id: 3, code: 'sing', name: 'KTV嗨唱', icon: 'micVocal', bgClass: 'bg-rose', cat: 'offline_sing', targetUrl: '/pages/activity/list?category=SING', badgeText: '热度高', sortOrder: 3, status: 1 },
{ id: 4, code: 'billiards', name: '同城台球', icon: 'billiards', bgClass: 'bg-sky', cat: 'offline_play', targetUrl: '/pages/activity/list?category=ALL', badgeText: '', sortOrder: 4, status: 1 },
{ id: 5, code: 'hiking', name: '户外爬山', icon: 'mountain', bgClass: 'bg-emerald', cat: 'offline_play', targetUrl: '/pages/activity/list?category=ALL', badgeText: '周末', sortOrder: 5, status: 1 },
{ id: 6, code: 'drink', name: '下午茶歇', icon: 'coffee', bgClass: 'bg-teal', cat: 'offline_drink', targetUrl: '/pages/activity/list?category=DRINK', badgeText: '', sortOrder: 6, status: 1 },
{ id: 7, code: 'boardgame', name: '剧本桌游', icon: 'dice', bgClass: 'bg-purple', cat: 'offline_play', targetUrl: '/pages/activity/list?category=ALL', badgeText: '', sortOrder: 7, status: 1 },
{ id: 8, code: 'fitness', name: '运动健身', icon: 'fitness', bgClass: 'bg-orange', cat: 'offline_play', targetUrl: '/pages/activity/list?category=ALL', badgeText: '', sortOrder: 8, status: 1 },
{ id: 9, code: 'bar', name: '同城微醺', icon: 'warmFlame', bgClass: 'bg-rose', cat: 'offline_drink', targetUrl: '/pages/activity/list?category=DRINK', badgeText: '清吧', sortOrder: 9, status: 1 },
{ id: 10, code: 'more', name: '全部活动', icon: 'gridAll', bgClass: 'bg-blue', cat: 'all', targetUrl: '/pages/activity/list?category=ALL', badgeText: '', sortOrder: 10, status: 1 }
];
private nextId = 11;
private secService = new ContentSecurityService();
getActiveCategories(): NavCategoryItem[] {
return this.categories
.filter((c) => c.status === 1)
.sort((a, b) => a.sortOrder - b.sortOrder);
}
getAllCategories(): NavCategoryItem[] {
return [...this.categories].sort((a, b) => a.sortOrder - b.sortOrder);
}
createCategory(payload: {
code?: string;
name: string;
icon: string;
bgClass?: string;
cat?: string;
targetUrl?: string;
badgeText?: string;
sortOrder?: number;
status?: number;
}): NavCategoryItem {
if (!payload.name || !payload.name.trim()) {
throw new Error('品类名称不能为空');
}
if (!payload.icon || !payload.icon.trim()) {
throw new Error('品类图标不能为空');
}
const sec = this.secService.checkText(payload.name + ' ' + (payload.badgeText || ''));
if (!sec.pass) {
const err = new Error(sec.message);
(err as any).code = sec.code;
throw err;
}
const item: NavCategoryItem = {
id: this.nextId++,
code: payload.code || `cat_${Date.now()}`,
name: payload.name.trim(),
icon: payload.icon.trim(),
bgClass: payload.bgClass || 'bg-indigo',
cat: payload.cat || 'all',
targetUrl: payload.targetUrl || '/pages/activity/list?category=ALL',
badgeText: payload.badgeText || '',
sortOrder: payload.sortOrder !== undefined ? Number(payload.sortOrder) : 0,
status: payload.status !== undefined ? Number(payload.status) : 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
this.categories.push(item);
return item;
}
updateCategory(id: number, payload: Partial<Omit<NavCategoryItem, 'id'>>): NavCategoryItem {
const idx = this.categories.findIndex((c) => c.id === id);
if (idx === -1) {
throw new Error('品类导航不存在或已被删除');
}
if (payload.name) {
const sec = this.secService.checkText(payload.name + ' ' + (payload.badgeText || ''));
if (!sec.pass) {
const err = new Error(sec.message);
(err as any).code = sec.code;
throw err;
}
}
const current = this.categories[idx];
const updated: NavCategoryItem = {
...current,
...payload,
updatedAt: new Date().toISOString()
};
this.categories[idx] = updated;
return updated;
}
toggleStatus(id: number, status: number): NavCategoryItem {
if (status !== 0 && status !== 1) {
throw new Error('状态参数必须为 0 或 1');
}
return this.updateCategory(id, { status });
}
deleteCategory(id: number): boolean {
const len = this.categories.length;
this.categories = this.categories.filter((c) => c.id !== id);
if (this.categories.length === len) {
throw new Error('品类导航不存在或已被删除');
}
return true;
}
}

查看文件

@@ -317,6 +317,26 @@ export async function initDatabase(): Promise<void> {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 17. 首页金刚区活动品类导航配置表
await conn.query(`
CREATE TABLE IF NOT EXISTS nav_categories (
id INT PRIMARY KEY AUTO_INCREMENT,
code VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(64) NOT NULL,
icon TEXT NOT NULL,
bg_class VARCHAR(64) DEFAULT 'bg-indigo',
cat VARCHAR(64) DEFAULT 'all',
target_url VARCHAR(255) NOT NULL DEFAULT '/pages/activity/list?category=ALL',
badge_text VARCHAR(32) DEFAULT '',
sort_order INT DEFAULT 0,
status TINYINT DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_sort (sort_order),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 初始化配置单条记录
const [cfgRows]: any = await conn.query('SELECT COUNT(*) as count FROM finance_config WHERE id = 1');
if (cfgRows[0].count === 0) {
@@ -525,4 +545,23 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
(3, '精酿清吧微醺畅聊 | 线下陪吃陪喝立减30元', '望京跳海酒馆 · 慢调交友', '同城陪喝', '同城陪喝', 'badge-bar', 'linear-gradient(135deg, #059669 0%, #10B981 100%)', 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800', '/pages/activity/detail?id=act_103', 3, 1);
`);
}
// 14. 种子金刚区活动品类导航
const [navRows]: any = await conn.query('SELECT COUNT(*) as count FROM nav_categories');
if (navRows[0].count === 0) {
await conn.query(`
INSERT INTO nav_categories (id, code, name, icon, bg_class, cat, target_url, badge_text, sort_order, status)
VALUES
(1, 'esports', '电子竞技', 'gameAll', 'bg-indigo', 'all', '/pages/activity/list?category=ALL', 'HOT', 1, 1),
(2, 'meal', '美食探店', 'utensils', 'bg-amber', 'offline_meal', '/pages/activity/list?category=MEAL', 'AA制', 2, 1),
(3, 'sing', 'KTV嗨唱', 'micVocal', 'bg-rose', 'offline_sing', '/pages/activity/list?category=SING', '热度高', 3, 1),
(4, 'billiards', '同城台球', 'billiards', 'bg-sky', 'offline_play', '/pages/activity/list?category=ALL', '', 4, 1),
(5, 'hiking', '户外爬山', 'mountain', 'bg-emerald', 'offline_play', '/pages/activity/list?category=ALL', '周末', 5, 1),
(6, 'drink', '下午茶歇', 'coffee', 'bg-teal', 'offline_drink', '/pages/activity/list?category=DRINK', '', 6, 1),
(7, 'boardgame', '剧本桌游', 'dice', 'bg-purple', 'offline_play', '/pages/activity/list?category=ALL', '', 7, 1),
(8, 'fitness', '运动健身', 'fitness', 'bg-orange', 'offline_play', '/pages/activity/list?category=ALL', '', 8, 1),
(9, 'bar', '同城微醺', 'warmFlame', 'bg-rose', 'offline_drink', '/pages/activity/list?category=DRINK', '清吧', 9, 1),
(10, 'more', '全部活动', 'gridAll', 'bg-blue', 'all', '/pages/activity/list?category=ALL', '', 10, 1);
`);
}
}

查看文件

@@ -851,6 +851,154 @@ export class MysqlRepository {
return res.affectedRows > 0;
}
async getNavCategories(): Promise<any[]> {
const [rows]: any = await pool.query(`
SELECT
id, code, name, icon, bg_class as bgClass, cat,
target_url as targetUrl, badge_text as badgeText,
sort_order as sortOrder, status,
created_at as createdAt, updated_at as updatedAt
FROM nav_categories
WHERE status = 1
ORDER BY sort_order ASC, id ASC
`);
return rows;
}
async getAdminNavCategories(): Promise<any[]> {
const [rows]: any = await pool.query(`
SELECT
id, code, name, icon, bg_class as bgClass, cat,
target_url as targetUrl, badge_text as badgeText,
sort_order as sortOrder, status,
created_at as createdAt, updated_at as updatedAt
FROM nav_categories
ORDER BY sort_order ASC, id ASC
`);
return rows;
}
async createNavCategory(data: {
code?: string;
name: string;
icon: string;
bgClass?: string;
cat?: string;
targetUrl?: string;
badgeText?: string;
sortOrder?: number;
status?: number;
}): Promise<any> {
const code = data.code || `cat_${Date.now()}`;
const name = data.name;
const icon = data.icon;
const bgClass = data.bgClass || 'bg-indigo';
const cat = data.cat || 'all';
const targetUrl = data.targetUrl || '/pages/activity/list?category=ALL';
const badgeText = data.badgeText || '';
const sortOrder = Number(data.sortOrder) || 0;
const status = data.status !== undefined ? Number(data.status) : 1;
const [res]: any = await pool.query(`
INSERT INTO nav_categories (code, name, icon, bg_class, cat, target_url, badge_text, sort_order, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [code, name, icon, bgClass, cat, targetUrl, badgeText, sortOrder, status]);
const newId = res.insertId;
const [rows]: any = await pool.query(`
SELECT
id, code, name, icon, bg_class as bgClass, cat,
target_url as targetUrl, badge_text as badgeText,
sort_order as sortOrder, status,
created_at as createdAt, updated_at as updatedAt
FROM nav_categories
WHERE id = ?
`, [newId]);
return rows[0];
}
async updateNavCategory(id: number | string, data: Partial<{
name: string;
icon: string;
bgClass: string;
cat: string;
targetUrl: string;
badgeText: string;
sortOrder: number;
status: number;
}>): Promise<any> {
const fields: string[] = [];
const values: any[] = [];
if (data.name !== undefined) {
fields.push('name = ?');
values.push(data.name);
}
if (data.icon !== undefined) {
fields.push('icon = ?');
values.push(data.icon);
}
if (data.bgClass !== undefined) {
fields.push('bg_class = ?');
values.push(data.bgClass);
}
if (data.cat !== undefined) {
fields.push('cat = ?');
values.push(data.cat);
}
if (data.targetUrl !== undefined) {
fields.push('target_url = ?');
values.push(data.targetUrl);
}
if (data.badgeText !== undefined) {
fields.push('badge_text = ?');
values.push(data.badgeText);
}
if (data.sortOrder !== undefined) {
fields.push('sort_order = ?');
values.push(Number(data.sortOrder));
}
if (data.status !== undefined) {
fields.push('status = ?');
values.push(Number(data.status));
}
if (fields.length > 0) {
values.push(id);
await pool.query(`UPDATE nav_categories SET ${fields.join(', ')} WHERE id = ?`, values);
}
const [rows]: any = await pool.query(`
SELECT
id, code, name, icon, bg_class as bgClass, cat,
target_url as targetUrl, badge_text as badgeText,
sort_order as sortOrder, status,
created_at as createdAt, updated_at as updatedAt
FROM nav_categories
WHERE id = ?
`, [id]);
return rows[0] || null;
}
async updateNavCategoryStatus(id: number | string, status: number): Promise<any> {
await pool.query('UPDATE nav_categories SET status = ? WHERE id = ?', [Number(status), id]);
const [rows]: any = await pool.query(`
SELECT
id, code, name, icon, bg_class as bgClass, cat,
target_url as targetUrl, badge_text as badgeText,
sort_order as sortOrder, status,
created_at as createdAt, updated_at as updatedAt
FROM nav_categories
WHERE id = ?
`, [id]);
return rows[0] || null;
}
async deleteNavCategory(id: number | string): Promise<boolean> {
const [res]: any = await pool.query('DELETE FROM nav_categories WHERE id = ?', [id]);
return res.affectedRows > 0;
}
async getArbitrations(): Promise<any[]> {

查看文件

@@ -739,6 +739,130 @@ app.delete('/api/admin/banners/:id', async (req: Request, res: Response) => {
}
});
// 首页金刚区活动品类导航列表 (小程序端读取上架状态)
app.get('/api/nav-categories', async (_req: Request, res: Response) => {
try {
const list = await repo.getNavCategories();
res.json({ code: 20000, data: list });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
// 管理后台: 获取全部品类导航列表 (含停用状态)
app.get('/api/admin/nav-categories', async (_req: Request, res: Response) => {
try {
const list = await repo.getAdminNavCategories();
res.json({ code: 20000, data: list });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
// 管理后台: 新增品类导航
app.post('/api/admin/nav-categories', async (req: Request, res: Response) => {
try {
const { code, name, icon, bgClass, cat, targetUrl, badgeText, sortOrder, status } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ code: 40000, message: '品类名称不能为空' });
}
if (!icon || !icon.trim()) {
return res.status(400).json({ code: 40000, message: '品类图标不能为空' });
}
// 微信内容安全检测
const secCheck = contentSecurityService.checkText(name + ' ' + (badgeText || ''));
if (!secCheck.pass) {
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
}
const item = await repo.createNavCategory({
code,
name: name.trim(),
icon: icon.trim(),
bgClass,
cat,
targetUrl,
badgeText,
sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0,
status: status !== undefined ? Number(status) : 1
});
res.json({ code: 20000, data: item, message: '品类导航创建成功' });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 管理后台: 修改品类导航
app.put('/api/admin/nav-categories/:id', async (req: Request, res: Response) => {
try {
const catId = String(req.params.id);
const { name, icon, bgClass, cat, targetUrl, badgeText, sortOrder, status } = req.body;
if (name) {
const secCheck = contentSecurityService.checkText(name + ' ' + (badgeText || ''));
if (!secCheck.pass) {
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
}
}
const updated = await repo.updateNavCategory(catId, {
name,
icon,
bgClass,
cat,
targetUrl,
badgeText,
sortOrder: sortOrder !== undefined ? Number(sortOrder) : undefined,
status: status !== undefined ? Number(status) : undefined
});
if (!updated) {
return res.status(404).json({ code: 40400, message: '品类导航不存在或已被删除' });
}
res.json({ code: 20000, data: updated, message: '品类导航修改成功' });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 管理后台: 切换品类导航显示/隐藏状态
app.post('/api/admin/nav-categories/:id/status', async (req: Request, res: Response) => {
try {
const catId = String(req.params.id);
const { status } = req.body;
if (status === undefined || (status !== 0 && status !== 1)) {
return res.status(400).json({ code: 40000, message: '状态参数必须为 0 或 1' });
}
const updated = await repo.updateNavCategoryStatus(catId, Number(status));
if (!updated) {
return res.status(404).json({ code: 40400, message: '品类导航不存在' });
}
res.json({ code: 20000, data: updated, message: status === 1 ? '品类导航已上架' : '品类导航已下线隐藏' });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 管理后台: 删除品类导航
app.delete('/api/admin/nav-categories/:id', async (req: Request, res: Response) => {
try {
const catId = String(req.params.id);
const ok = await repo.deleteNavCategory(catId);
if (!ok) {
return res.status(404).json({ code: 40400, message: '品类导航不存在或已被删除' });
}
res.json({ code: 20000, data: null, message: '品类导航已删除' });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 创建新订单 (落盘 MySQL)
app.post('/api/orders', async (req: Request, res: Response) => {
try {

查看文件

@@ -0,0 +1,161 @@
// 已读取AGENTS.md
import { NavCategoryService } from '../src/core/nav/nav-category.service';
import * as fs from 'fs';
import * as path from 'path';
describe('NavCategory Management System (TDD 首页金刚区导航品类后台配置与小程序联动)', () => {
let service: NavCategoryService;
const rootDir = path.resolve(__dirname, '../../');
const serverIndexPath = path.join(rootDir, 'server/src/index.ts');
const mysqlPath = path.join(rootDir, 'server/src/db/mysql.ts');
const repoPath = path.join(rootDir, 'server/src/db/repository.ts');
const mobileIndexPath = path.join(rootDir, 'pwuniapp/pages/index/index.vue');
beforeEach(() => {
service = new NavCategoryService();
});
describe('1. 核心服务 NavCategoryService 业务校验', () => {
it('默认加载 10 项经典品类种子且按 sortOrder 正序排序', () => {
const active = service.getActiveCategories();
expect(active.length).toBe(10);
expect(active[0].name).toBe('电子竞技');
expect(active[1].name).toBe('美食探店');
expect(active[2].name).toBe('KTV嗨唱');
expect(active[9].name).toBe('全部活动');
expect(active.every((c) => c.status === 1)).toBe(true);
});
it('成功新增金刚区品类导航并支持角标高亮', () => {
const item = service.createCategory({
code: 'camping',
name: '露营烧烤',
icon: 'warmFlame',
bgClass: 'bg-emerald',
cat: 'offline_play',
targetUrl: '/pages/activity/list?category=ALL',
badgeText: '新玩法',
sortOrder: 0, // 置顶
status: 1
});
expect(item.id).toBe(11);
expect(item.name).toBe('露营烧烤');
expect(item.badgeText).toBe('新玩法');
const active = service.getActiveCategories();
expect(active.length).toBe(11);
expect(active[0].id).toBe(11); // 置顶生效
});
it('创建品类时空名称或空图标应被拦截', () => {
expect(() => {
service.createCategory({
name: '',
icon: 'utensils'
});
}).toThrow('品类名称不能为空');
expect(() => {
service.createCategory({
name: '桌游体验',
icon: ' '
});
}).toThrow('品类图标不能为空');
});
it('品类名称或角标中含有违规敏感词应触发微信内容安全拦截', () => {
expect(() => {
service.createCategory({
name: '美女上门特殊服务招嫖',
icon: 'utensils'
});
}).toThrow();
expect(() => {
service.createCategory({
name: '私下转账免手续费活动',
icon: 'utensils'
});
}).toThrow();
});
it('支持快速上下线切换并在移动端列表中实时反映', () => {
const offline = service.toggleStatus(2, 0);
expect(offline.status).toBe(0);
// 移动端不再展示美食探店
const active = service.getActiveCategories();
expect(active.some((c) => c.id === 2)).toBe(false);
// 后台全量仍存在
expect(service.getAllCategories().some((c) => c.id === 2)).toBe(true);
// 重新上线
const online = service.toggleStatus(2, 1);
expect(online.status).toBe(1);
expect(service.getActiveCategories().some((c) => c.id === 2)).toBe(true);
});
it('更新品类配置与排序权重', () => {
const updated = service.updateCategory(3, {
name: '夜店派对',
badgeText: '超嗨',
sortOrder: 1
});
expect(updated.name).toBe('夜店派对');
expect(updated.badgeText).toBe('超嗨');
expect(updated.sortOrder).toBe(1);
});
it('删除品类导航项', () => {
const ok = service.deleteCategory(10);
expect(ok).toBe(true);
expect(service.getAllCategories().some((c) => c.id === 10)).toBe(false);
expect(() => {
service.deleteCategory(999);
}).toThrow('品类导航不存在或已被删除');
});
});
describe('2. 架构持久化与数据门禁校验', () => {
it('MySQL 数据库必须包含 nav_categories 表定义与 10 条种子数据', () => {
const mysqlContent = fs.readFileSync(mysqlPath, 'utf-8');
expect(mysqlContent).toContain('CREATE TABLE IF NOT EXISTS nav_categories');
expect(mysqlContent).toContain('sort_order INT DEFAULT 0');
expect(mysqlContent).toContain('status TINYINT DEFAULT 1');
expect(mysqlContent).toContain('INSERT INTO nav_categories');
});
it('Repository 数据仓储层必须实现完整的 nav_categories CRUD 方法', () => {
const repoContent = fs.readFileSync(repoPath, 'utf-8');
expect(repoContent).toContain('async getNavCategories(): Promise<any[]>');
expect(repoContent).toContain('async getAdminNavCategories(): Promise<any[]>');
expect(repoContent).toContain('async createNavCategory(');
expect(repoContent).toContain('async updateNavCategory(');
expect(repoContent).toContain('async updateNavCategoryStatus(');
expect(repoContent).toContain('async deleteNavCategory(');
expect(repoContent).toContain('WHERE status = 1');
expect(repoContent).toContain('ORDER BY sort_order ASC');
});
it('server/src/index.ts 必须注册完整的金刚区导航 API 路由与敏感词检查', () => {
const indexContent = fs.readFileSync(serverIndexPath, 'utf-8');
expect(indexContent).toContain("app.get('/api/nav-categories'");
expect(indexContent).toContain("app.get('/api/admin/nav-categories'");
expect(indexContent).toContain("app.post('/api/admin/nav-categories'");
expect(indexContent).toContain("app.put('/api/admin/nav-categories/:id'");
expect(indexContent).toContain("app.post('/api/admin/nav-categories/:id/status'");
expect(indexContent).toContain("app.delete('/api/admin/nav-categories/:id'");
});
it('服务文件与测试套件必须严格满足零系统 Emoji 规范', () => {
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
const serviceContent = fs.readFileSync(path.join(rootDir, 'server/src/core/nav/nav-category.service.ts'), 'utf-8');
const testContent = fs.readFileSync(__filename, 'utf-8');
expect(emojiRegex.test(serviceContent)).toBe(false);
expect(emojiRegex.test(testContent)).toBe(false);
});
});
});