Files
smt/pages/ai/index.vue
T
2026-03-10 23:00:37 +08:00

784 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">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="container">
<view v-if="pageLoading" class="skeleton">
<view class="skeleton-card"></view>
<view class="skeleton-card"></view>
<view class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-row"></view>
</view>
</view>
<view v-else>
<view class="stage-card">
<view class="stage-badge"> {{ stageDay }}/30 </view>
<text class="stage-label">当前减量计划阶段</text>
<text class="stage-name">阶段 {{ stage }} · {{ stageName }}</text>
<text class="stage-days">本阶段还剩 {{ daysLeft }} </text>
<view class="stage-progress-row">
<text class="stage-progress-label">阶段进度</text>
<text class="stage-progress-value">{{ Math.round(stageProgress * 100) }}%</text>
</view>
<view class="stage-progress-bar">
<view class="stage-progress-fill" :style="{ width: stageProgress * 100 + '%' }"></view>
</view>
</view>
<view class="section">
<view class="section-header">
<view class="section-title-row">
<text class="section-icon">🤖</text>
<text class="section-title">每日 AI 分析</text>
</view>
<text class="refresh-btn" @tap="refreshAiAdvice">刷新</text>
</view>
<view v-if="aiLoading" class="ai-loading-card">
<text class="ai-loading-text">正在生成今日分析...</text>
</view>
<view v-else-if="aiStatus === 'available'" class="ai-chat">
<view class="ai-avatar">🤖</view>
<view class="ai-chat-content">
<view class="ai-chat-header">
<text class="ai-chat-name">AI 教练</text>
<text class="ai-chat-time">建议日期 {{ aiAdviceDateText }}</text>
</view>
<view class="ai-chat-bubble">
<text class="ai-chat-text">{{ aiAdvice }}</text>
</view>
</view>
</view>
<view v-else class="ai-empty-card">
<text class="ai-empty-title">{{ aiStateTitle }}</text>
<text class="ai-empty-desc">{{ aiStateDesc }}</text>
<view v-if="aiStatus === 'locked'" class="ai-empty-btn" @tap="goRecord">
<text class="ai-empty-btn-text">去首页查看解锁入口</text>
</view>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">今日目标</text>
<text class="section-badge">已完成 {{ completedGoals }}/{{ goals.length }}</text>
</view>
<view v-if="goals.length > 0" class="goals-list">
<view
v-for="goal in goals"
:key="goal.id"
class="goal-item"
@tap="toggleGoal(goal)"
>
<view class="goal-check" :class="{ 'goal-check-done': goal.done }">
<text v-if="goal.done" class="goal-check-icon"></text>
</view>
<text class="goal-text" :class="{ 'goal-text-done': goal.done }">{{ goal.text }}</text>
<text v-if="goal.icon" class="goal-icon">{{ goal.icon }}</text>
</view>
</view>
<view v-else class="goal-empty">
<text class="goal-empty-text">暂未生成目标点击刷新重试</text>
</view>
</view>
</view>
</view>
<view class="record-btn" @tap="goRecord">
<text class="record-icon"></text>
<text>记录吸烟或烟瘾</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShareAppMessage } from '@dcloudio/uni-app'
import { useLogin } from '@/hooks/useLogin'
import { useProfileStore } from '@/stores/profile'
import { storage } from '@/utils/storage'
import * as api from '@/api'
const { waitForLogin } = useLogin()
const profileStore = useProfileStore()
const GOALS_STORAGE_KEY = 'ai_daily_goals_v1'
const statusBarHeight = ref(0)
const pageLoading = ref(true)
const aiLoading = ref(false)
const stageDay = ref(1)
const stage = ref(1)
const stageName = ref('记录期')
const daysLeft = ref(6)
const stageProgress = ref(0)
const aiAdvice = ref('')
const aiStatus = ref('empty')
const aiAdviceDateText = ref('--')
const dailyTarget = ref(0)
const goals = ref([])
const completedGoals = computed(() => {
return goals.value.filter(g => g.done).length
})
const aiStateTitle = computed(() => {
if (aiStatus.value === 'locked') return '今日 AI 建议未解锁'
if (aiStatus.value === 'no_data') return '暂无可分析数据'
if (aiStatus.value === 'unavailable') return 'AI 服务暂不可用'
return '暂无 AI 建议'
})
const aiStateDesc = computed(() => {
if (aiStatus.value === 'locked') return '可前往首页按照引导完成解锁后查看。'
if (aiStatus.value === 'no_data') return '先完成记录,系统会在次日生成更准确建议。'
if (aiStatus.value === 'unavailable') return '稍后重试,当前将继续使用基础戒烟计划。'
return '请点击刷新,或先记录行为数据。'
})
function goRecord() {
uni.switchTab({ url: '/pages/index/index' })
}
function localDateStr(date = new Date()) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function getDateBefore(days) {
const date = new Date()
date.setDate(date.getDate() - days)
return localDateStr(date)
}
function calculateStage(profile) {
const startAt = profile?.onboarding_completed_at || profile?.created_at
if (!startAt) {
stageDay.value = 1
stage.value = 1
stageName.value = '记录期'
daysLeft.value = 6
stageProgress.value = 1 / 7
return
}
const start = new Date(startAt)
if (isNaN(start.getTime())) {
stageDay.value = 1
stage.value = 1
stageName.value = '记录期'
daysLeft.value = 6
stageProgress.value = 1 / 7
return
}
const now = new Date()
const startDayMs = new Date(start.getFullYear(), start.getMonth(), start.getDate()).getTime()
const nowDayMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const dayIndex = Math.max(1, Math.floor((nowDayMs - startDayMs) / (24 * 60 * 60 * 1000)) + 1)
const planDay = Math.min(dayIndex, 30)
stageDay.value = planDay
// 使用阶段内进度,便于用户感知当前阶段完成度
if (planDay <= 7) {
stage.value = 1
stageName.value = '记录期'
daysLeft.value = 7 - planDay
stageProgress.value = planDay / 7
return
}
if (planDay <= 21) {
stage.value = 2
stageName.value = '减量期'
daysLeft.value = 21 - planDay
stageProgress.value = (planDay - 7) / 14
return
}
stage.value = 3
stageName.value = '巩固期'
daysLeft.value = Math.max(30 - planDay, 0)
stageProgress.value = Math.min((planDay - 21) / 9, 1)
}
function makeGoalTemplates(profile, adviceText) {
const templates = []
const target = Number(dailyTarget.value) || Number(profile?.baseline_cigs_per_day) || 0
if (target > 0) {
templates.push({
id: 'limit_target',
text: `今日吸烟控制在 ${target} 支以内`,
icon: '🎯'
})
}
const quitMotions = Array.isArray(profile?.quit_motivations) ? profile.quit_motivations : []
if (quitMotions.includes('身体健康')) {
templates.push({
id: 'health_walk',
text: '完成 10 分钟快走,分散烟瘾注意力',
icon: '🏃'
})
}
if (quitMotions.includes('省钱')) {
templates.push({
id: 'money_track',
text: '记录今天少抽的支数,观察省钱变化',
icon: '💰'
})
}
if (adviceText && adviceText.includes('散步')) {
templates.push({
id: 'advice_walk',
text: '按 AI 建议在高峰时段先散步 5 分钟',
icon: '🌿'
})
}
templates.push({
id: 'delay_urge',
text: '烟瘾发作时先延迟 10 分钟并喝一杯水',
icon: '⏳'
})
// 去重后最多展示 3 条,保证信息密度适中
const uniqueMap = new Map()
templates.forEach(item => {
if (!uniqueMap.has(item.id)) {
uniqueMap.set(item.id, item)
}
})
return Array.from(uniqueMap.values()).slice(0, 3)
}
function loadGoalsByDate(dateStr, templates) {
const allGoals = storage.get(GOALS_STORAGE_KEY, {})
const cached = Array.isArray(allGoals?.[dateStr]) ? allGoals[dateStr] : []
const doneMap = new Map(cached.map(item => [item.id, !!item.done]))
goals.value = templates.map(item => ({
...item,
done: doneMap.get(item.id) || false
}))
}
function persistGoals() {
const dateStr = localDateStr()
const allGoals = storage.get(GOALS_STORAGE_KEY, {})
allGoals[dateStr] = goals.value.map(item => ({
id: item.id,
text: item.text,
icon: item.icon,
done: !!item.done
}))
// 仅保留最近 14 天的目标完成状态,避免本地缓存无限增长
const keepDays = 14
const keys = Object.keys(allGoals).sort()
if (keys.length > keepDays) {
const removeKeys = keys.slice(0, keys.length - keepDays)
removeKeys.forEach(key => {
delete allGoals[key]
})
}
storage.set(GOALS_STORAGE_KEY, allGoals)
}
function toggleGoal(goal) {
goal.done = !goal.done
persistGoals()
}
function applyHomeData(home) {
const data = home?.data || {}
const summary = data.summary || {}
const adviceCard = data.advice_card || {}
if (summary.daily_target !== undefined && summary.daily_target !== null) {
dailyTarget.value = Number(summary.daily_target) || 0
}
if (adviceCard?.message && !aiAdvice.value) {
aiAdvice.value = adviceCard.message
aiStatus.value = 'available'
}
if (!aiAdvice.value && adviceCard?.status) {
const map = {
locked: 'locked',
no_data: 'no_data',
unavailable: 'unavailable',
empty: 'empty'
}
aiStatus.value = map[adviceCard.status] || 'empty'
}
}
async function fetchAiAdvice() {
aiLoading.value = true
const adviceDate = getDateBefore(1)
aiAdviceDateText.value = adviceDate
try {
const res = await api.getAiAdvice(adviceDate)
const data = res?.data || {}
const message = data.advice || data.message || ''
if (message) {
aiAdvice.value = message
aiStatus.value = 'available'
} else {
aiAdvice.value = ''
aiStatus.value = 'no_data'
}
if (data.date) {
aiAdviceDateText.value = data.date
}
} catch (e) {
const errMsg = e?.message || ''
aiAdvice.value = ''
// 与后端约定:未解锁时返回 403,对应文案包含“会员/广告/解锁”等关键词
if (errMsg.includes('会员') || errMsg.includes('广告') || errMsg.includes('解锁')) {
aiStatus.value = 'locked'
} else {
aiStatus.value = 'unavailable'
}
} finally {
aiLoading.value = false
}
}
async function refreshAiAdvice() {
await fetchAiAdvice()
const profile = profileStore.profile || {}
const templates = makeGoalTemplates(profile, aiAdvice.value)
loadGoalsByDate(localDateStr(), templates)
}
async function initPage() {
pageLoading.value = true
try {
const sys = uni.getSystemInfoSync()
statusBarHeight.value = sys.statusBarHeight || 0
await waitForLogin()
const [profileRes, homeRes] = await Promise.allSettled([
profileStore.fetchProfile(),
api.getHome()
])
if (profileRes.status === 'fulfilled') {
calculateStage(profileStore.profile || {})
}
if (homeRes.status === 'fulfilled') {
applyHomeData(homeRes.value)
}
await fetchAiAdvice()
const profile = profileStore.profile || {}
const templates = makeGoalTemplates(profile, aiAdvice.value)
loadGoalsByDate(localDateStr(), templates)
} catch (e) {
console.error('initPage error:', e)
aiStatus.value = 'unavailable'
} finally {
pageLoading.value = false
}
}
onMounted(() => {
initPage()
})
onShareAppMessage(() => {
return {
title: '戒烟助手 - AI 戒烟教练陪你',
path: 'pages/index/index'
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: linear-gradient(to bottom, #D1FAE5 0%, #F0FDF4 45%, #FFFFFF 100%);
box-sizing: border-box;
}
.status-bar {
background: linear-gradient(to bottom, #D1FAE5, #E9FDF2);
}
.container {
padding: 24rpx 32rpx 180rpx;
}
.skeleton {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.skeleton-card,
.skeleton-row {
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 50%, #E5E7EB 75%);
background-size: 200% 100%;
animation: shimmer 1.6s infinite;
}
.skeleton-card {
height: 260rpx;
border-radius: 24rpx;
}
.skeleton-list {
padding: 24rpx;
background-color: #FFFFFF;
border-radius: 24rpx;
}
.skeleton-row {
height: 92rpx;
border-radius: 18rpx;
margin-bottom: 16rpx;
}
.skeleton-row:last-child {
margin-bottom: 0;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.stage-card {
background: #FFFFFF;
border-radius: 28rpx;
padding: 32rpx;
margin-bottom: 32rpx;
position: relative;
box-shadow: 0 10rpx 28rpx rgba(16, 185, 129, 0.12);
border: 2rpx solid #ECFDF3;
}
.stage-badge {
position: absolute;
top: 24rpx;
right: 24rpx;
background-color: #10B981;
color: #FFFFFF;
padding: 8rpx 20rpx;
border-radius: 20rpx;
font-size: 24rpx;
font-weight: 600;
}
.stage-label {
font-size: 24rpx;
color: #059669;
display: block;
margin-bottom: 8rpx;
}
.stage-name {
font-size: 42rpx;
font-weight: 700;
color: #111827;
display: block;
margin-bottom: 8rpx;
}
.stage-days {
font-size: 24rpx;
color: #6B7280;
display: block;
margin-bottom: 24rpx;
}
.stage-progress-row {
display: flex;
justify-content: space-between;
margin-bottom: 12rpx;
}
.stage-progress-label {
font-size: 24rpx;
color: #6B7280;
}
.stage-progress-value {
font-size: 24rpx;
font-weight: 600;
color: #10B981;
}
.stage-progress-bar {
height: 12rpx;
background-color: #E5E7EB;
border-radius: 6rpx;
overflow: hidden;
}
.stage-progress-fill {
height: 100%;
background: linear-gradient(90deg, #10B981, #34D399);
border-radius: 6rpx;
}
.section {
margin-bottom: 32rpx;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-title-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.section-icon {
font-size: 30rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #111827;
}
.refresh-btn {
font-size: 24rpx;
color: #10B981;
background-color: #ECFDF3;
padding: 8rpx 18rpx;
border-radius: 16rpx;
}
.section-badge {
margin-left: auto;
font-size: 24rpx;
color: #059669;
background-color: #ECFDF3;
padding: 8rpx 16rpx;
border-radius: 16rpx;
}
.ai-loading-card,
.ai-empty-card,
.ai-chat {
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 28rpx;
display: flex;
border: 2rpx solid #ECFDF3;
box-shadow: 0 8rpx 22rpx rgba(16, 185, 129, 0.08);
}
.ai-loading-card {
justify-content: center;
}
.ai-loading-text {
color: #6B7280;
font-size: 26rpx;
}
.ai-empty-card {
flex-direction: column;
gap: 16rpx;
}
.ai-empty-title {
font-size: 30rpx;
font-weight: 600;
color: #111827;
}
.ai-empty-desc {
font-size: 25rpx;
color: #6B7280;
line-height: 1.6;
}
.ai-empty-btn {
align-self: flex-start;
background-color: #10B981;
padding: 14rpx 24rpx;
border-radius: 16rpx;
}
.ai-empty-btn-text {
font-size: 24rpx;
color: #FFFFFF;
}
.ai-chat {
gap: 20rpx;
}
.ai-avatar {
width: 64rpx;
height: 64rpx;
background-color: #ECFDF3;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
flex-shrink: 0;
}
.ai-chat-content {
flex: 1;
}
.ai-chat-header {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 16rpx;
}
.ai-chat-name {
font-weight: 600;
color: #059669;
}
.ai-chat-time {
font-size: 24rpx;
color: #9CA3AF;
}
.ai-chat-bubble {
background-color: #F9FAFB;
padding: 24rpx;
border-radius: 24rpx;
border-top-left-radius: 8rpx;
border: 2rpx solid #F3F4F6;
}
.ai-chat-text {
font-size: 28rpx;
line-height: 1.6;
color: #111827;
white-space: pre-wrap;
}
.goals-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.goal-item {
display: flex;
align-items: center;
gap: 20rpx;
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 28rpx;
border: 2rpx solid #ECFDF3;
box-shadow: 0 6rpx 16rpx rgba(16, 185, 129, 0.08);
}
.goal-check {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
border: 4rpx solid #D1D5DB;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.goal-check-done {
background-color: #10B981;
border-color: #10B981;
}
.goal-check-icon {
font-size: 28rpx;
color: #FFFFFF;
font-weight: 700;
}
.goal-text {
flex: 1;
font-size: 28rpx;
color: #111827;
}
.goal-text-done {
text-decoration: line-through;
color: #6B7280;
}
.goal-icon {
font-size: 32rpx;
}
.goal-empty {
background-color: #FFFFFF;
border-radius: 24rpx;
border: 2rpx dashed #D1D5DB;
padding: 32rpx;
}
.goal-empty-text {
font-size: 26rpx;
color: #6B7280;
}
.record-btn {
position: fixed;
bottom: 140rpx;
left: 32rpx;
right: 32rpx;
height: 96rpx;
background-color: #10B981;
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
font-size: 32rpx;
font-weight: 500;
color: #FFFFFF;
box-shadow: 0 12rpx 28rpx rgba(16, 185, 129, 0.25);
}
.record-icon {
font-size: 32rpx;
}
</style>