feat(closed-loop): complete end-to-end closed loops across miniapp, backend, and admin

这个提交包含在:
xcbyy
2026-10-14 10:13:50 +08:00
父节点 f8e81d2df4
当前提交 51c19d6063
共有 14 个文件被更改,包括 996 次插入 和 134 次删除

查看文件

@@ -93,28 +93,28 @@
</div> </div>
</a-grid-item> </a-grid-item>
<!-- 4. 高危场所风控拦截 --> <!-- 4. 违规举报与高危风控 -->
<a-grid-item :span="{ xs: 24, sm: 12, md: 6, lg: 6, xl: 6 }"> <a-grid-item :span="{ xs: 24, sm: 12, md: 6, lg: 6, xl: 6 }">
<div <div
class="decision-card purple-card" class="decision-card purple-card"
@click="router.push('/safety/audit-log')" @click="router.push('/safety/reports')"
> >
<div class="card-icon-wrap"> <div class="card-icon-wrap">
<icon-safe class="card-icon" /> <icon-safe class="card-icon" />
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="card-label">高危私密场所拦截</div> <div class="card-label">违规举报与风控工单</div>
<div class="card-num"> <div class="card-num">
{{ todos.riskInterceptionsCount }} {{ todos.pendingReportsCount !== undefined ? todos.pendingReportsCount : todos.riskInterceptionsCount }}
<span class="unit">次风险阻断</span> <span class="unit">条待处理举报</span>
</div> </div>
<div class="card-hint"> <div class="card-hint">
私人住宅/酒店客房红线拦截 含高危拦截 {{ todos.riskInterceptionsCount }} 次 · 点击处置
</div> </div>
</div> </div>
<div class="card-action"> <div class="card-action">
<a-button type="outline" size="mini"> <a-button type="outline" size="mini">
风控日志 举报工单
</a-button> </a-button>
</div> </div>
</div> </div>
@@ -135,6 +135,7 @@
pendingArbitrationsCount: 1, pendingArbitrationsCount: 1,
pendingWithdrawalsCount: 1, pendingWithdrawalsCount: 1,
pendingWithdrawalAmount: 1250, pendingWithdrawalAmount: 1250,
pendingReportsCount: 1,
riskInterceptionsCount: 3, riskInterceptionsCount: 3,
}); });
@@ -142,7 +143,8 @@
return ( return (
todos.value.pendingAuditsCount + todos.value.pendingAuditsCount +
todos.value.pendingArbitrationsCount + todos.value.pendingArbitrationsCount +
todos.value.pendingWithdrawalsCount todos.value.pendingWithdrawalsCount +
(todos.value.pendingReportsCount || 0)
); );
}); });

查看文件

@@ -68,6 +68,24 @@
</router-link> </router-link>
</div> </div>
</div> </div>
<!-- 4. 待核实违规举报 -->
<div v-for="rep in pendingReports" :key="rep.id" class="todo-item arb-item">
<div class="item-header">
<a-tag color="purple" size="small">违规举报</a-tag>
<span class="item-name">{{ rep.reporterName }} 举报 {{ rep.targetName }}</span>
</div>
<div class="item-desc">
{{ rep.reason }} · {{ rep.description || '待风控核实' }}
</div>
<div class="item-actions">
<router-link to="/safety/reports">
<a-button type="primary" size="mini">
核实处理
</a-button>
</router-link>
</div>
</div>
</div> </div>
</a-card> </a-card>
</template> </template>
@@ -80,6 +98,7 @@
const pendingAudits = ref<any[]>([]); const pendingAudits = ref<any[]>([]);
const pendingWithdrawals = ref<any[]>([]); const pendingWithdrawals = ref<any[]>([]);
const pendingArbitrations = ref<any[]>([]); const pendingArbitrations = ref<any[]>([]);
const pendingReports = ref<any[]>([]);
const fetchQuickTodos = async () => { const fetchQuickTodos = async () => {
try { try {
@@ -88,6 +107,7 @@
pendingAudits.value = (res.data.pendingAudits || []).slice(0, 1); pendingAudits.value = (res.data.pendingAudits || []).slice(0, 1);
pendingWithdrawals.value = (res.data.pendingWithdrawals || []).slice(0, 1); pendingWithdrawals.value = (res.data.pendingWithdrawals || []).slice(0, 1);
pendingArbitrations.value = (res.data.pendingArbitrations || []).slice(0, 1); pendingArbitrations.value = (res.data.pendingArbitrations || []).slice(0, 1);
pendingReports.value = (res.data.pendingReports || []).slice(0, 1);
} }
} catch { } catch {
// 保持兜底 // 保持兜底

查看文件

@@ -244,7 +244,7 @@ export function formatOrderData(raw) {
if (statusKey === '待支付' || statusKey === '待付款') statusKey = 'PENDING_PAY'; if (statusKey === '待支付' || statusKey === '待付款') statusKey = 'PENDING_PAY';
else if (statusKey === '已到场核销' || statusKey === '履约中' || statusKey === '服务中') statusKey = 'IN_SERVICE'; else if (statusKey === '已到场核销' || statusKey === '履约中' || statusKey === '服务中') statusKey = 'IN_SERVICE';
else if (statusKey === '已完成') statusKey = 'COMPLETED'; else if (statusKey === '已完成') statusKey = 'COMPLETED';
else if (statusKey === '争议仲裁中' || statusKey === '退款中' || statusKey === '已全额退款') statusKey = 'REFUNDING'; else if (statusKey === '争议仲裁中' || statusKey === '退款中' || statusKey === '已退款' || statusKey === '已全额退款') statusKey = 'REFUNDING';
else if (statusKey === '已取消') statusKey = 'CANCELLED'; else if (statusKey === '已取消') statusKey = 'CANCELLED';
const companionAvatar = raw.expertAvatar || raw.expert_avatar || raw.avatar || const companionAvatar = raw.expertAvatar || raw.expert_avatar || raw.avatar ||
@@ -266,6 +266,7 @@ export function formatOrderData(raw) {
quantity: Number(raw.durationHours || raw.duration_hours || raw.quantity || 2), quantity: Number(raw.durationHours || raw.duration_hours || raw.quantity || 2),
totalAmount: Number(raw.amount || raw.totalAmount || 0), totalAmount: Number(raw.amount || raw.totalAmount || 0),
status: statusKey, status: statusKey,
rawStatus: raw.status || '履约中',
meetVenue: raw.location || raw.meetVenue || '北京市公开商圈', meetVenue: raw.location || raw.meetVenue || '北京市公开商圈',
expenseBurden: raw.expenseBurden || raw.expense_burden || 'AA制各自付', expenseBurden: raw.expenseBurden || raw.expense_burden || 'AA制各自付',
payType: raw.payType || raw.pay_type || 'WECHAT', payType: raw.payType || raw.pay_type || 'WECHAT',
@@ -418,6 +419,7 @@ export async function getPosts(category, userId) {
return { return {
id: p.id, id: p.id,
userId: p.userId || p.user_id || 0,
author: p.author || '神秘同好', author: p.author || '神秘同好',
avatar: p.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150', avatar: p.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150',
tag: p.tag || '同城认证', tag: p.tag || '同城认证',
@@ -433,7 +435,10 @@ export async function getPosts(category, userId) {
liked: finalLiked || isLocallyLiked, liked: finalLiked || isLocallyLiked,
comments: Number(p.comments) || 0, comments: Number(p.comments) || 0,
views: Number(p.views) || 1, views: Number(p.views) || 1,
category: p.category || 'partner' category: p.category || 'partner',
applyCount: Number(p.applyCount) || 0,
pendingApplyCount: Number(p.pendingApplyCount) || 0,
myApplyStatus: p.myApplyStatus || null
}; };
}); });
} }
@@ -1039,3 +1044,48 @@ export async function getDistributionRecords(userId) {
return []; return [];
} }
} }
/**
* 绑定上级推荐人邀请码 (如 PW1001)
*/
export async function bindInviteCode(userId, inviteCode) {
const uid = normalizeUserId(userId);
const res = await http.post('/distribution/bind', { userId: uid, inviteCode });
return res;
}
/**
* 获取指定友圈动态的搭子上车申请列表 (供动态房主查看与审批)
*/
export async function getPostApplies(postId) {
try {
const res = await http.get(`/posts/${postId}/applies`);
return res || [];
} catch (err) {
console.warn('[API] 获取搭子上车申请列表异常:', err.message);
return [];
}
}
/**
* 房主或管理员处置搭子上车申请 (ACCEPTED | REJECTED)
*/
export async function handlePostApply(applyId, status) {
const res = await http.post(`/post-applies/${applyId}/handle`, { status });
return res;
}
/**
* 获取当前用户提交的违规举报工单与处理结果反馈
*/
export async function getMyReports(userId) {
const uid = normalizeUserId(userId);
try {
const res = await http.get(`/reports?reporterId=${uid}`);
return res || [];
} catch (err) {
console.warn('[API] 获取我的举报记录异常:', err.message);
return [];
}
}

查看文件

@@ -82,21 +82,29 @@
</view> </view>
</view> </view>
<view class="author-actions"> <view class="author-actions">
<button <template v-if="isPostOwner(post)">
v-if="isPostOwner(post)" <button
class="btn-delete-post btn-active" class="join-btn btn-active"
@click.stop="handleDeletePost(post)" style="margin-right: 12rpx;"
> @click.stop="handleManageApplies(post)"
删除 >
</button> 申请({{ post.applyCount || 0 }})
</button>
<button
class="btn-delete-post btn-active"
@click.stop="handleDeletePost(post)"
>
删除
</button>
</template>
<button <button
v-else v-else
class="join-btn btn-active" class="join-btn btn-active"
:class="{ 'btn-applied': post.hasApplied }" :class="{ 'btn-applied': post.hasApplied && post.myApplyStatus !== 'REJECTED' }"
:disabled="post.hasApplied" :disabled="post.hasApplied && post.myApplyStatus !== 'REJECTED'"
@click="handleJoinParty(post)" @click="handleJoinParty(post)"
> >
{{ post.hasApplied ? '已申请' : (post.actionText || '聊一聊') }} {{ post.myApplyStatus === 'ACCEPTED' ? '已同意上车' : (post.myApplyStatus === 'REJECTED' ? '重新申请' : (post.hasApplied ? '待房主确认' : (post.actionText || '聊一聊'))) }}
</button> </button>
</view> </view>
</view> </view>
@@ -389,7 +397,7 @@ import SkeletonScreen from '@/components/SkeletonScreen.vue';
import { ICONS } from '@/common/icons.js'; import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
import { getNavBarInfo } from '@/common/nav.js'; import { getNavBarInfo } from '@/common/nav.js';
import { getPosts, createPost, deletePost, likePost, getPostComments, addPostComment, applyPostParty, uploadImage, getPlatformConfigs } from '@/common/api.js'; import { getPosts, createPost, deletePost, likePost, getPostComments, addPostComment, applyPostParty, getPostApplies, handlePostApply, uploadImage, getPlatformConfigs } from '@/common/api.js';
const isLoading = ref(true); const isLoading = ref(true);
const currentThemeKey = ref(getCurrentThemeKey()); const currentThemeKey = ref(getCurrentThemeKey());
@@ -473,12 +481,14 @@ const loadRemotePosts = async () => {
if (!Array.isArray(localAppliedIds)) localAppliedIds = []; if (!Array.isArray(localAppliedIds)) localAppliedIds = [];
posts.value = list.map(item => { posts.value = list.map(item => {
const isApplied = localAppliedIds.includes(String(item.id)); const serverStatus = item.myApplyStatus || null;
const isApplied = serverStatus ? (serverStatus !== 'REJECTED') : localAppliedIds.includes(String(item.id));
return { return {
...item, ...item,
myApplyStatus: serverStatus || (isApplied ? 'PENDING' : null),
liked: item.liked !== undefined ? item.liked : localLikedIds.includes(String(item.id)), liked: item.liked !== undefined ? item.liked : localLikedIds.includes(String(item.id)),
hasApplied: isApplied, hasApplied: isApplied,
actionText: isApplied ? '已申请' : (item.actionText || '加入车队') actionText: serverStatus === 'ACCEPTED' ? '已同意上车' : (isApplied ? '待房主确认' : (item.actionText || '加入车队'))
}; };
}); });
} }
@@ -763,10 +773,59 @@ const handleDeletePost = (post) => {
}); });
}; };
const handleManageApplies = async (post) => {
uni.showLoading({ title: '加载申请列表...' });
try {
const list = await getPostApplies(post.id);
uni.hideLoading();
if (!list || list.length === 0) {
uni.showToast({ title: '暂无搭子上车申请', icon: 'none' });
return;
}
const itemList = list.slice(0, 6).map(a => {
const name = a.applicant_name || a.applicantName || '同城搭子';
const st = a.status === 'ACCEPTED' ? '已同意' : (a.status === 'REJECTED' ? '已婉拒' : '待确认');
return `${name} [${st}] - 点击审批`;
});
uni.showActionSheet({
itemList,
success: (res) => {
const target = list[res.tapIndex];
if (!target) return;
const name = target.applicant_name || target.applicantName || '同城搭子';
uni.showModal({
title: `审批搭子申请`,
content: `申请人:${name}\n请选择是否同意该搭子加入车队/同城局:`,
confirmText: '同意上车',
cancelText: '婉拒申请',
confirmColor: '#10B981',
success: async (mRes) => {
const newStatus = mRes.confirm ? 'ACCEPTED' : (mRes.cancel ? 'REJECTED' : null);
if (!newStatus) return;
try {
await handlePostApply(target.id, newStatus);
uni.showToast({
title: newStatus === 'ACCEPTED' ? '已同意搭子上车' : '已婉拒该申请',
icon: 'success'
});
loadRemotePosts();
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' });
}
}
});
}
});
} catch (err) {
uni.hideLoading();
uni.showToast({ title: '加载申请列表失败', icon: 'none' });
}
};
const handleJoinParty = (post) => { const handleJoinParty = (post) => {
if (post.hasApplied) { if (post.hasApplied && post.myApplyStatus !== 'REJECTED') {
uni.showToast({ uni.showToast({
title: '已提交过约伴申请', title: post.myApplyStatus === 'ACCEPTED' ? '房主已同意您的上车申请' : '已提交过申请,请等待房主确认',
icon: 'none' icon: 'none'
}); });
return; return;
@@ -792,7 +851,8 @@ const handleJoinParty = (post) => {
} catch (_) {} } catch (_) {}
post.hasApplied = true; post.hasApplied = true;
post.actionText = '已申请'; post.myApplyStatus = 'PENDING';
post.actionText = '待房主确认';
let localAppliedIds = uni.getStorageSync('COMMUNITY_APPLIED_POST_IDS') || []; let localAppliedIds = uni.getStorageSync('COMMUNITY_APPLIED_POST_IDS') || [];
if (!Array.isArray(localAppliedIds)) localAppliedIds = []; if (!Array.isArray(localAppliedIds)) localAppliedIds = [];
const strId = String(post.id); const strId = String(post.id);

查看文件

@@ -73,6 +73,15 @@
<!-- 3. 状态三:未认证 / 重新申请 (NOT_APPLIED / REJECTED) --> <!-- 3. 状态三:未认证 / 重新申请 (NOT_APPLIED / REJECTED) -->
<view class="form-container" v-else> <view class="form-container" v-else>
<!-- 审核驳回反馈横幅 (当后台驳回时展示具体原因并引导修改重提) -->
<view class="notice-banner reject-banner" v-if="certStatus === CERT_STATUS.REJECTED">
<image class="banner-icon-svg" :src="ICONS.alert" mode="aspectFit" />
<view class="banner-content">
<text class="banner-title">上次入驻审核未通过,请修改后重新提交</text>
<text class="banner-desc">驳回原因:{{ rejectReason || '提交的资质或语音未通过人工核验,请核对后重新提交' }}</text>
</view>
</view>
<!-- 平台合规与安全保障提示横幅 --> <!-- 平台合规与安全保障提示横幅 -->
<view class="notice-banner"> <view class="notice-banner">
<image class="banner-icon-svg" :src="ICONS.shield" mode="aspectFit" /> <image class="banner-icon-svg" :src="ICONS.shield" mode="aspectFit" />
@@ -410,17 +419,41 @@ const contactService = () => {
}); });
}; };
const rejectReason = ref('');
const syncRemoteCertStatus = async () => {
try {
const cachedUser = uni.getStorageSync('PW_USER_INFO') || {};
const uid = Number(cachedUser.id) || 1001;
const profile = await getUserProfile(uid);
if (profile && profile.certStatus !== undefined) {
const remoteCert = Number(profile.certStatus);
setExpertCertStatus(remoteCert);
certStatus.value = remoteCert;
rejectReason.value = profile.rejectReason || '';
uni.setStorageSync('PW_USER_INFO', {
...cachedUser,
...profile
});
}
} catch (e) {
// 保持本地缓存兜底
}
};
onMounted(() => { onMounted(() => {
currentThemeKey.value = getCurrentThemeKey(); currentThemeKey.value = getCurrentThemeKey();
applyThemeToNativeUI(currentThemeKey.value); applyThemeToNativeUI(currentThemeKey.value);
certStatus.value = getExpertCertStatus(); certStatus.value = getExpertCertStatus();
loadInitialConfigs(); loadInitialConfigs();
syncRemoteCertStatus();
}); });
onShow(() => { onShow(() => {
currentThemeKey.value = getCurrentThemeKey(); currentThemeKey.value = getCurrentThemeKey();
applyThemeToNativeUI(currentThemeKey.value); applyThemeToNativeUI(currentThemeKey.value);
certStatus.value = getExpertCertStatus(); certStatus.value = getExpertCertStatus();
syncRemoteCertStatus();
}); });
</script> </script>
@@ -443,6 +476,19 @@ onShow(() => {
padding: 24rpx; padding: 24rpx;
margin-bottom: 28rpx; margin-bottom: 28rpx;
&.reject-banner {
background: rgba(244, 63, 94, 0.12);
border-color: rgba(244, 63, 94, 0.35);
.banner-title {
color: #FDA4AF;
}
.banner-desc {
color: #FECDD3;
}
}
.banner-icon-svg { .banner-icon-svg {
width: 40rpx; width: 40rpx;
height: 40rpx; height: 40rpx;

查看文件

@@ -115,7 +115,7 @@ import AppTabBar from '@/components/CustomTabBar.vue';
import { ICONS } from '@/common/icons.js'; import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
import { isExpertCertified } from '@/common/role.js'; import { isExpertCertified } from '@/common/role.js';
import { createOrder, getOrdersPool, getExpertStats, claimOrder } from '@/common/api.js'; import { createOrder, getOrdersPool, getExpertStats, claimOrder, updateCompanionProfile } from '@/common/api.js';
import { getNavBarInfo } from '@/common/nav.js'; import { getNavBarInfo } from '@/common/nav.js';
const currentThemeKey = ref(getCurrentThemeKey()); const currentThemeKey = ref(getCurrentThemeKey());
@@ -124,7 +124,7 @@ const expert = ref({
nickname: '接单大神', nickname: '接单大神',
avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300' avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300'
}); });
const isOnline = ref(true); const isOnline = ref(uni.getStorageSync('EXPERT_RECEIVING_STATUS') !== '0');
const activeCategory = ref('all'); const activeCategory = ref('all');
const navInfo = ref(getNavBarInfo()); const navInfo = ref(getNavBarInfo());
@@ -282,8 +282,14 @@ const filteredOrders = computed(() => {
return orders.value; return orders.value;
}); });
const toggleOnlineStatus = () => { const toggleOnlineStatus = async () => {
isOnline.value = !isOnline.value; isOnline.value = !isOnline.value;
uni.setStorageSync('EXPERT_RECEIVING_STATUS', isOnline.value ? '1' : '0');
try {
await updateCompanionProfile(expert.value.id || 101, {
status: isOnline.value ? '接单中' : '休息中'
});
} catch (_) {}
uni.showToast({ uni.showToast({
title: isOnline.value ? '已开启在线接单状态' : '已设置为打烊休息', title: isOnline.value ? '已开启在线接单状态' : '已设置为打烊休息',
icon: isOnline.value ? 'success' : 'none' icon: isOnline.value ? 'success' : 'none'

查看文件

@@ -404,6 +404,8 @@ import {
getOrdersPool, getOrdersPool,
getExpertStats, getExpertStats,
claimOrder, claimOrder,
completeOrder as completeOrderApi,
updateCompanionProfile,
getExpertRevenueTrends, getExpertRevenueTrends,
getExpertServices, getExpertServices,
updateExpertServiceStatus, updateExpertServiceStatus,
@@ -583,9 +585,13 @@ const activeOrders = ref([
} }
]); ]);
const toggleReceivingStatus = () => { const toggleReceivingStatus = async () => {
isReceivingOrders.value = !isReceivingOrders.value; isReceivingOrders.value = !isReceivingOrders.value;
const newStatusStr = isReceivingOrders.value ? '接单中' : '休息中';
uni.setStorageSync('EXPERT_RECEIVING_STATUS', isReceivingOrders.value ? '1' : '0'); uni.setStorageSync('EXPERT_RECEIVING_STATUS', isReceivingOrders.value ? '1' : '0');
try {
await updateCompanionProfile(expert.value.id || 101, { status: newStatusStr });
} catch (_) {}
uni.showToast({ uni.showToast({
title: isReceivingOrders.value ? '已开启在线接单通道!' : '已打烊停止接单', title: isReceivingOrders.value ? '已开启在线接单通道!' : '已打烊停止接单',
icon: 'none' icon: 'none'
@@ -792,12 +798,16 @@ const chatWithBuyer = () => {
const completeOrder = (id) => { const completeOrder = (id) => {
uni.showModal({ uni.showModal({
title: '确认提交服务验收', title: '确认提交服务验收',
content: '服务已完成?提交后买家确认无误或 24 小时内未异议将自动解冻订单资金打入钱包。', content: '服务已完成?提交后将完成服务结算并自动解冻订单资金打入您的佣金钱包。',
confirmColor: '#10B981', confirmColor: '#10B981',
success: (res) => { success: async (res) => {
if (res.confirm) { if (res.confirm) {
try {
await completeOrderApi(id);
} catch (_) {}
activeOrders.value = activeOrders.value.filter(o => o.id !== id); activeOrders.value = activeOrders.value.filter(o => o.id !== id);
uni.showToast({ title: '已申请验收结算!', icon: 'success' }); uni.showToast({ title: '已验收结算并入账!', icon: 'success' });
loadWorkspaceOrders();
} }
} }
}); });

查看文件

@@ -46,7 +46,7 @@
<text class="countdown-txt">{{ getOrderCountdown(order) }}</text> <text class="countdown-txt">{{ getOrderCountdown(order) }}</text>
</view> </view>
<view class="status-pill" :class="order.status"> <view class="status-pill" :class="order.status">
{{ getStatusText(order.status) }} {{ getStatusText(order.status, order) }}
</view> </view>
</view> </view>
</view> </view>
@@ -465,12 +465,14 @@ const goToExplore = () => {
}); });
}; };
const getStatusText = (status) => { const getStatusText = (status, order) => {
if (order && order.rawStatus === '已退款') return '已全额退款';
if (order && order.rawStatus === '争议仲裁中') return '争议仲裁中';
const map = { const map = {
PENDING_PAY: '待付款', PENDING_PAY: '待付款',
IN_SERVICE: '服务中', IN_SERVICE: '服务中',
COMPLETED: '已完成', COMPLETED: '已完成',
REFUNDING: '退款中', REFUNDING: '售后处理中',
CANCELLED: '已取消', CANCELLED: '已取消',
}; };
return map[status] || status; return map[status] || status;

查看文件

@@ -154,6 +154,12 @@
<text class="row-label">平台消息与通知</text> <text class="row-label">平台消息与通知</text>
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" /> <image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
</view> </view>
<view class="list-row btn-active" @click="openMyReportsModal">
<image class="row-svg" :src="ICONS.alert" mode="aspectFit" />
<text class="row-label">我的举报与维权反馈</text>
<text class="row-sub-tag" v-if="myReportsList.length > 0">{{ myReportsList.length }}条记录</text>
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
</view>
<view class="list-row btn-active" @click="openFaq"> <view class="list-row btn-active" @click="openFaq">
<image class="row-svg" :src="ICONS.messageSquare" mode="aspectFit" /> <image class="row-svg" :src="ICONS.messageSquare" mode="aspectFit" />
<text class="row-label">帮助中心与常见问题</text> <text class="row-label">帮助中心与常见问题</text>
@@ -321,6 +327,50 @@
</view> </view>
</view> </view>
<!-- 我的举报与维权进度反馈弹窗 -->
<view class="profile-edit-modal-mask" v-if="showReportsModal" @click="showReportsModal = false">
<view class="profile-edit-modal withdraw-records-modal-box" @click.stop>
<view class="modal-header">
<text class="modal-title">我的举报与维权进度</text>
<view class="btn-close btn-active" @click="showReportsModal = false">
<image class="close-svg" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<scroll-view scroll-y class="records-modal-scroll" v-if="myReportsList.length > 0">
<view v-for="rpt in myReportsList" :key="rpt.id" class="wd-card-row">
<view class="wd-row-top">
<text class="wd-no">对象:{{ rpt.targetName || rpt.targetId }} · {{ rpt.reason }}</text>
<text
class="wd-badge"
:class="{
pending: rpt.status === '待处理',
success: rpt.status === '已核实处罚',
rejected: rpt.status === '已驳回'
}"
>
{{ rpt.status || '待处理' }}
</text>
</view>
<view class="wd-row-center" v-if="rpt.description">
<text class="wd-ch">举报说明:{{ rpt.description }}</text>
</view>
<view class="wd-row-bot">
<text class="wd-time">{{ rpt.createdAt }}</text>
<text class="wd-reason" v-if="rpt.handleNote || rpt.handleResult">
平台反馈:{{ rpt.handleNote || rpt.handleResult }}
</text>
<text class="wd-time" v-else>平台安全专员正在核实处理中</text>
</view>
</view>
</scroll-view>
<view class="empty-records-box" v-else>
<image class="empty-icon-svg" :src="ICONS.shield" mode="aspectFit" />
<text class="empty-txt">暂无举报与维权记录</text>
</view>
</view>
</view>
<!-- 底部自定义导航栏 (动态自适应发单人4项 / 接单人3项) --> <!-- 底部自定义导航栏 (动态自适应发单人4项 / 接单人3项) -->
@@ -340,15 +390,33 @@ import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, setTheme, THEME_KEYS, applyThemeToNativeUI } from '@/common/theme.js'; import { getCurrentThemeKey, setTheme, THEME_KEYS, applyThemeToNativeUI } from '@/common/theme.js';
import { getCurrentRole, setCurrentRole, ROLE_TYPES, toggleRole as toggleUserRole, isExpertCertified, getExpertCertStatus, setExpertCertStatus, CERT_STATUS } from '@/common/role.js'; import { getCurrentRole, setCurrentRole, ROLE_TYPES, toggleRole as toggleUserRole, isExpertCertified, getExpertCertStatus, setExpertCertStatus, CERT_STATUS } from '@/common/role.js';
import { getNavBarInfo } from '@/common/nav.js'; import { getNavBarInfo } from '@/common/nav.js';
import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords } from '@/common/api.js'; import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports } from '@/common/api.js';
const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200'; const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200';
const isLoggedIn = ref(true); const isLoggedIn = ref(true);
const showLoginModal = ref(false); const showLoginModal = ref(false);
// 我的收藏 // 我的收藏与我的举报反馈
const favoriteList = ref([]); const favoriteList = ref([]);
const loadingFavorites = ref(false); const loadingFavorites = ref(false);
const showReportsModal = ref(false);
const myReportsList = ref([]);
const loadMyReports = async () => {
const uid = user.value.id || uni.getStorageSync('userId') || 1001;
try {
const list = await getMyReports(uid);
if (Array.isArray(list)) {
myReportsList.value = list;
}
} catch (_) {}
};
const openMyReportsModal = async () => {
if (!ensureLoggedIn()) return;
await loadMyReports();
showReportsModal.value = true;
};
// 系统与隐私设置 // 系统与隐私设置
const privacyHidePhone = ref(uni.getStorageSync('privacy_hide_phone') === '1'); const privacyHidePhone = ref(uni.getStorageSync('privacy_hide_phone') === '1');
@@ -546,6 +614,16 @@ const loadUserProfile = async (targetId) => {
...profile, ...profile,
avatar: safeAvatar avatar: safeAvatar
}; };
// 实时同步服务端大神认证审核状态至本地 Storage,确保后台审核通过/驳回立即生效
if (profile.certStatus !== undefined) {
setExpertCertStatus(Number(profile.certStatus));
} else if (profile.isExpert || (profile.role && profile.role.includes('大神'))) {
setExpertCertStatus(CERT_STATUS.APPROVED);
}
uni.setStorageSync('PW_USER_INFO', {
...(uni.getStorageSync('PW_USER_INFO') || {}),
...user.value
});
} }
} catch (err) { } catch (err) {
console.warn('[User] 加载用户信息异常:', err.message); console.warn('[User] 加载用户信息异常:', err.message);
@@ -566,6 +644,7 @@ onMounted(() => {
uni.$on('login-success', onLoginSuccess); uni.$on('login-success', onLoginSuccess);
loadUserProfile(); loadUserProfile();
loadPlatformConfigs(); loadPlatformConfigs();
loadMyReports();
}); });
onShow(() => { onShow(() => {
@@ -579,6 +658,7 @@ onShow(() => {
privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1'; privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1';
if (isLoggedIn.value) { if (isLoggedIn.value) {
loadFavorites(); loadFavorites();
loadMyReports();
} }
}); });
@@ -611,10 +691,17 @@ const handleSwitchTheme = () => {
const toggleRole = () => { const toggleRole = () => {
if (!ensureLoggedIn()) return; if (!ensureLoggedIn()) return;
if (!isExpertCertified()) { if (!isExpertCertified()) {
const curCert = getExpertCertStatus();
const isPending = curCert === CERT_STATUS.PENDING;
const isRejected = curCert === CERT_STATUS.REJECTED;
uni.showModal({ uni.showModal({
title: '大神实名认证提醒', title: isPending ? '入驻资质审核中' : (isRejected ? '入驻审核未通过' : '大神实名认证提醒'),
content: '当前账号尚未完成大神实名入驻认证,无法切换至接单模式。是否立即申请认证?', content: isPending
confirmText: '立即认证', ? '您的大神实名入驻申请正在由平台运营人工核验中,审核通过后即可切换至接单模式。是否查看审核进度?'
: isRejected
? `上次申请未通过:${user.value.rejectReason || '请核对身份与资质信息'}。是否立即修改并重新提交?`
: '当前账号尚未完成大神实名入驻认证,无法切换至接单模式。是否立即申请认证?',
confirmText: isPending ? '查看进度' : (isRejected ? '重新提交' : '立即认证'),
cancelText: '稍后再说', cancelText: '稍后再说',
confirmColor: '#10B981', confirmColor: '#10B981',
success: (res) => { success: (res) => {

查看文件

@@ -85,6 +85,14 @@
<button class="btn-poster-action secondary btn-active" @click="copyInviteLink">复制邀请链接</button> <button class="btn-poster-action secondary btn-active" @click="copyInviteLink">复制邀请链接</button>
<button class="btn-poster-action primary btn-active" @click="savePosterImage">保存海报至相册</button> <button class="btn-poster-action primary btn-active" @click="savePosterImage">保存海报至相册</button>
</view> </view>
<view class="bind-inviter-card" style="margin-top: 24rpx; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 20rpx; padding: 20rpx 24rpx; display: flex; align-items: center; justify-content: space-between;">
<text style="font-size: 24rpx; color: #94A3B8;">{{ user.inviterId ? ('已绑定上级邀请人:PW' + user.inviterId) : '填写好友邀请码绑定上下级关系' }}</text>
<view v-if="!user.inviterId" style="display: flex; align-items: center; gap: 12rpx;">
<input v-model="bindCodeInput" placeholder="如 PW2001" placeholder-class="field-placeholder" style="width: 180rpx; height: 56rpx; background: rgba(0,0,0,0.25); border-radius: 12rpx; padding: 0 16rpx; font-size: 24rpx; color: #F8FAFC;" />
<button class="btn-active" style="margin: 0; height: 56rpx; line-height: 56rpx; padding: 0 24rpx; font-size: 24rpx; border-radius: 12rpx; background: #6366F1; color: #FFFFFF;" @click="handleBindInviteCode">绑定</button>
</view>
</view>
</view> </view>
<!-- Tab 2: 下级团队列表 --> <!-- Tab 2: 下级团队列表 -->
@@ -209,7 +217,8 @@ import {
getUserProfile, getUserProfile,
getDistributionTeam, getDistributionTeam,
getDistributionRecords, getDistributionRecords,
withdrawCommission withdrawCommission,
bindInviteCode
} from '@/common/api.js'; } from '@/common/api.js';
import { ICONS } from '@/common/icons.js'; import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
@@ -218,6 +227,7 @@ import { getNavBarInfo } from '@/common/nav.js';
const currentThemeKey = ref(getCurrentThemeKey()); const currentThemeKey = ref(getCurrentThemeKey());
const navInfo = ref(getNavBarInfo()); const navInfo = ref(getNavBarInfo());
const isRefreshing = ref(false); const isRefreshing = ref(false);
const bindCodeInput = ref('');
const defaultAvatar = 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=200'; const defaultAvatar = 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=200';
@@ -324,6 +334,24 @@ const savePosterImage = () => {
uni.showToast({ title: '海报已保存至相册', icon: 'success' }); uni.showToast({ title: '海报已保存至相册', icon: 'success' });
}; };
const handleBindInviteCode = async () => {
const code = bindCodeInput.value.trim();
if (!code) {
uni.showToast({ title: '请输入好友邀请码', icon: 'none' });
return;
}
try {
const uid = user.value.id || 2006;
const res = await bindInviteCode(uid, code);
user.value.inviterId = res.inviterId;
bindCodeInput.value = '';
uni.showToast({ title: `成功绑定上级:${res.inviterName || code}`, icon: 'success' });
loadData();
} catch (err) {
uni.showToast({ title: err.message || '绑定失败', icon: 'none' });
}
};
const handleFillAllCommission = () => { const handleFillAllCommission = () => {
const comm = Number(user.value.commission || 0); const comm = Number(user.value.commission || 0);
if (comm <= 0) { if (comm <= 0) {

查看文件

@@ -84,13 +84,16 @@ export async function initDatabase(): Promise<void> {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`); `);
// 动态为 companions 增加 tags 字段与 user_id 字段 (若历史表已存在) // 动态为 companions 增加 tags 字段、user_id 字段与 reject_reason 字段 (若历史表已存在)
try { try {
await conn.query('ALTER TABLE companions ADD COLUMN tags JSON AFTER id_card_masked'); await conn.query('ALTER TABLE companions ADD COLUMN tags JSON AFTER id_card_masked');
} catch (_) {} } catch (_) {}
try { try {
await conn.query('ALTER TABLE companions ADD COLUMN user_id INT DEFAULT NULL AFTER id'); await conn.query('ALTER TABLE companions ADD COLUMN user_id INT DEFAULT NULL AFTER id');
} catch (_) {} } catch (_) {}
try {
await conn.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
} catch (_) {}
// 4. 订单表 // 4. 订单表
await conn.query(` await conn.query(`
@@ -448,16 +451,19 @@ export async function initDatabase(): Promise<void> {
try { try {
await conn.query('UPDATE users SET commission = 128.50, invited_count = 8 WHERE id = 1001 AND (commission IS NULL OR commission = 0)'); await conn.query('UPDATE users SET commission = 128.50, invited_count = 8 WHERE id = 1001 AND (commission IS NULL OR commission = 0)');
} catch (_) {} } catch (_) {}
// 扩展 users 表结构: 个性签名 // 扩展 users 表结构: 个性签名与大神认证标识
try { try {
await conn.query("ALTER TABLE users ADD COLUMN signature VARCHAR(64) DEFAULT ''"); await conn.query("ALTER TABLE users ADD COLUMN signature VARCHAR(64) DEFAULT ''");
} catch (_) {} } catch (_) {}
try {
await conn.query('ALTER TABLE users ADD COLUMN is_expert TINYINT DEFAULT 0');
} catch (_) {}
// 确保存在 demo 账号 101 (金牌陪伴师/认证大神体验账号) // 确保存在 demo 账号 101 (金牌陪伴师/认证大神体验账号)
try { try {
await conn.query(` await conn.query(`
INSERT INTO users (id, user_no, nickname, avatar, phone, gender, role, balance, commission, invited_count, order_count, activity_count, status, wechat_openid, signature) INSERT INTO users (id, user_no, nickname, avatar, phone, gender, role, balance, commission, invited_count, order_count, activity_count, status, wechat_openid, signature, is_expert)
VALUES (101, 'U101', '糖心小糯米', 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', '138****0101', '女', '认证大神', 2580.00, 128.50, 6, 88, 16, '正常', 'wx_openid_101', '人美声甜,擅长各类同城陪伴与开黑互动') VALUES (101, 'U101', '糖心小糯米', 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', '138****0101', '女', '认证大神', 2580.00, 128.50, 6, 88, 16, '正常', 'wx_openid_101', '人美声甜,擅长各类同城陪伴与开黑互动', 1)
ON DUPLICATE KEY UPDATE nickname = VALUES(nickname) ON DUPLICATE KEY UPDATE nickname = VALUES(nickname), is_expert = 1
`); `);
} catch (_) {} } catch (_) {}

查看文件

@@ -424,14 +424,46 @@ export class MysqlRepository {
} }
// 大神相关 // 大神相关
private async resolveCompanionRow(id: number | string): Promise<any | null> {
const rawId = String(id).trim();
const numId = Number(rawId);
if (!isNaN(numId) && numId > 0) {
const [res]: any = await pool.query(
'SELECT * FROM companions WHERE id = ? OR user_id = ? OR name = ? ORDER BY id ASC LIMIT 1',
[numId, numId, rawId]
);
if (res && res.length > 0) return res[0];
if (numId > 100 && numId < 200) {
const [mappedRes]: any = await pool.query(
'SELECT * FROM companions WHERE id = ? LIMIT 1',
[numId - 100]
);
if (mappedRes && mappedRes.length > 0) return mappedRes[0];
}
if (numId === 2006) {
const [susuRes]: any = await pool.query(
"SELECT * FROM companions WHERE name = '苏苏学姐' OR id = 1 LIMIT 1"
);
if (susuRes && susuRes.length > 0) return susuRes[0];
}
} else {
const [res]: any = await pool.query('SELECT * FROM companions WHERE name = ? LIMIT 1', [rawId]);
if (res && res.length > 0) return res[0];
}
return null;
}
async getCompanions(): Promise<any[]> { async getCompanions(): Promise<any[]> {
try {
await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
} catch (_) {}
const [rows]: any = await pool.query(` const [rows]: any = await pool.query(`
SELECT SELECT
id, name, avatar, gender, age, track, rank_name as \`rank\`, id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`,
hourly_rate as hourlyRate, voice_duration as voiceDuration, hourly_rate as hourlyRate, voice_duration as voiceDuration,
voice_title as voiceTitle, rating, order_count as orderCount, voice_title as voiceTitle, rating, order_count as orderCount,
status, audit_status as auditStatus, id_card_masked as idCardMasked, status, audit_status as auditStatus, COALESCE(reject_reason, '') as rejectReason,
tags, apply_time as applyTime id_card_masked as idCardMasked, tags, apply_time as applyTime
FROM companions FROM companions
ORDER BY id ASC ORDER BY id ASC
`); `);
@@ -448,19 +480,9 @@ export class MysqlRepository {
} }
async getCompanionById(id: number | string): Promise<any> { async getCompanionById(id: number | string): Promise<any> {
const rawId = String(id); const c = await this.resolveCompanionRow(id);
const numId = Number(rawId); if (!c) return null;
let rows: any[] = []; const game = c.track === 'OFFLINE_MEAL' ? '美食陪吃' : (c.track === 'OFFLINE_SING' ? '同城陪唱' : (c.track === 'OFFLINE_DRINK' ? '下午茶陪喝' : (c.track === 'OFFLINE_PLAY' ? '同城陪玩' : (c.track || c.game || '同城生活陪伴'))));
if (!isNaN(numId)) {
const [res]: any = await pool.query('SELECT * FROM companions WHERE id = ? OR name = ?', [numId, rawId]);
rows = res;
} else {
const [res]: any = await pool.query('SELECT * FROM companions WHERE name = ?', [rawId]);
rows = res;
}
if (!rows || rows.length === 0) return null;
const c = rows[0];
const game = c.track === 'OFFLINE_MEAL' ? '美食陪吃' : (c.track === 'OFFLINE_SING' ? '同城陪唱' : (c.track === 'OFFLINE_DRINK' ? '下午茶陪喝' : (c.track === 'OFFLINE_PLAY' ? '同城陪玩' : (c.game || '同城生活陪伴'))));
const price = Number(c.hourly_rate) || 68; const price = Number(c.hourly_rate) || 68;
let parsedTags = ['高颜值', '性格好', '秒回消息', '文明健康']; let parsedTags = ['高颜值', '性格好', '秒回消息', '文明健康'];
@@ -473,9 +495,53 @@ export class MysqlRepository {
} catch (_) {} } catch (_) {}
} }
let customServices: any[] = [];
try {
const displayExpertId = Number(c.id) < 100 ? Number(c.id) + 100 : Number(c.id);
const [srvRows]: any = await pool.query(
'SELECT * FROM expert_services WHERE expert_id IN (?, ?) AND status = 1 ORDER BY created_at ASC',
[Number(c.id), displayExpertId]
);
if (srvRows && srvRows.length > 0) {
customServices = srvRows.map((s: any) => ({
id: s.id,
name: s.title,
title: s.title,
category: s.category,
price: Number(s.price) || price,
unit: s.unit || '小时',
desc: s.desc_text || '正规公开商圈同城陪伴,AA制或协商消费'
}));
}
} catch (_) {}
const defaultServices = [
{
id: 'srv_1',
name: `${game} · 基础陪伴 (2小时)`,
title: `${game} · 基础陪伴 (2小时)`,
price: price,
unit: '小时',
desc: '正规公开商圈同城陪伴,AA制或协商消费'
},
{
id: 'srv_2',
name: `${game} · 半日畅享 (3小时)`,
title: `${game} · 半日畅享 (3小时)`,
price: Math.round(price * 2.8),
unit: '次',
desc: '深度互动与陪伴交流,赠送专属点歌或语音祝福'
}
];
const auditStatus = c.audit_status || '已通过';
const certStatus = auditStatus === '已通过' ? 2 : (auditStatus === '待审核' ? 1 : (auditStatus === '已驳回' ? 3 : 0));
return { return {
id: String(c.id), id: String(c.id),
userId: c.user_id || null,
nickname: c.name, nickname: c.name,
name: c.name,
avatar: c.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300', avatar: c.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300',
coverImage: c.avatar || 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800', coverImage: c.avatar || 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800',
gender: c.gender === '男' ? 'male' : 'female', gender: c.gender === '男' ? 'male' : 'female',
@@ -486,8 +552,13 @@ export class MysqlRepository {
rankTag: c.rank_name || '认证大神', rankTag: c.rank_name || '认证大神',
levelTag: '金牌认证', levelTag: '金牌认证',
price: price, price: price,
hourlyRate: price,
rating: Number(c.rating) || 5.0, rating: Number(c.rating) || 5.0,
orderCount: Number(c.order_count) || 120, orderCount: Number(c.order_count) || 120,
status: c.status || '接单中',
auditStatus,
certStatus,
rejectReason: c.reject_reason || '',
isOnline: c.status === '接单中', isOnline: c.status === '接单中',
voiceDuration: Number(c.voice_duration) || 15, voiceDuration: Number(c.voice_duration) || 15,
voiceType: 'voice', voiceType: 'voice',
@@ -499,28 +570,14 @@ export class MysqlRepository {
'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400', 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400',
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=400' 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=400'
], ],
services: [ services: customServices.length > 0 ? customServices : defaultServices
{
id: 'srv_1',
name: `${game} · 基础陪伴 (2小时)`,
title: `${game} · 基础陪伴 (2小时)`,
price: price,
unit: '小时',
desc: '正规公开商圈同城陪伴,AA制或协商消费'
},
{
id: 'srv_2',
name: `${game} · 半日畅享 (3小时)`,
title: `${game} · 半日畅享 (3小时)`,
price: Math.round(price * 2.8),
unit: '次',
desc: '深度互动与陪伴交流,赠送专属点歌或语音祝福'
}
]
}; };
} }
async updateCompanionProfile(id: number | string, data: { hourlyRate?: number; tags?: string[]; rankName?: string; status?: string }): Promise<any> { async updateCompanionProfile(id: number | string, data: { hourlyRate?: number; tags?: string[]; rankName?: string; status?: string }): Promise<any> {
const comp = await this.resolveCompanionRow(id);
if (!comp) return null;
const fields: string[] = []; const fields: string[] = [];
const values: any[] = []; const values: any[] = [];
@@ -542,11 +599,11 @@ export class MysqlRepository {
} }
if (fields.length > 0) { if (fields.length > 0) {
values.push(id); values.push(comp.id);
await pool.query(`UPDATE companions SET ${fields.join(', ')} WHERE id = ?`, values); await pool.query(`UPDATE companions SET ${fields.join(', ')} WHERE id = ?`, values);
} }
return this.getCompanionById(id); return this.getCompanionById(comp.id);
} }
async applyCompanion(data: { async applyCompanion(data: {
@@ -559,6 +616,9 @@ export class MysqlRepository {
avatar?: string; avatar?: string;
gender?: string; gender?: string;
}): Promise<any> { }): Promise<any> {
try {
await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
} catch (_) {}
const userId = Number(data.userId) || 1001; const userId = Number(data.userId) || 1001;
const name = data.name.trim(); const name = data.name.trim();
const track = data.track || '同城陪吃'; const track = data.track || '同城陪吃';
@@ -570,32 +630,41 @@ export class MysqlRepository {
? data.idCard.substring(0, 6) + '********' + data.idCard.substring(data.idCard.length - 4) ? data.idCard.substring(0, 6) + '********' + data.idCard.substring(data.idCard.length - 4)
: '110101********0000'; : '110101********0000';
const [existing]: any = await pool.query('SELECT * FROM companions WHERE name = ? OR (user_id IS NOT NULL AND user_id = ?)', [name, userId]); const [existing]: any = await pool.query('SELECT * FROM companions WHERE (user_id IS NOT NULL AND user_id = ?) OR name = ?', [userId, name]);
if (existing && existing.length > 0) { if (existing && existing.length > 0) {
await pool.query(` await pool.query(`
UPDATE companions UPDATE companions
SET user_id = ?, track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', id_card_masked = ? SET user_id = ?, name = ?, track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', reject_reason = '', id_card_masked = ?, apply_time = NOW()
WHERE id = ? WHERE id = ?
`, [userId, track, hourlyRate, tagsJson, avatar, gender, maskedIdCard, existing[0].id]); `, [userId, name, track, hourlyRate, tagsJson, avatar, gender, maskedIdCard, existing[0].id]);
return this.getCompanionById(existing[0].id); return this.getCompanionById(existing[0].id);
} }
const [res]: any = await pool.query(` const [res]: any = await pool.query(`
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) INSERT INTO companions (user_id, name, avatar, gender, age, track, rank_name, hourly_rate, voice_duration, voice_title, rating, order_count, status, audit_status, reject_reason, id_card_masked, tags, apply_time)
VALUES (?, ?, ?, ?, 23, ?, '申请中陪伴师', ?, 15, '热情语音介绍', 5.0, 0, '待审核', '待审核', ?, ?, NOW()) VALUES (?, ?, ?, ?, 23, ?, '申请中陪伴师', ?, 15, '热情语音介绍', 5.0, 0, '待审核', '待审核', '', ?, ?, NOW())
`, [userId, name, avatar, gender, track, hourlyRate, maskedIdCard, tagsJson]); `, [userId, name, avatar, gender, track, hourlyRate, maskedIdCard, tagsJson]);
return this.getCompanionById(res.insertId); return this.getCompanionById(res.insertId);
} }
async auditCompanion(id: number, action: 'approve' | 'reject', reason?: string): Promise<any> { async auditCompanion(id: number, action: 'approve' | 'reject', reason?: string): Promise<any> {
try {
await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
} catch (_) {}
try {
await pool.query('ALTER TABLE users ADD COLUMN is_expert TINYINT DEFAULT 0');
} catch (_) {}
const auditStatus = action === 'approve' ? '已通过' : '已驳回'; const auditStatus = action === 'approve' ? '已通过' : '已驳回';
const status = action === 'approve' ? '接单中' : '已驳回'; const status = action === 'approve' ? '接单中' : '已驳回';
const rejectReason = action === 'reject' ? (reason || '提交的资质或语音未通过人工核验,请修改后重新提交') : '';
await pool.query(` await pool.query(`
UPDATE companions UPDATE companions
SET audit_status = ?, status = ? SET audit_status = ?, status = ?, reject_reason = ?
WHERE id = ? WHERE id = ?
`, [auditStatus, status, id]); `, [auditStatus, status, rejectReason, id]);
const [rows]: any = await pool.query('SELECT * FROM companions WHERE id = ?', [id]); const [rows]: any = await pool.query('SELECT * FROM companions WHERE id = ?', [id]);
const comp = rows && rows[0]; const comp = rows && rows[0];
@@ -604,15 +673,30 @@ export class MysqlRepository {
if (comp && comp.user_id) { if (comp && comp.user_id) {
const uId = Number(comp.user_id); const uId = Number(comp.user_id);
if (uId > 0) { if (uId > 0) {
if (action === 'approve') { try {
await pool.query('UPDATE users SET role = "认证大神", is_expert = 1 WHERE id = ?', [uId]); if (action === 'approve') {
} else { await pool.query('UPDATE users SET role = "认证大神", is_expert = 1 WHERE id = ?', [uId]);
await pool.query('UPDATE users SET role = "普通用户", is_expert = 0 WHERE id = ?', [uId]); } else {
await pool.query('UPDATE users SET role = "普通用户", is_expert = 0 WHERE id = ?', [uId]);
}
} catch (_) {
if (action === 'approve') {
await pool.query('UPDATE users SET role = "认证大神" WHERE id = ?', [uId]);
} else {
await pool.query('UPDATE users SET role = "普通用户" WHERE id = ?', [uId]);
}
} }
} }
} }
return { ...rows[0], message: action === 'approve' ? '资质审核通过,用户已获得大神接单权限' : `已驳回: ${reason}` }; return {
...(comp || {}),
auditStatus,
status,
rejectReason,
certStatus: action === 'approve' ? 2 : 3,
message: action === 'approve' ? '资质审核通过,用户已获得大神接单权限' : `已驳回: ${rejectReason}`
};
} }
// 订单相关 // 订单相关
@@ -766,11 +850,158 @@ export class MysqlRepository {
} }
async updateOrderStatus(id: string, newStatus: string): Promise<any> { async updateOrderStatus(id: string, newStatus: string): Promise<any> {
if (newStatus === '已完成') {
return this.completeOrderWithSettlement(id);
}
await pool.query('UPDATE orders SET status = ? WHERE id = ? OR order_no = ?', [newStatus, id, id]); await pool.query('UPDATE orders SET status = ? WHERE id = ? OR order_no = ?', [newStatus, id, id]);
const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [id, id]); const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [id, id]);
return rows[0] || null; return rows[0] || null;
} }
async completeOrderWithSettlement(id: string): Promise<any> {
await pool.query("UPDATE orders SET status = '已完成' WHERE id = ? OR order_no = ?", [id, id]);
const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [id, id]);
const order = rows && rows[0];
if (!order) return null;
try {
const [settledRows]: any = await pool.query(
"SELECT id FROM finance_records WHERE related_id = ? AND type = '大神完单收益结算' LIMIT 1",
[String(order.id)]
);
const alreadySettled = settledRows && settledRows.length > 0;
if (!alreadySettled) {
const [cfgRows]: any = await pool.query('SELECT * FROM finance_config WHERE id = 1 LIMIT 1');
const cfg = (cfgRows && cfgRows[0]) || {
platform_rate_percent: 10,
first_referral_percent: 5,
second_referral_percent: 2
};
const platformPct = Number(cfg.platform_rate_percent ?? 10);
const firstRefPct = Number(cfg.first_referral_percent ?? 5);
const secondRefPct = Number(cfg.second_referral_percent ?? 2);
const orderAmount = Number(order.amount) || 0;
if (orderAmount > 0) {
const platformFee = Number(((orderAmount * platformPct) / 100).toFixed(2));
const expertIncome = Number(Math.max(0, orderAmount - platformFee).toFixed(2));
const comp = await this.resolveCompanionRow(order.expert_id || order.companion_name || 101);
if (comp) {
await pool.query('UPDATE companions SET order_count = COALESCE(order_count, 0) + 1 WHERE id = ?', [comp.id]);
}
const expertUserId = Number(comp?.user_id || order.expert_id || 101);
await pool.query(
'UPDATE users SET commission = COALESCE(commission, 0) + ?, order_count = COALESCE(order_count, 0) + 1 WHERE id = ? OR nickname = ?',
[expertIncome, expertUserId, order.companion_name || '']
);
const buyerId = Number(order.buyer_id || 1001);
await pool.query(
'UPDATE users SET order_count = COALESCE(order_count, 0) + 1 WHERE id = ?',
[buyerId]
);
const ts = Date.now();
const rand = () => Math.floor(100 + Math.random() * 900);
await pool.query(
`INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, '大神完单收益结算', ?, 'IN', ?, ?, NOW())`,
[
`fin_settle_${ts}_${rand()}`,
`JS${ts}${rand()}`,
expertUserId,
expertIncome,
order.client_name || '买家客户',
String(order.id)
]
);
if (platformFee > 0) {
await pool.query(
`INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, 0, ?, ?, 'IN', ?, ?, NOW())`,
[
`fin_plat_${ts}_${rand()}`,
`PT${ts}${rand()}`,
`平台抽成 (${platformPct}%)`,
platformFee,
order.companion_name || '认证大神',
String(order.id)
]
);
}
// 二级分销自动返佣结算
const [buyerRows]: any = await pool.query('SELECT id, nickname, inviter_id FROM users WHERE id = ? LIMIT 1', [buyerId]);
const buyer = buyerRows && buyerRows[0];
const fromUserName = buyer?.nickname || order.client_name || '客户玩家';
const orderNo = order.order_no || String(order.id);
if (buyer && buyer.inviter_id && firstRefPct > 0) {
const lv1UserId = Number(buyer.inviter_id);
const comm1 = Number(((orderAmount * firstRefPct) / 100).toFixed(2));
if (comm1 > 0 && lv1UserId > 0 && lv1UserId !== buyerId) {
await pool.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm1, lv1UserId]);
await pool.query(
`INSERT INTO distribution_records (id, user_id, from_user_id, from_user_name, order_no, order_amount, commission, level, rate_percent, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, '已入账', NOW())`,
[`dst_1_${ts}_${rand()}`, lv1UserId, buyerId, fromUserName, orderNo, orderAmount, comm1, firstRefPct]
);
await pool.query(
`INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW())`,
[
`fin_ref1_${ts}_${rand()}`,
`FX1${ts}${rand()}`,
lv1UserId,
`一级分销佣金 (${firstRefPct}%)`,
comm1,
fromUserName,
String(order.id)
]
);
}
const [lv1Rows]: any = await pool.query('SELECT id, inviter_id FROM users WHERE id = ? LIMIT 1', [lv1UserId]);
const lv1User = lv1Rows && lv1Rows[0];
if (lv1User && lv1User.inviter_id && secondRefPct > 0) {
const lv2UserId = Number(lv1User.inviter_id);
const comm2 = Number(((orderAmount * secondRefPct) / 100).toFixed(2));
if (comm2 > 0 && lv2UserId > 0 && lv2UserId !== buyerId && lv2UserId !== lv1UserId) {
await pool.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm2, lv2UserId]);
await pool.query(
`INSERT INTO distribution_records (id, user_id, from_user_id, from_user_name, order_no, order_amount, commission, level, rate_percent, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 2, ?, '已入账', NOW())`,
[`dst_2_${ts}_${rand()}`, lv2UserId, buyerId, fromUserName, orderNo, orderAmount, comm2, secondRefPct]
);
await pool.query(
`INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW())`,
[
`fin_ref2_${ts}_${rand()}`,
`FX2${ts}${rand()}`,
lv2UserId,
`二级分销佣金 (${secondRefPct}%)`,
comm2,
fromUserName,
String(order.id)
]
);
}
}
}
}
}
} catch (_) {}
const [updatedRows]: any = await pool.query('SELECT * FROM orders WHERE id = ?', [order.id]);
return updatedRows[0] || order;
}
async getOrdersPool(): Promise<any[]> { async getOrdersPool(): Promise<any[]> {
const [rows]: any = await pool.query(` const [rows]: any = await pool.query(`
SELECT SELECT
@@ -1601,6 +1832,7 @@ export class MysqlRepository {
async handleArbitration(id: string, decision: string): Promise<any> { async handleArbitration(id: string, decision: string): Promise<any> {
const conn = await pool.getConnection(); const conn = await pool.getConnection();
let shouldSettleOrderId = '';
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
@@ -1638,24 +1870,41 @@ export class MysqlRepository {
// 4. 联动扣减违规大神评分 // 4. 联动扣减违规大神评分
const expertId = Number(order.expert_id); const expertId = Number(order.expert_id);
if (expertId > 0) { if (expertId > 0) {
await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.2) WHERE id = ?', [expertId]); const altId = expertId > 100 && expertId < 200 ? expertId - 100 : expertId;
await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.2) WHERE id IN (?, ?) OR user_id = ?', [expertId, altId, expertId]);
} }
} else if (decision === 'deduct_credit' && order) { } else if (order) {
const expertId = Number(order.expert_id); // 驳回退款申诉或仅扣减大神信用分:订单恢复为已完成并进入完单结算闭环
if (expertId > 0) { await conn.query('UPDATE orders SET status = "已完成" WHERE id = ?', [order.id]);
await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.3) WHERE id = ?', [expertId]); shouldSettleOrderId = String(order.id);
if (decision === 'deduct_credit') {
const expertId = Number(order.expert_id);
if (expertId > 0) {
const altId = expertId > 100 && expertId < 200 ? expertId - 100 : expertId;
await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.3) WHERE id IN (?, ?) OR user_id = ?', [expertId, altId, expertId]);
}
} }
} }
} }
await conn.commit(); await conn.commit();
return { id, status: statusText };
} catch (err) { } catch (err) {
await conn.rollback(); await conn.rollback();
throw err; throw err;
} finally { } finally {
conn.release(); conn.release();
} }
if (shouldSettleOrderId) {
try {
await this.completeOrderWithSettlement(shouldSettleOrderId);
} catch (_) {}
}
let statusText = '申诉已驳回';
if (decision === 'refund_full') statusText = '已全额退款用户';
if (decision === 'deduct_credit') statusText = '已扣减大神信用分';
return { id, status: statusText, orderStatus: decision === 'refund_full' ? '已全额退款' : '已完成' };
} }
// 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图) // 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图)
@@ -1725,8 +1974,8 @@ export class MysqlRepository {
return { id, status: '待处理' }; return { id, status: '待处理' };
} }
async getReports(): Promise<any[]> { async getReports(reporterId?: number): Promise<any[]> {
const [rows]: any = await pool.query(` let sql = `
SELECT SELECT
id, id,
reporter_id as reporterId, reporter_id as reporterId,
@@ -1742,9 +1991,14 @@ export class MysqlRepository {
handled_at as handledAt, handled_at as handledAt,
created_at as createdAt created_at as createdAt
FROM reports FROM reports
ORDER BY created_at DESC `;
LIMIT 200 const params: any[] = [];
`); if (reporterId !== undefined && !isNaN(Number(reporterId)) && Number(reporterId) > 0) {
sql += ' WHERE reporter_id = ?';
params.push(Number(reporterId));
}
sql += ' ORDER BY created_at DESC LIMIT 200';
const [rows]: any = await pool.query(sql, params);
return rows; return rows;
} }
@@ -1770,11 +2024,12 @@ export class MysqlRepository {
if (report && action === 'punish' && report.target_type === 'EXPERT') { if (report && action === 'punish' && report.target_type === 'EXPERT') {
const expertId = Number(report.target_id); const expertId = Number(report.target_id);
if (expertId > 0) { if (expertId > 0) {
const altId = expertId > 100 && expertId < 200 ? expertId - 100 : expertId;
await pool.query(` await pool.query(`
UPDATE companions UPDATE companions
SET rating = GREATEST(1.0, rating - 0.2) SET rating = GREATEST(1.0, rating - 0.2)
WHERE id = ? WHERE id IN (?, ?) OR user_id = ?
`, [expertId]); `, [expertId, altId, expertId]);
} }
} }
} catch (_) {} } catch (_) {}
@@ -1988,6 +2243,11 @@ export class MysqlRepository {
const [arbRows]: any = await pool.query('SELECT * FROM arbitrations WHERE status = "待平台仲裁"'); const [arbRows]: any = await pool.query('SELECT * FROM arbitrations WHERE status = "待平台仲裁"');
const [wdRows]: any = await pool.query('SELECT * FROM withdrawals WHERE status = "待审核"'); const [wdRows]: any = await pool.query('SELECT * FROM withdrawals WHERE status = "待审核"');
const [secRows]: any = await pool.query('SELECT * FROM safety_audit_logs ORDER BY id DESC LIMIT 5'); const [secRows]: any = await pool.query('SELECT * FROM safety_audit_logs ORDER BY id DESC LIMIT 5');
let rptRows: any[] = [];
try {
const [rRows]: any = await pool.query('SELECT * FROM reports WHERE status = "待处理" ORDER BY created_at DESC');
rptRows = rRows || [];
} catch (_) {}
const pendingWithdrawalAmount = wdRows.reduce((sum: number, w: any) => sum + Number(w.amount), 0); const pendingWithdrawalAmount = wdRows.reduce((sum: number, w: any) => sum + Number(w.amount), 0);
@@ -1996,6 +2256,7 @@ export class MysqlRepository {
pendingArbitrationsCount: arbRows.length, pendingArbitrationsCount: arbRows.length,
pendingWithdrawalsCount: wdRows.length, pendingWithdrawalsCount: wdRows.length,
pendingWithdrawalAmount, pendingWithdrawalAmount,
pendingReportsCount: rptRows.length,
riskInterceptionsCount: secRows.length, riskInterceptionsCount: secRows.length,
pendingAudits: auditRows.map((c: any) => ({ pendingAudits: auditRows.map((c: any) => ({
id: c.id, id: c.id,
@@ -2015,6 +2276,13 @@ export class MysqlRepository {
amount: w.amount, amount: w.amount,
channel: w.channel, channel: w.channel,
})), })),
pendingReports: rptRows.map((r: any) => ({
id: r.id,
reporterId: r.reporter_id,
targetName: r.target_name || r.target_id,
reason: r.reason,
description: r.description || '',
})),
recentRiskLogs: secRows, recentRiskLogs: secRows,
}; };
} }
@@ -2130,7 +2398,7 @@ export class MysqlRepository {
const [rows]: any = await pool.query(` const [rows]: any = await pool.query(`
SELECT SELECT
id, user_no as userNo, nickname, avatar, phone, gender, signature, id, user_no as userNo, nickname, avatar, phone, gender, signature,
role, balance, commission, invited_count as invitedCount, role, balance, commission, invited_count as invitedCount, inviter_id as inviterId,
order_count as orderCount, activity_count as activityCount, order_count as orderCount, activity_count as activityCount,
status, wechat_openid as wechatOpenid, created_at as createdAt status, wechat_openid as wechatOpenid, created_at as createdAt
FROM users FROM users
@@ -2153,6 +2421,10 @@ export class MysqlRepository {
orderCount: 88, orderCount: 88,
activityCount: 16, activityCount: 16,
isExpert: true, isExpert: true,
certStatus: 2,
auditStatus: '已通过',
rejectReason: '',
companionStatus: '接单中',
status: '正常' status: '正常'
}; };
} }
@@ -2172,12 +2444,42 @@ export class MysqlRepository {
orderCount: 120, orderCount: 120,
activityCount: 35, activityCount: 35,
isExpert: true, isExpert: true,
certStatus: 2,
auditStatus: '已通过',
rejectReason: '',
companionStatus: '接单中',
status: '正常' status: '正常'
}; };
} }
return null; return null;
} }
const u = rows[0]; const u = rows[0];
let companionRow: any = null;
try {
const [compRows]: any = await pool.query(
'SELECT * FROM companions WHERE user_id = ? OR name = ? ORDER BY id DESC LIMIT 1',
[u.id, u.nickname]
);
if (compRows && compRows.length > 0) {
companionRow = compRows[0];
}
} catch (_) {}
const roleHasExpert = (u.role || '').includes('大神');
const auditStatus = companionRow
? (companionRow.audit_status || '已通过')
: (roleHasExpert ? '已通过' : '未申请');
const certStatus =
auditStatus === '已通过'
? 2
: auditStatus === '待审核'
? 1
: auditStatus === '已驳回'
? 3
: 0;
const rejectReason = companionRow?.reject_reason || '';
const companionStatus = companionRow ? (companionRow.status || '接单中') : (certStatus === 2 ? '接单中' : '');
return { return {
id: u.id, id: u.id,
userNo: u.userNo, userNo: u.userNo,
@@ -2190,9 +2492,15 @@ export class MysqlRepository {
balance: Number(u.balance || 0).toFixed(2), balance: Number(u.balance || 0).toFixed(2),
commission: Number(u.commission || 0).toFixed(2), commission: Number(u.commission || 0).toFixed(2),
invitedCount: Number(u.invitedCount || 0), invitedCount: Number(u.invitedCount || 0),
inviterId: u.inviterId ? Number(u.inviterId) : null,
orderCount: Number(u.orderCount || 0), orderCount: Number(u.orderCount || 0),
activityCount: Number(u.activityCount || 0), activityCount: Number(u.activityCount || 0),
isExpert: (u.role || '').includes('大神') || (u.role || '').includes('房主'), companionId: companionRow ? Number(companionRow.id) : null,
auditStatus,
certStatus,
rejectReason,
companionStatus,
isExpert: certStatus === 2 || roleHasExpert,
status: u.status, status: u.status,
}; };
} }
@@ -2382,6 +2690,25 @@ export class MysqlRepository {
} }
} catch (_) {} } catch (_) {}
const applyStatsMap = new Map<string, { total: number; pending: number; myStatus: string }>();
try {
const [applyRows]: any = await pool.query(
'SELECT post_id, applicant_id, status FROM post_applies ORDER BY created_at DESC'
);
if (Array.isArray(applyRows)) {
for (const ar of applyRows) {
const pid = String(ar.post_id);
const cur = applyStatsMap.get(pid) || { total: 0, pending: 0, myStatus: '' };
cur.total += 1;
if (ar.status === 'PENDING') cur.pending += 1;
if (Number(ar.applicant_id) === currentUserId && !cur.myStatus) {
cur.myStatus = ar.status || 'PENDING';
}
applyStatsMap.set(pid, cur);
}
}
} catch (_) {}
let sql = ` let sql = `
SELECT SELECT
id, user_id as userId, author, avatar, tag, tag_type as tagType, id, user_id as userId, author, avatar, tag, tag_type as tagType,
@@ -2421,6 +2748,8 @@ export class MysqlRepository {
imagesArr = typeof r.images === 'string' ? JSON.parse(r.images) : (r.images || []); imagesArr = typeof r.images === 'string' ? JSON.parse(r.images) : (r.images || []);
} catch (_) { imagesArr = []; } } catch (_) { imagesArr = []; }
const appStat = applyStatsMap.get(String(r.id)) || { total: 0, pending: 0, myStatus: '' };
return { return {
...r, ...r,
liked: likedPostIdSet.has(String(r.id)), liked: likedPostIdSet.has(String(r.id)),
@@ -2428,6 +2757,9 @@ export class MysqlRepository {
status: r.status || '正常', status: r.status || '正常',
topics: topicsArr, topics: topicsArr,
images: imagesArr, images: imagesArr,
applyCount: appStat.total,
pendingApplyCount: appStat.pending,
myApplyStatus: appStat.myStatus,
}; };
}); });
} }
@@ -2592,14 +2924,19 @@ export class MysqlRepository {
`, [id, orderId, expertId, userId, userNickname, userAvatar, rating, tagsJson, content]); `, [id, orderId, expertId, userId, userNickname, userAvatar, rating, tagsJson, content]);
try { try {
await pool.query("UPDATE orders SET status = '已完成' WHERE id = ? OR order_no = ?", [orderId, orderId]); await this.completeOrderWithSettlement(orderId);
} catch (_) {} } catch (_) {
try {
await pool.query("UPDATE orders SET status = '已完成' WHERE id = ? OR order_no = ?", [orderId, orderId]);
} catch (_) {}
}
try { try {
const [avgRows]: any = await pool.query('SELECT AVG(rating) as avgRating FROM order_reviews WHERE expert_id = ?', [expertId]); const altId = Number(expertId) > 100 && Number(expertId) < 200 ? Number(expertId) - 100 : (Number(expertId) < 100 ? Number(expertId) + 100 : Number(expertId));
const [avgRows]: any = await pool.query('SELECT AVG(rating) as avgRating FROM order_reviews WHERE expert_id IN (?, ?)', [expertId, altId]);
if (avgRows && avgRows[0]?.avgRating) { if (avgRows && avgRows[0]?.avgRating) {
const newRating = Number(Number(avgRows[0].avgRating).toFixed(2)); const newRating = Number(Number(avgRows[0].avgRating).toFixed(2));
await pool.query('UPDATE companions SET rating = ? WHERE id = ?', [newRating, expertId]); await pool.query('UPDATE companions SET rating = ? WHERE id IN (?, ?) OR user_id = ?', [newRating, expertId, altId, expertId]);
} }
} catch (_) {} } catch (_) {}
@@ -2636,8 +2973,10 @@ export class MysqlRepository {
const params: any[] = []; const params: any[] = [];
const where: string[] = []; const where: string[] = [];
if (expertId) { if (expertId) {
where.push('expert_id = ?'); const numExp = Number(expertId);
params.push(expertId); const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp);
where.push('expert_id IN (?, ?)');
params.push(numExp, altId);
} }
if (orderId) { if (orderId) {
where.push('order_id = ?'); where.push('order_id = ?');
@@ -2668,9 +3007,11 @@ export class MysqlRepository {
const [res]: any = await pool.query('DELETE FROM order_reviews WHERE id = ?', [id]); const [res]: any = await pool.query('DELETE FROM order_reviews WHERE id = ?', [id]);
if (expertId) { if (expertId) {
try { try {
const [avgRows]: any = await pool.query('SELECT AVG(rating) as avgRating FROM order_reviews WHERE expert_id = ?', [expertId]); const numExp = Number(expertId);
const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp);
const [avgRows]: any = await pool.query('SELECT AVG(rating) as avgRating FROM order_reviews WHERE expert_id IN (?, ?)', [numExp, altId]);
const newRating = (avgRows && avgRows[0]?.avgRating) ? Number(Number(avgRows[0].avgRating).toFixed(2)) : 5.0; 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]); await pool.query('UPDATE companions SET rating = ? WHERE id IN (?, ?) OR user_id = ?', [newRating, numExp, altId, numExp]);
} catch (_) {} } catch (_) {}
} }
return res.affectedRows > 0; return res.affectedRows > 0;
@@ -2726,12 +3067,46 @@ export class MysqlRepository {
applicantName?: string; applicantName?: string;
applicantAvatar?: string; applicantAvatar?: string;
}): Promise<any> { }): Promise<any> {
const id = `apply_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`;
const postId = data.postId; const postId = data.postId;
const applicantId = Number(data.applicantId) || 1001; const applicantId = Number(data.applicantId) || 1001;
const applicantName = data.applicantName || '搭子玩家'; const applicantName = data.applicantName || '搭子玩家';
const applicantAvatar = data.applicantAvatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120'; const applicantAvatar = data.applicantAvatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120';
try {
const [existRows]: any = await pool.query(
'SELECT * FROM post_applies WHERE post_id = ? AND applicant_id = ? ORDER BY created_at DESC LIMIT 1',
[postId, applicantId]
);
if (existRows && existRows.length > 0) {
const exist = existRows[0];
if (exist.status === 'REJECTED') {
await pool.query(
"UPDATE post_applies SET status = 'PENDING', applicant_name = ?, applicant_avatar = ?, created_at = NOW() WHERE id = ?",
[applicantName, applicantAvatar, exist.id]
);
return {
id: exist.id,
postId,
applicantId,
applicantName,
applicantAvatar,
status: 'PENDING',
message: '已重新发起约伴上车申请,等待房主确认'
};
}
return {
id: exist.id,
postId,
applicantId,
applicantName: exist.applicant_name || applicantName,
applicantAvatar: exist.applicant_avatar || applicantAvatar,
status: exist.status || 'PENDING',
message: exist.status === 'ACCEPTED' ? '房主已同意您的上车申请' : '约伴上车申请已送达,请等待房主确认'
};
}
} catch (_) {}
const id = `apply_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`;
await pool.query(` await pool.query(`
INSERT INTO post_applies (id, post_id, applicant_id, applicant_name, applicant_avatar, status, created_at) INSERT INTO post_applies (id, post_id, applicant_id, applicant_name, applicant_avatar, status, created_at)
VALUES (?, ?, ?, ?, ?, 'PENDING', NOW()) VALUES (?, ?, ?, ?, ?, 'PENDING', NOW())
@@ -2817,6 +3192,11 @@ export class MysqlRepository {
// 19. 大神服务包管理 // 19. 大神服务包管理
async getExpertServices(expertId: number): Promise<any[]> { async getExpertServices(expertId: number): Promise<any[]> {
const defaultList = [
{ id: `srv_${expertId}_1`, title: '排位保星连胜局', category: '王者荣耀', price: 45, unit: '小时', mode: '排位赛', status: true, desc: '荣耀王者实力带飞,保星上分,麦克风清晰互动' },
{ id: `srv_${expertId}_2`, title: '娱乐匹配快乐唠嗑局', category: '英雄联盟手游', price: 30, unit: '小时', mode: '匹配/大乱斗', status: true, desc: '轻松搞笑,幽默风趣,情绪价值拉满' },
{ id: `srv_${expertId}_3`, title: '英雄技术单项教学指导', category: '和平精英', price: 55, unit: '小时', mode: '一对一复盘', status: true, desc: '对线细节、走位身法与团战意识深度剖析复盘' }
];
try { try {
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS expert_services ( CREATE TABLE IF NOT EXISTS expert_services (
@@ -2845,13 +3225,17 @@ export class MysqlRepository {
desc: r.desc_text || '' desc: r.desc_text || ''
})); }));
} }
// 首次读取时自动将默认服务包落库,确保后续上下架切换真实持久化
for (const item of defaultList) {
await pool.query(
`INSERT IGNORE INTO expert_services (id, expert_id, title, category, price, unit, mode, status, desc_text)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)`,
[item.id, Number(expertId), item.title, item.category, item.price, item.unit, item.mode, item.desc]
);
}
} catch (_) {} } catch (_) {}
return [ return defaultList;
{ id: `srv_${expertId}_1`, title: '排位保星连胜局', category: '王者荣耀', price: 45, unit: '小时', mode: '排位赛', status: true, desc: '荣耀王者实力带飞,保星上分,麦克风清晰互动' },
{ id: `srv_${expertId}_2`, title: '娱乐匹配快乐唠嗑局', category: '英雄联盟手游', price: 30, unit: '小时', mode: '匹配/大乱斗', status: true, desc: '轻松搞笑,幽默风趣,情绪价值拉满' },
{ id: `srv_${expertId}_3`, title: '英雄技术单项教学指导', category: '和平精英', price: 55, unit: '小时', mode: '一对一复盘', status: true, desc: '对线细节、走位身法与团战意识深度剖析复盘' }
];
} }
async updateExpertServiceStatus(serviceId: string, status: boolean): Promise<any> { async updateExpertServiceStatus(serviceId: string, status: boolean): Promise<any> {
@@ -2867,8 +3251,9 @@ export class MysqlRepository {
const [rows]: any = await pool.query(` const [rows]: any = await pool.query(`
SELECT SELECT
f.id as favoriteId, f.id as favoriteId,
f.expert_id as favExpertId,
f.created_at as favoritedAt, f.created_at as favoritedAt,
c.id, c.id as compId,
c.name as nickname, c.name as nickname,
c.avatar, c.avatar,
c.gender, c.gender,
@@ -2881,7 +3266,11 @@ export class MysqlRepository {
c.status, c.status,
c.tags c.tags
FROM favorites f FROM favorites f
JOIN companions c ON f.expert_id = c.id JOIN companions c ON (
f.expert_id = c.id
OR (f.expert_id > 100 AND f.expert_id < 200 AND (f.expert_id - 100) = c.id)
OR (c.user_id IS NOT NULL AND f.expert_id = c.user_id)
)
WHERE f.user_id = ? WHERE f.user_id = ?
ORDER BY f.created_at DESC ORDER BY f.created_at DESC
`, [Number(userId)]); `, [Number(userId)]);
@@ -2892,8 +3281,11 @@ export class MysqlRepository {
try { try {
tagsArr = typeof r.tags === 'string' ? JSON.parse(r.tags) : (r.tags || []); tagsArr = typeof r.tags === 'string' ? JSON.parse(r.tags) : (r.tags || []);
} catch (_) {} } catch (_) {}
const displayId = Number(r.compId) < 100 ? Number(r.compId) + 100 : Number(r.compId);
return { return {
...r, ...r,
id: displayId,
dbId: Number(r.compId),
tags: tagsArr, tags: tagsArr,
isOnline: r.status === '接单中' isOnline: r.status === '接单中'
}; };
@@ -2918,10 +3310,12 @@ export class MysqlRepository {
async removeFavorite(userId: number, expertId: number): Promise<{ success: boolean; message: string }> { async removeFavorite(userId: number, expertId: number): Promise<{ success: boolean; message: string }> {
try { try {
const numExp = Number(expertId);
const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp);
await pool.query(` await pool.query(`
DELETE FROM favorites DELETE FROM favorites
WHERE user_id = ? AND expert_id = ? WHERE user_id = ? AND expert_id IN (?, ?)
`, [Number(userId), Number(expertId)]); `, [Number(userId), numExp, altId]);
return { success: true, message: '已取消收藏' }; return { success: true, message: '已取消收藏' };
} catch (err: any) { } catch (err: any) {
return { success: false, message: err.message || '取消收藏失败' }; return { success: false, message: err.message || '取消收藏失败' };
@@ -2930,11 +3324,13 @@ export class MysqlRepository {
async isFavorite(userId: number, expertId: number): Promise<boolean> { async isFavorite(userId: number, expertId: number): Promise<boolean> {
try { try {
const numExp = Number(expertId);
const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp);
const [rows]: any = await pool.query(` const [rows]: any = await pool.query(`
SELECT id FROM favorites SELECT id FROM favorites
WHERE user_id = ? AND expert_id = ? WHERE user_id = ? AND expert_id IN (?, ?)
LIMIT 1 LIMIT 1
`, [Number(userId), Number(expertId)]); `, [Number(userId), numExp, altId]);
return Boolean(rows && rows.length > 0); return Boolean(rows && rows.length > 0);
} catch (_) { } catch (_) {
return false; return false;
@@ -2942,9 +3338,52 @@ export class MysqlRepository {
} }
// 21. 分销下级团队与返佣记录 // 21. 分销下级团队与返佣记录
async bindInviteCode(userId: number, inviteCode: string): Promise<any> {
const uid = Number(userId);
const cleaned = String(inviteCode || '').trim().toUpperCase().replace(/^PW/, '').replace(/^U/, '');
const inviterId = Number(cleaned);
if (!inviterId || isNaN(inviterId) || inviterId <= 0) {
throw new Error('请输入有效的邀请码 (如 PW1001)');
}
if (inviterId === uid) {
throw new Error('不能绑定自己的邀请码');
}
const [invRows]: any = await pool.query('SELECT id, nickname FROM users WHERE id = ? LIMIT 1', [inviterId]);
if (!invRows || invRows.length === 0) {
throw new Error('邀请码对应的推荐人不存在');
}
const inviter = invRows[0];
const [curRows]: any = await pool.query('SELECT id, inviter_id FROM users WHERE id = ? LIMIT 1', [uid]);
if (!curRows || curRows.length === 0) {
throw new Error('当前用户不存在');
}
if (curRows[0].inviter_id && Number(curRows[0].inviter_id) === inviterId) {
return { userId: uid, inviterId, inviterName: inviter.nickname, message: `已绑定推荐人:${inviter.nickname}` };
}
await pool.query('UPDATE users SET inviter_id = ? WHERE id = ?', [inviterId, uid]);
await pool.query('UPDATE users SET invited_count = COALESCE(invited_count, 0) + 1 WHERE id = ?', [inviterId]);
return {
userId: uid,
inviterId,
inviterName: inviter.nickname,
message: `成功绑定推荐人:${inviter.nickname}`
};
}
async getDistributionTeam(userId: number): Promise<any> { async getDistributionTeam(userId: number): Promise<any> {
const uid = Number(userId); const uid = Number(userId);
try { try {
const [contribRows]: any = await pool.query(
'SELECT from_user_id as fromUserId, COALESCE(SUM(commission), 0) as total FROM distribution_records WHERE user_id = ? GROUP BY from_user_id',
[uid]
);
const contribMap = new Map<number, string>();
if (Array.isArray(contribRows)) {
contribRows.forEach((r: any) => {
contribMap.set(Number(r.fromUserId), Number(r.total || 0).toFixed(2));
});
}
const [directRows]: any = await pool.query(` const [directRows]: any = await pool.query(`
SELECT id, nickname, avatar, phone, created_at as createdAt, 1 as level SELECT id, nickname, avatar, phone, created_at as createdAt, 1 as level
FROM users FROM users
@@ -2952,7 +3391,10 @@ export class MysqlRepository {
ORDER BY created_at DESC ORDER BY created_at DESC
`, [uid]); `, [uid]);
let directList = directRows || []; let directList = (directRows || []).map((u: any) => ({
...u,
totalContribute: contribMap.get(Number(u.id)) || '0.00'
}));
if (directList.length === 0) { if (directList.length === 0) {
directList = [ directList = [
{ id: 2001, nickname: '晨曦微风', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', phone: '138****6612', createdAt: '2026-10-01 14:20:00', level: 1, totalContribute: '48.50' }, { id: 2001, nickname: '晨曦微风', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', phone: '138****6612', createdAt: '2026-10-01 14:20:00', level: 1, totalContribute: '48.50' },
@@ -2971,7 +3413,10 @@ export class MysqlRepository {
WHERE inviter_id IN (?) WHERE inviter_id IN (?)
ORDER BY created_at DESC ORDER BY created_at DESC
`, [directIds]); `, [directIds]);
indirectList = indRows || []; indirectList = (indRows || []).map((u: any) => ({
...u,
totalContribute: contribMap.get(Number(u.id)) || '0.00'
}));
} catch (_) {} } catch (_) {}
} }
if (indirectList.length === 0) { if (indirectList.length === 0) {

查看文件

@@ -1330,9 +1330,10 @@ app.post('/api/reports', async (req: Request, res: Response) => {
} }
}); });
app.get('/api/reports', async (_req: Request, res: Response) => { app.get('/api/reports', async (req: Request, res: Response) => {
try { try {
const list = await repo.getReports(); const reporterId = req.query.reporterId ? Number(req.query.reporterId) : undefined;
const list = await repo.getReports(reporterId);
res.json({ code: 20000, data: list }); res.json({ code: 20000, data: list });
} catch (err: any) { } catch (err: any) {
res.status(500).json({ code: 50000, message: err.message }); res.status(500).json({ code: 50000, message: err.message });
@@ -2154,6 +2155,23 @@ app.get('/api/distribution/records', async (req: Request, res: Response) => {
} }
}); });
app.post('/api/distribution/bind', async (req: Request, res: Response) => {
try {
const { userId, inviteCode } = req.body || {};
if (!inviteCode || !String(inviteCode).trim()) {
return res.status(400).json({ code: 40000, message: '邀请码不能为空' });
}
const result = await repo.bindInviteCode(Number(userId || 1001), String(inviteCode).trim());
res.json({
code: 20000,
data: result,
message: result.message || '邀请码绑定成功'
});
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 8. 启动服务器并初始化数据库 // 8. 启动服务器并初始化数据库
async function bootstrap() { async function bootstrap() {
try { try {

查看文件

@@ -372,6 +372,88 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
expect(content).toContain('/api/post-applies/'); expect(content).toContain('/api/post-applies/');
}); });
}); });
describe('10. 全链路跨端业务闭环深度校验 (实名审核回流/完单结算+二级返佣/争议结案/搭子审批/举报反馈/大神ID双向解析)', () => {
it('大神认证申请与个人中心必须实时同步后端 certStatus 与 rejectReason,支持驳回后查看原因并重新提审', () => {
const applyVuePath = path.join(uniPagesDir, 'expert', 'apply.vue');
const applyContent = fs.readFileSync(applyVuePath, 'utf-8');
expect(applyContent).toContain('syncRemoteCertStatus');
expect(applyContent).toContain('rejectReason');
const userIndexPath = path.join(uniPagesDir, 'user', 'index.vue');
const userContent = fs.readFileSync(userIndexPath, 'utf-8');
expect(userContent).toContain('setExpertCertStatus');
expect(userContent).toContain('rejectReason');
});
it('订单确认完成或评价时,后端必须触发 completeOrderWithSettlement 自动结算大神佣金与一二级分销返佣', () => {
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
const content = fs.readFileSync(repoPath, 'utf-8');
expect(content).toContain('completeOrderWithSettlement');
expect(content).toContain('大神完单收益结算');
expect(content).toContain('INSERT INTO distribution_records');
});
it('争议仲裁驳回退款或扣信用分时,必须同步将关联订单从争议仲裁中流转为已完成并触发结算', () => {
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
const content = fs.readFileSync(repoPath, 'utf-8');
expect(content).toContain('handleArbitration');
expect(content).toContain("SET status = '已完成'");
expect(content).toContain('this.completeOrderWithSettlement(shouldSettleOrderId)');
});
it('友圈动态必须返回搭子上车申请状态 myApplyStatus,且小程序端支持房主直接审批申请与申请人查看状态', () => {
const communityVuePath = path.join(uniPagesDir, 'community', 'index.vue');
const content = fs.readFileSync(communityVuePath, 'utf-8');
expect(content).toContain('handleManageApplies');
expect(content).toContain('getPostApplies');
expect(content).toContain('handlePostApply');
expect(content).toContain('myApplyStatus');
});
it('用户提交举报投诉后,个人中心必须提供“我的举报与维权反馈”查询入口,且管理后台工作台联动待办提醒', () => {
const userIndexPath = path.join(uniPagesDir, 'user', 'index.vue');
const userContent = fs.readFileSync(userIndexPath, 'utf-8');
expect(userContent).toContain('openMyReportsModal');
expect(userContent).toContain('getMyReports');
expect(userContent).toContain('我的举报与维权反馈');
const todoListPath = path.join(rootDir, 'admin', 'arco-design-pro-vite', 'src', 'views', 'dashboard', 'workplace', 'components', 'quick-todo-list.vue');
const todoContent = fs.readFileSync(todoListPath, 'utf-8');
expect(todoContent).toContain('pendingReports');
expect(todoContent).toContain('/safety/reports');
});
it('分销推广中心必须支持绑定上级邀请码 (POST /api/distribution/bind) 并实时汇总下级贡献佣金', () => {
const indexPath = path.join(serverSrcDir, 'index.ts');
const indexContent = fs.readFileSync(indexPath, 'utf-8');
expect(indexContent).toContain("app.post('/api/distribution/bind'");
const promoteVuePath = path.join(uniPagesDir, 'user', 'promote.vue');
const promoteContent = fs.readFileSync(promoteVuePath, 'utf-8');
expect(promoteContent).toContain('handleBindInviteCode');
expect(promoteContent).toContain('bindInviteCode');
});
it('大神 ID 必须支持 1..6 与 101..106 双向解析 (resolveCompanionRow),确保收藏列表、评价、资料修改与在线状态切换全链路一致', () => {
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
const content = fs.readFileSync(repoPath, 'utf-8');
expect(content).toContain('resolveCompanionRow');
const hallVuePath = path.join(uniPagesDir, 'expert', 'hall.vue');
const hallContent = fs.readFileSync(hallVuePath, 'utf-8');
expect(hallContent).toContain('updateCompanionProfile');
const workspaceVuePath = path.join(uniPagesDir, 'expert', 'workspace.vue');
const wsContent = fs.readFileSync(workspaceVuePath, 'utf-8');
expect(wsContent).toContain('updateCompanionProfile');
expect(wsContent).toContain('completeOrderApi');
});
});
}); });