From 51682fffeac84db0fbaf63cfc3fe47e1466443e9 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Sat, 10 Oct 2026 09:08:18 +0800 Subject: [PATCH] feat(nav-category): support dynamic home grid categories in admin and uniapp --- .../arco-design-pro-vite/src/locale/en-US.ts | 1 + .../arco-design-pro-vite/src/locale/zh-CN.ts | 1 + .../src/router/routes/modules/banner.ts | 10 + .../src/views/banner/nav-category/index.vue | 405 ++++++++++++++++++ pwuniapp/common/api.js | 15 + pwuniapp/pages/index/index.vue | 49 ++- server/src/core/nav/nav-category.service.ts | 130 ++++++ server/src/db/mysql.ts | 39 ++ server/src/db/repository.ts | 148 +++++++ server/src/index.ts | 124 ++++++ server/tests/nav-category.spec.ts | 161 +++++++ 11 files changed, 1077 insertions(+), 6 deletions(-) create mode 100644 admin/arco-design-pro-vite/src/views/banner/nav-category/index.vue create mode 100644 server/src/core/nav/nav-category.service.ts create mode 100644 server/tests/nav-category.spec.ts diff --git a/admin/arco-design-pro-vite/src/locale/en-US.ts b/admin/arco-design-pro-vite/src/locale/en-US.ts index e0a7897..39545a1 100644 --- a/admin/arco-design-pro-vite/src/locale/en-US.ts +++ b/admin/arco-design-pro-vite/src/locale/en-US.ts @@ -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', diff --git a/admin/arco-design-pro-vite/src/locale/zh-CN.ts b/admin/arco-design-pro-vite/src/locale/zh-CN.ts index 2f5896b..c7ac142 100644 --- a/admin/arco-design-pro-vite/src/locale/zh-CN.ts +++ b/admin/arco-design-pro-vite/src/locale/zh-CN.ts @@ -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': '实时监控-服务端', diff --git a/admin/arco-design-pro-vite/src/router/routes/modules/banner.ts b/admin/arco-design-pro-vite/src/router/routes/modules/banner.ts index 16d5a90..a2d3e35 100644 --- a/admin/arco-design-pro-vite/src/router/routes/modules/banner.ts +++ b/admin/arco-design-pro-vite/src/router/routes/modules/banner.ts @@ -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: ['*'], + }, + }, ], }; diff --git a/admin/arco-design-pro-vite/src/views/banner/nav-category/index.vue b/admin/arco-design-pro-vite/src/views/banner/nav-category/index.vue new file mode 100644 index 0000000..db56662 --- /dev/null +++ b/admin/arco-design-pro-vite/src/views/banner/nav-category/index.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 0cee2ec..b7a590c 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -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; +} + diff --git a/pwuniapp/pages/index/index.vue b/pwuniapp/pages/index/index.vue index c0b9b7e..f3ef1e0 100644 --- a/pwuniapp/pages/index/index.vue +++ b/pwuniapp/pages/index/index.vue @@ -29,18 +29,19 @@ - + - + {{ item.name }} + {{ item.badgeText }} @@ -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); } diff --git a/server/src/core/nav/nav-category.service.ts b/server/src/core/nav/nav-category.service.ts new file mode 100644 index 0000000..ae147f5 --- /dev/null +++ b/server/src/core/nav/nav-category.service.ts @@ -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>): 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; + } +} diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 41326d2..d1a6356 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -317,6 +317,26 @@ export async function initDatabase(): Promise { ) 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 { (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); + `); + } } diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index 3751104..e77d852 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -851,6 +851,154 @@ export class MysqlRepository { return res.affectedRows > 0; } + async getNavCategories(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const [res]: any = await pool.query('DELETE FROM nav_categories WHERE id = ?', [id]); + return res.affectedRows > 0; + } + async getArbitrations(): Promise { diff --git a/server/src/index.ts b/server/src/index.ts index 1722705..50af214 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 { diff --git a/server/tests/nav-category.spec.ts b/server/tests/nav-category.spec.ts new file mode 100644 index 0000000..81d892d --- /dev/null +++ b/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'); + expect(repoContent).toContain('async getAdminNavCategories(): Promise'); + 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); + }); + }); +});