1242 lines
32 KiB
Plaintext
1242 lines
32 KiB
Plaintext
<!-- 访客管理 - 重构版本 -->
|
||
<template>
|
||
<view class="visitor-management">
|
||
<!-- Header -->
|
||
<view class="header">
|
||
<text class="header-title">访客管理</text>
|
||
<view class="header-actions">
|
||
<button class="action-btn" @click="showAddVisit">
|
||
<text class="btn-text">➕ 新增访客</text>
|
||
</button>
|
||
<button class="action-btn" @click="showPreregistration">
|
||
<text class="btn-text">📋 预约访问</text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Stats Cards -->
|
||
<view class="stats-section">
|
||
<view class="stat-card">
|
||
<view class="stat-icon">👥</view>
|
||
<view class="stat-content">
|
||
<text class="stat-number">{{ stats.today_visitors }}</text>
|
||
<text class="stat-label">今日访客</text>
|
||
</view>
|
||
</view>
|
||
<view class="stat-card">
|
||
<view class="stat-icon">🏠</view>
|
||
<view class="stat-content">
|
||
<text class="stat-number">{{ stats.current_visitors }}</text>
|
||
<text class="stat-label">当前在院</text>
|
||
</view>
|
||
</view>
|
||
<view class="stat-card">
|
||
<view class="stat-icon">📅</view>
|
||
<view class="stat-content">
|
||
<text class="stat-number">{{ stats.scheduled_visits }}</text>
|
||
<text class="stat-label">预约访问</text>
|
||
</view>
|
||
</view>
|
||
<view class="stat-card">
|
||
<view class="stat-icon">⏰</view>
|
||
<view class="stat-content">
|
||
<text class="stat-number">{{ stats.pending_approvals }}</text>
|
||
<text class="stat-label">待审核</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Filter Section -->
|
||
<view class="filters-section">
|
||
<view class="filter-row">
|
||
<view class="filter-group">
|
||
<text class="filter-label">访问状态</text>
|
||
<picker
|
||
:value="selectedStatusIndex"
|
||
:range="statusOptions"
|
||
range-key="label"
|
||
@change="onStatusChange"
|
||
>
|
||
<text class="picker-text">{{ selectedStatus?.label || '全部状态' }}</text>
|
||
</picker>
|
||
</view>
|
||
<view class="filter-group">
|
||
<text class="filter-label">患者筛选</text>
|
||
<picker
|
||
:value="selectedElderIndex"
|
||
:range="elderOptions"
|
||
range-key="name"
|
||
@change="onElderChange"
|
||
>
|
||
<text class="picker-text">{{ selectedElder?.name || '全部患者' }}</text>
|
||
</picker>
|
||
</view>
|
||
<view class="filter-group">
|
||
<text class="filter-label">时间范围</text>
|
||
<picker
|
||
:value="selectedTimeRangeIndex"
|
||
:range="timeRangeOptions"
|
||
range-key="label"
|
||
@change="onTimeRangeChange"
|
||
>
|
||
<text class="picker-text">{{ selectedTimeRange?.label || '今日' }}</text>
|
||
</picker>
|
||
</view>
|
||
</view>
|
||
<view class="filter-row">
|
||
<view class="search-group">
|
||
<input
|
||
class="search-input"
|
||
placeholder="搜索访客姓名或电话"
|
||
v-model="searchKeyword"
|
||
@input="onSearchInput"
|
||
/>
|
||
<button class="search-btn" @click="performSearch">
|
||
<text class="search-text">🔍</text>
|
||
</button>
|
||
</view>
|
||
<button class="refresh-btn" @click="refreshData">
|
||
<text class="refresh-text">🔄 刷新</text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Visitor List -->
|
||
<view class="visitors-section">
|
||
<view class="section-header">
|
||
<text class="section-title">访客记录 ({{ filteredVisits.length }})</text>
|
||
<view class="view-modes">
|
||
<button
|
||
class="mode-btn"
|
||
:class="{ active: viewMode === 'list' }"
|
||
@click="setViewMode('list')"
|
||
>
|
||
<text class="mode-text">📋</text>
|
||
</button>
|
||
<button
|
||
class="mode-btn"
|
||
:class="{ active: viewMode === 'calendar' }"
|
||
@click="setViewMode('calendar')"
|
||
>
|
||
<text class="mode-text">📅</text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- List View -->
|
||
<scroll-view
|
||
v-if="viewMode === 'list'"
|
||
class="visitors-list"
|
||
scroll-y="true"
|
||
:style="{ height: '500px' }"
|
||
>
|
||
<view
|
||
v-for="visit in filteredVisits"
|
||
:key="visit.id"
|
||
class="visit-item"
|
||
:class="{
|
||
'scheduled': visit.status === 'scheduled',
|
||
'in_progress': visit.status === 'in_progress',
|
||
'completed': visit.status === 'completed',
|
||
'cancelled': visit.status === 'cancelled'
|
||
}"
|
||
@click="viewVisitDetail(visit)"
|
||
>
|
||
<view class="visit-header">
|
||
<view class="visitor-info">
|
||
<text class="visitor-name">{{ visit.visitor_name }}</text>
|
||
<text class="visitor-relationship">{{ getRelationshipText(visit.relationship) }}</text>
|
||
</view>
|
||
<view class="visit-status">
|
||
<view class="status-badge" :class="visit.status">
|
||
<text class="status-text">{{ getStatusText(visit.status) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="visit-details">
|
||
<view class="detail-row">
|
||
<text class="detail-label">探访对象:</text>
|
||
<text class="detail-value">{{ visit.elder_name }}</text>
|
||
</view>
|
||
<view class="detail-row">
|
||
<text class="detail-label">访问时间:</text>
|
||
<text class="detail-value">{{ formatDateTime(visit.visit_date) }}</text>
|
||
</view>
|
||
<view class="detail-row" v-if="visit.duration">
|
||
<text class="detail-label">访问时长:</text>
|
||
<text class="detail-value">{{ visit.duration }} 分钟</text>
|
||
</view>
|
||
<view class="detail-row" v-if="visit.purpose">
|
||
<text class="detail-label">访问目的:</text>
|
||
<text class="detail-value">{{ visit.purpose }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="visit-actions">
|
||
<button
|
||
v-if="visit.status === 'scheduled'"
|
||
class="check-in-btn"
|
||
@click.stop="checkInVisitor(visit)"
|
||
>
|
||
<text class="btn-text">签到</text>
|
||
</button>
|
||
<button
|
||
v-if="visit.status === 'in_progress'"
|
||
class="check-out-btn"
|
||
@click.stop="checkOutVisitor(visit)"
|
||
>
|
||
<text class="btn-text">签退</text>
|
||
</button>
|
||
<button
|
||
v-if="visit.status === 'scheduled'"
|
||
class="cancel-btn"
|
||
@click.stop="cancelVisit(visit)"
|
||
>
|
||
<text class="btn-text">取消</text>
|
||
</button>
|
||
<button class="detail-btn" @click.stop="viewVisitDetail(visit)">
|
||
<text class="btn-text">详情</text>
|
||
</button>
|
||
</view>
|
||
|
||
<view v-if="visit.notes" class="visit-notes">
|
||
<text class="notes-text">备注: {{ visit.notes }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="filteredVisits.length === 0" class="empty-state">
|
||
<text class="empty-text">暂无访客记录</text>
|
||
<button class="add-btn" @click="showAddVisit">
|
||
<text class="btn-text">添加访客</text>
|
||
</button>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- Calendar View -->
|
||
<view v-if="viewMode === 'calendar'" class="calendar-section">
|
||
<view class="calendar-header">
|
||
<button class="nav-btn" @click="previousMonth">
|
||
<text class="nav-text">‹</text>
|
||
</button>
|
||
<text class="calendar-title">{{ currentMonthText }}</text>
|
||
<button class="nav-btn" @click="nextMonth">
|
||
<text class="nav-text">›</text>
|
||
</button>
|
||
</view>
|
||
<view class="calendar-grid">
|
||
<view class="calendar-weekdays">
|
||
<text class="weekday">日</text>
|
||
<text class="weekday">一</text>
|
||
<text class="weekday">二</text>
|
||
<text class="weekday">三</text>
|
||
<text class="weekday">四</text>
|
||
<text class="weekday">五</text>
|
||
<text class="weekday">六</text>
|
||
</view>
|
||
<view class="calendar-days">
|
||
<view
|
||
v-for="day in calendarDays"
|
||
:key="day.date"
|
||
class="calendar-day"
|
||
:class="{
|
||
'other-month': !day.currentMonth,
|
||
'today': day.isToday,
|
||
'has-visits': day.visitCount > 0
|
||
}"
|
||
@click="selectDate(day)"
|
||
>
|
||
<text class="day-number">{{ day.day }}</text>
|
||
<view v-if="day.visitCount > 0" class="visit-indicator">
|
||
<text class="visit-count">{{ day.visitCount }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Current Visitors -->
|
||
<view class="current-visitors-section" v-if="currentVisitors.length > 0">
|
||
<view class="section-header">
|
||
<text class="section-title">当前在院访客 ({{ currentVisitors.length }})</text>
|
||
<button class="view-all-btn" @click="showAllCurrentVisitors">
|
||
<text class="btn-text">查看全部</text>
|
||
</button>
|
||
</view>
|
||
<scroll-view class="current-visitors-list" scroll-y="true" :style="{ height: '200px' }">
|
||
<view
|
||
v-for="visitor in currentVisitors"
|
||
:key="visitor.id"
|
||
class="current-visitor-item"
|
||
@click="viewVisitDetail(visitor)"
|
||
>
|
||
<view class="visitor-avatar">
|
||
<text class="avatar-text">{{ getVisitorInitial(visitor.visitor_name) }}</text>
|
||
</view>
|
||
<view class="visitor-info">
|
||
<text class="visitor-name">{{ visitor.visitor_name }}</text>
|
||
<text class="visitor-patient">探访: {{ visitor.elder_name }}</text>
|
||
<text class="visitor-time">签到: {{ formatTime(visitor.check_in_time) }}</text>
|
||
</view>
|
||
<view class="visitor-actions">
|
||
<button class="quick-checkout-btn" @click.stop="checkOutVisitor(visitor)">
|
||
<text class="btn-text">签退</text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<!-- Scheduled Visits Today -->
|
||
<view class="scheduled-visits-section" v-if="todayScheduledVisits.length > 0">
|
||
<view class="section-header">
|
||
<text class="section-title">今日预约访问 ({{ todayScheduledVisits.length }})</text>
|
||
<button class="view-all-btn" @click="showTodaySchedule">
|
||
<text class="btn-text">查看全部</text>
|
||
</button>
|
||
</view>
|
||
<scroll-view class="scheduled-visits-list" scroll-y="true" :style="{ height: '200px' }">
|
||
<view
|
||
v-for="visit in todayScheduledVisits"
|
||
:key="visit.id"
|
||
class="scheduled-visit-item"
|
||
:class="{ 'overdue': isVisitOverdue(visit) }"
|
||
@click="viewVisitDetail(visit)"
|
||
>
|
||
<view class="visit-time">
|
||
<text class="time-text">{{ formatTime(visit.start_time) }}</text>
|
||
<text class="duration-text">{{ visit.estimated_duration }}分钟</text>
|
||
</view>
|
||
<view class="visit-info">
|
||
<text class="visitor-name">{{ visit.visitor_name }}</text>
|
||
<text class="patient-name">{{ visit.elder_name }}</text>
|
||
<text class="relationship">{{ getRelationshipText(visit.relationship) }}</text>
|
||
</view>
|
||
<view class="visit-actions">
|
||
<button class="checkin-btn" @click.stop="checkInVisitor(visit)">
|
||
<text class="btn-text">签到</text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import {
|
||
formatTime,
|
||
formatDate,
|
||
formatDateTime,
|
||
getTodayStart,
|
||
getTodayEnd,
|
||
getRecentDate,
|
||
getDaysAgo
|
||
} from '../types_new.uts'
|
||
|
||
// 数据类型定义
|
||
type VisitorStats = {
|
||
today_visitors: number
|
||
current_visitors: number
|
||
scheduled_visits: number
|
||
pending_approvals: number
|
||
}
|
||
|
||
type Visit = {
|
||
id: string
|
||
elder_id: string
|
||
elder_name: string
|
||
visitor_name: string
|
||
visitor_phone: string
|
||
relationship: string
|
||
visit_date: string
|
||
start_time: string
|
||
end_time: string
|
||
duration: number
|
||
purpose: string
|
||
status: string
|
||
check_in_time: string
|
||
check_out_time: string
|
||
notes: string
|
||
approved_by: string
|
||
created_at: string
|
||
estimated_duration: number | null
|
||
}
|
||
|
||
type Elder = {
|
||
id: string
|
||
name: string
|
||
room_number: string
|
||
bed_number: string
|
||
}
|
||
|
||
type FilterOption = {
|
||
value: string
|
||
label: string
|
||
}
|
||
|
||
type CalendarDay = {
|
||
date: string
|
||
day: number
|
||
currentMonth: boolean
|
||
isToday: boolean
|
||
visitCount: number
|
||
}
|
||
|
||
// 响应式数据
|
||
const stats = ref<VisitorStats>({
|
||
today_visitors: 0,
|
||
current_visitors: 0,
|
||
scheduled_visits: 0,
|
||
pending_approvals: 0
|
||
})
|
||
|
||
const visits = ref<Array<Visit>>([])
|
||
const currentVisitors = ref<Array<Visit>>([])
|
||
const todayScheduledVisits = ref<Array<Visit>>([])
|
||
const elders = ref<Array<Elder>>([])
|
||
|
||
const selectedStatusIndex = ref<number>(-1)
|
||
const selectedElderIndex = ref<number>(-1)
|
||
const selectedTimeRangeIndex = ref<number>(0)
|
||
const searchKeyword = ref<string>('')
|
||
const viewMode = ref<string>('list')
|
||
const currentDate = ref<Date>(new Date())
|
||
|
||
// 筛选选项
|
||
const statusOptions = ref<Array<FilterOption>>([
|
||
{ value: 'all', label: '全部状态' },
|
||
{ value: 'scheduled', label: '已预约' },
|
||
{ value: 'in_progress', label: '进行中' },
|
||
{ value: 'completed', label: '已完成' },
|
||
{ value: 'cancelled', label: '已取消' }
|
||
])
|
||
|
||
const timeRangeOptions = ref<Array<FilterOption>>([
|
||
{ value: 'today', label: '今日' },
|
||
{ value: '3days', label: '近3天' },
|
||
{ value: '7days', label: '近7天' },
|
||
{ value: '30days', label: '近30天' }
|
||
])
|
||
|
||
// 计算属性
|
||
const elderOptions = computed<Array<Elder>>(() => {
|
||
return [{ id: 'all', name: '全部患者', room_number: '', bed_number: '' } as Elder, ...elders.value]
|
||
})
|
||
|
||
const selectedStatus = computed<FilterOption | null>(() => {
|
||
if (selectedStatusIndex.value < 0 || selectedStatusIndex.value >= statusOptions.value.length) {
|
||
return null
|
||
}
|
||
return statusOptions.value[selectedStatusIndex.value]
|
||
})
|
||
|
||
const selectedElder = computed<Elder | null>(() => {
|
||
if (selectedElderIndex.value < 0 || selectedElderIndex.value >= elderOptions.value.length) {
|
||
return null
|
||
}
|
||
return elderOptions.value[selectedElderIndex.value]
|
||
})
|
||
|
||
const selectedTimeRange = computed<FilterOption | null>(() => {
|
||
if (selectedTimeRangeIndex.value < 0 || selectedTimeRangeIndex.value >= timeRangeOptions.value.length) {
|
||
return null
|
||
}
|
||
return timeRangeOptions.value[selectedTimeRangeIndex.value]
|
||
})
|
||
|
||
const filteredVisits = computed<Array<Visit>>(() => {
|
||
let filtered = visits.value
|
||
|
||
// 按状态筛选
|
||
if (selectedStatus.value !== null && selectedStatus.value.value !== 'all') {
|
||
filtered = filtered.filter(visit => visit.status === selectedStatus.value!.value)
|
||
}
|
||
|
||
// 按患者筛选
|
||
if (selectedElder.value !== null && selectedElder.value.id !== 'all') {
|
||
filtered = filtered.filter(visit => visit.elder_id === selectedElder.value!.id)
|
||
}
|
||
|
||
// 按时间范围筛选
|
||
if (selectedTimeRange.value !== null) {
|
||
const now = new Date()
|
||
let startDate: string
|
||
switch (selectedTimeRange.value.value) {
|
||
case 'today':
|
||
startDate = getTodayStart()
|
||
break
|
||
case '3days':
|
||
startDate = getDaysAgo(3)
|
||
break
|
||
case '7days':
|
||
startDate = getDaysAgo(7)
|
||
break
|
||
case '30days':
|
||
startDate = getDaysAgo(30)
|
||
break
|
||
default:
|
||
startDate = getTodayStart()
|
||
}
|
||
filtered = filtered.filter(visit => visit.visit_date >= startDate)
|
||
}
|
||
|
||
// 搜索关键词
|
||
if (searchKeyword.value.trim() !== '') {
|
||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||
filtered = filtered.filter(visit =>
|
||
visit.visitor_name.toLowerCase().includes(keyword) ||
|
||
visit.visitor_phone.includes(keyword) ||
|
||
visit.elder_name.toLowerCase().includes(keyword)
|
||
)
|
||
}
|
||
|
||
return filtered.sort((a, b) => new Date(b.visit_date).getTime() - new Date(a.visit_date).getTime())
|
||
})
|
||
|
||
const currentMonthText = computed<string>(() => {
|
||
const year = currentDate.value.getFullYear()
|
||
const month = currentDate.value.getMonth() + 1
|
||
return `${year}年${month}月`
|
||
})
|
||
|
||
const calendarDays = computed<Array<CalendarDay>>(() => {
|
||
const year = currentDate.value.getFullYear()
|
||
const month = currentDate.value.getMonth()
|
||
const today = new Date()
|
||
|
||
// 获取当月第一天
|
||
const firstDay = new Date(year, month, 1)
|
||
const startDate = new Date(firstDay)
|
||
startDate.setDate(startDate.getDate() - firstDay.getDay())
|
||
|
||
// 生成42天的日历
|
||
const days: Array<CalendarDay> = []
|
||
for (let i: Int = 0; i < 42; i++) {
|
||
const date = new Date(startDate)
|
||
date.setDate(startDate.getDate() + i)
|
||
|
||
const visitCount = visits.value.filter(visit => {
|
||
const visitDate = new Date(visit.visit_date)
|
||
return visitDate.toDateString() === date.toDateString()
|
||
}).length
|
||
|
||
days.push({
|
||
date: date.toISOString().split('T')[0],
|
||
day: date.getDate(),
|
||
currentMonth: date.getMonth() === month,
|
||
isToday: date.toDateString() === today.toDateString(),
|
||
visitCount
|
||
})
|
||
}
|
||
|
||
return days
|
||
})
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
loadData()
|
||
})
|
||
|
||
// 加载数据
|
||
const loadData = async () => {
|
||
await Promise.all([
|
||
loadStats(),
|
||
loadVisits(),
|
||
loadCurrentVisitors(),
|
||
loadTodayScheduledVisits(),
|
||
loadElders()
|
||
])
|
||
}
|
||
|
||
// 加载统计数据
|
||
const loadStats = async () => {
|
||
try {
|
||
// 今日访客数
|
||
const todayResult = await supa
|
||
.from('ec_visits')
|
||
.select('*', { count: 'exact' })
|
||
.gte('visit_date', getTodayStart())
|
||
.lte('visit_date', getTodayEnd())
|
||
.executeAs<Visit[]>()
|
||
|
||
// 当前在院访客数
|
||
const currentResult = await supa
|
||
.from('ec_visits')
|
||
.select('*', { count: 'exact' })
|
||
.eq('status', 'in_progress')
|
||
.executeAs<Visit[]>()
|
||
|
||
// 预约访问数
|
||
const scheduledResult = await supa
|
||
.from('ec_visits')
|
||
.select('*', { count: 'exact' })
|
||
.eq('status', 'scheduled')
|
||
.gte('visit_date', getTodayStart())
|
||
.executeAs<Visit[]>()
|
||
|
||
// 待审核数
|
||
const pendingResult = await supa
|
||
.from('ec_visits')
|
||
.select('*', { count: 'exact' })
|
||
.eq('status', 'pending_approval')
|
||
.executeAs<Visit[]>()
|
||
|
||
stats.value = {
|
||
today_visitors: todayResult.count ?? 0,
|
||
current_visitors: currentResult.count ?? 0,
|
||
scheduled_visits: scheduledResult.count ?? 0,
|
||
pending_approvals: pendingResult.count ?? 0
|
||
}
|
||
} catch (error) {
|
||
console.error('加载统计数据失败:', error)
|
||
}
|
||
}
|
||
|
||
// 加载访客记录
|
||
const loadVisits = async () => {
|
||
try {
|
||
const result = await supa
|
||
.from('ec_visits')
|
||
.select(`
|
||
id,
|
||
elder_id,
|
||
elder_name,
|
||
visitor_name,
|
||
visitor_phone,
|
||
relationship,
|
||
visit_date,
|
||
start_time,
|
||
end_time,
|
||
duration,
|
||
purpose,
|
||
status,
|
||
check_in_time,
|
||
check_out_time,
|
||
notes,
|
||
approved_by,
|
||
created_at
|
||
`)
|
||
.gte('visit_date', getDaysAgo(30))
|
||
.order('visit_date', { ascending: false })
|
||
.limit(200)
|
||
.executeAs<Visit[]>()
|
||
|
||
if (result.error == null && result.data != null) {
|
||
visits.value = result.data
|
||
}
|
||
} catch (error) {
|
||
console.error('加载访客记录失败:', error)
|
||
}
|
||
}
|
||
|
||
// 加载当前访客
|
||
const loadCurrentVisitors = async () => {
|
||
try {
|
||
const result = await supa
|
||
.from('ec_visits')
|
||
.select(`
|
||
id,
|
||
elder_id,
|
||
elder_name,
|
||
visitor_name,
|
||
visitor_phone,
|
||
relationship,
|
||
visit_date,
|
||
start_time,
|
||
check_in_time,
|
||
status
|
||
`)
|
||
.eq('status', 'in_progress')
|
||
.order('check_in_time', { ascending: false })
|
||
.executeAs<Array<Visit>>()
|
||
|
||
if (result.error === null && result.data !== null) {
|
||
currentVisitors.value = result.data
|
||
}
|
||
} catch (error) {
|
||
console.error('加载当前访客失败:', error)
|
||
}
|
||
}
|
||
|
||
// 加载今日预约访问
|
||
const loadTodayScheduledVisits = async () => {
|
||
try {
|
||
const result = await supa
|
||
.from('ec_visits')
|
||
.select(`
|
||
id,
|
||
elder_id,
|
||
elder_name,
|
||
visitor_name,
|
||
visitor_phone,
|
||
relationship,
|
||
visit_date,
|
||
start_time,
|
||
estimated_duration,
|
||
status
|
||
`)
|
||
.eq('status', 'scheduled')
|
||
.gte('visit_date', getTodayStart())
|
||
.lte('visit_date', getTodayEnd())
|
||
.order('start_time', { ascending: true })
|
||
.executeAs<Array<Visit>>()
|
||
|
||
if (result.error === null && result.data !== null) {
|
||
todayScheduledVisits.value = result.data
|
||
}
|
||
} catch (error) {
|
||
console.error('加载今日预约访问失败:', error)
|
||
}
|
||
}
|
||
|
||
// 加载患者列表
|
||
const loadElders = async () => {
|
||
try {
|
||
const result = await supa
|
||
.from('ec_elders')
|
||
.select(`
|
||
id,
|
||
name,
|
||
room_number,
|
||
bed_number
|
||
`)
|
||
.eq('status', 'active')
|
||
.order('room_number', { ascending: true })
|
||
.executeAs<Elder[]>()
|
||
|
||
if (result.error == null && result.data != null) {
|
||
elders.value = result.data
|
||
}
|
||
} catch (error) {
|
||
console.error('加载患者列表失败:', error)
|
||
}
|
||
}
|
||
|
||
// 辅助函数
|
||
const getStatusText = (status: string): string => {
|
||
const statusMap = new Map([
|
||
['scheduled', '已预约'],
|
||
['in_progress', '进行中'],
|
||
['completed', '已完成'],
|
||
['cancelled', '已取消'],
|
||
['pending_approval', '待审核']
|
||
])
|
||
return statusMap.get(status) ?? status
|
||
}
|
||
|
||
const getRelationshipText = (relationship: string): string => {
|
||
const relationshipMap = new Map([
|
||
['spouse', '配偶'],
|
||
['child', '子女'],
|
||
['parent', '父母'],
|
||
['sibling', '兄弟姐妹'],
|
||
['relative', '亲戚'],
|
||
['friend', '朋友'],
|
||
['other', '其他']
|
||
])
|
||
return relationshipMap.get(relationship) ?? relationship
|
||
}
|
||
|
||
const getVisitorInitial = (name: string): string => {
|
||
return name.charAt(0).toUpperCase()
|
||
}
|
||
|
||
const isVisitOverdue = (visit: Visit): boolean => {
|
||
const now = new Date()
|
||
const visitTime = new Date(`${visit.visit_date}T${visit.start_time}`)
|
||
return now > visitTime && visit.status === 'scheduled'
|
||
}
|
||
|
||
// 事件处理
|
||
const refreshData = () => {
|
||
loadData()
|
||
uni.showToast({ title: '数据已刷新', icon: 'success' })
|
||
}
|
||
|
||
const onStatusChange = (e: any) => {
|
||
selectedStatusIndex.value = e.detail.value
|
||
}
|
||
|
||
const onElderChange = (e: any) => {
|
||
selectedElderIndex.value = e.detail.value
|
||
}
|
||
|
||
const onTimeRangeChange = (e: any) => {
|
||
selectedTimeRangeIndex.value = e.detail.value
|
||
}
|
||
|
||
const onSearchInput = (e: any) => {
|
||
searchKeyword.value = e.detail.value
|
||
}
|
||
|
||
const performSearch = () => {
|
||
// 搜索逻辑在计算属性中处理
|
||
}
|
||
|
||
const setViewMode = (mode: string) => {
|
||
viewMode.value = mode
|
||
}
|
||
|
||
const previousMonth = () => {
|
||
const date = new Date(currentDate.value)
|
||
date.setMonth(date.getMonth() - 1)
|
||
currentDate.value = date
|
||
}
|
||
|
||
const nextMonth = () => {
|
||
const date = new Date(currentDate.value)
|
||
date.setMonth(date.getMonth() + 1)
|
||
currentDate.value = date
|
||
}
|
||
|
||
const selectDate = (day: CalendarDay) => {
|
||
if (day.visitCount > 0) {
|
||
// 显示该日期的访客记录
|
||
uni.navigateTo({
|
||
url: `/pages/ec/visitor/daily-visits?date=${day.date}`
|
||
})
|
||
}
|
||
}
|
||
|
||
const showAddVisit = () => {
|
||
uni.navigateTo({ url: '/pages/ec/visitor/add-visit' })
|
||
}
|
||
|
||
const showPreregistration = () => {
|
||
uni.navigateTo({ url: '/pages/ec/visitor/preregistration' })
|
||
}
|
||
|
||
const showAllCurrentVisitors = () => {
|
||
uni.navigateTo({ url: '/pages/ec/visitor/current-visitors' })
|
||
}
|
||
|
||
const showTodaySchedule = () => {
|
||
uni.navigateTo({ url: '/pages/ec/visitor/today-schedule' })
|
||
}
|
||
|
||
const viewVisitDetail = (visit: Visit) => {
|
||
uni.navigateTo({
|
||
url: `/pages/ec/visitor/visit-detail?id=${visit.id}`
|
||
})
|
||
}
|
||
|
||
const checkInVisitor = async (visit: Visit) => {
|
||
try {
|
||
const now = new Date().toISOString()
|
||
await supa
|
||
.from('ec_visits')
|
||
.update({
|
||
status: 'in_progress',
|
||
check_in_time: now
|
||
})
|
||
.eq('id', visit.id)
|
||
.executeAs<any>()
|
||
|
||
// 更新本地状态
|
||
let visitIndex = -1
|
||
for (let i: Int = 0; i < visits.value.length; i++) {
|
||
if (visits.value[i].id === visit.id) {
|
||
visitIndex = i
|
||
break
|
||
}
|
||
}
|
||
if (visitIndex >= 0) {
|
||
visits.value[visitIndex].status = 'in_progress'
|
||
visits.value[visitIndex].check_in_time = now
|
||
}
|
||
|
||
// 更新统计
|
||
stats.value.current_visitors++
|
||
|
||
uni.showToast({ title: '访客签到成功', icon: 'success' })
|
||
await loadCurrentVisitors()
|
||
} catch (error) {
|
||
console.error('访客签到失败:', error)
|
||
uni.showToast({ title: '签到失败', icon: 'error' })
|
||
}
|
||
}
|
||
|
||
const checkOutVisitor = async (visit: Visit) => {
|
||
try {
|
||
const now = new Date().toISOString()
|
||
const checkInTime = new Date(visit.check_in_time)
|
||
const checkOutTime = new Date(now)
|
||
const duration = Math.round((checkOutTime.getTime() - checkInTime.getTime()) / 60000) // 分钟
|
||
|
||
await supa
|
||
.from('ec_visits')
|
||
.update({
|
||
status: 'completed',
|
||
check_out_time: now,
|
||
duration: duration
|
||
})
|
||
.eq('id', visit.id)
|
||
.executeAs<any>()
|
||
|
||
// 更新本地状态
|
||
let visitIndex = -1
|
||
for (let i: Int = 0; i < visits.value.length; i++) {
|
||
if (visits.value[i].id === visit.id) {
|
||
visitIndex = i
|
||
break
|
||
}
|
||
}
|
||
if (visitIndex >= 0) {
|
||
visits.value[visitIndex].status = 'completed'
|
||
visits.value[visitIndex].check_out_time = now
|
||
visits.value[visitIndex].duration = duration
|
||
}
|
||
|
||
// 更新统计
|
||
stats.value.current_visitors = Math.max(0, stats.value.current_visitors - 1)
|
||
|
||
uni.showToast({ title: '访客签退成功', icon: 'success' })
|
||
await loadCurrentVisitors()
|
||
} catch (error) {
|
||
console.error('访客签退失败:', error)
|
||
uni.showToast({ title: '签退失败', icon: 'error' })
|
||
}
|
||
}
|
||
|
||
const cancelVisit = async (visit: Visit) => {
|
||
try {
|
||
await supa
|
||
.from('ec_visits')
|
||
.update({ status: 'cancelled' })
|
||
.eq('id', visit.id)
|
||
.executeAs<any>()
|
||
|
||
// 更新本地状态
|
||
let visitIndex = -1
|
||
for (let i: Int = 0; i < visits.value.length; i++) {
|
||
if (visits.value[i].id === visit.id) {
|
||
visitIndex = i
|
||
break
|
||
}
|
||
}
|
||
if (visitIndex >= 0) {
|
||
visits.value[visitIndex].status = 'cancelled'
|
||
}
|
||
|
||
uni.showToast({ title: '访问已取消', icon: 'success' })
|
||
} catch (error) {
|
||
console.error('取消访问失败:', error)
|
||
uni.showToast({ title: '取消失败', icon: 'error' })
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss">
|
||
/* uts-android 兼容性重构:
|
||
1. 移除所有嵌套选择器、伪类(如 :last-child、&.xxx),全部 class 扁平化。
|
||
2. 所有间距用 margin-right/margin-bottom 控制,禁止 gap、flex-wrap、嵌套。
|
||
3. 所有布局 display: flex,禁止 grid、gap、伪类。
|
||
4. 组件间距、分隔线全部用 border/margin 控制。
|
||
5. 新增.is-last、.is-active、.is-overdue、.is-today、.has-visits 等辅助 class。
|
||
*/
|
||
.visitor-management {
|
||
padding: 20px;
|
||
background: #f5f5f5;
|
||
min-height: 600px;
|
||
}
|
||
.header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
.header-title {
|
||
font-size: 22px;
|
||
font-weight: bold;
|
||
}
|
||
.header-actions {
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
.action-btn {
|
||
padding: 8px 16px;
|
||
border-radius: 20px;
|
||
border: 1px solid #1890ff;
|
||
background-color: #1890ff;
|
||
color: white;
|
||
margin-right: 10px;
|
||
}
|
||
.action-btn.is-last {
|
||
margin-right: 0;
|
||
}
|
||
.stats-section {
|
||
display: flex;
|
||
flex-direction: row;
|
||
margin-bottom: 20px;
|
||
}
|
||
.stat-card {
|
||
flex: 1;
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
margin-right: 15px;
|
||
padding: 20px;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
.stat-card.is-last {
|
||
margin-right: 0;
|
||
}
|
||
.stat-icon {
|
||
font-size: 24px;
|
||
margin-right: 10px;
|
||
}
|
||
.stat-content {
|
||
flex: 1;
|
||
}
|
||
.stat-number {
|
||
font-size: 20px;
|
||
font-weight: bold;
|
||
}
|
||
.stat-label {
|
||
font-size: 14px;
|
||
color: #666;
|
||
}
|
||
.filters-section {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.filter-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
.filter-row.is-last {
|
||
margin-bottom: 0;
|
||
}
|
||
.filter-group {
|
||
flex: 1;
|
||
margin-right: 15px;
|
||
}
|
||
.filter-group.is-last {
|
||
margin-right: 0;
|
||
}
|
||
.search-group {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
flex: 1;
|
||
margin-right: 15px;
|
||
}
|
||
.search-group.is-last {
|
||
margin-right: 0;
|
||
}
|
||
.search-input {
|
||
flex: 1;
|
||
border: 1px solid #ddd;
|
||
border-radius: 8px;
|
||
padding: 8px 12px;
|
||
font-size: 14px;
|
||
margin-right: 10px;
|
||
}
|
||
.search-btn {
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
background: #1890ff;
|
||
color: white;
|
||
border: none;
|
||
}
|
||
.refresh-btn {
|
||
padding: 8px 16px;
|
||
border-radius: 20px;
|
||
border: 1px solid #52c41a;
|
||
background-color: #52c41a;
|
||
color: white;
|
||
}
|
||
.visitors-section {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
margin-bottom: 20px;
|
||
padding: 16px;
|
||
}
|
||
.section-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 15px;
|
||
}
|
||
.section-title {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
.view-modes {
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
.mode-btn {
|
||
padding: 6px 12px;
|
||
border-radius: 15px;
|
||
border: 1px solid #ddd;
|
||
background-color: #fff;
|
||
color: #666;
|
||
margin-right: 8px;
|
||
}
|
||
.mode-btn.is-active {
|
||
background-color: #1890ff;
|
||
color: #fff;
|
||
border-color: #1890ff;
|
||
}
|
||
.mode-btn.is-last {
|
||
margin-right: 0;
|
||
}
|
||
.mode-text {
|
||
font-size: 14px;
|
||
}
|
||
.visitors-list {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
min-height: 300px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.visit-item {
|
||
padding: 16px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
.visit-item.is-last {
|
||
border-bottom: none;
|
||
}
|
||
.visit-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
.visitor-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
.visitor-name {
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-right: 10px;
|
||
}
|
||
.visitor-relationship {
|
||
font-size: 14px;
|
||
color: #1890ff;
|
||
}
|
||
.visit-status {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.status-badge {
|
||
padding: 4px 10px;
|
||
border-radius: 12px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
background-color: #f5f5f5;
|
||
color: #999;
|
||
}
|
||
.status-badge.scheduled {
|
||
background-color: #e6f7ff;
|
||
color: #1890ff;
|
||
}
|
||
.status-badge.in_progress {
|
||
background-color: #f6ffed;
|
||
color: #52c41a;
|
||
}
|
||
.status-badge.completed {
|
||
background-color: #f5f5f5;
|
||
color: #999;
|
||
}
|
||
.status-badge.cancelled {
|
||
background-color: #fff2f0;
|
||
color: #ff4d4f;
|
||
}
|
||
.status-text {
|
||
font-weight: 500;
|
||
}
|
||
.visit-details {
|
||
margin-bottom: 15px;
|
||
}
|
||
.detail-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
.detail-row.is-last {
|
||
margin-bottom: 0;
|
||
}
|
||
.detail-label {
|
||
font-size: 14px;
|
||
color: #666;
|
||
width: 80px;
|
||
flex-shrink: 0;
|
||
}
|
||
.detail-value {
|
||
font-size: 14px;
|
||
color: #333;
|
||
flex: 1;
|
||
}
|
||
.visit-actions {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
.check-in-btn, .check-out-btn, .cancel-btn, .detail-btn {
|
||
padding: 6px 12px;
|
||
border-radius: 15px;
|
||
border: none;
|
||
font-size: 12px;
|
||
color: white;
|
||
margin-right: 8px;
|
||
}
|
||
.check-in-btn {
|
||
background-color: #52c41a;
|
||
}
|
||
.check-out-btn {
|
||
background-color: #1890ff;
|
||
}
|
||
.cancel-btn {
|
||
background-color: #ff4d4f;
|
||
}
|
||
.detail-btn {
|
||
background-color: #d9d9d9;
|
||
color: #666;
|
||
}
|
||
.btn-text {
|
||
color: inherit;
|
||
}
|
||
.visit-notes {
|
||
font-size: 14px;
|
||
color: #666;
|
||
font-style: italic;
|
||
}
|
||
.empty-state {
|
||
padding: 60px 20px;
|
||
text-align: center;
|
||
}
|
||
.empty-text {
|
||
font-size: 16px;
|
||
color: #999;
|
||
margin-bottom: 20px;
|
||
display: block;
|
||
}
|
||
.add-btn {
|
||
padding: 12px 24px;
|
||
border-radius: 20px;
|
||
border: none;
|
||
background-color: #1890ff;
|
||
color: white;
|
||
font-size: 14px;
|
||
}
|
||
</style>
|