This commit is contained in:
huangjin
2026-06-29 17:34:59 +08:00
commit fe3ad20fe2
271 changed files with 51767 additions and 0 deletions

View File

@@ -0,0 +1,345 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold text-gray-800">预约管理</h1>
</div>
<el-card shadow="never" class="mb-3">
<el-row :gutter="12" align="middle">
<el-col :span="4">
<el-select v-model="statusFilter" placeholder="审批状态" clearable class="w-full">
<el-option label="全部" value="" />
<el-option label="待审核" :value="0" />
<el-option label="已通过" :value="1" />
<el-option label="已拒绝" :value="2" />
<el-option label="已取消" :value="3" />
</el-select>
</el-col>
<el-col :span="2">
<el-button type="primary" @click="page = 1; fetchData()">查询</el-button>
</el-col>
</el-row>
</el-card>
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
<el-table :data="appointments" stripe class="w-full" v-loading="loading">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="外部来访人员" min-width="180">
<template #default="{ row }">
<div class="flex flex-col text-sm leading-relaxed">
<template v-if="parseVisitors(row.visitor_info)?.length">
<span class="text-gray-800">
{{ parseVisitors(row.visitor_info)[0].name }}
<span v-if="parseVisitors(row.visitor_info).length > 1" class="text-gray-400 font-normal">
{{ parseVisitors(row.visitor_info).length }}
</span>
</span>
<span v-if="parseVisitors(row.visitor_info)[0]?.company" class="text-gray-400 text-xs">{{ parseVisitors(row.visitor_info)[0]?.company }}</span>
</template>
<span v-else class="text-gray-800">-</span>
</div>
</template>
</el-table-column>
<el-table-column label="公司接待人员" min-width="100">
<template #default="{ row }">
<span>{{ parseEmployee(row.employee_info)?.name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="来访事由" min-width="140">
<template #default="{ row }">
<span>{{ row.visit_purpose || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="来访时间" min-width="130">
<template #default="{ row }">
<span class="text-sm">{{ formatTime(row.visit_start_time) }} ~ {{ formatTime(row.visit_end_time) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="['warning','success','danger','info'][row.status]">
{{ ['待审核','已通过','已拒绝','已取消'][row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="total > 0" class="p-4 flex justify-center">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total,prev,pager,next"
@current-change="fetchData"
/>
</div>
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="预约详情" width="600px" :close-on-click-modal="false">
<template v-if="detailRow">
<div class="space-y-5">
<!-- 基本信息 -->
<div>
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-blue-500 rounded" />
<span class="text-sm font-medium text-gray-700">基本信息</span>
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-gray-50 rounded-lg p-4">
<div><span class="text-gray-400">预约ID</span><span class="text-gray-700">{{ detailRow.id }}</span></div>
<div><span class="text-gray-400">来访事由</span><span class="text-gray-700">{{ detailRow.visit_purpose || '-' }}</span></div>
<div class="col-span-2">
<span class="text-gray-400">来访时间</span>
<span class="text-gray-700">{{ formatTime(detailRow.visit_start_time) }} ~ {{ formatTime(detailRow.visit_end_time) }}</span>
</div>
<div>
<span class="text-gray-400">状态</span>
<el-tag :type="['warning','success','danger','info'][detailRow.status]" size="small">
{{ ['待审核','已通过','已拒绝','已取消'][detailRow.status] }}
</el-tag>
</div>
<div><span class="text-gray-400">创建时间</span><span class="text-gray-700">{{ formatTime(detailRow.created_at) }}</span></div>
</div>
</div>
<!-- 访客信息 -->
<div v-if="parsedVisitors?.length">
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-blue-500 rounded" />
<span class="text-sm font-medium text-gray-700">外部来访人员</span>
</div>
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<div v-for="(v, i) in parsedVisitors" :key="i" class="flex items-center gap-3 text-sm">
<span class="text-gray-800 font-medium">{{ v.name }}</span>
<span v-if="v.phone" class="text-gray-500">{{ v.phone }}</span>
<span v-if="v.company" class="text-gray-400">{{ v.company }}</span>
</div>
</div>
</div>
<!-- 被访人 -->
<div v-if="parsedEmployee">
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-blue-500 rounded" />
<span class="text-sm font-medium text-gray-700">公司接待人员</span>
</div>
<div class="bg-gray-50 rounded-lg p-4 text-sm">
<span class="text-gray-800 font-medium">{{ parsedEmployee.name }}</span>
<span v-if="parsedEmployee.phone" class="text-gray-500 ml-2">{{ parsedEmployee.phone }}</span>
<span v-if="parsedEmployee.department" class="text-gray-400 ml-2">{{ parsedEmployee.department }}</span>
</div>
</div>
<!-- 创建人 -->
<div v-if="parsedCreator">
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-blue-500 rounded" />
<span class="text-sm font-medium text-gray-700">创建人</span>
</div>
<div class="bg-gray-50 rounded-lg p-4 text-sm">
<span class="text-gray-800 font-medium">{{ parsedCreator.name }}</span>
<span v-if="parsedCreator.phone" class="text-gray-500 ml-2">{{ parsedCreator.phone }}</span>
</div>
</div>
<!-- 携带物品 -->
<div v-if="parsedGoods?.length">
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-blue-500 rounded" />
<span class="text-sm font-medium text-gray-700">携带物品</span>
</div>
<div class="bg-gray-50 rounded-lg p-4 space-y-1.5">
<div v-for="(g, i) in parsedGoods" :key="i" class="text-sm">
<span class="text-gray-800">{{ g.name }}</span>
<span v-if="g.quantity" class="text-gray-500 ml-1">x{{ g.quantity }}</span>
<span v-if="g.remark" class="text-gray-400 ml-2">{{ g.remark }}</span>
</div>
</div>
</div>
<!-- 车辆信息 -->
<div v-if="parsedVehicles?.length">
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-blue-500 rounded" />
<span class="text-sm font-medium text-gray-700">车辆信息</span>
</div>
<div class="bg-gray-50 rounded-lg p-4 space-y-1.5">
<div v-for="(v, i) in parsedVehicles" :key="i" class="text-sm">
<span class="text-gray-400">车辆{{ i + 1 }}</span>
<span class="text-gray-800">{{ [v.brand, v.plate, v.color].filter(Boolean).join(' ') }}</span>
</div>
</div>
</div>
<!-- 到访区域 -->
<div v-if="parsedAreas?.length">
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-blue-500 rounded" />
<span class="text-sm font-medium text-gray-700">到访区域</span>
</div>
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-700">{{ parsedAreas.join('、') }}</div>
</div>
<!-- 备注 -->
<div v-if="detailRow.remark">
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-blue-500 rounded" />
<span class="text-sm font-medium text-gray-700">备注</span>
</div>
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">{{ detailRow.remark }}</div>
</div>
<!-- 审核意见 -->
<div v-if="detailRow.comment">
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-blue-500 rounded" />
<span class="text-sm font-medium text-gray-700">审核意见</span>
</div>
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">{{ detailRow.comment }}</div>
</div>
<!-- 审批进度 -->
<div>
<div class="flex items-center gap-2 mb-3">
<div class="w-1 h-4 bg-blue-500 rounded" />
<span class="text-sm font-medium text-gray-700">审批进度</span>
</div>
<div v-if="workflowTasks.length === 0 && !workflowLoading" class="text-sm text-gray-400 bg-gray-50 rounded-lg p-4">暂无审批记录</div>
<div v-else-if="workflowLoading" class="text-sm text-gray-400 bg-gray-50 rounded-lg p-4">加载中...</div>
<div v-else class="space-y-0">
<div
v-for="(t, idx) in workflowTasks"
:key="t.id"
class="flex gap-3 pb-4"
:class="{ 'pb-0': idx === workflowTasks.length - 1 }"
>
<div class="flex flex-col items-center">
<div
class="w-3 h-3 rounded-full border-2 shrink-0 mt-1"
:class="{
'bg-green-500 border-green-500': t.status === 1,
'bg-red-500 border-red-500': t.status === 2,
'bg-orange-400 border-orange-400': t.status === 0,
'bg-gray-200 border-gray-300': t.status === 3 || !t.status,
}"
/>
<div v-if="idx < workflowTasks.length - 1" class="w-0.5 flex-1 mt-1"
:class="t.status === 1 ? 'bg-green-300' : 'bg-gray-200'" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700">{{ t.node_name }}</span>
<el-tag v-if="t.status === 0" type="warning" size="small">待审批</el-tag>
<el-tag v-else-if="t.status === 1" type="success" size="small">已通过</el-tag>
<el-tag v-else-if="t.status === 2" type="danger" size="small">已拒绝</el-tag>
<el-tag v-else size="small" type="info">已取消</el-tag>
</div>
<div class="text-xs text-gray-400 mt-0.5">
<template v-if="t.assignee">{{ t.assignee.real_name || t.assignee.nickname }}</template>
<template v-if="t.handled_at"> · {{ formatTime(t.handled_at) }}</template>
</div>
<div v-if="t.comment" class="text-xs text-gray-500 mt-0.5 bg-white rounded px-2 py-1 border">
{{ t.comment }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { onMounted } from 'vue'
const appointments = ref<any[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const statusFilter = ref('')
const detailVisible = ref(false)
const detailRow = ref<any>(null)
const workflowTasks = ref<any[]>([])
const workflowLoading = ref(false)
const parsedVisitors = computed(() => detailRow.value ? parseVisitors(detailRow.value.visitor_info) : null)
const parsedEmployee = computed(() => detailRow.value ? parseEmployee(detailRow.value.employee_info) : null)
const parsedCreator = computed(() => detailRow.value ? parseCreator(detailRow.value.creator_info) : null)
const parsedAreas = computed(() => detailRow.value ? parseAreas(detailRow.value.areas_info) : null)
const parsedGoods = computed(() => detailRow.value ? parseGoods(detailRow.value.goods_info) : null)
const parsedVehicles = computed(() => detailRow.value ? parseVehicles(detailRow.value.vehicle_info) : null)
function parseVisitors(info) {
if (!info) return null
try {
const parsed = JSON.parse(info)
// 对象格式:{ name, phone, company, visitors: [...] }
if (!Array.isArray(parsed) && parsed?.visitors) {
return parsed.visitors
}
// 已经是数组
if (Array.isArray(parsed)) return parsed
// 单个对象包装为数组
return [parsed]
} catch { return null }
}
function parseEmployee(info) { if (!info) return null; try { return JSON.parse(info) } catch { return null } }
function parseCreator(info) { if (!info) return null; try { const p = JSON.parse(info); return p.name ? p : null } catch { return null } }
function parseAreas(info) { if (!info) return null; try { return JSON.parse(info).map(a => a.area || a) } catch { return null } }
function parseGoods(info) { if (!info) return null; try { return JSON.parse(info) } catch { return null } }
function parseVehicles(info) { if (!info) return null; try { return JSON.parse(info) } catch { return null } }
function formatTime(t) {
if (!t) return '-'
const d = new Date(t)
const pad = n => n.toString().padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function fetchData() {
loading.value = true
try {
const params = { page: page.value, page_size: pageSize.value }
if (statusFilter.value !== '') params.status = statusFilter.value
const res = await request.Get('/v1/admin/appointments', { params })
appointments.value = res?.data?.list || []
total.value = res?.data?.total || 0
} catch { ElMessage.error('加载失败') }
finally { loading.value = false }
}
// 加载工作流审批记录
async function openDetail(row) {
detailRow.value = row
detailVisible.value = true
workflowTasks.value = []
workflowLoading.value = true
try {
// 查询工作流实例
const instances = await request.Get('/v1/workflow/instances', {
params: { business_type: 'appointment', business_id: row.id },
}) as any
const list = instances?.data?.list || []
if (list.length > 0) {
const detail = await request.Get(`/v1/workflow/instances/${list[0].id}`) as any
workflowTasks.value = detail?.data?.tasks || []
}
} catch { /* ignore */ }
finally { workflowLoading.value = false }
}
onMounted(fetchData)
</script>