| | |
| | | class="field-native-input" |
| | | placeholder="姓名 / 工号" |
| | | placeholder-class="field-native-placeholder" |
| | | confirm-type="done" |
| | | confirm-type="search" |
| | | @confirm="handleSearch" |
| | | /> |
| | | </view> |
| | | </view> |
| | |
| | | <image :src="departmentIcon" class="field-label-icon" mode="aspectFit"></image> |
| | | <text class="field-label">被访人部门</text> |
| | | </view> |
| | | <view class="field-box pressable-box" @click="openDepartmentPicker"> |
| | | <text class="field-value-text" :class="{ 'field-placeholder': !filters.department }"> |
| | | {{ filters.department || '部门名称' }} |
| | | </text> |
| | | <image :src="chevronDownIcon" class="field-arrow" mode="aspectFit"></image> |
| | | <view class="field-box"> |
| | | <input |
| | | v-model.trim="filters.department" |
| | | class="field-native-input" |
| | | placeholder="请输入部门名称" |
| | | placeholder-class="field-native-placeholder" |
| | | confirm-type="search" |
| | | @confirm="handleSearch" |
| | | /> |
| | | </view> |
| | | </view> |
| | | |
| | |
| | | class="field-native-input" |
| | | placeholder="姓名 / 证件号" |
| | | placeholder-class="field-native-placeholder" |
| | | confirm-type="done" |
| | | confirm-type="search" |
| | | @confirm="handleSearch" |
| | | /> |
| | | </view> |
| | | </view> |
| | |
| | | <image :src="reasonIcon" class="field-label-icon" mode="aspectFit"></image> |
| | | <text class="field-label">来访事由</text> |
| | | </view> |
| | | <view class="field-box pressable-box" @click="openReasonPicker"> |
| | | <text class="field-value-text" :class="{ 'field-placeholder': !filters.reason }"> |
| | | {{ filters.reason || '全部事由' }} |
| | | </text> |
| | | <image :src="chevronDownIcon" class="field-arrow" mode="aspectFit"></image> |
| | | <view class="field-box"> |
| | | <input |
| | | v-model.trim="filters.reason" |
| | | class="field-native-input" |
| | | placeholder="请输入事由" |
| | | placeholder-class="field-native-placeholder" |
| | | confirm-type="search" |
| | | @confirm="handleSearch" |
| | | /> |
| | | </view> |
| | | </view> |
| | | |
| | |
| | | </view> |
| | | |
| | | <view class="records-panel surface-card"> |
| | | <!-- 即使没有数据,这部分标题和 Excel 导出按钮也会展示 --> |
| | | <view class="records-head"> |
| | | <view class="records-title-wrap"> |
| | | <view class="records-title-icon-box"> |
| | |
| | | <text class="records-title">当前登记记录 - 共 {{ filteredRecords.length }} 条</text> |
| | | </view> |
| | | |
| | | <view class="export-btn"> |
| | | <view class="export-btn pressable-box" @click="handleExport"> |
| | | <image :src="exportIcon" class="export-btn-icon" mode="aspectFit"></image> |
| | | <text class="export-btn-text">Excel导出</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="records-grid"> |
| | | <view class="record-item" :class="record.tone" v-for="record in filteredRecords" :key="record.id" @click="openDetail(record.id)"> |
| | | <view class="record-top-bg"></view> |
| | | <view class="record-side-bar" :class="record.tone"></view> |
| | | <!-- 记录列表和空状态展示 --> |
| | | <scroll-view class="records-scroll-container" scroll-y show-scrollbar="true" v-if="filteredRecords.length > 0"> |
| | | <view class="records-grid"> |
| | | <view class="record-item" :class="record.tone" v-for="record in filteredRecords" :key="record.id" @click="openDetail(record.id)"> |
| | | <view class="record-top-bg"></view> |
| | | <view class="record-side-bar" :class="record.tone"></view> |
| | | |
| | | <view class="record-head-row"> |
| | | <view class="record-user"> |
| | | <view class="avatar-box" :class="record.tone"> |
| | | <text class="avatar-text">{{ record.initial }}</text> |
| | | <view class="record-head-row"> |
| | | <view class="record-user"> |
| | | <view class="avatar-box" :class="record.tone"> |
| | | <text class="avatar-text">{{ record.initial }}</text> |
| | | </view> |
| | | <view class="record-user-copy"> |
| | | <text class="record-name">{{ record.name }}</text> |
| | | <text class="record-id">{{ record.idCard }}</text> |
| | | </view> |
| | | </view> |
| | | <view class="record-user-copy"> |
| | | <text class="record-name">{{ record.name }}</text> |
| | | <text class="record-id">{{ record.idCard }}</text> |
| | | |
| | | <view class="record-status"> |
| | | <image :src="record.statusIcon" class="status-icon" mode="aspectFit"></image> |
| | | <text class="status-text" :class="record.tone">{{ record.status }}</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="record-status"> |
| | | <image :src="record.statusIcon" class="status-icon" mode="aspectFit"></image> |
| | | <text class="status-text" :class="record.tone">{{ record.status }}</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <view class="record-fields"> |
| | | <view class="record-field"> |
| | | <image :src="hostFieldIcon" class="record-field-icon" mode="aspectFit"></image> |
| | | <text class="record-field-value">被访人:{{ record.host }}</text> |
| | | </view> |
| | | <view class="record-field"> |
| | | <image :src="typeFieldIcon" class="record-field-icon" mode="aspectFit"></image> |
| | | <text class="record-field-value">登记方式:{{ record.type }}</text> |
| | | </view> |
| | | <view class="record-field"> |
| | | <image :src="areaFieldIcon" class="record-field-icon" mode="aspectFit"></image> |
| | | <text class="record-field-value">访问区域:{{ record.area }}</text> |
| | | </view> |
| | | <view class="record-field"> |
| | | <image :src="reasonFieldIcon" class="record-field-icon" mode="aspectFit"></image> |
| | | <text class="record-field-value">来访事由:{{ record.reason }}</text> |
| | | </view> |
| | | <view class="record-field record-field-bottom"> |
| | | <view class="record-field-left"> |
| | | <image :src="dateFieldIcon" class="record-field-icon" mode="aspectFit"></image> |
| | | <text class="record-field-value">有效期:{{ record.time }}</text> |
| | | <view class="record-fields"> |
| | | <view class="record-field"> |
| | | <image :src="hostFieldIcon" class="record-field-icon" mode="aspectFit"></image> |
| | | <text class="record-field-value">被访人:{{ record.host }}</text> |
| | | </view> |
| | | <view class="detail-chip"> |
| | | <text class="detail-chip-text">详情</text> |
| | | <image :src="chevronRightIcon" class="detail-chip-arrow" mode="aspectFit"></image> |
| | | <view class="record-field"> |
| | | <image :src="typeFieldIcon" class="record-field-icon" mode="aspectFit"></image> |
| | | <text class="record-field-value">登记方式:{{ record.type }}</text> |
| | | </view> |
| | | <view class="record-field"> |
| | | <image :src="areaFieldIcon" class="record-field-icon" mode="aspectFit"></image> |
| | | <text class="record-field-value">访问区域:{{ record.area }}</text> |
| | | </view> |
| | | <view class="record-field"> |
| | | <image :src="reasonFieldIcon" class="record-field-icon" mode="aspectFit"></image> |
| | | <text class="record-field-value">来访事由:{{ record.reason }}</text> |
| | | </view> |
| | | <view class="record-field record-field-bottom"> |
| | | <view class="record-field-left"> |
| | | <image :src="dateFieldIcon" class="record-field-icon" mode="aspectFit"></image> |
| | | <text class="record-field-value">有效期:{{ record.time }}</text> |
| | | </view> |
| | | <view class="detail-chip"> |
| | | <text class="detail-chip-text">详情</text> |
| | | <image :src="chevronRightIcon" class="detail-chip-arrow" mode="aspectFit"></image> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </scroll-view> |
| | | |
| | | <!-- 当没有数据时展示空状态,避免大面积白屏 --> |
| | | <view class="empty-state" v-else> |
| | | <text class="empty-text">暂无符合条件的访客记录</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- 弹窗/遮罩 --> |
| | | <teleport to="body"> |
| | | <view v-if="activeOverlay" class="overlay-mask" @click="closeOverlay"></view> |
| | | |
| | | <view v-if="activeOverlay === 'department'" class="overlay-sheet selector-sheet"> |
| | | <view class="sheet-head"> |
| | | <text class="sheet-title">选择部门</text> |
| | | <text class="sheet-close" @click="closeOverlay">关闭</text> |
| | | </view> |
| | | <view class="department-tree-panel"> |
| | | <scroll-view scroll-y class="department-tree-scroll"> |
| | | <view |
| | | v-for="node in departmentVisibleNodes" |
| | | :key="node.id" |
| | | class="department-node-row" |
| | | :class="{ 'department-node-row-active': tempDepartment === node.path }" |
| | | :style="{ paddingLeft: `${18 + (node.level - 1) * 28}px` }" |
| | | @click="selectDepartmentNode(node)" |
| | | > |
| | | <view class="department-node-main"> |
| | | <view |
| | | class="department-toggle" |
| | | :class="{ 'department-toggle-expanded': node.hasChildren && node.expanded, 'department-toggle-placeholder': !node.hasChildren }" |
| | | @click.stop="node.hasChildren && toggleDepartmentExpand(node)" |
| | | ></view> |
| | | <text class="department-node-text" :class="{ 'department-node-text-active': tempDepartment === node.path }">{{ node.name }}</text> |
| | | </view> |
| | | </view> |
| | | </scroll-view> |
| | | </view> |
| | | <view class="sheet-confirm-btn" @click="confirmDepartment"> |
| | | <text class="sheet-confirm-text">确定</text> |
| | | </view> |
| | | </view> |
| | | <view v-if="activeOverlay" class="overlay-mask" :class="{ 'date-overlay-mask': activeOverlay === 'date' }" @click="closeOverlay"></view> |
| | | |
| | | <view v-if="activeOverlay === 'area'" class="overlay-sheet selector-sheet"> |
| | | <view class="sheet-head"> |
| | |
| | | </view> |
| | | </view> |
| | | |
| | | <view v-if="activeOverlay === 'reason'" class="overlay-sheet reason-sheet"> |
| | | <view class="reason-sheet-head"> |
| | | <view class="reason-close-btn" @click="closeOverlay"> |
| | | <view class="reason-close-line"></view> |
| | | <view class="reason-close-line reason-close-line-reverse"></view> |
| | | </view> |
| | | <text class="reason-sheet-title">选择拜访事由</text> |
| | | <view class="reason-head-placeholder"></view> |
| | | </view> |
| | | <view class="reason-list"> |
| | | <view |
| | | v-for="item in reasonOptions" |
| | | :key="item" |
| | | class="reason-item" |
| | | :class="{ 'reason-item-active': tempReason === item }" |
| | | @click="tempReason = item" |
| | | > |
| | | <text class="reason-item-text" :class="{ 'reason-item-text-active': tempReason === item }">{{ item }}</text> |
| | | </view> |
| | | </view> |
| | | <view class="reason-submit-btn" @click="confirmReason"> |
| | | <text class="reason-submit-text">提交</text> |
| | | </view> |
| | | </view> |
| | | |
| | | <view v-if="activeOverlay === 'date'" class="overlay-sheet date-sheet"> |
| | | <view v-if="activeOverlay === 'date'" class="overlay-sheet date-sheet admin-home-date-sheet"> |
| | | <view class="sheet-head"> |
| | | <text class="sheet-title">选择来访时间范围</text> |
| | | <text class="sheet-close" @click="closeOverlay">关闭</text> |
| | |
| | | <text class="sheet-confirm-text">确定</text> |
| | | </view> |
| | | </view> |
| | | </teleport> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, reactive, ref } from 'vue'; |
| | | import { computed, reactive, ref, onMounted } from 'vue'; |
| | | import { getVisitorRecordPage } from '../../api/visitorRecord'; |
| | | import { getVisitAreaTree } from '../../api/visitArea'; |
| | | import { saveHistoryRecords, getCombinedHistoryRecords, getOfflineConfig } from '../../utils/db'; |
| | | import resetIcon from '../../static/admin-pages/slDEC.png'; |
| | | import searchIcon from '../../static/admin-pages/PNShK.png'; |
| | | import exportIcon from '../../static/admin-pages/QzvXP.png'; |
| | |
| | | guest: '', |
| | | area: '', |
| | | reason: '', |
| | | startDate: '2025-06-01', |
| | | endDate: '2025-06-30', |
| | | startDate: '', |
| | | endDate: '', |
| | | }); |
| | | |
| | | const filters = reactive(createFilters()); |
| | | const appliedFilters = reactive(createFilters()); |
| | | |
| | | const records = [ |
| | | { id: 1, initial: '李', name: '李明', idCard: '5101********2924', host: '王丽(安全部)', department: '综合管理部 / 安全部', type: '预约登记', area: 'A区 / 前台', reason: '商务洽谈', time: '2025-06-15 至 2026-05-14 · 全天', visitDate: '2025-06-15', status: '来访中', tone: 'green', statusIcon: statusGreenIcon }, |
| | | { id: 2, initial: '张', name: '张敏', idCard: '5101********8142', host: '赵磊(行政部)', department: '综合管理部 / 行政部', type: '现场登记', area: 'B区 / 会议室', reason: '项目对接', time: '2025-06-16 至 2026-05-14 · 全天', visitDate: '2025-06-16', status: '已签离', tone: 'blue', statusIcon: statusBlueIcon }, |
| | | { id: 3, initial: '王', name: '王丽白', idCard: '5101********6621', host: '陈明(研发部)', department: '研发中心 / 研发部', type: '现场登记', area: 'C区 / 样机室', reason: '设备送检', time: '2025-06-18 至 2026-05-14 · 全天', visitDate: '2025-06-18', status: '逾期滞留', tone: 'orange', statusIcon: statusOrangeIcon }, |
| | | { id: 4, initial: '赵', name: '赵俊杰', idCard: '5101********1028', host: '韩雪(综合部)', department: '综合管理部 / 综合部', type: '预约登记', area: 'A区 / 洽谈室', reason: '合同签署', time: '2025-06-20 至 2026-05-14 · 全天', visitDate: '2025-06-20', status: '超期自动', tone: 'purple', statusIcon: statusPurpleIcon }, |
| | | { id: 5, initial: '李', name: '李明', idCard: '5101********0217', host: '王丽(安全部)', department: '综合管理部 / 安全部', type: '预约登记', area: 'A区 / 前台', reason: '商务洽谈', time: '2025-06-22 至 2026-05-14 · 全天', visitDate: '2025-06-22', status: '来访中', tone: 'green', statusIcon: statusGreenIcon }, |
| | | { id: 6, initial: '张', name: '张敏', idCard: '5101********4418', host: '赵磊(行政部)', department: '综合管理部 / 行政部', type: '现场登记', area: 'B区 / 会议室', reason: '项目对接', time: '2025-06-25 至 2026-05-14 · 全天', visitDate: '2025-06-25', status: '已签离', tone: 'blue', statusIcon: statusBlueIcon }, |
| | | ]; |
| | | const records = ref([]); |
| | | const currentPage = ref(1); |
| | | const pageSize = ref(10); |
| | | const totalRecords = ref(0); |
| | | const isLoading = ref(false); |
| | | |
| | | const departmentTree = [ |
| | | { |
| | | id: 'chairman', |
| | | name: '董事长', |
| | | children: [ |
| | | { |
| | | id: 'general-manager', |
| | | name: '总经理', |
| | | children: [ |
| | | { |
| | | id: 'general-admin', |
| | | name: '综合管理部', |
| | | children: [ |
| | | { id: 'clean-office', name: '党风廉政建设办公室' }, |
| | | { id: 'discipline-room', name: '纪检监察室' }, |
| | | { id: 'hr-group', name: '人力行政组' }, |
| | | ], |
| | | }, |
| | | { |
| | | id: 'finance-center', |
| | | name: '财务中心', |
| | | children: [ |
| | | { id: 'budget-group', name: '预算管理组' }, |
| | | { id: 'settlement-group', name: '结算管理组' }, |
| | | ], |
| | | }, |
| | | ], |
| | | }, |
| | | ], |
| | | }, |
| | | { |
| | | id: 'operation-vp', |
| | | name: '运营总监', |
| | | children: [ |
| | | { |
| | | id: 'operation-center', |
| | | name: '运营中心', |
| | | children: [ |
| | | { |
| | | id: 'service-dept', |
| | | name: '客户服务部', |
| | | children: [ |
| | | { id: 'service-team-a', name: '客服一组' }, |
| | | { id: 'service-team-b', name: '客服二组' }, |
| | | ], |
| | | }, |
| | | ], |
| | | }, |
| | | ], |
| | | }, |
| | | ]; |
| | | const getStatusInfo = (statusStr) => { |
| | | switch (statusStr) { |
| | | case 'VISITING': |
| | | return { text: '来访中', tone: 'green', icon: statusGreenIcon }; |
| | | case 'SIGNED_OUT': |
| | | return { text: '已签离', tone: 'blue', icon: statusBlueIcon }; |
| | | case 'OVERDUE': |
| | | return { text: '逾期滞留', tone: 'orange', icon: statusOrangeIcon }; |
| | | case 'OVERDUE_AUTO_SIGNED_OUT': |
| | | case 'AUTO_SIGNED_OUT': |
| | | return { text: '超期自动', tone: 'purple', icon: statusPurpleIcon }; |
| | | case 'CANCELLED': |
| | | return { text: '已取消', tone: 'orange', icon: statusOrangeIcon }; |
| | | default: |
| | | return { text: statusStr || '未知', tone: 'green', icon: statusGreenIcon }; |
| | | } |
| | | }; |
| | | |
| | | const areaTree = [ |
| | | { name: 'A区', children: ['前台', '洽谈室', '样品间'] }, |
| | | { name: 'B区', children: ['会议室', '办公区', '档案室'] }, |
| | | { name: 'C区', children: ['样机室', '检修间', '闸机区'] }, |
| | | ]; |
| | | const getRegisterTypeInfo = (typeStr) => { |
| | | if (typeStr === 'APPOINTMENT') return '预约登记'; |
| | | if (typeStr === 'ONSITE') return '现场登记'; |
| | | return typeStr || '未知'; |
| | | }; |
| | | |
| | | const reasonOptions = ['会议', '拜访', '邀约']; |
| | | const activeOverlay = ref(''); |
| | | const tempDepartment = ref(''); |
| | | const tempAreaParent = ref(areaTree[0].name); |
| | | const tempArea = ref(''); |
| | | const tempReason = ref(''); |
| | | const activeDateField = ref('start'); |
| | | const tempStartDate = ref(filters.startDate); |
| | | const tempEndDate = ref(filters.endDate); |
| | | const years = ['2025', '2026', '2027']; |
| | | const months = Array.from({ length: 12 }, (_, index) => String(index + 1).padStart(2, '0')); |
| | | const selectedYear = ref('2025'); |
| | | const selectedMonth = ref('06'); |
| | | const selectedDay = ref('01'); |
| | | const datePickerValue = ref([0, 5, 0]); |
| | | const expandedDepartmentIds = ref(['chairman', 'general-manager', 'general-admin']); |
| | | const formatFilterDateTime = (dateValue, isEnd = false) => { |
| | | if (!dateValue) { |
| | | return ''; |
| | | } |
| | | |
| | | const days = computed(() => { |
| | | const count = new Date(Number(selectedYear.value), Number(selectedMonth.value), 0).getDate(); |
| | | return Array.from({ length: count }, (_, index) => String(index + 1).padStart(2, '0')); |
| | | return `${dateValue} ${isEnd ? '23:59:59' : '00:00:00'}`; |
| | | }; |
| | | |
| | | const getSelectedAreaLeafName = (areaValue = '') => { |
| | | if (!areaValue) { |
| | | return ''; |
| | | } |
| | | |
| | | const areaSegments = String(areaValue) |
| | | .split('/') |
| | | .map(item => item.trim()) |
| | | .filter(Boolean); |
| | | |
| | | return areaSegments[areaSegments.length - 1] || ''; |
| | | }; |
| | | |
| | | const hasActiveFilters = computed(() => { |
| | | return Boolean( |
| | | appliedFilters.host |
| | | || appliedFilters.department |
| | | || appliedFilters.guest |
| | | || appliedFilters.area |
| | | || appliedFilters.reason |
| | | || appliedFilters.startDate |
| | | || appliedFilters.endDate |
| | | ); |
| | | }); |
| | | |
| | | const findDepartmentAncestorIds = (items, targetPath, trail = [], nameTrail = []) => { |
| | | for (const item of items) { |
| | | const nextTrail = [...trail, item.id]; |
| | | const nextNameTrail = [...nameTrail, item.name]; |
| | | const applyClientFilters = (sourceList = []) => { |
| | | let filteredList = Array.isArray(sourceList) ? sourceList : []; |
| | | |
| | | if (nextNameTrail.join(' / ') === targetPath) { |
| | | return nextTrail; |
| | | if (appliedFilters.host) { |
| | | filteredList = filteredList.filter(item => (item.intervieweeName || '').includes(appliedFilters.host)); |
| | | } |
| | | if (appliedFilters.department) { |
| | | filteredList = filteredList.filter(item => (item.intervieweeDept || '').includes(appliedFilters.department)); |
| | | } |
| | | if (appliedFilters.guest) { |
| | | filteredList = filteredList.filter(item => |
| | | (item.visitorName || '').includes(appliedFilters.guest) |
| | | || (item.idCard || '').includes(appliedFilters.guest) |
| | | ); |
| | | } |
| | | if (appliedFilters.area) { |
| | | const areaKeyword = getSelectedAreaLeafName(appliedFilters.area); |
| | | filteredList = filteredList.filter(item => |
| | | (item.visitAreaNames || '').includes(areaKeyword) |
| | | || (item.visitAreaIds || '').includes(areaKeyword) |
| | | ); |
| | | } |
| | | if (appliedFilters.reason) { |
| | | filteredList = filteredList.filter(item => (item.visitReason || '').includes(appliedFilters.reason)); |
| | | } |
| | | if (appliedFilters.startDate) { |
| | | const sTime = new Date(`${appliedFilters.startDate} 00:00:00`).getTime(); |
| | | filteredList = filteredList.filter(item => { |
| | | const itemTime = new Date(item.validStartTime || item.registerTime).getTime(); |
| | | return itemTime >= sTime; |
| | | }); |
| | | } |
| | | if (appliedFilters.endDate) { |
| | | const eTime = new Date(`${appliedFilters.endDate} 23:59:59`).getTime(); |
| | | filteredList = filteredList.filter(item => { |
| | | const itemTime = new Date(item.validStartTime || item.registerTime).getTime(); |
| | | return itemTime <= eTime; |
| | | }); |
| | | } |
| | | |
| | | return filteredList; |
| | | }; |
| | | |
| | | const fetchRecords = async () => { |
| | | if (isLoading.value) { |
| | | return; |
| | | } |
| | | isLoading.value = true; |
| | | |
| | | try { |
| | | const payload = { |
| | | pageNum: 1, |
| | | pageSize: 9999, // 一次性获取所有数据 |
| | | intervieweeKeyword: appliedFilters.host, |
| | | intervieweeDept: appliedFilters.department, |
| | | visitorKeyword: appliedFilters.guest, |
| | | visitAreaKeyword: getSelectedAreaLeafName(appliedFilters.area), |
| | | visitReason: appliedFilters.reason, |
| | | validStartTime: formatFilterDateTime(appliedFilters.startDate), |
| | | validEndTime: formatFilterDateTime(appliedFilters.endDate, true), |
| | | }; |
| | | |
| | | let isOnline = true; |
| | | let onlineList = []; |
| | | try { |
| | | const res = await getVisitorRecordPage(payload); |
| | | if (res && res.code === 200 && res.data) { |
| | | // 兼容不同的后端返回结构:有的后端返回 list,有的返回 records |
| | | onlineList = res.data.records || res.data.list || []; |
| | | // 只有无筛选的全量查询结果才回写本地缓存,避免把筛选结果覆盖全量历史 |
| | | if (!hasActiveFilters.value) { |
| | | await saveHistoryRecords(onlineList); |
| | | } |
| | | } |
| | | } catch (networkError) { |
| | | console.log('无法获取线上历史记录,降级使用本地缓存', networkError); |
| | | isOnline = false; |
| | | } |
| | | |
| | | if (item.children?.length) { |
| | | const found = findDepartmentAncestorIds(item.children, targetPath, nextTrail, nextNameTrail); |
| | | if (found.length) return found; |
| | | // 无论线上成功与否,都读取一次本地数据;有筛选时优先使用接口结果,并补充本地待同步记录 |
| | | const combinedLocalList = await getCombinedHistoryRecords(); |
| | | let filteredList = []; |
| | | |
| | | if (isOnline && hasActiveFilters.value) { |
| | | const localPendingList = combinedLocalList.filter(item => item._source === 'local_pending'); |
| | | filteredList = [...applyClientFilters(localPendingList), ...onlineList]; |
| | | } else { |
| | | let combinedList = combinedLocalList; |
| | | |
| | | // 如果是在 H5/浏览器环境下预览,本地数据库会被 mock 返回空数组 |
| | | // 因此如果 combinedList 为空且在线请求成功,直接使用在线数据进行渲染兜底 |
| | | // #ifndef APP-PLUS |
| | | if (isOnline && combinedList.length === 0) { |
| | | combinedList = onlineList; |
| | | } |
| | | // #endif |
| | | |
| | | filteredList = applyClientFilters(combinedList); |
| | | } |
| | | |
| | | totalRecords.value = filteredList.length; |
| | | currentPage.value = 1; |
| | | |
| | | records.value = filteredList.map((item) => { |
| | | const statusInfo = getStatusInfo(item.status); |
| | | const name = item.visitorName || '未知'; |
| | | return { |
| | | id: item.recordId || item.id || Math.random().toString(36).substring(2), |
| | | initial: name.charAt(0), |
| | | name: name, |
| | | idCard: item.idCard || '', |
| | | host: `${item.intervieweeName || ''}(${item.intervieweeDept || '暂无部门'})`, |
| | | department: item.intervieweeDept || '暂无部门', |
| | | type: getRegisterTypeInfo(item.registerType), |
| | | area: item.visitAreaNames || item.visitAreaIds || '无', |
| | | reason: item.visitReason || '无', |
| | | time: item.validStartTime && item.validEndTime ? `${item.validStartTime} 至 ${item.validEndTime}` : '无', |
| | | visitDate: (item.validStartTime || item.registerTime || '').split(' ')[0] || '', |
| | | status: statusInfo.text, |
| | | tone: statusInfo.tone, |
| | | statusIcon: statusInfo.icon, |
| | | }; |
| | | }); |
| | | |
| | | if (!isOnline) { |
| | | uni.showToast({ title: '已加载离线记录', icon: 'none' }); |
| | | } |
| | | } catch (error) { |
| | | console.error('加载访客记录失败', error); |
| | | uni.showToast({ title: '加载失败', icon: 'none' }); |
| | | } finally { |
| | | isLoading.value = false; |
| | | } |
| | | }; |
| | | |
| | | const loadVisitAreas = async () => { |
| | | let rawData = []; |
| | | try { |
| | | const networkType = await new Promise(resolve => { |
| | | uni.getNetworkType({ |
| | | success: (res) => resolve(res.networkType) |
| | | }); |
| | | }); |
| | | |
| | | if (networkType !== 'none') { |
| | | const res = await getVisitAreaTree(); |
| | | if (res && res.code === 200 && Array.isArray(res.data)) { |
| | | rawData = res.data; |
| | | } |
| | | } |
| | | } catch (err) { |
| | | console.error('获取在线区域树失败:', err); |
| | | } |
| | | |
| | | if (!rawData.length) { |
| | | const cachedData = await getOfflineConfig('visit_area_tree'); |
| | | if (cachedData && Array.isArray(cachedData)) { |
| | | rawData = cachedData; |
| | | } |
| | | } |
| | | |
| | | return []; |
| | | if (rawData.length) { |
| | | areaTree.value = rawData.map(item => { |
| | | const name = item.name || item.areaName || item.label || item.areaId || item.id; |
| | | const rawChildren = (item.children && item.children.length > 0) ? item.children : [item]; |
| | | const children = rawChildren.map(child => child.name || child.areaName || child.label || child.areaId || child.id); |
| | | return { name, children }; |
| | | }); |
| | | if (!tempAreaParent.value) { |
| | | tempAreaParent.value = areaTree.value[0]?.name || ''; |
| | | } |
| | | } |
| | | }; |
| | | |
| | | const departmentVisibleNodes = computed(() => { |
| | | const rows = []; |
| | | |
| | | const walk = (items, level = 1, trail = []) => { |
| | | items.forEach((item) => { |
| | | const nextTrail = [...trail, item.name]; |
| | | const expanded = expandedDepartmentIds.value.includes(item.id); |
| | | const hasChildren = Array.isArray(item.children) && item.children.length > 0; |
| | | |
| | | rows.push({ |
| | | id: item.id, |
| | | name: item.name, |
| | | level, |
| | | path: nextTrail.join(' / '), |
| | | hasChildren, |
| | | expanded, |
| | | }); |
| | | |
| | | if (hasChildren && expanded) { |
| | | walk(item.children, level + 1, nextTrail); |
| | | } |
| | | }); |
| | | }; |
| | | |
| | | walk(departmentTree); |
| | | return rows; |
| | | onMounted(() => { |
| | | loadVisitAreas(); |
| | | fetchRecords(); |
| | | }); |
| | | |
| | | const currentAreaChildren = computed(() => areaTree.find((item) => item.name === tempAreaParent.value)?.children || []); |
| | | const areaTree = ref([]); |
| | | |
| | | const activeOverlay = ref(''); |
| | | const tempAreaParent = ref(''); |
| | | const tempArea = ref(''); |
| | | const activeDateField = ref('start'); |
| | | const tempStartDate = ref(filters.startDate); |
| | | const tempEndDate = ref(filters.endDate); |
| | | const currentYear = new Date().getFullYear(); |
| | | const years = Array.from({ length: 10 }, (_, i) => String(currentYear - 5 + i)); |
| | | const months = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0')); |
| | | const selectedYear = ref(''); |
| | | const selectedMonth = ref(''); |
| | | const selectedDay = ref(''); |
| | | const datePickerValue = ref([0, 0, 0]); |
| | | |
| | | const days = computed(() => { |
| | | if (!selectedYear.value || !selectedMonth.value) return []; |
| | | const daysInMonth = new Date(Number(selectedYear.value), Number(selectedMonth.value), 0).getDate(); |
| | | return Array.from({ length: daysInMonth }, (_, i) => String(i + 1).padStart(2, '0')); |
| | | }); |
| | | |
| | | const currentAreaChildren = computed(() => areaTree.value.find((item) => item.name === tempAreaParent.value)?.children || []); |
| | | |
| | | const filteredRecords = computed(() => { |
| | | return records.filter((record) => { |
| | | const hostMatch = !appliedFilters.host || record.host.includes(appliedFilters.host) || record.department.includes(appliedFilters.host); |
| | | const departmentMatch = !appliedFilters.department || record.department === appliedFilters.department; |
| | | const guestMatch = !appliedFilters.guest || record.name.includes(appliedFilters.guest) || record.idCard.includes(appliedFilters.guest); |
| | | const areaMatch = !appliedFilters.area || record.area === appliedFilters.area; |
| | | const reasonMatch = !appliedFilters.reason || record.reason === appliedFilters.reason; |
| | | const startMatch = !appliedFilters.startDate || record.visitDate >= appliedFilters.startDate; |
| | | const endMatch = !appliedFilters.endDate || record.visitDate <= appliedFilters.endDate; |
| | | |
| | | return hostMatch && departmentMatch && guestMatch && areaMatch && reasonMatch && startMatch && endMatch; |
| | | }); |
| | | return records.value; |
| | | }); |
| | | |
| | | const closeOverlay = () => { |
| | | activeOverlay.value = ''; |
| | | }; |
| | | |
| | | const openDepartmentPicker = () => { |
| | | tempDepartment.value = filters.department; |
| | | const ancestorIds = findDepartmentAncestorIds(departmentTree, filters.department); |
| | | if (ancestorIds.length) { |
| | | expandedDepartmentIds.value = Array.from(new Set(ancestorIds.slice(0, -1))); |
| | | } |
| | | activeOverlay.value = 'department'; |
| | | }; |
| | | |
| | | const toggleDepartmentExpand = (node) => { |
| | | if (!node.hasChildren) return; |
| | | |
| | | if (expandedDepartmentIds.value.includes(node.id)) { |
| | | expandedDepartmentIds.value = expandedDepartmentIds.value.filter((id) => id !== node.id); |
| | | } else { |
| | | expandedDepartmentIds.value = [...expandedDepartmentIds.value, node.id]; |
| | | } |
| | | }; |
| | | |
| | | const selectDepartmentNode = (node) => { |
| | | tempDepartment.value = node.path; |
| | | }; |
| | | |
| | | const confirmDepartment = () => { |
| | | filters.department = tempDepartment.value; |
| | | closeOverlay(); |
| | | }; |
| | | |
| | | const openAreaPicker = () => { |
| | | tempArea.value = filters.area; |
| | | const [parent] = filters.area.split(' / '); |
| | | tempAreaParent.value = parent || areaTree[0].name; |
| | | tempAreaParent.value = parent || (areaTree.value.length > 0 ? areaTree.value[0].name : ''); |
| | | activeOverlay.value = 'area'; |
| | | }; |
| | | |
| | |
| | | const confirmArea = () => { |
| | | filters.area = tempArea.value; |
| | | closeOverlay(); |
| | | }; |
| | | |
| | | const openReasonPicker = () => { |
| | | tempReason.value = filters.reason; |
| | | activeOverlay.value = 'reason'; |
| | | }; |
| | | |
| | | const confirmReason = () => { |
| | | filters.reason = tempReason.value; |
| | | closeOverlay(); |
| | | handleSearch(); |
| | | }; |
| | | |
| | | const syncDatePicker = (dateString) => { |
| | | if (!dateString) return; |
| | | const [year, month, day] = dateString.split('-'); |
| | | selectedYear.value = year; |
| | | selectedMonth.value = month; |
| | |
| | | closeOverlay(); |
| | | }; |
| | | |
| | | const handleReset = () => { |
| | | const resetSearch = () => { |
| | | Object.assign(filters, createFilters()); |
| | | Object.assign(appliedFilters, createFilters()); |
| | | fetchRecords(); |
| | | }; |
| | | |
| | | const handleReset = () => { |
| | | resetSearch(); |
| | | closeOverlay(); |
| | | }; |
| | | |
| | |
| | | startDate: filters.startDate, |
| | | endDate: filters.endDate, |
| | | }); |
| | | fetchRecords(); |
| | | closeOverlay(); |
| | | }; |
| | | |
| | | const handleExport = () => { |
| | | if (totalRecords.value === 0) { |
| | | uni.showToast({ title: '暂无数据可导出', icon: 'none' }); |
| | | return; |
| | | } |
| | | |
| | | uni.showLoading({ title: '正在生成导出文件...' }); |
| | | try { |
| | | let csvString = '\uFEFF'; |
| | | const headers = ['访客姓名', '身份证号', '被访人', '部门', '登记方式', '访问区域', '来访事由', '状态', '有效期']; |
| | | csvString += headers.join(',') + '\n'; |
| | | |
| | | records.value.forEach(item => { |
| | | const escapeCsv = (str) => { |
| | | if (str === null || str === undefined) return '""'; |
| | | const s = String(str).replace(/"/g, '""'); |
| | | return `"${s}"`; |
| | | }; |
| | | |
| | | const hostName = item.host.split('(')[0] || item.host; |
| | | const deptName = item.department; |
| | | |
| | | const row = [ |
| | | escapeCsv(item.name), |
| | | escapeCsv(item.idCard + '\t'), // 加 \t 防止身份证被 Excel 转成科学计数法 |
| | | escapeCsv(hostName), |
| | | escapeCsv(deptName), |
| | | escapeCsv(item.type), |
| | | escapeCsv(item.area), |
| | | escapeCsv(item.reason), |
| | | escapeCsv(item.status), |
| | | escapeCsv(item.time) |
| | | ]; |
| | | csvString += row.join(',') + '\n'; |
| | | }); |
| | | |
| | | const fileName = `访客记录_${Date.now()}.csv`; |
| | | |
| | | // #ifdef H5 |
| | | const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8' }); |
| | | const url = URL.createObjectURL(blob); |
| | | const a = document.createElement('a'); |
| | | a.href = url; |
| | | a.download = fileName; |
| | | document.body.appendChild(a); |
| | | a.click(); |
| | | document.body.removeChild(a); |
| | | URL.revokeObjectURL(url); |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '导出成功', icon: 'success' }); |
| | | // #endif |
| | | |
| | | // #ifndef H5 |
| | | const fs = uni.getFileSystemManager(); |
| | | const filePath = `${uni.env.USER_DATA_PATH}/${fileName}`; |
| | | |
| | | fs.writeFile({ |
| | | filePath, |
| | | data: csvString, |
| | | encoding: 'utf8', |
| | | success: () => { |
| | | uni.hideLoading(); |
| | | uni.openDocument({ |
| | | filePath, |
| | | showMenu: true, |
| | | success: () => { |
| | | console.log('打开文档成功'); |
| | | }, |
| | | fail: (err) => { |
| | | console.error('打开文件失败', err); |
| | | // 兜底策略:如果 openDocument 失败,尝试用 plus.runtime.openFile |
| | | if (typeof plus !== 'undefined' && plus.runtime) { |
| | | plus.io.resolveLocalFileSystemURL(filePath, (entry) => { |
| | | plus.runtime.openFile(entry.toLocalURL()); |
| | | }); |
| | | } else { |
| | | uni.showToast({ title: '打开文件失败', icon: 'none' }); |
| | | } |
| | | } |
| | | }); |
| | | }, |
| | | fail: (err) => { |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '文件保存失败', icon: 'none' }); |
| | | console.error('文件保存失败:', err); |
| | | } |
| | | }); |
| | | // #endif |
| | | |
| | | } catch (error) { |
| | | uni.hideLoading(); |
| | | console.error('导出失败', error); |
| | | uni.showToast({ title: '导出失败', icon: 'none' }); |
| | | } |
| | | }; |
| | | |
| | | const openDetail = (id) => { |
| | |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 24px; |
| | | height: 100%; |
| | | } |
| | | |
| | | .pressable-box { |
| | |
| | | color: #2E6CFF; |
| | | } |
| | | |
| | | .records-scroll-container { |
| | | flex: 1; |
| | | height: 0; |
| | | width: 100%; |
| | | } |
| | | |
| | | .records-scroll-container ::-webkit-scrollbar { |
| | | width: 6px; |
| | | background-color: transparent; |
| | | } |
| | | |
| | | .records-scroll-container ::-webkit-scrollbar-thumb { |
| | | background-color: #B8C9E8; |
| | | border-radius: 3px; |
| | | } |
| | | |
| | | .records-scroll-container ::-webkit-scrollbar-track { |
| | | background-color: transparent; |
| | | } |
| | | |
| | | .records-grid { |
| | | display: grid; |
| | | grid-template-columns: repeat(2, minmax(0, 1fr)); |
| | |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | flex: 1; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .record-field-icon { |
| | |
| | | } |
| | | |
| | | .detail-chip { |
| | | min-width: 54px; |
| | | height: 28px; |
| | | padding: 0 10px; |
| | | border-radius: 14px; |
| | |
| | | align-items: center; |
| | | justify-content: center; |
| | | gap: 4px; |
| | | flex-shrink: 0; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .detail-chip-text { |
| | | font-family: Inter, sans-serif; |
| | | font-size: 12px; |
| | | font-weight: 600; |
| | | line-height: 1; |
| | | color: #6A7B98; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .detail-chip-arrow { |
| | |
| | | position: fixed; |
| | | inset: 0; |
| | | background: rgba(15, 27, 52, 0.38); |
| | | z-index: 1000; |
| | | z-index: 9999; |
| | | border-radius: 0; |
| | | } |
| | | |
| | |
| | | border-radius: 32px 32px 0 0; |
| | | background: #FFFFFF; |
| | | box-shadow: 0 18px 42px rgba(11, 23, 48, 0.18); |
| | | z-index: 1001; |
| | | z-index: 10000; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | .sheet-confirm-btn { |
| | | height: 48px; |
| | | border-radius: 20px; |
| | | height: 52px; |
| | | border-radius: 14px; |
| | | padding: 0 24px; |
| | | display: flex; |
| | | align-items: center; |
| | |
| | | .tree-arrow { |
| | | width: 16px; |
| | | height: 16px; |
| | | } |
| | | |
| | | .department-tree-panel { |
| | | height: 420px; |
| | | border-radius: 24px; |
| | | border: 1px solid #E6EDF8; |
| | | background: #F8FBFF; |
| | | overflow: hidden; |
| | | margin-bottom: 18px; |
| | | } |
| | | |
| | | .department-tree-scroll { |
| | | width: 100%; |
| | | height: 100%; |
| | | } |
| | | |
| | | .department-node-row { |
| | | min-height: 58px; |
| | | padding-right: 18px; |
| | | box-sizing: border-box; |
| | | border-bottom: 1px solid #EEF3FB; |
| | | display: flex; |
| | | align-items: center; |
| | | } |
| | | |
| | | .department-node-row-active { |
| | | background: #EEF5FF; |
| | | } |
| | | |
| | | .department-node-main { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .department-toggle { |
| | | width: 0; |
| | | height: 0; |
| | | border-top: 6px solid transparent; |
| | | border-bottom: 6px solid transparent; |
| | | border-left: 8px solid #6F7E98; |
| | | transition: transform 0.2s ease; |
| | | } |
| | | |
| | | .department-toggle-expanded { |
| | | transform: rotate(90deg); |
| | | } |
| | | |
| | | .department-toggle-placeholder { |
| | | border-left-color: transparent; |
| | | } |
| | | |
| | | .department-node-text { |
| | | font-family: Inter, sans-serif; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | line-height: 1.2; |
| | | color: #4A5672; |
| | | } |
| | | |
| | | .department-node-text-active { |
| | | color: #2F6BFF; |
| | | font-weight: 700; |
| | | } |
| | | |
| | | .reason-list { |
| | |
| | | font-weight: 700; |
| | | color: #1F2A44; |
| | | } |
| | | |
| | | .date-overlay-mask { |
| | | background: rgba(31, 42, 68, 0.6); |
| | | } |
| | | |
| | | .admin-home-date-sheet { |
| | | height: 600px; |
| | | padding: 0; |
| | | border-radius: 28px 28px 0 0; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .admin-home-date-sheet .sheet-head { |
| | | height: 72px; |
| | | padding: 0 32px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | margin-bottom: 0; |
| | | border-bottom: 1px solid #EEF2FA; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .admin-home-date-sheet .sheet-title { |
| | | font-family: Inter, sans-serif; |
| | | font-size: 20px; |
| | | font-weight: 700; |
| | | color: #1F2A44; |
| | | } |
| | | |
| | | .admin-home-date-sheet .sheet-close { |
| | | font-family: Inter, sans-serif; |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #7B88A8; |
| | | padding: 10px; |
| | | } |
| | | |
| | | .admin-home-date-sheet .date-tab-row { |
| | | display: flex; |
| | | padding: 20px 32px 10px; |
| | | gap: 24px; |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .admin-home-date-sheet .date-tab { |
| | | flex: 1; |
| | | height: 64px; |
| | | border-radius: 12px; |
| | | border: 1px solid #EEF2FA; |
| | | display: flex; |
| | | flex-direction: column; |
| | | justify-content: center; |
| | | padding: 0 16px; |
| | | gap: 4px; |
| | | background: #FFFFFF; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .admin-home-date-sheet .date-tab-active { |
| | | border-color: #2F6BFF; |
| | | background: #F4F8FF; |
| | | } |
| | | |
| | | .admin-home-date-sheet .date-tab-text { |
| | | font-family: Inter, sans-serif; |
| | | font-size: 13px; |
| | | font-weight: 600; |
| | | color: #7B88A8; |
| | | } |
| | | |
| | | .admin-home-date-sheet .date-tab-active .date-tab-text { |
| | | color: #2F6BFF; |
| | | } |
| | | |
| | | .admin-home-date-sheet .date-tab-value { |
| | | font-family: Inter, sans-serif; |
| | | font-size: 16px; |
| | | font-weight: 700; |
| | | color: #1F2A44; |
| | | } |
| | | |
| | | .admin-home-date-sheet .date-picker-view { |
| | | flex: 1; |
| | | width: 100%; |
| | | height: auto; |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .admin-home-date-sheet .picker-item { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | height: 34px; |
| | | line-height: normal; |
| | | text-align: center; |
| | | font-family: Inter, sans-serif; |
| | | font-size: 18px; |
| | | font-weight: 600; |
| | | color: #1F2A44; |
| | | } |
| | | |
| | | .admin-home-date-sheet .sheet-confirm-btn { |
| | | margin: 16px 32px 32px; |
| | | height: 52px; |
| | | border-radius: 14px; |
| | | background: #2F6BFF; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | box-shadow: none; |
| | | } |
| | | |
| | | .admin-home-date-sheet .sheet-confirm-text { |
| | | font-family: Inter, sans-serif; |
| | | font-size: 16px; |
| | | font-weight: 700; |
| | | color: #FFFFFF; |
| | | } |
| | | |
| | | .empty-state { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | padding: 60px 0; |
| | | } |
| | | |
| | | .empty-text { |
| | | font-family: Inter, sans-serif; |
| | | font-size: 16px; |
| | | color: #7B88A8; |
| | | } |
| | | </style> |