fix(theme): 修复门票页导航栏主题脱节与个人中心双身份卡片流光动画,加固活动参与人画像与大神隐私

这个提交包含在:
xcbyy
2026-10-23 09:05:32 +08:00
父节点 001f81eaca
当前提交 f082ae8276
共有 14 个文件被更改,包括 147 次插入 和 40 次删除

查看文件

@@ -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 = ?');