zjk
2026-07-31 8665daa7aa863f96d0d8f27c167edae1efc38022
Merge branch 'master' into dev
17个文件已添加
35个文件已修改
1502 ■■■■■ 已修改文件
doc/sql/20260706_t_visitor_config.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/sql/20260723_t_visitor_machine_add_fingerprint.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/sql/20260723_visitor_record_add_export_permission.sql 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/sql/20260729_t_visitor_config_add_appointment_default_visit_time.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/SysVersionUpdateController.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/MiddlePlatformDataQuery.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application-prod.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application-test.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-applet/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUploadUtils.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/SysVisitorConfig.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/dto/SysVisitorConfigSaveRequest.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysDashboardMapper.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysDashboardServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysProtocolConfigServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVersionUpdateServiceImpl.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVisitorConfigServiceImpl.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/utils/ProtocolHtmlSanitizer.java 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVersionUpdateVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVisitorConfigVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/SysDashboardMapper.xml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/SysVisitorConfigMapper.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/SysVisitorMachineMapper.xml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/SysVisitorRecordMapper.xml 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/test/java/com/ruoyi/system/utils/ProtocolHtmlSanitizerTest.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/visitor-admin/version.js 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-appointment-register.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-appointment-time.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-device.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-face-capture.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-face-compare.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-id-card.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-onsite-register.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-print.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-receiver-confirm.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-register.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-sign-confirm.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/icons/svg/config-system.svg 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/assets/styles/visitor-admin.scss 383 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/utils/visitor-admin.js 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/visitor-admin/dashboard/index.vue 126 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/visitor-admin/device/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/visitor-admin/record/visitor.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/visitor-admin/system/config.vue 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/visitor-admin/system/protocol.vue 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/visitor-admin/system/version.vue 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
visitor-admin-ui/css/version-update.css 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
visitor-admin-ui/js/version-update.js 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
visitor-admin-ui/pages/version-update.html 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
doc/sql/20260706_t_visitor_config.sql
@@ -3,10 +3,11 @@
  `config_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '配置主键',
  `onsite_registration_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '现场登记开关:0关闭 1开启',
  `appointment_registration_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '预约登记开关:0关闭 1开启',
  `appointment_default_visit_time` varchar(5) DEFAULT NULL COMMENT '预约登记默认访问时间,格式HH:mm',
  `register_receiver_confirm_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '登记需接待人确认:0关闭 1开启',
  `sign_receiver_confirm_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '签离需接待人确认:0关闭 1开启',
  `face_compare_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '人像对比开关:0关闭 1开启',
  `face_capture_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '全方位人脸采集开关:0关闭 1开启',
  `face_capture_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '人脸采集开关:0关闭 1开启',
  `manual_id_card_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '允许手动输入身份证:0关闭 1开启',
  `print_fields` varchar(500) NOT NULL DEFAULT 'visitorName,idCard,appointmentTimeRange,interviewee' COMMENT '凭条打印字段,英文逗号分隔并按顺序打印',
  `system_name` varchar(100) NOT NULL DEFAULT '自主访客登记管理平台' COMMENT '系统名称',
@@ -27,6 +28,7 @@
  `config_id`,
  `onsite_registration_enabled`,
  `appointment_registration_enabled`,
  `appointment_default_visit_time`,
  `register_receiver_confirm_enabled`,
  `sign_receiver_confirm_enabled`,
  `face_compare_enabled`,
@@ -43,6 +45,7 @@
  1,
  1,
  1,
  NULL,
  1,
  1,
  1,
doc/sql/20260723_t_visitor_machine_add_fingerprint.sql
New file
@@ -0,0 +1,3 @@
-- 访客机管理:补充百度SDK指纹字段
ALTER TABLE `t_visitor_machine`
  ADD COLUMN `fingerprint` varchar(255) DEFAULT NULL COMMENT '百度SDK指纹' AFTER `online_status`;
doc/sql/20260723_visitor_record_add_export_permission.sql
New file
@@ -0,0 +1,29 @@
-- 访客登记记录新增导出按钮权限
INSERT INTO sys_menu (
  menu_id, menu_name, parent_id, order_num, path, component, query,
  is_frame, is_cache, menu_type, visible, status, perms, icon,
  create_by, create_time, remark
)
SELECT
  (SELECT COALESCE(MAX(menu_id), 0) + 1 FROM sys_menu menu_id_source),
  '访客记录导出', parent.menu_id, 2, '#', NULL, NULL,
  '1', '1', 'F', '0', '0', 'visitor:record:export', '#',
  'admin', NOW(), '访客后台按钮权限'
FROM sys_menu parent
WHERE parent.perms = 'visitor:record:view'
  AND NOT EXISTS (
    SELECT 1 FROM sys_menu existing WHERE existing.perms = 'visitor:record:export'
  );
-- 默认授权给若依管理员角色,普通角色可在角色管理中按需授权。
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT r.role_id, m.menu_id
FROM sys_role r
JOIN sys_menu m ON m.perms = 'visitor:record:export'
WHERE r.role_id = 1
  AND NOT EXISTS (
    SELECT 1
    FROM sys_role_menu rm
    WHERE rm.role_id = r.role_id
      AND rm.menu_id = m.menu_id
  );
doc/sql/20260729_t_visitor_config_add_appointment_default_visit_time.sql
New file
@@ -0,0 +1,3 @@
ALTER TABLE `t_visitor_config`
  ADD COLUMN `appointment_default_visit_time` varchar(5) DEFAULT NULL COMMENT '预约登记默认访问时间,格式HH:mm'
  AFTER `appointment_registration_enabled`;
ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/SysVersionUpdateController.java
@@ -1,9 +1,13 @@
package com.ruoyi.web.controller.api;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.file.FileUploadUtils;
import com.ruoyi.common.utils.file.FileUtils;
import com.ruoyi.framework.config.ServerConfig;
import com.ruoyi.system.dto.SysVersionUpdateAddRequest;
import com.ruoyi.system.dto.SysVersionUpdateDeleteRequest;
import com.ruoyi.system.dto.SysVersionUpdateDetailRequest;
@@ -15,6 +19,7 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
 * 版本更新接口
@@ -25,8 +30,15 @@
@RequestMapping("/system/version/update")
public class SysVersionUpdateController extends BaseController
{
    private static final long MAX_PACKAGE_SIZE = 200L * 1024 * 1024;
    private static final String[] ALLOWED_PACKAGE_EXTENSION = { "apk" };
    @Autowired
    private ISysVersionUpdateService versionUpdateService;
    @Autowired
    private ServerConfig serverConfig;
    /**
     * 分页查询版本更新列表
@@ -61,6 +73,36 @@
        return toAjax(versionUpdateService.add(request, getUsername()));
    }
    @Log(title = "版本更新", businessType = BusinessType.INSERT)
    @PreAuthorize("@ss.hasPermi('visitor:version:add')")
    @PostMapping("/upload")
    public AjaxResult upload(MultipartFile file)
    {
        if (file == null || file.isEmpty())
        {
            return error("请上传安装包");
        }
        if (file.getSize() >= MAX_PACKAGE_SIZE)
        {
            return error("安装包大小必须小于200M");
        }
        try
        {
            String fileName = FileUploadUtils.upload(RuoYiConfig.getUploadPath(), file,
                    ALLOWED_PACKAGE_EXTENSION, MAX_PACKAGE_SIZE);
            AjaxResult ajax = success();
            ajax.put("url", serverConfig.getUrl() + fileName);
            ajax.put("fileName", fileName);
            ajax.put("newFileName", FileUtils.getName(fileName));
            ajax.put("originalFilename", file.getOriginalFilename());
            return ajax;
        }
        catch (Exception e)
        {
            return error(e.getMessage());
        }
    }
    /**
     * 删除版本更新
     */
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java
@@ -154,7 +154,7 @@
        }
        if (deptService.checkDeptExistUser(deptId))
        {
            return warn("部门存在用户,不允许删除");
            return warn("操作失败:存在员工");
        }
        deptService.checkDeptDataScope(deptId);
        return AjaxResult.success(deptService.deleteDeptById(deptId));
ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/MiddlePlatformDataQuery.java
@@ -463,10 +463,10 @@
    }
    /**
     * 目的:将中台 1/0 在线状态转换为前端展示使用的 online/offline。
     * 目的:将中台 1/0 在线状态转换为本地数据库使用的在线状态枚举。
     */
    private static String toLocalOnlineStatus(Object value) {
        // 本地页面使用 online/offline,中台设备接口使用 1/0。
        return "1".equals(String.valueOf(value)) || "online".equalsIgnoreCase(String.valueOf(value)) ? "online" : "offline";
        return "1".equals(String.valueOf(value)) || "online".equalsIgnoreCase(String.valueOf(value))
                ? "ONLINE" : "OFFLINE";
    }
}
ruoyi-admin/src/main/resources/application-prod.yml
@@ -192,7 +192,7 @@
  # 过滤开关
  enabled: true
  # 排除链接(多个用逗号分隔)
  excludes: /system/notice
  excludes: /system/notice,/system/protocol/config/save
  # 匹配链接
  urlPatterns: /system/*,/monitor/*,/tool/*
# file upload
ruoyi-admin/src/main/resources/application-test.yml
@@ -185,7 +185,7 @@
  # 过滤开关
  enabled: true
  # 排除链接(多个用逗号分隔)
  excludes: /system/notice
  excludes: /system/notice,/system/protocol/config/save
  # 匹配链接
  urlPatterns: /system/*,/monitor/*,/tool/*
# file upload
ruoyi-applet/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java
@@ -118,7 +118,7 @@
        }
        if (deptService.checkDeptExistUser(deptId))
        {
            return warn("部门存在用户,不允许删除");
            return warn("操作失败:存在员工");
        }
        deptService.checkDeptDataScope(deptId);
        return AjaxResult.success(deptService.deleteDeptById(deptId));
ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUploadUtils.java
@@ -102,13 +102,20 @@
            throws FileSizeLimitExceededException, IOException, FileNameLengthLimitExceededException,
            InvalidExtensionException
    {
        return upload(baseDir, file, allowedExtension, DEFAULT_MAX_SIZE);
    }
    public static final String upload(String baseDir, MultipartFile file, String[] allowedExtension, long maxSize)
            throws FileSizeLimitExceededException, IOException, FileNameLengthLimitExceededException,
            InvalidExtensionException
    {
        int fileNamelength = Objects.requireNonNull(file.getOriginalFilename()).length();
        if (fileNamelength > FileUploadUtils.DEFAULT_FILE_NAME_LENGTH)
        {
            throw new FileNameLengthLimitExceededException(FileUploadUtils.DEFAULT_FILE_NAME_LENGTH);
        }
        assertAllowed(file, allowedExtension);
        assertAllowed(file, allowedExtension, maxSize);
        String fileName = extractFilename(file);
@@ -158,10 +165,16 @@
    public static final void assertAllowed(MultipartFile file, String[] allowedExtension)
            throws FileSizeLimitExceededException, InvalidExtensionException
    {
        assertAllowed(file, allowedExtension, DEFAULT_MAX_SIZE);
    }
    public static final void assertAllowed(MultipartFile file, String[] allowedExtension, long maxSize)
            throws FileSizeLimitExceededException, InvalidExtensionException
    {
        long size = file.getSize();
        if (size > DEFAULT_MAX_SIZE)
        if (size > maxSize)
        {
            throw new FileSizeLimitExceededException(DEFAULT_MAX_SIZE / 1024 / 1024);
            throw new FileSizeLimitExceededException(maxSize / 1024 / 1024);
        }
        String fileName = file.getOriginalFilename();
ruoyi-system/src/main/java/com/ruoyi/system/domain/SysVisitorConfig.java
@@ -24,6 +24,9 @@
    /** 预约登记开关 */
    private Boolean appointmentRegistrationEnabled;
    /** 预约登记默认访问时间,格式HH:mm */
    private String appointmentDefaultVisitTime;
    /** 登记需接待人确认 */
    private Boolean registerReceiverConfirmEnabled;
@@ -33,7 +36,7 @@
    /** 人像对比开关 */
    private Boolean faceCompareEnabled;
    /** 全方位人脸采集开关 */
    /** 人脸采集开关 */
    private Boolean faceCaptureEnabled;
    /** 允许手动输入身份证 */
ruoyi-system/src/main/java/com/ruoyi/system/dto/SysVisitorConfigSaveRequest.java
@@ -21,6 +21,10 @@
    @ApiModelProperty("是否允许预约登记")
    private Boolean appointmentRegistrationEnabled;
    /** 预约登记默认访问时间 */
    @ApiModelProperty("预约登记默认访问时间,格式HH:mm")
    private String appointmentDefaultVisitTime;
    /** 登记是否需要接待人确认 */
    @ApiModelProperty("登记是否需要接待人确认")
    private Boolean registerReceiverConfirmEnabled;
@@ -33,8 +37,8 @@
    @ApiModelProperty("是否启用人像对比")
    private Boolean faceCompareEnabled;
    /** 是否启用全方位人脸采集 */
    @ApiModelProperty("是否启用全方位人脸采集")
    /** 是否启用人脸采集 */
    @ApiModelProperty("是否启用人脸采集")
    private Boolean faceCaptureEnabled;
    /** 是否允许手动输入身份证 */
ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysDashboardMapper.java
@@ -21,7 +21,7 @@
                                                              @Param("beginTime") Date beginTime,
                                                              @Param("endTime") Date endTime);
    public SysDashboardStatusSummaryVO selectStatusSummary();
    public SysDashboardStatusSummaryVO selectStatusSummary(Date beginTime, Date endTime);
    public List<SysDashboardTrendDailyVO> selectTrendDaily(@Param("beginTime") Date beginTime,
                                                           @Param("endTime") Date endTime);
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysDashboardServiceImpl.java
@@ -61,7 +61,8 @@
                dashboardMapper.selectRegisterSummary("APPOINTMENT", range.getBeginTime(), range.getEndTime())));
        overview.setOnsiteAppointment(defaultRegisterSummary(
                dashboardMapper.selectRegisterSummary("ONSITE", range.getBeginTime(), range.getEndTime())));
        overview.setStatusSummary(defaultStatusSummary(dashboardMapper.selectStatusSummary()));
        overview.setStatusSummary(defaultStatusSummary(
                dashboardMapper.selectStatusSummary(range.getBeginTime(), range.getEndTime())));
        overview.setTrend(buildTrend(range, dashboardMapper.selectTrendDaily(range.getBeginTime(), range.getEndTime())));
        return overview;
    }
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysProtocolConfigServiceImpl.java
@@ -7,6 +7,7 @@
import com.ruoyi.system.dto.SysProtocolConfigSaveRequest;
import com.ruoyi.system.mapper.SysProtocolConfigMapper;
import com.ruoyi.system.service.ISysProtocolConfigService;
import com.ruoyi.system.utils.ProtocolHtmlSanitizer;
import com.ruoyi.system.vo.SysProtocolConfigVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -45,7 +46,7 @@
        {
            throw new ServiceException("协议名称不能为空");
        }
        String protocolContent = StringUtils.trim(request.getProtocolContent());
        String protocolContent = StringUtils.trim(ProtocolHtmlSanitizer.clean(request.getProtocolContent()));
        if (StringUtils.isEmpty(protocolContent))
        {
            throw new ServiceException("协议内容不能为空");
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVersionUpdateServiceImpl.java
@@ -16,6 +16,7 @@
import org.springframework.util.CollectionUtils;
import java.util.List;
import java.util.Objects;
/**
 * 版本更新服务实现
@@ -34,6 +35,12 @@
        SysVersionUpdatePageRequest query = request == null ? new SysVersionUpdatePageRequest() : request;
        PageInfo<SysVersionUpdateVO> pageInfo = new PageInfo<SysVersionUpdateVO>(query.getPageNum(), query.getPageSize());
        List<SysVersionUpdateVO> list = versionUpdateMapper.pageList(query, pageInfo);
        SysVersionUpdateVO latest = versionUpdateMapper.selectLatest();
        Long latestUpdateId = latest == null ? null : latest.getUpdateId();
        for (SysVersionUpdateVO versionUpdate : list)
        {
            versionUpdate.setLatest(Objects.equals(versionUpdate.getUpdateId(), latestUpdateId));
        }
        pageInfo.setRecords(list);
        return pageInfo;
    }
@@ -41,7 +48,12 @@
    @Override
    public SysVersionUpdateVO latest()
    {
        return versionUpdateMapper.selectLatest();
        SysVersionUpdateVO latest = versionUpdateMapper.selectLatest();
        if (latest != null)
        {
            latest.setLatest(Boolean.TRUE);
        }
        return latest;
    }
    @Override
@@ -56,6 +68,8 @@
        {
            throw new ServiceException("版本更新记录不存在");
        }
        SysVersionUpdateVO latest = versionUpdateMapper.selectLatest();
        detail.setLatest(latest != null && Objects.equals(detail.getUpdateId(), latest.getUpdateId()));
        return detail;
    }
@@ -71,11 +85,13 @@
        {
            throw new ServiceException("版本号不能为空");
        }
        String updateContent = StringUtils.trim(request.getUpdateContent());
        if (StringUtils.isEmpty(updateContent))
        String versionNumber = versionNo.replaceFirst("^[vV]", "");
        if (!versionNumber.matches("\\d+(\\.\\d+)*"))
        {
            throw new ServiceException("更新内容不能为空");
            throw new ServiceException("版本号格式不正确,请输入数字或使用小数点分隔");
        }
        versionNo = versionNumber;
        String updateContent = StringUtils.trim(request.getUpdateContent());
        String packageUrl = StringUtils.trim(request.getPackageUrl());
        if (StringUtils.isEmpty(packageUrl))
        {
@@ -99,6 +115,11 @@
        {
            throw new ServiceException("更新主键集合不能为空");
        }
        SysVersionUpdateVO latest = versionUpdateMapper.selectLatest();
        if (latest != null && request.getUpdateIds().contains(latest.getUpdateId()))
        {
            throw new ServiceException("最新版本不能删除");
        }
        return versionUpdateMapper.deleteByIds(request.getUpdateIds());
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVisitorConfigServiceImpl.java
@@ -93,6 +93,16 @@
        {
            config.setAppointmentRegistrationEnabled(request.getAppointmentRegistrationEnabled());
        }
        if (request.getAppointmentDefaultVisitTime() != null)
        {
            String appointmentDefaultVisitTime = StringUtils.trim(request.getAppointmentDefaultVisitTime());
            if (StringUtils.isNotEmpty(appointmentDefaultVisitTime)
                    && !appointmentDefaultVisitTime.matches("([01]\\d|2[0-3]):[0-5]\\d"))
            {
                throw new ServiceException("预约登记默认访问时间格式不正确");
            }
            config.setAppointmentDefaultVisitTime(appointmentDefaultVisitTime);
        }
        if (request.getRegisterReceiverConfirmEnabled() != null)
        {
            config.setRegisterReceiverConfirmEnabled(request.getRegisterReceiverConfirmEnabled());
@@ -181,6 +191,7 @@
        SysVisitorConfig config = new SysVisitorConfig();
        config.setOnsiteRegistrationEnabled(true);
        config.setAppointmentRegistrationEnabled(true);
        config.setAppointmentDefaultVisitTime("");
        config.setRegisterReceiverConfirmEnabled(true);
        config.setSignReceiverConfirmEnabled(true);
        config.setFaceCompareEnabled(true);
@@ -200,6 +211,7 @@
        vo.setConfigId(config.getConfigId());
        vo.setOnsiteRegistrationEnabled(config.getOnsiteRegistrationEnabled());
        vo.setAppointmentRegistrationEnabled(config.getAppointmentRegistrationEnabled());
        vo.setAppointmentDefaultVisitTime(config.getAppointmentDefaultVisitTime());
        vo.setRegisterReceiverConfirmEnabled(config.getRegisterReceiverConfirmEnabled());
        vo.setSignReceiverConfirmEnabled(config.getSignReceiverConfirmEnabled());
        vo.setFaceCompareEnabled(config.getFaceCompareEnabled());
ruoyi-system/src/main/java/com/ruoyi/system/utils/ProtocolHtmlSanitizer.java
New file
@@ -0,0 +1,141 @@
package com.ruoyi.system.utils;
import com.ruoyi.common.utils.html.HTMLFilter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
 * 协议富文本白名单清洗工具。
 *
 * @author ruoyi
 */
public final class ProtocolHtmlSanitizer
{
    private static final Pattern DANGEROUS_ELEMENT_PATTERN = Pattern.compile(
            "<(script|style|iframe|object|embed|link|meta)[^>]*>.*?</\\1\\s*>",
            Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
    private static final Pattern DANGEROUS_SINGLE_ELEMENT_PATTERN = Pattern.compile(
            "<(script|style|iframe|object|embed|link|meta)[^>]*?/?>",
            Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
    private static final Pattern STYLE_ATTRIBUTE_PATTERN = Pattern.compile(
            "\\sstyle\\s*=\\s*(\"([^\"]*)\"|'([^']*)'|([^\\s>]+))",
            Pattern.CASE_INSENSITIVE);
    private static final Pattern SAFE_COLOR_PATTERN = Pattern.compile("#[0-9a-fA-F]{3,8}");
    private static final Pattern SAFE_FONT_SIZE_PATTERN = Pattern.compile(
            "(?:xx-small|x-small|small|medium|large|x-large|xx-large|[1-9][0-9]{0,2}px)",
            Pattern.CASE_INSENSITIVE);
    private static final Pattern SAFE_FONT_WEIGHT_PATTERN = Pattern.compile("(?:bold|normal|[1-9]00)", Pattern.CASE_INSENSITIVE);
    private static final Pattern SAFE_FONT_STYLE_PATTERN = Pattern.compile("(?:italic|normal)", Pattern.CASE_INSENSITIVE);
    private ProtocolHtmlSanitizer()
    {
    }
    /**
     * 仅保留协议编辑器支持的安全 HTML 标签和格式。
     *
     * @param html 原始协议 HTML
     * @return 清洗后的协议 HTML
     */
    public static String clean(String html)
    {
        if (html == null)
        {
            return null;
        }
        String content = DANGEROUS_ELEMENT_PATTERN.matcher(html).replaceAll("");
        content = DANGEROUS_SINGLE_ELEMENT_PATTERN.matcher(content).replaceAll("");
        content = sanitizeStyleAttributes(content);
        return createFilter().filter(content).replace("\\\"", "\"");
    }
    private static String sanitizeStyleAttributes(String html)
    {
        Matcher matcher = STYLE_ATTRIBUTE_PATTERN.matcher(html);
        StringBuffer result = new StringBuffer();
        while (matcher.find())
        {
            String style = matcher.group(2) != null ? matcher.group(2)
                    : matcher.group(3) != null ? matcher.group(3) : matcher.group(4);
            String safeStyle = sanitizeStyle(style);
            String replacement = safeStyle.isEmpty() ? "" : " style=\"" + safeStyle + "\"";
            matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
        }
        matcher.appendTail(result);
        return result.toString();
    }
    private static String sanitizeStyle(String style)
    {
        List<String> safeDeclarations = new ArrayList<>();
        for (String declaration : style.split(";"))
        {
            String[] parts = declaration.split(":", 2);
            if (parts.length != 2)
            {
                continue;
            }
            String property = parts[0].trim().toLowerCase();
            String value = parts[1].trim();
            if ("text-align".equals(property) && Arrays.asList("left", "center", "right", "justify").contains(value.toLowerCase()))
            {
                safeDeclarations.add("text-align: " + value.toLowerCase());
            }
            else if ("color".equals(property) && SAFE_COLOR_PATTERN.matcher(value).matches())
            {
                safeDeclarations.add("color: " + value.toLowerCase());
            }
            else if ("font-size".equals(property) && SAFE_FONT_SIZE_PATTERN.matcher(value).matches())
            {
                safeDeclarations.add("font-size: " + value.toLowerCase());
            }
            else if ("font-weight".equals(property) && SAFE_FONT_WEIGHT_PATTERN.matcher(value).matches())
            {
                safeDeclarations.add("font-weight: " + value.toLowerCase());
            }
            else if ("font-style".equals(property) && SAFE_FONT_STYLE_PATTERN.matcher(value).matches())
            {
                safeDeclarations.add("font-style: " + value.toLowerCase());
            }
        }
        return String.join("; ", safeDeclarations);
    }
    private static HTMLFilter createFilter()
    {
        HashMap<String, List<String>> allowed = new HashMap<>();
        List<String> styleAttribute = Collections.singletonList("style");
        for (String tag : Arrays.asList("div", "p", "span", "ul", "ol", "li", "blockquote", "pre", "h1", "h2", "h3", "h4", "h5", "h6"))
        {
            allowed.put(tag, styleAttribute);
        }
        for (String tag : Arrays.asList("strong", "b", "em", "i", "br"))
        {
            allowed.put(tag, Collections.emptyList());
        }
        Map<String, Object> config = new HashMap<>();
        config.put("vAllowed", allowed);
        config.put("vSelfClosingTags", new String[] { "br" });
        config.put("vNeedClosingTags", new String[] { "div", "p", "span", "ul", "ol", "li", "blockquote", "pre",
                "h1", "h2", "h3", "h4", "h5", "h6", "strong", "b", "em", "i" });
        config.put("vDisallowed", new String[] {});
        config.put("vAllowedProtocols", new String[] {});
        config.put("vProtocolAtts", new String[] {});
        config.put("vRemoveBlanks", new String[] {});
        config.put("vAllowedEntities", new String[] { "amp", "gt", "lt", "quot", "nbsp" });
        config.put("stripComment", true);
        config.put("encodeQuotes", false);
        config.put("alwaysMakeTags", false);
        return new HTMLFilter(config);
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVersionUpdateVO.java
@@ -25,6 +25,8 @@
    private Boolean forceUpdate;
    private Boolean latest;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date createTime;
}
ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVisitorConfigVO.java
@@ -24,6 +24,9 @@
    @ApiModelProperty(value = "预约登记开关", example = "true", allowableValues = "true,false")
    private Boolean appointmentRegistrationEnabled;
    @ApiModelProperty(value = "预约登记默认访问时间", example = "18:00")
    private String appointmentDefaultVisitTime;
    @ApiModelProperty(value = "登记需接待人确认", example = "true", allowableValues = "true,false")
    private Boolean registerReceiverConfirmEnabled;
@@ -33,7 +36,7 @@
    @ApiModelProperty(value = "人像对比开关", example = "true", allowableValues = "true,false")
    private Boolean faceCompareEnabled;
    @ApiModelProperty(value = "全方位人脸采集开关", example = "true", allowableValues = "true,false")
    @ApiModelProperty(value = "人脸采集开关", example = "true", allowableValues = "true,false")
    private Boolean faceCaptureEnabled;
    @ApiModelProperty(value = "允许手动输入身份证", example = "true", allowableValues = "true,false")
ruoyi-system/src/main/resources/mapper/system/SysDashboardMapper.xml
@@ -17,7 +17,11 @@
    <select id="selectStatusSummary" resultType="SysDashboardStatusSummaryVO">
        select sum(case when status = 'VISITING' then 1 else 0 end) as visitingCount,
               sum(case when status in ('SIGNED_OUT', 'OVERDUE_AUTO_SIGNED_OUT') then 1 else 0 end) as signedOutCount,
               sum(case when status in ('SIGNED_OUT', 'OVERDUE_AUTO_SIGNED_OUT')
                             and sign_time is not null
                             and sign_time &gt;= #{param1}
                             and sign_time &lt; #{param2}
                        then 1 else 0 end) as signedOutCount,
               sum(case when status = 'PENDING_CONFIRM' then 1 else 0 end) as overdueCount
        from t_visitor_record
        where del_flag = '0'
ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml
@@ -66,7 +66,8 @@
    </select>
    
    <select id="checkDeptExistUser" parameterType="Long" resultType="int">
        select count(1) from sys_user where dept_id = #{deptId} and del_flag = '0'
        select (select count(1) from sys_user where dept_id = #{deptId} and del_flag = '0')
             + (select count(1) from t_employee where dept_id = #{deptId} and del_flag = '0')
    </select>
    
    <select id="hasChildByDeptId" parameterType="Long" resultType="int">
@@ -156,4 +157,4 @@
        update sys_dept set del_flag = '2' where dept_id = #{deptId}
    </delete>
</mapper>
</mapper>
ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml
@@ -94,8 +94,7 @@
        from sys_oper_log sol
        <where>
            <if test="query != null and query.operName != null and query.operName != ''">
                and (sol.oper_name like concat('%', #{query.operName}, '%')
                    or sol.nickName like concat('%', #{query.operName}, '%'))
                and sol.oper_name like concat('%', #{query.operName}, '%')
            </if>
            <if test="query != null and query.title != null and query.title != ''">
                and sol.title like concat('%', #{query.title}, '%')
ruoyi-system/src/main/resources/mapper/system/SysVisitorConfigMapper.xml
@@ -8,6 +8,7 @@
        <id     property="configId"                           column="config_id" />
        <result property="onsiteRegistrationEnabled"          column="onsite_registration_enabled" />
        <result property="appointmentRegistrationEnabled"     column="appointment_registration_enabled" />
        <result property="appointmentDefaultVisitTime"        column="appointment_default_visit_time" />
        <result property="registerReceiverConfirmEnabled"     column="register_receiver_confirm_enabled" />
        <result property="signReceiverConfirmEnabled"         column="sign_receiver_confirm_enabled" />
        <result property="faceCompareEnabled"                 column="face_compare_enabled" />
@@ -31,6 +32,7 @@
        select config_id,
               onsite_registration_enabled,
               appointment_registration_enabled,
               appointment_default_visit_time,
               register_receiver_confirm_enabled,
               sign_receiver_confirm_enabled,
               face_compare_enabled,
@@ -62,6 +64,7 @@
        insert into t_visitor_config (
            onsite_registration_enabled,
            appointment_registration_enabled,
            appointment_default_visit_time,
            register_receiver_confirm_enabled,
            sign_receiver_confirm_enabled,
            face_compare_enabled,
@@ -79,6 +82,7 @@
        ) values (
            #{onsiteRegistrationEnabled},
            #{appointmentRegistrationEnabled},
            #{appointmentDefaultVisitTime},
            #{registerReceiverConfirmEnabled},
            #{signReceiverConfirmEnabled},
            #{faceCompareEnabled},
@@ -100,6 +104,7 @@
        update t_visitor_config
        set onsite_registration_enabled = #{onsiteRegistrationEnabled},
            appointment_registration_enabled = #{appointmentRegistrationEnabled},
            appointment_default_visit_time = #{appointmentDefaultVisitTime},
            register_receiver_confirm_enabled = #{registerReceiverConfirmEnabled},
            sign_receiver_confirm_enabled = #{signReceiverConfirmEnabled},
            face_compare_enabled = #{faceCompareEnabled},
ruoyi-system/src/main/resources/mapper/system/SysVisitorMachineMapper.xml
@@ -158,6 +158,9 @@
            <if test="deviceId != null and deviceId != ''">
                device_id = #{deviceId},
            </if>
            <if test="onlineStatus != null and onlineStatus != ''">
                online_status = #{onlineStatus},
            </if>
            <if test="enabled != null">
                enabled = #{enabled},    <!-- 注意:enabled 可能是 Boolean 或 Integer,若允许 false 或 0,则只判 null -->
            </if>
@@ -175,7 +178,7 @@
            update_time = sysdate()
        </set>
        where machine_id = #{machineId}
          and del_flag = '0'
        and del_flag = '0'
    </update>
    <update id="updateEnabled">
ruoyi-system/src/main/resources/mapper/system/SysVisitorRecordMapper.xml
@@ -65,11 +65,30 @@
               id_card,
               register_type,
               interviewee_name,
               interviewee_dept,
               coalesce(
                   (
                       select d.dept_name
                       from t_employee e
                       inner join sys_dept d on d.dept_id = e.dept_id and d.del_flag = '0'
                       where e.employee_name = t_visitor_record.interviewee_name
                         and e.del_flag = '0'
                       order by e.employee_id
                       limit 1
                   ),
                   nullif(interviewee_dept, '')
               ) as interviewee_dept,
               receiver_name,
               receiver_verify_method,
               visit_area_ids,
               visit_area_names,
               coalesce(
                   (
                       select group_concat(a.area_name order by find_in_set(a.area_id, t_visitor_record.visit_area_ids) separator ',')
                       from t_visit_area a
                       where a.del_flag = '0'
                         and find_in_set(a.area_id, t_visitor_record.visit_area_ids)
                   ),
                   nullif(visit_area_names, '')
               ) as visit_area_names,
               plate_no,
               visit_reason,
               valid_start_time,
ruoyi-system/src/test/java/com/ruoyi/system/utils/ProtocolHtmlSanitizerTest.java
New file
@@ -0,0 +1,43 @@
package com.ruoyi.system.utils;
public class ProtocolHtmlSanitizerTest
{
    public static void main(String[] args)
    {
        shouldKeepSupportedFormatting();
        shouldRemoveDangerousContentAndAttributes();
    }
    private static void shouldKeepSupportedFormatting()
    {
        String html = "<div style=\"text-align: center; position: fixed\">入园承诺</div>\n"
                + "<div><span style=\"font-weight: bold; color: #111827\">第二行</span></div>";
        String result = ProtocolHtmlSanitizer.clean(html);
        require(result.contains("<div style=\"text-align: center\">入园承诺</div>"), "居中样式未保留");
        require(result.contains("<span style=\"font-weight: bold; color: #111827\">第二行</span>"), "文字样式未保留");
        require(!result.contains("position"), "非白名单样式未清除");
    }
    private static void shouldRemoveDangerousContentAndAttributes()
    {
        String html = "<div onclick=\"alert(1)\">正文<script>alert(1)</script>"
                + "<span style=\"background-image: url(javascript:alert(1))\">内容</span></div>";
        String result = ProtocolHtmlSanitizer.clean(html);
        require(result.contains("<div>正文<span>内容</span></div>"), "安全正文结构未保留");
        require(!result.contains("script"), "危险标签未清除");
        require(!result.contains("onclick"), "事件属性未清除");
        require(!result.contains("javascript"), "危险样式内容未清除");
    }
    private static void require(boolean condition, String message)
    {
        if (!condition)
        {
            throw new AssertionError(message);
        }
    }
}
ruoyi-ui/src/api/visitor-admin/version.js
@@ -5,14 +5,26 @@
}
export function getVersion(updateId) {
  return request({ url: '/system/version/update/detail', method: 'post', data: { updateId } })
  return request({ url: '/system/version/update/detail', method: 'post', data: { updateId }})
}
export function addVersion(data) {
  return request({ url: '/system/version/update/add', method: 'post', data })
}
export function delVersion(updateIds) {
  return request({ url: '/system/version/update/delete', method: 'post', data: { updateIds } })
export function uploadVersionPackage(data) {
  return request({
    url: '/system/version/update/upload',
    method: 'post',
    data,
    timeout: 120000,
    headers: {
      'Content-Type': 'multipart/form-data',
      repeatSubmit: false
    }
  })
}
export function delVersion(updateIds) {
  return request({ url: '/system/version/update/delete', method: 'post', data: { updateIds }})
}
ruoyi-ui/src/assets/icons/svg/config-appointment-register.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 2.5v3M16 2.5v3M3 8.5h18"/><circle cx="12" cy="14" r="3.2"/><path d="M12 12.2V14l1.2.8"/></svg>
ruoyi-ui/src/assets/icons/svg/config-appointment-time.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>
ruoyi-ui/src/assets/icons/svg/config-device.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v10"/><path d="m8 9 4 4 4-4"/><path d="M5 17a7 7 0 0 0 14 0"/></svg>
ruoyi-ui/src/assets/icons/svg/config-face-capture.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/><path d="M8.5 11v-1M15.5 11v-1M9 16c1.8 1.2 4.2 1.2 6 0M8 7.5c1-.7 2.3-1 4-1s3 .3 4 1"/></svg>
ruoyi-ui/src/assets/icons/svg/config-face-compare.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H4a1 1 0 0 0-1 1v4M16 3h4a1 1 0 0 1 1 1v4M8 21H4a1 1 0 0 1-1-1v-4M16 21h4a1 1 0 0 0 1-1v-4"/><circle cx="12" cy="10" r="3"/><path d="M7.5 18c.7-3 2.2-4.5 4.5-4.5s3.8 1.5 4.5 4.5"/></svg>
ruoyi-ui/src/assets/icons/svg/config-id-card.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="5" width="19" height="14" rx="2"/><circle cx="8" cy="11" r="2.2"/><path d="M4.8 16c.5-2 1.6-3 3.2-3s2.7 1 3.2 3M14 10h4.5M14 14h4.5"/></svg>
ruoyi-ui/src/assets/icons/svg/config-onsite-register.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 2.5v3M16 2.5v3M3 8.5h18M8.5 14l2.2 2.2 4.8-5"/></svg>
ruoyi-ui/src/assets/icons/svg/config-print.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 8V3.5h10V8M7 17H5a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M7 14h10v6.5H7zM17.5 11h.01"/></svg>
ruoyi-ui/src/assets/icons/svg/config-receiver-confirm.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="3"/><path d="M3.5 19c.4-4 2.2-6 5.5-6 2 0 3.5.7 4.4 2"/><path d="m15 17 2 2 4-5"/></svg>
ruoyi-ui/src/assets/icons/svg/config-register.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3.5h10a2 2 0 0 1 2 2v15H5v-15a2 2 0 0 1 2-2Z"/><path d="M9 3.5v3h6v-3M8.5 11h7M8.5 15h5"/></svg>
ruoyi-ui/src/assets/icons/svg/config-sign-confirm.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="3"/><path d="M3.5 19c.4-4 2.2-6 5.5-6 2 0 3.5.7 4.4 2M14 18h7M18 14l4 4-4 4"/></svg>
ruoyi-ui/src/assets/icons/svg/config-system.svg
New file
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.86 2.86-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21h-4v-.1A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.86-2.86.06-.06A1.7 1.7 0 0 0 4.2 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H2.4v-4h.1A1.7 1.7 0 0 0 4.2 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06L6.66 3.8l.06.06A1.7 1.7 0 0 0 8.6 4.2a1.7 1.7 0 0 0 1-.6A1.7 1.7 0 0 0 10 2.5v-.1h4v.1a1.7 1.7 0 0 0 1 1.7 1.7 1.7 0 0 0 1.88-.34l.06-.06 2.86 2.86-.06.06A1.7 1.7 0 0 0 19.4 8.6a1.7 1.7 0 0 0 .6 1c.3.26.69.4 1.1.4h.1v4h-.1a1.7 1.7 0 0 0-1.7 1Z"/></svg>
ruoyi-ui/src/assets/styles/visitor-admin.scss
@@ -416,6 +416,11 @@
    background: #fff2df;
  }
  &.unactivated {
    color: #708195;
    background: #eef2f6;
  }
  &.danger {
    color: var(--visitor-danger);
    background: #fff3f3;
@@ -781,6 +786,7 @@
    border-radius: 18px;
    background: linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(239, 247, 255, 0.62));
    box-shadow: inset 0 0 0 1px rgba(222, 233, 250, 0.72);
    cursor: pointer;
  }
  .chart-legend {
@@ -822,41 +828,72 @@
  .quick-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 12px;
    gap: 14px 16px;
    padding: 18px 0 0;
  }
  .quick-entry {
    height: 52px;
    height: 64px;
    display: grid;
    grid-template-columns: 38px 1fr;
    grid-template-columns: 42px 1fr;
    align-items: center;
    gap: 12px;
    padding: 0 14px;
    gap: 18px;
    padding: 0 20px;
    border: 1px solid #dbe8ff;
    border-radius: 12px;
    color: #19304f;
    background: #fff;
    font-size: 14px;
    font-size: 16px;
    font-weight: 800;
    cursor: pointer;
    transition: transform 0.16s ease, box-shadow 0.16s ease;
    outline: none;
    transition: transform 0.16s ease, border-color 0.16s ease, color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
  }
  .quick-entry:hover {
    transform: translateY(-1px);
    box-shadow: 0 10px 24px rgba(47, 103, 255, 0.12);
    border-color: #8bb4ff;
    color: #3974e8;
    background: linear-gradient(90deg, rgba(111, 163, 255, 0.12), rgba(111, 163, 255, 0.03));
    box-shadow: 0 10px 24px rgba(47, 103, 255, 0.14), inset 0 0 0 1px rgba(111, 163, 255, 0.08);
  }
  .quick-entry:hover .quick-icon {
    background: linear-gradient(145deg, #8db8ff, #6497f4);
    box-shadow: 0 8px 18px rgba(79, 137, 241, 0.28), inset 0 0 0 1px rgba(255, 255, 255, 0.36);
  }
  .quick-entry.active,
  .quick-entry:active,
  .quick-entry:focus-visible {
    border-color: #a86cff;
    color: #7146e8;
    background: linear-gradient(90deg, rgba(168, 108, 255, 0.12), rgba(168, 108, 255, 0.03));
    box-shadow: 0 8px 20px rgba(137, 83, 255, 0.14), inset 0 0 0 1px rgba(168, 108, 255, 0.12);
  }
  .quick-entry.active .quick-icon,
  .quick-entry:active .quick-icon,
  .quick-entry:focus-visible .quick-icon {
    background: linear-gradient(135deg, #8f5bff, #bd8cff);
    box-shadow: 0 8px 18px rgba(143, 91, 255, 0.28);
  }
  .quick-icon {
    width: 34px;
    height: 34px;
    position: relative;
    width: 42px;
    height: 42px;
    display: grid;
    place-items: center;
    border-radius: 12px;
    border-radius: 13px;
    color: #fff;
    background: var(--entry-color);
    box-shadow: 0 8px 18px rgba(47, 103, 255, 0.18);
    background: linear-gradient(145deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0)), var(--entry-color);
    box-shadow: 0 8px 18px rgba(82, 112, 180, 0.22), inset 0 0 0 1px rgba(255, 255, 255, 0.34);
    i {
      font-size: 19px;
      font-weight: 500;
    }
  }
  @media (max-width: 1280px) {
@@ -904,14 +941,15 @@
  grid-template-columns: 190px 1fr;
  border: 1px solid rgba(221, 232, 247, 0.92);
  border-radius: 14px;
  background: var(--visitor-card);
  background: #fff;
  box-shadow: 0 16px 40px rgba(34, 69, 126, 0.08);
  overflow: hidden;
}
.config-nav {
.config-card > .config-nav {
  padding: 18px 12px;
  border-right: 1px solid #eef3f8;
  background-color: #fff !important;
  h1 {
    margin: 0 0 28px;
@@ -934,32 +972,26 @@
  font-size: 13px;
  text-align: left;
  cursor: pointer;
  outline: 0;
  &.active {
    color: #1781ff;
    background: #e6f7ff;
    font-weight: 800;
    box-shadow: none;
  }
}
.nav-folder {
  width: 13px;
  height: 10px;
  display: inline-block;
  border: 1.5px solid currentColor;
  border-top: 0;
  position: relative;
}
.nav-folder::before {
  content: "";
  position: absolute;
  left: 0;
  top: -5px;
  width: 7px;
  height: 5px;
  border: 1.5px solid currentColor;
  border-bottom: 0;
.config-nav-icon {
  width: 16px;
  height: 16px;
  flex: 0 0 16px;
  color: currentColor;
  fill: none !important;
  stroke: currentColor;
  font-size: 15px;
  text-align: center;
  vertical-align: middle;
}
.config-workspace {
@@ -973,21 +1005,24 @@
  margin-bottom: 28px;
  h2 {
    position: relative;
    min-height: 22px;
    display: inline-flex;
    align-items: center;
    gap: 8px;
    margin: 0 0 14px;
    padding-left: 12px;
    color: #10213b;
    font-size: 15px;
    line-height: 22px;
    font-weight: 800;
  }
  h2::before {
    content: "";
    position: absolute;
    left: 0;
    top: 1px;
    width: 4px;
    height: 22px;
    flex: 0 0 4px;
    align-self: center;
    border-radius: 2px;
    background: #2e8064;
  }
@@ -1007,6 +1042,7 @@
  border-bottom: 1px solid #edf2f7;
  color: #10213b;
  font-size: 14px;
  line-height: 20px;
  font-weight: 800;
  input {
@@ -1017,6 +1053,9 @@
  .row-label {
    min-width: 140px;
    display: inline-flex;
    align-items: center;
    align-self: stretch;
  }
}
@@ -1024,8 +1063,67 @@
  width: 18px;
  height: 18px;
  flex: 0 0 18px;
  border: 2px solid #3b8267;
  border-radius: 2px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: #4c9378;
  fill: none !important;
  stroke: currentColor;
  font-size: 18px;
  font-weight: 600;
  vertical-align: middle;
}
.system-entry .row-icon {
  width: 16px;
  height: 16px;
  flex-basis: 16px;
}
.appointment-time-row {
  height: 48px;
  display: flex;
  align-items: center;
  gap: 12px;
  border-bottom: 1px solid #edf2f7;
  color: #10213b;
  font-size: 14px;
  line-height: 20px;
  font-weight: 800;
  .row-label {
    min-width: 176px;
    display: inline-flex;
    align-items: center;
    align-self: stretch;
  }
}
.time-day {
  color: #26384f;
  font-weight: 500;
}
.appointment-time-input {
  width: 118px !important;
  .el-input__inner {
    height: 30px;
    padding: 0 28px 0 10px;
    border-color: #cfd8e3;
    color: #26384f;
    font-size: 13px;
    line-height: 30px;
  }
  .el-input__prefix {
    display: none;
  }
  .el-input__suffix {
    right: 6px;
    line-height: 30px;
  }
}
.switch-control {
@@ -1064,7 +1162,18 @@
.print-layout {
  display: grid;
  grid-template-columns: minmax(420px, 1fr) 360px;
  align-items: start;
  gap: 44px;
}
.print-settings .config-section h2 {
  height: 20px;
  min-height: 20px;
  line-height: 20px;
}
.print-settings .config-section h2::before {
  align-self: center;
}
.print-field-list {
@@ -1083,7 +1192,7 @@
  border-radius: 6px;
  color: #253855;
  font-size: 13px;
  cursor: pointer;
  cursor: grab;
  user-select: none;
  input {
@@ -1091,29 +1200,19 @@
    height: 13px;
    margin: 0;
    accent-color: #2d725d;
    cursor: pointer;
  }
}
.print-field-item.sortable-chosen {
.print-field-chosen {
  background: #eef6ff;
  box-shadow: 0 6px 16px rgba(31, 88, 160, 0.14);
  cursor: grabbing;
}
.print-field-ghost {
  opacity: 0.45;
  background: #f3f8ff;
}
.print-field-drag {
  width: 16px;
  color: #6f86a3;
  cursor: grab;
  text-align: center;
  touch-action: none;
}
.print-field-drag:active {
  cursor: grabbing;
}
.sort-hint {
@@ -1130,24 +1229,62 @@
  font-size: 12px;
}
.sort-hint-icon {
  color: #f6b817;
  font-size: 14px;
}
.print-preview {
  width: 360px;
  max-width: 100%;
  align-self: start;
}
.preview-title {
  height: 34px;
  height: 26px;
  display: flex;
  align-items: center;
  gap: 10px;
  color: #111827;
  font-size: 14px;
  font-weight: 800;
  button {
    width: 22px;
    height: 22px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: 0;
    color: #6f747b;
    background: transparent;
    font-size: 16px;
    cursor: pointer;
  }
  button:hover {
    color: #2d725d;
  }
}
.preview-stage {
  width: 100%;
  min-height: 392px;
  box-sizing: border-box;
  display: flex;
  justify-content: center;
  padding: 10px 18px 24px;
  background: #f0f0f0;
}
.ticket-preview {
  width: 290px;
  min-height: 365px;
  margin: 36px 0 0 36px;
  box-sizing: border-box;
  padding: 18px 34px 24px;
  border: 1px solid #cfd5dd;
  background: #fff;
  box-shadow: 0 0 0 36px #f0f0f0;
  h3 {
    margin: 0 0 18px;
@@ -1204,10 +1341,14 @@
}
.logo-upload {
  width: 316px;
  max-width: 100%;
  height: 38px;
  display: flex;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 8px;
  position: relative;
  padding: 0 12px;
  border: 1px solid #d9e3ef;
  border-radius: 20px;
@@ -1215,6 +1356,48 @@
  background: #f7faff;
  font-size: 12px;
  overflow: hidden;
  cursor: pointer;
}
.logo-upload input[type="file"] {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  cursor: pointer;
}
.logo-upload input[type="file"]:disabled {
  cursor: not-allowed;
}
.logo-upload-button,
.logo-file-name {
  position: relative;
  z-index: 1;
  line-height: 1;
}
.logo-upload-button {
  height: 24px;
  display: inline-flex;
  align-items: center;
  padding: 0 10px;
  border: 1px solid #c8d2df;
  border-radius: 2px;
  color: #26384f;
  background: #fff;
  font-weight: 700;
}
.logo-file-name {
  min-width: 0;
  overflow: hidden;
  color: #6f7f92;
  font-weight: 600;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.config-actions {
@@ -1242,6 +1425,7 @@
.protocol-card {
  min-height: calc(100vh - 118px);
  position: relative;
  padding: 18px 26px 82px;
  border-radius: 6px;
  background: #fff;
@@ -1365,9 +1549,10 @@
  &.ordered::before {
    left: 0;
    top: 2px;
    content: "1\A2\A3";
    content: "1.\A 2.\A 3.";
    white-space: pre;
    font-size: 8px;
    font-family: Arial, sans-serif;
    font-size: 7px;
    font-weight: 700;
    line-height: 8px;
  }
@@ -1442,18 +1627,28 @@
.protocol-editor {
  min-height: 405px;
  position: relative;
  padding: 14px 8px;
  color: #1f2d3d;
  background: #fff;
  font-size: 14px;
  line-height: 1.8;
  white-space: pre-wrap;
  overflow-wrap: break-word;
  outline: 0;
  overflow: auto;
  &:empty::before {
  &.is-empty::before {
    content: attr(data-placeholder);
    position: absolute;
    top: 14px;
    left: 8px;
    color: #7c8da6;
    pointer-events: none;
  }
  &.is-empty:focus::before {
    display: none;
  }
  p {
@@ -1473,9 +1668,9 @@
}
.protocol-actions {
  position: fixed;
  left: 252px;
  bottom: 28px;
  position: absolute;
  left: 26px;
  bottom: 24px;
}
.protocol-save-button {
@@ -1501,9 +1696,6 @@
    min-width: 680px;
  }
  .protocol-actions {
    left: 238px;
  }
}
.version-page {
@@ -1617,7 +1809,23 @@
.version-form {
  display: grid;
  gap: 14px;
  padding: 18px 48px 20px 20px;
  padding: 18px 54px 20px;
}
.version-create-dialog {
  .el-dialog__header {
    padding: 20px 54px 10px;
    text-align: left;
  }
  .el-dialog__title {
    font-size: 14px;
    font-weight: 700;
  }
  .el-dialog__body {
    padding: 0;
  }
}
.version-row {
@@ -1630,7 +1838,9 @@
  > span:first-child {
    padding-top: 8px;
    text-align: right;
    font-size: 12px;
    font-weight: 600;
    text-align: left;
  }
  &.required > span:first-child::before {
@@ -1641,17 +1851,33 @@
}
.version-input-line {
  display: grid;
  grid-template-columns: 18px 1fr;
  align-items: center;
  position: relative;
  > span {
    position: absolute;
    z-index: 1;
    left: 10px;
    top: 9px;
    color: #24364f;
    line-height: 1;
  }
  input {
    padding-left: 25px;
  }
}
.version-row textarea {
  height: 68px;
  box-sizing: border-box;
}
.version-upload-field {
  width: 100%;
}
.upload-drop {
  width: 242px;
  width: 100%;
  height: 102px;
  display: grid;
  place-items: center;
@@ -1670,9 +1896,16 @@
    color: #24364f;
    background: #f9fbfe;
  }
  &.drag-over {
    border-color: #2e73ff;
    color: #2e73ff;
    background: #f5f9ff;
  }
}
.upload-tip {
  width: 100%;
  margin: 10px 0 0;
  color: #8a9ab0;
  font-size: 11px;
@@ -1692,6 +1925,10 @@
  input:checked + .switch-control::after {
    transform: translateX(26px);
  }
  input:checked + .switch-control {
    background: #22b573;
  }
}
.area-page,
ruoyi-ui/src/utils/visitor-admin.js
@@ -21,7 +21,29 @@
}
export function onlineText(value) {
  return value === 'online' || value === '1' || value === true ? '在线' : '离线'
  const status = normalizeOnlineStatus(value)
  if (status === 'ONLINE') {
    return '在线'
  }
  if (status === 'OFFLINE') {
    return '离线'
  }
  return '未激活'
}
export function onlineStatusClass(value) {
  return normalizeOnlineStatus(value).toLowerCase()
}
function normalizeOnlineStatus(value) {
  const status = String(value === undefined || value === null ? '' : value).trim().toUpperCase()
  if (status === 'ONLINE' || status === '1' || status === 'TRUE') {
    return 'ONLINE'
  }
  if (status === 'OFFLINE' || status === '0' || status === 'FALSE') {
    return 'OFFLINE'
  }
  return 'UNACTIVATED'
}
export function toIdArray(value) {
ruoyi-ui/src/views/visitor-admin/dashboard/index.vue
@@ -55,7 +55,7 @@
    <section class="lower-grid">
      <article class="panel trend-panel">
        <h2>登记趋势</h2>
        <canvas ref="trendChart" width="760" height="310" aria-label="登记趋势折线图" />
        <canvas ref="trendChart" width="760" height="310" aria-label="登记趋势折线图" @click="handleTrendChartClick" />
        <div class="chart-legend">
          <span><i class="legend-dot blue" />现场登记</span>
          <span><i class="legend-dot green" />预约登记</span>
@@ -69,9 +69,13 @@
            v-for="item in quickEntries"
            :key="item.title"
            class="quick-entry"
            :class="{ active: activeQuickEntry === item.path }"
            type="button"
            :style="{ '--entry-color': item.color }"
            @click="$router.push(item.path)"
            @mousedown="activeQuickEntry = item.path"
            @focus="activeQuickEntry = item.path"
            @blur="activeQuickEntry = ''"
            @click="handleQuickEntry(item)"
          >
            <span class="quick-icon"><i :class="item.icon" /></span>
            <span>{{ item.title }}</span>
@@ -91,6 +95,8 @@
  data() {
    return {
      activeDateFilter: '最近7天',
      activeTrendIndex: null,
      activeQuickEntry: '',
      dateRange: [],
      dateFilters: ['今日', '最近7天', '最近30天'],
      dateFilterMap: {
@@ -146,14 +152,14 @@
        }
      ],
      quickEntries: [
        { title: '部门管理', color: '#2580ff', icon: 'el-icon-office-building', path: '/visitor/organization/dept' },
        { title: '员工管理', color: '#ff7545', icon: 'el-icon-user', path: '/visitor/organization/employee' },
        { title: '设备管理', color: '#ff4c5d', icon: 'el-icon-monitor', path: '/visitor/device/machine' },
        { title: '预约记录', color: '#ff9e33', icon: 'el-icon-notebook-2', path: '/visitor/record/list' },
        { title: '区域管理', color: '#43bd19', icon: 'el-icon-location-outline', path: '/visitor/area' },
        { title: '账号管理', color: '#5b77f1', icon: 'el-icon-setting', path: '/visitor/account/admin' },
        { title: '系统配置', color: '#416bea', icon: 'el-icon-s-tools', path: '/visitor/system/config' },
        { title: '系统更新', color: '#22bfc4', icon: 'el-icon-refresh', path: '/visitor/system/version' }
        { title: '部门管理', color: '#78a7ff', icon: 'el-icon-office-building', path: '/visitor/organization/dept' },
        { title: '员工管理', color: '#a979f5', icon: 'el-icon-user', path: '/visitor/organization/employee' },
        { title: '设备管理', color: '#5bd5ad', icon: 'el-icon-mobile-phone', path: '/visitor/device/machine' },
        { title: '预约记录', color: '#ffad7b', icon: 'el-icon-reading', path: '/visitor/record/list' },
        { title: '区域管理', color: '#4ccbd5', icon: 'el-icon-map-location', path: '/visitor/area' },
        { title: '账号管理', color: '#7b88f5', icon: 'el-icon-lock', path: '/visitor/account/admin' },
        { title: '系统配置', color: '#9aa9bd', icon: 'el-icon-setting', path: '/visitor/system/config' },
        { title: '系统更新', color: '#6da8f7', icon: 'el-icon-refresh', path: '/visitor/system/version' }
      ],
      chartData: {
        labels: ['09-08', '09-09', '09-10', '09-11', '09-12', '09-13', '09-14'],
@@ -286,6 +292,7 @@
        appointment: this.normalizeSeries(trend.appointment, labels.length),
        vehicle: this.normalizeSeries(trend.vehicle, labels.length)
      }
      this.activeTrendIndex = null
    },
    // 目的:把趋势序列补齐为指定长度,避免图表绘制时数组缺位。
    normalizeSeries(series, length) {
@@ -301,6 +308,32 @@
    // 目的:格式化统计数值,空值按 0 展示。
    formatCount(value) {
      return String(value == null ? 0 : value)
    },
    // 目的:记录快捷入口选中态并进入对应管理页面。
    handleQuickEntry(item) {
      this.activeQuickEntry = item.path
      this.$router.push(item.path)
    },
    // 目的:根据点击位置选中最近的趋势日期并重绘图表。
    handleTrendChartClick(event) {
      const canvas = this.$refs.trendChart
      const labelCount = this.chartData.labels.length
      if (!canvas || !labelCount) {
        return
      }
      const rect = canvas.getBoundingClientRect()
      const dpr = window.devicePixelRatio || 1
      const logicalWidth = canvas.width / dpr
      const logicalX = (event.clientX - rect.left) * (logicalWidth / rect.width)
      const padding = { left: 42, right: 16 }
      const chartWidth = logicalWidth - padding.left - padding.right
      if (logicalX < padding.left || logicalX > logicalWidth - padding.right) {
        return
      }
      const divider = Math.max(labelCount - 1, 1)
      const index = Math.round(((logicalX - padding.left) / chartWidth) * divider)
      this.activeTrendIndex = Math.max(0, Math.min(labelCount - 1, index))
      this.drawTrendChart()
    },
    // 目的:按当前趋势数据重绘 canvas 折线图。
    drawTrendChart() {
@@ -327,6 +360,7 @@
      const ticks = [0, step, step * 2, step * 3, step * 4, step * 5, max]
      ctx.clearRect(0, 0, width, height)
      this.drawTrendSelection(ctx, padding, chartWidth, chartHeight)
      ctx.font = '10px Microsoft YaHei, Arial'
      ctx.textAlign = 'right'
      ctx.textBaseline = 'middle'
@@ -346,17 +380,71 @@
      ctx.textAlign = 'center'
      ctx.textBaseline = 'top'
      const labelDivider = Math.max(this.chartData.labels.length - 1, 1)
      const visibleLabelIndexes = this.getTrendLabelIndexes(this.chartData.labels.length, chartWidth)
      this.chartData.labels.forEach((label, index) => {
        if (!visibleLabelIndexes.has(index)) {
          return
        }
        const x = padding.left + (chartWidth / labelDivider) * index
        ctx.fillStyle = '#9aabc0'
        ctx.fillStyle = index === this.activeTrendIndex ? '#7b4dff' : '#9aabc0'
        ctx.font = index === this.activeTrendIndex ? '700 10px Microsoft YaHei, Arial' : '10px Microsoft YaHei, Arial'
        ctx.fillText(label, x, height - 20)
      })
      this.drawSeries(ctx, this.chartData.onsite, '#3c7cff', padding, chartWidth, chartHeight, max, true)
      this.drawSeries(ctx, this.chartData.appointment, '#23bd8e', padding, chartWidth, chartHeight, max, true)
      this.drawSeries(ctx, this.chartData.onsite, '#3c7cff', padding, chartWidth, chartHeight, max, true, this.activeTrendIndex)
      this.drawSeries(ctx, this.chartData.appointment, '#23bd8e', padding, chartWidth, chartHeight, max, true, this.activeTrendIndex)
    },
    // 目的:根据图表宽度均匀抽样日期标签,避免长时间范围下横轴文字重叠。
    getTrendLabelIndexes(labelCount, chartWidth) {
      const indexes = new Set()
      if (labelCount <= 0) {
        return indexes
      }
      const maxLabelCount = Math.max(2, Math.floor(chartWidth / 54) + 1)
      const visibleCount = Math.min(labelCount, maxLabelCount)
      const divider = Math.max(visibleCount - 1, 1)
      for (let position = 0; position < visibleCount; position++) {
        indexes.add(Math.round(((labelCount - 1) * position) / divider))
      }
      if (this.activeTrendIndex != null) {
        indexes.add(this.activeTrendIndex)
      }
      return indexes
    },
    // 目的:为当前选中日期绘制紫色圆角背景,与快捷入口选中态保持一致。
    drawTrendSelection(ctx, padding, chartWidth, chartHeight) {
      if (this.activeTrendIndex == null || !this.chartData.labels.length) {
        return
      }
      const divider = Math.max(this.chartData.labels.length - 1, 1)
      const spacing = chartWidth / divider
      const width = Math.min(74, Math.max(42, spacing * 0.72))
      const x = padding.left + spacing * this.activeTrendIndex - width / 2
      const y = 10
      const height = padding.top + chartHeight - y + 4
      const radius = 14
      ctx.save()
      ctx.beginPath()
      ctx.moveTo(x + radius, y)
      ctx.lineTo(x + width - radius, y)
      ctx.quadraticCurveTo(x + width, y, x + width, y + radius)
      ctx.lineTo(x + width, y + height - radius)
      ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
      ctx.lineTo(x + radius, y + height)
      ctx.quadraticCurveTo(x, y + height, x, y + height - radius)
      ctx.lineTo(x, y + radius)
      ctx.quadraticCurveTo(x, y, x + radius, y)
      ctx.closePath()
      ctx.fillStyle = 'rgba(148, 91, 255, 0.08)'
      ctx.strokeStyle = 'rgba(148, 91, 255, 0.28)'
      ctx.lineWidth = 1
      ctx.fill()
      ctx.stroke()
      ctx.restore()
    },
    // 目的:绘制单条趋势线和可选面积填充。
    drawSeries(ctx, data, color, padding, chartWidth, chartHeight, max, fill) {
    drawSeries(ctx, data, color, padding, chartWidth, chartHeight, max, fill, activeIndex) {
      const pointDivider = Math.max(data.length - 1, 1)
      const points = data.map((value, index) => ({
        x: padding.left + (chartWidth / pointDivider) * index,
@@ -388,10 +476,16 @@
        ctx.fill()
      }
      points.forEach(point => {
      points.forEach((point, index) => {
        if (index === activeIndex) {
          ctx.fillStyle = '#fff'
          ctx.beginPath()
          ctx.arc(point.x, point.y, 5.2, 0, Math.PI * 2)
          ctx.fill()
        }
        ctx.fillStyle = color
        ctx.beginPath()
        ctx.arc(point.x, point.y, 2.2, 0, Math.PI * 2)
        ctx.arc(point.x, point.y, index === activeIndex ? 3.6 : 2.2, 0, Math.PI * 2)
        ctx.fill()
      })
    }
ruoyi-ui/src/views/visitor-admin/device/index.vue
@@ -69,7 +69,7 @@
            </el-table-column>
            <el-table-column label="在线状态" align="center" width="100">
              <template slot-scope="scope">
                <span class="status-pill" :class="scope.row.onlineStatus === 'online' ? 'online' : 'offline'">{{ onlineLabel(scope.row.onlineStatus) }}</span>
                <span class="status-pill" :class="onlineClass(scope.row.onlineStatus)">{{ onlineLabel(scope.row.onlineStatus) }}</span>
              </template>
            </el-table-column>
            <el-table-column label="备注" prop="remark" min-width="140" show-overflow-tooltip />
@@ -133,7 +133,7 @@
<script>
import { treeArea } from '@/api/visitor-admin/area'
import { listDevice, getDevice, addDevice, updateDevice, changeDeviceStatus, logoutDevice, delDevice } from '@/api/visitor-admin/device'
import { getResponseData, getPageRows, getPageTotal, normalizeBoolean, onlineText, toIdArray } from '@/utils/visitor-admin'
import { getResponseData, getPageRows, getPageTotal, normalizeBoolean, onlineStatusClass, onlineText, toIdArray } from '@/utils/visitor-admin'
export default {
  name: 'VisitorDevice',
@@ -244,6 +244,10 @@
    onlineLabel(value) {
      return onlineText(value)
    },
    // 目的:根据数据库在线状态枚举返回对应的标签样式。
    onlineClass(value) {
      return onlineStatusClass(value)
    },
    // 目的:按区域名称关键字过滤区域树节点。
    filterAreaNode(value, data) {
      return !value || (data.areaName || '').indexOf(value) !== -1
ruoyi-ui/src/views/visitor-admin/record/visitor.vue
@@ -125,7 +125,7 @@
        { value: 'VISITING', label: '访问中', aliases: ['visiting'] },
        { value: 'SIGNED', label: '已签到', aliases: ['signed'] },
        { value: 'SIGNED_OUT', label: '已签离', aliases: ['signed_out'] },
        { value: 'OVERDUE_AUTO_SIGNED_OUT', label: '逾期自动签离', aliases: ['overdue_auto_signed_out'] },
        { value: 'OVERDUE_AUTO_SIGNED_OUT', label: '超期自动签离', aliases: ['overdue_auto_signed_out'] },
        { value: 'EXPIRED', label: '已过期', aliases: ['expired'] },
        { value: 'CANCELLED', label: '已取消', aliases: ['cancelled', 'canceled', 'CANCELED'] }
      ],
ruoyi-ui/src/views/visitor-admin/system/config.vue
@@ -11,7 +11,7 @@
          type="button"
          @click="activeTab = tab.value"
        >
          <span class="nav-folder"></span>
          <svg-icon class-name="config-nav-icon" :icon-class="tab.icon" />
          <span>{{ tab.label }}</span>
        </button>
      </aside>
@@ -20,33 +20,52 @@
        <section v-show="activeTab === 'register'">
          <div class="config-section">
            <h2>登记模式设置</h2>
            <label v-for="item in registerSwitches" :key="item.prop" class="config-switch-row">
              <span class="row-icon"></span>
              <span class="row-label">{{ item.label }}</span>
              <input v-model="form[item.prop]" type="checkbox">
              <span class="switch-control"></span>
            <label class="config-switch-row">
              <svg-icon class-name="row-icon" icon-class="config-onsite-register" />
              <span class="row-label">现场登记</span>
              <input v-model="form.onsiteRegistrationEnabled" type="checkbox">
              <span class="switch-control" />
            </label>
            <label class="config-switch-row">
              <svg-icon class-name="row-icon" icon-class="config-appointment-register" />
              <span class="row-label">预约登记</span>
              <input v-model="form.appointmentRegistrationEnabled" type="checkbox">
              <span class="switch-control" />
            </label>
            <div v-if="form.appointmentRegistrationEnabled" class="appointment-time-row">
              <svg-icon class-name="row-icon" icon-class="config-appointment-time" />
              <span class="row-label">预约登记默认访问时间</span>
              <span class="time-day">当天</span>
              <el-time-picker
                v-model="form.appointmentDefaultVisitTime"
                class="appointment-time-input"
                value-format="HH:mm"
                format="HH:mm"
                placeholder="请选择时间"
                :clearable="false"
              />
            </div>
          </div>
          <div class="config-section">
            <h2>接待人验证开关</h2>
            <p>设置登记、签离时是否需要接待人确认。若关闭,访客认证后直接打印凭条/签离时自动回收门禁权限。</p>
            <label v-for="item in receiverSwitches" :key="item.prop" class="config-switch-row">
              <span class="row-icon"></span>
              <svg-icon class-name="row-icon" :icon-class="item.icon" />
              <span class="row-label">{{ item.label }}</span>
              <input v-model="form[item.prop]" type="checkbox">
              <span class="switch-control"></span>
              <span class="switch-control" />
            </label>
          </div>
          <div class="config-section">
            <h2>人脸验证方式</h2>
            <p>人像对比即身份证和人脸对比确保为同一人(手动录入身份证除外),全方位人脸采集则360°采集人脸照片。</p>
            <p>人像对比即身份证和人脸对比确保为同一人(手动录入身份证除外),人脸采集则采集访客人脸照片。</p>
            <label v-for="item in faceSwitches" :key="item.prop" class="config-switch-row">
              <span class="row-icon"></span>
              <svg-icon class-name="row-icon" :icon-class="item.icon" />
              <span class="row-label">{{ item.label }}</span>
              <input v-model="form[item.prop]" type="checkbox">
              <span class="switch-control"></span>
              <span class="switch-control" />
            </label>
          </div>
@@ -54,10 +73,10 @@
            <h2>身份证手动输入</h2>
            <p>关闭后仅可刷身份证登记/签离。强制人脸比对时,开启则支持手动输入身份证并做人脸采集。</p>
            <label class="config-switch-row">
              <span class="row-icon"></span>
              <svg-icon class-name="row-icon" icon-class="config-id-card" />
              <span class="row-label">允许手动输入身份证</span>
              <input v-model="form.manualIdCardEnabled" type="checkbox">
              <span class="switch-control"></span>
              <span class="switch-control" />
            </label>
          </div>
        </section>
@@ -68,15 +87,14 @@
              <div class="config-section">
                <h2>凭条样式配置</h2>
                <p>勾选需要在凭条上打印的字段,支持拖拽排序(至少勾选1个字段)</p>
                <draggable v-model="printFieldOptions" class="print-field-list" handle=".print-field-drag" ghost-class="print-field-ghost" :delay="120" @end="syncPrintFieldsOrder">
                <draggable v-model="printFieldOptions" class="print-field-list" ghost-class="print-field-ghost" chosen-class="print-field-chosen" :delay="120" :animation="160" @end="syncPrintFieldsOrder">
                  <label v-for="item in printFieldOptions" :key="item.fieldCode" class="print-field-item">
                    <span class="print-field-drag">↕</span>
                    <input v-model="form.printFields" type="checkbox" :value="item.fieldCode">
                    <span>{{ item.fieldName }}</span>
                  </label>
                </draggable>
                <button class="sort-hint" type="button">
                  <span>↕</span>
                  <i class="el-icon-s-opportunity sort-hint-icon" aria-hidden="true" />
                  <span>长按选项拖拽排序</span>
                </button>
              </div>
@@ -85,17 +103,21 @@
            <aside class="print-preview">
              <div class="preview-title">
                <span>效果预览</span>
                <button type="button" title="刷新预览" @click="refreshPreview">↻</button>
                <button type="button" title="刷新预览" aria-label="刷新预览" @click="refreshPreview">
                  <i class="el-icon-refresh" aria-hidden="true" />
                </button>
              </div>
              <section class="ticket-preview" aria-label="登记凭证预览">
                <h3>登记凭证</h3>
                <div class="ticket-rows">
                  <div v-for="row in ticketRows" :key="row.label" class="ticket-row">
                    <strong>{{ row.label }}</strong>
                    <span>{{ row.value }}</span>
              <div class="preview-stage">
                <section class="ticket-preview" aria-label="登记凭证预览">
                  <h3>登记凭证</h3>
                  <div class="ticket-rows">
                    <div v-for="row in ticketRows" :key="row.label" class="ticket-row">
                      <strong>{{ row.label }}</strong>
                      <span>{{ row.value }}</span>
                    </div>
                  </div>
                </div>
              </section>
                </section>
              </div>
            </aside>
          </div>
        </section>
@@ -109,13 +131,14 @@
                  <span>系统名称</span>
                  <input v-model="form.systemName" type="text" placeholder="请输入系统名称">
                </label>
                <label class="input-field">
                <div class="input-field">
                  <span>Logo</span>
                  <div class="logo-upload">
                  <label class="logo-upload">
                    <input v-hasPermi="['visitor:config:edit']" type="file" accept="image/*" :disabled="uploadingLogo" @change="handleLogoChange">
                    <span>{{ uploadingLogo ? '上传中' : logoFileName }}</span>
                  </div>
                </label>
                    <span class="logo-upload-button">选择文件</span>
                    <span class="logo-file-name">{{ uploadingLogo ? '上传中' : logoFileName }}</span>
                  </label>
                </div>
              </div>
            </div>
@@ -137,10 +160,10 @@
              <h2>入口配置</h2>
              <p>关闭后入口不显示,但是仍可以点击该区域进入。</p>
              <label class="config-switch-row">
                <span class="row-icon"></span>
                <svg-icon class-name="row-icon" icon-class="config-device" />
                <span class="row-label">访客机管理侧入口</span>
                <input v-model="form.visitorMachineEntryEnabled" type="checkbox">
                <span class="switch-control"></span>
                <span class="switch-control" />
              </label>
            </div>
          </div>
@@ -163,6 +186,7 @@
const defaultForm = {
  onsiteRegistrationEnabled: true,
  appointmentRegistrationEnabled: true,
  appointmentDefaultVisitTime: '',
  registerReceiverConfirmEnabled: false,
  signReceiverConfirmEnabled: false,
  faceCompareEnabled: false,
@@ -188,21 +212,17 @@
      logoFileName: '未选择任何文件',
      form: { ...defaultForm },
      tabs: [
        { value: 'register', label: '登记配置' },
        { value: 'print', label: '打印凭条配置' },
        { value: 'system', label: '系统信息' }
      ],
      registerSwitches: [
        { prop: 'onsiteRegistrationEnabled', label: '现场登记' },
        { prop: 'appointmentRegistrationEnabled', label: '预约登记' }
        { value: 'register', label: '登记配置', icon: 'config-register' },
        { value: 'print', label: '打印凭条配置', icon: 'config-print' },
        { value: 'system', label: '系统信息', icon: 'config-system' }
      ],
      receiverSwitches: [
        { prop: 'registerReceiverConfirmEnabled', label: '登记需接待人确认' },
        { prop: 'signReceiverConfirmEnabled', label: '签离需接待人确认' }
        { prop: 'registerReceiverConfirmEnabled', label: '登记需接待人确认', icon: 'config-receiver-confirm' },
        { prop: 'signReceiverConfirmEnabled', label: '签离需接待人确认', icon: 'config-sign-confirm' }
      ],
      faceSwitches: [
        { prop: 'faceCompareEnabled', label: '人像对比' },
        { prop: 'faceCaptureEnabled', label: '全方位人脸采集' }
        { prop: 'faceCompareEnabled', label: '人像对比', icon: 'config-face-compare' },
        { prop: 'faceCaptureEnabled', label: '人脸采集', icon: 'config-face-capture' }
      ],
      printFieldOptions: [
        { fieldCode: 'visitorName', fieldName: '访客姓名' },
ruoyi-ui/src/views/visitor-admin/system/protocol.vue
@@ -10,25 +10,30 @@
      <form class="protocol-form" @submit.prevent="submitForm">
        <section class="editor-shell" :class="{ fullscreen }">
          <div v-if="canEditProtocol" class="editor-toolbar" role="toolbar" aria-label="富文本工具栏">
            <button type="button" title="加粗" @click="exec('bold')"><strong>B</strong></button>
            <button type="button" title="斜体" @click="exec('italic')"><em>I</em></button>
            <button type="button" title="文字颜色" @click="exec('foreColor', '#111827')"><span class="font-color">A</span></button>
            <button type="button" title="标题字号" @click="exec('fontSize', '4')"><span class="font-size">T<small>T</small></span></button>
            <button type="button" title="无序列表" @click="exec('insertUnorderedList')"><span class="list-icon unordered"></span></button>
            <button type="button" title="有序列表" @click="exec('insertOrderedList')"><span class="list-icon ordered"></span></button>
            <button type="button" title="左对齐" @click="exec('justifyLeft')"><span class="align-icon left"></span></button>
            <button type="button" title="居中" @click="exec('justifyCenter')"><span class="align-icon center"></span></button>
            <button type="button" title="右对齐" @click="exec('justifyRight')"><span class="align-icon right"></span></button>
            <button type="button" title="撤销" @click="exec('undo')"><span class="undo-icon">↶</span></button>
            <button type="button" title="重做" @click="exec('redo')"><span class="redo-icon">↷</span></button>
            <button type="button" title="加粗" @mousedown.prevent="exec('bold')"><strong>B</strong></button>
            <button type="button" title="斜体" @mousedown.prevent="exec('italic')"><em>I</em></button>
            <button type="button" title="文字颜色" @mousedown.prevent="exec('foreColor', '#111827')"><span class="font-color">A</span></button>
            <button type="button" title="标题字号" @mousedown.prevent="exec('fontSize', '4')"><span class="font-size">T<small>T</small></span></button>
            <button type="button" title="无序列表" @mousedown.prevent="exec('insertUnorderedList')"><span class="list-icon unordered"></span></button>
            <button type="button" title="有序列表" @mousedown.prevent="exec('insertOrderedList')"><span class="list-icon ordered"></span></button>
            <button type="button" title="左对齐" @mousedown.prevent="exec('justifyLeft')"><span class="align-icon left"></span></button>
            <button type="button" title="居中" @mousedown.prevent="exec('justifyCenter')"><span class="align-icon center"></span></button>
            <button type="button" title="右对齐" @mousedown.prevent="exec('justifyRight')"><span class="align-icon right"></span></button>
            <button type="button" title="撤销" @mousedown.prevent="exec('undo')"><span class="undo-icon">↶</span></button>
            <button type="button" title="重做" @mousedown.prevent="exec('redo')"><span class="redo-icon">↷</span></button>
            <button type="button" title="全屏编辑" @click="toggleFullscreen"><span class="fullscreen-icon"></span></button>
          </div>
          <div
            ref="editor"
            class="protocol-editor"
            :class="{ 'is-empty': editorEmpty }"
            :contenteditable="canEditProtocol"
            data-placeholder="请输入"
            @input="syncEditor"
            @focus="handleEditorFocus"
            @input="handleEditorInput"
            @mouseup="saveSelection"
            @keyup="saveSelection"
            @keydown.enter="handleEditorEnter"
            @paste="handlePaste"
          ></div>
        </section>
@@ -55,6 +60,8 @@
    return {
      saving: false,
      fullscreen: false,
      editorEmpty: true,
      savedRange: null,
      form: {
        protocolType,
        protocolName,
@@ -97,6 +104,9 @@
        this.$nextTick(() => {
          if (this.$refs.editor) {
            this.$refs.editor.innerHTML = this.form.protocolContent
            this.savedRange = null
            this.ensureEditorBlock(false)
            this.updateEditorEmpty()
          }
        })
      })
@@ -107,8 +117,94 @@
        this.denyMiddlePlatformWrite()
        return
      }
      this.$refs.editor && this.$refs.editor.focus()
      const editor = this.$refs.editor
      if (!editor) {
        return
      }
      editor.focus()
      this.restoreSelection()
      if (command === 'justifyLeft' || command === 'justifyCenter' || command === 'justifyRight') {
        document.execCommand('formatBlock', false, 'div')
      }
      if (command !== 'undo' && command !== 'redo') {
        document.execCommand('styleWithCSS', false, true)
      }
      document.execCommand(command, false, value || null)
      this.saveSelection()
      this.syncEditor()
    },
    // 目的:聚焦编辑器时确保块级结构存在,并记录当前光标位置供工具栏命令复用。
    handleEditorFocus() {
      this.ensureEditorBlock()
      this.saveSelection()
    },
    // 目的:输入后同步表单内容,并保存最新选区,避免点击工具栏时丢失格式作用位置。
    handleEditorInput() {
      this.syncEditor()
      this.saveSelection()
    },
    // 目的:仅保存编辑器内部选区,防止页面其他区域的选区被富文本命令误用。
    saveSelection() {
      const editor = this.$refs.editor
      const selection = window.getSelection()
      if (!editor || !selection || !selection.rangeCount) {
        return
      }
      const range = selection.getRangeAt(0)
      const container = range.commonAncestorContainer
      if (container === editor || editor.contains(container)) {
        this.savedRange = range.cloneRange()
      }
    },
    // 目的:执行工具栏命令前恢复编辑器选区,使居中等块级格式能够应用到选中段落。
    restoreSelection() {
      if (!this.savedRange) {
        return
      }
      const selection = window.getSelection()
      selection.removeAllRanges()
      selection.addRange(this.savedRange)
    },
    // 目的:确保编辑内容由块级行容器承载,使换行和对齐样式能够进入保存的 HTML。
    ensureEditorBlock(moveCaret = true) {
      const editor = this.$refs.editor
      if (!editor || editor.querySelector('div, p, ul, ol, blockquote, table')) {
        return
      }
      const block = document.createElement('div')
      const rootTextAlign = editor.style.textAlign
      while (editor.firstChild) {
        block.appendChild(editor.firstChild)
      }
      if (!block.childNodes.length) {
        block.appendChild(document.createElement('br'))
      }
      if (rootTextAlign) {
        block.style.textAlign = rootTextAlign
        editor.style.textAlign = ''
      }
      editor.appendChild(block)
      const selection = moveCaret ? window.getSelection() : null
      if (selection) {
        const range = document.createRange()
        range.selectNodeContents(block)
        range.collapse(false)
        selection.removeAllRanges()
        selection.addRange(range)
      }
      this.updateEditorEmpty()
    },
    // 目的:显式插入新的块级段落,确保回车换行能够作为 HTML 结构持久化到数据库。
    handleEditorEnter(event) {
      if (!this.canEditProtocol) {
        event.preventDefault()
        this.denyMiddlePlatformWrite()
        return
      }
      event.preventDefault()
      this.ensureEditorBlock()
      document.execCommand('defaultParagraphSeparator', false, 'div')
      document.execCommand('insertParagraph', false, null)
      this.syncEditor()
    },
    // 目的:切换编辑器全屏状态,并在切换后保持编辑焦点。
@@ -139,7 +235,45 @@
    },
    // 目的:把编辑器 DOM 内容同步回表单,并再次清洗 HTML。
    syncEditor() {
      this.form.protocolContent = this.$refs.editor ? this.sanitizeHtml(this.$refs.editor.innerHTML) : ''
      this.form.protocolContent = this.serializeEditorContent()
      this.updateEditorEmpty()
    },
    // 目的:将编辑器顶层内容统一为块级段落,并用换行符分隔后提交数据库。
    serializeEditorContent() {
      const editor = this.$refs.editor
      if (!editor) {
        return ''
      }
      const container = document.createElement('div')
      const blockTags = ['DIV', 'P', 'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'TABLE', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']
      let currentBlock = null
      Array.prototype.slice.call(editor.childNodes).forEach(node => {
        const isBlock = node.nodeType === 1 && blockTags.indexOf(node.nodeName) !== -1
        if (isBlock) {
          currentBlock = null
          container.appendChild(node.cloneNode(true))
          return
        }
        if (!currentBlock) {
          currentBlock = document.createElement('div')
          container.appendChild(currentBlock)
        }
        currentBlock.appendChild(node.cloneNode(true))
      })
      const html = Array.prototype.slice.call(container.childNodes)
        .map(node => node.outerHTML)
        .join('\n')
      return this.sanitizeHtml(html)
    },
    // 目的:根据编辑器真实文本和媒体内容维护占位提示显示状态。
    updateEditorEmpty() {
      const editor = this.$refs.editor
      if (!editor) {
        this.editorEmpty = true
        return
      }
      const text = String(editor.textContent || '').replace(/\u200B/g, '').trim()
      this.editorEmpty = !text && !editor.querySelector('img, video, audio, table, hr')
    },
    // 目的:校验并保存协议配置;中台只读模式下阻止提交。
    submitForm() {
@@ -153,6 +287,7 @@
        this.$modal.msgError('请输入入园承诺内容')
        return
      }
      const submittedContent = this.form.protocolContent
      this.saving = true
      saveProtocolConfig({
        ...this.form,
@@ -161,10 +296,14 @@
        enabled: true
      }).then(res => {
        const data = getResponseData(res) || {}
        this.form.protocolContent = this.sanitizeHtml(data.protocolContent || this.form.protocolContent)
        if (this.$refs.editor) {
          this.$refs.editor.innerHTML = this.form.protocolContent
        this.form = {
          ...this.form,
          ...data,
          protocolType,
          protocolName,
          protocolContent: submittedContent
        }
        this.updateEditorEmpty()
        this.$modal.msgSuccess('保存成功')
      }).finally(() => {
        this.saving = false
@@ -186,6 +325,13 @@
          }
        })
      })
      template.content.querySelectorAll('[align]').forEach(node => {
        const align = String(node.getAttribute('align') || '').toLowerCase()
        if (['left', 'center', 'right', 'justify'].indexOf(align) !== -1) {
          node.style.textAlign = align
        }
        node.removeAttribute('align')
      })
      return template.innerHTML
    },
    // 目的:将纯文本转义为安全 HTML,供纯文本粘贴时保留换行展示。
ruoyi-ui/src/views/visitor-admin/system/version.vue
@@ -13,20 +13,28 @@
            <th>版本号</th>
            <th>更新内容</th>
            <th>创建时间</th>
            <th v-if="!isMiddlePlatformReadonly && $auth.hasPermi('visitor:version:remove')">操作</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(row, index) in versionList" :key="row.updateId || index">
            <td>{{ index + 1 }}</td>
            <td>v{{ row.versionNo }}</td>
            <td>
              {{ formatVersionNo(row.versionNo) }}
              <el-tag v-if="row.latest" size="mini" type="success">最新</el-tag>
            </td>
            <td class="version-content-cell">
              <span class="version-content-text">{{ row.updateContent || '-' }}</span>
            </td>
            <td>{{ parseTime(row.createTime) }}</td>
            <td v-if="!isMiddlePlatformReadonly && $auth.hasPermi('visitor:version:remove')">
            <td>
              <div class="table-actions">
                <button v-hasPermi="['visitor:version:remove']" type="button" @click="handleDelete(row)">删除</button>
                <button type="button" @click="handleView(row)">查看</button>
                <button
                  v-if="!row.latest && !isMiddlePlatformReadonly && $auth.hasPermi('visitor:version:remove')"
                  type="button"
                  @click="handleDelete(row)"
                >删除</button>
              </div>
            </td>
          </tr>
@@ -36,36 +44,70 @@
      <pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
    </section>
    <el-dialog title="新建" :visible.sync="open" width="482px" append-to-body>
    <el-dialog title="版本详情" :visible.sync="detailOpen" width="560px" append-to-body>
      <el-form label-width="100px">
        <el-form-item label="版本号">
          <span>{{ formatVersionNo(detail.versionNo) }}</span>
          <el-tag v-if="detail.latest" size="mini" type="success">最新</el-tag>
        </el-form-item>
        <el-form-item label="更新内容">
          <div style="white-space: pre-wrap; word-break: break-all;">{{ detail.updateContent || '-' }}</div>
        </el-form-item>
        <el-form-item label="安装包">{{ detail.packageName || detail.packageUrl || '-' }}</el-form-item>
        <el-form-item label="强制更新">{{ detail.forceUpdate ? '是' : '否' }}</el-form-item>
        <el-form-item label="创建时间">{{ parseTime(detail.createTime) || '-' }}</el-form-item>
      </el-form>
      <div slot="footer" class="dialog-footer">
        <el-button @click="detailOpen = false">关闭</el-button>
      </div>
    </el-dialog>
    <el-dialog title="新建" :visible.sync="open" width="482px" append-to-body custom-class="version-create-dialog">
      <form class="version-form" @submit.prevent="submitForm">
        <label class="version-row required">
          <span>版本号:</span>
          <div class="version-input-line">
            <span>v</span>
            <input v-model="form.versionNo" class="visitor-native-input" type="text" placeholder="请输入">
            <input
              v-model="form.versionNo"
              class="visitor-native-input"
              type="text"
              inputmode="decimal"
              pattern="[0-9]+(\.[0-9]+)*"
              placeholder="请输入"
              @input="handleVersionNoInput"
            >
          </div>
        </label>
        <label class="version-row">
          <span>更新内容</span>
          <textarea v-model="form.updateContent" class="visitor-native-textarea" placeholder="请输入"></textarea>
          <textarea v-model="form.updateContent" class="visitor-native-textarea" placeholder="请输入" />
        </label>
        <div class="version-row required">
          <span>安装包:</span>
          <div>
            <button class="upload-drop" :class="{ 'has-file': packageFileName }" type="button" @click="$refs.packageFile.click()">
          <div class="version-upload-field">
            <button
              class="upload-drop"
              :class="{ 'has-file': packageFileName, 'drag-over': packageDragActive }"
              type="button"
              @click="$refs.packageFile.click()"
              @dragover.prevent="handlePackageDragOver"
              @dragleave="handlePackageDragLeave"
              @drop.prevent="handlePackageDrop"
            >
              <input ref="packageFile" type="file" hidden @change="handlePackageChange">
              <span><strong>{{ packageFileName ? packageFileName : '点击上传' }}</strong><i v-if="!packageFileName"> / </i>{{ packageFileName ? '' : '拖拽到此区域' }}</span>
            </button>
            <p class="upload-tip">请上传安装包文件,大小在60M以内</p>
            <p class="upload-tip">请上传安装包文件,大小须小于200M</p>
          </div>
        </div>
        <label class="version-row switch-line">
          <span>强制更新</span>
          <input v-model="form.forceUpdate" type="checkbox">
          <span class="switch-control"></span>
          <span class="switch-control" />
        </label>
        <footer class="visitor-form-actions">
@@ -78,8 +120,10 @@
</template>
<script>
import { listVersion, addVersion, delVersion } from '@/api/visitor-admin/version'
import { getPageRows, getPageTotal } from '@/utils/visitor-admin'
import { listVersion, getVersion, addVersion, delVersion, uploadVersionPackage } from '@/api/visitor-admin/version'
import { getPageRows, getPageTotal, getResponseData } from '@/utils/visitor-admin'
const MAX_PACKAGE_SIZE = 200 * 1024 * 1024
export default {
  name: 'VisitorVersionUpdate',
@@ -89,7 +133,11 @@
      loading: false,
      total: 0,
      open: false,
      detailOpen: false,
      detail: {},
      packageFile: null,
      packageFileName: '',
      packageDragActive: false,
      versionList: [],
      queryParams: {
        pageNum: 1,
@@ -114,6 +162,11 @@
        this.loading = false
      })
    },
    // 目的:兼容历史版本号是否包含 v 前缀,统一按 v+版本号展示。
    formatVersionNo(versionNo) {
      const value = String(versionNo || '').replace(/^[vV]/, '')
      return value ? `v${value}` : '-'
    },
    // 目的:打开新增版本弹窗;中台只读模式下禁止本地新增。
    handleAdd() {
      if (this.isMiddlePlatformReadonly) {
@@ -121,16 +174,59 @@
        return
      }
      this.form = { forceUpdate: false, versionNo: '', updateContent: '', packageName: '', packageUrl: '' }
      this.packageFile = null
      this.packageFileName = ''
      this.packageDragActive = false
      if (this.$refs.packageFile) {
        this.$refs.packageFile.value = ''
      }
      this.open = true
    },
    // 目的:记录选择的安装包文件名,并回填版本包名称和地址字段。
    // 目的:版本号只保留数字和小数点,支持多段版本号输入。
    handleVersionNoInput(event) {
      const versionNo = event.target.value.replace(/[^\d.]/g, '').replace(/\.{2,}/g, '.')
      event.target.value = versionNo
      this.form.versionNo = versionNo
    },
    // 目的:通过文件选择框读取安装包,并复用统一校验逻辑。
    handlePackageChange(event) {
      const file = event.target.files && event.target.files[0]
      this.setPackageFile(file, event.target)
    },
    // 目的:拖拽文件经过上传区域时阻止浏览器默认行为并展示激活状态。
    handlePackageDragOver() {
      this.packageDragActive = true
    },
    // 目的:文件离开整个上传区域后恢复默认样式,避免子元素切换导致闪烁。
    handlePackageDragLeave(event) {
      if (!event.currentTarget.contains(event.relatedTarget)) {
        this.packageDragActive = false
      }
    },
    // 目的:接收拖放的首个文件,并复用文件选择框的校验和回填逻辑。
    handlePackageDrop(event) {
      this.packageDragActive = false
      const file = event.dataTransfer.files && event.dataTransfer.files[0]
      this.setPackageFile(file, this.$refs.packageFile)
    },
    // 目的:统一处理点击选择与拖拽选择的安装包,避免重复校验逻辑。
    setPackageFile(file, input) {
      if (file && file.size >= MAX_PACKAGE_SIZE) {
        this.$modal.msgError('操作失败:安装包大小必须小于200M')
        if (input) {
          input.value = ''
        }
        this.packageFile = null
        this.packageFileName = ''
        this.form.packageName = ''
        this.form.packageUrl = ''
        return
      }
      this.packageFile = file || null
      this.packageFileName = file ? file.name : ''
      if (file) {
        this.form.packageName = file.name
        this.form.packageUrl = file.name
        this.form.packageUrl = ''
      }
    },
    // 目的:校验并提交新增版本;中台只读模式下禁止提交。
@@ -139,14 +235,35 @@
        this.denyMiddlePlatformWrite()
        return
      }
      if (!this.form.versionNo || !this.form.packageUrl) {
      if (!/^\d+(\.\d+)*$/.test(this.form.versionNo || '') || !this.packageFile) {
        this.$modal.msgError('请填写版本号并上传安装包')
        return
      }
      addVersion(this.form).then(() => {
      if (this.packageFile.size >= MAX_PACKAGE_SIZE) {
        this.$modal.msgError('操作失败:安装包大小必须小于200M')
        return
      }
      const formData = new FormData()
      formData.append('file', this.packageFile)
      uploadVersionPackage(formData).then(res => {
        const data = getResponseData(res) || {}
        this.form.packageUrl = data.fileName || data.url || ''
        this.form.packageName = data.originalFilename || data.newFileName || this.packageFile.name
        if (!this.form.packageUrl) {
          throw new Error('安装包上传失败,未返回文件地址')
        }
        return addVersion(this.form)
      }).then(() => {
        this.$modal.msgSuccess('新增成功')
        this.open = false
        this.getList()
      })
    },
    // 目的:查看任意版本的完整更新信息。
    handleView(row) {
      getVersion(row.updateId).then(res => {
        this.detail = getResponseData(res) || {}
        this.detailOpen = true
      })
    },
    // 目的:删除版本更新记录;中台只读模式下禁止本地删除。
@@ -155,6 +272,10 @@
        this.denyMiddlePlatformWrite()
        return
      }
      if (row.latest) {
        this.$modal.msgWarning('最新版本不能删除')
        return
      }
      this.$modal.confirm('是否确认删除该版本记录?').then(() => delVersion([row.updateId])).then(() => {
        this.$modal.msgSuccess('删除成功')
        this.getList()
visitor-admin-ui/css/version-update.css
@@ -141,7 +141,7 @@
.version-modal-header {
  height: 52px;
  justify-content: flex-start;
  padding-left: 22px;
  padding-left: 20px;
}
.version-modal-header h3 {
@@ -165,7 +165,10 @@
.version-row > span:first-child {
  padding-top: 8px;
  text-align: right;
  font-size: 12px;
  font-weight: 400;
  line-height: 16px;
  text-align: left;
}
.version-row.required > span:first-child::before {
@@ -178,24 +181,31 @@
  display: grid;
  grid-template-columns: 18px 1fr;
  align-items: center;
  width: 242px;
  height: 24px;
  border: 1px solid #cbd2dc;
  box-sizing: border-box;
}
.version-prefix {
  padding-left: 7px;
  color: #24364f;
}
.version-row input[type="text"] {
  height: 24px;
  height: 22px;
  min-width: 0;
  padding: 0 8px;
  border: 1px solid #cbd2dc;
  padding: 0 7px 0 0;
  border: 0;
  color: #1f2d3d;
  font: inherit;
  outline: 0;
}
.version-row textarea {
  width: 100%;
  width: 242px;
  height: 68px;
  box-sizing: border-box;
  resize: none;
  padding: 8px;
  border: 1px solid #cbd2dc;
@@ -205,10 +215,14 @@
  outline: 0;
}
.version-row input[type="text"]:focus,
.version-input-line:focus-within,
.version-row textarea:focus {
  border-color: #2f80ed;
  box-shadow: 0 0 0 2px rgba(47, 128, 237, 0.08);
}
.upload-field {
  width: 242px;
}
.upload-drop {
@@ -249,6 +263,7 @@
  margin: 10px 0 0;
  color: #8a9ab0;
  font-size: 11px;
  line-height: 16px;
  text-align: center;
}
@@ -286,7 +301,7 @@
}
.switch-line input:checked + .switch-control {
  background: #21c96b;
  background: #19be6b;
}
.switch-line input:checked + .switch-control::after {
visitor-admin-ui/js/version-update.js
@@ -3,7 +3,7 @@
    apiBaseUrl: "http://localhost:9098"
  }, window.VISITOR_LOGIN_CONFIG || {});
  var MAX_PACKAGE_SIZE = 60 * 1024 * 1024;
  var MAX_PACKAGE_SIZE = 200 * 1024 * 1024;
  var state = {
    pageNum: 1,
    pageSize: 20,
@@ -69,6 +69,7 @@
  function bindPageActions() {
    var uploadDrop = document.getElementById("uploadDrop");
    var packageFile = document.getElementById("packageFile");
    var versionNoInput = document.getElementById("versionNo");
    document.getElementById("addVersionButton").addEventListener("click", openAddModal);
    document.getElementById("versionForm").addEventListener("submit", function (event) {
@@ -78,6 +79,10 @@
        return;
      }
      saveVersion();
    });
    versionNoInput.addEventListener("input", function () {
      versionNoInput.value = versionNoInput.value.replace(/[^\d.]/g, "").replace(/\.{2,}/g, ".");
    });
    document.querySelectorAll("[data-close='version']").forEach(function (button) {
@@ -231,11 +236,15 @@
  function saveVersion() {
    var saveButton = document.getElementById("saveVersionButton");
    var versionNo = normalizeVersionNo(document.getElementById("versionNo").value);
    var versionNumber = document.getElementById("versionNo").value.trim();
    var updateContent = document.getElementById("updateContent").value.trim();
    if (!versionNo) {
    if (!versionNumber) {
      AppMessage.error("操作失败:请输入版本号");
      return;
    }
    if (!/^\d+(\.\d+)*$/.test(versionNumber)) {
      AppMessage.error("操作失败:版本号格式不正确,请输入数字或使用小数点分隔");
      return;
    }
    if (!updateContent) {
@@ -246,13 +255,17 @@
      AppMessage.error("操作失败:请上传安装包");
      return;
    }
    if (isPackageTooLarge(state.packageFile)) {
      AppMessage.error("操作失败:安装包大小必须小于200M");
      return;
    }
    saveButton.disabled = true;
    uploadPackage().then(function (fileInfo) {
      return apiRequest("/system/version/update/add", {
        method: "POST",
        body: JSON.stringify({
          versionNo: versionNo,
          versionNo: normalizeVersionNo(versionNumber),
          updateContent: updateContent,
          packageUrl: fileInfo.url || fileInfo.fileName || "",
          packageName: fileInfo.originalFilename || fileInfo.newFileName || state.packageFile.name,
@@ -271,9 +284,12 @@
  }
  function uploadPackage() {
    if (isPackageTooLarge(state.packageFile)) {
      return Promise.reject(new Error("安装包大小必须小于200M"));
    }
    var formData = new FormData();
    formData.append("file", state.packageFile);
    return apiRequest("/common/upload", {
    return apiRequest("/system/version/update/upload", {
      method: "POST",
      body: formData,
      skipJson: true
@@ -286,20 +302,24 @@
      updatePackageTip();
      return;
    }
    if (file.size > MAX_PACKAGE_SIZE) {
    if (isPackageTooLarge(file)) {
      state.packageFile = null;
      document.getElementById("packageFile").value = "";
      updatePackageTip();
      AppMessage.error("操作失败:安装包大小不能超过60M");
      AppMessage.error("操作失败:安装包大小必须小于200M");
      return;
    }
    state.packageFile = file;
    updatePackageTip(file.name);
  }
  function isPackageTooLarge(file) {
    return !!file && file.size >= MAX_PACKAGE_SIZE;
  }
  function updatePackageTip(fileName) {
    var uploadDrop = document.getElementById("uploadDrop");
    var text = fileName || "请上传安装包文件,大小在60M以内";
    var text = fileName || "请上传安装包文件,大小须小于200M";
    document.getElementById("packageTip").textContent = text;
    uploadDrop.classList.toggle("has-file", !!fileName);
    uploadDrop.querySelector("span").innerHTML = fileName
visitor-admin-ui/pages/version-update.html
@@ -145,7 +145,7 @@
          <span>版本号:</span>
          <div class="version-input-line">
            <span class="version-prefix">v</span>
            <input id="versionNo" type="text" placeholder="请输入">
            <input id="versionNo" type="text" inputmode="decimal" pattern="[0-9]+(\.[0-9]+)*" autocomplete="off" placeholder="请输入">
          </div>
        </label>
@@ -156,12 +156,12 @@
        <div class="version-row required">
          <span>安装包:</span>
          <div>
          <div class="upload-field">
            <button id="uploadDrop" class="upload-drop" type="button">
              <input id="packageFile" type="file" hidden>
              <span><strong>点击上传</strong><i>/</i>拖拽到此区域</span>
            </button>
            <p id="packageTip" class="upload-tip">请上传安装包文件,大小在60M以内</p>
            <p id="packageTip" class="upload-tip">请上传安装包文件,大小须小于200M</p>
          </div>
        </div>