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 e3a4b96..f5ca3cd 100644 --- a/admin/arco-design-pro-vite/src/locale/en-US.ts +++ b/admin/arco-design-pro-vite/src/locale/en-US.ts @@ -55,6 +55,7 @@ export default { 'menu.banner.navCategory': 'Category Navigation', 'menu.community': 'Community & Content', 'menu.community.postManage': 'Post & Comment Audit', + 'menu.community.reviewManage': 'Review Management', '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 890ef20..0e9da81 100644 --- a/admin/arco-design-pro-vite/src/locale/zh-CN.ts +++ b/admin/arco-design-pro-vite/src/locale/zh-CN.ts @@ -55,6 +55,7 @@ export default { 'menu.banner.navCategory': '金刚区导航品类', 'menu.community': '友圈与内容管理', 'menu.community.postManage': '动态与评论监管', + 'menu.community.reviewManage': '服务评价与回复监管', 'menu.server.dashboard': '仪表盘-服务端', 'menu.server.workplace': '工作台-服务端', 'menu.server.monitor': '实时监控-服务端', diff --git a/admin/arco-design-pro-vite/src/router/routes/modules/community.ts b/admin/arco-design-pro-vite/src/router/routes/modules/community.ts index 8f2dd08..2258297 100644 --- a/admin/arco-design-pro-vite/src/router/routes/modules/community.ts +++ b/admin/arco-design-pro-vite/src/router/routes/modules/community.ts @@ -22,6 +22,16 @@ const COMMUNITY: AppRouteRecordRaw = { roles: ['*'], }, }, + { + path: 'review-manage', + name: 'ReviewManage', + component: () => import('@/views/community/review-manage/index.vue'), + meta: { + locale: 'menu.community.reviewManage', + requiresAuth: true, + roles: ['*'], + }, + }, ], }; diff --git a/admin/arco-design-pro-vite/src/views/community/post-manage/index.vue b/admin/arco-design-pro-vite/src/views/community/post-manage/index.vue index 3687bb5..692c09e 100644 --- a/admin/arco-design-pro-vite/src/views/community/post-manage/index.vue +++ b/admin/arco-design-pro-vite/src/views/community/post-manage/index.vue @@ -153,7 +153,7 @@ - + @@ -266,6 +332,11 @@ const loadingComments = ref(false); const commentsList = ref([]); + // 搭子上车申请抽屉 + const appliesDrawerVisible = ref(false); + const loadingApplies = ref(false); + const appliesList = ref([]); + const fetchData = async () => { loading.value = true; try { @@ -345,6 +416,32 @@ } }; + const openAppliesDrawer = async (record: any) => { + currentPost.value = record; + appliesDrawerVisible.value = true; + loadingApplies.value = true; + try { + const res: any = await axios.get(`/api/posts/${record.id}/applies`); + appliesList.value = res.data?.data || res.data || []; + } catch (err) { + appliesList.value = []; + } finally { + loadingApplies.value = false; + } + }; + + const handleApplyAction = async (applyId: string, status: string) => { + try { + const res: any = await axios.post(`/api/post-applies/${applyId}/handle`, { status }); + Message.success(res.data?.message || (status === 'ACCEPTED' ? '已同意上车' : '已婉拒')); + if (currentPost.value) { + openAppliesDrawer(currentPost.value); + } + } catch (err: any) { + Message.error('处置上车申请失败'); + } + }; + onMounted(() => { fetchData(); }); diff --git a/admin/arco-design-pro-vite/src/views/community/review-manage/index.vue b/admin/arco-design-pro-vite/src/views/community/review-manage/index.vue new file mode 100644 index 0000000..c5bdb8a --- /dev/null +++ b/admin/arco-design-pro-vite/src/views/community/review-manage/index.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 61a6c69..13ed293 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -84,10 +84,13 @@ export async function initDatabase(): Promise { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); - // 动态为 companions 增加 tags 字段 (若历史表已存在) + // 动态为 companions 增加 tags 字段与 user_id 字段 (若历史表已存在) try { await conn.query('ALTER TABLE companions ADD COLUMN tags JSON AFTER id_card_masked'); } catch (_) {} + try { + await conn.query('ALTER TABLE companions ADD COLUMN user_id INT DEFAULT NULL AFTER id'); + } catch (_) {} // 4. 订单表 await conn.query(` @@ -279,12 +282,21 @@ export async function initDatabase(): Promise { rating INT NOT NULL DEFAULT 5, tags JSON, content TEXT, + reply_content TEXT, + reply_time DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_order (order_id), INDEX idx_expert (expert_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + try { + await conn.query('ALTER TABLE order_reviews ADD COLUMN reply_content TEXT AFTER content'); + } catch (_) {} + try { + await conn.query('ALTER TABLE order_reviews ADD COLUMN reply_time DATETIME AFTER reply_content'); + } catch (_) {} + // 14. 友圈动态互动评论表 await conn.query(` CREATE TABLE IF NOT EXISTS post_comments ( diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index 3c1c080..0f7c142 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -559,6 +559,7 @@ export class MysqlRepository { avatar?: string; gender?: string; }): Promise { + const userId = Number(data.userId) || 1001; const name = data.name.trim(); const track = data.track || '同城陪吃'; const hourlyRate = Number(data.hourlyRate) || 88; @@ -569,20 +570,20 @@ export class MysqlRepository { ? data.idCard.substring(0, 6) + '********' + data.idCard.substring(data.idCard.length - 4) : '110101********0000'; - const [existing]: any = await pool.query('SELECT * FROM companions WHERE name = ?', [name]); + const [existing]: any = await pool.query('SELECT * FROM companions WHERE name = ? OR (user_id IS NOT NULL AND user_id = ?)', [name, userId]); if (existing && existing.length > 0) { await pool.query(` UPDATE companions - SET track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', id_card_masked = ? + SET user_id = ?, track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', id_card_masked = ? WHERE id = ? - `, [track, hourlyRate, tagsJson, avatar, gender, maskedIdCard, existing[0].id]); + `, [userId, track, hourlyRate, tagsJson, avatar, gender, maskedIdCard, existing[0].id]); return this.getCompanionById(existing[0].id); } const [res]: any = await pool.query(` - INSERT INTO companions (name, avatar, gender, age, track, rank_name, hourly_rate, voice_duration, voice_title, rating, order_count, status, audit_status, id_card_masked, tags, apply_time) - VALUES (?, ?, ?, 23, ?, '申请中陪伴师', ?, 15, '热情语音介绍', 5.0, 0, '待审核', '待审核', ?, ?, NOW()) - `, [name, avatar, gender, track, hourlyRate, maskedIdCard, tagsJson]); + INSERT INTO companions (user_id, name, avatar, gender, age, track, rank_name, hourly_rate, voice_duration, voice_title, rating, order_count, status, audit_status, id_card_masked, tags, apply_time) + VALUES (?, ?, ?, ?, 23, ?, '申请中陪伴师', ?, 15, '热情语音介绍', 5.0, 0, '待审核', '待审核', ?, ?, NOW()) + `, [userId, name, avatar, gender, track, hourlyRate, maskedIdCard, tagsJson]); return this.getCompanionById(res.insertId); } @@ -597,7 +598,21 @@ export class MysqlRepository { `, [auditStatus, status, id]); const [rows]: any = await pool.query('SELECT * FROM companions WHERE id = ?', [id]); - return { ...rows[0], message: action === 'approve' ? '资质审核通过' : `已驳回: ${reason}` }; + const comp = rows && rows[0]; + + // 联动更新关联用户的角色与大神状态 + if (comp && comp.user_id) { + const uId = Number(comp.user_id); + if (uId > 0) { + if (action === 'approve') { + await pool.query('UPDATE users SET role = "认证大神", is_expert = 1 WHERE id = ?', [uId]); + } else { + await pool.query('UPDATE users SET role = "普通用户", is_expert = 0 WHERE id = ?', [uId]); + } + } + } + + return { ...rows[0], message: action === 'approve' ? '资质审核通过,用户已获得大神接单权限' : `已驳回: ${reason}` }; } // 订单相关 @@ -2603,10 +2618,19 @@ export class MysqlRepository { } async getOrderReviews(expertId?: number, orderId?: string): Promise { + try { + await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_content TEXT AFTER content'); + } catch (_) {} + try { + await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_time DATETIME AFTER reply_content'); + } catch (_) {} let sql = ` SELECT id, order_id as orderId, expert_id as expertId, user_id as userId, user_nickname as userNickname, user_avatar as userAvatar, - rating, tags, content, created_at as createdAt + rating, tags, content, + COALESCE(reply_content, '') as replyContent, + reply_time as replyTime, + created_at as createdAt FROM order_reviews `; const params: any[] = []; @@ -2637,6 +2661,21 @@ export class MysqlRepository { }); } + async deleteReview(id: string): Promise { + const [rows]: any = await pool.query('SELECT expert_id FROM order_reviews WHERE id = ?', [id]); + const expertId = rows && rows[0]?.expert_id; + + const [res]: any = await pool.query('DELETE FROM order_reviews WHERE id = ?', [id]); + if (expertId) { + try { + const [avgRows]: any = await pool.query('SELECT AVG(rating) as avgRating FROM order_reviews WHERE expert_id = ?', [expertId]); + const newRating = (avgRows && avgRows[0]?.avgRating) ? Number(Number(avgRows[0].avgRating).toFixed(2)) : 5.0; + await pool.query('UPDATE companions SET rating = ? WHERE id = ?', [newRating, expertId]); + } catch (_) {} + } + return res.affectedRows > 0; + } + // 14. 友圈动态评论 async getPostComments(postId: string): Promise { const [rows]: any = await pool.query(` @@ -2709,6 +2748,27 @@ export class MysqlRepository { }; } + async getPostApplies(postId?: string): Promise { + let sql = ` + SELECT id, post_id as postId, applicant_id as applicantId, applicant_name as applicantName, + applicant_avatar as applicantAvatar, status, created_at as createdAt + FROM post_applies + `; + const params: any[] = []; + if (postId) { + sql += ' WHERE post_id = ?'; + params.push(postId); + } + sql += ' ORDER BY created_at DESC'; + const [rows]: any = await pool.query(sql, params); + return rows; + } + + async handlePostApply(applyId: string, status: 'ACCEPTED' | 'REJECTED'): Promise { + await pool.query('UPDATE post_applies SET status = ? WHERE id = ?', [status, applyId]); + return { id: applyId, status }; + } + // 16. 用户个人提现记录查询 async getUserWithdrawals(userId: number): Promise { const [rows]: any = await pool.query(` @@ -2728,8 +2788,10 @@ export class MysqlRepository { async replyOrderReview(reviewId: string, replyContent: string): Promise { const now = new Date(); try { - await pool.query('ALTER TABLE order_reviews ADD COLUMN IF NOT EXISTS reply_content TEXT'); - await pool.query('ALTER TABLE order_reviews ADD COLUMN IF NOT EXISTS reply_time DATETIME'); + await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_content TEXT AFTER content'); + } catch (_) {} + try { + await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_time DATETIME AFTER reply_content'); } catch (_) {} await pool.query(` diff --git a/server/src/index.ts b/server/src/index.ts index a19bc5d..63a2418 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1903,6 +1903,32 @@ app.post('/api/reviews/:id/reply', async (req: Request, res: Response) => { } }); +// 管理员全量查询评价与回复列表 +app.get('/api/reviews', async (req: Request, res: Response) => { + try { + const expertId = req.query.expertId ? Number(req.query.expertId) : undefined; + const orderId = req.query.orderId ? String(req.query.orderId) : undefined; + const reviews = await repo.getOrderReviews(expertId, orderId); + res.json({ + code: 20000, + data: reviews + }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + +// 管理员违规下架/删除评价 +app.delete('/api/reviews/:id', async (req: Request, res: Response) => { + try { + const { id } = req.params; + await repo.deleteReview(String(id)); + res.json({ code: 20000, message: '违规评价已成功删除,大神信誉评分已重新计算' }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + // 友圈本人动态删除接口 app.delete('/api/posts/:id', async (req: Request, res: Response) => { try { @@ -2003,6 +2029,41 @@ app.post('/api/posts/:id/apply', async (req: Request, res: Response) => { } }); +// 获取指定动态或全部搭子上车申请列表 +app.get('/api/posts/:id/applies', async (req: Request, res: Response) => { + try { + const postId = String(req.params.id); + const applies = await repo.getPostApplies(postId); + res.json({ code: 20000, data: applies }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + +app.get('/api/post-applies', async (_req: Request, res: Response) => { + try { + const applies = await repo.getPostApplies(); + res.json({ code: 20000, data: applies }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + +// 审核/处置搭子上车申请 (同意/谢绝) +app.post('/api/post-applies/:id/handle', async (req: Request, res: Response) => { + try { + const applyId = String(req.params.id); + const { status } = req.body || {}; + if (!status || !['ACCEPTED', 'REJECTED'].includes(status)) { + return res.status(400).json({ code: 40000, message: '状态参数必须为 ACCEPTED 或 REJECTED' }); + } + const result = await repo.handlePostApply(applyId, status); + res.json({ code: 20000, data: result, message: status === 'ACCEPTED' ? '已同意搭子上车申请' : '已谢绝搭子上车申请' }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + // 7.8 陪玩收藏管理接口 (收藏/取消收藏/列表/状态探测) app.get('/api/favorites', async (req: Request, res: Response) => { try { diff --git a/server/tests/client-features.spec.ts b/server/tests/client-features.spec.ts index f227428..3f58c33 100644 --- a/server/tests/client-features.spec.ts +++ b/server/tests/client-features.spec.ts @@ -326,6 +326,52 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => { expect(content).toContain('resetAllFilters'); }); }); + + describe('9. 大神认证审核双向联动、服务评价监管与友圈搭子上车业务完整闭环', () => { + it('审核大神入驻通过时,必须联动更新 users 表 role 为 "认证大神" 与 is_expert = 1', () => { + const repoPath = path.join(serverSrcDir, 'db', 'repository.ts'); + const content = fs.readFileSync(repoPath, 'utf-8'); + + expect(content).toContain('auditCompanion'); + expect(content).toContain('UPDATE users SET role = "认证大神", is_expert = 1'); + expect(content).toContain('UPDATE users SET role = "普通用户", is_expert = 0'); + }); + + it('后端必须提供评价与回复监管接口 (GET /api/reviews 与 DELETE /api/reviews/:id)', () => { + const indexPath = path.join(serverSrcDir, 'index.ts'); + const content = fs.readFileSync(indexPath, 'utf-8'); + + expect(content).toContain("app.get('/api/reviews'"); + expect(content).toContain("app.delete('/api/reviews/:id'"); + }); + + it('管理后台路由与菜单中必须挂载服务评价与回复监管模块', () => { + const routePath = path.join(rootDir, 'admin', 'arco-design-pro-vite', 'src', 'router', 'routes', 'modules', 'community.ts'); + const content = fs.readFileSync(routePath, 'utf-8'); + + expect(content).toContain('review-manage'); + expect(content).toContain('menu.community.reviewManage'); + }); + + it('后端必须提供搭子上车申请查询与审核处置接口', () => { + const indexPath = path.join(serverSrcDir, 'index.ts'); + const content = fs.readFileSync(indexPath, 'utf-8'); + + expect(content).toContain("app.get('/api/posts/:id/applies'"); + expect(content).toContain("app.get('/api/post-applies'"); + expect(content).toContain("app.post('/api/post-applies/:id/handle'"); + }); + + it('管理后台动态管理必须支持查看搭子上车申请并执行同意/婉拒处置', () => { + const postManagePath = path.join(rootDir, 'admin', 'arco-design-pro-vite', 'src', 'views', 'community', 'post-manage', 'index.vue'); + const content = fs.readFileSync(postManagePath, 'utf-8'); + + expect(content).toContain('openAppliesDrawer'); + expect(content).toContain('appliesDrawerVisible'); + expect(content).toContain('handleApplyAction'); + expect(content).toContain('/api/post-applies/'); + }); + }); });