chore: sync local latest state and repository cleanup
This commit is contained in:
@@ -27,6 +27,14 @@
|
||||
<Icons name="trophy" class="mos-nav-icon" />
|
||||
<span>排行</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/profile"
|
||||
class="mos-nav-item"
|
||||
:class="{ active: route.path === '/profile' }"
|
||||
>
|
||||
<Icons name="user" class="mos-nav-icon" />
|
||||
<span>我的</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
<svg v-else-if="name === 'zap'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="name === 'user'" :class="iconClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createApp, type Plugin } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './styles/index.css'
|
||||
import MosquitoEnhancedPlugin from '../../index'
|
||||
import MosquitoEnhancedPlugin, { type MosquitoConfig } from '../../index'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(MosquitoEnhancedPlugin, {
|
||||
app.use(MosquitoEnhancedPlugin as Plugin<MosquitoConfig>, {
|
||||
baseUrl: import.meta.env.VITE_MOSQUITO_API_BASE_URL ?? '',
|
||||
apiKey: import.meta.env.VITE_MOSQUITO_API_KEY ?? '',
|
||||
userToken: import.meta.env.VITE_MOSQUITO_USER_TOKEN ?? ''
|
||||
})
|
||||
} as MosquitoConfig)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import ShareView from '../views/ShareView.vue'
|
||||
import LeaderboardView from '../views/LeaderboardView.vue'
|
||||
import ProfileView from '../views/ProfileView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -20,6 +21,11 @@ const router = createRouter({
|
||||
path: '/rank',
|
||||
name: 'rank',
|
||||
component: LeaderboardView
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: ProfileView
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
241
frontend/h5/src/views/ProfileView.vue
Normal file
241
frontend/h5/src/views/ProfileView.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<section class="mx-auto max-w-md px-4 pb-28 pt-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<header class="mos-hero relative overflow-hidden p-6">
|
||||
<div class="relative z-10">
|
||||
<h1 class="mos-title mt-4 text-2xl font-bold text-white">个人中心</h1>
|
||||
<p class="mt-2 text-sm text-white/80">查看邀请记录和奖励明细</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 border-b border-mosquito-line pb-2">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="px-4 py-2 text-sm font-semibold rounded-t-lg transition"
|
||||
:class="activeTab === tab.key
|
||||
? 'bg-mosquito-accent/10 text-mosquito-brand border-b-2 border-mosquito-brand'
|
||||
: 'text-mosquito-ink/70 hover:bg-mosquito-bg'"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="py-8 text-center text-mosquito-muted">
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<!-- 邀请记录 -->
|
||||
<div v-else-if="activeTab === 'invites'" class="space-y-4">
|
||||
<div v-if="invitedFriends.length === 0" class="mos-card p-6 text-center">
|
||||
<div class="mos-empty-icon">
|
||||
<Icons name="users" class="w-8 h-8" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-mosquito-ink mt-2">暂无邀请记录</h3>
|
||||
<p class="text-sm text-mosquito-muted mt-1">分享邀请链接后可查看</p>
|
||||
<RouterLink to="/share" class="mos-btn mos-btn-primary mt-4">
|
||||
去分享
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="friend in invitedFriends" :key="friend.nickname" class="mos-card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-semibold text-mosquito-ink">{{ friend.nickname }}</div>
|
||||
<div class="text-xs text-mosquito-muted">{{ friend.maskedPhone || friend.phone || '-' }}</div>
|
||||
</div>
|
||||
<span :class="getStatusClass(friend.status)" class="px-2 py-1 rounded-full text-xs font-semibold">
|
||||
{{ getStatusLabel(friend.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 奖励明细 -->
|
||||
<div v-else-if="activeTab === 'rewards'" class="space-y-4">
|
||||
<div v-if="rewards.length === 0" class="mos-card p-6 text-center">
|
||||
<div class="mos-empty-icon">
|
||||
<Icons name="gift" class="w-8 h-8" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-mosquito-ink mt-2">暂无奖励记录</h3>
|
||||
<p class="text-sm text-mosquito-muted mt-1">邀请好友后可获得奖励</p>
|
||||
<RouterLink to="/share" class="mos-btn mos-btn-primary mt-4">
|
||||
去分享
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="reward in rewards" :key="reward.createdAt" class="mos-card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-semibold text-mosquito-ink">{{ getRewardTypeLabel(reward.type) }}</div>
|
||||
<div class="text-xs text-mosquito-muted">{{ formatDate(reward.createdAt) }}</div>
|
||||
</div>
|
||||
<div class="text-lg font-bold text-mosquito-brand">+{{ reward.points }} 积分</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getUserIdFromToken, parseUserId } from '../../../shared/auth'
|
||||
import Icons from '../components/Icons.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const apiKey = import.meta.env.VITE_MOSQUITO_API_KEY
|
||||
const userToken = import.meta.env.VITE_MOSQUITO_USER_TOKEN
|
||||
|
||||
const routeUserId = computed(() => parseUserId(route.query.userId ?? route.params.userId))
|
||||
const userId = computed(() => getUserIdFromToken(userToken) ?? routeUserId.value ?? 0)
|
||||
const hasAuth = computed(() => Boolean(apiKey && userToken && userId.value))
|
||||
|
||||
const baseUrl = import.meta.env.VITE_MOSQUITO_API_BASE_URL || ''
|
||||
|
||||
const activeTab = ref('invites')
|
||||
const loading = ref(false)
|
||||
const invitedFriends = ref<any[]>([])
|
||||
const rewards = ref<any[]>([])
|
||||
|
||||
const tabs = [
|
||||
{ key: 'invites', label: '邀请记录' },
|
||||
{ key: 'rewards', label: '奖励明细' }
|
||||
]
|
||||
|
||||
const getAuthHeaders = () => ({
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': apiKey || '',
|
||||
...(userToken ? { Authorization: `Bearer ${userToken}` } : {})
|
||||
})
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PENDING': 'bg-yellow-100 text-yellow-700',
|
||||
'ACCEPTED': 'bg-green-100 text-green-700',
|
||||
'REJECTED': 'bg-red-100 text-red-700',
|
||||
'EXPIRED': 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
return map[status] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PENDING': '待接受',
|
||||
'ACCEPTED': '已接受',
|
||||
'REJECTED': '已拒绝',
|
||||
'EXPIRED': '已过期'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const getRewardTypeLabel = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'INVITE': '邀请奖励',
|
||||
'SHARE': '分享奖励',
|
||||
'REGISTER': '注册奖励',
|
||||
'CONVERSION': '转化奖励',
|
||||
'BONUS': '额外奖励'
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString('zh-CN')
|
||||
|
||||
// 获取活动列表以获取当前活动ID
|
||||
const getActivities = async () => {
|
||||
const response = await fetch(`${baseUrl}/api/v1/activities`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
const payload = await response.json()
|
||||
return payload?.data || []
|
||||
}
|
||||
|
||||
const loadInvitedFriends = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const activities = await getActivities()
|
||||
const activityId = activities[0]?.id
|
||||
if (!activityId) {
|
||||
invitedFriends.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
activityId: String(activityId),
|
||||
userId: String(userId.value),
|
||||
page: '0',
|
||||
size: '20'
|
||||
})
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/v1/me/invited-friends?${params}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
const payload = await response.json()
|
||||
invitedFriends.value = payload?.data || []
|
||||
} catch (error) {
|
||||
console.error('加载邀请记录失败:', error)
|
||||
invitedFriends.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadRewards = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const activities = await getActivities()
|
||||
const activityId = activities[0]?.id
|
||||
if (!activityId) {
|
||||
rewards.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
activityId: String(activityId),
|
||||
userId: String(userId.value),
|
||||
page: '0',
|
||||
size: '20'
|
||||
})
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/v1/me/rewards?${params}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
const payload = await response.json()
|
||||
rewards.value = payload?.data || []
|
||||
} catch (error) {
|
||||
console.error('加载奖励记录失败:', error)
|
||||
rewards.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
if (!hasAuth.value) {
|
||||
return
|
||||
}
|
||||
if (activeTab.value === 'invites') {
|
||||
await loadInvitedFriends()
|
||||
} else {
|
||||
await loadRewards()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (hasAuth.value) {
|
||||
loadData()
|
||||
}
|
||||
})
|
||||
|
||||
// 切换 tab 时加载数据
|
||||
import { watch } from 'vue'
|
||||
watch(activeTab, () => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -34,10 +34,10 @@
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<template v-if="hasAuth">
|
||||
<MosquitoShareButton :activity-id="activityId" :user-id="userId" />
|
||||
<button class="mos-btn mos-btn-accent !py-2 !px-4">
|
||||
<MosquitoShareButton :activity-id="activityId" :user-id="userId" @copied="handleCopied" @error="handleCopyError" />
|
||||
<button class="mos-btn mos-btn-accent !py-2 !px-4" @click="handleCopyLink">
|
||||
<Icons name="copy" class="w-4 h-4" />
|
||||
复制链接
|
||||
{{ copyButtonText }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -125,7 +125,7 @@ type ActivitySummary = {
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const { getActivities } = useMosquito()
|
||||
const { getActivities, getShareUrl } = useMosquito()
|
||||
const apiKey = import.meta.env.VITE_MOSQUITO_API_KEY
|
||||
const userToken = import.meta.env.VITE_MOSQUITO_USER_TOKEN
|
||||
const routeUserId = computed(() => parseUserId(route.query.userId ?? route.params.userId))
|
||||
@@ -134,6 +134,8 @@ const activityId = ref(1)
|
||||
const activityLabel = computed(() => `活动 #${activityId.value}`)
|
||||
const loadError = ref('')
|
||||
const hasAuth = computed(() => Boolean(apiKey && userToken && userId.value))
|
||||
const copyButtonText = ref('复制链接')
|
||||
const copyFeedback = ref<'success' | 'error' | null>(null)
|
||||
|
||||
const guideSteps = [
|
||||
'点击"分享给好友"生成专属链接',
|
||||
@@ -141,6 +143,65 @@ const guideSteps = [
|
||||
'回到首页查看最新排行和奖励进度'
|
||||
]
|
||||
|
||||
// 复制链接处理
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
const shareResponse = await getShareUrl(activityId.value, userId.value, 'default')
|
||||
let urlToCopy: string
|
||||
|
||||
if (shareResponse && typeof shareResponse === 'object') {
|
||||
if (shareResponse.originalUrl) {
|
||||
urlToCopy = shareResponse.originalUrl
|
||||
} else if (shareResponse.path) {
|
||||
urlToCopy = shareResponse.path.startsWith('http')
|
||||
? shareResponse.path
|
||||
: `${window.location.origin}${shareResponse.path}`
|
||||
} else {
|
||||
throw new Error('分享链接响应格式异常')
|
||||
}
|
||||
} else {
|
||||
throw new Error('分享链接响应格式异常')
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
try {
|
||||
await navigator.clipboard.writeText(urlToCopy)
|
||||
showCopyFeedback('success')
|
||||
} catch {
|
||||
// 回退到传统方法
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = urlToCopy
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
showCopyFeedback('success')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制链接失败:', error)
|
||||
showCopyFeedback('error')
|
||||
}
|
||||
}
|
||||
|
||||
// 显示复制反馈
|
||||
const showCopyFeedback = (type: 'success' | 'error') => {
|
||||
copyFeedback.value = type
|
||||
copyButtonText.value = type === 'success' ? '已复制!' : '复制失败'
|
||||
setTimeout(() => {
|
||||
copyButtonText.value = '复制链接'
|
||||
copyFeedback.value = null
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// MosquitoShareButton 回调处理
|
||||
const handleCopied = () => {
|
||||
showCopyFeedback('success')
|
||||
}
|
||||
|
||||
const handleCopyError = () => {
|
||||
showCopyFeedback('error')
|
||||
}
|
||||
|
||||
const loadActivity = async () => {
|
||||
if (!hasAuth.value) {
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user