From 7192c4db8c4b27433d5e2ab0f41f96cdc4ee08ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E5=93=A5=E5=A4=A7=E5=93=A5=E7=9A=84=E5=A4=A7?= =?UTF-8?q?=E5=93=A5=E5=93=A5?= Date: Fri, 17 Jul 2026 18:02:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(tongji):=20=E6=8E=A5=E5=85=A5=E5=B0=8F?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E7=9C=9F=E5=AE=9E=E5=91=A8=E6=A6=9C=E4=B8=8E?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=88=86=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TUICallKit-Vue3/pages.json | 4 +- .../endless-game/PLATFORM_INTEGRATION.md | 36 ++ .../composables/useGamePlatform.js | 244 +++++++++ .../tongji/endless-game/game-endless.scss | 6 + TUICallKit-Vue3/tongji/endless-game/index.vue | 162 ++++-- server/app/api/controller/TcmController.php | 49 ++ .../app/api/logic/tcm/GamePlatformLogic.php | 472 ++++++++++++++++++ .../add_tcm_endless_game_platform.sql | 73 +++ 8 files changed, 1014 insertions(+), 32 deletions(-) create mode 100644 TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md create mode 100644 TUICallKit-Vue3/tongji/endless-game/composables/useGamePlatform.js create mode 100644 server/app/api/logic/tcm/GamePlatformLogic.php create mode 100644 server/sql/1.9.20260717/add_tcm_endless_game_platform.sql diff --git a/TUICallKit-Vue3/pages.json b/TUICallKit-Vue3/pages.json index 759b8eee..ad66d150 100644 --- a/TUICallKit-Vue3/pages.json +++ b/TUICallKit-Vue3/pages.json @@ -230,7 +230,9 @@ "navigationStyle": "custom", "navigationBarTitleText": "控糖消消乐", "backgroundColor": "#eefbf4", - "disableScroll": false + "disableScroll": false, + "enableShareAppMessage": true, + "enableShareTimeline": true } } ] diff --git a/TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md b/TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md new file mode 100644 index 00000000..8d0ab463 --- /dev/null +++ b/TUICallKit-Vue3/tongji/endless-game/PLATFORM_INTEGRATION.md @@ -0,0 +1,36 @@ +# 控糖消消乐平台接入说明 + +## 已接入能力 + +- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。 +- 按周一日期、性别自动分配最多 7 人的同行组。 +- 以真实平台昵称、头像、认糖数和本周最高分排序。 +- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。 +- 待同步成绩最多本地保留 8 局,重新联网后自动补传。 +- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。 +- 受邀进入只记录一次轻量分享访问,不自动建立家庭或好友绑定。 + +## 接口 + +- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。 +- `POST /api/tcm/gameSubmitProgress`:上报 `session_key`、`learned_count`、`score`、`ended`。 +- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。 +- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`。 + +四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`。 + +## 部署顺序 + +1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`。 +2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php` 和 `TcmController.php`。 +3. 重新构建并上传小程序前端。 + +如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。 + +## 上线前检查 + +- 用男女各两个测试账号进入,确认被分入对应性别组。 +- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。 +- 断网完成几次消除,再联网打开榜单,确认成绩补传。 +- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。 +- 周一验证新周重新分组,旧周成绩不带入新周。 diff --git a/TUICallKit-Vue3/tongji/endless-game/composables/useGamePlatform.js b/TUICallKit-Vue3/tongji/endless-game/composables/useGamePlatform.js new file mode 100644 index 00000000..10b37b98 --- /dev/null +++ b/TUICallKit-Vue3/tongji/endless-game/composables/useGamePlatform.js @@ -0,0 +1,244 @@ +import { ref } from 'vue' + +const EMPTY_BOARD = { + week_start: '', + week_end: '', + sex_label: '同行', + group_size: 7, + member_count: 0, + players: [], + me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true }, + invite_code: '' +} +const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1' + +function createSessionKey() { + const random = Math.random().toString(36).slice(2, 12) + return `game_${Date.now().toString(36)}_${random}` +} + +function readPendingPayloads() { + try { + const stored = uni.getStorageSync(PENDING_SYNC_KEY) + if (!Array.isArray(stored)) return [] + return stored.filter(item => ( + item + && /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || '')) + && Number(item.learned_count) >= 0 + )).slice(-8) + } catch (_) { + return [] + } +} + +/** + * 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。 + * 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。 + */ +export function useGamePlatform(proxy, ensureLoggedIn) { + const connected = ref(false) + const loading = ref(false) + const syncStatus = ref('idle') + const leaderboard = ref({ ...EMPTY_BOARD }) + const confirmedSessionLearned = ref(0) + + let sessionKey = createSessionKey() + let syncTimer = null + let syncing = false + let queuedPayloads = readPendingPayloads() + let connectPromise = null + + function persistPendingPayloads() { + try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {} + } + + async function api(request) { + if (!proxy?.apiUrl) throw new Error('平台接口未初始化') + return proxy.apiUrl(request, false) + } + + function applyLeaderboard(data, confirmedForSession = '') { + if (!data || !Array.isArray(data.players)) return + const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey + if (!inactiveSessionResponse) { + leaderboard.value = { + ...EMPTY_BOARD, + ...data, + me: { ...EMPTY_BOARD.me, ...(data.me || {}) }, + players: data.players + } + } + if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) { + confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0 + } + connected.value = true + syncStatus.value = 'synced' + } + + async function refreshLeaderboard() { + const res = await api({ + url: '/api/tcm/gameWeeklyLeaderboard', + method: 'GET' + }) + if (res?.code !== 1 || !res.data) { + throw new Error(res?.msg || '同行榜加载失败') + } + applyLeaderboard(res.data) + return res.data + } + + async function acceptShare(inviteCode) { + const code = String(inviteCode || '').trim().toUpperCase() + if (!code) return + try { + await api({ + url: '/api/tcm/gameAcceptShare', + method: 'POST', + data: { invite_code: code } + }) + } catch (_) { + // 分享关系是辅助能力,不阻断进入游戏。 + } + } + + async function connect(inviteCode = '') { + if (connectPromise) return connectPromise + connectPromise = (async () => { + loading.value = true + try { + const loggedIn = await ensureLoggedIn() + if (!loggedIn) throw new Error('登录失败') + await acceptShare(inviteCode) + await refreshLeaderboard() + if (queuedPayloads.length) flushProgress() + return true + } catch (_) { + connected.value = false + syncStatus.value = 'offline' + return false + } finally { + loading.value = false + connectPromise = null + } + })() + return connectPromise + } + + function beginSession() { + if (syncTimer) clearTimeout(syncTimer) + sessionKey = createSessionKey() + confirmedSessionLearned.value = 0 + syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline') + return sessionKey + } + + function queueProgress(learnedCount, score, ended = false) { + const nextPayload = { + session_key: sessionKey, + learned_count: Math.max(0, Number(learnedCount) || 0), + score: Math.max(0, Number(score) || 0), + ended: ended ? 1 : 0 + } + const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey) + if (existingIndex >= 0) { + const existing = queuedPayloads[existingIndex] + queuedPayloads[existingIndex] = { + ...nextPayload, + learned_count: Math.max(existing.learned_count, nextPayload.learned_count), + score: Math.max(existing.score, nextPayload.score), + ended: Math.max(existing.ended, nextPayload.ended) + } + } else { + queuedPayloads.push(nextPayload) + } + persistPendingPayloads() + syncStatus.value = 'pending' + if (syncTimer) clearTimeout(syncTimer) + if (ended) { + flushProgress() + } else { + syncTimer = setTimeout(flushProgress, 1000) + } + } + + async function flushProgress() { + if (syncTimer) clearTimeout(syncTimer) + syncTimer = null + if (syncing || !queuedPayloads.length) return + const payload = queuedPayloads[0] + syncing = true + syncStatus.value = 'syncing' + try { + if (!connected.value) { + const loggedIn = await ensureLoggedIn() + if (!loggedIn) throw new Error('未登录') + } + const res = await api({ + url: '/api/tcm/gameSubmitProgress', + method: 'POST', + data: payload + }) + if (res?.code !== 1 || !res.data) { + throw new Error(res?.msg || '成绩保存失败') + } + applyLeaderboard(res.data, payload.session_key) + queuedPayloads = queuedPayloads.filter(item => item.session_key !== payload.session_key) + persistPendingPayloads() + } catch (_) { + connected.value = false + syncStatus.value = 'offline' + // 队首保留原绝对值,下次连接或打开榜单时安全重试。 + persistPendingPayloads() + } finally { + syncing = false + if (queuedPayloads.length && connected.value) { + setTimeout(flushProgress, 80) + } + } + } + + async function syncAndRefresh(learnedCount, score) { + queueProgress(learnedCount, score, false) + await flushProgress() + if (!connected.value) { + await connect() + if (queuedPayloads.length) await flushProgress() + } else { + try { await refreshLeaderboard() } catch (_) {} + } + } + + async function recordShare() { + try { + if (!connected.value && !(await connect())) return + const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' }) + if (res?.code === 1 && res.data?.invite_code) { + leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code } + } + } catch (_) { + // 分享本身仍可进行,统计失败不影响用户。 + } + } + + function dispose() { + if (syncTimer) clearTimeout(syncTimer) + syncTimer = null + persistPendingPayloads() + } + + return { + connected, + loading, + syncStatus, + leaderboard, + confirmedSessionLearned, + connect, + beginSession, + queueProgress, + flushProgress, + syncAndRefresh, + refreshLeaderboard, + recordShare, + dispose + } +} diff --git a/TUICallKit-Vue3/tongji/endless-game/game-endless.scss b/TUICallKit-Vue3/tongji/endless-game/game-endless.scss index 069de3fc..782b5e2b 100644 --- a/TUICallKit-Vue3/tongji/endless-game/game-endless.scss +++ b/TUICallKit-Vue3/tongji/endless-game/game-endless.scss @@ -321,6 +321,7 @@ .eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; } .eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; } .eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; } +.eg-weekly-preview.is-offline { color: #69766f; background: #e8eeeb; } .eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; } .eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; } .eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); } @@ -334,6 +335,7 @@ .eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; } .eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; } .eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; } +.eg-weekly-avatar-image { display: block; width: 100%; height: 100%; border-radius: 50%; } .eg-weekly-avatar.is-tone-2 { background: #e39932; } .eg-weekly-avatar.is-tone-3 { background: #6c83c8; } .eg-weekly-avatar.is-tone-4 { background: #e5683e; } @@ -353,6 +355,8 @@ .eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; } .eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; } .eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; } +.eg-weekly-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; } +.eg-weekly-share::after { border: 0; } .eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; } .eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; } @@ -389,6 +393,8 @@ .eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; } .eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; } .eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); } +.eg-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; } +.eg-share-btn::after { border: 0; } .eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; } @keyframes egPulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); } diff --git a/TUICallKit-Vue3/tongji/endless-game/index.vue b/TUICallKit-Vue3/tongji/endless-game/index.vue index 2684ea8e..4275ca15 100644 --- a/TUICallKit-Vue3/tongji/endless-game/index.vue +++ b/TUICallKit-Vue3/tongji/endless-game/index.vue @@ -197,9 +197,9 @@ 本周7人同行榜 - 界面预览 + {{ weeklyStatusLabel }} - {{ weeklyRangeLabel }} · 每周一更新 + {{ weeklyRangeLabel }} · {{ weeklySexLabel }} · {{ weeklyMemberCount }}/7人 {{ weeklyMyRank }} @@ -225,7 +225,10 @@ :class="{ 'is-me': player.isMe }" > {{ player.rank }} - {{ player.avatar }} + + + {{ player.avatar }} + {{ player.name }} {{ player.count }} @@ -242,7 +245,8 @@ 开始消除 - 当前为排行效果预览,真实好友数据接入后替换 + + 使用平台昵称参加真实周榜,每周一重新分组;分享不会自动绑定家庭关系 @@ -284,6 +288,7 @@ 本局得分{{ score }} 历史最高{{ bestScore }} 再玩一局 + 返回血糖周报 @@ -292,10 +297,12 @@