petstore-admin/src/views/AppointmentsView.vue
2026-08-01 23:47:58 +08:00

307 lines
10 KiB
Vue
Raw 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>
<div class="page-card">
<h2 class="page-title">预约中心</h2>
<div class="filter-row">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日"
end-placeholder="结束日"
style="width: 260px"
/>
<el-select v-model="status" clearable placeholder="状态" style="width: 140px">
<el-option label="待开始" value="new" />
<el-option label="服务中" value="doing" />
<el-option label="已完成" value="done" />
<el-option label="已取消" value="cancel" />
</el-select>
<el-select v-model="staffId" clearable placeholder="技师" style="width: 140px">
<el-option v-for="s in staffOptions" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-checkbox v-model="missingReport">待出报告</el-checkbox>
<el-checkbox v-model="overdueOnly">已过预约时间</el-checkbox>
<el-checkbox v-model="completedTodayOnly">今日完成</el-checkbox>
<el-input v-model="keyword" clearable placeholder="宠主/手机/宠物名" style="width: 200px" />
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe>
<el-table-column label="预约时间" min-width="210">
<template #default="{ row }">{{ appointmentRange(row) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">{{ statusLabel(row.status) }}</template>
</el-table-column>
<el-table-column label="宠主" min-width="140">
<template #default="{ row }">
{{ row.customerName || '—' }}
<span class="muted">{{ row.customerPhoneMasked || '' }}</span>
</template>
</el-table-column>
<el-table-column prop="petName" label="宠物" width="100" />
<el-table-column prop="serviceType" label="服务" min-width="110" />
<el-table-column prop="staffName" label="技师" width="100" />
<el-table-column label="报告" width="80">
<template #default="{ row }">
<el-button v-if="row.hasReport && row.reportId" link type="primary" @click="goReport(row)">有</el-button>
<span v-else>无</span>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button v-if="row.status === 'new'" link type="primary" @click="onStart(row)">开始服务</el-button>
<el-button
v-if="row.status === 'new' || row.status === 'doing'"
link
type="danger"
@click="onCancel(row)"
>取消</el-button>
</template>
</el-table-column>
</el-table>
<el-drawer v-model="drawer" title="预约详情" size="420px">
<template v-if="current">
<el-descriptions :column="1" border>
<el-descriptions-item label="时间">{{ appointmentRange(current) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(current.status) }}</el-descriptions-item>
<el-descriptions-item label="宠主">{{ current.customerName }} {{ current.customerPhoneMasked }}</el-descriptions-item>
<el-descriptions-item label="宠物">{{ current.petName }} / {{ current.petType }}</el-descriptions-item>
<el-descriptions-item label="服务">{{ current.serviceType }}</el-descriptions-item>
<el-descriptions-item label="技师">{{ current.staffName || '—' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ current.remark || '—' }}</el-descriptions-item>
<el-descriptions-item label="报告">{{ current.hasReport ? `已有 #${current.reportId}` : '无' }}</el-descriptions-item>
</el-descriptions>
<el-alert
class="hint"
type="info"
:closable="false"
title="拍照与填报告请回小程序完成"
/>
<div class="drawer-actions">
<el-button v-if="current.status === 'new'" type="primary" @click="onStart(current)">开始服务</el-button>
<el-button v-if="current.hasReport && current.reportId" @click="goReport(current)">查看报告</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
cancelAppointment,
getAppointmentDetail,
getAppointmentList,
getStaffList,
startAppointment,
} from '@/api'
type Appt = {
id: number
appointmentTime?: string
endTime?: string
durationMinutes?: number
status?: string
petName?: string
petType?: string
serviceType?: string
customerUserId?: number
createdByUserId?: number
assignedStaffId?: number
assignedUserId?: number
staffName?: string
customerName?: string
customerPhoneMasked?: string
hasReport?: boolean
reportId?: number | null
remark?: string
updateTime?: string
}
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const rows = ref<Appt[]>([])
const status = ref<string>((route.query.status as string) || '')
const keyword = ref('')
const dateFilter = ref((route.query.date as string) || '')
const dateRange = ref<[string, string] | ''>('')
const staffId = ref<number | undefined>()
const missingReport = ref(route.query.missingReport === '1')
const overdueOnly = ref(route.query.overdue === '1')
const completedTodayOnly = ref(route.query.completedToday === '1')
function appointmentRange(row: Appt): string {
const start = String(row.appointmentTime || '').replace('T', ' ').slice(0, 16)
const end = String(row.endTime || '').slice(11, 16)
if (!start) return '—'
return end ? `${start}${end}` : start
}
const staffOptions = ref<Array<{ id: number; name: string }>>([])
const drawer = ref(false)
const current = ref<Appt | null>(null)
function statusLabel(s?: string) {
switch (s) {
case 'new': return '待开始'
case 'doing': return '服务中'
case 'done': return '已完成'
case 'cancel': return '已取消'
default: return s || '-'
}
}
function todayPrefix() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function inDateRange(iso?: string) {
if (!iso) return false
const day = String(iso).slice(0, 10)
if (dateFilter.value === 'today') {
return day === todayPrefix()
}
if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
return day >= dateRange.value[0] && day <= dateRange.value[1]
}
return true
}
async function loadStaff() {
const res = await getStaffList()
const list = Array.isArray(res.data) ? (res.data as Array<Record<string, unknown>>) : []
staffOptions.value = list.map((u) => ({
id: Number(u.id),
name: String(u.name || u.phone || u.id),
}))
}
async function load() {
loading.value = true
try {
const res = await getAppointmentList({
status: status.value || undefined,
page: 1,
pageSize: 200,
})
let list = Array.isArray(res.data) ? (res.data as Appt[]) : []
list = list.filter((a) => inDateRange(a.appointmentTime))
if (staffId.value != null) {
list = list.filter((a) => (a.assignedStaffId ?? a.assignedUserId) === staffId.value)
}
if (missingReport.value) {
list = list.filter((a) => a.status === 'doing' && !a.hasReport)
}
if (overdueOnly.value) {
const now = Date.now()
list = list.filter((a) => {
const appointmentAt = Date.parse(String(a.appointmentTime || ''))
return a.status === 'new' && !Number.isNaN(appointmentAt) && appointmentAt < now
})
}
if (completedTodayOnly.value) {
const today = todayPrefix()
list = list.filter((a) => a.status === 'done' && String(a.updateTime || '').startsWith(today))
}
if (keyword.value.trim()) {
const q = keyword.value.trim()
list = list.filter((a) =>
(a.petName || '').includes(q)
|| (a.customerName || '').includes(q)
|| (a.customerPhoneMasked || '').includes(q),
)
}
rows.value = list
} finally {
loading.value = false
}
}
function reset() {
status.value = ''
keyword.value = ''
dateFilter.value = ''
dateRange.value = ''
staffId.value = undefined
missingReport.value = false
overdueOnly.value = false
completedTodayOnly.value = false
load()
}
async function openDetail(row: Appt) {
const res = await getAppointmentDetail(row.id)
if (res.code !== 200 || !res.data) {
ElMessage.error(res.message || '加载详情失败')
return
}
current.value = res.data as Appt
drawer.value = true
}
function goReport(row: Appt) {
if (!row.reportId) return
router.push({ path: '/reports', query: { q: String(row.reportId) } })
}
async function onStart(row: Appt) {
await ElMessageBox.confirm(`确认开始服务「${row.petName || row.id}」?`, '开始服务')
const res = await startAppointment(row.id)
if (res.code !== 200) {
ElMessage.error(res.message || '开始失败')
return
}
ElMessage.success('已开始服务')
drawer.value = false
await load()
}
async function onCancel(row: Appt) {
await ElMessageBox.confirm(`确认取消预约「${row.petName || row.id}」?`, '取消预约', { type: 'warning' })
const res = await cancelAppointment(row.id)
if (res.code !== 200) {
ElMessage.error(res.message || '取消失败')
return
}
ElMessage.success('已取消')
drawer.value = false
await load()
}
onMounted(async () => {
if (dateFilter.value === 'today') {
const t = todayPrefix()
dateRange.value = [t, t]
}
await loadStaff()
await load()
const focusId = Number(route.query.id)
if (Number.isFinite(focusId) && focusId > 0) {
await openDetail({ id: focusId })
}
})
</script>
<style scoped>
.muted {
margin-left: 6px;
color: #8a94a6;
font-size: 12px;
}
.hint {
margin-top: 16px;
}
.drawer-actions {
margin-top: 16px;
display: flex;
gap: 8px;
}
</style>