zjk
2026-07-17 77e2b70d3d769f45657257de89a979cf465e29e6
feat(admin): 完善访客管理后台功能,对接后端接口

- 新增看板、访客记录、版本检测相关接口封装
- 重构管理员首页:替换静态mock数据为后端接口拉取,优化图表滚动展示,调整周期筛选文案
- 重构访客记录模块:支持多条件搜索、离线数据缓存、Excel导出功能,替换静态数据为真实接口数据
- 重构访客详情页:使用后端接口获取数据,动态展示访客信息和图片
- 在配置面板新增版本检测功能,展示当前版本和更新状态
- 优化request请求工具类,增加URL合法性校验和安全处理
- 调整管理端页面样式布局,修复层级问题,统一页面结构
- 修改设备初始化默认IP为192.168.110.111
1个文件已添加
10个文件已修改
1576 ■■■■■ 已修改文件
api/dashboard.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
api/visitorRecord.js 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/admin/AdminConfigPanel.vue 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/admin/AdminRecordsPanel.vue 957 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/admin/AdminShell.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/admin/AdminTabBar.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/admin-home/index.vue 269 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/admin-record-detail/index.vue 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/device-init/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/db.js 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/request.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
api/dashboard.js
New file
@@ -0,0 +1,9 @@
import { request } from '../utils/request';
export const getDashboardOverview = (data) => {
  return request({
    url: '/api/dashboard/overview',
    method: 'POST',
    data,
  });
};
api/visitorRecord.js
@@ -31,3 +31,26 @@
    data, // 注意:离线补发接口要求传入数组格式
  });
};
export const getLatestVersion = () => {
  return request({
    url: '/api/visitorRecord/latestVersion',
    method: 'POST',
  });
};
export const getVisitorRecordPage = (data) => {
  return request({
    url: '/api/visitorRecord/page',
    method: 'POST',
    data,
  });
};
export const getVisitorRecordDetail = (data) => {
  return request({
    url: '/api/visitorRecord/detail',
    method: 'POST',
    data,
  });
};
components/admin/AdminConfigPanel.vue
@@ -49,17 +49,23 @@
        <view class="version-row">
          <image :src="versionIcon" class="inline-icon" mode="aspectFit"></image>
          <text class="version-label">当前版本号:</text>
          <text class="version-value">v2.1.0</text>
          <text class="version-value">{{ currentVersion }}</text>
        </view>
        <view class="update-banner">
        <view class="update-banner" v-if="hasNewVersion">
          <view class="update-copy">
            <image :src="updateHintIcon" class="inline-icon" mode="aspectFit"></image>
            <text class="update-text">检测到新版本 v2.2.0 可用</text>
            <text class="update-text">检测到新版本 {{ latestVersion }} 可用</text>
          </view>
          <view class="update-btn">
            <image :src="updateBtnIcon" class="banner-btn-icon" mode="aspectFit"></image>
            <text class="update-btn-text">立即更新</text>
          </view>
        </view>
        <view class="update-banner" v-else>
          <view class="update-copy">
            <image :src="updateHintIcon" class="inline-icon" mode="aspectFit" style="opacity: 0.5;"></image>
            <text class="update-text" style="color: #6D7F9B;">当前已是最新版本</text>
          </view>
        </view>
      </view>
@@ -100,7 +106,6 @@
      </view>
    </view>
    <teleport to="body">
      <view class="overlay-mask" v-if="showMoreActions || showLogoutDialog" @click="closeOverlays"></view>
      <view class="more-dialog" v-if="showMoreActions">
@@ -138,7 +143,6 @@
          </view>
        </view>
      </view>
    </teleport>
  </view>
</template>
@@ -159,6 +163,7 @@
import closeIcon from '../../static/admin-pages/pDMhJ.png';
import logoutIcon from '../../static/admin-pages/uZNeV.png';
import { deactivateVisitMachine } from '../../api/device';
import { getLatestVersion } from '../../api/visitorRecord';
import { clearRequestServerConfig, getRequestServerConfig, setRequestServerConfig } from '../../utils/request';
const VISITOR_MACHINE_IP_KEY = 'visitor_machine_ip';
@@ -167,6 +172,10 @@
const showMoreActions = ref(false);
const showLogoutDialog = ref(false);
const isLoggingOut = ref(false);
const currentVersion = ref('v2.1.0');
const latestVersion = ref('');
const hasNewVersion = ref(false);
const normalizeIpValue = (value = '') => {
  return String(value).trim().replace(/^https?:\/\//, '');
@@ -383,8 +392,30 @@
  }
};
const checkLatestVersion = async () => {
  try {
    const res = await getLatestVersion();
    if (res && res.code === 200 && res.data) {
      // 假设后端返回的数据为版本号字符串或者包含 version 字段的对象
      const versionStr = typeof res.data === 'object' ? (res.data.version || res.data.versionName || '') : res.data;
      if (versionStr) {
        latestVersion.value = versionStr;
        // 简单比对:如果获取到的版本和当前不一致,则认为是新版本(实际业务中可换成语义化版本对比库)
        if (latestVersion.value !== currentVersion.value) {
          hasNewVersion.value = true;
        } else {
          hasNewVersion.value = false;
        }
      }
    }
  } catch (error) {
    console.error('获取最新版本失败:', error);
  }
};
onMounted(() => {
  loadNetworkConfig();
  checkLatestVersion();
});
defineExpose({
@@ -642,7 +673,7 @@
  position: fixed;
  inset: 0;
  background: rgba(31, 42, 68, 0.6);
  z-index: 1000;
  z-index: 9999;
}
.more-dialog {
@@ -658,7 +689,7 @@
  flex-direction: column;
  gap: 26px;
  box-shadow: 0 18px 40px rgba(11, 23, 48, 0.12);
  z-index: 1001;
  z-index: 10000;
}
.dialog-head {
@@ -746,7 +777,7 @@
  flex-direction: column;
  gap: 44px;
  box-shadow: 0 18px 40px rgba(11, 23, 48, 0.12);
  z-index: 1002;
  z-index: 10001;
}
.logout-copy {
components/admin/AdminRecordsPanel.vue
@@ -13,7 +13,8 @@
              class="field-native-input"
              placeholder="姓名 / 工号"
              placeholder-class="field-native-placeholder"
              confirm-type="done"
              confirm-type="search"
              @confirm="handleSearch"
            />
          </view>
        </view>
@@ -23,11 +24,15 @@
            <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>
@@ -42,7 +47,8 @@
              class="field-native-input"
              placeholder="姓名 / 证件号"
              placeholder-class="field-native-placeholder"
              confirm-type="done"
              confirm-type="search"
              @confirm="handleSearch"
            />
          </view>
        </view>
@@ -67,11 +73,15 @@
            <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>
@@ -108,6 +118,7 @@
    </view>
    <view class="records-panel surface-card">
      <!-- 即使没有数据,这部分标题和 Excel 导出按钮也会展示 -->
      <view class="records-head">
        <view class="records-title-wrap">
          <view class="records-title-icon-box">
@@ -116,100 +127,76 @@
          <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">
@@ -246,32 +233,7 @@
        </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>
@@ -301,12 +263,14 @@
          <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';
@@ -334,200 +298,291 @@
  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';
};
@@ -538,19 +593,11 @@
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;
@@ -600,9 +647,14 @@
  closeOverlay();
};
const handleReset = () => {
const resetSearch = () => {
  Object.assign(filters, createFilters());
  Object.assign(appliedFilters, createFilters());
  fetchRecords();
};
const handleReset = () => {
  resetSearch();
  closeOverlay();
};
@@ -616,7 +668,104 @@
    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) => {
@@ -643,6 +792,7 @@
  display: flex;
  flex-direction: column;
  gap: 24px;
  height: 100%;
}
.pressable-box {
@@ -886,6 +1036,26 @@
  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));
@@ -1100,6 +1270,8 @@
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 1;
  min-width: 0;
}
.record-field-icon {
@@ -1117,6 +1289,7 @@
}
.detail-chip {
  min-width: 54px;
  height: 28px;
  padding: 0 10px;
  border-radius: 14px;
@@ -1125,13 +1298,17 @@
  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 {
@@ -1143,7 +1320,7 @@
  position: fixed;
  inset: 0;
  background: rgba(15, 27, 52, 0.38);
  z-index: 1000;
  z-index: 9999;
  border-radius: 0;
}
@@ -1156,7 +1333,7 @@
  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;
}
@@ -1190,8 +1367,8 @@
}
.sheet-confirm-btn {
  height: 48px;
  border-radius: 20px;
  height: 52px;
  border-radius: 14px;
  padding: 0 24px;
  display: flex;
  align-items: center;
@@ -1268,69 +1445,6 @@
.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 {
@@ -1489,4 +1603,137 @@
  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>
components/admin/AdminShell.vue
@@ -60,6 +60,8 @@
  padding: 30px 36px 40px;
  background: linear-gradient(180deg, #3B78FF 0%, #2860E8 50%, #163EBC 100%);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.bg-glow {
@@ -131,16 +133,15 @@
.content-wrap {
  position: relative;
  z-index: 1;
  min-height: 1536px;
  display: flex;
  flex-direction: column;
  gap: 24px;
  flex: 1;
}
.footer-info-wrap {
  position: relative;
  z-index: 1;
  min-height: 22px;
  margin-top: 24px;
}
components/admin/AdminTabBar.vue
@@ -45,7 +45,6 @@
<style scoped>
.bottom-nav {
  position: relative;
  z-index: 1;
  width: 100%;
  height: 132px;
  margin-top: 24px;
pages/admin-home/index.vue
@@ -224,18 +224,20 @@
          <text class="chart-panel-unit">单位:次</text>
        </view>
        <view v-if="trendChartData.length" class="chart-columns">
          <view class="chart-column" v-for="item in trendChartData" :key="item.label">
            <view class="chart-value-pill">
              <text class="chart-value-pill-text">{{ item.total }}</text>
        <scroll-view v-if="trendChartData.length" class="chart-scroll-view" scroll-x="true" show-scrollbar="true">
          <view class="chart-columns">
            <view class="chart-column" v-for="item in trendChartData" :key="item.label">
              <view class="chart-value-pill">
                <text class="chart-value-pill-text">{{ item.total }}</text>
              </view>
              <view class="chart-bars">
                <view class="chart-bar online-bar" :style="{ height: `${item.online}px` }"></view>
                <view class="chart-bar onsite-bar" :style="{ height: `${item.onsite}px` }"></view>
              </view>
              <text class="chart-label">{{ item.label }}</text>
            </view>
            <view class="chart-bars">
              <view class="chart-bar online-bar" :style="{ height: `${item.online}px` }"></view>
              <view class="chart-bar onsite-bar" :style="{ height: `${item.onsite}px` }"></view>
            </view>
            <text class="chart-label">{{ item.label }}</text>
          </view>
        </view>
        </scroll-view>
        <view v-else class="chart-empty-state">
          <text class="chart-empty-text">当前筛选范围暂无统计数据</text>
        </view>
@@ -305,6 +307,7 @@
<script setup>
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { getDashboardOverview } from '../../api/dashboard';
import AdminShell from '../../components/admin/AdminShell.vue';
import AdminTabBar from '../../components/admin/AdminTabBar.vue';
import AdminRecordsPanel from '../../components/admin/AdminRecordsPanel.vue';
@@ -334,8 +337,8 @@
const periodOptions = [
  { label: '今日', value: 'today' },
  { label: '本周', value: 'week' },
  { label: '本月', value: 'month' },
  { label: '近7天', value: 'week' },
  { label: '近30天', value: 'month' },
];
const formatDate = (date) => {
@@ -343,14 +346,6 @@
  const month = String(date.getMonth() + 1).padStart(2, '0');
  const day = String(date.getDate()).padStart(2, '0');
  return `${year}-${month}-${day}`;
};
const getStartOfWeek = (date) => {
  const current = new Date(date);
  const day = current.getDay() || 7;
  current.setHours(0, 0, 0, 0);
  current.setDate(current.getDate() - day + 1);
  return current;
};
const getPresetRange = (period) => {
@@ -365,22 +360,19 @@
  }
  if (period === 'week') {
    const start = getStartOfWeek(today);
    const end = new Date(start);
    end.setDate(start.getDate() + 6);
    const start = new Date(today);
    start.setDate(today.getDate() - 6);
    return {
      startDate: formatDate(start),
      endDate: formatDate(end),
      endDate: formatDate(today),
    };
  }
  const start = new Date(today.getFullYear(), today.getMonth(), 1);
  const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);
  const start = new Date(today);
  start.setDate(today.getDate() - 29);
  return {
    startDate: formatDate(start),
    endDate: formatDate(end),
    endDate: formatDate(today),
  };
};
@@ -466,90 +458,110 @@
  closeDatePicker();
};
const dashboardSourceData = [
  { date: '2026-07-10', onlineAppointments: 2, onlineCars: 1, onsiteAppointments: 5, onsiteCars: 2, stayingVisitors: 2, checkedInVisitors: 4, overdueVisitors: 0 },
  { date: '2026-07-09', onlineAppointments: 3, onlineCars: 1, onsiteAppointments: 7, onsiteCars: 2, stayingVisitors: 3, checkedInVisitors: 6, overdueVisitors: 1 },
  { date: '2026-07-08', onlineAppointments: 4, onlineCars: 2, onsiteAppointments: 9, onsiteCars: 3, stayingVisitors: 4, checkedInVisitors: 7, overdueVisitors: 1 },
  { date: '2026-07-07', onlineAppointments: 2, onlineCars: 1, onsiteAppointments: 6, onsiteCars: 2, stayingVisitors: 2, checkedInVisitors: 5, overdueVisitors: 0 },
  { date: '2026-07-06', onlineAppointments: 5, onlineCars: 2, onsiteAppointments: 12, onsiteCars: 4, stayingVisitors: 5, checkedInVisitors: 8, overdueVisitors: 1 },
  { date: '2026-07-05', onlineAppointments: 4, onlineCars: 2, onsiteAppointments: 8, onsiteCars: 3, stayingVisitors: 3, checkedInVisitors: 7, overdueVisitors: 0 },
  { date: '2026-07-04', onlineAppointments: 3, onlineCars: 1, onsiteAppointments: 6, onsiteCars: 2, stayingVisitors: 2, checkedInVisitors: 5, overdueVisitors: 0 },
  { date: '2026-07-03', onlineAppointments: 4, onlineCars: 2, onsiteAppointments: 10, onsiteCars: 3, stayingVisitors: 4, checkedInVisitors: 8, overdueVisitors: 1 },
  { date: '2026-07-02', onlineAppointments: 2, onlineCars: 1, onsiteAppointments: 7, onsiteCars: 2, stayingVisitors: 3, checkedInVisitors: 5, overdueVisitors: 0 },
  { date: '2026-07-01', onlineAppointments: 5, onlineCars: 2, onsiteAppointments: 11, onsiteCars: 4, stayingVisitors: 4, checkedInVisitors: 9, overdueVisitors: 1 },
];
const filteredDashboardData = computed(() => {
  return dashboardSourceData
    .filter((item) => item.date >= appliedStartDate.value && item.date <= appliedEndDate.value)
    .sort((prev, next) => prev.date.localeCompare(next.date));
const dashboardStats = ref({
  onlineAppointments: 0,
  onlineCars: 0,
  onsiteAppointments: 0,
  onsiteCars: 0,
  stayingVisitors: 0,
  checkedInVisitors: 0,
  overdueVisitors: 0,
});
const dashboardStats = computed(() => {
  if (!filteredDashboardData.value.length) {
    return {
      onlineAppointments: 0,
      onlineCars: 0,
      onsiteAppointments: 0,
      onsiteCars: 0,
      stayingVisitors: 0,
      checkedInVisitors: 0,
      overdueVisitors: 0,
    };
const trendChartData = ref([]);
const mapPeriodType = (draftPeriod) => {
  if (draftPeriod === 'today') return 'TODAY';
  if (draftPeriod === 'week') return 'RECENT_7_DAYS';
  if (draftPeriod === 'month') return 'RECENT_30_DAYS';
  return '';
};
const fetchDashboardData = async () => {
  try {
    const payload = {};
    if (appliedPeriod.value === 'custom') {
      payload.beginTime = `${appliedStartDate.value} 00:00:00`;
      const endDateObj = new Date(appliedEndDate.value);
      endDateObj.setDate(endDateObj.getDate() + 1);
      const year = endDateObj.getFullYear();
      const month = String(endDateObj.getMonth() + 1).padStart(2, '0');
      const day = String(endDateObj.getDate()).padStart(2, '0');
      payload.endTime = `${year}-${month}-${day} 00:00:00`;
    } else {
      payload.periodType = mapPeriodType(appliedPeriod.value);
    }
    uni.showLoading({ title: '加载中' });
    const res = await getDashboardOverview(payload);
    if (res && res.code === 200 && res.data) {
      const data = res.data;
      dashboardStats.value = {
        onlineAppointments: data.onlineAppointment?.registerCount || 0,
        onlineCars: data.onlineAppointment?.vehicleCount || 0,
        onsiteAppointments: data.onsiteAppointment?.registerCount || 0,
        onsiteCars: data.onsiteAppointment?.vehicleCount || 0,
        stayingVisitors: data.statusSummary?.visitingCount || 0,
        checkedInVisitors: data.statusSummary?.signedOutCount || 0, // 已签离/已签到
        overdueVisitors: data.statusSummary?.overdueCount || 0,
      };
      if (data.trend && Array.isArray(data.trend.labels)) {
        const { labels, onsite, appointment } = data.trend;
        const trendList = labels.map((label, index) => {
          return {
            label,
            onlineAppointments: appointment?.[index] || 0,
            onsiteAppointments: onsite?.[index] || 0,
          };
        });
        const maxValue = Math.max(
          ...trendList.map((item) => Math.max(item.onlineAppointments, item.onsiteAppointments)),
          1,
        );
        trendChartData.value = trendList.map((item) => {
          const scaleHeight = (value) => Math.max(24, Math.round((value / maxValue) * 228));
          return {
            label: item.label,
            total: `${item.onlineAppointments}/${item.onsiteAppointments}`,
            online: scaleHeight(item.onlineAppointments),
            onsite: scaleHeight(item.onsiteAppointments),
          };
        });
      } else {
        trendChartData.value = [];
      }
    }
  } catch (err) {
    console.error('获取看板数据失败', err);
    uni.showToast({ title: '数据加载失败', icon: 'none' });
  } finally {
    uni.hideLoading();
  }
};
  return filteredDashboardData.value.reduce(
    (result, item) => {
      result.onlineAppointments += item.onlineAppointments;
      result.onlineCars += item.onlineCars;
      result.onsiteAppointments += item.onsiteAppointments;
      result.onsiteCars += item.onsiteCars;
      result.stayingVisitors += item.stayingVisitors;
      result.checkedInVisitors += item.checkedInVisitors;
      result.overdueVisitors += item.overdueVisitors;
      return result;
    },
    {
      onlineAppointments: 0,
      onlineCars: 0,
      onsiteAppointments: 0,
      onsiteCars: 0,
      stayingVisitors: 0,
      checkedInVisitors: 0,
      overdueVisitors: 0,
    },
  );
});
const trendChartData = computed(() => {
  if (!filteredDashboardData.value.length) {
    return [];
  }
  const maxValue = Math.max(
    ...filteredDashboardData.value.map((item) => Math.max(item.onlineAppointments, item.onsiteAppointments)),
    1,
  );
  return filteredDashboardData.value.map((item) => {
    const date = new Date(item.date);
    const scaleHeight = (value) => Math.max(24, Math.round((value / maxValue) * 228));
    return {
      label: `${date.getMonth() + 1}/${date.getDate()}`,
      total: `${item.onlineAppointments}/${item.onsiteAppointments}`,
      online: scaleHeight(item.onlineAppointments),
      onsite: scaleHeight(item.onsiteAppointments),
    };
  });
});
const deviceInfo = [
  { icon: versionIcon, text: '当前版本号:v2.1.0' },
  { icon: vendorIcon, text: '服务厂家:智安科技' },
const deviceInfo = ref([
  { icon: versionIcon, text: '当前版本号:v1.0.0' },
  { icon: vendorIcon, text: '服务厂家:华南光电' },
  { icon: supportIcon, text: '技术支持:400-882-6688' },
  { icon: terminalIcon, text: '终端编号:K1024-8823' },
];
]);
const fetchDeviceFingerprint = async () => {
  try {
    const sysInfo = uni.getSystemInfoSync();
    // 优先尝试获取 deviceId,其次用 model 作为兜底
    const deviceId = sysInfo.deviceId || sysInfo.model || '未知设备';
    deviceInfo.value[3].text = `终端编号:${deviceId}`;
  } catch (err) {
    console.error('获取设备编号失败', err);
    deviceInfo.value[3].text = '终端编号:获取失败';
  }
};
const validTabs = ['home', 'records', 'config'];
@@ -565,6 +577,12 @@
  const nextRange = getPresetRange(period);
  draftStartDate.value = nextRange.startDate;
  draftEndDate.value = nextRange.endDate;
  // 点击预设周期时直接应用并触发查询
  appliedPeriod.value = period;
  appliedStartDate.value = nextRange.startDate;
  appliedEndDate.value = nextRange.endDate;
  fetchDashboardData();
};
const applyDashboardFilter = () => {
@@ -587,6 +605,8 @@
  appliedPeriod.value = draftPeriod.value;
  appliedStartDate.value = draftStartDate.value;
  appliedEndDate.value = draftEndDate.value;
  fetchDashboardData();
};
onLoad((options) => {
@@ -594,6 +614,9 @@
  if (validTabs.includes(nextTab)) {
    activeTab.value = nextTab;
  }
  fetchDashboardData();
  fetchDeviceFingerprint();
});
</script>
@@ -1291,13 +1314,38 @@
  color: #8FA6D2;
}
.chart-columns {
.chart-scroll-view {
  position: relative;
  z-index: 1;
  width: 100%;
  height: 380px;
}
/* 自定义微信小程序/App端的 scroll-view 滚动条样式 */
::v-deep .chart-scroll-view ::-webkit-scrollbar {
  width: 4px;
  height: 4px;
  background-color: transparent;
}
::v-deep .chart-scroll-view ::-webkit-scrollbar-thumb {
  border-radius: 4px;
  background-color: rgba(255, 255, 255, 0.2);
}
::v-deep .chart-scroll-view ::-webkit-scrollbar-track {
  background-color: rgba(255, 255, 255, 0.05);
  border-radius: 4px;
}
.chart-columns {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 22px;
  height: 370px;
  padding-bottom: 10px;
  min-width: 100%;
}
.chart-empty-state {
@@ -1318,7 +1366,8 @@
}
.chart-column {
  flex: 1;
  flex-shrink: 0;
  min-width: 60px;
  height: 100%;
  display: flex;
  flex-direction: column;
@@ -1356,7 +1405,7 @@
.chart-bar {
  width: 18px;
  border-radius: 9px 9px 0 0;
  border-radius: 9px;
}
.online-bar {
pages/admin-record-detail/index.vue
@@ -23,7 +23,7 @@
        <view class="visitor-layout">
          <view class="capture-col">
            <image :src="visitorCapturePlaceholder" class="capture-image" mode="widthFix"></image>
            <image :src="detail.faceImageUrl || visitorCapturePlaceholder" class="capture-image" mode="widthFix"></image>
            <text class="capture-time">登记采集{{ '\n' }}{{ detail.registerCaptureTime }} 录入</text>
          </view>
@@ -122,7 +122,7 @@
        <view class="visitor-layout">
          <view class="capture-col">
            <image :src="signoutCapturePlaceholder" class="capture-image" mode="widthFix"></image>
            <image :src="detail.signoutImageUrl || signoutCapturePlaceholder" class="capture-image" mode="widthFix"></image>
            <text class="capture-time">签离采集{{ '\n' }}{{ detail.signoutCaptureTime }} 录入</text>
          </view>
@@ -162,6 +162,7 @@
<script setup>
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { getVisitorRecordDetail } from '../../api/visitorRecord';
import AdminShell from '../../components/admin/AdminShell.vue';
import backIcon from '../../static/admin-detail/Ay837.png';
import visitorInfoIcon from '../../static/admin-detail/e7v1T.png';
@@ -190,96 +191,102 @@
import statusOrangeIcon from '../../static/admin-pages/D1nht.png';
import statusPurpleIcon from '../../static/admin-pages/NUszJ.png';
const id = ref('1');
const id = ref('');
onLoad((options) => {
  id.value = options?.id || '1';
const detail = ref({
  name: '-',
  status: '-',
  statusClass: 'stay',
  type: '-',
  idCard: '-',
  recordNo: '-',
  registerTime: '-',
  registerCaptureTime: '-',
  plateNumber: '-',
  host: '-',
  receiver: '-',
  hostVerify: '-',
  areaTags: [],
  validityText: '-',
  signoutCaptureTime: '-',
  signoutTime: '-',
  signoutReceiver: '-',
  signoutVerify: '-',
  faceImageUrl: '',
  signoutImageUrl: '',
});
const detailsMap = {
  1: {
    name: '李明',
    status: '来访中',
    statusClass: 'stay',
    type: '预约登记',
    idCard: '510151****95815',
    recordNo: '151561545',
    registerTime: '2025-05-15 12:25',
    registerCaptureTime: '2025-05-15 12:25',
    plateNumber: '川A104515',
    host: '李维',
    receiver: '李维',
    hostVerify: '人脸验证',
    areaTags: ['A区域', 'B区域'],
    validityText: '2025-05-15 全天',
    signoutCaptureTime: '2025-05-15 12:25',
    signoutTime: '2025-05-15 18:25',
    signoutReceiver: '李维',
    signoutVerify: '刷卡验证',
  },
  2: {
    name: '张敏',
    status: '已签离',
    statusClass: 'done',
    type: '现场登记',
    idCard: '510102****16234',
    recordNo: '151561589',
    registerTime: '2025-05-16 09:40',
    registerCaptureTime: '2025-05-16 09:40',
    plateNumber: '川A562314',
    host: '刘凯',
    receiver: '刘凯',
    hostVerify: '人脸验证',
    areaTags: ['A区域', 'C区域'],
    validityText: '2025-05-16 全天',
    signoutCaptureTime: '2025-05-16 17:48',
    signoutTime: '2025-05-16 17:48',
    signoutReceiver: '刘凯',
    signoutVerify: '刷卡验证',
  },
  3: {
    name: '王丽白',
    status: '逾期滞留',
    statusClass: 'warn',
    type: '预约登记',
    idCard: '510151****95815',
    recordNo: '151561545',
    registerTime: '2025-05-15 12:25',
    registerCaptureTime: '2025-05-15 12:25',
    plateNumber: '川A104515',
    host: '李维',
    receiver: '李维',
    hostVerify: '人脸验证',
    areaTags: ['A区域', 'B区域'],
    validityText: '2025-05-15 全天',
    signoutCaptureTime: '2025-05-15 12:25',
    signoutTime: '2025-05-15 18:25',
    signoutReceiver: '李维',
    signoutVerify: '刷卡验证',
  },
  4: {
    name: '赵俊杰',
    status: '超期自动',
    statusClass: 'idle',
    type: '现场登记',
    idCard: '510104****33561',
    recordNo: '151561628',
    registerTime: '2025-05-17 08:55',
    registerCaptureTime: '2025-05-17 08:55',
    plateNumber: '川A320145',
    host: '韩雨晴',
    receiver: '韩雨晴',
    hostVerify: '人脸验证',
    areaTags: ['B区域', 'C区域'],
    validityText: '2025-05-17 全天',
    signoutCaptureTime: '2025-05-17 18:10',
    signoutTime: '2025-05-17 18:10',
    signoutReceiver: '韩雨晴',
    signoutVerify: '刷卡验证',
  },
const getStatusInfo = (statusStr) => {
  switch (statusStr) {
    case 'VISITING':
      return { text: '来访中', class: 'stay' };
    case 'SIGNED_OUT':
      return { text: '已签离', class: 'done' };
    case 'OVERDUE':
      return { text: '逾期滞留', class: 'warn' };
    case 'OVERDUE_AUTO_SIGNED_OUT':
    case 'AUTO_SIGNED_OUT':
      return { text: '超期自动', class: 'idle' };
    case 'CANCELLED':
      return { text: '已取消', class: 'warn' };
    default:
      return { text: statusStr || '未知', class: 'stay' };
  }
};
const detail = computed(() => detailsMap[id.value] || detailsMap[1]);
const getRegisterTypeInfo = (typeStr) => {
  if (typeStr === 'APPOINTMENT') return '预约登记';
  if (typeStr === 'ONSITE') return '现场登记';
  return typeStr || '未知';
};
const fetchDetail = async (recordId) => {
  try {
    uni.showLoading({ title: '加载中...' });
    const res = await getVisitorRecordDetail({ recordId });
    if (res && res.code === 200 && res.data) {
      const data = res.data;
      const statusInfo = getStatusInfo(data.status);
      detail.value = {
        name: data.visitorName || '-',
        status: data.statusName || statusInfo.text,
        statusClass: statusInfo.class,
        type: data.registerTypeName || getRegisterTypeInfo(data.registerType),
        idCard: data.idCardMask || data.idCard || '-',
        recordNo: data.registerNo || '-',
        registerTime: data.registerTime || '-',
        registerCaptureTime: data.registerCollectTime || data.registerTime || '-',
        plateNumber: data.plateNo || '-',
        host: data.intervieweeName || '-',
        receiver: data.receiverName || data.intervieweeName || '-',
        hostVerify: data.receiverVerifyMethodName || data.receiverVerifyMethod || '-',
        areaTags: data.visitAreaNameList || (data.visitAreaNames ? data.visitAreaNames.split(',') : []),
        validityText: data.validTimeText || (data.validStartTime && data.validEndTime ? `${data.validStartTime} 至 ${data.validEndTime}` : '-'),
        signoutCaptureTime: data.signCollectTime || data.signTime || '-',
        signoutTime: data.signTime || '-',
        signoutReceiver: data.signReceiverName || '-',
        signoutVerify: data.signVerifyMethodName || data.signVerifyMethod || '-',
        faceImageUrl: data.faceImageUrl || '',
        signoutImageUrl: data.signImageUrl || '',
      };
    } else if (res && res.msg) {
      uni.showToast({ title: res.msg, icon: 'none' });
    }
  } catch (error) {
    console.error('获取详情失败', error);
    uni.showToast({ title: '获取详情失败', icon: 'none' });
  } finally {
    uni.hideLoading();
  }
};
onLoad((options) => {
  id.value = options?.id || '';
  if (id.value) {
    fetchDetail(id.value);
  }
});
const currentStatusIcon = computed(() => {
  const iconMap = {
pages/device-init/index.vue
@@ -33,10 +33,10 @@
onLoad(() => {
    const { ip, port } = getRequestServerConfig();
    formRows[1].value = ip || '192.168.110.8';
    formRows[1].value = ip || '192.168.110.111';
    formRows[2].value = port || '9099';
    setRequestServerConfig({
        ip:  '192.168.110.8',
        ip:  '192.168.110.111',
        port: '9099',
    });
    if (ip && port) {
utils/db.js
@@ -171,6 +171,79 @@
  await executeSql([sql]);
};
// ----------------- 历史记录缓存与结合 -----------------
export const saveHistoryRecords = async (records) => {
  // 删除已经同步的记录和旧的历史缓存,保留待同步的记录,避免数据冗余
  await executeSql([`DELETE FROM offline_records WHERE sync_status IN (1, 2)`]);
  if (!records || records.length === 0) return;
  const sqls = records.map(record => {
    const id = record.id || `hist_${Date.now()}_${Math.random().toString(36).substring(2)}`;
    const payloadStr = JSON.stringify(record).replace(/'/g, "''");
    const createTime = Date.now();
    // sync_status = 2 表示作为历史缓存数据
    return `INSERT INTO offline_records (id, type, payload, local_img_path, sync_status, create_time)
            VALUES ('${id}', 'history', '${payloadStr}', '', 2, ${createTime})`;
  });
  // 分批执行,防止 SQL 语句过长
  const chunkSize = 50;
  for (let i = 0; i < sqls.length; i += chunkSize) {
    const chunk = sqls.slice(i, i + chunkSize);
    await executeSql(chunk);
  }
};
export const getCombinedHistoryRecords = async () => {
  // 查询待同步的记录和历史记录
  const sql = `SELECT * FROM offline_records WHERE sync_status IN (0, 2) ORDER BY create_time DESC`;
  const rows = await selectSql(sql);
  const pendingMap = new Map();
  const historyList = [];
  const signouts = [];
  rows.forEach(row => {
    let payload;
    try {
      payload = JSON.parse(row.payload);
    } catch(e) {
      return;
    }
    if (row.sync_status === 2) {
      // 历史记录直接追加,不使用 idCard 去重
      historyList.push({ ...payload, _source: 'history' });
    } else if (row.sync_status === 0) {
      if (row.type === 'signout') {
        signouts.push(payload);
      } else {
        // 待同步的进场记录(appointment, online, 以及带有 _signedout 后缀的)
        const idCard = payload.idCard;
        const status = row.type.includes('signedout') ? 'SIGNED_OUT' : 'VISITING';
        // 使用 id 作为 fallback,避免缺少 idCard 时被覆盖
        pendingMap.set(idCard || row.id, {
           ...payload,
           status: status,
           _source: 'local_pending'
        });
      }
    }
  });
  // 将独立的签离记录应用到进场记录上
  signouts.forEach(so => {
    if (so.idCard && pendingMap.has(so.idCard)) {
      pendingMap.get(so.idCard).status = 'SIGNED_OUT';
    }
  });
  // 合并本地待同步记录和历史记录返回
  return [...Array.from(pendingMap.values()), ...historyList];
};
// ----------------- 配置/字典数据缓存 -----------------
export const saveOfflineConfig = async (key, value) => {
utils/request.js
@@ -44,6 +44,10 @@
};
const resolveRequestUrl = (url, baseURL) => {
    if (!url) {
        throw new Error('请求的 URL 不能为空');
    }
    if (/^https?:\/\//.test(url)) {
        return url;
    }
@@ -53,7 +57,8 @@
        throw new Error('未配置服务地址,请先完成设备激活');
    }
    return `${resolvedBaseUrl}${url.startsWith('/') ? url : `/${url}`}`;
    const safeUrl = String(url);
    return `${resolvedBaseUrl}${safeUrl.startsWith('/') ? safeUrl : `/${safeUrl}`}`;
};
const isBusinessSuccess = (responseData) => {