fix(theme): 修复门票页导航栏主题脱节与个人中心双身份卡片流光动画,加固活动参与人画像与大神隐私
这个提交包含在:
@@ -38,7 +38,8 @@ export function formatActivityData(raw) {
|
||||
startTime: raw.startTime || '',
|
||||
location: raw.location || '',
|
||||
creatorId: raw.creatorId != null ? Number(raw.creatorId) : (raw.creator_id != null ? Number(raw.creator_id) : 0),
|
||||
creatorNickname: raw.creatorNickname || '',
|
||||
// 创建人已被删除时给出真实状态,避免列表显示空白局长
|
||||
creatorNickname: raw.creatorNickname || '局长已注销',
|
||||
creatorAvatar: raw.creatorAvatar || '',
|
||||
status: raw.status || (joinedCount >= (raw.maxParticipants || 4) ? 'FULL' : 'RECRUITING'),
|
||||
allowDynamicPrice: Boolean(raw.allowDynamicPrice),
|
||||
@@ -236,7 +237,7 @@ export async function getCompanions(keyword) {
|
||||
price: Number(c.hourlyRate) || 0,
|
||||
rating: Number(c.rating) || 0,
|
||||
orderCount: Number(c.orderCount) || 0,
|
||||
isOnline: c.status === '接单中',
|
||||
isOnline: c.isOnline !== undefined ? Boolean(c.isOnline) : c.status === '接单中',
|
||||
voiceDuration: Number(c.voiceDuration) || 0,
|
||||
voiceType: 'voice',
|
||||
voiceTitle: c.voiceTitle || '',
|
||||
@@ -267,7 +268,6 @@ export async function matchCompanions(options = {}) {
|
||||
const list = Array.isArray(res) ? res : (res && Array.isArray(res.list) ? res.list : []);
|
||||
return list.map((c) => ({
|
||||
id: String(c.id),
|
||||
userId: Number(c.userId) || 0,
|
||||
nickname: c.nickname || '',
|
||||
avatar: c.avatar || '',
|
||||
gender: c.gender === '男' ? 'male' : (c.gender === '女' ? 'female' : ''),
|
||||
|
||||
@@ -72,7 +72,10 @@
|
||||
<text class="sec-title">组局阵地与发起人</text>
|
||||
</view>
|
||||
<view class="creator-box">
|
||||
<image class="creator-avatar btn-active" :src="activity.creatorAvatar" mode="aspectFill" @click="openHostProfile" />
|
||||
<image v-if="activity.creatorAvatar" class="creator-avatar btn-active" :src="activity.creatorAvatar" mode="aspectFill" @click="openHostProfile" />
|
||||
<view v-else class="creator-avatar creator-avatar-empty btn-active" @click="openHostProfile">
|
||||
<text class="creator-avatar-placeholder">?</text>
|
||||
</view>
|
||||
<view class="creator-info btn-active" @click="openHostProfile">
|
||||
<view class="creator-name-row">
|
||||
<text class="creator-name">{{ activity.creatorNickname }}</text>
|
||||
@@ -1147,6 +1150,21 @@ const handleJoin = () => {
|
||||
height: 80rpx;
|
||||
border-radius: 40rpx;
|
||||
margin-right: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.creator-avatar-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
|
||||
.creator-avatar-placeholder {
|
||||
font-size: 30rpx;
|
||||
color: #94A3B8;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.creator-info {
|
||||
|
||||
@@ -140,7 +140,10 @@
|
||||
<!-- 底部房主与价格 -->
|
||||
<view class="card-footer">
|
||||
<view class="creator-info">
|
||||
<image class="creator-avatar" :src="act.creatorAvatar" mode="aspectFill" />
|
||||
<image v-if="act.creatorAvatar" class="creator-avatar" :src="act.creatorAvatar" mode="aspectFill" />
|
||||
<view v-else class="creator-avatar creator-avatar-empty">
|
||||
<text class="creator-avatar-placeholder">?</text>
|
||||
</view>
|
||||
<text class="creator-name">局长:{{ act.creatorNickname }}</text>
|
||||
</view>
|
||||
|
||||
@@ -346,9 +349,9 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
const getCategoryTagClass = (game) => {
|
||||
if (game === '美食陪吃') return 'tag-meal';
|
||||
if (game === '同城陪唱') return 'tag-sing';
|
||||
if (game === '下午茶陪喝' || game === '同城微醺') return 'tag-drink';
|
||||
if (game === '美食陪吃' || game === '美食探店') return 'tag-meal';
|
||||
if (game === '同城陪唱' || game === 'KTV嗨唱') return 'tag-sing';
|
||||
if (game === '下午茶陪喝' || game === '下午茶歇' || game === '同城微醺') return 'tag-drink';
|
||||
return '';
|
||||
};
|
||||
|
||||
@@ -802,6 +805,20 @@ const createActivity = () => {
|
||||
height: 48rpx;
|
||||
border-radius: 24rpx;
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.creator-avatar-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
|
||||
.creator-avatar-placeholder {
|
||||
font-size: 22rpx;
|
||||
color: #94A3B8;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
.creator-name {
|
||||
font-size: 22rpx;
|
||||
|
||||
@@ -251,7 +251,7 @@ import AppTabBar from '@/components/CustomTabBar.vue';
|
||||
import { ICONS } from '@/common/icons.js';
|
||||
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
||||
import { isExpertCertified } from '@/common/role.js';
|
||||
import { claimOrder, getOrdersPool, getExpertStats, updateCompanionProfile, getUserProfile, getActivities, normalizeUserId } from '@/common/api.js';
|
||||
import { claimOrder, getOrdersPool, getExpertStats, updateCompanionProfile, getUserProfile, getActivities, getMyExpertProfile, normalizeUserId } from '@/common/api.js';
|
||||
import { getUserLocation } from '@/common/location.js';
|
||||
import { startHeartbeat, stopHeartbeat } from '@/common/expert-heartbeat.js';
|
||||
import { getNavBarInfo } from '@/common/nav.js';
|
||||
@@ -363,6 +363,15 @@ const loadHallData = async () => {
|
||||
}
|
||||
}).catch(() => {}),
|
||||
|
||||
getMyExpertProfile().then((profile) => {
|
||||
if (profile && profile.status) {
|
||||
isOnline.value = profile.status === '接单中';
|
||||
uni.setStorageSync('EXPERT_RECEIVING_STATUS', isOnline.value ? '1' : '0');
|
||||
if (isOnline.value) startHeartbeat();
|
||||
else stopHeartbeat();
|
||||
}
|
||||
}).catch(() => {}),
|
||||
|
||||
getOrdersPool(myCoords).then((pool) => {
|
||||
if (Array.isArray(pool)) {
|
||||
orders.value = pool.map(p => {
|
||||
@@ -475,26 +484,37 @@ const filteredOrders = computed(() => {
|
||||
});
|
||||
|
||||
const toggleOnlineStatus = async () => {
|
||||
isOnline.value = !isOnline.value;
|
||||
uni.setStorageSync('EXPERT_RECEIVING_STATUS', isOnline.value ? '1' : '0');
|
||||
const eid = Number(expert.value.id) || normalizeUserId();
|
||||
if (!eid) {
|
||||
uni.showToast({ title: '请先登录后再操作', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
const next = !isOnline.value;
|
||||
isOnline.value = next;
|
||||
uni.setStorageSync('EXPERT_RECEIVING_STATUS', next ? '1' : '0');
|
||||
try {
|
||||
await updateCompanionProfile(eid, {
|
||||
status: isOnline.value ? '接单中' : '休息中'
|
||||
status: next ? '接单中' : '休息中'
|
||||
});
|
||||
} catch (_) {}
|
||||
if (isOnline.value) {
|
||||
} catch (err) {
|
||||
// 服务端失败必须回滚本地状态,避免界面显示在线但实际不接单
|
||||
isOnline.value = !next;
|
||||
uni.setStorageSync('EXPERT_RECEIVING_STATUS', !next ? '1' : '0');
|
||||
stopHeartbeat();
|
||||
uni.showToast({
|
||||
title: (err && err.message) || '接单状态切换失败,请稍后重试',
|
||||
icon: 'none'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (next) {
|
||||
startHeartbeat();
|
||||
} else {
|
||||
stopHeartbeat();
|
||||
}
|
||||
uni.showToast({
|
||||
title: isOnline.value ? '已开启在线接单' : '已设置为打烊休息',
|
||||
icon: isOnline.value ? 'success' : 'none',
|
||||
title: next ? '已开启在线接单' : '已设置为打烊休息',
|
||||
icon: next ? 'success' : 'none',
|
||||
duration: 1500
|
||||
});
|
||||
};
|
||||
|
||||
@@ -562,6 +562,13 @@ const loadExpertProfile = async () => {
|
||||
voiceIntroUrl: profile.voiceIntroUrl || '',
|
||||
voiceDuration: Number(profile.voiceDuration) || 0
|
||||
};
|
||||
// 以服务端接单状态为准,避免本地缓存显示在线但实际已休息
|
||||
if (profile.status) {
|
||||
isReceivingOrders.value = profile.status === '接单中';
|
||||
uni.setStorageSync('EXPERT_RECEIVING_STATUS', isReceivingOrders.value ? '1' : '0');
|
||||
if (isReceivingOrders.value) startHeartbeat();
|
||||
else stopHeartbeat();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<view class="section-title-wrap">
|
||||
<view class="section-title">
|
||||
<view class="title-line"></view>
|
||||
<text>精选在线大神</text>
|
||||
<text>精选大神</text>
|
||||
</view>
|
||||
<view class="section-more btn-active" @click="startInstantMatch">
|
||||
<view class="pulse-point"></view>
|
||||
|
||||
@@ -836,6 +836,22 @@ export async function initDatabase(): Promise<void> {
|
||||
// 检查并插入初始种子数据(若表为空)
|
||||
await seedInitialDataIfEmpty(conn);
|
||||
|
||||
// 数据修复:历史订单 expert_id 指向已不存在的档案(如 101)时,按 companion_name 回填真实档案 id
|
||||
// 避免完单结算时按旧的 101→1 兼容映射把钱结算给错误的大神
|
||||
// 放在种子数据之后执行,保证全新数据库的种子订单也能被修复
|
||||
try {
|
||||
await conn.query(`
|
||||
UPDATE orders o
|
||||
JOIN companions c ON c.name = o.companion_name
|
||||
SET o.expert_id = c.id
|
||||
WHERE o.expert_id >= 100 AND o.expert_id < 200
|
||||
AND o.expert_id NOT IN (SELECT id FROM companions)
|
||||
AND o.companion_name IS NOT NULL AND o.companion_name <> ''
|
||||
`);
|
||||
} catch (_) {
|
||||
// 表不存在或数据不满足条件:忽略
|
||||
}
|
||||
|
||||
console.log('[MySQL] peiwan 数据库核心表及种子数据已完整就绪');
|
||||
} finally {
|
||||
conn.release();
|
||||
|
||||
@@ -155,11 +155,13 @@ export class MysqlRepository {
|
||||
|
||||
const [ptRows]: any = await pool.query(`
|
||||
SELECT
|
||||
id, activity_id as activityId, user_id as userId,
|
||||
pay_amount_cents as payAmountCents, verify_code as verifyCode,
|
||||
checkin_status as checkinStatus, verified_at as verifiedAt,
|
||||
verified_by as verifiedBy, created_at as createdAt
|
||||
FROM participants
|
||||
p.id, p.activity_id as activityId, p.user_id as userId,
|
||||
u.nickname as nickname, u.avatar as avatar,
|
||||
p.pay_amount_cents as payAmountCents, p.verify_code as verifyCode,
|
||||
p.checkin_status as checkinStatus, p.verified_at as verifiedAt,
|
||||
p.verified_by as verifiedBy, p.created_at as createdAt
|
||||
FROM participants p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
`);
|
||||
|
||||
return actRows.map((act: any) => {
|
||||
@@ -172,6 +174,8 @@ export class MysqlRepository {
|
||||
id: p.id,
|
||||
activityId: p.activityId,
|
||||
userId: p.userId,
|
||||
nickname: p.nickname || '',
|
||||
avatar: p.avatar || '',
|
||||
payAmountCents: p.payAmountCents,
|
||||
checkinStatus: Boolean(p.checkinStatus),
|
||||
verifiedAt: p.verifiedAt,
|
||||
@@ -220,13 +224,15 @@ export class MysqlRepository {
|
||||
|
||||
const [ptRows]: any = await pool.query(`
|
||||
SELECT
|
||||
id, activity_id as activityId, user_id as userId,
|
||||
pay_amount_cents as payAmountCents, verify_code as verifyCode,
|
||||
checkin_status as checkinStatus, verified_at as verifiedAt,
|
||||
verified_by as verifiedBy, created_at as createdAt
|
||||
FROM participants
|
||||
WHERE activity_id = ?
|
||||
ORDER BY created_at ASC
|
||||
p.id, p.activity_id as activityId, p.user_id as userId,
|
||||
u.nickname as nickname, u.avatar as avatar,
|
||||
p.pay_amount_cents as payAmountCents, p.verify_code as verifyCode,
|
||||
p.checkin_status as checkinStatus, p.verified_at as verifiedAt,
|
||||
p.verified_by as verifiedBy, p.created_at as createdAt
|
||||
FROM participants p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.activity_id = ?
|
||||
ORDER BY p.created_at ASC
|
||||
`, [id]);
|
||||
|
||||
return {
|
||||
@@ -872,7 +878,8 @@ export class MysqlRepository {
|
||||
id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`,
|
||||
hourly_rate as hourlyRate, voice_duration as voiceDuration,
|
||||
voice_title as voiceTitle, voice_intro_url as voiceIntroUrl, rating, order_count as orderCount,
|
||||
status, audit_status as auditStatus, COALESCE(reject_reason, '') as rejectReason,
|
||||
status, (status = '接单中' AND last_active_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)) as isOnline,
|
||||
audit_status as auditStatus, COALESCE(reject_reason, '') as rejectReason,
|
||||
id_card_masked as idCardMasked, tags, apply_time as applyTime
|
||||
FROM companions
|
||||
`;
|
||||
@@ -893,6 +900,7 @@ export class MysqlRepository {
|
||||
} catch (_) {}
|
||||
return {
|
||||
...r,
|
||||
isOnline: Boolean(r.isOnline),
|
||||
tags: tagsArr
|
||||
};
|
||||
});
|
||||
@@ -914,7 +922,11 @@ export class MysqlRepository {
|
||||
[nLat, nLng, id]
|
||||
);
|
||||
} else {
|
||||
await pool.query('UPDATE companions SET last_active_at = NOW() WHERE id = ?', [id]);
|
||||
// 本次没有有效定位:清空旧坐标,避免用过期位置参与 5km 匹配
|
||||
await pool.query(
|
||||
'UPDATE companions SET last_active_at = NOW(), latitude = NULL, longitude = NULL WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,7 +973,6 @@ export class MysqlRepository {
|
||||
const distanceKm = hasFrom && hasGeo ? haversineKm(fromLat, fromLng, lat as number, lng as number) : null;
|
||||
return {
|
||||
id: String(r.id),
|
||||
userId: Number(r.userId) || 0,
|
||||
nickname: r.name || '',
|
||||
avatar: r.avatar || '',
|
||||
gender: r.gender || '',
|
||||
@@ -1622,8 +1633,8 @@ export class MysqlRepository {
|
||||
FROM orders
|
||||
WHERE (
|
||||
buyer_id = ?
|
||||
OR expert_id = ?
|
||||
OR claimed_by = ?
|
||||
OR expert_id IN (SELECT id FROM companions WHERE user_id = ?)
|
||||
OR companion_name IN (SELECT name FROM companions WHERE user_id = ?)
|
||||
)
|
||||
AND ${statusWhere}
|
||||
@@ -1642,8 +1653,8 @@ export class MysqlRepository {
|
||||
FROM orders
|
||||
WHERE (
|
||||
buyer_id = ?
|
||||
OR expert_id = ?
|
||||
OR claimed_by = ?
|
||||
OR expert_id IN (SELECT id FROM companions WHERE user_id = ?)
|
||||
OR companion_name IN (SELECT name FROM companions WHERE user_id = ?)
|
||||
)
|
||||
AND ${statusWhere}
|
||||
@@ -2359,7 +2370,11 @@ export class MysqlRepository {
|
||||
if (raw <= 0) return { companionId: 0, userId: 0 };
|
||||
const comp = await this.resolveCompanionRow(raw);
|
||||
if (comp) {
|
||||
return { companionId: Number(comp.id) || raw, userId: Number(comp.user_id) || raw };
|
||||
// 未绑定登录用户的种子档案不能把「档案 id」当「用户 id」去匹配 claimed_by,避免 id 撞号
|
||||
return {
|
||||
companionId: Number(comp.id) || raw,
|
||||
userId: comp.user_id != null ? Number(comp.user_id) : 0,
|
||||
};
|
||||
}
|
||||
return { companionId: raw, userId: raw };
|
||||
}
|
||||
@@ -2394,7 +2409,7 @@ export class MysqlRepository {
|
||||
}> {
|
||||
const scope = await this.resolveExpertOrderScope(expertId);
|
||||
const eId = scope.companionId || scope.userId;
|
||||
const eUid = scope.userId || scope.companionId;
|
||||
const eUid = scope.userId;
|
||||
const now = new Date();
|
||||
|
||||
// 1. 近 7 天走势
|
||||
|
||||
@@ -1276,7 +1276,8 @@ app.get('/api/companions', async (req: Request, res: Response) => {
|
||||
const publicList = companions
|
||||
.filter((c: any) => c.auditStatus === '已通过')
|
||||
.map((c: any) => {
|
||||
const { rejectReason, ...rest } = c;
|
||||
// 公开目录不返回内部审核备注、用户 id 与证件脱敏串
|
||||
const { rejectReason, userId, idCardMasked, ...rest } = c;
|
||||
return rest;
|
||||
});
|
||||
res.json({
|
||||
@@ -1360,7 +1361,7 @@ app.get('/api/companions/:id', async (req: Request, res: Response) => {
|
||||
if (isVerifiedAdminRequest(req)) {
|
||||
return res.json({ code: 20000, data: companion });
|
||||
}
|
||||
const { rejectReason, ...publicCompanion } = companion;
|
||||
const { rejectReason, userId, idCardMasked, ...publicCompanion } = companion;
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: publicCompanion
|
||||
|
||||
@@ -14,6 +14,6 @@ describe('第三十一批大神详情隐私闸门', () => {
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = indexCode.slice(start, start + 800);
|
||||
expect(block).toContain('isVerifiedAdminRequest(req)');
|
||||
expect(block).toContain('const { rejectReason, ...publicCompanion } = companion;');
|
||||
expect(block).toContain('const { rejectReason, userId, idCardMasked, ...publicCompanion } = companion;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,6 @@ describe('第三十批大神目录隐私闸门', () => {
|
||||
const block = indexCode.slice(start, start + 900);
|
||||
expect(block).toContain('isVerifiedAdminRequest(req)');
|
||||
expect(block).toContain("c.auditStatus === '已通过'");
|
||||
expect(block).toContain('const { rejectReason, ...rest } = c;');
|
||||
expect(block).toContain('const { rejectReason, userId, idCardMasked, ...rest } = c;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ describe('第五十批大神收益统计范围闸门', () => {
|
||||
const stats = repoCode.slice(start, start + 1600);
|
||||
expect(stats).toContain('resolveExpertOrderScope');
|
||||
expect(stats).toContain('(expert_id = ? OR claimed_by = ?)');
|
||||
// 未绑定用户的种子档案不得把档案 id 冒充用户 id 去匹配 claimed_by
|
||||
expect(repoCode).toContain('userId: comp.user_id != null ? Number(comp.user_id) : 0');
|
||||
const trends = repoCode.slice(repoCode.indexOf('async getExpertRevenueTrends('));
|
||||
expect(trends.slice(0, 6000)).toContain('(expert_id = ? OR claimed_by = ?)');
|
||||
});
|
||||
|
||||
@@ -41,6 +41,15 @@ describe('大神 LBS 即时匹配与接单心跳', () => {
|
||||
expect(repo).toContain('async updateCompanionHeartbeat(');
|
||||
// 接单中切换时立即刷新活跃时间
|
||||
expect(repo).toContain("fields.push('last_active_at = NOW()')");
|
||||
// 无定位心跳必须清空旧坐标,避免过期位置继续参与 5km 匹配
|
||||
expect(repo).toContain('latitude = NULL, longitude = NULL');
|
||||
// 列表在线状态同样以心跳为准
|
||||
expect(repo).toContain("(status = '接单中' AND last_active_at >= DATE_SUB(NOW(), INTERVAL 10 MINUTE)) as isOnline");
|
||||
});
|
||||
|
||||
it('历史 expert_id=101 等失效订单必须按大神名称回填真实档案 id', () => {
|
||||
expect(mysql).toContain('o.expert_id >= 100 AND o.expert_id < 200');
|
||||
expect(mysql).toContain('JOIN companions c ON c.name = o.companion_name');
|
||||
});
|
||||
|
||||
it('匹配接口必须在详情路由之前注册,心跳接口必须鉴权', () => {
|
||||
|
||||
@@ -24,6 +24,8 @@ describe('第十二批读接口数据隔离闸门 (订单 / 我的组局 / 分
|
||||
const block = repoCode.slice(start, start + 2200);
|
||||
expect(block).toContain('buyer_id = ?');
|
||||
expect(block).toContain('claimed_by = ?');
|
||||
expect(block).toContain('expert_id IN (SELECT id FROM companions WHERE user_id = ?)');
|
||||
expect(block).not.toContain('OR expert_id = ?');
|
||||
expect(block).toContain('companion_name IN (SELECT name FROM companions');
|
||||
// 不得用「用户 id == 档案 id」做兜底归属,避免 id 撞号导致越权看到他人订单
|
||||
expect(block).not.toContain('WHERE user_id = ? OR id = ?');
|
||||
|
||||
在新工单中引用
屏蔽一个用户