feat: update contribution polling per latest spec - timestamp+id polling, combo_count, fading animation

This commit is contained in:
zheng020 2026-05-14 12:16:35 +08:00
parent ba075fffd3
commit 7e1da61397
2 changed files with 73 additions and 65 deletions

View File

@ -8,21 +8,21 @@
v-for="(record, index) in records" v-for="(record, index) in records"
:key="record.id" :key="record.id"
class="contribution-item" class="contribution-item"
:class="{ 'new-item': index === 0 && isNewRecord(record.id) }" :class="{ 'new-item': index === 0, 'fading-out': record.fading }"
> >
<image class="user-avatar" :src="record.user_avatar" mode="aspectFill" /> <image class="user-avatar" :src="record.user_avatar" mode="aspectFill" />
<text class="user-nickname">{{ record.user_nickname }}</text> <text class="user-nickname">{{ record.user_nickname }}</text>
<text class="contribute-text">贡献了</text> <text class="contribute-text">贡献了</text>
<image class="item-icon" :src="record.item_icon" mode="aspectFill" /> <image class="item-icon" :src="record.item_icon" mode="aspectFill" />
<text class="item-name">{{ record.item_name }}</text> <text class="item-name">{{ record.item_name }}</text>
<text class="item-quantity">x{{ record.quantity }}</text> <text class="item-quantity">x{{ record.combo_count > 1 ? record.combo_count : record.quantity }}</text>
</view> </view>
</scroll-view> </scroll-view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useContributionPolling } from '../composables/useContributionPolling.js' import { useContributionPolling } from '../composables/useContributionPolling.js'
const props = defineProps({ const props = defineProps({
@ -32,40 +32,14 @@ const props = defineProps({
} }
}) })
const visible = ref(true)
const isPageActive = ref(true) const isPageActive = ref(true)
const newRecordIds = ref([])
// 使 composable // 使 composable
const { records, loading, error, start, stop, refresh } = useContributionPolling( const { records, visible, loading, error, start, stop, reset } = useContributionPolling(
computed(() => props.activityId), computed(() => props.activityId),
isPageActive isPageActive
) )
//
function isNewRecord(id) {
return newRecordIds.value.includes(id)
}
// records
watch(records, (newRecords, oldRecords) => {
if (newRecords.length > 0 && oldRecords) {
const newIds = newRecords.map(r => r.id)
const oldIds = oldRecords.map(r => r.id)
// ID
for (const id of newIds) {
if (!oldIds.includes(id)) {
newRecordIds.value.push(id)
// 2
setTimeout(() => {
newRecordIds.value = newRecordIds.value.filter(i => i !== id)
}, 2000)
}
}
}
}, { deep: true })
onMounted(() => { onMounted(() => {
isPageActive.value = true isPageActive.value = true
}) })
@ -77,7 +51,7 @@ onUnmounted(() => {
// 使 // 使
defineExpose({ defineExpose({
refresh reset
}) })
</script> </script>
@ -125,6 +99,10 @@ defineExpose({
animation: slideIn 0.3s ease-out; animation: slideIn 0.3s ease-out;
} }
.fading-out {
animation: fadeOut 0.5s forwards;
}
.user-avatar { .user-avatar {
width: 48rpx; width: 48rpx;
height: 48rpx; height: 48rpx;
@ -186,4 +164,9 @@ defineExpose({
transform: translateX(0); transform: translateX(0);
} }
} }
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
</style> </style>

View File

@ -5,7 +5,7 @@ import { getActivityContributionsLatestApi } from '@/utils/api.js'
* 贡献轮询逻辑 composable * 贡献轮询逻辑 composable
* @param {Ref<string>} activityId - 活动ID * @param {Ref<string>} activityId - 活动ID
* @param {boolean} isPageActive - 页面是否可见 * @param {boolean} isPageActive - 页面是否可见
* @returns {Object} records, loading, error, start, stop, refresh * @returns {Object} records, visible, loading, error, start, stop, reset
*/ */
export function useContributionPolling(activityId, isPageActive) { export function useContributionPolling(activityId, isPageActive) {
const MAX_RECORDS = 5 const MAX_RECORDS = 5
@ -13,22 +13,42 @@ export function useContributionPolling(activityId, isPageActive) {
const RECORD_TTL = 5000 // 每条记录 5 秒后消失 const RECORD_TTL = 5000 // 每条记录 5 秒后消失
const records = ref([]) const records = ref([])
const visible = ref(true)
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
let latestId = 0 let latestTimestamp = 0 // 当前列表中最新记录的时间戳
let latestId = 0 // 当前列表中最新记录的 ID同时间戳内二次筛选用
let pollingTimer = null let pollingTimer = null
let isPolling = false let isPolling = false
const recordTimers = new Map() const recordTimers = new Map()
// 重置记录计时器 // 重置记录计时器
function resetRecordTimer(record) { function resetRecordTimer(record) {
// 清除已有定时器
if (recordTimers.has(record.id)) { if (recordTimers.has(record.id)) {
clearTimeout(recordTimers.get(record.id)) clearTimeout(recordTimers.get(record.id))
}
const timer = setTimeout(() => {
records.value = records.value.filter(r => r.id !== record.id)
recordTimers.delete(record.id) recordTimers.delete(record.id)
}
// 清除淡出状态(新数据到来时重置)
const target = records.value.find(r => r.id === record.id)
if (target) {
target.fading = false
}
// 设置新的消失定时器
const timer = setTimeout(() => {
// 标记为淡出状态
const target = records.value.find(r => r.id === record.id)
if (target) {
target.fading = true
// 等待动画完成后移除
setTimeout(() => {
records.value = records.value.filter(r => r.id !== record.id)
recordTimers.delete(record.id)
}, 500)
} else {
recordTimers.delete(record.id)
}
}, RECORD_TTL) }, RECORD_TTL)
recordTimers.set(record.id, timer) recordTimers.set(record.id, timer)
} }
@ -38,7 +58,7 @@ export function useContributionPolling(activityId, isPageActive) {
if (!activityId.value) return if (!activityId.value) return
try { try {
const res = await getActivityContributionsLatestApi(activityId.value, latestId, 1) const res = await getActivityContributionsLatestApi(activityId.value, latestTimestamp, latestId, 1)
// 处理 code 不为 200 的情况(静默忽略) // 处理 code 不为 200 的情况(静默忽略)
if (res.code !== 200) return if (res.code !== 200) return
@ -48,18 +68,19 @@ export function useContributionPolling(activityId, isPageActive) {
const newRecord = newRecords[0] const newRecord = newRecords[0]
// 检测到新记录id > latestId // 检测到新记录(时间戳更新,或时间戳相同但 ID 更新)
if (newRecord.id > latestId) { const isNew = newRecord.created_at > latestTimestamp ||
// 重置所有现有记录的计时器 (newRecord.created_at === latestTimestamp && newRecord.id > latestId)
records.value.forEach(resetRecordTimer)
if (isNew) {
// 重置所有现有记录的计时器(新数据到来,刷新列表)
records.value.forEach(resetRecordTimer)
// 新记录插入到列表头部 // 新记录插入到列表头部
records.value = [newRecord, ...records.value].slice(0, MAX_RECORDS) records.value = [newRecord, ...records.value].slice(0, MAX_RECORDS)
// 为新记录启动消失计时器 // 为新记录启动消失计时器
resetRecordTimer(newRecord) resetRecordTimer(newRecord)
// 更新时间戳和 ID
// 更新 latestId latestTimestamp = newRecord.created_at
latestId = newRecord.id latestId = newRecord.id
} }
} catch (e) { } catch (e) {
@ -76,7 +97,7 @@ export function useContributionPolling(activityId, isPageActive) {
error.value = null error.value = null
try { try {
const res = await getActivityContributionsLatestApi(activityId.value, 0, MAX_RECORDS) const res = await getActivityContributionsLatestApi(activityId.value, 0, 0, MAX_RECORDS)
if (res.code !== 200) { if (res.code !== 200) {
throw new Error(res.message || '获取贡献记录失败') throw new Error(res.message || '获取贡献记录失败')
@ -94,8 +115,11 @@ export function useContributionPolling(activityId, isPageActive) {
// 为每条记录启动计时器 // 为每条记录启动计时器
newRecords.forEach(record => resetRecordTimer(record)) newRecords.forEach(record => resetRecordTimer(record))
// 更新 latestId // 更新 latestTimestamp 和 latestId
latestId = newRecords.length > 0 ? newRecords[newRecords.length - 1].id : 0 if (newRecords.length > 0) {
latestTimestamp = newRecords[0].created_at
latestId = newRecords[0].id
}
} catch (e) { } catch (e) {
error.value = e.message || '获取贡献记录失败' error.value = e.message || '获取贡献记录失败'
console.error('[useContributionPolling] fetchAll error:', e) console.error('[useContributionPolling] fetchAll error:', e)
@ -110,42 +134,42 @@ export function useContributionPolling(activityId, isPageActive) {
if (!activityId.value) return if (!activityId.value) return
isPolling = true isPolling = true
latestId = 0 // 如果 latestTimestamp 为 0说明是首次或切换活动清空列表
if (latestTimestamp === 0) {
// 全量拉取首次 records.value = []
fetchAll() }
fetchLatest()
// 启动定时轮询
pollingTimer = setInterval(fetchLatest, POLL_INTERVAL) pollingTimer = setInterval(fetchLatest, POLL_INTERVAL)
} }
// 停止轮询 // 停止轮询(保留状态)
function stop() { function stop() {
if (pollingTimer) { if (pollingTimer) {
clearInterval(pollingTimer) clearInterval(pollingTimer)
pollingTimer = null pollingTimer = null
} }
// 停止所有记录的计时器
// 清除所有计时器
recordTimers.forEach(timer => clearTimeout(timer)) recordTimers.forEach(timer => clearTimeout(timer))
recordTimers.clear() recordTimers.clear()
isPolling = false isPolling = false
records.value = [] // 不清空 records、latestTimestamp、latestId保留状态以便恢复
latestId = 0
} }
// 强制刷新(全量拉取 // 重置所有状态(切换活动时用
function refresh() { function reset() {
stop() stop()
start() latestTimestamp = 0
latestId = 0
records.value = []
} }
// 监听页面可见性 // 监听页面可见性
watch(isPageActive, (active) => { watch(isPageActive, (active) => {
if (active) { if (active) {
visible.value = true
start() start()
} else { } else {
visible.value = false
stop() stop()
} }
}, { immediate: true }) }, { immediate: true })
@ -153,7 +177,7 @@ export function useContributionPolling(activityId, isPageActive) {
// 监听 activityId 变化 // 监听 activityId 变化
watch(activityId, (newId, oldId) => { watch(activityId, (newId, oldId) => {
if (newId !== oldId) { if (newId !== oldId) {
stop() reset()
if (isPageActive.value && newId) { if (isPageActive.value && newId) {
start() start()
} }
@ -162,15 +186,16 @@ export function useContributionPolling(activityId, isPageActive) {
// 组件卸载时清理 // 组件卸载时清理
onUnmounted(() => { onUnmounted(() => {
stop() reset()
}) })
return { return {
records, records,
visible,
loading, loading,
error, error,
start, start,
stop, stop,
refresh reset
} }
} }