Files
hms/apps/miniprogram-uniapp/src/pages-sub/pkg-health/input/index.vue
iven 2c567bd772 fix(mp): T40 UI 审查全量修复 + 设计体系一致性优化
Phase 0 基础设施:
- statusTag.ts: getStatusInlineStyle() 移除内联 borderRadius/padding/fontSize,仅返回 {background, color}
- 新增 SEVERITY_COLORS + getSeverityStyle() + getSeverityLabel() 统一告警严重程度样式
- variables.scss: 新增 9 个语义颜色别名 ($success/$danger/$warning/$info 等)
- mixins.scss: 新增 status-inline mixin 统一状态标签样式
- 7 个消费者页面添加 @include status-inline CSS 补偿

Phase 1 HIGH 修复 (4 页面):
- P46 随访管理: 移除 getTypeStyle() 硬编码 fontSize,替换文字 Loading 为组件
- P45 咨询详情医护: 添加 Loading/ErrorState 三态模板 + error ref
- P02 健康数据: 添加 loading ref + Loading 组件 + 错误 toast 提示
- P48 告警中心: 替换本地 SEVERITY_COLORS/SEVERITY_LABELS 为 statusTag.ts 导出

Phase 2 全局一致性:
- 2.1 触控补全: 17 页面为可点击元素添加 min-height: $touch-min
- 2.2 字号替换: 19 文件 31 处硬编码 px → Design Token CSS 变量
- 2.3 颜色替换: 18 文件 ~50 处硬编码十六进制 → SCSS 语义变量
- 2.4 elder-mode.scss: 新增 9 个选择器到触控放大清单

Phase 3 LOW 修复:
- 3.1 统一 Loading: 21 页面旧式文字加载 → <Loading> 组件
- 3.2 useElderClass: 8 页面补全长者模式 class 绑定
- 3.3 零散修复: 按钮 44px→48px,诊断记录添加 scroll-view 无限加载

同时新增 UniApp (Vue 3 + Vite) 小程序完整代码库 (146 文件)
2026-05-15 11:22:51 +08:00

243 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view :class="['input-page', elderClass]">
<view class="input-hero">
<view class="input-hero-icon">
<text class="input-hero-icon-text"></text>
</view>
<text class="input-hero-title">体征录入</text>
<text class="input-hero-sub">记录今日健康数据</text>
</view>
<view class="input-sync-entry" @tap="goDeviceSync">
<text class="input-sync-entry-text">从设备同步</text>
<text class="input-sync-entry-hint">蓝牙连接设备自动获取数据</text>
</view>
<view class="input-card">
<view class="input-card-header">
<view class="input-card-indicator">
<text class="input-card-indicator-char">{{ indicatorInitial }}</text>
</view>
<text class="input-card-label">指标类型</text>
</view>
<picker mode="selector" :range="indicatorLabels" :value="indicatorIdx" @change="onIndicatorChange">
<view class="input-picker-row">
<text class="input-picker-value">{{ INDICATORS[indicatorIdx].label }}</text>
<text class="input-picker-arrow">V</text>
</view>
</picker>
</view>
<view v-if="isBpIndicator" class="input-card">
<text class="input-section-title">血压数值</text>
<view class="input-bp-group">
<view class="input-bp-field">
<text class="input-field-label">收缩压</text>
<input type="digit" class="input-field-box" placeholder="如 120" :value="systolic" @input="(e: any) => systolic = e.detail.value" />
</view>
<view class="input-bp-divider">
<view class="input-bp-line" />
<text class="input-bp-slash">/</text>
<view class="input-bp-line" />
</view>
<view class="input-bp-field">
<text class="input-field-label">舒张压</text>
<input type="digit" class="input-field-box" placeholder="如 80" :value="diastolic" @input="(e: any) => diastolic = e.detail.value" />
</view>
</view>
<text class="input-field-unit">mmHg</text>
</view>
<view v-else class="input-card">
<text class="input-section-title">检测数值</text>
<input type="digit" class="input-field-box input-field-full" placeholder="请输入数值" :value="val" @input="(e: any) => val = e.detail.value" />
<text class="input-field-unit">{{ unitLabel }}</text>
</view>
<view class="input-card">
<text class="input-section-title">备注</text>
<input class="input-field-box input-field-full" placeholder="如饭后2小时可选" :value="note" @input="(e: any) => note = e.detail.value" />
</view>
<view :class="['input-submit', submitting ? 'input-submit-disabled' : '']" @tap="submitting ? undefined : handleSubmit">
<text class="input-submit-text">{{ submitting ? '提交中...' : '提交录入' }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { num, validateStr } from '@/utils/validate'
import { inputVitalSign, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } 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 { useElderClass } from '@/composables/useElderClass'
const INDICATORS = [
{ value: 'blood_pressure', label: '晨间血压 (mmHg)' },
{ value: 'blood_pressure_evening', 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 BP_INDICATORS = ['blood_pressure', 'blood_pressure_evening']
const valueCheck = num({ posMsg: '请输入有效数值' })
const systolicCheck = num({ min: 60, minMsg: '收缩压过低', max: 250, maxMsg: '收缩压过高,请及时就医', optional: true })
const diastolicCheck = num({ min: 40, minMsg: '舒张压过低', max: 150, maxMsg: '舒张压过高,请及时就医', optional: true })
function getWarnForIndicator(thresholds: HealthThreshold[], indicator: string) {
const isBp = BP_INDICATORS.includes(indicator)
const high = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'high')
const low = findThreshold(thresholds, isBp ? 'systolic_bp' : indicator, 'low')
if (!high && !low) return null
const warningMap: Record<string, string> = {
blood_pressure: '收缩压偏高,建议及时就医',
blood_pressure_evening: '收缩压偏高,建议及时就医',
heart_rate: '心率异常,请注意休息',
blood_sugar_fasting: '血糖偏高,建议就医检查',
blood_sugar_postprandial: '血糖偏高,建议就医检查',
}
return { max: high?.threshold_value, min: low?.threshold_value, warning: warningMap[indicator] ?? '数值异常,请关注' }
}
const { elderClass } = useElderClass()
const authStore = useAuthStore()
const healthStore = useHealthStore()
const pointsStore = usePointsStore()
const indicatorIdx = ref(0)
const thresholds = ref<HealthThreshold[]>(DEFAULT_THRESHOLDS)
const val = ref('')
const systolic = ref('')
const diastolic = ref('')
const note = ref('')
const submitting = ref(false)
const indicatorLabels = INDICATORS.map(i => i.label)
const isBpIndicator = computed(() => BP_INDICATORS.includes(INDICATORS[indicatorIdx.value].value))
const indicatorInitial = computed(() => INDICATORS[indicatorIdx.value].label.charAt(0))
const unitLabel = computed(() => INDICATORS[indicatorIdx.value].label.match(/\((.+)\)/)?.[1] || '')
const onIndicatorChange = (e: any) => { indicatorIdx.value = Number(e.detail.value) }
const goDeviceSync = () => {
uni.navigateTo({ url: '/pages-sub/device-sync/index?returnTo=input' })
}
onShow(() => {
getHealthThresholds().then(t => { if (t.length > 0) thresholds.value = t })
try {
const raw = uni.getStorageSync('device_sync_result')
if (!raw) return
uni.removeStorageSync('device_sync_result')
const syncData: Record<string, number> = typeof raw === 'string' ? JSON.parse(raw) : raw
if (syncData.systolic != null && syncData.diastolic != null) {
indicatorIdx.value = 0
systolic.value = String(syncData.systolic)
diastolic.value = String(syncData.diastolic)
} else if (syncData.blood_sugar != null) {
indicatorIdx.value = 3
val.value = String(syncData.blood_sugar)
} else if (syncData.heart_rate != null) {
indicatorIdx.value = 2
val.value = String(syncData.heart_rate)
}
} catch { /* ignore */ }
})
const handleSubmit = async () => {
const patient = authStore.currentPatient
if (!patient) { uni.showToast({ title: '请先选择就诊人', icon: 'none' }); return }
const currentIndicator = INDICATORS[indicatorIdx.value].value
if (BP_INDICATORS.includes(currentIndicator)) {
if (!systolic.value || !diastolic.value) { uni.showToast({ title: '请填写收缩压和舒张压', icon: 'none' }); return }
} else {
if (!val.value) { uni.showToast({ title: '请输入数值', icon: 'none' }); return }
}
const input = BP_INDICATORS.includes(currentIndicator)
? { indicator_type: currentIndicator as 'blood_pressure' | 'blood_pressure_evening', value: parseFloat(systolic.value), extra: { systolic: parseFloat(systolic.value), diastolic: parseFloat(diastolic.value) } }
: { indicator_type: currentIndicator as any, value: parseFloat(val.value) }
const valueResult = valueCheck.safeParse(input.value)
if (!valueResult.ok) { uni.showToast({ title: valueResult.message, icon: 'none' }); return }
if (input.extra?.systolic !== undefined) {
const r = systolicCheck.safeParse(input.extra.systolic)
if (!r.ok) { uni.showToast({ title: r.message, icon: 'none' }); return }
}
if (input.extra?.diastolic !== undefined) {
const r = diastolicCheck.safeParse(input.extra.diastolic)
if (!r.ok) { uni.showToast({ title: r.message, icon: 'none' }); return }
}
if (note.value) {
const err = validateStr(note.value, 200, '备注')
if (err) { uni.showToast({ title: err, icon: 'none' }); return }
}
const threshold = getWarnForIndicator(thresholds.value, currentIndicator)
if (threshold) {
const v = input.value
if ((threshold.max && v > threshold.max) || (threshold.min && v < threshold.min)) {
await uni.showModal({ title: '健康提示', content: threshold.warning, showCancel: false })
}
}
submitting.value = true
try {
await inputVitalSign(patient.id, { ...input, note: note.value || undefined })
healthStore.clearCache()
clearRequestCache('/health/')
pointsStore.invalidate()
uni.showToast({ title: '录入成功', icon: 'success' })
trackEvent('health_data_input', { type: currentIndicator })
setTimeout(() => uni.navigateBack(), 1000)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '录入失败'
uni.showToast({ title: msg, icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.input-page { min-height: 100vh; background: $bg; padding: 0 24px 120px; }
.input-hero { @include flex-center; flex-direction: column; padding: 40px 0 24px; }
.input-hero-icon { width: 56px; height: 56px; border-radius: 50%; background: $pri; @include flex-center; margin-bottom: 12px; }
.input-hero-icon-text { color: $white; font-size: var(--tk-font-body); font-weight: 600; }
.input-hero-title { font-size: var(--tk-font-title); font-weight: 600; color: $tx; }
.input-hero-sub { font-size: var(--tk-font-caption); color: $tx3; margin-top: 4px; }
.input-sync-entry { @include card; display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.input-sync-entry-text { font-size: var(--tk-font-body); color: $pri; font-weight: 500; }
.input-sync-entry-hint { font-size: var(--tk-font-cap); color: $tx3; }
.input-card { @include card; margin-bottom: 16px; }
.input-card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.input-card-indicator { width: 32px; height: 32px; border-radius: 8px; background: rgba($pri, 0.1); @include flex-center; }
.input-card-indicator-char { color: $pri; font-size: var(--tk-font-cap); font-weight: 600; }
.input-card-label { font-size: var(--tk-font-body); color: $tx; font-weight: 500; }
.input-picker-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-top: 1px solid rgba(0,0,0,0.05); }
.input-picker-value { font-size: var(--tk-font-body); color: $tx; }
.input-picker-arrow { color: $tx3; font-size: var(--tk-font-micro); }
.input-section-title { font-size: var(--tk-font-cap); color: $tx2; margin-bottom: 10px; display: block; }
.input-bp-group { display: flex; align-items: flex-end; gap: 8px; }
.input-bp-field { flex: 1; }
.input-field-label { font-size: var(--tk-font-cap); color: $tx3; display: block; margin-bottom: 6px; }
.input-field-box { height: 44px; border: 1px solid rgba(0,0,0,0.08); border-radius: $r; padding: 0 12px; font-size: var(--tk-font-body); width: 100%; box-sizing: border-box; }
.input-field-full { width: 100%; }
.input-bp-divider { display: flex; flex-direction: column; align-items: center; gap: 4px; padding-bottom: 10px; }
.input-bp-line { width: 1px; height: 12px; background: rgba(0,0,0,0.1); }
.input-bp-slash { color: $tx3; font-size: var(--tk-font-cap); }
.input-field-unit { font-size: var(--tk-font-cap); color: $tx3; margin-top: 6px; display: block; }
.input-submit { margin-top: 24px; height: 48px; background: $pri; border-radius: $r; @include flex-center; }
.input-submit-disabled { opacity: 0.5; }
.input-submit-text { color: $white; font-size: var(--tk-font-body); font-weight: 500; }
</style>