feat(mp+health): 小程序分包迁移 + 积分商城后台列表 API
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

- 小程序页面迁移到 pkg-health/pkg-mall/pkg-profile 分包目录
- 删除旧 pages/health/input、pages/mall/detail 等旧路径
- 导航路径更新为分包路径(/pages/pkg-mall/exchange/index 等)
- TrendChart 组件优化
- 后台添加 admin_list_products API(支持查看已下架商品)
- config/index.ts 添加 defineConstants 环境变量
- mp e2e check-readiness 路径修正
This commit is contained in:
iven
2026-04-29 07:29:49 +08:00
parent 9015a2b85e
commit cb6f5cc651
32 changed files with 229 additions and 516 deletions

View File

@@ -73,15 +73,15 @@ export default function Health() {
};
const goToInput = () => {
Taro.navigateTo({ url: '/pages/health/input/index' });
Taro.navigateTo({ url: '/pages/pkg-health/input/index' });
};
const goToDailyMonitoring = () => {
Taro.navigateTo({ url: '/pages/health/daily-monitoring/index' });
Taro.navigateTo({ url: '/pages/pkg-health/daily-monitoring/index' });
};
const goToTrend = (indicator: string) => {
Taro.navigateTo({ url: `/pages/health/trend/index?indicator=${indicator}` });
Taro.navigateTo({ url: `/pages/pkg-health/trend/index?indicator=${indicator}` });
};
const goToMall = () => {

View File

@@ -1,204 +0,0 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';
.input-page {
min-height: 100vh;
background: $bg;
padding: 0 0 60px;
}
/* ── hero ── */
.input-hero {
padding: 48px 32px 36px;
display: flex;
flex-direction: column;
align-items: center;
}
.input-hero-icon {
@include flex-center;
width: 88px;
height: 88px;
border-radius: $r-lg;
background: $pri-l;
margin-bottom: 20px;
}
.input-hero-icon-text {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 40px;
font-weight: bold;
color: $pri;
}
.input-hero-title {
@include section-title;
font-size: 36px;
margin-bottom: 8px;
}
.input-hero-sub {
font-size: 24px;
color: $tx3;
}
/* ── card ── */
.input-card {
background: $card;
border-radius: $r;
box-shadow: $shadow-md;
padding: 28px;
margin: 0 24px 20px;
}
.input-card-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
}
.input-card-indicator {
@include flex-center;
width: 44px;
height: 44px;
border-radius: $r-sm;
background: $acc-l;
}
.input-card-indicator-char {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 24px;
font-weight: bold;
color: $acc;
}
.input-card-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
}
/* ── picker ── */
.input-picker-row {
display: flex;
justify-content: space-between;
align-items: center;
background: $bg;
border-radius: $r-sm;
padding: 22px 24px;
}
.input-picker-value {
font-size: 28px;
color: $tx;
@include serif-number;
}
.input-picker-arrow {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 22px;
color: $tx3;
transform: rotate(180deg);
display: inline-block;
}
/* ── section title ── */
.input-section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-weight: bold;
color: $tx;
margin-bottom: 16px;
display: block;
}
/* ── blood pressure group ── */
.input-bp-group {
display: flex;
align-items: flex-end;
gap: 12px;
}
.input-bp-field {
flex: 1;
}
.input-field-label {
font-size: 22px;
color: $tx2;
display: block;
margin-bottom: 8px;
}
.input-bp-divider {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 20px;
gap: 6px;
}
.input-bp-line {
width: 16px;
height: 1px;
background: $bd;
}
.input-bp-slash {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 36px;
color: $tx3;
font-weight: 300;
}
/* ── input field ── */
.input-field-box {
background: $bg;
border-radius: $r-sm;
padding: 20px 24px;
font-size: 28px;
color: $tx;
@include serif-number;
box-sizing: border-box;
}
.input-field-full {
width: 100%;
}
.input-field-unit {
font-size: 22px;
color: $tx3;
display: block;
margin-top: 10px;
font-style: italic;
}
/* ── submit ── */
.input-submit {
background: $pri;
border-radius: $r;
padding: 26px;
text-align: center;
margin: 48px 24px 0;
box-shadow: $shadow-md;
transition: opacity 0.2s;
&:active {
opacity: 0.85;
}
}
.input-submit-disabled {
opacity: 0.5;
box-shadow: none;
}
.input-submit-text {
font-size: 32px;
color: white;
font-weight: bold;
letter-spacing: 2px;
}

View File

@@ -1,209 +0,0 @@
import { useState } from 'react';
import { View, Text, Input, Picker } from '@tarojs/components';
import Taro from '@tarojs/taro';
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';
const INDICATORS = [
{ value: 'blood_pressure', label: '血压 (mmHg)' },
{ value: 'heart_rate', label: '心率 (bpm)' },
{ value: 'blood_sugar_fasting', label: '空腹血糖 (mmol/L)' },
{ value: 'blood_sugar_postprandial', label: '餐后血糖 (mmol/L)' },
{ value: 'weight', label: '体重 (kg)' },
{ value: 'temperature', label: '体温 (℃)' },
];
const vitalSignSchema = z.object({
indicator_type: z.enum(['blood_pressure', 'heart_rate', 'blood_sugar_fasting', 'blood_sugar_postprandial', 'weight', 'temperature']),
value: z.number().positive({ message: '请输入有效数值' }),
extra: z.object({
systolic: z.number().min(60, '收缩压过低').max(250, '收缩压过高,请及时就医').optional(),
diastolic: z.number().min(40, '舒张压过低').max(150, '舒张压过高,请及时就医').optional(),
}).optional(),
note: z.string().max(200, '备注不能超过200字').optional(),
});
const WARN_THRESHOLDS: Record<string, { max?: number; min?: number; warning: string }> = {
blood_pressure: { max: 180, warning: '收缩压偏高,建议及时就医' },
heart_rate: { max: 120, min: 50, warning: '心率异常,请注意休息' },
blood_sugar_fasting: { max: 11.0, warning: '血糖偏高,建议就医检查' },
};
export default function HealthInput() {
const [indicatorIdx, setIndicatorIdx] = useState(0);
const [value, setValue] = useState('');
const [systolic, setSystolic] = useState('');
const [diastolic, setDiastolic] = useState('');
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const { currentPatient } = useAuthStore();
const { clearCache } = useHealthStore();
const handleSubmit = async () => {
if (!currentPatient) {
Taro.showToast({ title: '请先选择就诊人', icon: 'none' });
return;
}
const currentIndicator = INDICATORS[indicatorIdx].value;
if (currentIndicator === 'blood_pressure') {
if (!systolic || !diastolic) {
Taro.showToast({ title: '请填写收缩压和舒张压', icon: 'none' });
return;
}
} else {
if (!value) {
Taro.showToast({ title: '请输入数值', icon: 'none' });
return;
}
}
const input = currentIndicator === 'blood_pressure'
? { indicator_type: 'blood_pressure' as const, value: parseFloat(systolic), extra: { systolic: parseFloat(systolic), diastolic: parseFloat(diastolic) } }
: { indicator_type: currentIndicator as any, value: parseFloat(value) };
const result = vitalSignSchema.safeParse(input);
if (!result.success) {
Taro.showToast({ title: result.error.issues[0].message, icon: 'none' });
return;
}
const threshold = WARN_THRESHOLDS[currentIndicator];
if (threshold) {
const val = input.value;
if ((threshold.max && val > threshold.max) || (threshold.min && val < threshold.min)) {
await Taro.showModal({ title: '健康提示', content: threshold.warning, showCancel: false });
}
}
setSubmitting(true);
try {
await inputVitalSign(currentPatient.id, {
...input,
note: note || undefined,
});
clearCache();
clearRequestCache('/health/');
usePointsStore.getState().invalidate();
Taro.showToast({ title: '录入成功', icon: 'success' });
trackEvent('health_data_input', { type: currentIndicator });
setTimeout(() => Taro.navigateBack(), 1000);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '录入失败';
Taro.showToast({ title: msg, icon: 'none' });
} finally {
setSubmitting(false);
}
};
const indicatorInitial = INDICATORS[indicatorIdx].label.charAt(0);
return (
<View className='input-page'>
{/* 页面标题 */}
<View className='input-hero'>
<View className='input-hero-icon'>
<Text className='input-hero-icon-text'></Text>
</View>
<Text className='input-hero-title'></Text>
<Text className='input-hero-sub'></Text>
</View>
{/* 指标类型选择 */}
<View className='input-card'>
<View className='input-card-header'>
<View className='input-card-indicator'>
<Text className='input-card-indicator-char'>{indicatorInitial}</Text>
</View>
<Text className='input-card-label'></Text>
</View>
<Picker
mode='selector'
range={INDICATORS.map((i) => i.label)}
value={indicatorIdx}
onChange={(e) => setIndicatorIdx(Number(e.detail.value))}
>
<View className='input-picker-row'>
<Text className='input-picker-value'>{INDICATORS[indicatorIdx].label}</Text>
<Text className='input-picker-arrow'>V</Text>
</View>
</Picker>
</View>
{/* 数值输入 */}
{INDICATORS[indicatorIdx].value === 'blood_pressure' ? (
<View className='input-card'>
<Text className='input-section-title'></Text>
<View className='input-bp-group'>
<View className='input-bp-field'>
<Text className='input-field-label'></Text>
<Input
type='digit'
className='input-field-box'
placeholder='如 120'
value={systolic}
onInput={(e) => setSystolic(e.detail.value)}
/>
</View>
<View className='input-bp-divider'>
<View className='input-bp-line' />
<Text className='input-bp-slash'>/</Text>
<View className='input-bp-line' />
</View>
<View className='input-bp-field'>
<Text className='input-field-label'></Text>
<Input
type='digit'
className='input-field-box'
placeholder='如 80'
value={diastolic}
onInput={(e) => setDiastolic(e.detail.value)}
/>
</View>
</View>
<Text className='input-field-unit'>mmHg</Text>
</View>
) : (
<View className='input-card'>
<Text className='input-section-title'></Text>
<Input
type='digit'
className='input-field-box input-field-full'
placeholder='请输入数值'
value={value}
onInput={(e) => setValue(e.detail.value)}
/>
<Text className='input-field-unit'>
{INDICATORS[indicatorIdx].label.match(/\((.+)\)/)?.[1] || ''}
</Text>
</View>
)}
{/* 备注 */}
<View className='input-card'>
<Text className='input-section-title'></Text>
<Input
className='input-field-box input-field-full'
placeholder='如饭后2小时可选'
value={note}
onInput={(e) => setNote(e.detail.value)}
/>
</View>
{/* 提交 */}
<View
className={`input-submit ${submitting ? 'input-submit-disabled' : ''}`}
onClick={submitting ? undefined : handleSubmit}
>
<Text className='input-submit-text'>{submitting ? '提交中...' : '提交录入'}</Text>
</View>
</View>
);
}

View File

@@ -120,7 +120,7 @@ export default function Mall() {
Taro.showToast({ title: '已兑完', icon: 'none' });
return;
}
Taro.navigateTo({ url: `/pages/mall/exchange/index?product_id=${item.id}` });
Taro.navigateTo({ url: `/pages/pkg-mall/exchange/index?product_id=${item.id}` });
};
const balance = account?.balance ?? 0;
@@ -134,7 +134,7 @@ export default function Mall() {
</View>
<Text className='empty-title'></Text>
<Text className='empty-hint'>使</Text>
<View className='empty-action' onClick={() => Taro.navigateTo({ url: '/pages/profile/family-add/index' })}>
<View className='empty-action' onClick={() => Taro.navigateTo({ url: '/pages/pkg-profile/family-add/index' })}>
<Text className='empty-action-text'></Text>
</View>
</View>

View File

@@ -100,7 +100,7 @@ export default function ExchangeConfirm() {
confirmText: '查看订单',
success: () => {
Taro.navigateTo({
url: `/pages/mall/orders/index`,
url: `/pages/pkg-mall/orders/index`,
});
},
});

View File

@@ -39,12 +39,12 @@ export default function FamilyList() {
};
const goToAdd = () => {
Taro.navigateTo({ url: '/pages/profile/family-add/index' });
Taro.navigateTo({ url: '/pages/pkg-profile/family-add/index' });
};
const goToEdit = (patient: Patient) => {
Taro.setStorageSync('edit_patient', patient);
Taro.navigateTo({ url: `/pages/profile/family-add/index?id=${patient.id}` });
Taro.navigateTo({ url: `/pages/pkg-profile/family-add/index?id=${patient.id}` });
};
const genderText = (g?: string) => {

View File

@@ -6,13 +6,13 @@ import { usePointsStore } from '../../stores/points';
import './index.scss';
const MENU_ITEMS = [
{ label: '我的订单', char: '单', path: '/pages/mall/orders/index' },
{ label: '积分明细', char: '明', path: '/pages/mall/detail/index' },
{ label: '就诊人管理', char: '人', path: '/pages/profile/family/index' },
{ label: '我的报告', char: '报', path: '/pages/profile/reports/index' },
{ label: '我的随访', char: '随', path: '/pages/profile/followups/index' },
{ label: '用药提醒', char: '药', path: '/pages/profile/medication/index' },
{ label: '设置', char: '设', path: '/pages/profile/settings/index' },
{ label: '我的订单', char: '单', path: '/pages/pkg-mall/orders/index' },
{ label: '积分明细', char: '明', path: '/pages/pkg-mall/detail/index' },
{ label: '就诊人管理', char: '人', path: '/pages/pkg-profile/family/index' },
{ label: '我的报告', char: '报', path: '/pages/pkg-profile/reports/index' },
{ label: '我的随访', char: '随', path: '/pages/pkg-profile/followups/index' },
{ label: '用药提醒', char: '药', path: '/pages/pkg-profile/medication/index' },
{ label: '设置', char: '设', path: '/pages/pkg-profile/settings/index' },
];
export default function Profile() {
@@ -53,7 +53,7 @@ export default function Profile() {
{/* 积分余额 */}
<View
className='profile-stats'
onClick={() => Taro.navigateTo({ url: '/pages/mall/detail/index' })}
onClick={() => Taro.navigateTo({ url: '/pages/pkg-mall/detail/index' })}
>
<View className='stat-item'>
<Text className='stat-value'>{(pointsAccount?.balance ?? 0).toLocaleString()}</Text>