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:
@@ -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>
|
||||
@@ -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>
|
||||
317
apps/miniprogram-uniapp/src/pages-sub/doctor/alerts/index.vue
Normal file
317
apps/miniprogram-uniapp/src/pages-sub/doctor/alerts/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">✓</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>
|
||||
@@ -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>
|
||||
371
apps/miniprogram-uniapp/src/pages-sub/doctor/dialysis/index.vue
Normal file
371
apps/miniprogram-uniapp/src/pages-sub/doctor/dialysis/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
227
apps/miniprogram-uniapp/src/pages-sub/doctor/followup/index.vue
Normal file
227
apps/miniprogram-uniapp/src/pages-sub/doctor/followup/index.vue
Normal 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>
|
||||
451
apps/miniprogram-uniapp/src/pages-sub/doctor/index.vue
Normal file
451
apps/miniprogram-uniapp/src/pages-sub/doctor/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
344
apps/miniprogram-uniapp/src/pages-sub/doctor/patients/index.vue
Normal file
344
apps/miniprogram-uniapp/src/pages-sub/doctor/patients/index.vue
Normal 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>
|
||||
@@ -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">✓</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
220
apps/miniprogram-uniapp/src/pages-sub/doctor/report/index.vue
Normal file
220
apps/miniprogram-uniapp/src/pages-sub/doctor/report/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user