fix(mp): 健康页滚动卡死 + 文章样式丢失 — ScrollView height:0 修复 + RichArticle 18 条 tag-style 规则
- 健康页:移除冗余"健康"标题栏,ScrollView scrollY 添加 height:0 修复 flex 高度分配 - 健康页:useReachBottom(页面级)替换为 ScrollView onScrollToLower,修复模拟器卡死 - RichArticle:新增 18 条 tag-style 规则(h1-h4/p/ul/ol/li/table/th/td 等),确保文章内容在小程序中正确渲染样式
This commit is contained in:
@@ -7,6 +7,28 @@ interface RichArticleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TAG_STYLE = JSON.stringify({
|
||||
h1: 'font-size:20px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h2: 'font-size:18px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h3: 'font-size:16px;font-weight:700;color:#2D2A26;margin:16px 0 8px',
|
||||
h4: 'font-size:15px;font-weight:600;color:#2D2A26;margin:12px 0 6px',
|
||||
p: 'font-size:16px;color:#2D2A26;line-height:1.85;margin-bottom:12px',
|
||||
ul: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
|
||||
ol: 'padding-left:20px;margin:8px 0;font-size:16px;line-height:1.9;color:#2D2A26',
|
||||
li: 'margin-bottom:4px',
|
||||
blockquote: 'border-left:3px solid #C4623A;padding:6px 12px;color:#5A554F;margin:12px 0',
|
||||
strong: 'font-weight:700;color:#2D2A26',
|
||||
em: 'font-style:italic',
|
||||
code: 'background:#F5F0EB;padding:2px 6px;border-radius:4px;font-size:14px;color:#C4623A',
|
||||
pre: 'background:#F5F0EB;padding:12px;border-radius:8px;margin:14px 0;overflow-x:auto',
|
||||
table: 'width:100%;border-collapse:collapse;margin:8px 0;font-size:14px',
|
||||
th: 'border:1px solid #E8E2DC;padding:6px 8px;background:#FAF8F5;font-weight:600;text-align:left',
|
||||
td: 'border:1px solid #E8E2DC;padding:6px 8px',
|
||||
hr: 'border:none;border-top:1px dashed #D1D5DB;margin:14px 0',
|
||||
img: 'max-width:100%;border-radius:8px;margin:8px 0;display:block',
|
||||
a: 'color:#C4623A;text-decoration:none',
|
||||
});
|
||||
|
||||
function prepareHtml(raw: string): string {
|
||||
return sanitizeHtml(raw);
|
||||
}
|
||||
@@ -23,7 +45,7 @@ function RichArticle({ html, className }: RichArticleProps) {
|
||||
lazy-load
|
||||
selectable
|
||||
container-style="font-size:16px;color:#5A554F;line-height:1.8;word-break:break-word"
|
||||
tag-style='{"img":"max-width:100%;border-radius:8px;margin:12px auto;display:block","a":"color:#C4623A;text-decoration:none"}'
|
||||
tag-style={TAG_STYLE}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -2,515 +2,131 @@
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
.health-page {
|
||||
padding-bottom: calc(var(--tk-tabbar-space) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ─── 页头 ─── */
|
||||
.health-header {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.health-title {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.health-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
/* ─── 今日体征 hero 卡片 ─── */
|
||||
.vitals-grid {
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
background: linear-gradient(135deg, $card 60%, $pri-l);
|
||||
border-radius: var(--tk-card-radius);
|
||||
box-shadow: $shadow-md;
|
||||
padding: var(--tk-card-padding);
|
||||
|
||||
/* 覆盖 ContentCard 默认 padding/margin */
|
||||
&.content-card {
|
||||
padding: var(--tk-card-padding);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
}
|
||||
|
||||
.vitals-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.vitals-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.vitals-badge {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $acc;
|
||||
background: $acc-l;
|
||||
padding: 3px 10px;
|
||||
border-radius: $r-pill;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vitals-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.vital-cell {
|
||||
text-align: center;
|
||||
padding: var(--tk-gap-md) var(--tk-gap-sm);
|
||||
border-radius: $r-sm;
|
||||
background: $bg;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@include serif-number;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: block;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.vital-cell.vital-warn {
|
||||
background: $wrn-l;
|
||||
|
||||
.vital-value {
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.vital-cell.vital-ok {
|
||||
.vital-value {
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 快捷入口 — 横排 4 格图标 ─── */
|
||||
.quick-entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--tk-gap-xs);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
.quick-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ─── 分类标签 ─── */
|
||||
.health-categories {
|
||||
white-space: nowrap;
|
||||
padding: var(--tk-gap-xs) var(--tk-page-padding);
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.health-cat-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-xs);
|
||||
min-height: var(--tk-touch-min);
|
||||
justify-content: center;
|
||||
padding: var(--tk-gap-sm) 0;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: $r-sm;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.quick-icon-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quick-icon--input {
|
||||
background: $pri-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $pri;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--trend {
|
||||
background: $doc-pri-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $doc-pri;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--report {
|
||||
background: $acc-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--med {
|
||||
background: $wrn-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 告警横幅 ─── */
|
||||
.alert-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
background: $dan-l;
|
||||
border-radius: $r-sm;
|
||||
|
||||
/* 覆盖 ContentCard 默认样式 */
|
||||
&.content-card {
|
||||
background: $dan-l;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: $dan;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
flex: 1;
|
||||
padding: 8px 18px;
|
||||
margin-right: 8px;
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 500;
|
||||
color: $dan;
|
||||
}
|
||||
|
||||
.alert-arrow {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $dan;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ─── 趋势图 ─── */
|
||||
.trend-section {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include section-title;
|
||||
}
|
||||
|
||||
.trend-empty {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trend-empty-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 400;
|
||||
color: $tx2;
|
||||
background: $surface-alt;
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--active {
|
||||
background: var(--tk-pri);
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(196, 98, 58, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
padding: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 120px;
|
||||
background: $bg;
|
||||
border-radius: $r-sm;
|
||||
padding: var(--tk-gap-sm) var(--tk-gap-xs);
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trend-threshold-line {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
border-top: 1.5px dashed $wrn;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.trend-threshold-label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -16px;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $wrn;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.trend-bar-col {
|
||||
/* ─── 可滚动内容区 ─── */
|
||||
.health-scroll {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
/* 微信小程序 ScrollView scrollY 需要显式高度 */
|
||||
height: 0; /* flex:1 + height:0 让 flex 布局正确分配剩余高度 */
|
||||
}
|
||||
|
||||
/* ─── 文章列表 ─── */
|
||||
.health-article-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
width: 24px;
|
||||
border-radius: $r-xs $r-xs 0 0;
|
||||
min-height: 6px;
|
||||
|
||||
&.trend-bar-normal {
|
||||
background: var(--tk-pri);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.trend-bar-warn {
|
||||
background: $wrn;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-bar-label {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-text-secondary);
|
||||
margin-top: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
/* ─── BLE 设备卡片 ─── */
|
||||
.device-section {
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
padding: 0 var(--tk-page-padding) var(--tk-gap-lg);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
.content-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r-sm;
|
||||
background: var(--tk-pri-l);
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-icon-text {
|
||||
font-size: var(--tk-font-body);
|
||||
}
|
||||
|
||||
.device-info {
|
||||
.health-article-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-weight: 500;
|
||||
color: $tx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-desc {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $acc;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.device-arrow {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: var(--tk-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── 健康资讯入口 ─── */
|
||||
.article-entry {
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.article-entry-text {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── AI 建议卡片 ─── */
|
||||
.ai-suggestion-card {
|
||||
background: linear-gradient(135deg, #F0F7F0 0%, $acc-l 100%);
|
||||
border-radius: $r;
|
||||
padding: var(--tk-card-padding);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
box-shadow: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, $acc, $acc 60%, transparent);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.ai-card-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
.ai-card-count {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $acc;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ai-suggestion-item {
|
||||
padding: var(--tk-gap-sm) 0;
|
||||
border-bottom: 1px solid rgba($acc, 0.12);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-suggestion-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--tk-gap-xs);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-risk-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
|
||||
&.ai-risk-high {
|
||||
background: $dan;
|
||||
}
|
||||
|
||||
&.ai-risk-medium {
|
||||
background: $wrn;
|
||||
}
|
||||
|
||||
&.ai-risk-low {
|
||||
background: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-suggestion-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
.health-article-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── AI 建议反馈按钮 ─── */
|
||||
.ai-feedback-row {
|
||||
display: flex;
|
||||
gap: var(--tk-gap-xs);
|
||||
margin-top: var(--tk-gap-xs);
|
||||
padding-left: 20px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-feedback-btn {
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
border-radius: $r-xs;
|
||||
@include flex-center;
|
||||
padding: 0 var(--tk-gap-sm);
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&.ai-feedback-adopt {
|
||||
background: rgba($acc, 0.15);
|
||||
}
|
||||
|
||||
&.ai-feedback-ignore {
|
||||
background: $surface-alt;
|
||||
}
|
||||
|
||||
&.ai-feedback-consult {
|
||||
background: var(--tk-pri-l);
|
||||
}
|
||||
.health-article-title {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-body);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.ai-feedback-btn-text {
|
||||
font-size: var(--tk-font-micro);
|
||||
font-weight: 500;
|
||||
.health-article-summary {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
margin-bottom: var(--tk-gap-2xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-feedback-adopt .ai-feedback-btn-text {
|
||||
color: $acc;
|
||||
.health-article-meta {
|
||||
display: flex;
|
||||
gap: var(--tk-gap-sm);
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ai-feedback-consult .ai-feedback-btn-text {
|
||||
.health-article-tag {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: var(--tk-pri);
|
||||
background: var(--tk-pri-l);
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-xs;
|
||||
}
|
||||
|
||||
.health-article-date {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
// 长者模式
|
||||
.elder-mode .health-page {
|
||||
.health-cat-tab {
|
||||
padding: 10px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.health-article-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
.health-article-summary {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,244 +1,152 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import GuestGuard from '../../components/GuestGuard';
|
||||
import Loading from '../../components/Loading';
|
||||
import {
|
||||
listArticles,
|
||||
listCategories,
|
||||
listPublicArticles,
|
||||
listPublicCategories,
|
||||
type Article,
|
||||
type ArticleCategory,
|
||||
} from '../../services/article';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import SegmentTabs from '../../components/SegmentTabs';
|
||||
import { useHealthOverview, VITAL_TABS, type VitalType } from './useHealthOverview';
|
||||
import { submitSuggestionFeedback } from '../../services/ai-analysis';
|
||||
import EmptyState from '../../components/EmptyState';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
import Loading from '../../components/Loading';
|
||||
import { useElderClass } from '../../hooks/useElderClass';
|
||||
import './index.scss';
|
||||
|
||||
const QUICK_ENTRIES = [
|
||||
{ label: '录入体征', icon: '✏', color: 'input', path: '/pages/pkg-health/input/index' },
|
||||
{ label: '健康趋势', icon: '📈', color: 'trend', path: '/pages/pkg-health/trend/index' },
|
||||
{ label: '我的报告', icon: '📋', color: 'report', path: '/pages/pkg-profile/reports/index' },
|
||||
{ label: '健康档案', icon: '健', color: 'med', path: '/pages/pkg-profile/health-records/index' },
|
||||
] as const;
|
||||
|
||||
function statusClass(status?: string): string {
|
||||
if (!status) return '';
|
||||
if (status === 'high' || status === 'abnormal') return 'vital-warn';
|
||||
if (status === 'low') return 'vital-warn';
|
||||
return 'vital-ok';
|
||||
}
|
||||
|
||||
function formatDate(): string {
|
||||
const d = new Date();
|
||||
const month = d.getMonth() + 1;
|
||||
const day = d.getDate();
|
||||
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return `${month}月${day}日 周${weekDays[d.getDay()]}`;
|
||||
}
|
||||
|
||||
export default function Health() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const modeClass = useElderClass();
|
||||
const {
|
||||
todaySummary, loading, error, activeTab, trendData, trendLoading,
|
||||
aiSuggestions, thresholds, alertCount, handleTabChange, fetchData,
|
||||
} = useHealthOverview();
|
||||
const isLoggedIn = !!useAuthStore((s) => s.user);
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [categories, setCategories] = useState<ArticleCategory[]>([]);
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
|
||||
if (!user) {
|
||||
return <GuestGuard title='请先登录' desc='登录后即可查看健康数据' />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageShell padding="md" safeBottom={false} scroll={false} className={`health-page ${modeClass}`}>
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康总览</Text>
|
||||
</View>
|
||||
<Loading />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const maxTrendValue = trendData.reduce((max, d) => Math.max(max, d.value), 1);
|
||||
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
const summary = todaySummary || {};
|
||||
const vitals = [
|
||||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status },
|
||||
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status },
|
||||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status },
|
||||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status },
|
||||
];
|
||||
const recordedCount = vitals.filter((v) => v.value !== '—').length;
|
||||
|
||||
const getThresholdValue = (type: VitalType): number | null => {
|
||||
if (!thresholds.length) return null;
|
||||
const th = thresholds;
|
||||
if (type === 'blood_pressure') {
|
||||
const v = th.find((t) => t.indicator === 'systolic_bp' && t.level === 'high');
|
||||
return v?.threshold_value ?? 140;
|
||||
const fetchData = useCallback(async (p: number, append = false, categoryId?: string | null) => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const cid = categoryId !== undefined ? categoryId : activeCategory;
|
||||
const res = isLoggedIn
|
||||
? await listArticles({ page: p, category_id: cid || undefined })
|
||||
: await listPublicArticles({ page: p, category_id: cid || undefined });
|
||||
const list = res.data || [];
|
||||
setArticles(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[health] 加载文章列表失败:', err);
|
||||
setError(true);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
if (type === 'heart_rate') {
|
||||
const v = th.find((t) => t.indicator === 'heart_rate' && t.level === 'high');
|
||||
return v?.threshold_value ?? 100;
|
||||
}, [activeCategory, isLoggedIn]);
|
||||
|
||||
usePageData(
|
||||
useCallback(async () => {
|
||||
try {
|
||||
const cats = isLoggedIn
|
||||
? await listCategories()
|
||||
: await listPublicCategories();
|
||||
setCategories(cats || []);
|
||||
} catch (err) {
|
||||
console.warn('[health] 加载分类失败:', err);
|
||||
setCategories([]);
|
||||
}
|
||||
await fetchData(1);
|
||||
}, [fetchData, isLoggedIn]),
|
||||
{ throttleMs: 10000, enablePullDown: true },
|
||||
);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!loading && articles.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
}
|
||||
if (type === 'blood_sugar') {
|
||||
const v = th.find((t) => t.indicator === 'blood_sugar_fasting' && t.level === 'high');
|
||||
return v?.threshold_value ?? 6.1;
|
||||
}
|
||||
return null;
|
||||
}, [loading, articles.length, total, page, fetchData]);
|
||||
|
||||
const handleCategoryChange = (categoryId: string | null) => {
|
||||
setActiveCategory(categoryId);
|
||||
fetchData(1, false, categoryId);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const month = d.getMonth() + 1;
|
||||
const day = d.getDate();
|
||||
return `${month}月${day}日`;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell padding="md" safeBottom={false} scroll className={`health-page ${modeClass}`}>
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康总览</Text>
|
||||
<Text className='health-date'>{formatDate()}</Text>
|
||||
</View>
|
||||
<PageShell safeBottom={false} padding="none" scroll={false} className={`health-page ${modeClass}`}>
|
||||
{/* 分类标签 */}
|
||||
{categories.length > 0 && (
|
||||
<ScrollView scrollX className='health-categories'>
|
||||
<View
|
||||
className={`health-cat-tab ${!activeCategory ? 'health-cat-tab--active' : ''}`}
|
||||
onClick={() => handleCategoryChange(null)}
|
||||
>
|
||||
<Text>推荐</Text>
|
||||
</View>
|
||||
{categories.map((cat) => (
|
||||
<View
|
||||
key={cat.id}
|
||||
className={`health-cat-tab ${activeCategory === cat.id ? 'health-cat-tab--active' : ''}`}
|
||||
onClick={() => handleCategoryChange(cat.id)}
|
||||
>
|
||||
<Text>{cat.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* 今日体征 hero 卡片 */}
|
||||
<View className='vitals-grid'>
|
||||
<View className='vitals-header'>
|
||||
<Text className='vitals-title'>今日体征</Text>
|
||||
{recordedCount > 0 && (
|
||||
<Text className='vitals-badge'>已记录 {recordedCount} 项</Text>
|
||||
)}
|
||||
</View>
|
||||
{loading ? <Loading /> : (
|
||||
<View className='vitals-row'>
|
||||
{vitals.map((v) => (
|
||||
<View className={`vital-cell ${statusClass(v.status)}`} key={v.label}>
|
||||
<Text className='vital-value'>{v.value}</Text>
|
||||
<Text className='vital-unit'>{v.unit}</Text>
|
||||
<Text className='vital-label'>{v.label}</Text>
|
||||
</View>
|
||||
{/* 文章列表 */}
|
||||
<ScrollView scrollY className='health-scroll' onScrollToLower={loadMore} lowerThreshold={200}>
|
||||
{error ? (
|
||||
<ErrorState onRetry={() => fetchData(1, false, null)} />
|
||||
) : articles.length === 0 && !loading ? (
|
||||
<EmptyState text='暂无健康资讯' />
|
||||
) : (
|
||||
<View className='health-article-list'>
|
||||
{articles.map((a) => (
|
||||
<ContentCard
|
||||
key={a.id}
|
||||
padding='sm'
|
||||
margin='none'
|
||||
onPress={() => safeNavigateTo(`/pages/article/detail/index?id=${a.id}`)}
|
||||
>
|
||||
<View className='health-article-body'>
|
||||
<View className='health-article-content'>
|
||||
<Text className='health-article-title'>{a.title}</Text>
|
||||
{a.summary && (
|
||||
<Text className='health-article-summary'>{a.summary}</Text>
|
||||
)}
|
||||
<View className='health-article-meta'>
|
||||
{(a.category_name || a.category) && (
|
||||
<Text className='health-article-tag'>{a.category_name || a.category}</Text>
|
||||
)}
|
||||
{a.published_at && (
|
||||
<Text className='health-article-date'>{formatDate(a.published_at)}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ContentCard>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 快捷入口 — 横排 4 格图标 */}
|
||||
<View className='quick-entries'>
|
||||
{QUICK_ENTRIES.map((e) => (
|
||||
<View
|
||||
key={e.label}
|
||||
className='quick-entry'
|
||||
onClick={() => safeNavigateTo(e.path)}
|
||||
>
|
||||
<View className={`quick-icon quick-icon--${e.color}`}>
|
||||
<Text className='quick-icon-text'>{e.icon}</Text>
|
||||
</View>
|
||||
<Text className='quick-label'>{e.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 告警横幅 */}
|
||||
{alertCount > 0 && (
|
||||
<ContentCard
|
||||
variant="default"
|
||||
padding="sm"
|
||||
margin="none"
|
||||
className='alert-hint'
|
||||
onPress={() => safeNavigateTo('/pages/pkg-health/alerts/index')}
|
||||
>
|
||||
<View className='alert-dot' />
|
||||
<Text className='alert-text'>{alertCount} 条待处理告警</Text>
|
||||
<Text className='alert-arrow'>›</Text>
|
||||
</ContentCard>
|
||||
)}
|
||||
|
||||
{/* AI 建议 */}
|
||||
{aiSuggestions.length > 0 && (
|
||||
<View className='ai-suggestion-card'>
|
||||
<View className='ai-card-header'>
|
||||
<Text className='ai-card-title'>AI 健康建议</Text>
|
||||
<Text className='ai-card-count'>{aiSuggestions.length} 条</Text>
|
||||
</View>
|
||||
{aiSuggestions.map((s) => {
|
||||
const riskCls = s.risk_level === 'high' ? 'ai-risk-high' : s.risk_level === 'medium' ? 'ai-risk-medium' : 'ai-risk-low';
|
||||
const params = s.params as Record<string, unknown> | null;
|
||||
const reason = (params?.reason as string) || (params?.message as string) || '健康建议';
|
||||
return (
|
||||
<View key={s.id} className='ai-suggestion-item'>
|
||||
<View className='ai-suggestion-main' onClick={() => {
|
||||
if (s.suggestion_type === 'appointment') safeNavigateTo('/pages/appointment/create/index');
|
||||
else if (s.suggestion_type === 'followup') safeNavigateTo('/pages/pkg-profile/followups/index');
|
||||
}}>
|
||||
<View className={`ai-risk-dot ${riskCls}`} />
|
||||
<Text className='ai-suggestion-text'>{reason.slice(0, 50)}</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-row'>
|
||||
<View className='ai-feedback-btn ai-feedback-adopt' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'adopt'); Taro.showToast({ title: '已采纳', icon: 'success' }); fetchData(); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>采纳</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-btn ai-feedback-ignore' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'ignore'); Taro.showToast({ title: '已忽略', icon: 'success' }); fetchData(); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>忽略</Text>
|
||||
</View>
|
||||
<View className='ai-feedback-btn ai-feedback-consult' onClick={async () => {
|
||||
try { await submitSuggestionFeedback(s.id, 'consult'); safeNavigateTo('/pages/consultation/index'); }
|
||||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||||
}}>
|
||||
<Text className='ai-feedback-btn-text'>咨询医生</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 7天趋势 */}
|
||||
<View className='trend-section'>
|
||||
<Text className='section-title'>近 7 天趋势</Text>
|
||||
<SegmentTabs tabs={VITAL_TABS} activeKey={activeTab} onChange={handleTabChange} variant="pill" />
|
||||
{trendLoading ? <Loading /> : trendData.length === 0 ? (
|
||||
<ContentCard padding="md">
|
||||
<Text className='trend-empty-text'>暂无趋势数据</Text>
|
||||
</ContentCard>
|
||||
) : (
|
||||
<ContentCard padding="md">
|
||||
<View className='trend-bars'>
|
||||
{(() => {
|
||||
const tv = getThresholdValue(activeTab);
|
||||
if (tv) {
|
||||
const pct = Math.min(95, (tv / maxTrendValue) * 100);
|
||||
return (
|
||||
<View className='trend-threshold-line' style={`bottom:${((12 + pct * 1.08) / 120 * 100).toFixed(1)}%;`}>
|
||||
<Text className='trend-threshold-label'>{tv}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{trendData.map((point, i) => {
|
||||
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
|
||||
const tv = getThresholdValue(activeTab);
|
||||
const isAbnormal = tv ? point.value >= tv : false;
|
||||
const dayOfWeek = new Date(point.date).getDay();
|
||||
return (
|
||||
<View className='trend-bar-col' key={i}>
|
||||
<View
|
||||
className={`trend-bar ${isAbnormal ? 'trend-bar-warn' : 'trend-bar-normal'}`}
|
||||
style={`height:${heightPct}%;`}
|
||||
/>
|
||||
<Text className='trend-bar-label'>{dayLabels[dayOfWeek]}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ContentCard>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 健康资讯入口 */}
|
||||
<ContentCard onPress={() => safeNavigateTo('/pages/article/index')}>
|
||||
<Text className='article-entry-text'>最新健康资讯 ›</Text>
|
||||
</ContentCard>
|
||||
{loading && <Loading />}
|
||||
</ScrollView>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user