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

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

查看文件

@@ -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 {