<template>
|
<main v-loading="loading" class="visitor-page record-detail-page">
|
<div class="detail-heading">
|
<h1>预约详情</h1>
|
<button type="button" @click="goBack">返回</button>
|
</div>
|
|
<section class="detail-card visitor-info-card">
|
<h2><span class="section-mark"></span>访客信息</h2>
|
<div class="visitor-info-grid">
|
<div class="avatar-card">
|
<div class="avatar-placeholder" :class="{ 'has-image': detail.faceImageUrl }">
|
<img v-if="detail.faceImageUrl" :src="detail.faceImageUrl" alt="登记人像">
|
</div>
|
<strong>{{ displayText(detail.registerSource || '登记人像采集') }}</strong>
|
<span>{{ formatTime(detail.registerTime) }}</span>
|
</div>
|
<div class="info-pair"><span>姓名</span><strong>{{ displayText(detail.visitorName) }}</strong></div>
|
<div class="info-pair"><span>登记编号</span><strong>{{ displayText(detail.registerNo) }}</strong></div>
|
<div class="info-pair"><span>登记方式</span><strong>{{ formatRegisterType(detail.registerType) }}</strong></div>
|
<div class="info-pair"><span>登记时间</span><strong>{{ formatTime(detail.registerTime) }}</strong></div>
|
<div class="info-pair"><span>证件号码</span><strong>{{ maskIdCard(detail.idCard) }}</strong></div>
|
<div class="info-pair"><span>车牌号</span><strong>{{ displayText(detail.plateNo) }}</strong></div>
|
</div>
|
</section>
|
|
<div class="detail-two-column">
|
<section class="detail-card">
|
<h2><span class="section-mark"></span>被访者</h2>
|
<div class="detail-stack">
|
<div class="info-pair"><span>被访人</span><strong>{{ displayText(detail.intervieweeName) }}</strong></div>
|
<div class="info-pair"><span>登记接待人</span><strong>{{ displayText(detail.receiverName) }}</strong></div>
|
<div class="info-pair">
|
<span>接待人验证方式</span>
|
<strong><em class="soft-tag green">{{ formatVerifyMethod(detail.receiverVerifyMethod) }}</em></strong>
|
</div>
|
<div class="info-pair"><span>所属部门</span><strong>{{ displayText(detail.intervieweeDept) }}</strong></div>
|
</div>
|
</section>
|
<section class="detail-card">
|
<h2><span class="section-mark"></span>访问权限</h2>
|
<div class="detail-stack">
|
<div class="info-pair">
|
<span>可访问区域</span>
|
<strong v-if="visitAreaTags.length" class="tag-list">
|
<span v-for="name in visitAreaTags" :key="name">{{ name }}</span>
|
</strong>
|
<strong v-else>-</strong>
|
</div>
|
<div class="info-pair"><span>访问有效期</span><strong>{{ formatValidTime(detail.validStartTime, detail.validEndTime) }}</strong></div>
|
</div>
|
</section>
|
</div>
|
|
<section class="detail-card">
|
<h2><span class="section-mark"></span>签离记录</h2>
|
<div class="visitor-info-grid sign-grid">
|
<div class="avatar-card">
|
<div class="avatar-placeholder sign" :class="{ 'has-image': detail.signImageUrl }">
|
<img v-if="detail.signImageUrl" :src="detail.signImageUrl" alt="签离人像">
|
</div>
|
<strong>签离人像采集</strong>
|
<span>{{ formatTime(detail.signTime) }}</span>
|
</div>
|
<div class="info-pair"><span>签离时间</span><strong>{{ formatTime(detail.signTime) }}</strong></div>
|
<div class="info-pair"><span>签离接待人</span><strong>{{ displayText(detail.signReceiverName) }}</strong></div>
|
<div class="info-pair">
|
<span>接待人验证方式</span>
|
<strong><em class="soft-tag orange">{{ formatVerifyMethod(detail.signVerifyMethod) }}</em></strong>
|
</div>
|
</div>
|
</section>
|
|
<section class="detail-card">
|
<h2><span class="section-mark"></span>进出场明细</h2>
|
<table class="pass-table">
|
<thead>
|
<tr>
|
<th>类型</th>
|
<th>时间</th>
|
<th>门禁设备</th>
|
<th>编号</th>
|
<th>门禁抓拍人像</th>
|
</tr>
|
</thead>
|
<tbody v-if="detailPassRecords.length">
|
<tr v-for="(item, index) in detailPassRecords" :key="item.passId || index">
|
<td><span class="pass-tag" :class="passTypeClass(item.passType)">{{ passTypeLabel(item.passType) }}</span></td>
|
<td>{{ formatTime(item.passTime) }}</td>
|
<td>{{ displayText(item.doorDeviceName) }}</td>
|
<td>{{ displayText(item.doorDeviceCode) }}</td>
|
<td>
|
<span class="capture-cell">
|
<img v-if="item.captureImageUrl" class="capture-image" :src="item.captureImageUrl" alt="门禁抓拍人像">
|
<span v-else class="capture-icon">●</span>
|
<span>{{ item.captureImageUrl ? '门禁人像采集' : '-' }}</span>
|
</span>
|
</td>
|
</tr>
|
</tbody>
|
</table>
|
<div v-if="!detailPassRecords.length" class="record-empty">暂无进出场明细</div>
|
<p class="detail-tip">进出记录会门禁实时抓拍,人员轨迹完整可溯。</p>
|
</section>
|
</main>
|
</template>
|
|
<script>
|
import { getVisitorRecord } from '@/api/visitor-admin/record'
|
import { getResponseData } from '@/utils/visitor-admin'
|
|
export default {
|
name: 'VisitorRecordDetail',
|
// 目的:初始化详情页加载状态、详情数据和字段字典。
|
data() {
|
return {
|
loading: false,
|
detail: {},
|
registerTypes: [
|
{ value: 'ONSITE', label: '现场登记', aliases: ['onsite'] },
|
{ value: 'APPOINTMENT', label: '预约登记', aliases: ['appointment'] }
|
]
|
}
|
},
|
computed: {
|
// 目的:把详情访问区域拆成标签数组,适配中英文逗号和数组格式。
|
visitAreaTags() {
|
const value = this.detail.visitAreaNameList && this.detail.visitAreaNameList.length
|
? this.detail.visitAreaNameList
|
: this.detail.visitAreaNames
|
if (Array.isArray(value)) {
|
return value.map(item => String(item || '').trim()).filter(Boolean)
|
}
|
return String(value || '').split(/[,,]/).map(item => item.trim()).filter(Boolean)
|
},
|
// 目的:统一详情通行记录数据源,避免模板重复处理空值。
|
detailPassRecords() {
|
return Array.isArray(this.detail.passRecords) ? this.detail.passRecords : []
|
}
|
},
|
// 目的:进入详情页时按路由记录主键加载详情。
|
created() {
|
this.loadDetail()
|
},
|
methods: {
|
// 目的:读取路由中的访客登记记录主键,兼容 path 参数和 query 参数。
|
getRecordId() {
|
return this.$route.params.recordId || this.$route.query.recordId
|
},
|
// 目的:调用详情接口并填充页面数据,失败时保持页面空态。
|
loadDetail() {
|
const recordId = Number(this.getRecordId())
|
if (!recordId) {
|
this.$modal.msgError('缺少访客登记记录主键')
|
return
|
}
|
this.loading = true
|
getVisitorRecord(recordId).then(res => {
|
this.detail = getResponseData(res) || {}
|
}).catch(() => {
|
this.detail = {}
|
}).finally(() => {
|
this.loading = false
|
})
|
},
|
// 目的:返回访客登记记录列表页。
|
goBack() {
|
this.$router.push('/visitor/record/list')
|
},
|
// 目的:根据选项值获取展示文本,无匹配值时保留原值。
|
optionLabel(options, value) {
|
const normalized = String(value || '').toUpperCase()
|
const item = options.find(option => [option.value].concat(option.aliases || []).some(item => String(item).toUpperCase() === normalized))
|
return item ? item.label : (value || '-')
|
},
|
// 目的:统一详情字段空值展示,保留数字 0 这类有效值。
|
displayText(value) {
|
return value === 0 || value ? value : '-'
|
},
|
// 目的:把日期字段格式化为页面展示文本。
|
formatTime(value) {
|
return this.parseTime(value) || '-'
|
},
|
// 目的:格式化登记方式,未知值保留原始值便于排查数据。
|
formatRegisterType(value) {
|
return this.optionLabel(this.registerTypes, value)
|
},
|
// 目的:把接待人验证方式转换为业务展示文案。
|
formatVerifyMethod(value) {
|
const map = {
|
FACE: '人脸验证',
|
CARD: '刷卡验证',
|
MANUAL: '人工确认'
|
}
|
const normalized = String(value || '').toUpperCase()
|
return map[normalized] || this.displayText(value)
|
},
|
// 目的:拼接访问有效期,缺一端时仍保留另一端信息。
|
formatValidTime(start, end) {
|
const startText = this.formatTime(start)
|
const endText = this.formatTime(end)
|
if (startText === '-' && endText === '-') {
|
return '-'
|
}
|
return `${startText} 至 ${endText}`
|
},
|
// 目的:身份证号脱敏,页面只展示前 6 位和后 4 位。
|
maskIdCard(value) {
|
const text = String(value || '')
|
if (!text) {
|
return '-'
|
}
|
if (text.length < 8) {
|
return text
|
}
|
return `${text.slice(0, 6)}********${text.slice(-4)}`
|
},
|
// 目的:把进出场类型转换为标签文案,未知值保留原始值。
|
passTypeLabel(value) {
|
const normalized = String(value || '').toUpperCase()
|
if (normalized === 'OUT') {
|
return '出场'
|
}
|
if (normalized === 'IN') {
|
return '进场'
|
}
|
return this.displayText(value)
|
},
|
// 目的:按进出场类型返回标签颜色类名。
|
passTypeClass(value) {
|
return String(value || '').toUpperCase() === 'OUT' ? 'out' : 'in'
|
}
|
}
|
}
|
</script>
|
|
<style scoped>
|
.record-detail-page {
|
background: transparent;
|
}
|
|
.detail-heading {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
margin-bottom: 18px;
|
}
|
|
.detail-heading h1 {
|
margin: 0;
|
color: #15243d;
|
font-size: 18px;
|
font-weight: 800;
|
}
|
|
.detail-heading button {
|
height: 34px;
|
min-width: 70px;
|
padding: 0 18px;
|
border: 1px solid #d7e1ee;
|
border-radius: 8px;
|
color: #52657a;
|
background: #fff;
|
font-size: 13px;
|
cursor: pointer;
|
}
|
|
.detail-card {
|
margin-bottom: 26px;
|
padding: 22px 26px 28px;
|
border: 1px solid rgba(221, 232, 247, 0.92);
|
border-radius: 14px;
|
background: rgba(255, 255, 255, 0.94);
|
box-shadow: 0 16px 40px rgba(34, 69, 126, 0.08);
|
}
|
|
.detail-card h2 {
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
margin: 0 0 20px;
|
color: #142844;
|
font-size: 18px;
|
font-weight: 900;
|
}
|
|
.section-mark {
|
width: 4px;
|
height: 26px;
|
border-radius: 3px;
|
background: #2b7a5b;
|
}
|
|
.visitor-info-grid {
|
display: grid;
|
grid-template-columns: 98px repeat(2, minmax(220px, 1fr));
|
gap: 24px 70px;
|
align-items: start;
|
}
|
|
.avatar-card {
|
display: grid;
|
justify-items: center;
|
gap: 8px;
|
color: #73849a;
|
font-size: 12px;
|
text-align: center;
|
}
|
|
.avatar-card strong {
|
color: #25364a;
|
font-size: 13px;
|
}
|
|
.avatar-placeholder {
|
position: relative;
|
width: 72px;
|
height: 72px;
|
overflow: hidden;
|
border-radius: 20px;
|
background: #edf2f8;
|
}
|
|
.avatar-placeholder::before {
|
content: "";
|
position: absolute;
|
left: 22px;
|
top: 28px;
|
width: 28px;
|
height: 20px;
|
border-radius: 3px;
|
background: #8093ad;
|
}
|
|
.avatar-placeholder::after {
|
content: "";
|
position: absolute;
|
left: 31px;
|
top: 32px;
|
width: 10px;
|
height: 10px;
|
border: 3px solid #edf2f8;
|
border-radius: 50%;
|
}
|
|
.avatar-placeholder.sign::before {
|
left: 20px;
|
top: 22px;
|
width: 32px;
|
height: 32px;
|
border-radius: 50% 50% 6px 6px;
|
}
|
|
.avatar-placeholder.sign::after {
|
left: 44px;
|
top: 28px;
|
width: 16px;
|
height: 8px;
|
border: 0;
|
border-left: 3px solid #8093ad;
|
border-bottom: 3px solid #8093ad;
|
border-radius: 0;
|
background: transparent;
|
transform: rotate(-45deg);
|
}
|
|
.avatar-placeholder.has-image::before,
|
.avatar-placeholder.has-image::after {
|
content: none;
|
}
|
|
.avatar-placeholder img {
|
width: 100%;
|
height: 100%;
|
object-fit: cover;
|
}
|
|
.info-pair {
|
display: grid;
|
gap: 8px;
|
min-width: 0;
|
color: #7a8ca3;
|
font-size: 13px;
|
}
|
|
.info-pair strong {
|
color: #172b45;
|
font-size: 16px;
|
font-weight: 900;
|
line-height: 1.45;
|
word-break: break-all;
|
}
|
|
.detail-two-column {
|
display: grid;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
gap: 24px;
|
}
|
|
.detail-stack {
|
display: grid;
|
gap: 24px;
|
}
|
|
.soft-tag,
|
.pass-tag {
|
min-width: 58px;
|
height: 24px;
|
display: inline-flex;
|
align-items: center;
|
justify-content: center;
|
padding: 0 10px;
|
border-radius: 999px;
|
font-style: normal;
|
font-size: 12px;
|
font-weight: 800;
|
line-height: 1;
|
}
|
|
.soft-tag.green {
|
color: #168b53;
|
background: #e8f8ef;
|
}
|
|
.soft-tag.orange {
|
color: #d66a3d;
|
background: #ffece2;
|
}
|
|
.tag-list {
|
display: flex;
|
flex-wrap: wrap;
|
gap: 8px;
|
}
|
|
.tag-list span {
|
height: 26px;
|
display: inline-flex;
|
align-items: center;
|
padding: 0 14px;
|
border-radius: 999px;
|
color: #2f6699;
|
background: #eef6ff;
|
font-size: 13px;
|
}
|
|
.sign-grid {
|
grid-template-columns: 98px repeat(2, minmax(220px, 1fr));
|
}
|
|
.pass-table {
|
width: 86%;
|
margin-top: 8px;
|
border: 1px solid #e5edf7;
|
border-spacing: 0;
|
border-collapse: separate;
|
border-radius: 12px;
|
overflow: hidden;
|
table-layout: fixed;
|
background: #fff;
|
color: #111827;
|
font-size: 13px;
|
}
|
|
.pass-table thead tr {
|
height: 42px;
|
background: #f2f6fb;
|
}
|
|
.pass-table tbody tr {
|
height: 42px;
|
}
|
|
.pass-table tbody tr:nth-child(even) {
|
background: #f8fbff;
|
}
|
|
.pass-table th,
|
.pass-table td {
|
padding: 8px 10px;
|
text-align: left;
|
}
|
|
.pass-table th {
|
color: #516579;
|
font-weight: 800;
|
}
|
|
.pass-table td {
|
border-top: 1px solid #edf2f8;
|
color: #25364a;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.pass-tag.in {
|
color: #1677c8;
|
background: #dff2ff;
|
}
|
|
.pass-tag.out {
|
color: #bf7b00;
|
background: #fff0c7;
|
}
|
|
.capture-cell {
|
display: inline-flex;
|
align-items: center;
|
gap: 10px;
|
min-width: 0;
|
}
|
|
.capture-icon,
|
.capture-image {
|
width: 36px;
|
height: 36px;
|
flex: 0 0 36px;
|
border-radius: 10px;
|
}
|
|
.capture-icon {
|
display: inline-flex;
|
align-items: center;
|
justify-content: center;
|
color: #6f839f;
|
background: #edf2f8;
|
}
|
|
.capture-image {
|
object-fit: cover;
|
}
|
|
.record-empty {
|
width: 86%;
|
padding: 38px;
|
border: 1px solid #e5edf7;
|
border-top: 0;
|
border-radius: 0 0 12px 12px;
|
color: #8a9ab0;
|
text-align: center;
|
font-size: 13px;
|
background: #fff;
|
}
|
|
.detail-tip {
|
margin: 24px 0 0;
|
color: #7c8da3;
|
font-size: 12px;
|
}
|
|
@media (max-width: 980px) {
|
.visitor-info-grid,
|
.sign-grid {
|
grid-template-columns: 98px minmax(0, 1fr);
|
gap: 20px 28px;
|
}
|
|
.detail-two-column {
|
grid-template-columns: 1fr;
|
}
|
|
.pass-table,
|
.record-empty {
|
width: 100%;
|
}
|
}
|
|
@media (max-width: 640px) {
|
.record-detail-page {
|
padding: 14px;
|
}
|
|
.detail-card {
|
padding: 18px;
|
}
|
|
.visitor-info-grid,
|
.sign-grid {
|
grid-template-columns: 1fr;
|
}
|
}
|
</style>
|