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:
iven
2026-05-24 11:32:40 +08:00
parent 675f8a4b10
commit 1e59007bd5
58 changed files with 4950 additions and 494 deletions

View File

@@ -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 中的开发密钥

View File

@@ -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 || '')

View File

@@ -1,4 +1,5 @@
export default defineAppConfig({
lazyCodeLoading: 'requiredComponents',
pages: [
'pages/index/index',
'pages/login/index',

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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 },
);

View File

@@ -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);

View File

@@ -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')}
>

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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');
}

View File

@@ -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);
});
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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) {

View 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;
}

View File

@@ -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;
}

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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,
}

View File

@@ -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")]

View File

@@ -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>,

View File

@@ -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)))

View File

@@ -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 认证)

View File

@@ -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,

View 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)

View File

@@ -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),
]
}
}

View File

@@ -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
}
}

View File

@@ -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(())
}
}

View File

@@ -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
}
}

View File

@@ -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(())
}

View File

@@ -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;

View File

@@ -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"

View 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 2643msrender 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% | Ftoken 问题) |
| 小程序功能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` |

View 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 个

View 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 加载导致 FOUT5 个 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 |

View 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** | 200checked_in_today=true连续 2 天 |
| 咨询列表 | GET /health/consultation-sessions | **PASS** | 2001 条 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 经独立验证全部正常。

View 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 |

View 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 | 小 |

View 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 反馈)

View 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 天时间线 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

View 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>

View 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

View File

@@ -0,0 +1,246 @@
# 设备同步页面 设计规格
> 来源: mp-device-sync-redesign.html | 平台: 小程序(患者端) | 页面数: 7 | 生成: 2026-05-23
## 页面索引
| 页面 | 截图 | 路由 |
|------|------|------|
| 空闲态 | ![空闲态](./screenshots/screen-1.png) | pages/pkg-health/device-sync/index |
| 扫描中 | ![扫描中](./screenshots/screen-2.png) | pages/pkg-health/device-sync/index |
| 设备列表 | ![设备列表](./screenshots/list.png) | pages/pkg-health/device-sync/index |
| 连接中 | ![连接中](./screenshots/screen-4.png) | pages/pkg-health/device-sync/index |
| 已连接 | ![已连接](./screenshots/screen-5.png) | pages/pkg-health/device-sync/index |
| 同步完成 | ![同步完成](./screenshots/screen-6.png) | pages/pkg-health/device-sync/index |
| 错误状态 | ![错误状态](./screenshots/screen-7.png) | pages/pkg-health/device-sync/index |
## 一、Token 映射
| 原型值 | 项目 Token | 状态 |
|--------|-----------|------|
| T.pri (#C4623A) | --tk-pri | ✅ |
| T.priL (#F0DDD4) | --tk-pri-l | ✅ |
| T.priD (#8B3E1F) | --tk-pri-d | ✅ |
| T.bg (#F5F0EB) | $bg SCSS 变量 | ⚠️ 无 CSS Token直接用 $bg |
| T.card (#FFFFFF) | --tk-card-bg ($card) | ✅ |
| T.surface (#EDE8E2) | --tk-card-bg (≈) | ⚠️ 近似,用 $surface-alt SCSS 变量 |
| T.tx (#2D2A26) | $tx SCSS 变量 | ⚠️ 无 CSS Token直接用 $tx |
| T.tx2 (#5A554F) | $tx2 SCSS 变量 | ⚠️ 无 CSS Token直接用 $tx2 |
| T.tx3 (#78716C) | --tk-text-secondary ($tx3) | ✅ |
| T.bd (#E8E2DC) | $bd SCSS 变量 | ⚠️ 无 CSS Token直接用 $bd |
| T.bdL (#F0EBE5) | $bd-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.acc (#5B7A5E) | $acc SCSS 变量 | ⚠️ 无 CSS Token |
| T.accL (#E8F0E8) | $acc-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.wrn (#C4873A) | $wrn SCSS 变量 | ⚠️ 无 CSS Token |
| T.wrnL (#FFF3E0) | $wrn-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.dan (#B54A4A) | $dan SCSS 变量 | ⚠️ 无 CSS Token |
| T.danL (#FDEAEA) | $dan-l SCSS 变量 | ⚠️ 无 CSS Token |
| T.r (16) | --tk-card-radius ($r) | ✅ |
| T.rSm (12) | $r-sm SCSS 变量 | ⚠️ 无 CSS Token |
| T.rXs (8) | $r-xs SCSS 变量 | ⚠️ 无 CSS Token |
| T.serif (Georgia...) | 字体栈 | ❌ 不映射,直接硬编码 |
| T.sans (-apple-system...) | 字体栈 | ❌ 不映射,直接硬编码 |
> 状态标记: ✅ confirmed 直接使用 | ⚠️ pending 需 SCSS 变量 | ❌ unmatched 需硬编码
## 二、页面结构
### 1. 空闲态idle
![空闲态](./screenshots/screen-1.png)
布局层级(从上到下):
- **NavBar** — 深色主色背景,标题"设备同步"
- **Hero 区域** — 主色渐变背景135deg pri→priD包含
- 蓝牙图标72px 圆形,半透明白底)
- 标题"智能设备同步"serif 22px 700
- 副标题14px 0.75 白色透明度)
- **支持设备** — 三列标签(心率手环/血压计/血糖仪),每个含 SVG 图标
- **上次同步卡片** — ContentCard 样式,左侧绿色勾选图标 + 时间 + 右侧数据量 badge
- **待上传提示** — 黄色背景警告条($wrnL三角感叹号图标
- **扫描按钮** — 全宽主色按钮,蓝牙图标 + "扫描附近设备"
### 2. 扫描中scanning
![扫描中](./screenshots/screen-2.png)
布局层级:
- **NavBar** — 同上
- **居中脉冲区域**
- 三层脉冲圆环CSS animation: pulse-ring外层→中层→内层递进
- 中心 80px 圆形蓝牙图标($priL 底色)
- **标题** — serif 20px "正在搜索设备..."
- **副文本** — 14px $tx3 提示文字
- **进度条** — 180px 宽,渐变填充 $priL→$pri
- **计时文字** — 12px "已用时 6 秒"
### 3. 设备列表found
![设备列表](./screenshots/list.png)
布局层级:
- **NavBar** — 同上
- **结果头部** — 左侧"发现 N 台设备"标题 + 右侧"重新扫描"链接(含刷新图标)
- **设备卡片列表**×3— 每张卡片含:
-44px 圆角方块图标($priL 底色 + 蓝牙 SVG
-设备名16px 600+ 适配器类型12px $tx3
-信号强度条4 级竖条) + 箭头
- **未发现设备提示** — 虚线边框卡片,搜索图标 + 提示文字
### 4. 连接中connecting
![连接中](./screenshots/screen-4.png)
布局层级:
- **NavBar** — 同上
- **居中动画区域**
- 100px 旋转环border-top-color: $priCSS animation: connect-spin
- 60px 中心圆形蓝牙图标
- **标题** — serif 18px "正在连接 {设备名}"
- **副文本** — "正在进行蓝牙配对..."
- **步骤指示器** — 三点一线:发现设备(✓) → 连接中(●脉冲) → 同步数据(○)
### 5. 已连接connected
![已连接](./screenshots/screen-5.png)
布局层级:
- **NavBar** — 同上
- **连接状态卡片** — 绿色渐变背景acc→#4A6B4E),蓝牙图标 + 设备名 + "实时" badge
- **最新读数高亮卡片** — 大卡片r=16 圆角 + shadow
- 52px 心形图标
- 类型+时间小字
- 数值serif 36px 700+ 单位
- **历史读数列表** — 标题 + 表格行(类型/数值/时间),每行 12px 分隔线
- **采集计数** — 居中小字
- **操作按钮行** — 左侧全宽"上传数据"主色按钮 + 右侧 52px 红色断开按钮
### 6. 同步完成done
![同步完成](./screenshots/screen-6.png)
布局层级:
- **NavBar** — 同上
- **居中成功区域**
- 80px 绿色圆形勾选图标
- 标题"同步完成"serif 24px 700
- 副文本"数据已安全上传至健康管理平台"
- **三列统计卡片** — 上传条数(5)/数据类型(3)/成功率(100%)
- **完成按钮** — 全宽主色按钮
### 7. 错误状态error
![错误状态](./screenshots/screen-7.png)
布局层级:
- **NavBar** — 同上
- **居中错误区域**
- 80px 红色圆形叉号图标
- 标题"连接失败"serif 22px 700
- 错误描述文字
- **错误详情卡片** — 含错误码/设备/时间三行键值对
- **重试按钮** — 全宽主色按钮,含刷新图标
- **返回按钮** — 描边按钮
## 三、组件映射
| 原型元素 | 推荐组件 | 来源 | 备注 |
|----------|---------|------|------|
| 页面外壳 | PageShell | @components/ui/PageShell | padding="none"NavBar 自带 |
| 连接状态卡片 | ContentCard | @components/ui/ContentCard | variant="elevated",绿色渐变背景自定义 |
| 成功结果卡片 | ContentCard | @components/ui/ContentCard | variant="elevated",居中布局 |
| 错误详情卡片 | ContentCard | @components/ui/ContentCard | variant="outlined" |
| 扫描按钮/上传按钮 | PrimaryButton | @components/ui/PrimaryButton | size="large"full width |
| 断开连接按钮 | — | 自定义 | 红色小方块图标按钮 |
| 返回按钮 | SecondaryButton | @components/ui/SecondaryButton | — |
| 设备类型标签 | — | 自定义 DeviceTypeTag | 小图标+文字,$bdL 边框 |
| 信号强度 | — | 自定义 SignalBars | 4 级竖条 |
| 上次同步信息 | ListItem | @components/ui/ListItem | leftIcon + title + subtitle + extra |
| 历史读数行 | InfoRow | @components/ui/InfoRow | label + value + last |
| 待上传警告 | AlertCard | @components/ui/AlertCard | variant="bordered",黄色 |
> ⚠️ **需新建**: SignalBars — 4 级竖条信号强度指示器20 行以内小组件)
> ⚠️ **需新建**: DeviceTypeTag — 设备类型标签(图标+文字,已非常简单,可直接内联)
## 四、交互规格
| 元素 | 交互 | 触发 | 反馈 | 备注 |
|------|------|------|------|------|
| 扫描按钮 | 调用 handleScan | onClick | 按钮变灰+loading状态→scanning | 触发 BLE 扫描 |
| 设备卡片 | 调用 handleConnect | onClick | 状态→connecting显示旋转动画 | 传递选中的 BLEDevice |
| 重新扫描链接 | 调用 handleScan | onClick | 同扫描按钮 | 刷新设备列表 |
| 上传数据按钮 | 调用 handleSync | onClick | 状态→syncing → done/error | 上传采集数据到后端 |
| 断开连接按钮 | 调用 handleDisconnect | onClick | 断开 BLE状态→idle | 清空 liveReadings |
| 完成按钮 | handleDisconnect + navigateBack | onClick | 返回上一页 | 如果 returnTo=input 则回填 Storage |
| 重试按钮 | handleScan | onClick | 重新扫描 | 从 error 恢复 |
| 返回按钮 | Taro.navigateBack | onClick | 返回上一页 | 错误状态 |
| 实时数据面板 | 被动更新 | BLE 通知 | 新数据插入列表顶部,数值动画 | useBLEManager hook 驱动 |
## 五、状态变体
- **idle**: 默认状态,展示 Hero + 设备类型 + 上次同步 + 扫描按钮
- **scanning**: 脉冲动画 + 进度条 + 计时,不可操作(无按钮)
- **found**: 设备列表 + 重新扫描链接,点击设备进入 connecting
- **connecting**: 旋转环动画 + 步骤指示器,不可操作
- **connected**: 绿色连接状态卡 + 实时数据面板 + 上传/断开按钮
- **done**: 成功图标 + 统计卡片 + 完成按钮
- **error**: 错误图标 + 错误详情 + 重试/返回按钮
- **syncing**: 复用 scanning 的加载态样式,文字改为"正在上传数据..."
## 六、样式清单
### 关键样式参数
```
/* Hero 渐变 */
background: linear-gradient(135deg, $pri 0%, $pri-d 100%)
padding: 32px 20px 28px
/* 脉冲圆环 */
animation: pulse-ring 2s ease-out infinite
三层: 140px / 110px / 80px (center)
/* 旋转环 */
animation: connect-spin 1s linear infinite
border-top-color: $pri
/* 最新读数数值 */
font-family: $serif; font-size: 36px; font-weight: 700
/* 连接状态卡片渐变 */
background: linear-gradient(135deg, $acc 0%, #4A6B4E 100%)
/* 信号条 */
4 根竖条: height [4, 7, 10, 13]px, width: 3px, gap: 2px
活跃色: $acc, 非活跃: $bd
/* 主按钮 */
background: $pri; border-radius: $r-sm; padding: 16px;
box-shadow: 0 4px 16px rgba(196, 98, 58, 0.3)
```
### 字号映射
| 原型字号 | Token | 用途 |
|---------|-------|------|
| 36px | 超大数值,直接用 serif bold | 最新读数数值 |
| 28px | --tk-font-h1 | 统计卡片数值 |
| 24px | — | 成功/错误标题 |
| 22px | --tk-font-h2 | Hero 标题、连接中标题 |
| 20px | — | 历史读数数值 |
| 18px | --tk-font-body-lg | NavBar 标题、按钮文字 |
| 17px | — | 主按钮文字 |
| 16px | --tk-font-body | 设备名、按钮文字 |
| 15px | — | 完成页副文本 |
| 14px | --tk-font-body-sm | 副文本、描述、列表类型 |
| 13px | --tk-font-cap | 标签文字、小字 |
| 12px | — | 时间、提示 |
---
> 此规格由 design-handoff skill 自动生成。LLM 实施时请:
> 1. 先阅读截图建立视觉印象
> 2. 按 Token 映射表使用项目 Token✅ 标记的直接用,⚠️ 用 SCSS 变量)
> 3. 优先使用"组件映射"中列出的已有组件
> 4. 参考"交互规格"实现对应的交互逻辑
> 5. "需新建"的组件参考截图和布局描述从头实现

View File

@@ -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 明文存储在 Storagesecure-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. 确认后端不信任前端 Header2h
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 聊天页接入 PageShell2h
11. 移除 forceSetAuth bridge0.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

View 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

View File

@@ -60,7 +60,7 @@ HMS 平台的设计围绕一个核心命题:**让患者的健康数据产生
└──────────────┼───────────────┘
┌─────────┴─────────┐
│ 统一 API 网关
│ 统一 API 网关 │
│ /api/v1/* │
│ + /api/v1/fhir/* │
└─────────┬─────────┘