| | |
| | | </el-select> |
| | | </el-form-item> |
| | | <el-form-item label="访问区域"> |
| | | <el-select v-model="filters.accessArea" placeholder="请选择" clearable filterable> |
| | | <el-option v-for="area in passAreaOptions" :key="area.id" :label="area.areaName" :value="area.areaName" /> |
| | | </el-select> |
| | | <el-tree-select |
| | | v-model="filters.accessAreaId" |
| | | :data="passAreaOptions" |
| | | :props="{ label: 'areaName', children: 'children', value: 'id' }" |
| | | value-key="id" |
| | | node-key="id" |
| | | check-strictly |
| | | clearable |
| | | filterable |
| | | placeholder="请选择" |
| | | /> |
| | | </el-form-item> |
| | | <el-form-item label="属地单位"> |
| | | <el-select v-model="filters.localUnit" placeholder="请选择" clearable filterable> |
| | | <el-option v-for="unit in localUnitOptions" :key="unit.id" :label="unit.unitName" :value="unit.unitName" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <div class="search-actions top-actions"> |
| | | <el-button @click="resetSearch">重置</el-button> |
| | | <el-button type="primary" native-type="submit">搜索</el-button> |
| | | </div> |
| | | |
| | | <el-form-item label="来访时间" class="date-span-item"> |
| | | <el-date-picker |
| | | v-model="visitDateRange" |
| | |
| | | <el-option v-for="reason in visitReasonOptions" :key="reason.reasonCode" :label="reason.reasonName" :value="reason.reasonCode" /> |
| | | </el-select> |
| | | </el-form-item> |
| | | <div class="search-actions"> |
| | | <el-button @click="resetSearch">重置</el-button> |
| | | <el-button type="primary" native-type="submit">搜索</el-button> |
| | | </div> |
| | | </el-form> |
| | | </div> |
| | | |
| | |
| | | <el-button v-permission="'appointments:export'" type="primary" plain :loading="exporting" @click="exportExcel">导出</el-button> |
| | | </div> |
| | | |
| | | <el-table :data="rows" v-loading="loading" stripe border> |
| | | <div class="table-wrap"> |
| | | <el-table :data="rows" v-loading="loading" stripe border height="100%"> |
| | | <el-table-column label="序号" width="68" type="index" :index="indexMethod" /> |
| | | <el-table-column prop="visitTypeText" label="来访类型" width="110" /> |
| | | <el-table-column prop="visitedUserName" label="被访人" width="110" show-overflow-tooltip /> |
| | |
| | | <el-table-column prop="localUnitText" label="涉及属地单位" min-width="150" show-overflow-tooltip /> |
| | | <el-table-column prop="plateNoText" label="车牌号" width="120" show-overflow-tooltip /> |
| | | <el-table-column prop="visitReasonText" label="来访事由" min-width="140" show-overflow-tooltip /> |
| | | <el-table-column prop="visitReasonOther" label="事由说明" min-width="160" show-overflow-tooltip /> |
| | | <el-table-column prop="visitTimeText" label="来访时间" width="180" show-overflow-tooltip /> |
| | | <el-table-column label="预约状态" width="100" align="center"> |
| | | <template #default="{ row }"> |
| | |
| | | <el-button v-permission="'appointments:detail'" type="primary" link @click="openDetail(row)">详情</el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </el-table> |
| | | </div> |
| | | |
| | | <div class="pager-wrap"> |
| | | <div class="pager-total">共 {{ total }} 条</div> |
| | |
| | | <div class="detail-title-row"> |
| | | <div class="detail-title">预约信息</div> |
| | | <div class="detail-statuses"> |
| | | <el-tag :type="approvalStatusTagType(detailData.approvalStatusCode)" effect="plain"> |
| | | {{ detailData.approvalStatusText }} |
| | | </el-tag> |
| | | <el-tag :type="displayStatusTagType(detailData.displayStatusCode)" effect="plain"> |
| | | {{ detailData.displayStatusText }} |
| | | </el-tag> |
| | | <el-tag :type="approvalStatusTagType(detailData.approvalStatusCode)" effect="plain"> |
| | | {{ detailData.approvalStatusText }} |
| | | </el-tag> |
| | | </div> |
| | | </div> |
| | |
| | | </div> |
| | | <div class="detail-item"> |
| | | <div class="detail-label">来访时间</div> |
| | | <div class="detail-value">{{ detailData.visitStartText || "-" }}</div> |
| | | <div class="detail-value">{{ visitStartDisplayText }}</div> |
| | | </div> |
| | | <div class="detail-item"> |
| | | <div class="detail-label">结束时间</div> |
| | | <div class="detail-value">{{ detailData.visitEndText || "-" }}</div> |
| | | <div class="detail-value">{{ visitEndDisplayText }}</div> |
| | | </div> |
| | | <div class="detail-item detail-item-wide"> |
| | | <div class="detail-label">事由说明</div> |
| | | <div class="detail-value detail-value-multiline">{{ detailData.visitReasonOther || "-" }}</div> |
| | | </div> |
| | | </div> |
| | | </section> |
| | |
| | | </div> |
| | | <div v-else class="empty-text">暂无审批流程</div> |
| | | </section> |
| | | |
| | | <section class="detail-section"> |
| | | <div class="detail-title">提交人信息</div> |
| | | <div class="detail-grid"> |
| | | <div class="detail-item"> |
| | | <div class="detail-label">提交人</div> |
| | | <div class="detail-value">{{ submitterNameText }}</div> |
| | | </div> |
| | | <div class="detail-item"> |
| | | <div class="detail-label">联系电话</div> |
| | | <div class="detail-value">{{ submitterMobileText }}</div> |
| | | </div> |
| | | <div class="detail-item"> |
| | | <div class="detail-label">提交时间</div> |
| | | <div class="detail-value">{{ submitTimeText }}</div> |
| | | </div> |
| | | </div> |
| | | </section> |
| | | </template> |
| | | </el-dialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup lang="ts"> |
| | | import { onMounted, reactive, ref } from "vue"; |
| | | import { computed, onMounted, reactive, ref } from "vue"; |
| | | import { ElMessage } from "element-plus"; |
| | | import http from "../api/http"; |
| | | |
| | | type DeptOption = { id: number; deptName: string }; |
| | | type PassAreaOption = { id: number; areaName: string }; |
| | | type PassAreaOption = { id: number; areaName: string; pid?: number; children?: PassAreaOption[] }; |
| | | type LocalUnitOption = { id: number; unitName: string }; |
| | | type VisitReasonOption = { reasonCode: string; reasonName: string }; |
| | | |
| | |
| | | localUnitText: string; |
| | | plateNoText: string; |
| | | visitReasonText: string; |
| | | visitReasonOther: string; |
| | | visitTimeText: string; |
| | | displayStatusCode: string; |
| | | displayStatusText: string; |
| | |
| | | localUnitText: string; |
| | | plateNoText: string; |
| | | visitReasonText: string; |
| | | visitReasonOther: string; |
| | | visitTimeMode?: string; |
| | | visitTimeSlot?: string; |
| | | visitStartText: string; |
| | | visitEndText: string; |
| | | displayStatusCode: string; |
| | |
| | | const size = ref(10); |
| | | const total = ref(0); |
| | | const rows = ref<AppointmentRow[]>([]); |
| | | const visitDateRange = ref<string[]>([]); |
| | | const DEFAULT_VISIT_DATE_LOOKBACK_DAYS = 7; |
| | | const visitDateRange = ref<string[]>(createDefaultVisitDateRange()); |
| | | |
| | | const deptOptions = ref<DeptOption[]>([]); |
| | | const passAreaOptions = ref<PassAreaOption[]>([]); |
| | |
| | | const filters = reactive({ |
| | | visitedUserName: "", |
| | | visitedDeptId: undefined as number | undefined, |
| | | accessArea: "", |
| | | accessAreaId: undefined as number | undefined, |
| | | localUnit: "", |
| | | displayStatus: "", |
| | | approvalStatus: "", |
| | | visitorKeyword: "", |
| | | visitReasonCode: "", |
| | | }); |
| | | |
| | | const visitStartDisplayText = computed(() => formatVisitTimeRangeStart(detailData.value)); |
| | | const visitEndDisplayText = computed(() => formatVisitTimeRangeEnd(detailData.value)); |
| | | |
| | | const submitterNameText = computed(() => { |
| | | const detail = (detailData.value || {}) as Record<string, unknown>; |
| | | const value = |
| | | (detail.submitterName as string | undefined) |
| | | || (detail.submitterUserName as string | undefined) |
| | | || (detail.creatorName as string | undefined) |
| | | || (detail.createByName as string | undefined) |
| | | || (detail.applicantName as string | undefined); |
| | | return value && value.trim() ? value.trim() : "-"; |
| | | }); |
| | | |
| | | const submitterMobileText = computed(() => { |
| | | const detail = (detailData.value || {}) as Record<string, unknown>; |
| | | const value = |
| | | (detail.submitterMobile as string | undefined) |
| | | || (detail.submitterMobileMasked as string | undefined) |
| | | || (detail.mobileMasked as string | undefined); |
| | | return value && value.trim() ? value.trim() : "-"; |
| | | }); |
| | | |
| | | const submitTimeText = computed(() => { |
| | | const detail = (detailData.value || {}) as Record<string, unknown>; |
| | | const value = |
| | | (detail.submitTime as string | undefined) |
| | | || (detail.createTime as string | undefined); |
| | | return value ? formatActionTime(value) : "-"; |
| | | }); |
| | | |
| | | function formatDateForQuery(date: Date) { |
| | | const year = date.getFullYear(); |
| | | const month = `${date.getMonth() + 1}`.padStart(2, "0"); |
| | | const day = `${date.getDate()}`.padStart(2, "0"); |
| | | return `${year}-${month}-${day}`; |
| | | } |
| | | |
| | | function createDefaultVisitDateRange() { |
| | | const end = new Date(); |
| | | const start = new Date(end); |
| | | start.setDate(start.getDate() - DEFAULT_VISIT_DATE_LOOKBACK_DAYS); |
| | | return [formatDateForQuery(start), formatDateForQuery(end)]; |
| | | } |
| | | |
| | | function indexMethod(index: number) { |
| | | return (page.value - 1) * size.value + index + 1; |
| | |
| | | size: size.value, |
| | | visitedUserName: filters.visitedUserName.trim() || undefined, |
| | | visitedDeptId: filters.visitedDeptId, |
| | | accessArea: filters.accessArea || undefined, |
| | | accessAreaId: filters.accessAreaId, |
| | | localUnit: filters.localUnit || undefined, |
| | | visitDateStart: visitDateRange.value?.[0] || undefined, |
| | | visitDateEnd: visitDateRange.value?.[1] || undefined, |
| | |
| | | } |
| | | |
| | | async function loadLookups() { |
| | | const [deptRes, reasonRes, areaRes, unitRes] = await Promise.all([ |
| | | const [deptRes, reasonRes, unitRes] = await Promise.all([ |
| | | http.get("/sys/depts"), |
| | | http.get("/config/visit-reasons"), |
| | | http.get("/sys/pass-access-areas", { params: { page: 1, size: 500, pid: 0 } }), |
| | | http.get("/sys/local-units", { params: { page: 1, size: 500 } }), |
| | | ]); |
| | | deptOptions.value = (deptRes.data.result || []) as DeptOption[]; |
| | | visitReasonOptions.value = ((reasonRes.data.result || []) as VisitReasonOption[]).filter((item) => item.reasonCode && item.reasonName); |
| | | passAreaOptions.value = (areaRes.data.result || []) as PassAreaOption[]; |
| | | passAreaOptions.value = await loadAllPassAreaOptions(); |
| | | localUnitOptions.value = (unitRes.data.result || []) as LocalUnitOption[]; |
| | | } |
| | | |
| | | async function loadAllPassAreaOptions(): Promise<PassAreaOption[]> { |
| | | const rows: PassAreaOption[] = []; |
| | | const queue: number[] = [0]; |
| | | const visited = new Set<number>(); |
| | | while (queue.length) { |
| | | const pid = queue.shift()!; |
| | | if (visited.has(pid)) { |
| | | continue; |
| | | } |
| | | visited.add(pid); |
| | | const { data } = await http.get("/sys/pass-access-areas", { params: { page: 1, size: 1000, pid } }); |
| | | const list = (data.result || []) as PassAreaOption[]; |
| | | for (const item of list) { |
| | | rows.push({ id: Number(item.id), areaName: item.areaName, pid: Number(item.pid ?? 0), children: [] }); |
| | | queue.push(Number(item.id)); |
| | | } |
| | | } |
| | | return buildPassAreaTree(rows); |
| | | } |
| | | |
| | | function buildPassAreaTree(rows: PassAreaOption[]) { |
| | | if (!rows.length) { |
| | | return []; |
| | | } |
| | | const nodeMap = new Map<number, PassAreaOption>(); |
| | | const roots: PassAreaOption[] = []; |
| | | for (const row of rows) { |
| | | nodeMap.set(Number(row.id), { ...row, children: [] }); |
| | | } |
| | | for (const row of rows) { |
| | | const current = nodeMap.get(Number(row.id)); |
| | | if (!current) { |
| | | continue; |
| | | } |
| | | const pid = Number(row.pid ?? 0); |
| | | if (pid === 0) { |
| | | roots.push(current); |
| | | continue; |
| | | } |
| | | const parent = nodeMap.get(pid); |
| | | if (parent) { |
| | | parent.children = parent.children || []; |
| | | parent.children.push(current); |
| | | } else { |
| | | roots.push(current); |
| | | } |
| | | } |
| | | return roots; |
| | | } |
| | | |
| | | function doSearch() { |
| | |
| | | function resetSearch() { |
| | | filters.visitedUserName = ""; |
| | | filters.visitedDeptId = undefined; |
| | | filters.accessArea = ""; |
| | | filters.accessAreaId = undefined; |
| | | filters.localUnit = ""; |
| | | filters.displayStatus = ""; |
| | | filters.approvalStatus = ""; |
| | | filters.visitorKeyword = ""; |
| | | filters.visitReasonCode = ""; |
| | | visitDateRange.value = []; |
| | | visitDateRange.value = createDefaultVisitDateRange(); |
| | | page.value = 1; |
| | | load(); |
| | | } |
| | |
| | | params: { |
| | | visitedUserName: filters.visitedUserName.trim() || undefined, |
| | | visitedDeptId: filters.visitedDeptId, |
| | | accessArea: filters.accessArea || undefined, |
| | | accessAreaId: filters.accessAreaId, |
| | | localUnit: filters.localUnit || undefined, |
| | | visitDateStart: visitDateRange.value?.[0] || undefined, |
| | | visitDateEnd: visitDateRange.value?.[1] || undefined, |
| | |
| | | http.get(`/appointments/${row.id}`, { params: { uuid: row.uuid } }), |
| | | http.get("/approvals/flow-tree", { params: { orderId: row.id, orderUuid: row.uuid } }), |
| | | ]); |
| | | detailData.value = (detailRes.data.result || null) as AppointmentDetail | null; |
| | | flowNodes.value = (flowRes.data.result || []) as FlowNode[]; |
| | | const detail = (detailRes.data.result || null) as AppointmentDetail | null; |
| | | const flow = (flowRes.data.result || []) as FlowNode[]; |
| | | detailData.value = detail; |
| | | flowNodes.value = withInitiatorNode(flow, detail, row); |
| | | detailVisible.value = true; |
| | | } catch (e: unknown) { |
| | | ElMessage.error(e instanceof Error ? e.message : "加载详情失败"); |
| | | } |
| | | } |
| | | |
| | | function withInitiatorNode(flow: FlowNode[], detail: AppointmentDetail | null, row: AppointmentRow) { |
| | | const initiatorName = resolveInitiatorName(detail, row); |
| | | const anyDetail = (detail || {}) as Record<string, unknown>; |
| | | const actionTime = |
| | | (anyDetail.submitTime as string | undefined) |
| | | || (anyDetail.createTime as string | undefined) |
| | | || undefined; |
| | | const initiatorNode: FlowNode = { |
| | | nodeCode: "INITIATOR", |
| | | nodeName: "发起人", |
| | | nodeStatus: "APPROVED", |
| | | groups: [ |
| | | { |
| | | groupKey: "INITIATOR", |
| | | groupName: "发起信息", |
| | | groupStatus: "APPROVED", |
| | | users: [ |
| | | { |
| | | taskId: 0, |
| | | userName: initiatorName, |
| | | taskStatus: "APPROVED", |
| | | actionTime, |
| | | }, |
| | | ], |
| | | }, |
| | | ], |
| | | }; |
| | | return [initiatorNode, ...(flow || [])]; |
| | | } |
| | | |
| | | function resolveInitiatorName(detail: AppointmentDetail | null, row: AppointmentRow) { |
| | | const anyDetail = (detail || {}) as Record<string, unknown>; |
| | | const fromDetail = |
| | | (anyDetail.submitterName as string | undefined) |
| | | || (anyDetail.submitterUserName as string | undefined) |
| | | || (anyDetail.creatorName as string | undefined) |
| | | || (anyDetail.createByName as string | undefined) |
| | | || (anyDetail.applicantName as string | undefined); |
| | | const fromRow = (row as unknown as Record<string, unknown>).submitterName as string | undefined; |
| | | return (fromDetail || fromRow || "发起人").trim(); |
| | | } |
| | | |
| | | function flowNodeTagType(status?: string) { |
| | | if (status === "APPROVED") return "success"; |
| | | if (status === "REJECTED") return "danger"; |
| | | if (status === "CANCELLED") return "info"; |
| | | if (status === "PENDING") return "warning"; |
| | | return "info"; |
| | | } |
| | |
| | | function flowStatusClass(status?: string) { |
| | | if (status === "APPROVED") return "is-approved"; |
| | | if (status === "REJECTED") return "is-rejected"; |
| | | if (status === "CANCELLED") return "is-cancelled"; |
| | | if (status === "PENDING") return "is-pending"; |
| | | return "is-waiting"; |
| | | } |
| | |
| | | function flowNodeStatusText(status?: string) { |
| | | if (status === "APPROVED") return "已通过"; |
| | | if (status === "REJECTED") return "已拒绝"; |
| | | if (status === "CANCELLED") return "已取消"; |
| | | if (status === "PENDING") return "审批中"; |
| | | if (status === "WAITING") return "待处理"; |
| | | return status || "-"; |
| | |
| | | function formatActionTime(value?: string) { |
| | | if (!value) return "-"; |
| | | return String(value).replace("T", " ").slice(0, 16); |
| | | } |
| | | |
| | | function formatVisitTimeRangeStart(detail: AppointmentDetail | null) { |
| | | if (!detail) return "-"; |
| | | const start = detail.visitStartText || ""; |
| | | const mode = (detail.visitTimeMode || "").toUpperCase(); |
| | | const slot = (detail.visitTimeSlot || "").toUpperCase(); |
| | | if (!start) return "-"; |
| | | if (mode === "MULTI") { |
| | | return `${start} 00:00:00`; |
| | | } |
| | | if (slot === "PM") { |
| | | return `${start} 12:00:00`; |
| | | } |
| | | return `${start} 00:00:00`; |
| | | } |
| | | |
| | | function formatVisitTimeRangeEnd(detail: AppointmentDetail | null) { |
| | | if (!detail) return "-"; |
| | | const start = detail.visitStartText || ""; |
| | | const end = detail.visitEndText || start; |
| | | const mode = (detail.visitTimeMode || "").toUpperCase(); |
| | | const slot = (detail.visitTimeSlot || "").toUpperCase(); |
| | | if (!end) return "-"; |
| | | if (mode === "MULTI") { |
| | | return `${end} 23:59:59`; |
| | | } |
| | | if (slot === "AM") { |
| | | return `${end} 11:59:59`; |
| | | } |
| | | return `${end} 23:59:59`; |
| | | } |
| | | |
| | | onMounted(async () => { |
| | |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 12px; |
| | | height: calc(100vh - 170px); |
| | | min-height: calc(100vh - 170px); |
| | | } |
| | | |
| | | .search-card, |
| | |
| | | display: flex; |
| | | justify-content: flex-end; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .top-actions { |
| | | align-items: flex-start; |
| | | grid-column: 4 / 6; |
| | | align-self: end; |
| | | padding-top: 2px; |
| | | } |
| | | |
| | |
| | | |
| | | .table-card { |
| | | padding: 14px 16px 12px; |
| | | display: flex; |
| | | flex-direction: column; |
| | | flex: 1; |
| | | min-height: 0; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .table-toolbar { |
| | | display: flex; |
| | | justify-content: flex-start; |
| | | margin-bottom: 12px; |
| | | flex: none; |
| | | } |
| | | |
| | | .table-wrap { |
| | | flex: 1; |
| | | min-height: 0; |
| | | } |
| | | |
| | | .pager-wrap { |
| | |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | margin-top: 14px; |
| | | flex: none; |
| | | } |
| | | |
| | | .pager-total { |
| | |
| | | word-break: break-word; |
| | | } |
| | | |
| | | .detail-item-wide { |
| | | grid-column: 1 / -1; |
| | | } |
| | | |
| | | .detail-value-multiline { |
| | | display: -webkit-box; |
| | | max-height: 48px; |
| | | overflow: hidden; |
| | | -webkit-box-orient: vertical; |
| | | -webkit-line-clamp: 2; |
| | | } |
| | | |
| | | .flow-list { |
| | | display: flex; |
| | | flex-direction: column; |
| | |
| | | |
| | | .flow-marker.is-rejected { |
| | | background: #f56c6c; |
| | | } |
| | | |
| | | .flow-marker.is-cancelled { |
| | | background: #c0c4cc; |
| | | } |
| | | |
| | | .flow-marker.is-pending { |
| | |
| | | .date-span-item { |
| | | grid-column: span 2; |
| | | } |
| | | |
| | | .search-actions { |
| | | grid-column: 2 / 4; |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 900px) { |
| | |
| | | grid-column: span 2; |
| | | } |
| | | |
| | | .search-actions { |
| | | grid-column: span 2; |
| | | } |
| | | |
| | | .detail-grid { |
| | | grid-template-columns: 1fr; |
| | | } |