diff --git a/apps/miniprogram/.env.production b/apps/miniprogram/.env.production index 70d3710..ae59394 100644 --- a/apps/miniprogram/.env.production +++ b/apps/miniprogram/.env.production @@ -1,3 +1,5 @@ TARO_APP_API_URL=https://api.hms.example.com/api/v1 TARO_APP_DEFAULT_TENANT_ID= -TARO_APP_ENCRYPTION_KEY= +# TARO_APP_ENCRYPTION_KEY 不在此文件设置 +# 生产密钥通过 CI/CD 环境变量注入(dotenv 不覆盖已有 env var) +# 本地 build:weapp 测试时自动回退到 .env 中的开发密钥 diff --git a/apps/miniprogram/config/index.ts b/apps/miniprogram/config/index.ts index 3d89c95..9a2285c 100644 --- a/apps/miniprogram/config/index.ts +++ b/apps/miniprogram/config/index.ts @@ -2,6 +2,12 @@ import { defineConfig } from '@tarojs/cli'; import path from 'path'; export default defineConfig(async (merge) => { + // 生产构建缺少加密密钥时发出警告(不阻断构建,但提示开发者/CI 配置) + if (process.env.NODE_ENV === 'production' && !process.env.TARO_APP_ENCRYPTION_KEY) { + console.warn('[config] ⚠ TARO_APP_ENCRYPTION_KEY 未设置,将回退到 .env 中的开发密钥'); + console.warn('[config] 生产部署应通过 CI/CD 环境变量注入独立密钥'); + } + const baseConfig = { projectName: 'hms-miniprogram', date: '2026-4-23', @@ -19,6 +25,7 @@ export default defineConfig(async (merge) => { 'process.env.TARO_APP_WX_TEMPLATE_REPORT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_REPORT || ''), 'process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_CRITICAL_ALERT || ''), 'process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_HEALTH_ABNORMAL || ''), + 'process.env.TARO_APP_WX_TEMPLATE_MEDICATION': JSON.stringify(process.env.TARO_APP_WX_TEMPLATE_MEDICATION || ''), 'process.env.TARO_APP_DEFAULT_TENANT_ID': JSON.stringify(process.env.TARO_APP_DEFAULT_TENANT_ID || ''), 'process.env.TARO_APP_DEV_USER': JSON.stringify( process.env.NODE_ENV === 'production' ? '' : (process.env.TARO_APP_DEV_USER || '') diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index 68c465e..cc16c3b 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -1,4 +1,5 @@ export default defineAppConfig({ + lazyCodeLoading: 'requiredComponents', pages: [ 'pages/index/index', 'pages/login/index', diff --git a/apps/miniprogram/src/app.tsx b/apps/miniprogram/src/app.tsx index 7565dee..3b42d50 100644 --- a/apps/miniprogram/src/app.tsx +++ b/apps/miniprogram/src/app.tsx @@ -1,4 +1,5 @@ import './utils/crypto-polyfill'; +import './utils/abort-controller-polyfill'; import { useEffect, useRef, PropsWithChildren } from 'react'; import { useDidShow, useDidHide } from '@tarojs/taro'; import ErrorBoundary from './components/ErrorBoundary'; diff --git a/apps/miniprogram/src/components/SegmentTabs/index.tsx b/apps/miniprogram/src/components/SegmentTabs/index.tsx index b281294..7072dac 100644 --- a/apps/miniprogram/src/components/SegmentTabs/index.tsx +++ b/apps/miniprogram/src/components/SegmentTabs/index.tsx @@ -3,12 +3,12 @@ import { View, Text } from '@tarojs/components'; import './index.scss'; interface Tab { - key: string; - label: string; + readonly key: string; + readonly label: string; } interface SegmentTabsProps { - tabs: Tab[]; + tabs: readonly Tab[]; activeKey: string; onChange: (key: string) => void; variant?: 'underline' | 'pill'; diff --git a/apps/miniprogram/src/hooks/useAlertPolling.ts b/apps/miniprogram/src/hooks/useAlertPolling.ts index 34f7e10..ea8f864 100644 --- a/apps/miniprogram/src/hooks/useAlertPolling.ts +++ b/apps/miniprogram/src/hooks/useAlertPolling.ts @@ -79,8 +79,14 @@ export function useAlertPolling() { s.lastAlertCount = count; failCount = 0; - } catch { - failCount++; + } catch (err) { + // 权限不足时立即停止轮询,不再重试(避免反复弹 toast) + if (err instanceof Error && err.message === '权限不足') { + s.failCount = MAX_FAILURES; + return; + } + // 网络异常时快速累积失败计数(离线抑制下会在 3s 内快速耗尽重试) + failCount += 3; } if (gen !== s.generation) return; diff --git a/apps/miniprogram/src/pages/article/index.tsx b/apps/miniprogram/src/pages/article/index.tsx index 449db3d..13268d5 100644 --- a/apps/miniprogram/src/pages/article/index.tsx +++ b/apps/miniprogram/src/pages/article/index.tsx @@ -3,7 +3,13 @@ import { View, Text, ScrollView } from '@tarojs/components'; import Taro from '@tarojs/taro'; import { safeNavigateTo } from '@/utils/navigate'; import { usePageData } from '@/hooks/usePageData'; -import { listArticles, listCategories } from '../../services/article'; +import { useAuthStore } from '@/stores/auth'; +import { + listArticles, + listCategories, + listPublicArticles, + listPublicCategories, +} from '../../services/article'; import PageShell from '@/components/ui/PageShell'; import ContentCard from '@/components/ui/ContentCard'; import LoadingCard from '@/components/ui/LoadingCard'; @@ -33,6 +39,7 @@ interface ArticleCategory { export default function ArticleList() { const modeClass = useElderClass(); + const isLoggedIn = !!useAuthStore((s) => s.user); const [articles, setArticles] = useState([]); const [, setPage] = useState(1); const [, setTotal] = useState(0); @@ -46,10 +53,9 @@ export default function ArticleList() { setError(false); try { const cid = categoryId !== undefined ? categoryId : activeCategory; - const res = await listArticles({ - page: p, - category_id: cid || undefined, - }); + 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); @@ -61,19 +67,21 @@ export default function ArticleList() { } finally { setLoading(false); } - }, [activeCategory]); + }, [activeCategory, isLoggedIn]); usePageData( useCallback(async () => { try { - const cats = await listCategories(); + const cats = isLoggedIn + ? await listCategories() + : await listPublicCategories(); setCategories(cats || []); } catch (err) { console.warn('[article] 加载分类失败:', err); setCategories([]); } await fetchData(1); - }, [fetchData]), + }, [fetchData, isLoggedIn]), { throttleMs: 10000, enablePullDown: true }, ); diff --git a/apps/miniprogram/src/pages/health/index.scss b/apps/miniprogram/src/pages/health/index.scss index 01b4ef4..abefae1 100644 --- a/apps/miniprogram/src/pages/health/index.scss +++ b/apps/miniprogram/src/pages/health/index.scss @@ -7,7 +7,10 @@ /* ─── 页头 ─── */ .health-header { - margin-bottom: var(--tk-section-gap); + margin-bottom: var(--tk-gap-sm); + display: flex; + align-items: baseline; + justify-content: space-between; } .health-title { @@ -15,11 +18,50 @@ 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 { @@ -30,9 +72,13 @@ .vital-cell { text-align: center; - padding: var(--tk-gap-sm); + padding: var(--tk-gap-md) var(--tk-gap-sm); border-radius: $r-sm; background: $bg; + + &:active { + opacity: var(--tk-touch-feedback-opacity); + } } .vital-value { @@ -40,7 +86,9 @@ font-size: var(--tk-font-num); font-weight: 700; color: $tx; + font-variant-numeric: tabular-nums; display: block; + line-height: 1.1; } .vital-unit { @@ -53,8 +101,9 @@ .vital-label { font-size: var(--tk-font-cap); color: $tx2; + font-weight: 500; display: block; - margin-top: 4px; + margin-top: 6px; } .vital-cell.vital-warn { @@ -71,11 +120,11 @@ } } -/* ─── 快捷入口 ─── */ +/* ─── 快捷入口 — 横排 4 格图标 ─── */ .quick-entries { display: grid; grid-template-columns: repeat(4, 1fr); - gap: var(--tk-gap-sm); + gap: var(--tk-gap-xs); margin-bottom: var(--tk-section-gap); } @@ -86,6 +135,7 @@ 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); @@ -93,17 +143,47 @@ } .quick-icon { - width: 48px; - height: 48px; - border-radius: $r; - background: var(--tk-pri-l); + width: 44px; + height: 44px; + border-radius: $r-sm; @include flex-center; } .quick-icon-text { - font-size: var(--tk-font-body); + font-size: 18px; font-weight: 600; - color: var(--tk-pri); +} + +.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 { @@ -112,12 +192,21 @@ 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); @@ -125,8 +214,8 @@ } .alert-dot { - width: 10px; - height: 10px; + width: 8px; + height: 8px; border-radius: 50%; background: $dan; flex-shrink: 0; @@ -141,8 +230,9 @@ .alert-arrow { font-size: var(--tk-font-body); - color: $tx3; + color: $dan; flex-shrink: 0; + opacity: 0.6; } /* ─── 趋势图 ─── */ @@ -183,7 +273,7 @@ left: 8px; right: 8px; border-top: 1.5px dashed $wrn; - opacity: 0.6; + opacity: 0.5; pointer-events: none; } @@ -193,7 +283,7 @@ top: -16px; font-size: var(--tk-font-micro); color: $wrn; - opacity: 0.8; + opacity: 0.7; } .trend-bar-col { @@ -206,17 +296,18 @@ } .trend-bar { - width: 28px; + width: 24px; border-radius: $r-xs $r-xs 0 0; - min-height: 8px; - opacity: 0.8; + min-height: 6px; &.trend-bar-normal { background: var(--tk-pri); + opacity: 0.75; } &.trend-bar-warn { background: $wrn; + opacity: 0.85; } } @@ -286,30 +377,42 @@ } .article-entry-text { - font-size: var(--tk-font-cap); + font-size: var(--tk-font-body-sm); color: $tx; font-weight: 500; } /* ─── AI 建议卡片 ─── */ .ai-suggestion-card { - background: $acc-l; + background: linear-gradient(135deg, #F0F7F0 0%, $acc-l 100%); border-radius: $r; - padding: var(--tk-gap-md); + padding: var(--tk-card-padding); margin-bottom: var(--tk-section-gap); box-shadow: none; - border-left: 4px solid $acc; + 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-xs); + margin-bottom: var(--tk-gap-md); } .ai-card-title { - font-size: var(--tk-font-cap); + font-size: var(--tk-font-body-sm); font-weight: 600; color: $acc; } @@ -321,8 +424,8 @@ } .ai-suggestion-item { - padding: var(--tk-gap-xs) 0; - border-bottom: 1px solid rgba($acc, 0.15); + padding: var(--tk-gap-sm) 0; + border-bottom: 1px solid rgba($acc, 0.12); &:last-child { border-bottom: none; @@ -331,7 +434,7 @@ .ai-suggestion-main { display: flex; - align-items: center; + align-items: flex-start; gap: var(--tk-gap-xs); &:active { @@ -344,6 +447,7 @@ height: 8px; border-radius: 50%; flex-shrink: 0; + margin-top: 5px; &.ai-risk-high { background: $dan; @@ -362,19 +466,20 @@ font-size: var(--tk-font-cap); color: $tx2; line-height: 1.6; + flex: 1; } /* ─── AI 建议反馈按钮 ─── */ .ai-feedback-row { display: flex; gap: var(--tk-gap-xs); - margin-top: var(--tk-gap-2xs); + margin-top: var(--tk-gap-xs); padding-left: 20px; } .ai-feedback-btn { - height: 44px; - min-height: 44px; + height: 36px; + min-height: 36px; border-radius: $r-xs; @include flex-center; padding: 0 var(--tk-gap-sm); diff --git a/apps/miniprogram/src/pages/health/index.tsx b/apps/miniprogram/src/pages/health/index.tsx index 14aed5b..4045a4c 100644 --- a/apps/miniprogram/src/pages/health/index.tsx +++ b/apps/miniprogram/src/pages/health/index.tsx @@ -13,10 +13,10 @@ import { submitSuggestionFeedback } from '../../services/ai-analysis'; import './index.scss'; const QUICK_ENTRIES = [ - { label: '录入体征', icon: '笔', path: '/pages/pkg-health/input/index' }, - { label: '健康趋势', icon: '线', path: '/pages/pkg-health/trend/index' }, - { label: '我的报告', icon: '报', path: '/pages/pkg-profile/reports/index' }, - { label: '用药记录', icon: '药', path: '/pages/pkg-profile/medication/index' }, + { 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 { @@ -26,6 +26,14 @@ function statusClass(status?: string): string { 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(); @@ -59,6 +67,7 @@ export default function Health() { { 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; @@ -82,10 +91,17 @@ export default function Health() { 健康总览 + {formatDate()} - {/* 今日体征摘要 */} - + {/* 今日体征 hero 卡片 */} + + + 今日体征 + {recordedCount > 0 && ( + 已记录 {recordedCount} 项 + )} + {loading ? : ( {vitals.map((v) => ( @@ -97,9 +113,9 @@ export default function Health() { ))} )} - + - {/* 快捷入口 */} + {/* 快捷入口 — 横排 4 格图标 */} {QUICK_ENTRIES.map((e) => ( safeNavigateTo(e.path)} > - + {e.icon} {e.label} @@ -115,10 +131,12 @@ export default function Health() { ))} - {/* 告警提示 */} + {/* 告警横幅 */} {alertCount > 0 && ( safeNavigateTo('/pages/pkg-health/alerts/index')} > diff --git a/apps/miniprogram/src/pages/index/index.tsx b/apps/miniprogram/src/pages/index/index.tsx index 663d252..aeea7f4 100644 --- a/apps/miniprogram/src/pages/index/index.tsx +++ b/apps/miniprogram/src/pages/index/index.tsx @@ -105,8 +105,8 @@ function GuestHome({ modeClass }: { modeClass: string }) { indicatorActiveColor='#FFFFFF' autoplay={swiperAutoplay} circular - interval={4000} - duration={500} + interval={5000} + duration={300} > {slides.map((slide, idx) => ( diff --git a/apps/miniprogram/src/pages/pkg-health/device-sync/index.scss b/apps/miniprogram/src/pages/pkg-health/device-sync/index.scss index d6f7669..e2e08ea 100644 --- a/apps/miniprogram/src/pages/pkg-health/device-sync/index.scss +++ b/apps/miniprogram/src/pages/pkg-health/device-sync/index.scss @@ -2,245 +2,928 @@ @import '../../../styles/mixins.scss'; // PageShell 已接管:min-height, background, safe-bottom -// ContentCard 已接管:sync-status-card/sync-result-card 背景/圆角/阴影 +// NavBar 由 Taro 原生页面配置处理(index.config.ts navigationBarTitleText) -.sync-header { - background: var(--tk-pri); - padding: var(--tk-gap-2xl) var(--tk-gap-xl) var(--tk-gap-xl); - color: $card; +// ─── Body ─── +.ds-body { + padding: 0 var(--tk-gap-lg) calc(var(--tk-tabbar-space) + 20px); + + &--center { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + padding-top: 0; + } } -.sync-header-title { - @include section-title; - color: $card; -} - -.sync-section { - padding: var(--tk-gap-lg); -} - -.sync-hero { +// ─── Hero ─── +.ds-hero { + background: linear-gradient(135deg, $pri 0%, $pri-d 100%); + margin: 0 calc(var(--tk-gap-lg) * -1); + padding: var(--tk-gap-2xl) var(--tk-gap-lg) var(--tk-gap-xl); display: flex; flex-direction: column; align-items: center; - padding: var(--tk-gap-2xl) var(--tk-gap-lg); - background: $card; - border-radius: $r; margin-bottom: var(--tk-gap-lg); - box-shadow: $shadow-sm; } -.sync-hero-icon { - width: 80px; - height: 80px; +.ds-hero__icon { + width: 72px; + height: 72px; border-radius: 50%; - background: var(--tk-pri-l); + background: rgba(255, 255, 255, 0.15); @include flex-center; margin-bottom: var(--tk-section-gap); - color: var(--tk-pri); - font-family: 'Georgia', 'Times New Roman', serif; - font-size: var(--tk-font-num-lg); - font-weight: bold; } -.sync-hero-title { +.ds-hero__bt { + font-size: 24px; + font-weight: 700; + color: $white; + font-family: Georgia, 'Times New Roman', serif; +} + +.ds-hero__title { @include section-title; + color: $white; + font-family: Georgia, 'Times New Roman', serif; margin-bottom: var(--tk-gap-xs); } -.sync-hero-desc { - font-size: var(--tk-font-h1); +.ds-hero__desc { + font-size: var(--tk-font-body-sm); + color: rgba(255, 255, 255, 0.75); + text-align: center; +} + +// ─── 设备类型标签 ─── +.ds-types { + margin-bottom: var(--tk-gap-md); +} + +.ds-types__label { + display: block; + font-size: var(--tk-font-body-sm); + font-weight: 600; + color: $tx; + margin-bottom: var(--tk-gap-sm); + padding-left: 2px; +} + +.ds-types__row { + display: flex; + gap: var(--tk-gap-xs); +} + +.ds-type-tag { + display: flex; + align-items: center; + gap: 6px; + background: $card; + border: 1px solid $bd-l; + border-radius: $r-xs; + padding: 8px 12px; +} + +.ds-type-tag__dot { + width: 8px; + height: 8px; + border-radius: 50%; + + &--heart { background: $dan; } + &--bp { background: $pri; } + &--glu { background: $wrn; } +} + +.ds-type-tag__text { + font-size: var(--tk-font-cap); color: $tx2; } -.sync-action { - @include flex-center; - background: var(--tk-pri); - border-radius: $r-sm; - padding: var(--tk-section-gap) var(--tk-gap-2xl); - margin: var(--tk-gap-sm) 0; - - &--primary { - flex: 1; - background: var(--tk-pri); - } - - &--danger { - flex: 1; - background: $dan; - margin-left: var(--tk-gap-md); - } +// ─── 上次同步信息 ─── +.ds-sync-info { + margin-bottom: var(--tk-gap-sm) !important; } -.sync-action-text { - color: $card; - font-size: var(--tk-font-body-lg); - font-weight: 500; -} - -.sync-device-list { - margin-top: var(--tk-gap-md); -} - -.sync-section-title { - font-family: 'Georgia', 'Times New Roman', serif; - font-size: var(--tk-font-body-lg); - font-weight: bold; - color: $tx; - margin-bottom: var(--tk-gap-sm); - display: block; -} - -.sync-device-item { +.ds-sync-info__inner { display: flex; - justify-content: space-between; align-items: center; - background: $card; - border-radius: $r-sm; - padding: var(--tk-gap-lg); - margin-bottom: var(--tk-gap-sm); - box-shadow: $shadow-sm; + gap: var(--tk-gap-md); } -.sync-device-info { +.ds-sync-info__icon-wrap { + width: 36px; + height: 36px; + border-radius: 50%; + background: $acc-l; + @include flex-center; + flex-shrink: 0; +} + +.ds-sync-info__check { + font-size: 16px; + color: $acc; + font-weight: 700; +} + +.ds-sync-info__text { + flex: 1; display: flex; flex-direction: column; } -.sync-device-name { - font-size: var(--tk-font-body-lg); +.ds-sync-info__title { + font-size: var(--tk-font-body-sm); font-weight: 500; color: $tx; } -.sync-device-adapter { - font-size: var(--tk-font-body); - color: var(--tk-text-secondary); - margin-top: 4px; +.ds-sync-info__time { + font-size: var(--tk-font-cap); + color: $tx3; + margin-top: 2px; } -.sync-device-rssi { - font-size: var(--tk-font-body); - color: $tx2; +.ds-sync-info__badge { + background: $acc-l; + border-radius: $r-xs; + padding: 4px 10px; + font-size: var(--tk-font-cap); + color: $acc; + font-weight: 500; } -.sync-status-card { +// ─── 待上传警告 ─── +.ds-warning { + background: $wrn-l; + border-radius: $r-sm; + padding: 12px var(--tk-gap-md); display: flex; align-items: center; - margin-bottom: var(--tk-gap-md); + gap: 10px; + margin-bottom: var(--tk-gap-lg); } -.sync-status-dot { - width: 16px; - height: 16px; +.ds-warning__icon { + width: 20px; + height: 20px; border-radius: 50%; - margin-right: var(--tk-gap-md); - background: $tx3; + background: $wrn; + color: $white; + font-size: 12px; + font-weight: 700; + @include flex-center; + flex-shrink: 0; +} - &--connected { +.ds-warning__text { + font-size: var(--tk-font-cap); + color: $wrn; + font-weight: 500; +} + +// ─── 扫描按钮 ─── +.ds-scan-btn-wrap { + margin-top: var(--tk-gap-md); +} + +// ─── 设备列表 ─── +.ds-devices { + margin-top: var(--tk-gap-lg); +} + +.ds-devices__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--tk-gap-sm); +} + +.ds-devices__count { + font-size: var(--tk-font-body-sm); + font-weight: 600; + color: $tx; +} + +.ds-devices__rescan { + font-size: var(--tk-font-cap); + color: $pri; + font-weight: 500; +} + +.ds-device-card { + display: flex; + align-items: center; + gap: 14px; + background: $card; + border-radius: $r-sm; + padding: var(--tk-gap-md); + margin-bottom: 10px; + border: 1px solid $bd-l; +} + +.ds-device-card__icon { + width: 44px; + height: 44px; + border-radius: $r-sm; + background: $pri-l; + @include flex-center; + flex-shrink: 0; +} + +.ds-device-card__bt { + font-size: 14px; + font-weight: 700; + color: $pri; + font-family: Georgia, 'Times New Roman', serif; +} + +.ds-device-card__info { + flex: 1; + display: flex; + flex-direction: column; +} + +.ds-device-card__name { + font-size: var(--tk-font-body); + font-weight: 600; + color: $tx; +} + +.ds-device-card__adapter { + font-size: var(--tk-font-cap); + color: $tx3; + margin-top: 3px; +} + +.ds-device-card__signal { + display: flex; + align-items: flex-end; + gap: 2px; + height: 16px; + margin-right: 6px; +} + +.ds-signal-bar { + width: 3px; + border-radius: 1px; + background: $bd; + + &--active { background: $acc; } } -.sync-status-text { - font-size: var(--tk-font-body-lg); +.ds-device-card__arrow { + font-size: 18px; + color: $tx3; + font-weight: 300; +} + +.ds-device-card--generic { + border-style: dashed; + border-color: $bd; + + .ds-device-card__icon { + background: $surface-alt; + } +} + +.ds-devices__empty-hint { + margin-top: var(--tk-gap-md); + background: $card; + border-radius: $r-sm; + padding: 14px var(--tk-gap-md); + border: 1px dashed $bd; + display: flex; + align-items: center; + gap: 10px; +} + +.ds-devices__empty-icon { + width: 32px; + height: 32px; + border-radius: 50%; + background: $surface-alt; + @include flex-center; + font-size: 14px; + color: $tx3; + flex-shrink: 0; +} + +.ds-devices__empty-title { + display: block; + font-size: var(--tk-font-cap); + color: $tx2; +} + +.ds-devices__empty-desc { + display: block; + font-size: var(--tk-font-cap); + color: $tx3; + margin-top: 2px; +} + +// 空结果提示框(扫描完成但 0 设备) +.ds-devices--empty { + display: flex; + flex-direction: column; + gap: var(--tk-gap-md); +} + +.ds-devices__empty-box { + background: $card; + border-radius: $r; + border: 2px dashed $bd; + padding: var(--tk-gap-xl) var(--tk-gap-lg); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--tk-gap-xs); +} + +.ds-devices__empty-box-icon { + width: 48px; + height: 48px; + border-radius: 50%; + background: $wrn-l; + color: $wrn; + font-size: 22px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--tk-gap-xs); +} + +.ds-devices__empty-box-title { + font-size: var(--tk-font-body); + font-weight: 600; color: $tx; } -.sync-readings-panel { +.ds-devices__empty-box-desc { + font-size: var(--tk-font-body-sm); + color: $tx3; + line-height: 1.5; +} + +.ds-devices__rescan-wrap { + margin-top: var(--tk-gap-xs); +} + +// ─── 脉冲扫描动画 ─── +.ds-pulse { + position: relative; + width: 140px; + height: 140px; + margin-bottom: var(--tk-gap-2xl); +} + +.ds-pulse__ring { + position: absolute; + border-radius: 50%; + border: 2px solid $pri-l; + animation: ds-pulse-ring 2s ease-out infinite; + + &--1 { top: 0; left: 0; width: 140px; height: 140px; } + &--2 { top: 15px; left: 15px; width: 110px; height: 110px; animation-delay: 0.5s; } +} + +.ds-pulse__center { + position: absolute; + top: 30px; + left: 30px; + width: 80px; + height: 80px; + border-radius: 50%; + background: $pri-l; + @include flex-center; + animation: ds-pulse-dot 2s ease-in-out infinite; +} + +.ds-pulse__bt { + font-size: 24px; + font-weight: 700; + color: $pri; + font-family: Georgia, 'Times New Roman', serif; +} + +.ds-pulse__title { + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--tk-font-h2); + font-weight: 700; + color: $tx; + margin-bottom: var(--tk-gap-xs); + text-align: center; +} + +.ds-pulse__hint { + font-size: var(--tk-font-body-sm); + color: $tx3; + text-align: center; +} + +@keyframes ds-pulse-ring { + 0% { transform: scale(0.8); opacity: 0.6; } + 50% { transform: scale(1.3); opacity: 0; } + 100% { transform: scale(0.8); opacity: 0; } +} + +@keyframes ds-pulse-dot { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +// ─── 连接动画 ─── +.ds-connect-anim { + position: relative; + width: 100px; + height: 100px; + margin-bottom: 28px; +} + +.ds-connect-anim__ring { + position: absolute; + top: 0; + left: 0; + width: 100px; + height: 100px; + border-radius: 50%; + border: 3px solid $bd-l; + border-top-color: $pri; + animation: ds-connect-spin 1s linear infinite; +} + +.ds-connect-anim__center { + position: absolute; + top: 20px; + left: 20px; + width: 60px; + height: 60px; + border-radius: 50%; + background: $pri-l; + @include flex-center; +} + +.ds-connect-anim__bt { + font-size: 18px; + font-weight: 700; + color: $pri; + font-family: Georgia, 'Times New Roman', serif; +} + +.ds-connect-anim__title { + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--tk-font-body-lg); + font-weight: 700; + color: $tx; + margin-bottom: 6px; + text-align: center; +} + +.ds-connect-anim__sub { + font-size: var(--tk-font-body-sm); + color: $tx3; + text-align: center; +} + +@keyframes ds-connect-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +// ─── 步骤指示器 ─── +.ds-steps { + display: flex; + align-items: center; + gap: 8px; + margin-top: var(--tk-gap-xl); +} + +.ds-steps__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: $bd; + + &--done { background: $acc; } + &--active { + background: $pri; + animation: ds-pulse-dot 1s ease-in-out infinite; + } +} + +.ds-steps__label { + font-size: var(--tk-font-cap); + color: $tx3; + + &--done { color: $tx3; } + &--active { color: $pri; font-weight: 500; } +} + +.ds-steps__line { + width: 24px; + height: 1px; + background: $bd; + + &--active { background: $pri; } +} + +// ─── 已连接状态卡 ─── +.ds-connected-status { + background: linear-gradient(135deg, $acc 0%, $acc-d 100%); + border-radius: $r; + padding: var(--tk-gap-md); + display: flex; + align-items: center; + gap: 12px; + margin-bottom: var(--tk-gap-md); +} + +.ds-connected-status__icon { + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + @include flex-center; + flex-shrink: 0; +} + +.ds-connected-status__bt { + font-size: 14px; + font-weight: 700; + color: $white; + font-family: Georgia, 'Times New Roman', serif; +} + +.ds-connected-status__info { + flex: 1; +} + +.ds-connected-status__name { + font-size: var(--tk-font-body); + font-weight: 600; + color: $white; +} + +.ds-connected-status__sub { + font-size: var(--tk-font-cap); + color: rgba(255, 255, 255, 0.7); + margin-top: 2px; +} + +.ds-connected-status__badge { + background: rgba(255, 255, 255, 0.2); + border-radius: $r-xs; + padding: 4px 10px; + font-size: var(--tk-font-cap); + color: $white; +} + +// ─── 最新读数卡片 ─── +.ds-latest-reading { + margin-bottom: var(--tk-gap-md) !important; +} + +.ds-latest-reading__icon-wrap { + width: 52px; + height: 52px; + border-radius: $r-sm; + @include flex-center; + flex-shrink: 0; + + &--heart { + background: rgba($dan, 0.06); + } +} + +.ds-latest-reading__heart { + font-size: 28px; + color: $dan; +} + +.ds-latest-reading__body { + flex: 1; + margin-left: var(--tk-gap-md); +} + +.ds-latest-reading__label { + display: block; + font-size: var(--tk-font-cap); + color: $tx3; +} + +.ds-latest-reading__values { + display: flex; + align-items: baseline; + gap: 4px; + margin-top: 4px; +} + +.ds-latest-reading__num { + font-family: Georgia, 'Times New Roman', serif; + font-size: 36px; + font-weight: 700; + color: $tx; + @include serif-number; +} + +.ds-latest-reading__unit { + font-size: var(--tk-font-body-sm); + color: $tx3; +} + +// ─── 历史读数 ─── +.ds-history { + margin-bottom: var(--tk-gap-md); +} + +.ds-history__title { + display: block; + font-size: var(--tk-font-body-sm); + font-weight: 600; + color: $tx; + margin-bottom: var(--tk-gap-sm); + padding-left: 2px; +} + +.ds-history__list { background: $card; border-radius: $r-sm; - padding: var(--tk-gap-lg); - margin-bottom: var(--tk-gap-md); + overflow: hidden; box-shadow: $shadow-sm; } -.sync-reading-item { +.ds-history__row { display: flex; - justify-content: space-between; align-items: center; - padding: var(--tk-gap-sm) 0; + padding: 12px var(--tk-gap-md); border-bottom: 1px solid $bd-l; - &:last-child { + &--last { border-bottom: none; } } -.sync-reading-type { - font-size: var(--tk-font-h1); +.ds-history__type { + width: 90px; + font-size: var(--tk-font-body-sm); color: $tx2; } -.sync-reading-value { - font-size: var(--tk-font-body-lg); - font-weight: bold; - color: var(--tk-pri); +.ds-history__val-wrap { + flex: 1; + display: flex; + align-items: baseline; + gap: 3px; +} + +.ds-history__val { + font-family: Georgia, 'Times New Roman', serif; + font-size: 20px; + font-weight: 700; + color: $tx; @include serif-number; } -.sync-readings-count { - display: block; - margin-top: var(--tk-gap-sm); - font-size: var(--tk-font-body); - color: var(--tk-text-secondary); - text-align: center; +.ds-history__unit { + font-size: var(--tk-font-cap); + color: $tx3; } -.sync-actions-row { +.ds-history__count { + display: block; + text-align: center; + margin-top: var(--tk-gap-sm); + font-size: var(--tk-font-cap); + color: $tx3; +} + +// ─── 操作按钮 ─── +.ds-actions { display: flex; gap: var(--tk-gap-sm); + margin-top: var(--tk-section-gap); } -.sync-error { - margin: var(--tk-gap-lg); - padding: var(--tk-section-gap) var(--tk-gap-lg); +.ds-actions__upload { + flex: 1; + background: $pri; + border-radius: $r-sm; + padding: 14px; + @include flex-center; + box-shadow: $shadow-btn; +} + +.ds-actions__upload-text { + color: $white; + font-size: var(--tk-font-body-lg); + font-weight: 600; +} + +.ds-actions__disconnect { + width: 52px; background: $dan-l; border-radius: $r-sm; -} - -.sync-error-text { - font-size: var(--tk-font-h1); - color: $dan; -} - -.sync-loading { @include flex-center; - padding: 64px 24px; } -.sync-loading-text { - font-size: var(--tk-font-body-lg); - color: $tx2; +.ds-actions__disconnect-icon { + font-size: 16px; + color: $dan; + font-weight: 700; } -.sync-result-card { +// ─── 同步完成 ─── +.ds-done { + width: 100%; display: flex; flex-direction: column; align-items: center; - margin-bottom: var(--tk-gap-lg); - box-shadow: $shadow-sm; + padding: 0 var(--tk-gap-lg); } -.sync-result-icon { +.ds-done__icon { width: 80px; height: 80px; border-radius: 50%; background: $acc-l; @include flex-center; - color: $acc; - font-family: 'Georgia', 'Times New Roman', serif; - font-size: var(--tk-font-num-lg); - font-weight: bold; - margin-bottom: var(--tk-gap-md); + margin-bottom: var(--tk-gap-xl); } -.sync-result-title { - @include section-title; +.ds-done__check { + font-size: 32px; + color: $acc; + font-weight: 700; +} + +.ds-done__title { + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--tk-font-h1); + font-weight: 700; + color: $tx; margin-bottom: var(--tk-gap-xs); } -.sync-result-count { - font-size: var(--tk-font-h1); +.ds-done__desc { + font-size: var(--tk-font-body-sm); + color: $tx3; + text-align: center; + margin-bottom: var(--tk-gap-lg); +} + +.ds-done__stats { + display: flex; + gap: 12px; + width: 100%; + margin-bottom: var(--tk-gap-xl); +} + +.ds-done__stat { + flex: 1; + background: $card; + border-radius: $r-sm; + padding: var(--tk-gap-md); + text-align: center; + border: 1px solid $bd-l; +} + +.ds-done__stat-num { + display: block; + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--tk-font-num); + font-weight: 700; + color: $tx; + + &--pri { color: $pri; } + &--acc { color: $acc; font-size: var(--tk-font-body-lg); } +} + +.ds-done__stat-label { + display: block; + font-size: var(--tk-font-cap); + color: $tx3; + margin-top: 4px; +} + +// ─── 错误页面 ─── +.ds-error-page { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + padding: 0 var(--tk-gap-lg); +} + +.ds-error-page__icon { + width: 80px; + height: 80px; + border-radius: 50%; + background: $dan-l; + @include flex-center; + margin-bottom: var(--tk-gap-xl); +} + +.ds-error-page__x { + font-size: 28px; + color: $dan; + font-weight: 700; +} + +.ds-error-page__title { + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--tk-font-h2); + font-weight: 700; + color: $tx; + margin-bottom: var(--tk-gap-xs); +} + +.ds-error-page__desc { + font-size: var(--tk-font-body-sm); + color: $tx3; + text-align: center; + max-width: 260px; + margin-bottom: var(--tk-gap-lg); +} + +.ds-error-page__detail { + width: 100%; + background: $card; + border-radius: $r-sm; + padding: var(--tk-gap-md); + border: 1px solid $bd-l; + margin-bottom: var(--tk-gap-lg); +} + +.ds-error-page__detail-title { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--tk-font-cap); + font-weight: 500; + color: $tx; + margin-bottom: 12px; +} + +.ds-error-page__detail-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + margin-bottom: 4px; + + &--last { + margin-bottom: 0; + } +} + +.ds-error-page__detail-label { + font-size: var(--tk-font-cap); + color: $tx3; +} + +.ds-error-page__detail-value { + font-size: var(--tk-font-cap); + color: $tx; +} + +.ds-error-page__back { + width: 100%; + border-radius: $r-sm; + padding: 14px; + @include flex-center; + margin-top: 10px; + border: 1px solid $bd; +} + +.ds-error-page__back-text { + color: $tx2; + font-size: var(--tk-font-body); + font-weight: 500; +} + +// ─── 加载态 ─── +.ds-loading { + @include flex-center; + flex-direction: column; + padding: 64px 24px; + gap: var(--tk-gap-md); +} + +.ds-loading__spinner { + width: 40px; + height: 40px; + border-radius: 50%; + border: 3px solid $bd-l; + border-top-color: $pri; + animation: ds-connect-spin 1s linear infinite; +} + +.ds-loading__text { + font-size: var(--tk-font-body-lg); color: $tx2; } diff --git a/apps/miniprogram/src/pages/pkg-health/device-sync/index.tsx b/apps/miniprogram/src/pages/pkg-health/device-sync/index.tsx index 25b49d3..3445090 100644 --- a/apps/miniprogram/src/pages/pkg-health/device-sync/index.tsx +++ b/apps/miniprogram/src/pages/pkg-health/device-sync/index.tsx @@ -6,7 +6,7 @@ import { BLEManager } from '@/services/ble/BLEManager'; import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter'; import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter'; import { GlucoseMeterAdapter } from '@/services/ble/adapters/GlucoseMeterAdapter'; -import { CustomBandAdapter } from '@/services/ble/adapters/GenericBleAdapter'; +import { CustomBandAdapter, HuaweiBandAdapter, FallbackAdapter } from '@/services/ble/adapters/GenericBleAdapter'; import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler'; import { uploadReadings } from '@/services/device-sync'; import { useAuthStore } from '@/stores/auth'; @@ -14,12 +14,58 @@ import type { BLEDevice, NormalizedReading } from '@/services/ble/types'; import { useElderClass } from '@/hooks/useElderClass'; import PageShell from '@/components/ui/PageShell'; import ContentCard from '@/components/ui/ContentCard'; +import PrimaryButton from '@/components/ui/PrimaryButton'; import './index.scss'; -/** liveReadings 最大保留条数,防止内存无限增长 */ const MAX_LIVE_READINGS = 200; -type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error'; +type PageState = 'idle' | 'scanning' | 'found' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error'; + +const DEVICE_TYPE_MAP: Record = { + heart_rate: { label: '心率', unit: 'bpm' }, + blood_pressure: { label: '血压', unit: 'mmHg' }, + blood_glucose: { label: '血糖', unit: 'mmol/L' }, + blood_oxygen: { label: '血氧', unit: '%' }, + temperature: { label: '体温', unit: '°C' }, + steps: { label: '步数', unit: '步' }, + sleep: { label: '睡眠', unit: 'h' }, + stress: { label: '压力', unit: '' }, +}; + +function formatReadingValue(r: NormalizedReading): string { + if (r.device_type === 'heart_rate' && typeof r.values.heart_rate === 'number') { + return String(r.values.heart_rate); + } + if (r.device_type === 'blood_pressure') { + if (typeof r.values.systolic === 'number' && typeof r.values.diastolic === 'number') { + return `${r.values.systolic}/${r.values.diastolic}`; + } + if (r.metric === 'systolic' && typeof r.values.value === 'number') return String(r.values.value); + if (r.metric === 'diastolic' && typeof r.values.value === 'number') return String(r.values.value); + } + if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') { + return String(r.values.blood_glucose); + } + if (typeof r.values.value === 'number') return String(r.values.value); + return '--'; +} + +function getReadingUnit(r: NormalizedReading): string { + const mapped = DEVICE_TYPE_MAP[r.device_type]; + if (mapped) return mapped.unit; + return typeof r.values.unit === 'string' ? r.values.unit : ''; +} + +function getReadingLabel(r: NormalizedReading): string { + const mapped = DEVICE_TYPE_MAP[r.device_type]; + if (!mapped) return r.device_type; + if (r.device_type === 'blood_pressure') { + if (r.metric === 'systolic') return '收缩压'; + if (r.metric === 'diastolic') return '舒张压'; + return '血压'; + } + return mapped.label; +} export default function DeviceSync() { const modeClass = useElderClass(); @@ -35,15 +81,14 @@ export default function DeviceSync() { const [lastSyncAt, setLastSyncAt] = useState(null); const [pendingCount, setPendingCount] = useState(0); - const scheduler = useMemo(() => new DataSyncScheduler({ - intervalMs: 60 * 60 * 1000, - }), []); + const scheduler = useMemo(() => new DataSyncScheduler({ intervalMs: 60 * 60 * 1000 }), []); const bleManagerRef = useRef(null); const getBleManager = useCallback(() => { if (!bleManagerRef.current) { const mgr = new BLEManager({ scanTimeout: 10000, retryCount: 3 }); mgr.registerAdapter(XiaomiBandAdapter); + mgr.registerAdapter(HuaweiBandAdapter); mgr.registerAdapter(BloodPressureAdapter); mgr.registerAdapter(GlucoseMeterAdapter); mgr.registerAdapter(CustomBandAdapter); @@ -53,7 +98,12 @@ export default function DeviceSync() { }, []); useThrottledDidShow(() => { - const bleManager = getBleManager(); + let bleManager: BLEManager; + try { + bleManager = getBleManager(); + } catch { + return; + } bleManager.setOnConnectionChange(() => {}); bleManager.setOnReadings((readings) => { setLiveReadings((prev) => { @@ -61,17 +111,8 @@ export default function DeviceSync() { return merged.length > MAX_LIVE_READINGS ? merged.slice(-MAX_LIVE_READINGS) : merged; }); }); - - // 显示上次同步时间 setLastSyncAt(scheduler.getLastSyncAt()); - // 检查是否有未上传的缓冲数据 - const buffer = (bleManager as unknown as { dataBuffer?: Map }).dataBuffer; - if (buffer) { - setPendingCount(buffer.size); - } - - // 自动同步:超过间隔时尝试上传缓冲数据 if (currentPatient && scheduler.needsSync()) { scheduler.tryAutoSync(async () => { const count = await bleManager.flushPendingReadings(async (readings) => { @@ -95,18 +136,23 @@ export default function DeviceSync() { }, [scheduler]); const handleScan = useCallback(async () => { + console.log('[device-sync] 用户点击扫描按钮'); setPageState('scanning'); setDevices([]); setErrorMsg(''); try { const found = await getBleManager().scanDevices(); - setDevices(found); - if (found.length === 0) { - setErrorMsg('未发现支持的设备,请确认设备已开启蓝牙并靠近手机'); - } - setPageState('idle'); + console.log('[device-sync] 扫描返回设备数:', found.length); + // 未匹配适配器的设备分配 FallbackAdapter(尝试标准健康协议) + const withFallback = found.map((d) => + d.adapter ? d : { ...d, adapter: FallbackAdapter }, + ); + setDevices(withFallback); + setPageState('found'); } catch (e: unknown) { - setErrorMsg(e instanceof Error ? e.message : '扫描失败'); + const msg = e instanceof Error ? e.message : '扫描失败'; + console.error('[device-sync] 扫描异常:', msg); + setErrorMsg(msg); setPageState('error'); } }, []); @@ -126,33 +172,22 @@ export default function DeviceSync() { const handleSync = useCallback(async () => { if (!currentPatient || !selectedDevice) return; - setPageState('syncing'); setErrorMsg(''); - try { const result = await getBleManager().syncToServer(async (readings) => { - return uploadReadings( - currentPatient.id, - selectedDevice.deviceId, - selectedDevice.name, - readings, - ); + return uploadReadings(currentPatient.id, selectedDevice.deviceId, selectedDevice.name, readings); }); - if (result.success) { setSyncCount(result.uploadedCount); setLastSyncAt(Date.now()); setPageState('done'); - - // 如果从体征录入页跳转而来,将最新读数写入 storage 供回填 if (returnTo === 'input' && liveReadings.length > 0) { const mapped: Record = {}; for (const r of liveReadings) { if (r.device_type === 'blood_pressure') { if (r.metric === 'systolic' && typeof r.values.value === 'number') mapped.systolic = r.values.value; if (r.metric === 'diastolic' && typeof r.values.value === 'number') mapped.diastolic = r.values.value; - // 兼容 values 中直接包含 systolic/diastolic 的格式 if (typeof r.values.systolic === 'number') mapped.systolic = r.values.systolic as number; if (typeof r.values.diastolic === 'number') mapped.diastolic = r.values.diastolic as number; } else if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') { @@ -184,141 +219,325 @@ export default function DeviceSync() { setErrorMsg(''); }, []); - const renderIdle = () => ( - - - D - 设备同步 - 连接智能手环、血压计、血糖仪,自动采集健康数据 + const latestReading = liveReadings.length > 0 ? liveReadings[liveReadings.length - 1] : null; + + // ── 渲染子区域 ── + + const renderHero = () => ( + + + BT - - {(lastSyncAt || pendingCount > 0) && ( - - {lastSyncAt && ( - - 上次同步: {new Date(lastSyncAt).toLocaleTimeString()} - - )} - {pendingCount > 0 && ( - - {pendingCount} 条数据待上传 - - )} - - )} - - - 扫描设备 - - - {devices.length > 0 && ( - - 发现的设备 - {devices.map((d) => ( - handleConnect(d)} - > - - {d.name} - {d.adapter?.name} - - 信号 {d.RSSI > -60 ? '强' : d.RSSI > -80 ? '中' : '弱'} - - ))} - - )} + 智能设备同步 + 连接蓝牙设备,自动采集健康数据 ); - const renderConnected = () => ( - - - - 已连接: {selectedDevice?.name} - + const renderDeviceTypes = () => ( + + 支持的设备 + + 心率手环 + 血压计 + 血糖仪 + + + ); - {liveReadings.length > 0 && ( - - 实时数据 - {liveReadings.slice(-5).reverse().map((r, i) => ( - - - {r.device_type === 'heart_rate' ? '心率' - : r.device_type === 'blood_pressure' ? `血压(${r.metric === 'systolic' ? '收缩压' : r.metric === 'diastolic' ? '舒张压' : 'MAP'})` - : r.device_type === 'blood_glucose' ? '血糖' - : r.device_type} - - - {r.device_type === 'heart_rate' - ? `${r.values.heart_rate} bpm` - : r.metric - ? `${r.values.value} ${r.values.unit}` - : JSON.stringify(r.values)} - + const renderLastSync = () => { + if (!lastSyncAt && pendingCount === 0) return null; + return ( + + + + + + + 上次同步 + {lastSyncAt && {new Date(lastSyncAt).toLocaleTimeString()}} + + {pendingCount > 0 && ( + {pendingCount} 条待上传 + )} + + + ); + }; + + const renderPendingWarning = () => { + if (pendingCount <= 0) return null; + return ( + + ! + {pendingCount} 条数据待上传 + + ); + }; + + const renderScanButton = () => ( + + + 扫描附近设备 + + + ); + + const renderDeviceList = () => { + if (pageState !== 'found') return null; + if (devices.length === 0) { + return ( + + + ! + 未发现设备 + 请确认设备已开机且蓝牙已开启,并靠近手机后重试 + + + 重新扫描 + + + ); + } + return ( + + + 发现 {devices.length} 台设备 + 重新扫描 + + {devices.map((d) => { + const isFallback = d.adapter?.name === '通用设备'; + return ( + handleConnect(d)}> + + BT + + + {d.name} + {d.adapter?.name}{isFallback ? ' · 尝试标准协议' : ''} + + + {[4, 7, 10, 13].map((h, i) => ( + -60 ? 4 : d.RSSI > -80 ? 3 : d.RSSI > -90 ? 2 : 1) ? 'ds-signal-bar--active' : ''}`} + style={{ height: `${h}px` }} + /> + ))} + + + + ); + })} + + ? + + 没有找到你的设备? + 确保设备已开机且蓝牙已开启 + + + + ); + }; + + const renderLoading = (text: string) => ( + + + {text} + + ); + + const renderConnectedStatus = () => ( + + + BT + + + {selectedDevice?.name} + 已连接 · 正在采集数据 + + 实时 + + ); + + const renderLatestReading = () => { + if (!latestReading) return null; + return ( + + + + + + {getReadingLabel(latestReading)} · 刚刚 + + {formatReadingValue(latestReading)} + {getReadingUnit(latestReading)} + + + + ); + }; + + const renderReadingsHistory = () => { + if (liveReadings.length <= 1) return null; + const history = liveReadings.slice(0, -1).reverse().slice(0, 5); + return ( + + 历史读数 + + {history.map((r, i) => ( + + {getReadingLabel(r)} + + {formatReadingValue(r)} + {getReadingUnit(r)} + ))} - - 已采集 {liveReadings.length} 条数据 - - )} + 已采集 {liveReadings.length} 条数据 + + ); + }; - - - 上传数据 - - - 断开连接 - + const renderConnectedActions = () => ( + + + 上传数据 + + + ); const renderDone = () => ( - - - V - 同步完成 - 成功上传 {syncCount} 条数据 - - { - handleDisconnect(); - if (returnTo === 'input') { - Taro.navigateBack(); - } - }}> - {returnTo === 'input' ? '返回录入' : '完成'} + + + + + 同步完成 + 数据已安全上传至健康管理平台 + + + {syncCount} + 上传条数 + + + {new Set(liveReadings.map((r) => r.device_type)).size} + 数据类型 + + + 100% + 成功率 + + + { handleDisconnect(); if (returnTo === 'input') Taro.navigateBack(); }}> + {returnTo === 'input' ? '返回录入' : '完成'} + + + ); + + const renderError = () => ( + + + + + 连接失败 + {errorMsg || '无法连接到设备,请检查设备是否在范围内并重试'} + + 错误详情 + 设备{selectedDevice?.name || '--'} + 时间{new Date().toLocaleTimeString()} + + 重新扫描 + Taro.navigateBack()}> + 返回 ); + // ── 主渲染 ── return ( - - - 设备同步 - - - {errorMsg && ( - - {errorMsg} + + {/* 空闲态 */} + {pageState === 'idle' && ( + + {renderHero()} + {renderDeviceTypes()} + {renderLastSync()} + {renderPendingWarning()} + {renderScanButton()} )} - {(pageState === 'scanning' || pageState === 'connecting' || pageState === 'syncing') && ( - - - {pageState === 'scanning' && '正在扫描设备...'} - {pageState === 'connecting' && '正在连接设备...'} - {pageState === 'syncing' && '正在上传数据...'} - + {/* 扫描结果(设备列表或空结果提示) */} + {pageState === 'found' && ( + + {renderHero()} + {renderDeviceTypes()} + {renderDeviceList()} )} - {(pageState === 'idle' || pageState === 'error') && renderIdle()} - {pageState === 'connected' && renderConnected()} - {pageState === 'done' && renderDone()} + {/* 扫描中 */} + {pageState === 'scanning' && ( + + + + + + BT + + + 正在搜索设备... + 请确保设备已开启蓝牙并靠近手机 + + )} + + {/* 连接中 */} + {pageState === 'connecting' && ( + + + + + BT + + + 正在连接 {selectedDevice?.name} + 正在进行蓝牙配对... + + + 发现设备 + + + 连接中 + + + 同步数据 + + + )} + + {/* 同步中 */} + {pageState === 'syncing' && renderLoading('正在上传数据...')} + + {/* 已连接 */} + {pageState === 'connected' && ( + + {renderConnectedStatus()} + {renderLatestReading()} + {renderReadingsHistory()} + {renderConnectedActions()} + + )} + + {/* 同步完成 */} + {pageState === 'done' && {renderDone()}} + + {/* 错误态 */} + {pageState === 'error' && errorMsg && ( + {renderError()} + )} ); } diff --git a/apps/miniprogram/src/pages/pkg-profile/health-records/index.scss b/apps/miniprogram/src/pages/pkg-profile/health-records/index.scss index 9fb1a64..22b9f81 100644 --- a/apps/miniprogram/src/pages/pkg-profile/health-records/index.scss +++ b/apps/miniprogram/src/pages/pkg-profile/health-records/index.scss @@ -1,18 +1,12 @@ @import '../../../styles/variables.scss'; @import '../../../styles/mixins.scss'; -// PageShell 已接管:min-height, background, padding - .page-title { - font-family: 'Georgia', 'Times New Roman', serif; - font-size: var(--tk-font-num); - font-weight: bold; - color: $tx; - margin-bottom: var(--tk-section-gap); - display: block; + @include section-title; padding-left: var(--tk-gap-2xs); } +/* ─── 健康记录卡片 ─── */ .record-list { display: flex; flex-direction: column; @@ -65,3 +59,79 @@ display: block; margin-top: var(--tk-gap-xs); } + +/* ─── 诊断记录卡片 ─── */ +.diagnosis-list { + display: flex; + flex-direction: column; + gap: var(--tk-gap-md); +} + +.diagnosis-card { + background: $card; + border-radius: $r; + padding: var(--tk-card-padding-lg); + box-shadow: $shadow-sm; +} + +.diagnosis-card__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--tk-gap-xs); +} + +.diagnosis-card__name { + font-size: var(--tk-font-body-lg); + font-weight: bold; + color: $tx; + flex: 1; + margin-right: var(--tk-gap-sm); +} + +.diagnosis-card__status { + font-size: var(--tk-font-cap); + padding: 2px 8px; + border-radius: $r-pill; + font-weight: 600; + + &.active { background: $acc-l; color: $acc; } + &.resolved { background: $bd-l; color: $tx2; } + &.chronic { background: $wrn-l; color: $wrn; } +} + +.diagnosis-card__meta { + display: flex; + gap: var(--tk-gap-sm); + align-items: center; + margin-bottom: var(--tk-gap-2xs); +} + +.diagnosis-card__type { + font-size: var(--tk-font-cap); + padding: 1px 6px; + border-radius: $r-pill; + + &.primary { background: var(--tk-pri-l); color: var(--tk-pri); } + &.secondary { background: $bd-l; color: $tx2; } + &.comorbid { background: $wrn-l; color: $wrn; } +} + +.diagnosis-card__code { + font-size: var(--tk-font-cap); + color: $tx3; + font-variant-numeric: tabular-nums; +} + +.diagnosis-card__date { + font-size: var(--tk-font-cap); + color: $tx3; + display: block; +} + +.diagnosis-card__notes { + font-size: var(--tk-font-body); + color: $tx2; + display: block; + margin-top: var(--tk-gap-xs); +} diff --git a/apps/miniprogram/src/pages/pkg-profile/health-records/index.tsx b/apps/miniprogram/src/pages/pkg-profile/health-records/index.tsx index 8c763c6..1d3bf01 100644 --- a/apps/miniprogram/src/pages/pkg-profile/health-records/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/health-records/index.tsx @@ -3,89 +3,197 @@ import { View, Text } from '@tarojs/components'; import Taro, { useReachBottom } from '@tarojs/taro'; import { usePageData } from '@/hooks/usePageData'; import { getCachedPatientId } from '@/services/request'; -import { listHealthRecords, HealthRecord } from '../../../services/health-record'; +import { + listHealthRecords, + HealthRecord, + listDiagnoses, + Diagnosis, +} from '../../../services/health-record'; import EmptyState from '../../../components/EmptyState'; import Loading from '../../../components/Loading'; import { useElderClass } from '../../../hooks/useElderClass'; import PageShell from '@/components/ui/PageShell'; +import SegmentTabs from '../../../components/SegmentTabs'; import './index.scss'; -const TYPE_MAP: Record = { +const TABS = [ + { key: 'records', label: '体检记录' }, + { key: 'diagnoses', label: '诊断记录' }, +] as const; + +const RECORD_TYPE_MAP: Record = { checkup: '体检', follow_up: '复查', referral: '转诊', }; -export default function HealthRecords() { - const modeClass = useElderClass(); - const [records, setRecords] = useState([]); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [hasPatient, setHasPatient] = useState(true); +const DIAG_TYPE_MAP: Record = { + primary: { label: '主要', cls: 'primary' }, + secondary: { label: '次要', cls: 'secondary' }, + comorbid: { label: '合并症', cls: 'comorbid' }, +}; - const fetchData = useCallback(async (p: number, append = false) => { +const DIAG_STATUS_MAP: Record = { + active: { label: '活动', cls: 'active' }, + resolved: { label: '已解决', cls: 'resolved' }, + chronic: { label: '慢性', cls: 'chronic' }, +}; + +type TabKey = 'records' | 'diagnoses'; + +export default function HealthArchive() { + const modeClass = useElderClass(); + const [tab, setTab] = useState('records'); + + // --- 健康记录 --- + const [records, setRecords] = useState([]); + const [recordsPage, setRecordsPage] = useState(1); + const [recordsTotal, setRecordsTotal] = useState(0); + const [recordsLoading, setRecordsLoading] = useState(false); + + const fetchRecords = useCallback(async (p: number, append = false) => { const patientId = getCachedPatientId(); - if (!patientId) { - setRecords([]); - setHasPatient(false); - return; - } - setHasPatient(true); - setLoading(true); + if (!patientId) return; + setRecordsLoading(true); try { const res = await listHealthRecords(patientId, { page: p, page_size: 20 }); const list = res.data || []; setRecords(append ? (prev) => [...prev, ...list] : list); - setTotal(res.total); - setPage(p); + setRecordsTotal(res.total); + setRecordsPage(p); } catch (err) { console.warn('[health-records] 加载失败:', err); Taro.showToast({ title: '加载失败', icon: 'none' }); } finally { - setLoading(false); + setRecordsLoading(false); } }, []); - usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true }); + // --- 诊断记录 --- + const [diagnoses, setDiagnoses] = useState([]); + const [diagPage, setDiagPage] = useState(1); + const [diagTotal, setDiagTotal] = useState(0); + const [diagLoading, setDiagLoading] = useState(false); + const [diagnosesLoaded, setDiagnosesLoaded] = useState(false); + + const fetchDiagnoses = useCallback(async (p: number, append = false) => { + const patientId = getCachedPatientId(); + if (!patientId) return; + setDiagLoading(true); + try { + const res = await listDiagnoses(patientId, { page: p, page_size: 20 }); + const list = res.data || []; + setDiagnoses(append ? (prev) => [...prev, ...list] : list); + setDiagTotal(res.total); + setDiagPage(p); + setDiagnosesLoaded(true); + } catch (err) { + console.warn('[diagnoses] 加载失败:', err); + Taro.showToast({ title: '加载失败', icon: 'none' }); + } finally { + setDiagLoading(false); + } + }, []); + + const handleRefresh = useCallback(async () => { + if (tab === 'records') { + await fetchRecords(1); + } else { + await fetchDiagnoses(1); + } + }, [tab, fetchRecords, fetchDiagnoses]); + + const handleTabSwitch = (key: TabKey) => { + if (key === tab) return; + setTab(key); + if (key === 'diagnoses' && !diagnosesLoaded) { + fetchDiagnoses(1); + } + }; + + usePageData(handleRefresh, { throttleMs: 10000, enablePullDown: true }); + + const currentLoading = tab === 'records' ? recordsLoading : diagLoading; + const currentItems = tab === 'records' ? records.length : diagnoses.length; + const currentTotal = tab === 'records' ? recordsTotal : diagTotal; useReachBottom(() => { - if (!loading && records.length < total) { - fetchData(page + 1, true); + if (currentLoading || currentItems >= currentTotal) return; + if (tab === 'records') { + fetchRecords(recordsPage + 1, true); + } else { + fetchDiagnoses(diagPage + 1, true); } }); + const hasPatient = !!getCachedPatientId(); + return ( - 健康记录 + 健康档案 - - {records.map((r) => ( - - - - {TYPE_MAP[r.record_type] || r.record_type} - - {r.record_date} + handleTabSwitch(k as TabKey)} variant="pill" /> + + {tab === 'records' && ( + + {records.map((r) => ( + + + + {RECORD_TYPE_MAP[r.record_type] || r.record_type} + + {r.record_date} + + {r.overall_assessment && ( + {r.overall_assessment} + )} + {r.source && ( + 来源:{r.source} + )} + {r.notes && ( + {r.notes} + )} - {r.overall_assessment && ( - {r.overall_assessment} - )} - {r.source && ( - 来源:{r.source} - )} - {r.notes && ( - {r.notes} - )} - - ))} - - - {records.length === 0 && !loading && ( - + ))} + )} - {loading && } + {tab === 'diagnoses' && ( + + {diagnoses.map((d) => { + const typeInfo = DIAG_TYPE_MAP[d.diagnosis_type] || { label: d.diagnosis_type, cls: '' }; + const statusInfo = DIAG_STATUS_MAP[d.status] || { label: d.status, cls: '' }; + return ( + + + {d.diagnosis_name} + + {statusInfo.label} + + + + + {typeInfo.label} + + {d.icd_code} + + 诊断日期:{d.diagnosed_date} + {d.notes && ( + {d.notes} + )} + + ); + })} + + )} + + {currentItems === 0 && !currentLoading && ( + + )} + + {currentLoading && } ); } diff --git a/apps/miniprogram/src/pages/pkg-profile/reports/index.scss b/apps/miniprogram/src/pages/pkg-profile/reports/index.scss index edd3d26..fae4939 100644 --- a/apps/miniprogram/src/pages/pkg-profile/reports/index.scss +++ b/apps/miniprogram/src/pages/pkg-profile/reports/index.scss @@ -1,13 +1,12 @@ @import '../../../styles/variables.scss'; @import '../../../styles/mixins.scss'; -// PageShell 已接管:min-height, background, padding - .page-title { @include section-title; padding-left: var(--tk-gap-2xs); } +/* ─── 检查报告卡片 ─── */ .report-list { display: flex; flex-direction: column; @@ -80,3 +79,63 @@ display: block; padding-left: 72px; } + +/* ─── AI 分析卡片 ─── */ +.ai-list { + display: flex; + flex-direction: column; + gap: var(--tk-gap-md); +} + +.ai-card { + background: $card; + border-radius: $r; + padding: var(--tk-card-padding-lg); + box-shadow: $shadow-sm; + + &:active { + opacity: var(--tk-touch-feedback-opacity); + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--tk-gap-xs); + } + + &__type { + font-size: var(--tk-font-body-lg); + font-weight: bold; + color: $tx; + flex: 1; + } + + &__status { + font-size: var(--tk-font-cap); + padding: 2px 8px; + border-radius: $r-pill; + font-weight: 600; + + &.completed { background: $acc-l; color: $acc; } + &.streaming { background: var(--tk-pri-l); color: var(--tk-pri); } + &.failed { background: $dan-l; color: $dan; } + &.pending { background: $bd-l; color: $tx2; } + } + + &__footer { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__time { + font-size: var(--tk-font-cap); + color: $tx3; + } + + &__model { + font-size: var(--tk-font-cap); + color: $tx3; + } +} diff --git a/apps/miniprogram/src/pages/pkg-profile/reports/index.tsx b/apps/miniprogram/src/pages/pkg-profile/reports/index.tsx index 90e34df..b5b8736 100644 --- a/apps/miniprogram/src/pages/pkg-profile/reports/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/reports/index.tsx @@ -5,104 +5,187 @@ import { safeNavigateTo } from '@/utils/navigate'; import { usePageData } from '@/hooks/usePageData'; import { getCachedPatientId } from '@/services/request'; import { listReports, LabReport } from '../../../services/report'; +import { listAiAnalysis, type AiAnalysisItem } from '../../../services/ai-analysis'; import EmptyState from '../../../components/EmptyState'; import Loading from '../../../components/Loading'; import { useElderClass } from '../../../hooks/useElderClass'; import PageShell from '@/components/ui/PageShell'; +import SegmentTabs from '../../../components/SegmentTabs'; import './index.scss'; +type TabKey = 'reports' | 'ai'; + +const TABS = [ + { key: 'reports', label: '检查报告' }, + { key: 'ai', label: 'AI 解读' }, +] as const; + +const AI_TYPE_LABELS: Record = { + lab_report_interpretation: '化验单解读', + health_trend_analysis: '趋势分析', + personalized_checkup_plan: '体检方案', + report_summary_generation: '报告摘要', +}; + +const AI_STATUS_MAP: Record = { + completed: { text: '已完成', cls: 'completed' }, + streaming: { text: '分析中', cls: 'streaming' }, + failed: { text: '失败', cls: 'failed' }, + pending: { text: '等待中', cls: 'pending' }, +}; + export default function MyReports() { const modeClass = useElderClass(); - const [reports, setReports] = useState([]); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); - const [hasPatient, setHasPatient] = useState(true); + const [tab, setTab] = useState('reports'); - const fetchData = useCallback(async (p: number, append = false) => { + // --- 检查报告 --- + const [reports, setReports] = useState([]); + const [reportsPage, setReportsPage] = useState(1); + const [reportsTotal, setReportsTotal] = useState(0); + const [reportsLoading, setReportsLoading] = useState(false); + + const fetchReports = useCallback(async (p: number, append = false) => { const patientId = getCachedPatientId(); - if (!patientId) { - setReports([]); - setHasPatient(false); - return; - } - setHasPatient(true); - setLoading(true); + if (!patientId) return; + setReportsLoading(true); try { const res = await listReports(patientId, p); const list = res.data || []; setReports(append ? (prev) => [...prev, ...list] : list); - setTotal(res.total); - setPage(p); + setReportsTotal(res.total); + setReportsPage(p); } catch (err) { console.warn('[reports] 加载报告列表失败:', err); Taro.showToast({ title: '加载失败', icon: 'none' }); } finally { - setLoading(false); + setReportsLoading(false); } }, []); - usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true }); + // --- AI 分析 --- + const [aiList, setAiList] = useState([]); + const [aiPage, setAiPage] = useState(1); + const [aiHasMore, setAiHasMore] = useState(true); + const [aiLoading, setAiLoading] = useState(false); + const [aiLoaded, setAiLoaded] = useState(false); + + const fetchAiList = useCallback(async (p: number, append = false) => { + setAiLoading(true); + try { + const res = await listAiAnalysis(p, 20); + const items = res.data || []; + setAiList(append ? (prev) => [...prev, ...items] : items); + setAiPage(p); + setAiHasMore(items.length >= 20); + setAiLoaded(true); + } catch (err) { + console.warn('[ai-report] 加载分析列表失败:', err); + Taro.showToast({ title: '加载失败', icon: 'none' }); + } finally { + setAiLoading(false); + } + }, []); + + const handleRefresh = useCallback(async () => { + if (tab === 'reports') { + await fetchReports(1); + } else { + await fetchAiList(1); + } + }, [tab, fetchReports, fetchAiList]); + + const handleTabSwitch = (key: TabKey) => { + if (key === tab) return; + setTab(key); + if (key === 'ai' && !aiLoaded) { + fetchAiList(1); + } + }; + + usePageData(handleRefresh, { throttleMs: 10000, enablePullDown: true }); + + const currentLoading = tab === 'reports' ? reportsLoading : aiLoading; useReachBottom(() => { - if (!loading && reports.length < total) { - fetchData(page + 1, true); + if (currentLoading) return; + if (tab === 'reports') { + if (reports.length < reportsTotal) fetchReports(reportsPage + 1, true); + } else { + if (aiHasMore) fetchAiList(aiPage + 1, true); } }); - const goToDetail = (id: string) => { - safeNavigateTo(`/pages/pkg-profile/reports/detail/index?id=${id}`); - }; + const hasPatient = !!getCachedPatientId(); - const formatStatus = (report: LabReport) => { + const formatReportStatus = (report: LabReport) => { const items = report.items; if (!items || !Array.isArray(items)) return 'unknown'; const vals = items as Array<{ is_abnormal?: boolean }>; - const hasAbnormal = vals.some((v) => v.is_abnormal); - return hasAbnormal ? 'abnormal' : 'normal'; - }; - - const typeInitial = (type: string) => { - return type ? type.charAt(0) : '报'; + return vals.some((v) => v.is_abnormal) ? 'abnormal' : 'normal'; }; return ( - 检查报告 + 我的报告 - - {reports.map((r) => { - const status = formatStatus(r); - return ( - goToDetail(r.id)} - > - - - - {typeInitial(r.report_type)} + handleTabSwitch(k as TabKey)} variant="pill" /> + + {tab === 'reports' && ( + + {reports.map((r) => { + const status = formatReportStatus(r); + return ( + safeNavigateTo(`/pages/pkg-profile/reports/detail/index?id=${r.id}`)}> + + + + {r.report_type ? r.report_type.charAt(0) : '报'} + + {r.report_type} - {r.report_type} + + {status === 'normal' ? '正常' : status === 'abnormal' ? '异常' : '未知'} + - - {status === 'normal' ? '正常' : status === 'abnormal' ? '异常' : '未知'} - + {r.report_date} - {r.report_date} - - ); - })} - + ); + })} + + )} - {reports.length === 0 && !loading && ( + {tab === 'ai' && ( + + {aiList.map((item) => { + const si = AI_STATUS_MAP[item.status] || { text: item.status, cls: '' }; + return ( + item.status === 'completed' && safeNavigateTo(`/pages/ai-report/detail/index?id=${item.id}`)} + > + + {AI_TYPE_LABELS[item.analysis_type] || item.analysis_type} + {si.text} + + + {new Date(item.created_at).toLocaleString('zh-CN')} + {item.model_used} + + + ); + })} + + )} + + {tab === 'reports' && reports.length === 0 && !reportsLoading && ( )} - - {loading && ( - + {tab === 'ai' && aiList.length === 0 && !aiLoading && ( + )} + + {currentLoading && } ); } diff --git a/apps/miniprogram/src/pages/pkg-profile/settings/index.scss b/apps/miniprogram/src/pages/pkg-profile/settings/index.scss index b443151..56aaf1a 100644 --- a/apps/miniprogram/src/pages/pkg-profile/settings/index.scss +++ b/apps/miniprogram/src/pages/pkg-profile/settings/index.scss @@ -65,3 +65,14 @@ font-family: 'Georgia', 'Times New Roman', serif; flex-shrink: 0; } + +.settings-toggle { + font-size: var(--tk-font-cap); + color: $tx3; + margin-right: var(--tk-gap-xs); + + &.settings-toggle--active { + color: var(--tk-pri); + font-weight: 600; + } +} diff --git a/apps/miniprogram/src/pages/pkg-profile/settings/index.tsx b/apps/miniprogram/src/pages/pkg-profile/settings/index.tsx index d08b63f..b75a85a 100644 --- a/apps/miniprogram/src/pages/pkg-profile/settings/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/settings/index.tsx @@ -2,6 +2,7 @@ import { View, Text } from '@tarojs/components'; import Taro from '@tarojs/taro'; import { safeNavigateTo } from '@/utils/navigate'; import { useAuthStore } from '../../../stores/auth'; +import { useUIStore } from '../../../stores/ui'; import { invalidateHeadersCache, clearRequestCache } from '@/services/request'; import { useElderClass } from '../../../hooks/useElderClass'; import PageShell from '@/components/ui/PageShell'; @@ -12,6 +13,8 @@ export default function Settings() { const modeClass = useElderClass(); const logout = useAuthStore((s) => s.logout); const isMedicalStaff = useAuthStore((s) => s.isMedicalStaff); + const mode = useUIStore((s) => s.mode); + const toggleMode = useUIStore((s) => s.toggle); const handleClearCache = async () => { const { confirm } = await Taro.showModal({ @@ -72,6 +75,16 @@ export default function Settings() { 设置 + + + + + 长辈模式 + + {mode === 'elder' ? '已开启' : '未开启'} + + {'>'} + diff --git a/apps/miniprogram/src/services/analytics.ts b/apps/miniprogram/src/services/analytics.ts index f35b1c8..58bcc8d 100644 --- a/apps/miniprogram/src/services/analytics.ts +++ b/apps/miniprogram/src/services/analytics.ts @@ -66,6 +66,7 @@ function persistQueue(): void { } export function trackEvent(event: EventName | string, properties?: Record): void { + if (flushDisabled) return; loadQueue(); const evt: AnalyticsEvent = { event, @@ -89,7 +90,10 @@ export function trackPageView(pageName: string, properties?: Record { + if (flushDisabled) return; loadQueue(); if (memoryQueue.length === 0) return; @@ -99,9 +103,16 @@ export async function flushEvents(): Promise { try { await api.post('/analytics/batch', { events: batch }); - } catch (e) { - // 静默失败,不打印错误避免控制台洪泛 - void e; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg === '权限不足' || msg === '登录已过期') { + // 权限不足或未认证,停止后续 flush 并丢弃队列 + flushDisabled = true; + memoryQueue = []; + persistQueue(); + return; + } + // 其他错误(网络等)保留队列重试 memoryQueue = [...batch.slice(-MAX_QUEUE_SIZE), ...memoryQueue].slice(-MAX_QUEUE_SIZE); persistQueue(); } @@ -111,3 +122,8 @@ export function getQueueSize(): number { loadQueue(); return memoryQueue.length; } + +/** 登录/切换用户时调用,重新启用 flush */ +export function resetAnalyticsDisabled(): void { + flushDisabled = false; +} diff --git a/apps/miniprogram/src/services/article.ts b/apps/miniprogram/src/services/article.ts index da9a41d..8ad5e21 100644 --- a/apps/miniprogram/src/services/article.ts +++ b/apps/miniprogram/src/services/article.ts @@ -1,3 +1,4 @@ +import Taro from '@tarojs/taro'; import { api } from './request'; export interface Article { @@ -41,6 +42,46 @@ export function buildCategoryTree(flat: ArticleCategory[]): ArticleCategory[] { return roots; } +/** 获取默认 tenant_id(用于公开 API 调用) */ +function getDefaultTenantId(): string { + return Taro.getStorageSync('tenant_id') || process.env.TARO_APP_DEFAULT_TENANT_ID || ''; +} + +// --------------------------------------------------------------------------- +// 公开端点(无需认证,游客可访问) +// --------------------------------------------------------------------------- + +export async function listPublicArticles(params?: { + page?: number; + page_size?: number; + category_id?: string; + tag_id?: string; + keyword?: string; +}) { + const tenantId = getDefaultTenantId(); + return api.get<{ data: Article[]; total: number }>('/public/articles', { + tenant_id: tenantId, + page: params?.page ?? 1, + page_size: params?.page_size ?? 20, + ...params, + }); +} + +export async function listPublicCategories() { + const tenantId = getDefaultTenantId(); + return api.get('/public/article-categories', { + tenant_id: tenantId, + }); +} + +export async function getPublicArticleDetail(id: string) { + return api.get
(`/public/articles/${id}`); +} + +// --------------------------------------------------------------------------- +// 认证端点(需要登录) +// --------------------------------------------------------------------------- + export async function listArticles(params?: { page?: number; page_size?: number; @@ -60,11 +101,6 @@ export async function getArticleDetail(id: string) { return api.get
(`/health/articles/${id}`); } -/** 公开文章详情(无需认证) */ -export async function getPublicArticleDetail(id: string) { - return api.get
(`/public/articles/${id}`); -} - export async function listCategories() { return api.get('/health/article-categories'); } diff --git a/apps/miniprogram/src/services/ble/BLEManager.ts b/apps/miniprogram/src/services/ble/BLEManager.ts index be54e23..9996155 100644 --- a/apps/miniprogram/src/services/ble/BLEManager.ts +++ b/apps/miniprogram/src/services/ble/BLEManager.ts @@ -70,10 +70,13 @@ export class BLEManager { /** 初始化蓝牙适配器 */ async initialize(): Promise { + console.log('[ble] 步骤1: 开始初始化蓝牙适配器...'); try { await Taro.openBluetoothAdapter(); + console.log('[ble] 步骤1: 蓝牙适配器初始化成功'); } catch (e: unknown) { const errMsg = e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg || '蓝牙初始化失败,请检查蓝牙是否开启'; + console.error('[ble] 步骤1: 蓝牙初始化失败:', errMsg); throw new Error(errMsg); } } @@ -83,38 +86,61 @@ export class BLEManager { await this.initialize(); const discovered = new Map(); + const allModelKeywords = this.adapters.flatMap((a) => a.supportedModels); + + console.log('[ble] 步骤2: 注册的适配器:', this.adapters.map((a) => a.name)); + console.log('[ble] 步骤2: 匹配关键词:', allModelKeywords); + + let scanDeviceCount = 0; const onFound = (res: BLEScanResult) => { - for (const device of res.devices || []) { + const devices = res.devices || []; + scanDeviceCount += devices.length; + + for (const device of devices) { const name = device.name || device.localName || ''; if (!name) continue; const adapter = this.matchAdapter(name); - if (adapter) { - discovered.set(device.deviceId, { - deviceId: device.deviceId, - name, - RSSI: device.RSSI ?? 0, - localName: device.localName, - advertisData: device.advertisData, - adapter, - }); + + // 每个新发现的设备都打印(最多前 30 个避免日志爆炸) + if (discovered.size < 30 && !discovered.has(device.deviceId)) { + console.log(`[ble] 发现设备: "${name}" (RSSI:${device.RSSI ?? '?'}, 匹配:${adapter?.name ?? '无'})`); } + + discovered.set(device.deviceId, { + deviceId: device.deviceId, + name, + RSSI: device.RSSI ?? 0, + localName: device.localName, + advertisData: device.advertisData, + adapter: adapter ?? undefined, + }); } }; Taro.onBluetoothDeviceFound(onFound); - const allServiceUUIDs = this.adapters.flatMap((a) => a.serviceUUIDs); + console.log('[ble] 步骤3: 开始扫描 (超时', this.config.scanTimeout, 'ms)...'); + // 不传 services 参数 — 扫描所有 BLE 设备,避免设备使用私有 UUID 被过滤掉 await Taro.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, - services: allServiceUUIDs.length > 0 ? allServiceUUIDs : undefined, }); return new Promise((resolve) => { this.scanTimer = setTimeout(async () => { await this.stopScan(); Taro.offBluetoothDeviceFound(onFound); - resolve(Array.from(discovered.values())); + + const results = Array.from(discovered.values()); + console.log('[ble] 步骤4: 扫描结束'); + console.log('[ble] 回调触发设备总数:', scanDeviceCount); + console.log('[ble] 有名称的设备数:', discovered.size); + console.log('[ble] 最终返回设备数:', results.length); + if (results.length > 0) { + console.log('[ble] 设备列表:', results.map((d) => `${d.name} (${d.adapter?.name ?? '无适配器'})`)); + } + + resolve(results); }, this.config.scanTimeout); }); } diff --git a/apps/miniprogram/src/services/ble/adapters/GenericBleAdapter.ts b/apps/miniprogram/src/services/ble/adapters/GenericBleAdapter.ts index 87d79df..48650bd 100644 --- a/apps/miniprogram/src/services/ble/adapters/GenericBleAdapter.ts +++ b/apps/miniprogram/src/services/ble/adapters/GenericBleAdapter.ts @@ -142,4 +142,30 @@ export const CustomBandAdapter = createGenericBleAdapter({ profiles: ['heart_rate', 'health_thermometer'], }); +/** 华为手环/手表 BLE 适配器 */ +export const HuaweiBandAdapter = createGenericBleAdapter({ + name: 'Huawei Band', + supportedModels: [ + 'HUAWEI Band', + 'HUAWEI Watch', + 'Huawei Band', + 'Huawei Watch', + 'HW-B', + 'HUAW', + '华为手环', + '华为手表', + ], + profiles: ['heart_rate', 'health_thermometer'], +}); + +/** + * 万能 fallback 适配器 — 匹配所有有名称的设备 + * 尝试标准 BLE 健康协议(心率/体温/血压),设备不支持的服务会被安全跳过 + */ +export const FallbackAdapter = createGenericBleAdapter({ + name: '通用设备', + supportedModels: [], // 不参与 matchAdapter,仅作为 fallback + profiles: ['heart_rate', 'health_thermometer', 'blood_pressure'], +}); + export default CustomBandAdapter; diff --git a/apps/miniprogram/src/services/ble/adapters/index.ts b/apps/miniprogram/src/services/ble/adapters/index.ts index db7a504..6ed7302 100644 --- a/apps/miniprogram/src/services/ble/adapters/index.ts +++ b/apps/miniprogram/src/services/ble/adapters/index.ts @@ -1,4 +1,4 @@ export { XiaomiBandAdapter } from './XiaomiBandAdapter'; export { BloodPressureAdapter } from './BloodPressureAdapter'; export { GlucoseMeterAdapter } from './GlucoseMeterAdapter'; -export { CustomBandAdapter, createGenericBleAdapter } from './GenericBleAdapter'; +export { CustomBandAdapter, HuaweiBandAdapter, FallbackAdapter, createGenericBleAdapter } from './GenericBleAdapter'; diff --git a/apps/miniprogram/src/services/request.ts b/apps/miniprogram/src/services/request.ts index dc585db..5deb21c 100644 --- a/apps/miniprogram/src/services/request.ts +++ b/apps/miniprogram/src/services/request.ts @@ -22,6 +22,38 @@ const ERROR_CODE_MAP: Record = { CONCURRENCY_CONFLICT: '数据已被其他人修改,请刷新后重试', }; +// --- 网络异常状态感知 --- +// 检测到网络故障后,短时间内抑制后续请求,避免并发请求全部超时产生大量 toast +// 连续失败时指数退避(3s → 6s → 12s → 30s),避免后端不可达时请求洪泛 +const OFFLINE_SUPPRESS_MS = 3000; +const OFFLINE_MAX_MS = 30_000; +let offlineDetectedAt = 0; +let offlineSuppressMs = OFFLINE_SUPPRESS_MS; +let networkToastShown = false; +let consecutiveNetErrors = 0; + +function isOffline(): boolean { + return offlineDetectedAt > 0 && Date.now() - offlineDetectedAt < offlineSuppressMs; +} + +function markOffline(): void { + offlineDetectedAt = Date.now(); + consecutiveNetErrors++; + // 指数退避:连续失败越多,抑制时间越长(3s → 6s → 12s → 30s cap) + offlineSuppressMs = Math.min(OFFLINE_MAX_MS, OFFLINE_SUPPRESS_MS * Math.pow(2, consecutiveNetErrors - 1)); + if (!networkToastShown) { + networkToastShown = true; + Taro.showToast({ title: '网络异常,请检查连接', icon: 'none', duration: 2000 }); + setTimeout(() => { networkToastShown = false; }, offlineSuppressMs); + } +} + +function clearOffline(): void { + offlineDetectedAt = 0; + offlineSuppressMs = OFFLINE_SUPPRESS_MS; + consecutiveNetErrors = 0; +} + function safeGet(key: string): string { return secureGet(key); } @@ -139,6 +171,12 @@ async function request(method: string, path: string, data?: unknown, timeout? let retryCount401 = 0; for (;;) { if (signal?.aborted) throw new Error('请求已取消'); + + // 离线抑制:刚检测到网络故障时,直接跳过请求,避免 9+ 并发请求全部超时 + if (isOffline()) { + throw new Error('网络异常'); + } + if (!bypassLimiter) await limiter.acquire(); try { const headers = await getHeaders(); @@ -153,10 +191,13 @@ async function request(method: string, path: string, data?: unknown, timeout? Taro.showToast({ title: '网络超时,请重试', icon: 'none' }); throw new Error('网络超时'); } - Taro.showToast({ title: '网络异常,请检查连接', icon: 'none' }); + // 网络异常:标记离线 + toast 去重(3 秒内只弹一次) + markOffline(); throw new Error('网络异常'); } + // 请求成功,清除离线标记 + clearOffline(); if (signal?.aborted) throw new Error('请求已取消'); if (res.statusCode === 401) { @@ -181,7 +222,6 @@ async function request(method: string, path: string, data?: unknown, timeout? } if (res.statusCode === 403) { - Taro.showToast({ title: '权限不足', icon: 'none' }); throw new Error('权限不足'); } @@ -275,4 +315,6 @@ export function resetForTesting(): void { headersCacheTs = 0; refreshPromise = null; isLoggingOut = false; + offlineDetectedAt = 0; + networkToastShown = false; } diff --git a/apps/miniprogram/src/stores/auth.ts b/apps/miniprogram/src/stores/auth.ts index 5753c10..f141d86 100644 --- a/apps/miniprogram/src/stores/auth.ts +++ b/apps/miniprogram/src/stores/auth.ts @@ -2,13 +2,14 @@ import { create } from 'zustand'; import Taro from '@tarojs/taro'; import * as authApi from '@/services/auth'; import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage'; -import { clearRequestCache, markLoggingOut, clearLoggingOut, setCachedPatientId } from '@/services/request'; +import { clearRequestCache, invalidateHeadersCache, markLoggingOut, clearLoggingOut, setCachedPatientId } from '@/services/request'; // secureGet 已内置明文键 fallback,无需再手动 fallback function storageGet(key: string): string { return secureGet(key); } import { resetAllStores } from './index'; +import { resetAnalyticsDisabled } from '@/services/analytics'; // --- 内存缓存,避免每次 Tab 切换重复 Storage IPC + JSON.parse --- let cachedUserJson = ''; @@ -142,6 +143,8 @@ export const useAuthStore = create((set, get) => ({ secureSet('tenant_id', user.tenant_id || ''); set({ user, roles, loading: false }); clearLoggingOut(); + invalidateHeadersCache(); + resetAnalyticsDisabled(); get().loadPatients(); return true; } @@ -175,7 +178,8 @@ export const useAuthStore = create((set, get) => ({ secureSet('tenant_id', resp.user?.tenant_id || tenantId); set({ user: resp.user, roles, loading: false }); clearLoggingOut(); - // 登录成功后自动加载患者档案(如果有的话) + invalidateHeadersCache(); + resetAnalyticsDisabled(); get().loadPatients(); return true; } catch (err) { @@ -211,6 +215,8 @@ export const useAuthStore = create((set, get) => ({ secureRemove('wechat_openid'); set({ user: tokenData.user, roles, loading: false }); clearLoggingOut(); + invalidateHeadersCache(); + resetAnalyticsDisabled(); get().loadPatients(); return true; } catch (err: unknown) { diff --git a/apps/miniprogram/src/utils/abort-controller-polyfill.ts b/apps/miniprogram/src/utils/abort-controller-polyfill.ts new file mode 100644 index 0000000..016357e --- /dev/null +++ b/apps/miniprogram/src/utils/abort-controller-polyfill.ts @@ -0,0 +1,57 @@ +/** + * AbortController / AbortSignal polyfill — 微信小程序 JS 运行时 + * + * 微信小程序 JSCore/V8 不提供 AbortController / AbortSignal Web API。 + * usePageData hook 在每个页面挂载时 new AbortController(), + * 缺少 polyfill 会导致 ReferenceError 崩溃,影响全部 ~40 个数据页面。 + * + * 在 app.tsx 首行导入(crypto-polyfill 之后),确保在任何页面代码之前执行。 + * + * 实现了 usePageData / request.ts 所需的完整规范子集: + * - signal.aborted (getter) + * - controller.abort() + * - signal.addEventListener('abort', cb) / removeEventListener + */ + +if (typeof globalThis.AbortController === 'undefined') { + class _AbortSignal { + aborted = false; + private _listeners: Array<() => void> = []; + + addEventListener(type: string, cb: () => void): void { + if (type === 'abort') this._listeners.push(cb); + } + + removeEventListener(_type: string, cb: () => void): void { + this._listeners = this._listeners.filter((fn) => fn !== cb); + } + + /** @internal 触发 abort 事件 */ + _doAbort(): void { + if (this.aborted) return; + this.aborted = true; + const listeners = this._listeners.slice(); + this._listeners = []; + for (const fn of listeners) { + try { + fn(); + } catch { + /* best-effort dispatch */ + } + } + } + } + + class _AbortController { + readonly signal = new _AbortSignal(); + + abort(): void { + this.signal._doAbort(); + } + } + + // @ts-expect-error — polyfill: globalThis 上原本没有 AbortController + globalThis.AbortController = _AbortController; + // @ts-expect-error — polyfill: globalThis 上原本没有 AbortSignal + globalThis.AbortSignal = _AbortSignal; +} diff --git a/apps/web/src/api/health/articles.ts b/apps/web/src/api/health/articles.ts index 06b8e95..61a678a 100644 --- a/apps/web/src/api/health/articles.ts +++ b/apps/web/src/api/health/articles.ts @@ -23,6 +23,7 @@ export interface ArticleListItem { review_note?: string; view_count: number; sort_order: number; + is_public: boolean; published_at?: string; created_at: string; updated_at: string; @@ -43,6 +44,7 @@ export interface CreateArticleReq { category_id?: string; tag_ids?: string[]; sort_order?: number; + is_public?: boolean; } export interface UpdateArticleReq { @@ -55,6 +57,7 @@ export interface UpdateArticleReq { category_id?: string; tag_ids?: string[]; sort_order?: number; + is_public?: boolean; version: number; } diff --git a/apps/web/src/pages/health/articleEditor/ArticleEditor.tsx b/apps/web/src/pages/health/articleEditor/ArticleEditor.tsx index 19b553b..fe6001f 100644 --- a/apps/web/src/pages/health/articleEditor/ArticleEditor.tsx +++ b/apps/web/src/pages/health/articleEditor/ArticleEditor.tsx @@ -45,6 +45,7 @@ export default function ArticleEditor() { const [categoryId, setCategoryId] = useState(undefined); const [selectedTagIds, setSelectedTagIds] = useState([]); const [sortOrder, setSortOrder] = useState(0); + const [isPublic, setIsPublic] = useState(true); const [version, setVersion] = useState(0); // 选项数据 @@ -101,6 +102,7 @@ export default function ArticleEditor() { setCategoryId(article.category_id); setSelectedTagIds(article.tags?.map((t) => t.id) || []); setSortOrder(article.sort_order); + setIsPublic(article.is_public ?? true); setVersion(article.version); } catch { message.error('加载文章失败'); @@ -230,6 +232,7 @@ export default function ArticleEditor() { category_id: categoryId, tag_ids: selectedTagIds, sort_order: sortOrder, + is_public: isPublic, version, }); message.success('文章已保存'); @@ -245,6 +248,7 @@ export default function ArticleEditor() { category_id: categoryId, tag_ids: selectedTagIds, sort_order: sortOrder, + is_public: isPublic, }); message.success('文章已创建'); navigate('/health/articles'); @@ -256,7 +260,7 @@ export default function ArticleEditor() { } }, [ id, isEdit, title, summary, content, coverImage, slug, categoryId, - selectedTagIds, sortOrder, version, navigate, + selectedTagIds, sortOrder, isPublic, version, navigate, ]); const handleSubmit = useCallback(async () => { @@ -277,6 +281,7 @@ export default function ArticleEditor() { category_id: categoryId, tag_ids: selectedTagIds, sort_order: sortOrder, + is_public: isPublic, version, }); const updated = await articleApi.get(id); @@ -292,6 +297,7 @@ export default function ArticleEditor() { category_id: categoryId, tag_ids: selectedTagIds, sort_order: sortOrder, + is_public: isPublic, }); currentVersion = created.version; setVersion(created.version); @@ -312,7 +318,7 @@ export default function ArticleEditor() { } }, [ id, isEdit, title, summary, content, coverImage, slug, categoryId, - selectedTagIds, sortOrder, version, navigate, + selectedTagIds, sortOrder, isPublic, version, navigate, ]); if (loading) { @@ -463,6 +469,8 @@ export default function ArticleEditor() { onSlugChange={setSlug} sortOrder={sortOrder} onSortOrderChange={setSortOrder} + isPublic={isPublic} + onIsPublicChange={setIsPublic} categories={categories} tags={tags} /> diff --git a/apps/web/src/pages/health/articleEditor/ArticleSettingsDrawer.tsx b/apps/web/src/pages/health/articleEditor/ArticleSettingsDrawer.tsx index 994706e..76ca9c7 100644 --- a/apps/web/src/pages/health/articleEditor/ArticleSettingsDrawer.tsx +++ b/apps/web/src/pages/health/articleEditor/ArticleSettingsDrawer.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Drawer, Input, Select, Space, Upload, Button, message } from 'antd'; +import { Drawer, Input, Select, Space, Upload, Button, Switch, message } from 'antd'; import { UploadOutlined, PictureOutlined } from '@ant-design/icons'; import type { ArticleTagItem } from '../../../api/health/articles'; import { uploadFile } from '../../../api/upload'; @@ -21,6 +21,8 @@ interface ArticleSettingsDrawerProps { onSlugChange: (v: string) => void; sortOrder: number; onSortOrderChange: (v: number) => void; + isPublic: boolean; + onIsPublicChange: (v: boolean) => void; categories: { id: string; name: string }[]; tags: ArticleTagItem[]; } @@ -53,6 +55,8 @@ export default function ArticleSettingsDrawer({ onSlugChange, sortOrder, onSortOrderChange, + isPublic, + onIsPublicChange, categories, tags, }: ArticleSettingsDrawerProps) { @@ -190,6 +194,17 @@ export default function ArticleSettingsDrawer({ placeholder="0" /> + + {/* 公开可见 */} +
+
+ +
+ 开启后游客可在小程序查看此文章 +
+
+ +
diff --git a/crates/erp-health/src/dto/article_dto.rs b/crates/erp-health/src/dto/article_dto.rs index 69962fe..2c2eb07 100644 --- a/crates/erp-health/src/dto/article_dto.rs +++ b/crates/erp-health/src/dto/article_dto.rs @@ -7,6 +7,10 @@ use erp_core::sanitize::{ sanitize_option, sanitize_rich_html_option, sanitize_string, strip_html_tags, }; +const fn default_true() -> bool { + true +} + // --------------------------------------------------------------------------- // 文章 DTOs // --------------------------------------------------------------------------- @@ -29,6 +33,8 @@ pub struct ArticleResp { pub review_note: Option, pub view_count: i32, pub sort_order: i32, + /// 是否公开(游客可访问) + pub is_public: bool, /// 文章关联的分类 ID(来自 article_category 表) pub category_id: Option, /// 文章关联的标签名称列表 @@ -49,6 +55,8 @@ pub struct ArticleListItem { pub published_at: Option>, pub status: String, pub view_count: i32, + /// 是否公开(游客可访问) + pub is_public: bool, /// 分类 ID pub category_id: Option, /// 标签名称列表 @@ -96,6 +104,9 @@ pub struct CreateArticleReq { /// 标签 ID 列表 #[serde(default)] pub tag_ids: Vec, + /// 是否公开(游客可访问),默认 true + #[serde(default = "default_true")] + pub is_public: bool, } impl CreateArticleReq { @@ -134,6 +145,8 @@ pub struct UpdateArticleReq { /// 标签 ID 列表(传入则整体替换) pub tag_ids: Option>, pub sort_order: Option, + /// 是否公开(游客可访问) + pub is_public: Option, pub version: i32, } diff --git a/crates/erp-health/src/entity/article.rs b/crates/erp-health/src/entity/article.rs index d0081ed..a20134f 100644 --- a/crates/erp-health/src/entity/article.rs +++ b/crates/erp-health/src/entity/article.rs @@ -41,6 +41,8 @@ pub struct Model { pub view_count: i32, /// 排序权重 pub sort_order: i32, + /// 是否公开(游客可访问) + pub is_public: bool, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[sea_orm(skip_serializing_if = "Option::is_none")] diff --git a/crates/erp-health/src/handler/article_category_handler.rs b/crates/erp-health/src/handler/article_category_handler.rs index d7333c1..b42fadb 100644 --- a/crates/erp-health/src/handler/article_category_handler.rs +++ b/crates/erp-health/src/handler/article_category_handler.rs @@ -1,7 +1,7 @@ //! 文章分类 Handler use axum::Extension; -use axum::extract::{FromRef, Json, Path, State}; +use axum::extract::{FromRef, Json, Path, Query, State}; use erp_core::error::AppError; use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, TenantContext}; @@ -12,6 +12,32 @@ use crate::state::HealthState; use validator::Validate; +// --------------------------------------------------------------------------- +// 公开端点(小程序游客 / 无需认证) +// --------------------------------------------------------------------------- + +#[derive(Debug, serde::Deserialize)] +pub struct PublicCategoryQuery { + pub tenant_id: uuid::Uuid, +} + +/// GET /public/article-categories — 公开分类列表(无需认证) +pub async fn list_public_categories( + State(state): State, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let result = article_category_service::list_categories(&state, params.tenant_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +// --------------------------------------------------------------------------- +// 管理端端点(需要认证) +// --------------------------------------------------------------------------- + pub async fn list_categories( State(state): State, Extension(ctx): Extension, diff --git a/crates/erp-health/src/handler/article_handler.rs b/crates/erp-health/src/handler/article_handler.rs index 2f9782d..0ba76a7 100644 --- a/crates/erp-health/src/handler/article_handler.rs +++ b/crates/erp-health/src/handler/article_handler.rs @@ -45,6 +45,7 @@ where params.category_id, params.tag_id, params.keyword, + None, // 管理端不过滤 is_public ) .await?; Ok(Json(ApiResponse::ok(result))) @@ -69,6 +70,7 @@ pub async fn list_public_articles( params.category_id, params.tag_id, params.keyword, + Some(true), // 公开端点只返回 is_public=true 的文章 ) .await?; Ok(Json(ApiResponse::ok(result))) diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index a3757ff..f822a27 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -5,7 +5,9 @@ use erp_core::error::AppResult; use erp_core::events::EventBus; use erp_core::module::{ErpModule, PermissionDescriptor}; -use crate::handler::{article_handler, banner_handler, ble_gateway_handler}; +use crate::handler::{ + article_category_handler, article_handler, banner_handler, ble_gateway_handler, +}; pub struct HealthModule; @@ -203,6 +205,10 @@ impl HealthModule { "/public/articles/{id}", axum::routing::get(article_handler::get_public_article), ) + .route( + "/public/article-categories", + axum::routing::get(article_category_handler::list_public_categories), + ) } /// FHIR R4 只读路由(使用 OAuth client_credentials 认证) diff --git a/crates/erp-health/src/service/article_service.rs b/crates/erp-health/src/service/article_service.rs index 363f1d4..95f8a67 100644 --- a/crates/erp-health/src/service/article_service.rs +++ b/crates/erp-health/src/service/article_service.rs @@ -21,7 +21,7 @@ use crate::error::{HealthError, HealthResult}; use crate::service::validation; use crate::state::HealthState; -/// 文章列表(管理端,支持状态/分类/标签/关键词筛选) +/// 文章列表(管理端,支持状态/分类/标签/关键词/公开状态筛选) #[allow(clippy::too_many_arguments)] pub async fn list_articles( state: &HealthState, @@ -33,6 +33,7 @@ pub async fn list_articles( category_id: Option, tag_id: Option, keyword: Option, + is_public: Option, ) -> HealthResult> { let limit = page_size.min(100); let offset = page.saturating_sub(1) * limit; @@ -47,6 +48,9 @@ pub async fn list_articles( if let Some(ref s) = status { query = query.filter(article::Column::Status.eq(s)); } + if let Some(pub_flag) = is_public { + query = query.filter(article::Column::IsPublic.eq(pub_flag)); + } if let Some(cid) = category_id { query = query.filter(article::Column::CategoryId.eq(cid)); } @@ -104,6 +108,7 @@ pub async fn list_articles( published_at: m.published_at, status: m.status, view_count: m.view_count, + is_public: m.is_public, category_id: m.category_id, tags, version: m.version, @@ -374,6 +379,7 @@ pub async fn create_article( review_note: Set(None), view_count: Set(0), sort_order: Set(0), + is_public: Set(req.is_public), created_at: Set(now), updated_at: Set(now), created_by: Set(operator_id), @@ -445,6 +451,9 @@ pub async fn update_article( if let Some(v) = req.sort_order { active.sort_order = Set(v); } + if let Some(v) = req.is_public { + active.is_public = Set(v); + } active.updated_at = Set(Utc::now()); active.updated_by = Set(operator_id); active.version = Set(next_ver); @@ -530,6 +539,7 @@ fn full_model_to_resp(m: article::Model, tags: Vec) -> ArticleResp { review_note: m.review_note, view_count: m.view_count, sort_order: m.sort_order, + is_public: m.is_public, category_id: m.category_id, tags, created_at: m.created_at, diff --git a/crates/erp-server/_server_err3.txt b/crates/erp-server/_server_err3.txt new file mode 100644 index 0000000..9c3a541 --- /dev/null +++ b/crates/erp-server/_server_err3.txt @@ -0,0 +1,3 @@ + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.81s + Running `G:\hms\target\debug\erp-server.exe` +error: process didn't exit successfully: `G:\hms\target\debug\erp-server.exe` (exit code: 1) diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 446dfc3..e44caaf 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -168,6 +168,7 @@ mod m20260521_000163_reorganize_menus_by_business_flow; mod m20260521_000164_reorganize_menus_scheme_b; mod m20260522_000160_article_add_is_public; mod m20260522_000161_patient_points_manage_perm; +mod m20260522_000162_seed_patient_miniprogram_permissions; pub struct Migrator; @@ -343,6 +344,7 @@ impl MigratorTrait for Migrator { Box::new(m20260521_000164_reorganize_menus_scheme_b::Migration), Box::new(m20260522_000160_article_add_is_public::Migration), Box::new(m20260522_000161_patient_points_manage_perm::Migration), + Box::new(m20260522_000162_seed_patient_miniprogram_permissions::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260521_000158_alerts_add_source_columns.rs b/crates/erp-server/migration/src/m20260521_000158_alerts_add_source_columns.rs new file mode 100644 index 0000000..abf2eb1 --- /dev/null +++ b/crates/erp-server/migration/src/m20260521_000158_alerts_add_source_columns.rs @@ -0,0 +1,37 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // alerts 表新增 source(告警来源)和 original_id(关联原始告警)字段 + manager + .alter_table( + Table::alter() + .table(Alias::new("alerts")) + .add_column( + ColumnDef::new(Alias::new("source")) + .string() + .not_null() + .default("rule_engine"), + ) + .add_column(ColumnDef::new(Alias::new("original_id")).uuid().null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("alerts")) + .drop_column(Alias::new("source")) + .drop_column(Alias::new("original_id")) + .to_owned(), + ) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260521_000159_patient_phone_and_consent_seed.rs b/crates/erp-server/migration/src/m20260521_000159_patient_phone_and_consent_seed.rs new file mode 100644 index 0000000..1515252 --- /dev/null +++ b/crates/erp-server/migration/src/m20260521_000159_patient_phone_and_consent_seed.rs @@ -0,0 +1,108 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 1. patient 表新增 phone 和 phone_hash 字段 + manager + .alter_table( + Table::alter() + .table(Alias::new("patient")) + .add_column(ColumnDef::new(Alias::new("phone")).text().null()) + .add_column(ColumnDef::new(Alias::new("phone_hash")).text().null()) + .to_owned(), + ) + .await?; + + // 2. 为所有现有活跃患者自动授予 data_processing 同意(默认拒绝策略下保持向后兼容) + let seed_consent_sql_1 = r#" + INSERT INTO consent (id, tenant_id, patient_id, consent_type, consent_scope, status, granted_at, consent_method, created_at, updated_at, version) + SELECT + gen_random_uuid(), + p.tenant_id, + p.id, + 'data_processing', + 'all', + 'granted', + NOW(), + 'system_auto', + NOW(), + NOW(), + 1 + FROM patient p + WHERE p.status = 'active' + AND p.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM consent c + WHERE c.patient_id = p.id + AND c.tenant_id = p.tenant_id + AND c.consent_type = 'data_processing' + AND c.deleted_at IS NULL + ) + "#; + manager + .get_connection() + .execute_unprepared(seed_consent_sql_1) + .await?; + + // 3. 为所有现有活跃患者自动授予 health_data_collection 同意 + let seed_consent_sql_2 = r#" + INSERT INTO consent (id, tenant_id, patient_id, consent_type, consent_scope, status, granted_at, consent_method, created_at, updated_at, version) + SELECT + gen_random_uuid(), + p.tenant_id, + p.id, + 'health_data_collection', + 'all', + 'granted', + NOW(), + 'system_auto', + NOW(), + NOW(), + 1 + FROM patient p + WHERE p.status = 'active' + AND p.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM consent c + WHERE c.patient_id = p.id + AND c.tenant_id = p.tenant_id + AND c.consent_type = 'health_data_collection' + AND c.deleted_at IS NULL + ) + "#; + manager + .get_connection() + .execute_unprepared(seed_consent_sql_2) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 删除系统自动生成的 consent 记录 + let delete_sql = r#" + DELETE FROM consent WHERE consent_method = 'system_auto' + "#; + manager + .get_connection() + .execute_unprepared(delete_sql) + .await?; + + // 移除 phone 和 phone_hash 列 + manager + .alter_table( + Table::alter() + .table(Alias::new("patient")) + .drop_column(Alias::new("phone")) + .drop_column(Alias::new("phone_hash")) + .to_owned(), + ) + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260522_000160_article_add_is_public.rs b/crates/erp-server/migration/src/m20260522_000160_article_add_is_public.rs new file mode 100644 index 0000000..18d899e --- /dev/null +++ b/crates/erp-server/migration/src/m20260522_000160_article_add_is_public.rs @@ -0,0 +1,34 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("article")) + .add_column( + ColumnDef::new(Alias::new("is_public")) + .boolean() + .not_null() + .default(true), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("article")) + .drop_column(Alias::new("is_public")) + .to_owned(), + ) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260522_000162_seed_patient_miniprogram_permissions.rs b/crates/erp-server/migration/src/m20260522_000162_seed_patient_miniprogram_permissions.rs new file mode 100644 index 0000000..8aeb16b --- /dev/null +++ b/crates/erp-server/migration/src/m20260522_000162_seed_patient_miniprogram_permissions.rs @@ -0,0 +1,170 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 1) 注册 system.analytics.submit 幽灵权限(代码中 require_permission 使用但未注册) + let sys = "00000000-0000-0000-0000-000000000000"; + db.execute_unprepared(&format!( + "INSERT INTO permissions (id, tenant_id, name, code, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT gen_random_uuid(), t.id, '提交埋点数据', 'system.analytics.submit', 'system', 'submit', '小程序端埋点数据批量提交', NOW(), NOW(), '{sys}', '{sys}', NULL, 1 \ + FROM tenant t \ + WHERE NOT EXISTS (SELECT 1 FROM permissions p WHERE p.tenant_id = t.id AND p.code = 'system.analytics.submit' AND p.deleted_at IS NULL)" + )).await?; + + // 2) 患者角色缺失的 .manage 权限(小程序端写入操作) + let patient_manage_perms: &[&str] = &[ + // 体征录入 + "health.health-data.manage", + // 日常监测创建 + "health.daily-monitoring.manage", + // 预约创建/取消 + "health.appointment.manage", + // 医生列表(预约选医生) + "health.doctor.list", + // 随访提交 + "health.follow-up.manage", + // 咨询创建/发送消息 + "health.consultation.manage", + // 药物提醒 CRUD + "health.medication-reminders.manage", + // 知情同意授权/撤回 + "health.consent.manage", + // 设备数据上传 + "health.device-readings.manage", + // 患者自更新(绑定手机、自助建档) + "health.patient.manage", + // AI 分析报告查看 + "ai.analysis.list", + // AI 聊天会话列表 + "ai.chat.session.list", + // AI 聊天会话管理 + "ai.chat.session.manage", + // 埋点提交 + "system.analytics.submit", + ]; + + // 为所有租户的 patient 角色批量分配(幂等,data_scope=self) + assign_perms_by_codes(db, "patient", patient_manage_perms).await?; + + // 3) 患者角色缺失的 .list 权限 + let patient_list_perms: &[&str] = &[ + // 化验报告 + 健康记录 + 诊断记录 + 体征列表(共享 health.health-data.list) + "health.health-data.list", + // 行动收件箱(首页工作台) + "health.action-inbox.list", + ]; + + assign_perms_by_codes(db, "patient", patient_list_perms).await?; + + // 4) 为 admin/doctor/nurse/health_manager 角色 also 分配 system.analytics.submit + // 这些角色可能也需要埋点权限 + let analytics_roles: &[&str] = &["admin", "doctor", "nurse", "health_manager"]; + for role in analytics_roles { + assign_single_perm(db, role, "system.analytics.submit").await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 移除 patient 角色新增的权限关联 + let remove_codes: &[&str] = &[ + "health.health-data.manage", + "health.health-data.list", + "health.daily-monitoring.manage", + "health.appointment.manage", + "health.doctor.list", + "health.follow-up.manage", + "health.consultation.manage", + "health.medication-reminders.manage", + "health.consent.manage", + "health.device-readings.manage", + "health.patient.manage", + "ai.analysis.list", + "ai.chat.session.list", + "ai.chat.session.manage", + "system.analytics.submit", + "health.action-inbox.list", + ]; + + let codes_csv: String = remove_codes + .iter() + .map(|c| format!("'{}'", c)) + .collect::>() + .join(","); + + db.execute_unprepared(&format!( + "DELETE FROM role_permissions \ + WHERE role_id IN (SELECT id FROM roles WHERE code = 'patient') \ + AND permission_id IN (SELECT id FROM permissions WHERE code IN ({codes_csv}))" + )) + .await?; + + // 移除其他角色的 system.analytics.submit + let analytics_roles: &[&str] = &["admin", "doctor", "nurse", "health_manager"]; + for role in analytics_roles { + db.execute_unprepared(&format!( + "DELETE FROM role_permissions \ + WHERE role_id IN (SELECT id FROM roles WHERE code = '{role}') \ + AND permission_id IN (SELECT id FROM permissions WHERE code = 'system.analytics.submit')" + )).await?; + } + + // 软删除 system.analytics.submit 权限 + db.execute_unprepared( + "UPDATE permissions SET deleted_at = NOW() WHERE code = 'system.analytics.submit' AND deleted_at IS NULL" + ).await?; + + Ok(()) + } +} + +async fn assign_perms_by_codes( + db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>, + role_code: &str, + perm_codes: &[&str], +) -> Result<(), DbErr> { + let codes_csv: String = perm_codes + .iter() + .map(|c| format!("'{}'", c)) + .collect::>() + .join(","); + + db.execute_unprepared(&format!( + "INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT r.id, p.id, r.tenant_id, 'self', NOW(), NOW(), r.id, r.id, NULL, 1 \ + FROM roles r \ + JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ({codes_csv}) AND p.deleted_at IS NULL \ + WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \ + ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \ + DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()" + )).await?; + + Ok(()) +} + +async fn assign_single_perm( + db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>, + role_code: &str, + perm_code: &str, +) -> Result<(), DbErr> { + db.execute_unprepared(&format!( + "INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \ + FROM roles r \ + JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code = '{perm_code}' AND p.deleted_at IS NULL \ + WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \ + ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \ + DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()" + )).await?; + + Ok(()) +} diff --git a/crates/erp-server/tests/integration/health_article_tests.rs b/crates/erp-server/tests/integration/health_article_tests.rs index 70dd038..ece80c0 100644 --- a/crates/erp-server/tests/integration/health_article_tests.rs +++ b/crates/erp-server/tests/integration/health_article_tests.rs @@ -24,6 +24,7 @@ fn default_create_article_req() -> CreateArticleReq { content_type: None, category_id: None, tag_ids: vec![], + is_public: true, } } @@ -206,6 +207,7 @@ async fn test_article_update() { category_id: None, tag_ids: None, sort_order: None, + is_public: None, version: article.version, }, ) @@ -248,6 +250,7 @@ async fn test_article_list_filter() { None, None, None, + None, ) .await .unwrap(); @@ -263,6 +266,7 @@ async fn test_article_list_filter() { None, None, None, + None, ) .await .unwrap(); @@ -417,6 +421,7 @@ async fn test_tag_crud_and_article_association() { content_type: None, category_id: None, sort_order: None, + is_public: None, }, ) .await @@ -489,6 +494,7 @@ async fn test_article_version_conflict() { category_id: None, tag_ids: None, sort_order: None, + is_public: None, }, ) .await @@ -514,6 +520,7 @@ async fn test_article_version_conflict() { category_id: None, tag_ids: None, sort_order: None, + is_public: None, }, ) .await; diff --git a/dev.ps1 b/dev.ps1 index 1bd2730..34928f6 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -23,10 +23,10 @@ $LogDir = ".logs" $env:ERP__DATABASE__URL = "postgres://postgres:123123@localhost:5432/erp" $env:ERP__JWT__SECRET = "dev-secret-key-change-in-prod" $env:ERP__AUTH__SUPER_ADMIN_PASSWORD = "Admin@2026" -$env:ERP__REDIS__URL = "redis://:NMPjsdx5MTTZyJXQ@129.204.154.246:6379" +$env:ERP__REDIS__URL = "redis://localhost:6379" $env:ERP__WECHAT__APPID = "wx20f4ef9cc2ec66c5" $env:ERP__WECHAT__SECRET = "52679a563af519590e882c4b8d846f7b" -$env:ERP__WECHAT__DEV_MODE = "false" +$env:ERP__WECHAT__DEV_MODE = "true" $env:ERP__HEALTH__AES_KEY = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" $env:ERP__HEALTH__HMAC_KEY = "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5" $env:ERP__RATE_LIMIT__FAIL_CLOSE = "false" diff --git a/docs/audits/v3-beta/01-executive-summary.md b/docs/audits/v3-beta/01-executive-summary.md new file mode 100644 index 0000000..0a67577 --- /dev/null +++ b/docs/audits/v3-beta/01-executive-summary.md @@ -0,0 +1,97 @@ +# HMS V3 Beta 多学科综合测试报告 — 执行摘要 + +> 测试日期: 2026-05-21 | 分支: feat/media-library-banner +> 测试团队: 5 个专家团队并行(Web功能 / 性能兼容 / 小程序 / API / 静态分析) +> 报告版本: v1.0 + +## 1. 测试范围与方法 + +| 维度 | 方法 | 工具 | +|------|------|------| +| Web 前端功能 | 核心业务流程操作 + 边缘场景 | chrome-devtools MCP | +| Web 性能/兼容性 | Lighthouse + Core Web Vitals + 5 种视口 | chrome-devtools MCP | +| 小程序功能 | 5 Tab 页 + 核心功能 + API 验证 | weapp-local MCP | +| API 端点 | 69 个测试用例(CRUD/权限/注入/边界值) | curl/Bash | +| 静态代码分析 | TypeScript 类型/安全/性能反模式 | Grep/Read/Bash | + +## 2. 总体评估 + +| 指标 | 值 | +|------|-----| +| **综合质量评级** | **B- (6.5/10)** | +| **测试总项数** | **248 项**(功能 54 + 性能 26 + API 69 + 静态 99+) | +| **综合通过率** | **78.2%** | +| **发现问题总数** | **36 个** | +| **CRITICAL** | **4 个** | +| **HIGH** | **8 个** | +| **MEDIUM** | **15 个** | +| **LOW** | **9 个** | + +## 3. 关键发现 + +### CRITICAL(阻塞 Beta 发布) + +| ID | 来源 | 问题 | 影响 | +|----|------|------|------| +| C-01 | 小程序 | `inject_auth` 写明文键,`request.ts` 只读加密键,所有 API 无 token | 小程序所有认证功能不可用 | +| C-02 | 小程序 | `secure-storage.ts` UTF-16 截断中文,加密存储后解密损坏 | 用户数据(含中文名)存储失败 | +| C-03 | Web 兼容 | 移动端 375px 表格不可用,无响应式替代布局 | 移动端用户完全无法操作 | +| C-04 | Web 兼容 | 移动横屏 812x375 内容区域空白 | 横屏模式页面无法使用 | + +### HIGH(影响核心业务流程) + +| ID | 来源 | 问题 | 影响 | +|----|------|------|------| +| H-01 | Web 功能 | 患者创建表单缺少前端必填校验,空表单提交成功 | 脏数据进入系统 | +| H-02 | Web 功能 | 预约列表 API 网络连接异常,无数据显示 | 预约管理不可用 | +| H-03 | Web 兼容 | 平板 768px 表格数据不加载 | 平板端不可用 | +| H-04 | Web 性能 | 患者列表 LCP 2643ms(render delay 99.8%) | 页面加载慢 | +| H-05 | Web 性能 | 仪表盘 API 每个端点重复调用 4 次 | 不必要的网络/服务器负载 | +| H-06 | API | 健康数据 DTO-Entity 映射断裂,测量值全存 null(通过率 20%) | 日常监测功能实质失效 | +| H-07 | API | 500 字符文章标题导致 HTTP 500 内部错误 | 应返回 400 验证错误 | +| H-08 | 静态分析 | Web 前端 10+ 处 `.catch(() => {})` 静默吞错 | 错误不可追踪 | + +## 4. 各维度通过率 + +| 测试域 | 通过率 | 评级 | +|--------|--------|------| +| API 端点(69 项) | 82.6% | B | +| 小程序 UI 渲染(38 项) | 100% | A | +| 小程序功能(应用内 3 项) | 0% | F(token 问题) | +| 小程序功能(API 直测 4 项) | 100% | A | +| Web 前端功能(8 大领域) | 62.5%(5/8 完全通过) | B- | +| Lighthouse Desktop | 94/100/100 | A | +| Lighthouse Mobile | 94/100/100 | A | +| Web Desktop 视口 | PASS | A | +| Web Tablet 视口 | FAIL | D | +| Web Mobile 视口 | FAIL | F | + +## 5. 发布就绪度判定 + +### 结论: **CONDITIONAL BETA** — 需修复 4 个 CRITICAL + 3 个 HIGH 后可发布 + +### 阻塞项(必须修复,预计 3-4 天) + +1. **C-01/C-02 小程序 token/加密问题** — 统一 `safeGet` fallback + 修复 UTF-8 编码(预计 3h) +2. **C-03/C-04 移动端响应式** — 添加卡片视图 + 修复 768px 断点(预计 2d) +3. **H-01 患者表单验证** — 前端添加 `form.validateFields()`(预计 1h) +4. **H-06 健康数据 DTO 映射** — 修复字段映射(预计 4h) +5. **H-07 文章标题 500 错误** — 添加 DTO 长度校验(预计 30min) + +### 建议项(Beta 后迭代,预计 5-7 天) + +- M-01~M-05: 对比度/暗色模式/API 校验/XSS/搜索等 +- L-01~L-09: 弃用警告/i18n/内联样式等 + +## 6. 报告索引 + +| 章节 | 文件 | +|------|------| +| 执行摘要(本文档) | `01-executive-summary.md` | +| Web 前端功能测试 | `02-web-functional.md` | +| Web 性能与兼容性测试 | `03-web-perf-compat.md` | +| 小程序功能测试 | `04-miniprogram.md` | +| API 深度测试 | `05-api-deep-test.md` | +| 静态代码分析 | `06-static-analysis.md` | +| 跨部门头脑风暴 | `07-brainstorm.md` | +| Beta 就绪验收清单 | `08-beta-checklist.md` | diff --git a/docs/audits/v3-beta/02-web-functional.md b/docs/audits/v3-beta/02-web-functional.md new file mode 100644 index 0000000..4b2f963 --- /dev/null +++ b/docs/audits/v3-beta/02-web-functional.md @@ -0,0 +1,119 @@ +# Web 前端核心业务功能测试 + +> 测试工具: chrome-devtools MCP | 环境: Chrome, 1920x1080 +> 测试账号: admin / Admin@2026 | 截图: `docs/qa/screenshots/` + +## 1. 登录流程 — PASS + +| 测试项 | 结果 | 说明 | +|--------|------|------| +| 登录页渲染 | PASS | 双栏布局,品牌信息完整 | +| 登录后跳转 | PASS | 跳转至工作台 `/#/` | +| 侧边栏菜单 | PASS | 7 个一级菜单加载(工作台/患者中心/随访关怀/健康监测/运营管理/AI助手/系统管理) | +| 用户信息显示 | PASS | 右上角"系统管理员" + 头像 | +| 权限不足页面 | PASS | 403 页面清晰,含返回首页按钮 | +| XSS 安全 | PASS | SQL 注入测试数据 `Robert"); DROP TABLE patients;--` 正确转义显示 | + +## 2. 患者管理 — FAIL (2 issues) + +| 测试项 | 结果 | 说明 | +|--------|------|------| +| 患者列表加载 | PASS | 136 条记录,7 页分页 | +| 分页切换 | PASS | 第 2 页数据正确 | +| 创建表单打开 | PASS | 4 个分组(基本信息/联系方式/医疗信息/紧急联系人) | +| 编辑表单 | PASS | 预填充已有数据 | +| **空表单提交** | **FAIL** | 空表单成功提交创建患者(后端有校验但前端未拦截) | +| **搜索功能** | **FAIL** | 输入 "Test" 搜索后列表仍显示全部 136 条 | + +### H-01: 患者创建表单缺少前端必填校验 + +- **严重性:** HIGH +- **证据:** 点击"保存"空表单后审计日志显示"创建 了 患者" +- **根因:** Ant Design Form 未配置 `rules: [{ required: true }]` 或未调用 `form.validateFields()` +- **修复:** 在 `PatientList.tsx` 的 DrawerForm 中添加 `rules` 配置,提交前调 `form.validateFields()` +- **预计工时:** 1h + +### M-01: 患者搜索不生效 + +- **严重性:** MEDIUM +- **证据:** 搜索框输入 "Test" + 回车,列表无变化 +- **根因:** 搜索框 `keyword` 参数可能未正确传递到 API 请求 +- **修复:** 检查搜索输入与 API 参数绑定 +- **预计工时:** 2h + +## 3. 健康数据/实时监控 — PASS + +| 测试项 | 结果 | 说明 | +|--------|------|------| +| 实时监控页 | PASS | 危急/高危/中等/低危告警计数正确 | +| 告警面板 | PASS | 1 个高危患者活跃告警 | +| 告警列表 | PASS | 5 条告警记录,状态/严重程度正确 | +| 筛选功能 | PASS | 患者下拉框存在 | + +## 4. 预约管理 — FAIL (1 issue) + +| 测试项 | 结果 | 说明 | +|--------|------|------| +| 预约列表页渲染 | PASS | 表头正确(患者/医护/类型/日期/时段/状态/创建时间/备注/操作) | +| **预约数据** | **FAIL** | 表格显示 "No data" + "网络连接异常,请检查网络" | +| 新建预约按钮 | PASS | 按钮可见 | + +### H-02: 预约列表 API 网络连接异常 + +- **严重性:** HIGH +- **证据:** 页面显示"网络连接异常"No data"同时出现 +- **根因:** 可能是后端 API 错误或前端 API 路径不匹配 +- **修复:** 检查 `/api/v1/health/appointments` 端点状态和前端 API 路径 +- **预计工时:** 2h + +## 5. 咨询管理 — PASS + +| 测试项 | 结果 | 说明 | +|--------|------|------| +| 咨询列表加载 | PASS | 18 条咨询记录 | +| 状态显示 | PASS | 已关闭/进行中/等待中正确 | +| 操作按钮 | PASS | 进行中的会话显示"关闭"按钮 | +| 未读消息计数 | PASS | 患者端/医护端分别显示 | +| 筛选/导出 | PASS | 状态筛选、日期范围、导出按钮均存在 | + +## 6. 工作台/仪表盘 — PASS_WITH_ISSUES + +| 测试项 | 结果 | 说明 | +|--------|------|------| +| 工作台首页 | PASS | 6 大状态卡片 + 统计 + 模块状态 + 活跃度 | +| 系统状态 | PASS | PostgreSQL/API/定时任务/文件存储/消息队列/缓存 全绿 | +| 统计数据 | PASS | 注册用户 27/今日活跃 4/本周 9/月活 18 | +| 最近操作 | PASS | 实时显示登录/创建/删除操作 | +| 通知面板 | PASS | 危急值告警和待办事项正常 | +| 侧边栏折叠 | PASS | 折叠后仅图标,悬停展开子菜单 | +| **Admin Dashboard** | **FAIL** | `/#/health/admin-dashboard` 显示 403 | + +### M-02: Admin Dashboard URL 直接访问 403 + +- **严重性:** MEDIUM +- **说明:** AdminDashboard 组件存在但路由未注册,该页面可能仅作为工作台内嵌组件使用 +- **修复:** 移除直接访问路径或正确注册路由并配置权限 +- **预计工时:** 1h + +## 7. 主题切换 — PASS (4/4) + +| 主题 | 结果 | 说明 | +|------|------|------| +| 信任蓝(默认) | PASS | 蓝色系侧边栏 | +| 深邃夜色 | PASS | 深色侧边栏和页头 | +| 翡翠清雅 | PASS | 绿色系 | +| 温润东方 | PASS | 暖色调 | +| 持久化 | PASS | localStorage `hms-theme` 保存,刷新后保持 | + +## 8. 控制台警告 + +| 类型 | 消息 | 严重性 | +|------|------|--------| +| WARN | `[antd: Drawer] width is deprecated. Please use size instead.` | LOW | +| WARN | `[antd: List] component is deprecated. And will be removed in next major version.` | LOW | + +## 小结 + +- **完全通过领域:** 5/8(登录/健康数据/咨询/工作台/主题) +- **存在问题领域:** 3/8(患者管理/预约/仪表盘路由) +- **HIGH 问题:** 2 个 | **MEDIUM 问题:** 2 个 | **LOW 问题:** 2 个 diff --git a/docs/audits/v3-beta/03-web-perf-compat.md b/docs/audits/v3-beta/03-web-perf-compat.md new file mode 100644 index 0000000..5a5891c --- /dev/null +++ b/docs/audits/v3-beta/03-web-perf-compat.md @@ -0,0 +1,175 @@ +# Web 前端性能与兼容性测试 + +> 测试工具: chrome-devtools MCP (Lighthouse + Performance Trace + Emulate) +> 截图: `g:\hms\screenshots/` | 追踪: `g:\hms\trace-*.json` + +## 1. Lighthouse 审计 + +### 1.1 Desktop (Navigation) + +| 类别 | 分数 | 状态 | +|------|------|------| +| Accessibility | **94** | GOOD | +| Best Practices | **100** | PERFECT | +| SEO | **100** | PERFECT | +| Agentic Browsing | **61** | NEEDS_WORK | + +**失败审计项 (4):** +1. CLS 0.127 超过 0.1 阈值(Desktop 有,Mobile 无) +2. 浅色模式 `#94a3b8` 灰色文字在白底上对比度 2.56:1(需 4.5:1) +3. h1 后直接跳 h3,缺少 h2 层级 +4. llms.txt 文件缺少 H1 标题和链接 + +### 1.2 Mobile (Navigation) + +| 类别 | 分数 | 状态 | +|------|------|------| +| Accessibility | **94** | GOOD | +| Best Practices | **100** | PERFECT | +| SEO | **100** | PERFECT | +| Agentic Browsing | **67** | NEEDS_WORK | + +**失败项与 Desktop 相同**(color-contrast + heading-order + llms-txt)。Mobile CLS 为 0 通过。 + +### 1.3 Dark Mode (Snapshot) + +| 类别 | 分数 | 下降 | +|------|------|------| +| Accessibility | **92** | -2 | +| Best Practices | **100** | — | +| SEO | **80** | -20 | + +**Dark Mode 额外问题:** +- 侧边栏菜单项对比度不足(4.39:1 / 3.95:1 / 4.45:1,均未达 4.5:1) +- 表单元素缺少 `label` 关联 +- 分页链接不可爬取 + +## 2. Core Web Vitals + +### 2.1 工作台(Dashboard) + +| 指标 | 值 | 评级 | +|------|-----|------| +| **LCP** | **1381ms** | NEEDS IMPROVEMENT | +| **CLS** | **0.04** | GOOD | +| **TTFB** | **6ms** | GOOD | +| DOM 大小 | 311 elements | GOOD | +| DOM 深度 | 13 层 | GOOD | + +**LCP 瓶颈:** TTFB 6ms (0.4%) + Render Delay **1375ms (99.6%)** +**CLS 根因:** Noto Sans SC 字体从 Google Fonts 加载导致 FOUT,5 个 woff2 文件 + +### 2.2 患者列表 + +| 指标 | 值 | 评级 | +|------|-----|------| +| **LCP** | **2643ms** | NEEDS IMPROVEMENT | +| **CLS** | **0.01** | GOOD | +| **TTFB** | **4ms** | GOOD | +| DOM 大小 | 944 elements | MODERATE | + +**LCP 瓶颈:** TTFB 4ms (0.2%) + Render Delay **2639ms (99.8%)** + +**强制回流:** 总计 **460ms** +- `measureScrollbarSize` (antd): 341ms + 43ms +- `setScaleParam` (antd): 76ms +- 全部来自 Ant Design 表格组件内部 + +## 3. 多视口兼容性 + +### 3.1 Desktop 1920×1080 — PASS +- 侧边栏展开,菜单完整 +- 表格完整显示 +- **注意:** 仪表盘出现"网络连接异常"错误提示 + +### 3.2 Laptop 1366×768 — PASS +- 侧边栏正常展开 +- 患者表格完整,分页器可见 +- 筛选栏全部可见 + +### 3.3 Tablet iPad 768×1024 — **FAIL (HIGH)** + +- 侧边栏折叠为仅图标模式 +- **面包屑显示"页面"而非实际名称** +- **表格数据完全未加载** — 主内容区只有头部和筛选栏,表格区域为空 +- 评级: **H-03** + +### 3.4 Mobile iPhone 375×812 — **FAIL (CRITICAL)** + +- 侧边栏展开覆盖全屏 +- 8 列数据在 375px 宽度严重挤压 +- 出现 3 条错误消息("网络连接异常" + 2×"加载数据失败") +- 操作按钮(edit/delete)极小,触摸目标不足 44px +- 评级: **C-03** — 应提供卡片视图替代 + +### 3.5 Mobile Landscape 812×375 — **FAIL (CRITICAL)** + +- **内容区域完全空白** — main 区域只有 loading/busy 状态 +- 面包屑显示"页面" +- 评级: **C-04** + +## 4. Dark Mode 对比度问题 + +### 4.1 侧边栏低对比度 + +| 元素 | 对比度 | 标准 | +|------|--------|------| +| 跳转链接 / H logo | 4.07:1 | 需 4.5:1 | +| 患者中心 | 4.39:1 | 需 4.5:1 | +| 患者管理 | 3.95:1 | 需 4.5:1 | + +### 4.2 系统管理卡片浅色背景(Dark Mode 下不协调) + +| 元素 | 对比度 | 背景 | +|------|--------|------| +| 运行中 | 3.15:1 | 浅绿 | +| 菜单管理 | 3.84:1 | 浅蓝 | +| 系统配置 | 3.07:1 | 浅黄 | + +**根因:** 系统管理区块在 Dark Mode 下仍使用浅色背景,未跟随主题切换。 + +## 5. 网络请求分析 + +### 5.1 API 重复调用 + +仪表盘每个端点被调用 **4 次**: + +| 端点 | 调用次数 | +|------|---------| +| `/health/admin/statistics/patients` | ×4 | +| `/health/admin/statistics/consultations` | ×4 | +| `/health/admin/statistics/follow-ups` | ×4 | +| `/health/admin/points/statistics` | ×4 | +| `/health/admin/statistics/health-data` | ×4 | +| `/health/admin/statistics/dialysis` | ×4 | +| `/health/doctors` | ×4 | +| `/menus/user` | ×4 | +| `/config/themes` | ×4 | +| `/health/action-inbox` | ×4 | + +**根因:** 可能来自 React Strict Mode 双重渲染 + 组件重复挂载 +**评级:** H-05 + +### 5.2 第三方资源 + +| 资源 | 大小 | 影响 | +|------|------|------| +| Google Fonts (Noto Sans SC) | 1.3 MB | 最大外部资源,导致 CLS | + +## 6. 问题汇总 + +| ID | 严重性 | 问题 | 修复建议 | 工时 | +|----|--------|------|----------|------| +| C-03 | CRITICAL | Mobile 375px 表格不可用 | 添加 `<768px` 卡片视图 | 2d | +| C-04 | CRITICAL | Mobile 横屏内容空白 | 修复 812×375 路由加载 | 4h | +| H-03 | HIGH | Tablet 768px 数据不加载 | 修复断点 + 侧边栏同步 | 4h | +| H-04 | HIGH | 患者列表 LCP 2643ms | 字体预加载 + 虚拟滚动 | 1d | +| H-05 | HIGH | 仪表盘 API ×4 重复调用 | 检查 useEffect 依赖 | 4h | +| M-03 | MEDIUM | 浅色模式 #94a3b8 对比度 | 改为 #64748b | 30min | +| M-04 | MEDIUM | Dark Mode 系统管理卡片 | 深色背景变体 | 4h | +| M-05 | MEDIUM | Antd 表格 reflow 460ms | 固定 scroll.x/y | 2h | +| M-06 | MEDIUM | Noto Sans SC 1.3MB CLS | font-display: optional | 1h | +| M-07 | MEDIUM | 面包屑显示"页面" | 修复 tablet/mobile 路由名 | 1h | +| L-01 | LOW | heading-order h1→h3 | 插入 h2 或 aria-level | 30min | +| L-02 | LOW | 表单元素缺 label | 添加 aria-label | 1h | +| L-03 | LOW | antd Drawer width 弃用 | 迁移到 size 属性 | 30min | diff --git a/docs/audits/v3-beta/04-miniprogram.md b/docs/audits/v3-beta/04-miniprogram.md new file mode 100644 index 0000000..acfbfeb --- /dev/null +++ b/docs/audits/v3-beta/04-miniprogram.md @@ -0,0 +1,146 @@ +# 小程序功能测试报告 + +> 测试工具: weapp-local MCP | 环境: 微信开发者工具, iPhone 12/13 Pro 模拟器 +> iOS 10.0.1, 390×844 | 分支: feat/media-library-banner + +## 1. 连接与认证 + +| 项目 | 结果 | 说明 | +|------|------|------| +| MCP 连接 | PASS | ws://localhost:9420 连接成功 | +| inject_auth | PASS_WITH_ISSUES | 报告"注入成功"但存在集成问题(C-01) | +| Auth 手动恢复 | PASS | 通过 `__hms` bridge 手动 restoreAuth 成功 | + +## 2. Tab 页面测试 + +### 2.1 首页 (pages/index/index) — PASS + +| 检查项 | 结果 | 详情 | +|--------|------|------| +| 问候语 | PASS | "晚上好,系统管理员" + "5月21日周四" | +| 消息铃铛 | PASS | 可点击 | +| 签到卡片 | PASS | 进度环 0%,4 个 capsule(血压/心率/血糖/体重) | +| 今日体征 | PASS | 4 张卡片,值"---",标签"未记录" | +| 操作按钮 | PASS | "记录体征" + "预约挂号" | +| SOS 按钮 | PASS | 存在 | +| 访客模式 | PASS | 未登录显示轮播图 + 健康资讯 + 注册 CTA | +| Console 错误 | PASS | 无 | + +### 2.2 健康 Tab (pages/health/index) — PASS_WITH_ISSUES + +| 检查项 | 结果 | 详情 | +|--------|------|------| +| 页面加载 | PASS | 分段选项卡(血压/心率/血糖/体重) | +| 录入表单 | PASS | 收缩压+舒张压输入框 + 参考范围提示 | +| 趋势图 | PASS | 空状态"暂无趋势数据"正确显示 | +| **保存功能** | **FAIL** | 日志 `[health] 保存体征数据失败: {}`(C-01) | + +### 2.3 助手 Tab / AI 聊天 (pages/messages/index) — PASS + +| 检查项 | 结果 | 详情 | +|--------|------|------| +| 页面加载 | PASS | 标题"健康助手 . 小华" | +| 在线状态 | PASS | 绿色圆点 + "24小时在线" | +| 输入框 | PASS | placeholder "输入您的问题..." | +| 发送按钮 | PASS | 存在,无输入时 disabled | + +### 2.4 我的 Tab (pages/profile/index) — PASS + +| 检查项 | 结果 | 详情 | +|--------|------|------| +| 用户卡片 | PASS | 头像"系" + "系统管理员" | +| 统计数据 | PASS | 健康积分 0 + 连续打卡 0 天 | +| 功能菜单 | PASS | 5 大分组 17 个菜单项完整 | +| 退出登录 | PASS | 红色按钮存在 | +| Console 错误 | PASS | 无 | + +### 2.5 商城 Tab — 不在 TabBar 内,需导航访问 + +## 3. 非 Tab 页面测试 + +### 3.1 积分商城 (pages/mall/index) — PASS_WITH_ISSUES + +| 检查项 | 结果 | 详情 | +|--------|------|------| +| 页面加载 | PASS | 积分头部 + 签到按钮 | +| 空状态 | PASS | "暂无商品" + "更多好物即将上架" | +| **签到功能** | **FAIL** | 日志 `[points] 签到失败: {}`(C-01) | + +### 3.2 咨询列表 (pages/consultation/index) — PASS_WITH_ISSUES + +| 检查项 | 结果 | 详情 | +|--------|------|------| +| 页面导航 | PASS | 成功导航 | +| 骨架屏 | PASS | 4 个 loading card | +| **数据加载** | **FAIL** | 永久 loading 状态,无超时提示(C-01 + BUG-03) | + +## 4. 核心功能 API 验证(绕过小程序 request 层) + +| API | 方法 | 结果 | 详情 | +|-----|------|------|------| +| 积分账户 | GET /health/points/account | **PASS** | 余额 40,总获得 50,总消费 10 | +| 血压保存 | POST /health/patients/{id}/vital-signs | **PASS** | 200,返回完整记录 | +| 每日签到 | POST /health/points/checkin | **PASS** | 200,checked_in_today=true,连续 2 天 | +| 咨询列表 | GET /health/consultation-sessions | **PASS** | 200,1 条 active 会话 | + +**结论:** 后端 API 全部正常,所有功能性问题源于小程序端 token 读取。 + +## 5. BUG 详细分析 + +### C-01: inject_auth 与 request.ts 的 storage 键不匹配 + +- **严重性:** CRITICAL +- **文件:** `services/request.ts:23-29` +- **现象:** `inject_auth` 写入明文键(`access_token`),`request.ts` 的 `safeGet()` 只调用 `secureGet()`(读 `_es_` 前缀加密键),不 fallback 到明文键 +- **根因:** `safeGet` 在 `secureGet` 返回空字符串时不 fallback(空字符串不抛异常,只在 catch 中 fallback)。而 `auth.ts` 的 `storageGet` 在 `secureGet` 返回 falsy 时正确 fallback +- **影响:** 所有需要认证的功能不可用(体征保存、签到、咨询、数据加载) +- **修复:** 统一 `safeGet` 和 `storageGet` 的 fallback 逻辑,或让 `inject_auth` 写入加密键 +- **预计工时:** 1h + +### C-02: secure-storage.ts UTF-16 截断中文字符 + +- **严重性:** CRITICAL +- **文件:** `utils/secure-storage.ts:13-23` +- **现象:** `toBase64` 使用 `Uint8Array` 截断 UTF-16 高位字节 +- **根因:** `charCodeAt` 返回的 UTF-16 编码值超过 255 时被截断为 8 位 +- **影响:** 任何含中文的数据(如 `display_name`="系统管理员")经 encrypt-decrypt 循环后损坏,`JSON.parse` 失败 +- **修复:** 使用 `TextEncoder`/`TextDecoder` 进行 UTF-8 编解码 +- **预计工时:** 2h + +### BUG-03: 咨询列表无超时处理 + +- **严重性:** MEDIUM +- **文件:** `pages/consultation/index` +- **现象:** API 失败时无用户反馈,页面永远显示骨架屏 +- **修复:** 添加加载超时和错误状态 UI +- **预计工时:** 1h + +### BUG-04: 错误日志输出空对象 + +- **严重性:** MEDIUM +- **现象:** 签到/体征保存失败时 `catch` 输出 `{}` +- **修复:** 使用 `JSON.stringify(err, Object.getOwnPropertyNames(err))` 输出完整错误 +- **预计工时:** 30min + +## 6. 测试统计 + +| 类别 | 测试项 | PASS | FAIL | PASS_WITH_ISSUES | +|------|--------|------|------|------------------| +| 连接与认证 | 3 | 1 | 0 | 2 | +| Tab 页面 | 4 | 3 | 0 | 1 | +| 非 Tab 页面 | 2 | 0 | 0 | 2 | +| UI 元素 | 38 | 38 | 0 | 0 | +| 核心功能(API 直测) | 4 | 4 | 0 | 0 | +| 核心功能(应用内) | 3 | 0 | 3 | 0 | +| **合计** | **54** | **46** | **3** | **5** | + +**UI 渲染通过率:** 100% (38/38) +**API 直测通过率:** 100% (4/4) +**应用内功能通过率:** 0% (0/3) — 全部因 C-01 失败 +**综合通过率:** 85.2% (46/54) + +## 7. 评价 + +**UI 层质量:** 优秀(A 级)— 所有页面正确渲染,空状态处理完善,设计系统一致性好。 + +**功能层质量:** 失败(F 级)— 但根因集中在一个 CRITICAL 问题(C-01 token 读取),修复后预计 100% 通过。后端 API 经独立验证全部正常。 diff --git a/docs/audits/v3-beta/05-api-deep-test.md b/docs/audits/v3-beta/05-api-deep-test.md new file mode 100644 index 0000000..08f3d50 --- /dev/null +++ b/docs/audits/v3-beta/05-api-deep-test.md @@ -0,0 +1,159 @@ +# API 端点深度测试报告 + +> 测试工具: curl/Bash | 环境: http://localhost:3000 +> 测试账号: admin / Admin@2026 (完整权限) | 总用例: 69 + +## 1. 模块通过率汇总 + +| 模块 | 测试数 | 通过 | 失败 | 通过率 | +|------|--------|------|------|--------| +| 认证与权限 | 8 | 8 | 0 | **100%** | +| 患者 CRUD | 11 | 10 | 1 | **90.9%** | +| 患者分页/注入 | 7 | 5 | 2 | **71.4%** | +| 患者删除 | 2 | 2 | 0 | **100%** | +| 健康数据 | 5 | 1 | 4 | **20%** | +| 预约系统 | 7 | 7 | 0 | **100%** | +| 咨询管理 | 9 | 7 | 2 | **77.8%** | +| 内容管理 | 13 | 10 | 3 | **76.9%** | +| 通用/跨切面 | 7 | 7 | 0 | **100%** | +| **总计** | **69** | **57** | **12** | **82.6%** | + +## 2. 认证与权限 — 100% PASS + +| ID | 测试 | 结果 | +|----|------|------| +| AUTH-01 | 错误密码 | PASS — `message=未授权` | +| AUTH-02 | 不存在的用户 | PASS — `message=未授权` | +| AUTH-03 | 无 Token 访问 | PASS — HTTP 401 | +| AUTH-04 | 无效 Token | PASS — HTTP 401 | +| AUTH-05 | 空 body 登录 | PASS — 429 限流触发 | +| AUTH-06 | SQL 注入 (`' OR 1=1 --`) | PASS — 无数据泄漏 | +| AUTH-07 | 超长密码 (10000 字符) | PASS — 429 限流触发 | +| AUTH-08 | 有效 Token | PASS — 200 + data | + +**亮点:** 限流机制有效,登录端点不泄漏信息(统一返回"未授权"),SQL 注入被正确处理。 + +## 3. 患者 CRUD — 90.9% PASS + +| ID | 测试 | 结果 | 说明 | +|----|------|------|------| +| PATIENT-01 | 空名称创建 | PASS | `400: 患者姓名不能为空` | +| PATIENT-02 | 500 字符名称 | PASS | `400: 长度不能超过255` | +| PATIENT-03 | 未来出生日期 (2099) | PASS | `400: 出生日期不能是未来日期` | +| PATIENT-04 | XSS in name (`` 直接存入 name 字段 +- 前端 React 默认转义,但建议服务端也做消毒 +- **修复:** 添加 HTML sanitize 或正则剥离标签 + +### 患者分页/注入测试 + +| ID | 测试 | 结果 | +|----|------|------| +| PATIENT-10 | limit=10000 | **FAIL (LOW)** — 无上限,可能导致性能问题 | +| PATIENT-12 | SQL 注入 in search | **FAIL (MEDIUM)** — 连接错误 (HTTP 000) | + +## 4. 健康数据 — 20% PASS (最差模块) + +| ID | 测试 | 结果 | 说明 | +|----|------|------|------| +| HEALTH-01 | 极端血压 (0/0) | **FAIL** | HTTP 200,值存为 null | +| HEALTH-02 | 极端心率 (999) | **FAIL** | HTTP 200,值存为 null | +| HEALTH-03 | 负值 (-10) | **FAIL** | HTTP 200,值存为 null | +| HEALTH-04 | 无效 UUID | PASS | `422: UUID parsing failed` | +| HEALTH-05 | 未来日期 (2099) | **FAIL** | HTTP 200,记录被创建 | + +### H-06: 日常监测 DTO-Entity 映射断裂 (HIGH) + +**这是本次测试发现的最严重的后端问题。** + +- **现象:** API 接受 `indicator_type`、`value`、`systolic`、`diastolic` 等字段但静默忽略,创建的记录所有测量字段为 null +- **根因:** DTO 字段与 Entity 列名不匹配。DTO 使用 `systolic`/`diastolic`,Entity 期望 `morning_bp_systolic`/`morning_bp_diastolic` +- **影响:** 日常监测功能实质失效 — 小程序录入的体征数据无法正确存储 +- **修复:** 重构 DTO 字段映射,或统一 DTO/Entity 字段命名 +- **预计工时:** 4h + +**同时发现:** 无值范围校验(血压 0、心率 999 被接受)、未来 record_date 无校验。 + +## 5. 预约系统 — 100% PASS + +| ID | 测试 | 结果 | +|----|------|------| +| APPOINT-01 | 列表查询 | PASS | +| APPOINT-02 | 空 doctor_id | PASS — 422 | +| APPOINT-03 | 无效 UUID | PASS — 422 | +| APPOINT-04 | 不存在的预约 | PASS — 404 | +| APPOINT-05 | page=0 | PASS | +| APPOINT-11 | 排班已满 | PASS — `400: 排班已满` | +| APPOINT-12 | 重复预约 | PASS — `400: 排班已满` | + +**亮点:** UUID 校验、容量检查、404 处理全部正确。 + +## 6. 咨询管理 — 77.8% PASS + +| ID | 测试 | 结果 | +|----|------|------| +| CONSULT-02 | 空描述创建 | **FAIL (LOW)** — 接受空描述 | +| CONSULT-05 | XSS in description | **FAIL (MEDIUM)** — XSS 存储原值 | +| CONSULT-06~09 | 评分范围 1-5 | **PASS** — 校验完善 | + +**亮点:** 评分校验优秀(1-5 范围 + 只能评已关闭会话)。 + +## 7. 内容管理 — 76.9% PASS + +| ID | 测试 | 结果 | +|----|------|------| +| ARTICLE-04 | 500 字符标题 | **FAIL (HIGH)** — HTTP 500 内部错误 | +| CATEGORY-02 | 空分类名称 | **FAIL (MEDIUM)** — 接受空名称 | +| TAG-04 | 重复标签名 | **FAIL (LOW)** — 允许重复 | + +### ARTICLE-04: 500 字符标题导致 500 错误 (HIGH) + +- **现象:** 500 字符文章标题返回 HTTP 500 Internal Server Error +- **根因:** DTO 缺少 `#[validate(length(max=255))]`,数据库列长度约束违反导致未处理的 DB 错误 +- **修复:** 添加 DTO 长度校验 + 全局 DB 错误映射 +- **预计工时:** 30min + +### CATEGORY-02: 空分类名称被接受 (MEDIUM) + +- 文章标题有空校验,标签名称有空校验,但分类名称没有 +- **修复:** 添加 `#[validate(length(min=1))]` + +## 8. 通用/跨切面 — 100% PASS + +| ID | 测试 | 结果 | +|----|------|------| +| GENERIC-01 | 3 个并发更新 | PASS — 1 成功 + 2 冲突 (409) | +| GENERIC-02 | 错误 JSON body | PASS — 400 | +| GENERIC-03 | 缺少 Content-Type | PASS — 415 | +| GENERIC-04 | GET 带 body | PASS — body 被忽略 | +| GENERIC-05 | 超大页码 | PASS — 空列表 | +| GENERIC-06 | 快速连续请求 | PASS — 全 200 | +| GENERIC-07 | 不存在的文章 ID | PASS — 404 | + +**亮点:** 乐观锁在并发下表现完美(1 成功 + 2 冲突),HTTP 状态码使用规范。 + +## 9. 失败项汇总 + +| ID | 严重性 | 模块 | 问题 | 修复 | 工时 | +|----|--------|------|------|------|------| +| H-06 | HIGH | 健康数据 | DTO-Entity 映射断裂 | 重构字段映射 | 4h | +| H-07 | HIGH | 内容管理 | 500 字符标题 → HTTP 500 | 添加 DTO 校验 | 30min | +| M-08 | MEDIUM | 健康数据 | 极端值无校验 | 添加范围校验 | 2h | +| M-09 | MEDIUM | 健康数据 | 未来 record_date | 添加日期校验 | 30min | +| M-10 | MEDIUM | 咨询 | XSS 存储未消毒 | HTML sanitize | 1h | +| M-11 | MEDIUM | 内容管理 | 空分类名被接受 | 添加 validate | 30min | +| M-12 | MEDIUM | 患者 | SQL 注入导致连接错误 | 调查 URL 编码 | 2h | +| M-13 | MEDIUM | 患者 | XSS 存储未消毒 | HTML sanitize | 1h | +| L-04 | LOW | 患者 | limit 无上限 | 设 max=200 | 30min | +| L-05 | LOW | 咨询 | 空描述被接受 | validate 或文档 | 30min | +| L-06 | LOW | 内容管理 | 重复标签名 | 唯一约束 | 1h | diff --git a/docs/audits/v3-beta/06-static-analysis.md b/docs/audits/v3-beta/06-static-analysis.md new file mode 100644 index 0000000..0fd0090 --- /dev/null +++ b/docs/audits/v3-beta/06-static-analysis.md @@ -0,0 +1,139 @@ +# 前端代码静态分析报告 + +> 分析范围: apps/web/src/ (316 TS/TSX) + apps/miniprogram/src/ (167 TS/TSX) +> 分析工具: Grep/Read/Bash + +## 1. TypeScript 类型安全 — MEDIUM + +### Web 前端 + +生产代码仅 1 处 `any`: + +| 文件 | 行号 | 问题 | +|------|------|------| +| `hooks/usePaginatedData.ts` | 39 | `fetchFn: (...args: any[]) =>` — 建议用泛型 `A extends unknown[]` | + +测试文件中 17 处 `as any`(mock 场景),影响低。 + +### 小程序 — 10 处 `as any` + +| 文件 | 行号 | 问题 | 严重性 | +|------|------|------|--------| +| `app.tsx` | 24, 29 | `(globalThis as any).__hms` | LOW — 调试辅助 | +| `pages/login/index.tsx` | 9 | `(__wxConfig as any).envVersion` | MEDIUM | +| `services/request.ts` | 250 | `method: method as any` | MEDIUM | +| `pages/pkg-health/device-sync/index.tsx` | 69 | `(bleManager as any).dataBuffer` | HIGH | +| `pages/appointment/create/index.tsx` | 132 | `(Taro.requestSubscribeMessage as any)` | MEDIUM | + +**修复建议:** 创建 `types/global.d.ts` 和 `types/taro.d.ts` 补全缺失类型。 + +## 2. 错误处理 — HIGH + +### Web 前端静默吞错 (10+ 处) + +| 文件 | 行号 | 模式 | +|------|------|------| +| `pages/Home.tsx` | 224, 232, 238 | 个人统计加载失败被吞 | +| `pages/Roles.tsx` | 46 | 权限列表加载失败被吞 | +| `pages/health/ArticleManageList.tsx` | 119 | 文章列表加载失败被吞 | +| `pages/health/DialysisManageList.tsx` | 49 | 透析列表加载失败被吞 | +| `pages/health/components/DoctorSelect.tsx` | 28 | 医生列表加载失败被吞 | +| `pages/health/components/workbench/OperatorWorkbench.tsx` | 35 | 工作台数据加载失败被吞 | + +另有 10 处 `catch { }`(ChatPage 4 处 / useAlertSSE 2 处 / MainLayout 1 处 / usePaginatedData 1 处 / NotificationPanel 1 处 / App.tsx 1 处)。 + +**修复:** `.catch(() => {})` → `.catch((err) => console.warn('[context] 操作失败:', err))`,或设置错误状态。 + +### 小程序 + +仅 1 处静默 catch(`followups/detail/index.tsx:58`),有注释解释,属合理模式。 + +## 3. 安全问题 — HIGH (1 处) + +### dangerouslySetInnerHTML 无消毒 + +`pages/health/articleEditor/ArticlePhonePreview.tsx:243`: +```tsx +
+``` + +- `content` 来自 wangEditor 富文本输出 +- 后台管理预览组件,内容由管理员创建(非 UGC) +- **仍建议引入 DOMPurify 做客户端消毒** +- 预计工时: 30min + +### 硬编码 URL — LOW + +| 文件 | 内容 | 评估 | +|------|------|------| +| `AiConfigPage.tsx:340,402` | `http://localhost:11434` | Ollama 默认 URL,仅作 placeholder | +| `miniprogram/services/request.ts:4` | `localhost:3000` fallback | 开发环境 fallback,生产需运行时校验 | + +**无硬编码密钥或密码。** ✅ + +## 4. 可访问性 — LOW + +- 未发现缺少 `alt` 的 `` — Web 前端全用 Ant Design 组件 +- 3 处 `onClick` 在非 button 元素上使用(MainLayout 侧边栏 logo/折叠按钮 + ActionThreadDrawer 事件链接) +- **修复:** 添加 `role="button"` + `tabIndex={0}` + `onKeyDown` + +## 5. 大文件 — MEDIUM + +### Web 前端 (500+ 行) + +| 文件 | 行数 | 建议 | +|------|------|------| +| `AdminDashboard.tsx` | 734 | 拆分统计卡片、图表、表格 | +| `ArticleManageList.tsx` | 654 | 拆分筛选栏、表格、详情抽屉 | +| `FollowUpTaskList.tsx` | 543 | 拆分筛选、列表、详情 | +| `ConsultationDetail.tsx` | 542 | 拆分消息区、信息栏 | +| `BannerManage.tsx` | 526 | 拆分表格和表单 | +| `AppointmentList.tsx` | 520 | 拆分筛选和表格 | +| `AiKnowledgePage.tsx` | 508 | 拆分列表和编辑 | + +所有文件在 800 行限制内(CLAUDE.md 规范),但建议拆分提升可维护性。 + +### 小程序 (300+ 行) + +| 文件 | 行数 | +|------|------| +| `daily-monitoring/index.tsx` | 449 | +| `health/index.tsx` | 376 | +| `index/index.tsx` | 371 | + +小程序文件总体控制得更好。 + +## 6. 国际化 — MEDIUM (不阻塞) + +- **Web 前端:** 97 个文件 / 375 处硬编码中文文本 +- **高频文件:** DashboardWidgets (47) / DoctorWorkbench (19) / OperatorWorkbench (18) +- **影响:** 当前定位国内单语平台,短期不影响 +- **建议:** 新代码使用 i18n key,旧代码逐步迁移 + +## 7. 内联样式 — LOW + +- **1,548 处** `style={{}}` 分布在 129 个文件 +- **高频:** DoctorWorkbench (68) / AdminDashboard (54) / OperatorWorkbench (49) / DashboardWidgets (47) +- 部分动态计算(width/height)不可避免,静态样式应迁移到 CSS + +## 8. 值得肯定的方面 + +1. **TypeScript 类型安全整体优秀** — 生产代码仅 1 处 `any` +2. **小程序已完全消除 Web API 依赖** — 无 `localStorage`/`btoa`/`atob` +3. **无硬编码密钥或密码** — 敏感值全走环境变量 +4. **eslint-disable 使用规范** — 每处有注释解释 +5. **所有文件在 800 行限制内** +6. **小程序 console 日志格式统一** — `[模块名] 描述: error` + +## 9. 问题汇总 + +| 严重性 | 问题 | 文件数 | 修复工作量 | +|--------|------|--------|-----------| +| HIGH | 静默吞错 `.catch(() => {})` | 10+ | 小 — 改为 warn 日志 | +| HIGH | dangerouslySetInnerHTML 无消毒 | 1 | 小 — 引入 DOMPurify | +| MEDIUM | 小程序 `as any` 类型断言 | 10 | 中 — 补全类型声明 | +| MEDIUM | 硬编码中文 (i18n) | 97 | 大 — 渐进迁移 | +| MEDIUM | 500+ 行大文件 | 7 | 中 — 拆分子组件 | +| LOW | 内联样式过多 | 129 | 大 — 渐进迁移 | +| LOW | localhost fallback URL | 2 | 小 — 运行时校验 | +| LOW | 非交互元素 onClick 缺 a11y | 3 | 小 | diff --git a/docs/audits/v3-beta/07-brainstorm.md b/docs/audits/v3-beta/07-brainstorm.md new file mode 100644 index 0000000..1c0f884 --- /dev/null +++ b/docs/audits/v3-beta/07-brainstorm.md @@ -0,0 +1,222 @@ +# 跨部门头脑风暴 — 问题研讨与优化方案 + +> 日期: 2026-05-21 | 参与方: 前端/后端/小程序/安全/UX/DevOps +> 基于 V3 Beta 综合测试发现 + +## 1. 会议议题 + +基于 5 个专家团队的测试发现,识别出 **4 个 CRITICAL + 8 个 HIGH + 15 个 MEDIUM** 问题。本次头脑风暴聚焦于: + +1. CRITICAL 问题修复方案与优先级 +2. 移动端响应式架构决策 +3. 小程序安全存储架构改进 +4. 后端 DTO-Entity 映射质量管控 +5. Beta 发布时间线 + +--- + +## 2. 议题一: 小程序认证链路断裂 (C-01 + C-02) + +### 问题 + +`inject_auth` → 明文键 → `request.ts safeGet` 只读加密键 → 所有 API 无 token +`secure-storage.ts` → UTF-16 截断 → 中文数据加密后解密损坏 + +### 方案讨论 + +| 方案 | 描述 | 优点 | 缺点 | +|------|------|------|------| +| **A. 统一 safeGet fallback** | `safeGet` 在 `secureGet` 返回空时 fallback 到明文键 | 改动最小(1 文件) | 认证路径依赖两套存储 | +| **B. inject_auth 写加密键** | MCP 注入时直接写 `_es_` 前缀加密键 | 根因修复 | MCP 需实现加密逻辑 | +| **C. 统一存储层重构** | 所有读写走单一 `storageGet/storageSet`,内部处理加密/明文 fallback | 架构最优 | 改动范围大 | + +### 决策 + +**采用方案 A + 修复 C-02**,预计 3h: +1. `request.ts safeGet` 添加与 `auth.ts storageGet` 一致的 fallback 逻辑 +2. `secure-storage.ts toBase64/fromBase64` 改用 `TextEncoder/TextDecoder` +3. 添加单元测试验证中文字符加密/解密循环 + +--- + +## 3. 议题二: 移动端响应式 (C-03 + C-04 + H-03) + +### 问题 + +- 375px: 表格不可用,列严重挤压 +- 812×375: 内容区域空白 +- 768px: 表格数据不加载 + +### 方案讨论 + +| 方案 | 描述 | 工时 | 效果 | +|------|------|------|------| +| **A. Ant Design ProTable 响应式** | 使用 `responsive` 配置自动切换卡片视图 | 2d | 列表页全覆盖 | +| **B. CSS Grid + 媒体查询** | 手写 `@media` 断点,表格→卡片 | 3d | 精细控制 | +| **C. 独立移动端组件** | 为移动端创建 `MobilePatientCard` 等组件 | 5d | 最佳 UX | + +### 决策 + +**采用方案 A**,Ant Design ProTable 自带 responsive 支持: +1. 为 `<768px` 启用 `cardView` 模式 +2. 修复 768px 断点侧边栏折叠同步问题 +3. 修复 812×375 高度不足导致懒加载未触发 + +**注意:** HMS 定位为 PC 管理后台,移动端支持优先级低于小程序。方案 A 满足"基本可用"即可。 + +--- + +## 4. 议题三: 健康数据 DTO 映射 (H-06) + +### 问题 + +日常监测 API 通过率 20%,DTO 字段(`systolic`/`diastolic`)与 Entity 列名(`morning_bp_systolic`/`morning_bp_diastolic`)不匹配,导致所有测量值存为 null。 + +### 根因分析 + +1. DTO 设计采用通用字段名,Entity 使用具体时段字段名 +2. Handler 层缺少 DTO→Entity 的显式映射逻辑 +3. SeaORM 隐式匹配字段名,不匹配的静默为 null + +### 修复方案 + +1. **DTO 重构:** 定义 `CreateDailyMonitoringReq` 明确映射到 Entity 字段 +2. **Handler 添加映射:** 显式 `entity.morning_bp_systolic = dto.systolic` 等 +3. **添加集成测试:** 确保写入后能正确读回 +4. **值范围校验:** 血压 60-300 / 心率 30-250 / 血糖 1-50 +5. **日期校验:** `record_date <= today` + +预计工时: 4h + +--- + +## 5. 议题四: 安全问题汇总 (XSS + SSRF + 输入校验) + +### 发现清单 + +| 问题 | 位置 | 风险 | +|------|------|------| +| XSS 存储未消毒(患者名/咨询描述) | patient_handler / consultation_handler | Stored XSS | +| dangerouslySetInnerHTML 无消毒 | ArticlePhonePreview.tsx | DOM XSS | +| 空分类名被接受 | article_category_handler | 数据质量 | +| 文章标题超长导致 500 | article_handler | DoS/信息泄漏 | +| API limit 无上限 | 多个 list 端点 | 资源耗尽 | + +### 修复优先级 + +1. **P0 (1h):** 文章标题添加 `#[validate(length(max=255))]` +2. **P1 (2h):** 患者名/咨询描述添加 HTML sanitize +3. **P1 (30min):** ArticlePhonePreview 引入 DOMPurify +4. **P2 (1h):** 所有 list 端点 limit 上限设为 200 +5. **P2 (30min):** 分类名称添加 `#[validate(length(min=1))]` + +--- + +## 6. 议题五: 性能优化路线图 + +### 关键性能指标 + +| 指标 | 当前值 | 目标 | 优先级 | +|------|--------|------|--------| +| Dashboard LCP | 1381ms | < 1000ms | P1 | +| Patient List LCP | 2643ms | < 2000ms | P1 | +| API 重复调用 | ×4 | ×1 | P0 | +| Antd Table Reflow | 460ms | < 100ms | P2 | +| Noto Sans SC | 1.3MB | < 300KB | P2 | + +### 优化方案 + +1. **API 去重 (P0, 4h):** 检查 AdminDashboard useEffect 依赖项,考虑 React Query 缓存 +2. **字体优化 (P2, 1h):** `font-display: optional` + 预加载关键子集 +3. **虚拟滚动 (P2, 2h):** Antd Table `scroll={{ virtual: true }}` +4. **固定 scroll (P2, 1h):** 设置固定 `scroll.x`/`scroll.y` 避免 `measureScrollbarSize` + +--- + +## 7. 议题六: 代码质量提升 + +### 静默吞错治理 + +**原则:** 所有 catch 块至少记录 `console.warn`,关键路径设置错误状态。 + +```typescript +// BAD +.catch(() => {}) + +// GOOD +.catch((err) => { + console.warn('[PatientList] 加载统计数据失败:', err); + // 可选: setErrorState(true) +}) +``` + +### 大文件拆分计划 + +| 文件 | 行数 | 拆分方案 | 优先级 | +|------|------|---------|--------| +| AdminDashboard.tsx | 734 | StatsCards + Charts + ModuleStatus | P2 | +| ArticleManageList.tsx | 654 | FilterBar + ArticleTable + DetailDrawer | P2 | +| FollowUpTaskList.tsx | 543 | TaskFilter + TaskTable + TaskDetail | P3 | + +--- + +## 8. 行动计划与时间线 + +### Phase 0: CRITICAL 修复(Day 1-2,阻塞 Beta) + +| 任务 | 负责方 | 工时 | 依赖 | +|------|--------|------|------| +| C-01: safeGet fallback | 前端 | 1h | — | +| C-02: UTF-8 编码 | 前端 | 2h | — | +| H-01: 患者表单验证 | 前端 | 1h | — | +| H-06: DTO-Entity 映射 | 后端 | 4h | — | +| H-07: 文章标题校验 | 后端 | 30min | — | +| H-02: 预约列表 API | 全栈 | 2h | 需调查根因 | + +### Phase 1: HIGH 修复(Day 3-4) + +| 任务 | 负责方 | 工时 | +|------|--------|------| +| C-03/C-04: 移动端卡片视图 | 前端 | 2d | +| H-03: 768px 断点修复 | 前端 | 4h | +| H-05: API 去重 | 前端 | 4h | +| XSS sanitize (患者/咨询) | 后端 | 2h | + +### Phase 2: MEDIUM + 性能优化(Day 5-7) + +| 任务 | 负责方 | 工时 | +|------|--------|------| +| 对比度修复 | 前端 | 30min | +| Dark Mode 卡片 | 前端 | 4h | +| 静默吞错治理 | 前端 | 2h | +| 字体优化 | 前端 | 1h | +| API 输入校验补全 | 后端 | 3h | + +### Phase 3: LOW + 技术债(Beta 后迭代) + +- i18n 迁移(渐进) +- 大文件拆分(渐进) +- 内联样式清理(渐进) +- 类型声明补全(小程序) + +--- + +## 9. 会议结论 + +### Beta 发布条件 + +**必须在 Phase 0 + Phase 1 完成后才能发布 Beta 版本:** + +1. ✅ 4 个 CRITICAL 全部修复 +2. ✅ 8 个 HIGH 全部修复 +3. ✅ 所有修复通过回归测试 +4. ✅ `cargo check` + `cargo test` + `pnpm build` 全部通过 +5. ✅ 浏览器 + 小程序手动验证核心流程 + +### 预计时间线 + +- **Phase 0:** Day 1-2 (CRITICAL + HIGH 后端) +- **Phase 1:** Day 3-4 (移动端 + API 去重 + XSS) +- **Beta 发布:** Day 4 结束 +- **Phase 2:** Day 5-7 (MEDIUM + 性能) +- **正式版 V1:** Day 7+ (根据 Beta 反馈) diff --git a/docs/audits/v3-beta/08-beta-checklist.md b/docs/audits/v3-beta/08-beta-checklist.md new file mode 100644 index 0000000..416dc9c --- /dev/null +++ b/docs/audits/v3-beta/08-beta-checklist.md @@ -0,0 +1,142 @@ +# Beta 就绪验收清单 + +> 基于 V3 Beta 综合测试发现 | 更新: 2026-05-21 +> 目标: 明确 Beta 发布前的必须完成项和验证标准 + +## 1. 阻塞项(必须修复)— Phase 0 + +### 1.1 小程序认证链路 + +- [ ] **C-01:** `services/request.ts` 的 `safeGet` 添加明文键 fallback 逻辑 +- [ ] **C-02:** `utils/secure-storage.ts` 的 `toBase64/fromBase64` 改用 `TextEncoder/TextDecoder` +- [ ] 验证: 小程序内体征保存、签到、咨询列表 API 调用成功 +- [ ] 验证: 含中文的 `user_data` 加密存储后解密正确 + +### 1.2 Web 前端核心功能 + +- [ ] **H-01:** `PatientList.tsx` 创建表单添加 `form.validateFields()` 前端校验 +- [ ] **H-02:** 预约列表 API 网络异常排查修复 +- [ ] 验证: 空表单提交被前端拦截,显示校验错误 +- [ ] 验证: 预约列表页正常加载数据 + +### 1.3 后端数据完整性 + +- [ ] **H-06:** 日常监测 DTO-Entity 字段映射修复 +- [ ] **H-07:** 文章标题 DTO 添加 `#[validate(length(max=255))]` +- [ ] 验证: 血压/心率/血糖写入后能正确读回 +- [ ] 验证: 500 字符标题返回 400 而非 500 + +## 2. HIGH 项(应该修复)— Phase 1 + +### 2.1 移动端响应式 + +- [ ] **C-03:** Mobile 375px 添加卡片/列表视图替代表格 +- [ ] **C-04:** Mobile 横屏 812×375 内容区域空白修复 +- [ ] **H-03:** Tablet 768px 侧边栏折叠与内容区域同步 +- [ ] 验证: 5 种视口 (1920×1080 / 1366×768 / 768×1024 / 375×812 / 812×375) 全部 PASS + +### 2.2 性能 + +- [ ] **H-04:** 患者列表 LCP 优化至 < 2000ms +- [ ] **H-05:** 仪表盘 API 每个端点从 ×4 降至 ×1 +- [ ] 验证: Lighthouse Desktop Accessibility ≥ 94 + +### 2.3 安全 + +- [ ] 患者名/咨询描述 HTML sanitize +- [ ] ArticlePhonePreview 引入 DOMPurify +- [ ] 验证: XSS payload 存储后不执行 + +## 3. 构建与部署验证 + +### 3.1 后端 + +- [ ] `cargo check --workspace` 无错误 +- [ ] `cargo test --workspace` 全部通过 +- [ ] `cargo clippy -- -D warnings` 无警告 +- [ ] 后端服务正常启动,健康检查 200 + +### 3.2 Web 前端 + +- [ ] `pnpm build` 生产构建通过 +- [ ] `pnpm test` 单元测试通过 +- [ ] 4 种主题切换正常 +- [ ] 所有核心页面加载无 console error + +### 3.3 小程序 + +- [ ] `pnpm build:weapp` 构建通过 +- [ ] 微信开发者工具中 5 个 Tab 页全部可访问 +- [ ] 体征保存、签到、咨询功能正常 +- [ ] 无 JS 异常 + +## 4. 回归测试清单 + +### 4.1 核心业务流程 + +| 流程 | 验证点 | 状态 | +|------|--------|------| +| 登录 → 工作台 | 菜单加载、统计数据显示 | ⬜ | +| 患者创建 | 表单校验、数据保存 | ⬜ | +| 患者搜索 | 关键字过滤生效 | ⬜ | +| 预约列表 | 数据加载、分页 | ⬜ | +| 咨询管理 | 列表、状态切换、评分 | ⬜ | +| 主题切换 | 4 种主题 + 持久化 | ⬜ | + +### 4.2 API 端点抽检 + +| 端点 | 方法 | 验证 | 状态 | +|------|------|------|------| +| /auth/login | POST | 正确/错误密码 | ⬜ | +| /health/patients | GET/POST | CRUD + 校验 | ⬜ | +| /health/daily-monitoring | POST | DTO 映射正确 | ⬜ | +| /health/articles | POST | 标题长度校验 | ⬜ | +| /health/appointments | GET | 列表加载 | ⬜ | + +### 4.3 小程序核心功能 + +| 功能 | 验证点 | 状态 | +|------|--------|------| +| 登录 | Token 获取、存储、读取 | ⬜ | +| 首页 | 体征概览、操作按钮 | ⬜ | +| 体征保存 | 血压写入 + 读回 | ⬜ | +| 签到 | 积分增加 | ⬜ | +| AI 聊天 | 消息发送 | ⬜ | +| 咨询列表 | 数据加载 | ⬜ | + +## 5. 发布签名 + +| 角色 | 确认 | 日期 | +|------|------|------| +| 前端负责人 | ⬜ | — | +| 后端负责人 | ⬜ | — | +| 小程序负责人 | ⬜ | — | +| 安全负责人 | ⬜ | — | +| QA 负责人 | ⬜ | — | +| 产品负责人 | ⬜ | — | + +--- + +## 6. 已知限制(Beta 版本) + +以下问题在 Beta 版本中 **不阻塞**,将在后续迭代中修复: + +1. **移动端响应式** — PC 管理后台移动端体验不佳(有小程序替代) +2. **i18n** — 375 处硬编码中文(国内单语定位) +3. **内联样式** — 1,548 处 `style={{}}`(功能不影响) +4. **API limit 上限** — 无 200 上限(可通过浏览器 DevTools 触发) +5. **重复标签** — 无唯一约束(管理员操作,风险低) +6. **Dark Mode 对比度** — 部分卡片浅色背景(视觉问题,不影响功能) +7. **大文件** — 7 个 500+ 行 TSX 文件(可维护性,非功能问题) + +## 7. 测试报告索引 + +| 章节 | 文件 | 关键发现 | +|------|------|---------| +| 执行摘要 | `01-executive-summary.md` | 36 个问题,B- 评级 | +| Web 功能测试 | `02-web-functional.md` | 8 领域 5 通过,H×2 M×2 | +| 性能/兼容性 | `03-web-perf-compat.md` | Lighthouse 94/100/100,移动端 FAIL | +| 小程序测试 | `04-miniprogram.md` | UI 100%,功能 0%(token 问题) | +| API 深度测试 | `05-api-deep-test.md` | 82.6% 通过率,健康数据 20% | +| 静态分析 | `06-static-analysis.md` | 吞错 10+,i18n 375 处 | +| 头脑风暴 | `07-brainstorm.md` | 3 Phase 修复计划,7 天时间线 | diff --git a/docs/design/mp-device-sync-redesign-preview.png b/docs/design/mp-device-sync-redesign-preview.png new file mode 100644 index 0000000..7847032 Binary files /dev/null and b/docs/design/mp-device-sync-redesign-preview.png differ diff --git a/docs/design/mp-device-sync-redesign.html b/docs/design/mp-device-sync-redesign.html new file mode 100644 index 0000000..823eaed --- /dev/null +++ b/docs/design/mp-device-sync-redesign.html @@ -0,0 +1,754 @@ + + + + + +HMS 小程序 — 设备同步(重新设计) + + + + + + + +
HMS 小程序 · 设备同步(重新设计)
+
7 个状态屏幕:空闲 → 扫描中 → 设备列表 → 连接中 → 已连接(实时数据)→ 同步完成 → 错误状态
+
+ + + + \ No newline at end of file diff --git a/docs/design/mp-device-sync-redesign/META.yml b/docs/design/mp-device-sync-redesign/META.yml new file mode 100644 index 0000000..028e5cd --- /dev/null +++ b/docs/design/mp-device-sync-redesign/META.yml @@ -0,0 +1,12 @@ +prototype: mp-device-sync-redesign.html +source: docs/design/mp-device-sync-redesign.html +variant: patient +generated_at: "2026-05-23T12:00:00+08:00" +tokens: + matched: 23 + unmatched: 2 +components: + total: 12 + mapped: 8 + new: 2 +interactions: 9 diff --git a/docs/design/mp-device-sync-redesign/SPEC.md b/docs/design/mp-device-sync-redesign/SPEC.md new file mode 100644 index 0000000..174b85d --- /dev/null +++ b/docs/design/mp-device-sync-redesign/SPEC.md @@ -0,0 +1,246 @@ +# 设备同步页面 设计规格 + +> 来源: mp-device-sync-redesign.html | 平台: 小程序(患者端) | 页面数: 7 | 生成: 2026-05-23 + +## 页面索引 + +| 页面 | 截图 | 路由 | +|------|------|------| +| 空闲态 | ![空闲态](./screenshots/screen-1.png) | pages/pkg-health/device-sync/index | +| 扫描中 | ![扫描中](./screenshots/screen-2.png) | pages/pkg-health/device-sync/index | +| 设备列表 | ![设备列表](./screenshots/list.png) | pages/pkg-health/device-sync/index | +| 连接中 | ![连接中](./screenshots/screen-4.png) | pages/pkg-health/device-sync/index | +| 已连接 | ![已连接](./screenshots/screen-5.png) | pages/pkg-health/device-sync/index | +| 同步完成 | ![同步完成](./screenshots/screen-6.png) | pages/pkg-health/device-sync/index | +| 错误状态 | ![错误状态](./screenshots/screen-7.png) | pages/pkg-health/device-sync/index | + +## 一、Token 映射 + +| 原型值 | 项目 Token | 状态 | +|--------|-----------|------| +| T.pri (#C4623A) | --tk-pri | ✅ | +| T.priL (#F0DDD4) | --tk-pri-l | ✅ | +| T.priD (#8B3E1F) | --tk-pri-d | ✅ | +| T.bg (#F5F0EB) | $bg SCSS 变量 | ⚠️ 无 CSS Token,直接用 $bg | +| T.card (#FFFFFF) | --tk-card-bg ($card) | ✅ | +| T.surface (#EDE8E2) | --tk-card-bg (≈) | ⚠️ 近似,用 $surface-alt SCSS 变量 | +| T.tx (#2D2A26) | $tx SCSS 变量 | ⚠️ 无 CSS Token,直接用 $tx | +| T.tx2 (#5A554F) | $tx2 SCSS 变量 | ⚠️ 无 CSS Token,直接用 $tx2 | +| T.tx3 (#78716C) | --tk-text-secondary ($tx3) | ✅ | +| T.bd (#E8E2DC) | $bd SCSS 变量 | ⚠️ 无 CSS Token,直接用 $bd | +| T.bdL (#F0EBE5) | $bd-l SCSS 变量 | ⚠️ 无 CSS Token | +| T.acc (#5B7A5E) | $acc SCSS 变量 | ⚠️ 无 CSS Token | +| T.accL (#E8F0E8) | $acc-l SCSS 变量 | ⚠️ 无 CSS Token | +| T.wrn (#C4873A) | $wrn SCSS 变量 | ⚠️ 无 CSS Token | +| T.wrnL (#FFF3E0) | $wrn-l SCSS 变量 | ⚠️ 无 CSS Token | +| T.dan (#B54A4A) | $dan SCSS 变量 | ⚠️ 无 CSS Token | +| T.danL (#FDEAEA) | $dan-l SCSS 变量 | ⚠️ 无 CSS Token | +| T.r (16) | --tk-card-radius ($r) | ✅ | +| T.rSm (12) | $r-sm SCSS 变量 | ⚠️ 无 CSS Token | +| T.rXs (8) | $r-xs SCSS 变量 | ⚠️ 无 CSS Token | +| T.serif (Georgia...) | 字体栈 | ❌ 不映射,直接硬编码 | +| T.sans (-apple-system...) | 字体栈 | ❌ 不映射,直接硬编码 | + +> 状态标记: ✅ confirmed 直接使用 | ⚠️ pending 需 SCSS 变量 | ❌ unmatched 需硬编码 + +## 二、页面结构 + +### 1. 空闲态(idle) + +![空闲态](./screenshots/screen-1.png) + +布局层级(从上到下): +- **NavBar** — 深色主色背景,标题"设备同步" +- **Hero 区域** — 主色渐变背景(135deg pri→priD),包含: + - 蓝牙图标(72px 圆形,半透明白底) + - 标题"智能设备同步"(serif 22px 700) + - 副标题(14px 0.75 白色透明度) +- **支持设备** — 三列标签(心率手环/血压计/血糖仪),每个含 SVG 图标 +- **上次同步卡片** — ContentCard 样式,左侧绿色勾选图标 + 时间 + 右侧数据量 badge +- **待上传提示** — 黄色背景警告条($wrnL),三角感叹号图标 +- **扫描按钮** — 全宽主色按钮,蓝牙图标 + "扫描附近设备" + +### 2. 扫描中(scanning) + +![扫描中](./screenshots/screen-2.png) + +布局层级: +- **NavBar** — 同上 +- **居中脉冲区域**: + - 三层脉冲圆环(CSS animation: pulse-ring),外层→中层→内层递进 + - 中心 80px 圆形蓝牙图标($priL 底色) +- **标题** — serif 20px "正在搜索设备..." +- **副文本** — 14px $tx3 提示文字 +- **进度条** — 180px 宽,渐变填充 $priL→$pri +- **计时文字** — 12px "已用时 6 秒" + +### 3. 设备列表(found) + +![设备列表](./screenshots/list.png) + +布局层级: +- **NavBar** — 同上 +- **结果头部** — 左侧"发现 N 台设备"标题 + 右侧"重新扫描"链接(含刷新图标) +- **设备卡片列表**(×3)— 每张卡片含: + - 左:44px 圆角方块图标($priL 底色 + 蓝牙 SVG) + - 中:设备名(16px 600)+ 适配器类型(12px $tx3) + - 右:信号强度条(4 级竖条) + 箭头 +- **未发现设备提示** — 虚线边框卡片,搜索图标 + 提示文字 + +### 4. 连接中(connecting) + +![连接中](./screenshots/screen-4.png) + +布局层级: +- **NavBar** — 同上 +- **居中动画区域**: + - 100px 旋转环(border-top-color: $pri,CSS animation: connect-spin) + - 60px 中心圆形蓝牙图标 +- **标题** — serif 18px "正在连接 {设备名}" +- **副文本** — "正在进行蓝牙配对..." +- **步骤指示器** — 三点一线:发现设备(✓) → 连接中(●脉冲) → 同步数据(○) + +### 5. 已连接(connected) + +![已连接](./screenshots/screen-5.png) + +布局层级: +- **NavBar** — 同上 +- **连接状态卡片** — 绿色渐变背景(acc→#4A6B4E),蓝牙图标 + 设备名 + "实时" badge +- **最新读数高亮卡片** — 大卡片(r=16 圆角 + shadow),含: + - 52px 心形图标 + - 类型+时间小字 + - 数值(serif 36px 700)+ 单位 +- **历史读数列表** — 标题 + 表格行(类型/数值/时间),每行 12px 分隔线 +- **采集计数** — 居中小字 +- **操作按钮行** — 左侧全宽"上传数据"主色按钮 + 右侧 52px 红色断开按钮 + +### 6. 同步完成(done) + +![同步完成](./screenshots/screen-6.png) + +布局层级: +- **NavBar** — 同上 +- **居中成功区域**: + - 80px 绿色圆形勾选图标 + - 标题"同步完成"(serif 24px 700) + - 副文本"数据已安全上传至健康管理平台" +- **三列统计卡片** — 上传条数(5)/数据类型(3)/成功率(100%) +- **完成按钮** — 全宽主色按钮 + +### 7. 错误状态(error) + +![错误状态](./screenshots/screen-7.png) + +布局层级: +- **NavBar** — 同上 +- **居中错误区域**: + - 80px 红色圆形叉号图标 + - 标题"连接失败"(serif 22px 700) + - 错误描述文字 +- **错误详情卡片** — 含错误码/设备/时间三行键值对 +- **重试按钮** — 全宽主色按钮,含刷新图标 +- **返回按钮** — 描边按钮 + +## 三、组件映射 + +| 原型元素 | 推荐组件 | 来源 | 备注 | +|----------|---------|------|------| +| 页面外壳 | PageShell | @components/ui/PageShell | padding="none",NavBar 自带 | +| 连接状态卡片 | ContentCard | @components/ui/ContentCard | variant="elevated",绿色渐变背景自定义 | +| 成功结果卡片 | ContentCard | @components/ui/ContentCard | variant="elevated",居中布局 | +| 错误详情卡片 | ContentCard | @components/ui/ContentCard | variant="outlined" | +| 扫描按钮/上传按钮 | PrimaryButton | @components/ui/PrimaryButton | size="large",full width | +| 断开连接按钮 | — | 自定义 | 红色小方块图标按钮 | +| 返回按钮 | SecondaryButton | @components/ui/SecondaryButton | — | +| 设备类型标签 | — | 自定义 DeviceTypeTag | 小图标+文字,$bdL 边框 | +| 信号强度 | — | 自定义 SignalBars | 4 级竖条 | +| 上次同步信息 | ListItem | @components/ui/ListItem | leftIcon + title + subtitle + extra | +| 历史读数行 | InfoRow | @components/ui/InfoRow | label + value + last | +| 待上传警告 | AlertCard | @components/ui/AlertCard | variant="bordered",黄色 | + +> ⚠️ **需新建**: SignalBars — 4 级竖条信号强度指示器(20 行以内小组件) +> ⚠️ **需新建**: DeviceTypeTag — 设备类型标签(图标+文字,已非常简单,可直接内联) + +## 四、交互规格 + +| 元素 | 交互 | 触发 | 反馈 | 备注 | +|------|------|------|------|------| +| 扫描按钮 | 调用 handleScan | onClick | 按钮变灰+loading,状态→scanning | 触发 BLE 扫描 | +| 设备卡片 | 调用 handleConnect | onClick | 状态→connecting,显示旋转动画 | 传递选中的 BLEDevice | +| 重新扫描链接 | 调用 handleScan | onClick | 同扫描按钮 | 刷新设备列表 | +| 上传数据按钮 | 调用 handleSync | onClick | 状态→syncing → done/error | 上传采集数据到后端 | +| 断开连接按钮 | 调用 handleDisconnect | onClick | 断开 BLE,状态→idle | 清空 liveReadings | +| 完成按钮 | handleDisconnect + navigateBack | onClick | 返回上一页 | 如果 returnTo=input 则回填 Storage | +| 重试按钮 | handleScan | onClick | 重新扫描 | 从 error 恢复 | +| 返回按钮 | Taro.navigateBack | onClick | 返回上一页 | 错误状态 | +| 实时数据面板 | 被动更新 | BLE 通知 | 新数据插入列表顶部,数值动画 | useBLEManager hook 驱动 | + +## 五、状态变体 + +- **idle**: 默认状态,展示 Hero + 设备类型 + 上次同步 + 扫描按钮 +- **scanning**: 脉冲动画 + 进度条 + 计时,不可操作(无按钮) +- **found**: 设备列表 + 重新扫描链接,点击设备进入 connecting +- **connecting**: 旋转环动画 + 步骤指示器,不可操作 +- **connected**: 绿色连接状态卡 + 实时数据面板 + 上传/断开按钮 +- **done**: 成功图标 + 统计卡片 + 完成按钮 +- **error**: 错误图标 + 错误详情 + 重试/返回按钮 +- **syncing**: 复用 scanning 的加载态样式,文字改为"正在上传数据..." + +## 六、样式清单 + +### 关键样式参数 + +``` +/* Hero 渐变 */ +background: linear-gradient(135deg, $pri 0%, $pri-d 100%) +padding: 32px 20px 28px + +/* 脉冲圆环 */ +animation: pulse-ring 2s ease-out infinite +三层: 140px / 110px / 80px (center) + +/* 旋转环 */ +animation: connect-spin 1s linear infinite +border-top-color: $pri + +/* 最新读数数值 */ +font-family: $serif; font-size: 36px; font-weight: 700 + +/* 连接状态卡片渐变 */ +background: linear-gradient(135deg, $acc 0%, #4A6B4E 100%) + +/* 信号条 */ +4 根竖条: height [4, 7, 10, 13]px, width: 3px, gap: 2px +活跃色: $acc, 非活跃: $bd + +/* 主按钮 */ +background: $pri; border-radius: $r-sm; padding: 16px; +box-shadow: 0 4px 16px rgba(196, 98, 58, 0.3) +``` + +### 字号映射 + +| 原型字号 | Token | 用途 | +|---------|-------|------| +| 36px | 超大数值,直接用 serif bold | 最新读数数值 | +| 28px | --tk-font-h1 | 统计卡片数值 | +| 24px | — | 成功/错误标题 | +| 22px | --tk-font-h2 | Hero 标题、连接中标题 | +| 20px | — | 历史读数数值 | +| 18px | --tk-font-body-lg | NavBar 标题、按钮文字 | +| 17px | — | 主按钮文字 | +| 16px | --tk-font-body | 设备名、按钮文字 | +| 15px | — | 完成页副文本 | +| 14px | --tk-font-body-sm | 副文本、描述、列表类型 | +| 13px | --tk-font-cap | 标签文字、小字 | +| 12px | — | 时间、提示 | + +--- + +> 此规格由 design-handoff skill 自动生成。LLM 实施时请: +> 1. 先阅读截图建立视觉印象 +> 2. 按 Token 映射表使用项目 Token(✅ 标记的直接用,⚠️ 用 SCSS 变量) +> 3. 优先使用"组件映射"中列出的已有组件 +> 4. 参考"交互规格"实现对应的交互逻辑 +> 5. "需新建"的组件参考截图和布局描述从头实现 diff --git a/docs/discussions/2026-05-20-miniprogram-production-ready-brainstorm.md b/docs/discussions/2026-05-20-miniprogram-production-ready-brainstorm.md new file mode 100644 index 0000000..e71e078 --- /dev/null +++ b/docs/discussions/2026-05-20-miniprogram-production-ready-brainstorm.md @@ -0,0 +1,104 @@ +# 小程序上线前五专家组深度审计 + 头脑风暴 + +> 日期: 2026-05-20 | 参与者: UX/UI 审计 / 性能稳定性 / 安全审计 / 产品架构 / 代码质量 + +## 背景 + +小程序(62 页面 + 34 组件 + 38 service)即将交付用户测试。启动 5 个并行专家组进行全方位深度审计,确保交付版本的质量和可用性。 + +## 五专家组综合评分 + +| 专家组 | 评分 | CRITICAL | HIGH | MEDIUM | LOW | 总问题数 | +|--------|------|----------|------|--------|-----|----------| +| UX/UI 审计 | 6.2/10 B- | 3 | 8 | 14 | 9 | 34 | +| 性能稳定性 | 6.5/10 B- | 1 | 4 | 10 | 8 | 25 | +| 安全审计 | 5.1/10 D+ | 2 | 5 | 8 | 6 | 21 | +| 产品架构 | 6.0/10 C+ | 2 | 6 | 8 | 5 | 21 | +| 代码质量 | — | 0 | 2 | 3 | 0 | 134 空 catch + 10 any | +| **综合** | **6.0/10 C+** | **8** | **25** | **43** | **28** | **~135+** | + +## CRITICAL 汇总(必须修复,阻断用户测试) + +| # | 来源 | 问题 | 影响 | +|---|------|------|------| +| 1 | 产品 | 咨询创建页缺失,"发起咨询"按钮导航失败 | 核心咨询流程阻断 | +| 2 | 产品 | 随访流程不闭环(患者无触发入口 + 医生无执行页面) | 医疗质量核心链路断裂 | +| 3 | 安全 | 硬编码管理员凭据 `admin/Admin@2026` 在源码中 | 反编译可获取管理员权限 | +| 4 | 安全 | Token 明文存储在 Storage(secure-storage 实际无加密) | 设备丢失 = 身份冒用 | +| 5 | UX | AI 聊天页 13 处硬编码字号,长者模式完全失效 | TabBar 核心页老年用户不可用 | +| 6 | UX | 咨询详情页 14 处硬编码字号 | 医患沟通场景老年患者无法阅读 | +| 7 | UX | Loading 文字 28px 过大,误认为标题 | 视觉层级混乱 | +| 8 | 性能 | 咨询页长轮询可能永远不启动(dataLoadedRef 时序竞争) | 咨询消息收不到 | + +## HIGH 汇总(严重影响体验,应在上线前修复) + +| # | 来源 | 问题 | +|---|------|------| +| 1 | 产品 | "消息" Tab 实为 AI 聊天,非消息中心,命名误导 | +| 2 | 产品 | 预约创建未选就诊人,多就诊人场景不可用 | +| 3 | 产品 | 趋势图仅 7 天柱状图,缺长期趋势和对比 | +| 4 | 产品 | 日常监测/设备同步入口层级过深 | +| 5 | UX | 87 处页面硬编码字号,长者模式系统性失效 | +| 6 | UX | StatusTag 色值与设计系统不一致 | +| 7 | UX | 44 个页面缺少 ErrorState | +| 8 | UX | AI 聊天页未使用 PageShell 组件 | +| 9 | 安全 | X-Patient-Id/X-Tenant-Id Header 可能导致越权 | +| 10 | 安全 | openid 明文存储和跨网络传输 | +| 11 | 安全 | RichText XSS 绕过风险 | +| 12 | 性能 | 主包 12 页面可能超 2MB,无法发布 | +| 13 | 性能 | 无虚拟滚动,长列表性能差 | +| 14 | 性能 | 首页 4 个并行 API 无批量优化 | +| 15 | 代码 | 134 处空 catch 静默吞错 | + +## 头脑风暴 — 上线策略 + +### 方案 A: 保守上线(修复所有 CRITICAL + 安全加固) + +**时间**: 3-4 天 +**范围**: 8 个 CRITICAL + 安全 TOP 3 +**风险**: HIGH 级别问题可能影响用户第一印象 + +### 方案 B: 全面打磨(修复 CRITICAL + HIGH + 关键 MEDIUM) + +**时间**: 7-10 天 +**范围**: 全部 CRITICAL + HIGH + 选定 MEDIUM +**风险**: 延迟用户测试,但交付质量更高 + +### 方案 C: 分层交付(推荐) + +**时间**: 分 3 批,每批 2-3 天 +**范围**: +- **Batch 1 (P0, 2天)**: 安全 CRITICAL + 功能 CRITICAL + 性能 CRITICAL +- **Batch 2 (P1, 2天)**: UX 一致性 + 长者模式修复 + HIGH 级产品问题 +- **Batch 3 (P2, 3天)**: MEDIUM 级优化 + 性能优化 + 代码质量 + +## 决策 + +采用**方案 C 分层交付**,优先确保安全和功能完整,然后打磨体验。 + +### Batch 1 修复清单(P0, 预估 2 天) + +1. 移除硬编码凭据 → 环境变量注入(1h) +2. 确认后端不信任前端 Header(2h) +3. 咨询创建页缺失 → 新增页面或移除入口按钮(4h) +4. 咨询页长轮询启动时序修复(2h) +5. Loading 文字 token 修正(0.5h) +6. Token 存储安全加固(4h)— 可延至 Batch 2 + +### Batch 2 修复清单(P1, 预估 2 天) + +7. AI 聊天页 + 咨询详情页字号 token 替换(4h) +8. 医生端核心页面字号 token 替换(3h) +9. StatusTag 色值对齐设计系统(1h) +10. AI 聊天页接入 PageShell(2h) +11. 移除 forceSetAuth bridge(0.5h) +12. 随访流程闭环补全(4h)— 可延至 Batch 3 + +### Batch 3 修复清单(P2, 预估 3 天) + +13. 全局 87 处硬字号 → token 批量替换 +14. 74 处硬 padding → token 批量替换 +15. 44 个页面补充 ErrorState +16. 主包瘦身 + splitChunks 配置 +17. 空 catch 添加日志 +18. AI 聊天历史持久化(接后端 API) diff --git a/docs/discussions/2026-05-22-my-page-subpages-necessity.md b/docs/discussions/2026-05-22-my-page-subpages-necessity.md new file mode 100644 index 0000000..4431cb6 --- /dev/null +++ b/docs/discussions/2026-05-22-my-page-subpages-necessity.md @@ -0,0 +1,92 @@ +# 小程序"我的"页面子页面必要性分析 + +> 日期: 2026-05-22 | 参与者: 产品经理 / UX 研究员 / UX 架构师 / 医疗业务专家 / 前端技术专家 + +## 背景 + +小程序患者端"我的"页面当前有 5 个分组共 19 个菜单入口 + 1 个消息通知独立入口 = 20 个可点击项。远超移动端认知负荷上限(7±2),需要从全局角度分析各子页面的存在必要性。 + +## 讨论要点 + +### 核心问题诊断 + +1. **功能堆砌**:把所有没有找到更好归属的功能都塞进"我的",导致它变成了"功能大全"而非"个人中心" +2. **入口重复**:4 个入口在其他 Tab 已有更自然的路径(积分商城、用药记录、在线咨询、我的报告) +3. **透析噪音**:透析管理 3 个入口对所有用户无条件展示,80%+ 非透析用户看到无关功能 +4. **语义模糊**:健康记录/我的报告/诊断记录三入口,患者分不清区别 +5. **性能浪费**:消息未读数请求 50 条列表而非 count 接口 +6. **静态菜单**:无法按患者画像动态显示 + +### 各入口使用频率评估 + +| 频率 | 入口 | +|------|------| +| 高频(日活) | 消息通知、用药记录(慢病) | +| 中频(周活) | 我的预约、我的随访、在线咨询、积分商城 | +| 低频(月活) | 我的报告、健康记录、AI 分析、诊断记录、就诊人管理 | +| 极低频 | 透析处方、知情同意、线下活动、长辈模式、设备同步、设置 | + +### 患者画像与功能需求矩阵 + +| 功能 | 普通体检者(50-60%) | 慢病患者(20-25%) | 透析患者(5-8%) | 术后随访(10-15%) | +|------|:---:|:---:|:---:|:---:| +| 我的报告 | 高 | 高 | 高 | 高 | +| 我的预约 | 高 | 中 | 中 | 中 | +| AI 分析 | 高 | 高 | 中 | 高 | +| 健康记录 | 中 | 高 | 中 | 高 | +| 用药记录 | 低 | 高 | 高 | 中 | +| 我的随访 | 低 | 高 | 中 | 高 | +| 透析管理 | 无 | 无 | 高 | 无 | +| 诊断记录 | 低 | 中 | 中 | 高 | + +## 结论 + +### 共识意见 + +1. **入口数应从 20 缩减到 9-11 个**(常驻 9 + 动态 1-2) +2. **移除 4 个重复入口**:积分商城(TabBar已有)、用药记录(健康Tab已有)、在线咨询(助手Tab可达)、我的报告("我的"保留但健康Tab快捷入口改为AI分析) +3. **透析管理按需显示**:仅透析患者可见,三入口合并为一 +4. **健康数据合并**:健康记录+诊断记录合并为"健康档案"(Tab切换) +5. **长辈模式降级**:从一级入口移入设置页 + +### 优化后菜单结构 + +``` +[消息通知] ← 优化为 getUnreadCount() + +健康档案 + ├── 我的报告(Tab: 检查报告 / AI 解读) + └── 健康档案(Tab: 体检记录 / 诊断记录) + +就诊服务 + ├── 我的预约 + ├── 我的随访 + └── 在线咨询 + +透析管理 ← 仅透析患者可见 + └── (内页 Tab: 透析记录 / 透析处方 / 同意书) + +账号 + ├── 就诊人管理 + ├── 设备同步 + └── 设置(含长辈模式开关) +``` + +### 行动优先级 + +| 优先级 | 行动 | 预期效果 | 工期 | +|--------|------|---------|------| +| P0 | 未读消息改用 getUnreadCount() | 节省 500ms+ | 0.5天 | +| P0 | 移除 3 个重复入口 | 减少 3 个入口 | 0.5天 | +| P1 | 透析管理条件显示 | 80%用户减少3个无关入口 | 1-2天 | +| P1 | 透析三页合并为一 | 节省 20-30KB | 1-2天 | +| P1 | 抽取 usePaginatedList hook | 消除 300 行重复代码 | 1天 | +| P2 | 健康记录/诊断合并为健康档案 | 减少 2 个入口 | 1-2天 | +| P2 | 长辈模式降级到设置页 | 减少 1 个入口 | 0.5天 | +| P2 | 线下活动改为消息推送触达 | 减少 1 个入口 | 0.5天 | + +### 待定 + +- 后端 `patient` 表是否已有 `patient_type` 字段?需确认才能实现动态菜单 +- AI 分析是否应完全合并到"我的报告"Tab,还是保留独立入口? +- 设备同步最终放在"账号"组还是"健康"Tab? diff --git a/docs/健康管理/HMS系统设计思路.md b/docs/健康管理/HMS系统设计思路.md index b0a7bda..b948201 100644 --- a/docs/健康管理/HMS系统设计思路.md +++ b/docs/健康管理/HMS系统设计思路.md @@ -60,7 +60,7 @@ HMS 平台的设计围绕一个核心命题:**让患者的健康数据产生 └──────────────┼───────────────┘ │ ┌─────────┴─────────┐ - │ 统一 API 网关 │ + │ 统一 API 网关 │ │ /api/v1/* │ │ + /api/v1/fhir/* │ └─────────┬─────────┘