From 8665daa7aa863f96d0d8f27c167edae1efc38022 Mon Sep 17 00:00:00 2001
From: zjk <852185829@qq.com>
Date: 星期五, 31 七月 2026 14:46:19 +0800
Subject: [PATCH] Merge branch 'master' into dev

---
 ruoyi-admin/src/main/resources/application-test.yml                                        |    2 
 ruoyi-ui/src/views/visitor-admin/record/visitor.vue                                        |    2 
 ruoyi-system/src/test/java/com/ruoyi/system/utils/ProtocolHtmlSanitizerTest.java           |   43 +
 doc/sql/20260729_t_visitor_config_add_appointment_default_visit_time.sql                   |    3 
 ruoyi-ui/src/utils/visitor-admin.js                                                        |   24 
 ruoyi-ui/src/assets/styles/visitor-admin.scss                                              |  383 +++++++++++--
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysProtocolConfigServiceImpl.java |    3 
 ruoyi-system/src/main/resources/mapper/system/SysDashboardMapper.xml                       |    6 
 ruoyi-ui/src/api/visitor-admin/version.js                                                  |   18 
 ruoyi-ui/src/assets/icons/svg/config-sign-confirm.svg                                      |    1 
 ruoyi-ui/src/views/visitor-admin/system/version.vue                                        |  155 +++++
 ruoyi-ui/src/views/visitor-admin/dashboard/index.vue                                       |  126 ++++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVisitorConfigServiceImpl.java  |   12 
 ruoyi-ui/src/assets/icons/svg/config-face-capture.svg                                      |    1 
 ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVersionUpdateVO.java                     |    2 
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/SysVersionUpdateController.java     |   42 +
 ruoyi-ui/src/assets/icons/svg/config-system.svg                                            |    1 
 ruoyi-ui/src/assets/icons/svg/config-appointment-register.svg                              |    1 
 doc/sql/20260706_t_visitor_config.sql                                                      |    5 
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysDashboardMapper.java                 |    2 
 ruoyi-ui/src/assets/icons/svg/config-device.svg                                            |    1 
 ruoyi-admin/src/main/resources/application-prod.yml                                        |    2 
 ruoyi-system/src/main/java/com/ruoyi/system/utils/ProtocolHtmlSanitizer.java               |  141 +++++
 doc/sql/20260723_visitor_record_add_export_permission.sql                                  |   29 +
 ruoyi-ui/src/views/visitor-admin/system/protocol.vue                                       |  180 ++++++
 ruoyi-ui/src/assets/icons/svg/config-id-card.svg                                           |    1 
 visitor-admin-ui/css/version-update.css                                                    |   31 
 ruoyi-ui/src/assets/icons/svg/config-face-compare.svg                                      |    1 
 ruoyi-system/src/main/resources/mapper/system/SysVisitorRecordMapper.xml                   |   23 
 ruoyi-system/src/main/java/com/ruoyi/system/dto/SysVisitorConfigSaveRequest.java           |    8 
 visitor-admin-ui/js/version-update.js                                                      |   36 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVersionUpdateServiceImpl.java  |   29 
 ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml                            |    5 
 ruoyi-applet/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java          |    2 
 ruoyi-ui/src/assets/icons/svg/config-appointment-time.svg                                  |    1 
 ruoyi-ui/src/assets/icons/svg/config-onsite-register.svg                                   |    1 
 doc/sql/20260723_t_visitor_machine_add_fingerprint.sql                                     |    3 
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/MiddlePlatformDataQuery.java       |    6 
 ruoyi-ui/src/assets/icons/svg/config-register.svg                                          |    1 
 ruoyi-ui/src/views/visitor-admin/device/index.vue                                          |    8 
 ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVisitorConfigVO.java                     |    5 
 ruoyi-ui/src/assets/icons/svg/config-receiver-confirm.svg                                  |    1 
 ruoyi-system/src/main/resources/mapper/system/SysVisitorMachineMapper.xml                  |    5 
 ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml                         |    3 
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java           |    2 
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysDashboardServiceImpl.java      |    3 
 ruoyi-ui/src/assets/icons/svg/config-print.svg                                             |    1 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/SysVisitorConfig.java                   |    5 
 ruoyi-system/src/main/resources/mapper/system/SysVisitorConfigMapper.xml                   |    5 
 visitor-admin-ui/pages/version-update.html                                                 |    6 
 ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUploadUtils.java                |   19 
 ruoyi-ui/src/views/visitor-admin/system/config.vue                                         |  106 ++-
 52 files changed, 1,280 insertions(+), 222 deletions(-)

diff --git a/doc/sql/20260706_t_visitor_config.sql b/doc/sql/20260706_t_visitor_config.sql
index 85a4635..54488fd 100644
--- a/doc/sql/20260706_t_visitor_config.sql
+++ b/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,
diff --git a/doc/sql/20260723_t_visitor_machine_add_fingerprint.sql b/doc/sql/20260723_t_visitor_machine_add_fingerprint.sql
new file mode 100644
index 0000000..3d38bfb
--- /dev/null
+++ b/doc/sql/20260723_t_visitor_machine_add_fingerprint.sql
@@ -0,0 +1,3 @@
+-- 访客机管理:补充百度SDK指纹字段
+ALTER TABLE `t_visitor_machine`
+  ADD COLUMN `fingerprint` varchar(255) DEFAULT NULL COMMENT '百度SDK指纹' AFTER `online_status`;
diff --git a/doc/sql/20260723_visitor_record_add_export_permission.sql b/doc/sql/20260723_visitor_record_add_export_permission.sql
new file mode 100644
index 0000000..eee095d
--- /dev/null
+++ b/doc/sql/20260723_visitor_record_add_export_permission.sql
@@ -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
+  );
diff --git a/doc/sql/20260729_t_visitor_config_add_appointment_default_visit_time.sql b/doc/sql/20260729_t_visitor_config_add_appointment_default_visit_time.sql
new file mode 100644
index 0000000..5e540ab
--- /dev/null
+++ b/doc/sql/20260729_t_visitor_config_add_appointment_default_visit_time.sql
@@ -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`;
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/SysVersionUpdateController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/SysVersionUpdateController.java
index 28c5f40..1504dc4 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/api/SysVersionUpdateController.java
+++ b/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());
+        }
+    }
+
     /**
      * 删除版本更新
      */
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java
index b718a50..ed30b01 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java
+++ b/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));
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/MiddlePlatformDataQuery.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/MiddlePlatformDataQuery.java
index 2e5fb1d..a32e2d2 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/tool/MiddlePlatformDataQuery.java
+++ b/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";
     }
 }
diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml
index c5e0b47..028f161 100644
--- a/ruoyi-admin/src/main/resources/application-prod.yml
+++ b/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
diff --git a/ruoyi-admin/src/main/resources/application-test.yml b/ruoyi-admin/src/main/resources/application-test.yml
index e26e741..eccc799 100644
--- a/ruoyi-admin/src/main/resources/application-test.yml
+++ b/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
diff --git a/ruoyi-applet/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java b/ruoyi-applet/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java
index 990d0f7..bd86ef2 100644
--- a/ruoyi-applet/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java
+++ b/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));
diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUploadUtils.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUploadUtils.java
index d9f2b13..5ff593f 100644
--- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/FileUploadUtils.java
+++ b/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();
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/SysVisitorConfig.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/SysVisitorConfig.java
index fa8ed96..b0c1e18 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/SysVisitorConfig.java
+++ b/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;
 
     /** 允许手动输入身份证 */
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/dto/SysVisitorConfigSaveRequest.java b/ruoyi-system/src/main/java/com/ruoyi/system/dto/SysVisitorConfigSaveRequest.java
index 4bbeba3..b062caf 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/dto/SysVisitorConfigSaveRequest.java
+++ b/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;
 
     /** 是否允许手动输入身份证 */
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysDashboardMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysDashboardMapper.java
index 52c3bc0..2cdb55c 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysDashboardMapper.java
+++ b/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);
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysDashboardServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysDashboardServiceImpl.java
index fe539d4..8ff02fb 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysDashboardServiceImpl.java
+++ b/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;
     }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysProtocolConfigServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysProtocolConfigServiceImpl.java
index 1260f36..b85cdbe 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysProtocolConfigServiceImpl.java
+++ b/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("协议内容不能为空");
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVersionUpdateServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVersionUpdateServiceImpl.java
index 0f1fb7e..d31000a 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVersionUpdateServiceImpl.java
+++ b/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());
     }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVisitorConfigServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVisitorConfigServiceImpl.java
index 4789ad8..ec2bd80 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysVisitorConfigServiceImpl.java
+++ b/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());
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/utils/ProtocolHtmlSanitizer.java b/ruoyi-system/src/main/java/com/ruoyi/system/utils/ProtocolHtmlSanitizer.java
new file mode 100644
index 0000000..2cb1ce6
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/utils/ProtocolHtmlSanitizer.java
@@ -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);
+    }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVersionUpdateVO.java b/ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVersionUpdateVO.java
index 5238162..3915d97 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVersionUpdateVO.java
+++ b/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;
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVisitorConfigVO.java b/ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVisitorConfigVO.java
index ed559fa..641f14f 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/vo/SysVisitorConfigVO.java
+++ b/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")
diff --git a/ruoyi-system/src/main/resources/mapper/system/SysDashboardMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysDashboardMapper.xml
index 1c8efef..d9c0526 100644
--- a/ruoyi-system/src/main/resources/mapper/system/SysDashboardMapper.xml
+++ b/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'
diff --git a/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml
index cf439f6..c09db51 100644
--- a/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml
+++ b/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> 
\ No newline at end of file
+</mapper>
diff --git a/ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml
index ef60b58..bc2cd67 100644
--- a/ruoyi-system/src/main/resources/mapper/system/SysOperLogMapper.xml
+++ b/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}, '%')
diff --git a/ruoyi-system/src/main/resources/mapper/system/SysVisitorConfigMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysVisitorConfigMapper.xml
index 9e95e2f..9a03f30 100644
--- a/ruoyi-system/src/main/resources/mapper/system/SysVisitorConfigMapper.xml
+++ b/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},
diff --git a/ruoyi-system/src/main/resources/mapper/system/SysVisitorMachineMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysVisitorMachineMapper.xml
index 89d55c5..0a36cfa 100644
--- a/ruoyi-system/src/main/resources/mapper/system/SysVisitorMachineMapper.xml
+++ b/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">
diff --git a/ruoyi-system/src/main/resources/mapper/system/SysVisitorRecordMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysVisitorRecordMapper.xml
index 4fa43d4..0fe2613 100644
--- a/ruoyi-system/src/main/resources/mapper/system/SysVisitorRecordMapper.xml
+++ b/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,
diff --git a/ruoyi-system/src/test/java/com/ruoyi/system/utils/ProtocolHtmlSanitizerTest.java b/ruoyi-system/src/test/java/com/ruoyi/system/utils/ProtocolHtmlSanitizerTest.java
new file mode 100644
index 0000000..59fa9d3
--- /dev/null
+++ b/ruoyi-system/src/test/java/com/ruoyi/system/utils/ProtocolHtmlSanitizerTest.java
@@ -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);
+        }
+    }
+}
diff --git a/ruoyi-ui/src/api/visitor-admin/version.js b/ruoyi-ui/src/api/visitor-admin/version.js
index f071f43..6c7d78e 100644
--- a/ruoyi-ui/src/api/visitor-admin/version.js
+++ b/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 }})
+}
diff --git a/ruoyi-ui/src/assets/icons/svg/config-appointment-register.svg b/ruoyi-ui/src/assets/icons/svg/config-appointment-register.svg
new file mode 100644
index 0000000..3d81da6
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-appointment-register.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/icons/svg/config-appointment-time.svg b/ruoyi-ui/src/assets/icons/svg/config-appointment-time.svg
new file mode 100644
index 0000000..2ef2b0e
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-appointment-time.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/icons/svg/config-device.svg b/ruoyi-ui/src/assets/icons/svg/config-device.svg
new file mode 100644
index 0000000..0cf70d5
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-device.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/icons/svg/config-face-capture.svg b/ruoyi-ui/src/assets/icons/svg/config-face-capture.svg
new file mode 100644
index 0000000..0ce89f7
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-face-capture.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/icons/svg/config-face-compare.svg b/ruoyi-ui/src/assets/icons/svg/config-face-compare.svg
new file mode 100644
index 0000000..6e4bbf3
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-face-compare.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/icons/svg/config-id-card.svg b/ruoyi-ui/src/assets/icons/svg/config-id-card.svg
new file mode 100644
index 0000000..12b9382
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-id-card.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/icons/svg/config-onsite-register.svg b/ruoyi-ui/src/assets/icons/svg/config-onsite-register.svg
new file mode 100644
index 0000000..53526db
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-onsite-register.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/icons/svg/config-print.svg b/ruoyi-ui/src/assets/icons/svg/config-print.svg
new file mode 100644
index 0000000..a17040b
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-print.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/icons/svg/config-receiver-confirm.svg b/ruoyi-ui/src/assets/icons/svg/config-receiver-confirm.svg
new file mode 100644
index 0000000..25d2183
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-receiver-confirm.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/icons/svg/config-register.svg b/ruoyi-ui/src/assets/icons/svg/config-register.svg
new file mode 100644
index 0000000..e892b43
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-register.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/icons/svg/config-sign-confirm.svg b/ruoyi-ui/src/assets/icons/svg/config-sign-confirm.svg
new file mode 100644
index 0000000..8801bf7
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-sign-confirm.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/icons/svg/config-system.svg b/ruoyi-ui/src/assets/icons/svg/config-system.svg
new file mode 100644
index 0000000..8976ee4
--- /dev/null
+++ b/ruoyi-ui/src/assets/icons/svg/config-system.svg
@@ -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>
diff --git a/ruoyi-ui/src/assets/styles/visitor-admin.scss b/ruoyi-ui/src/assets/styles/visitor-admin.scss
index d06c309..36b3ca8 100644
--- a/ruoyi-ui/src/assets/styles/visitor-admin.scss
+++ b/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,
diff --git a/ruoyi-ui/src/utils/visitor-admin.js b/ruoyi-ui/src/utils/visitor-admin.js
index 77e98e1..3923db7 100644
--- a/ruoyi-ui/src/utils/visitor-admin.js
+++ b/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) {
diff --git a/ruoyi-ui/src/views/visitor-admin/dashboard/index.vue b/ruoyi-ui/src/views/visitor-admin/dashboard/index.vue
index f4c038e..2fb6c53 100644
--- a/ruoyi-ui/src/views/visitor-admin/dashboard/index.vue
+++ b/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()
       })
     }
diff --git a/ruoyi-ui/src/views/visitor-admin/device/index.vue b/ruoyi-ui/src/views/visitor-admin/device/index.vue
index 0f6f4dd..ec69942 100644
--- a/ruoyi-ui/src/views/visitor-admin/device/index.vue
+++ b/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
diff --git a/ruoyi-ui/src/views/visitor-admin/record/visitor.vue b/ruoyi-ui/src/views/visitor-admin/record/visitor.vue
index cd9c201..d56e72d 100644
--- a/ruoyi-ui/src/views/visitor-admin/record/visitor.vue
+++ b/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'] }
       ],
diff --git a/ruoyi-ui/src/views/visitor-admin/system/config.vue b/ruoyi-ui/src/views/visitor-admin/system/config.vue
index aeb0039..b684e2e 100644
--- a/ruoyi-ui/src/views/visitor-admin/system/config.vue
+++ b/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: '访客姓名' },
diff --git a/ruoyi-ui/src/views/visitor-admin/system/protocol.vue b/ruoyi-ui/src/views/visitor-admin/system/protocol.vue
index 6518781..6b17a1e 100644
--- a/ruoyi-ui/src/views/visitor-admin/system/protocol.vue
+++ b/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,供纯文本粘贴时保留换行展示。
diff --git a/ruoyi-ui/src/views/visitor-admin/system/version.vue b/ruoyi-ui/src/views/visitor-admin/system/version.vue
index 8c42ae2..4b20bb6 100644
--- a/ruoyi-ui/src/views/visitor-admin/system/version.vue
+++ b/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()
diff --git a/visitor-admin-ui/css/version-update.css b/visitor-admin-ui/css/version-update.css
index 294f8d4..267bda6 100644
--- a/visitor-admin-ui/css/version-update.css
+++ b/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 {
diff --git a/visitor-admin-ui/js/version-update.js b/visitor-admin-ui/js/version-update.js
index d6a1d95..6c8622f 100644
--- a/visitor-admin-ui/js/version-update.js
+++ b/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
diff --git a/visitor-admin-ui/pages/version-update.html b/visitor-admin-ui/pages/version-update.html
index f9e3c71..ec635cb 100644
--- a/visitor-admin-ui/pages/version-update.html
+++ b/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>
 

--
Gitblit v1.7.1