puzhibing
2026-08-28 e92cd56bb56b111c1afe9922191f9897215ff6b0
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;
  }