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

735 lines
20 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" @click="editNickname">
<text class="profile-name">{{ userStore.userInfo?.nickName || '探索者' }}<text v-if="userStore.isLoggedIn" class="nick-edit"> ✏️</text></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="balance-card" v-if="userStore.isLoggedIn">
<view class="balance-left">
<view class="coin-row">
<text class="coin-icon">✨</text>
<text class="coin-num">{{ vpayBalance }}</text>
<text class="coin-name">星尘</text>
</view>
<text class="free-left">今日免费问答剩余 {{ freeRemaining }} 次</text>
</view>
<view class="balance-actions">
<view class="recharge-go" @click="goRecharge"><text>充值</text></view>
<view class="recharge-go outline" @click="goLedger"><text>明细</text></view>
</view>
</view>
<!-- 未登录:引导登录 -->
<view class="balance-card" v-if="!userStore.isLoggedIn" @click="goLogin">
<view class="balance-left">
<text class="login-guide-title">登录后开始探索</text>
<text class="login-guide-sub">每天 5 次免费 AI 问答 · 点亮知识星球</text>
</view>
<view class="recharge-go"><text>去登录</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 v-if="userStore.isLoggedIn" class="action-btn logout" @click="handleLogout">
<text>退出登录</text>
</view>
<text class="version">宇之然 AI 维度 v{{ versionText }}</text>
<!-- 页脚:公司信息 / 联系客服 -->
<view class="foot">
<image class="foot-qr" src="/static/yzr/kefu.png" mode="widthFix" show-menu-by-longpress @click="previewKefu" />
<text class="foot-tip">微信客服 · 长按识别二维码,或点下方按钮直接联系</text>
<!-- #ifdef MP-WEIXIN -->
<button class="foot-contact" open-type="contact" session-from="profile-footer">💬 在线联系客服</button>
<!-- #endif -->
<text class="foot-line">联系邮箱:contact@yuzhiran.com</text>
<text class="foot-line">北京宇之然科技中心 · © 2026</text>
<!-- #ifdef H5 -->
<view class="foot-line"><a class="foot-link" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">京ICP备2026007249号-2</a> · <a class="foot-link" href="https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622" target="_blank" rel="noopener">京公网安备11011502039622号</a></view>
<!-- #endif -->
</view>
</view>
</template>
<script setup>
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { knowledgeApi, aiChatApi, request } from '@/utils/api'
import { DIMENSIONS } from '@/utils/dimensions'
import AppIcon from '@/components/AppIcon.vue'
import { APP_VERSION } from '@/utils/version'
const SUB_GUEST = '游客 · 点亮进度保存在本机,登录后同步'
const SUB_USER = '已点亮 {n} 颗星球 · 连续 {d} 天'
const PLAN_DESC_FREE = '每日 5 次 AI 问答 · 答对检测题点亮星球'
const userStore = useUserStore()
const dimensions = DIMENSIONS
const collectedCount = ref(0)
const vpayBalance = ref(0)
const freeRemaining = ref(5)
const stats = ref(null)
const byDim = ref({})
const quotaInfo = ref(null)
const versionText = APP_VERSION
const profileSubText = computed(() => {
if (!userStore.isLoggedIn) return SUB_GUEST
if (!stats.value) return '知识星图探索中'
return `已点亮 ${stats.value.totalLit} 颗星球 · 连续 ${stats.value.streak} 天`
})
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()
}
})
// 每次进入/返回本页都刷新余额与额度(充值页支付成功后返回时余额同步)
onShow(() => {
if (userStore.isLoggedIn) {
loadVpayInfo()
loadQuota()
}
})
async function loadVpayInfo() {
try {
const data = await request('GET', '/api/vpay/info', null, userStore.token)
vpayBalance.value = data.balance || 0
if (userStore.quota) freeRemaining.value = userStore.quota.remainingQuota ?? 5
} catch (e) {
// 未开通或未登录
}
}
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 editNickname() {
if (!userStore.isLoggedIn) {
uni.showModal({
title: '请先登录',
content: '登录后即可修改昵称',
confirmText: '去登录',
showCancel: false,
success: () => uni.navigateTo({ url: '/pages/login/login' }),
})
return
}
uni.showModal({
title: '修改昵称',
editable: true,
placeholderText: '请输入新昵称(最多20字)',
success: async (m) => {
if (!m.confirm) return
const nickName = String(m.content || '').trim()
if (!nickName) return uni.showToast({ title: '昵称不能为空', icon: 'none' })
try {
await request('PUT', '/api/auth/nickname', { nickName }, userStore.token)
await userStore.fetchMe()
uni.showToast({ title: '昵称已更新', icon: 'success' })
} catch (e) {
uni.showToast({ title: e.message || '修改失败', icon: 'none' })
}
},
})
}
function previewKefu() {
uni.previewImage({ urls: ['/static/yzr/kefu.png'] })
}
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 goHome() { uni.switchTab({ url: '/pages/index/index' }) }
function goLedger() { uni.navigateTo({ url: '/pages/vpay/ledger' }) }
function goRecharge() { uni.navigateTo({ url: '/pages/vpay/recharge' }) }
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;
}
.nick-edit { font-size: 14px; opacity: 0.7; }
.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>
<style scoped>
.vpay-entry {
display: flex; align-items: center; gap: 12px;
background: linear-gradient(135deg, rgba(91,95,248,.1), rgba(139,92,246,.06));
border: 1.5px solid var(--glass-hover-border);
border-radius: var(--radius-lg); padding: 14px 16px; margin-bottom: 12px;
}
.ve-icon { font-size: 22px; }
.ve-info { flex: 1; }
.ve-title { font-size: 14px; font-weight: 700; color: var(--text-primary); display: block; }
.ve-desc { font-size: 11px; color: var(--text-tertiary); }
.ve-arrow { font-size: 14px; color: var(--text-muted); }
</style>
<style scoped>
.balance-card {
display: flex; align-items: center; gap: 14px;
background: linear-gradient(135deg, #5b5ff8, #8b5cf6);
border-radius: var(--radius-lg);
padding: 18px 20px;
margin-bottom: 12px;
}
.balance-left { flex: 1; }
.coin-row { display: flex; align-items: baseline; gap: 6px; }
.coin-icon { font-size: 20px; }
.coin-num { font-size: 32px; font-weight: 800; color: #fff; }
.coin-name { font-size: 13px; color: rgba(255,255,255,.7); }
.free-left { display: block; font-size: 11px; color: rgba(255,255,255,.55); margin-top: 4px; }
.recharge-go {
padding: 10px 22px;
background: #fff;
border-radius: var(--radius-full);
color: #5b5ff8;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
</style>
<style scoped>
.stats-hint { text-align: center; padding: 4px 0 8px; }
.stats-hint text { font-size: 11px; color: var(--text-muted); }
</style>
<style scoped>
.foot {
margin-top: 8px;
padding: 24px 0 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.foot-qr { width: 120px; border-radius: 8px; background: #fff; }
.foot-tip { font-size: 11px; color: var(--text-muted, #b6bac9); }
.foot-contact {
margin-top: 4px;
font-size: 13px;
color: #6366f1;
background: var(--color-brand-bg, #f0f0ff);
border-radius: 999px;
padding: 0 18px;
height: 32px;
line-height: 32px;
}
.foot-contact::after { border: none; }
.foot-line { font-size: 11px; color: var(--text-muted, #b6bac9); }
.foot-link { font-size: 11px; color: var(--text-tertiary, #9aa0b4); text-decoration: none; }
.balance-actions { display: flex; gap: 8px; }
.recharge-go.outline { background: transparent; border: 1.5px solid rgba(255,255,255,.6); color: #fff; }
</style>