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 @@