diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 18ef8bf..c052cd9 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -223,6 +223,7 @@ export async function getCompanions(keyword) { .filter((c) => !c.auditStatus || c.auditStatus === '已通过') .map((c) => ({ id: String(c.id), + userId: Number(c.userId) || 0, nickname: c.name || c.nickname || '', avatar: c.avatar || '', gender: c.gender === '男' ? 'male' : (c.gender === '女' ? 'female' : ''), diff --git a/pwuniapp/pages/activity/my-ticket.vue b/pwuniapp/pages/activity/my-ticket.vue index b496fbb..f61471c 100644 --- a/pwuniapp/pages/activity/my-ticket.vue +++ b/pwuniapp/pages/activity/my-ticket.vue @@ -128,7 +128,7 @@ import { onLoad, onShow } from '@dcloudio/uni-app'; import { getActivityDetail, getMyActivities, leaveActivity, normalizeUserId } from '@/common/api.js'; import { encodeCode128B } from '@/common/barcode.js'; import { generateQRCodeMatrix } from '@/common/qrcode.js'; -import { getCurrentThemeKey } from '@/common/theme.js'; +import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; const currentThemeKey = ref(getCurrentThemeKey()); @@ -231,22 +231,34 @@ const loadTicketData = async (query = savedQuery) => { } }; +const onThemeChanged = (key) => { + currentThemeKey.value = key; + applyThemeToNativeUI(key); +}; + onLoad(async (query) => { + currentThemeKey.value = getCurrentThemeKey(); + applyThemeToNativeUI(currentThemeKey.value); savedQuery = query; await loadTicketData(query); }); onShow(async () => { currentThemeKey.value = getCurrentThemeKey(); + applyThemeToNativeUI(currentThemeKey.value); await loadTicketData(); }); onMounted(() => { + currentThemeKey.value = getCurrentThemeKey(); + applyThemeToNativeUI(currentThemeKey.value); + uni.$on('theme-change', onThemeChanged); updateTime(); timer = setInterval(updateTime, 1000); }); onUnmounted(() => { + uni.$off('theme-change', onThemeChanged); if (timer) clearInterval(timer); }); diff --git a/pwuniapp/pages/expert/workspace.vue b/pwuniapp/pages/expert/workspace.vue index 66de315..c8715ae 100644 --- a/pwuniapp/pages/expert/workspace.vue +++ b/pwuniapp/pages/expert/workspace.vue @@ -468,7 +468,7 @@ import { onShow } from '@dcloudio/uni-app'; import { ICONS } from '@/common/icons.js'; import { openSupportSheet } from '@/common/support.js'; import { isExpertCertified } from '@/common/role.js'; -import { getCurrentThemeKey } from '@/common/theme.js'; +import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { queryCheckinCode, universalVerifyCheckin, @@ -670,6 +670,7 @@ const playVoice = () => { }; onUnmounted(() => { + uni.$off('theme-change', onThemeChanged); stopVoiceTimer(); try { if (recorderManager && voiceRecording.value) recorderManager.stop(); } catch (_) {} try { if (voiceAudioCtx) { voiceAudioCtx.destroy(); voiceAudioCtx = null; } } catch (_) {} @@ -887,7 +888,15 @@ const loadWorkspaceOrders = async () => { } }; +const onThemeChanged = (key) => { + currentThemeKey.value = key; + applyThemeToNativeUI(key); +}; + onMounted(() => { + currentThemeKey.value = getCurrentThemeKey(); + applyThemeToNativeUI(currentThemeKey.value); + uni.$on('theme-change', onThemeChanged); if (isExpertCertified()) { loadWorkspaceOrders(); } @@ -895,6 +904,7 @@ onMounted(() => { onShow(() => { currentThemeKey.value = getCurrentThemeKey(); + applyThemeToNativeUI(currentThemeKey.value); if (!isExpertCertified()) { uni.showToast({ title: '非认证大神禁止访问工作台', diff --git a/pwuniapp/pages/index/index.vue b/pwuniapp/pages/index/index.vue index 77fcd96..d089119 100644 --- a/pwuniapp/pages/index/index.vue +++ b/pwuniapp/pages/index/index.vue @@ -216,7 +216,7 @@ - 雷达即时派单匹配 + 在线大神即时匹配 @@ -236,21 +236,29 @@ - 首位接单大神命中 + 已为你匹配到在线大神 {{ matchedExpert.nickname }} - 接单中 + + {{ matchedExpert.isOnline ? '接单中' : '休息中' }} + {{ matchedExpert.game }} · {{ matchedExpert.rankTag }} ¥{{ matchedExpert.price }}/小时 - - + + @@ -272,7 +280,7 @@ import AppTabBar from '@/components/CustomTabBar.vue'; import SkeletonScreen from '@/components/SkeletonScreen.vue'; import PrivacyModal from '@/components/PrivacyModal.vue'; import HomePopupModal from '@/components/HomePopupModal.vue'; -import { getCompanions, getBanners, getNavCategories, bindInviteCode } from '@/common/api.js'; +import { getCompanions, getBanners, getNavCategories, bindInviteCode, normalizeUserId } from '@/common/api.js'; import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { getNavBarInfo } from '@/common/nav.js'; @@ -575,33 +583,51 @@ const resetAllFilters = () => { filterPriceRange.value = 'all'; }; -// 雷达即时速配逻辑 +// 即时速配:每次重新拉取大神状态,只匹配「真实注册 + 已通过审核 + 当前接单中」的大神, +// 排除自己;换一位时优先避开上一位,避免长期命中同一条种子数据。 let matchTimer = null; -const startInstantMatch = () => { +let matchSeq = 0; +let lastMatchedId = ''; +const startInstantMatch = async () => { + const seq = ++matchSeq; showMatchModal.value = true; isMatching.value = true; matchedExpert.value = null; - if (matchTimer) clearTimeout(matchTimer); + const startedAt = Date.now(); + + let freshList = null; + try { + freshList = await getCompanions(); + } catch (_) {} + if (seq !== matchSeq) return; + if (Array.isArray(freshList) && freshList.length > 0) { + experts.value = freshList; + } + + const wait = Math.max(0, 600 - (Date.now() - startedAt)); matchTimer = setTimeout(() => { + if (seq !== matchSeq) return; isMatching.value = false; - // 按真实在线状态与距离推荐(有 distanceKm 时取最近),不做随机抽取 - const onlineList = experts.value.filter(e => e.isOnline); - const candidates = onlineList.length > 0 ? onlineList : experts.value; - const sorted = [...candidates].sort((a, b) => { - const da = Number(a.distanceKm); - const db = Number(b.distanceKm); - if (Number.isFinite(da) && Number.isFinite(db)) return da - db; - if (Number.isFinite(da)) return -1; - if (Number.isFinite(db)) return 1; - return 0; - }); - matchedExpert.value = sorted[0] || null; - }, 600); + const uid = Number(normalizeUserId()) || 0; + const eligible = (experts.value || []).filter( + (e) => e.isOnline && Number(e.userId) > 0 && Number(e.userId) !== uid + ); + const rotated = eligible.filter((e) => String(e.id) !== String(lastMatchedId)); + const pool = rotated.length > 0 ? rotated : eligible; + if (pool.length === 0) { + matchedExpert.value = null; + return; + } + const picked = pool[Math.floor(Math.random() * pool.length)]; + matchedExpert.value = picked; + lastMatchedId = String(picked.id); + }, wait); }; const closeMatchModal = () => { showMatchModal.value = false; + matchSeq += 1; if (matchTimer) clearTimeout(matchTimer); }; @@ -1671,6 +1697,10 @@ const navTo = (url) => { background: #059669; padding: 2rpx 8rpx; border-radius: 6rpx; + + &.offline { + background: #64748B; + } } } @@ -1692,25 +1722,40 @@ const navTo = (url) => { .match-actions-row { width: 100%; display: flex; - gap: 16rpx; + gap: 20rpx; .btn-match-action { - flex: 1; - height: 72rpx; - line-height: 70rpx; - font-size: 24rpx; + height: 84rpx; + line-height: 82rpx; + font-size: 26rpx; font-weight: 600; - border-radius: 36rpx; + border-radius: 42rpx; margin: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8rpx; + box-sizing: border-box; &.secondary { - background: rgba(255, 255, 255, 0.08); - color: #94A3B8; + flex: 1; + background: rgba(255, 255, 255, 0.06); + color: #CBD5E1; + border: 1px solid rgba(255, 255, 255, 0.14); } &.primary { + flex: 1.5; background: linear-gradient(135deg, #10B981, #059669); color: #FFFFFF; + border: none; + box-shadow: 0 8rpx 20rpx rgba(16, 185, 129, 0.28); + } + + .match-btn-icon { + width: 30rpx; + height: 30rpx; + flex-shrink: 0; } } } @@ -1790,5 +1835,31 @@ const navTo = (url) => { } } } + + .price-modal-box.match-modal-box { + .matched-expert-card { + background: #FDFBF7; + border-color: #E7E5E4; + } + + .matched-info .matched-name-row .matched-name { + color: #1C1917; + } + + .matched-info .matched-game { + color: #78716C; + } + + .match-actions-row .btn-match-action.secondary { + background: #F5F2EC; + border-color: #E7E5E4; + color: #57534E; + } + + .match-empty-box .empty-txt, + .radar-status-text { + color: #78716C; + } + } } diff --git a/pwuniapp/pages/user/coupons.vue b/pwuniapp/pages/user/coupons.vue index ca9c440..0f452d0 100644 --- a/pwuniapp/pages/user/coupons.vue +++ b/pwuniapp/pages/user/coupons.vue @@ -106,7 +106,7 @@ import { ref, computed } from 'vue'; import { onShow } from '@dcloudio/uni-app'; import { ICONS } from '@/common/icons.js'; -import { getCurrentThemeKey } from '@/common/theme.js'; +import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { getNavBarInfo } from '@/common/nav.js'; import { getMarketingCoupons, claimCoupon, getMyCoupons } from '@/common/api.js'; @@ -215,6 +215,7 @@ const handleBack = () => { onShow(() => { navInfo.value = getNavBarInfo(); currentThemeKey.value = getCurrentThemeKey(); + applyThemeToNativeUI(currentThemeKey.value); reloadAll(); }); diff --git a/pwuniapp/pages/user/index.vue b/pwuniapp/pages/user/index.vue index eb64814..180008c 100644 --- a/pwuniapp/pages/user/index.vue +++ b/pwuniapp/pages/user/index.vue @@ -1487,23 +1487,31 @@ const navTo = (url) => { margin-bottom: 28rpx; overflow: hidden; box-shadow: 0 8rpx 28rpx rgba(99, 102, 241, 0.35); + background: transparent; &::before { content: ''; position: absolute; - inset: -50%; + left: 50%; + top: 50%; + width: 900rpx; + height: 900rpx; + margin-left: -450rpx; + margin-top: -450rpx; background: conic-gradient(from 0deg, transparent 0%, #6366F1 25%, #10B981 50%, #6366F1 75%, transparent 100%); - animation: glowSpin 5s linear infinite; + animation: glowSpin 4s linear infinite; z-index: 0; + pointer-events: none; } &::after { content: ''; position: absolute; - inset: 2rpx; + inset: 3rpx; background: linear-gradient(135deg, #1E1B4B 0%, #0F172A 100%); border-radius: 22rpx; z-index: 1; + pointer-events: none; } .role-content { @@ -2019,17 +2027,19 @@ const navTo = (url) => { } .role-switch-card, .identity-banner { - background-color: #FFFFFF; - border: 1px solid #F0ECE4; - box-shadow: 0 4rpx 20rpx rgba(234, 88, 12, 0.12); + background: transparent; + border: none; + box-shadow: 0 6rpx 24rpx rgba(234, 88, 12, 0.18); &::after { - background: linear-gradient(135deg, #FFFFFF 0%, #FDFBF7 100%); + background: linear-gradient(135deg, #FFFFFF 0%, #FFFDF9 100%); border-radius: 22rpx; + inset: 3rpx; } &::before { background: conic-gradient(from 0deg, transparent 0%, #EA580C 25%, #F59E0B 50%, #EA580C 75%, transparent 100%); + animation: glowSpin 4s linear infinite; } .role-content .role-left { @@ -3056,6 +3066,24 @@ const navTo = (url) => { } } } + +@keyframes glowSpin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@-webkit-keyframes glowSpin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} diff --git a/pwuniapp/pages/user/notifications.vue b/pwuniapp/pages/user/notifications.vue index da585ad..c21c117 100644 --- a/pwuniapp/pages/user/notifications.vue +++ b/pwuniapp/pages/user/notifications.vue @@ -73,7 +73,7 @@ import { ref, computed } from 'vue'; import { onShow } from '@dcloudio/uni-app'; import { ICONS } from '@/common/icons.js'; -import { getCurrentThemeKey } from '@/common/theme.js'; +import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { getNavBarInfo } from '@/common/nav.js'; import { getNotifications, markNotificationsRead } from '@/common/api.js'; @@ -206,6 +206,7 @@ const handleBack = () => { onShow(() => { navInfo.value = getNavBarInfo(); currentThemeKey.value = getCurrentThemeKey(); + applyThemeToNativeUI(currentThemeKey.value); loadData(); }); diff --git a/server/tests/client-features.spec.ts b/server/tests/client-features.spec.ts index e56f48f..50f8d18 100644 --- a/server/tests/client-features.spec.ts +++ b/server/tests/client-features.spec.ts @@ -343,6 +343,21 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => { expect(content).toContain('empty-search-box'); expect(content).toContain('resetAllFilters'); }); + + it('即时匹配必须实时拉取大神、只匹配真实注册且接单中的大神,并支持换一位', () => { + const indexVuePath = path.join(uniPagesDir, 'index', 'index.vue'); + const content = fs.readFileSync(indexVuePath, 'utf-8'); + const apiContent = fs.readFileSync(path.join(rootDir, 'pwuniapp', 'common', 'api.js'), 'utf-8'); + + expect(content).toContain('await getCompanions()'); + expect(content).toContain('Number(e.userId) > 0'); + expect(content).toContain('Number(e.userId) !== uid'); + expect(content).toContain('lastMatchedId'); + expect(content).toContain('换一位'); + expect(content).toContain('在线大神即时匹配'); + expect(content).not.toContain('首位接单大神命中'); + expect(apiContent).toContain('userId: Number(c.userId) || 0'); + }); }); describe('9. 大神认证审核双向联动、服务评价监管与友圈搭子上车业务完整闭环', () => {