Files
akmon/pages/ec/visitor/management.uvue
2026-01-20 08:04:15 +08:00

1242 lines
32 KiB
Plaintext
Raw Permalink Blame History

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