liujie
2026-07-24 3fcac334c71651a64e744670b865119877aee499
update
23个文件已修改
482 ■■■■■ 已修改文件
doc/sql/20260706_t_visitor_machine.sql 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/SysVisitorRecordController.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-applet/src/main/java/com/ruoyi/web/controller/api/SysApiVisitorRecordManageController.java 142 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysVisitorRecordMapper.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/ISysVisitorRecordService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVisitorConfigServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVisitorRecordServiceImpl.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVisitorMachineVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/SysVisitorMachineMapper.xml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/SysVisitorRecordMapper.xml 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/visitor-admin/record.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/components/ImageUpload/index.vue 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/layout/components/Navbar.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/utils/request.js 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/visitor-admin/device/index.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/visitor-admin/organization/employee.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/visitor-admin/record/visitor.vue 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/visitor-admin/system/config.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/vue.config.js 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
visitor-admin-ui/css/visitor-machine.css 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
visitor-admin-ui/js/visitor-machine.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
visitor-admin-ui/js/visitor-record.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
visitor-admin-ui/pages/visitor-machine.html 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/sql/20260706_t_visitor_machine.sql
@@ -7,6 +7,7 @@
  `device_id` varchar(100) NOT NULL COMMENT '设备ID',
  `enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
  `online_status` varchar(32) NOT NULL DEFAULT 'UNACTIVATED' COMMENT '在线状态:UNACTIVATED未激活 ONLINE在线 OFFLINE离线',
  `fingerprint` varchar(255) DEFAULT NULL COMMENT '百度SDK指纹',
  `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '删除标识,0正常 1删除',
  `create_by` varchar(64) DEFAULT '' COMMENT '创建者',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/SysVisitorRecordController.java
@@ -24,6 +24,8 @@
@RequestMapping("/system/visitor/record")
public class SysVisitorRecordController extends BaseController
{
    private static final int EXPORT_PAGE_SIZE = 10000;
    @Autowired
    private ISysVisitorRecordService visitorRecordService;
@@ -39,6 +41,20 @@
    }
    /**
     * 导出访客登记记录
     */
    @Log(title = "访客登记记录", businessType = BusinessType.EXPORT)
    @PreAuthorize("@ss.hasPermi('visitor:record:export')")
    @PostMapping("/export")
    public AjaxResult export(@RequestBody(required = false) SysVisitorRecordPageRequest request)
    {
        SysVisitorRecordPageRequest query = request == null ? new SysVisitorRecordPageRequest() : request;
        query.setPageNum(1);
        query.setPageSize(EXPORT_PAGE_SIZE);
        return success(visitorRecordService.pageList(query).getRecords());
    }
    /**
     * 查看访客登记详情
     */
    @Log(title = "访客登记记录", businessType = BusinessType.SELECT)
ruoyi-applet/src/main/java/com/ruoyi/web/controller/api/SysApiVisitorRecordManageController.java
@@ -14,11 +14,24 @@
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.List;
/**
 * 管理侧访客记录接口
@@ -60,6 +73,135 @@
    }
    /**
     * 导出访客记录
     */
    @ApiOperation(
            value = "导出访客记录",
            notes = "按分页查询接口的筛选条件查询全部访客记录并导出 Excel,不进行分页"
    )
    @PostMapping("/export")
    public void export(HttpServletResponse response,
                       @RequestBody(required = false) SysVisitorRecordPageRequest request) throws IOException
    {
        List<SysVisitorRecordVO> records = visitorRecordService.list(request);
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setCharacterEncoding("utf-8");
        String fileName = URLEncoder.encode("访客记录", "UTF-8").replace("+", "%20");
        response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
        SXSSFWorkbook workbook = new SXSSFWorkbook(500);
        try
        {
            writeVisitorRecordSheet(workbook, records);
            workbook.write(response.getOutputStream());
            response.flushBuffer();
        }
        finally
        {
            workbook.dispose();
            workbook.close();
        }
    }
    private void writeVisitorRecordSheet(SXSSFWorkbook workbook, List<SysVisitorRecordVO> records)
    {
        String[] headers = {
                "登记编号", "访客姓名", "证件号码", "登记类型", "被访人", "被访部门", "访问区域",
                "车牌号", "来访事由", "有效期开始时间", "有效期结束时间", "状态", "登记时间"
        };
        int[] columnWidths = {20, 16, 24, 16, 16, 20, 24, 16, 24, 22, 22, 20, 22};
        Sheet sheet = workbook.createSheet("访客记录");
        sheet.createFreezePane(0, 1);
        CellStyle headerStyle = createHeaderStyle(workbook);
        Row headerRow = sheet.createRow(0);
        for (int index = 0; index < headers.length; index++)
        {
            Cell cell = headerRow.createCell(index);
            cell.setCellValue(headers[index]);
            cell.setCellStyle(headerStyle);
            sheet.setColumnWidth(index, columnWidths[index] * 256);
        }
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        for (int index = 0; index < records.size(); index++)
        {
            SysVisitorRecordVO record = records.get(index);
            Row row = sheet.createRow(index + 1);
            setCellValue(row, 0, record.getRegisterNo());
            setCellValue(row, 1, record.getVisitorName());
            setCellValue(row, 2, record.getIdCard());
            setCellValue(row, 3, registerTypeName(record.getRegisterType()));
            setCellValue(row, 4, record.getIntervieweeName());
            setCellValue(row, 5, record.getIntervieweeDept());
            setCellValue(row, 6, record.getVisitAreaNames());
            setCellValue(row, 7, record.getPlateNo());
            setCellValue(row, 8, record.getVisitReason());
            setCellValue(row, 9, formatDate(dateFormat, record.getValidStartTime()));
            setCellValue(row, 10, formatDate(dateFormat, record.getValidEndTime()));
            setCellValue(row, 11, statusName(record.getStatus()));
            setCellValue(row, 12, formatDate(dateFormat, record.getRegisterTime()));
        }
    }
    private CellStyle createHeaderStyle(SXSSFWorkbook workbook)
    {
        Font font = workbook.createFont();
        font.setBold(true);
        CellStyle style = workbook.createCellStyle();
        style.setFont(font);
        style.setAlignment(HorizontalAlignment.CENTER);
        return style;
    }
    private void setCellValue(Row row, int columnIndex, String value)
    {
        row.createCell(columnIndex).setCellValue(value == null ? "" : value);
    }
    private String formatDate(SimpleDateFormat dateFormat, java.util.Date date)
    {
        return date == null ? "" : dateFormat.format(date);
    }
    private String registerTypeName(String registerType)
    {
        if ("APPOINTMENT".equalsIgnoreCase(registerType))
        {
            return "预约登记";
        }
        if ("ONSITE".equalsIgnoreCase(registerType))
        {
            return "现场登记";
        }
        return registerType;
    }
    private String statusName(String status)
    {
        if ("VISITING".equalsIgnoreCase(status))
        {
            return "来访中";
        }
        if ("SIGNED_OUT".equalsIgnoreCase(status))
        {
            return "已签离";
        }
        if ("PENDING_CONFIRM".equalsIgnoreCase(status))
        {
            return "逾期滞留";
        }
        if ("OVERDUE_AUTO_SIGNED_OUT".equalsIgnoreCase(status))
        {
            return "超期自动签离";
        }
        if ("CANCELLED".equalsIgnoreCase(status))
        {
            return "已取消";
        }
        return status;
    }
    /**
     * 查询访客记录详情
     */
    @ApiOperation(
ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysVisitorRecordMapper.java
@@ -28,6 +28,8 @@
    public List<SysVisitorRecordVO> pageList(@Param("query") SysVisitorRecordPageRequest query,
                                             @Param("pageInfo") PageInfo<SysVisitorRecordVO> pageInfo);
    public List<SysVisitorRecordVO> list(@Param("query") SysVisitorRecordPageRequest query);
    public SysVisitorRecordDetailVO selectDetailById(Long recordId);
    public List<SysVisitorPassRecordVO> selectPassRecordsByRecordId(Long recordId);
ruoyi-system/src/main/java/com/ruoyi/system/service/ISysVisitorRecordService.java
@@ -7,6 +7,8 @@
import com.ruoyi.system.vo.SysVisitorRecordDetailVO;
import com.ruoyi.system.vo.SysVisitorRecordVO;
import java.util.List;
/**
 * 访客登记记录服务
 *
@@ -16,6 +18,8 @@
{
    public PageInfo<SysVisitorRecordVO> pageList(SysVisitorRecordPageRequest request);
    public List<SysVisitorRecordVO> list(SysVisitorRecordPageRequest request);
    public SysVisitorRecordDetailVO detail(SysVisitorRecordDetailRequest request);
    public int refreshVisitorStatus();
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVisitorConfigServiceImpl.java
@@ -44,6 +44,8 @@
        labels.put("plateNo", "车牌号");
        labels.put("visitReason", "来访事由");
        labels.put("visitArea", "访问区域");
        labels.put("receiverName", "接待人");
        labels.put("registerTime", "登记时间");
        PRINT_FIELD_LABELS = Collections.unmodifiableMap(labels);
    }
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVisitorRecordServiceImpl.java
@@ -38,20 +38,18 @@
    public PageInfo<SysVisitorRecordVO> pageList(SysVisitorRecordPageRequest request)
    {
        refreshVisitorStatus();
        SysVisitorRecordPageRequest query = request == null ? new SysVisitorRecordPageRequest() : request;
        query.setIntervieweeName(StringUtils.trim(query.getIntervieweeName()));
        query.setIntervieweeKeyword(StringUtils.trim(query.getIntervieweeKeyword()));
        query.setIntervieweeDept(StringUtils.trim(query.getIntervieweeDept()));
        query.setVisitAreaKeyword(StringUtils.trim(query.getVisitAreaKeyword()));
        query.setStatus(StringUtils.trim(query.getStatus()));
        query.setVisitorName(StringUtils.trim(query.getVisitorName()));
        query.setVisitorKeyword(StringUtils.trim(query.getVisitorKeyword()));
        query.setVisitReason(StringUtils.trim(query.getVisitReason()));
        query.setRegisterType(StringUtils.trim(query.getRegisterType()));
        SysVisitorRecordPageRequest query = normalizeQuery(request);
        PageInfo<SysVisitorRecordVO> pageInfo = new PageInfo<SysVisitorRecordVO>(query.getPageNum(), query.getPageSize());
        List<SysVisitorRecordVO> list = visitorRecordMapper.pageList(query, pageInfo);
        pageInfo.setRecords(list);
        return pageInfo;
    }
    @Override
    public List<SysVisitorRecordVO> list(SysVisitorRecordPageRequest request)
    {
        refreshVisitorStatus();
        return visitorRecordMapper.list(normalizeQuery(request));
    }
    @Override
@@ -259,4 +257,19 @@
        }
        return recordId;
    }
    private SysVisitorRecordPageRequest normalizeQuery(SysVisitorRecordPageRequest request)
    {
        SysVisitorRecordPageRequest query = request == null ? new SysVisitorRecordPageRequest() : request;
        query.setIntervieweeName(StringUtils.trim(query.getIntervieweeName()));
        query.setIntervieweeKeyword(StringUtils.trim(query.getIntervieweeKeyword()));
        query.setIntervieweeDept(StringUtils.trim(query.getIntervieweeDept()));
        query.setVisitAreaKeyword(StringUtils.trim(query.getVisitAreaKeyword()));
        query.setStatus(StringUtils.trim(query.getStatus()));
        query.setVisitorName(StringUtils.trim(query.getVisitorName()));
        query.setVisitorKeyword(StringUtils.trim(query.getVisitorKeyword()));
        query.setVisitReason(StringUtils.trim(query.getVisitReason()));
        query.setRegisterType(StringUtils.trim(query.getRegisterType()));
        return query;
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVisitorMachineVO.java
@@ -25,6 +25,8 @@
    private String deviceId;
    private String fingerprint;
    private Boolean enabled;
    private String onlineStatus;
ruoyi-system/src/main/resources/mapper/system/SysVisitorMachineMapper.xml
@@ -12,6 +12,7 @@
        <result property="deviceId"     column="device_id" />
        <result property="enabled"      column="enabled" />
        <result property="onlineStatus" column="online_status" />
        <result property="fingerprint"  column="fingerprint" />
        <result property="delFlag"      column="del_flag" />
        <result property="remark"       column="remark" />
        <result property="createBy"     column="create_by" />
@@ -27,6 +28,7 @@
        <result property="deviceName"   column="device_name" />
        <result property="brand"        column="brand" />
        <result property="deviceId"     column="device_id" />
        <result property="fingerprint"  column="fingerprint" />
        <result property="enabled"      column="enabled" />
        <result property="onlineStatus" column="online_status" />
        <result property="remark"       column="remark" />
@@ -41,6 +43,7 @@
               device_id,
               enabled,
               online_status,
               fingerprint,
               del_flag,
               remark,
               create_by,
@@ -57,6 +60,7 @@
               m.device_name,
               m.brand,
               m.device_id,
               m.fingerprint,
               m.enabled,
               m.online_status,
               m.remark,
@@ -157,6 +161,12 @@
            <if test="enabled != null">
                enabled = #{enabled},    <!-- 注意:enabled 可能是 Boolean 或 Integer,若允许 false 或 0,则只判 null -->
            </if>
            <if test="onlineStatus != null and onlineStatus != ''">
                online_status = #{onlineStatus},
            </if>
            <if test="fingerprint != null and fingerprint != ''">
                fingerprint = #{fingerprint},
            </if>
            <if test="remark != null and remark != ''">
                remark = #{remark},
            </if>
ruoyi-system/src/main/resources/mapper/system/SysVisitorRecordMapper.xml
@@ -86,7 +86,7 @@
        from t_visitor_record
    </sql>
    <select id="pageList" resultMap="SysVisitorRecordVOResult">
    <sql id="selectVisitorRecordList">
        <include refid="selectVisitorRecordBase" />
        <where>
            and del_flag = '0'
@@ -124,13 +124,21 @@
                )
            </if>
            <if test="query.visitReason != null and query.visitReason != ''">
                and visit_reason = #{query.visitReason}
                and visit_reason like concat('%', #{query.visitReason}, '%')
            </if>
            <if test="query.registerType != null and query.registerType != ''">
                and register_type = #{query.registerType}
            </if>
        </where>
        order by register_time desc, record_id desc
    </sql>
    <select id="pageList" resultMap="SysVisitorRecordVOResult">
        <include refid="selectVisitorRecordList" />
    </select>
    <select id="list" resultMap="SysVisitorRecordVOResult">
        <include refid="selectVisitorRecordList" />
    </select>
    <select id="selectDetailById" parameterType="Long" resultMap="SysVisitorRecordDetailVOResult">
ruoyi-ui/src/api/visitor-admin/record.js
@@ -5,11 +5,15 @@
}
export function getVisitorRecord(recordId) {
  return request({ url: '/system/visitor/record/detail', method: 'post', data: { recordId } })
  return request({ url: '/system/visitor/record/detail', method: 'post', data: { recordId }})
}
export function delVisitorRecord(recordIds) {
  return request({ url: '/system/visitor/record/delete', method: 'post', data: { recordIds } })
  return request({ url: '/system/visitor/record/delete', method: 'post', data: { recordIds }})
}
export function exportVisitorRecord(data) {
  return request({ url: '/system/visitor/record/export', method: 'post', data })
}
export function listOperationLog(data) {
@@ -19,4 +23,3 @@
export function exportOperationLog(data) {
  return request({ url: '/system/operation/log/export', method: 'post', data })
}
ruoyi-ui/src/components/ImageUpload/index.vue
@@ -68,6 +68,11 @@
    isShowTip: {
      type: Boolean,
      default: true
    },
    // 是否在上传前压缩图片
    compress: {
      type: Boolean,
      default: false
    }
  },
  data() {
@@ -140,15 +145,63 @@
        this.$modal.msgError(`文件格式不正确, 请上传${this.fileType.join("/")}图片格式文件!`);
        return false;
      }
      if (this.fileSize) {
        const isLt = file.size / 1024 / 1024 < this.fileSize;
        if (!isLt) {
          this.$modal.msgError(`上传头像图片大小不能超过 ${this.fileSize} MB!`);
          return false;
      const compressedFile = this.compress ? this.compressImage(file) : Promise.resolve(file);
      return compressedFile.then(uploadFile => {
        if (this.fileSize) {
          const isLt = uploadFile.size / 1024 / 1024 < this.fileSize;
          if (!isLt) {
            throw new Error("FILE_SIZE_EXCEEDED");
          }
        }
      }
      this.$modal.loading("正在上传图片,请稍候...");
      this.number++;
        this.$modal.loading("正在上传图片,请稍候...");
        this.number++;
        return uploadFile;
      }).catch(error => {
        const message = error && error.message === "FILE_SIZE_EXCEEDED"
          ? `上传头像图片大小不能超过 ${this.fileSize} MB!`
          : "图片压缩失败,请重试";
        this.$modal.msgError(message);
        return Promise.reject(error);
      });
    },
    // 将人脸图片限制在合理尺寸并转为 JPEG,减少上传和设备同步数据量
    compressImage(file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = event => {
          const image = new Image();
          image.onload = () => {
            const maxWidth = 1280;
            const maxHeight = 1280;
            const scale = Math.min(1, maxWidth / image.width, maxHeight / image.height);
            const resized = scale < 1;
            const canvas = document.createElement("canvas");
            canvas.width = Math.max(1, Math.round(image.width * scale));
            canvas.height = Math.max(1, Math.round(image.height * scale));
            const context = canvas.getContext("2d");
            if (!context) {
              resolve(file);
              return;
            }
            context.drawImage(image, 0, 0, canvas.width, canvas.height);
            canvas.toBlob(blob => {
              if (!blob || (!resized && blob.size >= file.size)) {
                resolve(file);
                return;
              }
              const fileName = file.name.replace(/\.[^.]+$/, "") + ".jpg";
              resolve(new File([blob], fileName, {
                type: "image/jpeg",
                lastModified: Date.now()
              }));
            }, "image/jpeg", 0.82);
          };
          image.onerror = reject;
          image.src = event.target.result;
        };
        reader.onerror = reject;
        reader.readAsDataURL(file);
      });
    },
    // 文件个数超出
    handleExceed() {
ruoyi-ui/src/layout/components/Navbar.vue
@@ -120,7 +120,7 @@
      },
      passwordRules: {
        oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
        newPassword: [{ validator: strongPassword, trigger: 'blur' }],
        newPassword: [{ required: true, validator: strongPassword, trigger: 'blur' }],
        confirmPassword: [{ required: true, message: '请确认新密码', trigger: 'blur' }, { validator: confirmPassword, trigger: 'blur' }]
      }
    }
ruoyi-ui/src/utils/request.js
@@ -4,7 +4,6 @@
import { getToken } from '@/utils/auth'
import errorCode from '@/utils/errorCode'
import { tansParams, blobValidate } from "@/utils/ruoyi";
import cache from '@/plugins/cache'
import { saveAs } from 'file-saver'
import visitorAdminConfig from '@/config/visitor-admin'
@@ -83,8 +82,6 @@
  config.baseURL = getVisitorApiBaseUrl()
  // 是否需要设置 token
  const isToken = (config.headers || {}).isToken === false
  // 是否需要防止数据重复提交
  const isRepeatSubmit = (config.headers || {}).repeatSubmit === false
  if (getToken() && !isToken) {
    config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
  }
@@ -94,35 +91,6 @@
    url = url.slice(0, -1);
    config.params = {};
    config.url = url;
  }
  if (!isRepeatSubmit && (config.method === 'post' || config.method === 'put')) {
    const requestObj = {
      url: config.url,
      data: typeof config.data === 'object' ? JSON.stringify(config.data) : config.data,
      time: new Date().getTime()
    }
    const requestSize = Object.keys(JSON.stringify(requestObj)).length; // 请求数据大小
    const limitSize = 5 * 1024 * 1024; // 限制存放数据5M
    if (requestSize >= limitSize) {
      console.warn(`[${config.url}]: ` + '请求数据大小超出允许的5M限制,无法进行防重复提交验证。')
      return config;
    }
    const sessionObj = cache.session.getJSON('sessionObj')
    if (sessionObj === undefined || sessionObj === null || sessionObj === '') {
      cache.session.setJSON('sessionObj', requestObj)
    } else {
      const s_url = sessionObj.url;                  // 请求地址
      const s_data = sessionObj.data;                // 请求数据
      const s_time = sessionObj.time;                // 请求时间
      const interval = 1000;                         // 间隔时间(ms),小于此时间视为重复提交
      if (s_data === requestObj.data && requestObj.time - s_time < interval && s_url === requestObj.url) {
        const message = '数据正在处理,请勿重复提交';
        console.warn(`[${s_url}]: ` + message)
        return Promise.reject(new Error(message))
      } else {
        cache.session.setJSON('sessionObj', requestObj)
      }
    }
  }
  return config
}, error => {
ruoyi-ui/src/views/visitor-admin/device/index.vue
@@ -60,6 +60,7 @@
            <el-table-column label="设备名称" prop="deviceName" min-width="120" show-overflow-tooltip />
            <el-table-column label="品牌" prop="brand" min-width="100" show-overflow-tooltip />
            <el-table-column label="设备ID" prop="deviceId" min-width="150" show-overflow-tooltip />
            <el-table-column v-if="deviceType === 'machine'" label="百度SDK指纹" prop="fingerprint" min-width="160" show-overflow-tooltip />
            <el-table-column label="所属区域" prop="areaName" min-width="130" show-overflow-tooltip />
            <el-table-column label="启用状态" align="center" width="100">
              <template slot-scope="scope">
ruoyi-ui/src/views/visitor-admin/organization/employee.vue
@@ -143,7 +143,7 @@
          </el-col>
        </el-row>
        <el-form-item label="人脸图片" prop="faceImage" class="employee-face-upload">
          <image-upload v-model="form.faceImage" :limit="1" :file-size="5" :is-show-tip="false" />
          <image-upload v-model="form.faceImage" :limit="1" :file-size="5" :is-show-tip="false" :compress="true" />
        </el-form-item>
      </el-form>
      <div slot="footer" class="dialog-footer">
ruoyi-ui/src/views/visitor-admin/record/visitor.vue
@@ -4,7 +4,7 @@
      <header class="visitor-header">
        <h1>访客登记记录</h1>
      </header>
    <el-form v-show="showSearch" ref="queryForm" :model="queryParams" size="small" :inline="true" class="visitor-query">
    <el-form ref="queryForm" :model="queryParams" size="small" :inline="true" class="visitor-query">
      <el-form-item label="访客姓名" prop="visitorName">
        <el-input v-model="queryParams.visitorName" placeholder="请输入访客姓名" clearable @keyup.enter.native="handleQuery" />
      </el-form-item>
@@ -13,6 +13,9 @@
      </el-form-item>
      <el-form-item label="被访部门" prop="intervieweeDept">
        <el-input v-model="queryParams.intervieweeDept" placeholder="请输入被访部门" clearable @keyup.enter.native="handleQuery" />
      </el-form-item>
      <el-form-item label="来访事由" prop="visitReason">
        <el-input v-model="queryParams.visitReason" placeholder="请输入来访事由" clearable @keyup.enter.native="handleQuery" />
      </el-form-item>
      <el-form-item label="访问区域" prop="areaId">
        <treeselect v-model="queryParams.areaId" :options="areaOptions" placeholder="请选择区域" clearable class="query-tree" />
@@ -44,16 +47,21 @@
    </el-form>
    <el-row :gutter="10" class="mb8">
      <el-col v-if="!isMiddlePlatformReadonly" v-hasPermi="['visitor:record:remove']" :span="1.5">
        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete()">删除</el-button>
      <el-col v-hasPermi="['visitor:record:export']" :span="1.5">
        <el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport">导出</el-button>
      </el-col>
      <right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
      <el-col v-if="!isMiddlePlatformReadonly" v-hasPermi="['visitor:record:remove']" :span="1.5">
        <el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete">批量删除</el-button>
      </el-col>
    </el-row>
    <el-table v-loading="loading" class="visitor-table" :data="recordList" @selection-change="handleSelectionChange">
      <el-table-column v-if="!isMiddlePlatformReadonly" type="selection" width="50" align="center" />
      <el-table-column label="登记编号" prop="registerNo" min-width="150" show-overflow-tooltip />
      <el-table-column label="访客姓名" prop="visitorName" min-width="150" />
      <el-table-column label="身份证" min-width="150" show-overflow-tooltip>
        <template slot-scope="scope">{{ maskIdCard(scope.row.idCard) }}</template>
      </el-table-column>
      <el-table-column label="登记类型" align="center" min-width="150">
        <template slot-scope="scope">{{ optionLabel(registerTypes, scope.row.registerType) }}</template>
      </el-table-column>
@@ -61,6 +69,10 @@
      <el-table-column label="被访部门" prop="intervieweeDept" min-width="150" show-overflow-tooltip />
      <el-table-column label="访问区域" prop="visitAreaNames" min-width="150" show-overflow-tooltip />
      <el-table-column label="车牌号" prop="plateNo" min-width="150" />
      <el-table-column label="来访事由" prop="visitReason" min-width="150" show-overflow-tooltip />
      <el-table-column label="有效期" min-width="260" show-overflow-tooltip>
        <template slot-scope="scope">{{ validTimeText(scope.row.validStartTime, scope.row.validEndTime) }}</template>
      </el-table-column>
      <el-table-column label="状态" align="center" min-width="150">
        <template slot-scope="scope">
          <span class="record-status-pill" :class="statusClass(scope.row.status)">{{ optionLabel(statusOptions, scope.row.status) }}</span>
@@ -69,10 +81,9 @@
      <el-table-column label="登记时间" align="center" prop="registerTime" min-width="150">
        <template slot-scope="scope">{{ parseTime(scope.row.registerTime) }}</template>
      </el-table-column>
      <el-table-column label="操作" align="center" width="150" fixed="right" class-name="small-padding fixed-width">
      <el-table-column label="操作" align="center" width="90" fixed="right" class-name="small-padding fixed-width">
        <template slot-scope="scope">
          <el-button size="mini" type="text" icon="el-icon-view" @click="handleDetail(scope.row)">详情</el-button>
          <el-button v-if="!isMiddlePlatformReadonly" v-hasPermi="['visitor:record:remove']" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
@@ -83,10 +94,11 @@
</template>
<script>
import { saveAs } from 'file-saver'
import Treeselect from '@riophae/vue-treeselect'
import '@riophae/vue-treeselect/dist/vue-treeselect.css'
import { treeArea } from '@/api/visitor-admin/area'
import { listVisitorRecord, delVisitorRecord } from '@/api/visitor-admin/record'
import { listVisitorRecord, delVisitorRecord, exportVisitorRecord } from '@/api/visitor-admin/record'
import { getResponseData, getPageRows, getPageTotal, normalizeAreaOptions, toIdArray } from '@/utils/visitor-admin'
export default {
@@ -96,7 +108,6 @@
  data() {
    return {
      loading: false,
      showSearch: true,
      multiple: true,
      ids: [],
      total: 0,
@@ -197,6 +208,24 @@
      }
      return 'is-neutral'
    },
    // 目的:列表只展示身份证前四位和后四位,中间内容统一脱敏。
    maskIdCard(value) {
      const text = String(value || '')
      if (!text) {
        return '-'
      }
      if (text.length <= 8) {
        return text
      }
      return text.slice(0, 4) + '*'.repeat(text.length - 8) + text.slice(-4)
    },
    // 目的:把有效期开始和结束时间合并成列表展示文本。
    validTimeText(start, end) {
      if (!start && !end) {
        return '-'
      }
      return (this.parseTime(start) || '-') + ' 至 ' + (this.parseTime(end) || '-')
    },
    // 目的:从第一页重新执行访客记录查询。
    handleQuery() {
      this.queryParams.pageNum = 1
@@ -208,6 +237,66 @@
      this.resetForm('queryForm')
      this.handleQuery()
    },
    // 目的:导出当前筛选条件下的访客登记记录。
    handleExport() {
      exportVisitorRecord(this.buildQuery()).then(res => {
        const rows = Array.isArray(getResponseData(res)) ? getResponseData(res) : []
        const blob = new Blob([this.buildExcelXml(rows)], { type: 'application/vnd.ms-excel;charset=utf-8' })
        saveAs(blob, `访客登记记录_${this.parseTime(new Date(), '{y}{m}{d}{h}{i}{s}')}.xls`)
      })
    },
    // 目的:把访客登记记录转换成 Excel 2003 XML,保持与操作日志导出方式一致。
    buildExcelXml(rows) {
      const columns = [
        { title: '登记编号', key: 'registerNo', width: 150 },
        { title: '访客姓名', key: 'visitorName', width: 100 },
        { title: '证件号码', key: 'idCard', width: 170 },
        { title: '登记类型', value: row => this.optionLabel(this.registerTypes, row.registerType), width: 90 },
        { title: '被访人', key: 'intervieweeName', width: 100 },
        { title: '被访部门', key: 'intervieweeDept', width: 130 },
        { title: '访问区域', key: 'visitAreaNames', width: 150 },
        { title: '车牌号', key: 'plateNo', width: 100 },
        { title: '来访事由', key: 'visitReason', width: 180 },
        { title: '有效期开始时间', value: row => this.parseTime(row.validStartTime) || '', width: 150 },
        { title: '有效期结束时间', value: row => this.parseTime(row.validEndTime) || '', width: 150 },
        { title: '状态', value: row => this.optionLabel(this.statusOptions, row.status), width: 100 },
        { title: '登记时间', value: row => this.parseTime(row.registerTime) || '', width: 150 }
      ]
      const columnXml = columns.map(column => `<Column ss:Width="${column.width}"/>`).join('')
      const headerXml = `<Row>${columns.map(column => this.excelCell(column.title, 'Header')).join('')}</Row>`
      const bodyXml = rows.map(row => `<Row>${columns.map(column => {
        const value = column.value ? column.value(row) : row[column.key]
        return this.excelCell(value)
      }).join('')}</Row>`).join('')
      return `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
 xmlns:o="urn:schemas-microsoft-com:office:office"
 xmlns:x="urn:schemas-microsoft-com:office:excel"
 xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
 <Styles>
  <Style ss:ID="Header"><Font ss:Bold="1"/><Interior ss:Color="#EAF1F9" ss:Pattern="Solid"/></Style>
 </Styles>
 <Worksheet ss:Name="访客登记记录">
  <Table>${columnXml}${headerXml}${bodyXml}</Table>
 </Worksheet>
</Workbook>`
    },
    // 目的:生成 Excel 文本单元格并按需应用表头样式。
    excelCell(value, styleId) {
      const style = styleId ? ` ss:StyleID="${styleId}"` : ''
      return `<Cell${style}><Data ss:Type="String">${this.escapeXml(value)}</Data></Cell>`
    },
    // 目的:转义 Excel XML 特殊字符,避免访客数据破坏文件结构。
    escapeXml(value) {
      return String(value == null ? '' : value).replace(/[<>&'"]/g, char => ({
        '<': '&lt;',
        '>': '&gt;',
        '&': '&amp;',
        "'": '&apos;',
        '"': '&quot;'
      }[char]))
    },
    // 目的:同步表格勾选状态,控制批量删除按钮可用性。
    handleSelectionChange(selection) {
      this.ids = selection.map(item => item.recordId)
@@ -217,14 +306,14 @@
    handleDetail(row) {
      this.$router.push(`/visitor/record/detail/${row.recordId}`)
    },
    // 目的:删除访客记录;中台只读模式下禁止本地删除。
    handleDelete(row) {
    // 目的:批量删除已勾选的访客记录;中台只读模式下禁止本地删除。
    handleDelete() {
      if (this.isMiddlePlatformReadonly) {
        this.denyMiddlePlatformWrite()
        return
      }
      const ids = row ? [row.recordId] : toIdArray(this.ids)
      this.$modal.confirm('是否确认删除选中的访客登记记录?').then(() => delVisitorRecord(ids)).then(() => {
      const ids = toIdArray(this.ids)
      this.$modal.confirm(`是否确认批量删除选中的 ${ids.length} 条访客登记记录?`).then(() => delVisitorRecord(ids)).then(() => {
        this.$modal.msgSuccess('删除成功')
        this.getList()
      })
ruoyi-ui/src/views/visitor-admin/system/config.vue
@@ -211,7 +211,9 @@
        { fieldCode: 'visitAreaNames', fieldName: '访问区域' },
        { fieldCode: 'validTime', fieldName: '有效期' },
        { fieldCode: 'plateNo', fieldName: '车牌号' },
        { fieldCode: 'visitReason', fieldName: '来访事由' }
        { fieldCode: 'visitReason', fieldName: '来访事由' },
        { fieldCode: 'receiverName', fieldName: '接待人' },
        { fieldCode: 'registerTime', fieldName: '登记时间' }
      ]
    }
  },
@@ -225,7 +227,9 @@
        visitAreaNames: 'A区 / 办公楼',
        validTime: '2026-07-07 09:00~18:00',
        plateNo: '苏A12345',
        visitReason: '商务拜访'
        visitReason: '商务拜访',
        receiverName: '王五',
        registerTime: '2026-07-23 09:00'
      }
      return this.printFieldOptions
        .filter(item => this.form.printFields.indexOf(item.fieldCode) !== -1)
ruoyi-ui/vue.config.js
@@ -35,14 +35,14 @@
    proxy: {
      // detail: https://cli.vuejs.org/config/#devserver-proxy
      [process.env.VUE_APP_BASE_API]: {
        target: `http://localhost:9098`,
        target: `http://221.182.45.100:9799`,
        changeOrigin: true,
        pathRewrite: {
          ['^' + process.env.VUE_APP_BASE_API]: ''
        }
      },
      '/profile': {
        target: `http://localhost:9098`,
        target: `http://221.182.45.100:9799`,
        changeOrigin: true
      }
    },
visitor-admin-ui/css/visitor-machine.css
@@ -1020,6 +1020,8 @@
.machine-table th:nth-child(8),
.machine-table td:nth-child(8),
.machine-table th:nth-child(9),
.machine-table td:nth-child(9) {
  width: 11.111%;
.machine-table td:nth-child(9),
.machine-table th:nth-child(10),
.machine-table td:nth-child(10) {
  width: 10%;
}
visitor-admin-ui/js/visitor-machine.js
@@ -232,6 +232,7 @@
        '  <td>' + escapeHtml(item.deviceName || "-") + '</td>',
        '  <td>' + escapeHtml(item.brand || "-") + '</td>',
        '  <td>' + escapeHtml(item.deviceId || "-") + '</td>',
        '  <td title="' + escapeHtml(item.fingerprint || "") + '">' + escapeHtml(item.fingerprint || "-") + '</td>',
        '  <td>' + escapeHtml(item.areaName || "-") + '</td>',
        '  <td>' + renderEnabledStatus(item.enabled) + '</td>',
        '  <td>' + renderOnlineStatus(item.onlineStatus) + '</td>',
visitor-admin-ui/js/visitor-record.js
@@ -476,7 +476,15 @@
    if (text.length < 8) {
      return text || "-";
    }
    return text.slice(0, 6) + "********" + text.slice(-4);
    return text.slice(0, 4) + repeatString("*", text.length - 8) + text.slice(-4);
  }
  function repeatString(value, count) {
    var result = "";
    for (var index = 0; index < count; index++) {
      result += value;
    }
    return result;
  }
  function toServerDateTime(value) {
visitor-admin-ui/pages/visitor-machine.html
@@ -154,6 +154,7 @@
                    <th>设备名称</th>
                    <th>品牌</th>
                    <th>设备ID</th>
                    <th>百度SDK指纹</th>
                    <th>所属区域</th>
                    <th>启用状态</th>
                    <th>在线状态</th>