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 文件)
This commit is contained in:
iven
2026-05-15 11:22:51 +08:00
parent 18fa6ce6d4
commit 2c567bd772
147 changed files with 36561 additions and 564 deletions

View File

@@ -0,0 +1,242 @@
<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>