Merge branch 'master' into dev
| | |
| | | `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 '系统名称', |
| | |
| | | `config_id`, |
| | | `onsite_registration_enabled`, |
| | | `appointment_registration_enabled`, |
| | | `appointment_default_visit_time`, |
| | | `register_receiver_confirm_enabled`, |
| | | `sign_receiver_confirm_enabled`, |
| | | `face_compare_enabled`, |
| | |
| | | 1, |
| | | 1, |
| | | 1, |
| | | NULL, |
| | | 1, |
| | | 1, |
| | | 1, |
| New file |
| | |
| | | -- 访客机管理:补充百度SDK指纹字段 |
| | | ALTER TABLE `t_visitor_machine` |
| | | ADD COLUMN `fingerprint` varchar(255) DEFAULT NULL COMMENT '百度SDK指纹' AFTER `online_status`; |
| New file |
| | |
| | | -- 访客登记记录新增导出按钮权限 |
| | | 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 |
| | | ); |
| New file |
| | |
| | | ALTER TABLE `t_visitor_config` |
| | | ADD COLUMN `appointment_default_visit_time` varchar(5) DEFAULT NULL COMMENT '预约登记默认访问时间,格式HH:mm' |
| | | AFTER `appointment_registration_enabled`; |
| | |
| | | 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; |
| | |
| | | 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; |
| | | |
| | | /** |
| | | * 版本更新接口 |
| | |
| | | @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; |
| | | |
| | | /** |
| | | * 分页查询版本更新列表 |
| | |
| | | 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()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 删除版本更新 |
| | | */ |
| | |
| | | } |
| | | if (deptService.checkDeptExistUser(deptId)) |
| | | { |
| | | return warn("部门存在用户,不允许删除"); |
| | | return warn("操作失败:存在员工"); |
| | | } |
| | | deptService.checkDeptDataScope(deptId); |
| | | return AjaxResult.success(deptService.deleteDeptById(deptId)); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 目的:将中台 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"; |
| | | } |
| | | } |
| | |
| | | # 过滤开关 |
| | | enabled: true |
| | | # 排除链接(多个用逗号分隔) |
| | | excludes: /system/notice |
| | | excludes: /system/notice,/system/protocol/config/save |
| | | # 匹配链接 |
| | | urlPatterns: /system/*,/monitor/*,/tool/* |
| | | # file upload |
| | |
| | | # 过滤开关 |
| | | enabled: true |
| | | # 排除链接(多个用逗号分隔) |
| | | excludes: /system/notice |
| | | excludes: /system/notice,/system/protocol/config/save |
| | | # 匹配链接 |
| | | urlPatterns: /system/*,/monitor/*,/tool/* |
| | | # file upload |
| | |
| | | } |
| | | if (deptService.checkDeptExistUser(deptId)) |
| | | { |
| | | return warn("部门存在用户,不允许删除"); |
| | | return warn("操作失败:存在员工"); |
| | | } |
| | | deptService.checkDeptDataScope(deptId); |
| | | return AjaxResult.success(deptService.deleteDeptById(deptId)); |
| | |
| | | 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); |
| | | |
| | |
| | | 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(); |
| | |
| | | /** 预约登记开关 */ |
| | | private Boolean appointmentRegistrationEnabled; |
| | | |
| | | /** 预约登记默认访问时间,格式HH:mm */ |
| | | private String appointmentDefaultVisitTime; |
| | | |
| | | /** 登记需接待人确认 */ |
| | | private Boolean registerReceiverConfirmEnabled; |
| | | |
| | |
| | | /** 人像对比开关 */ |
| | | private Boolean faceCompareEnabled; |
| | | |
| | | /** 全方位人脸采集开关 */ |
| | | /** 人脸采集开关 */ |
| | | private Boolean faceCaptureEnabled; |
| | | |
| | | /** 允许手动输入身份证 */ |
| | |
| | | @ApiModelProperty("是否允许预约登记") |
| | | private Boolean appointmentRegistrationEnabled; |
| | | |
| | | /** 预约登记默认访问时间 */ |
| | | @ApiModelProperty("预约登记默认访问时间,格式HH:mm") |
| | | private String appointmentDefaultVisitTime; |
| | | |
| | | /** 登记是否需要接待人确认 */ |
| | | @ApiModelProperty("登记是否需要接待人确认") |
| | | private Boolean registerReceiverConfirmEnabled; |
| | |
| | | @ApiModelProperty("是否启用人像对比") |
| | | private Boolean faceCompareEnabled; |
| | | |
| | | /** 是否启用全方位人脸采集 */ |
| | | @ApiModelProperty("是否启用全方位人脸采集") |
| | | /** 是否启用人脸采集 */ |
| | | @ApiModelProperty("是否启用人脸采集") |
| | | private Boolean faceCaptureEnabled; |
| | | |
| | | /** 是否允许手动输入身份证 */ |
| | |
| | | @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); |
| | |
| | | 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; |
| | | } |
| | |
| | | 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; |
| | |
| | | { |
| | | throw new ServiceException("协议名称不能为空"); |
| | | } |
| | | String protocolContent = StringUtils.trim(request.getProtocolContent()); |
| | | String protocolContent = StringUtils.trim(ProtocolHtmlSanitizer.clean(request.getProtocolContent())); |
| | | if (StringUtils.isEmpty(protocolContent)) |
| | | { |
| | | throw new ServiceException("协议内容不能为空"); |
| | |
| | | import org.springframework.util.CollectionUtils; |
| | | |
| | | import java.util.List; |
| | | import java.util.Objects; |
| | | |
| | | /** |
| | | * 版本更新服务实现 |
| | |
| | | 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; |
| | | } |
| | |
| | | @Override |
| | | public SysVersionUpdateVO latest() |
| | | { |
| | | return versionUpdateMapper.selectLatest(); |
| | | SysVersionUpdateVO latest = versionUpdateMapper.selectLatest(); |
| | | if (latest != null) |
| | | { |
| | | latest.setLatest(Boolean.TRUE); |
| | | } |
| | | return latest; |
| | | } |
| | | |
| | | @Override |
| | |
| | | { |
| | | throw new ServiceException("版本更新记录不存在"); |
| | | } |
| | | SysVersionUpdateVO latest = versionUpdateMapper.selectLatest(); |
| | | detail.setLatest(latest != null && Objects.equals(detail.getUpdateId(), latest.getUpdateId())); |
| | | return detail; |
| | | } |
| | | |
| | |
| | | { |
| | | 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)) |
| | | { |
| | |
| | | { |
| | | throw new ServiceException("更新主键集合不能为空"); |
| | | } |
| | | SysVersionUpdateVO latest = versionUpdateMapper.selectLatest(); |
| | | if (latest != null && request.getUpdateIds().contains(latest.getUpdateId())) |
| | | { |
| | | throw new ServiceException("最新版本不能删除"); |
| | | } |
| | | return versionUpdateMapper.deleteByIds(request.getUpdateIds()); |
| | | } |
| | | } |
| | |
| | | { |
| | | 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()); |
| | |
| | | SysVisitorConfig config = new SysVisitorConfig(); |
| | | config.setOnsiteRegistrationEnabled(true); |
| | | config.setAppointmentRegistrationEnabled(true); |
| | | config.setAppointmentDefaultVisitTime(""); |
| | | config.setRegisterReceiverConfirmEnabled(true); |
| | | config.setSignReceiverConfirmEnabled(true); |
| | | config.setFaceCompareEnabled(true); |
| | |
| | | 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()); |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | |
| | | private Boolean forceUpdate; |
| | | |
| | | private Boolean latest; |
| | | |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | private Date createTime; |
| | | } |
| | |
| | | @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; |
| | | |
| | |
| | | @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") |
| | |
| | | |
| | | <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 >= #{param1} |
| | | and sign_time < #{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' |
| | |
| | | </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"> |
| | |
| | | update sys_dept set del_flag = '2' where dept_id = #{deptId} |
| | | </delete> |
| | | |
| | | </mapper> |
| | | </mapper> |
| | |
| | | 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}, '%') |
| | |
| | | <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" /> |
| | |
| | | select config_id, |
| | | onsite_registration_enabled, |
| | | appointment_registration_enabled, |
| | | appointment_default_visit_time, |
| | | register_receiver_confirm_enabled, |
| | | sign_receiver_confirm_enabled, |
| | | face_compare_enabled, |
| | |
| | | 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, |
| | |
| | | ) values ( |
| | | #{onsiteRegistrationEnabled}, |
| | | #{appointmentRegistrationEnabled}, |
| | | #{appointmentDefaultVisitTime}, |
| | | #{registerReceiverConfirmEnabled}, |
| | | #{signReceiverConfirmEnabled}, |
| | | #{faceCompareEnabled}, |
| | |
| | | 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}, |
| | |
| | | <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> |
| | |
| | | update_time = sysdate() |
| | | </set> |
| | | where machine_id = #{machineId} |
| | | and del_flag = '0' |
| | | and del_flag = '0' |
| | | </update> |
| | | |
| | | <update id="updateEnabled"> |
| | |
| | | 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, |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | 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 }}) |
| | | } |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | <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> |
| | |
| | | background: #fff2df; |
| | | } |
| | | |
| | | &.unactivated { |
| | | color: #708195; |
| | | background: #eef2f6; |
| | | } |
| | | |
| | | &.danger { |
| | | color: var(--visitor-danger); |
| | | background: #fff3f3; |
| | |
| | | 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 { |
| | |
| | | .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) { |
| | |
| | | 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; |
| | |
| | | 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 { |
| | |
| | | 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; |
| | | } |
| | | |
| | |
| | | border-bottom: 1px solid #edf2f7; |
| | | color: #10213b; |
| | | font-size: 14px; |
| | | line-height: 20px; |
| | | font-weight: 800; |
| | | |
| | | input { |
| | |
| | | |
| | | .row-label { |
| | | min-width: 140px; |
| | | display: inline-flex; |
| | | align-items: center; |
| | | align-self: stretch; |
| | | } |
| | | } |
| | | |
| | |
| | | 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 { |
| | |
| | | .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 { |
| | |
| | | border-radius: 6px; |
| | | color: #253855; |
| | | font-size: 13px; |
| | | cursor: pointer; |
| | | cursor: grab; |
| | | user-select: none; |
| | | |
| | | input { |
| | |
| | | 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 { |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | .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; |
| | |
| | | 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 { |
| | |
| | | |
| | | .protocol-card { |
| | | min-height: calc(100vh - 118px); |
| | | position: relative; |
| | | padding: 18px 26px 82px; |
| | | border-radius: 6px; |
| | | background: #fff; |
| | |
| | | &.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; |
| | | } |
| | |
| | | |
| | | .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 { |
| | |
| | | } |
| | | |
| | | .protocol-actions { |
| | | position: fixed; |
| | | left: 252px; |
| | | bottom: 28px; |
| | | position: absolute; |
| | | left: 26px; |
| | | bottom: 24px; |
| | | } |
| | | |
| | | .protocol-save-button { |
| | |
| | | min-width: 680px; |
| | | } |
| | | |
| | | .protocol-actions { |
| | | left: 238px; |
| | | } |
| | | } |
| | | |
| | | .version-page { |
| | |
| | | .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 { |
| | |
| | | |
| | | > span:first-child { |
| | | padding-top: 8px; |
| | | text-align: right; |
| | | font-size: 12px; |
| | | font-weight: 600; |
| | | text-align: left; |
| | | } |
| | | |
| | | &.required > span:first-child::before { |
| | |
| | | } |
| | | |
| | | .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; |
| | |
| | | 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; |
| | |
| | | input:checked + .switch-control::after { |
| | | transform: translateX(26px); |
| | | } |
| | | |
| | | input:checked + .switch-control { |
| | | background: #22b573; |
| | | } |
| | | } |
| | | |
| | | .area-page, |
| | |
| | | } |
| | | |
| | | 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) { |
| | |
| | | <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> |
| | |
| | | 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> |
| | |
| | | data() { |
| | | return { |
| | | activeDateFilter: '最近7天', |
| | | activeTrendIndex: null, |
| | | activeQuickEntry: '', |
| | | dateRange: [], |
| | | dateFilters: ['今日', '最近7天', '最近30天'], |
| | | dateFilterMap: { |
| | |
| | | } |
| | | ], |
| | | 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'], |
| | |
| | | appointment: this.normalizeSeries(trend.appointment, labels.length), |
| | | vehicle: this.normalizeSeries(trend.vehicle, labels.length) |
| | | } |
| | | this.activeTrendIndex = null |
| | | }, |
| | | // 目的:把趋势序列补齐为指定长度,避免图表绘制时数组缺位。 |
| | | normalizeSeries(series, length) { |
| | |
| | | // 目的:格式化统计数值,空值按 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() { |
| | |
| | | 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' |
| | |
| | | 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, |
| | |
| | | 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() |
| | | }) |
| | | } |
| | |
| | | </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 /> |
| | |
| | | <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', |
| | |
| | | onlineLabel(value) { |
| | | return onlineText(value) |
| | | }, |
| | | // 目的:根据数据库在线状态枚举返回对应的标签样式。 |
| | | onlineClass(value) { |
| | | return onlineStatusClass(value) |
| | | }, |
| | | // 目的:按区域名称关键字过滤区域树节点。 |
| | | filterAreaNode(value, data) { |
| | | return !value || (data.areaName || '').indexOf(value) !== -1 |
| | |
| | | { 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'] } |
| | | ], |
| | |
| | | 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> |
| | |
| | | <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> |
| | | |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | <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> |
| | | |
| | |
| | | <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> |
| | |
| | | const defaultForm = { |
| | | onsiteRegistrationEnabled: true, |
| | | appointmentRegistrationEnabled: true, |
| | | appointmentDefaultVisitTime: '', |
| | | registerReceiverConfirmEnabled: false, |
| | | signReceiverConfirmEnabled: false, |
| | | faceCompareEnabled: false, |
| | |
| | | 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: '访客姓名' }, |
| | |
| | | <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> |
| | |
| | | return { |
| | | saving: false, |
| | | fullscreen: false, |
| | | editorEmpty: true, |
| | | savedRange: null, |
| | | form: { |
| | | protocolType, |
| | | protocolName, |
| | |
| | | this.$nextTick(() => { |
| | | if (this.$refs.editor) { |
| | | this.$refs.editor.innerHTML = this.form.protocolContent |
| | | this.savedRange = null |
| | | this.ensureEditorBlock(false) |
| | | this.updateEditorEmpty() |
| | | } |
| | | }) |
| | | }) |
| | |
| | | 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() |
| | | }, |
| | | // 目的:切换编辑器全屏状态,并在切换后保持编辑焦点。 |
| | |
| | | }, |
| | | // 目的:把编辑器 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() { |
| | |
| | | this.$modal.msgError('请输入入园承诺内容') |
| | | return |
| | | } |
| | | const submittedContent = this.form.protocolContent |
| | | this.saving = true |
| | | saveProtocolConfig({ |
| | | ...this.form, |
| | |
| | | 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 |
| | |
| | | } |
| | | }) |
| | | }) |
| | | 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,供纯文本粘贴时保留换行展示。 |
| | |
| | | <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> |
| | |
| | | <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"> |
| | |
| | | </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', |
| | |
| | | loading: false, |
| | | total: 0, |
| | | open: false, |
| | | detailOpen: false, |
| | | detail: {}, |
| | | packageFile: null, |
| | | packageFileName: '', |
| | | packageDragActive: false, |
| | | versionList: [], |
| | | queryParams: { |
| | | pageNum: 1, |
| | |
| | | this.loading = false |
| | | }) |
| | | }, |
| | | // 目的:兼容历史版本号是否包含 v 前缀,统一按 v+版本号展示。 |
| | | formatVersionNo(versionNo) { |
| | | const value = String(versionNo || '').replace(/^[vV]/, '') |
| | | return value ? `v${value}` : '-' |
| | | }, |
| | | // 目的:打开新增版本弹窗;中台只读模式下禁止本地新增。 |
| | | handleAdd() { |
| | | if (this.isMiddlePlatformReadonly) { |
| | |
| | | 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 = '' |
| | | } |
| | | }, |
| | | // 目的:校验并提交新增版本;中台只读模式下禁止提交。 |
| | |
| | | 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 |
| | | }) |
| | | }, |
| | | // 目的:删除版本更新记录;中台只读模式下禁止本地删除。 |
| | |
| | | this.denyMiddlePlatformWrite() |
| | | return |
| | | } |
| | | if (row.latest) { |
| | | this.$modal.msgWarning('最新版本不能删除') |
| | | return |
| | | } |
| | | this.$modal.confirm('是否确认删除该版本记录?').then(() => delVersion([row.updateId])).then(() => { |
| | | this.$modal.msgSuccess('删除成功') |
| | | this.getList() |
| | |
| | | .version-modal-header { |
| | | height: 52px; |
| | | justify-content: flex-start; |
| | | padding-left: 22px; |
| | | padding-left: 20px; |
| | | } |
| | | |
| | | .version-modal-header h3 { |
| | |
| | | |
| | | .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 { |
| | |
| | | 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; |
| | |
| | | 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 { |
| | |
| | | margin: 10px 0 0; |
| | | color: #8a9ab0; |
| | | font-size: 11px; |
| | | line-height: 16px; |
| | | text-align: center; |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | .switch-line input:checked + .switch-control { |
| | | background: #21c96b; |
| | | background: #19be6b; |
| | | } |
| | | |
| | | .switch-line input:checked + .switch-control::after { |
| | |
| | | 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, |
| | |
| | | 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) { |
| | |
| | | return; |
| | | } |
| | | saveVersion(); |
| | | }); |
| | | |
| | | versionNoInput.addEventListener("input", function () { |
| | | versionNoInput.value = versionNoInput.value.replace(/[^\d.]/g, "").replace(/\.{2,}/g, "."); |
| | | }); |
| | | |
| | | document.querySelectorAll("[data-close='version']").forEach(function (button) { |
| | |
| | | |
| | | 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) { |
| | |
| | | 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, |
| | |
| | | } |
| | | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | <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> |
| | | |
| | |
| | | |
| | | <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> |
| | | |