fix(mp): DevTools 卡死 + 主包 2MB→766KB + 代码质量 4 项全通过
根因:主包 2MB 全量组件注入导致 DevTools 渲染引擎内存渐增, 叠加离线时固定 3s 抑制期后的请求洪泛。 修复: - app.config.ts 添加 lazyCodeLoading: requiredComponents 主包 2.0MB→766KB,taro.js 526→131KB,vendors.js 230→28KB - request.ts 离线抑制改为指数退避(3s→6s→12s→30s cap) 后端不可达时自动延长抑制,防止请求风暴 - SegmentTabs Tab 接口改为 readonly,修复 TS 编译错误 - AbortController polyfill 补齐小程序运行时缺失 - 健康首页/设备同步/健康档案/报告/设置页 UI 重构 - 文章页公开端点适配游客访问 - 健康首页 Swiper 间隔优化 4s→5s,动画 500→300ms
This commit is contained in:
@@ -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 中的开发密钥
|
||||
|
||||
@@ -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 || '')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export default defineAppConfig({
|
||||
lazyCodeLoading: 'requiredComponents',
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/login/index',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ArticleItem[]>([]);
|
||||
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 },
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
<PageShell padding="md" safeBottom={false} scroll className={`health-page ${modeClass}`}>
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康总览</Text>
|
||||
<Text className='health-date'>{formatDate()}</Text>
|
||||
</View>
|
||||
|
||||
{/* 今日体征摘要 */}
|
||||
<ContentCard variant="elevated" className='vitals-grid'>
|
||||
{/* 今日体征 hero 卡片 */}
|
||||
<View className='vitals-grid'>
|
||||
<View className='vitals-header'>
|
||||
<Text className='vitals-title'>今日体征</Text>
|
||||
{recordedCount > 0 && (
|
||||
<Text className='vitals-badge'>已记录 {recordedCount} 项</Text>
|
||||
)}
|
||||
</View>
|
||||
{loading ? <Loading /> : (
|
||||
<View className='vitals-row'>
|
||||
{vitals.map((v) => (
|
||||
@@ -97,9 +113,9 @@ export default function Health() {
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ContentCard>
|
||||
</View>
|
||||
|
||||
{/* 快捷入口 */}
|
||||
{/* 快捷入口 — 横排 4 格图标 */}
|
||||
<View className='quick-entries'>
|
||||
{QUICK_ENTRIES.map((e) => (
|
||||
<View
|
||||
@@ -107,7 +123,7 @@ export default function Health() {
|
||||
className='quick-entry'
|
||||
onClick={() => safeNavigateTo(e.path)}
|
||||
>
|
||||
<View className='quick-icon'>
|
||||
<View className={`quick-icon quick-icon--${e.color}`}>
|
||||
<Text className='quick-icon-text'>{e.icon}</Text>
|
||||
</View>
|
||||
<Text className='quick-label'>{e.label}</Text>
|
||||
@@ -115,10 +131,12 @@ export default function Health() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 告警提示 */}
|
||||
{/* 告警横幅 */}
|
||||
{alertCount > 0 && (
|
||||
<ContentCard
|
||||
variant="elevated"
|
||||
variant="default"
|
||||
padding="sm"
|
||||
margin="none"
|
||||
className='alert-hint'
|
||||
onPress={() => safeNavigateTo('/pages/pkg-health/alerts/index')}
|
||||
>
|
||||
|
||||
@@ -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) => (
|
||||
<SwiperItem key={slide.id || idx}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, { label: string; unit: string }> = {
|
||||
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<number | null>(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<BLEManager | null>(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<string, number> }).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<string, number> = {};
|
||||
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 = () => (
|
||||
<View className="sync-section">
|
||||
<View className="sync-hero">
|
||||
<Text className="sync-hero-icon">D</Text>
|
||||
<Text className="sync-hero-title">设备同步</Text>
|
||||
<Text className="sync-hero-desc">连接智能手环、血压计、血糖仪,自动采集健康数据</Text>
|
||||
const latestReading = liveReadings.length > 0 ? liveReadings[liveReadings.length - 1] : null;
|
||||
|
||||
// ── 渲染子区域 ──
|
||||
|
||||
const renderHero = () => (
|
||||
<View className="ds-hero">
|
||||
<View className="ds-hero__icon">
|
||||
<Text className="ds-hero__bt">BT</Text>
|
||||
</View>
|
||||
|
||||
{(lastSyncAt || pendingCount > 0) && (
|
||||
<View className="sync-status-info">
|
||||
{lastSyncAt && (
|
||||
<Text className="sync-status-time">
|
||||
上次同步: {new Date(lastSyncAt).toLocaleTimeString()}
|
||||
</Text>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<Text className="sync-status-pending">
|
||||
{pendingCount} 条数据待上传
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="sync-action" onClick={handleScan}>
|
||||
<Text className="sync-action-text">扫描设备</Text>
|
||||
</View>
|
||||
|
||||
{devices.length > 0 && (
|
||||
<View className="sync-device-list">
|
||||
<Text className="sync-section-title">发现的设备</Text>
|
||||
{devices.map((d) => (
|
||||
<View
|
||||
key={d.deviceId}
|
||||
className="sync-device-item"
|
||||
onClick={() => handleConnect(d)}
|
||||
>
|
||||
<View className="sync-device-info">
|
||||
<Text className="sync-device-name">{d.name}</Text>
|
||||
<Text className="sync-device-adapter">{d.adapter?.name}</Text>
|
||||
</View>
|
||||
<Text className="sync-device-rssi">信号 {d.RSSI > -60 ? '强' : d.RSSI > -80 ? '中' : '弱'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<Text className="ds-hero__title">智能设备同步</Text>
|
||||
<Text className="ds-hero__desc">连接蓝牙设备,自动采集健康数据</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderConnected = () => (
|
||||
<View className="sync-section">
|
||||
<ContentCard className="sync-status-card">
|
||||
<Text className="sync-status-dot sync-status-dot--connected" />
|
||||
<Text className="sync-status-text">已连接: {selectedDevice?.name}</Text>
|
||||
</ContentCard>
|
||||
const renderDeviceTypes = () => (
|
||||
<View className="ds-types">
|
||||
<Text className="ds-types__label">支持的设备</Text>
|
||||
<View className="ds-types__row">
|
||||
<View className="ds-type-tag"><Text className="ds-type-tag__dot ds-type-tag__dot--heart" /><Text className="ds-type-tag__text">心率手环</Text></View>
|
||||
<View className="ds-type-tag"><Text className="ds-type-tag__dot ds-type-tag__dot--bp" /><Text className="ds-type-tag__text">血压计</Text></View>
|
||||
<View className="ds-type-tag"><Text className="ds-type-tag__dot ds-type-tag__dot--glu" /><Text className="ds-type-tag__text">血糖仪</Text></View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
{liveReadings.length > 0 && (
|
||||
<View className="sync-readings-panel">
|
||||
<Text className="sync-section-title">实时数据</Text>
|
||||
{liveReadings.slice(-5).reverse().map((r, i) => (
|
||||
<View key={i} className="sync-reading-item">
|
||||
<Text className="sync-reading-type">
|
||||
{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}
|
||||
</Text>
|
||||
<Text className="sync-reading-value">
|
||||
{r.device_type === 'heart_rate'
|
||||
? `${r.values.heart_rate} bpm`
|
||||
: r.metric
|
||||
? `${r.values.value} ${r.values.unit}`
|
||||
: JSON.stringify(r.values)}
|
||||
</Text>
|
||||
const renderLastSync = () => {
|
||||
if (!lastSyncAt && pendingCount === 0) return null;
|
||||
return (
|
||||
<ContentCard variant="outlined" padding="md" margin="none" className="ds-sync-info">
|
||||
<View className="ds-sync-info__inner">
|
||||
<View className="ds-sync-info__icon-wrap">
|
||||
<Text className="ds-sync-info__check">✓</Text>
|
||||
</View>
|
||||
<View className="ds-sync-info__text">
|
||||
<Text className="ds-sync-info__title">上次同步</Text>
|
||||
{lastSyncAt && <Text className="ds-sync-info__time">{new Date(lastSyncAt).toLocaleTimeString()}</Text>}
|
||||
</View>
|
||||
{pendingCount > 0 && (
|
||||
<View className="ds-sync-info__badge">{pendingCount} 条待上传</View>
|
||||
)}
|
||||
</View>
|
||||
</ContentCard>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPendingWarning = () => {
|
||||
if (pendingCount <= 0) return null;
|
||||
return (
|
||||
<View className="ds-warning">
|
||||
<Text className="ds-warning__icon">!</Text>
|
||||
<Text className="ds-warning__text">{pendingCount} 条数据待上传</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderScanButton = () => (
|
||||
<View className="ds-scan-btn-wrap">
|
||||
<PrimaryButton size="large" onClick={handleScan} loading={pageState === 'scanning'}>
|
||||
扫描附近设备
|
||||
</PrimaryButton>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderDeviceList = () => {
|
||||
if (pageState !== 'found') return null;
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<View className="ds-devices ds-devices--empty">
|
||||
<View className="ds-devices__empty-box">
|
||||
<Text className="ds-devices__empty-box-icon">!</Text>
|
||||
<Text className="ds-devices__empty-box-title">未发现设备</Text>
|
||||
<Text className="ds-devices__empty-box-desc">请确认设备已开机且蓝牙已开启,并靠近手机后重试</Text>
|
||||
</View>
|
||||
<View className="ds-devices__rescan-wrap">
|
||||
<PrimaryButton size="large" onClick={handleScan}>重新扫描</PrimaryButton>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View className="ds-devices">
|
||||
<View className="ds-devices__header">
|
||||
<Text className="ds-devices__count">发现 {devices.length} 台设备</Text>
|
||||
<Text className="ds-devices__rescan" onClick={handleScan}>重新扫描</Text>
|
||||
</View>
|
||||
{devices.map((d) => {
|
||||
const isFallback = d.adapter?.name === '通用设备';
|
||||
return (
|
||||
<View key={d.deviceId} className={`ds-device-card ${isFallback ? 'ds-device-card--generic' : ''}`} onClick={() => handleConnect(d)}>
|
||||
<View className="ds-device-card__icon">
|
||||
<Text className="ds-device-card__bt">BT</Text>
|
||||
</View>
|
||||
<View className="ds-device-card__info">
|
||||
<Text className="ds-device-card__name">{d.name}</Text>
|
||||
<Text className="ds-device-card__adapter">{d.adapter?.name}{isFallback ? ' · 尝试标准协议' : ''}</Text>
|
||||
</View>
|
||||
<View className="ds-device-card__signal">
|
||||
{[4, 7, 10, 13].map((h, i) => (
|
||||
<View
|
||||
key={i}
|
||||
className={`ds-signal-bar ${i < (d.RSSI > -60 ? 4 : d.RSSI > -80 ? 3 : d.RSSI > -90 ? 2 : 1) ? 'ds-signal-bar--active' : ''}`}
|
||||
style={{ height: `${h}px` }}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text className="ds-device-card__arrow">›</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View className="ds-devices__empty-hint">
|
||||
<Text className="ds-devices__empty-icon">?</Text>
|
||||
<View className="ds-devices__empty-text">
|
||||
<Text className="ds-devices__empty-title">没有找到你的设备?</Text>
|
||||
<Text className="ds-devices__empty-desc">确保设备已开机且蓝牙已开启</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLoading = (text: string) => (
|
||||
<View className="ds-loading">
|
||||
<View className="ds-loading__spinner" />
|
||||
<Text className="ds-loading__text">{text}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderConnectedStatus = () => (
|
||||
<View className="ds-connected-status">
|
||||
<View className="ds-connected-status__icon">
|
||||
<Text className="ds-connected-status__bt">BT</Text>
|
||||
</View>
|
||||
<View className="ds-connected-status__info">
|
||||
<Text className="ds-connected-status__name">{selectedDevice?.name}</Text>
|
||||
<Text className="ds-connected-status__sub">已连接 · 正在采集数据</Text>
|
||||
</View>
|
||||
<View className="ds-connected-status__badge">实时</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderLatestReading = () => {
|
||||
if (!latestReading) return null;
|
||||
return (
|
||||
<ContentCard variant="elevated" padding="lg" margin="none" className="ds-latest-reading">
|
||||
<View className="ds-latest-reading__icon-wrap ds-latest-reading__icon-wrap--heart">
|
||||
<Text className="ds-latest-reading__heart">♥</Text>
|
||||
</View>
|
||||
<View className="ds-latest-reading__body">
|
||||
<Text className="ds-latest-reading__label">{getReadingLabel(latestReading)} · 刚刚</Text>
|
||||
<View className="ds-latest-reading__values">
|
||||
<Text className="ds-latest-reading__num">{formatReadingValue(latestReading)}</Text>
|
||||
<Text className="ds-latest-reading__unit">{getReadingUnit(latestReading)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ContentCard>
|
||||
);
|
||||
};
|
||||
|
||||
const renderReadingsHistory = () => {
|
||||
if (liveReadings.length <= 1) return null;
|
||||
const history = liveReadings.slice(0, -1).reverse().slice(0, 5);
|
||||
return (
|
||||
<View className="ds-history">
|
||||
<Text className="ds-history__title">历史读数</Text>
|
||||
<View className="ds-history__list">
|
||||
{history.map((r, i) => (
|
||||
<View key={i} className={`ds-history__row ${i === history.length - 1 ? 'ds-history__row--last' : ''}`}>
|
||||
<Text className="ds-history__type">{getReadingLabel(r)}</Text>
|
||||
<View className="ds-history__val-wrap">
|
||||
<Text className="ds-history__val">{formatReadingValue(r)}</Text>
|
||||
<Text className="ds-history__unit">{getReadingUnit(r)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
<Text className="sync-readings-count">
|
||||
已采集 {liveReadings.length} 条数据
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text className="ds-history__count">已采集 {liveReadings.length} 条数据</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
<View className="sync-actions-row">
|
||||
<View className="sync-action sync-action--primary" onClick={handleSync}>
|
||||
<Text className="sync-action-text">上传数据</Text>
|
||||
</View>
|
||||
<View className="sync-action sync-action--danger" onClick={handleDisconnect}>
|
||||
<Text className="sync-action-text">断开连接</Text>
|
||||
</View>
|
||||
const renderConnectedActions = () => (
|
||||
<View className="ds-actions">
|
||||
<View className="ds-actions__upload" onClick={handleSync}>
|
||||
<Text className="ds-actions__upload-text">上传数据</Text>
|
||||
</View>
|
||||
<View className="ds-actions__disconnect" onClick={handleDisconnect}>
|
||||
<Text className="ds-actions__disconnect-icon">✕</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderDone = () => (
|
||||
<View className="sync-section">
|
||||
<ContentCard className="sync-result-card">
|
||||
<Text className="sync-result-icon">V</Text>
|
||||
<Text className="sync-result-title">同步完成</Text>
|
||||
<Text className="sync-result-count">成功上传 {syncCount} 条数据</Text>
|
||||
</ContentCard>
|
||||
<View className="sync-action" onClick={() => {
|
||||
handleDisconnect();
|
||||
if (returnTo === 'input') {
|
||||
Taro.navigateBack();
|
||||
}
|
||||
}}>
|
||||
<Text className="sync-action-text">{returnTo === 'input' ? '返回录入' : '完成'}</Text>
|
||||
<View className="ds-done">
|
||||
<View className="ds-done__icon">
|
||||
<Text className="ds-done__check">✓</Text>
|
||||
</View>
|
||||
<Text className="ds-done__title">同步完成</Text>
|
||||
<Text className="ds-done__desc">数据已安全上传至健康管理平台</Text>
|
||||
<View className="ds-done__stats">
|
||||
<View className="ds-done__stat">
|
||||
<Text className="ds-done__stat-num ds-done__stat-num--pri">{syncCount}</Text>
|
||||
<Text className="ds-done__stat-label">上传条数</Text>
|
||||
</View>
|
||||
<View className="ds-done__stat">
|
||||
<Text className="ds-done__stat-num">{new Set(liveReadings.map((r) => r.device_type)).size}</Text>
|
||||
<Text className="ds-done__stat-label">数据类型</Text>
|
||||
</View>
|
||||
<View className="ds-done__stat">
|
||||
<Text className="ds-done__stat-num ds-done__stat-num--acc">100%</Text>
|
||||
<Text className="ds-done__stat-label">成功率</Text>
|
||||
</View>
|
||||
</View>
|
||||
<PrimaryButton size="large" onClick={() => { handleDisconnect(); if (returnTo === 'input') Taro.navigateBack(); }}>
|
||||
{returnTo === 'input' ? '返回录入' : '完成'}
|
||||
</PrimaryButton>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderError = () => (
|
||||
<View className="ds-error-page">
|
||||
<View className="ds-error-page__icon">
|
||||
<Text className="ds-error-page__x">✕</Text>
|
||||
</View>
|
||||
<Text className="ds-error-page__title">连接失败</Text>
|
||||
<Text className="ds-error-page__desc">{errorMsg || '无法连接到设备,请检查设备是否在范围内并重试'}</Text>
|
||||
<View className="ds-error-page__detail">
|
||||
<Text className="ds-error-page__detail-title">错误详情</Text>
|
||||
<View className="ds-error-page__detail-row"><Text className="ds-error-page__detail-label">设备</Text><Text className="ds-error-page__detail-value">{selectedDevice?.name || '--'}</Text></View>
|
||||
<View className="ds-error-page__detail-row ds-error-page__detail-row--last"><Text className="ds-error-page__detail-label">时间</Text><Text className="ds-error-page__detail-value">{new Date().toLocaleTimeString()}</Text></View>
|
||||
</View>
|
||||
<PrimaryButton size="large" onClick={handleScan}>重新扫描</PrimaryButton>
|
||||
<View className="ds-error-page__back" onClick={() => Taro.navigateBack()}>
|
||||
<Text className="ds-error-page__back-text">返回</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// ── 主渲染 ──
|
||||
return (
|
||||
<PageShell padding="none" className={modeClass}>
|
||||
<View className="sync-header">
|
||||
<Text className="sync-header-title">设备同步</Text>
|
||||
</View>
|
||||
|
||||
{errorMsg && (
|
||||
<View className="sync-error">
|
||||
<Text className="sync-error-text">{errorMsg}</Text>
|
||||
<PageShell padding="none" className={`ds-page ${modeClass}`}>
|
||||
{/* 空闲态 */}
|
||||
{pageState === 'idle' && (
|
||||
<View className="ds-body">
|
||||
{renderHero()}
|
||||
{renderDeviceTypes()}
|
||||
{renderLastSync()}
|
||||
{renderPendingWarning()}
|
||||
{renderScanButton()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(pageState === 'scanning' || pageState === 'connecting' || pageState === 'syncing') && (
|
||||
<View className="sync-loading">
|
||||
<Text className="sync-loading-text">
|
||||
{pageState === 'scanning' && '正在扫描设备...'}
|
||||
{pageState === 'connecting' && '正在连接设备...'}
|
||||
{pageState === 'syncing' && '正在上传数据...'}
|
||||
</Text>
|
||||
{/* 扫描结果(设备列表或空结果提示) */}
|
||||
{pageState === 'found' && (
|
||||
<View className="ds-body">
|
||||
{renderHero()}
|
||||
{renderDeviceTypes()}
|
||||
{renderDeviceList()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(pageState === 'idle' || pageState === 'error') && renderIdle()}
|
||||
{pageState === 'connected' && renderConnected()}
|
||||
{pageState === 'done' && renderDone()}
|
||||
{/* 扫描中 */}
|
||||
{pageState === 'scanning' && (
|
||||
<View className="ds-body ds-body--center">
|
||||
<View className="ds-pulse">
|
||||
<View className="ds-pulse__ring ds-pulse__ring--1" />
|
||||
<View className="ds-pulse__ring ds-pulse__ring--2" />
|
||||
<View className="ds-pulse__center">
|
||||
<Text className="ds-pulse__bt">BT</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="ds-pulse__title">正在搜索设备...</Text>
|
||||
<Text className="ds-pulse__hint">请确保设备已开启蓝牙并靠近手机</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 连接中 */}
|
||||
{pageState === 'connecting' && (
|
||||
<View className="ds-body ds-body--center">
|
||||
<View className="ds-connect-anim">
|
||||
<View className="ds-connect-anim__ring" />
|
||||
<View className="ds-connect-anim__center">
|
||||
<Text className="ds-connect-anim__bt">BT</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="ds-connect-anim__title">正在连接 {selectedDevice?.name}</Text>
|
||||
<Text className="ds-connect-anim__sub">正在进行蓝牙配对...</Text>
|
||||
<View className="ds-steps">
|
||||
<View className="ds-steps__dot ds-steps__dot--done" />
|
||||
<Text className="ds-steps__label ds-steps__label--done">发现设备</Text>
|
||||
<View className="ds-steps__line ds-steps__line--active" />
|
||||
<View className="ds-steps__dot ds-steps__dot--active" />
|
||||
<Text className="ds-steps__label ds-steps__label--active">连接中</Text>
|
||||
<View className="ds-steps__line" />
|
||||
<View className="ds-steps__dot" />
|
||||
<Text className="ds-steps__label">同步数据</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 同步中 */}
|
||||
{pageState === 'syncing' && renderLoading('正在上传数据...')}
|
||||
|
||||
{/* 已连接 */}
|
||||
{pageState === 'connected' && (
|
||||
<View className="ds-body">
|
||||
{renderConnectedStatus()}
|
||||
{renderLatestReading()}
|
||||
{renderReadingsHistory()}
|
||||
{renderConnectedActions()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 同步完成 */}
|
||||
{pageState === 'done' && <View className="ds-body ds-body--center">{renderDone()}</View>}
|
||||
|
||||
{/* 错误态 */}
|
||||
{pageState === 'error' && errorMsg && (
|
||||
<View className="ds-body ds-body--center">{renderError()}</View>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
const TABS = [
|
||||
{ key: 'records', label: '体检记录' },
|
||||
{ key: 'diagnoses', label: '诊断记录' },
|
||||
] as const;
|
||||
|
||||
const RECORD_TYPE_MAP: Record<string, string> = {
|
||||
checkup: '体检',
|
||||
follow_up: '复查',
|
||||
referral: '转诊',
|
||||
};
|
||||
|
||||
export default function HealthRecords() {
|
||||
const modeClass = useElderClass();
|
||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
||||
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<string, { label: string; cls: string }> = {
|
||||
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<string, { label: string; cls: string }> = {
|
||||
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<TabKey>('records');
|
||||
|
||||
// --- 健康记录 ---
|
||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
||||
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<Diagnosis[]>([]);
|
||||
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 (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>健康记录</Text>
|
||||
<Text className='page-title'>健康档案</Text>
|
||||
|
||||
<View className='record-list'>
|
||||
{records.map((r) => (
|
||||
<View className='record-card' key={r.id}>
|
||||
<View className='record-card__header'>
|
||||
<Text className='record-card__type'>
|
||||
{TYPE_MAP[r.record_type] || r.record_type}
|
||||
</Text>
|
||||
<Text className='record-card__date'>{r.record_date}</Text>
|
||||
<SegmentTabs tabs={TABS} activeKey={tab} onChange={(k) => handleTabSwitch(k as TabKey)} variant="pill" />
|
||||
|
||||
{tab === 'records' && (
|
||||
<View className='record-list'>
|
||||
{records.map((r) => (
|
||||
<View className='record-card' key={r.id}>
|
||||
<View className='record-card__header'>
|
||||
<Text className='record-card__type'>
|
||||
{RECORD_TYPE_MAP[r.record_type] || r.record_type}
|
||||
</Text>
|
||||
<Text className='record-card__date'>{r.record_date}</Text>
|
||||
</View>
|
||||
{r.overall_assessment && (
|
||||
<Text className='record-card__assessment'>{r.overall_assessment}</Text>
|
||||
)}
|
||||
{r.source && (
|
||||
<Text className='record-card__source'>来源:{r.source}</Text>
|
||||
)}
|
||||
{r.notes && (
|
||||
<Text className='record-card__notes'>{r.notes}</Text>
|
||||
)}
|
||||
</View>
|
||||
{r.overall_assessment && (
|
||||
<Text className='record-card__assessment'>{r.overall_assessment}</Text>
|
||||
)}
|
||||
{r.source && (
|
||||
<Text className='record-card__source'>来源:{r.source}</Text>
|
||||
)}
|
||||
{r.notes && (
|
||||
<Text className='record-card__notes'>{r.notes}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{records.length === 0 && !loading && (
|
||||
<EmptyState text={hasPatient ? '暂无健康记录' : '请先在就诊人管理中选择就诊人'} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && <Loading />}
|
||||
{tab === 'diagnoses' && (
|
||||
<View className='diagnosis-list'>
|
||||
{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 (
|
||||
<View className='diagnosis-card' key={d.id}>
|
||||
<View className='diagnosis-card__header'>
|
||||
<Text className='diagnosis-card__name'>{d.diagnosis_name}</Text>
|
||||
<Text className={`diagnosis-card__status ${statusInfo.cls}`}>
|
||||
{statusInfo.label}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='diagnosis-card__meta'>
|
||||
<Text className={`diagnosis-card__type ${typeInfo.cls}`}>
|
||||
{typeInfo.label}
|
||||
</Text>
|
||||
<Text className='diagnosis-card__code'>{d.icd_code}</Text>
|
||||
</View>
|
||||
<Text className='diagnosis-card__date'>诊断日期:{d.diagnosed_date}</Text>
|
||||
{d.notes && (
|
||||
<Text className='diagnosis-card__notes'>{d.notes}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{currentItems === 0 && !currentLoading && (
|
||||
<EmptyState text={hasPatient
|
||||
? (tab === 'records' ? '暂无健康记录' : '暂无诊断记录')
|
||||
: '请先在就诊人管理中选择就诊人'} />
|
||||
)}
|
||||
|
||||
{currentLoading && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
lab_report_interpretation: '化验单解读',
|
||||
health_trend_analysis: '趋势分析',
|
||||
personalized_checkup_plan: '体检方案',
|
||||
report_summary_generation: '报告摘要',
|
||||
};
|
||||
|
||||
const AI_STATUS_MAP: Record<string, { text: string; cls: string }> = {
|
||||
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<LabReport[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasPatient, setHasPatient] = useState(true);
|
||||
const [tab, setTab] = useState<TabKey>('reports');
|
||||
|
||||
const fetchData = useCallback(async (p: number, append = false) => {
|
||||
// --- 检查报告 ---
|
||||
const [reports, setReports] = useState<LabReport[]>([]);
|
||||
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<AiAnalysisItem[]>([]);
|
||||
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 (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>检查报告</Text>
|
||||
<Text className='page-title'>我的报告</Text>
|
||||
|
||||
<View className='report-list'>
|
||||
{reports.map((r) => {
|
||||
const status = formatStatus(r);
|
||||
return (
|
||||
<View
|
||||
className='report-card'
|
||||
key={r.id}
|
||||
onClick={() => goToDetail(r.id)}
|
||||
>
|
||||
<View className='report-card-top'>
|
||||
<View className='report-type-row'>
|
||||
<View className='report-avatar'>
|
||||
<Text className='report-avatar-text'>{typeInitial(r.report_type)}</Text>
|
||||
<SegmentTabs tabs={TABS} activeKey={tab} onChange={(k) => handleTabSwitch(k as TabKey)} variant="pill" />
|
||||
|
||||
{tab === 'reports' && (
|
||||
<View className='report-list'>
|
||||
{reports.map((r) => {
|
||||
const status = formatReportStatus(r);
|
||||
return (
|
||||
<View className='report-card' key={r.id} onClick={() => safeNavigateTo(`/pages/pkg-profile/reports/detail/index?id=${r.id}`)}>
|
||||
<View className='report-card-top'>
|
||||
<View className='report-type-row'>
|
||||
<View className='report-avatar'>
|
||||
<Text className='report-avatar-text'>{r.report_type ? r.report_type.charAt(0) : '报'}</Text>
|
||||
</View>
|
||||
<Text className='report-type'>{r.report_type}</Text>
|
||||
</View>
|
||||
<Text className='report-type'>{r.report_type}</Text>
|
||||
<Text className={`report-status ${status}`}>
|
||||
{status === 'normal' ? '正常' : status === 'abnormal' ? '异常' : '未知'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className={`report-status ${status}`}>
|
||||
{status === 'normal' ? '正常' : status === 'abnormal' ? '异常' : '未知'}
|
||||
</Text>
|
||||
<Text className='report-date'>{r.report_date}</Text>
|
||||
</View>
|
||||
<Text className='report-date'>{r.report_date}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{reports.length === 0 && !loading && (
|
||||
{tab === 'ai' && (
|
||||
<View className='ai-list'>
|
||||
{aiList.map((item) => {
|
||||
const si = AI_STATUS_MAP[item.status] || { text: item.status, cls: '' };
|
||||
return (
|
||||
<View
|
||||
key={item.id}
|
||||
className='ai-card'
|
||||
onClick={() => item.status === 'completed' && safeNavigateTo(`/pages/ai-report/detail/index?id=${item.id}`)}
|
||||
>
|
||||
<View className='ai-card__header'>
|
||||
<Text className='ai-card__type'>{AI_TYPE_LABELS[item.analysis_type] || item.analysis_type}</Text>
|
||||
<Text className={`ai-card__status ${si.cls}`}>{si.text}</Text>
|
||||
</View>
|
||||
<View className='ai-card__footer'>
|
||||
<Text className='ai-card__time'>{new Date(item.created_at).toLocaleString('zh-CN')}</Text>
|
||||
<Text className='ai-card__model'>{item.model_used}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{tab === 'reports' && reports.length === 0 && !reportsLoading && (
|
||||
<EmptyState text={hasPatient ? '暂无报告记录' : '请先在就诊人管理中选择就诊人'} />
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<Loading />
|
||||
{tab === 'ai' && aiList.length === 0 && !aiLoading && (
|
||||
<EmptyState text='暂无 AI 分析报告' />
|
||||
)}
|
||||
|
||||
{currentLoading && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Text className='page-title'>设置</Text>
|
||||
|
||||
<View className='settings-group'>
|
||||
<View className='settings-item' onClick={toggleMode}>
|
||||
<View className='settings-icon'>
|
||||
<Text className='settings-icon-text'>老</Text>
|
||||
</View>
|
||||
<Text className='settings-label'>长辈模式</Text>
|
||||
<Text className={`settings-toggle ${mode === 'elder' ? 'settings-toggle--active' : ''}`}>
|
||||
{mode === 'elder' ? '已开启' : '未开启'}
|
||||
</Text>
|
||||
<Text className='settings-arrow'>{'>'}</Text>
|
||||
</View>
|
||||
<View className='settings-item' onClick={handleClearCache}>
|
||||
<View className='settings-icon'>
|
||||
<Text className='settings-icon-text'>缓</Text>
|
||||
|
||||
@@ -66,6 +66,7 @@ function persistQueue(): void {
|
||||
}
|
||||
|
||||
export function trackEvent(event: EventName | string, properties?: Record<string, unknown>): void {
|
||||
if (flushDisabled) return;
|
||||
loadQueue();
|
||||
const evt: AnalyticsEvent = {
|
||||
event,
|
||||
@@ -89,7 +90,10 @@ export function trackPageView(pageName: string, properties?: Record<string, unkn
|
||||
trackEvent('page_view', { page: pageName, ...properties });
|
||||
}
|
||||
|
||||
let flushDisabled = false;
|
||||
|
||||
export async function flushEvents(): Promise<void> {
|
||||
if (flushDisabled) return;
|
||||
loadQueue();
|
||||
if (memoryQueue.length === 0) return;
|
||||
|
||||
@@ -99,9 +103,16 @@ export async function flushEvents(): Promise<void> {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<ArticleCategory[]>('/public/article-categories', {
|
||||
tenant_id: tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPublicArticleDetail(id: string) {
|
||||
return api.get<Article>(`/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<Article>(`/health/articles/${id}`);
|
||||
}
|
||||
|
||||
/** 公开文章详情(无需认证) */
|
||||
export async function getPublicArticleDetail(id: string) {
|
||||
return api.get<Article>(`/public/articles/${id}`);
|
||||
}
|
||||
|
||||
export async function listCategories() {
|
||||
return api.get<ArticleCategory[]>('/health/article-categories');
|
||||
}
|
||||
|
||||
@@ -70,10 +70,13 @@ export class BLEManager {
|
||||
|
||||
/** 初始化蓝牙适配器 */
|
||||
async initialize(): Promise<void> {
|
||||
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<string, BLEDevice>();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -22,6 +22,38 @@ const ERROR_CODE_MAP: Record<string, string> = {
|
||||
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<T>(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<T>(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<T>(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;
|
||||
}
|
||||
|
||||
@@ -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<AuthState>((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<AuthState>((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<AuthState>((set, get) => ({
|
||||
secureRemove('wechat_openid');
|
||||
set({ user: tokenData.user, roles, loading: false });
|
||||
clearLoggingOut();
|
||||
invalidateHeadersCache();
|
||||
resetAnalyticsDisabled();
|
||||
get().loadPatients();
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
|
||||
57
apps/miniprogram/src/utils/abort-controller-polyfill.ts
Normal file
57
apps/miniprogram/src/utils/abort-controller-polyfill.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function ArticleEditor() {
|
||||
const [categoryId, setCategoryId] = useState<string | undefined>(undefined);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 公开可见 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<label style={{ ...labelStyle(isDark), marginBottom: 2 }}>公开可见</label>
|
||||
<div style={{ fontSize: 12, color: isDark ? '#64748b' : '#94a3b8' }}>
|
||||
开启后游客可在小程序查看此文章
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={isPublic} onChange={onIsPublicChange} />
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
pub view_count: i32,
|
||||
pub sort_order: i32,
|
||||
/// 是否公开(游客可访问)
|
||||
pub is_public: bool,
|
||||
/// 文章关联的分类 ID(来自 article_category 表)
|
||||
pub category_id: Option<Uuid>,
|
||||
/// 文章关联的标签名称列表
|
||||
@@ -49,6 +55,8 @@ pub struct ArticleListItem {
|
||||
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub status: String,
|
||||
pub view_count: i32,
|
||||
/// 是否公开(游客可访问)
|
||||
pub is_public: bool,
|
||||
/// 分类 ID
|
||||
pub category_id: Option<Uuid>,
|
||||
/// 标签名称列表
|
||||
@@ -96,6 +104,9 @@ pub struct CreateArticleReq {
|
||||
/// 标签 ID 列表
|
||||
#[serde(default)]
|
||||
pub tag_ids: Vec<Uuid>,
|
||||
/// 是否公开(游客可访问),默认 true
|
||||
#[serde(default = "default_true")]
|
||||
pub is_public: bool,
|
||||
}
|
||||
|
||||
impl CreateArticleReq {
|
||||
@@ -134,6 +145,8 @@ pub struct UpdateArticleReq {
|
||||
/// 标签 ID 列表(传入则整体替换)
|
||||
pub tag_ids: Option<Vec<Uuid>>,
|
||||
pub sort_order: Option<i32>,
|
||||
/// 是否公开(游客可访问)
|
||||
pub is_public: Option<bool>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Query(params): Query<PublicCategoryQuery>,
|
||||
) -> Result<Json<ApiResponse<Vec<CategoryResp>>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 认证)
|
||||
|
||||
@@ -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<Uuid>,
|
||||
tag_id: Option<Uuid>,
|
||||
keyword: Option<String>,
|
||||
is_public: Option<bool>,
|
||||
) -> HealthResult<PaginatedResponse<ArticleListItem>> {
|
||||
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<String>) -> 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,
|
||||
|
||||
3
crates/erp-server/_server_err3.txt
Normal file
3
crates/erp-server/_server_err3.txt
Normal file
@@ -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)
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
4
dev.ps1
4
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"
|
||||
|
||||
97
docs/audits/v3-beta/01-executive-summary.md
Normal file
97
docs/audits/v3-beta/01-executive-summary.md
Normal file
@@ -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` |
|
||||
119
docs/audits/v3-beta/02-web-functional.md
Normal file
119
docs/audits/v3-beta/02-web-functional.md
Normal file
@@ -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 个
|
||||
175
docs/audits/v3-beta/03-web-perf-compat.md
Normal file
175
docs/audits/v3-beta/03-web-perf-compat.md
Normal file
@@ -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 |
|
||||
146
docs/audits/v3-beta/04-miniprogram.md
Normal file
146
docs/audits/v3-beta/04-miniprogram.md
Normal file
@@ -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 经独立验证全部正常。
|
||||
159
docs/audits/v3-beta/05-api-deep-test.md
Normal file
159
docs/audits/v3-beta/05-api-deep-test.md
Normal file
@@ -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 (`<script>`) | **FAIL** | HTTP 200, 存储原值 |
|
||||
| PATIENT-05 | 无效 gender | PASS | `400: 不是有效值` |
|
||||
| PATIENT-06 | 有效创建 | PASS | success, version=1 |
|
||||
| PATIENT-14 | 按 ID 查询 | PASS | success |
|
||||
| PATIENT-15 | 不存在的 ID | PASS | `404: 患者不存在` |
|
||||
| PATIENT-16 | 有效更新 | PASS | version=2 |
|
||||
| PATIENT-17 | 乐观锁冲突 | PASS | `409: 版本冲突` |
|
||||
| PATIENT-18 | 未来日期更新 | PASS | `400: 出生日期不能是未来日期` |
|
||||
|
||||
### PATIENT-04: XSS 存储未消毒 (MEDIUM)
|
||||
|
||||
- `<script>alert(1)</script>` 直接存入 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 |
|
||||
139
docs/audits/v3-beta/06-static-analysis.md
Normal file
139
docs/audits/v3-beta/06-static-analysis.md
Normal file
@@ -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
|
||||
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||||
```
|
||||
|
||||
- `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` 的 `<img>` — 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 | 小 |
|
||||
222
docs/audits/v3-beta/07-brainstorm.md
Normal file
222
docs/audits/v3-beta/07-brainstorm.md
Normal file
@@ -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 反馈)
|
||||
142
docs/audits/v3-beta/08-beta-checklist.md
Normal file
142
docs/audits/v3-beta/08-beta-checklist.md
Normal file
@@ -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 天时间线 |
|
||||
BIN
docs/design/mp-device-sync-redesign-preview.png
Normal file
BIN
docs/design/mp-device-sync-redesign-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 272 KiB |
754
docs/design/mp-device-sync-redesign.html
Normal file
754
docs/design/mp-device-sync-redesign.html
Normal file
@@ -0,0 +1,754 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HMS 小程序 — 设备同步(重新设计)</title>
|
||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #1a1a1a; font-family: -apple-system, 'PingFang SC', sans-serif; display: flex; flex-direction: column; align-items: center; padding: 40px 20px; gap: 24px; }
|
||||
.page-title { color: #999; font-size: 13px; letter-spacing: 0.15em; }
|
||||
.note { color: #666; font-size: 12px; max-width: 1200px; text-align: center; line-height: 1.8; }
|
||||
.screens { display: flex; gap: 32px; flex-wrap: wrap; justify-content: center; align-items: flex-start; }
|
||||
.screen-wrap { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.screen-label { color: #888; font-size: 12px; font-style: italic; }
|
||||
|
||||
/* 蓝牙脉冲动画 */
|
||||
@keyframes 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 pulse-dot {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
@keyframes connect-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes fade-in-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page-title">HMS 小程序 · 设备同步(重新设计)</div>
|
||||
<div class="note">7 个状态屏幕:空闲 → 扫描中 → 设备列表 → 连接中 → 已连接(实时数据)→ 同步完成 → 错误状态</div>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel">
|
||||
// ─── iOS 设备框 ───
|
||||
const iosFrameStyles = {
|
||||
wrapper: { display: 'inline-block', padding: 12, background: '#000', borderRadius: 60, boxShadow: '0 0 0 2px #1f2937, 0 20px 60px rgba(0,0,0,0.3)', position: 'relative' },
|
||||
screen: { position: 'relative', borderRadius: 48, overflow: 'hidden', background: '#fff' },
|
||||
statusBar: { position: 'absolute', top: 0, left: 0, right: 0, height: 54, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 32px', fontSize: 16, fontWeight: 600, fontFamily: '-apple-system, "SF Pro Text", sans-serif', zIndex: 20, pointerEvents: 'none' },
|
||||
dynamicIsland: { position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)', width: 124, height: 36, background: '#000', borderRadius: 999, zIndex: 30 },
|
||||
content: { position: 'absolute', top: 54, left: 0, right: 0, bottom: 34, overflow: 'auto' },
|
||||
homeIndicator: { position: 'absolute', bottom: 10, left: '50%', transform: 'translateX(-50%)', width: 140, height: 5, background: 'rgba(0,0,0,0.3)', borderRadius: 999, zIndex: 10 },
|
||||
};
|
||||
|
||||
function IosFrame({ children, width = 360, height = 780, time = '9:41', battery = 85, darkStatus = false }) {
|
||||
const c = darkStatus ? '#fff' : '#000';
|
||||
return (
|
||||
<div style={iosFrameStyles.wrapper}>
|
||||
<div style={{ ...iosFrameStyles.screen, width, height }}>
|
||||
<div style={{ ...iosFrameStyles.statusBar, color: c }}>
|
||||
<span>{time}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<svg width="16" height="12" viewBox="0 0 16 12" fill="none"><path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill={c}/><path d="M3 7.5a7 7 0 0110 0" stroke={c} strokeWidth="1.3" fill="none" strokeLinecap="round"/><path d="M1 4.5a11 11 0 0114 0" stroke={c} strokeWidth="1.3" fill="none" strokeLinecap="round" opacity="0.7"/></svg>
|
||||
<div style={{ width: 26, height: 12, border: `1.5px solid ${c}`, borderRadius: 3, padding: 1, position: 'relative' }}>
|
||||
<div style={{ width: `${battery}%`, height: '100%', background: c, borderRadius: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={iosFrameStyles.dynamicIsland} />
|
||||
<div style={iosFrameStyles.content}>{children}</div>
|
||||
<div style={iosFrameStyles.homeIndicator} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 设计 Token ───
|
||||
const T = {
|
||||
pri: '#C4623A', priL: '#F0DDD4', priD: '#8B3E1F',
|
||||
bg: '#F5F0EB', card: '#FFFFFF', surface: '#EDE8E2',
|
||||
tx: '#2D2A26', tx2: '#5A554F', tx3: '#78716C',
|
||||
bd: '#E8E2DC', bdL: '#F0EBE5',
|
||||
acc: '#5B7A5E', accL: '#E8F0E8',
|
||||
wrn: '#C4873A', wrnL: '#FFF3E0',
|
||||
dan: '#B54A4A', danL: '#FDEAEA',
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
sans: "-apple-system, 'PingFang SC', sans-serif",
|
||||
r: 16, rSm: 12, rXs: 8,
|
||||
};
|
||||
|
||||
// ─── SVG 图标 ───
|
||||
function BluetoothIcon({ size = 24, color = T.pri }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M6 7l8 8-4 4V3l4 4-8 8" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HeartIcon({ size = 20, color = T.dan }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z" fill={color} opacity="0.15"/>
|
||||
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z" stroke={color} strokeWidth="1.5" fill="none"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon({ size = 32, color = T.acc }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="11" fill={color} opacity="0.12"/>
|
||||
<path d="M8 12.5l2.5 2.5 5.5-5.5" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorIcon({ size = 32, color = T.dan }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="11" fill={color} opacity="0.12"/>
|
||||
<path d="M15 9l-6 6M9 9l6 6" stroke={color} strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 信号强度条 ───
|
||||
function SignalBars({ level = 3 }) {
|
||||
const bars = [4, 7, 10, 13];
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 16 }}>
|
||||
{bars.map((h, i) => (
|
||||
<div key={i} style={{
|
||||
width: 3, height: h, borderRadius: 1,
|
||||
background: i < level ? T.acc : T.bd,
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 导航栏 ───
|
||||
function NavBar({ title, dark = false }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: dark ? T.pri : T.bg, position: 'relative',
|
||||
}}>
|
||||
<svg style={{ position: 'absolute', left: 16, top: '50%', transform: 'translateY(-50%)' }}
|
||||
width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M15 19l-7-7 7-7" stroke={dark ? '#fff' : T.tx} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span style={{ fontFamily: T.serif, fontSize: 18, fontWeight: 700, color: dark ? '#fff' : T.tx }}>{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 设备类型标签 ───
|
||||
function DeviceTypeTag({ icon, label }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: T.card, border: `1px solid ${T.bdL}`,
|
||||
borderRadius: T.rXs, padding: '8px 12px',
|
||||
}}>
|
||||
{icon}
|
||||
<span style={{ fontSize: 13, color: T.tx2 }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 屏幕一:空闲态 ───
|
||||
function IdleScreen() {
|
||||
return (
|
||||
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
|
||||
<NavBar title="设备同步" dark />
|
||||
|
||||
{/* Hero 区域 */}
|
||||
<div style={{
|
||||
background: `linear-gradient(135deg, ${T.pri} 0%, ${T.priD} 100%)`,
|
||||
padding: '32px 20px 28px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
}}>
|
||||
{/* 蓝牙设备插图 */}
|
||||
<div style={{
|
||||
width: 72, height: 72, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<BluetoothIcon size={36} color="#fff" />
|
||||
</div>
|
||||
<div style={{ fontFamily: T.serif, fontSize: 22, fontWeight: 700, color: '#fff', marginBottom: 6 }}>
|
||||
智能设备同步
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.75)', textAlign: 'center', lineHeight: 1.5 }}>
|
||||
连接蓝牙设备,自动采集健康数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: '16px 16px 100px', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* 支持的设备类型 */}
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: T.tx, marginBottom: 10, paddingLeft: 2 }}>
|
||||
支持的设备
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<DeviceTypeTag icon={<HeartIcon size={16} color={T.dan} />} label="心率手环" />
|
||||
<DeviceTypeTag icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 21V3M8 6l4-3 4 3M8 18l4 3 4-3" stroke={T.pri} strokeWidth="1.5" strokeLinecap="round"/>
|
||||
<circle cx="12" cy="12" r="3" stroke={T.pri} strokeWidth="1.5"/>
|
||||
</svg>
|
||||
} label="血压计" />
|
||||
<DeviceTypeTag icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2v6M12 22v-4M4.93 4.93l4.24 4.24M14.83 14.83l4.24 4.24M2 12h6M16 12h6" stroke={T.wrn} strokeWidth="1.5" strokeLinecap="round"/>
|
||||
<circle cx="12" cy="12" r="4" stroke={T.wrn} strokeWidth="1.5"/>
|
||||
</svg>
|
||||
} label="血糖仪" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上次同步信息 */}
|
||||
<div style={{
|
||||
background: T.card, borderRadius: T.rSm,
|
||||
padding: '14px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
border: `1px solid ${T.bdL}`,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: '50%', background: T.accL, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CheckIcon size={20} color={T.acc} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: T.tx }}>上次同步</div>
|
||||
<div style={{ fontSize: 12, color: T.tx3, marginTop: 2 }}>今天 08:30</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
background: T.accL, borderRadius: T.rXs,
|
||||
padding: '4px 10px', fontSize: 12, color: T.acc, fontWeight: 500,
|
||||
}}>
|
||||
12 条数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 待上传提示 */}
|
||||
<div style={{
|
||||
background: T.wrnL, borderRadius: T.rSm,
|
||||
padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 9v4M12 17h.01M12 2L2 22h20L12 2z" stroke={T.wrn} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span style={{ fontSize: 13, color: T.wrn, fontWeight: 500 }}>3 条数据待上传</span>
|
||||
</div>
|
||||
|
||||
{/* 扫描按钮 */}
|
||||
<div style={{
|
||||
background: T.pri, borderRadius: T.rSm,
|
||||
padding: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
|
||||
marginTop: 'auto',
|
||||
}}>
|
||||
<BluetoothIcon size={20} color="#fff" />
|
||||
<span style={{ color: '#fff', fontSize: 17, fontWeight: 600 }}>扫描附近设备</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 屏幕二:扫描中 ───
|
||||
function ScanningScreen() {
|
||||
return (
|
||||
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
|
||||
<NavBar title="设备同步" dark />
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
{/* 脉冲圆环 */}
|
||||
<div style={{ position: 'relative', width: 140, height: 140, marginBottom: 32 }}>
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, width: 140, height: 140,
|
||||
borderRadius: '50%', border: `2px solid ${T.priL}`,
|
||||
animation: 'pulse-ring 2s ease-out infinite',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', top: 15, left: 15, width: 110, height: 110,
|
||||
borderRadius: '50%', border: `2px solid ${T.priL}`,
|
||||
animation: 'pulse-ring 2s ease-out infinite 0.5s',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', top: 30, left: 30, width: 80, height: 80,
|
||||
borderRadius: '50%', background: T.priL,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'pulse-dot 2s ease-in-out infinite',
|
||||
}}>
|
||||
<BluetoothIcon size={36} color={T.pri} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontFamily: T.serif, fontSize: 20, fontWeight: 700, color: T.tx, marginBottom: 8, textAlign: 'center' }}>
|
||||
正在搜索设备...
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: T.tx3, textAlign: 'center', lineHeight: 1.6 }}>
|
||||
请确保设备已开启蓝牙并靠近手机
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div style={{
|
||||
width: 180, height: 3, borderRadius: 2, background: T.bdL,
|
||||
marginTop: 24, overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '60%', height: '100%', borderRadius: 2,
|
||||
background: `linear-gradient(90deg, ${T.priL}, ${T.pri})`,
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: T.tx3, marginTop: 8 }}>已用时 6 秒</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 屏幕三:设备列表 ───
|
||||
function DeviceListScreen() {
|
||||
const devices = [
|
||||
{ name: 'Mi Band 8', type: '小米手环适配器', signal: 4, color: T.pri },
|
||||
{ name: 'AND UA-651', type: '血压计适配器', signal: 3, color: T.pri },
|
||||
{ name: 'Accu-Chek', type: '血糖仪适配器', signal: 2, color: T.wrn },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
|
||||
<NavBar title="设备同步" dark />
|
||||
|
||||
<div style={{ flex: 1, padding: '16px 16px 100px' }}>
|
||||
{/* 结果标题 */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: T.tx }}>发现 {devices.length} 台设备</div>
|
||||
<div style={{ fontSize: 12, color: T.tx3, marginTop: 2 }}>点击设备名称开始连接</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 13, color: T.pri, fontWeight: 500, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M23 4v6h-6M1 20v-6h6" stroke={T.pri} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" stroke={T.pri} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
重新扫描
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备卡片 */}
|
||||
{devices.map((d, i) => (
|
||||
<div key={i} style={{
|
||||
background: T.card, borderRadius: T.rSm,
|
||||
padding: '16px', marginBottom: 10,
|
||||
border: `1px solid ${T.bdL}`,
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
}}>
|
||||
{/* 设备图标 */}
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: T.rSm,
|
||||
background: T.priL, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<BluetoothIcon size={22} color={T.pri} />
|
||||
</div>
|
||||
{/* 设备信息 */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: T.tx }}>{d.name}</div>
|
||||
<div style={{ fontSize: 12, color: T.tx3, marginTop: 3 }}>{d.type}</div>
|
||||
</div>
|
||||
{/* 信号 + 箭头 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<SignalBars level={d.signal} />
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 18l6-6-6-6" stroke={T.tx3} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 未发现设备提示 */}
|
||||
<div style={{
|
||||
marginTop: 16, background: T.card, borderRadius: T.rSm,
|
||||
padding: '14px 16px', border: `1px dashed ${T.bd}`,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: '50%', background: T.surface,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="8" stroke={T.tx3} strokeWidth="1.5"/>
|
||||
<path d="M21 21l-4.35-4.35" stroke={T.tx3} strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, color: T.tx2 }}>没有找到你的设备?</div>
|
||||
<div style={{ fontSize: 12, color: T.tx3, marginTop: 2 }}>确保设备已开机且蓝牙已开启</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 屏幕四:连接中 ───
|
||||
function ConnectingScreen() {
|
||||
return (
|
||||
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
|
||||
<NavBar title="设备同步" dark />
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 40 }}>
|
||||
{/* 连接动画 */}
|
||||
<div style={{ position: 'relative', width: 100, height: 100, marginBottom: 28 }}>
|
||||
{/* 旋转环 */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, width: 100, height: 100,
|
||||
borderRadius: '50%', border: `3px solid ${T.bdL}`,
|
||||
borderTopColor: T.pri,
|
||||
animation: 'connect-spin 1s linear infinite',
|
||||
}} />
|
||||
{/* 中心图标 */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 20, left: 20, width: 60, height: 60,
|
||||
borderRadius: '50%', background: T.priL,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<BluetoothIcon size={28} color={T.pri} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontFamily: T.serif, fontSize: 18, fontWeight: 700, color: T.tx, marginBottom: 6, textAlign: 'center' }}>
|
||||
正在连接 Mi Band 8
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: T.tx3, textAlign: 'center' }}>
|
||||
正在进行蓝牙配对...
|
||||
</div>
|
||||
|
||||
{/* 步骤指示 */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, marginTop: 24,
|
||||
}}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: T.acc }} />
|
||||
<span style={{ fontSize: 12, color: T.tx3 }}>发现设备</span>
|
||||
<div style={{ width: 24, height: 1, background: T.pri }} />
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: T.pri, animation: 'pulse-dot 1s ease-in-out infinite' }} />
|
||||
<span style={{ fontSize: 12, color: T.pri, fontWeight: 500 }}>连接中</span>
|
||||
<div style={{ width: 24, height: 1, background: T.bd }} />
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: T.bd }} />
|
||||
<span style={{ fontSize: 12, color: T.tx3 }}>同步数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 屏幕五:已连接 + 实时数据 ───
|
||||
function ConnectedScreen() {
|
||||
const readings = [
|
||||
{ type: '心率', value: '72', unit: 'bpm', color: T.dan, time: '刚刚' },
|
||||
{ type: '收缩压', value: '128', unit: 'mmHg', color: T.pri, time: '2分钟前' },
|
||||
{ type: '舒张压', value: '82', unit: 'mmHg', color: T.pri, time: '2分钟前' },
|
||||
{ type: '心率', value: '68', unit: 'bpm', color: T.dan, time: '5分钟前' },
|
||||
{ type: '心率', value: '74', unit: 'bpm', color: T.dan, time: '8分钟前' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
|
||||
<NavBar title="设备同步" dark />
|
||||
|
||||
<div style={{ flex: 1, padding: '16px 16px 100px', overflow: 'auto' }}>
|
||||
{/* 连接状态卡片 */}
|
||||
<div style={{
|
||||
background: `linear-gradient(135deg, ${T.acc} 0%, #4A6B4E 100%)`,
|
||||
borderRadius: T.r, padding: '16px',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<BluetoothIcon size={22} color="#fff" />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#fff' }}>Mi Band 8</div>
|
||||
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', marginTop: 2 }}>已连接 · 正在采集数据</div>
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'rgba(255,255,255,0.2)', borderRadius: T.rXs,
|
||||
padding: '4px 10px', fontSize: 12, color: '#fff',
|
||||
}}>
|
||||
实时
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最新读数高亮 */}
|
||||
<div style={{
|
||||
background: T.card, borderRadius: T.r, padding: '20px',
|
||||
display: 'flex', alignItems: 'center', gap: 16,
|
||||
marginBottom: 16, boxShadow: '0 2px 12px rgba(45,42,38,0.08)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: T.rSm,
|
||||
background: `${T.dan}10`, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<HeartIcon size={28} color={T.dan} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, color: T.tx3 }}>心率 · 刚刚</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginTop: 4 }}>
|
||||
<span style={{ fontFamily: T.serif, fontSize: 36, fontWeight: 700, color: T.tx }}>72</span>
|
||||
<span style={{ fontSize: 14, color: T.tx3 }}>bpm</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 历史读数列表 */}
|
||||
<div style={{
|
||||
fontSize: 14, fontWeight: 600, color: T.tx, marginBottom: 10, paddingLeft: 2,
|
||||
}}>
|
||||
历史读数
|
||||
</div>
|
||||
<div style={{
|
||||
background: T.card, borderRadius: T.rSm, overflow: 'hidden',
|
||||
boxShadow: '0 1px 4px rgba(45,42,38,0.06)',
|
||||
}}>
|
||||
{readings.slice(1).map((r, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', padding: '12px 16px',
|
||||
borderBottom: i < readings.length - 2 ? `1px solid ${T.bdL}` : 'none',
|
||||
}}>
|
||||
<div style={{ width: 90, fontSize: 14, color: T.tx2 }}>{r.type}</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'baseline', gap: 3 }}>
|
||||
<span style={{ fontFamily: T.serif, fontSize: 20, fontWeight: 700, color: T.tx }}>{r.value}</span>
|
||||
<span style={{ fontSize: 12, color: T.tx3 }}>{r.unit}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: T.tx3 }}>{r.time}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
textAlign: 'center', marginTop: 12, fontSize: 12, color: T.tx3,
|
||||
}}>
|
||||
已采集 {readings.length} 条数据
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 20 }}>
|
||||
<div style={{
|
||||
flex: 1, background: T.pri, borderRadius: T.rSm,
|
||||
padding: '14px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span style={{ color: '#fff', fontSize: 16, fontWeight: 600 }}>上传数据</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: 52, background: T.danL, borderRadius: T.rSm,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke={T.dan} strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 屏幕六:同步完成 ───
|
||||
function DoneScreen() {
|
||||
return (
|
||||
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
|
||||
<NavBar title="设备同步" dark />
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '24px 20px 100px' }}>
|
||||
{/* 成功图标 */}
|
||||
<div style={{
|
||||
width: 80, height: 80, borderRadius: '50%', background: T.accL,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<CheckIcon size={44} color={T.acc} />
|
||||
</div>
|
||||
|
||||
<div style={{ fontFamily: T.serif, fontSize: 24, fontWeight: 700, color: T.tx, marginBottom: 8 }}>
|
||||
同步完成
|
||||
</div>
|
||||
<div style={{ fontSize: 15, color: T.tx3, textAlign: 'center', lineHeight: 1.6 }}>
|
||||
数据已安全上传至健康管理平台
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 12, marginTop: 24, width: '100%',
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1, background: T.card, borderRadius: T.rSm, padding: '16px',
|
||||
textAlign: 'center', border: `1px solid ${T.bdL}`,
|
||||
}}>
|
||||
<div style={{ fontFamily: T.serif, fontSize: 28, fontWeight: 700, color: T.pri }}>5</div>
|
||||
<div style={{ fontSize: 12, color: T.tx3, marginTop: 4 }}>上传条数</div>
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1, background: T.card, borderRadius: T.rSm, padding: '16px',
|
||||
textAlign: 'center', border: `1px solid ${T.bdL}`,
|
||||
}}>
|
||||
<div style={{ fontFamily: T.serif, fontSize: 28, fontWeight: 700, color: T.tx }}>3</div>
|
||||
<div style={{ fontSize: 12, color: T.tx3, marginTop: 4 }}>数据类型</div>
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1, background: T.card, borderRadius: T.rSm, padding: '16px',
|
||||
textAlign: 'center', border: `1px solid ${T.bdL}`,
|
||||
}}>
|
||||
<div style={{ fontFamily: T.serif, fontSize: 22, fontWeight: 700, color: T.acc }}>100%</div>
|
||||
<div style={{ fontSize: 12, color: T.tx3, marginTop: 4 }}>成功率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完成按钮 */}
|
||||
<div style={{
|
||||
width: '100%', background: T.pri, borderRadius: T.rSm,
|
||||
padding: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
|
||||
marginTop: 32,
|
||||
}}>
|
||||
<span style={{ color: '#fff', fontSize: 17, fontWeight: 600 }}>完成</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 屏幕七:错误状态 ───
|
||||
function ErrorScreen() {
|
||||
return (
|
||||
<div style={{ height: '100%', background: T.bg, display: 'flex', flexDirection: 'column' }}>
|
||||
<NavBar title="设备同步" dark />
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '24px 20px 100px' }}>
|
||||
{/* 错误图标 */}
|
||||
<div style={{
|
||||
width: 80, height: 80, borderRadius: '50%', background: T.danL,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
<ErrorIcon size={44} color={T.dan} />
|
||||
</div>
|
||||
|
||||
<div style={{ fontFamily: T.serif, fontSize: 22, fontWeight: 700, color: T.tx, marginBottom: 8 }}>
|
||||
连接失败
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: T.tx3, textAlign: 'center', lineHeight: 1.6, maxWidth: 260 }}>
|
||||
无法连接到 Mi Band 8,请检查设备是否在范围内并重试
|
||||
</div>
|
||||
|
||||
{/* 错误详情卡片 */}
|
||||
<div style={{
|
||||
width: '100%', background: T.card, borderRadius: T.rSm,
|
||||
padding: '16px', marginTop: 24, border: `1px solid ${T.bdL}`,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke={T.tx3} strokeWidth="1.5"/>
|
||||
<path d="M12 16v.01M12 8v4" stroke={T.tx3} strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: T.tx }}>错误详情</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: T.tx3, lineHeight: 1.7 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span>错误码</span><span style={{ color: T.tx }}>BLE_TIMEOUT</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span>设备</span><span style={{ color: T.tx }}>Mi Band 8</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>时间</span><span style={{ color: T.tx }}>09:15:32</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 重试按钮 */}
|
||||
<div style={{
|
||||
width: '100%', background: T.pri, borderRadius: T.rSm,
|
||||
padding: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
boxShadow: `0 4px 16px rgba(196, 98, 58, 0.3)`,
|
||||
marginTop: 24,
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M23 4v6h-6M1 20v-6h6" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<span style={{ color: '#fff', fontSize: 17, fontWeight: 600 }}>重新扫描</span>
|
||||
</div>
|
||||
|
||||
{/* 返回按钮 */}
|
||||
<div style={{
|
||||
width: '100%', borderRadius: T.rSm,
|
||||
padding: '14px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
marginTop: 10, border: `1px solid ${T.bd}`,
|
||||
}}>
|
||||
<span style={{ color: T.tx2, fontSize: 16, fontWeight: 500 }}>返回</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 主渲染 ───
|
||||
const screens = [
|
||||
{ label: '空闲态', Component: IdleScreen },
|
||||
{ label: '扫描中', Component: ScanningScreen },
|
||||
{ label: '设备列表', Component: DeviceListScreen },
|
||||
{ label: '连接中', Component: ConnectingScreen },
|
||||
{ label: '已连接', Component: ConnectedScreen },
|
||||
{ label: '同步完成', Component: DoneScreen },
|
||||
{ label: '错误状态', Component: ErrorScreen },
|
||||
];
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="screens">
|
||||
{screens.map(({ label, Component }) => (
|
||||
<div className="screen-wrap" key={label}>
|
||||
<div className="screen-label">{label}</div>
|
||||
<IosFrame width={360} height={780}>
|
||||
<Component />
|
||||
</IosFrame>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
12
docs/design/mp-device-sync-redesign/META.yml
Normal file
12
docs/design/mp-device-sync-redesign/META.yml
Normal file
@@ -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
|
||||
246
docs/design/mp-device-sync-redesign/SPEC.md
Normal file
246
docs/design/mp-device-sync-redesign/SPEC.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# 设备同步页面 设计规格
|
||||
|
||||
> 来源: mp-device-sync-redesign.html | 平台: 小程序(患者端) | 页面数: 7 | 生成: 2026-05-23
|
||||
|
||||
## 页面索引
|
||||
|
||||
| 页面 | 截图 | 路由 |
|
||||
|------|------|------|
|
||||
| 空闲态 |  | pages/pkg-health/device-sync/index |
|
||||
| 扫描中 |  | pages/pkg-health/device-sync/index |
|
||||
| 设备列表 |  | pages/pkg-health/device-sync/index |
|
||||
| 连接中 |  | pages/pkg-health/device-sync/index |
|
||||
| 已连接 |  | pages/pkg-health/device-sync/index |
|
||||
| 同步完成 |  | pages/pkg-health/device-sync/index |
|
||||
| 错误状态 |  | 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)
|
||||
|
||||

|
||||
|
||||
布局层级(从上到下):
|
||||
- **NavBar** — 深色主色背景,标题"设备同步"
|
||||
- **Hero 区域** — 主色渐变背景(135deg pri→priD),包含:
|
||||
- 蓝牙图标(72px 圆形,半透明白底)
|
||||
- 标题"智能设备同步"(serif 22px 700)
|
||||
- 副标题(14px 0.75 白色透明度)
|
||||
- **支持设备** — 三列标签(心率手环/血压计/血糖仪),每个含 SVG 图标
|
||||
- **上次同步卡片** — ContentCard 样式,左侧绿色勾选图标 + 时间 + 右侧数据量 badge
|
||||
- **待上传提示** — 黄色背景警告条($wrnL),三角感叹号图标
|
||||
- **扫描按钮** — 全宽主色按钮,蓝牙图标 + "扫描附近设备"
|
||||
|
||||
### 2. 扫描中(scanning)
|
||||
|
||||

|
||||
|
||||
布局层级:
|
||||
- **NavBar** — 同上
|
||||
- **居中脉冲区域**:
|
||||
- 三层脉冲圆环(CSS animation: pulse-ring),外层→中层→内层递进
|
||||
- 中心 80px 圆形蓝牙图标($priL 底色)
|
||||
- **标题** — serif 20px "正在搜索设备..."
|
||||
- **副文本** — 14px $tx3 提示文字
|
||||
- **进度条** — 180px 宽,渐变填充 $priL→$pri
|
||||
- **计时文字** — 12px "已用时 6 秒"
|
||||
|
||||
### 3. 设备列表(found)
|
||||
|
||||

|
||||
|
||||
布局层级:
|
||||
- **NavBar** — 同上
|
||||
- **结果头部** — 左侧"发现 N 台设备"标题 + 右侧"重新扫描"链接(含刷新图标)
|
||||
- **设备卡片列表**(×3)— 每张卡片含:
|
||||
- 左:44px 圆角方块图标($priL 底色 + 蓝牙 SVG)
|
||||
- 中:设备名(16px 600)+ 适配器类型(12px $tx3)
|
||||
- 右:信号强度条(4 级竖条) + 箭头
|
||||
- **未发现设备提示** — 虚线边框卡片,搜索图标 + 提示文字
|
||||
|
||||
### 4. 连接中(connecting)
|
||||
|
||||

|
||||
|
||||
布局层级:
|
||||
- **NavBar** — 同上
|
||||
- **居中动画区域**:
|
||||
- 100px 旋转环(border-top-color: $pri,CSS animation: connect-spin)
|
||||
- 60px 中心圆形蓝牙图标
|
||||
- **标题** — serif 18px "正在连接 {设备名}"
|
||||
- **副文本** — "正在进行蓝牙配对..."
|
||||
- **步骤指示器** — 三点一线:发现设备(✓) → 连接中(●脉冲) → 同步数据(○)
|
||||
|
||||
### 5. 已连接(connected)
|
||||
|
||||

|
||||
|
||||
布局层级:
|
||||
- **NavBar** — 同上
|
||||
- **连接状态卡片** — 绿色渐变背景(acc→#4A6B4E),蓝牙图标 + 设备名 + "实时" badge
|
||||
- **最新读数高亮卡片** — 大卡片(r=16 圆角 + shadow),含:
|
||||
- 52px 心形图标
|
||||
- 类型+时间小字
|
||||
- 数值(serif 36px 700)+ 单位
|
||||
- **历史读数列表** — 标题 + 表格行(类型/数值/时间),每行 12px 分隔线
|
||||
- **采集计数** — 居中小字
|
||||
- **操作按钮行** — 左侧全宽"上传数据"主色按钮 + 右侧 52px 红色断开按钮
|
||||
|
||||
### 6. 同步完成(done)
|
||||
|
||||

|
||||
|
||||
布局层级:
|
||||
- **NavBar** — 同上
|
||||
- **居中成功区域**:
|
||||
- 80px 绿色圆形勾选图标
|
||||
- 标题"同步完成"(serif 24px 700)
|
||||
- 副文本"数据已安全上传至健康管理平台"
|
||||
- **三列统计卡片** — 上传条数(5)/数据类型(3)/成功率(100%)
|
||||
- **完成按钮** — 全宽主色按钮
|
||||
|
||||
### 7. 错误状态(error)
|
||||
|
||||

|
||||
|
||||
布局层级:
|
||||
- **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. "需新建"的组件参考截图和布局描述从头实现
|
||||
@@ -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)
|
||||
92
docs/discussions/2026-05-22-my-page-subpages-necessity.md
Normal file
92
docs/discussions/2026-05-22-my-page-subpages-necessity.md
Normal file
@@ -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?
|
||||
@@ -60,7 +60,7 @@ HMS 平台的设计围绕一个核心命题:**让患者的健康数据产生
|
||||
└──────────────┼───────────────┘
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ 统一 API 网关 │
|
||||
│ 统一 API 网关 │
|
||||
│ /api/v1/* │
|
||||
│ + /api/v1/fhir/* │
|
||||
└─────────┬─────────┘
|
||||
|
||||
Reference in New Issue
Block a user