feat: add read-only smoke share page and profile share entry

This commit is contained in:
root
2026-03-10 18:53:10 +08:00
parent 50109b3a96
commit db3218160b
4 changed files with 694 additions and 1 deletions
+101 -1
View File
@@ -7,6 +7,26 @@
<view class="section">
<view class="menu-list">
<view class="menu-item">
<view class="menu-icon menu-icon-green">🔗</view>
<view class="menu-content">
<text class="menu-label">分享戒烟记录</text>
<text class="menu-desc">{{ shareDesc }}</text>
</view>
<button class="share-btn" open-type="share" :disabled="shareLoading || !shareToken">
{{ shareLoading ? '生成中' : '分享' }}
</button>
</view>
<view class="menu-item" @tap="prepareShareToken(true)">
<view class="menu-icon menu-icon-gray">🔄</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-item" @tap="goOnboarding">
<view class="menu-icon menu-icon-green">📝</view>
<view class="menu-content">
@@ -46,16 +66,68 @@
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { onShareAppMessage } from '@dcloudio/uni-app'
import { createShare } from '@/api'
import { useUserStore } from '@/stores/user'
import { useLogin } from '@/hooks/useLogin'
const userStore = useUserStore()
const { waitForLogin } = useLogin()
const shareToken = ref('')
const shareExpireAt = ref('')
const shareLoading = ref(false)
const userName = computed(() => userStore.user?.nickname || '戒烟用户')
const userAvatar = computed(() => userStore.user?.avatar_url || '/static/images/default-avatar.png')
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 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 goOnboarding() {
uni.navigateTo({ url: '/pages/onboarding/index' })
}
@@ -99,8 +171,16 @@ function logout() {
})
}
onShareAppMessage(() => {
return {
title: `${userName.value}的戒烟记录(仅查看)`,
path: sharePath.value
}
})
onMounted(async () => {
await waitForLogin()
await prepareShareToken(false)
})
</script>
@@ -189,6 +269,26 @@ onMounted(async () => {
color: #9CA3AF;
}
.share-btn {
margin: 0;
padding: 10rpx 20rpx;
line-height: 1.4;
font-size: 24rpx;
border: none;
border-radius: 999rpx;
color: #ffffff;
background: #10b981;
}
.share-btn[disabled] {
background: #9ca3af;
color: #ffffff;
}
.share-btn::after {
border: none;
}
.logout-btn {
text-align: center;
padding: 28rpx;
+575
View File
@@ -0,0 +1,575 @@
<template>
<view class="page">
<view v-if="loading" class="state-wrap">
<text class="state-text">加载分享数据中...</text>
</view>
<view v-else-if="errorText" class="state-wrap">
<text class="state-text">{{ errorText }}</text>
<button class="retry-btn" @tap="reload">重新加载</button>
</view>
<view v-else>
<view class="owner-card">
<image class="avatar" :src="owner.avatar_url || defaultAvatar" mode="aspectFill"></image>
<view class="owner-main">
<text class="owner-name">{{ owner.nickname || '戒烟用户' }}</text>
<text class="owner-desc">分享了自己的戒烟记录只读</text>
</view>
</view>
<view class="overview-grid">
<view class="overview-item">
<text class="overview-label">今日吸烟</text>
<text class="overview-value">{{ overview.today_count || 0 }}</text>
</view>
<view class="overview-item">
<text class="overview-label">今日忍住</text>
<text class="overview-value">{{ overview.resisted_count || 0 }}</text>
</view>
<view class="overview-item">
<text class="overview-label">连续记录</text>
<text class="overview-value">{{ overview.streak_days || 0 }}</text>
</view>
<view class="overview-item">
<text class="overview-label">较昨日变化</text>
<text class="overview-value" :class="overview.exceeded_yesterday ? 'warning' : 'success'">
{{ overview.exceeded_yesterday ? '+' : '-' }}{{ overview.reduced_from_yesterday || 0 }}
</text>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">统计报表</text>
<view class="range-tabs">
<view
v-for="item in rangeTabs"
:key="item.value"
class="range-item"
:class="{ active: range === item.value }"
@tap="switchRange(item.value)"
>
{{ item.label }}
</view>
</view>
</view>
<view class="stats-row">
<view class="stats-block">
<text class="stats-key">日均支数</text>
<text class="stats-val">{{ stats.daily_average || 0 }}</text>
</view>
<view class="stats-block">
<text class="stats-key">变化幅度</text>
<text class="stats-val">{{ stats.change_percent || 0 }}%</text>
</view>
<view class="stats-block">
<text class="stats-key">范围忍住</text>
<text class="stats-val">{{ stats.resisted_total || 0 }}</text>
</view>
</view>
<view class="trend-wrap">
<view v-for="(item, index) in stats.trend || []" :key="index" class="trend-item">
<text class="trend-label">{{ item.label }}</text>
<view class="trend-bar-bg">
<view class="trend-bar" :style="{ width: trendWidth(item.count) + '%' }"></view>
</view>
<text class="trend-count">{{ item.count }}</text>
</view>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">记录详情只读</text>
<view class="range-tabs">
<view
v-for="item in logTypeTabs"
:key="item.value"
class="range-item"
:class="{ active: logType === item.value }"
@tap="switchLogType(item.value)"
>
{{ item.label }}
</view>
</view>
</view>
<view v-if="logs.length === 0" class="empty-box">
<text class="empty-text">暂无记录</text>
</view>
<view v-else class="log-list">
<view v-for="item in logs" :key="item.id" class="log-item" @tap="showDetail(item)">
<view class="log-top">
<text class="log-time">{{ displayTime(item) }}</text>
<text class="log-type" :class="resolveType(item) === 'resisted' ? 'resisted' : 'smoke'">
{{ resolveType(item) === 'resisted' ? '已忍住' : '已抽烟' }}
</text>
</view>
<view class="log-meta">
<text>数量{{ item.num ?? 0 }}</text>
<text>强度{{ levelLabel(item.level) }}</text>
</view>
<text class="log-remark">{{ (item.remark && String(item.remark).trim()) || '无备注' }}</text>
</view>
</view>
<button
v-if="logs.length < total"
class="load-more"
:disabled="loadingMore"
@tap="loadMore"
>
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getShareData } from '@/api'
const defaultAvatar = '/static/images/default-avatar.png'
const loading = ref(true)
const loadingMore = ref(false)
const errorText = ref('')
const shareToken = ref('')
const range = ref('week')
const logType = ref('all')
const owner = ref({})
const overview = ref({})
const stats = ref({})
const logs = ref([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const rangeTabs = [
{ label: '周', value: 'week' },
{ label: '月', value: 'month' },
{ label: '年', value: 'year' }
]
const logTypeTabs = [
{ label: '全部', value: 'all' },
{ label: '已抽烟', value: 'smoke' },
{ label: '已忍住', value: 'resisted' }
]
const maxTrend = computed(() => {
const values = (stats.value?.trend || []).map((item) => Number(item.count) || 0)
const max = Math.max(...values, 0)
return max <= 0 ? 1 : max
})
function trendWidth(count) {
const n = Number(count) || 0
return Math.max(8, Math.round((n / maxTrend.value) * 100))
}
function resolveType(item) {
if ((item?.level || 0) === 0 && (item?.num || 0) === 0) {
return 'resisted'
}
return 'smoke'
}
function levelLabel(level) {
const value = Number(level)
if (Number.isNaN(value)) return '未知'
if (value <= 1) return '轻微'
if (value === 2) return '中等'
if (value === 3) return '明显'
if (value === 4) return '强烈'
return '极强'
}
function displayTime(item) {
if (item?.smoke_at) {
return String(item.smoke_at).replace('T', ' ').slice(0, 19)
}
if (item?.smoke_time) {
return String(item.smoke_time).slice(0, 10)
}
if (item?.createtime) {
const ts = Number(item.createtime)
if (!Number.isNaN(ts) && ts > 0) {
const d = new Date(ts * 1000)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const s = String(d.getSeconds()).padStart(2, '0')
return `${y}-${m}-${day} ${h}:${mm}:${s}`
}
}
return '--'
}
async function fetchShare(resetLogs = false) {
if (!shareToken.value) {
errorText.value = '分享参数缺失'
loading.value = false
return
}
if (resetLogs) {
page.value = 1
logs.value = []
}
const params = {
range: range.value,
type: logType.value,
page: page.value,
page_size: pageSize.value
}
try {
const res = await getShareData(shareToken.value, params)
const payload = res.data || {}
owner.value = payload.owner || {}
overview.value = payload.overview || {}
stats.value = payload.stats || {}
const logPayload = payload.logs || {}
const items = logPayload.items || []
if (resetLogs) {
logs.value = items
} else {
logs.value = [...logs.value, ...items]
}
total.value = Number(logPayload.total || 0)
page.value = Number(logPayload.page || page.value)
} catch (e) {
console.error('fetchShare error:', e)
errorText.value = e?.message || '分享已失效或不可访问'
} finally {
loading.value = false
loadingMore.value = false
}
}
async function switchRange(next) {
if (range.value === next) return
range.value = next
loading.value = true
errorText.value = ''
await fetchShare(true)
}
async function switchLogType(next) {
if (logType.value === next) return
logType.value = next
loading.value = true
errorText.value = ''
await fetchShare(true)
}
async function loadMore() {
if (loadingMore.value || logs.value.length >= total.value) return
loadingMore.value = true
page.value += 1
await fetchShare(false)
}
function showDetail(item) {
uni.showModal({
title: '记录详情',
showCancel: false,
content: [
`时间:${displayTime(item)}`,
`类型:${resolveType(item) === 'resisted' ? '已忍住' : '已抽烟'}`,
`数量:${item.num ?? 0}`,
`强度:${levelLabel(item.level)}`,
`备注:${(item.remark && String(item.remark).trim()) || '无备注'}`
].join('\n')
})
}
async function reload() {
loading.value = true
errorText.value = ''
await fetchShare(true)
}
onLoad(async (options) => {
shareToken.value = String(options?.share_token || '').trim()
await fetchShare(true)
})
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
background: #f5f7fa;
}
.state-wrap {
padding: 120rpx 40rpx;
text-align: center;
}
.state-text {
font-size: 28rpx;
color: #6b7280;
}
.retry-btn {
margin-top: 24rpx;
font-size: 26rpx;
background: #10b981;
color: #fff;
border: none;
border-radius: 16rpx;
}
.owner-card {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx;
background: #ffffff;
border-radius: 20rpx;
}
.avatar {
width: 90rpx;
height: 90rpx;
border-radius: 50%;
background: #e5e7eb;
}
.owner-main {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.owner-name {
font-size: 32rpx;
font-weight: 700;
color: #111827;
}
.owner-desc {
font-size: 24rpx;
color: #6b7280;
}
.overview-grid {
margin-top: 20rpx;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.overview-item {
padding: 20rpx;
background: #fff;
border-radius: 16rpx;
}
.overview-label {
font-size: 22rpx;
color: #6b7280;
}
.overview-value {
margin-top: 10rpx;
display: block;
font-size: 34rpx;
font-weight: 700;
color: #111827;
}
.overview-value.success {
color: #10b981;
}
.overview-value.warning {
color: #ef4444;
}
.section {
margin-top: 20rpx;
padding: 20rpx;
background: #fff;
border-radius: 20rpx;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: #111827;
}
.range-tabs {
display: flex;
gap: 8rpx;
}
.range-item {
padding: 8rpx 18rpx;
font-size: 22rpx;
color: #6b7280;
border-radius: 999rpx;
background: #f3f4f6;
}
.range-item.active {
background: #10b981;
color: #fff;
}
.stats-row {
margin-top: 18rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12rpx;
}
.stats-block {
padding: 14rpx;
border-radius: 14rpx;
background: #f8fafc;
}
.stats-key {
font-size: 20rpx;
color: #6b7280;
}
.stats-val {
display: block;
margin-top: 8rpx;
font-size: 30rpx;
font-weight: 700;
color: #111827;
}
.trend-wrap {
margin-top: 18rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.trend-item {
display: grid;
grid-template-columns: 1.5fr 3fr 0.8fr;
align-items: center;
gap: 10rpx;
}
.trend-label,
.trend-count {
font-size: 22rpx;
color: #374151;
}
.trend-bar-bg {
height: 16rpx;
border-radius: 999rpx;
background: #e5e7eb;
overflow: hidden;
}
.trend-bar {
height: 100%;
background: linear-gradient(90deg, #34d399, #10b981);
}
.empty-box {
padding: 40rpx 0;
text-align: center;
}
.empty-text {
font-size: 24rpx;
color: #6b7280;
}
.log-list {
margin-top: 14rpx;
display: flex;
flex-direction: column;
gap: 14rpx;
}
.log-item {
padding: 18rpx;
border-radius: 14rpx;
background: #f8fafc;
}
.log-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.log-time {
font-size: 24rpx;
font-weight: 600;
color: #111827;
}
.log-type {
font-size: 20rpx;
padding: 6rpx 12rpx;
border-radius: 999rpx;
}
.log-type.smoke {
background: #ffedd5;
color: #c2410c;
}
.log-type.resisted {
background: #dcfce7;
color: #047857;
}
.log-meta {
margin-top: 10rpx;
display: flex;
gap: 18rpx;
font-size: 22rpx;
color: #4b5563;
}
.log-remark {
margin-top: 8rpx;
font-size: 22rpx;
color: #6b7280;
}
.load-more {
margin-top: 16rpx;
font-size: 24rpx;
border-radius: 14rpx;
border: none;
background: #ecfdf5;
color: #047857;
}
</style>