fix: 完善全系统业务闭环(大神入驻联动用户角色、服务评价人工下架监管、友圈搭子上车审核与抽屉)
这个提交包含在:
@@ -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',
|
||||
|
||||
@@ -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': '实时监控-服务端',
|
||||
|
||||
@@ -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: ['*'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
|
||||
<a-table-column title="发布时间" data-index="createdAt" :width="160" />
|
||||
|
||||
<a-table-column title="管理操作" :width="200" fixed="right">
|
||||
<a-table-column title="管理操作" :width="280" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space :size="8">
|
||||
<!-- 置顶切换 -->
|
||||
@@ -185,6 +185,16 @@
|
||||
评论
|
||||
</a-button>
|
||||
|
||||
<!-- 搭子上车申请 -->
|
||||
<a-button
|
||||
size="small"
|
||||
type="text"
|
||||
status="success"
|
||||
@click="openAppliesDrawer(record)"
|
||||
>
|
||||
上车申请
|
||||
</a-button>
|
||||
|
||||
<!-- 违规删除 -->
|
||||
<a-popconfirm
|
||||
content="确定彻底删除该条动态及其所有评论吗?此操作不可逆。"
|
||||
@@ -246,6 +256,62 @@
|
||||
该动态暂无任何评论互动
|
||||
</div>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 搭子上车申请抽屉 (Drawer) -->
|
||||
<a-drawer
|
||||
v-model:visible="appliesDrawerVisible"
|
||||
:width="540"
|
||||
:title="`搭子上车申请 - ${currentPost ? currentPost.author : ''}`"
|
||||
:footer="false"
|
||||
>
|
||||
<div v-if="loadingApplies" style="text-align: center; padding: 40px 0">
|
||||
<a-spin />
|
||||
</div>
|
||||
<div v-else-if="appliesList.length > 0">
|
||||
<a-list :bordered="false">
|
||||
<a-list-item v-for="app in appliesList" :key="app.id">
|
||||
<a-list-item-meta
|
||||
:title="app.applicantName || app.applicant_name || '搭子玩家'"
|
||||
:description="`申请时间: ${app.createdAt || app.created_at || '-'} | 用户ID: ${app.applicantId || app.applicant_id || '-'}`"
|
||||
>
|
||||
<template #avatar>
|
||||
<a-avatar :size="36">
|
||||
<img :src="app.applicantAvatar || app.applicant_avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120'" alt="avatar" />
|
||||
</a-avatar>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="app.status === 'PENDING' || app.status === '待处理'"
|
||||
size="mini"
|
||||
type="primary"
|
||||
status="success"
|
||||
@click="handleApplyAction(app.id, 'ACCEPTED')"
|
||||
>
|
||||
同意上车
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="app.status === 'PENDING' || app.status === '待处理'"
|
||||
size="mini"
|
||||
type="secondary"
|
||||
status="danger"
|
||||
@click="handleApplyAction(app.id, 'REJECTED')"
|
||||
>
|
||||
婉拒
|
||||
</a-button>
|
||||
<a-tag v-else :color="app.status === 'ACCEPTED' || app.status === '已同意' ? 'green' : 'gray'">
|
||||
{{ app.status === 'ACCEPTED' ? '已同意' : (app.status === 'REJECTED' ? '已婉拒' : app.status) }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</div>
|
||||
<div v-else style="text-align: center; padding: 60px 0; color: var(--color-text-3)">
|
||||
该动态暂无任何搭子上车申请
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -266,6 +332,11 @@
|
||||
const loadingComments = ref(false);
|
||||
const commentsList = ref<any[]>([]);
|
||||
|
||||
// 搭子上车申请抽屉
|
||||
const appliesDrawerVisible = ref(false);
|
||||
const loadingApplies = ref(false);
|
||||
const appliesList = ref<any[]>([]);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<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 @click="fetchData">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新评价
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-alert type="info" style="margin-bottom: 16px">
|
||||
评价合规底线:平台保护买家客观真实评价权益,同时严厉打击恶意差评敲诈、人身攻击辱骂、同行恶意抹黑或私下引流等违规言论;删除违规评价将自动重新核算大神的综合评分。
|
||||
</a-alert>
|
||||
|
||||
<!-- 搜索筛选栏 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 16px">
|
||||
<a-col :span="6">
|
||||
<a-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索评价人、关联订单号或评价内容"
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix><icon-search /></template>
|
||||
</a-input>
|
||||
</a-col>
|
||||
<a-col :span="5">
|
||||
<a-select v-model="ratingFilter" placeholder="评分星级筛选" allow-clear>
|
||||
<a-option :value="0">全部星级</a-option>
|
||||
<a-option :value="5">5星好评</a-option>
|
||||
<a-option :value="4">4星良好</a-option>
|
||||
<a-option :value="3">3星中评</a-option>
|
||||
<a-option :value="2">2星差评</a-option>
|
||||
<a-option :value="1">1星极差</a-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-table
|
||||
:data="filteredList"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10, showTotal: true }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="评价工单ID" data-index="id" :width="140">
|
||||
<template #cell="{ record }">
|
||||
<span class="code-font">{{ record.id }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="关联订单" data-index="orderId" :width="160">
|
||||
<template #cell="{ record }">
|
||||
<span class="code-font">{{ record.orderId }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="评价用户" :width="160">
|
||||
<template #cell="{ record }">
|
||||
<a-space>
|
||||
<a-avatar :size="32" shape="circle">
|
||||
<img :src="record.userAvatar" alt="avatar" />
|
||||
</a-avatar>
|
||||
<div>
|
||||
<div style="font-weight: 600">{{ record.userNickname }}</div>
|
||||
<div style="font-size: 11px; color: var(--color-text-3)">UID: {{ record.userId }}</div>
|
||||
</div>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="被评大神ID" data-index="expertId" :width="120">
|
||||
<template #cell="{ record }">
|
||||
<span class="code-font">大神 #{{ record.expertId }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="评分与印象标签" :width="200">
|
||||
<template #cell="{ record }">
|
||||
<div style="margin-bottom: 4px">
|
||||
<a-rate :model-value="record.rating" readonly allow-half size="small" />
|
||||
<span style="font-weight: 600; margin-left: 6px; color: #ff7d00">{{ record.rating }}分</span>
|
||||
</div>
|
||||
<a-space wrap :size="4">
|
||||
<a-tag v-for="(tag, idx) in record.tags" :key="idx" size="small" color="arcoblue">
|
||||
{{ tag }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="买家评价陈述" :width="280">
|
||||
<template #cell="{ record }">
|
||||
<div class="content-box">{{ record.content || '无文字评价' }}</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="大神回复内容" :width="220">
|
||||
<template #cell="{ record }">
|
||||
<div v-if="record.replyContent" class="reply-box">
|
||||
<div style="font-weight: 600; font-size: 11px; color: #165dff">大神回复:</div>
|
||||
<div>{{ record.replyContent }}</div>
|
||||
</div>
|
||||
<span v-else style="color: var(--color-text-4); font-size: 12px">暂未回复</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="评价时间" data-index="createdAt" :width="160" />
|
||||
|
||||
<a-table-column title="操作" :width="120" fixed="right" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-popconfirm
|
||||
content="确定下架并删除该条评价吗?删除后将自动重算大神信誉评级,此操作不可逆。"
|
||||
type="warning"
|
||||
@ok="handleDelete(record.id)"
|
||||
>
|
||||
<a-button type="text" status="danger" size="small">
|
||||
违规删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const rawList = ref<any[]>([]);
|
||||
const searchKeyword = ref('');
|
||||
const ratingFilter = ref(0);
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await axios.get('/api/reviews');
|
||||
if (res && res.data) {
|
||||
rawList.value = Array.isArray(res.data) ? res.data : (res.data.data || []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Message.error('获取评价列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredList = computed(() => {
|
||||
return rawList.value.filter((item) => {
|
||||
const matchRating = !ratingFilter.value || Math.floor(item.rating) === ratingFilter.value;
|
||||
const kw = searchKeyword.value.trim().toLowerCase();
|
||||
const matchKw =
|
||||
!kw ||
|
||||
String(item.id).toLowerCase().includes(kw) ||
|
||||
String(item.orderId).toLowerCase().includes(kw) ||
|
||||
String(item.userNickname || '').toLowerCase().includes(kw) ||
|
||||
String(item.content || '').toLowerCase().includes(kw);
|
||||
return matchRating && matchKw;
|
||||
});
|
||||
});
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const res: any = await axios.delete(`/api/reviews/${id}`);
|
||||
Message.success(res.data?.message || '违规评价已下架删除');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
Message.error(err.response?.data?.message || err.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.breadcrumb {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.code-font {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
.content-box {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-fill-2);
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.reply-box {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-2);
|
||||
background: var(--color-fill-3);
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #165dff;
|
||||
}
|
||||
</style>
|
||||
@@ -84,10 +84,13 @@ export async function initDatabase(): Promise<void> {
|
||||
) 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<void> {
|
||||
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 (
|
||||
|
||||
@@ -559,6 +559,7 @@ export class MysqlRepository {
|
||||
avatar?: string;
|
||||
gender?: string;
|
||||
}): Promise<any> {
|
||||
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<any[]> {
|
||||
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<boolean> {
|
||||
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<any[]> {
|
||||
const [rows]: any = await pool.query(`
|
||||
@@ -2709,6 +2748,27 @@ export class MysqlRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async getPostApplies(postId?: string): Promise<any[]> {
|
||||
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<any> {
|
||||
await pool.query('UPDATE post_applies SET status = ? WHERE id = ?', [status, applyId]);
|
||||
return { id: applyId, status };
|
||||
}
|
||||
|
||||
// 16. 用户个人提现记录查询
|
||||
async getUserWithdrawals(userId: number): Promise<any[]> {
|
||||
const [rows]: any = await pool.query(`
|
||||
@@ -2728,8 +2788,10 @@ export class MysqlRepository {
|
||||
async replyOrderReview(reviewId: string, replyContent: string): Promise<any> {
|
||||
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(`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户