Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View File

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>