perf(miniprogram): 全面性能优化 — 分包加载 + 请求缓存 + 渲染优化
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

分包加载(主包从 517KB 降至 275KB,-47%):
- 将 27 个页面拆入 6 个分包(health/doctor/mall/profile/content/device)
- vendors.js 从 192KB 降至 36KB(-81%)
- echarts 514KB 仅在访问健康趋势页时按需加载

请求层优化:
- GET 请求增加 in-flight 去重 + 60s TTL 响应缓存
- 新建 points store 集中管理积分/签到状态(消除 5 处重复调用)
- health store todaySummary 增加 60s TTL
- mutation 后自动失效缓存(health input/daily-monitoring)
- logout 时清空请求缓存

渲染优化:
- 7 个组件添加 React.memo(EcCanvas/TrendChart/Loading/EmptyState 等)
- 修复 TrendChart setChartReady 导致的双重渲染
- 静态数组(quickServices/quickActions/trendLinks)提取到模块级
- restoreAuth 从页面级提升到 App 级别
- 文章列表图片添加 lazyLoad

构建优化:
- prod 配置添加 terser(drop_console + drop_debugger)
- crypto-js 从全量引入改为按需引入(AES + Utf8)
This commit is contained in:
iven
2026-04-28 11:44:37 +08:00
parent 1bece3d41f
commit fcfc0ba5d9
24 changed files with 268 additions and 192 deletions

View File

@@ -119,7 +119,7 @@ export default function ArticleList() {
</View>
{a.cover_image && (
<View className='article-card-cover'>
<Image className='cover-img' src={a.cover_image} mode='aspectFill' />
<Image className='cover-img' src={a.cover_image} mode='aspectFill' lazyLoad />
</View>
)}
</View>

View File

@@ -4,6 +4,9 @@ import Taro, { useDidShow } from '@tarojs/taro';
import { z } from 'zod';
import { createDailyMonitoring } from '@/services/health';
import { useAuthStore } from '@/stores/auth';
import { useHealthStore } from '@/stores/health';
import { usePointsStore } from '@/stores/points';
import { clearRequestCache } from '@/services/request';
import { trackEvent } from '@/services/analytics';
import './index.scss';
@@ -217,6 +220,9 @@ export default function DailyMonitoring() {
});
trackEvent('daily_monitoring_submit', { date: recordDate });
useHealthStore.getState().clearCache();
clearRequestCache('/health/');
usePointsStore.getState().invalidate();
Taro.showToast({ title: '上报成功', icon: 'success' });
setTimeout(() => {

View File

@@ -3,12 +3,24 @@ import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { useHealthStore } from '../../stores/health';
import { listDailyMonitoring, DailyMonitoring } from '../../services/health';
import { getCheckinStatus, CheckinStatus } from '../../services/points';
import { usePointsStore } from '../../stores/points';
import { useAuthStore } from '../../stores/auth';
import { trackEvent } from '../../services/analytics';
import Loading from '../../components/Loading';
import './index.scss';
const QUICK_ACTIONS = [
{ label: '日常上报', char: '日', bg: 'icon-primary' },
{ label: '体征录入', char: '录', bg: 'icon-accent' },
{ label: '查看趋势', char: '势', bg: 'icon-warn' },
];
const TREND_LINKS = [
{ label: '血压趋势', indicator: 'blood_pressure_systolic', char: '压' },
{ label: '心率趋势', indicator: 'heart_rate', char: '率' },
{ label: '血糖趋势', indicator: 'blood_sugar_fasting', char: '糖' },
];
function getStatusTag(status?: string) {
if (status === 'high') return { label: '偏高', cls: 'tag-warn' };
if (status === 'low') return { label: '偏低', cls: 'tag-warn' };
@@ -39,23 +51,17 @@ function getBarPercent(value: number | undefined, ref?: string): number {
export default function Health() {
const { todaySummary, loading, refreshToday } = useHealthStore();
const { checkinStatus, refresh: refreshPoints } = usePointsStore();
const { currentPatient } = useAuthStore();
const [checkinStatus, setCheckinStatus] = useState<CheckinStatus | null>(null);
const [recentRecords, setRecentRecords] = useState<DailyMonitoring[]>([]);
useDidShow(() => {
refreshToday();
loadExtraData();
refreshPoints();
loadRecentRecords();
});
const loadExtraData = async () => {
try {
const status = await getCheckinStatus();
setCheckinStatus(status);
} catch {
// points API 可能不可用
}
const loadRecentRecords = async () => {
if (currentPatient) {
try {
const resp = await listDailyMonitoring(currentPatient.id, { page: 1, page_size: 3 });
@@ -91,16 +97,12 @@ export default function Health() {
];
const quickActions = [
{ label: '日常上报', char: '日', bg: 'icon-primary', action: goToDailyMonitoring },
{ label: '体征录入', char: '录', bg: 'icon-accent', action: goToInput },
{ label: '查看趋势', char: '势', bg: 'icon-warn', action: () => goToTrend('blood_pressure_systolic') },
{ ...QUICK_ACTIONS[0], action: goToDailyMonitoring },
{ ...QUICK_ACTIONS[1], action: goToInput },
{ ...QUICK_ACTIONS[2], action: () => goToTrend('blood_pressure_systolic') },
];
const trendLinks = [
{ label: '血压趋势', indicator: 'blood_pressure_systolic', char: '压' },
{ label: '心率趋势', indicator: 'heart_rate', char: '率' },
{ label: '血糖趋势', indicator: 'blood_sugar_fasting', char: '糖' },
];
const trendLinks = TREND_LINKS;
const formatBp = (record: DailyMonitoring) => {
const parts: string[] = [];

View File

@@ -5,6 +5,8 @@ import { z } from 'zod';
import { inputVitalSign } from '../../../services/health';
import { useAuthStore } from '../../../stores/auth';
import { useHealthStore } from '@/stores/health';
import { usePointsStore } from '@/stores/points';
import { clearRequestCache } from '@/services/request';
import { trackEvent } from '@/services/analytics';
import './index.scss';
@@ -88,6 +90,8 @@ export default function HealthInput() {
note: note || undefined,
});
clearCache();
clearRequestCache('/health/');
usePointsStore.getState().invalidate();
Taro.showToast({ title: '录入成功', icon: 'success' });
trackEvent('health_data_input', { type: currentIndicator });
setTimeout(() => Taro.navigateBack(), 1000);

View File

@@ -11,6 +11,14 @@ import * as followupApi from '@/services/followup';
import * as articleApi from '../../services/article';
import './index.scss';
const QUICK_SERVICES = [
{ label: '预约挂号', char: '约', path: '/pages/appointment/create/index' },
{ label: '健康录入', char: '录', path: '/pages/health/input/index' },
{ label: '健康趋势', char: '势', path: '/pages/health/trend/index' },
{ label: '资讯文章', char: '文', path: '/pages/article/index' },
{ label: 'AI 报告', char: 'AI', path: '/pages/ai-report/list/index' },
];
interface UpcomingItem {
id: string;
title: string;
@@ -21,14 +29,13 @@ interface UpcomingItem {
}
export default function Index() {
const { user, currentPatient, restore: restoreAuth } = useAuthStore();
const { user, currentPatient } = useAuthStore();
const { todaySummary, loading, refreshToday } = useHealthStore();
const [upcomingItems, setUpcomingItems] = useState<UpcomingItem[]>([]);
const [upcomingLoading, setUpcomingLoading] = useState(false);
const [articles, setArticles] = useState<articleApi.Article[]>([]);
useDidShow(() => {
restoreAuth();
refreshToday();
loadUpcoming();
loadArticles();
@@ -92,18 +99,6 @@ export default function Index() {
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
const displayName = user?.display_name || currentPatient?.name || '访客';
const quickServices = [
{ label: '预约挂号', char: '约', path: '/pages/appointment/create/index' },
{ label: '健康录入', char: '录', path: '/pages/health/input/index' },
{ label: '健康趋势', char: '势', path: '/pages/health/trend/index' },
{ label: '资讯文章', char: '文', path: '/pages/article/index' },
{ label: 'AI 报告', char: 'AI', path: '/pages/ai-report/list/index' },
];
const handleServiceClick = (path: string) => {
Taro.navigateTo({ url: path });
};
const healthItems = [
{ label: '血压', value: todaySummary?.blood_pressure ? `${todaySummary.blood_pressure.systolic}/${todaySummary.blood_pressure.diastolic}` : '--/--', unit: 'mmHg', status: todaySummary?.blood_pressure?.status },
{ label: '心率', value: todaySummary?.heart_rate ? `${todaySummary.heart_rate.value}` : '--', unit: 'bpm', status: todaySummary?.heart_rate?.status },
@@ -165,8 +160,8 @@ export default function Index() {
<View className='services-section'>
<Text className='section-title'></Text>
<View className='services-row'>
{quickServices.map((svc) => (
<View className='service-btn' key={svc.label} onClick={() => handleServiceClick(svc.path)}>
{QUICK_SERVICES.map((svc) => (
<View className='service-btn' key={svc.label} onClick={() => Taro.navigateTo({ url: svc.path })}>
<View className='service-icon-wrap'>
<Text className='service-icon-text'>{svc.char}</Text>
</View>

View File

@@ -1,8 +1,9 @@
import React, { useState, useCallback, useRef } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, useReachBottom, usePullDownRefresh } from '@tarojs/taro';
import { getAccount, listMyTransactions } from '../../../services/points';
import type { PointsAccount, PointsTransaction } from '../../../services/points';
import { listMyTransactions } from '../../../services/points';
import type { PointsTransaction } from '../../../services/points';
import { usePointsStore } from '../../../stores/points';
import EmptyState from '../../../components/EmptyState';
import Loading from '../../../components/Loading';
import './index.scss';
@@ -14,7 +15,7 @@ const TYPE_TABS = [
];
export default function PointsDetail() {
const [account, setAccount] = useState<PointsAccount | null>(null);
const { account, refresh: refreshPoints } = usePointsStore();
const [transactions, setTransactions] = useState<PointsTransaction[]>([]);
const [activeTab, setActiveTab] = useState('');
const [page, setPage] = useState(1);
@@ -22,15 +23,6 @@ export default function PointsDetail() {
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
const fetchAccount = useCallback(async () => {
try {
const acct = await getAccount();
setAccount(acct);
} catch {
// 账户可能尚未创建
}
}, []);
const fetchTransactions = useCallback(
async (pageNum: number, type: string, isRefresh = false) => {
if (loadingRef.current) return;
@@ -65,9 +57,9 @@ export default function PointsDetail() {
const loadAll = useCallback(
async (type?: string) => {
const t = type !== undefined ? type : activeTab;
await Promise.all([fetchAccount(), fetchTransactions(1, t, true)]);
await Promise.all([refreshPoints(), fetchTransactions(1, t, true)]);
},
[fetchAccount, fetchTransactions, activeTab],
[refreshPoints, fetchTransactions, activeTab],
);
useDidShow(() => {

View File

@@ -2,11 +2,11 @@ import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import {
getAccount,
listProducts,
exchangeProduct,
} from '../../../services/points';
import type { PointsAccount, PointsProduct } from '../../../services/points';
import type { PointsProduct } from '../../../services/points';
import { usePointsStore } from '../../../stores/points';
import Loading from '../../../components/Loading';
import './index.scss';
@@ -30,7 +30,7 @@ const TYPE_COLOR: Record<string, string> = {
export default function ExchangeConfirm() {
const [product, setProduct] = useState<PointsProduct | null>(null);
const [account, setAccount] = useState<PointsAccount | null>(null);
const { account, refresh: refreshPoints } = usePointsStore();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -50,9 +50,9 @@ export default function ExchangeConfirm() {
setLoading(true);
try {
const [productRes, accountRes] = await Promise.all([
const [productRes] = await Promise.all([
listProducts({ page: 1, page_size: 100 }),
getAccount(),
refreshPoints(),
]);
const found = productRes.data.find((p) => p.id === productId);
if (!found) {
@@ -61,14 +61,13 @@ export default function ExchangeConfirm() {
return;
}
setProduct(found);
setAccount(accountRes);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
setTimeout(() => Taro.navigateBack(), 1500);
} finally {
setLoading(false);
}
}, []);
}, [refreshPoints]);
const balance = account?.balance ?? 0;
const cost = product?.points_cost ?? 0;

View File

@@ -1,14 +1,10 @@
import React, { useState, useCallback, useRef } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, useReachBottom, usePullDownRefresh } from '@tarojs/taro';
import {
getAccount,
dailyCheckin,
getCheckinStatus,
listProducts,
} from '../../services/points';
import type { PointsAccount, PointsProduct, CheckinStatus } from '../../services/points';
import { listProducts } from '../../services/points';
import type { PointsProduct } from '../../services/points';
import { useAuthStore } from '../../stores/auth';
import { usePointsStore } from '../../stores/points';
import Loading from '../../components/Loading';
import './index.scss';
@@ -27,8 +23,7 @@ const TYPE_BG: Record<string, string> = {
export default function Mall() {
const { currentPatient } = useAuthStore();
const [account, setAccount] = useState<PointsAccount | null>(null);
const [checkinStatus, setCheckinStatus] = useState<CheckinStatus | null>(null);
const { account, checkinStatus, refresh: refreshPoints, doCheckin } = usePointsStore();
const [products, setProducts] = useState<PointsProduct[]>([]);
const [productType, setProductType] = useState('');
const [page, setPage] = useState(1);
@@ -38,24 +33,6 @@ export default function Mall() {
const [noProfile, setNoProfile] = useState(false);
const loadingRef = useRef(false);
const fetchAccountAndCheckin = useCallback(async () => {
if (!currentPatient) {
setNoProfile(true);
return;
}
setNoProfile(false);
try {
const [acct, status] = await Promise.all([
getAccount(),
getCheckinStatus(),
]);
setAccount(acct);
setCheckinStatus(status);
} catch {
// 账户可能尚未创建
}
}, [currentPatient]);
const fetchProducts = useCallback(
async (pageNum: number, type: string, isRefresh = false) => {
if (loadingRef.current) return;
@@ -88,9 +65,14 @@ export default function Mall() {
const loadAll = useCallback(
async (type?: string) => {
const t = type !== undefined ? type : productType;
await Promise.all([fetchAccountAndCheckin(), fetchProducts(1, t, true)]);
if (!currentPatient) {
setNoProfile(true);
return;
}
setNoProfile(false);
await Promise.all([refreshPoints(), fetchProducts(1, t, true)]);
},
[fetchAccountAndCheckin, fetchProducts, productType],
[currentPatient, refreshPoints, fetchProducts, productType],
);
useDidShow(() => {
@@ -114,11 +96,10 @@ export default function Mall() {
if (checkinLoading || checkinStatus?.checked_in_today) return;
setCheckinLoading(true);
try {
const result = await dailyCheckin();
setCheckinStatus(result);
const acct = await getAccount();
setAccount(acct);
Taro.showToast({ title: '签到成功', icon: 'success', duration: 2000 });
const ok = await doCheckin();
if (ok) {
Taro.showToast({ title: '签到成功', icon: 'success', duration: 2000 });
}
} catch (err) {
Taro.showToast({
title: err instanceof Error ? err.message : '签到失败',

View File

@@ -1,9 +1,8 @@
import React, { useState, useCallback } from 'react';
import React from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { useAuthStore } from '../../stores/auth';
import { getAccount, getCheckinStatus } from '../../services/points';
import type { PointsAccount, CheckinStatus } from '../../services/points';
import { usePointsStore } from '../../stores/points';
import './index.scss';
const MENU_ITEMS = [
@@ -17,28 +16,13 @@ const MENU_ITEMS = [
];
export default function Profile() {
const { user, restore: restoreAuth, logout } = useAuthStore();
const [pointsAccount, setPointsAccount] = useState<PointsAccount | null>(null);
const [checkinInfo, setCheckinInfo] = useState<CheckinStatus | null>(null);
const { user, logout } = useAuthStore();
const { account: pointsAccount, checkinStatus: checkinInfo, refresh: refreshPoints } = usePointsStore();
useDidShow(() => {
restoreAuth();
loadPointsInfo();
refreshPoints();
});
const loadPointsInfo = useCallback(async () => {
try {
const [acct, status] = await Promise.all([
getAccount(),
getCheckinStatus(),
]);
setPointsAccount(acct);
setCheckinInfo(status);
} catch {
// 账户可能尚未创建
}
}, []);
const handleMenuClick = (path: string) => {
Taro.navigateTo({ url: path });
};