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>