feat: 完善AI助手页真实数据接入与界面风格统一
This commit is contained in:
+537
-106
@@ -1,57 +1,90 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page">
|
||||||
<view class="stage-card">
|
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||||
<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="container">
|
||||||
<view class="section-header">
|
<view v-if="pageLoading" class="skeleton">
|
||||||
<text class="section-icon">🤖</text>
|
<view class="skeleton-card"></view>
|
||||||
<text class="section-title">每日 AI 分析</text>
|
<view class="skeleton-card"></view>
|
||||||
</view>
|
<view class="skeleton-list">
|
||||||
|
<view v-for="i in 3" :key="i" class="skeleton-row"></view>
|
||||||
<view 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">· 刚刚</text>
|
|
||||||
</view>
|
|
||||||
<view class="ai-chat-bubble">
|
|
||||||
<text class="ai-chat-text">{{ aiAdvice }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="section">
|
<view v-else>
|
||||||
<view class="section-header">
|
<view class="stage-card">
|
||||||
<text class="section-title">今日目标</text>
|
<view class="stage-badge">第 {{ stageDay }}/30 天</view>
|
||||||
<text class="section-badge">已完成 {{ completedGoals }}/{{ goals.length }}</text>
|
<text class="stage-label">当前减量计划阶段</text>
|
||||||
</view>
|
<text class="stage-name">阶段 {{ stage }} · {{ stageName }}</text>
|
||||||
|
<text class="stage-days">本阶段还剩 {{ daysLeft }} 天</text>
|
||||||
<view class="goals-list">
|
<view class="stage-progress-row">
|
||||||
<view
|
<text class="stage-progress-label">阶段进度</text>
|
||||||
v-for="goal in goals"
|
<text class="stage-progress-value">{{ Math.round(stageProgress * 100) }}%</text>
|
||||||
:key="goal.id"
|
</view>
|
||||||
class="goal-item"
|
<view class="stage-progress-bar">
|
||||||
@tap="toggleGoal(goal)"
|
<view class="stage-progress-fill" :style="{ width: stageProgress * 100 + '%' }"></view>
|
||||||
>
|
</view>
|
||||||
<view class="goal-check" :class="{ 'goal-check-done': goal.done }">
|
</view>
|
||||||
<text v-if="goal.done" class="goal-check-icon">✓</text>
|
|
||||||
|
<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>
|
||||||
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -66,45 +99,311 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useLogin } from '@/hooks/useLogin'
|
import { useLogin } from '@/hooks/useLogin'
|
||||||
|
import { useProfileStore } from '@/stores/profile'
|
||||||
|
import { storage } from '@/utils/storage'
|
||||||
import * as api from '@/api'
|
import * as api from '@/api'
|
||||||
|
|
||||||
const { waitForLogin } = useLogin()
|
const { waitForLogin } = useLogin()
|
||||||
const loading = ref(true)
|
const profileStore = useProfileStore()
|
||||||
|
|
||||||
const stageDay = ref(18)
|
const GOALS_STORAGE_KEY = 'ai_daily_goals_v1'
|
||||||
const stage = ref(2)
|
|
||||||
const stageName = ref('减量期')
|
|
||||||
const daysLeft = ref(12)
|
|
||||||
const stageProgress = ref(0.4)
|
|
||||||
|
|
||||||
const aiAdvice = ref('早上好 Alex。昨天你的吸烟量比限额少了 2 支。这是一个巨大的胜利!🏆\n\n数据显示你的烟瘾在下午 2 点左右达到顶峰——今天试着那个时候去散散步。')
|
const statusBarHeight = ref(0)
|
||||||
|
const pageLoading = ref(true)
|
||||||
|
const aiLoading = ref(false)
|
||||||
|
|
||||||
const goals = ref([
|
const stageDay = ref(1)
|
||||||
{ id: 1, text: '喝 2 升水', icon: '🏆', done: true },
|
const stage = ref(1)
|
||||||
{ id: 2, text: '控制在 5 支烟以内', icon: '', done: false },
|
const stageName = ref('记录期')
|
||||||
{ id: 3, text: '阅读激励卡片', icon: '🏆', done: false }
|
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(() => {
|
const completedGoals = computed(() => {
|
||||||
return goals.value.filter(g => g.done).length
|
return goals.value.filter(g => g.done).length
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleGoal(goal) {
|
const aiStateTitle = computed(() => {
|
||||||
goal.done = !goal.done
|
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() {
|
function goRecord() {
|
||||||
uni.switchTab({ url: '/pages/index/index' })
|
uni.switchTab({ url: '/pages/index/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initPage() {
|
function localDateStr(date = new Date()) {
|
||||||
loading.value = true
|
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 {
|
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()
|
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) {
|
} catch (e) {
|
||||||
console.error('initPage error:', e)
|
console.error('initPage error:', e)
|
||||||
|
aiStatus.value = 'unavailable'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
pageLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,26 +415,73 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: #0D1F17;
|
background: linear-gradient(to bottom, #D1FAE5 0%, #F0FDF4 45%, #FFFFFF 100%);
|
||||||
padding: 32rpx;
|
|
||||||
padding-bottom: 200rpx;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-card {
|
.status-bar {
|
||||||
background: linear-gradient(135deg, rgba(74, 222, 128, 0.15) 0%, rgba(74, 222, 128, 0.05) 100%);
|
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;
|
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;
|
padding: 32rpx;
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: 32rpx;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
box-shadow: 0 10rpx 28rpx rgba(16, 185, 129, 0.12);
|
||||||
|
border: 2rpx solid #ECFDF3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-badge {
|
.stage-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 24rpx;
|
top: 24rpx;
|
||||||
right: 24rpx;
|
right: 24rpx;
|
||||||
background-color: #4ADE80;
|
background-color: #10B981;
|
||||||
color: #0D1F17;
|
color: #FFFFFF;
|
||||||
padding: 8rpx 20rpx;
|
padding: 8rpx 20rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
@@ -144,22 +490,22 @@ onMounted(() => {
|
|||||||
|
|
||||||
.stage-label {
|
.stage-label {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #4ADE80;
|
color: #059669;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-name {
|
.stage-name {
|
||||||
font-size: 44rpx;
|
font-size: 42rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #FFFFFF;
|
color: #111827;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-days {
|
.stage-days {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #9CA3AF;
|
color: #6B7280;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: 24rpx;
|
||||||
}
|
}
|
||||||
@@ -172,75 +518,140 @@ onMounted(() => {
|
|||||||
|
|
||||||
.stage-progress-label {
|
.stage-progress-label {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #9CA3AF;
|
color: #6B7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-progress-value {
|
.stage-progress-value {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #4ADE80;
|
color: #10B981;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-progress-bar {
|
.stage-progress-bar {
|
||||||
height: 12rpx;
|
height: 12rpx;
|
||||||
background-color: rgba(74, 222, 128, 0.2);
|
background-color: #E5E7EB;
|
||||||
border-radius: 6rpx;
|
border-radius: 6rpx;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-progress-fill {
|
.stage-progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #4ADE80, #22C55E);
|
background: linear-gradient(90deg, #10B981, #34D399);
|
||||||
border-radius: 6rpx;
|
border-radius: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section { margin-bottom: 32rpx; }
|
.section {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12rpx;
|
justify-content: space-between;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-icon { font-size: 32rpx; }
|
.section-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-icon {
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 32rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #10B981;
|
||||||
|
background-color: #ECFDF3;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-badge {
|
.section-badge {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #4ADE80;
|
color: #059669;
|
||||||
background-color: rgba(74, 222, 128, 0.1);
|
background-color: #ECFDF3;
|
||||||
padding: 8rpx 16rpx;
|
padding: 8rpx 16rpx;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-loading-card,
|
||||||
|
.ai-empty-card,
|
||||||
.ai-chat {
|
.ai-chat {
|
||||||
background-color: #1A3325;
|
background-color: #FFFFFF;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
padding: 32rpx;
|
padding: 28rpx;
|
||||||
display: flex;
|
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;
|
gap: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-avatar {
|
.ai-avatar {
|
||||||
width: 64rpx;
|
width: 64rpx;
|
||||||
height: 64rpx;
|
height: 64rpx;
|
||||||
background-color: #243D2E;
|
background-color: #ECFDF3;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 32rpx;
|
font-size: 30rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-content { flex: 1; }
|
.ai-chat-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-chat-header {
|
.ai-chat-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -251,25 +662,26 @@ onMounted(() => {
|
|||||||
|
|
||||||
.ai-chat-name {
|
.ai-chat-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #4ADE80;
|
color: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-time {
|
.ai-chat-time {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #6B7280;
|
color: #9CA3AF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-bubble {
|
.ai-chat-bubble {
|
||||||
background-color: #243D2E;
|
background-color: #F9FAFB;
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
border-top-left-radius: 8rpx;
|
border-top-left-radius: 8rpx;
|
||||||
|
border: 2rpx solid #F3F4F6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-text {
|
.ai-chat-text {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #FFFFFF;
|
color: #111827;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,16 +695,18 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
background-color: #1A3325;
|
background-color: #FFFFFF;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
padding: 28rpx;
|
padding: 28rpx;
|
||||||
|
border: 2rpx solid #ECFDF3;
|
||||||
|
box-shadow: 0 6rpx 16rpx rgba(16, 185, 129, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.goal-check {
|
.goal-check {
|
||||||
width: 48rpx;
|
width: 48rpx;
|
||||||
height: 48rpx;
|
height: 48rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 4rpx solid #374151;
|
border: 4rpx solid #D1D5DB;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -300,20 +714,20 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.goal-check-done {
|
.goal-check-done {
|
||||||
background-color: #4ADE80;
|
background-color: #10B981;
|
||||||
border-color: #4ADE80;
|
border-color: #10B981;
|
||||||
}
|
}
|
||||||
|
|
||||||
.goal-check-icon {
|
.goal-check-icon {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #0D1F17;
|
color: #FFFFFF;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.goal-text {
|
.goal-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #FFFFFF;
|
color: #111827;
|
||||||
}
|
}
|
||||||
|
|
||||||
.goal-text-done {
|
.goal-text-done {
|
||||||
@@ -321,7 +735,21 @@ onMounted(() => {
|
|||||||
color: #6B7280;
|
color: #6B7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.goal-icon { font-size: 32rpx; }
|
.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 {
|
.record-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -329,7 +757,7 @@ onMounted(() => {
|
|||||||
left: 32rpx;
|
left: 32rpx;
|
||||||
right: 32rpx;
|
right: 32rpx;
|
||||||
height: 96rpx;
|
height: 96rpx;
|
||||||
background-color: #4ADE80;
|
background-color: #10B981;
|
||||||
border-radius: 48rpx;
|
border-radius: 48rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -337,8 +765,11 @@ onMounted(() => {
|
|||||||
gap: 12rpx;
|
gap: 12rpx;
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #0D1F17;
|
color: #FFFFFF;
|
||||||
|
box-shadow: 0 12rpx 28rpx rgba(16, 185, 129, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-icon { font-size: 32rpx; }
|
.record-icon {
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user