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