feat(nav-category): support dynamic home grid categories in admin and uniapp
这个提交包含在:
@@ -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 {
|
||||
|
||||
161
server/tests/nav-category.spec.ts
普通文件
161
server/tests/nav-category.spec.ts
普通文件
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户