fix(mp): DevTools 卡死 + 主包 2MB→766KB + 代码质量 4 项全通过
根因:主包 2MB 全量组件注入导致 DevTools 渲染引擎内存渐增, 叠加离线时固定 3s 抑制期后的请求洪泛。 修复: - app.config.ts 添加 lazyCodeLoading: requiredComponents 主包 2.0MB→766KB,taro.js 526→131KB,vendors.js 230→28KB - request.ts 离线抑制改为指数退避(3s→6s→12s→30s cap) 后端不可达时自动延长抑制,防止请求风暴 - SegmentTabs Tab 接口改为 readonly,修复 TS 编译错误 - AbortController polyfill 补齐小程序运行时缺失 - 健康首页/设备同步/健康档案/报告/设置页 UI 重构 - 文章页公开端点适配游客访问 - 健康首页 Swiper 间隔优化 4s→5s,动画 500→300ms
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export default defineAppConfig({
|
||||
lazyCodeLoading: 'requiredComponents',
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/login/index',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './utils/crypto-polyfill';
|
||||
import './utils/abort-controller-polyfill';
|
||||
import { useEffect, useRef, PropsWithChildren } from 'react';
|
||||
import { useDidShow, useDidHide } from '@tarojs/taro';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
|
||||
@@ -3,12 +3,12 @@ import { View, Text } from '@tarojs/components';
|
||||
import './index.scss';
|
||||
|
||||
interface Tab {
|
||||
key: string;
|
||||
label: string;
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
interface SegmentTabsProps {
|
||||
tabs: Tab[];
|
||||
tabs: readonly Tab[];
|
||||
activeKey: string;
|
||||
onChange: (key: string) => void;
|
||||
variant?: 'underline' | 'pill';
|
||||
|
||||
@@ -79,8 +79,14 @@ export function useAlertPolling() {
|
||||
s.lastAlertCount = count;
|
||||
|
||||
failCount = 0;
|
||||
} catch {
|
||||
failCount++;
|
||||
} catch (err) {
|
||||
// 权限不足时立即停止轮询,不再重试(避免反复弹 toast)
|
||||
if (err instanceof Error && err.message === '权限不足') {
|
||||
s.failCount = MAX_FAILURES;
|
||||
return;
|
||||
}
|
||||
// 网络异常时快速累积失败计数(离线抑制下会在 3s 内快速耗尽重试)
|
||||
failCount += 3;
|
||||
}
|
||||
|
||||
if (gen !== s.generation) return;
|
||||
|
||||
@@ -3,7 +3,13 @@ import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { listArticles, listCategories } from '../../services/article';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
listArticles,
|
||||
listCategories,
|
||||
listPublicArticles,
|
||||
listPublicCategories,
|
||||
} from '../../services/article';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import LoadingCard from '@/components/ui/LoadingCard';
|
||||
@@ -33,6 +39,7 @@ interface ArticleCategory {
|
||||
|
||||
export default function ArticleList() {
|
||||
const modeClass = useElderClass();
|
||||
const isLoggedIn = !!useAuthStore((s) => s.user);
|
||||
const [articles, setArticles] = useState<ArticleItem[]>([]);
|
||||
const [, setPage] = useState(1);
|
||||
const [, setTotal] = useState(0);
|
||||
@@ -46,10 +53,9 @@ export default function ArticleList() {
|
||||
setError(false);
|
||||
try {
|
||||
const cid = categoryId !== undefined ? categoryId : activeCategory;
|
||||
const res = await listArticles({
|
||||
page: p,
|
||||
category_id: cid || undefined,
|
||||
});
|
||||
const res = isLoggedIn
|
||||
? await listArticles({ page: p, category_id: cid || undefined })
|
||||
: await listPublicArticles({ page: p, category_id: cid || undefined });
|
||||
const list = res.data || [];
|
||||
setArticles(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
@@ -61,19 +67,21 @@ export default function ArticleList() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeCategory]);
|
||||
}, [activeCategory, isLoggedIn]);
|
||||
|
||||
usePageData(
|
||||
useCallback(async () => {
|
||||
try {
|
||||
const cats = await listCategories();
|
||||
const cats = isLoggedIn
|
||||
? await listCategories()
|
||||
: await listPublicCategories();
|
||||
setCategories(cats || []);
|
||||
} catch (err) {
|
||||
console.warn('[article] 加载分类失败:', err);
|
||||
setCategories([]);
|
||||
}
|
||||
await fetchData(1);
|
||||
}, [fetchData]),
|
||||
}, [fetchData, isLoggedIn]),
|
||||
{ throttleMs: 10000, enablePullDown: true },
|
||||
);
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
|
||||
/* ─── 页头 ─── */
|
||||
.health-header {
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
margin-bottom: var(--tk-gap-sm);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.health-title {
|
||||
@@ -15,11 +18,50 @@
|
||||
font-size: var(--tk-font-h1);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* ─── 今日体征摘要 ─── */
|
||||
.health-date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
/* ─── 今日体征 hero 卡片 ─── */
|
||||
.vitals-grid {
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
background: linear-gradient(135deg, $card 60%, $pri-l);
|
||||
border-radius: var(--tk-card-radius);
|
||||
box-shadow: $shadow-md;
|
||||
padding: var(--tk-card-padding);
|
||||
|
||||
/* 覆盖 ContentCard 默认 padding/margin */
|
||||
&.content-card {
|
||||
padding: var(--tk-card-padding);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
}
|
||||
|
||||
.vitals-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.vitals-title {
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $tx2;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.vitals-badge {
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $acc;
|
||||
background: $acc-l;
|
||||
padding: 3px 10px;
|
||||
border-radius: $r-pill;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vitals-row {
|
||||
@@ -30,9 +72,13 @@
|
||||
|
||||
.vital-cell {
|
||||
text-align: center;
|
||||
padding: var(--tk-gap-sm);
|
||||
padding: var(--tk-gap-md) var(--tk-gap-sm);
|
||||
border-radius: $r-sm;
|
||||
background: $bg;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
@@ -40,7 +86,9 @@
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: 700;
|
||||
color: $tx;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: block;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
@@ -53,8 +101,9 @@
|
||||
.vital-label {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.vital-cell.vital-warn {
|
||||
@@ -71,11 +120,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 快捷入口 ─── */
|
||||
/* ─── 快捷入口 — 横排 4 格图标 ─── */
|
||||
.quick-entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--tk-gap-sm);
|
||||
gap: var(--tk-gap-xs);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
}
|
||||
|
||||
@@ -86,6 +135,7 @@
|
||||
gap: var(--tk-gap-xs);
|
||||
min-height: var(--tk-touch-min);
|
||||
justify-content: center;
|
||||
padding: var(--tk-gap-sm) 0;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
@@ -93,17 +143,47 @@
|
||||
}
|
||||
|
||||
.quick-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r;
|
||||
background: var(--tk-pri-l);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: $r-sm;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.quick-icon-text {
|
||||
font-size: var(--tk-font-body);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--tk-pri);
|
||||
}
|
||||
|
||||
.quick-icon--input {
|
||||
background: $pri-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $pri;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--trend {
|
||||
background: $doc-pri-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $doc-pri;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--report {
|
||||
background: $acc-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $acc;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-icon--med {
|
||||
background: $wrn-l;
|
||||
|
||||
.quick-icon-text {
|
||||
color: $wrn;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-label {
|
||||
@@ -112,12 +192,21 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── 告警提示 ─── */
|
||||
/* ─── 告警横幅 ─── */
|
||||
.alert-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--tk-gap-sm);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
background: $dan-l;
|
||||
border-radius: $r-sm;
|
||||
|
||||
/* 覆盖 ContentCard 默认样式 */
|
||||
&.content-card {
|
||||
background: $dan-l;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
@@ -125,8 +214,8 @@
|
||||
}
|
||||
|
||||
.alert-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: $dan;
|
||||
flex-shrink: 0;
|
||||
@@ -141,8 +230,9 @@
|
||||
|
||||
.alert-arrow {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx3;
|
||||
color: $dan;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ─── 趋势图 ─── */
|
||||
@@ -183,7 +273,7 @@
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
border-top: 1.5px dashed $wrn;
|
||||
opacity: 0.6;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -193,7 +283,7 @@
|
||||
top: -16px;
|
||||
font-size: var(--tk-font-micro);
|
||||
color: $wrn;
|
||||
opacity: 0.8;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.trend-bar-col {
|
||||
@@ -206,17 +296,18 @@
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
width: 28px;
|
||||
width: 24px;
|
||||
border-radius: $r-xs $r-xs 0 0;
|
||||
min-height: 8px;
|
||||
opacity: 0.8;
|
||||
min-height: 6px;
|
||||
|
||||
&.trend-bar-normal {
|
||||
background: var(--tk-pri);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.trend-bar-warn {
|
||||
background: $wrn;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,30 +377,42 @@
|
||||
}
|
||||
|
||||
.article-entry-text {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-size: var(--tk-font-body-sm);
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── AI 建议卡片 ─── */
|
||||
.ai-suggestion-card {
|
||||
background: $acc-l;
|
||||
background: linear-gradient(135deg, #F0F7F0 0%, $acc-l 100%);
|
||||
border-radius: $r;
|
||||
padding: var(--tk-gap-md);
|
||||
padding: var(--tk-card-padding);
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
box-shadow: none;
|
||||
border-left: 4px solid $acc;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, $acc, $acc 60%, transparent);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
margin-bottom: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.ai-card-title {
|
||||
font-size: var(--tk-font-cap);
|
||||
font-size: var(--tk-font-body-sm);
|
||||
font-weight: 600;
|
||||
color: $acc;
|
||||
}
|
||||
@@ -321,8 +424,8 @@
|
||||
}
|
||||
|
||||
.ai-suggestion-item {
|
||||
padding: var(--tk-gap-xs) 0;
|
||||
border-bottom: 1px solid rgba($acc, 0.15);
|
||||
padding: var(--tk-gap-sm) 0;
|
||||
border-bottom: 1px solid rgba($acc, 0.12);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
@@ -331,7 +434,7 @@
|
||||
|
||||
.ai-suggestion-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--tk-gap-xs);
|
||||
|
||||
&:active {
|
||||
@@ -344,6 +447,7 @@
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
|
||||
&.ai-risk-high {
|
||||
background: $dan;
|
||||
@@ -362,19 +466,20 @@
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx2;
|
||||
line-height: 1.6;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ─── AI 建议反馈按钮 ─── */
|
||||
.ai-feedback-row {
|
||||
display: flex;
|
||||
gap: var(--tk-gap-xs);
|
||||
margin-top: var(--tk-gap-2xs);
|
||||
margin-top: var(--tk-gap-xs);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.ai-feedback-btn {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
border-radius: $r-xs;
|
||||
@include flex-center;
|
||||
padding: 0 var(--tk-gap-sm);
|
||||
|
||||
@@ -13,10 +13,10 @@ import { submitSuggestionFeedback } from '../../services/ai-analysis';
|
||||
import './index.scss';
|
||||
|
||||
const QUICK_ENTRIES = [
|
||||
{ label: '录入体征', icon: '笔', path: '/pages/pkg-health/input/index' },
|
||||
{ label: '健康趋势', icon: '线', path: '/pages/pkg-health/trend/index' },
|
||||
{ label: '我的报告', icon: '报', path: '/pages/pkg-profile/reports/index' },
|
||||
{ label: '用药记录', icon: '药', path: '/pages/pkg-profile/medication/index' },
|
||||
{ label: '录入体征', icon: '✏', color: 'input', path: '/pages/pkg-health/input/index' },
|
||||
{ label: '健康趋势', icon: '📈', color: 'trend', path: '/pages/pkg-health/trend/index' },
|
||||
{ label: '我的报告', icon: '📋', color: 'report', path: '/pages/pkg-profile/reports/index' },
|
||||
{ label: '健康档案', icon: '健', color: 'med', path: '/pages/pkg-profile/health-records/index' },
|
||||
] as const;
|
||||
|
||||
function statusClass(status?: string): string {
|
||||
@@ -26,6 +26,14 @@ function statusClass(status?: string): string {
|
||||
return 'vital-ok';
|
||||
}
|
||||
|
||||
function formatDate(): string {
|
||||
const d = new Date();
|
||||
const month = d.getMonth() + 1;
|
||||
const day = d.getDate();
|
||||
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return `${month}月${day}日 周${weekDays[d.getDay()]}`;
|
||||
}
|
||||
|
||||
export default function Health() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const modeClass = useElderClass();
|
||||
@@ -59,6 +67,7 @@ export default function Health() {
|
||||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status },
|
||||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status },
|
||||
];
|
||||
const recordedCount = vitals.filter((v) => v.value !== '—').length;
|
||||
|
||||
const getThresholdValue = (type: VitalType): number | null => {
|
||||
if (!thresholds.length) return null;
|
||||
@@ -82,10 +91,17 @@ export default function Health() {
|
||||
<PageShell padding="md" safeBottom={false} scroll className={`health-page ${modeClass}`}>
|
||||
<View className='health-header'>
|
||||
<Text className='health-title'>健康总览</Text>
|
||||
<Text className='health-date'>{formatDate()}</Text>
|
||||
</View>
|
||||
|
||||
{/* 今日体征摘要 */}
|
||||
<ContentCard variant="elevated" className='vitals-grid'>
|
||||
{/* 今日体征 hero 卡片 */}
|
||||
<View className='vitals-grid'>
|
||||
<View className='vitals-header'>
|
||||
<Text className='vitals-title'>今日体征</Text>
|
||||
{recordedCount > 0 && (
|
||||
<Text className='vitals-badge'>已记录 {recordedCount} 项</Text>
|
||||
)}
|
||||
</View>
|
||||
{loading ? <Loading /> : (
|
||||
<View className='vitals-row'>
|
||||
{vitals.map((v) => (
|
||||
@@ -97,9 +113,9 @@ export default function Health() {
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ContentCard>
|
||||
</View>
|
||||
|
||||
{/* 快捷入口 */}
|
||||
{/* 快捷入口 — 横排 4 格图标 */}
|
||||
<View className='quick-entries'>
|
||||
{QUICK_ENTRIES.map((e) => (
|
||||
<View
|
||||
@@ -107,7 +123,7 @@ export default function Health() {
|
||||
className='quick-entry'
|
||||
onClick={() => safeNavigateTo(e.path)}
|
||||
>
|
||||
<View className='quick-icon'>
|
||||
<View className={`quick-icon quick-icon--${e.color}`}>
|
||||
<Text className='quick-icon-text'>{e.icon}</Text>
|
||||
</View>
|
||||
<Text className='quick-label'>{e.label}</Text>
|
||||
@@ -115,10 +131,12 @@ export default function Health() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 告警提示 */}
|
||||
{/* 告警横幅 */}
|
||||
{alertCount > 0 && (
|
||||
<ContentCard
|
||||
variant="elevated"
|
||||
variant="default"
|
||||
padding="sm"
|
||||
margin="none"
|
||||
className='alert-hint'
|
||||
onPress={() => safeNavigateTo('/pages/pkg-health/alerts/index')}
|
||||
>
|
||||
|
||||
@@ -105,8 +105,8 @@ function GuestHome({ modeClass }: { modeClass: string }) {
|
||||
indicatorActiveColor='#FFFFFF'
|
||||
autoplay={swiperAutoplay}
|
||||
circular
|
||||
interval={4000}
|
||||
duration={500}
|
||||
interval={5000}
|
||||
duration={300}
|
||||
>
|
||||
{slides.map((slide, idx) => (
|
||||
<SwiperItem key={slide.id || idx}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import { BLEManager } from '@/services/ble/BLEManager';
|
||||
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
|
||||
import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter';
|
||||
import { GlucoseMeterAdapter } from '@/services/ble/adapters/GlucoseMeterAdapter';
|
||||
import { CustomBandAdapter } from '@/services/ble/adapters/GenericBleAdapter';
|
||||
import { CustomBandAdapter, HuaweiBandAdapter, FallbackAdapter } from '@/services/ble/adapters/GenericBleAdapter';
|
||||
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
@@ -14,12 +14,58 @@ import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
|
||||
import { useElderClass } from '@/hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import PrimaryButton from '@/components/ui/PrimaryButton';
|
||||
import './index.scss';
|
||||
|
||||
/** liveReadings 最大保留条数,防止内存无限增长 */
|
||||
const MAX_LIVE_READINGS = 200;
|
||||
|
||||
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
|
||||
type PageState = 'idle' | 'scanning' | 'found' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
|
||||
|
||||
const DEVICE_TYPE_MAP: Record<string, { label: string; unit: string }> = {
|
||||
heart_rate: { label: '心率', unit: 'bpm' },
|
||||
blood_pressure: { label: '血压', unit: 'mmHg' },
|
||||
blood_glucose: { label: '血糖', unit: 'mmol/L' },
|
||||
blood_oxygen: { label: '血氧', unit: '%' },
|
||||
temperature: { label: '体温', unit: '°C' },
|
||||
steps: { label: '步数', unit: '步' },
|
||||
sleep: { label: '睡眠', unit: 'h' },
|
||||
stress: { label: '压力', unit: '' },
|
||||
};
|
||||
|
||||
function formatReadingValue(r: NormalizedReading): string {
|
||||
if (r.device_type === 'heart_rate' && typeof r.values.heart_rate === 'number') {
|
||||
return String(r.values.heart_rate);
|
||||
}
|
||||
if (r.device_type === 'blood_pressure') {
|
||||
if (typeof r.values.systolic === 'number' && typeof r.values.diastolic === 'number') {
|
||||
return `${r.values.systolic}/${r.values.diastolic}`;
|
||||
}
|
||||
if (r.metric === 'systolic' && typeof r.values.value === 'number') return String(r.values.value);
|
||||
if (r.metric === 'diastolic' && typeof r.values.value === 'number') return String(r.values.value);
|
||||
}
|
||||
if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') {
|
||||
return String(r.values.blood_glucose);
|
||||
}
|
||||
if (typeof r.values.value === 'number') return String(r.values.value);
|
||||
return '--';
|
||||
}
|
||||
|
||||
function getReadingUnit(r: NormalizedReading): string {
|
||||
const mapped = DEVICE_TYPE_MAP[r.device_type];
|
||||
if (mapped) return mapped.unit;
|
||||
return typeof r.values.unit === 'string' ? r.values.unit : '';
|
||||
}
|
||||
|
||||
function getReadingLabel(r: NormalizedReading): string {
|
||||
const mapped = DEVICE_TYPE_MAP[r.device_type];
|
||||
if (!mapped) return r.device_type;
|
||||
if (r.device_type === 'blood_pressure') {
|
||||
if (r.metric === 'systolic') return '收缩压';
|
||||
if (r.metric === 'diastolic') return '舒张压';
|
||||
return '血压';
|
||||
}
|
||||
return mapped.label;
|
||||
}
|
||||
|
||||
export default function DeviceSync() {
|
||||
const modeClass = useElderClass();
|
||||
@@ -35,15 +81,14 @@ export default function DeviceSync() {
|
||||
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
const scheduler = useMemo(() => new DataSyncScheduler({
|
||||
intervalMs: 60 * 60 * 1000,
|
||||
}), []);
|
||||
const scheduler = useMemo(() => new DataSyncScheduler({ intervalMs: 60 * 60 * 1000 }), []);
|
||||
|
||||
const bleManagerRef = useRef<BLEManager | null>(null);
|
||||
const getBleManager = useCallback(() => {
|
||||
if (!bleManagerRef.current) {
|
||||
const mgr = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
|
||||
mgr.registerAdapter(XiaomiBandAdapter);
|
||||
mgr.registerAdapter(HuaweiBandAdapter);
|
||||
mgr.registerAdapter(BloodPressureAdapter);
|
||||
mgr.registerAdapter(GlucoseMeterAdapter);
|
||||
mgr.registerAdapter(CustomBandAdapter);
|
||||
@@ -53,7 +98,12 @@ export default function DeviceSync() {
|
||||
}, []);
|
||||
|
||||
useThrottledDidShow(() => {
|
||||
const bleManager = getBleManager();
|
||||
let bleManager: BLEManager;
|
||||
try {
|
||||
bleManager = getBleManager();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
bleManager.setOnConnectionChange(() => {});
|
||||
bleManager.setOnReadings((readings) => {
|
||||
setLiveReadings((prev) => {
|
||||
@@ -61,17 +111,8 @@ export default function DeviceSync() {
|
||||
return merged.length > MAX_LIVE_READINGS ? merged.slice(-MAX_LIVE_READINGS) : merged;
|
||||
});
|
||||
});
|
||||
|
||||
// 显示上次同步时间
|
||||
setLastSyncAt(scheduler.getLastSyncAt());
|
||||
|
||||
// 检查是否有未上传的缓冲数据
|
||||
const buffer = (bleManager as unknown as { dataBuffer?: Map<string, number> }).dataBuffer;
|
||||
if (buffer) {
|
||||
setPendingCount(buffer.size);
|
||||
}
|
||||
|
||||
// 自动同步:超过间隔时尝试上传缓冲数据
|
||||
if (currentPatient && scheduler.needsSync()) {
|
||||
scheduler.tryAutoSync(async () => {
|
||||
const count = await bleManager.flushPendingReadings(async (readings) => {
|
||||
@@ -95,18 +136,23 @@ export default function DeviceSync() {
|
||||
}, [scheduler]);
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
console.log('[device-sync] 用户点击扫描按钮');
|
||||
setPageState('scanning');
|
||||
setDevices([]);
|
||||
setErrorMsg('');
|
||||
try {
|
||||
const found = await getBleManager().scanDevices();
|
||||
setDevices(found);
|
||||
if (found.length === 0) {
|
||||
setErrorMsg('未发现支持的设备,请确认设备已开启蓝牙并靠近手机');
|
||||
}
|
||||
setPageState('idle');
|
||||
console.log('[device-sync] 扫描返回设备数:', found.length);
|
||||
// 未匹配适配器的设备分配 FallbackAdapter(尝试标准健康协议)
|
||||
const withFallback = found.map((d) =>
|
||||
d.adapter ? d : { ...d, adapter: FallbackAdapter },
|
||||
);
|
||||
setDevices(withFallback);
|
||||
setPageState('found');
|
||||
} catch (e: unknown) {
|
||||
setErrorMsg(e instanceof Error ? e.message : '扫描失败');
|
||||
const msg = e instanceof Error ? e.message : '扫描失败';
|
||||
console.error('[device-sync] 扫描异常:', msg);
|
||||
setErrorMsg(msg);
|
||||
setPageState('error');
|
||||
}
|
||||
}, []);
|
||||
@@ -126,33 +172,22 @@ export default function DeviceSync() {
|
||||
|
||||
const handleSync = useCallback(async () => {
|
||||
if (!currentPatient || !selectedDevice) return;
|
||||
|
||||
setPageState('syncing');
|
||||
setErrorMsg('');
|
||||
|
||||
try {
|
||||
const result = await getBleManager().syncToServer(async (readings) => {
|
||||
return uploadReadings(
|
||||
currentPatient.id,
|
||||
selectedDevice.deviceId,
|
||||
selectedDevice.name,
|
||||
readings,
|
||||
);
|
||||
return uploadReadings(currentPatient.id, selectedDevice.deviceId, selectedDevice.name, readings);
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setSyncCount(result.uploadedCount);
|
||||
setLastSyncAt(Date.now());
|
||||
setPageState('done');
|
||||
|
||||
// 如果从体征录入页跳转而来,将最新读数写入 storage 供回填
|
||||
if (returnTo === 'input' && liveReadings.length > 0) {
|
||||
const mapped: Record<string, number> = {};
|
||||
for (const r of liveReadings) {
|
||||
if (r.device_type === 'blood_pressure') {
|
||||
if (r.metric === 'systolic' && typeof r.values.value === 'number') mapped.systolic = r.values.value;
|
||||
if (r.metric === 'diastolic' && typeof r.values.value === 'number') mapped.diastolic = r.values.value;
|
||||
// 兼容 values 中直接包含 systolic/diastolic 的格式
|
||||
if (typeof r.values.systolic === 'number') mapped.systolic = r.values.systolic as number;
|
||||
if (typeof r.values.diastolic === 'number') mapped.diastolic = r.values.diastolic as number;
|
||||
} else if (r.device_type === 'blood_glucose' && typeof r.values.blood_glucose === 'number') {
|
||||
@@ -184,141 +219,325 @@ export default function DeviceSync() {
|
||||
setErrorMsg('');
|
||||
}, []);
|
||||
|
||||
const renderIdle = () => (
|
||||
<View className="sync-section">
|
||||
<View className="sync-hero">
|
||||
<Text className="sync-hero-icon">D</Text>
|
||||
<Text className="sync-hero-title">设备同步</Text>
|
||||
<Text className="sync-hero-desc">连接智能手环、血压计、血糖仪,自动采集健康数据</Text>
|
||||
const latestReading = liveReadings.length > 0 ? liveReadings[liveReadings.length - 1] : null;
|
||||
|
||||
// ── 渲染子区域 ──
|
||||
|
||||
const renderHero = () => (
|
||||
<View className="ds-hero">
|
||||
<View className="ds-hero__icon">
|
||||
<Text className="ds-hero__bt">BT</Text>
|
||||
</View>
|
||||
|
||||
{(lastSyncAt || pendingCount > 0) && (
|
||||
<View className="sync-status-info">
|
||||
{lastSyncAt && (
|
||||
<Text className="sync-status-time">
|
||||
上次同步: {new Date(lastSyncAt).toLocaleTimeString()}
|
||||
</Text>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<Text className="sync-status-pending">
|
||||
{pendingCount} 条数据待上传
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="sync-action" onClick={handleScan}>
|
||||
<Text className="sync-action-text">扫描设备</Text>
|
||||
</View>
|
||||
|
||||
{devices.length > 0 && (
|
||||
<View className="sync-device-list">
|
||||
<Text className="sync-section-title">发现的设备</Text>
|
||||
{devices.map((d) => (
|
||||
<View
|
||||
key={d.deviceId}
|
||||
className="sync-device-item"
|
||||
onClick={() => handleConnect(d)}
|
||||
>
|
||||
<View className="sync-device-info">
|
||||
<Text className="sync-device-name">{d.name}</Text>
|
||||
<Text className="sync-device-adapter">{d.adapter?.name}</Text>
|
||||
</View>
|
||||
<Text className="sync-device-rssi">信号 {d.RSSI > -60 ? '强' : d.RSSI > -80 ? '中' : '弱'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<Text className="ds-hero__title">智能设备同步</Text>
|
||||
<Text className="ds-hero__desc">连接蓝牙设备,自动采集健康数据</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderConnected = () => (
|
||||
<View className="sync-section">
|
||||
<ContentCard className="sync-status-card">
|
||||
<Text className="sync-status-dot sync-status-dot--connected" />
|
||||
<Text className="sync-status-text">已连接: {selectedDevice?.name}</Text>
|
||||
</ContentCard>
|
||||
const renderDeviceTypes = () => (
|
||||
<View className="ds-types">
|
||||
<Text className="ds-types__label">支持的设备</Text>
|
||||
<View className="ds-types__row">
|
||||
<View className="ds-type-tag"><Text className="ds-type-tag__dot ds-type-tag__dot--heart" /><Text className="ds-type-tag__text">心率手环</Text></View>
|
||||
<View className="ds-type-tag"><Text className="ds-type-tag__dot ds-type-tag__dot--bp" /><Text className="ds-type-tag__text">血压计</Text></View>
|
||||
<View className="ds-type-tag"><Text className="ds-type-tag__dot ds-type-tag__dot--glu" /><Text className="ds-type-tag__text">血糖仪</Text></View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
{liveReadings.length > 0 && (
|
||||
<View className="sync-readings-panel">
|
||||
<Text className="sync-section-title">实时数据</Text>
|
||||
{liveReadings.slice(-5).reverse().map((r, i) => (
|
||||
<View key={i} className="sync-reading-item">
|
||||
<Text className="sync-reading-type">
|
||||
{r.device_type === 'heart_rate' ? '心率'
|
||||
: r.device_type === 'blood_pressure' ? `血压(${r.metric === 'systolic' ? '收缩压' : r.metric === 'diastolic' ? '舒张压' : 'MAP'})`
|
||||
: r.device_type === 'blood_glucose' ? '血糖'
|
||||
: r.device_type}
|
||||
</Text>
|
||||
<Text className="sync-reading-value">
|
||||
{r.device_type === 'heart_rate'
|
||||
? `${r.values.heart_rate} bpm`
|
||||
: r.metric
|
||||
? `${r.values.value} ${r.values.unit}`
|
||||
: JSON.stringify(r.values)}
|
||||
</Text>
|
||||
const renderLastSync = () => {
|
||||
if (!lastSyncAt && pendingCount === 0) return null;
|
||||
return (
|
||||
<ContentCard variant="outlined" padding="md" margin="none" className="ds-sync-info">
|
||||
<View className="ds-sync-info__inner">
|
||||
<View className="ds-sync-info__icon-wrap">
|
||||
<Text className="ds-sync-info__check">✓</Text>
|
||||
</View>
|
||||
<View className="ds-sync-info__text">
|
||||
<Text className="ds-sync-info__title">上次同步</Text>
|
||||
{lastSyncAt && <Text className="ds-sync-info__time">{new Date(lastSyncAt).toLocaleTimeString()}</Text>}
|
||||
</View>
|
||||
{pendingCount > 0 && (
|
||||
<View className="ds-sync-info__badge">{pendingCount} 条待上传</View>
|
||||
)}
|
||||
</View>
|
||||
</ContentCard>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPendingWarning = () => {
|
||||
if (pendingCount <= 0) return null;
|
||||
return (
|
||||
<View className="ds-warning">
|
||||
<Text className="ds-warning__icon">!</Text>
|
||||
<Text className="ds-warning__text">{pendingCount} 条数据待上传</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderScanButton = () => (
|
||||
<View className="ds-scan-btn-wrap">
|
||||
<PrimaryButton size="large" onClick={handleScan} loading={pageState === 'scanning'}>
|
||||
扫描附近设备
|
||||
</PrimaryButton>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderDeviceList = () => {
|
||||
if (pageState !== 'found') return null;
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<View className="ds-devices ds-devices--empty">
|
||||
<View className="ds-devices__empty-box">
|
||||
<Text className="ds-devices__empty-box-icon">!</Text>
|
||||
<Text className="ds-devices__empty-box-title">未发现设备</Text>
|
||||
<Text className="ds-devices__empty-box-desc">请确认设备已开机且蓝牙已开启,并靠近手机后重试</Text>
|
||||
</View>
|
||||
<View className="ds-devices__rescan-wrap">
|
||||
<PrimaryButton size="large" onClick={handleScan}>重新扫描</PrimaryButton>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View className="ds-devices">
|
||||
<View className="ds-devices__header">
|
||||
<Text className="ds-devices__count">发现 {devices.length} 台设备</Text>
|
||||
<Text className="ds-devices__rescan" onClick={handleScan}>重新扫描</Text>
|
||||
</View>
|
||||
{devices.map((d) => {
|
||||
const isFallback = d.adapter?.name === '通用设备';
|
||||
return (
|
||||
<View key={d.deviceId} className={`ds-device-card ${isFallback ? 'ds-device-card--generic' : ''}`} onClick={() => handleConnect(d)}>
|
||||
<View className="ds-device-card__icon">
|
||||
<Text className="ds-device-card__bt">BT</Text>
|
||||
</View>
|
||||
<View className="ds-device-card__info">
|
||||
<Text className="ds-device-card__name">{d.name}</Text>
|
||||
<Text className="ds-device-card__adapter">{d.adapter?.name}{isFallback ? ' · 尝试标准协议' : ''}</Text>
|
||||
</View>
|
||||
<View className="ds-device-card__signal">
|
||||
{[4, 7, 10, 13].map((h, i) => (
|
||||
<View
|
||||
key={i}
|
||||
className={`ds-signal-bar ${i < (d.RSSI > -60 ? 4 : d.RSSI > -80 ? 3 : d.RSSI > -90 ? 2 : 1) ? 'ds-signal-bar--active' : ''}`}
|
||||
style={{ height: `${h}px` }}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text className="ds-device-card__arrow">›</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View className="ds-devices__empty-hint">
|
||||
<Text className="ds-devices__empty-icon">?</Text>
|
||||
<View className="ds-devices__empty-text">
|
||||
<Text className="ds-devices__empty-title">没有找到你的设备?</Text>
|
||||
<Text className="ds-devices__empty-desc">确保设备已开机且蓝牙已开启</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLoading = (text: string) => (
|
||||
<View className="ds-loading">
|
||||
<View className="ds-loading__spinner" />
|
||||
<Text className="ds-loading__text">{text}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderConnectedStatus = () => (
|
||||
<View className="ds-connected-status">
|
||||
<View className="ds-connected-status__icon">
|
||||
<Text className="ds-connected-status__bt">BT</Text>
|
||||
</View>
|
||||
<View className="ds-connected-status__info">
|
||||
<Text className="ds-connected-status__name">{selectedDevice?.name}</Text>
|
||||
<Text className="ds-connected-status__sub">已连接 · 正在采集数据</Text>
|
||||
</View>
|
||||
<View className="ds-connected-status__badge">实时</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderLatestReading = () => {
|
||||
if (!latestReading) return null;
|
||||
return (
|
||||
<ContentCard variant="elevated" padding="lg" margin="none" className="ds-latest-reading">
|
||||
<View className="ds-latest-reading__icon-wrap ds-latest-reading__icon-wrap--heart">
|
||||
<Text className="ds-latest-reading__heart">♥</Text>
|
||||
</View>
|
||||
<View className="ds-latest-reading__body">
|
||||
<Text className="ds-latest-reading__label">{getReadingLabel(latestReading)} · 刚刚</Text>
|
||||
<View className="ds-latest-reading__values">
|
||||
<Text className="ds-latest-reading__num">{formatReadingValue(latestReading)}</Text>
|
||||
<Text className="ds-latest-reading__unit">{getReadingUnit(latestReading)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ContentCard>
|
||||
);
|
||||
};
|
||||
|
||||
const renderReadingsHistory = () => {
|
||||
if (liveReadings.length <= 1) return null;
|
||||
const history = liveReadings.slice(0, -1).reverse().slice(0, 5);
|
||||
return (
|
||||
<View className="ds-history">
|
||||
<Text className="ds-history__title">历史读数</Text>
|
||||
<View className="ds-history__list">
|
||||
{history.map((r, i) => (
|
||||
<View key={i} className={`ds-history__row ${i === history.length - 1 ? 'ds-history__row--last' : ''}`}>
|
||||
<Text className="ds-history__type">{getReadingLabel(r)}</Text>
|
||||
<View className="ds-history__val-wrap">
|
||||
<Text className="ds-history__val">{formatReadingValue(r)}</Text>
|
||||
<Text className="ds-history__unit">{getReadingUnit(r)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
<Text className="sync-readings-count">
|
||||
已采集 {liveReadings.length} 条数据
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text className="ds-history__count">已采集 {liveReadings.length} 条数据</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
<View className="sync-actions-row">
|
||||
<View className="sync-action sync-action--primary" onClick={handleSync}>
|
||||
<Text className="sync-action-text">上传数据</Text>
|
||||
</View>
|
||||
<View className="sync-action sync-action--danger" onClick={handleDisconnect}>
|
||||
<Text className="sync-action-text">断开连接</Text>
|
||||
</View>
|
||||
const renderConnectedActions = () => (
|
||||
<View className="ds-actions">
|
||||
<View className="ds-actions__upload" onClick={handleSync}>
|
||||
<Text className="ds-actions__upload-text">上传数据</Text>
|
||||
</View>
|
||||
<View className="ds-actions__disconnect" onClick={handleDisconnect}>
|
||||
<Text className="ds-actions__disconnect-icon">✕</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderDone = () => (
|
||||
<View className="sync-section">
|
||||
<ContentCard className="sync-result-card">
|
||||
<Text className="sync-result-icon">V</Text>
|
||||
<Text className="sync-result-title">同步完成</Text>
|
||||
<Text className="sync-result-count">成功上传 {syncCount} 条数据</Text>
|
||||
</ContentCard>
|
||||
<View className="sync-action" onClick={() => {
|
||||
handleDisconnect();
|
||||
if (returnTo === 'input') {
|
||||
Taro.navigateBack();
|
||||
}
|
||||
}}>
|
||||
<Text className="sync-action-text">{returnTo === 'input' ? '返回录入' : '完成'}</Text>
|
||||
<View className="ds-done">
|
||||
<View className="ds-done__icon">
|
||||
<Text className="ds-done__check">✓</Text>
|
||||
</View>
|
||||
<Text className="ds-done__title">同步完成</Text>
|
||||
<Text className="ds-done__desc">数据已安全上传至健康管理平台</Text>
|
||||
<View className="ds-done__stats">
|
||||
<View className="ds-done__stat">
|
||||
<Text className="ds-done__stat-num ds-done__stat-num--pri">{syncCount}</Text>
|
||||
<Text className="ds-done__stat-label">上传条数</Text>
|
||||
</View>
|
||||
<View className="ds-done__stat">
|
||||
<Text className="ds-done__stat-num">{new Set(liveReadings.map((r) => r.device_type)).size}</Text>
|
||||
<Text className="ds-done__stat-label">数据类型</Text>
|
||||
</View>
|
||||
<View className="ds-done__stat">
|
||||
<Text className="ds-done__stat-num ds-done__stat-num--acc">100%</Text>
|
||||
<Text className="ds-done__stat-label">成功率</Text>
|
||||
</View>
|
||||
</View>
|
||||
<PrimaryButton size="large" onClick={() => { handleDisconnect(); if (returnTo === 'input') Taro.navigateBack(); }}>
|
||||
{returnTo === 'input' ? '返回录入' : '完成'}
|
||||
</PrimaryButton>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderError = () => (
|
||||
<View className="ds-error-page">
|
||||
<View className="ds-error-page__icon">
|
||||
<Text className="ds-error-page__x">✕</Text>
|
||||
</View>
|
||||
<Text className="ds-error-page__title">连接失败</Text>
|
||||
<Text className="ds-error-page__desc">{errorMsg || '无法连接到设备,请检查设备是否在范围内并重试'}</Text>
|
||||
<View className="ds-error-page__detail">
|
||||
<Text className="ds-error-page__detail-title">错误详情</Text>
|
||||
<View className="ds-error-page__detail-row"><Text className="ds-error-page__detail-label">设备</Text><Text className="ds-error-page__detail-value">{selectedDevice?.name || '--'}</Text></View>
|
||||
<View className="ds-error-page__detail-row ds-error-page__detail-row--last"><Text className="ds-error-page__detail-label">时间</Text><Text className="ds-error-page__detail-value">{new Date().toLocaleTimeString()}</Text></View>
|
||||
</View>
|
||||
<PrimaryButton size="large" onClick={handleScan}>重新扫描</PrimaryButton>
|
||||
<View className="ds-error-page__back" onClick={() => Taro.navigateBack()}>
|
||||
<Text className="ds-error-page__back-text">返回</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// ── 主渲染 ──
|
||||
return (
|
||||
<PageShell padding="none" className={modeClass}>
|
||||
<View className="sync-header">
|
||||
<Text className="sync-header-title">设备同步</Text>
|
||||
</View>
|
||||
|
||||
{errorMsg && (
|
||||
<View className="sync-error">
|
||||
<Text className="sync-error-text">{errorMsg}</Text>
|
||||
<PageShell padding="none" className={`ds-page ${modeClass}`}>
|
||||
{/* 空闲态 */}
|
||||
{pageState === 'idle' && (
|
||||
<View className="ds-body">
|
||||
{renderHero()}
|
||||
{renderDeviceTypes()}
|
||||
{renderLastSync()}
|
||||
{renderPendingWarning()}
|
||||
{renderScanButton()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(pageState === 'scanning' || pageState === 'connecting' || pageState === 'syncing') && (
|
||||
<View className="sync-loading">
|
||||
<Text className="sync-loading-text">
|
||||
{pageState === 'scanning' && '正在扫描设备...'}
|
||||
{pageState === 'connecting' && '正在连接设备...'}
|
||||
{pageState === 'syncing' && '正在上传数据...'}
|
||||
</Text>
|
||||
{/* 扫描结果(设备列表或空结果提示) */}
|
||||
{pageState === 'found' && (
|
||||
<View className="ds-body">
|
||||
{renderHero()}
|
||||
{renderDeviceTypes()}
|
||||
{renderDeviceList()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(pageState === 'idle' || pageState === 'error') && renderIdle()}
|
||||
{pageState === 'connected' && renderConnected()}
|
||||
{pageState === 'done' && renderDone()}
|
||||
{/* 扫描中 */}
|
||||
{pageState === 'scanning' && (
|
||||
<View className="ds-body ds-body--center">
|
||||
<View className="ds-pulse">
|
||||
<View className="ds-pulse__ring ds-pulse__ring--1" />
|
||||
<View className="ds-pulse__ring ds-pulse__ring--2" />
|
||||
<View className="ds-pulse__center">
|
||||
<Text className="ds-pulse__bt">BT</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="ds-pulse__title">正在搜索设备...</Text>
|
||||
<Text className="ds-pulse__hint">请确保设备已开启蓝牙并靠近手机</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 连接中 */}
|
||||
{pageState === 'connecting' && (
|
||||
<View className="ds-body ds-body--center">
|
||||
<View className="ds-connect-anim">
|
||||
<View className="ds-connect-anim__ring" />
|
||||
<View className="ds-connect-anim__center">
|
||||
<Text className="ds-connect-anim__bt">BT</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="ds-connect-anim__title">正在连接 {selectedDevice?.name}</Text>
|
||||
<Text className="ds-connect-anim__sub">正在进行蓝牙配对...</Text>
|
||||
<View className="ds-steps">
|
||||
<View className="ds-steps__dot ds-steps__dot--done" />
|
||||
<Text className="ds-steps__label ds-steps__label--done">发现设备</Text>
|
||||
<View className="ds-steps__line ds-steps__line--active" />
|
||||
<View className="ds-steps__dot ds-steps__dot--active" />
|
||||
<Text className="ds-steps__label ds-steps__label--active">连接中</Text>
|
||||
<View className="ds-steps__line" />
|
||||
<View className="ds-steps__dot" />
|
||||
<Text className="ds-steps__label">同步数据</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 同步中 */}
|
||||
{pageState === 'syncing' && renderLoading('正在上传数据...')}
|
||||
|
||||
{/* 已连接 */}
|
||||
{pageState === 'connected' && (
|
||||
<View className="ds-body">
|
||||
{renderConnectedStatus()}
|
||||
{renderLatestReading()}
|
||||
{renderReadingsHistory()}
|
||||
{renderConnectedActions()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 同步完成 */}
|
||||
{pageState === 'done' && <View className="ds-body ds-body--center">{renderDone()}</View>}
|
||||
|
||||
{/* 错误态 */}
|
||||
{pageState === 'error' && errorMsg && (
|
||||
<View className="ds-body ds-body--center">{renderError()}</View>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
|
||||
.page-title {
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
font-size: var(--tk-font-num);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
margin-bottom: var(--tk-section-gap);
|
||||
display: block;
|
||||
@include section-title;
|
||||
padding-left: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
/* ─── 健康记录卡片 ─── */
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -65,3 +59,79 @@
|
||||
display: block;
|
||||
margin-top: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
/* ─── 诊断记录卡片 ─── */
|
||||
.diagnosis-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.diagnosis-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: var(--tk-card-padding-lg);
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
|
||||
.diagnosis-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
.diagnosis-card__name {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
margin-right: var(--tk-gap-sm);
|
||||
}
|
||||
|
||||
.diagnosis-card__status {
|
||||
font-size: var(--tk-font-cap);
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-pill;
|
||||
font-weight: 600;
|
||||
|
||||
&.active { background: $acc-l; color: $acc; }
|
||||
&.resolved { background: $bd-l; color: $tx2; }
|
||||
&.chronic { background: $wrn-l; color: $wrn; }
|
||||
}
|
||||
|
||||
.diagnosis-card__meta {
|
||||
display: flex;
|
||||
gap: var(--tk-gap-sm);
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
.diagnosis-card__type {
|
||||
font-size: var(--tk-font-cap);
|
||||
padding: 1px 6px;
|
||||
border-radius: $r-pill;
|
||||
|
||||
&.primary { background: var(--tk-pri-l); color: var(--tk-pri); }
|
||||
&.secondary { background: $bd-l; color: $tx2; }
|
||||
&.comorbid { background: $wrn-l; color: $wrn; }
|
||||
}
|
||||
|
||||
.diagnosis-card__code {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.diagnosis-card__date {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.diagnosis-card__notes {
|
||||
font-size: var(--tk-font-body);
|
||||
color: $tx2;
|
||||
display: block;
|
||||
margin-top: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
@@ -3,89 +3,197 @@ import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useReachBottom } from '@tarojs/taro';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getCachedPatientId } from '@/services/request';
|
||||
import { listHealthRecords, HealthRecord } from '../../../services/health-record';
|
||||
import {
|
||||
listHealthRecords,
|
||||
HealthRecord,
|
||||
listDiagnoses,
|
||||
Diagnosis,
|
||||
} from '../../../services/health-record';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import SegmentTabs from '../../../components/SegmentTabs';
|
||||
import './index.scss';
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
const TABS = [
|
||||
{ key: 'records', label: '体检记录' },
|
||||
{ key: 'diagnoses', label: '诊断记录' },
|
||||
] as const;
|
||||
|
||||
const RECORD_TYPE_MAP: Record<string, string> = {
|
||||
checkup: '体检',
|
||||
follow_up: '复查',
|
||||
referral: '转诊',
|
||||
};
|
||||
|
||||
export default function HealthRecords() {
|
||||
const modeClass = useElderClass();
|
||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasPatient, setHasPatient] = useState(true);
|
||||
const DIAG_TYPE_MAP: Record<string, { label: string; cls: string }> = {
|
||||
primary: { label: '主要', cls: 'primary' },
|
||||
secondary: { label: '次要', cls: 'secondary' },
|
||||
comorbid: { label: '合并症', cls: 'comorbid' },
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async (p: number, append = false) => {
|
||||
const DIAG_STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '活动', cls: 'active' },
|
||||
resolved: { label: '已解决', cls: 'resolved' },
|
||||
chronic: { label: '慢性', cls: 'chronic' },
|
||||
};
|
||||
|
||||
type TabKey = 'records' | 'diagnoses';
|
||||
|
||||
export default function HealthArchive() {
|
||||
const modeClass = useElderClass();
|
||||
const [tab, setTab] = useState<TabKey>('records');
|
||||
|
||||
// --- 健康记录 ---
|
||||
const [records, setRecords] = useState<HealthRecord[]>([]);
|
||||
const [recordsPage, setRecordsPage] = useState(1);
|
||||
const [recordsTotal, setRecordsTotal] = useState(0);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
|
||||
const fetchRecords = useCallback(async (p: number, append = false) => {
|
||||
const patientId = getCachedPatientId();
|
||||
if (!patientId) {
|
||||
setRecords([]);
|
||||
setHasPatient(false);
|
||||
return;
|
||||
}
|
||||
setHasPatient(true);
|
||||
setLoading(true);
|
||||
if (!patientId) return;
|
||||
setRecordsLoading(true);
|
||||
try {
|
||||
const res = await listHealthRecords(patientId, { page: p, page_size: 20 });
|
||||
const list = res.data || [];
|
||||
setRecords(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
setRecordsTotal(res.total);
|
||||
setRecordsPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[health-records] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRecordsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
|
||||
// --- 诊断记录 ---
|
||||
const [diagnoses, setDiagnoses] = useState<Diagnosis[]>([]);
|
||||
const [diagPage, setDiagPage] = useState(1);
|
||||
const [diagTotal, setDiagTotal] = useState(0);
|
||||
const [diagLoading, setDiagLoading] = useState(false);
|
||||
const [diagnosesLoaded, setDiagnosesLoaded] = useState(false);
|
||||
|
||||
const fetchDiagnoses = useCallback(async (p: number, append = false) => {
|
||||
const patientId = getCachedPatientId();
|
||||
if (!patientId) return;
|
||||
setDiagLoading(true);
|
||||
try {
|
||||
const res = await listDiagnoses(patientId, { page: p, page_size: 20 });
|
||||
const list = res.data || [];
|
||||
setDiagnoses(append ? (prev) => [...prev, ...list] : list);
|
||||
setDiagTotal(res.total);
|
||||
setDiagPage(p);
|
||||
setDiagnosesLoaded(true);
|
||||
} catch (err) {
|
||||
console.warn('[diagnoses] 加载失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setDiagLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (tab === 'records') {
|
||||
await fetchRecords(1);
|
||||
} else {
|
||||
await fetchDiagnoses(1);
|
||||
}
|
||||
}, [tab, fetchRecords, fetchDiagnoses]);
|
||||
|
||||
const handleTabSwitch = (key: TabKey) => {
|
||||
if (key === tab) return;
|
||||
setTab(key);
|
||||
if (key === 'diagnoses' && !diagnosesLoaded) {
|
||||
fetchDiagnoses(1);
|
||||
}
|
||||
};
|
||||
|
||||
usePageData(handleRefresh, { throttleMs: 10000, enablePullDown: true });
|
||||
|
||||
const currentLoading = tab === 'records' ? recordsLoading : diagLoading;
|
||||
const currentItems = tab === 'records' ? records.length : diagnoses.length;
|
||||
const currentTotal = tab === 'records' ? recordsTotal : diagTotal;
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && records.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
if (currentLoading || currentItems >= currentTotal) return;
|
||||
if (tab === 'records') {
|
||||
fetchRecords(recordsPage + 1, true);
|
||||
} else {
|
||||
fetchDiagnoses(diagPage + 1, true);
|
||||
}
|
||||
});
|
||||
|
||||
const hasPatient = !!getCachedPatientId();
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>健康记录</Text>
|
||||
<Text className='page-title'>健康档案</Text>
|
||||
|
||||
<View className='record-list'>
|
||||
{records.map((r) => (
|
||||
<View className='record-card' key={r.id}>
|
||||
<View className='record-card__header'>
|
||||
<Text className='record-card__type'>
|
||||
{TYPE_MAP[r.record_type] || r.record_type}
|
||||
</Text>
|
||||
<Text className='record-card__date'>{r.record_date}</Text>
|
||||
<SegmentTabs tabs={TABS} activeKey={tab} onChange={(k) => handleTabSwitch(k as TabKey)} variant="pill" />
|
||||
|
||||
{tab === 'records' && (
|
||||
<View className='record-list'>
|
||||
{records.map((r) => (
|
||||
<View className='record-card' key={r.id}>
|
||||
<View className='record-card__header'>
|
||||
<Text className='record-card__type'>
|
||||
{RECORD_TYPE_MAP[r.record_type] || r.record_type}
|
||||
</Text>
|
||||
<Text className='record-card__date'>{r.record_date}</Text>
|
||||
</View>
|
||||
{r.overall_assessment && (
|
||||
<Text className='record-card__assessment'>{r.overall_assessment}</Text>
|
||||
)}
|
||||
{r.source && (
|
||||
<Text className='record-card__source'>来源:{r.source}</Text>
|
||||
)}
|
||||
{r.notes && (
|
||||
<Text className='record-card__notes'>{r.notes}</Text>
|
||||
)}
|
||||
</View>
|
||||
{r.overall_assessment && (
|
||||
<Text className='record-card__assessment'>{r.overall_assessment}</Text>
|
||||
)}
|
||||
{r.source && (
|
||||
<Text className='record-card__source'>来源:{r.source}</Text>
|
||||
)}
|
||||
{r.notes && (
|
||||
<Text className='record-card__notes'>{r.notes}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{records.length === 0 && !loading && (
|
||||
<EmptyState text={hasPatient ? '暂无健康记录' : '请先在就诊人管理中选择就诊人'} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && <Loading />}
|
||||
{tab === 'diagnoses' && (
|
||||
<View className='diagnosis-list'>
|
||||
{diagnoses.map((d) => {
|
||||
const typeInfo = DIAG_TYPE_MAP[d.diagnosis_type] || { label: d.diagnosis_type, cls: '' };
|
||||
const statusInfo = DIAG_STATUS_MAP[d.status] || { label: d.status, cls: '' };
|
||||
return (
|
||||
<View className='diagnosis-card' key={d.id}>
|
||||
<View className='diagnosis-card__header'>
|
||||
<Text className='diagnosis-card__name'>{d.diagnosis_name}</Text>
|
||||
<Text className={`diagnosis-card__status ${statusInfo.cls}`}>
|
||||
{statusInfo.label}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='diagnosis-card__meta'>
|
||||
<Text className={`diagnosis-card__type ${typeInfo.cls}`}>
|
||||
{typeInfo.label}
|
||||
</Text>
|
||||
<Text className='diagnosis-card__code'>{d.icd_code}</Text>
|
||||
</View>
|
||||
<Text className='diagnosis-card__date'>诊断日期:{d.diagnosed_date}</Text>
|
||||
{d.notes && (
|
||||
<Text className='diagnosis-card__notes'>{d.notes}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{currentItems === 0 && !currentLoading && (
|
||||
<EmptyState text={hasPatient
|
||||
? (tab === 'records' ? '暂无健康记录' : '暂无诊断记录')
|
||||
: '请先在就诊人管理中选择就诊人'} />
|
||||
)}
|
||||
|
||||
{currentLoading && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
||||
|
||||
// PageShell 已接管:min-height, background, padding
|
||||
|
||||
.page-title {
|
||||
@include section-title;
|
||||
padding-left: var(--tk-gap-2xs);
|
||||
}
|
||||
|
||||
/* ─── 检查报告卡片 ─── */
|
||||
.report-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -80,3 +79,63 @@
|
||||
display: block;
|
||||
padding-left: 72px;
|
||||
}
|
||||
|
||||
/* ─── AI 分析卡片 ─── */
|
||||
.ai-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tk-gap-md);
|
||||
}
|
||||
|
||||
.ai-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: var(--tk-card-padding-lg);
|
||||
box-shadow: $shadow-sm;
|
||||
|
||||
&:active {
|
||||
opacity: var(--tk-touch-feedback-opacity);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--tk-gap-xs);
|
||||
}
|
||||
|
||||
&__type {
|
||||
font-size: var(--tk-font-body-lg);
|
||||
font-weight: bold;
|
||||
color: $tx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: var(--tk-font-cap);
|
||||
padding: 2px 8px;
|
||||
border-radius: $r-pill;
|
||||
font-weight: 600;
|
||||
|
||||
&.completed { background: $acc-l; color: $acc; }
|
||||
&.streaming { background: var(--tk-pri-l); color: var(--tk-pri); }
|
||||
&.failed { background: $dan-l; color: $dan; }
|
||||
&.pending { background: $bd-l; color: $tx2; }
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
|
||||
&__model {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,104 +5,187 @@ import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { usePageData } from '@/hooks/usePageData';
|
||||
import { getCachedPatientId } from '@/services/request';
|
||||
import { listReports, LabReport } from '../../../services/report';
|
||||
import { listAiAnalysis, type AiAnalysisItem } from '../../../services/ai-analysis';
|
||||
import EmptyState from '../../../components/EmptyState';
|
||||
import Loading from '../../../components/Loading';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
import SegmentTabs from '../../../components/SegmentTabs';
|
||||
import './index.scss';
|
||||
|
||||
type TabKey = 'reports' | 'ai';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'reports', label: '检查报告' },
|
||||
{ key: 'ai', label: 'AI 解读' },
|
||||
] as const;
|
||||
|
||||
const AI_TYPE_LABELS: Record<string, string> = {
|
||||
lab_report_interpretation: '化验单解读',
|
||||
health_trend_analysis: '趋势分析',
|
||||
personalized_checkup_plan: '体检方案',
|
||||
report_summary_generation: '报告摘要',
|
||||
};
|
||||
|
||||
const AI_STATUS_MAP: Record<string, { text: string; cls: string }> = {
|
||||
completed: { text: '已完成', cls: 'completed' },
|
||||
streaming: { text: '分析中', cls: 'streaming' },
|
||||
failed: { text: '失败', cls: 'failed' },
|
||||
pending: { text: '等待中', cls: 'pending' },
|
||||
};
|
||||
|
||||
export default function MyReports() {
|
||||
const modeClass = useElderClass();
|
||||
const [reports, setReports] = useState<LabReport[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasPatient, setHasPatient] = useState(true);
|
||||
const [tab, setTab] = useState<TabKey>('reports');
|
||||
|
||||
const fetchData = useCallback(async (p: number, append = false) => {
|
||||
// --- 检查报告 ---
|
||||
const [reports, setReports] = useState<LabReport[]>([]);
|
||||
const [reportsPage, setReportsPage] = useState(1);
|
||||
const [reportsTotal, setReportsTotal] = useState(0);
|
||||
const [reportsLoading, setReportsLoading] = useState(false);
|
||||
|
||||
const fetchReports = useCallback(async (p: number, append = false) => {
|
||||
const patientId = getCachedPatientId();
|
||||
if (!patientId) {
|
||||
setReports([]);
|
||||
setHasPatient(false);
|
||||
return;
|
||||
}
|
||||
setHasPatient(true);
|
||||
setLoading(true);
|
||||
if (!patientId) return;
|
||||
setReportsLoading(true);
|
||||
try {
|
||||
const res = await listReports(patientId, p);
|
||||
const list = res.data || [];
|
||||
setReports(append ? (prev) => [...prev, ...list] : list);
|
||||
setTotal(res.total);
|
||||
setPage(p);
|
||||
setReportsTotal(res.total);
|
||||
setReportsPage(p);
|
||||
} catch (err) {
|
||||
console.warn('[reports] 加载报告列表失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setReportsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePageData(async () => { await fetchData(1); }, { throttleMs: 10000, enablePullDown: true });
|
||||
// --- AI 分析 ---
|
||||
const [aiList, setAiList] = useState<AiAnalysisItem[]>([]);
|
||||
const [aiPage, setAiPage] = useState(1);
|
||||
const [aiHasMore, setAiHasMore] = useState(true);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiLoaded, setAiLoaded] = useState(false);
|
||||
|
||||
const fetchAiList = useCallback(async (p: number, append = false) => {
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const res = await listAiAnalysis(p, 20);
|
||||
const items = res.data || [];
|
||||
setAiList(append ? (prev) => [...prev, ...items] : items);
|
||||
setAiPage(p);
|
||||
setAiHasMore(items.length >= 20);
|
||||
setAiLoaded(true);
|
||||
} catch (err) {
|
||||
console.warn('[ai-report] 加载分析列表失败:', err);
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (tab === 'reports') {
|
||||
await fetchReports(1);
|
||||
} else {
|
||||
await fetchAiList(1);
|
||||
}
|
||||
}, [tab, fetchReports, fetchAiList]);
|
||||
|
||||
const handleTabSwitch = (key: TabKey) => {
|
||||
if (key === tab) return;
|
||||
setTab(key);
|
||||
if (key === 'ai' && !aiLoaded) {
|
||||
fetchAiList(1);
|
||||
}
|
||||
};
|
||||
|
||||
usePageData(handleRefresh, { throttleMs: 10000, enablePullDown: true });
|
||||
|
||||
const currentLoading = tab === 'reports' ? reportsLoading : aiLoading;
|
||||
|
||||
useReachBottom(() => {
|
||||
if (!loading && reports.length < total) {
|
||||
fetchData(page + 1, true);
|
||||
if (currentLoading) return;
|
||||
if (tab === 'reports') {
|
||||
if (reports.length < reportsTotal) fetchReports(reportsPage + 1, true);
|
||||
} else {
|
||||
if (aiHasMore) fetchAiList(aiPage + 1, true);
|
||||
}
|
||||
});
|
||||
|
||||
const goToDetail = (id: string) => {
|
||||
safeNavigateTo(`/pages/pkg-profile/reports/detail/index?id=${id}`);
|
||||
};
|
||||
const hasPatient = !!getCachedPatientId();
|
||||
|
||||
const formatStatus = (report: LabReport) => {
|
||||
const formatReportStatus = (report: LabReport) => {
|
||||
const items = report.items;
|
||||
if (!items || !Array.isArray(items)) return 'unknown';
|
||||
const vals = items as Array<{ is_abnormal?: boolean }>;
|
||||
const hasAbnormal = vals.some((v) => v.is_abnormal);
|
||||
return hasAbnormal ? 'abnormal' : 'normal';
|
||||
};
|
||||
|
||||
const typeInitial = (type: string) => {
|
||||
return type ? type.charAt(0) : '报';
|
||||
return vals.some((v) => v.is_abnormal) ? 'abnormal' : 'normal';
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell className={modeClass}>
|
||||
<Text className='page-title'>检查报告</Text>
|
||||
<Text className='page-title'>我的报告</Text>
|
||||
|
||||
<View className='report-list'>
|
||||
{reports.map((r) => {
|
||||
const status = formatStatus(r);
|
||||
return (
|
||||
<View
|
||||
className='report-card'
|
||||
key={r.id}
|
||||
onClick={() => goToDetail(r.id)}
|
||||
>
|
||||
<View className='report-card-top'>
|
||||
<View className='report-type-row'>
|
||||
<View className='report-avatar'>
|
||||
<Text className='report-avatar-text'>{typeInitial(r.report_type)}</Text>
|
||||
<SegmentTabs tabs={TABS} activeKey={tab} onChange={(k) => handleTabSwitch(k as TabKey)} variant="pill" />
|
||||
|
||||
{tab === 'reports' && (
|
||||
<View className='report-list'>
|
||||
{reports.map((r) => {
|
||||
const status = formatReportStatus(r);
|
||||
return (
|
||||
<View className='report-card' key={r.id} onClick={() => safeNavigateTo(`/pages/pkg-profile/reports/detail/index?id=${r.id}`)}>
|
||||
<View className='report-card-top'>
|
||||
<View className='report-type-row'>
|
||||
<View className='report-avatar'>
|
||||
<Text className='report-avatar-text'>{r.report_type ? r.report_type.charAt(0) : '报'}</Text>
|
||||
</View>
|
||||
<Text className='report-type'>{r.report_type}</Text>
|
||||
</View>
|
||||
<Text className='report-type'>{r.report_type}</Text>
|
||||
<Text className={`report-status ${status}`}>
|
||||
{status === 'normal' ? '正常' : status === 'abnormal' ? '异常' : '未知'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className={`report-status ${status}`}>
|
||||
{status === 'normal' ? '正常' : status === 'abnormal' ? '异常' : '未知'}
|
||||
</Text>
|
||||
<Text className='report-date'>{r.report_date}</Text>
|
||||
</View>
|
||||
<Text className='report-date'>{r.report_date}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{reports.length === 0 && !loading && (
|
||||
{tab === 'ai' && (
|
||||
<View className='ai-list'>
|
||||
{aiList.map((item) => {
|
||||
const si = AI_STATUS_MAP[item.status] || { text: item.status, cls: '' };
|
||||
return (
|
||||
<View
|
||||
key={item.id}
|
||||
className='ai-card'
|
||||
onClick={() => item.status === 'completed' && safeNavigateTo(`/pages/ai-report/detail/index?id=${item.id}`)}
|
||||
>
|
||||
<View className='ai-card__header'>
|
||||
<Text className='ai-card__type'>{AI_TYPE_LABELS[item.analysis_type] || item.analysis_type}</Text>
|
||||
<Text className={`ai-card__status ${si.cls}`}>{si.text}</Text>
|
||||
</View>
|
||||
<View className='ai-card__footer'>
|
||||
<Text className='ai-card__time'>{new Date(item.created_at).toLocaleString('zh-CN')}</Text>
|
||||
<Text className='ai-card__model'>{item.model_used}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{tab === 'reports' && reports.length === 0 && !reportsLoading && (
|
||||
<EmptyState text={hasPatient ? '暂无报告记录' : '请先在就诊人管理中选择就诊人'} />
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<Loading />
|
||||
{tab === 'ai' && aiList.length === 0 && !aiLoading && (
|
||||
<EmptyState text='暂无 AI 分析报告' />
|
||||
)}
|
||||
|
||||
{currentLoading && <Loading />}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,3 +65,14 @@
|
||||
font-family: 'Georgia', 'Times New Roman', serif;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-toggle {
|
||||
font-size: var(--tk-font-cap);
|
||||
color: $tx3;
|
||||
margin-right: var(--tk-gap-xs);
|
||||
|
||||
&.settings-toggle--active {
|
||||
color: var(--tk-pri);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { safeNavigateTo } from '@/utils/navigate';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
import { useUIStore } from '../../../stores/ui';
|
||||
import { invalidateHeadersCache, clearRequestCache } from '@/services/request';
|
||||
import { useElderClass } from '../../../hooks/useElderClass';
|
||||
import PageShell from '@/components/ui/PageShell';
|
||||
@@ -12,6 +13,8 @@ export default function Settings() {
|
||||
const modeClass = useElderClass();
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const isMedicalStaff = useAuthStore((s) => s.isMedicalStaff);
|
||||
const mode = useUIStore((s) => s.mode);
|
||||
const toggleMode = useUIStore((s) => s.toggle);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
const { confirm } = await Taro.showModal({
|
||||
@@ -72,6 +75,16 @@ export default function Settings() {
|
||||
<Text className='page-title'>设置</Text>
|
||||
|
||||
<View className='settings-group'>
|
||||
<View className='settings-item' onClick={toggleMode}>
|
||||
<View className='settings-icon'>
|
||||
<Text className='settings-icon-text'>老</Text>
|
||||
</View>
|
||||
<Text className='settings-label'>长辈模式</Text>
|
||||
<Text className={`settings-toggle ${mode === 'elder' ? 'settings-toggle--active' : ''}`}>
|
||||
{mode === 'elder' ? '已开启' : '未开启'}
|
||||
</Text>
|
||||
<Text className='settings-arrow'>{'>'}</Text>
|
||||
</View>
|
||||
<View className='settings-item' onClick={handleClearCache}>
|
||||
<View className='settings-icon'>
|
||||
<Text className='settings-icon-text'>缓</Text>
|
||||
|
||||
@@ -66,6 +66,7 @@ function persistQueue(): void {
|
||||
}
|
||||
|
||||
export function trackEvent(event: EventName | string, properties?: Record<string, unknown>): void {
|
||||
if (flushDisabled) return;
|
||||
loadQueue();
|
||||
const evt: AnalyticsEvent = {
|
||||
event,
|
||||
@@ -89,7 +90,10 @@ export function trackPageView(pageName: string, properties?: Record<string, unkn
|
||||
trackEvent('page_view', { page: pageName, ...properties });
|
||||
}
|
||||
|
||||
let flushDisabled = false;
|
||||
|
||||
export async function flushEvents(): Promise<void> {
|
||||
if (flushDisabled) return;
|
||||
loadQueue();
|
||||
if (memoryQueue.length === 0) return;
|
||||
|
||||
@@ -99,9 +103,16 @@ export async function flushEvents(): Promise<void> {
|
||||
|
||||
try {
|
||||
await api.post('/analytics/batch', { events: batch });
|
||||
} catch (e) {
|
||||
// 静默失败,不打印错误避免控制台洪泛
|
||||
void e;
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg === '权限不足' || msg === '登录已过期') {
|
||||
// 权限不足或未认证,停止后续 flush 并丢弃队列
|
||||
flushDisabled = true;
|
||||
memoryQueue = [];
|
||||
persistQueue();
|
||||
return;
|
||||
}
|
||||
// 其他错误(网络等)保留队列重试
|
||||
memoryQueue = [...batch.slice(-MAX_QUEUE_SIZE), ...memoryQueue].slice(-MAX_QUEUE_SIZE);
|
||||
persistQueue();
|
||||
}
|
||||
@@ -111,3 +122,8 @@ export function getQueueSize(): number {
|
||||
loadQueue();
|
||||
return memoryQueue.length;
|
||||
}
|
||||
|
||||
/** 登录/切换用户时调用,重新启用 flush */
|
||||
export function resetAnalyticsDisabled(): void {
|
||||
flushDisabled = false;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { api } from './request';
|
||||
|
||||
export interface Article {
|
||||
@@ -41,6 +42,46 @@ export function buildCategoryTree(flat: ArticleCategory[]): ArticleCategory[] {
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** 获取默认 tenant_id(用于公开 API 调用) */
|
||||
function getDefaultTenantId(): string {
|
||||
return Taro.getStorageSync('tenant_id') || process.env.TARO_APP_DEFAULT_TENANT_ID || '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 公开端点(无需认证,游客可访问)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listPublicArticles(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category_id?: string;
|
||||
tag_id?: string;
|
||||
keyword?: string;
|
||||
}) {
|
||||
const tenantId = getDefaultTenantId();
|
||||
return api.get<{ data: Article[]; total: number }>('/public/articles', {
|
||||
tenant_id: tenantId,
|
||||
page: params?.page ?? 1,
|
||||
page_size: params?.page_size ?? 20,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPublicCategories() {
|
||||
const tenantId = getDefaultTenantId();
|
||||
return api.get<ArticleCategory[]>('/public/article-categories', {
|
||||
tenant_id: tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPublicArticleDetail(id: string) {
|
||||
return api.get<Article>(`/public/articles/${id}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 认证端点(需要登录)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listArticles(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
@@ -60,11 +101,6 @@ export async function getArticleDetail(id: string) {
|
||||
return api.get<Article>(`/health/articles/${id}`);
|
||||
}
|
||||
|
||||
/** 公开文章详情(无需认证) */
|
||||
export async function getPublicArticleDetail(id: string) {
|
||||
return api.get<Article>(`/public/articles/${id}`);
|
||||
}
|
||||
|
||||
export async function listCategories() {
|
||||
return api.get<ArticleCategory[]>('/health/article-categories');
|
||||
}
|
||||
|
||||
@@ -70,10 +70,13 @@ export class BLEManager {
|
||||
|
||||
/** 初始化蓝牙适配器 */
|
||||
async initialize(): Promise<void> {
|
||||
console.log('[ble] 步骤1: 开始初始化蓝牙适配器...');
|
||||
try {
|
||||
await Taro.openBluetoothAdapter();
|
||||
console.log('[ble] 步骤1: 蓝牙适配器初始化成功');
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg || '蓝牙初始化失败,请检查蓝牙是否开启';
|
||||
console.error('[ble] 步骤1: 蓝牙初始化失败:', errMsg);
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
}
|
||||
@@ -83,38 +86,61 @@ export class BLEManager {
|
||||
await this.initialize();
|
||||
|
||||
const discovered = new Map<string, BLEDevice>();
|
||||
const allModelKeywords = this.adapters.flatMap((a) => a.supportedModels);
|
||||
|
||||
console.log('[ble] 步骤2: 注册的适配器:', this.adapters.map((a) => a.name));
|
||||
console.log('[ble] 步骤2: 匹配关键词:', allModelKeywords);
|
||||
|
||||
let scanDeviceCount = 0;
|
||||
|
||||
const onFound = (res: BLEScanResult) => {
|
||||
for (const device of res.devices || []) {
|
||||
const devices = res.devices || [];
|
||||
scanDeviceCount += devices.length;
|
||||
|
||||
for (const device of devices) {
|
||||
const name = device.name || device.localName || '';
|
||||
if (!name) continue;
|
||||
const adapter = this.matchAdapter(name);
|
||||
if (adapter) {
|
||||
discovered.set(device.deviceId, {
|
||||
deviceId: device.deviceId,
|
||||
name,
|
||||
RSSI: device.RSSI ?? 0,
|
||||
localName: device.localName,
|
||||
advertisData: device.advertisData,
|
||||
adapter,
|
||||
});
|
||||
|
||||
// 每个新发现的设备都打印(最多前 30 个避免日志爆炸)
|
||||
if (discovered.size < 30 && !discovered.has(device.deviceId)) {
|
||||
console.log(`[ble] 发现设备: "${name}" (RSSI:${device.RSSI ?? '?'}, 匹配:${adapter?.name ?? '无'})`);
|
||||
}
|
||||
|
||||
discovered.set(device.deviceId, {
|
||||
deviceId: device.deviceId,
|
||||
name,
|
||||
RSSI: device.RSSI ?? 0,
|
||||
localName: device.localName,
|
||||
advertisData: device.advertisData,
|
||||
adapter: adapter ?? undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Taro.onBluetoothDeviceFound(onFound);
|
||||
|
||||
const allServiceUUIDs = this.adapters.flatMap((a) => a.serviceUUIDs);
|
||||
console.log('[ble] 步骤3: 开始扫描 (超时', this.config.scanTimeout, 'ms)...');
|
||||
// 不传 services 参数 — 扫描所有 BLE 设备,避免设备使用私有 UUID 被过滤掉
|
||||
await Taro.startBluetoothDevicesDiscovery({
|
||||
allowDuplicatesKey: false,
|
||||
services: allServiceUUIDs.length > 0 ? allServiceUUIDs : undefined,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.scanTimer = setTimeout(async () => {
|
||||
await this.stopScan();
|
||||
Taro.offBluetoothDeviceFound(onFound);
|
||||
resolve(Array.from(discovered.values()));
|
||||
|
||||
const results = Array.from(discovered.values());
|
||||
console.log('[ble] 步骤4: 扫描结束');
|
||||
console.log('[ble] 回调触发设备总数:', scanDeviceCount);
|
||||
console.log('[ble] 有名称的设备数:', discovered.size);
|
||||
console.log('[ble] 最终返回设备数:', results.length);
|
||||
if (results.length > 0) {
|
||||
console.log('[ble] 设备列表:', results.map((d) => `${d.name} (${d.adapter?.name ?? '无适配器'})`));
|
||||
}
|
||||
|
||||
resolve(results);
|
||||
}, this.config.scanTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,4 +142,30 @@ export const CustomBandAdapter = createGenericBleAdapter({
|
||||
profiles: ['heart_rate', 'health_thermometer'],
|
||||
});
|
||||
|
||||
/** 华为手环/手表 BLE 适配器 */
|
||||
export const HuaweiBandAdapter = createGenericBleAdapter({
|
||||
name: 'Huawei Band',
|
||||
supportedModels: [
|
||||
'HUAWEI Band',
|
||||
'HUAWEI Watch',
|
||||
'Huawei Band',
|
||||
'Huawei Watch',
|
||||
'HW-B',
|
||||
'HUAW',
|
||||
'华为手环',
|
||||
'华为手表',
|
||||
],
|
||||
profiles: ['heart_rate', 'health_thermometer'],
|
||||
});
|
||||
|
||||
/**
|
||||
* 万能 fallback 适配器 — 匹配所有有名称的设备
|
||||
* 尝试标准 BLE 健康协议(心率/体温/血压),设备不支持的服务会被安全跳过
|
||||
*/
|
||||
export const FallbackAdapter = createGenericBleAdapter({
|
||||
name: '通用设备',
|
||||
supportedModels: [], // 不参与 matchAdapter,仅作为 fallback
|
||||
profiles: ['heart_rate', 'health_thermometer', 'blood_pressure'],
|
||||
});
|
||||
|
||||
export default CustomBandAdapter;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { XiaomiBandAdapter } from './XiaomiBandAdapter';
|
||||
export { BloodPressureAdapter } from './BloodPressureAdapter';
|
||||
export { GlucoseMeterAdapter } from './GlucoseMeterAdapter';
|
||||
export { CustomBandAdapter, createGenericBleAdapter } from './GenericBleAdapter';
|
||||
export { CustomBandAdapter, HuaweiBandAdapter, FallbackAdapter, createGenericBleAdapter } from './GenericBleAdapter';
|
||||
|
||||
@@ -22,6 +22,38 @@ const ERROR_CODE_MAP: Record<string, string> = {
|
||||
CONCURRENCY_CONFLICT: '数据已被其他人修改,请刷新后重试',
|
||||
};
|
||||
|
||||
// --- 网络异常状态感知 ---
|
||||
// 检测到网络故障后,短时间内抑制后续请求,避免并发请求全部超时产生大量 toast
|
||||
// 连续失败时指数退避(3s → 6s → 12s → 30s),避免后端不可达时请求洪泛
|
||||
const OFFLINE_SUPPRESS_MS = 3000;
|
||||
const OFFLINE_MAX_MS = 30_000;
|
||||
let offlineDetectedAt = 0;
|
||||
let offlineSuppressMs = OFFLINE_SUPPRESS_MS;
|
||||
let networkToastShown = false;
|
||||
let consecutiveNetErrors = 0;
|
||||
|
||||
function isOffline(): boolean {
|
||||
return offlineDetectedAt > 0 && Date.now() - offlineDetectedAt < offlineSuppressMs;
|
||||
}
|
||||
|
||||
function markOffline(): void {
|
||||
offlineDetectedAt = Date.now();
|
||||
consecutiveNetErrors++;
|
||||
// 指数退避:连续失败越多,抑制时间越长(3s → 6s → 12s → 30s cap)
|
||||
offlineSuppressMs = Math.min(OFFLINE_MAX_MS, OFFLINE_SUPPRESS_MS * Math.pow(2, consecutiveNetErrors - 1));
|
||||
if (!networkToastShown) {
|
||||
networkToastShown = true;
|
||||
Taro.showToast({ title: '网络异常,请检查连接', icon: 'none', duration: 2000 });
|
||||
setTimeout(() => { networkToastShown = false; }, offlineSuppressMs);
|
||||
}
|
||||
}
|
||||
|
||||
function clearOffline(): void {
|
||||
offlineDetectedAt = 0;
|
||||
offlineSuppressMs = OFFLINE_SUPPRESS_MS;
|
||||
consecutiveNetErrors = 0;
|
||||
}
|
||||
|
||||
function safeGet(key: string): string {
|
||||
return secureGet(key);
|
||||
}
|
||||
@@ -139,6 +171,12 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
let retryCount401 = 0;
|
||||
for (;;) {
|
||||
if (signal?.aborted) throw new Error('请求已取消');
|
||||
|
||||
// 离线抑制:刚检测到网络故障时,直接跳过请求,避免 9+ 并发请求全部超时
|
||||
if (isOffline()) {
|
||||
throw new Error('网络异常');
|
||||
}
|
||||
|
||||
if (!bypassLimiter) await limiter.acquire();
|
||||
try {
|
||||
const headers = await getHeaders();
|
||||
@@ -153,10 +191,13 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
Taro.showToast({ title: '网络超时,请重试', icon: 'none' });
|
||||
throw new Error('网络超时');
|
||||
}
|
||||
Taro.showToast({ title: '网络异常,请检查连接', icon: 'none' });
|
||||
// 网络异常:标记离线 + toast 去重(3 秒内只弹一次)
|
||||
markOffline();
|
||||
throw new Error('网络异常');
|
||||
}
|
||||
|
||||
// 请求成功,清除离线标记
|
||||
clearOffline();
|
||||
if (signal?.aborted) throw new Error('请求已取消');
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
@@ -181,7 +222,6 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
}
|
||||
|
||||
if (res.statusCode === 403) {
|
||||
Taro.showToast({ title: '权限不足', icon: 'none' });
|
||||
throw new Error('权限不足');
|
||||
}
|
||||
|
||||
@@ -275,4 +315,6 @@ export function resetForTesting(): void {
|
||||
headersCacheTs = 0;
|
||||
refreshPromise = null;
|
||||
isLoggingOut = false;
|
||||
offlineDetectedAt = 0;
|
||||
networkToastShown = false;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import { create } from 'zustand';
|
||||
import Taro from '@tarojs/taro';
|
||||
import * as authApi from '@/services/auth';
|
||||
import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage';
|
||||
import { clearRequestCache, markLoggingOut, clearLoggingOut, setCachedPatientId } from '@/services/request';
|
||||
import { clearRequestCache, invalidateHeadersCache, markLoggingOut, clearLoggingOut, setCachedPatientId } from '@/services/request';
|
||||
|
||||
// secureGet 已内置明文键 fallback,无需再手动 fallback
|
||||
function storageGet(key: string): string {
|
||||
return secureGet(key);
|
||||
}
|
||||
import { resetAllStores } from './index';
|
||||
import { resetAnalyticsDisabled } from '@/services/analytics';
|
||||
|
||||
// --- 内存缓存,避免每次 Tab 切换重复 Storage IPC + JSON.parse ---
|
||||
let cachedUserJson = '';
|
||||
@@ -142,6 +143,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
secureSet('tenant_id', user.tenant_id || '');
|
||||
set({ user, roles, loading: false });
|
||||
clearLoggingOut();
|
||||
invalidateHeadersCache();
|
||||
resetAnalyticsDisabled();
|
||||
get().loadPatients();
|
||||
return true;
|
||||
}
|
||||
@@ -175,7 +178,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
secureSet('tenant_id', resp.user?.tenant_id || tenantId);
|
||||
set({ user: resp.user, roles, loading: false });
|
||||
clearLoggingOut();
|
||||
// 登录成功后自动加载患者档案(如果有的话)
|
||||
invalidateHeadersCache();
|
||||
resetAnalyticsDisabled();
|
||||
get().loadPatients();
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -211,6 +215,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
secureRemove('wechat_openid');
|
||||
set({ user: tokenData.user, roles, loading: false });
|
||||
clearLoggingOut();
|
||||
invalidateHeadersCache();
|
||||
resetAnalyticsDisabled();
|
||||
get().loadPatients();
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
|
||||
57
apps/miniprogram/src/utils/abort-controller-polyfill.ts
Normal file
57
apps/miniprogram/src/utils/abort-controller-polyfill.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* AbortController / AbortSignal polyfill — 微信小程序 JS 运行时
|
||||
*
|
||||
* 微信小程序 JSCore/V8 不提供 AbortController / AbortSignal Web API。
|
||||
* usePageData hook 在每个页面挂载时 new AbortController(),
|
||||
* 缺少 polyfill 会导致 ReferenceError 崩溃,影响全部 ~40 个数据页面。
|
||||
*
|
||||
* 在 app.tsx 首行导入(crypto-polyfill 之后),确保在任何页面代码之前执行。
|
||||
*
|
||||
* 实现了 usePageData / request.ts 所需的完整规范子集:
|
||||
* - signal.aborted (getter)
|
||||
* - controller.abort()
|
||||
* - signal.addEventListener('abort', cb) / removeEventListener
|
||||
*/
|
||||
|
||||
if (typeof globalThis.AbortController === 'undefined') {
|
||||
class _AbortSignal {
|
||||
aborted = false;
|
||||
private _listeners: Array<() => void> = [];
|
||||
|
||||
addEventListener(type: string, cb: () => void): void {
|
||||
if (type === 'abort') this._listeners.push(cb);
|
||||
}
|
||||
|
||||
removeEventListener(_type: string, cb: () => void): void {
|
||||
this._listeners = this._listeners.filter((fn) => fn !== cb);
|
||||
}
|
||||
|
||||
/** @internal 触发 abort 事件 */
|
||||
_doAbort(): void {
|
||||
if (this.aborted) return;
|
||||
this.aborted = true;
|
||||
const listeners = this._listeners.slice();
|
||||
this._listeners = [];
|
||||
for (const fn of listeners) {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
/* best-effort dispatch */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _AbortController {
|
||||
readonly signal = new _AbortSignal();
|
||||
|
||||
abort(): void {
|
||||
this.signal._doAbort();
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error — polyfill: globalThis 上原本没有 AbortController
|
||||
globalThis.AbortController = _AbortController;
|
||||
// @ts-expect-error — polyfill: globalThis 上原本没有 AbortSignal
|
||||
globalThis.AbortSignal = _AbortSignal;
|
||||
}
|
||||
Reference in New Issue
Block a user