Files
ai-dimension/client/src/pages/profile/profile.vue
T

625 lines
17 KiB
Vue
Raw 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>
<view class="page-profile page-layout">
<view class="status-bar-placeholder"></view>
<!-- 学习者档案头 -->
<view class="profile-card">
<view class="profile-avatar">
<image
v-if="userStore.userInfo?.avatarUrl"
:src="userStore.userInfo.avatarUrl"
class="avatar-img"
mode="aspectFill"
/>
<view v-else class="avatar-placeholder">
<AppIcon name="planet-white" :size="26" />
</view>
</view>
<view class="profile-main">
<text class="profile-name">{{ userStore.userInfo?.nickName || '探索者' }}</text>
<text class="profile-sub">{{ profileSubText }}</text>
</view>
<view class="streak-chip" v-if="stats && stats.streak > 0">
<AppIcon name="flame" :size="13" />
<text>{{ stats.streak }} 天</text>
</view>
</view>
<!-- 学习数据 -->
<view class="stats-row">
<view class="stat-tile" @click="goProgress">
<text class="stat-num">{{ stats?.totalLit || 0 }}</text>
<text class="stat-label">点亮星球</text>
</view>
<view class="stat-tile">
<text class="stat-num">{{ stats?.streak || 0 }}</text>
<text class="stat-label">连续天数</text>
</view>
<view class="stat-tile" @click="goFavorites">
<text class="stat-num">{{ collectedCount }}</text>
<text class="stat-label">收藏知识</text>
</view>
</view>
<!-- 五星域点亮进度 -->
<view class="dims-progress" v-if="stats">
<view v-for="dim in dimensions" :key="dim.id" class="dim-row" @click="goDim(dim.id)">
<view class="dim-dot" :style="{ background: dim.iconBg }">
<AppIcon :name="dim.icon" :size="13" />
</view>
<text class="dim-name">{{ dim.name }}</text>
<view class="dim-bar">
<view class="dim-bar-fill" :style="{ width: dimPercent(dim.id) + '%', background: dim.color }"></view>
</view>
<text class="dim-frac">{{ stats.litByDim?.[dim.id] || 0 }}/{{ byDim[dim.id] || 0 }}</text>
</view>
</view>
<!-- 订阅卡片 -->
<view class="plan-card" @click="goPayment">
<view class="plan-info">
<text class="plan-title">{{ planTitle }}</text>
<text class="plan-desc">{{ planDesc }}</text>
<view class="plan-progress" v-if="userStore.subInfo">
<view class="progress-track">
<view class="progress-fill" :style="{ width: planPercent + '%' }"></view>
</view>
<text class="progress-text">{{ planExpiryText }}</text>
</view>
</view>
<view class="plan-btn" :class="{ disabled: userStore.isSubscribed }">
<text>{{ planBtnText }}</text>
</view>
</view>
<!-- AI 问答配额 -->
<view class="quota-card" @click="goChat" v-if="userStore.isLoggedIn && quotaInfo">
<view class="quota-head">
<AppIcon name="chat" :size="15" />
<text class="quota-card-title">AI 问答 · 今日</text>
</view>
<view class="quota-bar">
<view class="quota-fill" :style="{ width: quotaPercent + '%' }"></view>
</view>
<view class="quota-foot">
<text class="quota-text">剩余 {{ quotaInfo.remainingQuota }} 次</text>
<text class="quota-share" @click.stop="goShare">分享 +5 次</text>
</view>
</view>
<!-- 菜单列表 -->
<view class="menu-section">
<view class="menu-item" @click="goFavorites">
<view class="m-icon" style="background:var(--dim1-bg)"><AppIcon name="star" :size="16" /></view>
<text class="m-label">我的收藏</text>
<text class="m-badge">{{ collectedCount }}</text>
<AppIcon name="arrow-gray" :size="12" />
</view>
<view class="menu-item" @click="goProgress">
<view class="m-icon" style="background:var(--dim2-bg)"><AppIcon name="rocket" :size="16" /></view>
<text class="m-label">学习进度</text>
<AppIcon name="arrow-gray" :size="12" />
</view>
<view class="menu-item" @click="goAchievements">
<view class="m-icon" style="background:var(--dim4-bg)"><AppIcon name="trophy" :size="16" /></view>
<text class="m-label">成就</text>
<AppIcon name="arrow-gray" :size="12" />
</view>
<view class="menu-item" @click="goLeaderboard">
<view class="m-icon" style="background:var(--dim3-bg)"><AppIcon name="sparkle" :size="16" /></view>
<text class="m-label">排行榜</text>
<AppIcon name="arrow-gray" :size="12" />
</view>
<view class="menu-item" @click="goFeedback">
<view class="m-icon" style="background:var(--dim5-bg)"><AppIcon name="feedback" :size="16" /></view>
<text class="m-label">意见反馈</text>
<AppIcon name="arrow-gray" :size="12" />
</view>
<view class="menu-item" @click="goSettings">
<view class="m-icon" style="background:var(--bg-tertiary)"><AppIcon name="settings-gray" :size="16" /></view>
<text class="m-label">设置</text>
<AppIcon name="arrow-gray" :size="12" />
</view>
</view>
<!-- 登录/退出 -->
<view class="action-row" v-if="!userStore.isLoggedIn">
<view class="action-btn primary" @click="goLogin">
<text>微信一键登录</text>
</view>
<view class="action-btn ghost" @click="goBrowse">
<text>暂不登录,先体验</text>
</view>
</view>
<view class="action-btn logout" v-else @click="handleLogout">
<text>退出登录</text>
</view>
<text class="version">宇之然 AI 维度 v{{ versionText }}</text>
</view>
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { knowledgeApi, aiChatApi } from '@/utils/api'
import { DIMENSIONS } from '@/utils/dimensions'
import AppIcon from '@/components/AppIcon.vue'
const VERSION = '1.2.0'
const SUB_GUEST = '游客 · 点亮进度保存在本机,登录后同步'
const SUB_USER = '已点亮 {n} 颗星球 · 连续 {d} 天'
const PLAN_FREE = '✦ 免费版'
const PLAN_DESC_FREE = '每日 5 次 AI 问答 · 答对检测题点亮星球'
const userStore = useUserStore()
const dimensions = DIMENSIONS
const collectedCount = ref(0)
const stats = ref(null)
const byDim = ref({})
const quotaInfo = ref(null)
const versionText = VERSION
const profileSubText = computed(() => {
if (!userStore.isLoggedIn) return SUB_GUEST
if (!stats.value) return '知识星图探索中'
return `已点亮 ${stats.value.totalLit} 颗星球 · 连续 ${stats.value.streak} 天`
})
const planTitle = computed(() =>
userStore.isSubscribed ? `✦ ${userStore.subscriptionLevel?.toUpperCase() || 'PRO'} 会员` : PLAN_FREE
)
const planDesc = computed(() =>
userStore.isSubscribed ? '全量知识解锁 · AI 问答无限次' : PLAN_DESC_FREE
)
const planBtnText = computed(() => {
if (userStore.isLoggedIn && userStore.isSubscribed) return '已订阅'
if (userStore.isLoggedIn) return '升级 Pro'
return '登录后升级'
})
const planPercent = computed(() => {
const sub = userStore.subInfo
if (!sub?.expiresAt || !sub?.startedAt) return 100
const total = new Date(sub.expiresAt) - new Date(sub.startedAt)
const passed = Date.now() - new Date(sub.startedAt)
if (total <= 0) return 100
return Math.min(100, Math.max(0, Math.round((passed / total) * 100)))
})
const planExpiryText = computed(() => {
if (!userStore.subInfo?.expiresAt) return ''
const diffDays = Math.ceil((new Date(userStore.subInfo.expiresAt) - Date.now()) / 86400000)
return diffDays <= 0 ? '已过期' : `${diffDays} 天后到期`
})
const quotaPercent = computed(() => {
if (!quotaInfo.value) return 0
const total = (quotaInfo.value.freeQuota || 5) + (quotaInfo.value.sharedQuota || 0)
if (!total) return 0
return Math.round((quotaInfo.value.remainingQuota / total) * 100)
})
onMounted(() => {
loadStats()
if (userStore.isLoggedIn) {
userStore.fetchMe()
loadCollected()
loadQuota()
}
})
async function loadStats() {
try {
const data = await knowledgeApi.stats(userStore.isLoggedIn ? userStore.token : '')
stats.value = data.user
byDim.value = data.byDim || {}
} catch (e) {
// 游客或失败:不显示进度区
}
}
async function loadCollected() {
if (!userStore.token) return
try {
const list = await knowledgeApi.collected(userStore.token)
collectedCount.value = list.length
} catch (e) {
collectedCount.value = 0
}
}
async function loadQuota() {
try {
quotaInfo.value = await aiChatApi.quota(userStore.token)
} catch (e) {}
}
function dimPercent(dimId) {
const total = byDim.value[dimId] || 0
if (!total) return 0
return Math.round(((stats.value?.litByDim?.[dimId] || 0) / total) * 100)
}
function goDim(id) {
uni.navigateTo({ url: `/pages/dimension/dimension?id=${id}` })
}
async function handleLogout() {
const res = await uni.showModal({ title: '退出登录', content: '确定退出当前账号?' })
if (res.confirm) {
userStore.logout()
stats.value = null
uni.showToast({ title: '已退出', icon: 'none' })
}
}
function goLogin() { uni.navigateTo({ url: '/pages/login/login' }) }
function goBrowse() { uni.switchTab({ url: '/pages/index/index' }) }
function goChat() { uni.switchTab({ url: '/pages/chat/chat' }) }
function goFavorites() { uni.navigateTo({ url: '/pages/favorites/favorites' }) }
function goProgress() { uni.navigateTo({ url: '/pages/progress/progress' }) }
function goAchievements() { uni.navigateTo({ url: '/pages/achievements/achievements' }) }
function goLeaderboard() { uni.navigateTo({ url: '/pages/leaderboard/leaderboard' }) }
function goFeedback() { uni.navigateTo({ url: '/pages/feedback/feedback' }) }
function goSettings() { uni.navigateTo({ url: '/pages/settings/settings' }) }
function goPayment() {
if (!userStore.isLoggedIn) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
uni.navigateTo({ url: '/pages/payment/payment' })
}
function goShare() {
if (!userStore.token) return
uni.showShareMenu({ withShareTicket: true })
aiChatApi.shareGain(userStore.token).then(() => {
uni.showToast({ title: '已记录分享 +5 次', icon: 'success' })
loadQuota()
}).catch(() => {})
}
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: '我在 AI 维度点亮了知识星球,来一起探索',
path: '/pages/index/index'
}))
onShareTimeline(() => ({
title: 'AI 维度 · 点亮你的知识星球'
}))
// #endif
</script>
<style lang="scss" scoped>
.page-profile {
min-height: 100vh;
background: var(--bg-primary);
}
.status-bar-placeholder { height: 44px; }
.profile-card {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 0 16px;
}
.profile-avatar {
width: 62px; height: 62px;
border-radius: 50%;
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
.avatar-img { width: 100%; height: 100%; }
.avatar-placeholder {
width: 100%; height: 100%;
border-radius: 50%;
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
}
}
.profile-main { flex: 1; min-width: 0; }
.profile-name {
display: block;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.profile-sub {
display: block;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
}
.streak-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: var(--radius-full);
background: rgba(229, 28, 28, 0.08);
border: 1px solid rgba(229, 28, 28, 0.15);
font-size: 12px;
font-weight: 700;
color: var(--dim5-color);
flex-shrink: 0;
}
/* 学习数据 */
.stats-row {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.stat-tile {
flex: 1;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 14px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
box-shadow: var(--card-shadow, none);
.stat-num {
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
}
.stat-label {
font-size: 11px;
color: var(--text-tertiary);
}
}
/* 五星域进度 */
.dims-progress {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 6px 16px;
margin-bottom: 12px;
box-shadow: var(--card-shadow, none);
}
.dim-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
}
.dim-dot {
width: 26px; height: 26px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dim-name {
font-size: 12px;
color: var(--text-secondary);
width: 56px;
flex-shrink: 0;
}
.dim-bar {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--bg-tertiary);
overflow: hidden;
}
.dim-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.dim-frac {
font-size: 11px;
color: var(--text-muted);
width: 40px;
text-align: right;
flex-shrink: 0;
}
/* 订阅 */
.plan-card {
display: flex;
align-items: center;
gap: 12px;
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 12px;
background: linear-gradient(135deg, #1a1a3a, #2d2b5e);
border: 1px solid rgba(123, 135, 253, 0.3);
}
.plan-info { flex: 1; min-width: 0; }
.plan-title {
display: block;
font-size: 16px;
font-weight: 700;
color: #fff;
margin-bottom: 4px;
}
.plan-desc {
display: block;
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
line-height: 1.5;
}
.plan-progress {
margin-top: 8px;
.progress-track {
height: 3px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #7b87fd, #a2b2ff);
border-radius: 2px;
}
.progress-text {
font-size: 10px;
color: rgba(255, 255, 255, 0.4);
margin-top: 4px;
display: block;
}
}
.plan-btn {
padding: 9px 16px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-brand), #8b5cf6);
color: #fff;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
&.disabled { opacity: 0.5; }
}
/* 配额 */
.quota-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 12px;
box-shadow: var(--card-shadow, none);
}
.quota-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
.quota-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
}
.quota-bar {
height: 6px;
border-radius: 3px;
background: var(--bg-tertiary);
overflow: hidden;
margin-bottom: 8px;
}
.quota-fill {
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, var(--color-brand), var(--color-brand-light));
transition: width 0.3s ease;
}
.quota-foot {
display: flex;
justify-content: space-between;
align-items: center;
.quota-text {
font-size: 12px;
color: var(--text-secondary);
}
.quota-share {
font-size: 12px;
font-weight: 600;
color: var(--color-brand);
}
}
/* 菜单 */
.menu-section {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 4px 16px;
margin-bottom: 16px;
box-shadow: var(--card-shadow, none);
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 0;
border-bottom: 1px solid var(--glass-border);
&:last-child { border-bottom: none; }
}
.m-icon {
width: 32px; height: 32px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.m-label {
flex: 1;
font-size: 14px;
color: var(--text-primary);
}
.m-badge {
font-size: 11px;
color: var(--text-muted);
}
/* 操作 */
.action-row {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.action-btn {
flex: 1;
height: 46px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
&.primary {
background: linear-gradient(135deg, var(--color-brand), var(--color-brand-light));
color: #fff;
}
&.ghost {
background: var(--glass-bg);
border: 1.5px solid var(--glass-border);
color: var(--text-secondary);
}
&.logout {
flex: none;
width: 100%;
background: var(--glass-bg);
border: 1.5px solid var(--glass-border);
color: var(--dim5-color);
margin-bottom: 12px;
}
}
.version {
display: block;
text-align: center;
font-size: 11px;
color: var(--text-muted);
padding: 8px 0 20px;
}
</style>