<template>
|
<div class="appointment-page">
|
<div class="search-card">
|
<div class="panel-title">预约记录</div>
|
<el-form class="search-grid" @submit.prevent="doSearch">
|
<el-form-item label="被访人">
|
<el-input v-model="filters.visitedUserName" placeholder="请输入" clearable />
|
</el-form-item>
|
<el-form-item label="被访人部门">
|
<el-select v-model="filters.visitedDeptId" placeholder="请选择部门" clearable filterable>
|
<el-option v-for="dept in deptOptions" :key="dept.id" :label="dept.deptName" :value="dept.id" />
|
</el-select>
|
</el-form-item>
|
<el-form-item label="访问区域">
|
<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>
|
<el-form-item label="来访时间" class="date-span-item">
|
<el-date-picker
|
v-model="visitDateRange"
|
type="daterange"
|
unlink-panels
|
range-separator="至"
|
start-placeholder="开始时间"
|
end-placeholder="结束时间"
|
value-format="YYYY-MM-DD"
|
/>
|
</el-form-item>
|
<el-form-item label="预约状态">
|
<el-select v-model="filters.displayStatus" placeholder="请选择" clearable>
|
<el-option label="待拜访" value="PENDING_VISIT" />
|
<el-option label="生效中" value="EFFECTIVE" />
|
<el-option label="已失效" value="INVALID" />
|
</el-select>
|
</el-form-item>
|
<el-form-item label="审核状态">
|
<el-select v-model="filters.approvalStatus" placeholder="请选择" clearable>
|
<el-option label="经办人" value="LEVEL1_HANDLER" />
|
<el-option label="部门" value="LEVEL2_DEPT_LEADER" />
|
<el-option label="属地部门" value="LEVEL3_LOCAL_DEPT_LEADER" />
|
<el-option label="主管部门" value="LEVEL4_VISIT_DEPT" />
|
<el-option label="主管部门领导" value="LEVEL5_VISIT_DEPT_LEADER" />
|
<el-option label="已通过" value="APPROVED" />
|
<el-option label="已拒绝" value="REJECTED" />
|
<el-option label="已取消" value="CANCELLED" />
|
</el-select>
|
</el-form-item>
|
<el-form-item label="访客">
|
<el-input v-model="filters.visitorKeyword" placeholder="请输入" clearable />
|
</el-form-item>
|
<el-form-item label="来访事由">
|
<el-select v-model="filters.visitReasonCode" placeholder="请选择" clearable filterable>
|
<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>
|
|
<div class="table-card">
|
<div class="table-toolbar">
|
<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 :max-height="tableMaxHeight">
|
<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="visitedDeptName" label="被访人部门" width="130" show-overflow-tooltip />
|
<el-table-column prop="accessAreaText" label="访问区域" min-width="140" 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="visitTimeText" label="来访时间" width="180" show-overflow-tooltip />
|
<el-table-column label="预约状态" width="100" align="center">
|
<template #default="{ row }">
|
<el-tag :type="displayStatusTagType(row.displayStatusCode)" effect="plain">
|
{{ row.displayStatusText }}
|
</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column label="审核状态" width="120" align="center">
|
<template #default="{ row }">
|
<el-tag :type="approvalStatusTagType(row.approvalStatusCode)" effect="plain">
|
{{ row.approvalStatusText }}
|
</el-tag>
|
</template>
|
</el-table-column>
|
<el-table-column prop="visitorNamesText" label="访客" min-width="140" show-overflow-tooltip />
|
<el-table-column label="操作" width="90" fixed="right">
|
<template #default="{ row }">
|
<el-button v-permission="'appointments:detail'" type="primary" link @click="openDetail(row)">详情</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
|
<div class="pager-wrap">
|
<div class="pager-total">共 {{ total }} 条</div>
|
<el-pagination
|
background
|
layout="sizes, prev, pager, next"
|
:total="total"
|
:page-size="size"
|
:page-sizes="[10, 20, 50, 100]"
|
v-model:current-page="page"
|
@current-change="load"
|
@size-change="onSizeChange"
|
/>
|
</div>
|
</div>
|
|
<el-dialog v-model="detailVisible" title="预约详情" width="960px" destroy-on-close>
|
<template v-if="detailData">
|
<section class="detail-section">
|
<div class="detail-title-row">
|
<div class="detail-title">预约信息</div>
|
<div class="detail-statuses">
|
<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 class="detail-grid">
|
<div class="detail-item">
|
<div class="detail-label">来访类型</div>
|
<div class="detail-value">{{ detailData.visitTypeText || "-" }}</div>
|
</div>
|
<div class="detail-item">
|
<div class="detail-label">被访人</div>
|
<div class="detail-value">{{ detailData.visitedUserName || "-" }}</div>
|
</div>
|
<div class="detail-item">
|
<div class="detail-label">受访区域</div>
|
<div class="detail-value">{{ detailData.accessAreaText || "-" }}</div>
|
</div>
|
<div class="detail-item">
|
<div class="detail-label">涉及属地单位</div>
|
<div class="detail-value">{{ detailData.localUnitText || "-" }}</div>
|
</div>
|
<div class="detail-item">
|
<div class="detail-label">车牌号码</div>
|
<div class="detail-value">{{ detailData.plateNoText || "-" }}</div>
|
</div>
|
<div class="detail-item">
|
<div class="detail-label">来访事由</div>
|
<div class="detail-value">{{ detailData.visitReasonText || "-" }}</div>
|
</div>
|
<div class="detail-item">
|
<div class="detail-label">来访时间</div>
|
<div class="detail-value">{{ visitStartDisplayText }}</div>
|
</div>
|
<div class="detail-item">
|
<div class="detail-label">结束时间</div>
|
<div class="detail-value">{{ visitEndDisplayText }}</div>
|
</div>
|
</div>
|
</section>
|
|
<section class="detail-section">
|
<div class="detail-title">访客</div>
|
<el-table v-if="detailData.visitors.length" :data="detailData.visitors" border class="detail-table">
|
<el-table-column label="序号" width="68" type="index" />
|
<el-table-column prop="name" label="姓名" min-width="100" />
|
<el-table-column prop="gender" label="性别" width="80" align="center" />
|
<el-table-column prop="idCardMasked" label="身份证号" min-width="160" />
|
<el-table-column prop="mobileMasked" label="联系电话" min-width="130" />
|
<el-table-column prop="companyName" label="公司" min-width="160" show-overflow-tooltip />
|
</el-table>
|
<div v-else class="empty-text">暂无访客信息</div>
|
</section>
|
|
<section class="detail-section">
|
<div class="detail-title">审核流程</div>
|
<div v-if="flowNodes.length" class="flow-list">
|
<div v-for="(node, nodeIndex) in flowNodes" :key="`${node.nodeCode}-${nodeIndex}`" class="flow-node">
|
<div class="flow-marker-col">
|
<div class="flow-marker" :class="flowStatusClass(node.nodeStatus)"></div>
|
<div v-if="nodeIndex !== flowNodes.length - 1" class="flow-line"></div>
|
</div>
|
<div class="flow-content">
|
<div class="flow-head">
|
<span class="flow-name">{{ node.nodeName }}</span>
|
<el-tag size="small" :type="flowNodeTagType(node.nodeStatus)" effect="plain">
|
{{ flowNodeStatusText(node.nodeStatus) }}
|
</el-tag>
|
</div>
|
<div v-for="(group, groupIndex) in node.groups" :key="`${group.groupKey}-${groupIndex}`" class="flow-group">
|
<div class="flow-group-name">{{ group.groupName || "审批组" }}</div>
|
<div class="flow-users">
|
<div v-for="user in group.users" :key="user.taskId" class="flow-user">
|
<span class="flow-user-name">{{ user.userName || "-" }}</span>
|
<span class="flow-user-status">{{ flowNodeStatusText(user.taskStatus) }}</span>
|
<span class="flow-user-time">{{ formatActionTime(user.actionTime) }}</span>
|
<span v-if="user.comment" class="flow-user-comment">{{ user.comment }}</span>
|
</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
</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 { 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; pid?: number; children?: PassAreaOption[] };
|
type LocalUnitOption = { id: number; unitName: string };
|
type VisitReasonOption = { reasonCode: string; reasonName: string };
|
|
type AppointmentRow = {
|
id: number;
|
uuid: string;
|
orderNo: string;
|
visitTypeText: string;
|
visitedUserName: string;
|
visitedDeptName: string;
|
accessAreaText: string;
|
localUnitText: string;
|
plateNoText: string;
|
visitReasonText: string;
|
visitTimeText: string;
|
displayStatusCode: string;
|
displayStatusText: string;
|
approvalStatusCode: string;
|
approvalStatusText: string;
|
visitorNamesText: string;
|
status: string;
|
};
|
|
type AppointmentDetailVisitor = {
|
visitorId?: number;
|
name?: string;
|
gender?: string;
|
idCardMasked?: string;
|
mobileMasked?: string;
|
companyName?: string;
|
plateNo?: string;
|
driverName?: string;
|
};
|
|
type AppointmentDetail = {
|
id: number;
|
uuid: string;
|
orderNo: string;
|
visitTypeText: string;
|
visitedUserName: string;
|
visitedDeptName: string;
|
accessAreaText: string;
|
localUnitText: string;
|
plateNoText: string;
|
visitReasonText: string;
|
visitTimeMode?: string;
|
visitTimeSlot?: string;
|
visitStartText: string;
|
visitEndText: string;
|
displayStatusCode: string;
|
displayStatusText: string;
|
approvalStatusCode: string;
|
approvalStatusText: string;
|
visitors: AppointmentDetailVisitor[];
|
};
|
|
type FlowUser = {
|
taskId: number;
|
userName?: string;
|
taskStatus?: string;
|
comment?: string;
|
actionTime?: string;
|
};
|
|
type FlowGroup = {
|
groupKey: string;
|
groupName?: string;
|
groupStatus?: string;
|
users: FlowUser[];
|
};
|
|
type FlowNode = {
|
nodeCode: string;
|
nodeName: string;
|
nodeStatus?: string;
|
groups: FlowGroup[];
|
};
|
|
const loading = ref(false);
|
const exporting = ref(false);
|
const page = ref(1);
|
const size = ref(10);
|
const total = ref(0);
|
const rows = ref<AppointmentRow[]>([]);
|
const tableMaxHeight = computed(() => (rows.value.length > 10 ? 560 : undefined));
|
const visitDateRange = ref<string[]>([]);
|
|
const deptOptions = ref<DeptOption[]>([]);
|
const passAreaOptions = ref<PassAreaOption[]>([]);
|
const localUnitOptions = ref<LocalUnitOption[]>([]);
|
const visitReasonOptions = ref<VisitReasonOption[]>([]);
|
|
const detailVisible = ref(false);
|
const detailData = ref<AppointmentDetail | null>(null);
|
const flowNodes = ref<FlowNode[]>([]);
|
|
const filters = reactive({
|
visitedUserName: "",
|
visitedDeptId: undefined as number | undefined,
|
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 indexMethod(index: number) {
|
return (page.value - 1) * size.value + index + 1;
|
}
|
|
function buildParams() {
|
return {
|
page: page.value,
|
size: size.value,
|
visitedUserName: filters.visitedUserName.trim() || undefined,
|
visitedDeptId: filters.visitedDeptId,
|
accessAreaId: filters.accessAreaId,
|
localUnit: filters.localUnit || undefined,
|
visitDateStart: visitDateRange.value?.[0] || undefined,
|
visitDateEnd: visitDateRange.value?.[1] || undefined,
|
displayStatus: filters.displayStatus || undefined,
|
approvalStatus: filters.approvalStatus || undefined,
|
visitorKeyword: filters.visitorKeyword.trim() || undefined,
|
visitReasonCode: filters.visitReasonCode || undefined,
|
};
|
}
|
|
async function load() {
|
loading.value = true;
|
try {
|
const { data } = await http.get("/appointments", { params: buildParams() });
|
rows.value = (data.result || []) as AppointmentRow[];
|
total.value = Number(data.total) || 0;
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "加载失败");
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function loadLookups() {
|
const [deptRes, reasonRes, unitRes] = await Promise.all([
|
http.get("/sys/depts"),
|
http.get("/config/visit-reasons"),
|
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 = 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() {
|
page.value = 1;
|
load();
|
}
|
|
function resetSearch() {
|
filters.visitedUserName = "";
|
filters.visitedDeptId = undefined;
|
filters.accessAreaId = undefined;
|
filters.localUnit = "";
|
filters.displayStatus = "";
|
filters.approvalStatus = "";
|
filters.visitorKeyword = "";
|
filters.visitReasonCode = "";
|
visitDateRange.value = [];
|
page.value = 1;
|
load();
|
}
|
|
function onSizeChange(value: number) {
|
size.value = value;
|
page.value = 1;
|
load();
|
}
|
|
function displayStatusTagType(code?: string) {
|
if (code === "EFFECTIVE") return "success";
|
if (code === "PENDING_VISIT") return "warning";
|
return "info";
|
}
|
|
function approvalStatusTagType(code?: string) {
|
if (code === "APPROVED") return "success";
|
if (code === "REJECTED") return "danger";
|
if (code === "CANCELLED") return "info";
|
return "warning";
|
}
|
|
function resolveExportFileName(contentDisposition?: string) {
|
if (!contentDisposition) return "appointments.xlsx";
|
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
|
if (utf8Match?.[1]) {
|
return decodeURIComponent(utf8Match[1]);
|
}
|
const plainMatch = contentDisposition.match(/filename="?([^"]+)"?/i);
|
return plainMatch?.[1] || "appointments.xlsx";
|
}
|
|
async function exportExcel() {
|
exporting.value = true;
|
try {
|
const response = await http.get("/appointments/export", {
|
params: {
|
visitedUserName: filters.visitedUserName.trim() || undefined,
|
visitedDeptId: filters.visitedDeptId,
|
accessAreaId: filters.accessAreaId,
|
localUnit: filters.localUnit || undefined,
|
visitDateStart: visitDateRange.value?.[0] || undefined,
|
visitDateEnd: visitDateRange.value?.[1] || undefined,
|
displayStatus: filters.displayStatus || undefined,
|
approvalStatus: filters.approvalStatus || undefined,
|
visitorKeyword: filters.visitorKeyword.trim() || undefined,
|
visitReasonCode: filters.visitReasonCode || undefined,
|
},
|
responseType: "blob",
|
});
|
const blob = response.data as Blob;
|
const contentType = response.headers["content-type"];
|
if (typeof contentType === "string" && contentType.includes("application/json")) {
|
const text = await blob.text();
|
const body = JSON.parse(text) as { message?: string };
|
throw new Error(body.message || "导出失败");
|
}
|
const url = window.URL.createObjectURL(blob);
|
const link = document.createElement("a");
|
link.href = url;
|
link.download = resolveExportFileName(response.headers["content-disposition"]);
|
document.body.appendChild(link);
|
link.click();
|
document.body.removeChild(link);
|
window.URL.revokeObjectURL(url);
|
ElMessage.success("导出成功");
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "导出失败");
|
} finally {
|
exporting.value = false;
|
}
|
}
|
|
async function openDetail(row: AppointmentRow) {
|
try {
|
const [detailRes, flowRes] = await Promise.all([
|
http.get(`/appointments/${row.id}`, { params: { uuid: row.uuid } }),
|
http.get("/approvals/flow-tree", { params: { orderId: row.id, orderUuid: row.uuid } }),
|
]);
|
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 () => {
|
try {
|
await loadLookups();
|
} catch (e: unknown) {
|
ElMessage.error(e instanceof Error ? e.message : "加载筛选项失败");
|
}
|
load();
|
});
|
</script>
|
|
<style scoped>
|
.appointment-page {
|
display: flex;
|
flex-direction: column;
|
gap: 12px;
|
}
|
|
.search-card,
|
.table-card {
|
background: #ffffff;
|
border: 1px solid #e5ecfb;
|
border-radius: 10px;
|
box-shadow: 0 8px 24px rgba(77, 111, 197, 0.06);
|
}
|
|
.search-card {
|
padding: 16px 18px 10px;
|
}
|
|
.panel-title {
|
color: #2e446e;
|
font-size: 18px;
|
font-weight: 700;
|
margin-bottom: 14px;
|
}
|
|
.search-grid {
|
display: grid;
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
gap: 8px 18px;
|
align-items: start;
|
}
|
|
.search-grid :deep(.el-form-item) {
|
margin-bottom: 12px;
|
}
|
|
.search-grid :deep(.el-form-item__label) {
|
color: #42567f;
|
font-weight: 600;
|
}
|
|
.search-grid :deep(.el-input),
|
.search-grid :deep(.el-select),
|
.search-grid :deep(.el-date-editor) {
|
width: 100%;
|
}
|
|
.search-actions {
|
display: flex;
|
justify-content: flex-end;
|
gap: 10px;
|
grid-column: 4 / 6;
|
align-self: end;
|
padding-top: 2px;
|
}
|
|
.date-span-item {
|
grid-column: span 2;
|
}
|
|
.table-card {
|
padding: 14px 16px 12px;
|
}
|
|
.table-toolbar {
|
display: flex;
|
justify-content: flex-start;
|
margin-bottom: 12px;
|
}
|
|
.pager-wrap {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
margin-top: 14px;
|
}
|
|
.pager-total {
|
color: #7484a6;
|
font-size: 13px;
|
}
|
|
.detail-table {
|
margin-top: 16px;
|
}
|
|
.detail-section + .detail-section {
|
margin-top: 22px;
|
}
|
|
.detail-title-row {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
margin-bottom: 16px;
|
}
|
|
.detail-title {
|
color: #2e446e;
|
font-size: 16px;
|
font-weight: 700;
|
}
|
|
.detail-statuses {
|
display: flex;
|
gap: 8px;
|
}
|
|
.detail-grid {
|
display: grid;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
gap: 14px 32px;
|
}
|
|
.detail-item {
|
display: flex;
|
align-items: flex-start;
|
gap: 12px;
|
min-height: 24px;
|
}
|
|
.detail-label {
|
width: 88px;
|
color: #6b7a99;
|
flex: none;
|
}
|
|
.detail-value {
|
color: #2f3d57;
|
line-height: 1.6;
|
word-break: break-word;
|
}
|
|
.flow-list {
|
display: flex;
|
flex-direction: column;
|
gap: 2px;
|
}
|
|
.flow-node {
|
display: flex;
|
gap: 14px;
|
}
|
|
.flow-marker-col {
|
width: 20px;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
flex: none;
|
}
|
|
.flow-marker {
|
width: 10px;
|
height: 10px;
|
border-radius: 50%;
|
margin-top: 8px;
|
background: #c0c4cc;
|
}
|
|
.flow-marker.is-approved {
|
background: #67c23a;
|
}
|
|
.flow-marker.is-rejected {
|
background: #f56c6c;
|
}
|
|
.flow-marker.is-cancelled {
|
background: #c0c4cc;
|
}
|
|
.flow-marker.is-pending {
|
background: #e6a23c;
|
}
|
|
.flow-line {
|
width: 2px;
|
flex: 1;
|
background: #dcdfe6;
|
margin-top: 4px;
|
}
|
|
.flow-content {
|
flex: 1;
|
padding-bottom: 16px;
|
}
|
|
.flow-head {
|
display: flex;
|
align-items: center;
|
gap: 10px;
|
margin-bottom: 8px;
|
}
|
|
.flow-name {
|
color: #2f3d57;
|
font-weight: 600;
|
}
|
|
.flow-group {
|
padding: 10px 12px;
|
border: 1px solid #ebeef5;
|
border-radius: 8px;
|
background: #fafcff;
|
}
|
|
.flow-group + .flow-group {
|
margin-top: 8px;
|
}
|
|
.flow-group-name {
|
color: #606266;
|
font-size: 13px;
|
margin-bottom: 8px;
|
}
|
|
.flow-users {
|
display: flex;
|
flex-direction: column;
|
gap: 6px;
|
}
|
|
.flow-user {
|
display: grid;
|
grid-template-columns: 120px 80px 140px 1fr;
|
gap: 10px;
|
color: #2f3d57;
|
font-size: 13px;
|
}
|
|
.flow-user-status {
|
color: #909399;
|
}
|
|
.flow-user-time {
|
color: #909399;
|
}
|
|
.flow-user-comment {
|
color: #606266;
|
}
|
|
.empty-text {
|
color: #909399;
|
text-align: center;
|
padding: 24px 0 8px;
|
}
|
|
@media (max-width: 1400px) {
|
.search-grid {
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
}
|
|
.date-span-item {
|
grid-column: span 2;
|
}
|
|
.search-actions {
|
grid-column: 2 / 4;
|
}
|
}
|
|
@media (max-width: 900px) {
|
.search-grid {
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
}
|
|
.date-span-item {
|
grid-column: span 2;
|
}
|
|
.search-actions {
|
grid-column: span 2;
|
}
|
|
.detail-grid {
|
grid-template-columns: 1fr;
|
}
|
|
.flow-user {
|
grid-template-columns: 1fr;
|
}
|
}
|
</style>
|