From e92cd56bb56b111c1afe9922191f9897215ff6b0 Mon Sep 17 00:00:00 2001
From: puzhibing <393733352@qq.com>
Date: 星期五, 28 八月 2026 17:55:27 +0800
Subject: [PATCH] 提交数据库和服务器部署说明文档
---
frontend-admin/src/views/AppointmentListView.vue | 309 ++++++++++++++++++++++++++++++++++++++++++++++-----
1 files changed, 277 insertions(+), 32 deletions(-)
diff --git a/frontend-admin/src/views/AppointmentListView.vue b/frontend-admin/src/views/AppointmentListView.vue
index 29d5d20..a084345 100644
--- a/frontend-admin/src/views/AppointmentListView.vue
+++ b/frontend-admin/src/views/AppointmentListView.vue
@@ -12,20 +12,23 @@
</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"
@@ -64,6 +67,10 @@
<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>
@@ -72,7 +79,8 @@
<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 />
@@ -81,6 +89,7 @@
<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 }">
@@ -102,7 +111,8 @@
<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>
@@ -125,11 +135,11 @@
<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>
@@ -161,11 +171,15 @@
</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>
@@ -214,18 +228,36 @@
</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 };
@@ -240,6 +272,7 @@
localUnitText: string;
plateNoText: string;
visitReasonText: string;
+ visitReasonOther: string;
visitTimeText: string;
displayStatusCode: string;
displayStatusText: string;
@@ -271,6 +304,9 @@
localUnitText: string;
plateNoText: string;
visitReasonText: string;
+ visitReasonOther: string;
+ visitTimeMode?: string;
+ visitTimeSlot?: string;
visitStartText: string;
visitEndText: string;
displayStatusCode: string;
@@ -308,7 +344,8 @@
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[]>([]);
@@ -322,13 +359,58 @@
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;
@@ -340,7 +422,7 @@
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,
@@ -365,16 +447,65 @@
}
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() {
@@ -385,13 +516,13 @@
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();
}
@@ -432,7 +563,7 @@
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,
@@ -472,17 +603,62 @@
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";
}
@@ -490,6 +666,7 @@
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";
}
@@ -497,6 +674,7 @@
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 || "-";
@@ -505,6 +683,37 @@
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 () => {
@@ -522,6 +731,8 @@
display: flex;
flex-direction: column;
gap: 12px;
+ height: calc(100vh - 170px);
+ min-height: calc(100vh - 170px);
}
.search-card,
@@ -569,10 +780,8 @@
display: flex;
justify-content: flex-end;
gap: 10px;
-}
-
-.top-actions {
- align-items: flex-start;
+ grid-column: 4 / 6;
+ align-self: end;
padding-top: 2px;
}
@@ -582,12 +791,23 @@
.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 {
@@ -595,6 +815,7 @@
justify-content: space-between;
align-items: center;
margin-top: 14px;
+ flex: none;
}
.pager-total {
@@ -653,6 +874,18 @@
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;
@@ -686,6 +919,10 @@
.flow-marker.is-rejected {
background: #f56c6c;
+}
+
+.flow-marker.is-cancelled {
+ background: #c0c4cc;
}
.flow-marker.is-pending {
@@ -773,6 +1010,10 @@
.date-span-item {
grid-column: span 2;
}
+
+ .search-actions {
+ grid-column: 2 / 4;
+ }
}
@media (max-width: 900px) {
@@ -784,6 +1025,10 @@
grid-column: span 2;
}
+ .search-actions {
+ grid-column: span 2;
+ }
+
.detail-grid {
grid-template-columns: 1fr;
}
--
Gitblit v1.7.1