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 文件)
243 lines
12 KiB
Vue
243 lines
12 KiB
Vue
<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>
|