Initial commit of akmon project
This commit is contained in:
0
pages/ec/admin/all-service-records.uvue
Normal file
0
pages/ec/admin/all-service-records.uvue
Normal file
303
pages/ec/admin/care-records.uvue
Normal file
303
pages/ec/admin/care-records.uvue
Normal file
@@ -0,0 +1,303 @@
|
||||
<!-- 服务记录页面 - uts-android 兼容版 -->
|
||||
<template>
|
||||
<view class="service-records">
|
||||
<view class="header">
|
||||
<text class="header-title">服务记录</text>
|
||||
<button class="refresh-btn" @click="refreshData">
|
||||
<text class="refresh-text">🔄 刷新</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="filters-section">
|
||||
<view class="filter-row">
|
||||
<view class="filter-group">
|
||||
<text class="filter-label">老人</text>
|
||||
<button class="picker-btn" @click="showElderActionSheet">
|
||||
<text class="picker-text">{{ selectedElder?.name ?? '全部' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="filter-group">
|
||||
<text class="filter-label">服务类型</text>
|
||||
<button class="picker-btn" @click="showTypeActionSheet">
|
||||
<text class="picker-text">{{ selectedType?.label ?? '全部' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="filter-group">
|
||||
<text class="filter-label">时间范围</text>
|
||||
<button class="picker-btn" @click="showTimeRangeActionSheet">
|
||||
<text class="picker-text">{{ selectedTimeRange?.label ?? '近7天' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="records-list" direction="vertical" :style="{ height: '500px' }">
|
||||
<view v-for="record in filteredRecords" :key="record.id" class="record-item" @click="viewDetail(record)">
|
||||
<view class="record-header">
|
||||
<text class="elder-name">{{ record.elder_name ?? '未知' }}</text>
|
||||
<text class="service-type">{{ record.service_type ?? '未知类型' }}</text>
|
||||
<text class="record-time">{{ formatDateTime(record.created_at ?? '') }}</text>
|
||||
</view>
|
||||
<view class="record-content">
|
||||
<text class="caregiver">护理员: {{ record.caregiver_name ?? '未分配' }}</text>
|
||||
<text class="notes" v-if="record.notes">备注: {{ record.notes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="filteredRecords.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无服务记录</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { formatDateTime as formatDateTimeUtil } from '../types.uts'
|
||||
|
||||
type ServiceRecord = {
|
||||
id: string
|
||||
task_id: string | null
|
||||
elder_id: string
|
||||
caregiver_id: string
|
||||
elder_name?: string
|
||||
caregiver_name?: string
|
||||
start_time: string | null
|
||||
end_time: string | null
|
||||
actual_duration: number | null
|
||||
care_content: string | null
|
||||
elder_condition: string | null
|
||||
issues_notes: string | null
|
||||
photo_urls: string[] | null
|
||||
status: string
|
||||
rating: number | null
|
||||
supervisor_notes: string | null
|
||||
created_at: string
|
||||
}
|
||||
type Elder = { id: string, name: string }
|
||||
type FilterOption = { value: string, label: string }
|
||||
|
||||
const records = ref<ServiceRecord[]>([])
|
||||
const elders = ref<Elder[]>([])
|
||||
const selectedElderIndex = ref<number>(-1)
|
||||
const selectedTypeIndex = ref<number>(-1)
|
||||
const selectedTimeRangeIndex = ref<number>(1)
|
||||
|
||||
const typeOptions = ref<FilterOption[]>([
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'nursing', label: '护理' },
|
||||
{ value: 'meal', label: '餐饮' },
|
||||
{ value: 'activity', label: '活动' },
|
||||
{ value: 'cleaning', label: '清洁' }
|
||||
])
|
||||
const timeRangeOptions = ref<FilterOption[]>([
|
||||
{ value: '3days', label: '近3天' },
|
||||
{ value: '7days', label: '近7天' },
|
||||
{ value: '30days', label: '近30天' }
|
||||
])
|
||||
|
||||
const elderOptions = computed<Elder[]>(() => [ { id: 'all', name: '全部' }, ...elders.value ])
|
||||
const selectedElder = computed(() => elderOptions.value[selectedElderIndex.value] ?? elderOptions.value[0])
|
||||
const selectedType = computed(() => typeOptions.value[selectedTypeIndex.value] ?? typeOptions.value[0])
|
||||
const selectedTimeRange = computed(() => timeRangeOptions.value[selectedTimeRangeIndex.value] ?? timeRangeOptions.value[1])
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
let list = records.value
|
||||
if (selectedElder.value.id !== 'all') {
|
||||
list = list.filter(r => r.elder_id === selectedElder.value.id)
|
||||
}
|
||||
if (selectedType.value.value !== 'all') {
|
||||
list = list.filter(r => r.service_type === selectedType.value.value)
|
||||
}
|
||||
// 时间范围
|
||||
const now = new Date()
|
||||
let startDate = new Date()
|
||||
if (selectedTimeRange.value.value === '3days') startDate.setDate(now.getDate() - 3)
|
||||
else if (selectedTimeRange.value.value === '7days') startDate.setDate(now.getDate() - 7)
|
||||
else if (selectedTimeRange.value.value === '30days') startDate.setDate(now.getDate() - 30)
|
||||
list = list.filter(r => r.created_at >= startDate.toISOString())
|
||||
return list
|
||||
})
|
||||
|
||||
const formatDateTime = (dt: string) => formatDateTimeUtil(dt)
|
||||
|
||||
const refreshData = () => { loadRecords(); loadElders(); }
|
||||
|
||||
const loadRecords = async () => {
|
||||
try {
|
||||
const result = await supa
|
||||
.from('ec_care_records')
|
||||
.select('id, elder_id, ec_care_records_elder_id_fkey(name), record_type, ec_care_records_caregiver_id_fkey(username), created_at,issues_notes, supervisor_notes', {})
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100)
|
||||
.executeAs<ServiceRecord[]>()
|
||||
if (result.error == null && result.data != null) {
|
||||
records.value = result.data
|
||||
}
|
||||
} catch (e) { console.error('加载服务记录失败', e) }
|
||||
}
|
||||
const loadElders = async () => {
|
||||
try {
|
||||
const result = await supa
|
||||
.from('ec_elders')
|
||||
.select('id, name', {})
|
||||
.eq('status', 'active')
|
||||
.order('name', { ascending: true })
|
||||
.executeAs<Elder[]>()
|
||||
if (result.error == null && result.data != null) {
|
||||
elders.value = result.data
|
||||
}
|
||||
} catch (e) { console.error('加载老人列表失败', e) }
|
||||
}
|
||||
const showElderActionSheet = () => {
|
||||
const options = elderOptions.value.map(e => e.name)
|
||||
uni.showActionSheet({
|
||||
itemList: options,
|
||||
success: (res:any) => { selectedElderIndex.value = res.tapIndex }
|
||||
})
|
||||
}
|
||||
const showTypeActionSheet = () => {
|
||||
const options = typeOptions.value.map(t => t.label)
|
||||
uni.showActionSheet({
|
||||
itemList: options,
|
||||
success: (res:any) => { selectedTypeIndex.value = res.tapIndex }
|
||||
})
|
||||
}
|
||||
const showTimeRangeActionSheet = () => {
|
||||
const options = timeRangeOptions.value.map(t => t.label)
|
||||
uni.showActionSheet({
|
||||
itemList: options,
|
||||
success: (res:any) => { selectedTimeRangeIndex.value = res.tapIndex }
|
||||
})
|
||||
}
|
||||
const viewDetail = (record: ServiceRecord) => {
|
||||
uni.navigateTo({ url: `/pages/ec/admin/service-record-detail?id=${record.id}` })
|
||||
}
|
||||
onMounted(() => { refreshData() })
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* uts-android 兼容性重构:
|
||||
1. 移除所有嵌套选择器、伪类(如 :last-child),全部 class 扁平化。
|
||||
2. 所有间距用 margin-right/margin-bottom 控制,禁止 gap、flex-wrap、嵌套。
|
||||
3. 所有布局 display: flex,禁止 grid、gap、伪类。
|
||||
4. 组件间距、分隔线全部用 border/margin 控制。
|
||||
*/
|
||||
.service-records {
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.refresh-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #52c41a;
|
||||
background-color: #52c41a;
|
||||
color: white;
|
||||
}
|
||||
.filters-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-group {
|
||||
flex: 1;
|
||||
margin-right: 15px;
|
||||
}
|
||||
.filter-group.is-last {
|
||||
margin-right: 0;
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
.picker-btn {
|
||||
width: 180rpx;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.picker-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #f9f9f9;
|
||||
display: block;
|
||||
}
|
||||
.records-list {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
min-height: 300px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.record-item {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.record-item.is-last {
|
||||
border-bottom: none;
|
||||
}
|
||||
.record-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.elder-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.service-type {
|
||||
font-size: 14px;
|
||||
color: #1890ff;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.record-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.record-content {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.caregiver {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.notes {
|
||||
color: #faad14;
|
||||
}
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-text {
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
647
pages/ec/admin/caregiver-management.uvue
Normal file
647
pages/ec/admin/caregiver-management.uvue
Normal file
@@ -0,0 +1,647 @@
|
||||
<template>
|
||||
<scroll-view class="caregiver-management-container" direction="vertical">
|
||||
<!-- Header -->
|
||||
<view class="header">
|
||||
<view class="header-content">
|
||||
<text class="header-title">护工管理</text>
|
||||
<text class="header-subtitle">管理护理人员信息和工作安排</text>
|
||||
</view>
|
||||
<button class="add-btn" @click="addCaregiver">
|
||||
<text class="btn-text">+ 添加护工</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- Statistics -->
|
||||
<view class="stats-section">
|
||||
<view class="stats-flex">
|
||||
<view class="stat-card">
|
||||
<text class="stat-number">{{ stats.total_caregivers }}</text>
|
||||
<text class="stat-label">总护工数</text>
|
||||
<text class="stat-trend">在岗人员</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-number">{{ stats.active_caregivers }}</text>
|
||||
<text class="stat-label">在线人数</text>
|
||||
<text class="stat-trend">当前班次</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-number">{{ stats.on_leave }}</text>
|
||||
<text class="stat-label">请假人数</text>
|
||||
<text class="stat-trend">今日</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-number">{{ stats.workload_avg }}%</text>
|
||||
<text class="stat-label">平均工作量</text>
|
||||
<text class="stat-trend">本周</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Filter Section -->
|
||||
<view class="filter-section">
|
||||
<view class="search-box">
|
||||
<input class="search-input" v-model="searchKeyword" placeholder="搜索护工姓名或工号" />
|
||||
<text class="search-icon">🔍</text>
|
||||
</view>
|
||||
<scroll-view class="filter-tabs" direction="horizontal">
|
||||
<view v-for="filter in filterOptions" :key="filter.value"
|
||||
class="filter-tab" :class="currentFilter === filter.value ? 'active' : ''"
|
||||
@tap="setFilter(filter.value)">
|
||||
<text class="filter-text">{{ filter.label }}</text>
|
||||
<text v-if="filter.count > 0" class="filter-count">{{ filter.count }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- Caregiver List -->
|
||||
<view class="caregivers-section">
|
||||
<view v-if="filteredCaregivers.length === 0" class="empty-state">
|
||||
<text class="empty-icon">👥</text>
|
||||
<text class="empty-text">暂无护工信息</text>
|
||||
<text class="empty-subtitle">点击右上角添加新的护工</text>
|
||||
</view>
|
||||
<view v-else class="caregivers-list">
|
||||
<view v-for="caregiver in filteredCaregivers" :key="caregiver.id"
|
||||
class="caregiver-card" @click="viewCaregiverDetail(caregiver)">
|
||||
<view class="caregiver-header">
|
||||
<view class="caregiver-avatar-section">
|
||||
<image class="caregiver-avatar" :src="caregiver.avatar ?? '/static/default-avatar.png'" mode="aspectFill"></image>
|
||||
<view class="status-indicator" :class="getStatusClass(caregiver.status)"></view>
|
||||
</view>
|
||||
<view class="caregiver-info">
|
||||
<text class="caregiver-name">{{ caregiver.name }}</text>
|
||||
<text class="caregiver-id">工号: {{ caregiver.employee_id }}</text>
|
||||
<text class="caregiver-level">{{ getLevelText(caregiver.care_level) }}</text>
|
||||
</view>
|
||||
<view class="caregiver-status">
|
||||
<text class="status-text" :class="getStatusClass(caregiver.status)">
|
||||
{{ getStatusText(caregiver.status) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="caregiver-details">
|
||||
<view class="detail-row">
|
||||
<view class="detail-item">
|
||||
<text class="detail-label">联系电话:</text>
|
||||
<text class="detail-value">{{ caregiver.phone ?? '--' }}</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<text class="detail-label">入职时间:</text>
|
||||
<text class="detail-value">{{ formatDate(caregiver.hire_date) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<view class="detail-item">
|
||||
<text class="detail-label">负责老人:</text>
|
||||
<text class="detail-value">{{ caregiver.assigned_elders ?? 0 }} 人</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<text class="detail-label">本月评分:</text>
|
||||
<text class="detail-value rating" :class="getRatingClass(caregiver.rating)">
|
||||
{{ caregiver.rating ?? '--' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="caregiver-actions">
|
||||
<button class="action-btn primary" @click.stop="editCaregiver(caregiver)">
|
||||
<text class="btn-text">编辑</text>
|
||||
</button>
|
||||
<button class="action-btn secondary" @click.stop="viewSchedule(caregiver)">
|
||||
<text class="btn-text">排班</text>
|
||||
</button>
|
||||
<button class="action-btn" :class="caregiver.status === 'active' ? 'danger' : 'success'"
|
||||
@click.stop="toggleStatus(caregiver)">
|
||||
<text class="btn-text">{{ caregiver.status === 'active' ? '停用' : '启用' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { CaregiverInfo, CaregiverStats } from '../types.uts'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
caregivers: [] as CaregiverInfo[],
|
||||
stats: {
|
||||
total_caregivers: 0,
|
||||
active_caregivers: 0,
|
||||
on_leave: 0,
|
||||
workload_avg: 0
|
||||
} as CaregiverStats,
|
||||
searchKeyword: '',
|
||||
currentFilter: 'all',
|
||||
filterOptions: [
|
||||
{ label: '全部', value: 'all', count: 0 },
|
||||
{ label: '在岗', value: 'active', count: 0 },
|
||||
{ label: '请假', value: 'on_leave', count: 0 },
|
||||
{ label: '离职', value: 'inactive', count: 0 }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredCaregivers(): CaregiverInfo[] {
|
||||
let filtered = this.caregivers
|
||||
// 按关键词搜索
|
||||
if (this.searchKeyword.trim() !== '') {
|
||||
const keyword = this.searchKeyword.toLowerCase()
|
||||
filtered = filtered.filter(caregiver =>
|
||||
caregiver.name.toLowerCase().includes(keyword) ||
|
||||
caregiver.employee_id.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
// 按状态筛选
|
||||
if (this.currentFilter !== 'all') {
|
||||
filtered = filtered.filter(caregiver => caregiver.status === this.currentFilter)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.loadCaregiverData()
|
||||
},
|
||||
onShow() {
|
||||
this.loadCaregiverData()
|
||||
},
|
||||
methods: {
|
||||
async loadCaregiverData() {
|
||||
try {
|
||||
// 获取护工列表(ak_users表,role为caregiver)
|
||||
const caregiversResult = await supa
|
||||
.from('ak_users')
|
||||
.select('*')
|
||||
.eq('role', 'caregiver')
|
||||
.executeAs<CaregiverInfo>()
|
||||
if (caregiversResult.error) throw caregiversResult.error
|
||||
this.caregivers = caregiversResult.data || []
|
||||
|
||||
// 统计数据
|
||||
const totalRes = await supa.from('ak_users').select('*', { count: 'exact' }).eq('role', 'caregiver').executeAs<CaregiverInfo>()
|
||||
const activeRes = await supa.from('ak_users').select('*', { count: 'exact' }).eq('role', 'caregiver').eq('status', 'active').executeAs<CaregiverInfo>()
|
||||
const leaveRes = await supa.from('ak_users').select('*', { count: 'exact' }).eq('role', 'caregiver').eq('status', 'on_leave').executeAs<CaregiverInfo>()
|
||||
const inactiveRes = await supa.from('ak_users').select('*', { count: 'exact' }).eq('role', 'caregiver').eq('status', 'inactive').executeAs<CaregiverInfo>()
|
||||
// 平均工作量(假设有 assigned_elders 字段为数字)
|
||||
let workloadSum = 0
|
||||
let workloadCount = 0
|
||||
for (const c of this.caregivers) {
|
||||
if (typeof c.assigned_elders === 'number') {
|
||||
workloadSum += c.assigned_elders
|
||||
workloadCount++
|
||||
}
|
||||
}
|
||||
this.stats = {
|
||||
total_caregivers: totalRes.count || 0,
|
||||
active_caregivers: activeRes.count || 0,
|
||||
on_leave: leaveRes.count || 0,
|
||||
workload_avg: workloadCount > 0 ? Math.round(workloadSum / workloadCount) : 0
|
||||
}
|
||||
this.updateFilterCounts()
|
||||
} catch (error) {
|
||||
console.error('加载护工数据失败:', error)
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
updateFilterCounts() {
|
||||
this.filterOptions.forEach(filter => {
|
||||
if (filter.value === 'all') {
|
||||
filter.count = this.caregivers.length
|
||||
} else {
|
||||
filter.count = this.caregivers.filter(c => c.status === filter.value).length
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
setFilter(filter: string) {
|
||||
this.currentFilter = filter
|
||||
},
|
||||
|
||||
addCaregiver() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/ec/admin/caregiver-form'
|
||||
})
|
||||
},
|
||||
|
||||
editCaregiver(caregiver: CaregiverInfo) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/admin/caregiver-form?id=${caregiver.id}`
|
||||
})
|
||||
},
|
||||
|
||||
viewCaregiverDetail(caregiver: CaregiverInfo) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/admin/caregiver-detail?id=${caregiver.id}`
|
||||
})
|
||||
},
|
||||
|
||||
viewSchedule(caregiver: CaregiverInfo) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/admin/caregiver-schedule?id=${caregiver.id}`
|
||||
})
|
||||
},
|
||||
|
||||
async toggleStatus(caregiver: CaregiverInfo) {
|
||||
const newStatus = caregiver.status === 'active' ? 'inactive' : 'active'
|
||||
const actionText = newStatus === 'active' ? '启用' : '停用'
|
||||
|
||||
uni.showModal({
|
||||
title: '确认操作',
|
||||
content: `确定要${actionText}护工 ${caregiver.name} 吗?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const result = await supa.executeAs('update_caregiver_status', {
|
||||
caregiver_id: caregiver.id,
|
||||
status: newStatus
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
uni.showToast({
|
||||
title: `${actionText}成功`,
|
||||
icon: 'success'
|
||||
})
|
||||
this.loadCaregiverData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${actionText}护工失败:`, error)
|
||||
uni.showToast({
|
||||
title: `${actionText}失败`,
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
getStatusClass(status: string): string {
|
||||
const statusMap = {
|
||||
'active': 'status-active',
|
||||
'on_leave': 'status-leave',
|
||||
'inactive': 'status-inactive'
|
||||
}
|
||||
return statusMap[status] || 'status-inactive'
|
||||
},
|
||||
|
||||
getStatusText(status: string): string {
|
||||
const statusMap = {
|
||||
'active': '在岗',
|
||||
'on_leave': '请假',
|
||||
'inactive': '离职'
|
||||
}
|
||||
return statusMap[status] || '未知'
|
||||
},
|
||||
|
||||
getLevelText(level: string): string {
|
||||
const levelMap = {
|
||||
'junior': '初级护工',
|
||||
'intermediate': '中级护工',
|
||||
'senior': '高级护工',
|
||||
'supervisor': '护工主管'
|
||||
}
|
||||
return levelMap[level] || '护工'
|
||||
},
|
||||
|
||||
getRatingClass(rating: number): string {
|
||||
if (rating >= 4.5) return 'rating-excellent'
|
||||
if (rating >= 4.0) return 'rating-good'
|
||||
if (rating >= 3.5) return 'rating-fair'
|
||||
return 'rating-poor'
|
||||
},
|
||||
|
||||
formatDate(dateString: string): string {
|
||||
if (dateString === null || dateString === undefined || dateString === '') return '--'
|
||||
const date = new Date(dateString)
|
||||
return `${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.caregiver-management-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40rpx 30rpx 30rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.header-subtitle {
|
||||
font-size: 26rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 2rpx solid rgba(255,255,255,0.3);
|
||||
border-radius: 25rpx;
|
||||
padding: 15rpx 25rpx;
|
||||
color: white;
|
||||
}
|
||||
.btn-text {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
padding: 30rpx;
|
||||
}
|
||||
.stats-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 30rpx 20rpx;
|
||||
border-radius: 15rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.1);
|
||||
margin-right: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
flex: 1 1 40%;
|
||||
min-width: 260rpx;
|
||||
max-width: 48%;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
.stat-trend {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
padding: 0 30rpx 20rpx;
|
||||
}
|
||||
.search-box {
|
||||
position: relative;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
background: white;
|
||||
border: 2rpx solid #e1e1e1;
|
||||
border-radius: 40rpx;
|
||||
padding: 0 60rpx 0 30rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 30rpx;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 30rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
white-space: nowrap;
|
||||
flex-direction: row;
|
||||
}
|
||||
.filter-tab {
|
||||
display: flex;
|
||||
|
||||
width:100rpx;
|
||||
align-items: center;
|
||||
padding: 20rpx 25rpx;
|
||||
background: white;
|
||||
border-radius: 25rpx;
|
||||
margin-right: 15rpx;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.1);
|
||||
}
|
||||
.filter-tab.active {
|
||||
background: linear-gradient(to bottom right, #667eea, #764ba2);
|
||||
color: white;
|
||||
}
|
||||
.filter-count {
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
padding: 5rpx 10rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 20rpx;
|
||||
min-width: 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.filter-tab.active .filter-count {
|
||||
background: rgba(255,255,255,0.3);
|
||||
color: white;
|
||||
|
||||
}
|
||||
|
||||
.caregivers-section {
|
||||
padding: 0 30rpx 30rpx;
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 80rpx;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
.empty-text {
|
||||
font-size: 32rpx;
|
||||
color: #666;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
.empty-subtitle {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.caregivers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.caregiver-card {
|
||||
background: white;
|
||||
margin-bottom: 20rpx;
|
||||
border-radius: 15rpx;
|
||||
padding: 30rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.1);
|
||||
}
|
||||
.caregiver-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.caregiver-avatar-section {
|
||||
position: relative;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
.caregiver-avatar {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 50rpx;
|
||||
border: 4rpx solid #f0f0f0;
|
||||
}
|
||||
.status-indicator {
|
||||
position: absolute;
|
||||
bottom: 5rpx;
|
||||
right: 5rpx;
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 10rpx;
|
||||
border: 3rpx solid white;
|
||||
}
|
||||
.status-indicator.status-active {
|
||||
background-color: #4CAF50;
|
||||
}
|
||||
.status-indicator.status-leave {
|
||||
background-color: #FF9800;
|
||||
}
|
||||
.status-indicator.status-inactive {
|
||||
background-color: #f44336;
|
||||
}
|
||||
.caregiver-info {
|
||||
flex: 1;
|
||||
}
|
||||
.caregiver-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
.caregiver-id {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
.caregiver-level {
|
||||
font-size: 24rpx;
|
||||
color: #667eea;
|
||||
}
|
||||
.caregiver-status {
|
||||
}
|
||||
.status-text {
|
||||
padding: 10rpx 15rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
.status-text.status-active {
|
||||
background: #e8f5e8;
|
||||
color: #4CAF50;
|
||||
}
|
||||
.status-text.status-leave {
|
||||
background: #fff3e0;
|
||||
color: #FF9800;
|
||||
}
|
||||
.status-text.status-inactive {
|
||||
background: #ffebee;
|
||||
color: #f44336;
|
||||
}
|
||||
.caregiver-details {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
.detail-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.detail-label {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
min-width: 120rpx;
|
||||
}
|
||||
.detail-value {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
.detail-value.rating {
|
||||
font-weight: bold;
|
||||
}
|
||||
.detail-value.rating.rating-excellent {
|
||||
color: #4CAF50;
|
||||
}
|
||||
.detail-value.rating.rating-good {
|
||||
color: #8BC34A;
|
||||
}
|
||||
.detail-value.rating.rating-fair {
|
||||
color: #FF9800;
|
||||
}
|
||||
.detail-value.rating.rating-poor {
|
||||
color: #f44336;
|
||||
}
|
||||
.caregiver-actions {
|
||||
display: flex;
|
||||
margin-top: 20rpx;
|
||||
border-top: 2rpx solid #f0f0f0;
|
||||
}
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 60rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
margin-right: 15rpx;
|
||||
}
|
||||
.action-btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
.action-btn.secondary {
|
||||
background: #f8f9ff;
|
||||
color: #667eea;
|
||||
border: 2rpx solid #e1e8ff;
|
||||
}
|
||||
.action-btn.success {
|
||||
background: #e8f5e8;
|
||||
color: #4CAF50;
|
||||
border: 2rpx solid #c8e6c8;
|
||||
}
|
||||
.action-btn.danger {
|
||||
background: #ffebee;
|
||||
color: #f44336;
|
||||
border: 2rpx solid #ffcdd2;
|
||||
}
|
||||
</style>
|
||||
845
pages/ec/admin/dashboard.uvue
Normal file
845
pages/ec/admin/dashboard.uvue
Normal file
@@ -0,0 +1,845 @@
|
||||
<!-- 养老管理系统 - 管理员仪表板 (简化版) -->
|
||||
<template>
|
||||
<view class="admin-dashboard">
|
||||
<view class="header">
|
||||
<text class="title">养老管理系统</text>
|
||||
<text class="welcome">管理员,{{ currentTime }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 数据概览卡片 -->
|
||||
<view class="overview-section">
|
||||
<view class="overview-card" v-for="(card, idx) in overviewCards" :key="idx" :class="{ 'is-last': idx === overviewCards.length - 1 }" @click="navTo(card.navurl)" >
|
||||
<view class="card-icon">{{ card.icon }}</view>
|
||||
<view class="card-content">
|
||||
<text class="card-number">{{ card.number }}</text>
|
||||
<text class="card-label">{{ card.label }}</text>
|
||||
</view>
|
||||
<view v-if="card.trend !== undefined" class="card-trend" :class="card.trend >= 0 ? 'positive' : 'negative'">
|
||||
<text class="trend-text">{{ card.trend >= 0 ? '+' : '' }}{{ card.trend }}</text>
|
||||
</view>
|
||||
<view v-if="card.status !== undefined" class="card-status">
|
||||
<text class="status-text">{{ card.status }}</text>
|
||||
</view>
|
||||
<view v-if="card.alert !== undefined && card.alert > 0" class="card-alert">
|
||||
<text class="alert-text">需要处理</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 快速操作区 -->
|
||||
<view class="actions-section">
|
||||
<text class="section-title">快速操作</text>
|
||||
<view class="actions-grid">
|
||||
<view class="action-card" @click="navigateToElderManagement">
|
||||
<view class="action-icon">👴</view>
|
||||
<text class="action-title">老人管理</text>
|
||||
<text class="action-desc">档案、健康、护理</text>
|
||||
</view>
|
||||
<view class="action-card" @click="navigateToCaregiverManagement">
|
||||
<view class="action-icon">👩⚕️</view>
|
||||
<text class="action-title">员工管理</text>
|
||||
<text class="action-desc">排班、绩效、培训</text>
|
||||
</view>
|
||||
<view class="action-card" @click="navigateToHealthMonitoring">
|
||||
<view class="action-icon">💊</view>
|
||||
<text class="action-title">健康监测</text>
|
||||
<text class="action-desc">体征、用药、预警</text>
|
||||
</view>
|
||||
<view class="action-card is-last" @click="navigateToServiceRecords">
|
||||
<view class="action-icon">📋</view>
|
||||
<text class="action-title">服务记录</text>
|
||||
<text class="action-desc">护理、餐饮、活动</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 紧急提醒列表 -->
|
||||
<view class="alerts-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">紧急提醒</text>
|
||||
<text class="section-more" @click="navigateToAlerts">查看全部</text>
|
||||
</view>
|
||||
<view class="alerts-list">
|
||||
<view v-for="alert in urgentAlerts" :key="alert.id" class="alert-item" :class="alert.severity"
|
||||
@click="handleAlert(alert)">
|
||||
<view class="alert-icon">
|
||||
<text class="icon-text">{{ getAlertIconDisplay(alert.severity ?? '') }}</text>
|
||||
</view>
|
||||
<view class="alert-content">
|
||||
<text class="alert-title">{{ alert.title ?? '' }}</text>
|
||||
<text class="alert-elder">{{ alert.elder_name ?? '未知' }}</text>
|
||||
<text class="alert-time">{{ formatDateTimeDisplay(alert.created_at ?? '') }}</text>
|
||||
</view>
|
||||
<view class="alert-actions">
|
||||
<button class="alert-btn" @click.stop="acknowledgeAlert(alert)">处理</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 今日护理任务 -->
|
||||
<view class="tasks-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">今日护理任务</text>
|
||||
<text class="section-more" @click="navigateToTasks">查看全部</text>
|
||||
</view>
|
||||
<view class="tasks-list">
|
||||
<view v-for="task in todayTasks" :key="task.id" class="task-item" :class="task.status"
|
||||
@click="viewTaskDetail(task)">
|
||||
<view class="task-info">
|
||||
<text class="task-title">{{ task.task_name }}</text> <text
|
||||
class="task-elder">{{ task.elder_name ?? '未知' }}</text>
|
||||
<text class="task-time">{{ formatTimeDisplay(task.scheduled_time ?? '') }}</text>
|
||||
</view>
|
||||
<view class="task-status">
|
||||
<view class="status-badge" :class="task.status">
|
||||
<text class="badge-text">{{ getTaskStatusTextDisplay(task.status ?? '') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-caregiver">
|
||||
<text class="caregiver-name">{{ task.caregiver_name ?? '未分配' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 最近活动记录 -->
|
||||
<view class="activities-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">最近活动</text>
|
||||
<text class="section-more" @click="navigateToActivities">查看全部</text>
|
||||
</view>
|
||||
<view class="activities-list">
|
||||
<view v-for="activity in recentActivities" :key="activity.id" class="activity-item">
|
||||
<view class="activity-avatar">
|
||||
<text class="avatar-text">{{ activity.elder_name?.charAt(0)??'--' }}</text>
|
||||
</view>
|
||||
<view class="activity-content">
|
||||
<text class="activity-title">{{ activity.description ?? '' }}</text>
|
||||
<text
|
||||
class="activity-meta">{{ (activity.elder_name ?? '未知') + ' · ' + formatDateTimeDisplay(activity.created_at ?? '') }}</text>
|
||||
</view>
|
||||
<view class="activity-type">
|
||||
<text class="type-tag"
|
||||
:class="activity.record_type">{{ getRecordTypeTextDisplay(activity.record_type ?? '') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import type { Elder, HealthAlert, CareTask, CareRecord, DashboardStats } from '../types.uts'
|
||||
import { formatDateTime as formatDateTimeUtil, formatTime as formatTimeUtil, getAlertIcon as getAlertIconUtil, getTaskStatusText as getTaskStatusTextUtil, getRecordTypeText as getRecordTypeTextUtil } from '../types.uts' // 将函数作为方法暴露给模板
|
||||
function formatDateTimeDisplay(dateTime : string | null) : string {
|
||||
if (dateTime == null) return ''
|
||||
return formatDateTimeUtil(dateTime)
|
||||
}
|
||||
|
||||
function formatTimeDisplay(time : string | null) : string {
|
||||
if (time == null) return ''
|
||||
return formatTimeUtil(time)
|
||||
}
|
||||
|
||||
function getAlertIconDisplay(severity : string) : string {
|
||||
if (severity == null) return '❓'
|
||||
return getAlertIconUtil(severity)
|
||||
}
|
||||
|
||||
function getTaskStatusTextDisplay(status : string) : string {
|
||||
if (status == null) return '未知'
|
||||
return getTaskStatusTextUtil(status)
|
||||
}
|
||||
|
||||
function getRecordTypeTextDisplay(type : string | null) : string {
|
||||
return getRecordTypeTextUtil(type)
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const currentTime = ref<string>('')
|
||||
const stats = ref<DashboardStats>({
|
||||
total_elders: 0,
|
||||
total_caregivers: 0,
|
||||
on_duty_caregivers: 0,
|
||||
occupancy_rate: 0,
|
||||
available_beds: 0,
|
||||
urgent_alerts: 0,
|
||||
elders_trend: 0
|
||||
})
|
||||
|
||||
// 数据列表
|
||||
const urgentAlerts = ref<Array<HealthAlert>>([])
|
||||
const todayTasks = ref<Array<CareTask>>([])
|
||||
const recentActivities = ref<Array<CareRecord>>([])
|
||||
|
||||
// 更新当前时间
|
||||
const updateCurrentTime = () => {
|
||||
const now = new Date()
|
||||
const hours = now.getHours().toString().padStart(2, '0')
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0')
|
||||
currentTime.value = `今天 ${hours}:${minutes}`
|
||||
}
|
||||
// 获取今天开始和结束时间
|
||||
const getTodayRange = () : UTSJSONObject => {
|
||||
const today = new Date()
|
||||
const startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
const endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1)
|
||||
return {
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString()
|
||||
} as UTSJSONObject
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
const loadStatistics = async () => {
|
||||
try {
|
||||
// 加载老人总数
|
||||
const eldersResult = await supa
|
||||
.from('ec_elders')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('status', 'active')
|
||||
.executeAs<Elder>()
|
||||
|
||||
if (eldersResult.error === null) {
|
||||
stats.value.total_elders = eldersResult.total ?? 0
|
||||
}
|
||||
|
||||
// 加载护理员总数
|
||||
const caregiversResult = await supa
|
||||
.from('ak_users')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('role', 'caregiver')
|
||||
.eq('status', 'active')
|
||||
.executeAs<any>()
|
||||
if (caregiversResult.error === null) {
|
||||
stats.value.total_caregivers = caregiversResult.total ?? 0
|
||||
stats.value.on_duty_caregivers = Math.floor((caregiversResult.total ?? 0) * 0.7) // 假设70%在班
|
||||
}
|
||||
|
||||
// 计算入住率
|
||||
const facilityResult = await supa
|
||||
.from('ec_facilities')
|
||||
.select('capacity, current_occupancy', {})
|
||||
.single()
|
||||
.executeAs<UTSJSONObject>()
|
||||
|
||||
if (facilityResult.error === null && facilityResult.data !== null) {
|
||||
let facilityData = facilityResult.data
|
||||
// 先判断是否为数组
|
||||
if (Array.isArray(facilityData) && facilityData.length > 0) {
|
||||
facilityData = facilityData[0]
|
||||
}
|
||||
let capacity = 0
|
||||
let occupancy = 0
|
||||
if (facilityData && typeof facilityData.get === 'function') {
|
||||
capacity = facilityData.get('capacity') as number ?? 0
|
||||
occupancy = facilityData.get('current_occupancy') as number ?? 0
|
||||
} else if (facilityData) {
|
||||
capacity = (facilityData['capacity'] as number) ?? 0
|
||||
occupancy = (facilityData['current_occupancy'] as number) ?? 0
|
||||
}
|
||||
if (capacity > 0) {
|
||||
stats.value.occupancy_rate = Math.round((occupancy / capacity) * 100)
|
||||
stats.value.available_beds = capacity - occupancy
|
||||
}
|
||||
}
|
||||
|
||||
// 加载紧急提醒数量
|
||||
const alertsResult = await supa
|
||||
.from('ec_health_alerts')
|
||||
.select('*', { count: 'exact' })
|
||||
.in('severity', ['high', 'critical'])
|
||||
.eq('status', 'active')
|
||||
.executeAs<HealthAlert>()
|
||||
if (alertsResult.error === null) {
|
||||
stats.value.urgent_alerts = alertsResult.total ?? 0
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载紧急提醒列表
|
||||
const loadUrgentAlerts = async () => {
|
||||
try {
|
||||
|
||||
const result = await supa
|
||||
.from('ec_health_alerts')
|
||||
.select('id, title, severity, elder_id, created_at, status, ec_elders!ec_health_alerts_elder_id_fkey(name)', {})
|
||||
.in('severity', ['high', 'critical'])
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5)
|
||||
.executeAs<Array<HealthAlert>>()
|
||||
if (result.error === null && result.data !== null) {
|
||||
urgentAlerts.value = result.data as Array<HealthAlert>
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载紧急提醒失败:', error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 加载今日任务列表
|
||||
const loadTodayTasks = async () => {
|
||||
try {
|
||||
const todayRange = getTodayRange()
|
||||
const start = todayRange.get('start') as string
|
||||
const end = todayRange.get('end') as string
|
||||
|
||||
const result = await supa
|
||||
.from('ec_care_tasks')
|
||||
.select(`
|
||||
id,
|
||||
task_name,
|
||||
elder_name,
|
||||
scheduled_time,
|
||||
status,
|
||||
priority,
|
||||
caregiver_name
|
||||
`, {})
|
||||
.gte('scheduled_time', start).lt('scheduled_time', end)
|
||||
.order('scheduled_time', { ascending: true })
|
||||
.limit(8)
|
||||
.executeAs<Array<CareTask>>()
|
||||
|
||||
if (result.error === null && result.data !== null) {
|
||||
todayTasks.value = result.data as Array<CareTask>
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载今日任务失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载最近活动记录
|
||||
const loadRecentActivities = async () => {
|
||||
try {
|
||||
const threeDaysAgo = new Date()
|
||||
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
|
||||
const result = await supa
|
||||
.from('ec_care_records')
|
||||
.select('id, description, ec_care_records_elder_id_fkey(name) , record_type, created_at', {})
|
||||
.gte('created_at', threeDaysAgo.toISOString())
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5)
|
||||
.executeAs<Array<CareRecord>>()
|
||||
if (result.error === null && result.data !== null) {
|
||||
recentActivities.value = result.data as Array<CareRecord>
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载最近活动失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理提醒
|
||||
const handleAlert = (alert : HealthAlert) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/alerts/detail?id=${alert.id}`
|
||||
})
|
||||
}
|
||||
|
||||
const acknowledgeAlert = async (alert : HealthAlert) => {
|
||||
try {
|
||||
await supa
|
||||
.from('ec_health_alerts')
|
||||
.update({ status: 'acknowledged' })
|
||||
.eq('id', alert.id)
|
||||
.executeAs<any>()
|
||||
|
||||
// 重新加载数据
|
||||
loadUrgentAlerts()
|
||||
loadStatistics()
|
||||
} catch (error) {
|
||||
console.error('处理提醒失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const viewTaskDetail = (task : CareTask) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/tasks/detail?id=${task.id}`
|
||||
})
|
||||
}
|
||||
|
||||
// 导航函数
|
||||
const navigateToElderManagement = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/ec/admin/elder-management'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToCaregiverManagement = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/ec/admin/caregiver-management'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToHealthMonitoring = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/ec/admin/health-monitoring'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToServiceRecords = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/ec/admin/service-records'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToAlerts = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/ec/health/ecalert'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToTasks = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/ec/tasks/list'
|
||||
})
|
||||
}
|
||||
|
||||
const navigateToActivities = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/ec/activity/management'
|
||||
})
|
||||
}
|
||||
|
||||
// 数据概览卡片数据
|
||||
const overviewCards = computed(() => [
|
||||
{
|
||||
icon: '👥',
|
||||
number: stats.value.total_elders,
|
||||
label: '入住老人',
|
||||
trend: stats.value.elders_trend,
|
||||
navurl: '/pages/ec/admin/elder-management'
|
||||
},
|
||||
{
|
||||
icon: '👨⚕️',
|
||||
number: stats.value.total_caregivers,
|
||||
label: '护理人员',
|
||||
status: `${stats.value.on_duty_caregivers} 在班`,
|
||||
navurl: '/pages/ec/admin/caregiver-management'
|
||||
},
|
||||
{
|
||||
icon: '🏥',
|
||||
number: stats.value.occupancy_rate + '%',
|
||||
label: '入住率',
|
||||
status: `${stats.value.available_beds} 空床`,
|
||||
navurl: '/pages/ec/admin/health-monitoring'
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
number: stats.value.urgent_alerts,
|
||||
label: '紧急提醒',
|
||||
alert: stats.value.urgent_alerts,
|
||||
navurl: '/pages/ec/health/ecalert-history'
|
||||
}
|
||||
])
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
updateCurrentTime()
|
||||
loadStatistics()
|
||||
loadUrgentAlerts()
|
||||
loadTodayTasks()
|
||||
loadRecentActivities()
|
||||
|
||||
// 定时更新时间
|
||||
setInterval(() => {
|
||||
updateCurrentTime()
|
||||
}, 60000) // 每分钟更新一次
|
||||
})
|
||||
|
||||
function navTo(url: string | undefined) {
|
||||
if (url) {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.overview-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
flex: 1 1 160px;
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px 10px;
|
||||
margin-right: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overview-card.is-last {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 32px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.card-trend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.card-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
font-size: 12px;
|
||||
color: #ff4d4f;
|
||||
background-color: #fff2f0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
flex: 1 1 140px;
|
||||
min-width: 110px;
|
||||
max-width: 180px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 14px 8px;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-card.is-last {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.action-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.alerts-section,
|
||||
.tasks-section,
|
||||
.activities-section {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: 14px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.alert-item.is-last {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.alert-elder {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.alert-btn {
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.task-item.is-last {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.task-elder {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.task-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-badge-pending {
|
||||
background-color: #fff7e6;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.status-badge-in_progress {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.status-badge-completed {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.activity-item.is-last {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.activity-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
background-color: #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.activity-meta {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background-color: #f0f0f0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.overview-section {
|
||||
gap: 8px;
|
||||
}
|
||||
.overview-card {
|
||||
flex: 1 1 120px;
|
||||
min-width: 100px;
|
||||
max-width: 160px;
|
||||
padding: 10px 4px;
|
||||
margin-right: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.actions-grid {
|
||||
gap: 6px;
|
||||
}
|
||||
.action-card {
|
||||
flex: 1 1 90px;
|
||||
min-width: 80px;
|
||||
max-width: 120px;
|
||||
padding: 8px 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
837
pages/ec/admin/elder-form.uvue
Normal file
837
pages/ec/admin/elder-form.uvue
Normal file
@@ -0,0 +1,837 @@
|
||||
<template> <scroll-view class="elder-form-container">
|
||||
<!-- Header -->
|
||||
<view class="form-header">
|
||||
<text class="header-title">{{ isEdit ? '编辑老人信息' : '添加新老人' }}</text>
|
||||
<text class="header-subtitle">请填写完整的老人基本信息和健康状况</text>
|
||||
</view>
|
||||
|
||||
<!-- 使用 form 标签进行统一数据收集 -->
|
||||
<form @submit="onFormSubmit">
|
||||
<!-- 基本信息 -->
|
||||
<view class="form-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">基本信息</text>
|
||||
<text class="required-tip">* 必填项</text>
|
||||
</view>
|
||||
|
||||
<!-- 头像上传 -->
|
||||
<view class="avatar-upload-section">
|
||||
<view class="avatar-container" @click="uploadAvatar">
|
||||
<image v-if="avatar" class="avatar-preview" :src="avatar" mode="aspectFill"></image>
|
||||
<view v-else class="avatar-placeholder">
|
||||
<text class="upload-icon">📷</text>
|
||||
<text class="upload-text">上传头像</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 头像数据隐藏字段 -->
|
||||
<input name="avatar" type="text" :value="avatar" style="display: none;" />
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-item required">
|
||||
<text class="form-label">姓名</text>
|
||||
<input class="form-input" name="name" :value="name" placeholder="请输入老人姓名" />
|
||||
</view>
|
||||
<view class="form-item required">
|
||||
<text class="form-label">性别</text>
|
||||
<!-- 选择器改为 actionSheet -->
|
||||
<view class="form-input" @click="chooseGender">
|
||||
<text class="picker-text">{{ genderOptions[gender === 'male' ? 0 : 1] }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
<!-- 性别数据隐藏字段 -->
|
||||
<input name="gender" type="text" :value="gender" style="display: none;" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-item required">
|
||||
<text class="form-label">出生日期</text>
|
||||
<!-- 出生日期选择,改用 lime-date-time-picker -->
|
||||
<view class="form-item required">
|
||||
<text class="form-label">出生日期</text>
|
||||
<view class="form-input" @click="showBirthDatePicker = true">
|
||||
<text class="picker-text">{{ birth_date !== '' ? birth_date : '选择日期' }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
<input name="birth_date" type="text" :value="birth_date" style="display: none;" />
|
||||
<l-date-time-picker v-if="showBirthDatePicker" v-model="birth_date" title="选择出生日期"
|
||||
mode="年月日" :start="'1920-01-01'" :end="new Date().toISOString().split('T')[0]"
|
||||
confirm-btn="确认" cancel-btn="取消" @confirm="onBirthDateConfirm"
|
||||
@cancel="showBirthDatePicker = false" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">身份证号</text>
|
||||
<view class="id-card-input-row">
|
||||
<input class="form-input" name="id_card" :value="id_card" placeholder="请输入身份证号码" />
|
||||
<button class="id-card-photo-btn" type="button" @click="uploadIdCardPhoto">
|
||||
<text class="photo-icon">📷</text>
|
||||
<text class="photo-text">上传照片</text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="id_card_photo_url" class="id-card-photo-preview">
|
||||
<image :src="id_card_photo_url" mode="aspectFill" class="id-card-photo-img" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-item">
|
||||
<text class="form-label">联系电话</text>
|
||||
<input class="form-input" name="phone" :value="phone" placeholder="请输入联系电话" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">房间号</text>
|
||||
<input class="form-input" name="room_number" :value="room_number" placeholder="请输入房间号" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">联系地址</text>
|
||||
<textarea class="form-textarea" name="address" :value="address" placeholder="请输入详细地址" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 健康状况 -->
|
||||
<view class="form-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">健康状况</text>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-item">
|
||||
<text class="form-label">健康状态</text>
|
||||
<!-- 选择器改为 actionSheet -->
|
||||
<view class="form-input" @click="chooseHealthStatus">
|
||||
<text
|
||||
class="picker-text">{{ healthStatusOptions[health_status === 'good' ? 0 : health_status === 'fair' ? 1 : health_status === 'poor' ? 2 : 3] }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
<!-- 健康状态数据隐藏字段 -->
|
||||
<input name="health_status" type="text" :value="health_status" style="display: none;" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">护理等级</text>
|
||||
<!-- 选择器改为 actionSheet -->
|
||||
<view class="form-input" @click="chooseCareLevel">
|
||||
<text
|
||||
class="picker-text">{{ careLevelOptions[care_level === 'level1' ? 0 : care_level === 'level2' ? 1 : care_level === 'level3' ? 2 : 3] }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
<!-- 护理等级数据隐藏字段 -->
|
||||
<input name="care_level" type="text" :value="care_level" style="display: none;" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="form-label">疾病史</text>
|
||||
<textarea class="form-textarea" name="medical_history" :value="medical_history"
|
||||
placeholder="请输入主要疾病史和治疗情况" />
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="form-label">过敏史</text>
|
||||
<textarea class="form-textarea" name="allergies" :value="allergies" placeholder="请输入过敏史,如无请填写'无'" />
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<text class="form-label">特殊需求</text>
|
||||
<textarea class="form-textarea" name="special_needs" :value="special_needs"
|
||||
placeholder="请输入特殊护理需求" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 紧急联系人 -->
|
||||
<view class="form-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">紧急联系人</text>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-item">
|
||||
<text class="form-label">联系人姓名</text>
|
||||
<input class="form-input" name="emergency_contact_name" :value="emergency_contact_name"
|
||||
placeholder="请输入联系人姓名" />
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">与老人关系</text>
|
||||
<input class="form-input" name="emergency_contact_relationship"
|
||||
:value="emergency_contact_relationship" placeholder="如:子女、配偶等" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="form-item">
|
||||
<text class="form-label">联系电话</text>
|
||||
<input class="form-input" name="emergency_contact_phone" :value="emergency_contact_phone"
|
||||
placeholder="请输入联系电话" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="form-actions">
|
||||
<button class="btn btn-cancel" type="button" @click="goBack">取消</button>
|
||||
<button class="btn btn-submit" form-type="submit" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '保存中...' : (isEdit ? '更新信息' : '添加老人') }}
|
||||
</button>
|
||||
</view>
|
||||
</form>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import type { Elder } from '../types.uts'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
isEdit: false,
|
||||
elderId: '',
|
||||
isSubmitting: false,
|
||||
// 所有表单变量一维展开
|
||||
name: '',
|
||||
id_card: '',
|
||||
gender: 'male',
|
||||
birth_date: '',
|
||||
avatar: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
health_status: 'good',
|
||||
care_level: 'level1',
|
||||
medical_history: '',
|
||||
allergies: '',
|
||||
special_needs: '',
|
||||
emergency_contact_name: '',
|
||||
emergency_contact_relationship: '',
|
||||
emergency_contact_phone: '',
|
||||
room_number: '',
|
||||
id_card_photo_url: '', // 身份证照片url
|
||||
// 选项
|
||||
genderOptions: ['男', '女'],
|
||||
healthStatusOptions: ['良好', '一般', '较差', '危重'],
|
||||
careLevelOptions: ['一级护理', '二级护理', '三级护理', '特级护理'],
|
||||
showBirthDatePicker: false // 控制出生日期选择器显示
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
if (options['id'] !== null && options['id'] !== undefined) {
|
||||
this.isEdit = true
|
||||
this.elderId = options['id'] as string
|
||||
this.loadElderInfo(this.elderId)
|
||||
} else {
|
||||
this.isEdit = false
|
||||
this.elderId = ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 性别选择
|
||||
chooseGender() {
|
||||
uni.showActionSheet({
|
||||
itemList: this.genderOptions,
|
||||
success: (res : any) => {
|
||||
if (res.tapIndex !== null) {
|
||||
this.gender = res.tapIndex === 0 ? 'male' : 'female'
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
// 出生日期选择
|
||||
chooseBirthDate() {
|
||||
uni.showActionSheet({
|
||||
itemList: this.getDateOptions(),
|
||||
success: (res : any) => {
|
||||
if (res.tapIndex !== null) {
|
||||
const selectedDate = this.getDateOptions()[res.tapIndex]
|
||||
this.birth_date = selectedDate
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
// 健康状态选择
|
||||
chooseHealthStatus() {
|
||||
uni.showActionSheet({
|
||||
itemList: this.healthStatusOptions,
|
||||
success: (res : any) => {
|
||||
if (res.tapIndex !== null) {
|
||||
const statusMap = ['good', 'fair', 'poor', 'critical']
|
||||
this.health_status = statusMap[res.tapIndex]
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
// 护理等级选择
|
||||
chooseCareLevel() {
|
||||
uni.showActionSheet({
|
||||
itemList: this.careLevelOptions,
|
||||
success: (res : any) => {
|
||||
if (res.tapIndex !== null) {
|
||||
const levelMap = ['level1', 'level2', 'level3', 'special']
|
||||
this.care_level = levelMap[res.tapIndex]
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
// 统一的表单提交处理
|
||||
onFormSubmit(e : UniFormSubmitEvent) {
|
||||
// 从表单数据中更新 formData
|
||||
const formValues = e.detail.value
|
||||
|
||||
// 更新所有表单字段
|
||||
this.name = formValues.getString('name') ?? ''
|
||||
this.id_card = formValues.getString('id_card') ?? ''
|
||||
this.phone = formValues.getString('phone') ?? ''
|
||||
this.room_number = formValues.getString('room_number') ?? ''
|
||||
this.address = formValues.getString('address') ?? ''
|
||||
this.medical_history = formValues.getString('medical_history') ?? ''
|
||||
this.allergies = formValues.getString('allergies') ?? ''
|
||||
this.special_needs = formValues.getString('special_needs') ?? ''
|
||||
this.emergency_contact_name = formValues.getString('emergency_contact_name') ?? ''
|
||||
this.emergency_contact_relationship = formValues.getString('emergency_contact_relationship') ?? ''
|
||||
this.emergency_contact_phone = formValues.getString('emergency_contact_phone') ?? ''
|
||||
|
||||
// 对于 picker 组件,数据已经通过事件处理更新到 formData 了
|
||||
// avatar, gender, birth_date, health_status, care_level 无需从表单中获取
|
||||
|
||||
// 执行表单提交逻辑
|
||||
this.submitForm()
|
||||
},
|
||||
async loadElderInfo(id : string) {
|
||||
try {
|
||||
const result = await supa.from('ec_elders')
|
||||
.select('*', {})
|
||||
.eq('id', id)
|
||||
.executeAs<Elder>()
|
||||
// UTS/uni-app-x: result 结构兼容性处理
|
||||
if (result !== null && typeof result === 'object' && result.data !== null && result.data instanceof Array && result.data.length > 0) {
|
||||
const elder = result.data[0] as Elder
|
||||
this.formData = { ...elder }
|
||||
this.updateFormIndexes()
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '未找到老人信息',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载老人信息失败:', error)
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
updateFormIndexes() {
|
||||
// 更新选择器索引
|
||||
this.genderIndex = this.formData.gender === 'male' ? 0 : 1
|
||||
|
||||
const healthStatusMap = {
|
||||
'good': 0,
|
||||
'fair': 1,
|
||||
'poor': 2,
|
||||
'critical': 3
|
||||
}
|
||||
this.healthStatusIndex = healthStatusMap[this.formData.health_status] ?? 0
|
||||
|
||||
const careLevelMap = {
|
||||
'level1': 0,
|
||||
'level2': 1,
|
||||
'level3': 2,
|
||||
'special': 3
|
||||
}
|
||||
this.careLevelIndex = careLevelMap[this.formData.care_level] ?? 0
|
||||
},
|
||||
onGenderChange(e : UniPickerChangeEvent) {
|
||||
this.genderIndex = e.detail.value as number
|
||||
this.formData.gender = (e.detail.value as number) === 0 ? 'male' : 'female'
|
||||
},
|
||||
|
||||
onBirthDateChange(e : UniPickerChangeEvent) {
|
||||
this.formData.birth_date = e.detail.value as string
|
||||
},
|
||||
|
||||
onHealthStatusChange(e : UniPickerChangeEvent) {
|
||||
this.healthStatusIndex = e.detail.value as number
|
||||
const statusMap = ['good', 'fair', 'poor', 'critical']
|
||||
this.formData.health_status = statusMap[e.detail.value as number]
|
||||
},
|
||||
|
||||
onCareLevelChange(e : UniPickerChangeEvent) {
|
||||
this.careLevelIndex = e.detail.value as number
|
||||
const levelMap = ['level1', 'level2', 'level3', 'special']
|
||||
this.formData.care_level = levelMap[e.detail.value as number]
|
||||
},
|
||||
|
||||
uploadAvatar() {
|
||||
// 上传头像逻辑
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
// 这里应该上传到服务器并获取URL
|
||||
// 暂时使用本地路径
|
||||
this.formData.avatar = res.tempFilePaths[0]
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
uploadIdCardPhoto() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const filePath = res.tempFilePaths[0]
|
||||
// 上传到 storage
|
||||
const cloudPath = 'elder_idcard/' + Date.now() + '_' + Math.floor(Math.random() * 10000) + '.jpg'
|
||||
uniCloud.uploadFile({
|
||||
filePath: filePath,
|
||||
cloudPath: cloudPath,
|
||||
success: (uploadRes) => {
|
||||
if (uploadRes.fileID) {
|
||||
this.id_card_photo_url = uploadRes.fileID
|
||||
uni.showToast({ title: '上传成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '上传失败', icon: 'error' })
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('身份证上传失败:', err)
|
||||
uni.showToast({ title: '上传失败', icon: 'error' })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async submitForm() {
|
||||
if (!this.validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isSubmitting = true
|
||||
|
||||
try {
|
||||
if (this.isEdit) {
|
||||
await this.updateElder()
|
||||
} else {
|
||||
await this.createElder()
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: this.isEdit ? '更新成功' : '添加成功',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
this.goBack()
|
||||
}, 1500)
|
||||
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
uni.showToast({
|
||||
title: '保存失败',
|
||||
icon: 'error'
|
||||
})
|
||||
} finally {
|
||||
this.isSubmitting = false
|
||||
}
|
||||
},
|
||||
|
||||
async createElder() {
|
||||
const result = await supa.executeAs('create_elder', {
|
||||
...this.formData,
|
||||
age: this.calculateAge(this.formData.birth_date),
|
||||
admission_date: new Date().toISOString().split('T')[0],
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? '添加失败')
|
||||
}
|
||||
},
|
||||
|
||||
async updateElder() {
|
||||
const result = await supa.executeAs('update_elder', {
|
||||
elder_id: this.elderId,
|
||||
...this.formData,
|
||||
age: this.calculateAge(this.formData.birth_date)
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? '更新失败')
|
||||
}
|
||||
},
|
||||
|
||||
validateForm() {
|
||||
if (!this.formData.name.trim()) {
|
||||
uni.showToast({
|
||||
title: '请输入姓名',
|
||||
icon: 'error'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this.formData.birth_date) {
|
||||
uni.showToast({
|
||||
title: '请选择出生日期',
|
||||
icon: 'error'
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
calculateAge(birthDate : string) : number {
|
||||
const today = new Date()
|
||||
const birth = new Date(birthDate)
|
||||
let age = today.getFullYear() - birth.getFullYear()
|
||||
const monthDiff = today.getMonth() - birth.getMonth()
|
||||
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||
age--
|
||||
}
|
||||
|
||||
return age
|
||||
},
|
||||
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
|
||||
scanIdCardPhoto() {
|
||||
// 调用拍照识别身份证的接口
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['original'],
|
||||
sourceType: ['camera'],
|
||||
success: (res) => {
|
||||
const imagePath = res.tempFilePaths[0]
|
||||
|
||||
// 这里调用身份证识别的云函数或API
|
||||
// 假设有一个云函数叫做 'recognizeIdCard'
|
||||
uni.cloud.callFunction({
|
||||
name: 'recognizeIdCard',
|
||||
data: {
|
||||
image: imagePath
|
||||
},
|
||||
success: (res) => {
|
||||
if (res.result && res.result.code === 200) {
|
||||
// 假设返回的结果中有 name, id_card, gender, birth_date 字段
|
||||
const { name, id_card, gender, birth_date } = res.result.data
|
||||
|
||||
// 更新表单数据
|
||||
this.name = name
|
||||
this.id_card = id_card
|
||||
this.gender = gender === '男' ? 'male' : 'female'
|
||||
this.birth_date = birth_date
|
||||
|
||||
uni.showToast({
|
||||
title: '识别成功',
|
||||
icon: 'success'
|
||||
})
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '识别失败,请重试',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('调用云函数失败:', err)
|
||||
uni.showToast({
|
||||
title: '识别失败,请重试',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
uploadIdCardPhoto() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const filePath = res.tempFilePaths[0]
|
||||
// 上传到 storage
|
||||
const cloudPath = 'elder_idcard/' + Date.now() + '_' + Math.floor(Math.random() * 10000) + '.jpg'
|
||||
uniCloud.uploadFile({
|
||||
filePath: filePath,
|
||||
cloudPath: cloudPath,
|
||||
success: (uploadRes) => {
|
||||
if (uploadRes.fileID) {
|
||||
this.id_card_photo_url = uploadRes.fileID
|
||||
uni.showToast({ title: '上传成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '上传失败', icon: 'error' })
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('身份证上传失败:', err)
|
||||
uni.showToast({ title: '上传失败', icon: 'error' })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.elder-form-container {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40rpx 30rpx 30rpx;
|
||||
color: white;
|
||||
|
||||
.header-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 26rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: white;
|
||||
margin: 20rpx 30rpx;
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30rpx;
|
||||
padding-bottom: 20rpx;
|
||||
border-bottom: 2rpx solid #f0f0f0;
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.required-tip {
|
||||
font-size: 24rpx;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-upload-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 60rpx;
|
||||
overflow: hidden;
|
||||
border: 4rpx solid #e1e1e1;
|
||||
position: relative;
|
||||
|
||||
.avatar-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f8f8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.upload-icon {
|
||||
font-size: 30rpx;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
flex: 1;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-bottom: 15rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
border: 2rpx solid #e1e1e1;
|
||||
border-radius: 10rpx;
|
||||
padding: 0 20rpx;
|
||||
font-size: 28rpx;
|
||||
background-color: #fff;
|
||||
|
||||
&:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
min-height: 120rpx;
|
||||
border: 2rpx solid #e1e1e1;
|
||||
border-radius: 10rpx;
|
||||
padding: 20rpx;
|
||||
font-size: 28rpx;
|
||||
background-color: #fff;
|
||||
|
||||
&:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.form-picker {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
border: 2rpx solid #e1e1e1;
|
||||
border-radius: 10rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.picker-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 20rpx;
|
||||
|
||||
.picker-text {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
padding: 30rpx;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 10rpx;
|
||||
font-size: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background-color: #f5f5f5;
|
||||
color: #666;
|
||||
border: 2rpx solid #e1e1e1;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.id-card-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2rpx solid #e1e1e1;
|
||||
border-radius: 10rpx;
|
||||
background-color: #fff;
|
||||
padding: 0 10rpx;
|
||||
}
|
||||
|
||||
.id-card-input-row .form-input {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border: none;
|
||||
padding: 0 10rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.id-card-photo-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100rpx;
|
||||
height: 80rpx;
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10rpx;
|
||||
font-size: 28rpx;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
.photo-icon {
|
||||
margin-right: 5rpx;
|
||||
}
|
||||
|
||||
.id-card-photo-preview {
|
||||
margin-top: 10rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.id-card-photo-img {
|
||||
width: 100%;
|
||||
max-width: 300rpx;
|
||||
height: auto;
|
||||
border-radius: 10rpx;
|
||||
border: 2rpx solid #e1e1e1;
|
||||
}
|
||||
</style>
|
||||
734
pages/ec/admin/elder-management.uvue
Normal file
734
pages/ec/admin/elder-management.uvue
Normal file
@@ -0,0 +1,734 @@
|
||||
<!-- 养老管理系统 - 老人管理页面 (简化版) -->
|
||||
<template>
|
||||
<view class="elder-management">
|
||||
<!-- 顶部搜索和操作区 -->
|
||||
<view class="header-section">
|
||||
<view class="search-container">
|
||||
<input class="search-input" placeholder="搜索老人姓名、房间号..." v-model="searchKeyword" @input="handleSearch" />
|
||||
<view class="search-icon"></view>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<button class="action-btn primary" @click="addNewElder">
|
||||
<text class="btn-icon">➕</text>
|
||||
<text class="btn-text">新增老人</text>
|
||||
</button>
|
||||
<button class="action-btn secondary" @click="exportElders">
|
||||
<text class="btn-icon"></text>
|
||||
<text class="btn-text">导出</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<view class="stats-container">
|
||||
<view class="stat-card">
|
||||
<view class="stat-number">{{ totalElders }}</view>
|
||||
<view class="stat-label">总入住</view>
|
||||
<view class="stat-trend positive">+{{ newEldersThisMonth }}</view>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<view class="stat-number">{{ selfCareElders }}</view>
|
||||
<view class="stat-label">自理老人</view>
|
||||
<view class="stat-percent">{{ selfCarePercent }}%</view>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<view class="stat-number">{{ assistedCareElders }}</view>
|
||||
<view class="stat-label">半护理</view>
|
||||
<view class="stat-percent">{{ assistedCarePercent }}%</view>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<view class="stat-number">{{ fullCareElders }}</view>
|
||||
<view class="stat-label">全护理</view>
|
||||
<view class="stat-percent">{{ fullCarePercent }}%</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选器 -->
|
||||
<view class="filter-container">
|
||||
<scroll-view class="filter-scroll" direction="horizontal" :show-scrollbar="false">
|
||||
<view class="filter-item" :class="{ 'is-active': selectedCareLevel == 'all' }" @click="filterByCareLevel('all')">
|
||||
全部
|
||||
</view>
|
||||
<view class="filter-item" :class="{ 'is-active': selectedCareLevel == 'self_care' }" @click="filterByCareLevel('self_care')">
|
||||
自理
|
||||
</view>
|
||||
<view class="filter-item" :class="{ 'is-active': selectedCareLevel == 'assisted' }" @click="filterByCareLevel('assisted')">
|
||||
半护理
|
||||
</view>
|
||||
<view class="filter-item" :class="{ 'is-active': selectedCareLevel == 'full_care' }" @click="filterByCareLevel('full_care')">
|
||||
全护理
|
||||
</view>
|
||||
<view class="filter-item" :class="{ 'is-active': selectedCareLevel == 'dementia' }" @click="filterByCareLevel('dementia')">
|
||||
失智护理
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 老人列表 -->
|
||||
<view class="elders-section">
|
||||
<scroll-view class="elders-list" direction="vertical" :refresher-enabled="true"
|
||||
:refresher-triggered="isRefreshing" @refresherrefresh="refreshElders">
|
||||
<view class="elder-card" v-for="elder in filteredElders" :key="elder.id" @click="viewElderDetail(elder)">
|
||||
<view class="elder-header">
|
||||
<view class="elder-avatar">
|
||||
<image class="avatar-image" :src="elder.profile_picture ?? ''" mode="aspectFill"
|
||||
@error="handleAvatarError" v-if="elder.profile_picture !== null" />
|
||||
<text class="avatar-fallback" v-else>{{ elder.name.charAt(0) }}</text>
|
||||
</view>
|
||||
<view class="elder-basic">
|
||||
<text class="elder-name">{{ elder.name }}</text>
|
||||
<text class="elder-info">{{ elder.age ?? 0 }}岁 · {{ elder.gender == 'male' ? '男' : '女' }}</text>
|
||||
<text class="elder-room">{{ (elder.room_number ?? '') + (elder.bed_number ?? '') }}</text>
|
||||
</view>
|
||||
<view class="elder-status">
|
||||
<view class="health-status" :class="getHealthStatusClass(elder.health_status)">
|
||||
<text class="status-text">{{ getHealthStatusText(elder.health_status) }}</text>
|
||||
</view>
|
||||
<view class="care-level" :class="getCareLevelClass(elder.care_level)">
|
||||
<text class="level-text">{{ getCareLevelText(elder.care_level) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="elder-details">
|
||||
<view class="detail-row">
|
||||
<view class="detail-item">
|
||||
<text class="detail-icon"></text>
|
||||
<text class="detail-text">入住:{{ formatDate(elder.admission_date) }}</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<text class="detail-icon">⚕️</text>
|
||||
<text class="detail-text">护理员:待分配</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<view class="detail-item">
|
||||
<text class="detail-icon"></text>
|
||||
<text class="detail-text">联系人:{{ elder.emergency_contact ?? '无' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="elder-actions">
|
||||
<button class="action-btn small" @click.stop="viewHealthRecord(elder)">
|
||||
<text class="btn-text">健康记录</text>
|
||||
</button>
|
||||
<button class="action-btn small" @click.stop="viewCareRecord(elder)">
|
||||
<text class="btn-text">护理记录</text>
|
||||
</button>
|
||||
<button class="action-btn small primary" @click.stop="editElder(elder)">
|
||||
<text class="btn-text">编辑</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state" v-if="filteredElders.length == 0 && !isLoading">
|
||||
<text class="empty-icon"></text>
|
||||
<text class="empty-title">暂无老人信息</text>
|
||||
<text class="empty-description">{{ getEmptyStateText() }}</text>
|
||||
<button class="empty-action" @click="addNewElder">添加第一位老人</button>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view class="loading-state" v-if="isLoading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 浮动操作按钮 -->
|
||||
<view class="fab-container">
|
||||
<view class="fab" @click="quickActions">
|
||||
<text class="fab-icon">⚡</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
import type { Elder } from '../types.uts'
|
||||
import { formatDate, getCareLevelText, getHealthStatusText } from '../types.uts'
|
||||
|
||||
// 响应式数据
|
||||
const elders = ref<Array<Elder>>([])
|
||||
const filteredElders = ref<Array<Elder>>([])
|
||||
const searchKeyword = ref<string>('')
|
||||
const selectedCareLevel = ref<string>('all')
|
||||
const isLoading = ref<boolean>(false)
|
||||
const isRefreshing = ref<boolean>(false)
|
||||
|
||||
// 统计数据
|
||||
const totalElders = ref<number>(0)
|
||||
const selfCareElders = ref<number>(0)
|
||||
const assistedCareElders = ref<number>(0)
|
||||
const fullCareElders = ref<number>(0)
|
||||
const newEldersThisMonth = ref<number>(0)
|
||||
|
||||
// 计算百分比
|
||||
const selfCarePercent = computed(() => {
|
||||
return totalElders.value > 0 ? Math.round((selfCareElders.value / totalElders.value) * 100) : 0
|
||||
})
|
||||
|
||||
const assistedCarePercent = computed(() => {
|
||||
return totalElders.value > 0 ? Math.round((assistedCareElders.value / totalElders.value) * 100) : 0
|
||||
})
|
||||
|
||||
const fullCarePercent = computed(() => {
|
||||
return totalElders.value > 0 ? Math.round((fullCareElders.value / totalElders.value) * 100) : 0
|
||||
})
|
||||
|
||||
// 加载老人数据
|
||||
const loadElders = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const result = await supa
|
||||
.from('ec_elders')
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
age,
|
||||
gender,
|
||||
room_number,
|
||||
bed_number,
|
||||
health_status,
|
||||
care_level,
|
||||
profile_picture,
|
||||
emergency_contact,
|
||||
emergency_phone,
|
||||
admission_date,
|
||||
status
|
||||
`)
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false })
|
||||
.executeAs<Array<Elder>>()
|
||||
|
||||
if (result.error == null && result.data !== null) {
|
||||
elders.value = result.data
|
||||
applyFilters()
|
||||
updateStatistics()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载老人数据失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新统计数据
|
||||
const updateStatistics = () => {
|
||||
totalElders.value = elders.value.length
|
||||
|
||||
selfCareElders.value = elders.value.filter(elder => elder.care_level == 'self_care').length
|
||||
assistedCareElders.value = elders.value.filter(elder => elder.care_level == 'assisted').length
|
||||
fullCareElders.value = elders.value.filter(elder => elder.care_level == 'full_care').length
|
||||
|
||||
// 计算本月新增老人数
|
||||
const thisMonth = new Date()
|
||||
thisMonth.setDate(1)
|
||||
thisMonth.setHours(0, 0, 0, 0)
|
||||
|
||||
newEldersThisMonth.value = elders.value.filter(elder => {
|
||||
const admissionDate = elder.admission_date
|
||||
if (admissionDate !== '') {
|
||||
const admission = new Date(admissionDate)
|
||||
return admission >= thisMonth
|
||||
}
|
||||
return false
|
||||
}).length
|
||||
}
|
||||
|
||||
// 应用筛选
|
||||
const applyFilters = () => {
|
||||
let filtered = elders.value
|
||||
|
||||
// 护理等级筛选
|
||||
if (selectedCareLevel.value !== 'all') {
|
||||
filtered = filtered.filter(elder => elder.care_level == selectedCareLevel.value)
|
||||
}
|
||||
|
||||
// 搜索关键词筛选
|
||||
if (searchKeyword.value !== '') {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
filtered = filtered.filter(elder => {
|
||||
const name = elder.name.toLowerCase()
|
||||
const roomNumber = elder.room_number ?? ''
|
||||
return name.includes(keyword) || roomNumber.includes(keyword)
|
||||
})
|
||||
}
|
||||
|
||||
filteredElders.value = filtered
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
// 护理等级筛选
|
||||
const filterByCareLevel = (level: string) => {
|
||||
selectedCareLevel.value = level
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshElders = async () => {
|
||||
isRefreshing.value = true
|
||||
await loadElders()
|
||||
isRefreshing.value = false
|
||||
}
|
||||
|
||||
// 查看老人详情
|
||||
const viewElderDetail = (elder: Elder) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/elders/detail?id=${elder.id}`
|
||||
})
|
||||
}
|
||||
|
||||
// 查看健康记录
|
||||
const viewHealthRecord = (elder: Elder) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/health/records?elderId=${elder.id}`
|
||||
})
|
||||
}
|
||||
|
||||
// 查看护理记录
|
||||
const viewCareRecord = (elder: Elder) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/care/records?elderId=${elder.id}`
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑老人信息
|
||||
const editElder = (elder: Elder) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/elders/edit?id=${elder.id}`
|
||||
})
|
||||
}
|
||||
|
||||
// 新增老人
|
||||
const addNewElder = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/ec/admin/elder-form'
|
||||
})
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
const exportElders = () => {
|
||||
uni.showToast({
|
||||
title: '导出功能开发中',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
|
||||
// 快速操作
|
||||
const quickActions = () => {
|
||||
uni.showActionSheet({
|
||||
itemList: ['批量操作', '数据同步', '生成报表'],
|
||||
success: (res) => {
|
||||
console.log('选择了第' + (res.tapIndex + 1) + '个操作')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 头像错误处理
|
||||
const handleAvatarError = () => {
|
||||
// 头像加载失败时的处理
|
||||
}
|
||||
|
||||
// 获取空状态文本
|
||||
const getEmptyStateText = (): string => {
|
||||
if (searchKeyword.value !== '') {
|
||||
return '没有找到匹配的老人信息'
|
||||
}
|
||||
if (selectedCareLevel.value !== 'all') {
|
||||
return `没有${getCareLevelText(selectedCareLevel.value)}的老人`
|
||||
}
|
||||
return '还没有老人入住,点击下方按钮添加第一位老人'
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadElders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.elder-management {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 头部区域 */
|
||||
.header-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex: 1;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
padding: 0 40px 0 15px;
|
||||
font-size: 14px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
margin-left: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #fff;
|
||||
color: #666;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-right: 15px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card.is-last {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
font-size: 12px;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.stat-percent {
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
/* 筛选器 */
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
margin-right: 10px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-item.is-active {
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
/* 老人列表 */
|
||||
.elders-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.elders-list {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.elder-card {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.elder-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.elder-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 30px;
|
||||
margin-right: 15px;
|
||||
overflow: hidden;
|
||||
background-color: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-fallback {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.elder-basic {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.elder-name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.elder-info {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.elder-room {
|
||||
font-size: 14px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.elder-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.health-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.health-excellent {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.health-good {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.health-fair {
|
||||
background-color: #fff7e6;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.health-poor {
|
||||
background-color: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.care-level {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.care-self {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.care-assisted {
|
||||
background-color: #fff7e6;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.care-full {
|
||||
background-color: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* 详细信息 */
|
||||
.elder-details {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.elder-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 浮动按钮 */
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
.fab {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 28px;
|
||||
background-color: #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
}
|
||||
</style>
|
||||
748
pages/ec/admin/elder-management_new.uvue
Normal file
748
pages/ec/admin/elder-management_new.uvue
Normal file
@@ -0,0 +1,748 @@
|
||||
<template>
|
||||
<view class="elder-management">
|
||||
<!-- 顶部搜索和操作区 -->
|
||||
<view class="header-section">
|
||||
<view class="search-container">
|
||||
<input class="search-input" placeholder="搜索老人姓名、房间号..." v-model="searchKeyword" @input="handleSearch" />
|
||||
<view class="search-icon">🔍</view>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<button class="action-btn primary" @click="addNewElder">
|
||||
<text class="btn-icon">➕</text>
|
||||
<text class="btn-text">新增老人</text>
|
||||
</button>
|
||||
<button class="action-btn secondary" @click="exportElders">
|
||||
<text class="btn-icon">📊</text>
|
||||
<text class="btn-text">导出</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<view class="stats-container">
|
||||
<view class="stat-card">
|
||||
<view class="stat-number">{{ elderStats.total }}</view>
|
||||
<view class="stat-label">总入住</view>
|
||||
<view class="stat-trend positive">+{{ elderStats.new_this_month }}</view>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<view class="stat-number">{{ elderStats.self_care }}</view>
|
||||
<view class="stat-label">自理老人</view>
|
||||
<view class="stat-percent">{{ getSelfCarePercent() }}%</view>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<view class="stat-number">{{ elderStats.assisted_care }}</view>
|
||||
<view class="stat-label">半护理</view>
|
||||
<view class="stat-percent">{{ getAssistedCarePercent() }}%</view>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<view class="stat-number">{{ elderStats.full_care }}</view>
|
||||
<view class="stat-label">全护理</view>
|
||||
<view class="stat-percent">{{ getFullCarePercent() }}%</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选器 -->
|
||||
<view class="filter-container">
|
||||
<scroll-view class="filter-scroll" direction="horizontal" :show-scrollbar="false">
|
||||
<view class="filter-item" :class="{ active: selectedCareLevel === 'all' }" @click="filterByCareLevel('all')">
|
||||
全部
|
||||
</view>
|
||||
<view class="filter-item" :class="{ active: selectedCareLevel === '1' }" @click="filterByCareLevel('1')">
|
||||
一级护理
|
||||
</view>
|
||||
<view class="filter-item" :class="{ active: selectedCareLevel === '2' }" @click="filterByCareLevel('2')">
|
||||
二级护理
|
||||
</view>
|
||||
<view class="filter-item" :class="{ active: selectedCareLevel === '3' }" @click="filterByCareLevel('3')">
|
||||
三级护理
|
||||
</view>
|
||||
<view class="filter-item" :class="{ active: selectedCareLevel === '4' }" @click="filterByCareLevel('4')">
|
||||
特级护理
|
||||
</view>
|
||||
<view class="filter-item" :class="{ active: selectedHealthStatus === 'stable' }" @click="filterByHealthStatus('stable')">
|
||||
健康稳定
|
||||
</view>
|
||||
<view class="filter-item" :class="{ active: selectedHealthStatus === 'attention' }" @click="filterByHealthStatus('attention')">
|
||||
需要关注
|
||||
</view>
|
||||
<view class="filter-item" :class="{ active: selectedHealthStatus === 'critical' }" @click="filterByHealthStatus('critical')">
|
||||
危险状态
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 老人列表 -->
|
||||
<view class="elders-list" v-if="filteredElders.length > 0">
|
||||
<view class="elder-card" v-for="elder in filteredElders" :key="elder.id">
|
||||
<view class="elder-info">
|
||||
<view class="elder-avatar">
|
||||
<image class="avatar-image" :src="elder.profile_picture" mode="aspectFill"
|
||||
@error="handleAvatarError" v-if="elder.profile_picture" />
|
||||
<text class="avatar-fallback" v-else>{{ elder.name.charAt(0) }}</text>
|
||||
</view>
|
||||
<view class="elder-details">
|
||||
<view class="elder-name-row">
|
||||
<text class="elder-name">{{ elder.name }}</text>
|
||||
<view class="elder-status" :class="elder.health_status">
|
||||
<text class="status-text">{{ getHealthStatusText(elder.health_status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="elder-info-text">{{ elder.age }}岁 · {{ elder.gender === 'male' ? '男' : '女' }}</text>
|
||||
<text class="elder-room">{{ elder.room_number }}房 {{ elder.bed_number }}床</text>
|
||||
<text class="elder-care-level">{{ getCareLevelText(elder.care_level) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="elder-actions">
|
||||
<button class="action-btn-small" @click="viewElderDetail(elder.id)">
|
||||
<text class="btn-text">详情</text>
|
||||
</button>
|
||||
<button class="action-btn-small edit" @click="editElder(elder.id)">
|
||||
<text class="btn-text">编辑</text>
|
||||
</button>
|
||||
<button class="action-btn-small health" @click="viewHealthRecord(elder.id)">
|
||||
<text class="btn-text">健康</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state" v-else>
|
||||
<text class="empty-icon">👥</text>
|
||||
<text class="empty-title">暂无老人信息</text>
|
||||
<text class="empty-subtitle">点击"新增老人"按钮添加第一位老人</text>
|
||||
<button class="empty-action-btn" @click="addNewElder">
|
||||
<text class="btn-text">新增老人</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 分页器 -->
|
||||
<view class="pagination" v-if="totalPages > 1">
|
||||
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">
|
||||
<text class="btn-text">上一页</text>
|
||||
</button>
|
||||
<view class="page-info">
|
||||
<text class="page-text">第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</text>
|
||||
</view>
|
||||
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">
|
||||
<text class="btn-text">下一页</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view class="loading-overlay" v-if="isLoading">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.elder-management {
|
||||
padding: 40rpx;
|
||||
background-color: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30rpx;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 24rpx 60rpx 24rpx 24rpx;
|
||||
background: white;
|
||||
border-radius: 24rpx;
|
||||
border: 1rpx solid #ddd;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 20rpx;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 32rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
border-radius: 20rpx;
|
||||
border: none;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #007AFF;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background: white;
|
||||
color: #007AFF;
|
||||
border: 1rpx solid #007AFF;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: white;
|
||||
padding: 40rpx;
|
||||
border-radius: 24rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
font-size: 24rpx;
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.stat-trend.positive {
|
||||
background: #e8f5e8;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.stat-percent {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
background: #f0f0f0;
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
flex-direction: row;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: inline-block;
|
||||
padding: 20rpx 30rpx;
|
||||
margin-right: 20rpx;
|
||||
background: white;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid #ddd;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-item.active {
|
||||
background: #007AFF;
|
||||
color: white;
|
||||
border-color: #007AFF;
|
||||
}
|
||||
|
||||
.elders-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.elder-card {
|
||||
background: white;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.elder-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.elder-avatar {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 50rpx;
|
||||
margin-right: 24rpx;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-fallback {
|
||||
font-size: 36rpx;
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.elder-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.elder-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.elder-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.elder-status {
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.elder-status.stable {
|
||||
background: #e8f5e8;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.elder-status.attention {
|
||||
background: #fff3e0;
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.elder-status.critical {
|
||||
background: #ffebee;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.elder-info-text {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.elder-room {
|
||||
font-size: 26rpx;
|
||||
color: #007AFF;
|
||||
display: block;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.elder-care-level {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
background: #f0f0f0;
|
||||
padding: 4rpx 8rpx;
|
||||
border-radius: 8rpx;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.elder-actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.action-btn-small {
|
||||
padding: 16rpx 20rpx;
|
||||
border-radius: 16rpx;
|
||||
border: none;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.action-btn-small.edit {
|
||||
background: #007AFF;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn-small.health {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 30rpx;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 20rpx 30rpx;
|
||||
background: white;
|
||||
border: 1rpx solid #ddd;
|
||||
border-radius: 16rpx;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
background: white;
|
||||
padding: 20rpx 30rpx;
|
||||
border-radius: 16rpx;
|
||||
border: 1rpx solid #ddd;
|
||||
}
|
||||
|
||||
.page-text {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 120rpx 40rpx;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 120rpx;
|
||||
display: block;
|
||||
margin-bottom: 30rpx;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 36rpx;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.empty-action-btn {
|
||||
padding: 30rpx 60rpx;
|
||||
background: #007AFF;
|
||||
color: white;
|
||||
border-radius: 20rpx;
|
||||
border: none;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255,255,255,0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border: 6rpx solid #f0f0f0;
|
||||
border-top: 6rpx solid #007AFF;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getHealthStatusText, getCareLevelText } from '../types.uts'
|
||||
import type { ElderInfo, ElderStats } from '../types.uts'
|
||||
|
||||
// 数据状态
|
||||
const elders = ref<ElderInfo[]>([])
|
||||
const elderStats = ref<ElderStats>({
|
||||
total: 0,
|
||||
new_this_month: 0,
|
||||
self_care: 0,
|
||||
assisted_care: 0,
|
||||
full_care: 0
|
||||
})
|
||||
|
||||
// UI状态
|
||||
const searchKeyword = ref('')
|
||||
const selectedCareLevel = ref('all')
|
||||
const selectedHealthStatus = ref('all')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const filteredElders = computed(() => {
|
||||
let filtered = [...elders.value]
|
||||
|
||||
// 搜索筛选
|
||||
if (searchKeyword.value.trim()) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
filtered = filtered.filter(elder =>
|
||||
elder.name.toLowerCase().includes(keyword) ||
|
||||
(elder.room_number && elder.room_number.includes(keyword)) ||
|
||||
(elder.bed_number && elder.bed_number.includes(keyword))
|
||||
)
|
||||
}
|
||||
|
||||
// 护理等级筛选
|
||||
if (selectedCareLevel.value !== 'all') {
|
||||
filtered = filtered.filter(elder => elder.care_level === selectedCareLevel.value)
|
||||
}
|
||||
|
||||
// 健康状态筛选
|
||||
if (selectedHealthStatus.value !== 'all') {
|
||||
filtered = filtered.filter(elder => elder.health_status === selectedHealthStatus.value)
|
||||
}
|
||||
|
||||
// 分页
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return filtered.slice(start, end)
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
const totalFiltered = getTotalFilteredCount()
|
||||
return Math.ceil(totalFiltered / pageSize.value)
|
||||
})
|
||||
|
||||
// 辅助函数
|
||||
function getTotalFilteredCount(): number {
|
||||
let filtered = [...elders.value]
|
||||
|
||||
if (searchKeyword.value.trim()) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
filtered = filtered.filter(elder =>
|
||||
elder.name.toLowerCase().includes(keyword) ||
|
||||
(elder.room_number && elder.room_number.includes(keyword)) ||
|
||||
(elder.bed_number && elder.bed_number.includes(keyword))
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedCareLevel.value !== 'all') {
|
||||
filtered = filtered.filter(elder => elder.care_level === selectedCareLevel.value)
|
||||
}
|
||||
|
||||
if (selectedHealthStatus.value !== 'all') {
|
||||
filtered = filtered.filter(elder => elder.health_status === selectedHealthStatus.value)
|
||||
}
|
||||
|
||||
return filtered.length
|
||||
}
|
||||
|
||||
function getSelfCarePercent(): number {
|
||||
if (elderStats.value.total === 0) return 0
|
||||
return Math.round((elderStats.value.self_care / elderStats.value.total) * 100)
|
||||
}
|
||||
|
||||
function getAssistedCarePercent(): number {
|
||||
if (elderStats.value.total === 0) return 0
|
||||
return Math.round((elderStats.value.assisted_care / elderStats.value.total) * 100)
|
||||
}
|
||||
|
||||
function getFullCarePercent(): number {
|
||||
if (elderStats.value.total === 0) return 0
|
||||
return Math.round((elderStats.value.full_care / elderStats.value.total) * 100)
|
||||
}
|
||||
|
||||
function handleAvatarError() {
|
||||
// 头像加载失败时的处理
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
function handleSearch() {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function filterByCareLevel(level: string) {
|
||||
selectedCareLevel.value = level
|
||||
selectedHealthStatus.value = 'all'
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function filterByHealthStatus(status: string) {
|
||||
selectedHealthStatus.value = status
|
||||
selectedCareLevel.value = 'all'
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
}
|
||||
}
|
||||
|
||||
function addNewElder() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/ec/admin/elder-form'
|
||||
})
|
||||
}
|
||||
|
||||
function viewElderDetail(elderId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/admin/elder-detail?elder_id=${elderId}`
|
||||
})
|
||||
}
|
||||
|
||||
function editElder(elderId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/admin/elder-form?elder_id=${elderId}`
|
||||
})
|
||||
}
|
||||
|
||||
function viewHealthRecord(elderId: string) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ec/admin/health-record?elder_id=${elderId}`
|
||||
})
|
||||
}
|
||||
|
||||
async function exportElders() {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const supa = (globalThis as any).supa
|
||||
const result = await supa.executeAs('export_elders', {
|
||||
filters: {
|
||||
care_level: selectedCareLevel.value,
|
||||
health_status: selectedHealthStatus.value,
|
||||
search_keyword: searchKeyword.value
|
||||
}
|
||||
})
|
||||
|
||||
if (result && result.length > 0) {
|
||||
uni.showToast({
|
||||
title: '导出成功',
|
||||
icon: 'success'
|
||||
})
|
||||
// 这里可以处理导出文件
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
uni.showToast({
|
||||
title: '导出失败',
|
||||
icon: 'error'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 数据加载
|
||||
async function loadElders() {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const supa = (globalThis as any).supa
|
||||
const result = await supa.executeAs('get_elders_list', {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value
|
||||
})
|
||||
|
||||
if (result && result.length > 0) {
|
||||
elders.value = result
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载老人列表失败:', error)
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'error'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadElderStats() {
|
||||
try {
|
||||
const supa = (globalThis as any).supa
|
||||
const result = await supa.executeAs('get_elder_stats')
|
||||
|
||||
if (result && result.length > 0) {
|
||||
elderStats.value = result[0]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadElders(),
|
||||
loadElderStats()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
1253
pages/ec/admin/health-monitoring.uvue
Normal file
1253
pages/ec/admin/health-monitoring.uvue
Normal file
File diff suppressed because it is too large
Load Diff
337
pages/ec/admin/service-records.uvue
Normal file
337
pages/ec/admin/service-records.uvue
Normal file
@@ -0,0 +1,337 @@
|
||||
|
||||
<template>
|
||||
<view class="all-service-records">
|
||||
<view class="header">
|
||||
<text class="header-title">全部服务记录</text>
|
||||
<button class="refresh-btn" @click="refreshData">
|
||||
<text class="refresh-text">🔄 刷新</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="filters-section">
|
||||
<view class="filter-row">
|
||||
<view class="filter-group">
|
||||
<text class="filter-label">老人</text>
|
||||
<button class="picker-btn" @click="showElderActionSheet">
|
||||
<text class="picker-text">{{ selectedElder?.name ?? '全部' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="filter-group">
|
||||
<text class="filter-label">服务类型</text>
|
||||
<button class="picker-btn" @click="showTypeActionSheet">
|
||||
<text class="picker-text">{{ selectedType?.label ?? '全部' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="filter-group">
|
||||
<text class="filter-label">时间范围</text>
|
||||
<button class="picker-btn" @click="showTimeRangeActionSheet">
|
||||
<text class="picker-text">{{ selectedTimeRange?.label ?? '近7天' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="records-list" direction="vertical" :style="{ height: '500px' }">
|
||||
<view v-for="record in filteredRecords" :key="record.id" class="record-item" @click="viewDetail(record)">
|
||||
<view class="record-header">
|
||||
<text class="elder-name">{{ record.elder_name ?? '未知' }}</text>
|
||||
<text class="service-type">{{ serviceTypeLabel(record.service_type) }}</text>
|
||||
<text class="record-time">{{ formatDateTime(record.created_at ?? '') }}</text>
|
||||
</view>
|
||||
<view class="record-content">
|
||||
<text v-if="record.caregiver_name">护理员: {{ record.caregiver_name }}</text>
|
||||
<text v-if="record.doctor_name">医生: {{ record.doctor_name }}</text>
|
||||
<text v-if="record.meal_type">餐次: {{ record.meal_type }}</text>
|
||||
<text v-if="record.activity_name">活动: {{ record.activity_name }}</text>
|
||||
<text class="notes" v-if="record.notes">备注: {{ record.notes }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="filteredRecords.length === 0" class="empty-state">
|
||||
<text class="empty-text">暂无服务记录</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
import { formatDateTime as formatDateTimeUtil } from '../types.uts'
|
||||
|
||||
type AggregatedServiceRecord = {
|
||||
id: string
|
||||
service_type: 'nursing' | 'medical' | 'meal' | 'activity'
|
||||
elder_id: string
|
||||
elder_name?: string
|
||||
caregiver_name?: string
|
||||
doctor_name?: string
|
||||
meal_type?: string
|
||||
activity_name?: string
|
||||
created_at: string
|
||||
notes?: string
|
||||
}
|
||||
type Elder = { id: string, name: string }
|
||||
type FilterOption = { value: string, label: string }
|
||||
|
||||
const records = ref<AggregatedServiceRecord[]>([])
|
||||
const elders = ref<Elder[]>([])
|
||||
const selectedElderIndex = ref<number>(-1)
|
||||
const selectedTypeIndex = ref<number>(-1)
|
||||
const selectedTimeRangeIndex = ref<number>(1)
|
||||
|
||||
const typeOptions = ref<FilterOption[]>([
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'nursing', label: '护理' },
|
||||
{ value: 'medical', label: '医疗' },
|
||||
{ value: 'meal', label: '餐饮' },
|
||||
{ value: 'activity', label: '活动' }
|
||||
])
|
||||
const timeRangeOptions = ref<FilterOption[]>([
|
||||
{ value: '3days', label: '近3天' },
|
||||
{ value: '7days', label: '近7天' },
|
||||
{ value: '30days', label: '近30天' }
|
||||
])
|
||||
|
||||
const elderOptions = computed<Elder[]>(() => [ { id: 'all', name: '全部' }, ...elders.value ])
|
||||
const selectedElder = computed(() => elderOptions.value[selectedElderIndex.value] ?? elderOptions.value[0])
|
||||
const selectedType = computed(() => typeOptions.value[selectedTypeIndex.value] ?? typeOptions.value[0])
|
||||
const selectedTimeRange = computed(() => timeRangeOptions.value[selectedTimeRangeIndex.value] ?? timeRangeOptions.value[1])
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
let list = records.value
|
||||
if (selectedElder.value.id !== 'all') {
|
||||
list = list.filter(r => r.elder_id === selectedElder.value.id)
|
||||
}
|
||||
if (selectedType.value.value !== 'all') {
|
||||
list = list.filter(r => r.service_type === selectedType.value.value)
|
||||
}
|
||||
// 时间范围
|
||||
const now = new Date()
|
||||
let startDate = new Date()
|
||||
if (selectedTimeRange.value.value === '3days') startDate.setDate(now.getDate() - 3)
|
||||
else if (selectedTimeRange.value.value === '7days') startDate.setDate(now.getDate() - 7)
|
||||
else if (selectedTimeRange.value.value === '30days') startDate.setDate(now.getDate() - 30)
|
||||
list = list.filter(r => r.created_at >= startDate.toISOString())
|
||||
return list
|
||||
})
|
||||
|
||||
const formatDateTime = (dt: string) => formatDateTimeUtil(dt)
|
||||
const serviceTypeLabel = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
nursing: '护理',
|
||||
medical: '医疗',
|
||||
meal: '餐饮',
|
||||
activity: '活动'
|
||||
}
|
||||
return map[type] ?? type
|
||||
}
|
||||
|
||||
const refreshData = () => { loadRecords(); loadElders(); }
|
||||
|
||||
const loadRecords = async () => {
|
||||
try {
|
||||
// 聚合查询:分别查四个表,合并后排序
|
||||
const [nursing, medical, meal, activity] = await Promise.all([
|
||||
supa.from('ec_care_records').select('id, elder_id, ec_care_records_elder_id_fkey(name), ec_care_records_caregiver_id_fkey(username), created_at, issues_notes', {}).order('created_at', { ascending: false }).limit(50).executeAs<any[]>(),
|
||||
supa.from('ec_medical_records').select('id, elder_id, ec_medical_records_elder_id_fkey(name), doctor_id, created_at, diagnosis, notes', {}).order('created_at', { ascending: false }).limit(50).executeAs<any[]>(),
|
||||
supa.from('ec_meal_records').select('id, elder_id, ec_meal_records_elder_id_fkey(name), meal_type, created_at, notes', {}).order('created_at', { ascending: false }).limit(50).executeAs<any[]>(),
|
||||
supa.from('ec_activity_participations').select('id, elder_id, ec_activity_participations_elder_id_fkey(name), activity_id, created_at, behavior_notes', {}).order('created_at', { ascending: false }).limit(50).executeAs<any[]>()
|
||||
])
|
||||
const nList = (nursing.data ?? []).map(r => ({
|
||||
id: r.id,
|
||||
service_type: 'nursing',
|
||||
elder_id: r.elder_id,
|
||||
elder_name: r.ec_care_records_elder_id_fkey?.name,
|
||||
caregiver_name: r.ec_care_records_caregiver_id_fkey?.username,
|
||||
created_at: r.created_at,
|
||||
notes: r.issues_notes
|
||||
}))
|
||||
const mList = (medical.data ?? []).map(r => ({
|
||||
id: r.id,
|
||||
service_type: 'medical',
|
||||
elder_id: r.elder_id,
|
||||
elder_name: r.ec_medical_records_elder_id_fkey?.name,
|
||||
doctor_name: r.doctor_id, // 可进一步 join doctor name
|
||||
created_at: r.created_at,
|
||||
notes: r.diagnosis || r.notes
|
||||
}))
|
||||
const mealList = (meal.data ?? []).map(r => ({
|
||||
id: r.id,
|
||||
service_type: 'meal',
|
||||
elder_id: r.elder_id,
|
||||
elder_name: r.ec_meal_records_elder_id_fkey?.name,
|
||||
meal_type: r.meal_type,
|
||||
created_at: r.created_at,
|
||||
notes: r.notes
|
||||
}))
|
||||
const aList = (activity.data ?? []).map(r => ({
|
||||
id: r.id,
|
||||
service_type: 'activity',
|
||||
elder_id: r.elder_id,
|
||||
elder_name: r.ec_activity_participations_elder_id_fkey?.name,
|
||||
activity_name: r.activity_id, // 可进一步 join activity name
|
||||
created_at: r.created_at,
|
||||
notes: r.behavior_notes
|
||||
}))
|
||||
// 合并并按时间排序
|
||||
const all = [...nList, ...mList, ...mealList, ...aList].sort((a, b) => b.created_at.localeCompare(a.created_at))
|
||||
records.value = all
|
||||
} catch (e) { console.error('加载服务记录失败', e) }
|
||||
}
|
||||
const loadElders = async () => {
|
||||
try {
|
||||
const result = await supa
|
||||
.from('ec_elders')
|
||||
.select('id, name', {})
|
||||
.eq('status', 'active')
|
||||
.order('name', { ascending: true })
|
||||
.executeAs<Elder[]>()
|
||||
if (result.error == null && result.data != null) {
|
||||
elders.value = result.data
|
||||
}
|
||||
} catch (e) { console.error('加载老人列表失败', e) }
|
||||
}
|
||||
const showElderActionSheet = () => {
|
||||
const options = elderOptions.value.map(e => e.name)
|
||||
uni.showActionSheet({
|
||||
itemList: options,
|
||||
success: (res:any) => { selectedElderIndex.value = res.tapIndex }
|
||||
})
|
||||
}
|
||||
const showTypeActionSheet = () => {
|
||||
const options = typeOptions.value.map(t => t.label)
|
||||
uni.showActionSheet({
|
||||
itemList: options,
|
||||
success: (res:any) => { selectedTypeIndex.value = res.tapIndex }
|
||||
})
|
||||
}
|
||||
const showTimeRangeActionSheet = () => {
|
||||
const options = timeRangeOptions.value.map(t => t.label)
|
||||
uni.showActionSheet({
|
||||
itemList: options,
|
||||
success: (res:any) => { selectedTimeRangeIndex.value = res.tapIndex }
|
||||
})
|
||||
}
|
||||
const viewDetail = (record: AggregatedServiceRecord) => {
|
||||
// 可根据 service_type 跳转不同详情页
|
||||
uni.navigateTo({ url: `/pages/ec/admin/service-record-detail?id=${record.id}&type=${record.service_type}` })
|
||||
}
|
||||
onMounted(() => { refreshData() })
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.all-service-records {
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.refresh-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #52c41a;
|
||||
background-color: #52c41a;
|
||||
color: white;
|
||||
}
|
||||
.filters-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-group {
|
||||
flex: 1;
|
||||
margin-right: 15px;
|
||||
}
|
||||
.filter-group.is-last {
|
||||
margin-right: 0;
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
.picker-btn {
|
||||
width: 180rpx;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.picker-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #f9f9f9;
|
||||
display: block;
|
||||
}
|
||||
.records-list {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
min-height: 300px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.record-item {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.record-item.is-last {
|
||||
border-bottom: none;
|
||||
}
|
||||
.record-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.elder-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.service-type {
|
||||
font-size: 14px;
|
||||
color: #1890ff;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.record-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.record-content {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.notes {
|
||||
color: #faad14;
|
||||
}
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-text {
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user