Files
smt/src/pages/profile/index.vue
T
2026-04-16 11:45:57 +08:00

610 lines
13 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">
<view class="page-bg"></view>
<view class="nav-placeholder" :style="{ height: navBarHeight + 'px' }"></view>
<view class="section">
<text class="section-label">当前账号</text>
<view class="user-section card">
<!-- #ifdef MP-WEIXIN -->
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<image class="avatar" :src="userAvatar" mode="aspectFill"></image>
</button>
<!-- #endif -->
<!-- #ifdef H5 -->
<image class="avatar" :src="userAvatar" mode="aspectFill" @tap="onChooseAvatarH5"></image>
<!-- #endif -->
<view class="user-copy">
<!-- #ifdef MP-WEIXIN -->
<input
type="nickname"
class="nickname-input"
:value="userStore.user?.nickname || ''"
placeholder="点击设置昵称"
@blur="onNicknameChange"
/>
<!-- #endif -->
<!-- #ifdef H5 -->
<text class="user-name">{{ userName }}</text>
<!-- #endif -->
<text class="user-desc">点击头像或昵称可修改</text>
<view class="user-meta">
<text class="user-pill">{{ modeText }}</text>
<text class="user-pill user-pill-muted">{{ shareToken ? '分享已启用' : '分享未生成' }}</text>
</view>
</view>
</view>
</view>
<view class="section">
<text class="section-label">常用操作</text>
<view class="menu-list card">
<view class="menu-item">
<view class="menu-icon menu-icon-accent">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">分享戒烟记录</text>
<text class="menu-desc">{{ shareDesc }}</text>
<view class="menu-actions">
<text class="menu-action" @tap.stop="previewSharePage">预览分享页</text>
<text class="menu-action-sep">·</text>
<text class="menu-action" @tap.stop="handleRefreshShare">刷新</text>
</view>
</view>
<button class="share-btn" open-type="share" :disabled="shareLoading || !shareToken">
{{ shareLoading ? '生成中' : '去分享' }}
</button>
</view>
<view class="menu-divider"></view>
<view class="menu-item" @tap="goSupervisor">
<view class="menu-icon menu-icon-accent">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">监督人</text>
<text class="menu-desc">邀请朋友监督你或查看你监督的人</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-divider"></view>
<view class="menu-item" @tap="goNSTI">
<view class="menu-icon menu-icon-nsti">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">赛博尼古丁测试</text>
<text class="menu-desc">{{ nstiDesc }}</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-divider"></view>
<view class="menu-item" @tap="goOnboarding">
<view class="menu-icon menu-icon-accent">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">重新填写问卷</text>
<text class="menu-desc">修改打卡模式吸烟基线等个人信息</text>
</view>
<text class="menu-arrow"></text>
</view>
</view>
</view>
<view class="section">
<text class="section-label">更多设置</text>
<view class="menu-list card">
<view class="menu-item" @tap="clearCache">
<view class="menu-icon menu-icon-muted">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">清除缓存</text>
<text class="menu-desc">仅清理本地缓存不影响云端数据</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-divider"></view>
<view class="menu-item" @tap="copyInfo">
<view class="menu-icon menu-icon-muted">
<text class="menu-glyph"></text>
</view>
<view class="menu-content">
<text class="menu-label">意见反馈</text>
<text class="menu-desc">复制反馈邮箱发送使用建议或问题</text>
</view>
<text class="menu-arrow"></text>
</view>
</view>
</view>
<text class="version">版本 1.0.0</text>
<view class="bottom-safe"></view>
</view>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import { useProfileStore } from '@/stores/profile'
import { useUserStore } from '@/stores/user'
import { useLogin } from '@/hooks/useLogin'
import { createShare } from '@/api/smoke'
import { updateUserProfile, uploadFile } from '@/api/auth'
import { getLatestNSTIResult } from '@/utils/nsti'
const profileStore = useProfileStore()
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const shareToken = ref('')
const shareExpireAt = ref('')
const shareLoading = ref(false)
const navBarHeight = ref(0)
const latestNSTIResult = ref(null)
const userName = computed(() => userStore.user?.nickname || '戒烟用户')
const userAvatar = computed(() => userStore.user?.avatar_url || 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png')
const modeText = computed(() => {
if (userStore.mode === 'quit') return '戒烟打卡'
if (userStore.mode === 'record') return '记录抽烟'
return '未选择'
})
const nstiDesc = computed(() => latestNSTIResult.value
? `你上次测出:${latestNSTIResult.value.name}`
: '10 道题,测测你是哪一种抽象戒烟人格')
const shareDesc = computed(() => {
if (!shareToken.value) {
return shareLoading.value ? '正在生成分享信息...' : '先生成分享令牌后即可分享给朋友'
}
return `有效期至 ${formatExpire(shareExpireAt.value)},仅查看权限`
})
const sharePath = computed(() => {
if (!shareToken.value) {
return 'pages/index/index'
}
return `pages/share/index?share_token=${shareToken.value}`
})
function setupNavBar() {
const systemInfo = uni.getSystemInfoSync()
const statusBarH = systemInfo.statusBarHeight || 0
try {
const menuBtn = uni.getMenuButtonBoundingClientRect()
navBarHeight.value = menuBtn.bottom + (menuBtn.top - statusBarH)
} catch (e) {
navBarHeight.value = statusBarH + 44
}
}
function formatExpire(value) {
if (!value) return '--'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${hh}:${mm}`
}
async function prepareShareToken(showToast = false) {
if (shareLoading.value) return
shareLoading.value = true
try {
const res = await createShare({ days: 7 })
shareToken.value = res.data?.share_token || ''
shareExpireAt.value = res.data?.expire_at || ''
if (showToast) {
uni.showToast({ title: '分享链接已刷新', icon: 'success' })
}
} catch (e) {
console.error('prepareShareToken error:', e)
if (showToast) {
uni.showToast({ title: '生成分享失败', icon: 'none' })
}
} finally {
shareLoading.value = false
}
}
function handleRefreshShare() {
prepareShareToken(true)
}
function previewSharePage() {
if (!shareToken.value) {
uni.showToast({ title: '分享令牌尚未生成', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/share/index?share_token=${shareToken.value}`
})
}
async function onChooseAvatar(e) {
const avatarUrl = e.detail.avatarUrl
if (!avatarUrl) return
try {
uni.showLoading({ title: '上传头像...' })
const uploadRes = await uploadFile(avatarUrl)
await doUpdateProfile({ avatar_url: uploadRes.url })
} catch (err) {
console.error('头像上传失败:', err)
uni.showToast({ title: '头像更新失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
function onChooseAvatarH5() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album'],
success: async (res) => {
const tempPath = res.tempFilePaths[0]
try {
uni.showLoading({ title: '上传头像...' })
const uploadRes = await uploadFile(tempPath)
await doUpdateProfile({ avatar_url: uploadRes.url })
} catch (err) {
console.error('头像上传失败:', err)
uni.showToast({ title: '头像更新失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
})
}
async function onNicknameChange(e) {
const nickname = (e.detail.value || '').trim()
if (!nickname || nickname === userStore.user?.nickname) return
try {
await doUpdateProfile({ nickname })
} catch (err) {
uni.showToast({ title: '昵称更新失败', icon: 'none' })
}
}
async function doUpdateProfile(data) {
try {
const res = await updateUserProfile(data)
userStore.updateUser(res.data)
uni.showToast({ title: '修改成功', icon: 'success' })
} catch (e) {
console.error('doUpdateProfile error:', e)
uni.showToast({ title: '修改失败', icon: 'none' })
}
}
function goOnboarding() {
uni.navigateTo({ url: '/pages/onboarding/index' })
}
function goNSTI() {
uni.navigateTo({ url: '/pages/nsti/index' })
}
function goSupervisor() {
uni.navigateTo({ url: '/pages/supervisor/index' })
}
function clearCache() {
uni.showModal({
title: '清除缓存',
content: '将清除本地缓存数据,不会影响云端记录',
success: (res) => {
if (res.confirm) {
try {
uni.clearStorageSync()
uni.showToast({ title: '缓存已清除', icon: 'success' })
} catch (e) {
uni.showToast({ title: '清除失败', icon: 'none' })
}
}
}
})
}
function copyInfo() {
uni.setClipboardData({
data: '806669289@qq.com',
success: () => {
uni.showToast({ title: '反馈邮箱已复制', icon: 'success' })
}
})
}
onShareAppMessage(() => {
return {
title: `${userName.value}的戒烟记录(仅查看)`,
path: sharePath.value
}
})
onMounted(() => {
setupNavBar()
})
onShow(async () => {
await waitForLogin()
await profileStore.fetchProfile()
await prepareShareToken(false)
latestNSTIResult.value = getLatestNSTIResult()
})
</script>
<style scoped>
.page {
min-height: 100vh;
position: relative;
background-color: #F5F7F6;
padding: 0 32rpx 0;
box-sizing: border-box;
}
.page-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 500rpx;
background: linear-gradient(180deg, #DDF3EB 0%, #F5F7F6 100%);
z-index: 0;
}
.nav-placeholder,
.section,
.version {
position: relative;
z-index: 1;
}
.section {
margin-bottom: 36rpx;
}
.section-label {
display: block;
margin: 0 0 16rpx 16rpx;
font-size: 28rpx;
font-weight: 600;
color: #666666;
}
.card {
background: #FFFFFF;
border-radius: 32rpx;
padding: 32rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.03);
}
.user-section {
display: flex;
align-items: center;
gap: 32rpx;
}
.avatar-btn {
padding: 0;
margin: 0;
background: transparent;
border: none;
line-height: 1;
width: 128rpx;
height: 128rpx;
flex-shrink: 0;
}
.avatar-btn::after {
border: none;
}
.avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
background-color: #F0F4F2;
flex-shrink: 0;
}
.user-copy {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.nickname-input {
font-size: 38rpx;
font-weight: 700;
color: #1A1A1A;
background: transparent;
border: none;
padding: 0;
height: 56rpx;
line-height: 56rpx;
width: 100%;
}
.user-name {
font-size: 38rpx;
font-weight: 700;
color: #1A1A1A;
}
.user-desc {
display: block;
margin-top: 8rpx;
font-size: 26rpx;
color: #999999;
}
.user-meta {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 16rpx;
}
.user-pill {
padding: 8rpx 20rpx;
border-radius: 999rpx;
background: #E8F5F0;
font-size: 22rpx;
font-weight: 600;
color: #10B981;
}
.user-pill-muted {
background: #F5F5F5;
color: #999999;
}
.menu-list {
padding: 8rpx 32rpx;
}
.menu-item {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
background: transparent;
}
.menu-divider {
height: 1px;
background: #F0F0F0;
margin: 0;
}
.menu-icon {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.menu-icon-accent {
background: #E8F5F0;
}
.menu-icon-muted {
background: #F5F5F5;
}
.menu-icon-nsti {
background: linear-gradient(135deg, #FFE0F1 0%, #E6FFDE 100%);
}
.menu-glyph {
font-size: 30rpx;
font-weight: 700;
}
.menu-icon-accent .menu-glyph {
color: #10B981;
}
.menu-icon-nsti .menu-glyph {
color: #C2187A;
}
.menu-icon-muted .menu-glyph {
color: #999999;
}
.menu-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.menu-label {
font-size: 30rpx;
color: #1A1A1A;
font-weight: 600;
}
.menu-desc {
font-size: 24rpx;
line-height: 1.4;
color: #999999;
}
.menu-actions {
margin-top: 8rpx;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12rpx;
font-size: 24rpx;
}
.menu-action {
color: #10B981;
font-weight: 500;
}
.menu-action-sep {
color: #D4D4D4;
}
.share-btn {
margin: 0;
padding: 12rpx 32rpx;
line-height: 1.5;
font-size: 26rpx;
border: none;
border-radius: 999rpx;
color: #FFFFFF;
background: #10B981;
font-weight: 600;
}
.share-btn[disabled] {
background: #A7F3D0;
color: #FFFFFF;
}
.share-btn::after {
border: none;
}
.menu-arrow {
font-size: 36rpx;
color: #CCCCCC;
}
.version {
display: block;
text-align: center;
font-size: 24rpx;
color: #B0B0B0;
margin-top: 40rpx;
}
.bottom-safe {
height: calc(40rpx + env(safe-area-inset-bottom));
}
</style>