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,448 @@
<template>
<Loading v-if="pageLoading" text="加载中..." />
<scroll-view v-else scroll-y class="page-scroll">
<view :class="['page-content', elderClass]">
<!-- Tab 筛选 -->
<scroll-view scroll-x class="tab-bar">
<view
v-for="tab in FILTER_TABS"
:key="tab.key"
:class="['tab-chip', { 'tab-chip--active': activeFilter === tab.key }]"
@tap="handleFilterChange(tab.key)"
>
<text class="tab-chip__text">{{ tab.label }}</text>
</view>
</scroll-view>
<!-- 我的统计 -->
<view v-if="stats" class="section">
<text class="section-title">我的统计</text>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-item__value">{{ stats.pending }}</text>
<text class="stat-item__label">待处理</text>
</view>
<view class="stat-item stat-item--warn">
<text class="stat-item__value">{{ stats.overdue }}</text>
<text class="stat-item__label">紧急事项</text>
</view>
<view class="stat-item stat-item--success">
<text class="stat-item__value">{{ stats.completed_today }}</text>
<text class="stat-item__label">今日完成</text>
</view>
</view>
</view>
<!-- 团队概览 -->
<view v-if="team" class="section">
<text class="section-title">团队概览</text>
<view class="team-row">
<text class="team-row__label">团队待处理</text>
<text class="team-row__value">{{ team.total_pending }}</text>
</view>
<view class="team-row">
<text class="team-row__label">平均响应时间</text>
<text class="team-row__value">{{ formatResponseTime(team.avg_response_time) }}</text>
</view>
<view v-if="team.members.length > 0" class="team-members">
<view
v-for="member in team.members"
:key="member.user_id"
class="member-item"
>
<text class="member-item__name">{{ member.user_name }}</text>
<text class="member-item__role">{{ member.role }}</text>
<text
:class="['member-item__tasks', member.active_tasks > 0 ? 'member-item__tasks--active' : '']"
>
{{ member.active_tasks }}
</text>
</view>
</view>
</view>
<!-- 我的患者 -->
<view class="section">
<text class="section-title">我的患者</text>
<EmptyState v-if="patients.length === 0" icon="👥" title="暂无患者" />
<view v-else class="patient-cards">
<view
v-for="p in filteredPatients"
:key="p.patient_id"
class="patient-card"
@tap="goPatientDetail(p.patient_id)"
>
<view class="patient-card__header">
<text class="patient-card__name">{{ p.patient_name }}</text>
<text v-if="p.bed_number" class="patient-card__bed">
{{ p.bed_number }}
</text>
</view>
<view v-if="p.primary_diagnosis" class="patient-card__diagnosis">
<text class="patient-card__diagnosis-text">{{ p.primary_diagnosis }}</text>
</view>
<view class="patient-card__footer">
<view v-if="p.open_action_count > 0" class="patient-card__actions">
<text class="patient-card__actions-text">
{{ p.open_action_count }} 项待办
</text>
</view>
<text v-if="p.care_plan_status" class="patient-card__plan">
{{ p.care_plan_status }}
</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import {
getWorkbenchStats,
getTeamOverview,
getMyPatients,
} from '@/services/doctor/actionInbox'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
interface WorkbenchStats {
pending: number
in_progress: number
completed_today: number
overdue: number
}
interface TeamMember {
user_id: string
user_name: string
role: string
active_tasks: number
}
interface TeamOverviewData {
team_name: string
members: TeamMember[]
total_pending: number
avg_response_time: number
}
interface NursePatientSummary {
patient_id: string
patient_name: string
bed_number?: string
primary_diagnosis?: string
care_plan_status?: string
open_action_count: number
}
const FILTER_TABS = [
{ key: '', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'urgent', label: '紧急' },
] as const
const { elderClass } = useElderClass()
const stats = ref<WorkbenchStats | null>(null)
const team = ref<TeamOverviewData | null>(null)
const patients = ref<NursePatientSummary[]>([])
const activeFilter = ref('')
const pageLoading = ref(true)
const filteredPatients = computed(() => {
const list = patients.value
if (activeFilter.value === 'pending') {
return list.filter((p) => p.open_action_count > 0)
}
if (activeFilter.value === 'urgent') {
return list.filter((p) => p.open_action_count >= 3)
}
return list
})
function formatResponseTime(minutes: number): string {
if (minutes < 60) return `${Math.round(minutes)} 分钟`
const hours = Math.floor(minutes / 60)
const mins = Math.round(minutes % 60)
return mins > 0 ? `${hours} 小时 ${mins} 分钟` : `${hours} 小时`
}
async function loadData() {
pageLoading.value = true
try {
const [s, t, p] = await Promise.all([
getWorkbenchStats(true),
getTeamOverview(),
getMyPatients(),
])
stats.value = s
team.value = t as TeamOverviewData
patients.value = p || []
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
}
}
function handleFilterChange(key: string) {
activeFilter.value = key
}
function goPatientDetail(patientId: string) {
uni.navigateTo({
url: `/pages-sub/doctor/patients/detail/index?id=${patientId}`,
})
}
onShow(() => {
loadData()
})
onPullDownRefresh(() => {
loadData().finally(() => {
uni.stopPullDownRefresh()
})
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
// ── 标签栏 ──
.tab-bar {
white-space: nowrap;
margin-bottom: 24px;
width: 100%;
}
.tab-chip {
display: inline-flex;
align-items: center;
padding: 10px 28px;
min-height: $touch-min;
border-radius: $r-pill;
background: $card;
box-shadow: $shadow-sm;
margin-right: 12px;
&--active {
background: $pri;
.tab-chip__text {
color: $card;
}
}
&__text {
font-size: var(--tk-font-body);
color: $tx2;
font-weight: 500;
}
}
// ── 通用区块 ──
.section {
background: $card;
border-radius: $r-lg;
padding: 28px;
margin-bottom: 20px;
box-shadow: $shadow-sm;
}
.section-title {
@include section-title;
}
// ── 统计网格 ──
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
.stat-item {
text-align: center;
background: $pri-l;
border-radius: $r;
padding: 20px 12px;
&--warn {
background: $wrn-l;
}
&--success {
background: $acc-l;
}
&__value {
@include serif-number;
font-size: var(--tk-font-num-lg);
font-weight: 700;
color: $pri;
display: block;
margin-bottom: 6px;
.stat-item--warn & {
color: $wrn;
}
.stat-item--success & {
color: $acc;
}
}
&__label {
font-size: var(--tk-font-body-sm);
color: $tx2;
}
}
// ── 团队概览 ──
.team-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid $bd-l;
&:last-of-type {
border-bottom: none;
}
&__label {
font-size: var(--tk-font-body);
color: $tx2;
}
&__value {
@include serif-number;
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
}
}
.team-members {
margin-top: 16px;
border-top: 1px solid $bd-l;
padding-top: 16px;
}
.member-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
&__name {
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
flex: 1;
}
&__role {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-right: 16px;
}
&__tasks {
font-size: var(--tk-font-body-sm);
color: $tx3;
&--active {
color: $wrn;
font-weight: 600;
}
}
}
// ── 患者卡片 ──
.patient-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.patient-card {
background: $bg;
border-radius: $r;
padding: 20px;
&:active {
background: $bd-l;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__name {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
}
&__bed {
font-size: var(--tk-font-body-sm);
color: $tx2;
background: $card;
padding: 4px 12px;
border-radius: $r-xs;
}
&__diagnosis {
margin-bottom: 10px;
}
&__diagnosis-text {
font-size: var(--tk-font-body);
color: $tx2;
}
&__footer {
display: flex;
justify-content: space-between;
align-items: center;
}
&__actions {
background: $wrn-l;
padding: 4px 12px;
border-radius: $r-xs;
}
&__actions-text {
font-size: var(--tk-font-body-sm);
color: $wrn;
font-weight: 500;
}
&__plan {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
}
</style>

View File

@@ -0,0 +1,383 @@
<template>
<Loading v-if="pageLoading" text="加载中..." />
<view v-else-if="!alert" :class="['error-wrap', elderClass]">
<text class="error-text">告警信息加载失败</text>
</view>
<scroll-view v-else scroll-y class="page-scroll">
<view :class="['page-content', elderClass]">
<!-- 告警标题 + 严重程度 -->
<view class="section">
<view class="alert-header">
<text class="alert-header__title">{{ alert.title }}</text>
<view
class="alert-header__severity"
:style="getSeverityStyle(alert.severity)"
>
<text class="alert-header__severity-text">
{{ getSeverityLabel(alert.severity) }}
</text>
</view>
</view>
<view class="status-row">
<text class="status-row__label">状态</text>
<view
class="status-row__badge"
:style="getStatusInlineStyle(alert.status)"
>
<text class="status-row__badge-text">
{{ getStatusLabel(alert.status) }}
</text>
</view>
</view>
</view>
<!-- 患者与指标信息 -->
<view class="section">
<text class="section-title">告警详情</text>
<view class="info-grid">
<view v-if="alert.detail?.patient_name" class="info-item">
<text class="info-label">患者</text>
<text class="info-value">{{ alert.detail.patient_name }}</text>
</view>
<view v-if="alert.detail?.indicator_name" class="info-item">
<text class="info-label">指标</text>
<text class="info-value">{{ alert.detail.indicator_name }}</text>
</view>
<view v-if="alert.detail?.threshold_value != null" class="info-item">
<text class="info-label">阈值</text>
<text class="info-value">{{ alert.detail.threshold_value }}</text>
</view>
<view v-if="alert.detail?.actual_value != null" class="info-item">
<text class="info-label">实际值</text>
<text class="info-value info-value--warn">
{{ alert.detail.actual_value }}
</text>
</view>
</view>
</view>
<!-- 时间信息 -->
<view class="section">
<text class="section-title">时间线</text>
<view class="timeline">
<view class="timeline-item">
<text class="timeline-item__label">创建时间</text>
<text class="timeline-item__value">
{{ formatDate(alert.created_at, 'YYYY-MM-DD HH:mm') }}
</text>
</view>
<view v-if="alert.acknowledged_at" class="timeline-item">
<text class="timeline-item__label">确认时间</text>
<text class="timeline-item__value">
{{ formatDate(alert.acknowledged_at, 'YYYY-MM-DD HH:mm') }}
</text>
</view>
<view v-if="alert.resolved_at" class="timeline-item">
<text class="timeline-item__label">解决时间</text>
<text class="timeline-item__value">
{{ formatDate(alert.resolved_at, 'YYYY-MM-DD HH:mm') }}
</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view v-if="hasActions" class="section">
<text class="section-title">操作</text>
<view class="action-buttons">
<button
v-if="alert.status === 'pending'"
class="btn btn--primary"
:loading="actionLoading === 'acknowledge'"
@tap="handleAcknowledge"
>
确认告警
</button>
<button
v-if="alert.status === 'pending'"
class="btn btn--outline"
:loading="actionLoading === 'dismiss'"
@tap="handleDismiss"
>
忽略
</button>
<button
v-if="alert.status === 'acknowledged'"
class="btn btn--primary"
:loading="actionLoading === 'resolve'"
@tap="handleResolve"
>
标记已解决
</button>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import {
listAlerts,
acknowledgeAlert,
dismissAlert,
resolveAlert,
getCachedAlert,
updateCachedAlert,
} from '@/services/doctor/alerts'
import type { Alert } from '@/services/doctor/alerts'
import { getStatusInlineStyle, getStatusLabel, getSeverityStyle, getSeverityLabel } from '@/utils/statusTag'
import { formatDate } from '@/utils/date'
import Loading from '@/components/Loading.vue'
const { elderClass } = useElderClass()
const alert = ref<Alert | null>(null)
const pageLoading = ref(true)
const actionLoading = ref<string | null>(null)
const alertId = ref('')
const hasActions = computed(() => {
const s = alert.value?.status
return s === 'pending' || s === 'acknowledged'
})
async function loadAlert() {
if (!alertId.value) return
pageLoading.value = true
try {
// 优先从列表页缓存读取
const cached = getCachedAlert(alertId.value)
if (cached) {
alert.value = cached
return
}
// 缓存未命中时回退到列表查询
const res = await listAlerts({ page: 1, page_size: 100 })
const found = (res.data || []).find((a) => a.id === alertId.value)
alert.value = found || null
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
}
}
async function handleAcknowledge() {
if (!alert.value || actionLoading.value) return
actionLoading.value = 'acknowledge'
try {
const updated = await acknowledgeAlert(alert.value.id, alert.value.version)
alert.value = { ...alert.value, ...updated }
updateCachedAlert(alert.value)
uni.showToast({ title: '已确认', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
actionLoading.value = null
}
}
async function handleDismiss() {
if (!alert.value || actionLoading.value) return
actionLoading.value = 'dismiss'
try {
const updated = await dismissAlert(alert.value.id, alert.value.version)
alert.value = { ...alert.value, ...updated }
updateCachedAlert(alert.value)
uni.showToast({ title: '已忽略', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
actionLoading.value = null
}
}
async function handleResolve() {
if (!alert.value || actionLoading.value) return
actionLoading.value = 'resolve'
try {
const updated = await resolveAlert(alert.value.id, alert.value.version)
alert.value = { ...alert.value, ...updated }
updateCachedAlert(alert.value)
uni.showToast({ title: '已解决', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
actionLoading.value = null
}
}
onLoad((query) => {
alertId.value = query?.id || ''
loadAlert()
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
.error-wrap {
@include flex-center;
height: 100vh;
background: $bg;
}
.error-text {
font-size: var(--tk-font-body-lg);
color: $tx3;
}
// ── 通用区块 ──
.section {
background: $card;
border-radius: $r-lg;
padding: 28px;
margin-bottom: 20px;
box-shadow: $shadow-sm;
}
.section-title {
@include section-title;
}
// ── 告警头部 ──
.alert-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
&__title {
font-size: var(--tk-font-h1);
font-weight: 700;
color: $tx;
flex: 1;
margin-right: 16px;
line-height: 1.4;
}
&__severity {
@include status-inline;
flex-shrink: 0;
}
&__severity-text {
font-size: var(--tk-font-body);
font-weight: 600;
}
}
// ── 状态行 ──
.status-row {
display: flex;
align-items: center;
gap: 12px;
&__label {
font-size: var(--tk-font-body);
color: $tx3;
}
&__badge {
@include status-inline;
}
&__badge-text {
font-size: var(--tk-font-body-sm);
}
}
// ── 信息网格 ──
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.info-label {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body-lg);
color: $tx;
font-weight: 500;
&--warn {
color: $dan;
}
}
// ── 时间线 ──
.timeline {
display: flex;
flex-direction: column;
gap: 16px;
}
.timeline-item {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&__label {
font-size: var(--tk-font-body);
color: $tx2;
}
&__value {
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
}
// ── 操作按钮 ──
.action-buttons {
display: flex;
gap: 16px;
}
.btn {
flex: 1;
height: $btn-primary-h;
border-radius: $r;
font-size: var(--tk-font-body-lg);
font-weight: 600;
border: none;
@include touch-target;
&--primary {
@include btn-primary;
}
&--outline {
@include btn-outline;
}
}
</style>

View File

@@ -0,0 +1,317 @@
<template>
<Loading v-if="pageLoading && alerts.length === 0" text="加载中..." />
<scroll-view
v-else
scroll-y
class="page-scroll"
@scrolltolower="onLoadMore"
>
<view :class="['page-content', elderClass]">
<!-- 严重程度筛选 -->
<scroll-view scroll-x class="tab-bar">
<view
v-for="tab in SEVERITY_TABS"
:key="tab.key"
:class="['tab-chip', { 'tab-chip--active': activeSeverity === tab.key }]"
@tap="handleSeverityChange(tab.key)"
>
<text class="tab-chip__text">{{ tab.label }}</text>
</view>
</scroll-view>
<!-- 列表统计 -->
<view v-if="filteredAlerts.length > 0" class="list-meta">
<text class="list-meta__text"> {{ filteredAlerts.length }} 条告警</text>
</view>
<!-- 告警卡片 -->
<EmptyState v-if="!pageLoading && filteredAlerts.length === 0" icon="🔔" title="暂无告警" />
<view v-else class="alert-cards">
<view
v-for="alert in filteredAlerts"
:key="alert.id"
class="alert-card"
@tap="goDetail(alert.id)"
>
<view class="alert-card__header">
<text class="alert-card__title">{{ alert.title }}</text>
<view
class="alert-card__severity"
:style="getSeverityStyle(alert.severity)"
>
<text class="alert-card__severity-text">
{{ getSeverityLabel(alert.severity) }}
</text>
</view>
</view>
<view class="alert-card__body">
<text v-if="alert.detail?.patient_name" class="alert-card__patient">
{{ alert.detail.patient_name }}
</text>
<text v-if="alert.detail?.indicator_name" class="alert-card__indicator">
{{ alert.detail.indicator_name }}
</text>
</view>
<view class="alert-card__footer">
<text class="alert-card__time">{{ formatAlertTime(alert.created_at) }}</text>
<view
class="alert-card__status"
:style="getStatusInlineStyle(alert.status)"
>
<text class="alert-card__status-text">{{ getStatusLabel(alert.status) }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="!loadingMore && alerts.length >= total && total > 0" class="load-hint-wrap">
<text class="load-hint">没有更多了</text>
</view>
<Loading v-if="loadingMore" text="加载中..." />
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { listAlerts, cacheAlerts } from '@/services/doctor/alerts'
import type { Alert } from '@/services/doctor/alerts'
import { getStatusInlineStyle, getStatusLabel, getSeverityStyle, getSeverityLabel } from '@/utils/statusTag'
import { formatDate, getRelativeTime } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
const SEVERITY_TABS = [
{ key: '', label: '全部' },
{ key: 'info', label: getSeverityLabel('info') },
{ key: 'warning', label: getSeverityLabel('warning') },
{ key: 'critical', label: getSeverityLabel('critical') },
{ key: 'urgent', label: getSeverityLabel('urgent') },
] as const
const { elderClass } = useElderClass()
const alerts = ref<Alert[]>([])
const activeSeverity = ref('')
const pageLoading = ref(true)
const loadingMore = ref(false)
const isLoading = ref(false)
const total = ref(0)
const page = ref(1)
const filteredAlerts = computed(() => {
if (!activeSeverity.value) return alerts.value
return alerts.value.filter((a) => a.severity === activeSeverity.value)
})
function formatAlertTime(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 1) return getRelativeTime(dateStr)
if (diffDays < 7) return `${diffDays}天前`
return formatDate(dateStr, 'MM-DD HH:mm')
}
async function loadAlerts(pageNum: number, isRefresh = false) {
if (isLoading.value) return
isLoading.value = true
if (isRefresh) {
pageLoading.value = true
} else {
loadingMore.value = true
}
try {
const res = await listAlerts({
page: pageNum,
page_size: 20,
})
const list = res.data || []
if (isRefresh) {
alerts.value = list
} else {
alerts.value = [...alerts.value, ...list]
}
cacheAlerts(list)
total.value = res.total || 0
page.value = pageNum
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
loadingMore.value = false
isLoading.value = false
}
}
function handleSeverityChange(key: string) {
activeSeverity.value = key
}
function goDetail(id: string) {
uni.navigateTo({ url: `/pages-sub/doctor/alerts/detail/index?id=${id}` })
}
function onLoadMore() {
if (!isLoading.value && alerts.value.length < total.value) {
loadAlerts(page.value + 1)
}
}
onShow(() => {
loadAlerts(1, true)
})
onPullDownRefresh(() => {
loadAlerts(1, true).finally(() => {
uni.stopPullDownRefresh()
})
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
// ── 标签栏 ──
.tab-bar {
white-space: nowrap;
margin-bottom: 24px;
width: 100%;
}
.tab-chip {
display: inline-flex;
align-items: center;
padding: 10px 28px;
min-height: $touch-min;
border-radius: $r-pill;
background: $card;
box-shadow: $shadow-sm;
margin-right: 12px;
&--active {
background: $pri;
.tab-chip__text {
color: $card;
}
}
&__text {
font-size: var(--tk-font-body);
color: $tx2;
font-weight: 500;
}
}
// ── 列表统计 ──
.list-meta {
margin-bottom: 16px;
&__text {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
}
// ── 告警卡片 ──
.alert-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.alert-card {
@include card;
position: relative;
&:active {
background: $bd-l;
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
&__title {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
flex: 1;
margin-right: 16px;
line-height: 1.4;
}
&__severity {
@include status-inline;
flex-shrink: 0;
}
&__severity-text {
font-size: var(--tk-font-body-sm);
font-weight: 600;
}
&__body {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
&__patient {
font-size: var(--tk-font-body);
color: $tx2;
font-weight: 500;
}
&__indicator {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
&__footer {
display: flex;
justify-content: space-between;
align-items: center;
}
&__time {
font-size: var(--tk-font-cap);
color: $tx3;
}
&__status {
@include status-inline;
}
&__status-text {
font-size: var(--tk-font-body-sm);
}
}
// ── 加载提示 ──
.load-hint-wrap {
text-align: center;
padding: 20px;
}
.load-hint {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<view :class="['chat-page', elderClass]">
<!-- Header -->
<view class="chat-header">
<text class="chat-header__title">{{ session?.subject || '在线咨询' }}</text>
<text v-if="isOpen" class="chat-header__close" @tap="handleClose">关闭会话</text>
</view>
<!-- Loading -->
<Loading v-if="loading" text="加载中..." />
<!-- Error -->
<ErrorState v-else-if="error" :text="error" @retry="loadData" />
<!-- 消息列表 -->
<scroll-view v-else
scroll-y
class="chat-messages"
:scroll-into-view="scrollInto"
scroll-with-animation
>
<template v-if="messages.length > 0">
<view
v-for="(msg, idx) in messages"
:key="msg.id"
:id="`msg-${idx + 1}`"
:class="['msg-row', msg.sender_role === 'doctor' ? 'msg-row--self' : '']"
>
<view :class="['msg-bubble', msg.sender_role === 'doctor' ? 'msg-bubble--self' : 'msg-bubble--other']">
<text class="msg-text">{{ msg.content }}</text>
<text class="msg-time">{{ formatTime(msg.created_at) }}</text>
</view>
</view>
</template>
<view v-else class="chat-empty">
<text class="chat-empty__text">暂无消息发送第一条消息开始对话</text>
</view>
</scroll-view>
<!-- 输入栏会话进行中 -->
<view v-if="!loading && !error && isOpen" class="chat-input-bar">
<input
class="chat-input"
placeholder="输入消息..."
:value="inputText"
@input="(e: any) => inputText = e.detail.value"
confirm-type="send"
@confirm="handleSend"
:disabled="sending"
/>
<view
:class="['chat-send-btn', (!inputText.trim() || sending) ? 'chat-send-btn--disabled' : '']"
@tap="handleSend"
>
<text class="chat-send-btn__text">{{ sending ? '...' : '发送' }}</text>
</view>
</view>
<!-- 已关闭提示 -->
<view v-else-if="!loading && !error" class="chat-closed-bar">
<text class="chat-closed-bar__text">会话已关闭</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor'
import { useElderClass } from '@/composables/useElderClass'
import Loading from '@/components/Loading.vue'
import ErrorState from '@/components/ErrorState.vue'
const { elderClass } = useElderClass()
const sessionId = ref('')
const session = ref<doctorApi.ConsultationSession | null>(null)
const messages = ref<doctorApi.ConsultationMessage[]>([])
const inputText = ref('')
const sending = ref(false)
const loading = ref(true)
const error = ref('')
const scrollInto = ref('')
const isOpen = computed(() => session.value?.status !== 'closed')
function formatTime(dateStr: string): string {
const d = new Date(dateStr)
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
}
function scrollToBottom(count: number) {
nextTick(() => {
scrollInto.value = `msg-${count}`
})
}
async function loadData() {
if (!sessionId.value) return
loading.value = true
error.value = ''
try {
const [s, m] = await Promise.all([
doctorApi.getSession(sessionId.value),
doctorApi.listMessages(sessionId.value, { page: 1, page_size: 50 }),
])
session.value = s
messages.value = m.data || []
scrollToBottom(messages.value.length)
} catch {
error.value = '加载失败,请重试'
} finally {
loading.value = false
}
}
async function markRead() {
if (!sessionId.value) return
try {
await doctorApi.markSessionRead(sessionId.value)
} catch {
// 静默忽略
}
}
async function handleSend() {
const text = inputText.value.trim()
if (!text || sending.value) return
sending.value = true
inputText.value = ''
try {
const msg = await doctorApi.sendMessage(sessionId.value, text)
messages.value = [...messages.value, msg]
scrollToBottom(messages.value.length)
} catch {
uni.showToast({ title: '发送失败', icon: 'none' })
inputText.value = text
} finally {
sending.value = false
}
}
function handleClose() {
uni.showModal({
title: '确认关闭',
content: '关闭后将无法继续对话,确认关闭?',
success: async (res) => {
if (res.confirm) {
try {
await doctorApi.closeSession(sessionId.value, session.value?.version ?? 0)
uni.showToast({ title: '已关闭', icon: 'success' })
loadData()
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
},
})
}
onLoad((query) => {
sessionId.value = query?.id || ''
if (sessionId.value) {
loadData()
markRead()
}
})
</script>
<style lang="scss" scoped>
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
background: $bg;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
background: $card;
border-bottom: 1px solid $bd;
&__title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: var(--tk-font-h1);
font-weight: 600;
color: $tx;
}
&__close {
font-size: var(--tk-font-body);
color: $dan;
padding: 8px 16px;
}
}
.chat-messages {
flex: 1;
padding: 24px;
}
.msg-row {
display: flex;
margin-bottom: 20px;
&--self {
justify-content: flex-end;
}
}
.msg-bubble {
max-width: 70%;
padding: 20px 24px;
border-radius: $r-lg;
position: relative;
&--other {
background: $card;
border-top-left-radius: 4px;
}
&--self {
background: $pri;
border-top-right-radius: 4px;
}
}
.msg-text {
font-size: var(--tk-font-body-lg);
color: $tx;
display: block;
line-height: 1.6;
word-break: break-all;
.msg-bubble--self & {
color: $card;
}
}
.msg-time {
@include serif-number;
font-size: var(--tk-font-body-sm);
color: $tx3;
display: block;
margin-top: 8px;
text-align: right;
.msg-bubble--self & {
color: rgba(255, 255, 255, 0.7);
}
}
.chat-empty {
text-align: center;
padding: 120px 32px;
&__text {
font-size: var(--tk-font-body);
color: $tx3;
}
}
.chat-input-bar {
display: flex;
align-items: center;
padding: 16px 24px;
background: $card;
border-top: 1px solid $bd;
@include safe-bottom;
}
.chat-input {
flex: 1;
background: $bd-l;
border-radius: $r;
padding: 16px 20px;
font-size: var(--tk-font-body-lg);
margin-right: 16px;
}
.chat-send-btn {
background: $pri;
border-radius: $r;
padding: 16px 28px;
flex-shrink: 0;
&--disabled {
opacity: 0.5;
}
&__text {
font-size: var(--tk-font-body-lg);
color: $card;
font-weight: 500;
}
}
.chat-closed-bar {
padding: 24px;
text-align: center;
background: $card;
border-top: 1px solid $bd;
&__text {
font-size: var(--tk-font-body);
color: $tx3;
}
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<scroll-view scroll-y :class="['consultation-page', elderClass]">
<!-- Tab 筛选 -->
<view class="tabs">
<view
v-for="t in TABS"
:key="t.key"
:class="['tab', activeTab === t.key ? 'tab--active' : '']"
@tap="handleTabChange(t.key)"
>
<text>{{ t.label }}</text>
</view>
</view>
<!-- 加载态 -->
<Loading v-if="loading && sessions.length === 0" text="加载中..." />
<!-- 空态 -->
<EmptyState v-else-if="sessions.length === 0" icon="💬" title="暂无咨询会话" />
<!-- 会话列表 -->
<view v-else class="session-list">
<view
v-for="s in sessions"
:key="s.id"
class="session-card"
@tap="goDetail(s.id)"
>
<view class="session-card__top">
<text class="session-card__subject">{{ s.subject || '在线咨询' }}</text>
<view class="session-card__status" :style="getStatusInlineStyle(s.status)">
<text class="session-card__status-text">{{ getStatusLabel(s.status) }}</text>
</view>
</view>
<view class="session-card__info">
<text class="session-card__type">
{{ s.consultation_type === 'text' ? '图文' : s.consultation_type === 'video' ? '视频' : '咨询' }}
</text>
<text class="session-card__time">{{ formatTime(s.last_message_at) }}</text>
</view>
<text v-if="s.last_message" class="session-card__preview">{{ s.last_message }}</text>
<view v-if="(s.unread_count_doctor ?? 0) > 0" class="session-card__badge">
<text class="session-card__badge-text">{{ s.unread_count_doctor }}</text>
</view>
</view>
</view>
<!-- 分页 -->
<view v-if="total > 20" class="pagination">
<text
:class="['pagination__btn', page <= 1 ? 'disabled' : '']"
@tap="page > 1 && (page = page - 1)"
>上一页</text>
<text class="pagination__info">{{ page }} / {{ totalPages }}</text>
<text
:class="['pagination__btn', page >= totalPages ? 'disabled' : '']"
@tap="page < totalPages && (page = page + 1)"
>下一页</text>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor'
import { useElderClass } from '@/composables/useElderClass'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { formatDate } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
const TABS = [
{ key: '', label: '全部' },
{ key: 'active', label: '进行中' },
{ key: 'waiting', label: '等待中' },
{ key: 'closed', label: '已关闭' },
] as const
const { elderClass } = useElderClass()
const sessions = ref<doctorApi.ConsultationSession[]>([])
const activeTab = ref('')
const loading = ref(true)
const total = ref(0)
const page = ref(1)
const totalPages = computed(() => Math.ceil(total.value / 20))
function goDetail(id: string) {
uni.navigateTo({ url: `/pages-sub/doctor/consultation/detail/index?id=${id}` })
}
function handleTabChange(key: string) {
activeTab.value = key
page.value = 1
}
function formatTime(dateStr?: string | null): string {
if (!dateStr) return ''
return formatDate(dateStr, 'MM-DD HH:mm')
}
async function loadSessions() {
loading.value = true
try {
const res = await doctorApi.listSessions({
page: page.value,
page_size: 20,
status: activeTab.value || undefined,
})
sessions.value = res.data || []
total.value = res.total || 0
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
watch([page, activeTab], () => { loadSessions() })
onShow(() => {
loadSessions()
})
</script>
<style lang="scss" scoped>
.consultation-page {
min-height: 100vh;
background: $bg;
}
.tabs {
display: flex;
background: $card;
padding: 0 16px;
border-bottom: 1px solid $bd;
}
.tab {
flex: 1;
text-align: center;
padding: 24px 0;
font-size: var(--tk-font-body-lg);
color: $tx2;
position: relative;
&--active {
color: $pri;
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 30%;
right: 30%;
height: 4px;
background: $pri;
border-radius: $r-xs;
}
}
}
.session-list {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.session-card {
@include card;
position: relative;
&:active {
background: $bd-l;
}
&__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
&__subject {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16px;
}
&__status {
display: inline-block;
border-radius: 6px;
padding: 2px 8px;
flex-shrink: 0;
}
&__status-text {
font-size: var(--tk-font-body);
font-weight: 500;
}
&__info {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
}
&__type {
@include tag($pri-l, $pri);
}
&__time {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
&__preview {
font-size: var(--tk-font-body);
color: $tx2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__badge {
position: absolute;
top: 20px;
right: 20px;
min-width: 36px;
height: 36px;
background: $dan;
border-radius: $r-pill;
@include flex-center;
padding: 0 8px;
}
&__badge-text {
@include serif-number;
font-size: var(--tk-font-body);
color: $card;
font-weight: 600;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
padding: 24px;
&__btn {
font-size: var(--tk-font-body);
color: $pri;
padding: 12px 24px;
&.disabled {
color: $tx3;
}
}
&__info {
font-size: var(--tk-font-body-sm);
color: $tx2;
}
}
</style>

View File

@@ -0,0 +1,435 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- 患者选择 -->
<view class="section-card">
<text class="section-title">选择患者</text>
<view class="patient-search">
<input
class="search-input"
placeholder="搜索患者姓名"
:value="patientSearch"
@input="(e: any) => { patientSearch = e.detail.value; searchPatients() }"
/>
</view>
<view v-if="searchingPatient" class="loading-hint">
<text class="loading-hint__text">搜索中...</text>
</view>
<view v-else-if="patientResults.length > 0" class="patient-list">
<view
v-for="p in patientResults"
:key="p.id"
:class="['patient-item', form.patient_id === p.id ? 'patient-item--selected' : '']"
@tap="selectPatient(p)"
>
<text class="patient-item__name">{{ p.name }}</text>
<text class="patient-item__info">{{ p.gender === 'male' ? '男' : p.gender === 'female' ? '女' : '' }}</text>
<view v-if="form.patient_id === p.id" class="patient-item__check">
<text class="check-icon">&#10003;</text>
</view>
</view>
</view>
<view v-else-if="patientSearch && !searchingPatient" class="empty-hint">
<text class="empty-hint__text">未找到患者</text>
</view>
</view>
<!-- 透析信息 -->
<view class="section-card">
<text class="section-title">透析信息</text>
<view class="form-field">
<text class="form-label">透析日期 <text class="required">*</text></text>
<picker mode="date" :value="form.dialysis_date" @change="(e: any) => form.dialysis_date = e.detail.value">
<view :class="['picker-display', form.dialysis_date ? '' : 'placeholder']">
{{ form.dialysis_date || '请选择日期' }}
</view>
</picker>
</view>
<view class="form-field">
<text class="form-label">透析方式 <text class="required">*</text></text>
<picker :range="dialysisTypes" :range-key="'label'" @change="(e: any) => form.dialysis_type = dialysisTypes[e.detail.value].value">
<view :class="['picker-display', form.dialysis_type ? '' : 'placeholder']">
{{ currentDialysisTypeLabel || '请选择透析方式' }}
</view>
</picker>
</view>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">开始时间</text>
<picker mode="time" :value="form.start_time" @change="(e: any) => form.start_time = e.detail.value">
<view :class="['picker-display', form.start_time ? '' : 'placeholder']">
{{ form.start_time || '选择时间' }}
</view>
</picker>
</view>
<view class="form-field form-field--half">
<text class="form-label">结束时间</text>
<picker mode="time" :value="form.end_time" @change="(e: any) => form.end_time = e.detail.value">
<view :class="['picker-display', form.end_time ? '' : 'placeholder']">
{{ form.end_time || '选择时间' }}
</view>
</picker>
</view>
</view>
</view>
<!-- 体征输入 -->
<view class="section-card">
<text class="section-title">体征数据</text>
<view class="form-field">
<text class="form-label">透前体重 (kg)</text>
<input
type="digit"
class="form-input"
placeholder="请输入"
:value="form.pre_weight ?? ''"
@input="(e: any) => updateNumericField('pre_weight', e.detail.value)"
/>
</view>
<view class="form-field">
<text class="form-label">干体重 (kg)</text>
<input
type="digit"
class="form-input"
placeholder="请输入"
:value="form.dry_weight ?? ''"
@input="(e: any) => updateNumericField('dry_weight', e.detail.value)"
/>
</view>
<view class="form-field">
<text class="form-label">超滤目标 (ml)</text>
<input
type="digit"
class="form-input"
placeholder="请输入"
:value="form.ultrafiltration_volume ?? ''"
@input="(e: any) => updateNumericField('ultrafiltration_volume', e.detail.value)"
/>
</view>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">透前收缩压</text>
<input
type="digit"
class="form-input"
placeholder="mmHg"
:value="form.pre_bp_systolic ?? ''"
@input="(e: any) => updateNumericField('pre_bp_systolic', e.detail.value)"
/>
</view>
<view class="form-field form-field--half">
<text class="form-label">透前舒张压</text>
<input
type="digit"
class="form-input"
placeholder="mmHg"
:value="form.pre_bp_diastolic ?? ''"
@input="(e: any) => updateNumericField('pre_bp_diastolic', e.detail.value)"
/>
</view>
</view>
</view>
<!-- 备注 -->
<view class="section-card">
<text class="section-title">备注</text>
<textarea
class="form-textarea"
placeholder="并发症记录或其他备注(选填)"
:value="form.complication_notes"
@input="(e: any) => form.complication_notes = e.detail.value"
:maxlength="1000"
/>
</view>
<!-- 提交 -->
<view class="submit-wrap">
<view :class="['action-btn', submitting ? 'disabled' : '']" @tap="submitting ? undefined : handleSubmit">
<text class="action-btn-text">{{ submitting ? '提交中...' : '提交记录' }}</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useElderClass } from '@/composables/useElderClass'
import { createDialysisRecord } from '@/services/doctor/dialysis'
import { listPatients } from '@/services/doctor/patient'
import type { PatientItem } from '@/services/doctor/patient'
const DIALYSIS_TYPES = [
{ label: '血液透析', value: 'hemodialysis' },
{ label: '腹膜透析', value: 'peritoneal' },
] as const
const dialysisTypes = DIALYSIS_TYPES
const { elderClass } = useElderClass()
const form = reactive({
patient_id: '',
dialysis_date: '',
dialysis_type: '',
start_time: '',
end_time: '',
pre_weight: undefined as number | undefined,
dry_weight: undefined as number | undefined,
ultrafiltration_volume: undefined as number | undefined,
pre_bp_systolic: undefined as number | undefined,
pre_bp_diastolic: undefined as number | undefined,
complication_notes: '',
})
const patientSearch = ref('')
const patientResults = ref<PatientItem[]>([])
const searchingPatient = ref(false)
const submitting = ref(false)
const currentDialysisTypeLabel = computed(() => {
const found = DIALYSIS_TYPES.find((t) => t.value === form.dialysis_type)
return found ? found.label : ''
})
let searchTimer: ReturnType<typeof setTimeout> | null = null
function searchPatients() {
if (searchTimer) clearTimeout(searchTimer)
if (!patientSearch.value.trim()) {
patientResults.value = []
return
}
searchTimer = setTimeout(async () => {
searchingPatient.value = true
try {
const res = await listPatients({ search: patientSearch.value.trim(), page_size: 10 })
patientResults.value = res.data || []
} catch {
patientResults.value = []
} finally {
searchingPatient.value = false
}
}, 300)
}
function selectPatient(p: PatientItem) {
form.patient_id = form.patient_id === p.id ? '' : p.id
}
function updateNumericField(field: keyof typeof form, raw: string) {
const val = raw.trim() === '' ? undefined : Number(raw)
;(form as any)[field] = isNaN(val as number) ? undefined : val
}
async function handleSubmit() {
if (!form.patient_id) {
uni.showToast({ title: '请选择患者', icon: 'none' })
return
}
if (!form.dialysis_date) {
uni.showToast({ title: '请选择透析日期', icon: 'none' })
return
}
if (!form.dialysis_type) {
uni.showToast({ title: '请选择透析方式', icon: 'none' })
return
}
submitting.value = true
try {
await createDialysisRecord({
patient_id: form.patient_id,
dialysis_date: form.dialysis_date,
dialysis_type: form.dialysis_type,
start_time: form.start_time || undefined,
end_time: form.end_time || undefined,
pre_weight: form.pre_weight,
dry_weight: form.dry_weight,
ultrafiltration_volume: form.ultrafiltration_volume,
pre_bp_systolic: form.pre_bp_systolic,
pre_bp_diastolic: form.pre_bp_diastolic,
complication_notes: form.complication_notes.trim() || undefined,
})
uni.showToast({ title: '创建成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 800)
} catch {
uni.showToast({ title: '创建失败', icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 160px; }
.section-card {
@include card;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
// Search
.patient-search { margin-bottom: 12px; }
.search-input {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
box-sizing: border-box;
}
.loading-hint, .empty-hint {
@include flex-center;
padding: 24px 0;
}
.loading-hint__text, .empty-hint__text {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
.patient-list {
max-height: 320px;
overflow-y: auto;
}
.patient-item {
display: flex;
align-items: center;
padding: 16px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
border-radius: $r-xs;
&:active { background: $bd-l; }
&--selected {
background: $pri-l;
}
&__name {
flex: 1;
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
&__info {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-right: 12px;
}
&__check {
width: 32px;
height: 32px;
@include flex-center;
background: $pri;
border-radius: 50%;
}
}
.check-icon {
color: $card;
font-size: var(--tk-font-body-sm);
}
// Form
.form-field {
margin-bottom: 16px;
}
.form-field--half {
flex: 1;
}
.form-row {
display: flex;
gap: 12px;
}
.form-label {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: 8px;
}
.required { color: $dan; }
.form-input {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
box-sizing: border-box;
}
.picker-display {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
display: flex;
align-items: center;
box-sizing: border-box;
}
.picker-display.placeholder {
color: $tx3;
}
.form-textarea {
width: 100%;
min-height: 120px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 12px;
font-size: var(--tk-font-body);
color: $tx;
box-sizing: border-box;
background: $card;
}
// Submit
.submit-wrap {
margin: 0 24px;
}
.action-btn {
@include btn-primary;
}
.action-btn.disabled { opacity: 0.5; }
.action-btn-text {
color: $card;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,283 @@
<template>
<Loading v-if="pageLoading" text="加载中..." />
<ErrorState v-else-if="error || !record" text="记录加载失败" :on-retry="loadData" />
<scroll-view v-else scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- 基本信息 -->
<view class="info-card">
<view class="info-header">
<text class="patient-name">{{ recordPatientName }}</text>
<view class="status-tag" :style="getStatusInlineStyle(record.status)">
<text class="status-tag__text">{{ getStatusLabel(record.status) }}</text>
</view>
</view>
<view class="info-row">
<text class="info-label">透析日期</text>
<text class="info-value">{{ record.dialysis_date }}</text>
</view>
<view class="info-row">
<text class="info-label">透析方式</text>
<text class="info-value">{{ dialysisTypeLabel(record.dialysis_type) }}</text>
</view>
<view v-if="record.start_time" class="info-row">
<text class="info-label">开始时间</text>
<text class="info-value">{{ record.start_time }}</text>
</view>
<view v-if="record.end_time" class="info-row">
<text class="info-label">结束时间</text>
<text class="info-value">{{ record.end_time }}</text>
</view>
<view v-if="record.dialysis_duration" class="info-row">
<text class="info-label">透析时长</text>
<text class="info-value">{{ record.dialysis_duration }} 分钟</text>
</view>
</view>
<!-- 体征数据 -->
<view class="section-card">
<text class="section-title">体征数据</text>
<view class="vitals-grid">
<view v-if="record.pre_bp_systolic != null" class="vital-item">
<text class="vital-value">{{ record.pre_bp_systolic }}/{{ record.pre_bp_diastolic }}</text>
<text class="vital-label">透前血压 mmHg</text>
</view>
<view v-if="record.post_bp_systolic != null" class="vital-item">
<text class="vital-value">{{ record.post_bp_systolic }}/{{ record.post_bp_diastolic }}</text>
<text class="vital-label">透后血压 mmHg</text>
</view>
<view v-if="record.pre_weight != null" class="vital-item">
<text class="vital-value">{{ record.pre_weight }}</text>
<text class="vital-label">透前体重 kg</text>
</view>
<view v-if="record.post_weight != null" class="vital-item">
<text class="vital-value">{{ record.post_weight }}</text>
<text class="vital-label">透后体重 kg</text>
</view>
<view v-if="record.ultrafiltration_volume != null" class="vital-item">
<text class="vital-value">{{ record.ultrafiltration_volume }}</text>
<text class="vital-label">超滤量 ml</text>
</view>
<view v-if="record.blood_flow_rate != null" class="vital-item">
<text class="vital-value">{{ record.blood_flow_rate }}</text>
<text class="vital-label">血流量 ml/min</text>
</view>
</view>
</view>
<!-- 并发症 -->
<view v-if="record.complication_notes" class="section-card">
<text class="section-title">并发症记录</text>
<view class="warning-block">
<text class="warning-block__text">{{ record.complication_notes }}</text>
</view>
</view>
<!-- 备注 -->
<view v-if="record.symptoms && Object.keys(record.symptoms).length > 0" class="section-card">
<text class="section-title">备注</text>
<text class="notes-text">{{ JSON.stringify(record.symptoms, null, 2) }}</text>
</view>
<!-- 审核操作 -->
<view v-if="record.status === 'pending'" class="action-card">
<view
:class="['action-btn', reviewing ? 'disabled' : '']"
@tap="reviewing ? undefined : handleReview"
>
<text class="action-btn-text">{{ reviewing ? '处理中...' : '审核通过' }}</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { getDialysisRecordById, reviewDialysisRecord } from '@/services/doctor/dialysis'
import type { DialysisRecord } from '@/services/dialysis'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import Loading from '@/components/Loading.vue'
import ErrorState from '@/components/ErrorState.vue'
const DIALYSIS_TYPE_MAP: Record<string, string> = {
hemodialysis: '血液透析',
peritoneal: '腹膜透析',
hemofiltration: '血液滤过',
}
const { elderClass } = useElderClass()
const record = ref<DialysisRecord | null>(null)
const recordPatientName = ref('')
const pageLoading = ref(true)
const error = ref(false)
const reviewing = ref(false)
let recordId = ''
function dialysisTypeLabel(type: string): string {
return DIALYSIS_TYPE_MAP[type] || type
}
async function loadData() {
if (!recordId) return
pageLoading.value = true
error.value = false
try {
const data = await getDialysisRecordById(recordId)
record.value = data
} catch {
error.value = true
} finally {
pageLoading.value = false
}
}
async function handleReview() {
if (!record.value) return
reviewing.value = true
try {
const updated = await reviewDialysisRecord(recordId, record.value.version)
record.value = updated
uni.showToast({ title: '审核通过', icon: 'success' })
} catch {
uni.showToast({ title: '审核失败', icon: 'none' })
} finally {
reviewing.value = false
}
}
onLoad((query) => {
recordId = query?.id || ''
if (!recordId) { error.value = true; pageLoading.value = false; return }
loadData()
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 120px; }
.info-card {
@include card;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.patient-name {
font-size: var(--tk-font-title);
font-weight: 600;
color: $tx;
}
.status-tag {
display: inline-block;
border-radius: 6px;
padding: 2px 8px;
}
.status-tag__text {
font-size: var(--tk-font-body);
font-weight: 500;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: var(--tk-font-cap);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
.section-card {
@include card;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
.vitals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.vital-item {
background: $pri-l;
border-radius: $r-sm;
padding: 16px;
text-align: center;
}
.vital-value {
@include serif-number;
font-size: var(--tk-font-num);
font-weight: 700;
color: $pri;
display: block;
margin-bottom: 4px;
}
.vital-label {
font-size: var(--tk-font-cap);
color: $tx2;
}
.warning-block {
background: $wrn-l;
border-radius: $r-sm;
padding: 16px;
}
.warning-block__text {
font-size: var(--tk-font-body-sm);
color: $wrn;
line-height: 1.6;
}
.notes-text {
font-size: var(--tk-font-body-sm);
color: $tx2;
line-height: 1.6;
}
.action-card {
@include card;
}
.action-btn {
@include btn-primary;
}
.action-btn.disabled { opacity: 0.5; }
.action-btn-text {
color: $card;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,371 @@
<template>
<Loading v-if="pageLoading && records.length === 0" text="加载中..." />
<scroll-view
v-else
scroll-y
class="page-scroll"
@scrolltolower="onLoadMore"
>
<view :class="['page-content', elderClass]">
<!-- 状态筛选 -->
<view class="tabs">
<view
v-for="tab in STATUS_TABS"
:key="tab.key"
:class="['tab', activeStatus === tab.key ? 'tab--active' : '']"
@tap="handleStatusChange(tab.key)"
>
<text>{{ tab.label }}</text>
</view>
</view>
<!-- 列表统计 -->
<view v-if="records.length > 0" class="list-meta">
<text class="list-meta__text"> {{ total }} 条记录</text>
</view>
<!-- 透析记录卡片 -->
<EmptyState v-if="!pageLoading && records.length === 0" icon="💉" title="暂无透析记录" />
<view v-else class="record-cards">
<view
v-for="record in records"
:key="record.id"
class="record-card"
@tap="goDetail(record.id)"
>
<view class="record-card__header">
<text class="record-card__patient">{{ record.patient_name || formatDate(record.dialysis_date, 'MM-DD') }}</text>
<view
class="record-card__status"
:style="getStatusInlineStyle(record.status)"
>
<text class="record-card__status-text">
{{ getStatusLabel(record.status) }}
</text>
</view>
</view>
<view class="record-card__body">
<view class="record-card__info-row">
<text class="record-card__label">透析日期</text>
<text class="record-card__value">{{ formatDate(record.dialysis_date, 'YYYY-MM-DD') }}</text>
</view>
<view class="record-card__info-row">
<text class="record-card__label">透析方式</text>
<text class="record-card__value">{{ dialysisTypeLabel(record.dialysis_type) }}</text>
</view>
<view v-if="record.dialysis_duration" class="record-card__info-row">
<text class="record-card__label">时长</text>
<text class="record-card__value">{{ record.dialysis_duration }} 分钟</text>
</view>
</view>
<view class="record-card__type-tag">
<text class="record-card__type-tag-text">
{{ dialysisTypeShort(record.dialysis_type) }}
</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="!loadingMore && records.length >= total && total > 0" class="load-hint-wrap">
<text class="load-hint">没有更多了</text>
</view>
<Loading v-if="loadingMore" text="加载中..." />
</view>
<!-- 新建按钮 -->
<view class="fab" @tap="goCreate">
<text class="fab__icon">+</text>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { onLoad, onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { listDialysisRecords } from '@/services/doctor/dialysis'
import type { DialysisRecord } from '@/services/doctor/dialysis'
// 医生端透析列表后端返回的扩展字段
interface DoctorDialysisRecord extends DialysisRecord {
patient_name?: string
}
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { formatDate } from '@/utils/date'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
const STATUS_TABS = [
{ key: '', label: '全部' },
{ key: 'in_progress', label: '进行中' },
{ key: 'completed', label: '已完成' },
{ key: 'cancelled', label: '已取消' },
] as const
const { elderClass } = useElderClass()
const records = ref<DoctorDialysisRecord[]>([])
const activeStatus = ref('')
const pageLoading = ref(true)
const loadingMore = ref(false)
const isLoading = ref(false)
const total = ref(0)
const page = ref(1)
const patientId = ref('')
function dialysisTypeLabel(type: string): string {
if (type === 'hemodialysis') return '血液透析'
if (type === 'peritoneal') return '腹膜透析'
return type
}
function dialysisTypeShort(type: string): string {
if (type === 'hemodialysis') return 'HD'
if (type === 'peritoneal') return 'PD'
return type
}
async function loadRecords(pageNum: number, isRefresh = false) {
if (isLoading.value) return
isLoading.value = true
if (isRefresh) {
pageLoading.value = true
} else {
loadingMore.value = true
}
try {
const params: { page: number; page_size: number; status?: string } = {
page: pageNum,
page_size: 20,
}
if (activeStatus.value) {
params.status = activeStatus.value
}
const res = await listDialysisRecords(patientId.value, params)
const list = res.data || []
if (isRefresh) {
records.value = list
} else {
records.value = [...records.value, ...list]
}
total.value = res.total || 0
page.value = pageNum
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
loadingMore.value = false
isLoading.value = false
}
}
function handleStatusChange(key: string) {
activeStatus.value = key
}
function goDetail(id: string) {
uni.navigateTo({
url: `/pages-sub/doctor/dialysis/detail/index?id=${id}`,
})
}
function goCreate() {
uni.navigateTo({
url: `/pages-sub/doctor/dialysis/create/index${patientId.value ? `?patientId=${patientId.value}` : ''}`,
})
}
function onLoadMore() {
if (!isLoading.value && records.value.length < total.value) {
loadRecords(page.value + 1)
}
}
watch(activeStatus, () => {
loadRecords(1, true)
})
onLoad((query) => {
patientId.value = query?.patientId || ''
})
onShow(() => {
loadRecords(1, true)
})
onPullDownRefresh(() => {
loadRecords(1, true).finally(() => {
uni.stopPullDownRefresh()
})
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
position: relative;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
// ── 状态标签 ──
.tabs {
display: flex;
background: $card;
border-radius: $r;
box-shadow: $shadow-sm;
margin-bottom: 24px;
overflow: hidden;
}
.tab {
flex: 1;
text-align: center;
padding: 20px 0;
font-size: var(--tk-font-body);
color: $tx2;
position: relative;
&--active {
color: $pri;
font-weight: 600;
background: $pri-l;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 20%;
right: 20%;
height: 3px;
background: $pri;
border-radius: 3px;
}
}
}
// ── 列表统计 ──
.list-meta {
margin-bottom: 16px;
&__text {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
}
// ── 记录卡片 ──
.record-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.record-card {
@include card;
position: relative;
&:active {
background: $bd-l;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
&__patient {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
flex: 1;
}
&__status {
@include status-inline;
flex-shrink: 0;
}
&__status-text {
font-size: var(--tk-font-body-sm);
}
&__body {
display: flex;
flex-direction: column;
gap: 8px;
}
&__info-row {
display: flex;
justify-content: space-between;
align-items: center;
}
&__label {
font-size: var(--tk-font-body);
color: $tx3;
}
&__value {
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
&__type-tag {
position: absolute;
top: 28px;
right: 28px;
margin-top: 32px;
}
&__type-tag-text {
@include tag($pri-l, $pri);
font-size: var(--tk-font-body-sm);
}
}
// ── 加载提示 ──
.load-hint-wrap {
text-align: center;
padding: 20px;
}
.load-hint {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
// ── 浮动新建按钮 ──
.fab {
position: fixed;
right: 32px;
bottom: 100px;
width: 56px;
height: 56px;
border-radius: 50%;
background: $pri;
@include flex-center;
box-shadow: $shadow-lg;
&:active {
opacity: 0.85;
transform: scale(0.95);
}
&__icon {
font-size: var(--tk-font-h1);
color: $card;
font-weight: 300;
line-height: 1;
}
}
</style>

View File

@@ -0,0 +1,438 @@
<template>
<view :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- Loading -->
<Loading v-if="loading" text="加载中..." />
<!-- Error -->
<ErrorState v-else-if="error || !task" text="任务不存在" />
<template v-else>
<!-- Task info card -->
<view class="info-card">
<view class="info-header">
<text class="patient-name">{{ task.patient_name || '未知患者' }}</text>
<text class="status-tag" :style="getStatusInlineStyle(task.status)">
{{ getStatusLabel(task.status) }}
</text>
</view>
<view class="info-row">
<text class="info-label">随访方式</text>
<text class="info-value">{{ getTypeLabel(task.follow_up_type) }}</text>
</view>
<view class="info-row">
<text class="info-label">计划日期</text>
<text class="info-value">{{ task.planned_date }}</text>
</view>
<view v-if="task.content_template" class="info-desc">
<text class="info-desc-label">随访内容</text>
<text class="info-desc-text">{{ task.content_template }}</text>
</view>
</view>
<!-- History records -->
<view class="section-card">
<text class="section-title">历史记录</text>
<view v-if="records.length === 0" class="empty-records">
<text class="empty-text">暂无随访记录</text>
</view>
<view v-for="record in records" :key="record.id" class="record-item">
<view class="record-date-row">
<text class="record-date">{{ record.executed_date }}</text>
</view>
<view v-if="record.result" class="record-field">
<text class="record-field-label">随访结果</text>
<text class="record-field-value">{{ record.result }}</text>
</view>
<view v-if="record.patient_condition" class="record-field">
<text class="record-field-label">患者状况</text>
<text class="record-field-value">{{ record.patient_condition }}</text>
</view>
<view v-if="record.medical_advice" class="record-field">
<text class="record-field-label">医嘱建议</text>
<text class="record-field-value">{{ record.medical_advice }}</text>
</view>
</view>
</view>
<!-- Submit form (only when can submit) -->
<view v-if="canSubmit" class="submit-card">
<!-- Start button when pending/overdue -->
<view v-if="task.status === 'pending' || task.status === 'overdue'" class="start-btn-wrap">
<view
:class="['action-btn', startingTask ? 'disabled' : '']"
@tap="startingTask ? undefined : handleStart"
>
<text class="action-btn-text">{{ startingTask ? '处理中...' : '开始随访' }}</text>
</view>
</view>
<!-- Form when in_progress -->
<template v-if="task.status === 'in_progress'">
<text class="section-title">填写随访记录</text>
<view class="form-field">
<text class="form-label"><text class="required">*</text> 随访结果</text>
<textarea
class="form-textarea"
placeholder="请输入随访结果"
:value="formData.result"
@input="(e: any) => formData.result = e.detail.value"
:maxlength="1000"
/>
</view>
<view class="form-field">
<text class="form-label">患者状况</text>
<textarea
class="form-textarea"
placeholder="请描述患者当前状况(选填)"
:value="formData.patient_condition"
@input="(e: any) => formData.patient_condition = e.detail.value"
:maxlength="500"
/>
</view>
<view class="form-field">
<text class="form-label">医嘱建议</text>
<textarea
class="form-textarea"
placeholder="请输入医嘱建议(选填)"
:value="formData.medical_advice"
@input="(e: any) => formData.medical_advice = e.detail.value"
:maxlength="500"
/>
</view>
<view class="form-field">
<text class="form-label">下次随访日期</text>
<picker
mode="date"
:value="formData.next_follow_up_date"
@change="(e: any) => formData.next_follow_up_date = e.detail.value"
>
<view class="date-picker">
<text :class="['date-text', formData.next_follow_up_date ? '' : 'placeholder']">
{{ formData.next_follow_up_date || '请选择日期' }}
</text>
</view>
</picker>
</view>
<view
:class="['action-btn', submitting ? 'disabled' : '']"
@tap="submitting ? undefined : handleSubmit"
>
<text class="action-btn-text">{{ submitting ? '提交中...' : '提交记录' }}</text>
</view>
</template>
</view>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor/followup'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { useElderClass } from '@/composables/useElderClass'
import ErrorState from '@/components/ErrorState.vue'
import Loading from '@/components/Loading.vue'
const TYPE_MAP: Record<string, string> = {
phone: '电话',
visit: '门诊',
online: '线上',
home: '家访',
}
const { elderClass } = useElderClass()
const task = ref<doctorApi.FollowUpTask | null>(null)
const records = ref<doctorApi.FollowUpRecord[]>([])
const loading = ref(true)
const error = ref(false)
const startingTask = ref(false)
const submitting = ref(false)
let taskId = ''
const formData = reactive({
result: '',
patient_condition: '',
medical_advice: '',
next_follow_up_date: '',
})
const canSubmit = computed(() => {
if (!task.value) return false
return ['pending', 'in_progress', 'overdue'].includes(task.value.status)
})
function getTypeLabel(type: string): string {
return TYPE_MAP[type] || type
}
async function fetchDetail() {
loading.value = true
error.value = false
try {
const [taskData, recordsRes] = await Promise.all([
doctorApi.getFollowUpTask(taskId),
doctorApi.listFollowUpRecords({ task_id: taskId }),
])
task.value = taskData
records.value = recordsRes.data || []
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function handleStart() {
if (!task.value) return
startingTask.value = true
try {
const updated = await doctorApi.updateFollowUpTask(
taskId,
{ status: 'in_progress' },
task.value.version,
)
task.value = updated
uni.showToast({ title: '已开始随访', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
startingTask.value = false
}
}
async function handleSubmit() {
if (!formData.result.trim()) {
uni.showToast({ title: '请输入随访结果', icon: 'none' })
return
}
submitting.value = true
try {
await doctorApi.createFollowUpRecord(taskId, {
result: formData.result.trim(),
patient_condition: formData.patient_condition.trim() || undefined,
medical_advice: formData.medical_advice.trim() || undefined,
next_follow_up_date: formData.next_follow_up_date || undefined,
})
uni.showToast({ title: '提交成功', icon: 'success' })
formData.result = ''
formData.patient_condition = ''
formData.medical_advice = ''
formData.next_follow_up_date = ''
fetchDetail()
} catch {
uni.showToast({ title: '提交失败', icon: 'none' })
} finally {
submitting.value = false
}
}
onLoad((query) => {
taskId = query?.id || ''
if (!taskId) { error.value = true; loading.value = false; return }
fetchDetail()
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 120px; }
// Info card
.info-card {
@include card;
margin-bottom: 16px;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.patient-name {
font-size: var(--tk-font-title);
font-weight: 600;
color: $tx;
}
.status-tag {
@include status-inline;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: var(--tk-font-cap);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body);
color: $tx;
}
.info-desc {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.info-desc-label {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
margin-bottom: 4px;
}
.info-desc-text {
font-size: var(--tk-font-body-sm);
color: $tx2;
line-height: 1.6;
}
// Section card
.section-card {
@include card;
margin-bottom: 16px;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
// Records
.empty-records {
@include flex-center;
padding: 40px 0;
}
.empty-text {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
.record-item {
padding: 16px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.record-item:last-child { border-bottom: none; }
.record-date-row {
margin-bottom: 8px;
}
.record-date {
font-size: var(--tk-font-cap);
color: $tx3;
}
.record-field {
margin-bottom: 6px;
}
.record-field-label {
display: block;
font-size: var(--tk-font-cap);
color: $tx3;
margin-bottom: 2px;
}
.record-field-value {
font-size: var(--tk-font-body-sm);
color: $tx;
line-height: 1.6;
}
// Submit card
.submit-card {
@include card;
}
.start-btn-wrap {
margin-bottom: 16px;
}
.action-btn {
@include btn-primary;
}
.action-btn.disabled { opacity: 0.5; }
.action-btn-text {
color: $white;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
// Form
.form-field {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: 8px;
}
.required { color: $dan; }
.form-textarea {
width: 100%;
min-height: 120px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 12px;
font-size: var(--tk-font-body);
color: $tx;
box-sizing: border-box;
background: $card;
}
.date-picker {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 12px;
@include flex-center;
justify-content: flex-start;
}
.date-text {
font-size: var(--tk-font-body);
color: $tx;
}
.date-text.placeholder { color: $tx3; }
</style>

View File

@@ -0,0 +1,227 @@
<template>
<view :class="['page-scroll', elderClass]">
<view class="page-content">
<text class="page-title">随访任务</text>
<!-- Tab filter -->
<view class="tabs">
<view
v-for="tab in TABS" :key="tab.key"
:class="['tab', activeTab === tab.key ? 'active' : '']"
@tap="handleTabChange(tab.key)"
>
<text :class="['tab-text', activeTab === tab.key ? 'active' : '']">{{ tab.label }}</text>
</view>
</view>
<!-- Loading -->
<Loading v-if="loading && tasks.length === 0" text="加载中..." />
<!-- Empty -->
<EmptyState v-else-if="tasks.length === 0" icon="📋" title="暂无随访任务" />
<!-- Task list -->
<scroll-view v-else scroll-y class="list-scroll" @scrolltolower="loadMore">
<view
v-for="item in tasks" :key="item.id"
class="task-card"
@tap="goDetail(item.id)"
>
<view class="card-header">
<view class="type-badge" :style="getTypeStyle(item.follow_up_type)">
<text class="type-text">{{ getTypeLabel(item.follow_up_type) }}</text>
</view>
<text :class="['status-tag', item.status]" :style="getStatusInlineStyle(item.status)">
{{ getStatusLabel(item.status) }}
</text>
</view>
<text class="patient-name">{{ item.patient_name || '未知患者' }}</text>
<view class="card-footer">
<text class="planned-date">计划日期{{ item.planned_date }}</text>
</view>
</view>
<Loading v-if="loading" text="加载中..." />
<view v-if="!loading && tasks.length >= total && total > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor/followup'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { useElderClass } from '@/composables/useElderClass'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
const TABS = [
{ key: '', label: '全部' },
{ key: 'pending', label: '待处理' },
{ key: 'in_progress', label: '进行中' },
{ key: 'completed', label: '已完成' },
{ key: 'overdue', label: '已逾期' },
]
const TYPE_MAP: Record<string, { label: string; bg: string; color: string }> = {
phone: { label: '电话', bg: '#E8F0E8', color: '#5B7A5E' },
visit: { label: '门诊', bg: '#F0DDD4', color: '#C4623A' },
online: { label: '线上', bg: '#E0F0FF', color: '#3B82B8' },
home: { label: '家访', bg: '#FFF3E0', color: '#C4873A' },
}
const { elderClass } = useElderClass()
const tasks = ref<doctorApi.FollowUpTask[]>([])
const total = ref(0)
const page = ref(1)
const activeTab = ref('')
const loading = ref(false)
let patientId = ''
let loadingGuard = false
function getTypeLabel(type: string): string {
return TYPE_MAP[type]?.label || type
}
function getTypeStyle(type: string): Record<string, string> {
const info = TYPE_MAP[type]
if (!info) return { background: '#F1F5F9', color: '#78716C' }
return { background: info.bg, color: info.color }
}
async function fetchTasks(pageNum: number, status: string, isRefresh = false) {
if (loadingGuard) return
loadingGuard = true
loading.value = true
try {
const params: Record<string, unknown> = { page: pageNum, page_size: 20 }
if (status) params.status = status
if (patientId) params.patient_id = patientId
const res = await doctorApi.listFollowUpTasks(params as Parameters<typeof doctorApi.listFollowUpTasks>[0])
const list = res.data || []
tasks.value = isRefresh ? list : [...tasks.value, ...list]
total.value = res.total
page.value = pageNum
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loadingGuard = false
loading.value = false
}
}
function handleTabChange(key: string) {
activeTab.value = key
fetchTasks(1, key, true)
}
function loadMore() {
if (!loading.value && tasks.value.length < total.value) {
fetchTasks(page.value + 1, activeTab.value)
}
}
function goDetail(id: string) {
uni.navigateTo({ url: `/pages-sub/doctor/followup/detail/index?id=${id}` })
}
onLoad((query) => {
patientId = query?.patientId || ''
fetchTasks(1, '', true)
})
onPullDownRefresh(() => {
fetchTasks(1, activeTab.value, true).finally(() => uni.stopPullDownRefresh())
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 28px 0 120px; }
.page-title { @include section-title; margin-left: 24px; }
.tabs {
display: flex;
padding: 0 24px 16px;
gap: 8px;
flex-wrap: wrap;
}
.tab {
padding: 6px 16px;
min-height: $touch-min;
display: flex;
align-items: center;
border-radius: 20px;
background: rgba(0, 0, 0, 0.04);
}
.tab.active { background: $pri; }
.tab-text {
font-size: var(--tk-font-cap);
color: $tx2;
}
.tab-text.active { color: $card; }
.list-scroll { height: calc(100vh - 160px); }
.task-card {
background: $card;
border-radius: $r;
padding: 20px 24px;
margin: 0 24px 16px;
box-shadow: $shadow-sm;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: $r-xs;
}
.type-text {
font-size: var(--tk-font-cap);
font-weight: 500;
}
.status-tag {
@include status-inline;
}
.patient-name {
display: block;
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 8px;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.planned-date {
font-size: var(--tk-font-cap);
color: $tx3;
}
.no-more { @include flex-center; padding: 20px; }
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
</style>

View File

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

View File

@@ -0,0 +1,402 @@
<template>
<Loading v-if="pageLoading" text="加载中..." />
<view v-else-if="!patient" :class="['error-wrap', elderClass]">
<text class="error-text">患者信息加载失败</text>
</view>
<scroll-view v-else scroll-y class="page-scroll">
<view :class="['page-content', elderClass]">
<!-- 基本信息 -->
<view class="section">
<text class="section-title">基本信息</text>
<view class="info-grid">
<view class="info-item">
<text class="info-label">姓名</text>
<text class="info-value">{{ patient.name }}</text>
</view>
<view class="info-item">
<text class="info-label">性别</text>
<text class="info-value">{{ genderLabel(patient.gender) }}</text>
</view>
<view class="info-item">
<text class="info-label">年龄</text>
<text class="info-value">{{ calcAge(patient.birth_date) }}</text>
</view>
<view v-if="patient.blood_type" class="info-item">
<text class="info-label">血型</text>
<text class="info-value">{{ patient.blood_type }}</text>
</view>
</view>
</view>
<!-- 医疗信息 -->
<view v-if="patient.allergy_history || patient.medical_history_summary" class="section">
<text class="section-title">医疗信息</text>
<view v-if="patient.allergy_history" class="warning-card">
<text class="warning-label">过敏史</text>
<text class="warning-text">{{ patient.allergy_history }}</text>
</view>
<view v-if="patient.medical_history_summary" class="info-block">
<text class="info-block-label">病史摘要</text>
<text class="info-block-text">{{ patient.medical_history_summary }}</text>
</view>
</view>
<!-- 健康概览 -->
<view v-if="summary" class="section">
<text class="section-title">健康概览</text>
<view v-if="summary.latest_vital_signs" class="vitals-grid">
<view
v-if="summary.latest_vital_signs.systolic_bp != null"
class="vital-item"
>
<text class="vital-value">{{ summary.latest_vital_signs.systolic_bp }}/{{ summary.latest_vital_signs.diastolic_bp }}</text>
<text class="vital-label">血压 mmHg</text>
</view>
<view
v-if="summary.latest_vital_signs.heart_rate != null"
class="vital-item"
>
<text class="vital-value">{{ summary.latest_vital_signs.heart_rate }}</text>
<text class="vital-label">心率 bpm</text>
</view>
<view
v-if="summary.latest_vital_signs.weight != null"
class="vital-item"
>
<text class="vital-value">{{ summary.latest_vital_signs.weight }}</text>
<text class="vital-label">体重 kg</text>
</view>
<view
v-if="summary.latest_vital_signs.blood_sugar != null"
class="vital-item"
>
<text class="vital-value">{{ summary.latest_vital_signs.blood_sugar }}</text>
<text class="vital-label">血糖 mmol/L</text>
</view>
</view>
<view v-if="summary.pending_follow_ups != null && summary.pending_follow_ups > 0" class="stat-row">
<text class="stat-label">待处理随访</text>
<text class="stat-value stat-value--warn">{{ summary.pending_follow_ups }} </text>
</view>
</view>
<!-- 近期化验 -->
<view v-if="summary?.latest_lab_report" class="section">
<text class="section-title">近期化验</text>
<view
class="lab-item"
@tap="goReportDetail(summary!.latest_lab_report!.id)"
>
<view class="lab-item__header">
<text class="lab-item__type">{{ summary.latest_lab_report.report_type }}</text>
<text class="lab-item__date">{{ summary.latest_lab_report.report_date }}</text>
</view>
<text
v-if="(summary.latest_lab_report.abnormal_count ?? 0) > 0"
class="lab-item__abnormal"
>{{ summary.latest_lab_report.abnormal_count }} 项异常</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="section">
<text class="section-title">操作</text>
<view class="action-buttons">
<view class="action-btn" @tap="goReports">
<text class="action-btn__text">查看化验报告</text>
</view>
<view class="action-btn" @tap="goFollowups">
<text class="action-btn__text">随访记录</text>
</view>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { getPatient, getHealthSummary } from '@/services/doctor/patient'
import type { PatientDetail, HealthSummary } from '@/services/doctor/patient'
import Loading from '@/components/Loading.vue'
const { elderClass } = useElderClass()
const patient = ref<PatientDetail | null>(null)
const summary = ref<HealthSummary | null>(null)
const pageLoading = ref(true)
const patientId = ref('')
function genderLabel(g?: string): string {
if (g === 'male') return '男'
if (g === 'female') return '女'
return g || '-'
}
function calcAge(bd?: string): string {
if (!bd) return '-'
const diff = Date.now() - new Date(bd).getTime()
return String(Math.floor(diff / (365.25 * 24 * 3600 * 1000)))
}
async function loadData() {
if (!patientId.value) return
pageLoading.value = true
try {
const [p, s] = await Promise.all([
getPatient(patientId.value),
getHealthSummary(patientId.value),
])
patient.value = p
summary.value = s
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
}
}
function goReportDetail(reportId: string) {
uni.navigateTo({
url: `/pages-sub/doctor/report/detail/index?patientId=${patientId.value}&id=${reportId}`,
})
}
function goReports() {
uni.navigateTo({
url: `/pages-sub/doctor/report/index?patientId=${patientId.value}`,
})
}
function goFollowups() {
uni.navigateTo({
url: `/pages-sub/doctor/followup/index?patientId=${patientId.value}`,
})
}
onLoad((query) => {
patientId.value = query?.id || ''
loadData()
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
.error-wrap {
@include flex-center;
height: 100vh;
background: $bg;
}
.error-text {
font-size: var(--tk-font-body-lg);
color: $tx3;
}
// ── 通用区块 ──
.section {
background: $card;
border-radius: $r-lg;
padding: 28px;
margin-bottom: 20px;
box-shadow: $shadow-sm;
}
.section-title {
@include section-title;
}
// ── 信息网格 ──
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: var(--tk-font-body);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body-lg);
color: $tx;
font-weight: 500;
}
// ── 过敏警告卡 ──
.warning-card {
background: $wrn-l;
border-radius: $r;
padding: 20px;
margin-bottom: 16px;
}
.warning-label {
font-size: var(--tk-font-body);
color: $wrn;
font-weight: 600;
display: block;
margin-bottom: 8px;
}
.warning-text {
font-size: var(--tk-font-h1);
color: $pri-d;
}
// ── 病史摘要 ──
.info-block {
margin-bottom: 12px;
}
.info-block-label {
font-size: var(--tk-font-body);
color: $tx3;
display: block;
margin-bottom: 8px;
}
.info-block-text {
font-size: var(--tk-font-h1);
color: $tx;
line-height: 1.6;
}
// ── 体征网格 ──
.vitals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.vital-item {
background: $pri-l;
border-radius: $r;
padding: 20px;
text-align: center;
}
.vital-value {
@include serif-number;
font-size: var(--tk-font-num-lg);
font-weight: 700;
color: $pri;
display: block;
margin-bottom: 4px;
}
.vital-label {
font-size: var(--tk-font-body);
color: $tx2;
}
// ── 统计行 ──
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
}
.stat-label {
font-size: var(--tk-font-h1);
color: $tx2;
}
.stat-value {
@include serif-number;
font-size: var(--tk-font-h1);
font-weight: 600;
color: $tx;
&--warn {
color: $wrn;
}
}
// ── 化验卡片 ──
.lab-item {
padding: 20px 0;
min-height: $touch-min;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
&:active {
background: $bd-l;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__type {
font-size: var(--tk-font-h1);
font-weight: 500;
color: $tx;
}
&__date {
font-size: var(--tk-font-h2);
color: $tx3;
}
&__abnormal {
font-size: var(--tk-font-h2);
color: $dan;
font-weight: 500;
}
}
// ── 操作按钮 ──
.action-buttons {
display: flex;
gap: 16px;
}
.action-btn {
flex: 1;
text-align: center;
padding: 20px;
min-height: $touch-min;
display: flex;
align-items: center;
justify-content: center;
border-radius: $r;
background: $pri;
&:active {
opacity: 0.85;
}
&__text {
color: $card;
font-size: var(--tk-font-h1);
font-weight: 500;
}
}
</style>

View File

@@ -0,0 +1,344 @@
<template>
<Loading v-if="pageLoading && patients.length === 0" text="加载中..." />
<scroll-view v-else scroll-y class="page-scroll" @scrolltolower="onLoadMore">
<view :class="['page-content', elderClass]">
<!-- 搜索栏 -->
<view class="search-bar">
<input
class="search-input"
placeholder="搜索患者姓名/手机号"
placeholder-class="search-placeholder"
:value="search"
confirm-type="search"
@input="onSearchInput"
@confirm="handleSearch"
/>
</view>
<!-- 标签过滤 -->
<scroll-view v-if="tags.length > 0" scroll-x class="tag-filter">
<view
:class="['tag-chip', { active: !activeTag }]"
@tap="handleTagFilter('')"
>
<text>全部</text>
</view>
<view
v-for="tag in tags"
:key="tag.id"
:class="['tag-chip', { active: activeTag === tag.id }]"
:style="activeTag === tag.id && tag.color ? `background: ${tag.color}; color: white` : ''"
@tap="handleTagFilter(tag.id)"
>
<text>{{ tag.name }}</text>
</view>
</scroll-view>
<!-- 患者数量 -->
<view class="patient-count">
<text> {{ total }} 位患者</text>
</view>
<!-- 患者卡片列表 -->
<EmptyState v-if="patients.length === 0" icon="📋" title="暂无患者数据" />
<view v-else class="patient-cards">
<view
v-for="p in patients"
:key="p.id"
class="patient-card"
@tap="goDetail(p.id)"
>
<view class="patient-card__header">
<text class="patient-card__name">{{ p.name }}</text>
<text class="patient-card__meta">{{ genderLabel(p.gender) }} {{ calcAge(p.birth_date) }}</text>
</view>
<view v-if="p.tags && p.tags.length > 0" class="patient-card__tags">
<view
v-for="t in p.tags"
:key="t.id"
class="patient-tag"
:style="t.color ? `background: ${t.color}20; color: ${t.color}` : ''"
>
<text class="patient-tag__text">{{ t.name }}</text>
</view>
</view>
<text v-if="p.status" :class="['patient-card__status', `patient-card__status--${p.status}`]">
{{ p.status === 'active' ? '活跃' : p.status === 'inactive' ? '非活跃' : p.status }}
</text>
</view>
</view>
<!-- 加载更多提示 -->
<view v-if="!loadingMore && patients.length >= total && total > 0" class="load-hint-wrap">
<text class="load-hint">没有更多了</text>
</view>
<Loading v-if="loadingMore" text="加载中..." />
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { listPatients, listPatientTags } from '@/services/doctor/patient'
import type { PatientItem, PatientTag } from '@/services/doctor/patient'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
const { elderClass } = useElderClass()
const patients = ref<PatientItem[]>([])
const tags = ref<PatientTag[]>([])
const activeTag = ref('')
const search = ref('')
const pageLoading = ref(true)
const loadingMore = ref(false)
const isLoading = ref(false)
const total = ref(0)
const page = ref(1)
function genderLabel(gender?: string): string {
if (!gender) return ''
if (gender === 'male') return '男'
if (gender === 'female') return '女'
return gender
}
function calcAge(birthDate?: string): string {
if (!birthDate) return ''
const birth = new Date(birthDate)
const now = new Date()
let age = now.getFullYear() - birth.getFullYear()
if (
now.getMonth() < birth.getMonth() ||
(now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate())
) {
age--
}
return `${age}`
}
async function loadTags() {
try {
const res = await listPatientTags()
tags.value = res.data || []
} catch {
// 静默失败
}
}
async function loadPatients(pageNum: number, isRefresh = false) {
if (isLoading.value) return
isLoading.value = true
if (isRefresh) {
pageLoading.value = true
} else {
loadingMore.value = true
}
try {
const res = await listPatients({
page: pageNum,
page_size: 20,
search: search.value || undefined,
tag_id: activeTag.value || undefined,
})
const list = res.data || []
if (isRefresh) {
patients.value = list
} else {
patients.value = [...patients.value, ...list]
}
total.value = res.total || 0
page.value = pageNum
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
pageLoading.value = false
loadingMore.value = false
isLoading.value = false
}
}
function onSearchInput(e: { detail: { value: string } }) {
search.value = e.detail.value
}
function handleSearch() {
loadPatients(1, true)
}
function handleTagFilter(tagId: string) {
activeTag.value = tagId === activeTag.value ? '' : tagId
}
function goDetail(id: string) {
uni.navigateTo({ url: `/pages-sub/doctor/patients/detail/index?id=${id}` })
}
function onLoadMore() {
if (!isLoading.value && patients.value.length < total.value) {
loadPatients(page.value + 1)
}
}
watch(activeTag, () => {
loadPatients(1, true)
})
onMounted(() => {
loadTags()
loadPatients(1, true)
})
onPullDownRefresh(() => {
loadPatients(1, true).finally(() => {
uni.stopPullDownRefresh()
})
})
onShow(() => {
// 从详情页返回时不需要重新加载,保留列表状态
})
</script>
<style lang="scss" scoped>
.page-scroll {
height: 100vh;
background: $bg;
}
.page-content {
padding: 24px;
padding-bottom: 120px;
}
// ── 搜索栏 ──
.search-bar {
margin-bottom: 20px;
}
.search-input {
background: $card;
border-radius: $r;
padding: 20px 24px;
font-size: var(--tk-font-body-lg);
width: 100%;
box-sizing: border-box;
box-shadow: $shadow-sm;
}
.search-placeholder {
color: $tx3;
}
// ── 标签过滤 ──
.tag-filter {
white-space: nowrap;
margin-bottom: 20px;
width: 100%;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 10px 24px;
min-height: $touch-min;
border-radius: $r-pill;
background: $bd-l;
font-size: var(--tk-font-h2);
color: $tx2;
margin-right: 16px;
&.active {
background: $pri;
color: $card;
}
}
// ── 患者计数 ──
.patient-count {
margin-bottom: 16px;
text {
font-size: var(--tk-font-h2);
color: $tx3;
}
}
// ── 患者卡片 ──
.patient-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.patient-card {
background: $card;
border-radius: $r-lg;
padding: 28px;
box-shadow: $shadow-sm;
&:active {
background: $bd-l;
}
&__header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
&__name {
font-size: var(--tk-font-num);
font-weight: 600;
color: $tx;
margin-right: 16px;
}
&__meta {
font-size: var(--tk-font-h2);
color: $tx2;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
&__status {
@include tag($bg, $tx2);
&--active {
@include tag($acc-l, $acc);
}
&--inactive {
@include tag($bd-l, $tx3);
}
}
}
.patient-tag {
padding: 4px 14px;
border-radius: $r;
background: $pri-l;
&__text {
font-size: var(--tk-font-body);
}
}
// ── 加载提示 ──
.load-hint-wrap {
text-align: center;
padding: 20px;
}
.load-hint {
font-size: var(--tk-font-h2);
color: $tx3;
}
</style>

View File

@@ -0,0 +1,517 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- 患者选择 -->
<view class="section-card">
<text class="section-title">选择患者</text>
<view class="patient-search">
<input
class="search-input"
placeholder="搜索患者姓名"
:value="patientSearch"
@input="(e: any) => { patientSearch = e.detail.value; searchPatients() }"
/>
</view>
<view v-if="searchingPatient" class="loading-hint">
<text class="loading-hint__text">搜索中...</text>
</view>
<view v-else-if="patientResults.length > 0" class="patient-list">
<view
v-for="p in patientResults"
:key="p.id"
:class="['patient-item', form.patient_id === p.id ? 'patient-item--selected' : '']"
@tap="selectPatient(p)"
>
<text class="patient-item__name">{{ p.name }}</text>
<text class="patient-item__info">{{ p.gender === 'male' ? '男' : p.gender === 'female' ? '女' : '' }}</text>
<view v-if="form.patient_id === p.id" class="patient-item__check">
<text class="check-icon">&#10003;</text>
</view>
</view>
</view>
<view v-else-if="patientSearch && !searchingPatient" class="empty-hint">
<text class="empty-hint__text">未找到患者</text>
</view>
</view>
<!-- 透析方式与频率 -->
<view class="section-card">
<text class="section-title">透析方案</text>
<view class="form-field">
<text class="form-label">透析器型号</text>
<input
class="form-input"
placeholder="如 F60S"
:value="form.dialyzer_model"
@input="(e: any) => form.dialyzer_model = e.detail.value"
/>
</view>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">频率/ <text class="required">*</text></text>
<input
type="number"
class="form-input"
placeholder="如 3"
:value="form.frequency_per_week ?? ''"
@input="(e: any) => updateNumericField('frequency_per_week', e.detail.value)"
/>
</view>
<view class="form-field form-field--half">
<text class="form-label">单次时长分钟</text>
<input
type="digit"
class="form-input"
placeholder="如 240"
:value="form.duration_minutes ?? ''"
@input="(e: any) => updateNumericField('duration_minutes', e.detail.value)"
/>
</view>
</view>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">生效日期</text>
<picker mode="date" :value="form.effective_from" @change="(e: any) => form.effective_from = e.detail.value">
<view :class="['picker-display', form.effective_from ? '' : 'placeholder']">
{{ form.effective_from || '选择日期' }}
</view>
</picker>
</view>
<view class="form-field form-field--half">
<text class="form-label">失效日期</text>
<picker mode="date" :value="form.effective_to" @change="(e: any) => form.effective_to = e.detail.value">
<view :class="['picker-display', form.effective_to ? '' : 'placeholder']">
{{ form.effective_to || '选择日期' }}
</view>
</picker>
</view>
</view>
</view>
<!-- 透析参数 -->
<view class="section-card">
<text class="section-title">透析参数</text>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">血流量 (ml/min)</text>
<input
type="digit"
class="form-input"
placeholder="如 300"
:value="form.blood_flow_rate ?? ''"
@input="(e: any) => updateNumericField('blood_flow_rate', e.detail.value)"
/>
</view>
<view class="form-field form-field--half">
<text class="form-label">透析液流量 (ml/min)</text>
<input
type="digit"
class="form-input"
placeholder="如 500"
:value="form.dialysate_flow_rate ?? ''"
@input="(e: any) => updateNumericField('dialysate_flow_rate', e.detail.value)"
/>
</view>
</view>
<view class="form-field">
<text class="form-label">抗凝方式</text>
<input
class="form-input"
placeholder="如 肝素、低分子肝素"
:value="form.anticoagulation_type"
@input="(e: any) => form.anticoagulation_type = e.detail.value"
/>
</view>
<view class="form-field">
<text class="form-label">抗凝剂量</text>
<input
class="form-input"
placeholder="如 2000IU"
:value="form.anticoagulation_dose"
@input="(e: any) => form.anticoagulation_dose = e.detail.value"
/>
</view>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">透析液钾 (mmol/L)</text>
<input
type="digit"
class="form-input"
placeholder="如 2.0"
:value="form.dialysate_potassium ?? ''"
@input="(e: any) => updateNumericField('dialysate_potassium', e.detail.value)"
/>
</view>
<view class="form-field form-field--half">
<text class="form-label">透析液钙 (mmol/L)</text>
<input
type="digit"
class="form-input"
placeholder="如 1.5"
:value="form.dialysate_calcium ?? ''"
@input="(e: any) => updateNumericField('dialysate_calcium', e.detail.value)"
/>
</view>
</view>
<view class="form-field">
<text class="form-label">膜面积 (m2)</text>
<input
type="digit"
class="form-input"
placeholder="如 1.8"
:value="form.membrane_area ?? ''"
@input="(e: any) => updateNumericField('membrane_area', e.detail.value)"
/>
</view>
</view>
<!-- 目标参数 -->
<view class="section-card">
<text class="section-title">目标参数</text>
<view class="form-row">
<view class="form-field form-field--half">
<text class="form-label">干体重 (kg)</text>
<input
type="digit"
class="form-input"
placeholder="如 65.0"
:value="form.target_dry_weight ?? ''"
@input="(e: any) => updateNumericField('target_dry_weight', e.detail.value)"
/>
</view>
<view class="form-field form-field--half">
<text class="form-label">超滤目标 (ml)</text>
<input
type="digit"
class="form-input"
placeholder="如 2000"
:value="form.target_ultrafiltration_ml ?? ''"
@input="(e: any) => updateNumericField('target_ultrafiltration_ml', e.detail.value)"
/>
</view>
</view>
</view>
<!-- 血管通路 -->
<view class="section-card">
<text class="section-title">血管通路</text>
<view class="form-field">
<text class="form-label">通路类型</text>
<input
class="form-input"
placeholder="如 动静脉内瘘、中心静脉导管"
:value="form.vascular_access_type"
@input="(e: any) => form.vascular_access_type = e.detail.value"
/>
</view>
<view class="form-field">
<text class="form-label">通路位置</text>
<input
class="form-input"
placeholder="如 左前臂"
:value="form.vascular_access_location"
@input="(e: any) => form.vascular_access_location = e.detail.value"
/>
</view>
</view>
<!-- 备注 -->
<view class="section-card">
<text class="section-title">备注</text>
<textarea
class="form-textarea"
placeholder="处方备注(选填)"
:value="form.notes"
@input="(e: any) => form.notes = e.detail.value"
:maxlength="1000"
/>
</view>
<!-- 提交 -->
<view class="submit-wrap">
<view :class="['action-btn', submitting ? 'disabled' : '']" @tap="submitting ? undefined : handleSubmit">
<text class="action-btn-text">{{ submitting ? '提交中...' : '创建处方' }}</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useElderClass } from '@/composables/useElderClass'
import { createDialysisPrescription } from '@/services/doctor/dialysis'
import { listPatients } from '@/services/doctor/patient'
import type { PatientItem } from '@/services/doctor/patient'
const { elderClass } = useElderClass()
const form = reactive({
patient_id: '',
dialyzer_model: '',
frequency_per_week: undefined as number | undefined,
duration_minutes: undefined as number | undefined,
blood_flow_rate: undefined as number | undefined,
dialysate_flow_rate: undefined as number | undefined,
anticoagulation_type: '',
anticoagulation_dose: '',
dialysate_potassium: undefined as number | undefined,
dialysate_calcium: undefined as number | undefined,
dialysate_bicarbonate: undefined as number | undefined,
membrane_area: undefined as number | undefined,
target_dry_weight: undefined as number | undefined,
target_ultrafiltration_ml: undefined as number | undefined,
vascular_access_type: '',
vascular_access_location: '',
effective_from: '',
effective_to: '',
notes: '',
})
const patientSearch = ref('')
const patientResults = ref<PatientItem[]>([])
const searchingPatient = ref(false)
const submitting = ref(false)
let searchTimer: ReturnType<typeof setTimeout> | null = null
function searchPatients() {
if (searchTimer) clearTimeout(searchTimer)
if (!patientSearch.value.trim()) {
patientResults.value = []
return
}
searchTimer = setTimeout(async () => {
searchingPatient.value = true
try {
const res = await listPatients({ search: patientSearch.value.trim(), page_size: 10 })
patientResults.value = res.data || []
} catch {
patientResults.value = []
} finally {
searchingPatient.value = false
}
}, 300)
}
function selectPatient(p: PatientItem) {
form.patient_id = form.patient_id === p.id ? '' : p.id
}
function updateNumericField(field: keyof typeof form, raw: string) {
const val = raw.trim() === '' ? undefined : Number(raw)
;(form as any)[field] = isNaN(val as number) ? undefined : val
}
async function handleSubmit() {
if (!form.patient_id) {
uni.showToast({ title: '请选择患者', icon: 'none' })
return
}
if (!form.frequency_per_week) {
uni.showToast({ title: '请填写透析频率', icon: 'none' })
return
}
submitting.value = true
try {
await createDialysisPrescription({
patient_id: form.patient_id,
dialyzer_model: form.dialyzer_model || undefined,
frequency_per_week: form.frequency_per_week,
duration_minutes: form.duration_minutes,
blood_flow_rate: form.blood_flow_rate,
dialysate_flow_rate: form.dialysate_flow_rate,
anticoagulation_type: form.anticoagulation_type || undefined,
anticoagulation_dose: form.anticoagulation_dose || undefined,
dialysate_potassium: form.dialysate_potassium,
dialysate_calcium: form.dialysate_calcium,
dialysate_bicarbonate: form.dialysate_bicarbonate,
membrane_area: form.membrane_area,
target_dry_weight: form.target_dry_weight,
target_ultrafiltration_ml: form.target_ultrafiltration_ml,
vascular_access_type: form.vascular_access_type || undefined,
vascular_access_location: form.vascular_access_location || undefined,
effective_from: form.effective_from || undefined,
effective_to: form.effective_to || undefined,
notes: form.notes.trim() || undefined,
})
uni.showToast({ title: '创建成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 800)
} catch {
uni.showToast({ title: '创建失败', icon: 'none' })
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 160px; }
.section-card {
@include card;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
// Search
.patient-search { margin-bottom: 12px; }
.search-input {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
box-sizing: border-box;
}
.loading-hint, .empty-hint {
@include flex-center;
padding: 24px 0;
}
.loading-hint__text, .empty-hint__text {
font-size: var(--tk-font-body-sm);
color: $tx3;
}
.patient-list {
max-height: 320px;
overflow-y: auto;
}
.patient-item {
display: flex;
align-items: center;
padding: 16px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
border-radius: $r-xs;
&:active { background: $bd-l; }
&--selected { background: $pri-l; }
&__name {
flex: 1;
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
&__info {
font-size: var(--tk-font-body-sm);
color: $tx3;
margin-right: 12px;
}
&__check {
width: 32px;
height: 32px;
@include flex-center;
background: $pri;
border-radius: 50%;
}
}
.check-icon {
color: $card;
font-size: var(--tk-font-body-sm);
}
// Form
.form-field {
margin-bottom: 16px;
}
.form-field--half {
flex: 1;
}
.form-row {
display: flex;
gap: 12px;
}
.form-label {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: 8px;
}
.required { color: $dan; }
.form-input {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
box-sizing: border-box;
}
.picker-display {
height: 48px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
background: $card;
display: flex;
align-items: center;
box-sizing: border-box;
}
.picker-display.placeholder { color: $tx3; }
.form-textarea {
width: 100%;
min-height: 120px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 12px;
font-size: var(--tk-font-body);
color: $tx;
box-sizing: border-box;
background: $card;
}
// Submit
.submit-wrap { margin: 0 24px; }
.action-btn {
@include btn-primary;
}
.action-btn.disabled { opacity: 0.5; }
.action-btn-text {
color: $card;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<Loading v-if="pageLoading" text="加载中..." />
<ErrorState v-else-if="error || !prescription" text="处方加载失败" :on-retry="loadData" />
<scroll-view v-else scroll-y :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- 处方信息 -->
<view class="info-card">
<view class="info-header">
<text class="section-label">处方信息</text>
<view class="status-tag" :style="getStatusInlineStyle(prescription.status)">
<text class="status-tag__text">{{ getStatusLabel(prescription.status) }}</text>
</view>
</view>
<view class="info-row">
<text class="info-label">透析方式</text>
<text class="info-value">{{ prescription.dialyzer_model || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">频率</text>
<text class="info-value">{{ prescription.frequency_per_week ? `${prescription.frequency_per_week} 次/周` : '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">单次时长</text>
<text class="info-value">{{ prescription.duration_minutes ? `${prescription.duration_minutes} 分钟` : '-' }}</text>
</view>
<view v-if="prescription.effective_from" class="info-row">
<text class="info-label">生效日期</text>
<text class="info-value">{{ prescription.effective_from }}</text>
</view>
<view v-if="prescription.effective_to" class="info-row">
<text class="info-label">失效日期</text>
<text class="info-value">{{ prescription.effective_to }}</text>
</view>
</view>
<!-- 透析参数 -->
<view class="section-card">
<text class="section-title">透析参数</text>
<view v-if="prescription.blood_flow_rate != null" class="info-row">
<text class="info-label">血流量</text>
<text class="info-value">{{ prescription.blood_flow_rate }} ml/min</text>
</view>
<view v-if="prescription.dialysate_flow_rate != null" class="info-row">
<text class="info-label">透析液流量</text>
<text class="info-value">{{ prescription.dialysate_flow_rate }} ml/min</text>
</view>
<view v-if="prescription.dialysate_potassium != null" class="info-row">
<text class="info-label">透析液钾浓度</text>
<text class="info-value">{{ prescription.dialysate_potassium }} mmol/L</text>
</view>
<view v-if="prescription.dialysate_calcium != null" class="info-row">
<text class="info-label">透析液钙浓度</text>
<text class="info-value">{{ prescription.dialysate_calcium }} mmol/L</text>
</view>
<view v-if="prescription.dialysate_bicarbonate != null" class="info-row">
<text class="info-label">透析液碳酸氢盐</text>
<text class="info-value">{{ prescription.dialysate_bicarbonate }} mmol/L</text>
</view>
<view v-if="prescription.anticoagulation_type" class="info-row">
<text class="info-label">抗凝方式</text>
<text class="info-value">{{ prescription.anticoagulation_type }}</text>
</view>
<view v-if="prescription.anticoagulation_dose" class="info-row">
<text class="info-label">抗凝剂量</text>
<text class="info-value">{{ prescription.anticoagulation_dose }}</text>
</view>
</view>
<!-- 目标参数 -->
<view class="section-card">
<text class="section-title">目标参数</text>
<view class="vitals-grid">
<view v-if="prescription.target_dry_weight != null" class="vital-item">
<text class="vital-value">{{ prescription.target_dry_weight }}</text>
<text class="vital-label">干体重 kg</text>
</view>
<view v-if="prescription.target_ultrafiltration_ml != null" class="vital-item">
<text class="vital-value">{{ prescription.target_ultrafiltration_ml }}</text>
<text class="vital-label">超滤目标 ml</text>
</view>
<view v-if="prescription.membrane_area != null" class="vital-item">
<text class="vital-value">{{ prescription.membrane_area }}</text>
<text class="vital-label">膜面积 m2</text>
</view>
</view>
</view>
<!-- 血管通路 -->
<view v-if="prescription.vascular_access_type" class="section-card">
<text class="section-title">血管通路</text>
<view class="info-row">
<text class="info-label">通路类型</text>
<text class="info-value">{{ prescription.vascular_access_type }}</text>
</view>
<view v-if="prescription.vascular_access_location" class="info-row">
<text class="info-label">通路位置</text>
<text class="info-value">{{ prescription.vascular_access_location }}</text>
</view>
</view>
<!-- 备注 -->
<view v-if="prescription.notes" class="section-card">
<text class="section-title">备注</text>
<text class="notes-text">{{ prescription.notes }}</text>
</view>
<!-- 操作 -->
<view v-if="prescription.status === 'active'" class="action-card">
<view
:class="['action-btn--outline', deactivating ? 'disabled' : '']"
@tap="deactivating ? undefined : handleDeactivate"
>
<text class="action-btn--outline__text">{{ deactivating ? '处理中...' : '停用处方' }}</text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { getDialysisPrescriptionById, updateDialysisPrescription } from '@/services/doctor/dialysis'
import type { DialysisPrescription } from '@/services/dialysis'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import Loading from '@/components/Loading.vue'
import ErrorState from '@/components/ErrorState.vue'
const { elderClass } = useElderClass()
const prescription = ref<DialysisPrescription | null>(null)
const pageLoading = ref(true)
const error = ref(false)
const deactivating = ref(false)
let prescriptionId = ''
async function loadData() {
if (!prescriptionId) return
pageLoading.value = true
error.value = false
try {
const data = await getDialysisPrescriptionById(prescriptionId)
prescription.value = data
} catch {
error.value = true
} finally {
pageLoading.value = false
}
}
async function handleDeactivate() {
if (!prescription.value) return
deactivating.value = true
try {
const updated = await updateDialysisPrescription(
prescriptionId,
{ status: 'inactive' },
prescription.value.version,
)
prescription.value = updated
uni.showToast({ title: '已停用', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
} finally {
deactivating.value = false
}
}
onLoad((query) => {
prescriptionId = query?.id || ''
if (!prescriptionId) { error.value = true; pageLoading.value = false; return }
loadData()
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 120px; }
.info-card {
@include card;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-label {
font-size: var(--tk-font-title);
font-weight: 600;
color: $tx;
}
.status-tag {
display: inline-block;
border-radius: 6px;
padding: 2px 8px;
}
.status-tag__text {
font-size: var(--tk-font-body);
font-weight: 500;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: var(--tk-font-cap);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
.section-card {
@include card;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
.vitals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.vital-item {
background: $pri-l;
border-radius: $r-sm;
padding: 16px;
text-align: center;
}
.vital-value {
@include serif-number;
font-size: var(--tk-font-num);
font-weight: 700;
color: $pri;
display: block;
margin-bottom: 4px;
}
.vital-label {
font-size: var(--tk-font-cap);
color: $tx2;
}
.notes-text {
font-size: var(--tk-font-body-sm);
color: $tx2;
line-height: 1.6;
}
.action-card {
@include card;
}
.action-btn--outline {
@include btn-outline;
&.disabled { opacity: 0.5; }
&__text {
color: $pri;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
}
</style>

View File

@@ -0,0 +1,336 @@
<template>
<scroll-view scroll-y :class="['page-scroll', elderClass]">
<!-- 搜索 -->
<view class="search-bar">
<input
class="search-input"
placeholder="搜索患者姓名"
:value="searchKeyword"
@input="(e: any) => { searchKeyword = e.detail.value; debouncedSearch() }"
/>
</view>
<!-- Tab 筛选 -->
<view class="tabs">
<view
v-for="t in TABS"
:key="t.key"
:class="['tab', activeTab === t.key ? 'tab--active' : '']"
@tap="handleTabChange(t.key)"
>
<text>{{ t.label }}</text>
</view>
</view>
<!-- 加载态 -->
<Loading v-if="loading && prescriptions.length === 0" text="加载中..." />
<!-- 空态 -->
<EmptyState v-else-if="prescriptions.length === 0" icon="📋" title="暂无透析处方" />
<!-- 处方列表 -->
<view v-else class="prescription-list">
<view
v-for="p in prescriptions"
:key="p.id"
class="prescription-card"
@tap="goDetail(p.id)"
>
<view class="prescription-card__top">
<text class="prescription-card__patient">{{ patientNameMap[p.patient_id] || '未知患者' }}</text>
<view class="prescription-card__status" :style="getStatusInlineStyle(p.status)">
<text class="prescription-card__status-text">{{ getStatusLabel(p.status) }}</text>
</view>
</view>
<view class="prescription-card__meta">
<text class="prescription-card__type">
{{ dialysisTypeLabel(p.dialyzer_model) }}
</text>
<text v-if="p.frequency_per_week" class="prescription-card__freq">
{{ p.frequency_per_week }}/
</text>
</view>
<text class="prescription-card__date">{{ p.created_at?.substring(0, 10) }}</text>
</view>
</view>
<!-- 分页 -->
<view v-if="total > pageSize" class="pagination">
<text
:class="['pagination__btn', page <= 1 ? 'disabled' : '']"
@tap="page > 1 && (page = page - 1)"
>上一页</text>
<text class="pagination__info">{{ page }} / {{ totalPages }}</text>
<text
:class="['pagination__btn', page >= totalPages ? 'disabled' : '']"
@tap="page < totalPages && (page = page + 1)"
>下一页</text>
</view>
<!-- 创建按钮 -->
<view class="fab" @tap="goCreate">
<text class="fab__icon">+</text>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useElderClass } from '@/composables/useElderClass'
import { listDialysisPrescriptions } from '@/services/doctor/dialysis'
import type { DialysisPrescription } from '@/services/dialysis'
import { listPatients } from '@/services/doctor/patient'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import Loading from '@/components/Loading.vue'
import EmptyState from '@/components/EmptyState.vue'
const TABS = [
{ key: '', label: '全部' },
{ key: 'active', label: '生效中' },
{ key: 'inactive', label: '已停用' },
{ key: 'expired', label: '已过期' },
] as const
const { elderClass } = useElderClass()
const pageSize = 20
const prescriptions = ref<DialysisPrescription[]>([])
const patientNameMap = ref<Record<string, string>>({})
const activeTab = ref('')
const searchKeyword = ref('')
const loading = ref(true)
const total = ref(0)
const page = ref(1)
const totalPages = computed(() => Math.ceil(total.value / pageSize))
function handleTabChange(key: string) {
activeTab.value = key
page.value = 1
}
function dialysisTypeLabel(model?: string): string {
if (!model) return '透析处方'
return model
}
function goDetail(id: string) {
uni.navigateTo({ url: `/pages-sub/doctor/prescription/detail/index?id=${id}` })
}
function goCreate() {
uni.navigateTo({ url: '/pages-sub/doctor/prescription/create/index' })
}
let searchTimer: ReturnType<typeof setTimeout> | null = null
function debouncedSearch() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
page.value = 1
loadPrescriptions()
}, 300)
}
async function loadPrescriptions() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: page.value,
page_size: pageSize,
}
if (activeTab.value) params.status = activeTab.value
const res = await listDialysisPrescriptions(params)
prescriptions.value = res.data || []
total.value = res.total || 0
// Resolve patient names
const ids = [...new Set(prescriptions.value.map((p) => p.patient_id))]
await resolvePatientNames(ids)
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function resolvePatientNames(ids: string[]) {
const missing = ids.filter((id) => !patientNameMap.value[id])
if (missing.length === 0) return
try {
// Load patients in batches to resolve names
for (const id of missing) {
try {
const res = await listPatients({ page_size: 1 })
// If we have data, try to find the patient
const patient = res.data?.find((p) => p.id === id)
if (patient) {
patientNameMap.value = { ...patientNameMap.value, [id]: patient.name }
}
} catch { /* skip individual failures */ }
}
} catch { /* non-critical */ }
}
watch([page, activeTab], () => { loadPrescriptions() })
onShow(() => {
loadPrescriptions()
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.search-bar {
padding: 16px 24px;
background: $card;
}
.search-input {
height: 48px;
border: 1px solid $bd;
border-radius: $r-pill;
padding: 0 24px;
font-size: var(--tk-font-body);
color: $tx;
background: $bg;
box-sizing: border-box;
}
.tabs {
display: flex;
background: $card;
padding: 0 16px;
border-bottom: 1px solid $bd;
}
.tab {
flex: 1;
text-align: center;
padding: 24px 0;
font-size: var(--tk-font-body-lg);
color: $tx2;
position: relative;
&--active {
color: $pri;
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 30%;
right: 30%;
height: 4px;
background: $pri;
border-radius: $r-xs;
}
}
}
.prescription-list {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.prescription-card {
@include card;
&:active { background: $bd-l; }
&__top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
&__patient {
font-size: var(--tk-font-body-lg);
font-weight: 600;
color: $tx;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16px;
}
&__status {
display: inline-block;
border-radius: 6px;
padding: 2px 8px;
flex-shrink: 0;
}
&__status-text {
font-size: var(--tk-font-body);
font-weight: 500;
}
&__meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
&__type {
@include tag($pri-l, $pri);
}
&__freq {
font-size: var(--tk-font-body-sm);
color: $tx2;
}
&__date {
font-size: var(--tk-font-cap);
color: $tx3;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
padding: 24px;
&__btn {
font-size: var(--tk-font-body);
color: $pri;
padding: 12px 24px;
&.disabled { color: $tx3; }
}
&__info {
font-size: var(--tk-font-body-sm);
color: $tx2;
}
}
.fab {
position: fixed;
right: 32px;
bottom: 100px;
width: 56px;
height: 56px;
background: $pri;
border-radius: 50%;
@include flex-center;
box-shadow: $shadow-lg;
&:active { opacity: 0.85; }
}
.fab__icon {
color: $card;
font-size: var(--tk-font-hero);
font-weight: 300;
}
</style>

View File

@@ -0,0 +1,349 @@
<template>
<view :class="['page-scroll', elderClass]">
<view class="page-content">
<!-- Loading -->
<Loading v-if="loading" text="加载中..." />
<!-- Error -->
<view v-else-if="!report" class="empty-wrap">
<text class="empty-text">报告不存在</text>
</view>
<template v-else>
<!-- Report info card -->
<view class="info-card">
<view class="info-header">
<text class="report-type">{{ report.report_type }}</text>
<text class="status-tag" :style="getStatusInlineStyle(report.status)">
{{ getStatusLabel(report.status) }}
</text>
</view>
<view class="info-row">
<text class="info-label">报告日期</text>
<text class="info-value">{{ report.report_date }}</text>
</view>
<view v-if="report.abnormal_count != null" class="info-row">
<text class="info-label">异常指标</text>
<text :class="['info-value', report.abnormal_count > 0 ? 'abnormal' : 'normal']">
{{ report.abnormal_count }}
</text>
</view>
</view>
<!-- Indicators list -->
<view class="indicators-card">
<text class="section-title">检查指标</text>
<view v-if="indicators.length === 0" class="empty-indicators">
<text class="empty-text">暂无指标数据</text>
</view>
<view v-for="(item, idx) in indicators" :key="idx" class="indicator-item">
<view class="indicator-left">
<text class="indicator-name">{{ item.name }}</text>
<text class="indicator-value">{{ item.value }}{{ item.unit ? ` ${item.unit}` : '' }}</text>
</view>
<view class="indicator-right">
<text v-if="item.reference_min != null && item.reference_max != null" class="indicator-ref">
{{ item.reference_min }}~{{ item.reference_max }}
</text>
<text :class="['indicator-status', getIndicatorStatusClass(item)]">
{{ getIndicatorStatusLabel(item) }}
</text>
</view>
</view>
</view>
<!-- Doctor interpretation -->
<view v-if="report.doctor_notes" class="notes-card">
<text class="section-title">医生解读</text>
<text class="notes-text">{{ report.doctor_notes }}</text>
</view>
<!-- Review section (for doctor) -->
<view v-if="canReview" class="review-card">
<text class="section-title">审核报告</text>
<view class="form-field">
<text class="form-label">审核意见</text>
<textarea
class="form-textarea"
placeholder="请输入审核意见(选填)"
:value="reviewNotes"
@input="(e: any) => reviewNotes = e.detail.value"
:maxlength="500"
/>
</view>
<view
:class="['action-btn', reviewing ? 'disabled' : '']"
@tap="reviewing ? undefined : handleReview"
>
<text class="action-btn-text">{{ reviewing ? '提交中...' : '确认审核' }}</text>
</view>
</view>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor/labReport'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { useElderClass } from '@/composables/useElderClass'
import Loading from '@/components/Loading.vue'
interface IndicatorDisplay {
name: string
value: number
unit?: string
reference_min?: number
reference_max?: number
is_abnormal?: boolean
}
const { elderClass } = useElderClass()
const report = ref<doctorApi.LabReportDetail | null>(null)
const loading = ref(true)
const reviewing = ref(false)
const reviewNotes = ref('')
let patientId = ''
let reportId = ''
const indicators = computed<IndicatorDisplay[]>(() => {
if (!report.value?.items) return []
return report.value.items
})
const canReview = computed(() => {
if (!report.value) return false
return report.value.status !== 'reviewed' && report.value.status !== 'verified'
})
function getIndicatorStatusClass(item: IndicatorDisplay): string {
if (item.is_abnormal) {
if (item.reference_min != null && item.value < item.reference_min) return 'low'
return 'high'
}
return 'normal'
}
function getIndicatorStatusLabel(item: IndicatorDisplay): string {
if (item.is_abnormal) {
if (item.reference_min != null && item.value < item.reference_min) return '偏低'
return '偏高'
}
return '正常'
}
async function fetchDetail() {
loading.value = true
try {
report.value = await doctorApi.getLabReport(patientId, reportId)
reviewNotes.value = report.value?.doctor_notes || ''
} catch {
report.value = null
} finally {
loading.value = false
}
}
async function handleReview() {
if (!report.value) return
reviewing.value = true
try {
const updated = await doctorApi.reviewLabReport(patientId, reportId, {
doctor_notes: reviewNotes.value.trim() || undefined,
version: report.value.version,
})
report.value = updated
uni.showToast({ title: '审核完成', icon: 'success' })
} catch {
uni.showToast({ title: '审核失败', icon: 'none' })
} finally {
reviewing.value = false
}
}
onLoad((query) => {
patientId = query?.patientId || ''
reportId = query?.reportId || ''
if (!patientId || !reportId) { loading.value = false; return }
fetchDetail()
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 24px 0 120px; }
.empty-wrap { @include flex-center; padding: 120px 0; }
.empty-text { font-size: var(--tk-font-body); color: $tx3; }
// Info card
.info-card {
@include card;
margin-bottom: 16px;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.report-type {
font-size: var(--tk-font-h2);
font-weight: 600;
color: $tx;
}
.status-tag {
@include status-inline;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: var(--tk-font-cap);
color: $tx3;
}
.info-value {
font-size: var(--tk-font-body);
color: $tx;
}
.info-value.abnormal { color: $dan; font-weight: 600; }
.info-value.normal { color: $acc; }
// Indicators card
.indicators-card {
@include card;
margin-bottom: 16px;
}
.section-title {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
margin-bottom: 16px;
display: block;
}
.empty-indicators {
@include flex-center;
padding: 40px 0;
}
.indicator-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.indicator-item:last-child { border-bottom: none; }
.indicator-left { flex: 1; }
.indicator-name {
display: block;
font-size: var(--tk-font-cap);
color: $tx2;
margin-bottom: 2px;
}
.indicator-value {
display: block;
font-size: var(--tk-font-body);
color: $tx;
font-weight: 500;
}
.indicator-right {
text-align: right;
flex-shrink: 0;
margin-left: 16px;
}
.indicator-ref {
display: block;
font-size: var(--tk-font-micro);
color: $tx3;
margin-bottom: 2px;
}
.indicator-status {
display: block;
font-size: var(--tk-font-cap);
font-weight: 500;
}
.indicator-status.high { color: $wrn; }
.indicator-status.low { color: $info; }
.indicator-status.normal { color: $acc; }
// Notes card
.notes-card {
@include card;
margin-bottom: 16px;
}
.notes-text {
font-size: var(--tk-font-body-sm);
color: $tx;
line-height: 1.7;
}
// Review card
.review-card {
@include card;
}
.form-field { margin-bottom: 16px; }
.form-label {
display: block;
font-size: var(--tk-font-body-sm);
color: $tx2;
margin-bottom: 8px;
}
.form-textarea {
width: 100%;
min-height: 120px;
border: 1px solid $bd;
border-radius: $r-sm;
padding: 12px;
font-size: var(--tk-font-body);
color: $tx;
box-sizing: border-box;
background: $card;
}
.action-btn {
@include btn-primary;
}
.action-btn.disabled { opacity: 0.5; }
.action-btn-text {
color: $white;
font-size: var(--tk-font-body-lg);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,220 @@
<template>
<view :class="['page-scroll', elderClass]">
<view class="page-content">
<text class="page-title">化验报告</text>
<!-- Search bar -->
<view class="search-bar">
<input
class="search-input"
placeholder="搜索患者姓名"
:value="searchText"
@input="(e: any) => searchText = e.detail.value"
@confirm="handleSearch"
confirm-type="search"
/>
</view>
<!-- Loading -->
<Loading v-if="loading && reports.length === 0" text="加载中..." />
<!-- Empty -->
<EmptyState v-else-if="reports.length === 0 && !loading" icon="📋" title="暂无化验报告" />
<!-- Report list -->
<scroll-view v-else scroll-y class="list-scroll" @scrolltolower="loadMore">
<view
v-for="item in reports" :key="item.id"
class="report-card"
@tap="goDetail(item.id)"
>
<view class="card-header">
<text class="report-type">{{ item.report_type }}</text>
<text class="status-tag" :style="getStatusInlineStyle(item.status)">
{{ getStatusLabel(item.status) }}
</text>
</view>
<view class="card-body">
<text class="report-date">报告日期{{ item.report_date }}</text>
<view v-if="item.abnormal_count != null && item.abnormal_count > 0" class="abnormal-badge">
<text class="abnormal-text">{{ item.abnormal_count }} 项异常</text>
</view>
</view>
</view>
<Loading v-if="loading" text="加载中..." />
<view v-if="!loading && reports.length >= total && total > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import * as doctorApi from '@/services/doctor/labReport'
import { listPatients } from '@/services/doctor/patient'
import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'
import { useElderClass } from '@/composables/useElderClass'
import EmptyState from '@/components/EmptyState.vue'
import Loading from '@/components/Loading.vue'
const { elderClass } = useElderClass()
const reports = ref<doctorApi.LabReportItem[]>([])
const total = ref(0)
const page = ref(1)
const loading = ref(false)
const searchText = ref('')
let patientId = ''
let currentPatientId = ''
let loadingGuard = false
async function fetchReports(pageNum: number, isRefresh = false) {
if (loadingGuard || !currentPatientId) return
loadingGuard = true
loading.value = true
try {
const res = await doctorApi.listLabReports(currentPatientId, {
page: pageNum,
page_size: 20,
})
const list = res.data || []
reports.value = isRefresh ? list : [...reports.value, ...list]
total.value = res.total
page.value = pageNum
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loadingGuard = false
loading.value = false
}
}
async function handleSearch() {
const keyword = searchText.value.trim()
if (!keyword) return
loading.value = true
try {
const res = await listPatients({ search: keyword, page_size: 1 })
const patients = res.data || []
if (patients.length > 0) {
currentPatientId = patients[0].id
fetchReports(1, true)
} else {
uni.showToast({ title: '未找到该患者', icon: 'none' })
loading.value = false
}
} catch {
uni.showToast({ title: '搜索失败', icon: 'none' })
loading.value = false
}
}
function loadMore() {
if (!loading.value && reports.value.length < total.value) {
fetchReports(page.value + 1)
}
}
function goDetail(reportId: string) {
uni.navigateTo({
url: `/pages-sub/doctor/report/detail/index?reportId=${reportId}&patientId=${currentPatientId}`,
})
}
onLoad((query) => {
patientId = query?.patientId || ''
if (patientId) {
currentPatientId = patientId
fetchReports(1, true)
}
})
onPullDownRefresh(() => {
if (currentPatientId) {
fetchReports(1, true).finally(() => uni.stopPullDownRefresh())
} else {
uni.stopPullDownRefresh()
}
})
</script>
<style lang="scss" scoped>
.page-scroll { min-height: 100vh; background: $bg; }
.page-content { padding: 28px 0 120px; }
.page-title { @include section-title; margin-left: 24px; }
// Search bar
.search-bar {
padding: 0 24px 16px;
}
.search-input {
height: 48px;
background: $card;
border-radius: $r;
padding: 0 16px;
font-size: var(--tk-font-body);
color: $tx;
box-shadow: $shadow-sm;
}
// List
.list-scroll { height: calc(100vh - 180px); }
.report-card {
background: $card;
border-radius: $r;
padding: 20px 24px;
margin: 0 24px 16px;
box-shadow: $shadow-sm;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.report-type {
font-size: var(--tk-font-body);
font-weight: 600;
color: $tx;
}
.status-tag {
@include status-inline;
}
.card-body {
display: flex;
justify-content: space-between;
align-items: center;
}
.report-date {
font-size: var(--tk-font-cap);
color: $tx3;
}
.abnormal-badge {
padding: 2px 10px;
border-radius: $r-pill;
background: $dan-l;
}
.abnormal-text {
font-size: var(--tk-font-cap);
color: $dan;
font-weight: 500;
}
.no-more { @include flex-center; padding: 20px; }
.no-more-text { font-size: var(--tk-font-cap); color: $tx3; }
</style>