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 @@
+
+
+
+ 首页轮播与运营
+ 金刚区导航品类
+
+
+
+
+
+
+
+ 新增品类导航
+
+
+
+ 刷新
+
+
+
+
+
+
+ 金刚区为小程序首页核心导流宫格网格。支持自由配置品类名称、SVG图标标识或上传自定图标、微光背景底色、跳转链接路径、右上角特惠高亮角标与显示排序。
+
+
+
+
+
+
+
+
+
+
+ {{ record.icon }}
+
+
+
+
+
+
+
+ {{ record.name }}
+
+ {{ record.badgeText }}
+
+
+ 标识码: {{ record.code }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ record.targetUrl }}
+
+
+
+
+
+
+
+
+
+
+
+ handleToggleStatus(record, val)"
+ >
+ 上架
+ 下线
+
+
+
+
+
+
+
+
+
+ 编辑
+
+
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 电竞游戏 (gameAll)
+ 美食餐饮 (utensils)
+ KTV嗨唱 (micVocal)
+ 同城台球 (billiards)
+ 户外登山 (mountain)
+ 咖啡茶歇 (coffee)
+ 桌游骰子 (dice)
+ 运动健身 (fitness)
+ 火热微醺 (warmFlame)
+ 全部活动 (gridAll)
+
+
+
+
+
+
+
+
+
+ 靛蓝 (bg-indigo)
+ 琥珀 (bg-amber)
+ 玫瑰 (bg-rose)
+ 天蓝 (bg-sky)
+ 翡翠 (bg-emerald)
+ 青碧 (bg-teal)
+ 罗兰 (bg-purple)
+ 暖橙 (bg-orange)
+ 经典蓝 (bg-blue)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 立即上架(小程序端立即可见)
+ 暂存草稿(下线隐藏)
+
+
+
+
+
+
+
+
+
+
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);
+ });
+ });
+});