Files
wenzi/frontend/components/MosquitoLeaderboard.vue
Your Name 91a0b77f7a test(cache): 修复CacheConfigTest边界值测试
- 修改 shouldVerifyCacheManager_withMaximumIntegerTtl 为 shouldVerifyCacheManager_withMaximumAllowedTtl
- 使用正确的最大TTL值(10080分钟,7天)而不是 Integer.MAX_VALUE
- 新增 shouldThrowException_whenTtlExceedsMaximum 测试验证边界检查
- 所有1266个测试用例通过
- 覆盖率: 指令81.89%, 行88.48%, 分支51.55%

docs: 添加项目状态报告
- 生成 PROJECT_STATUS_REPORT.md 详细记录项目当前状态
- 包含质量指标、已完成功能、待办事项和技术债务
2026-03-02 13:31:54 +08:00

415 lines
12 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="mosquito-leaderboard">
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="loading-skeleton">
<div v-for="i in 5" :key="i" class="skeleton-item"></div>
</div>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<div class="error-content">
<svg class="w-8 h-8 text-red-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-gray-900 font-medium">加载失败</p>
<p class="text-gray-600 text-sm mt-1">{{ error.message }}</p>
<button
class="retry-button mt-3 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm"
@click="retryLoad"
>
重试
</button>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="entries.length === 0" class="empty-state">
<div class="empty-content">
<svg class="w-12 h-12 text-gray-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<p class="text-gray-900 font-medium">暂无排行榜数据</p>
<p class="text-gray-600 text-sm mt-1">邀请好友加入您将出现在排行榜中</p>
</div>
</div>
<!-- 排行榜内容 -->
<div v-else class="leaderboard-content">
<!-- Top N 显示 -->
<div v-if="topN" class="top-section">
<div
v-for="(entry, index) in topEntries"
:key="entry.userId"
class="leaderboard-item top-item"
:class="`top-${index + 1}`"
>
<div class="rank">
<div class="rank-number">{{ index + 1 }}</div>
<div v-if="index < 3" class="rank-badge">
<svg v-if="index === 0" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
<svg v-else-if="index === 1" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"></path>
</svg>
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z" clip-rule="evenodd"></path>
</svg>
</div>
</div>
<div class="user-info">
<div class="user-name">{{ entry.userName }}</div>
<div class="user-meta">{{ entry.inviteCount || entry.score }} 个好友</div>
</div>
<div class="score">
<div class="score-number">{{ entry.score }}</div>
</div>
</div>
</div>
<!-- 完整排行榜 -->
<div class="full-list">
<div
v-for="(entry, index) in displayEntries"
:key="entry.userId"
class="leaderboard-item"
:class="{ 'current-user': isCurrentUser(entry) }"
>
<div class="rank">
<div class="rank-number">{{ startIndex + index + 1 }}</div>
</div>
<div class="user-info">
<div class="user-name" :class="{ 'font-bold': isCurrentUser(entry) }">
{{ entry.userName }}
<span v-if="isCurrentUser(entry)" class="user-badge"></span>
</div>
<div class="user-meta">{{ entry.inviteCount || entry.score }} 个好友</div>
</div>
<div class="score">
<div class="score-number">{{ entry.score }}</div>
</div>
</div>
</div>
<!-- 分页控制 -->
<div v-if="hasPagination" class="pagination">
<button
class="pagination-button"
:disabled="page === 0"
@click="prevPage"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<div class="pagination-info">
{{ page + 1 }} {{ totalPages }}
</div>
<button
class="pagination-button"
:disabled="page >= totalPages - 1"
@click="nextPage"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
<!-- 导出按钮 -->
<div class="export-section">
<button
class="export-button"
@click="exportLeaderboard"
:disabled="loading"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
导出CSV
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useMosquito } from '../index'
interface Props {
activityId: number
page?: number
size?: number
topN?: number
currentUserId?: number
}
const props = withDefaults(defineProps<Props>(), {
page: 0,
size: 20,
topN: 10,
currentUserId: undefined
})
const emit = defineEmits<{
loaded: [entries: any[]]
error: [error: Error]
}>()
const { getLeaderboard, exportLeaderboardCsv } = useMosquito()
const loading = ref(false)
const error = ref<Error | null>(null)
const entries = ref<any[]>([])
const pagination = ref({
page: props.page,
size: props.size,
total: 0,
totalPages: 0
})
// 计算属性
const topEntries = computed(() => {
return props.topN ? entries.value.slice(0, props.topN) : entries.value
})
const displayEntries = computed(() => {
if (props.topN) {
return entries.value.slice(props.topN)
}
return entries.value
})
const startIndex = computed(() => {
return pagination.value.page * pagination.value.size
})
const totalPages = computed(() => {
return pagination.value.totalPages || Math.ceil(pagination.value.total / pagination.value.size)
})
const hasPagination = computed(() => {
return totalPages.value > 1
})
// 检查是否为当前用户
const isCurrentUser = (entry: any) => {
return props.currentUserId && entry.userId === props.currentUserId
}
// 加载排行榜数据
const loadLeaderboard = async () => {
if (loading.value) return
loading.value = true
error.value = null
try {
const result = await getLeaderboard(
props.activityId,
props.page,
props.size
)
entries.value = result?.data || []
const meta = result?.meta?.pagination
pagination.value = {
page: meta?.page ?? props.page,
size: meta?.size ?? props.size,
total: meta?.total ?? entries.value.length,
totalPages: meta?.totalPages ?? Math.ceil((meta?.total ?? entries.value.length) / props.size)
}
emit('loaded', entries.value)
} catch (err) {
console.error('加载排行榜失败:', err)
error.value = err as Error
emit('error', error.value)
} finally {
loading.value = false
}
}
// 重试加载
const retryLoad = () => {
loadLeaderboard()
}
// 分页控制
const prevPage = () => {
if (pagination.value.page > 0) {
pagination.value.page--
loadLeaderboard()
}
}
const nextPage = () => {
if (pagination.value.page < totalPages.value - 1) {
pagination.value.page++
loadLeaderboard()
}
}
// 导出排行榜
const exportLeaderboard = async () => {
try {
const csvData = await exportLeaderboardCsv(props.activityId)
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
if (link.download !== undefined) {
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `leaderboard-${props.activityId}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
} catch (err) {
console.error('导出排行榜失败:', err)
error.value = err as Error
}
}
// 组件挂载时加载数据
loadLeaderboard()
// 监听参数变化
watch(() => [props.activityId, props.page, props.size, props.topN], () => {
loadLeaderboard()
}, { deep: true })
</script>
<style scoped>
.mosquito-leaderboard {
@apply rounded-2xl border border-mosquito-line bg-mosquito-surface shadow-soft;
}
.loading-state {
@apply p-6;
}
.loading-skeleton {
@apply space-y-3;
}
.skeleton-item {
@apply h-12 bg-gray-200 rounded animate-pulse;
}
.error-state,
.empty-state {
@apply p-8 text-center;
}
.error-content,
.empty-content {
@apply inline-block;
}
.retry-button {
@apply px-4 py-2 bg-mosquito-accent text-white rounded-md hover:bg-mosquito-accent/90 transition-colors text-sm;
}
.leaderboard-content {
@apply divide-y divide-gray-200;
}
.top-section {
@apply p-4 bg-gradient-to-r from-mosquito-accent/10 to-mosquito-accent2/10;
}
.leaderboard-item {
@apply flex items-center justify-between p-4 hover:bg-gray-50 transition-colors;
}
.leaderboard-item.current-user {
@apply bg-mosquito-accent/10 border-l-4 border-mosquito-accent;
}
.leaderboard-item.top-item {
@apply p-6 border-b border-gray-200;
}
.leaderboard-item.top-item.top-1 {
@apply bg-gradient-to-r from-mosquito-accent2/20 to-mosquito-accent/10 border-b-2 border-mosquito-accent/40;
}
.leaderboard-item.top-item.top-2 {
@apply bg-gradient-to-r from-mosquito-accent2/15 to-mosquito-accent2/5 border-b-2 border-mosquito-accent2/40;
}
.leaderboard-item.top-item.top-3 {
@apply bg-gradient-to-r from-mosquito-accent/15 to-mosquito-accent/5 border-b-2 border-mosquito-accent/40;
}
.rank {
@apply flex items-center justify-center w-12 h-12 rounded-full bg-mosquito-surface shadow-sm;
}
.rank-number {
@apply font-bold text-lg text-mosquito-ink;
}
.rank-badge {
@absolute -top-1 -right-1;
}
.user-info {
@apply flex-1 ml-4;
}
.user-name {
@apply font-medium text-mosquito-ink;
}
.user-badge {
@apply ml-2 px-2 py-1 text-xs bg-mosquito-accent/15 text-mosquito-brand rounded-full;
}
.user-meta {
@apply text-sm text-mosquito-ink/60;
}
.score {
@apply text-right;
}
.score-number {
@apply font-bold text-lg text-mosquito-ink;
}
.pagination {
@apply flex items-center justify-between p-4 bg-mosquito-bg border-t border-mosquito-line;
}
.pagination-button {
@apply p-2 text-mosquito-ink/60 hover:text-mosquito-ink hover:bg-mosquito-accent/10 rounded-md transition-colors;
}
.pagination-button:disabled {
@apply text-mosquito-ink/30 cursor-not-allowed hover:text-mosquito-ink/30 hover:bg-transparent;
}
.pagination-info {
@apply text-sm text-mosquito-ink/60;
}
.export-section {
@apply p-4 bg-mosquito-bg border-t border-mosquito-line;
}
.export-button {
@apply inline-flex items-center px-4 py-2 bg-mosquito-accent text-white rounded-md hover:bg-mosquito-accent/90 transition-colors text-sm;
}
.export-button:disabled {
@apply bg-mosquito-ink/30 cursor-not-allowed;
}
</style>