6707 lines
204 KiB
Vue
6707 lines
204 KiB
Vue
<template>
|
||
<view class="daily-page">
|
||
<CelebrateBurst :show="celebrateShow" :title="celebrateTitle" :subtitle="celebrateSub" />
|
||
<view v-if="authChecking" class="page-gate-loading">
|
||
<text class="page-gate-loading-text">正在登录…</text>
|
||
</view>
|
||
|
||
<block v-if="!authChecking">
|
||
<!-- 顶部 Hero -->
|
||
<view class="hero hero-more">
|
||
<view class="hero-bg" />
|
||
<view class="hero-content">
|
||
<view class="hero-row">
|
||
<view class="hero-main">
|
||
<text class="hero-title">日常护理</text>
|
||
<text class="hero-subtitle">{{ patientName || '健康档案' }}</text>
|
||
</view>
|
||
<view class="hero-pill-group">
|
||
<view class="hero-pill" @click="goGlucosePage">
|
||
<view class="hero-pill-icon-wrap">
|
||
<TongjiIcon name="glucose" size="sm" color="#386641" />
|
||
</view>
|
||
<text class="hero-pill-text">血糖</text>
|
||
</view>
|
||
<view class="hero-pill" @click="onRefresh">
|
||
<view class="hero-pill-icon-wrap">
|
||
<TongjiIcon name="refresh" size="sm" color="#ffffff" />
|
||
</view>
|
||
<text class="hero-pill-text">刷新</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 血糖历史:日历 · 明细 -->
|
||
<view v-if="diagnosisId" class="glucose-history-block">
|
||
<view class="range-bar">
|
||
<view
|
||
v-for="opt in rangeOptions"
|
||
:key="opt.value"
|
||
class="range-item"
|
||
:class="{ active: rangeMode === opt.value }"
|
||
@click="onChangeRange(opt.value)"
|
||
>
|
||
<text>{{ opt.label }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="heatmapWeeks.length > 0" class="card calendar-card">
|
||
<view class="calendar-head">
|
||
<view class="calendar-head-left">
|
||
<text class="card-title">健康日历</text>
|
||
<text class="card-subtitle">{{ heatmapMonthLabel }} · 已记录 {{ heatmapTotalRecords }} / {{ heatmapInRangeDays }} 天</text>
|
||
</view>
|
||
</view>
|
||
<view class="calendar-weekdays">
|
||
<text class="calendar-weekday">一</text>
|
||
<text class="calendar-weekday">二</text>
|
||
<text class="calendar-weekday">三</text>
|
||
<text class="calendar-weekday">四</text>
|
||
<text class="calendar-weekday">五</text>
|
||
<text class="calendar-weekday">六</text>
|
||
<text class="calendar-weekday calendar-weekday-weekend">日</text>
|
||
</view>
|
||
<view class="calendar-grid">
|
||
<view v-for="(week, wi) in heatmapWeeks" :key="`wk-${wi}`" class="calendar-row">
|
||
<view
|
||
v-for="cell in week"
|
||
:key="cell.date"
|
||
class="calendar-cell"
|
||
:class="[
|
||
cell.level < 0 ? 'off' : `level-${cell.level}`,
|
||
{ today: cell.isToday, clickable: cell.inRange && cell.level > 0 }
|
||
]"
|
||
@click="cell.inRange && cell.level > 0 ? jumpToDayRecord(cell.date) : null"
|
||
>
|
||
<text class="calendar-cell-day">{{ cell.day }}</text>
|
||
<view v-if="cell.level > 0" class="calendar-cell-mark" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="heatmap-foot">
|
||
<text class="heatmap-foot-text">{{ todayChecked ? '今日已完成记录' : '今日还没有记录' }}</text>
|
||
<view class="heatmap-foot-cta" @click="openInputForm()">
|
||
<text>{{ todayChecked ? '编辑血糖' : '去记血糖' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="records-section">
|
||
<view class="records-head">
|
||
<text class="card-title">血糖明细</text>
|
||
<text class="records-count">共 {{ bloodRecordDays.length }} 天</text>
|
||
</view>
|
||
<view v-if="loading" class="state-card">
|
||
<text class="state-text">加载中…</text>
|
||
</view>
|
||
<view v-else-if="bloodRecordDays.length === 0" class="state-card">
|
||
<text class="state-text">该时段暂无血糖记录</text>
|
||
</view>
|
||
<view
|
||
v-for="day in bloodRecordDays"
|
||
:key="day.date"
|
||
:id="`day-${day.date.replace(/-/g, '')}`"
|
||
class="day-card"
|
||
>
|
||
<view class="day-head">
|
||
<view class="day-head-left">
|
||
<text class="day-date">{{ day.dateLabel }}</text>
|
||
<text v-if="day.isToday" class="day-today-pill">今天</text>
|
||
</view>
|
||
<text class="day-weekday">{{ day.weekday }}</text>
|
||
</view>
|
||
<view v-if="day.blood" class="day-block">
|
||
<view class="metric-grid">
|
||
<view class="metric-cell">
|
||
<text class="metric-label">空腹</text>
|
||
<view class="metric-value-row">
|
||
<text class="metric-value" :class="{ 'is-high': day.blood.isHighFasting }">
|
||
{{ formatValue(day.blood.fasting_blood_sugar) }}
|
||
</text>
|
||
<text class="metric-unit">mmol/L</text>
|
||
</view>
|
||
</view>
|
||
<view class="metric-cell">
|
||
<text class="metric-label">餐后</text>
|
||
<view class="metric-value-row">
|
||
<text class="metric-value" :class="{ 'is-high': day.blood.isHighPostprandial }">
|
||
{{ formatValue(day.blood.postprandial_blood_sugar) }}
|
||
</text>
|
||
<text class="metric-unit">mmol/L</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="day.blood.remark" class="day-remark">
|
||
<text>{{ day.blood.remark }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 稳糖乐园 (Bento 垂直堆叠布局) -->
|
||
<view v-if="diagnosisId" class="card pg-card">
|
||
<view class="card-head">
|
||
<view class="card-head-left">
|
||
<text class="card-title">稳糖乐园</text>
|
||
<text class="card-subtitle">用健康打卡浇灌你的稳糖树</text>
|
||
</view>
|
||
<view class="pg-points-badge">
|
||
<text class="pg-points-num">{{ gamifyPoints }}</text>
|
||
<text class="pg-points-label">稳糖积分</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="allTasksCleared" class="mission-complete-banner">
|
||
<TongjiIcon name="trophy" size="md" color="#CA8A04" />
|
||
<view class="mission-complete-text">
|
||
<text class="mission-complete-title">今日任务大满贯</text>
|
||
<text class="mission-complete-sub">小树今天喝饱啦,明天继续加油</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="pg-content-stack">
|
||
<!-- 今日记录快捷入口(一屏可见,不必下滑找任务)-->
|
||
<view class="record-quick-zone">
|
||
<view class="record-quick-head">
|
||
<text class="record-quick-title">今日记录</text>
|
||
<text class="record-quick-hint">记全四项,再给小树浇水领积分</text>
|
||
</view>
|
||
<view class="record-quick-row">
|
||
<view
|
||
class="record-quick-chip"
|
||
:class="{ done: todayChecked, pending: !todayChecked }"
|
||
@click="openInputForm()"
|
||
>
|
||
<view class="record-quick-icon-wrap">
|
||
<TongjiIcon name="glucose" size="md" :color="todayChecked ? '#386641' : '#204E2B'" />
|
||
</view>
|
||
<text class="record-quick-label">血糖</text>
|
||
<text class="record-quick-status">{{ todayChecked ? '已记' : '去记录' }}</text>
|
||
</view>
|
||
<view
|
||
class="record-quick-chip"
|
||
:class="{ done: todayHasBp, pending: !todayHasBp }"
|
||
@click="openBpForm()"
|
||
>
|
||
<view class="record-quick-icon-wrap">
|
||
<TongjiIcon name="activity" size="md" :color="todayHasBp ? '#386641' : '#204E2B'" />
|
||
</view>
|
||
<text class="record-quick-label">血压</text>
|
||
<text class="record-quick-status">{{ todayHasBp ? '已记' : '去记录' }}</text>
|
||
</view>
|
||
<view
|
||
class="record-quick-chip"
|
||
:class="{ done: todayHasDiet, pending: !todayHasDiet }"
|
||
@click="openDietForm()"
|
||
>
|
||
<view class="record-quick-icon-wrap">
|
||
<TongjiIcon name="droplet" size="md" :color="todayHasDiet ? '#386641' : '#204E2B'" />
|
||
</view>
|
||
<text class="record-quick-label">饮食</text>
|
||
<text class="record-quick-status">{{ todayHasDiet ? '已记' : '去记录' }}</text>
|
||
</view>
|
||
<view
|
||
class="record-quick-chip"
|
||
:class="{ done: todayHasExercise, pending: !todayHasExercise }"
|
||
@click="openExerciseForm()"
|
||
>
|
||
<view class="record-quick-icon-wrap">
|
||
<TongjiIcon name="activity" size="md" :color="todayHasExercise ? '#386641' : '#204E2B'" />
|
||
</view>
|
||
<text class="record-quick-label">运动</text>
|
||
<text class="record-quick-status">{{ todayHasExercise ? '已记' : '去记录' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 今日任务区(与快捷入口对应,完成可浇水领奖)-->
|
||
<view class="today-tasks-zone">
|
||
<view class="tasks-zone-title">今日控糖任务</view>
|
||
<view class="tasks-list">
|
||
<view
|
||
v-for="task in todayTasks"
|
||
:key="task.id"
|
||
class="task-row-item"
|
||
:class="{ completed: task.completed, claimed: task.claimed, pending: !task.completed, 'just-done': taskJustDoneId === task.id }"
|
||
@click="onGamifyTaskTap(task)"
|
||
>
|
||
<view class="task-left">
|
||
<view class="task-status-icon" :class="{ checked: task.completed }">
|
||
<text v-if="task.completed">✓</text>
|
||
<text v-else>○</text>
|
||
</view>
|
||
<text class="task-name">{{ task.name }}</text>
|
||
</view>
|
||
<view class="task-right">
|
||
<text class="task-points">+{{ task.points }}积分</text>
|
||
<view
|
||
class="task-action-btn"
|
||
:class="{ claimed: task.claimed, pending: !task.completed && !task.claimed, disabled: task.claimed }"
|
||
@click.stop="onGamifyTaskTap(task)"
|
||
>
|
||
<text>{{ task.claimed ? '已领' : (task.completed ? '去浇水' : '去记录') }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 控糖树成长区(10 级成长 + 路线图)-->
|
||
<view class="tree-growth-zone">
|
||
<view class="tree-graphic-wrap tree-tappable" @click="onTreeWhisper">
|
||
<view class="tree-lv-badge">Lv.{{ treeLevel }}</view>
|
||
<view class="tree-mood-pill">
|
||
<text class="tree-mood-emoji">{{ treeEmoji }}</text>
|
||
<text class="tree-mood-text">{{ treeMood }}</text>
|
||
</view>
|
||
<SugarTreeGraphic :level="treeLevel" size="lg" :watering="treeWatering" />
|
||
<view v-if="floatReward" :key="floatReward.key" class="float-reward">
|
||
<text>+{{ floatReward.text }}</text>
|
||
</view>
|
||
<text class="tree-tap-hint">点树听鼓励 ›</text>
|
||
</view>
|
||
<view class="tree-progress-wrap">
|
||
<view class="tree-level-info">
|
||
<text class="tree-level-tag">{{ treeLevelName }}</text>
|
||
<text class="tree-progress-text">{{ treeProgressLabel }}</text>
|
||
</view>
|
||
<view class="tree-progress-track">
|
||
<view class="tree-progress-bar" :style="{ width: treeProgress + '%' }" />
|
||
</view>
|
||
<text v-if="treeNextHint" class="tree-next-hint">{{ treeNextHint }}</text>
|
||
<scroll-view scroll-x class="tree-roadmap-scroll" :show-scrollbar="false">
|
||
<view class="tree-roadmap-row">
|
||
<view
|
||
v-for="node in TREE_LEVEL_ROADMAP"
|
||
:key="node.level"
|
||
class="tree-roadmap-node"
|
||
:class="{ done: node.level < treeLevel, current: node.level === treeLevel, locked: node.level > treeLevel }"
|
||
>
|
||
<text class="tree-roadmap-emoji">{{ node.emoji }}</text>
|
||
<text class="tree-roadmap-lv">Lv{{ node.level }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
<view class="tree-water-action">
|
||
<button class="water-btn" :class="{ disabled: treeWatering, pulse: claimablePoints > 0 }" @click="waterTree">
|
||
<TongjiIcon name="droplet" size="sm" color="#FFFFFF" />
|
||
<text class="water-btn-text">{{ claimablePoints > 0 ? `浇水领 ${claimablePoints}分` : '给小树浇水' }}</text>
|
||
</button>
|
||
<text v-if="gamifyStatusTip" class="water-hint">{{ gamifyStatusTip }}</text>
|
||
<text v-else-if="claimablePoints === 0 && !allTasksCleared" class="water-hint">先完成上方记录,再浇水领积分</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="unlockedBadges.length" class="badge-wall">
|
||
<text class="badge-wall-title">我的成就</text>
|
||
<scroll-view scroll-x class="badge-wall-scroll" :show-scrollbar="false">
|
||
<view class="badge-wall-row">
|
||
<view v-for="b in unlockedBadges" :key="b.id" class="badge-chip">
|
||
<view class="badge-chip-icon">
|
||
<TongjiIcon :name="b.icon" size="sm" :color="b.color" />
|
||
</view>
|
||
<text class="badge-chip-label">{{ b.label }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<!-- 分享战报 -->
|
||
<view class="pg-share-row">
|
||
<view class="pg-share-btn" @click="openWeeklyReport">
|
||
<view class="pg-share-icon-wrap">
|
||
<TongjiIcon name="share" size="md" color="#204E2B" />
|
||
</view>
|
||
<view class="pg-share-text">
|
||
<text class="pg-share-title">分享本周战报</text>
|
||
<text class="pg-share-sub">邀请家人查看近7日血糖记录</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 家人点赞 -->
|
||
<view
|
||
v-if="diagnosisId"
|
||
class="family-like-strip"
|
||
:class="{ 'has-likes': familyLikeSummary.like_count > 0 }"
|
||
@click="onFamilyLikeStripTap"
|
||
>
|
||
<text class="family-like-icon">❤️</text>
|
||
<view class="family-like-body">
|
||
<text class="family-like-title">{{ familyLikeSummary.like_count > 0 ? '家人为您点赞' : '邀请家人点赞' }}</text>
|
||
<text class="family-like-sub">{{ familyLikeSummary.summary_line }}</text>
|
||
<view v-if="familyLikeSummary.recent.length" class="family-like-recent">
|
||
<text
|
||
v-for="(item, idx) in familyLikeSummary.recent.slice(0, 3)"
|
||
:key="idx"
|
||
class="family-like-recent-item"
|
||
>{{ item.nickname }} · {{ item.time_label }}</text>
|
||
</view>
|
||
</view>
|
||
<text class="family-like-arrow">›</text>
|
||
</view>
|
||
|
||
<!-- 每日鼓励 -->
|
||
<view v-if="claimablePoints > 0 || !todayChecked" class="encourage-strip" @click="onEncourageTap">
|
||
<text class="encourage-icon">💬</text>
|
||
<view class="encourage-body">
|
||
<text class="encourage-text">{{ encourageLine }}</text>
|
||
<text class="encourage-tip">轻触换一句鼓励 ›</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 就诊卡选择器(精简为药丸 chip)-->
|
||
<view v-if="cards.length > 0" class="card-switcher">
|
||
<view class="card-switcher-head">
|
||
<text class="card-switcher-label">就诊卡</text>
|
||
<text class="card-switcher-count">{{ cards.length }} 张</text>
|
||
</view>
|
||
<scroll-view scroll-x class="card-switcher-scroll" :show-scrollbar="false">
|
||
<view class="card-chip-row">
|
||
<view
|
||
v-for="card in cards"
|
||
:key="card.id"
|
||
class="card-chip"
|
||
:class="{ active: Number(card.id) === Number(diagnosisId) }"
|
||
@click="onSelectCard(card)"
|
||
>
|
||
<text class="card-chip-name">{{ card.patient_name || '未填' }}</text>
|
||
<text v-if="card.age" class="card-chip-meta">{{ card.age }}岁</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
<!-- 日常记录列表 -->
|
||
<view class="records-section">
|
||
<view class="records-head">
|
||
<text class="card-title">饮食 · 运动 · 血压</text>
|
||
<text class="records-count">共 {{ recordDays.length }} 天</text>
|
||
</view>
|
||
|
||
<view v-if="loading" class="state-card">
|
||
<text class="state-text">加载中...</text>
|
||
</view>
|
||
<view v-else-if="recordDays.length === 0" class="state-card">
|
||
<text class="state-icon">📭</text>
|
||
<text class="state-text">该时段暂无任何记录</text>
|
||
</view>
|
||
|
||
<view
|
||
v-for="day in recordDays"
|
||
:key="day.date"
|
||
:id="`day-${day.date.replace(/-/g, '')}`"
|
||
class="day-card"
|
||
>
|
||
<view class="day-head">
|
||
<view class="day-head-left">
|
||
<text class="day-date">{{ day.dateLabel }}</text>
|
||
<text v-if="day.isToday" class="day-today-pill">今天</text>
|
||
</view>
|
||
<view class="day-head-right">
|
||
<text class="day-weekday">{{ day.weekday }}</text>
|
||
<view class="day-speak-btn" @click="speakDay(day)">
|
||
<text class="day-speak-icon">🔊</text>
|
||
<text class="day-speak-text">朗读</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 血压与用药 -->
|
||
<view v-if="day.blood && day.blood.hasBpOrMed" class="day-block">
|
||
<view class="day-block-head">
|
||
<view class="day-block-tag tag-blood">血压 · 用药</view>
|
||
<text v-if="day.blood.record_time" class="day-block-time">{{ day.blood.record_time }}</text>
|
||
</view>
|
||
<view class="metric-grid">
|
||
<view v-if="day.blood.bpText && day.blood.bpText !== '—'" class="metric-cell">
|
||
<view class="metric-row-head">
|
||
<text class="metric-label">血压</text>
|
||
<view v-if="day.blood.isHighBp" class="metric-flag up">
|
||
<text class="metric-flag-icon">▲</text>
|
||
<text class="metric-flag-text">偏高</text>
|
||
</view>
|
||
</view>
|
||
<view class="metric-value-row">
|
||
<text class="metric-value" :class="{ 'is-high': day.blood.isHighBp }">{{ day.blood.bpText }}</text>
|
||
<text class="metric-unit">mmHg</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="day.blood.western_medicine || day.blood.insulin" class="day-meta-row">
|
||
<text v-if="day.blood.western_medicine" class="meta-chip">💊 {{ day.blood.western_medicine }}</text>
|
||
<text v-if="day.blood.insulin" class="meta-chip">💉 {{ day.blood.insulin }}</text>
|
||
</view>
|
||
<view v-if="day.blood.remark" class="day-remark"><text>{{ day.blood.remark }}</text></view>
|
||
</view>
|
||
|
||
<!-- 饮食 -->
|
||
<view v-if="day.diet" class="day-block">
|
||
<view class="day-block-head">
|
||
<view class="day-block-tag tag-diet">饮食</view>
|
||
</view>
|
||
<view class="diet-rows">
|
||
<view v-if="day.diet.breakfast_foods" class="diet-row">
|
||
<text class="diet-meal">🌞 早餐</text>
|
||
<text class="diet-text">{{ day.diet.breakfast_foods }}</text>
|
||
</view>
|
||
<view v-if="day.diet.lunch_foods" class="diet-row">
|
||
<text class="diet-meal">🍱 午餐</text>
|
||
<text class="diet-text">{{ day.diet.lunch_foods }}</text>
|
||
</view>
|
||
<view v-if="day.diet.dinner_foods" class="diet-row">
|
||
<text class="diet-meal">🌙 晚餐</text>
|
||
<text class="diet-text">{{ day.diet.dinner_foods }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="day.diet.note" class="day-remark">
|
||
<text>{{ day.diet.note }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 运动 -->
|
||
<view v-if="day.exercise" class="day-block">
|
||
<view class="day-block-head">
|
||
<view class="day-block-tag tag-exercise">运动</view>
|
||
</view>
|
||
<view class="exercise-row">
|
||
<text class="exercise-type">{{ day.exercise.exercise_type || '运动' }}</text>
|
||
<text v-if="day.exercise.duration" class="exercise-duration">{{ day.exercise.duration }} 分钟</text>
|
||
<text v-if="day.exercise.intensityText" class="exercise-intensity">{{ day.exercise.intensityText }}</text>
|
||
</view>
|
||
<view v-if="day.exercise.note" class="day-remark">
|
||
<text>{{ day.exercise.note }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 跟踪备注 -->
|
||
<view v-if="day.tracking && day.tracking.content" class="day-block">
|
||
<view class="day-block-head">
|
||
<view class="day-block-tag tag-tracking">备注</view>
|
||
</view>
|
||
<view class="tracking-content">
|
||
<text
|
||
v-for="(line, idx) in splitTrackingLines(day.tracking.content)"
|
||
:key="idx"
|
||
class="tracking-line"
|
||
>
|
||
{{ line }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="footer-tip">
|
||
<text>数据每日同步 · 阈值仅作参考,请遵医嘱</text>
|
||
</view>
|
||
|
||
<!-- 录入今日血糖 -->
|
||
<view v-if="inputForm.visible" class="input-modal-mask" @click="closeInputForm">
|
||
<view class="input-modal" @click.stop>
|
||
<view class="input-modal-grip" />
|
||
<view class="input-modal-head">
|
||
<view class="input-modal-title-wrap">
|
||
<text class="input-modal-title">{{ inputForm.id ? '修改今日血糖' : '录入今日血糖' }}</text>
|
||
<text class="input-modal-sub">{{ todayText }} · {{ patientName || '本人' }}</text>
|
||
</view>
|
||
<view class="input-modal-close" @click="closeInputForm">
|
||
<text class="input-modal-close-icon">×</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="inputForm.loading" class="input-modal-loading">
|
||
<text>加载今日数据中…</text>
|
||
</view>
|
||
<scroll-view v-else scroll-y class="input-modal-body input-modal-body-ready">
|
||
<view class="input-modal-tip">
|
||
<text>请如实填写,留空表示该项不上传。当天可多次修改。</text>
|
||
</view>
|
||
<view class="input-section-title">血糖(mmol/L)</view>
|
||
<view class="input-field">
|
||
<view class="input-field-label">
|
||
<view class="input-field-dot input-field-dot-fasting" />
|
||
<text>空腹血糖</text>
|
||
</view>
|
||
<view class="input-field-input">
|
||
<input
|
||
type="digit"
|
||
:value="inputForm.fasting_blood_sugar"
|
||
placeholder="如 6.5"
|
||
placeholder-class="input-placeholder"
|
||
@input="onInputField('fasting_blood_sugar', $event)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
<view class="input-field">
|
||
<view class="input-field-label">
|
||
<view class="input-field-dot input-field-dot-postprandial" />
|
||
<text>餐后血糖</text>
|
||
</view>
|
||
<view class="input-field-input">
|
||
<input
|
||
type="digit"
|
||
:value="inputForm.postprandial_blood_sugar"
|
||
placeholder="如 8.0"
|
||
placeholder-class="input-placeholder"
|
||
@input="onInputField('postprandial_blood_sugar', $event)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
<view class="input-field">
|
||
<view class="input-field-label">
|
||
<view class="input-field-dot input-field-dot-other" />
|
||
<text>其他血糖</text>
|
||
</view>
|
||
<view class="input-field-input">
|
||
<input
|
||
type="digit"
|
||
:value="inputForm.other_blood_sugar"
|
||
placeholder="如 7.2"
|
||
placeholder-class="input-placeholder"
|
||
@input="onInputField('other_blood_sugar', $event)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
<view class="input-section-title">备注</view>
|
||
<view class="input-field input-field-textarea">
|
||
<textarea
|
||
:value="inputForm.remark"
|
||
placeholder="如:饭前空腹测量、餐后2小时测量等(选填)"
|
||
placeholder-class="input-placeholder"
|
||
:show-confirm-bar="false"
|
||
:auto-height="true"
|
||
@input="onInputField('remark', $event)"
|
||
/>
|
||
</view>
|
||
</scroll-view>
|
||
<view class="input-modal-actions">
|
||
<view
|
||
v-if="inputForm.id"
|
||
class="input-modal-btn danger"
|
||
:class="{ disabled: inputForm.submitting }"
|
||
@click="deleteInputRecord"
|
||
>
|
||
<text>删除</text>
|
||
</view>
|
||
<view
|
||
class="input-modal-btn primary"
|
||
:class="{ disabled: inputForm.submitting || inputForm.loading }"
|
||
@click="submitInputForm"
|
||
>
|
||
<text>{{ inputForm.submitting ? '提交中…' : (inputForm.id ? '保存修改' : '提交录入') }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 录入血压弹层 -->
|
||
<view v-if="bpForm.visible" class="input-modal-mask" @click="closeBpForm">
|
||
<view class="input-modal" @click.stop>
|
||
<view class="input-modal-grip" />
|
||
<view class="input-modal-head">
|
||
<view class="input-modal-title-wrap">
|
||
<text class="input-modal-title">{{ bpForm.id ? '修改今日血压' : '录入今日血压' }}</text>
|
||
<text class="input-modal-sub">{{ todayText }} · {{ patientName || '本人' }}</text>
|
||
</view>
|
||
<view class="input-modal-close" @click="closeBpForm"><text class="input-modal-close-icon">×</text></view>
|
||
</view>
|
||
<view v-if="bpForm.loading" class="input-modal-loading"><text>加载中…</text></view>
|
||
<scroll-view v-else scroll-y class="input-modal-body">
|
||
<view class="input-section-title">血压(mmHg)</view>
|
||
<view class="input-field-row">
|
||
<view class="input-field input-field-half">
|
||
<view class="input-field-label"><text>高压</text></view>
|
||
<view class="input-field-input">
|
||
<input type="number" :value="bpForm.systolic_pressure" placeholder="如 120" placeholder-class="input-placeholder" @input="onBpField('systolic_pressure', $event)" />
|
||
</view>
|
||
</view>
|
||
<view class="input-field input-field-half">
|
||
<view class="input-field-label"><text>低压</text></view>
|
||
<view class="input-field-input">
|
||
<input type="number" :value="bpForm.diastolic_pressure" placeholder="如 80" placeholder-class="input-placeholder" @input="onBpField('diastolic_pressure', $event)" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="input-section-title">用药(选填)</view>
|
||
<view class="input-field">
|
||
<view class="input-field-label"><text>西药</text></view>
|
||
<view class="input-field-input">
|
||
<input type="text" :value="bpForm.western_medicine" placeholder="如:二甲双胍" placeholder-class="input-placeholder" @input="onBpField('western_medicine', $event)" />
|
||
</view>
|
||
</view>
|
||
<view class="input-field">
|
||
<view class="input-field-label"><text>胰岛素</text></view>
|
||
<view class="input-field-input">
|
||
<input type="text" :value="bpForm.insulin" placeholder="如:门冬 8U" placeholder-class="input-placeholder" @input="onBpField('insulin', $event)" />
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
<view class="input-modal-actions">
|
||
<view v-if="bpForm.id" class="input-modal-btn danger" :class="{ disabled: bpForm.submitting }" @click="deleteBpRecord"><text>删除</text></view>
|
||
<view class="input-modal-btn primary" :class="{ disabled: bpForm.submitting || bpForm.loading }" @click="submitBpForm"><text>{{ bpForm.submitting ? '提交中…' : '保存' }}</text></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 录入今日饮食 -->
|
||
<view v-if="dietForm.visible" class="input-modal-mask" @click="closeDietForm">
|
||
<view class="input-modal" @click.stop>
|
||
<view class="input-modal-grip" />
|
||
<view class="input-modal-head">
|
||
<view class="input-modal-title-wrap">
|
||
<text class="input-modal-title">记今日饮食</text>
|
||
<text class="input-modal-sub">{{ todayText }}</text>
|
||
</view>
|
||
<view class="input-modal-close" @click="closeDietForm"><text class="input-modal-close-icon">×</text></view>
|
||
</view>
|
||
<view v-if="dietForm.loading" class="input-modal-loading"><text>加载中…</text></view>
|
||
<scroll-view v-else scroll-y class="input-modal-body">
|
||
<view v-if="dietAiPrefill && dietAiPrefill.breakfast" class="input-modal-ai-tip">
|
||
<text class="input-modal-ai-tip-label">AI 推荐</text>
|
||
<text class="input-modal-ai-tip-text">早 {{ dietAiPrefill.breakfast }} · 午 {{ dietAiPrefill.lunch }} · 晚 {{ dietAiPrefill.dinner }}</text>
|
||
<view class="input-modal-ai-tip-btn" @click="fillDietFormFromAi">
|
||
<text>一键填入</text>
|
||
</view>
|
||
</view>
|
||
<view class="input-modal-tip"><text>至少填写一餐,完成后可浇水领取积分。</text></view>
|
||
<view class="input-field">
|
||
<view class="input-field-label"><text>早餐</text></view>
|
||
<view class="input-field-input">
|
||
<input type="text" :value="dietForm.breakfast_foods" placeholder="如:玉米碴粥、鸡蛋" placeholder-class="input-placeholder" @input="onDietField('breakfast_foods', $event)" />
|
||
</view>
|
||
</view>
|
||
<view class="input-field">
|
||
<view class="input-field-label"><text>午餐</text></view>
|
||
<view class="input-field-input">
|
||
<input type="text" :value="dietForm.lunch_foods" placeholder="如:二米饭、炖白菜豆腐" placeholder-class="input-placeholder" @input="onDietField('lunch_foods', $event)" />
|
||
</view>
|
||
</view>
|
||
<view class="input-field">
|
||
<view class="input-field-label"><text>晚餐</text></view>
|
||
<view class="input-field-input">
|
||
<input type="text" :value="dietForm.dinner_foods" placeholder="如:杂面窝头、炒茄子" placeholder-class="input-placeholder" @input="onDietField('dinner_foods', $event)" />
|
||
</view>
|
||
</view>
|
||
<view class="input-field input-field-textarea">
|
||
<textarea :value="dietForm.note" placeholder="饮食备注(选填)" placeholder-class="input-placeholder" :show-confirm-bar="false" :auto-height="true" @input="onDietField('note', $event)" />
|
||
</view>
|
||
</scroll-view>
|
||
<view class="input-modal-actions">
|
||
<view class="input-modal-btn primary" :class="{ disabled: dietForm.submitting }" @click="submitDietForm">
|
||
<text>{{ dietForm.submitting ? '提交中…' : '保存饮食' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 录入今日运动 -->
|
||
<view v-if="exerciseForm.visible" class="input-modal-mask" @click="closeExerciseForm">
|
||
<view class="input-modal" @click.stop>
|
||
<view class="input-modal-grip" />
|
||
<view class="input-modal-head">
|
||
<view class="input-modal-title-wrap">
|
||
<text class="input-modal-title">记今日运动</text>
|
||
<text class="input-modal-sub">{{ todayText }}</text>
|
||
</view>
|
||
<view class="input-modal-close" @click="closeExerciseForm"><text class="input-modal-close-icon">×</text></view>
|
||
</view>
|
||
<view v-if="exerciseForm.loading" class="input-modal-loading"><text>加载中…</text></view>
|
||
<scroll-view v-else scroll-y class="input-modal-body">
|
||
<view class="input-modal-tip"><text>填写运动类型或时长,完成后可浇水领取积分。</text></view>
|
||
<view class="input-field">
|
||
<view class="input-field-label"><text>运动类型</text></view>
|
||
<view class="input-field-input">
|
||
<input type="text" :value="exerciseForm.exercise_type" placeholder="如:散步、太极拳" placeholder-class="input-placeholder" @input="onExerciseField('exercise_type', $event)" />
|
||
</view>
|
||
</view>
|
||
<view class="input-field">
|
||
<view class="input-field-label"><text>时长(分钟)</text></view>
|
||
<view class="input-field-input">
|
||
<input type="number" :value="exerciseForm.duration" placeholder="如:30" placeholder-class="input-placeholder" @input="onExerciseField('duration', $event)" />
|
||
</view>
|
||
</view>
|
||
<view class="input-section-title">运动强度</view>
|
||
<view class="intensity-row">
|
||
<view
|
||
v-for="opt in exerciseIntensityOptions"
|
||
:key="opt.value"
|
||
class="intensity-chip"
|
||
:class="{ active: exerciseForm.intensity === opt.value }"
|
||
@click="exerciseForm.intensity = opt.value"
|
||
>
|
||
<text>{{ opt.label }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
<view class="input-modal-actions">
|
||
<view class="input-modal-btn primary" :class="{ disabled: exerciseForm.submitting }" @click="submitExerciseForm">
|
||
<text>{{ exerciseForm.submitting ? '提交中…' : '保存运动' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 本周战报(脱敏,可分享)-->
|
||
<view v-if="weeklyReportVisible" class="report-mask" @click="closeWeeklyReport">
|
||
<view class="report-panel" @click.stop>
|
||
<view class="report-panel-head">
|
||
<text class="report-panel-title">本周稳糖战报</text>
|
||
<text class="report-panel-close" @click="closeWeeklyReport">×</text>
|
||
</view>
|
||
<view class="report-poster">
|
||
<view class="report-poster-top">
|
||
<SugarTreeGraphic :level="reportTreeLevel" size="md" />
|
||
<text class="report-poster-name">{{ weeklyReportData.patientLabel }}</text>
|
||
</view>
|
||
<text class="report-poster-week">{{ weeklyReportData.weekLabel }}</text>
|
||
<view class="report-stat-grid">
|
||
<view class="report-stat-item">
|
||
<text class="report-stat-num">{{ weeklyReportData.recordDays }}</text>
|
||
<text class="report-stat-label">天有记录</text>
|
||
</view>
|
||
<view class="report-stat-item">
|
||
<text class="report-stat-num">{{ weeklyReportData.completeDays }}</text>
|
||
<text class="report-stat-label">完整一日</text>
|
||
</view>
|
||
<view class="report-stat-item">
|
||
<text class="report-stat-num">{{ weeklyReportData.streakDays }}</text>
|
||
<text class="report-stat-label">连续天数</text>
|
||
</view>
|
||
</view>
|
||
<text class="report-poster-quote">{{ weeklyReportData.quote }}</text>
|
||
<text class="report-poster-disclaimer">习惯参考,不替代医嘱 · 家人打开分享可查看近7日记录</text>
|
||
</view>
|
||
<view class="report-actions">
|
||
<!-- #ifdef MP-WEIXIN -->
|
||
<button class="report-share-btn" open-type="share" @click="prepareShareWeekly">分享给家人</button>
|
||
<!-- #endif -->
|
||
<!-- #ifndef MP-WEIXIN -->
|
||
<view class="report-share-btn" @click="shareWeeklyFallback"><text>分享给家人</text></view>
|
||
<!-- #endif -->
|
||
<view class="report-secondary-btn" @click="closeWeeklyReport"><text>关闭</text></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</block>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, getCurrentInstance, nextTick, watch } from 'vue'
|
||
import { onLoad, onShow, onHide, onUnload, onPullDownRefresh, onShareAppMessage } from '@dcloudio/uni-app'
|
||
import SugarTreeGraphic from '../components/SugarTreeGraphic.vue'
|
||
import TongjiIcon from '../components/TongjiIcon.vue'
|
||
import CelebrateBurst from '../components/CelebrateBurst.vue'
|
||
import { TREE_LEVELS, TREE_MAX_LEVEL, calcTreeFromPoints, pickTreeWhisper } from '../utils/treeLevels.js'
|
||
import { consumeDietAiPrefill } from '../composables/useDietAi.js'
|
||
|
||
const { proxy } = getCurrentInstance()
|
||
|
||
/** 将接口 msg / 异常对象转为可展示的字符串,避免 [object Object] */
|
||
function formatUserMessage(msg, fallback = '') {
|
||
if (msg == null || msg === '') return fallback
|
||
if (typeof msg === 'string') return msg
|
||
if (typeof msg === 'number' || typeof msg === 'boolean') return String(msg)
|
||
if (Array.isArray(msg)) {
|
||
const parts = msg.map((item) => {
|
||
if (item == null) return ''
|
||
if (typeof item === 'string') return item
|
||
if (typeof item === 'number' || typeof item === 'boolean') return String(item)
|
||
if (typeof item === 'object') {
|
||
return item.msg || item.message || item.error || item.title || item.label || ''
|
||
}
|
||
return String(item)
|
||
}).filter(Boolean)
|
||
return parts.length ? parts.join(';') : fallback
|
||
}
|
||
if (typeof msg === 'object') {
|
||
return msg.msg || msg.message || msg.error || msg.title || msg.label || fallback
|
||
}
|
||
return String(msg)
|
||
}
|
||
|
||
function showUserToast(title, options = {}) {
|
||
const text = formatUserMessage(title, '')
|
||
if (!text) return
|
||
uni.showToast({
|
||
title: text,
|
||
icon: options.icon || 'none',
|
||
duration: options.duration
|
||
})
|
||
}
|
||
|
||
const diagnosisId = ref(0)
|
||
const patientId = ref(0)
|
||
const patientName = ref('')
|
||
const age = ref(0)
|
||
const gender = ref(0)
|
||
|
||
/** 登录 / 就诊卡门禁校验中(避免未登录时闪屏) */
|
||
const authChecking = ref(false)
|
||
let gateRedirected = false
|
||
|
||
const loading = ref(false)
|
||
const cardsLoading = ref(false)
|
||
const cards = ref([])
|
||
const rangeMode = ref('7')
|
||
const rangeOptions = [
|
||
{ value: '7', label: '最近 7 天' },
|
||
{ value: '30', label: '最近 30 天' }
|
||
]
|
||
|
||
const diagnosisTypeMap = {
|
||
first_visit: '初诊',
|
||
follow_up: '复诊',
|
||
consultation: '会诊'
|
||
}
|
||
|
||
const syndromeTypeMap = {
|
||
qi_deficiency: '气虚',
|
||
blood_deficiency: '血虚',
|
||
yin_deficiency: '阴虚',
|
||
yang_deficiency: '阳虚',
|
||
qi_stagnation: '气滞',
|
||
blood_stasis: '血瘀',
|
||
phlegm_dampness: '痰湿',
|
||
damp_heat: '湿热',
|
||
cold_dampness: '寒湿',
|
||
wind_cold: '风寒',
|
||
wind_heat: '风热'
|
||
}
|
||
|
||
function getCardSubtitle(card) {
|
||
if (!card) return ''
|
||
const parts = []
|
||
if (card.gender_desc) parts.push(card.gender_desc)
|
||
else if (Number(card.gender) === 1) parts.push('男')
|
||
else if (Number(card.gender) === 2 || Number(card.gender) === 0) parts.push('女')
|
||
if (card.age) parts.push(`${card.age}岁`)
|
||
const dt = diagnosisTypeMap[card.diagnosis_type] || card.diagnosis_type
|
||
if (dt) parts.push(dt)
|
||
const st = syndromeTypeMap[card.syndrome_type] || card.syndrome_type
|
||
if (st) parts.push(st)
|
||
return parts.join(' · ')
|
||
}
|
||
|
||
const bloodRecords = ref([])
|
||
const dietRecords = ref([])
|
||
const exerciseRecords = ref([])
|
||
const trackingNotes = ref([])
|
||
|
||
const hoverInfo = ref(null)
|
||
let hoverTimer = null
|
||
|
||
|
||
const ageText = computed(() => (age.value > 0 ? `${age.value} 岁` : ''))
|
||
const genderText = computed(() => {
|
||
const map = { 1: '男', 2: '女' }
|
||
return map[Number(gender.value)] || ''
|
||
})
|
||
const rangeText = computed(() => {
|
||
const opt = rangeOptions.find((o) => o.value === rangeMode.value)
|
||
return opt ? opt.label : ''
|
||
})
|
||
|
||
// ============ 时间区间 ============
|
||
function formatDate(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 parseDate(str) {
|
||
if (!str) return new Date()
|
||
const [y, m, d] = str.split('-').map(Number)
|
||
return new Date(y, (m || 1) - 1, d || 1)
|
||
}
|
||
|
||
const currentRange = computed(() => {
|
||
const days = rangeMode.value === '30' ? 30 : 7
|
||
const end = new Date()
|
||
end.setHours(0, 0, 0, 0)
|
||
const start = new Date(end)
|
||
start.setDate(start.getDate() - (days - 1))
|
||
return { start: formatDate(start), end: formatDate(end) }
|
||
})
|
||
|
||
const visibleDates = computed(() => {
|
||
const { start, end } = currentRange.value
|
||
if (!start || !end) return []
|
||
const dates = []
|
||
const cursor = parseDate(end)
|
||
const lower = parseDate(start).getTime()
|
||
while (cursor.getTime() >= lower) {
|
||
dates.push(formatDate(cursor))
|
||
cursor.setDate(cursor.getDate() - 1)
|
||
}
|
||
return dates
|
||
})
|
||
|
||
const chartDates = computed(() => [...visibleDates.value].reverse())
|
||
|
||
// ============ 数据聚合 ============
|
||
function toNumber(v) {
|
||
if (v === null || v === undefined || v === '') return null
|
||
const n = Number(v)
|
||
return Number.isFinite(n) && n !== 0 ? n : null
|
||
}
|
||
|
||
function getBloodSugarThresholds(ageValue) {
|
||
const a = Number(ageValue)
|
||
if (!Number.isFinite(a) || a <= 0) return null
|
||
if (a < 50) return { fasting: 7, postprandial: 9 }
|
||
return { fasting: 8, postprandial: 10 }
|
||
}
|
||
|
||
function isHighFasting(v) {
|
||
const n = toNumber(v)
|
||
const t = getBloodSugarThresholds(age.value)
|
||
return n !== null && t !== null && n >= t.fasting
|
||
}
|
||
|
||
function isHighPostprandial(v) {
|
||
const n = toNumber(v)
|
||
const t = getBloodSugarThresholds(age.value)
|
||
return n !== null && t !== null && n >= t.postprandial
|
||
}
|
||
|
||
function isHighBp(row) {
|
||
if (!row) return false
|
||
const s = toNumber(row.systolic_pressure)
|
||
const d = toNumber(row.diastolic_pressure)
|
||
return (s !== null && s > 140) || (d !== null && d > 90)
|
||
}
|
||
|
||
function formatBp(row) {
|
||
const s = row?.systolic_pressure
|
||
const d = row?.diastolic_pressure
|
||
if ((s === null || s === undefined || s === '' || s === 0) && (d === null || d === undefined || d === '' || d === 0)) return '—'
|
||
return `${s || '—'} / ${d || '—'}`
|
||
}
|
||
|
||
function formatValue(v) {
|
||
const n = toNumber(v)
|
||
return n === null ? '—' : String(n)
|
||
}
|
||
|
||
const intensityTextMap = { 1: '低强度', 2: '中强度', 3: '高强度' }
|
||
|
||
const bloodByDate = computed(() => {
|
||
const map = {}
|
||
for (const r of bloodRecords.value) {
|
||
const date = String(r.record_date || '')
|
||
if (!date) continue
|
||
if (!map[date]) {
|
||
map[date] = {
|
||
record_date: date,
|
||
record_time: r.record_time || '',
|
||
fasting_blood_sugar: '',
|
||
postprandial_blood_sugar: '',
|
||
other_blood_sugar: '',
|
||
systolic_pressure: '',
|
||
diastolic_pressure: '',
|
||
western_medicine: '',
|
||
insulin: '',
|
||
remark: ''
|
||
}
|
||
}
|
||
const merged = map[date]
|
||
const fields = ['fasting_blood_sugar', 'postprandial_blood_sugar', 'other_blood_sugar', 'systolic_pressure', 'diastolic_pressure', 'western_medicine', 'insulin', 'remark']
|
||
for (const f of fields) {
|
||
if ((merged[f] === '' || merged[f] === null || merged[f] === undefined) && r[f] !== '' && r[f] !== null && r[f] !== undefined) {
|
||
merged[f] = r[f]
|
||
}
|
||
}
|
||
}
|
||
for (const date in map) {
|
||
const m = map[date]
|
||
m.isHighFasting = isHighFasting(m.fasting_blood_sugar)
|
||
m.isHighPostprandial = isHighPostprandial(m.postprandial_blood_sugar)
|
||
m.isHighOther = isHighPostprandial(m.other_blood_sugar)
|
||
m.isHighBp = isHighBp(m)
|
||
m.bpText = formatBp(m)
|
||
m.hasBpOrMed = m.bpText !== '—' || !!(m.western_medicine || m.insulin || m.remark)
|
||
}
|
||
return map
|
||
})
|
||
|
||
const dietByDate = computed(() => {
|
||
const map = {}
|
||
for (const r of dietRecords.value) {
|
||
const date = String(r.record_date || '')
|
||
if (!date || map[date]) continue
|
||
map[date] = r
|
||
}
|
||
return map
|
||
})
|
||
|
||
const exerciseByDate = computed(() => {
|
||
const map = {}
|
||
for (const r of exerciseRecords.value) {
|
||
const date = String(r.record_date || '')
|
||
if (!date || map[date]) continue
|
||
const item = { ...r }
|
||
item.intensityText = intensityTextMap[Number(r.intensity)] || ''
|
||
map[date] = item
|
||
}
|
||
return map
|
||
})
|
||
|
||
const trackingByDate = computed(() => {
|
||
const map = {}
|
||
for (const r of trackingNotes.value) {
|
||
const date = String(r.note_date || '')
|
||
if (!date || map[date]) continue
|
||
map[date] = r
|
||
}
|
||
return map
|
||
})
|
||
|
||
const weekdayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||
|
||
function getWeekday(dateStr) {
|
||
return weekdayNames[parseDate(dateStr).getDay()] || ''
|
||
}
|
||
|
||
const todayStr = computed(() => formatDate(new Date()))
|
||
|
||
const recordDays = computed(() => {
|
||
const result = []
|
||
for (const date of visibleDates.value) {
|
||
const bloodRaw = bloodByDate.value[date]
|
||
const blood = bloodRaw && bloodRaw.hasBpOrMed ? bloodRaw : null
|
||
const diet = dietByDate.value[date]
|
||
const exercise = exerciseByDate.value[date]
|
||
const tracking = trackingByDate.value[date]
|
||
const hasAny = blood || diet || exercise || (tracking && tracking.content)
|
||
if (!hasAny) continue
|
||
const [y, m, d] = date.split('-')
|
||
result.push({
|
||
date,
|
||
dateLabel: `${m}-${d}`,
|
||
weekday: getWeekday(date),
|
||
isToday: date === todayStr.value,
|
||
blood,
|
||
diet,
|
||
exercise,
|
||
tracking
|
||
})
|
||
}
|
||
return result
|
||
})
|
||
|
||
const bloodRecordDays = computed(() => {
|
||
const result = []
|
||
for (const date of visibleDates.value) {
|
||
const blood = bloodByDate.value[date]
|
||
if (!blood) continue
|
||
const hasSugar = toNumber(blood.fasting_blood_sugar) !== null
|
||
|| toNumber(blood.postprandial_blood_sugar) !== null
|
||
|| toNumber(blood.other_blood_sugar) !== null
|
||
if (!hasSugar) continue
|
||
const [y, m, d] = date.split('-')
|
||
result.push({
|
||
date,
|
||
dateLabel: `${m}-${d}`,
|
||
weekday: getWeekday(date),
|
||
isToday: date === todayStr.value,
|
||
blood
|
||
})
|
||
}
|
||
return result
|
||
})
|
||
|
||
const bloodHasData = computed(() => {
|
||
return chartDates.value.some((date) => {
|
||
const b = bloodByDate.value[date]
|
||
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
|
||
})
|
||
})
|
||
|
||
const bloodRecordDayCount = computed(() => {
|
||
return chartDates.value.filter((date) => {
|
||
const b = bloodByDate.value[date]
|
||
return b && (toNumber(b.fasting_blood_sugar) !== null || toNumber(b.postprandial_blood_sugar) !== null)
|
||
}).length
|
||
})
|
||
|
||
const bloodHighDayCount = computed(() => {
|
||
return chartDates.value.filter((date) => {
|
||
const b = bloodByDate.value[date]
|
||
return b && (b.isHighFasting || b.isHighPostprandial)
|
||
}).length
|
||
})
|
||
|
||
const bloodNormalDayCount = computed(() => bloodRecordDayCount.value - bloodHighDayCount.value)
|
||
|
||
// 趋势比较:dir = up/down/flat, delta = 与前一次的绝对差
|
||
// 注意:对于血糖/血压,「up = 升高 = 风险」,所以语义颜色按健康角度而非数学方向
|
||
function makeTrend(curr, prev, precision = 1) {
|
||
if (curr == null || prev == null) return null
|
||
const diff = Number(curr) - Number(prev)
|
||
if (!Number.isFinite(diff)) return null
|
||
const abs = Math.abs(diff)
|
||
const dir = abs < 0.01 ? 'flat' : diff > 0 ? 'up' : 'down'
|
||
const sign = dir === 'up' ? '+' : dir === 'down' ? '-' : ''
|
||
const deltaText = dir === 'flat' ? '持平' : `${sign}${abs.toFixed(precision)}`
|
||
return { dir, delta: abs, deltaText, prev }
|
||
}
|
||
|
||
// 找某字段在指定日期之前最近一条非空记录
|
||
function findPrev(field, beforeDate) {
|
||
const dates = chartDates.value
|
||
const idx = dates.indexOf(beforeDate)
|
||
if (idx <= 0) return null
|
||
for (let i = idx - 1; i >= 0; i--) {
|
||
const b = bloodByDate.value[dates[i]]
|
||
if (!b) continue
|
||
const v = toNumber(b[field])
|
||
if (v !== null) return { date: dates[i], value: v }
|
||
}
|
||
return null
|
||
}
|
||
|
||
function findPrevBp(beforeDate) {
|
||
const dates = chartDates.value
|
||
const idx = dates.indexOf(beforeDate)
|
||
if (idx <= 0) return null
|
||
for (let i = idx - 1; i >= 0; i--) {
|
||
const b = bloodByDate.value[dates[i]]
|
||
if (!b) continue
|
||
const s = toNumber(b.systolic_pressure)
|
||
const d = toNumber(b.diastolic_pressure)
|
||
if (s !== null || d !== null) return { date: dates[i], systolic: s, diastolic: d }
|
||
}
|
||
return null
|
||
}
|
||
|
||
const latestStat = computed(() => {
|
||
const dates = [...chartDates.value].reverse()
|
||
const dummy = { value: '—', date: '暂无记录', isHigh: false, trend: null }
|
||
const result = { fasting: { ...dummy }, postprandial: { ...dummy }, other: { ...dummy } }
|
||
for (const date of dates) {
|
||
const b = bloodByDate.value[date]
|
||
if (!b) continue
|
||
if (result.fasting.value === '—' && toNumber(b.fasting_blood_sugar) !== null) {
|
||
const cur = toNumber(b.fasting_blood_sugar)
|
||
const prev = findPrev('fasting_blood_sugar', date)
|
||
result.fasting = {
|
||
value: String(b.fasting_blood_sugar),
|
||
date: date.slice(5),
|
||
isHigh: b.isHighFasting,
|
||
trend: prev ? makeTrend(cur, prev.value) : null
|
||
}
|
||
}
|
||
if (result.postprandial.value === '—' && toNumber(b.postprandial_blood_sugar) !== null) {
|
||
const cur = toNumber(b.postprandial_blood_sugar)
|
||
const prev = findPrev('postprandial_blood_sugar', date)
|
||
result.postprandial = {
|
||
value: String(b.postprandial_blood_sugar),
|
||
date: date.slice(5),
|
||
isHigh: b.isHighPostprandial,
|
||
trend: prev ? makeTrend(cur, prev.value) : null
|
||
}
|
||
}
|
||
if (result.other.value === '—' && toNumber(b.other_blood_sugar) !== null) {
|
||
const cur = toNumber(b.other_blood_sugar)
|
||
const prev = findPrev('other_blood_sugar', date)
|
||
result.other = {
|
||
value: String(b.other_blood_sugar),
|
||
date: date.slice(5),
|
||
isHigh: b.isHighOther,
|
||
trend: prev ? makeTrend(cur, prev.value) : null
|
||
}
|
||
}
|
||
if (result.fasting.value !== '—' && result.postprandial.value !== '—' && result.other.value !== '—') break
|
||
}
|
||
return result
|
||
})
|
||
|
||
// 给某天某字段计算 day-over-day 趋势(用于日常记录列表的指标小标签)
|
||
function getDayTrend(date, field, precision = 1) {
|
||
const b = bloodByDate.value[date]
|
||
if (!b) return null
|
||
const cur = toNumber(b[field])
|
||
if (cur === null) return null
|
||
const prev = findPrev(field, date)
|
||
if (!prev) return null
|
||
return makeTrend(cur, prev.value, precision)
|
||
}
|
||
|
||
function getDayBpTrend(date) {
|
||
const b = bloodByDate.value[date]
|
||
if (!b) return null
|
||
const cur = toNumber(b.systolic_pressure)
|
||
if (cur === null) return null
|
||
const prev = findPrevBp(date)
|
||
if (!prev || prev.systolic == null) return null
|
||
return makeTrend(cur, prev.systolic, 0)
|
||
}
|
||
|
||
// ============ 登录 / 就诊卡门禁 ============
|
||
/** 进行中的登录任务(避免与 App.onLaunch 重复发起 mnpLogin,并保证业务接口在其之后) */
|
||
let authSessionPromise = null
|
||
|
||
function hasAuthToken() {
|
||
return !!String(uni.getStorageSync('token') || '').trim()
|
||
}
|
||
|
||
function clearAuthStorage() {
|
||
uni.removeStorageSync('token')
|
||
authSessionPromise = null
|
||
}
|
||
|
||
function wxLoginGetCode() {
|
||
return new Promise((resolve, reject) => {
|
||
uni.login({
|
||
provider: 'weixin',
|
||
success: (res) => {
|
||
if (res && res.code) resolve(res.code)
|
||
else reject(new Error('微信登录未返回 code'))
|
||
},
|
||
fail: reject
|
||
})
|
||
})
|
||
}
|
||
|
||
async function mnpLoginWithCode(code) {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/login/mnpLogin',
|
||
method: 'POST',
|
||
data: { code }
|
||
}, false)
|
||
if (res && res.code === 1 && res.data && res.data.token) {
|
||
uni.setStorageSync('token', res.data.token)
|
||
uni.setStorageSync('userData', res.data)
|
||
return res.data
|
||
}
|
||
clearAuthStorage()
|
||
throw new Error(formatUserMessage(res?.msg, '登录失败'))
|
||
}
|
||
|
||
async function doWxLogin() {
|
||
const code = await wxLoginGetCode()
|
||
await mnpLoginWithCode(code)
|
||
return hasAuthToken()
|
||
}
|
||
|
||
/** 校验 token 有效;无效则清除并重新 mnpLogin(缺 token 时后端 code=0,不能当作已登录) */
|
||
async function verifyOrLogin() {
|
||
if (!hasAuthToken()) {
|
||
return doWxLogin()
|
||
}
|
||
try {
|
||
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
|
||
if (res && res.code === 1 && res.data) {
|
||
uni.setStorageSync('userData', res.data)
|
||
return true
|
||
}
|
||
} catch (e) {
|
||
/* 网络异常走重新登录 */
|
||
}
|
||
clearAuthStorage()
|
||
try {
|
||
return await doWxLogin()
|
||
} catch (e2) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/** 登录完成后再调业务接口;并发调用共享同一 Promise */
|
||
async function ensureLoggedIn() {
|
||
if (authSessionPromise) {
|
||
return authSessionPromise
|
||
}
|
||
authSessionPromise = verifyOrLogin()
|
||
.then((ok) => {
|
||
if (!ok) authSessionPromise = null
|
||
return !!ok
|
||
})
|
||
.catch(() => {
|
||
authSessionPromise = null
|
||
return false
|
||
})
|
||
return authSessionPromise
|
||
}
|
||
|
||
function redirectToCardEntry() {
|
||
if (gateRedirected) return
|
||
gateRedirected = true
|
||
const returnUrl = encodeURIComponent('/tongji/pages/more')
|
||
uni.redirectTo({
|
||
url: `/pages/Card/edit_card?add=1&returnUrl=${returnUrl}`
|
||
})
|
||
}
|
||
|
||
// ============ 就诊卡 ============
|
||
async function fetchCardList() {
|
||
if (!hasAuthToken()) return []
|
||
cardsLoading.value = true
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/getCardList',
|
||
method: 'GET',
|
||
data: {}
|
||
}, false)
|
||
if (res && res.code === 1 && Array.isArray(res.data)) {
|
||
cards.value = res.data
|
||
return res.data
|
||
}
|
||
cards.value = []
|
||
return []
|
||
} catch (e) {
|
||
cards.value = []
|
||
return []
|
||
} finally {
|
||
cardsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function applyCard(card) {
|
||
if (!card || !hasAuthToken()) return
|
||
diagnosisId.value = Number(card.id) || 0
|
||
patientId.value = Number(card.patient_id) || 0
|
||
patientName.value = card.patient_name || ''
|
||
age.value = Number(card.age) || 0
|
||
gender.value = Number(card.gender) || 0
|
||
lastFamilyLikeCount.value = getStoredFamilyLikeSeen(Number(card.id) || 0)
|
||
familyLikeSummary.value = {
|
||
like_count: 0,
|
||
summary_line: '分享战报给家人,邀请他们为您点赞鼓劲',
|
||
recent: []
|
||
}
|
||
gamifyStatusTip.value = ''
|
||
await fetchGamifyState()
|
||
}
|
||
|
||
async function onSelectCard(card) {
|
||
if (!card || Number(card.id) === Number(diagnosisId.value)) return
|
||
bloodRecords.value = []
|
||
dietRecords.value = []
|
||
exerciseRecords.value = []
|
||
trackingNotes.value = []
|
||
hoverInfo.value = null
|
||
await applyCard(card)
|
||
fetchAll()
|
||
}
|
||
|
||
// ============ 接口请求 ============
|
||
async function fetchAll() {
|
||
if (!hasAuthToken() || !diagnosisId.value) return
|
||
loading.value = true
|
||
const { start, end } = currentRange.value
|
||
try {
|
||
const [winRes, noteRes] = await Promise.all([
|
||
proxy.apiUrl({
|
||
url: '/api/tcm/dailyTrackingWindow',
|
||
method: 'GET',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
patient_id: patientId.value || 0,
|
||
start_date: start,
|
||
end_date: end
|
||
}
|
||
}, false),
|
||
proxy.apiUrl({
|
||
url: '/api/tcm/dailyTrackingNotes',
|
||
method: 'GET',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
patient_id: patientId.value || 0
|
||
}
|
||
}, false)
|
||
])
|
||
|
||
if (winRes && winRes.code === 1) {
|
||
const d = winRes.data || {}
|
||
bloodRecords.value = Array.isArray(d.blood_records) ? d.blood_records : []
|
||
dietRecords.value = Array.isArray(d.diet_records) ? d.diet_records : []
|
||
exerciseRecords.value = Array.isArray(d.exercise_records) ? d.exercise_records : []
|
||
if (d.diagnosis) {
|
||
if (!patientName.value) patientName.value = d.diagnosis.patient_name || ''
|
||
if (!age.value) age.value = Number(d.diagnosis.age) || 0
|
||
if (!gender.value) gender.value = Number(d.diagnosis.gender) || 0
|
||
if (!patientId.value) patientId.value = Number(d.diagnosis.patient_id) || 0
|
||
}
|
||
} else {
|
||
bloodRecords.value = []
|
||
dietRecords.value = []
|
||
exerciseRecords.value = []
|
||
if (winRes?.msg) showUserToast(winRes.msg)
|
||
}
|
||
|
||
if (noteRes && noteRes.code === 1) {
|
||
const list = Array.isArray(noteRes.data) ? noteRes.data : []
|
||
trackingNotes.value = list.filter((it) => visibleDates.value.includes(String(it?.note_date || '')))
|
||
} else {
|
||
trackingNotes.value = []
|
||
}
|
||
// 拉取游戏化积分/小树状态
|
||
await fetchGamifyState()
|
||
await loadFamilyLikeSummary()
|
||
} catch (e) {
|
||
uni.showToast({ title: '加载失败,请稍后再试', icon: 'none' })
|
||
} finally {
|
||
loading.value = false
|
||
// 自动播报:数据就绪后朗读一次最新摘要
|
||
if (ttsAutoPlay.value && !recordOverlayOpen.value) {
|
||
const token = ++ttsAutoToken
|
||
setTimeout(() => {
|
||
if (token === ttsAutoToken && !recordOverlayOpen.value) speakLatestSummary()
|
||
}, 350)
|
||
}
|
||
}
|
||
}
|
||
|
||
function onChangeRange(value) {
|
||
if (rangeMode.value === value) return
|
||
rangeMode.value = value
|
||
fetchAll()
|
||
}
|
||
|
||
function onRefresh() {
|
||
fetchAll()
|
||
}
|
||
|
||
// ============ 时段问候 / 打卡 / 健康日历 ============
|
||
/** 时段问候:文案 + 统一 TongjiIcon(与全页图标体系一致)*/
|
||
function buildTimeGreetingMeta(hour = new Date().getHours()) {
|
||
const h = Number(hour) || 0
|
||
if (h < 5) return { text: '夜深了', icon: 'moon' }
|
||
if (h < 11) return { text: '早上好', icon: 'sun' }
|
||
if (h < 13) return { text: '中午好', icon: 'sun' }
|
||
if (h < 18) return { text: '下午好', icon: 'sun' }
|
||
if (h < 22) return { text: '晚上好', icon: 'sunset' }
|
||
return { text: '夜深了', icon: 'moon' }
|
||
}
|
||
|
||
const timeGreetingMeta = computed(() => buildTimeGreetingMeta())
|
||
|
||
// 复用已有的 formatDate(date) -> 'YYYY-MM-DD'
|
||
|
||
function dayHasBloodRecord(key) {
|
||
const b = bloodByDate.value[key]
|
||
if (!b) return false
|
||
return toNumber(b.fasting_blood_sugar) !== null ||
|
||
toNumber(b.postprandial_blood_sugar) !== null ||
|
||
toNumber(b.other_blood_sugar) !== null ||
|
||
toNumber(b.systolic_pressure) !== null ||
|
||
toNumber(b.diastolic_pressure) !== null
|
||
}
|
||
|
||
// 给一天打个完整度分(0-3):0 无 / 1 仅 1 项 / 2 有 2~3 项 / 3 有 4 项以上
|
||
function dayCoverageLevel(key) {
|
||
const b = bloodByDate.value[key]
|
||
const dt = dietByDate.value[key]
|
||
const ex = exerciseByDate.value[key]
|
||
if (!b && !dt && !ex) return 0
|
||
let score = 0
|
||
if (b) {
|
||
const fields = ['fasting_blood_sugar', 'postprandial_blood_sugar', 'other_blood_sugar', 'systolic_pressure', 'diastolic_pressure']
|
||
score += fields.filter((f) => toNumber(b[f]) !== null).length
|
||
}
|
||
if (dt && (dt.breakfast_foods || dt.lunch_foods || dt.dinner_foods)) score += 1
|
||
if (ex && (ex.exercise_type || ex.duration)) score += 1
|
||
if (score === 0) return 0
|
||
if (score === 1) return 1
|
||
if (score <= 3) return 2
|
||
return 3
|
||
}
|
||
|
||
// todayStr 已在上方定义,这里直接复用
|
||
const todayChecked = computed(() => dayHasBloodRecord(todayStr.value))
|
||
|
||
// 连续打卡天数:从今天往前回溯,连续有 blood 记录的天数;今天没打卡时从昨天起算(保留 streak 心态)
|
||
const streakDays = computed(() => {
|
||
const today = new Date()
|
||
let count = 0
|
||
let startedFromYesterday = false
|
||
for (let i = 0; i < 90; i++) {
|
||
const d = new Date(today)
|
||
d.setDate(today.getDate() - i)
|
||
const key = formatDate(d)
|
||
const has = dayHasBloodRecord(key)
|
||
if (has) {
|
||
count++
|
||
} else {
|
||
if (i === 0) {
|
||
// 今天还没测:从昨天开始算 streak(不打断)
|
||
startedFromYesterday = true
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
}
|
||
return { days: count, startedFromYesterday }
|
||
})
|
||
|
||
// 近 7 天打卡情况:从 6 天前到今天
|
||
const last7Dots = computed(() => {
|
||
const arr = []
|
||
const today = new Date()
|
||
const wkChars = ['日', '一', '二', '三', '四', '五', '六']
|
||
for (let i = 6; i >= 0; i--) {
|
||
const d = new Date(today)
|
||
d.setDate(today.getDate() - i)
|
||
const key = formatDate(d)
|
||
arr.push({
|
||
date: key,
|
||
label: wkChars[d.getDay()],
|
||
isToday: i === 0,
|
||
hasRecord: dayHasBloodRecord(key)
|
||
})
|
||
}
|
||
return arr
|
||
})
|
||
|
||
const last7CheckedCount = computed(() => last7Dots.value.filter((d) => d.hasRecord).length)
|
||
|
||
function dayHasFastingRecord(key) {
|
||
const b = bloodByDate.value[key]
|
||
return !!(b && toNumber(b.fasting_blood_sugar) !== null)
|
||
}
|
||
|
||
function dayHasPostprandialRecord(key) {
|
||
const b = bloodByDate.value[key]
|
||
return !!(b && toNumber(b.postprandial_blood_sugar) !== null)
|
||
}
|
||
|
||
function dayIsCompleteRecord(key) {
|
||
return dayHasFastingRecord(key) && dayHasPostprandialRecord(key)
|
||
}
|
||
|
||
const weekCompleteDays = computed(() => last7Dots.value.filter((d) => dayIsCompleteRecord(d.date)).length)
|
||
|
||
// 健康日历热力图:取当前数据范围内所有日期,按周组装成 6x7(最多)网格
|
||
const heatmapWeeks = computed(() => {
|
||
const dates = chartDates.value
|
||
if (!dates || !dates.length) return []
|
||
// 确保按时间正序
|
||
const sorted = [...dates].sort()
|
||
// 找到第一天所在周的周一,最后一天所在周的周日
|
||
const first = new Date(sorted[0])
|
||
const last = new Date(sorted[sorted.length - 1])
|
||
// JS getDay: 0=Sun..6=Sat;我们以周一为起点
|
||
const firstDow = (first.getDay() + 6) % 7 // 0=Mon..6=Sun
|
||
const lastDow = (last.getDay() + 6) % 7
|
||
const gridStart = new Date(first)
|
||
gridStart.setDate(first.getDate() - firstDow)
|
||
const gridEnd = new Date(last)
|
||
gridEnd.setDate(last.getDate() + (6 - lastDow))
|
||
|
||
const totalDays = Math.round((gridEnd - gridStart) / 86400000) + 1
|
||
const weeks = []
|
||
let cur = new Date(gridStart)
|
||
for (let w = 0; w < Math.ceil(totalDays / 7); w++) {
|
||
const week = []
|
||
for (let i = 0; i < 7; i++) {
|
||
const key = formatDate(cur)
|
||
const inRange = sorted.includes(key)
|
||
week.push({
|
||
date: key,
|
||
day: cur.getDate(),
|
||
inRange,
|
||
isToday: key === todayStr.value,
|
||
level: inRange ? dayCoverageLevel(key) : -1
|
||
})
|
||
cur.setDate(cur.getDate() + 1)
|
||
}
|
||
weeks.push(week)
|
||
}
|
||
return weeks
|
||
})
|
||
|
||
const heatmapTotalRecords = computed(() => {
|
||
let c = 0
|
||
for (const w of heatmapWeeks.value) for (const c2 of w) if (c2.inRange && c2.level > 0) c++
|
||
return c
|
||
})
|
||
|
||
const heatmapInRangeDays = computed(() => {
|
||
let c = 0
|
||
for (const w of heatmapWeeks.value) for (const c2 of w) if (c2.inRange) c++
|
||
return c
|
||
})
|
||
|
||
// 月历的"月份标签":取热力图覆盖范围里跨度最大的月份
|
||
const heatmapMonthLabel = computed(() => {
|
||
const weeks = heatmapWeeks.value
|
||
if (!weeks.length) return ''
|
||
const counter = new Map()
|
||
for (const w of weeks) {
|
||
for (const c of w) {
|
||
if (!c.inRange) continue
|
||
const [y, m] = c.date.split('-')
|
||
const key = `${y}-${m}`
|
||
counter.set(key, (counter.get(key) || 0) + 1)
|
||
}
|
||
}
|
||
if (!counter.size) {
|
||
const first = weeks[0][0].date.split('-')
|
||
return `${Number(first[0])}年${Number(first[1])}月`
|
||
}
|
||
let best = ''
|
||
let bestCount = -1
|
||
for (const [k, v] of counter) {
|
||
if (v > bestCount) { best = k; bestCount = v }
|
||
}
|
||
const [y, m] = best.split('-')
|
||
return `${Number(y)}年${Number(m)}月`
|
||
})
|
||
|
||
function jumpToDayRecord(date) {
|
||
// 滚动到下方对应日期 records 列表项;若不在 visible 列表则提示
|
||
if (!bloodRecordDays.value.some((d) => d.date === date)) {
|
||
uni.showToast({ title: '该日无记录', icon: 'none' })
|
||
return
|
||
}
|
||
uni.createSelectorQuery().in(proxy)
|
||
.select(`#day-${date.replace(/-/g, '')}`)
|
||
.boundingClientRect()
|
||
.selectViewport()
|
||
.scrollOffset()
|
||
.exec((res) => {
|
||
if (!res || !res[0]) return
|
||
const top = res[0].top + (res[1] ? res[1].scrollTop : 0) - 80
|
||
uni.pageScrollTo({ scrollTop: top, duration: 280 })
|
||
})
|
||
}
|
||
|
||
// ============ 录入今日数据 ============
|
||
const todayText = computed(() => {
|
||
const d = new Date()
|
||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||
const day = String(d.getDate()).padStart(2, '0')
|
||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||
return `${d.getFullYear()}-${m}-${day} ${weekdays[d.getDay()]}`
|
||
})
|
||
|
||
const emptyInputForm = () => ({
|
||
visible: false,
|
||
loading: false,
|
||
submitting: false,
|
||
id: 0,
|
||
fasting_blood_sugar: '',
|
||
postprandial_blood_sugar: '',
|
||
other_blood_sugar: '',
|
||
remark: ''
|
||
})
|
||
const inputForm = ref(emptyInputForm())
|
||
|
||
const emptyBpForm = () => ({
|
||
visible: false,
|
||
loading: false,
|
||
submitting: false,
|
||
id: 0,
|
||
systolic_pressure: '',
|
||
diastolic_pressure: '',
|
||
western_medicine: '',
|
||
insulin: '',
|
||
remark: ''
|
||
})
|
||
const bpForm = ref(emptyBpForm())
|
||
|
||
function prepareRecordOverlay() {
|
||
ttsStop()
|
||
}
|
||
|
||
function onInputField(field, e) {
|
||
inputForm.value[field] = e?.detail?.value ?? ''
|
||
}
|
||
|
||
async function openInputForm() {
|
||
if (!diagnosisId.value) {
|
||
showUserToast('请先选择就诊卡')
|
||
return
|
||
}
|
||
const token = uni.getStorageSync('token')
|
||
if (!token) {
|
||
showUserToast('请先登录后再录入')
|
||
return
|
||
}
|
||
prepareRecordOverlay()
|
||
inputForm.value = { ...emptyInputForm(), visible: true, loading: true }
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyTodayBloodRecord',
|
||
method: 'GET',
|
||
data: { diagnosis_id: diagnosisId.value }
|
||
}, false)
|
||
if (res && res.code === 1 && res.data && res.data.record) {
|
||
const r = res.data.record
|
||
inputForm.value.id = Number(r.id) || 0
|
||
inputForm.value.fasting_blood_sugar = numToStr(r.fasting_blood_sugar)
|
||
inputForm.value.postprandial_blood_sugar = numToStr(r.postprandial_blood_sugar)
|
||
inputForm.value.other_blood_sugar = numToStr(r.other_blood_sugar)
|
||
inputForm.value.remark = String(r.remark || '')
|
||
}
|
||
} catch (e) {
|
||
/* 静默:拉取失败时仍允许新增录入 */
|
||
} finally {
|
||
inputForm.value.loading = false
|
||
}
|
||
}
|
||
|
||
function closeInputForm() {
|
||
if (inputForm.value.submitting) return
|
||
inputForm.value.visible = false
|
||
}
|
||
|
||
async function submitInputForm() {
|
||
if (inputForm.value.submitting || inputForm.value.loading) return
|
||
const f = inputForm.value
|
||
const hasAny = [
|
||
f.fasting_blood_sugar, f.postprandial_blood_sugar, f.other_blood_sugar
|
||
].some((v) => v !== '' && v !== null && Number(v) > 0) || !!(f.remark && f.remark.trim())
|
||
if (!hasAny) {
|
||
showUserToast('请至少填写一项数据')
|
||
return
|
||
}
|
||
inputForm.value.submitting = true
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailySaveBloodRecord',
|
||
method: 'POST',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
fasting_blood_sugar: f.fasting_blood_sugar,
|
||
postprandial_blood_sugar: f.postprandial_blood_sugar,
|
||
other_blood_sugar: f.other_blood_sugar,
|
||
remark: f.remark
|
||
}
|
||
}, false)
|
||
if (res && res.code === 1) {
|
||
showUserToast(res.msg || '已保存', { icon: 'success' })
|
||
inputForm.value.visible = false
|
||
await fetchAll()
|
||
await fetchGamifyState()
|
||
if (claimablePoints.value > 0) {
|
||
showUserToast('血糖已记录,点击浇水领取积分', { duration: 2800 })
|
||
}
|
||
} else {
|
||
showUserToast((res && res.msg) || '保存失败')
|
||
}
|
||
} catch (e) {
|
||
showUserToast('网络异常,请稍后再试')
|
||
} finally {
|
||
inputForm.value.submitting = false
|
||
}
|
||
}
|
||
|
||
function deleteInputRecord() {
|
||
if (!inputForm.value.id || inputForm.value.submitting) return
|
||
uni.showModal({
|
||
title: '删除确认',
|
||
content: '确定删除今天已录入的血糖数据吗?删除后无法恢复。',
|
||
confirmColor: '#dc2626',
|
||
success: async (m) => {
|
||
if (!m.confirm) return
|
||
inputForm.value.submitting = true
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyDeleteBloodRecord',
|
||
method: 'POST',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
id: inputForm.value.id
|
||
}
|
||
}, false)
|
||
if (res && res.code === 1) {
|
||
uni.showToast({ title: '已删除', icon: 'success' })
|
||
inputForm.value.visible = false
|
||
await fetchAll()
|
||
await fetchGamifyState()
|
||
} else {
|
||
showUserToast((res && res.msg) || '删除失败')
|
||
}
|
||
} catch (e) {
|
||
uni.showToast({ title: '网络异常,请稍后再试', icon: 'none' })
|
||
} finally {
|
||
inputForm.value.submitting = false
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
function onBpField(field, e) {
|
||
const val = e?.detail?.value ?? ''
|
||
bpForm.value[field] = val
|
||
}
|
||
|
||
async function openBpForm() {
|
||
if (!diagnosisId.value) {
|
||
showUserToast('请先选择就诊卡')
|
||
return
|
||
}
|
||
const token = uni.getStorageSync('token')
|
||
if (!token) {
|
||
showUserToast('请先登录后再录入')
|
||
return
|
||
}
|
||
// 微信小程序 canvas 是原生组件、z-index 无法被弹层覆盖,
|
||
// 这里通过 v-if 卸载它;下次显示时 selectorQuery 拿到的会是新节点,
|
||
// 因此先清空缓存的 ctx / node,避免下次 ensureCanvas 复用旧引用。
|
||
prepareRecordOverlay()
|
||
// 打开弹层,先拉取今日已有记录预填
|
||
bpForm.value = { ...emptyBpForm(), visible: true, loading: true }
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyTodayBloodRecord',
|
||
method: 'GET',
|
||
data: { diagnosis_id: diagnosisId.value }
|
||
}, false)
|
||
if (res && res.code === 1 && res.data && res.data.record) {
|
||
const r = res.data.record
|
||
bpForm.value.id = Number(r.id) || 0
|
||
bpForm.value.systolic_pressure = numToStr(r.systolic_pressure)
|
||
bpForm.value.diastolic_pressure = numToStr(r.diastolic_pressure)
|
||
bpForm.value.western_medicine = String(r.western_medicine || '')
|
||
bpForm.value.insulin = String(r.insulin || '')
|
||
bpForm.value.remark = String(r.remark || '')
|
||
}
|
||
} catch (e) {
|
||
// 静默:拉取失败时仍允许新增录入
|
||
} finally {
|
||
bpForm.value.loading = false
|
||
}
|
||
}
|
||
|
||
function numToStr(v) {
|
||
if (v === null || v === undefined || v === '' || v === 0 || v === '0') return ''
|
||
if (typeof v === 'object') return ''
|
||
return String(v)
|
||
}
|
||
|
||
function closeBpForm() {
|
||
if (bpForm.value.submitting) return
|
||
bpForm.value.visible = false
|
||
// 关闭后 canvas 会重新挂载,需触发一次重绘
|
||
nextTick(() => {})
|
||
}
|
||
|
||
const emptyDietForm = () => ({
|
||
visible: false,
|
||
loading: false,
|
||
submitting: false,
|
||
id: 0,
|
||
breakfast_foods: '',
|
||
lunch_foods: '',
|
||
dinner_foods: '',
|
||
note: ''
|
||
})
|
||
const dietForm = ref(emptyDietForm())
|
||
const dietAiPrefill = ref(null)
|
||
|
||
function fillDietFormFromAi() {
|
||
const r = dietAiPrefill.value
|
||
if (!r) return
|
||
if (r.breakfast) dietForm.value.breakfast_foods = r.breakfast
|
||
if (r.lunch) dietForm.value.lunch_foods = r.lunch
|
||
if (r.dinner) dietForm.value.dinner_foods = r.dinner
|
||
if (r.tips) {
|
||
const note = String(dietForm.value.note || '').trim()
|
||
dietForm.value.note = note ? `${note};${r.tips}` : r.tips
|
||
}
|
||
}
|
||
|
||
function onDietField(field, e) {
|
||
dietForm.value[field] = e?.detail?.value ?? ''
|
||
}
|
||
|
||
async function openDietForm(options = {}) {
|
||
const autoFill = !!options.autoFill
|
||
if (!dietAiPrefill.value) {
|
||
dietAiPrefill.value = consumeDietAiPrefill()
|
||
}
|
||
if (!diagnosisId.value) {
|
||
showUserToast('请先选择就诊卡')
|
||
return
|
||
}
|
||
const token = uni.getStorageSync('token')
|
||
if (!token) {
|
||
showUserToast('请先登录后再录入')
|
||
return
|
||
}
|
||
prepareRecordOverlay()
|
||
dietForm.value = { ...emptyDietForm(), visible: true, loading: true }
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyTodayDietRecord',
|
||
method: 'GET',
|
||
data: { diagnosis_id: diagnosisId.value }
|
||
}, false)
|
||
if (res?.code === 1 && res.data?.record) {
|
||
const r = res.data.record
|
||
dietForm.value.id = Number(r.id) || 0
|
||
dietForm.value.breakfast_foods = String(r.breakfast_foods || '')
|
||
dietForm.value.lunch_foods = String(r.lunch_foods || '')
|
||
dietForm.value.dinner_foods = String(r.dinner_foods || '')
|
||
dietForm.value.note = String(r.note || '')
|
||
}
|
||
} catch (e) {}
|
||
finally {
|
||
dietForm.value.loading = false
|
||
if (autoFill && dietAiPrefill.value?.breakfast) {
|
||
fillDietFormFromAi()
|
||
}
|
||
}
|
||
}
|
||
|
||
function closeDietForm() {
|
||
if (dietForm.value.submitting) return
|
||
dietForm.value.visible = false
|
||
dietAiPrefill.value = null
|
||
nextTick(() => {})
|
||
}
|
||
|
||
async function submitDietForm() {
|
||
if (dietForm.value.submitting || dietForm.value.loading) return
|
||
const f = dietForm.value
|
||
if (!f.breakfast_foods.trim() && !f.lunch_foods.trim() && !f.dinner_foods.trim()) {
|
||
showUserToast('请至少填写一餐饮食')
|
||
return
|
||
}
|
||
dietForm.value.submitting = true
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailySaveDietRecord',
|
||
method: 'POST',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
breakfast_foods: f.breakfast_foods,
|
||
lunch_foods: f.lunch_foods,
|
||
dinner_foods: f.dinner_foods,
|
||
note: f.note
|
||
}
|
||
}, false)
|
||
if (res?.code === 1) {
|
||
showUserToast(res.msg || '已保存', { icon: 'success' })
|
||
dietForm.value.visible = false
|
||
await fetchAll()
|
||
await fetchGamifyState()
|
||
if (claimablePoints.value > 0) {
|
||
showUserToast('饮食已记录,点击浇水领取积分', { duration: 2800 })
|
||
}
|
||
} else {
|
||
showUserToast((res && res.msg) || '保存失败')
|
||
}
|
||
} catch (e) {
|
||
showUserToast('网络异常,请稍后再试')
|
||
} finally {
|
||
dietForm.value.submitting = false
|
||
}
|
||
}
|
||
|
||
const exerciseIntensityOptions = [
|
||
{ value: 1, label: '低强度' },
|
||
{ value: 2, label: '中强度' },
|
||
{ value: 3, label: '高强度' }
|
||
]
|
||
const emptyExerciseForm = () => ({
|
||
visible: false,
|
||
loading: false,
|
||
submitting: false,
|
||
id: 0,
|
||
exercise_type: '',
|
||
duration: '',
|
||
intensity: 2,
|
||
note: ''
|
||
})
|
||
const exerciseForm = ref(emptyExerciseForm())
|
||
|
||
/** 任一录入弹层打开时需卸载 canvas(小程序原生组件会盖住普通 view)*/
|
||
const recordOverlayOpen = computed(() =>
|
||
inputForm.value.visible || bpForm.value.visible || dietForm.value.visible || exerciseForm.value.visible
|
||
)
|
||
|
||
function onExerciseField(field, e) {
|
||
exerciseForm.value[field] = e?.detail?.value ?? ''
|
||
}
|
||
|
||
async function openExerciseForm() {
|
||
if (!diagnosisId.value) {
|
||
showUserToast('请先选择就诊卡')
|
||
return
|
||
}
|
||
const token = uni.getStorageSync('token')
|
||
if (!token) {
|
||
showUserToast('请先登录后再录入')
|
||
return
|
||
}
|
||
prepareRecordOverlay()
|
||
exerciseForm.value = { ...emptyExerciseForm(), visible: true, loading: true }
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyTodayExerciseRecord',
|
||
method: 'GET',
|
||
data: { diagnosis_id: diagnosisId.value }
|
||
}, false)
|
||
if (res?.code === 1 && res.data?.record) {
|
||
const r = res.data.record
|
||
exerciseForm.value.id = Number(r.id) || 0
|
||
exerciseForm.value.exercise_type = String(r.exercise_type || '')
|
||
exerciseForm.value.duration = numToStr(r.duration)
|
||
exerciseForm.value.intensity = Number(r.intensity) || 2
|
||
exerciseForm.value.note = String(r.note || '')
|
||
}
|
||
} catch (e) {}
|
||
finally {
|
||
exerciseForm.value.loading = false
|
||
}
|
||
}
|
||
|
||
function closeExerciseForm() {
|
||
if (exerciseForm.value.submitting) return
|
||
exerciseForm.value.visible = false
|
||
nextTick(() => {})
|
||
}
|
||
|
||
async function submitExerciseForm() {
|
||
if (exerciseForm.value.submitting || exerciseForm.value.loading) return
|
||
const f = exerciseForm.value
|
||
if (!f.exercise_type.trim() && !f.duration) {
|
||
showUserToast('请填写运动类型或时长')
|
||
return
|
||
}
|
||
exerciseForm.value.submitting = true
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailySaveExerciseRecord',
|
||
method: 'POST',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
exercise_type: f.exercise_type,
|
||
duration: f.duration,
|
||
intensity: f.intensity,
|
||
note: f.note
|
||
}
|
||
}, false)
|
||
if (res?.code === 1) {
|
||
showUserToast(res.msg || '已保存', { icon: 'success' })
|
||
exerciseForm.value.visible = false
|
||
await fetchAll()
|
||
await fetchGamifyState()
|
||
if (claimablePoints.value > 0) {
|
||
showUserToast('运动已记录,点击浇水领取积分', { duration: 2800 })
|
||
}
|
||
} else {
|
||
showUserToast((res && res.msg) || '保存失败')
|
||
}
|
||
} catch (e) {
|
||
showUserToast('网络异常,请稍后再试')
|
||
} finally {
|
||
exerciseForm.value.submitting = false
|
||
}
|
||
}
|
||
|
||
async function submitBpForm() {
|
||
if (bpForm.value.submitting || bpForm.value.loading) return
|
||
|
||
// 至少填一个有效字段
|
||
const f = bpForm.value
|
||
const hasAny = [
|
||
f.systolic_pressure, f.diastolic_pressure
|
||
].some((v) => v !== '' && v !== null && Number(v) > 0)
|
||
|| !!(String(f.western_medicine || '').trim() || String(f.insulin || '').trim() || String(f.remark || '').trim())
|
||
if (!hasAny) {
|
||
uni.showToast({ title: '请至少填写一项数据', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
bpForm.value.submitting = true
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailySaveBloodRecord',
|
||
method: 'POST',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
systolic_pressure: f.systolic_pressure,
|
||
diastolic_pressure: f.diastolic_pressure,
|
||
western_medicine: f.western_medicine,
|
||
insulin: f.insulin,
|
||
remark: f.remark
|
||
}
|
||
}, false)
|
||
if (res && res.code === 1) {
|
||
showUserToast(res.msg || '已保存', { icon: 'success' })
|
||
bpForm.value.visible = false
|
||
await fetchAll()
|
||
await fetchGamifyState()
|
||
if (claimablePoints.value > 0) {
|
||
showUserToast('记录已保存,点击浇水领取积分', { duration: 2800 })
|
||
}
|
||
} else {
|
||
showUserToast((res && res.msg) || '保存失败')
|
||
}
|
||
} catch (e) {
|
||
uni.showToast({ title: '网络异常,请稍后再试', icon: 'none' })
|
||
} finally {
|
||
bpForm.value.submitting = false
|
||
}
|
||
}
|
||
|
||
function deleteBpRecord() {
|
||
if (!bpForm.value.id || bpForm.value.submitting) return
|
||
uni.showModal({
|
||
title: '删除确认',
|
||
content: '确定删除今天已录入的数据吗?删除后无法恢复。',
|
||
confirmColor: '#dc2626',
|
||
success: async (m) => {
|
||
if (!m.confirm) return
|
||
bpForm.value.submitting = true
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyDeleteBloodRecord',
|
||
method: 'POST',
|
||
data: {
|
||
diagnosis_id: diagnosisId.value,
|
||
id: bpForm.value.id
|
||
}
|
||
}, false)
|
||
if (res && res.code === 1) {
|
||
uni.showToast({ title: '已删除', icon: 'success' })
|
||
bpForm.value.visible = false
|
||
fetchAll()
|
||
} else {
|
||
showUserToast((res && res.msg) || '删除失败')
|
||
}
|
||
} catch (e) {
|
||
uni.showToast({ title: '网络异常,请稍后再试', icon: 'none' })
|
||
} finally {
|
||
bpForm.value.submitting = false
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
function splitTrackingLines(content) {
|
||
return String(content || '')
|
||
.split('\n')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
// ============ 语音播报(适老化)============
|
||
// 优先用 微信同声传译插件 (mp-weixin);H5 走 window.speechSynthesis;
|
||
// 都不可用则轻提示,避免 showModal 挡住录入弹层。
|
||
const ttsEnabled = ref(true)
|
||
const ttsAutoPlay = ref(false)
|
||
const ttsSpeaking = ref(false)
|
||
let ttsAudioCtx = null
|
||
let ttsAutoToken = 0
|
||
|
||
function teardownTtsAudio() {
|
||
if (ttsAudioCtx) {
|
||
try { ttsAudioCtx.stop() } catch (e) {}
|
||
try { ttsAudioCtx.destroy && ttsAudioCtx.destroy() } catch (e) {}
|
||
ttsAudioCtx = null
|
||
}
|
||
}
|
||
|
||
function ttsStop() {
|
||
teardownTtsAudio()
|
||
// #ifdef H5
|
||
try {
|
||
if (typeof window !== 'undefined' && window.speechSynthesis) {
|
||
window.speechSynthesis.cancel()
|
||
}
|
||
} catch (e) {}
|
||
// #endif
|
||
ttsSpeaking.value = false
|
||
}
|
||
|
||
function normalizeTtsText(text) {
|
||
if (text == null) return ''
|
||
if (typeof text === 'string') return text.replace(/\s+/g, ' ').trim()
|
||
if (Array.isArray(text)) {
|
||
return text
|
||
.map((item) => {
|
||
if (typeof item === 'string') return item
|
||
if (item && typeof item === 'object') {
|
||
return item.title || item.label || item.desc || item.msg || item.message || ''
|
||
}
|
||
return formatUserMessage(item, '')
|
||
})
|
||
.filter(Boolean)
|
||
.join(',')
|
||
}
|
||
if (typeof text === 'object') {
|
||
return text.title || text.label || text.desc || text.msg || text.message || ''
|
||
}
|
||
return formatUserMessage(text, '')
|
||
}
|
||
|
||
function ttsSpeak(text) {
|
||
if (!ttsEnabled.value) return
|
||
const content = normalizeTtsText(text)
|
||
if (!content) return
|
||
|
||
ttsStop()
|
||
|
||
// H5:浏览器 SpeechSynthesis(慢速适老化)
|
||
// #ifdef H5
|
||
try {
|
||
if (typeof window !== 'undefined' && window.speechSynthesis) {
|
||
const u = new window.SpeechSynthesisUtterance(content)
|
||
u.lang = 'zh-CN'
|
||
u.rate = 0.85
|
||
u.pitch = 1
|
||
u.volume = 1
|
||
u.onstart = () => { ttsSpeaking.value = true }
|
||
u.onend = () => { ttsSpeaking.value = false }
|
||
u.onerror = () => { ttsSpeaking.value = false }
|
||
window.speechSynthesis.speak(u)
|
||
return
|
||
}
|
||
} catch (e) {}
|
||
// #endif
|
||
|
||
// 微信小程序:同声传译插件 TTS → mp3 → innerAudioContext 播放
|
||
// #ifdef MP-WEIXIN
|
||
try {
|
||
// eslint-disable-next-line no-undef
|
||
const plugin = requirePlugin('WechatSI')
|
||
ttsSpeaking.value = true
|
||
plugin.textToSpeech({
|
||
lang: 'zh_CN',
|
||
tts: true,
|
||
content,
|
||
success: (res) => {
|
||
if (!res || !res.filename) {
|
||
ttsSpeaking.value = false
|
||
return
|
||
}
|
||
teardownTtsAudio()
|
||
ttsAudioCtx = uni.createInnerAudioContext()
|
||
ttsAudioCtx.src = res.filename
|
||
ttsAudioCtx.obeyMuteSwitch = false
|
||
ttsAudioCtx.onPlay(() => { ttsSpeaking.value = true })
|
||
ttsAudioCtx.onEnded(() => { ttsSpeaking.value = false })
|
||
ttsAudioCtx.onError(() => { ttsSpeaking.value = false })
|
||
ttsAudioCtx.onStop(() => { ttsSpeaking.value = false })
|
||
ttsAudioCtx.play()
|
||
},
|
||
fail: (err) => {
|
||
ttsSpeaking.value = false
|
||
console.warn('WechatSI textToSpeech failed', err)
|
||
uni.showToast({ title: '语音不可用,请检查网络/插件', icon: 'none', duration: 2000 })
|
||
}
|
||
})
|
||
return
|
||
} catch (e) {
|
||
console.warn('WechatSI plugin not available', e)
|
||
}
|
||
// #endif
|
||
|
||
// 其它端兜底:轻提示,勿用 modal 遮挡录入等操作
|
||
showUserToast(content, { duration: 3200 })
|
||
}
|
||
|
||
function ttsToggleEnabled() {
|
||
ttsEnabled.value = !ttsEnabled.value
|
||
if (!ttsEnabled.value) {
|
||
ttsAutoPlay.value = false
|
||
ttsStop()
|
||
}
|
||
}
|
||
|
||
function ttsToggleAutoPlay() {
|
||
if (!ttsEnabled.value) {
|
||
ttsEnabled.value = true
|
||
}
|
||
ttsAutoPlay.value = !ttsAutoPlay.value
|
||
if (ttsAutoPlay.value) {
|
||
const text = buildLatestSummaryText()
|
||
if (text) ttsSpeak(text)
|
||
} else {
|
||
ttsStop()
|
||
}
|
||
}
|
||
|
||
// ============ 播报文案构造 ============
|
||
function readableNumber(v) {
|
||
if (v === null || v === undefined || v === '') return ''
|
||
return String(v).replace(/(\d+)\.(\d+)/g, (_, a, b) => `${a}点${b}`)
|
||
}
|
||
|
||
function buildLatestSummaryText() {
|
||
const f = latestStat.value.fasting
|
||
const p = latestStat.value.postprandial
|
||
const o = latestStat.value.other
|
||
const head = `${patientName.value || '您'}的最新血糖情况:`
|
||
const lines = []
|
||
if (f && f.value !== '—') {
|
||
let line = `空腹血糖 ${readableNumber(f.value)} 毫摩每升`
|
||
if (f.isHigh) line += ',偏高,请遵医嘱。'
|
||
else line += '。'
|
||
if (f.trend && f.trend.dir !== 'flat') {
|
||
line += `比上次${f.trend.dir === 'up' ? '升高' : '下降'} ${readableNumber(f.trend.delta.toFixed(1))}。`
|
||
}
|
||
lines.push(line)
|
||
}
|
||
if (p && p.value !== '—') {
|
||
let line = `餐后血糖 ${readableNumber(p.value)} 毫摩每升`
|
||
if (p.isHigh) line += ',偏高,请注意饮食。'
|
||
else line += '。'
|
||
if (p.trend && p.trend.dir !== 'flat') {
|
||
line += `比上次${p.trend.dir === 'up' ? '升高' : '下降'} ${readableNumber(p.trend.delta.toFixed(1))}。`
|
||
}
|
||
lines.push(line)
|
||
}
|
||
if (o && o.value !== '—') {
|
||
let line = `其他时段血糖 ${readableNumber(o.value)} 毫摩每升`
|
||
if (o.isHigh) line += ',偏高,请遵医嘱。'
|
||
else line += '。'
|
||
if (o.trend && o.trend.dir !== 'flat') {
|
||
line += `比上次${o.trend.dir === 'up' ? '升高' : '下降'} ${readableNumber(o.trend.delta.toFixed(1))}。`
|
||
}
|
||
lines.push(line)
|
||
}
|
||
if (!lines.length) {
|
||
return `${patientName.value || '您'}近期暂无血糖记录。`
|
||
}
|
||
return head + lines.join(' ')
|
||
}
|
||
|
||
function buildDayText(day) {
|
||
if (!day) return ''
|
||
const dateLabel = `${day.dateLabel.replace('-', '月')}日${day.weekday ? ' ' + day.weekday : ''}`
|
||
const lines = [`${dateLabel} 的健康记录:`]
|
||
if (day.blood) {
|
||
const b = day.blood
|
||
if (toNumber(b.fasting_blood_sugar) !== null) {
|
||
lines.push(`空腹血糖 ${readableNumber(b.fasting_blood_sugar)} 毫摩每升${b.isHighFasting ? ',偏高。' : '。'}`)
|
||
}
|
||
if (toNumber(b.postprandial_blood_sugar) !== null) {
|
||
lines.push(`餐后血糖 ${readableNumber(b.postprandial_blood_sugar)} 毫摩每升${b.isHighPostprandial ? ',偏高。' : '。'}`)
|
||
}
|
||
if (toNumber(b.other_blood_sugar) !== null) {
|
||
lines.push(`其他血糖 ${readableNumber(b.other_blood_sugar)} 毫摩每升${b.isHighOther ? ',偏高。' : '。'}`)
|
||
}
|
||
const sys = toNumber(b.systolic_pressure)
|
||
const dia = toNumber(b.diastolic_pressure)
|
||
if (sys !== null || dia !== null) {
|
||
let line = '血压'
|
||
if (sys !== null) line += ` 高压 ${readableNumber(sys)}`
|
||
if (dia !== null) line += `,低压 ${readableNumber(dia)}`
|
||
line += ' 毫米汞柱'
|
||
line += b.isHighBp ? ',偏高。' : '。'
|
||
lines.push(line)
|
||
}
|
||
if (b.western_medicine) lines.push(`西药:${b.western_medicine}。`)
|
||
if (b.insulin) lines.push(`胰岛素:${b.insulin}。`)
|
||
}
|
||
if (day.diet) {
|
||
const d = day.diet
|
||
if (d.breakfast_foods) lines.push(`早餐:${d.breakfast_foods}。`)
|
||
if (d.lunch_foods) lines.push(`午餐:${d.lunch_foods}。`)
|
||
if (d.dinner_foods) lines.push(`晚餐:${d.dinner_foods}。`)
|
||
}
|
||
if (day.exercise) {
|
||
const ex = day.exercise
|
||
const parts = []
|
||
if (ex.exercise_type) parts.push(ex.exercise_type)
|
||
if (ex.duration) parts.push(`${ex.duration} 分钟`)
|
||
if (ex.intensityText) parts.push(ex.intensityText)
|
||
if (parts.length) lines.push(`运动:${parts.join(',')}。`)
|
||
}
|
||
if (day.tracking && day.tracking.content) {
|
||
lines.push(`备注:${String(day.tracking.content).replace(/\n+/g, ',')}。`)
|
||
}
|
||
return lines.join(' ')
|
||
}
|
||
|
||
function speakLatestSummary() {
|
||
const text = buildLatestSummaryText()
|
||
if (text) ttsSpeak(text)
|
||
}
|
||
|
||
function speakDay(day) {
|
||
if (!day || typeof day !== 'object' || !day.date) return
|
||
const text = buildDayText(day)
|
||
if (text) ttsSpeak(text)
|
||
}
|
||
|
||
// ============ 波浪图绘制 ============
|
||
function ensureCanvas() {
|
||
return new Promise((resolve) => {
|
||
if (canvasCtx) {
|
||
resolve()
|
||
return
|
||
}
|
||
const query = uni.createSelectorQuery().in(proxy)
|
||
query.select('#bloodWaveChart')
|
||
.fields({ node: true, size: true })
|
||
.exec((res) => {
|
||
const item = res && res[0]
|
||
if (!item || !item.node) {
|
||
resolve()
|
||
return
|
||
}
|
||
canvasNode = item.node
|
||
canvasCtx = canvasNode.getContext('2d')
|
||
try {
|
||
canvasDpr = uni.getSystemInfoSync().pixelRatio || 1
|
||
} catch (e) {
|
||
canvasDpr = 1
|
||
}
|
||
canvasWidth = item.width
|
||
canvasHeight = item.height
|
||
canvasNode.width = canvasWidth * canvasDpr
|
||
canvasNode.height = canvasHeight * canvasDpr
|
||
canvasCtx.scale(canvasDpr, canvasDpr)
|
||
resolve()
|
||
})
|
||
})
|
||
}
|
||
|
||
let redrawScheduled = false
|
||
function scheduleChartRedraw() {
|
||
if (redrawScheduled) return
|
||
redrawScheduled = true
|
||
setTimeout(async () => {
|
||
redrawScheduled = false
|
||
await ensureCanvas()
|
||
drawChart()
|
||
}, 60)
|
||
}
|
||
|
||
function buildSeries(field) {
|
||
return chartDates.value.map((date) => {
|
||
const b = bloodByDate.value[date]
|
||
return b ? toNumber(b[field]) : null
|
||
})
|
||
}
|
||
|
||
function computeYRange(series1, series2) {
|
||
const values = [...series1, ...series2].filter((v) => v !== null)
|
||
if (!values.length) return { yMin: 4, yMax: 12 }
|
||
let min = Math.min(...values)
|
||
let max = Math.max(...values)
|
||
// 给上下留一点空间
|
||
const span = max - min
|
||
if (span < 2) {
|
||
min = Math.max(0, min - 1)
|
||
max = max + 1
|
||
} else {
|
||
min = Math.max(0, min - span * 0.2)
|
||
max = max + span * 0.2
|
||
}
|
||
// 不低于4,不高于25
|
||
min = Math.max(2, Math.floor(min))
|
||
max = Math.min(28, Math.ceil(max))
|
||
if (max - min < 4) max = min + 4
|
||
return { yMin: min, yMax: max }
|
||
}
|
||
|
||
function drawChart() {
|
||
if (!canvasCtx || !canvasWidth || !canvasHeight) return
|
||
const ctx = canvasCtx
|
||
const w = canvasWidth
|
||
const h = canvasHeight
|
||
|
||
ctx.clearRect(0, 0, w, h)
|
||
|
||
const padding = { left: 40, right: 18, top: 26, bottom: 36 }
|
||
const cw = w - padding.left - padding.right
|
||
const ch = h - padding.top - padding.bottom
|
||
|
||
const fastingSeries = buildSeries('fasting_blood_sugar')
|
||
const postSeries = buildSeries('postprandial_blood_sugar')
|
||
const { yMin, yMax } = computeYRange(fastingSeries, postSeries)
|
||
const n = chartDates.value.length
|
||
const threshold = getBloodSugarThresholds(age.value)
|
||
const valueToY = (v) => padding.top + ch * (1 - (v - yMin) / (yMax - yMin))
|
||
|
||
// 1) 背景:纯净浅色 + 阈值以上一抹极淡红
|
||
ctx.fillStyle = '#fbfcfd'
|
||
ctx.fillRect(padding.left, padding.top, cw, ch)
|
||
if (threshold) {
|
||
const yThr = valueToY(threshold.fasting)
|
||
const top = Math.max(padding.top, Math.min(yThr, padding.top + ch))
|
||
if (top > padding.top) {
|
||
// 渐变让红色从顶部往阈值线方向过渡,更柔和
|
||
const dangerGrad = ctx.createLinearGradient(0, padding.top, 0, top)
|
||
dangerGrad.addColorStop(0, 'rgba(254, 226, 226, 0.55)')
|
||
dangerGrad.addColorStop(1, 'rgba(254, 226, 226, 0.15)')
|
||
ctx.fillStyle = dangerGrad
|
||
ctx.fillRect(padding.left, padding.top, cw, top - padding.top)
|
||
}
|
||
}
|
||
|
||
// 2) 网格 —— 极淡,不喧宾夺主
|
||
ctx.strokeStyle = '#eef2f5'
|
||
ctx.lineWidth = 1
|
||
const yTicks = 4
|
||
for (let i = 0; i <= yTicks; i++) {
|
||
const y = padding.top + (ch * i) / yTicks
|
||
ctx.beginPath()
|
||
ctx.moveTo(padding.left, y)
|
||
ctx.lineTo(padding.left + cw, y)
|
||
ctx.stroke()
|
||
}
|
||
|
||
// Y 轴刻度(适老化:加大 + 加深)
|
||
ctx.font = 'bold 12px sans-serif'
|
||
ctx.fillStyle = '#64748b'
|
||
ctx.textAlign = 'right'
|
||
ctx.textBaseline = 'middle'
|
||
for (let i = 0; i <= yTicks; i++) {
|
||
const y = padding.top + (ch * i) / yTicks
|
||
const v = yMax - ((yMax - yMin) * i) / yTicks
|
||
ctx.fillText(v.toFixed(1), padding.left - 8, y)
|
||
}
|
||
|
||
// 3) X 轴日期 —— 最多 5 个(适老化:加大、加深)
|
||
ctx.font = 'bold 12px sans-serif'
|
||
ctx.fillStyle = '#475569'
|
||
ctx.textAlign = 'center'
|
||
ctx.textBaseline = 'top'
|
||
if (n > 0) {
|
||
const maxLabels = n > 20 ? 5 : 6
|
||
const labelStep = Math.max(1, Math.ceil(n / maxLabels))
|
||
chartDates.value.forEach((date, i) => {
|
||
if (i !== 0 && i !== n - 1 && i % labelStep !== 0) return
|
||
const x = n === 1 ? padding.left + cw / 2 : padding.left + (cw * i) / (n - 1)
|
||
ctx.fillText(date.slice(5), x, padding.top + ch + 8)
|
||
})
|
||
}
|
||
|
||
// 4) 阈值参考线
|
||
if (threshold) {
|
||
const y = valueToY(threshold.fasting)
|
||
if (y >= padding.top && y <= padding.top + ch) {
|
||
ctx.strokeStyle = '#fda4af'
|
||
ctx.lineWidth = 1
|
||
try { ctx.setLineDash([6, 5]) } catch (e) {}
|
||
ctx.beginPath()
|
||
ctx.moveTo(padding.left, y)
|
||
ctx.lineTo(padding.left + cw, y)
|
||
ctx.stroke()
|
||
try { ctx.setLineDash([]) } catch (e) {}
|
||
ctx.font = 'bold 11px sans-serif'
|
||
ctx.fillStyle = '#dc2626'
|
||
ctx.textAlign = 'left'
|
||
ctx.textBaseline = 'middle'
|
||
ctx.fillText(`空腹阈 ${threshold.fasting}`, padding.left + 6, y - 9)
|
||
}
|
||
}
|
||
|
||
// 5) 绘制曲线
|
||
const seriesMeta = [
|
||
{
|
||
data: fastingSeries,
|
||
color: '#204E2B',
|
||
fillTop: 'rgba(32,78,43,0.18)',
|
||
fillBottom: 'rgba(32,78,43,0)',
|
||
label: '空腹',
|
||
highThreshold: threshold ? threshold.fasting : null
|
||
},
|
||
{
|
||
data: postSeries,
|
||
color: '#386641',
|
||
fillTop: 'rgba(56,102,65,0.14)',
|
||
fillBottom: 'rgba(56,102,65,0)',
|
||
label: '餐后',
|
||
highThreshold: threshold ? threshold.postprandial : null
|
||
}
|
||
]
|
||
|
||
const xAt = (i) => (n === 1 ? padding.left + cw / 2 : padding.left + (cw * i) / (n - 1))
|
||
const coordCache = []
|
||
// 7 天内显示圆点,30 天以纯曲线为主
|
||
const showDots = n <= 10
|
||
|
||
seriesMeta.forEach((meta) => {
|
||
const projected = meta.data.map((v, i) => ({
|
||
x: xAt(i),
|
||
y: v === null ? null : valueToY(v),
|
||
value: v
|
||
}))
|
||
coordCache.push(projected)
|
||
|
||
const segments = []
|
||
let cur = []
|
||
projected.forEach((p) => {
|
||
if (p.y !== null) cur.push(p)
|
||
else if (cur.length) { segments.push(cur); cur = [] }
|
||
})
|
||
if (cur.length) segments.push(cur)
|
||
|
||
segments.forEach((seg) => {
|
||
if (seg.length === 1) {
|
||
ctx.fillStyle = meta.color
|
||
ctx.beginPath()
|
||
ctx.arc(seg[0].x, seg[0].y, 4, 0, Math.PI * 2)
|
||
ctx.fill()
|
||
return
|
||
}
|
||
|
||
const drawPath = () => {
|
||
ctx.beginPath()
|
||
ctx.moveTo(seg[0].x, seg[0].y)
|
||
for (let i = 0; i < seg.length - 1; i++) {
|
||
const p0 = seg[i - 1] || seg[i]
|
||
const p1 = seg[i]
|
||
const p2 = seg[i + 1]
|
||
const p3 = seg[i + 2] || seg[i + 1]
|
||
const cp1x = p1.x + (p2.x - p0.x) / 6
|
||
const cp1y = p1.y + (p2.y - p0.y) / 6
|
||
const cp2x = p2.x - (p3.x - p1.x) / 6
|
||
const cp2y = p2.y - (p3.y - p1.y) / 6
|
||
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y)
|
||
}
|
||
}
|
||
|
||
// 渐变填充
|
||
drawPath()
|
||
ctx.lineTo(seg[seg.length - 1].x, padding.top + ch)
|
||
ctx.lineTo(seg[0].x, padding.top + ch)
|
||
ctx.closePath()
|
||
const grad = ctx.createLinearGradient(0, padding.top, 0, padding.top + ch)
|
||
grad.addColorStop(0, meta.fillTop)
|
||
grad.addColorStop(1, meta.fillBottom)
|
||
ctx.fillStyle = grad
|
||
ctx.fill()
|
||
|
||
// 描线
|
||
drawPath()
|
||
ctx.strokeStyle = meta.color
|
||
ctx.lineWidth = 2.4
|
||
ctx.lineCap = 'round'
|
||
ctx.lineJoin = 'round'
|
||
ctx.stroke()
|
||
|
||
// 圆点(短窗口期)
|
||
if (showDots) {
|
||
seg.forEach((p) => {
|
||
ctx.beginPath()
|
||
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2)
|
||
ctx.fillStyle = '#ffffff'
|
||
ctx.fill()
|
||
ctx.strokeStyle = meta.color
|
||
ctx.lineWidth = 1.8
|
||
ctx.stroke()
|
||
})
|
||
}
|
||
})
|
||
|
||
// 标注「最高点」—— 仅一个,简洁
|
||
let maxIdx = -1
|
||
let maxVal = -Infinity
|
||
projected.forEach((p, i) => {
|
||
if (p.value != null && p.value > maxVal) {
|
||
maxVal = p.value
|
||
maxIdx = i
|
||
}
|
||
})
|
||
if (maxIdx >= 0) {
|
||
const p = projected[maxIdx]
|
||
const isHigh = meta.highThreshold != null && p.value >= meta.highThreshold
|
||
|
||
// 强调点
|
||
ctx.beginPath()
|
||
ctx.arc(p.x, p.y, 5, 0, Math.PI * 2)
|
||
ctx.fillStyle = isHigh ? '#dc2626' : meta.color
|
||
ctx.fill()
|
||
ctx.strokeStyle = '#ffffff'
|
||
ctx.lineWidth = 2.5
|
||
ctx.stroke()
|
||
|
||
// 标签(适老化:加大峰值标签)
|
||
const labelText = String(p.value)
|
||
ctx.font = 'bold 12px sans-serif'
|
||
const textWidth = ctx.measureText(labelText).width
|
||
const padX = 8
|
||
const padY = 5
|
||
const boxWidth = textWidth + padX * 2
|
||
const boxHeight = 14 + padY * 2
|
||
let labelX = p.x
|
||
let labelAbove = true
|
||
if (p.y - 12 - boxHeight - 4 < padding.top) labelAbove = false
|
||
let labelY = labelAbove ? p.y - 12 - boxHeight / 2 : p.y + 12 + boxHeight / 2
|
||
// 边界保护
|
||
if (labelX - boxWidth / 2 < padding.left + 2) labelX = padding.left + boxWidth / 2 + 2
|
||
if (labelX + boxWidth / 2 > padding.left + cw - 2) labelX = padding.left + cw - boxWidth / 2 - 2
|
||
// 标签底色
|
||
ctx.fillStyle = isHigh ? '#dc2626' : meta.color
|
||
ctx.fillRect(labelX - boxWidth / 2, labelY - boxHeight / 2, boxWidth, boxHeight)
|
||
// 标签文字
|
||
ctx.fillStyle = '#ffffff'
|
||
ctx.textAlign = 'center'
|
||
ctx.textBaseline = 'middle'
|
||
ctx.fillText(labelText, labelX, labelY)
|
||
}
|
||
})
|
||
|
||
chartCoords = {
|
||
padding,
|
||
cw,
|
||
ch,
|
||
n,
|
||
fasting: coordCache[0] || [],
|
||
postprandial: coordCache[1] || []
|
||
}
|
||
}
|
||
|
||
function onChartTouch(e) {
|
||
if (!chartCoords || !canvasWidth) return
|
||
if (!chartDates.value.length) return
|
||
let touchX = 0
|
||
let touchY = 0
|
||
if (e && e.touches && e.touches[0]) {
|
||
touchX = e.touches[0].x ?? e.touches[0].clientX ?? 0
|
||
touchY = e.touches[0].y ?? e.touches[0].clientY ?? 0
|
||
}
|
||
const { padding, cw, n } = chartCoords
|
||
if (touchX < padding.left || touchX > padding.left + cw) {
|
||
return
|
||
}
|
||
const ratio = n <= 1 ? 0 : (touchX - padding.left) / cw
|
||
let idx = Math.round(ratio * (n - 1))
|
||
if (idx < 0) idx = 0
|
||
if (idx > n - 1) idx = n - 1
|
||
const date = chartDates.value[idx]
|
||
const fp = chartCoords.fasting[idx]
|
||
const pp = chartCoords.postprandial[idx]
|
||
const anchorX = fp?.x ?? pp?.x ?? padding.left + cw * (idx / Math.max(1, n - 1))
|
||
|
||
const left = Math.max(8, Math.min(canvasWidth - 130, anchorX - 60))
|
||
const top = Math.max(4, touchY - 70)
|
||
|
||
hoverInfo.value = {
|
||
date: date.slice(5),
|
||
fasting: fp?.value ?? null,
|
||
postprandial: pp?.value ?? null,
|
||
left,
|
||
top
|
||
}
|
||
if (hoverTimer) clearTimeout(hoverTimer)
|
||
hoverTimer = setTimeout(() => {
|
||
hoverInfo.value = null
|
||
}, 2500)
|
||
}
|
||
|
||
// ============ 生命周期 ============
|
||
function readUserContext() {
|
||
try {
|
||
const userData = uni.getStorageSync('userData')
|
||
if (userData && userData.diagnosis) {
|
||
if (!patientId.value) patientId.value = Number(userData.diagnosis.patient_id) || 0
|
||
if (!diagnosisId.value) diagnosisId.value = Number(userData.diagnosis.id) || 0
|
||
if (!patientName.value) patientName.value = userData.diagnosis.patient_name || ''
|
||
if (!age.value) age.value = Number(userData.diagnosis.age) || 0
|
||
if (!gender.value) gender.value = Number(userData.diagnosis.gender) || 0
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function bootstrap(options) {
|
||
options = options || {}
|
||
gateRedirected = false
|
||
authChecking.value = true
|
||
|
||
// 登录完成前不写 diagnosisId,避免模板/子流程提前打业务接口
|
||
const passedDiagnosisId = Number(options.diagnosis_id || options.id || 0)
|
||
const passedPatientId = Number(options.patient_id || 0)
|
||
const passedPatientName = options.patient_name
|
||
? decodeURIComponent(String(options.patient_name))
|
||
: ''
|
||
const passedAge = Number(options.age) || 0
|
||
const passedGender = Number(options.gender) || 0
|
||
|
||
const loggedIn = await ensureLoggedIn()
|
||
if (!loggedIn || !hasAuthToken()) {
|
||
authChecking.value = false
|
||
showUserToast('登录失败,请稍后重试')
|
||
return
|
||
}
|
||
|
||
readUserContext()
|
||
|
||
const list = await fetchCardList()
|
||
|
||
if (Array.isArray(list) && list.length > 0) {
|
||
let target = null
|
||
if (passedDiagnosisId) {
|
||
target = list.find((c) => Number(c.id) === Number(passedDiagnosisId))
|
||
}
|
||
if (!target) target = list[0]
|
||
await applyCard(target)
|
||
} else {
|
||
if (passedDiagnosisId) diagnosisId.value = passedDiagnosisId
|
||
if (passedPatientId) patientId.value = passedPatientId
|
||
if (passedPatientName) patientName.value = passedPatientName
|
||
if (passedAge) age.value = passedAge
|
||
if (passedGender) gender.value = passedGender
|
||
if (!diagnosisId.value || !patientId.value) {
|
||
readUserContext()
|
||
}
|
||
if (!diagnosisId.value) {
|
||
authChecking.value = false
|
||
redirectToCardEntry()
|
||
return
|
||
}
|
||
}
|
||
|
||
authChecking.value = false
|
||
await fetchAll()
|
||
if (options.openDiet === '1' || options.openDiet === 1) {
|
||
await openDietForm({ autoFill: true })
|
||
}
|
||
}
|
||
|
||
onLoad((options) => {
|
||
bootstrap(options)
|
||
})
|
||
|
||
onShareAppMessage(() => {
|
||
const d = weeklyReportData.value
|
||
const code = pendingShareInviteCode.value
|
||
const title =
|
||
shareTitle.value ||
|
||
`【稳糖打卡】${d.patientLabel}本周记录${d.recordDays}天(邀请码今日有效)`
|
||
const path = code
|
||
? `/tongji/pages/index?from=share&invite_code=${code}`
|
||
: '/tongji/pages/more'
|
||
return { title, path }
|
||
})
|
||
|
||
onShow(() => {
|
||
if (authChecking.value || gateRedirected) return
|
||
if (diagnosisId.value) {
|
||
loadFamilyLikeSummary()
|
||
}
|
||
})
|
||
|
||
onPullDownRefresh(async () => {
|
||
if (await ensureLoggedIn()) {
|
||
await fetchAll()
|
||
}
|
||
uni.stopPullDownRefresh()
|
||
})
|
||
|
||
onHide(() => {
|
||
ttsStop()
|
||
})
|
||
|
||
onUnload(() => {
|
||
ttsStop()
|
||
})
|
||
|
||
// ============ 稳糖乐园 (游戏化积分/小树) ============
|
||
const gamifyPoints = ref(0)
|
||
const gamifyBadges = ref([])
|
||
const gamifyTaskAwards = ref({})
|
||
const gamifyTodayTasks = ref([])
|
||
const gamifyClaimablePoints = ref(0)
|
||
const gamifyStatusTip = ref('')
|
||
const TREE_LEVEL_ROADMAP = TREE_LEVELS
|
||
const treeWatering = ref(false)
|
||
|
||
function isApiSuccess(res) {
|
||
return !!(res && Number(res.code) === 1)
|
||
}
|
||
|
||
function applyGamifyPayload(data) {
|
||
if (!data || typeof data !== 'object') return
|
||
gamifyPoints.value = Number(data.points) || 0
|
||
gamifyBadges.value = Array.isArray(data.badges) ? [...data.badges] : []
|
||
try {
|
||
gamifyTaskAwards.value = data.task_awards ? JSON.parse(JSON.stringify(data.task_awards)) : {}
|
||
} catch (e) {
|
||
gamifyTaskAwards.value = data.task_awards || {}
|
||
}
|
||
gamifyTodayTasks.value = Array.isArray(data.today_tasks)
|
||
? data.today_tasks.map((t) => ({
|
||
id: String(t.id || ''),
|
||
name: String(t.name || ''),
|
||
points: Number(t.points) || 0,
|
||
completed: !!t.completed,
|
||
claimed: !!t.claimed
|
||
}))
|
||
: []
|
||
gamifyClaimablePoints.value = Number(data.claimable_points) || 0
|
||
if (data.message) {
|
||
gamifyStatusTip.value = String(data.message)
|
||
}
|
||
}
|
||
|
||
async function fetchGamifyState() {
|
||
if (!hasAuthToken() || !diagnosisId.value) return
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyGetGamify',
|
||
method: 'GET',
|
||
data: { diagnosis_id: diagnosisId.value }
|
||
}, false)
|
||
if (isApiSuccess(res) && res.data) {
|
||
applyGamifyPayload(res.data)
|
||
}
|
||
} catch (e) {
|
||
console.error('获取游戏化数据失败', e)
|
||
}
|
||
}
|
||
|
||
const todayHasBp = computed(() => {
|
||
const b = bloodByDate.value[todayStr.value]
|
||
return !!(b && b.hasBpOrMed)
|
||
})
|
||
|
||
const todayHasDiet = computed(() => {
|
||
const today = todayStr.value
|
||
const d = dietByDate.value[today]
|
||
return !!(d && (d.breakfast_foods || d.lunch_foods || d.dinner_foods))
|
||
})
|
||
|
||
const todayHasExercise = computed(() => {
|
||
const today = todayStr.value
|
||
const ex = exerciseByDate.value[today]
|
||
return !!(ex && (ex.exercise_type || ex.duration))
|
||
})
|
||
|
||
const todayTasks = computed(() => {
|
||
if (gamifyTodayTasks.value.length) {
|
||
return gamifyTodayTasks.value
|
||
}
|
||
const today = todayStr.value
|
||
const awards = gamifyTaskAwards.value[today] || {}
|
||
return [
|
||
{
|
||
id: 'blood',
|
||
name: '测血糖血压',
|
||
points: 10,
|
||
completed: todayChecked.value,
|
||
claimed: !!awards['blood']
|
||
},
|
||
{
|
||
id: 'diet',
|
||
name: '记今日饮食',
|
||
points: 10,
|
||
completed: todayHasDiet.value,
|
||
claimed: !!awards['diet']
|
||
},
|
||
{
|
||
id: 'exercise',
|
||
name: '记今日运动',
|
||
points: 10,
|
||
completed: todayHasExercise.value,
|
||
claimed: !!awards['exercise']
|
||
}
|
||
]
|
||
})
|
||
|
||
const claimablePoints = computed(() => {
|
||
if (gamifyTodayTasks.value.length) {
|
||
return gamifyClaimablePoints.value
|
||
}
|
||
return todayTasks.value
|
||
.filter(t => t.completed && !t.claimed)
|
||
.reduce((sum, t) => sum + t.points, 0)
|
||
})
|
||
|
||
const allTasksCleared = computed(() => {
|
||
const tasks = todayTasks.value
|
||
return tasks.length > 0 && tasks.every((t) => t.completed && t.claimed)
|
||
})
|
||
|
||
const unlockedBadges = computed(() => {
|
||
const list = []
|
||
const streak = Number(streakDays.value?.days) || 0
|
||
if (todayChecked.value) {
|
||
list.push({ id: 'today', label: '今日打卡', icon: 'check-circle', color: '#386641' })
|
||
}
|
||
if (streak >= 3) {
|
||
list.push({ id: 'streak', label: `连击${streak}天`, icon: 'flame', color: '#EA580C' })
|
||
}
|
||
if (streak >= 7) {
|
||
list.push({ id: 'week', label: '一周达人', icon: 'trophy', color: '#CA8A04' })
|
||
}
|
||
if (last7CheckedCount.value >= 5) {
|
||
list.push({ id: 'w5', label: '本周5天+', icon: 'calendar', color: '#204E2B' })
|
||
}
|
||
if (gamifyPoints.value >= 50) {
|
||
list.push({ id: 'p50', label: '积分50+', icon: 'sparkles', color: '#204E2B' })
|
||
}
|
||
if (gamifyPoints.value >= 100) {
|
||
list.push({ id: 'p100', label: '积分破百', icon: 'trophy', color: '#7C3AED' })
|
||
}
|
||
if (gamifyPoints.value >= 200) {
|
||
list.push({ id: 'p200', label: '积分200+', icon: 'sparkles', color: '#7C3AED' })
|
||
}
|
||
if (treeLevel.value >= 3) {
|
||
list.push({ id: 'tree3', label: '小树苗', icon: 'glucose', color: '#0D9488' })
|
||
}
|
||
if (treeLevel.value >= 5) {
|
||
list.push({ id: 'tree5', label: '拔节高', icon: 'activity', color: '#386641' })
|
||
}
|
||
if (treeLevel.value >= 7) {
|
||
list.push({ id: 'tree7', label: '初绽香', icon: 'heart', color: '#EA580C' })
|
||
}
|
||
if (treeState.value.isMax) {
|
||
list.push({ id: 'tree9', label: '圆满树', icon: 'trophy', color: '#CA8A04' })
|
||
}
|
||
return list
|
||
})
|
||
|
||
const celebrateShow = ref(false)
|
||
const celebrateTitle = ref('')
|
||
const celebrateSub = ref('')
|
||
const floatReward = ref(null)
|
||
const inviteHeartBurst = ref(false)
|
||
const taskJustDoneId = ref('')
|
||
let celebrateTimer = null
|
||
let taskPopTimer = null
|
||
|
||
const INVITE_TREE_WHISPERS = [
|
||
'Ta 正在努力控糖,您的鼓励很重要。',
|
||
'好习惯比完美数值更珍贵,一起加油。',
|
||
'每天记一笔,就是在照顾未来的自己。',
|
||
'点赞一下,让 Ta 知道家人在身边。'
|
||
]
|
||
|
||
function hapticLight() {
|
||
try {
|
||
uni.vibrateShort({ type: 'light' })
|
||
} catch (e) {}
|
||
}
|
||
|
||
function triggerCelebrate(title, sub = '') {
|
||
if (celebrateTimer) clearTimeout(celebrateTimer)
|
||
celebrateTitle.value = title
|
||
celebrateSub.value = sub
|
||
celebrateShow.value = true
|
||
celebrateTimer = setTimeout(() => {
|
||
celebrateShow.value = false
|
||
celebrateSub.value = ''
|
||
}, 2400)
|
||
}
|
||
|
||
function showFloatReward(points) {
|
||
floatReward.value = { text: points, key: Date.now() }
|
||
setTimeout(() => {
|
||
floatReward.value = null
|
||
}, 1100)
|
||
}
|
||
|
||
function popTaskDone(taskId) {
|
||
if (!taskId) return
|
||
taskJustDoneId.value = taskId
|
||
if (taskPopTimer) clearTimeout(taskPopTimer)
|
||
taskPopTimer = setTimeout(() => {
|
||
taskJustDoneId.value = ''
|
||
}, 700)
|
||
}
|
||
|
||
function onTreeWhisper() {
|
||
if (treeWatering.value) return
|
||
const line = pickTreeWhisper(treeLevel.value)
|
||
showUserToast(line, { duration: 2800 })
|
||
hapticLight()
|
||
}
|
||
|
||
function onInviteTreeWhisper() {
|
||
const line = INVITE_TREE_WHISPERS[Math.floor(Math.random() * INVITE_TREE_WHISPERS.length)]
|
||
showUserToast(line, { duration: 2800 })
|
||
hapticLight()
|
||
}
|
||
|
||
async function waterTree() {
|
||
if (!diagnosisId.value) return
|
||
if (treeWatering.value) return
|
||
|
||
const oldLevel = treeLevel.value
|
||
treeWatering.value = true
|
||
setTimeout(() => {
|
||
treeWatering.value = false
|
||
}, 1000)
|
||
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyWaterTree',
|
||
method: 'POST',
|
||
data: { diagnosis_id: diagnosisId.value }
|
||
}, false)
|
||
if (!isApiSuccess(res)) {
|
||
showUserToast((res && res.msg) || '浇水失败')
|
||
return
|
||
}
|
||
const data = res.data && typeof res.data === 'object' ? res.data : {}
|
||
const addedPoints = Number(data.added_points) || 0
|
||
applyGamifyPayload({
|
||
...data,
|
||
message: data.message || res.msg || ''
|
||
})
|
||
await nextTick()
|
||
|
||
const tip = formatUserMessage(data.message || res.msg, '')
|
||
if (addedPoints > 0) {
|
||
showFloatReward(addedPoints)
|
||
hapticLight()
|
||
triggerCelebrate('浇水成功', tip || `+${addedPoints} 稳糖积分,小树咕咚喝饱啦`)
|
||
const claimedIds = Array.isArray(data.claimed_tasks) ? data.claimed_tasks : []
|
||
claimedIds.forEach((id) => popTaskDone(id))
|
||
const newLevel = treeLevel.value
|
||
if (newLevel > oldLevel) {
|
||
const meta = TREE_LEVELS[newLevel] || TREE_LEVELS[0]
|
||
setTimeout(() => triggerCelebrate('小树升级啦', `Lv.${newLevel} ${meta.name} · ${meta.desc}`), 520)
|
||
}
|
||
setTimeout(() => {
|
||
if (todayTasks.value.every((t) => t.completed && t.claimed)) {
|
||
triggerCelebrate('今日任务大满贯', '明天继续来浇浇水吧')
|
||
}
|
||
}, 900)
|
||
return
|
||
}
|
||
|
||
// 无可领积分:先同步 UI,再按服务端 pending 引导或提示
|
||
if (tip) {
|
||
showUserToast(tip, { duration: 2800 })
|
||
}
|
||
|
||
const needRecord = todayTasks.value.find((t) => !t.completed && !t.claimed)
|
||
if (needRecord) {
|
||
onGamifyTaskTap(needRecord)
|
||
return
|
||
}
|
||
|
||
const pending = Array.isArray(data.pending_tasks) ? data.pending_tasks : []
|
||
if (pending.length) {
|
||
const match = todayTasks.value.find((t) => t.id === pending[0].id)
|
||
|| todayTasks.value.find((t) => t.name === pending[0].name)
|
||
if (match && !match.completed) {
|
||
onGamifyTaskTap(match)
|
||
} else if (!tip) {
|
||
showUserToast(`先完成:${pending[0].name}`, { duration: 2800 })
|
||
}
|
||
return
|
||
}
|
||
|
||
if (Number(data.claimable_points) > 0) {
|
||
showUserToast('还有积分待领取,请再点一次浇水')
|
||
return
|
||
}
|
||
|
||
if (allTasksCleared.value && !tip) {
|
||
showUserToast('今日奖励已全部领取', { duration: 2800 })
|
||
}
|
||
} catch (e) {
|
||
showUserToast('浇水失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
const treeState = computed(() => calcTreeFromPoints(gamifyPoints.value))
|
||
const treeLevel = computed(() => treeState.value.level)
|
||
const treeProgress = computed(() => treeState.value.progress)
|
||
const treeLevelName = computed(() => treeState.value.name)
|
||
const treeEmoji = computed(() => treeState.value.emoji)
|
||
const treeMood = computed(() => treeState.value.mood)
|
||
const treeProgressLabel = computed(() => {
|
||
if (treeState.value.isMax) return '已满级 · 圆满树'
|
||
return `${treeState.value.xpInLevel}/${treeState.value.xpNeed} XP`
|
||
})
|
||
const treeNextHint = computed(() => {
|
||
if (treeState.value.isMax) return '您已养成满级稳糖树,习惯就是最好的奖励'
|
||
if (claimablePoints.value > 0) return `浇水后可再积 ${claimablePoints.value} 分,离升级更近一步`
|
||
return `再积 ${treeState.value.pointsToNext} 分,升级「${treeState.value.nextName}」`
|
||
})
|
||
|
||
// ============ 分享 / 家人点赞 / 邀请观看 ============
|
||
const familyNicknameOptions = ['家人', '亲友', '老伴', '子女']
|
||
|
||
const weeklyReportVisible = ref(false)
|
||
const shareTitle = ref('')
|
||
const pendingShareInviteCode = ref('')
|
||
|
||
const familyLikeSummary = ref({
|
||
like_count: 0,
|
||
summary_line: '分享战报给家人,邀请他们为您点赞鼓劲',
|
||
recent: []
|
||
})
|
||
const lastFamilyLikeCount = ref(0)
|
||
|
||
const VIEWER_KEY_STORAGE = 'tongji_viewer_key_v1'
|
||
|
||
function familyLikeSeenStorageKey(dId = diagnosisId.value) {
|
||
return `tongji_family_like_seen_${Number(dId) || 0}_${todayStr.value}`
|
||
}
|
||
|
||
function getStoredFamilyLikeSeen(dId = diagnosisId.value) {
|
||
try {
|
||
return Number(uni.getStorageSync(familyLikeSeenStorageKey(dId))) || 0
|
||
} catch (e) {
|
||
return 0
|
||
}
|
||
}
|
||
|
||
function setStoredFamilyLikeSeen(count, dId = diagnosisId.value) {
|
||
try {
|
||
uni.setStorageSync(familyLikeSeenStorageKey(dId), Number(count) || 0)
|
||
} catch (e) {}
|
||
}
|
||
|
||
function getViewerKey() {
|
||
try {
|
||
let key = uni.getStorageSync(VIEWER_KEY_STORAGE)
|
||
if (!key) {
|
||
key = `v_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
|
||
uni.setStorageSync(VIEWER_KEY_STORAGE, key)
|
||
}
|
||
return String(key).slice(0, 64)
|
||
} catch (e) {
|
||
return `v_${Date.now()}`
|
||
}
|
||
}
|
||
|
||
function getWeekOfYear(date) {
|
||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
||
const dayNum = d.getUTCDay() || 7
|
||
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
|
||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
|
||
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
|
||
}
|
||
|
||
const weeklyReportData = computed(() => {
|
||
const name = (patientName.value || '我').trim()
|
||
const patientLabel = name.length > 6 ? `${name.slice(0, 6)}…` : name
|
||
const now = new Date()
|
||
const weekLabel = `${now.getFullYear()}年 第${getWeekOfYear(now)}周`
|
||
const rd = last7CheckedCount.value
|
||
const cd = weekCompleteDays.value
|
||
let quote = '坚持记录,就是在照顾未来的自己。'
|
||
if (rd >= 7) quote = '本周每天都留下了记录,这份自律值得骄傲!'
|
||
else if (cd >= 5) quote = `本周有 ${cd} 天完成了空腹+餐后记录,习惯越来越稳。`
|
||
else if (rd >= 4) quote = `本周已记录 ${rd} 天,比不记录强一百倍。`
|
||
else if (rd > 0) quote = '好的开始!下周试着多记录几天。'
|
||
else quote = '从今天记一笔开始,小树就会发芽。'
|
||
return {
|
||
patientLabel,
|
||
weekLabel,
|
||
recordDays: rd,
|
||
completeDays: cd,
|
||
streakDays: streakDays.value.days,
|
||
quote
|
||
}
|
||
})
|
||
|
||
const reportTreeLevel = computed(() => treeLevel.value)
|
||
|
||
const inviteTreeLevel = computed(() => Number(invitePreview.value.tree_level) || 0)
|
||
|
||
const inviteDailyRecords = computed(() => {
|
||
const list = invitePreview.value.daily_records
|
||
return Array.isArray(list) ? list : []
|
||
})
|
||
|
||
function formatInviteSugar(v) {
|
||
if (v == null || v === '') return '—'
|
||
return String(v)
|
||
}
|
||
|
||
const ENCOURAGE_LINES = [
|
||
'每天记一笔,就是在照顾未来的自己。',
|
||
'坚持记录比纠结数值更重要,您做得很好。',
|
||
'给树浇浇水,也给自己点个赞。',
|
||
'家人会看到您的努力,习惯比完美更珍贵。',
|
||
'空腹、餐后各记一次,完整一日更有成就感。'
|
||
]
|
||
const encourageIndex = ref(0)
|
||
const encourageLine = computed(() => {
|
||
const pending = todayTasks.value.find((t) => !t.completed)
|
||
if (pending?.id === 'blood') return '先完成血糖记录,今日第一格就亮起来。'
|
||
if (pending?.id === 'diet') return '记下今日饮食,离领奖更近一步。'
|
||
if (pending?.id === 'exercise') return '动一动、记一记,小树长得更快。'
|
||
return ENCOURAGE_LINES[encourageIndex.value % ENCOURAGE_LINES.length]
|
||
})
|
||
|
||
function onEncourageTap() {
|
||
encourageIndex.value = (encourageIndex.value + 1) % ENCOURAGE_LINES.length
|
||
const line = ENCOURAGE_LINES[encourageIndex.value]
|
||
if (ttsEnabled.value) {
|
||
ttsSpeak(line)
|
||
} else {
|
||
showUserToast(line, { duration: 2800 })
|
||
}
|
||
}
|
||
|
||
function goGlucosePage() {
|
||
uni.navigateTo({ url: '/tongji/pages/index' })
|
||
}
|
||
|
||
function onGamifyTaskTap(task) {
|
||
if (!task) return
|
||
if (task.claimed) {
|
||
showUserToast('该任务积分已领取')
|
||
return
|
||
}
|
||
if (!task.completed) {
|
||
if (task.id === 'blood') {
|
||
openInputForm()
|
||
return
|
||
}
|
||
if (task.id === 'diet') {
|
||
openDietForm()
|
||
return
|
||
}
|
||
if (task.id === 'exercise') {
|
||
openExerciseForm()
|
||
return
|
||
}
|
||
showUserToast(`请先完成:${task.name}`)
|
||
return
|
||
}
|
||
waterTree()
|
||
}
|
||
|
||
function shareInviteCacheKey() {
|
||
return `tongji_invite_${diagnosisId.value}_${todayStr.value}`
|
||
}
|
||
|
||
async function ensureShareInvite() {
|
||
if (!diagnosisId.value) return ''
|
||
const cacheKey = shareInviteCacheKey()
|
||
try {
|
||
const cached = uni.getStorageSync(cacheKey)
|
||
if (cached && cached.invite_code && cached.invite_date === todayStr.value) {
|
||
pendingShareInviteCode.value = cached.invite_code
|
||
return cached.invite_code
|
||
}
|
||
} catch (e) {}
|
||
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyCreateShareInvite',
|
||
method: 'POST',
|
||
data: { diagnosis_id: diagnosisId.value }
|
||
}, false)
|
||
if (res && res.code === 1 && res.data && res.data.invite_code) {
|
||
const code = res.data.invite_code
|
||
pendingShareInviteCode.value = code
|
||
try {
|
||
uni.setStorageSync(cacheKey, {
|
||
invite_code: code,
|
||
invite_date: todayStr.value
|
||
})
|
||
} catch (e) {}
|
||
return code
|
||
}
|
||
if (res?.msg) showUserToast(res.msg)
|
||
} catch (e) {
|
||
showUserToast('生成邀请码失败')
|
||
}
|
||
return ''
|
||
}
|
||
|
||
async function prepareShareWeekly() {
|
||
const code = await ensureShareInvite()
|
||
if (!code) return
|
||
const d = weeklyReportData.value
|
||
shareTitle.value = `【稳糖打卡】${d.patientLabel}本周记录${d.recordDays}天,邀请码 ${code}(今日有效)`
|
||
}
|
||
|
||
function shareWeeklyFallback() {
|
||
prepareShareWeekly().then((code) => {
|
||
if (!code) return
|
||
const d = weeklyReportData.value
|
||
const text = `【稳糖打卡】${d.patientLabel} ${d.weekLabel}\n本周 ${d.recordDays} 天有记录、${d.completeDays} 个完整日、连续 ${d.streakDays} 天\n${d.quote}\n邀请码:${code}(仅今日有效)\n打开小程序「日常记录」输入邀请码查看`
|
||
uni.setClipboardData({
|
||
data: text,
|
||
success: () => showUserToast('战报文案已复制,可发给家人')
|
||
})
|
||
})
|
||
}
|
||
|
||
async function openWeeklyReport() {
|
||
if (!diagnosisId.value) {
|
||
showUserToast('请先选择就诊卡')
|
||
return
|
||
}
|
||
await ensureShareInvite()
|
||
weeklyReportVisible.value = true
|
||
}
|
||
|
||
function closeWeeklyReport() {
|
||
weeklyReportVisible.value = false
|
||
}
|
||
|
||
async function loadFamilyLikeSummary() {
|
||
if (!hasAuthToken() || !diagnosisId.value) return
|
||
const token = uni.getStorageSync('token')
|
||
if (!token) return
|
||
try {
|
||
const res = await proxy.apiUrl({
|
||
url: '/api/tcm/dailyFamilyLikeSummary',
|
||
method: 'GET',
|
||
data: { diagnosis_id: diagnosisId.value }
|
||
}, false)
|
||
if (res && res.code === 1 && res.data) {
|
||
const storedSeen = getStoredFamilyLikeSeen()
|
||
const prev = Math.max(lastFamilyLikeCount.value, storedSeen)
|
||
const next = Number(res.data.like_count) || 0
|
||
familyLikeSummary.value = {
|
||
like_count: next,
|
||
summary_line: res.data.summary_line || '',
|
||
recent: Array.isArray(res.data.recent) ? res.data.recent : []
|
||
}
|
||
// 仅在有「新增」点赞时庆祝,避免刷新/重进页面重复弹层
|
||
if (next > prev) {
|
||
const delta = next - prev
|
||
triggerCelebrate(
|
||
delta === 1 && prev === 0 ? '首个家人点赞' : '收到家人鼓励',
|
||
delta === 1 ? '分享真的有用,继续加油' : `新增 ${delta} 位家人点赞,共 ${next} 人`
|
||
)
|
||
hapticLight()
|
||
}
|
||
lastFamilyLikeCount.value = next
|
||
setStoredFamilyLikeSeen(next)
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
function onFamilyLikeStripTap() {
|
||
if (familyLikeSummary.value.like_count > 0) {
|
||
if (ttsEnabled.value) {
|
||
ttsSpeak(familyLikeSummary.value.summary_line)
|
||
} else {
|
||
showUserToast(familyLikeSummary.value.summary_line, { duration: 2400 })
|
||
}
|
||
return
|
||
}
|
||
openWeeklyReport()
|
||
}
|
||
|
||
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.page-gate-loading {
|
||
min-height: 60vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 80rpx 40rpx;
|
||
}
|
||
.page-gate-loading-text {
|
||
font-size: 28rpx;
|
||
color: #64748b;
|
||
}
|
||
.daily-page {
|
||
/* 详见 ../styles/daily-theme.scss */
|
||
--radius-card: 32rpx;
|
||
--radius-pill: 999rpx;
|
||
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||
|
||
min-height: 100vh;
|
||
padding-bottom: 64rpx;
|
||
}
|
||
|
||
/* === Hero 区域 === */
|
||
.hero {
|
||
position: relative;
|
||
padding: 60rpx 36rpx 120rpx; /* 适度增加空间,增强空气感 */
|
||
color: #ffffff;
|
||
overflow: hidden;
|
||
}
|
||
.hero-bg {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(135deg, #163d22 0%, #204e2b 55%, #386641 100%);
|
||
z-index: 0;
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
background: radial-gradient(circle at 85% 15%, rgba(175, 226, 179, 0.15) 0%, transparent 60%);
|
||
}
|
||
}
|
||
.hero-content {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.hero-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12rpx;
|
||
}
|
||
.hero-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding-top: 4rpx;
|
||
}
|
||
.hero-title {
|
||
font-size: 56rpx; /* 大字体适老化 */
|
||
font-weight: 800;
|
||
color: #ffffff;
|
||
line-height: 1.2;
|
||
letter-spacing: 1rpx;
|
||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.15);
|
||
}
|
||
.hero-subtitle {
|
||
display: block;
|
||
font-size: 30rpx;
|
||
color: rgba(255, 255, 255, 0.95);
|
||
margin-top: 6rpx;
|
||
font-weight: 500;
|
||
}
|
||
.hero-pill-group {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
gap: 12rpx;
|
||
max-width: 380rpx;
|
||
}
|
||
.hero-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
border: 1rpx solid rgba(255, 255, 255, 0.2);
|
||
border-radius: var(--radius-pill);
|
||
padding: 18rpx 30rpx;
|
||
backdrop-filter: blur(12px);
|
||
min-height: 80rpx; /* 点击热区符合规范 */
|
||
box-sizing: border-box;
|
||
transition: var(--transition-smooth);
|
||
&:active {
|
||
background: rgba(255, 255, 255, 0.24);
|
||
transform: scale(0.96);
|
||
}
|
||
&.voice {
|
||
background: rgba(255, 255, 255, 0.18);
|
||
border: 2rpx solid rgba(255, 255, 255, 0.4);
|
||
padding: 16rpx 30rpx;
|
||
}
|
||
&.voice.speaking {
|
||
background: #fbbf24;
|
||
border-color: #fde68a;
|
||
color: var(--slate-900);
|
||
animation: voice-pulse 1.4s ease-in-out infinite;
|
||
.hero-pill-text {
|
||
color: var(--slate-900);
|
||
}
|
||
}
|
||
&.voice.disabled {
|
||
opacity: 0.5;
|
||
}
|
||
&.input {
|
||
background: #ffffff;
|
||
border: 2rpx solid #ffffff;
|
||
padding: 16rpx 32rpx;
|
||
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.1);
|
||
.hero-pill-text {
|
||
color: var(--primary);
|
||
font-weight: 700;
|
||
}
|
||
&:active {
|
||
background: var(--primary-light);
|
||
}
|
||
}
|
||
}
|
||
@keyframes voice-pulse {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.55); }
|
||
50% { box-shadow: 0 0 0 12rpx rgba(251, 191, 36, 0); }
|
||
}
|
||
.hero-pill-icon-wrap {
|
||
width: 36rpx;
|
||
height: 36rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.hero-pill-text {
|
||
font-size: 32rpx;
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* === Bento 风格指标卡 === */
|
||
.stat-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 20rpx;
|
||
margin: -60rpx 24rpx 0;
|
||
position: relative;
|
||
z-index: 3;
|
||
}
|
||
.stat-card {
|
||
background: #ffffff;
|
||
border-radius: var(--radius-card);
|
||
padding: 30rpx 26rpx;
|
||
box-shadow: var(--shadow-premium);
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
min-height: 250rpx;
|
||
border: 1rpx solid rgba(15, 23, 42, 0.04);
|
||
box-sizing: border-box;
|
||
transition: var(--transition-smooth);
|
||
position: relative;
|
||
overflow: hidden;
|
||
&:active {
|
||
transform: translateY(-2rpx);
|
||
box-shadow: 0 16rpx 36rpx rgba(15, 23, 42, 0.08);
|
||
}
|
||
}
|
||
.stat-fasting {
|
||
background: radial-gradient(circle at 100% 0%, #e6fdf9 0%, #ffffff 65%);
|
||
}
|
||
.stat-postprandial {
|
||
background: radial-gradient(circle at 100% 0%, #fff7ed 0%, #ffffff 65%);
|
||
}
|
||
.stat-other {
|
||
grid-column: span 2;
|
||
background: radial-gradient(circle at 100% 0%, #f5f3ff 0%, #ffffff 65%);
|
||
display: grid !important;
|
||
grid-template-areas:
|
||
"head value"
|
||
"foot value" !important;
|
||
grid-template-columns: 1fr auto !important;
|
||
grid-template-rows: auto auto !important;
|
||
align-items: center !important;
|
||
min-height: 170rpx !important;
|
||
padding: 26rpx 32rpx !important;
|
||
|
||
.stat-card-head {
|
||
grid-area: head !important;
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
justify-content: flex-start !important;
|
||
margin: 0 !important;
|
||
padding: 0 !important;
|
||
gap: 12rpx !important;
|
||
}
|
||
.stat-card-value-row {
|
||
grid-area: value !important;
|
||
display: flex !important;
|
||
align-items: baseline !important;
|
||
margin: 0 !important;
|
||
padding: 0 !important;
|
||
gap: 6rpx !important;
|
||
}
|
||
.stat-card-foot {
|
||
grid-area: foot !important;
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
justify-content: flex-start !important;
|
||
margin-top: 12rpx !important;
|
||
padding: 0 !important;
|
||
gap: 16rpx !important;
|
||
}
|
||
}
|
||
.stat-card.is-high {
|
||
box-shadow: var(--shadow-glow-danger);
|
||
border-color: rgba(239, 68, 68, 0.08);
|
||
}
|
||
.stat-card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8rpx;
|
||
}
|
||
.stat-card-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 8rpx 18rpx;
|
||
border-radius: 12rpx;
|
||
font-size: 26rpx;
|
||
font-weight: 700;
|
||
}
|
||
.stat-fasting .stat-card-tag {
|
||
background: rgba(13, 148, 136, 0.09);
|
||
color: #386641;
|
||
}
|
||
.stat-postprandial .stat-card-tag {
|
||
background: rgba(245, 158, 11, 0.09);
|
||
color: #d97706;
|
||
}
|
||
.stat-other .stat-card-tag {
|
||
background: rgba(99, 102, 241, 0.09);
|
||
color: #4f46e5;
|
||
}
|
||
.stat-card-tag-dot {
|
||
display: none;
|
||
}
|
||
.stat-card-tag-text {
|
||
font-size: 26rpx;
|
||
font-weight: 700;
|
||
}
|
||
.stat-card-flag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6rpx;
|
||
padding: 6rpx 14rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: rgba(239, 68, 68, 0.08);
|
||
border: 1rpx solid rgba(239, 68, 68, 0.15);
|
||
}
|
||
.stat-card-flag-icon {
|
||
font-size: 20rpx;
|
||
color: var(--danger);
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
.stat-card-flag-text {
|
||
font-size: 22rpx;
|
||
color: var(--danger);
|
||
font-weight: 700;
|
||
}
|
||
.stat-card-value-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 6rpx;
|
||
margin-top: 16rpx;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
.stat-card-value {
|
||
font-size: 64rpx;
|
||
font-weight: 900;
|
||
background: linear-gradient(180deg, var(--slate-900) 0%, #334155 100%);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
color: transparent;
|
||
line-height: 1.05;
|
||
letter-spacing: -1rpx;
|
||
&.is-high {
|
||
background: var(--danger-grad);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
color: transparent;
|
||
}
|
||
}
|
||
.stat-card-unit {
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 600;
|
||
}
|
||
.stat-card-foot {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8rpx;
|
||
margin-top: 4rpx;
|
||
}
|
||
.stat-card-date {
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 500;
|
||
}
|
||
.stat-card-trend {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
padding: 6rpx 14rpx;
|
||
border-radius: var(--radius-pill);
|
||
transition: var(--transition-smooth);
|
||
&.trend-up {
|
||
background: rgba(239, 68, 68, 0.08);
|
||
.stat-card-trend-arrow,
|
||
.stat-card-trend-delta {
|
||
color: var(--danger);
|
||
}
|
||
}
|
||
&.trend-down {
|
||
background: rgba(16, 185, 129, 0.08);
|
||
.stat-card-trend-arrow,
|
||
.stat-card-trend-delta {
|
||
color: var(--success);
|
||
}
|
||
}
|
||
&.trend-flat {
|
||
background: var(--slate-100);
|
||
.stat-card-trend-arrow,
|
||
.stat-card-trend-delta {
|
||
color: var(--slate-600);
|
||
}
|
||
}
|
||
}
|
||
.stat-card-trend-arrow {
|
||
font-size: 24rpx;
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
}
|
||
.stat-card-trend-delta {
|
||
font-size: 24rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* === 就诊卡选择器 === */
|
||
.card-switcher {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
margin: 22rpx 24rpx 0;
|
||
padding: 8rpx 12rpx;
|
||
background: transparent;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
}
|
||
.card-switcher-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
flex-shrink: 0;
|
||
padding-right: 10rpx;
|
||
border-right: 2rpx solid var(--slate-200);
|
||
}
|
||
.card-switcher-label {
|
||
font-size: 28rpx;
|
||
color: var(--slate-900);
|
||
font-weight: 700;
|
||
}
|
||
.card-switcher-count {
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
background: var(--slate-100);
|
||
padding: 4rpx 14rpx;
|
||
border-radius: var(--radius-pill);
|
||
font-weight: 600;
|
||
}
|
||
.card-switcher-scroll {
|
||
flex: 1;
|
||
min-width: 0;
|
||
white-space: nowrap;
|
||
}
|
||
.card-chip-row {
|
||
display: inline-flex;
|
||
flex-wrap: nowrap;
|
||
gap: 8rpx;
|
||
padding: 2rpx;
|
||
}
|
||
.card-chip {
|
||
flex-shrink: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding: 16rpx 28rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: #ffffff;
|
||
border: 1rpx solid var(--slate-200);
|
||
transition: var(--transition-smooth);
|
||
min-height: 68rpx;
|
||
box-sizing: border-box;
|
||
&.active {
|
||
background: var(--primary-grad);
|
||
border-color: transparent;
|
||
box-shadow: var(--shadow-glow-primary);
|
||
.card-chip-name,
|
||
.card-chip-meta {
|
||
color: #ffffff;
|
||
}
|
||
}
|
||
&:active {
|
||
transform: scale(0.96);
|
||
}
|
||
}
|
||
.card-chip-name {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: #1e293b;
|
||
line-height: 1.1;
|
||
}
|
||
.card-chip-meta {
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.range-bar {
|
||
display: flex;
|
||
gap: 12rpx;
|
||
margin: 22rpx 24rpx 0;
|
||
background: #ffffff;
|
||
border-radius: var(--radius-pill);
|
||
padding: 10rpx;
|
||
box-shadow: var(--shadow-sm);
|
||
border: 1rpx solid var(--slate-100);
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
.range-item {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: 22rpx 0;
|
||
border-radius: var(--radius-pill);
|
||
font-size: 32rpx;
|
||
color: #334155;
|
||
font-weight: 600;
|
||
min-height: 76rpx;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: var(--transition-smooth);
|
||
&.active {
|
||
background: var(--primary-grad);
|
||
color: #ffffff;
|
||
font-weight: 700;
|
||
box-shadow: var(--shadow-glow-primary);
|
||
}
|
||
&:active:not(.active) {
|
||
background: var(--slate-100);
|
||
}
|
||
}
|
||
|
||
/* === 语音播报设置 === */
|
||
.voice-bar {
|
||
margin: 22rpx 24rpx 0;
|
||
background: #ffffff;
|
||
border-radius: var(--radius-card);
|
||
padding: 26rpx;
|
||
box-shadow: var(--shadow-premium);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 18rpx;
|
||
border: 2rpx solid #e0f2fe;
|
||
transition: var(--transition-smooth);
|
||
&:active {
|
||
border-color: #bae6fd;
|
||
}
|
||
}
|
||
.voice-bar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.voice-bar-icon {
|
||
font-size: 48rpx;
|
||
line-height: 1;
|
||
}
|
||
.voice-bar-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
min-width: 0;
|
||
}
|
||
.voice-bar-title {
|
||
font-size: 34rpx;
|
||
font-weight: 700;
|
||
color: var(--slate-900);
|
||
}
|
||
.voice-bar-hint {
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
margin-top: 4rpx;
|
||
}
|
||
.voice-bar-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
.voice-switch {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
padding: 10rpx 12rpx 10rpx 18rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--slate-100);
|
||
transition: var(--transition-smooth);
|
||
min-height: 76rpx;
|
||
box-sizing: border-box;
|
||
&.on {
|
||
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
||
}
|
||
}
|
||
.voice-switch-label {
|
||
font-size: 28rpx;
|
||
color: #1e293b;
|
||
font-weight: 700;
|
||
}
|
||
.voice-switch-track {
|
||
width: 80rpx;
|
||
height: 44rpx;
|
||
background: var(--slate-300);
|
||
border-radius: var(--radius-pill);
|
||
position: relative;
|
||
transition: var(--transition-smooth);
|
||
.voice-switch.on & {
|
||
background: var(--warning);
|
||
}
|
||
}
|
||
.voice-switch-knob {
|
||
position: absolute;
|
||
width: 36rpx;
|
||
height: 36rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: #ffffff;
|
||
top: 4rpx;
|
||
left: 4rpx;
|
||
box-shadow: 0 2rpx 6rpx rgba(15, 23, 42, 0.15);
|
||
transition: var(--transition-smooth);
|
||
.voice-switch.on & {
|
||
transform: translateX(36rpx);
|
||
}
|
||
}
|
||
.voice-toggle {
|
||
width: 76rpx;
|
||
height: 76rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--primary-grad);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: var(--shadow-glow-primary);
|
||
transition: var(--transition-smooth);
|
||
&.off {
|
||
background: var(--slate-400);
|
||
box-shadow: none;
|
||
}
|
||
&:active {
|
||
transform: scale(0.95);
|
||
}
|
||
}
|
||
.voice-toggle-icon {
|
||
font-size: 36rpx;
|
||
color: #ffffff;
|
||
line-height: 1;
|
||
}
|
||
|
||
.card {
|
||
background: #ffffff;
|
||
border-radius: var(--radius-card);
|
||
margin: 28rpx 24rpx 0;
|
||
padding: 30rpx 28rpx;
|
||
box-shadow: var(--shadow-premium);
|
||
animation: card-rise 0.45s ease-out both;
|
||
}
|
||
|
||
/* 动效延迟展现列表,视觉更灵动 */
|
||
.streak-strip { animation: card-rise 0.45s ease-out 0.03s both; }
|
||
.card-switcher { animation: card-rise 0.45s ease-out 0.06s both; }
|
||
.range-bar { animation: card-rise 0.4s ease-out 0.08s both; }
|
||
.voice-bar { animation: card-rise 0.4s ease-out 0.10s both; }
|
||
.chart-card { animation: card-rise 0.5s ease-out 0.12s both; }
|
||
.calendar-card { animation: card-rise 0.5s ease-out 0.14s both; }
|
||
.records-section { animation: card-rise 0.5s ease-out 0.16s both; }
|
||
|
||
@keyframes card-rise {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(16rpx);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
@keyframes stat-card-pop {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(24rpx) scale(0.96);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
}
|
||
|
||
.stat-card {
|
||
animation: stat-card-pop 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||
}
|
||
.stat-fasting { animation-delay: 0.04s; }
|
||
.stat-postprandial { animation-delay: 0.08s; }
|
||
.stat-other { animation-delay: 0.12s; }
|
||
|
||
.card-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.card-title {
|
||
font-size: 36rpx;
|
||
font-weight: 800;
|
||
color: var(--slate-900);
|
||
letter-spacing: 0.5rpx;
|
||
}
|
||
.card-subtitle {
|
||
display: block;
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
margin-top: 6rpx;
|
||
}
|
||
.card-legend {
|
||
display: flex;
|
||
gap: 18rpx;
|
||
}
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
font-size: 22rpx;
|
||
color: var(--slate-600);
|
||
}
|
||
.legend-dot {
|
||
width: 16rpx;
|
||
height: 16rpx;
|
||
border-radius: var(--radius-pill);
|
||
}
|
||
.legend-dot-fasting { background: var(--primary); }
|
||
.legend-dot-postprandial { background: var(--warning); }
|
||
|
||
.chart-summary {
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: 0;
|
||
margin-top: 18rpx;
|
||
padding: 16rpx 4rpx;
|
||
background: linear-gradient(135deg, var(--slate-50) 0%, var(--slate-100) 100%);
|
||
border-radius: 18rpx;
|
||
border: 1rpx solid #eef2f5;
|
||
}
|
||
.chart-summary-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4rpx;
|
||
position: relative;
|
||
}
|
||
.chart-summary-item + .chart-summary-item::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
top: 18%;
|
||
bottom: 18%;
|
||
width: 1rpx;
|
||
background: var(--slate-200);
|
||
}
|
||
.chart-summary-num {
|
||
font-size: 44rpx;
|
||
font-weight: 800;
|
||
color: var(--slate-900);
|
||
line-height: 1.1;
|
||
letter-spacing: -0.5rpx;
|
||
}
|
||
.chart-summary-item:first-child .chart-summary-num {
|
||
color: var(--danger);
|
||
}
|
||
.chart-summary-item:nth-child(2) .chart-summary-num {
|
||
color: var(--primary);
|
||
}
|
||
.chart-summary-label {
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
letter-spacing: 1rpx;
|
||
font-weight: 500;
|
||
margin-top: 4rpx;
|
||
}
|
||
|
||
.chart-canvas-wrap {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 460rpx;
|
||
margin-top: 22rpx;
|
||
}
|
||
.chart-canvas-placeholder {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(180deg, var(--slate-50) 0%, var(--slate-100) 100%);
|
||
border-radius: 18rpx;
|
||
color: var(--slate-600);
|
||
font-size: 28rpx;
|
||
letter-spacing: 1rpx;
|
||
font-weight: 500;
|
||
}
|
||
.chart-canvas {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.chart-empty {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 14rpx;
|
||
color: var(--slate-600);
|
||
}
|
||
.chart-empty-icon {
|
||
font-size: 72rpx;
|
||
}
|
||
.chart-empty-text {
|
||
font-size: 30rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 500;
|
||
}
|
||
.chart-tip {
|
||
position: absolute;
|
||
min-width: 220rpx;
|
||
padding: 16rpx 20rpx;
|
||
background: rgba(15, 23, 42, 0.94);
|
||
color: #ffffff;
|
||
border-radius: 12rpx;
|
||
box-shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.18);
|
||
pointer-events: none;
|
||
z-index: 999;
|
||
}
|
||
.chart-tip-date {
|
||
font-size: 26rpx;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
font-weight: 600;
|
||
}
|
||
.chart-tip-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
margin-top: 8rpx;
|
||
}
|
||
.chart-tip-text {
|
||
font-size: 28rpx;
|
||
color: #ffffff;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* === 日常记录列表样式 === */
|
||
.records-section {
|
||
margin: 28rpx 24rpx 0;
|
||
}
|
||
.records-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20rpx;
|
||
padding: 0 6rpx;
|
||
}
|
||
.records-count {
|
||
font-size: 28rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 600;
|
||
}
|
||
.day-card {
|
||
background: #ffffff;
|
||
border-radius: 28rpx;
|
||
padding: 30rpx 28rpx;
|
||
margin-bottom: 22rpx;
|
||
box-shadow: var(--shadow-premium);
|
||
border: var(--border-premium);
|
||
}
|
||
.day-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 14rpx;
|
||
border-bottom: 1rpx solid var(--slate-100);
|
||
padding-bottom: 12rpx;
|
||
}
|
||
.day-head-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
}
|
||
.day-date {
|
||
font-size: 36rpx;
|
||
font-weight: 800;
|
||
color: var(--slate-900);
|
||
}
|
||
.day-today-pill {
|
||
font-size: 24rpx;
|
||
padding: 4rpx 16rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--primary-grad);
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
box-shadow: var(--shadow-glow-primary);
|
||
}
|
||
.day-head-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14rpx;
|
||
}
|
||
.day-weekday {
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 500;
|
||
}
|
||
.day-speak-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding: 14rpx 28rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--primary-light);
|
||
border: 1rpx solid rgba(13, 148, 136, 0.2);
|
||
box-shadow: none;
|
||
min-height: 64rpx;
|
||
box-sizing: border-box;
|
||
transition: var(--transition-smooth);
|
||
.day-speak-icon {
|
||
font-size: 26rpx;
|
||
color: var(--primary);
|
||
}
|
||
.day-speak-text {
|
||
font-size: 26rpx;
|
||
color: var(--primary);
|
||
font-weight: 700;
|
||
}
|
||
&:active {
|
||
background: var(--primary-glow);
|
||
transform: scale(0.96);
|
||
}
|
||
}
|
||
.day-block {
|
||
margin-top: 18rpx;
|
||
padding: 24rpx;
|
||
background: var(--slate-50);
|
||
border-radius: 20rpx;
|
||
border: 1rpx solid var(--slate-100);
|
||
}
|
||
.day-block-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.day-block-tag {
|
||
font-size: 24rpx;
|
||
padding: 8rpx 20rpx;
|
||
border-radius: var(--radius-pill);
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
&.tag-blood {
|
||
background: var(--primary-light);
|
||
color: var(--primary);
|
||
border: 1rpx solid rgba(13, 148, 136, 0.15);
|
||
}
|
||
&.tag-diet {
|
||
background: var(--warning-light);
|
||
color: #d97706;
|
||
border: 1rpx solid rgba(245, 158, 11, 0.15);
|
||
}
|
||
&.tag-exercise {
|
||
background: var(--violet-light);
|
||
color: #4f46e5;
|
||
border: 1rpx solid rgba(99, 102, 241, 0.15);
|
||
}
|
||
&.tag-tracking {
|
||
background: var(--slate-100);
|
||
color: var(--slate-600);
|
||
border: 1rpx solid var(--slate-200);
|
||
}
|
||
}
|
||
.day-block-time {
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 500;
|
||
}
|
||
.metric-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12rpx;
|
||
margin-top: 14rpx;
|
||
}
|
||
.metric-cell {
|
||
flex: 1 1 calc(50% - 6rpx);
|
||
background: #ffffff;
|
||
border-radius: 14rpx;
|
||
padding: 18rpx 18rpx 16rpx;
|
||
position: relative;
|
||
border: 1rpx solid var(--slate-100);
|
||
}
|
||
.metric-row-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8rpx;
|
||
}
|
||
.metric-label {
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 600;
|
||
}
|
||
.metric-flag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
padding: 2rpx 10rpx 2rpx 6rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--danger-light);
|
||
border: 1rpx solid #fecaca;
|
||
&.up {
|
||
background: var(--danger-grad);
|
||
border-color: transparent;
|
||
box-shadow: var(--shadow-glow-danger);
|
||
}
|
||
}
|
||
.metric-flag-icon {
|
||
font-size: 16rpx;
|
||
color: #ffffff;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
.metric-flag-text {
|
||
font-size: 18rpx;
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
}
|
||
.metric-value-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 6rpx;
|
||
margin-top: 6rpx;
|
||
}
|
||
.metric-value {
|
||
font-size: 40rpx;
|
||
font-weight: 800;
|
||
color: var(--slate-900);
|
||
line-height: 1.1;
|
||
&.is-high {
|
||
color: var(--danger);
|
||
}
|
||
}
|
||
.metric-unit {
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 500;
|
||
}
|
||
.metric-trend {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
margin-top: 10rpx;
|
||
padding: 2rpx 12rpx 2rpx 8rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--slate-100);
|
||
border: 1rpx solid transparent;
|
||
&.trend-up {
|
||
background: #fef3c7;
|
||
border-color: #fde68a;
|
||
.metric-trend-arrow,
|
||
.metric-trend-text {
|
||
color: #b45309;
|
||
}
|
||
}
|
||
&.trend-down {
|
||
background: #dcfce7;
|
||
border-color: #bbf7d0;
|
||
.metric-trend-arrow,
|
||
.metric-trend-text {
|
||
color: #15803d;
|
||
}
|
||
}
|
||
&.trend-flat {
|
||
background: var(--slate-100);
|
||
border-color: var(--slate-200);
|
||
.metric-trend-arrow,
|
||
.metric-trend-text {
|
||
color: var(--slate-600);
|
||
}
|
||
}
|
||
}
|
||
.metric-trend-arrow {
|
||
font-size: 20rpx;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
.metric-trend-text {
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
}
|
||
.day-meta-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10rpx;
|
||
margin-top: 14rpx;
|
||
}
|
||
.meta-chip {
|
||
font-size: 22rpx;
|
||
color: var(--slate-600);
|
||
background: #ffffff;
|
||
border-radius: var(--radius-pill);
|
||
padding: 6rpx 18rpx;
|
||
border: 1rpx solid var(--slate-200);
|
||
}
|
||
.day-remark {
|
||
margin-top: 14rpx;
|
||
font-size: 28rpx;
|
||
color: var(--slate-900);
|
||
line-height: 1.7;
|
||
}
|
||
.diet-rows {
|
||
margin-top: 14rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10rpx;
|
||
}
|
||
.diet-row {
|
||
display: flex;
|
||
gap: 10rpx;
|
||
align-items: flex-start;
|
||
}
|
||
.diet-meal {
|
||
flex-shrink: 0;
|
||
width: 140rpx;
|
||
font-size: 28rpx;
|
||
color: #b45309;
|
||
font-weight: 700;
|
||
}
|
||
.diet-text {
|
||
flex: 1;
|
||
font-size: 28rpx;
|
||
color: var(--slate-900);
|
||
line-height: 1.7;
|
||
}
|
||
.exercise-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
margin-top: 14rpx;
|
||
}
|
||
.exercise-type {
|
||
font-size: 26rpx;
|
||
color: var(--slate-900);
|
||
font-weight: 600;
|
||
}
|
||
.exercise-duration {
|
||
font-size: 24rpx;
|
||
color: var(--violet);
|
||
background: var(--violet-light);
|
||
border-radius: var(--radius-pill);
|
||
padding: 4rpx 16rpx;
|
||
font-weight: 600;
|
||
}
|
||
.exercise-intensity {
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 500;
|
||
}
|
||
.tracking-content {
|
||
margin-top: 12rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6rpx;
|
||
}
|
||
.tracking-line {
|
||
font-size: 28rpx;
|
||
color: var(--slate-900);
|
||
line-height: 1.7;
|
||
word-break: break-all;
|
||
}
|
||
.state-card {
|
||
background: #ffffff;
|
||
border-radius: var(--radius-card);
|
||
padding: 60rpx 24rpx;
|
||
text-align: center;
|
||
box-shadow: var(--shadow-premium);
|
||
border: var(--border-premium);
|
||
}
|
||
.state-icon {
|
||
display: block;
|
||
font-size: 76rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.state-text {
|
||
font-size: 30rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 500;
|
||
}
|
||
.footer-tip {
|
||
text-align: center;
|
||
font-size: 24rpx;
|
||
color: var(--slate-400);
|
||
margin-top: 40rpx;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* === 顶部 Hero 漂浮小饰品 === */
|
||
.hero-orb {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
pointer-events: none;
|
||
filter: blur(4rpx);
|
||
}
|
||
.hero-orb-1 {
|
||
width: 220rpx;
|
||
height: 220rpx;
|
||
top: -60rpx;
|
||
right: -40rpx;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
}
|
||
.hero-orb-2 {
|
||
width: 140rpx;
|
||
height: 140rpx;
|
||
top: 40rpx;
|
||
right: 240rpx;
|
||
background: rgba(253, 224, 71, 0.1);
|
||
}
|
||
.hero-orb-3 {
|
||
width: 90rpx;
|
||
height: 90rpx;
|
||
bottom: 30rpx;
|
||
left: 40%;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
.hero-greet-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
margin-bottom: 6rpx;
|
||
}
|
||
.hero-greet-icon-wrap {
|
||
width: 52rpx;
|
||
height: 52rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(255, 255, 255, 0.18);
|
||
border: 1rpx solid rgba(255, 255, 255, 0.28);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.hero-greet-text {
|
||
font-size: 30rpx;
|
||
color: rgba(255, 255, 255, 0.95);
|
||
letter-spacing: 2rpx;
|
||
font-weight: 500;
|
||
}
|
||
.hero-subtitle-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
margin-top: 10rpx;
|
||
flex-wrap: wrap;
|
||
}
|
||
.hero-status-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding: 8rpx 18rpx 8rpx 12rpx;
|
||
border-radius: var(--radius-pill);
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
backdrop-filter: blur(8px);
|
||
&.done {
|
||
background: rgba(34, 197, 94, 0.25);
|
||
color: #ffffff;
|
||
border: 1rpx solid rgba(187, 247, 208, 0.5);
|
||
}
|
||
&.pending {
|
||
background: rgba(251, 191, 36, 0.28);
|
||
color: #ffffff;
|
||
border: 1rpx solid rgba(253, 230, 138, 0.5);
|
||
}
|
||
}
|
||
.hero-status-icon {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.hero-status-text {
|
||
line-height: 1.2;
|
||
}
|
||
|
||
/* === 打卡条 === */
|
||
.streak-strip {
|
||
position: relative;
|
||
margin: 22rpx 24rpx 0;
|
||
background: linear-gradient(135deg, #ffffff 0%, var(--primary-light) 100%);
|
||
border: 1rpx solid rgba(13, 148, 136, 0.12);
|
||
border-radius: var(--radius-card);
|
||
padding: 32rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 20rpx;
|
||
box-shadow: 0 10rpx 30rpx rgba(13, 148, 136, 0.05);
|
||
overflow: hidden;
|
||
min-height: 132rpx;
|
||
box-sizing: border-box;
|
||
transition: var(--transition-smooth);
|
||
&:active {
|
||
transform: scale(0.99);
|
||
}
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: -40rpx;
|
||
right: -40rpx;
|
||
width: 160rpx;
|
||
height: 160rpx;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle, rgba(251, 191, 36, 0.15) 0%, rgba(251, 191, 36, 0) 70%);
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
.streak-strip-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
.streak-flame {
|
||
width: 92rpx;
|
||
height: 92rpx;
|
||
border-radius: 24rpx;
|
||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: inset 0 -3rpx 0 rgba(245, 158, 11, 0.18);
|
||
flex-shrink: 0;
|
||
&.active {
|
||
background: linear-gradient(135deg, #fb923c 0%, #f97316 100%);
|
||
box-shadow: 0 6rpx 14rpx rgba(249, 115, 22, 0.35), inset 0 -3rpx 0 rgba(0, 0, 0, 0.08);
|
||
}
|
||
}
|
||
.streak-flame-icon {
|
||
font-size: 52rpx;
|
||
line-height: 1;
|
||
}
|
||
.streak-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
.streak-num-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8rpx;
|
||
flex-wrap: wrap;
|
||
}
|
||
.streak-num {
|
||
font-size: 64rpx;
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
letter-spacing: -1rpx;
|
||
background: var(--primary-grad);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
color: transparent;
|
||
}
|
||
.streak-num-unit {
|
||
font-size: 32rpx;
|
||
color: var(--slate-900);
|
||
font-weight: 700;
|
||
}
|
||
.streak-hint {
|
||
margin-left: 8rpx;
|
||
font-size: 22rpx;
|
||
color: var(--slate-600);
|
||
background: var(--slate-100);
|
||
border-radius: var(--radius-pill);
|
||
padding: 4rpx 14rpx;
|
||
font-weight: 500;
|
||
}
|
||
.streak-label {
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
letter-spacing: 0.5rpx;
|
||
margin-top: 2rpx;
|
||
}
|
||
.streak-strip-right {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 10rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
.streak-dots-row {
|
||
display: flex;
|
||
gap: 12rpx;
|
||
}
|
||
.streak-dot {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 6rpx;
|
||
}
|
||
.streak-dot-label {
|
||
font-size: 22rpx;
|
||
color: var(--slate-600);
|
||
line-height: 1;
|
||
font-weight: 600;
|
||
}
|
||
.streak-dot-mark {
|
||
width: 26rpx;
|
||
height: 26rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--slate-200);
|
||
}
|
||
.streak-dot.done .streak-dot-mark {
|
||
background: var(--primary-grad);
|
||
box-shadow: 0 2rpx 6rpx rgba(32, 78, 43, 0.3);
|
||
}
|
||
.streak-dot.today {
|
||
.streak-dot-label {
|
||
color: var(--slate-900);
|
||
font-weight: 700;
|
||
}
|
||
.streak-dot-mark {
|
||
width: 30rpx;
|
||
height: 30rpx;
|
||
border: 2rpx solid var(--primary);
|
||
}
|
||
}
|
||
.streak-dot.today.done .streak-dot-mark {
|
||
border-color: #ffffff;
|
||
box-shadow: 0 0 0 3rpx var(--primary), 0 2rpx 8rpx rgba(32, 78, 43, 0.4);
|
||
}
|
||
.streak-week-text {
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
letter-spacing: 1rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* === 健康月历 === */
|
||
.calendar-card {
|
||
margin: 22rpx 24rpx 0;
|
||
}
|
||
.calendar-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16rpx;
|
||
}
|
||
.calendar-head-left {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.calendar-stat {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 2rpx;
|
||
padding: 4rpx 18rpx 4rpx 22rpx;
|
||
background: linear-gradient(135deg, #eef6ef 0%, #ccfbf1 100%);
|
||
border-radius: 20rpx;
|
||
border-left: 6rpx solid var(--primary);
|
||
}
|
||
.calendar-stat-num {
|
||
font-size: 48rpx;
|
||
font-weight: 800;
|
||
color: #386641;
|
||
line-height: 1.1;
|
||
letter-spacing: -1rpx;
|
||
}
|
||
.calendar-stat-label {
|
||
font-size: 22rpx;
|
||
color: #0f766e;
|
||
font-weight: 600;
|
||
}
|
||
.calendar-weekdays {
|
||
display: flex;
|
||
margin-top: 22rpx;
|
||
padding: 0 4rpx;
|
||
}
|
||
.calendar-weekday {
|
||
flex: 1;
|
||
text-align: center;
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 700;
|
||
letter-spacing: 1rpx;
|
||
line-height: 1.4;
|
||
padding: 8rpx 0;
|
||
}
|
||
.calendar-weekday-weekend {
|
||
color: var(--warning);
|
||
}
|
||
.calendar-grid {
|
||
margin-top: 6rpx;
|
||
background: var(--slate-50);
|
||
border-radius: 20rpx;
|
||
padding: 12rpx 6rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6rpx;
|
||
}
|
||
.calendar-row {
|
||
display: flex;
|
||
gap: 6rpx;
|
||
}
|
||
.calendar-cell {
|
||
flex: 1;
|
||
aspect-ratio: 1 / 1;
|
||
min-height: 84rpx;
|
||
border-radius: 18rpx;
|
||
background: #ffffff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
transition: var(--transition-smooth);
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
border: 2rpx solid transparent;
|
||
&.level-0 {
|
||
background: #ffffff;
|
||
border-color: var(--slate-100);
|
||
}
|
||
&.off {
|
||
background: transparent;
|
||
opacity: 0.4;
|
||
}
|
||
&.level-1 {
|
||
background: var(--primary-light);
|
||
border-color: rgba(13, 148, 136, 0.15);
|
||
.calendar-cell-day { color: var(--primary); }
|
||
}
|
||
&.level-2 {
|
||
background: #ccfbf1;
|
||
border-color: #99f6e4;
|
||
.calendar-cell-day { color: #0f766e; }
|
||
}
|
||
&.level-3 {
|
||
background: var(--primary-grad);
|
||
.calendar-cell-day { color: #ffffff; }
|
||
box-shadow: 0 4rpx 12rpx rgba(13, 148, 136, 0.2);
|
||
}
|
||
&.today {
|
||
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%) !important;
|
||
border: 4rpx solid #ffffff;
|
||
box-shadow: 0 0 0 3rpx #ea580c, 0 6rpx 16rpx rgba(234, 88, 12, 0.35);
|
||
.calendar-cell-day { color: #ffffff; }
|
||
}
|
||
&.clickable:active {
|
||
transform: scale(0.94);
|
||
}
|
||
}
|
||
.calendar-cell-day {
|
||
font-size: 32rpx;
|
||
color: var(--slate-900);
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
.calendar-cell.today .calendar-cell-day {
|
||
color: #ffffff;
|
||
font-weight: 800;
|
||
transform: translateY(-6rpx);
|
||
}
|
||
.calendar-cell.off .calendar-cell-day {
|
||
color: var(--slate-400);
|
||
font-weight: 500;
|
||
}
|
||
.calendar-cell-mark {
|
||
position: absolute;
|
||
right: 8rpx;
|
||
bottom: 8rpx;
|
||
width: 10rpx;
|
||
height: 10rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: rgba(255, 255, 255, 0.85);
|
||
box-shadow: 0 0 0 2rpx rgba(15, 23, 42, 0.08);
|
||
}
|
||
.calendar-cell.level-1 .calendar-cell-mark {
|
||
background: #386641;
|
||
box-shadow: none;
|
||
}
|
||
.calendar-cell.today .calendar-cell-mark {
|
||
display: none;
|
||
}
|
||
.calendar-cell-today-tag {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(220, 38, 38, 0.92);
|
||
color: #ffffff;
|
||
font-size: 18rpx;
|
||
font-weight: 700;
|
||
padding: 3rpx 0;
|
||
text-align: center;
|
||
line-height: 1.2;
|
||
letter-spacing: 2rpx;
|
||
}
|
||
.calendar-legend {
|
||
margin-top: 18rpx;
|
||
padding: 14rpx 6rpx 4rpx;
|
||
}
|
||
.calendar-legend-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-around;
|
||
gap: 8rpx;
|
||
}
|
||
.calendar-legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
}
|
||
.calendar-legend-cell {
|
||
width: 28rpx;
|
||
height: 28rpx;
|
||
border-radius: 8rpx;
|
||
background: var(--slate-100);
|
||
border: 1rpx solid var(--slate-200);
|
||
&.level-1 { background: #ccfbf1; border-color: #99f6e4; }
|
||
&.level-2 { background: linear-gradient(135deg, #cfe8d3 0%, #afe2b3 100%); border-color: #386641; }
|
||
&.level-3 { background: var(--primary-grad); border-color: transparent; }
|
||
&.legend-today {
|
||
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
|
||
border: 2rpx solid #ffffff;
|
||
box-shadow: 0 0 0 2rpx #ea580c;
|
||
}
|
||
}
|
||
.calendar-legend-text {
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 600;
|
||
}
|
||
.heatmap-foot {
|
||
margin-top: 22rpx;
|
||
padding-top: 18rpx;
|
||
border-top: 1rpx dashed var(--slate-200);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12rpx;
|
||
}
|
||
.heatmap-foot-text {
|
||
font-size: 28rpx;
|
||
color: #334155;
|
||
font-weight: 600;
|
||
}
|
||
.heatmap-foot-cta {
|
||
background: var(--primary-grad);
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
padding: 18rpx 30rpx;
|
||
border-radius: var(--radius-pill);
|
||
box-shadow: var(--shadow-glow-primary);
|
||
min-height: 76rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
transition: var(--transition-smooth);
|
||
&:active {
|
||
transform: scale(0.96);
|
||
}
|
||
}
|
||
|
||
/* === 弹窗表单(bottom sheet)=== */
|
||
.input-modal-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(15, 23, 42, 0.45);
|
||
backdrop-filter: blur(12px);
|
||
z-index: 999;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
animation: input-modal-mask-in 0.38s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||
}
|
||
.input-modal {
|
||
width: 100%;
|
||
max-width: 750rpx;
|
||
background: #ffffff;
|
||
border-radius: 36rpx 36rpx 0 0;
|
||
padding: 16rpx 0 calc(20rpx + env(safe-area-inset-bottom));
|
||
box-shadow: 0 -12rpx 36rpx rgba(15, 23, 42, 0.12);
|
||
max-height: 88vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
animation: input-modal-rise 0.46s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||
position: relative;
|
||
z-index: 1000;
|
||
will-change: transform, opacity;
|
||
}
|
||
@keyframes input-modal-mask-in {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
@keyframes input-modal-rise {
|
||
from { transform: translate3d(0, 100%, 0); opacity: 0.96; }
|
||
to { transform: translate3d(0, 0, 0); opacity: 1; }
|
||
}
|
||
@keyframes input-modal-content-in {
|
||
from { opacity: 0; transform: translate3d(0, 24rpx, 0); }
|
||
to { opacity: 1; transform: translate3d(0, 0, 0); }
|
||
}
|
||
.input-modal-grip {
|
||
width: 80rpx;
|
||
height: 6rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--slate-200);
|
||
margin: 0 auto 8rpx;
|
||
}
|
||
.input-modal-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12rpx 32rpx 16rpx;
|
||
border-bottom: 1rpx solid var(--slate-100);
|
||
}
|
||
.input-modal-title-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
}
|
||
.input-modal-title {
|
||
font-size: 40rpx;
|
||
font-weight: 800;
|
||
color: var(--slate-900);
|
||
letter-spacing: 0.5rpx;
|
||
}
|
||
.input-modal-sub {
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
margin-top: 4rpx;
|
||
}
|
||
.input-modal-close {
|
||
width: 80rpx;
|
||
height: 80rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--slate-100);
|
||
transition: var(--transition-smooth);
|
||
&:active {
|
||
background: var(--slate-200);
|
||
}
|
||
}
|
||
.input-modal-close-icon {
|
||
font-size: 42rpx;
|
||
color: var(--slate-600);
|
||
line-height: 1;
|
||
font-weight: 700;
|
||
}
|
||
.input-modal-loading {
|
||
min-height: 520rpx;
|
||
padding: 120rpx 0;
|
||
text-align: center;
|
||
color: var(--slate-600);
|
||
font-size: 30rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.input-modal-body {
|
||
flex: 1;
|
||
min-height: 520rpx;
|
||
padding: 20rpx 32rpx 28rpx;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
}
|
||
.input-modal-body-ready {
|
||
animation: input-modal-content-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||
}
|
||
.input-modal-tip {
|
||
background: var(--primary-light);
|
||
border: 1rpx dashed #99f6e4;
|
||
border-radius: 14rpx;
|
||
padding: 18rpx 20rpx;
|
||
font-size: 26rpx;
|
||
color: #0f766e;
|
||
line-height: 1.5;
|
||
margin-bottom: 22rpx;
|
||
}
|
||
.input-section-title {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: #334155;
|
||
letter-spacing: 1rpx;
|
||
margin: 18rpx 0 14rpx;
|
||
}
|
||
.intensity-row {
|
||
display: flex;
|
||
gap: 12rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.intensity-chip {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: 16rpx 8rpx;
|
||
border-radius: 14rpx;
|
||
background: var(--slate-50);
|
||
border: 2rpx solid var(--slate-200);
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
transition: var(--transition-smooth);
|
||
&.active {
|
||
background: var(--success-light);
|
||
border-color: var(--success);
|
||
color: #047857;
|
||
font-weight: 700;
|
||
}
|
||
}
|
||
.input-field {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18rpx;
|
||
padding: 14rpx 0;
|
||
border-bottom: 1rpx solid var(--slate-100);
|
||
min-width: 0;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
min-height: 88rpx;
|
||
}
|
||
.input-field-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
flex: 0 0 auto;
|
||
width: 180rpx;
|
||
font-size: 30rpx;
|
||
color: var(--slate-900);
|
||
font-weight: 700;
|
||
}
|
||
.input-field-dot {
|
||
width: 14rpx;
|
||
height: 14rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--slate-400);
|
||
flex-shrink: 0;
|
||
}
|
||
.input-field-dot-fasting { background: var(--primary); }
|
||
.input-field-dot-postprandial { background: var(--warning); }
|
||
.input-field-dot-other { background: var(--violet); }
|
||
|
||
.input-field-input {
|
||
flex: 1 1 0;
|
||
min-width: 0;
|
||
background: var(--slate-50);
|
||
border-radius: 16rpx;
|
||
padding: 18rpx 20rpx;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
min-height: 76rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
border: 2rpx solid transparent;
|
||
transition: var(--transition-smooth);
|
||
&:focus-within {
|
||
border-color: var(--primary);
|
||
background: #ffffff;
|
||
box-shadow: 0 0 0 4rpx var(--primary-glow);
|
||
}
|
||
input {
|
||
width: 100%;
|
||
min-width: 0;
|
||
font-size: 34rpx;
|
||
color: var(--slate-900);
|
||
text-align: right;
|
||
box-sizing: border-box;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
.input-placeholder {
|
||
color: var(--slate-400);
|
||
font-size: 28rpx;
|
||
}
|
||
.input-field-row {
|
||
display: flex;
|
||
gap: 18rpx;
|
||
min-width: 0;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.input-field-half {
|
||
flex: 1 1 0;
|
||
min-width: 0;
|
||
border-bottom: 1rpx solid var(--slate-100);
|
||
}
|
||
.input-field-half .input-field-label {
|
||
width: 80rpx;
|
||
flex: 0 0 auto;
|
||
}
|
||
.input-field-row .input-field-input {
|
||
padding: 16rpx 16rpx;
|
||
input {
|
||
font-size: 32rpx;
|
||
}
|
||
}
|
||
.input-field-textarea {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
border-bottom: none;
|
||
padding: 10rpx 0 0;
|
||
textarea {
|
||
width: 100%;
|
||
min-height: 140rpx;
|
||
background: var(--slate-50);
|
||
border-radius: 14rpx;
|
||
padding: 20rpx 22rpx;
|
||
font-size: 30rpx;
|
||
color: var(--slate-900);
|
||
box-sizing: border-box;
|
||
line-height: 1.6;
|
||
border: 2rpx solid transparent;
|
||
transition: var(--transition-smooth);
|
||
&:focus {
|
||
border-color: var(--primary);
|
||
background: #ffffff;
|
||
box-shadow: 0 0 0 4rpx var(--primary-glow);
|
||
}
|
||
}
|
||
}
|
||
.input-modal-actions {
|
||
display: flex;
|
||
gap: 18rpx;
|
||
padding: 20rpx 32rpx 0;
|
||
border-top: 1rpx solid var(--slate-100);
|
||
}
|
||
.input-modal-btn {
|
||
flex: 1;
|
||
height: 100rpx;
|
||
border-radius: 20rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 32rpx;
|
||
font-weight: 700;
|
||
letter-spacing: 2rpx;
|
||
transition: var(--transition-smooth);
|
||
&.primary {
|
||
background: var(--primary-grad);
|
||
color: #ffffff;
|
||
box-shadow: var(--shadow-glow-primary);
|
||
&:active {
|
||
transform: translateY(1rpx);
|
||
}
|
||
}
|
||
&.danger {
|
||
flex: 0 0 180rpx;
|
||
background: var(--danger-light);
|
||
color: var(--danger);
|
||
border: 1rpx solid rgba(239, 68, 68, 0.2);
|
||
&:active {
|
||
background: #ffe4e6;
|
||
}
|
||
}
|
||
&.disabled {
|
||
opacity: 0.5;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
/* === 稳糖乐园 === */
|
||
.pg-card {
|
||
margin: 28rpx 24rpx 0;
|
||
background: #ffffff;
|
||
border-radius: 32rpx;
|
||
padding: 30rpx 28rpx;
|
||
box-shadow: var(--shadow-premium);
|
||
border: var(--border-premium);
|
||
animation: card-rise 0.45s ease-out 0.08s both;
|
||
.card-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-bottom: 1rpx solid var(--slate-100);
|
||
padding-bottom: 20rpx;
|
||
}
|
||
.pg-points-badge {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
background: linear-gradient(135deg, #eef6ef 0%, #ccfbf1 100%);
|
||
border-left: 6rpx solid var(--primary);
|
||
padding: 8rpx 18rpx 8rpx 20rpx;
|
||
border-radius: 16rpx;
|
||
.pg-points-num {
|
||
font-size: 38rpx;
|
||
font-weight: 800;
|
||
color: #386641;
|
||
line-height: 1.1;
|
||
}
|
||
.pg-points-label {
|
||
font-size: 20rpx;
|
||
color: #0f766e;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
}
|
||
.pg-content-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 28rpx;
|
||
margin-top: 24rpx;
|
||
}
|
||
.record-quick-zone {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
}
|
||
.record-quick-head {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4rpx;
|
||
padding-left: 6rpx;
|
||
}
|
||
.record-quick-title {
|
||
font-size: 28rpx;
|
||
font-weight: 800;
|
||
color: #334155;
|
||
}
|
||
.record-quick-hint {
|
||
font-size: 22rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 600;
|
||
}
|
||
.record-quick-row {
|
||
display: flex;
|
||
gap: 12rpx;
|
||
}
|
||
.record-quick-chip {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding: 24rpx 12rpx;
|
||
background: #ffffff;
|
||
border: 1rpx solid var(--slate-200);
|
||
border-radius: 24rpx;
|
||
box-shadow: 0 4rpx 12rpx rgba(15, 23, 42, 0.02);
|
||
transition: var(--transition-smooth);
|
||
&:active {
|
||
transform: scale(0.98);
|
||
}
|
||
&.pending {
|
||
border-color: rgba(13, 148, 136, 0.2);
|
||
background: linear-gradient(180deg, var(--primary-light) 0%, #ffffff 100%);
|
||
}
|
||
&.done {
|
||
border-color: rgba(16, 185, 129, 0.2);
|
||
background: linear-gradient(180deg, var(--success-light) 0%, #ffffff 100%);
|
||
}
|
||
}
|
||
.record-quick-icon-wrap {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--slate-50);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
.record-quick-chip.pending & {
|
||
background: #ccfbf1;
|
||
}
|
||
.record-quick-chip.done & {
|
||
background: #dcfce7;
|
||
}
|
||
}
|
||
.record-quick-label {
|
||
font-size: 26rpx;
|
||
font-weight: 800;
|
||
color: var(--slate-900);
|
||
}
|
||
.record-quick-status {
|
||
font-size: 22rpx;
|
||
font-weight: 700;
|
||
color: #386641;
|
||
.record-quick-chip.done & {
|
||
color: var(--success);
|
||
}
|
||
}
|
||
.tree-growth-zone {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 20rpx;
|
||
background: linear-gradient(145deg, #eef6ef 0%, #f8fafc 55%, #fffbeb 100%);
|
||
border-radius: 24rpx;
|
||
padding: 24rpx 20rpx;
|
||
border: 2rpx solid #99f6e4;
|
||
}
|
||
.tree-graphic-wrap {
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
width: 152rpx;
|
||
min-height: 200rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.tree-lv-badge {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 2;
|
||
padding: 4rpx 14rpx;
|
||
border-radius: 999rpx;
|
||
background: linear-gradient(135deg, #204e2b, #386641);
|
||
color: #ffffff;
|
||
font-size: 20rpx;
|
||
font-weight: 800;
|
||
box-shadow: 0 4rpx 12rpx rgba(32, 78, 43, 0.35);
|
||
}
|
||
.tree-mood-pill {
|
||
position: absolute;
|
||
top: 36rpx;
|
||
right: -8rpx;
|
||
z-index: 2;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
padding: 4rpx 10rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
border: 1rpx solid #e2e8f0;
|
||
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.08);
|
||
}
|
||
.tree-mood-emoji {
|
||
font-size: 22rpx;
|
||
line-height: 1;
|
||
}
|
||
.tree-mood-text {
|
||
font-size: 20rpx;
|
||
font-weight: 700;
|
||
color: #64748b;
|
||
}
|
||
.tree-next-hint {
|
||
display: block;
|
||
margin-top: 8rpx;
|
||
font-size: 22rpx;
|
||
color: #0f766e;
|
||
font-weight: 600;
|
||
line-height: 1.45;
|
||
}
|
||
.tree-roadmap-scroll {
|
||
width: 100%;
|
||
margin-top: 14rpx;
|
||
white-space: nowrap;
|
||
}
|
||
.tree-roadmap-row {
|
||
display: inline-flex;
|
||
gap: 10rpx;
|
||
padding-bottom: 4rpx;
|
||
}
|
||
.tree-roadmap-node {
|
||
display: inline-flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
min-width: 56rpx;
|
||
padding: 8rpx 6rpx;
|
||
border-radius: 14rpx;
|
||
background: #faf9f5;
|
||
border: 1rpx solid #e2e8f0;
|
||
opacity: 0.55;
|
||
&.done {
|
||
opacity: 0.85;
|
||
background: #ecfdf5;
|
||
border-color: #a7f3d0;
|
||
}
|
||
&.current {
|
||
opacity: 1;
|
||
background: linear-gradient(180deg, #ccfbf1, #ffffff);
|
||
border-color: #386641;
|
||
box-shadow: 0 4rpx 14rpx rgba(32, 78, 43, 0.25);
|
||
transform: scale(1.06);
|
||
}
|
||
&.locked {
|
||
opacity: 0.4;
|
||
}
|
||
}
|
||
.tree-roadmap-emoji {
|
||
font-size: 26rpx;
|
||
line-height: 1;
|
||
}
|
||
.tree-roadmap-lv {
|
||
font-size: 18rpx;
|
||
font-weight: 700;
|
||
color: #64748b;
|
||
.tree-roadmap-node.current & {
|
||
color: #0f766e;
|
||
}
|
||
}
|
||
.tree-tappable:active {
|
||
opacity: 0.92;
|
||
}
|
||
.tree-tap-hint,
|
||
.invite-tree-tap-hint {
|
||
margin-top: 4rpx;
|
||
font-size: 20rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 600;
|
||
}
|
||
.invite-tree-tappable {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
.float-reward {
|
||
position: absolute;
|
||
top: 8rpx;
|
||
right: 0;
|
||
z-index: 3;
|
||
animation: float-reward-rise 1.1s ease-out forwards;
|
||
text {
|
||
font-size: 32rpx;
|
||
font-weight: 800;
|
||
color: var(--success);
|
||
text-shadow: 0 2rpx 8rpx rgba(5, 150, 105, 0.25);
|
||
}
|
||
}
|
||
@keyframes float-reward-rise {
|
||
0% { opacity: 1; transform: translateY(0) scale(0.8); }
|
||
100% { opacity: 0; transform: translateY(-56rpx) scale(1.15); }
|
||
}
|
||
.mission-complete-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
margin-bottom: 20rpx;
|
||
padding: 18rpx 20rpx;
|
||
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||
border: 2rpx solid #fde68a;
|
||
border-radius: 20rpx;
|
||
animation: mission-glow 2s ease-in-out infinite;
|
||
}
|
||
@keyframes mission-glow {
|
||
0%, 100% { box-shadow: 0 0 0 rgba(251, 191, 36, 0); }
|
||
50% { box-shadow: 0 0 20rpx rgba(251, 191, 36, 0.35); }
|
||
}
|
||
.mission-complete-title {
|
||
display: block;
|
||
font-size: 28rpx;
|
||
font-weight: 800;
|
||
color: #92400e;
|
||
}
|
||
.mission-complete-sub {
|
||
display: block;
|
||
margin-top: 4rpx;
|
||
font-size: 24rpx;
|
||
color: #b45309;
|
||
}
|
||
.badge-wall {
|
||
margin-top: 12rpx;
|
||
}
|
||
.badge-wall-title {
|
||
display: block;
|
||
font-size: 32rpx;
|
||
font-weight: 800;
|
||
color: #475569;
|
||
margin-bottom: 16rpx;
|
||
padding-left: 6rpx;
|
||
}
|
||
.badge-wall-scroll {
|
||
width: 100%;
|
||
white-space: nowrap;
|
||
}
|
||
.badge-wall-row {
|
||
display: inline-flex;
|
||
gap: 14rpx;
|
||
padding-bottom: 8rpx;
|
||
}
|
||
.badge-chip {
|
||
display: inline-flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
min-width: 140rpx;
|
||
padding: 18rpx 20rpx;
|
||
background: #ffffff;
|
||
border: 1rpx solid var(--slate-200);
|
||
border-radius: 18rpx;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.badge-chip-icon {
|
||
width: 58rpx;
|
||
height: 58rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--slate-50);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.badge-chip-label {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: #334155;
|
||
white-space: nowrap;
|
||
}
|
||
.tree-progress-wrap {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10rpx;
|
||
min-width: 0;
|
||
}
|
||
.tree-level-info {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
}
|
||
.tree-level-tag {
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
color: var(--slate-900);
|
||
background: var(--slate-200);
|
||
padding: 6rpx 18rpx;
|
||
border-radius: var(--radius-pill);
|
||
}
|
||
.tree-progress-text {
|
||
font-size: 28rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 600;
|
||
}
|
||
.tree-progress-track {
|
||
height: 16rpx;
|
||
background: var(--slate-200);
|
||
border-radius: var(--radius-pill);
|
||
overflow: hidden;
|
||
}
|
||
.tree-progress-bar {
|
||
height: 100%;
|
||
background: var(--primary-grad);
|
||
border-radius: var(--radius-pill);
|
||
transition: width 0.4s ease;
|
||
}
|
||
.tree-water-action {
|
||
margin-top: 6rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
}
|
||
.water-hint {
|
||
font-size: 22rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 600;
|
||
text-align: center;
|
||
line-height: 1.4;
|
||
}
|
||
.water-btn {
|
||
width: 100%;
|
||
height: 76rpx;
|
||
background: var(--primary-grad);
|
||
color: #ffffff;
|
||
border-radius: var(--radius-pill);
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10rpx;
|
||
border: none;
|
||
box-shadow: var(--shadow-glow-primary);
|
||
transition: var(--transition-smooth);
|
||
&:active {
|
||
transform: scale(0.98);
|
||
background: linear-gradient(135deg, #386641 0%, var(--primary) 100%);
|
||
}
|
||
&.disabled {
|
||
opacity: 0.7;
|
||
pointer-events: none;
|
||
}
|
||
&.pulse {
|
||
animation: water-btn-pulse 1.6s ease-in-out infinite;
|
||
}
|
||
}
|
||
@keyframes water-btn-pulse {
|
||
0%, 100% { box-shadow: var(--shadow-glow-primary); }
|
||
50% { box-shadow: 0 4rpx 24rpx rgba(32, 78, 43, 0.5); }
|
||
}
|
||
|
||
.today-tasks-zone {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14rpx;
|
||
}
|
||
.tasks-zone-title {
|
||
font-size: 28rpx;
|
||
font-weight: 800;
|
||
color: #334155;
|
||
letter-spacing: 0.5rpx;
|
||
padding-left: 6rpx;
|
||
}
|
||
.tasks-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
}
|
||
.task-row-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background: var(--slate-50);
|
||
border: 1rpx solid var(--slate-200);
|
||
border-radius: 20rpx;
|
||
padding: 20rpx 24rpx;
|
||
min-height: 100rpx;
|
||
box-sizing: border-box;
|
||
transition: var(--transition-smooth);
|
||
&.pending {
|
||
border-color: #99f6e4;
|
||
background: linear-gradient(90deg, var(--primary-light) 0%, #ffffff 100%);
|
||
}
|
||
&.completed {
|
||
border-color: #ccfbf1;
|
||
background: var(--primary-light);
|
||
}
|
||
&.claimed {
|
||
opacity: 0.92;
|
||
}
|
||
&.just-done {
|
||
animation: task-pop 0.65s ease;
|
||
}
|
||
}
|
||
@keyframes task-pop {
|
||
0% { transform: scale(1); }
|
||
40% { transform: scale(1.03); box-shadow: 0 8rpx 20rpx rgba(32, 78, 43, 0.2); }
|
||
100% { transform: scale(1); }
|
||
}
|
||
.task-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
min-width: 0;
|
||
}
|
||
.task-status-icon {
|
||
width: 36rpx;
|
||
height: 36rpx;
|
||
border-radius: var(--radius-pill);
|
||
border: 2rpx solid var(--slate-400);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 24rpx;
|
||
font-weight: 800;
|
||
color: var(--slate-400);
|
||
line-height: 1;
|
||
flex-shrink: 0;
|
||
&.checked {
|
||
border-color: var(--primary);
|
||
background: var(--primary);
|
||
color: #ffffff;
|
||
}
|
||
}
|
||
.task-name {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: var(--slate-900);
|
||
.task-row-item.completed & {
|
||
color: #0f766e;
|
||
}
|
||
}
|
||
.task-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
.task-points {
|
||
font-size: 24rpx;
|
||
font-weight: 600;
|
||
color: var(--slate-600);
|
||
.task-row-item.completed & {
|
||
color: var(--primary);
|
||
}
|
||
}
|
||
.task-action-btn {
|
||
padding: 10rpx 22rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--slate-200);
|
||
color: var(--slate-600);
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
min-height: 52rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
transition: var(--transition-smooth);
|
||
&.pending {
|
||
background: var(--primary);
|
||
color: #ffffff;
|
||
box-shadow: 0 4rpx 10rpx rgba(32, 78, 43, 0.2);
|
||
&:active {
|
||
transform: scale(0.96);
|
||
}
|
||
}
|
||
&:not(.disabled):not(.claimed):not(.pending) {
|
||
background: var(--primary);
|
||
color: #ffffff;
|
||
box-shadow: 0 4rpx 10rpx rgba(32, 78, 43, 0.2);
|
||
&:active {
|
||
transform: scale(0.96);
|
||
}
|
||
}
|
||
&.claimed {
|
||
background: #ccfbf1;
|
||
color: #386641;
|
||
}
|
||
&.disabled {
|
||
opacity: 0.6;
|
||
}
|
||
}
|
||
.pg-share-row {
|
||
margin-top: 8rpx;
|
||
}
|
||
.pg-share-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
padding: 20rpx 22rpx;
|
||
background: var(--primary-light);
|
||
border: 1rpx solid #99f6e4;
|
||
border-radius: 20rpx;
|
||
transition: var(--transition-smooth);
|
||
&:active {
|
||
opacity: 0.85;
|
||
transform: translateY(1rpx);
|
||
}
|
||
}
|
||
.pg-share-icon-wrap {
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
border-radius: 18rpx;
|
||
background: #ffffff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.pg-share-title {
|
||
display: block;
|
||
font-size: 34rpx;
|
||
font-weight: 800;
|
||
color: #0f766e;
|
||
}
|
||
.pg-share-sub {
|
||
display: block;
|
||
margin-top: 6rpx;
|
||
font-size: 28rpx;
|
||
color: var(--slate-600);
|
||
}
|
||
|
||
/* === 家人点赞及鼓励 === */
|
||
.family-like-strip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18rpx;
|
||
margin: 20rpx 24rpx 0;
|
||
padding: 24rpx 28rpx;
|
||
background: #ffffff;
|
||
border-radius: 24rpx;
|
||
border: 1.5rpx solid #fecdd3;
|
||
box-shadow: 0 4rpx 16rpx rgba(225, 29, 72, 0.06);
|
||
transition: var(--transition-smooth);
|
||
&.has-likes {
|
||
background: linear-gradient(135deg, #fff1f2 0%, #ffffff 100%);
|
||
}
|
||
&:active {
|
||
transform: scale(0.99);
|
||
}
|
||
}
|
||
.family-like-icon {
|
||
font-size: 40rpx;
|
||
}
|
||
.family-like-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.family-like-title {
|
||
display: block;
|
||
font-size: 34rpx;
|
||
font-weight: 800;
|
||
color: #9f1239;
|
||
}
|
||
.family-like-sub {
|
||
display: block;
|
||
margin-top: 8rpx;
|
||
font-size: 30rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 500;
|
||
}
|
||
.family-like-recent {
|
||
margin-top: 10rpx;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10rpx;
|
||
}
|
||
.family-like-recent-item {
|
||
font-size: 26rpx;
|
||
color: #be123c;
|
||
background: #ffe4e6;
|
||
padding: 6rpx 16rpx;
|
||
border-radius: var(--radius-pill);
|
||
font-weight: 600;
|
||
}
|
||
.family-like-arrow {
|
||
font-size: 36rpx;
|
||
color: var(--slate-400);
|
||
}
|
||
|
||
.encourage-strip {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 16rpx;
|
||
margin: 16rpx 24rpx 0;
|
||
padding: 24rpx 24rpx;
|
||
background: #ecfdf5;
|
||
border-radius: 20rpx;
|
||
border: 1rpx solid #a7f3d0;
|
||
transition: var(--transition-smooth);
|
||
&:active {
|
||
background: #d1fae5;
|
||
}
|
||
}
|
||
.encourage-icon {
|
||
font-size: 36rpx;
|
||
}
|
||
.encourage-text {
|
||
display: block;
|
||
font-size: 32rpx;
|
||
font-weight: 700;
|
||
color: #065f46;
|
||
line-height: 1.5;
|
||
}
|
||
.encourage-tip {
|
||
display: block;
|
||
margin-top: 8rpx;
|
||
font-size: 28rpx;
|
||
color: var(--slate-600);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* === 共享/邀请查看样式 === */
|
||
.invite-view-wrap {
|
||
min-height: 100vh;
|
||
background: linear-gradient(180deg, #eef6ef 0%, #f4f4f0 42%, #faf9f5 100%);
|
||
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
|
||
}
|
||
.invite-hero {
|
||
position: relative;
|
||
padding: 48rpx 32rpx 56rpx;
|
||
overflow: hidden;
|
||
}
|
||
.invite-hero-bg {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: linear-gradient(145deg, #204e2b 0%, #386641 55%, #163d22 100%);
|
||
}
|
||
.invite-hero-content {
|
||
position: relative;
|
||
z-index: 1;
|
||
color: #ffffff;
|
||
}
|
||
.invite-hero-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10rpx;
|
||
padding: 10rpx 18rpx;
|
||
background: rgba(255, 255, 255, 0.16);
|
||
border: 1rpx solid rgba(255, 255, 255, 0.28);
|
||
border-radius: var(--radius-pill);
|
||
margin-bottom: 18rpx;
|
||
}
|
||
.invite-hero-badge-text {
|
||
font-size: 24rpx;
|
||
font-weight: 600;
|
||
letter-spacing: 1rpx;
|
||
}
|
||
.invite-hero-title {
|
||
display: block;
|
||
font-size: 40rpx;
|
||
font-weight: 800;
|
||
line-height: 1.35;
|
||
color: #ffffff;
|
||
}
|
||
.invite-hero-sub {
|
||
display: block;
|
||
margin-top: 10rpx;
|
||
font-size: 26rpx;
|
||
color: rgba(255, 255, 255, 0.92);
|
||
line-height: 1.45;
|
||
}
|
||
.invite-hero-patient {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
margin-top: 16rpx;
|
||
padding: 8rpx 16rpx;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
border-radius: 12rpx;
|
||
font-size: 26rpx;
|
||
font-weight: 600;
|
||
}
|
||
.invite-body {
|
||
margin-top: -28rpx;
|
||
padding: 0 24rpx 24rpx;
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
.invite-card {
|
||
background: #ffffff;
|
||
border-radius: var(--radius-card);
|
||
padding: 24rpx;
|
||
margin-bottom: 20rpx;
|
||
box-shadow: 0 8rpx 28rpx rgba(8, 145, 178, 0.08);
|
||
border: 1rpx solid rgba(8, 145, 178, 0.08);
|
||
}
|
||
.invite-card-head,
|
||
.invite-section-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.invite-card-icon-wrap {
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
border-radius: 18rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.invite-card-icon-wrap--cyan { background: #eef6ef; }
|
||
.invite-card-icon-wrap--teal { background: #eef6ef; }
|
||
.invite-card-icon-wrap--green { background: #ecfdf5; }
|
||
.invite-card-head-text,
|
||
.invite-section-head-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.invite-card-label {
|
||
display: block;
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: #1b1c1a;
|
||
}
|
||
.invite-card-hint {
|
||
display: block;
|
||
margin-top: 4rpx;
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
}
|
||
.invite-code-value {
|
||
display: block;
|
||
text-align: center;
|
||
font-size: 44rpx;
|
||
font-weight: 800;
|
||
letter-spacing: 6rpx;
|
||
color: #204e2b;
|
||
padding: 20rpx 12rpx;
|
||
background: linear-gradient(180deg, #eef6ef, #eef6ef);
|
||
border-radius: 16rpx;
|
||
border: 2rpx dashed rgba(8, 145, 178, 0.25);
|
||
}
|
||
.invite-summary-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
.invite-tree-panel {
|
||
flex-shrink: 0;
|
||
}
|
||
.invite-tree-meta {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.invite-tree-title {
|
||
display: block;
|
||
font-size: 30rpx;
|
||
font-weight: 800;
|
||
color: #1b1c1a;
|
||
}
|
||
.invite-tree-desc {
|
||
display: block;
|
||
margin-top: 6rpx;
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
line-height: 1.4;
|
||
}
|
||
.invite-tree-week {
|
||
display: block;
|
||
margin-top: 8rpx;
|
||
font-size: 22rpx;
|
||
color: var(--slate-400);
|
||
}
|
||
.invite-stat-grid {
|
||
display: flex;
|
||
gap: 12rpx;
|
||
}
|
||
.invite-stat-item {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: 18rpx 10rpx;
|
||
background: var(--slate-50);
|
||
border-radius: 18rpx;
|
||
border: 1rpx solid var(--slate-200);
|
||
}
|
||
.invite-stat-icon-wrap {
|
||
width: 48rpx;
|
||
height: 48rpx;
|
||
margin: 0 auto 8rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: #ffffff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 2rpx 8rpx rgba(15, 23, 42, 0.06);
|
||
}
|
||
.invite-stat-num {
|
||
display: block;
|
||
font-size: 38rpx;
|
||
font-weight: 800;
|
||
color: #1b1c1a;
|
||
line-height: 1.1;
|
||
}
|
||
.invite-stat-label {
|
||
display: block;
|
||
font-size: 22rpx;
|
||
color: var(--slate-600);
|
||
margin-top: 6rpx;
|
||
}
|
||
.invite-quote-box {
|
||
margin-top: 16rpx;
|
||
padding: 18rpx 20rpx;
|
||
background: #eef6ef;
|
||
border-radius: 16rpx;
|
||
border-left: 6rpx solid #204e2b;
|
||
}
|
||
.invite-quote {
|
||
font-size: 26rpx;
|
||
color: #1b1c1a;
|
||
line-height: 1.55;
|
||
}
|
||
.invite-section-title {
|
||
display: block;
|
||
font-size: 30rpx;
|
||
font-weight: 800;
|
||
color: #1b1c1a;
|
||
}
|
||
.invite-section-sub {
|
||
display: block;
|
||
margin-top: 4rpx;
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
}
|
||
.invite-records-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
padding: 36rpx 24rpx;
|
||
text-align: center;
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
background: var(--slate-50);
|
||
border-radius: 16rpx;
|
||
}
|
||
.invite-timeline {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
.invite-day-row {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
&.is-empty .invite-day-panel {
|
||
opacity: 0.85;
|
||
}
|
||
}
|
||
.invite-day-rail {
|
||
width: 28rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
padding-top: 8rpx;
|
||
}
|
||
.invite-day-dot {
|
||
width: 16rpx;
|
||
height: 16rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--slate-200);
|
||
border: 3rpx solid #ffffff;
|
||
box-shadow: 0 0 0 2rpx #cbd5e1;
|
||
&.filled {
|
||
background: #204e2b;
|
||
box-shadow: 0 0 0 2rpx #b8dcc0;
|
||
}
|
||
}
|
||
.invite-day-line {
|
||
flex: 1;
|
||
width: 2rpx;
|
||
min-height: 24rpx;
|
||
margin: 6rpx 0;
|
||
background: linear-gradient(180deg, #cbd5e1, #e2e8f0);
|
||
}
|
||
.invite-day-panel {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 0 0 20rpx;
|
||
}
|
||
.invite-day-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
.invite-day-date {
|
||
font-size: 30rpx;
|
||
font-weight: 800;
|
||
color: var(--slate-900);
|
||
}
|
||
.invite-day-week {
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
}
|
||
.invite-metric-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10rpx;
|
||
}
|
||
.invite-metric-chip {
|
||
flex: 1;
|
||
min-width: calc(50% - 6rpx);
|
||
padding: 14rpx 16rpx;
|
||
background: var(--slate-50);
|
||
border-radius: 14rpx;
|
||
border: 1rpx solid var(--slate-200);
|
||
}
|
||
.invite-metric-label {
|
||
display: block;
|
||
font-size: 22rpx;
|
||
font-weight: 600;
|
||
color: var(--slate-600);
|
||
margin-bottom: 6rpx;
|
||
}
|
||
.invite-metric-value-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 6rpx;
|
||
flex-wrap: wrap;
|
||
}
|
||
.invite-metric-value {
|
||
font-size: 34rpx;
|
||
font-weight: 800;
|
||
color: #204e2b;
|
||
line-height: 1.2;
|
||
&.is-high {
|
||
color: var(--danger);
|
||
}
|
||
}
|
||
.invite-metric-unit {
|
||
font-size: 22rpx;
|
||
color: var(--slate-600);
|
||
}
|
||
.invite-metric-flag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4rpx;
|
||
margin-top: 8rpx;
|
||
padding: 4rpx 10rpx;
|
||
font-size: 20rpx;
|
||
font-weight: 600;
|
||
color: var(--danger);
|
||
background: var(--danger-light);
|
||
border-radius: 8rpx;
|
||
}
|
||
.invite-day-empty {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding: 14rpx 16rpx;
|
||
font-size: 26rpx;
|
||
color: var(--slate-400);
|
||
background: var(--slate-50);
|
||
border-radius: 14rpx;
|
||
}
|
||
.invite-nickname-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12rpx;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
.invite-nickname-chip {
|
||
min-height: 72rpx;
|
||
padding: 14rpx 28rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--slate-50);
|
||
border: 2rpx solid var(--slate-200);
|
||
font-size: 28rpx;
|
||
color: var(--slate-600);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: var(--transition-smooth);
|
||
&:active {
|
||
opacity: 0.88;
|
||
}
|
||
&.active {
|
||
background: var(--success);
|
||
border-color: var(--success);
|
||
color: #ffffff;
|
||
font-weight: 700;
|
||
}
|
||
}
|
||
.invite-like-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12rpx;
|
||
min-height: 96rpx;
|
||
padding: 24rpx 32rpx;
|
||
background: var(--success-grad);
|
||
color: #ffffff;
|
||
border-radius: var(--radius-pill);
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
box-shadow: var(--shadow-glow-success);
|
||
transition: var(--transition-smooth);
|
||
&:active {
|
||
transform: scale(0.98);
|
||
opacity: 0.92;
|
||
}
|
||
&.done {
|
||
background: var(--success-light);
|
||
color: var(--success);
|
||
border: 2rpx solid #a7f3d0;
|
||
box-shadow: none;
|
||
}
|
||
&.loading { opacity: 0.72; }
|
||
&.burst { animation: invite-heart-burst 0.85s ease; }
|
||
}
|
||
@keyframes invite-heart-burst {
|
||
0% { transform: scale(1); }
|
||
30% { transform: scale(1.06); box-shadow: 0 12rpx 32rpx rgba(5, 150, 105, 0.4); }
|
||
100% { transform: scale(1); }
|
||
}
|
||
.invite-footer {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
gap: 10rpx;
|
||
padding: 8rpx 12rpx 0;
|
||
}
|
||
.invite-disclaimer {
|
||
flex: 1;
|
||
font-size: 22rpx;
|
||
color: var(--slate-600);
|
||
line-height: 1.5;
|
||
text-align: left;
|
||
}
|
||
.invite-skeleton-wrap {
|
||
padding: 0 24rpx;
|
||
margin-top: -28rpx;
|
||
}
|
||
.invite-skeleton-card {
|
||
height: 160rpx;
|
||
margin-bottom: 20rpx;
|
||
border-radius: var(--radius-card);
|
||
background: linear-gradient(90deg, var(--slate-200) 25%, var(--slate-100) 50%, var(--slate-200) 75%);
|
||
background-size: 200% 100%;
|
||
animation: invite-shimmer 1.2s ease-in-out infinite;
|
||
}
|
||
.invite-skeleton-short { height: 120rpx; }
|
||
.invite-skeleton-tall { height: 320rpx; }
|
||
@keyframes invite-shimmer {
|
||
0% { background-position: 200% 0; }
|
||
100% { background-position: -200% 0; }
|
||
}
|
||
.invite-error-card {
|
||
margin: 32rpx 24rpx;
|
||
padding: 48rpx 32rpx;
|
||
background: #ffffff;
|
||
border-radius: var(--radius-card);
|
||
text-align: center;
|
||
box-shadow: var(--shadow-premium);
|
||
}
|
||
.invite-error-icon-wrap {
|
||
width: 96rpx;
|
||
height: 96rpx;
|
||
margin: 0 auto 20rpx;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--danger-light);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.invite-error-title {
|
||
display: block;
|
||
font-size: 32rpx;
|
||
font-weight: 800;
|
||
color: #1b1c1a;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
.invite-error-text {
|
||
font-size: 26rpx;
|
||
color: var(--slate-600);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* === 战报弹层 === */
|
||
.report-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 2000;
|
||
background: rgba(15, 23, 42, 0.45);
|
||
backdrop-filter: blur(8px);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
}
|
||
.report-panel {
|
||
width: 100%;
|
||
max-height: 88vh;
|
||
background: #ffffff;
|
||
border-radius: 28rpx 28rpx 0 0;
|
||
padding: 28rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
|
||
}
|
||
.report-panel-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
.report-panel-title {
|
||
font-size: 34rpx;
|
||
font-weight: 800;
|
||
color: var(--slate-900);
|
||
}
|
||
.report-panel-close {
|
||
font-size: 44rpx;
|
||
color: var(--slate-400);
|
||
padding: 0 8rpx;
|
||
}
|
||
.report-poster {
|
||
padding: 24rpx;
|
||
background: linear-gradient(180deg, var(--primary-light) 0%, #ffffff 100%);
|
||
border-radius: var(--radius-card);
|
||
border: 1rpx solid #ccfbf1;
|
||
}
|
||
.report-poster-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
.report-poster-name {
|
||
font-size: 32rpx;
|
||
font-weight: 700;
|
||
color: var(--slate-900);
|
||
}
|
||
.report-poster-week {
|
||
display: block;
|
||
font-size: 24rpx;
|
||
color: var(--slate-600);
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.report-stat-grid {
|
||
display: flex;
|
||
gap: 12rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.report-stat-item {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: 14rpx 8rpx;
|
||
background: #ffffff;
|
||
border-radius: 16rpx;
|
||
border: 1rpx solid var(--slate-100);
|
||
}
|
||
.report-stat-num {
|
||
display: block;
|
||
font-size: 34rpx;
|
||
font-weight: 800;
|
||
color: var(--success);
|
||
}
|
||
.report-stat-label {
|
||
display: block;
|
||
font-size: 22rpx;
|
||
color: var(--slate-600);
|
||
}
|
||
.report-poster-quote {
|
||
display: block;
|
||
font-size: 26rpx;
|
||
color: #334155;
|
||
line-height: 1.5;
|
||
}
|
||
.report-poster-disclaimer {
|
||
display: block;
|
||
margin-top: 12rpx;
|
||
font-size: 22rpx;
|
||
color: var(--slate-400);
|
||
}
|
||
.report-actions {
|
||
margin-top: 24rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
}
|
||
.report-share-btn {
|
||
height: 88rpx;
|
||
line-height: 88rpx;
|
||
text-align: center;
|
||
background: var(--primary-grad);
|
||
color: #ffffff;
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
border-radius: var(--radius-pill);
|
||
border: none;
|
||
box-shadow: var(--shadow-glow-primary);
|
||
}
|
||
.report-secondary-btn {
|
||
height: 72rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--slate-600);
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.daily-page.is-invite-viewer {
|
||
background: var(--primary-light, #eef6ef);
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
@import '../styles/daily-theme.scss';
|
||
|
||
.input-modal-ai-tip {
|
||
margin-bottom: 20rpx;
|
||
padding: 20rpx;
|
||
border-radius: 16rpx;
|
||
background: rgba(32, 78, 43, 0.04);
|
||
border: 1rpx solid rgba(32, 78, 43, 0.12);
|
||
}
|
||
.input-modal-ai-tip-label {
|
||
display: block;
|
||
font-size: 24rpx;
|
||
font-weight: 700;
|
||
color: var(--primary);
|
||
margin-bottom: 8rpx;
|
||
}
|
||
.input-modal-ai-tip-text {
|
||
display: block;
|
||
font-size: 24rpx;
|
||
line-height: 1.5;
|
||
color: var(--primary-container);
|
||
}
|
||
.input-modal-ai-tip-btn {
|
||
display: inline-flex;
|
||
margin-top: 12rpx;
|
||
padding: 8rpx 20rpx;
|
||
border-radius: 999rpx;
|
||
border: 2rpx solid var(--primary);
|
||
color: var(--primary);
|
||
font-size: 24rpx;
|
||
font-weight: 600;
|
||
}
|
||
</style>
|