Files
hms/apps/miniprogram-uniapp/src/pages-sub/doctor/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

452 lines
11 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>
<Loading v-if="pageLoading" text="加载中..." />
<scroll-view v-else scroll-y class="page-scroll">
<view :class="['page-content', elderClass]">
<!-- 顶部问候 -->
<view class="header">
<text class="header-title">医护工作台</text>
<text class="header-greeting">{{ greeting }}{{ displayName }}</text>
<text class="header-date">{{ todayStr }}</text>
</view>
<!-- 异常体征告警横幅 -->
<view v-if="alertCount > 0" class="alert-banner" @tap="goAlerts">
<text class="alert-icon">!</text>
<text class="alert-text">{{ alertCount }} 位患者体征异常</text>
<text class="alert-link">查看 ></text>
</view>
<!-- 搜索框 -->
<view class="search-bar">
<input
class="search-input"
placeholder="搜索患者姓名..."
placeholder-class="search-placeholder"
:focus="false"
@focus="goPatients"
/>
</view>
<!-- 工作概览 -->
<view class="section">
<text class="section-title">工作概览</text>
<view class="grid-2">
<view
v-for="card in visibleCards"
:key="card.key"
class="overview-card"
@tap="navigateTo(card.route)"
>
<text class="overview-card__initial">{{ card.initial }}</text>
<text class="overview-card__num">{{ getValue(card.key) }}</text>
<text class="overview-card__label">{{ card.label }}</text>
</view>
</view>
</view>
<!-- 健康审核 -->
<view v-if="visibleHealthCards.length > 0" class="section">
<text class="section-title">健康审核</text>
<view class="grid-2">
<view
v-for="card in visibleHealthCards"
:key="card.key"
class="overview-card"
@tap="navigateTo(card.route)"
>
<text class="overview-card__initial">{{ card.initial }}</text>
<text class="overview-card__num">{{ getValue(card.key) }}</text>
<text class="overview-card__label">{{ card.label }}</text>
</view>
</view>
</view>
<!-- 快捷操作 -->
<view class="section">
<text class="section-title">快捷操作</text>
<view class="grid-4">
<view
v-for="action in visibleQuickActions"
:key="action.route"
class="quick-action"
@tap="navigateTo(action.route)"
>
<view class="quick-action__icon-wrap">
<text class="quick-action__initial">{{ action.initial }}</text>
<text
v-if="action.label === '告警中心' && alertCount > 0"
class="quick-action__badge"
>{{ alertCount > 99 ? '99+' : alertCount }}</text>
</view>
<text class="quick-action__label">{{ action.label }}</text>
</view>
</view>
</view>
<!-- 退出登录 -->
<view class="footer">
<text class="logout-btn" @tap="handleLogout">退出登录</text>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useAuthStore } from '@/stores/auth'
import { useElderClass } from '@/composables/useElderClass'
import { getDashboard } from '@/services/doctor/dashboard'
import type { DoctorDashboard } from '@/services/doctor/dashboard'
import Loading from '@/components/Loading.vue'
interface CardConfig {
key: keyof DoctorDashboard
label: string
initial: string
route: string
roles?: string[]
}
const ALL_CARDS: CardConfig[] = [
{ key: 'total_patients', label: '我的患者', initial: '患', route: '/pages-sub/doctor/patients/index' },
{ key: 'unread_messages', label: '未读消息', initial: '消', route: '/pages-sub/doctor/consultation/index' },
{ key: 'pending_follow_ups', label: '待处理随访', initial: '随', route: '/pages-sub/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ key: 'today_consultations', label: '今日咨询', initial: '诊', route: '/pages-sub/doctor/consultation/index', roles: ['doctor', 'health_manager'] },
]
const ALL_HEALTH_CARDS: CardConfig[] = [
{ key: 'pending_lab_review', label: '待审化验', initial: '化', route: '/pages-sub/doctor/report/index', roles: ['doctor'] },
{ key: 'today_appointments', label: '今日预约', initial: '约', route: '/pages-sub/doctor/patients/index' },
]
interface QuickAction {
label: string
initial: string
route: string
roles: string[]
}
const ALL_QUICK_ACTIONS: QuickAction[] = [
{ label: '化验审核', initial: '审', route: '/pages-sub/doctor/report/index', roles: ['doctor'] },
{ label: '患者查询', initial: '查', route: '/pages-sub/doctor/patients/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '随访记录', initial: '随', route: '/pages-sub/doctor/followup/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '告警中心', initial: '警', route: '/pages-sub/doctor/alerts/index', roles: ['doctor', 'nurse', 'health_manager'] },
{ label: '透析管理', initial: '透', route: '/pages-sub/doctor/dialysis/index', roles: ['doctor'] },
{ label: '处方管理', initial: '方', route: '/pages-sub/doctor/prescription/index', roles: ['doctor'] },
{ label: '行动收件箱', initial: '行', route: '/pages-sub/doctor/action-inbox/index', roles: ['doctor', 'nurse', 'health_manager'] },
]
const ROLE_LABELS: Record<string, string> = {
doctor: '医生',
nurse: '护士',
health_manager: '健康管理师',
admin: '管理员',
operator: '运营',
}
const authStore = useAuthStore()
const { elderClass } = useElderClass()
const dashboard = ref<DoctorDashboard | null>(null)
const alertCount = ref(0)
const pageLoading = ref(true)
const displayName = computed(() => {
const user = authStore.user
const roles = authStore.roles
if (user?.display_name) return user.display_name
if (user?.username) return user.username
const primary = roles.find(r => r !== 'admin')
return primary ? (ROLE_LABELS[primary] || primary) : '医护'
})
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 12) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
const todayStr = computed(() => {
return new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' })
})
function hasRole(allowed: string[] | undefined): boolean {
if (!allowed) return true
return authStore.roles.some(r => r === 'admin' || allowed.includes(r))
}
const visibleCards = computed(() => ALL_CARDS.filter(c => hasRole(c.roles)))
const visibleHealthCards = computed(() => ALL_HEALTH_CARDS.filter(c => hasRole(c.roles)))
const visibleQuickActions = computed(() => ALL_QUICK_ACTIONS.filter(a => hasRole(a.roles)))
function getValue(key: keyof DoctorDashboard): number | string {
if (!dashboard.value) return '-'
return dashboard.value[key] ?? 0
}
function navigateTo(url: string) {
uni.navigateTo({ url })
}
function goPatients() {
uni.navigateTo({ url: '/pages-sub/doctor/patients/index' })
}
function goAlerts() {
uni.navigateTo({ url: '/pages-sub/doctor/alerts/index' })
}
function handleLogout() {
authStore.logout()
}
async function loadDashboard() {
pageLoading.value = true
try {
const data = await getDashboard()
dashboard.value = data
const count = (data as Record<string, unknown>)?.abnormal_vital_count
alertCount.value = typeof count === 'number' ? count : 0
} catch {
// 静默失败,显示占位
} finally {
pageLoading.value = false
}
}
onMounted(() => {
loadDashboard()
})
onShow(() => {
authStore.restore()
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 32px 24px 120px;
}
// ── 顶部问候 ──
.header {
margin-bottom: 40px;
}
.header-title {
@include section-title;
margin-bottom: 12px;
}
.header-greeting {
display: block;
font-size: var(--tk-font-h2);
color: $tx2;
margin-bottom: 8px;
}
.header-date {
font-size: var(--tk-font-h2);
color: $tx3;
}
// ── 告警横幅 ──
.alert-banner {
display: flex;
align-items: center;
margin: 0 0 24px;
padding: 16px 20px;
min-height: $touch-min;
background: $dan-l;
border-radius: $r;
border-left: 4px solid $dan;
}
.alert-icon {
@include flex-center;
width: 36px;
height: 36px;
border-radius: 50%;
background: $dan;
color: $white;
text-align: center;
line-height: 36px;
font-weight: bold;
font-size: var(--tk-font-body);
margin-right: 12px;
flex-shrink: 0;
}
.alert-text {
flex: 1;
font-size: var(--tk-font-h1);
color: $dan;
}
.alert-link {
font-size: var(--tk-font-h2);
color: $dan;
flex-shrink: 0;
}
// ── 搜索框 ──
.search-bar {
margin-bottom: 24px;
}
.search-input {
background: $surface-alt;
border-radius: $r;
padding: 16px 20px;
font-size: var(--tk-font-h1);
color: $tx;
width: 100%;
box-sizing: border-box;
}
.search-placeholder {
color: $tx3;
}
// ── 通用区块 ──
.section {
margin-bottom: 40px;
}
.section-title {
@include section-title;
}
// ── 工作概览网格 ──
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.overview-card {
background: $card;
border-radius: $r-lg;
padding: 28px 24px;
text-align: center;
box-shadow: $shadow-md;
transition: transform 0.15s;
&:active {
transform: scale(0.97);
}
}
.overview-card__initial {
display: inline-flex;
@include flex-center;
width: 56px;
height: 56px;
border-radius: $r;
background: $pri-l;
color: $pri;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: 700;
margin-bottom: 8px;
}
.overview-card__num {
@include serif-number;
font-size: var(--tk-font-hero);
font-weight: 700;
color: $tx;
display: block;
margin-bottom: 8px;
}
.overview-card__label {
font-size: var(--tk-font-h2);
color: $tx2;
}
// ── 快捷操作 ──
.grid-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.quick-action {
background: $card;
border-radius: $r-lg;
padding: 28px 20px;
text-align: center;
box-shadow: $shadow-md;
&:active {
opacity: 0.8;
}
}
.quick-action__icon-wrap {
position: relative;
display: inline-flex;
margin-bottom: 8px;
}
.quick-action__initial {
@include flex-center;
width: 56px;
height: 56px;
border-radius: $r;
background: $acc-l;
color: $acc;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-body-lg);
font-weight: 700;
}
.quick-action__badge {
position: absolute;
top: -6px;
right: -12px;
min-width: 32px;
height: 32px;
line-height: 32px;
text-align: center;
background: $dan;
color: $white;
font-size: var(--tk-font-body-sm);
font-weight: 700;
border-radius: $r-pill;
padding: 0 6px;
}
.quick-action__label {
font-size: var(--tk-font-h2);
color: $tx2;
display: block;
}
// ── 底部 ──
.footer {
margin-top: 60px;
text-align: center;
padding-bottom: env(safe-area-inset-bottom);
}
.logout-btn {
color: $dan;
font-size: var(--tk-font-h2);
padding: 16px 48px;
min-height: $touch-min;
display: inline-flex;
align-items: center;
}
</style>