From e45e74e5ae207f7642be9a88ed99b593de89fb3b Mon Sep 17 00:00:00 2001
From: longz <longzhaoz@qq.com>
Date: 星期三, 24 六月 2026 16:54:46 +0800
Subject: [PATCH] 1.0

---
 yuyueMiniProgram/src/pages/index/index.vue                                              |    2 
 yuyueMiniProgram/src/manifest.json                                                      |    6 
 yuyueMiniProgram/src/pages/login/index.vue                                              |    4 
 backend/src/main/java/com/sinata/appointment/controller/MiniVisitorController.java      |   10 
 frontend-h5/src/auto-imports.d.ts                                                       |    2 
 yuyueMiniProgram/src/pages/reviewDetail/index.vue                                       |    8 
 frontend-h5/src/api/visitor.ts                                                          |    8 
 backend/src/main/java/com/sinata/appointment/dto/SensitiveWordImportResultVo.java       |   26 ++
 backend/src/main/java/com/sinata/appointment/controller/ConfigController.java           |   26 ++
 yuyueMiniProgram/src/pages/appointment/index.vue                                        |    2 
 frontend-h5/src/pages/commonVisitors/index.vue                                          |  148 +++++++++--
 backend/src/main/resources/db/migration/V41__sensitive_word_import_permission.sql       |   16 +
 yuyueMiniProgram/src/api/core/request.ts                                                |    8 
 backend/src/main/java/com/sinata/appointment/service/MiniVisitorApplicationService.java |   26 ++
 yuyueMiniProgram/manifest.config.ts                                                     |    6 
 yuyueMiniProgram/src/pages.json                                                         |    4 
 frontend-admin/src/views/SensitiveWordsView.vue                                         |   75 +++++
 yuyueMiniProgram/pages.config.ts                                                        |    2 
 /dev/null                                                                               |    0 
 yuyueMiniProgram/src/auto-imports.d.ts                                                  |    2 
 backend/src/main/resources/application.yml                                              |   12 
 frontend-h5/src/api/core/request.ts                                                     |    8 
 backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java      |   62 +++-
 yuyueMiniProgram/src/utils/mini-login.ts                                                |    4 
 frontend-h5/src/pages/index/index.vue                                                   |    2 
 backend/src/main/java/com/sinata/appointment/service/ConfigApplicationService.java      |  105 ++++++++
 backend/src/test/java/com/sinata/appointment/service/IntegrationWechatServiceTest.java  |   20 
 yuyueMiniProgram/src/pages/commonVisitors/index.vue                                     |  148 +++++++++--
 backend/src/main/java/com/sinata/appointment/service/LocalImageUploadService.java       |    6 
 frontend-h5/src/pages/reviewDetail/index.vue                                            |    8 
 yuyueMiniProgram/src/api/visitor.ts                                                     |    8 
 31 files changed, 638 insertions(+), 126 deletions(-)

diff --git a/backend/src/main/java/com/sinata/appointment/controller/ConfigController.java b/backend/src/main/java/com/sinata/appointment/controller/ConfigController.java
index 80f3b23..30f7b8b 100644
--- a/backend/src/main/java/com/sinata/appointment/controller/ConfigController.java
+++ b/backend/src/main/java/com/sinata/appointment/controller/ConfigController.java
@@ -14,6 +14,7 @@
 import com.sinata.appointment.dto.FieldConfigPatchRequest;
 import com.sinata.appointment.dto.SensitiveWordCreateRequest;
 import com.sinata.appointment.dto.SensitiveWordEnabledPatchRequest;
+import com.sinata.appointment.dto.SensitiveWordImportResultVo;
 import com.sinata.appointment.dto.SensitiveWordListVo;
 import com.sinata.appointment.dto.SensitiveWordUpdateRequest;
 import com.sinata.appointment.dto.VisitReasonCreateRequest;
@@ -31,6 +32,9 @@
 import jakarta.validation.constraints.NotBlank;
 import java.util.List;
 import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PatchMapping;
@@ -41,6 +45,7 @@
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
 
 @Tag(name = "系统配置")
 @RestController
@@ -144,6 +149,27 @@
         return ResultUtil.success();
     }
 
+    @Operation(summary = "敏感词库-导入")
+    @PostMapping("/sensitive-words/manage/import")
+    @SaCheckPermission("config:sensitive-words:import")
+    public Result<SensitiveWordImportResultVo> importSensitiveWords(@RequestParam("file") MultipartFile file) {
+        return ResultUtil.data(configApplicationService.importSensitiveWords(file));
+    }
+
+    @Operation(summary = "敏感词库-导入模板")
+    @GetMapping("/sensitive-words/manage/import-template")
+    @SaCheckPermission("config:sensitive-words:import")
+    public ResponseEntity<byte[]> sensitiveWordImportTemplate() {
+        return ResponseEntity.ok()
+                .contentType(
+                        MediaType.parseMediaType(
+                                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+                .header(
+                        HttpHeaders.CONTENT_DISPOSITION,
+                        "attachment; filename=\"sensitive-word-import-template.xlsx\"")
+                .body(configApplicationService.sensitiveWordImportTemplateExcel());
+    }
+
     @GetMapping("/fields")
     @SaCheckPermission("config:booking-settings:page")
     public Result<List<BizFieldConfig>> listFields() {
diff --git a/backend/src/main/java/com/sinata/appointment/controller/MiniVisitorController.java b/backend/src/main/java/com/sinata/appointment/controller/MiniVisitorController.java
index cb40450..98469d6 100644
--- a/backend/src/main/java/com/sinata/appointment/controller/MiniVisitorController.java
+++ b/backend/src/main/java/com/sinata/appointment/controller/MiniVisitorController.java
@@ -13,6 +13,7 @@
 import jakarta.validation.constraints.NotBlank;
 import java.util.List;
 import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PatchMapping;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -70,4 +71,13 @@
         miniVisitorApplicationService.setDefault(id, uuid);
         return ResultUtil.success();
     }
+
+    @Operation(summary = "删除访客")
+    @DeleteMapping("/{id}")
+    public Result<Void> delete(
+            @PathVariable Long id,
+            @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+        miniVisitorApplicationService.delete(id, uuid);
+        return ResultUtil.success();
+    }
 }
diff --git a/backend/src/main/java/com/sinata/appointment/dto/SensitiveWordImportResultVo.java b/backend/src/main/java/com/sinata/appointment/dto/SensitiveWordImportResultVo.java
new file mode 100644
index 0000000..e75a0c0
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/SensitiveWordImportResultVo.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "敏感词导入结果")
+public class SensitiveWordImportResultVo {
+
+    @Schema(description = "读取到的数据行数")
+    private int totalRows;
+
+    @Schema(description = "成功导入数量")
+    private int successCount;
+
+    @Schema(description = "重复跳过数量")
+    private int duplicateCount;
+
+    @Schema(description = "无效跳过数量")
+    private int invalidCount;
+
+    @Schema(description = "错误提示,最多返回前20条")
+    private List<String> errorMessages = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/ConfigApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/ConfigApplicationService.java
index c151b00..9dc8214 100644
--- a/backend/src/main/java/com/sinata/appointment/service/ConfigApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/ConfigApplicationService.java
@@ -3,6 +3,7 @@
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.alibaba.excel.EasyExcel;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.sinata.appointment.constants.BizErrorCode;
@@ -17,6 +18,7 @@
 import com.sinata.appointment.dto.FieldConfigPatchRequest;
 import com.sinata.appointment.dto.SensitiveWordCreateRequest;
 import com.sinata.appointment.dto.SensitiveWordEnabledPatchRequest;
+import com.sinata.appointment.dto.SensitiveWordImportResultVo;
 import com.sinata.appointment.dto.SensitiveWordListVo;
 import com.sinata.appointment.dto.SensitiveWordUpdateRequest;
 import com.sinata.appointment.dto.VisitReasonCreateRequest;
@@ -38,12 +40,17 @@
 import com.sinata.appointment.mapper.SysBizSettingMapper;
 import com.sinata.appointment.mapper.SysDeptMapper;
 import com.sinata.appointment.util.UuidAssert;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.time.LocalDate;
 import java.time.YearMonth;
 import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
 import java.util.Comparator;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
 import java.util.stream.Collectors;
@@ -53,6 +60,7 @@
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
 
 @Service
 @RequiredArgsConstructor
@@ -180,6 +188,74 @@
         loadActiveSensitiveWord(id, uuid);
         bizSensitiveWordMapper.deleteById(id);
         sensitiveWordFilterService.evictCache();
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public SensitiveWordImportResultVo importSensitiveWords(MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "导入文件不能为空");
+        }
+        String filename = file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase();
+        if (!filename.endsWith(".xlsx") && !filename.endsWith(".xls")) {
+            throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "请上传 xls 或 xlsx 文件");
+        }
+
+        List<Map<Integer, String>> rows;
+        try {
+            rows = EasyExcel.read(file.getInputStream()).sheet().headRowNumber(0).doReadSync();
+        } catch (IOException ex) {
+            throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "读取导入文件失败");
+        }
+
+        SensitiveWordImportResultVo result = new SensitiveWordImportResultVo();
+        Set<String> fileWords = new LinkedHashSet<>();
+        Set<String> existingWords = loadExistingSensitiveWords();
+        int rowNo = 0;
+        for (Map<Integer, String> row : rows) {
+            rowNo++;
+            String raw = row == null ? null : row.get(0);
+            if (rowNo == 1 && isSensitiveWordHeader(raw)) {
+                continue;
+            }
+            if (!StringUtils.hasText(raw)) {
+                continue;
+            }
+            result.setTotalRows(result.getTotalRows() + 1);
+            String word;
+            try {
+                word = normalizeSensitiveWord(raw);
+            } catch (BizException ex) {
+                result.setInvalidCount(result.getInvalidCount() + 1);
+                addImportError(result, "第" + rowNo + "行:" + ex.getMessage());
+                continue;
+            }
+            if (fileWords.contains(word) || existingWords.contains(word)) {
+                result.setDuplicateCount(result.getDuplicateCount() + 1);
+                continue;
+            }
+            BizSensitiveWord entity = new BizSensitiveWord();
+            entity.setWord(word);
+            entity.setEnabled(1);
+            bizSensitiveWordMapper.insert(entity);
+            fileWords.add(word);
+            existingWords.add(word);
+            result.setSuccessCount(result.getSuccessCount() + 1);
+        }
+        if (result.getSuccessCount() > 0) {
+            sensitiveWordFilterService.evictCache();
+        }
+        return result;
+    }
+
+    public byte[] sensitiveWordImportTemplateExcel() {
+        try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+            List<List<String>> head = List.of(List.of("敏感词"));
+            List<List<String>> rows = List.of(List.of("示例敏感词"));
+            EasyExcel.write(bos).head(head).sheet("敏感词导入").doWrite(rows);
+            return bos.toByteArray();
+        } catch (IOException ex) {
+            throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "生成导入模板失败");
+        }
     }
 
     public List<BizFieldConfig> listFieldConfigs() {
@@ -572,6 +648,35 @@
         return normalized;
     }
 
+    private Set<String> loadExistingSensitiveWords() {
+        List<BizSensitiveWord> rows =
+                bizSensitiveWordMapper.selectList(
+                        new LambdaQueryWrapper<BizSensitiveWord>()
+                                .eq(BizSensitiveWord::getDelFlag, 0)
+                                .select(BizSensitiveWord::getWord));
+        Set<String> words = new HashSet<>();
+        for (BizSensitiveWord row : rows) {
+            if (StringUtils.hasText(row.getWord())) {
+                words.add(row.getWord().trim());
+            }
+        }
+        return words;
+    }
+
+    private boolean isSensitiveWordHeader(String value) {
+        if (!StringUtils.hasText(value)) {
+            return false;
+        }
+        String text = value.trim();
+        return "敏感词".equals(text) || "敏感词库".equals(text) || "word".equalsIgnoreCase(text);
+    }
+
+    private void addImportError(SensitiveWordImportResultVo result, String message) {
+        if (result.getErrorMessages().size() < 20) {
+            result.getErrorMessages().add(message);
+        }
+    }
+
     private void assertReasonNameUnique(String reasonName, Long excludeId) {
         LambdaQueryWrapper<BizVisitReason> q =
                 new LambdaQueryWrapper<BizVisitReason>()
diff --git a/backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java b/backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java
index 778babf..940cdec 100644
--- a/backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java
@@ -6,11 +6,13 @@
 import com.sinata.appointment.constants.ApprovalNodeCode;
 import com.sinata.appointment.constants.ApprovalTaskStatus;
 import com.sinata.appointment.entity.ApprovalTask;
+import com.sinata.appointment.entity.AppointmentVisitor;
 import com.sinata.appointment.entity.AppointmentOrder;
 import com.sinata.appointment.entity.BizVisitReason;
 import com.sinata.appointment.entity.MpUser;
 import com.sinata.appointment.integration.WechatMiniGateway;
 import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.mapper.AppointmentVisitorMapper;
 import com.sinata.appointment.mapper.BizVisitReasonMapper;
 import com.sinata.appointment.mapper.MpUserMapper;
 import java.time.LocalDate;
@@ -32,12 +34,12 @@
 public class IntegrationWechatService {
 
     private static final String APPROVAL_RESULT_TEMPLATE_ID =
-            "4FQovCBNFM7rth42AWHTusHajO788eT9VpplSN4ZG2s";
+            "rIJpfN51_q818RTpJAqTe5ssTwWCHnxDM5rpq64uuKM";
     private static final String NOTICE_RESULT_TEMPLATE_ID =
-            "GC1S3T2qHCbz8ow130u7elRczuqW6Z8q54mKRCYE-xo";
+            "Wip0ODJgybZpeFpcvev_0GK1wQXEqZw-LHOjS-UyDLw";
     private static final String APPROVAL_RESULT_PAGE = "pages/record/index";
     private static final String APPROVAL_TODO_TEMPLATE_ID =
-            "BX49OBHvRcoYiCw0ZLqx04BiWQ9KaEQwuVth_Lh_M2A";
+            "QlNwG99WXWztRDgtlLcE0fHxfY2VwaX8PRMrwGpEync";
     private static final String APPROVAL_TODO_PAGE = "pages/reviewList/index";
     private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
     private static final DateTimeFormatter TEMPLATE_TIME_FORMATTER =
@@ -46,6 +48,7 @@
     private final WechatMiniGateway wechatMiniGateway;
     private final MpUserMapper mpUserMapper;
     private final ApprovalTaskMapper approvalTaskMapper;
+    private final AppointmentVisitorMapper appointmentVisitorMapper;
     private final BizVisitReasonMapper bizVisitReasonMapper;
 
     @Async
@@ -70,10 +73,9 @@
                 return;
             }
             Map<String, String> templateData = new LinkedHashMap<>();
-            templateData.put("thing1", approved ? "审核通过" : "审核未通过");
-            templateData.put("thing2", abbreviate(resolveSubmitterName(submitter), 20));
-            templateData.put("time3", buildVisitTemplateTimeValue(order));
-            templateData.put("thing4", abbreviate(buildVisitReasonText(order), 20));
+            templateData.put("phrase4", approved ? "通过" : "未通过");
+            templateData.put("time2", buildVisitTemplateTimeValue(order));
+            templateData.put("thing5", approved ? "预约审核通过" : "预约审核未通过");
             wechatMiniGateway.sendSubscribeMessage(
                     submitter.getOpenId(), APPROVAL_RESULT_TEMPLATE_ID, APPROVAL_RESULT_PAGE, templateData);
             log.info(
@@ -97,9 +99,10 @@
                 return;
             }
             Map<String, String> templateData = new LinkedHashMap<>();
-            templateData.put("thing6", buildVisitorCountText(visitorCount));
-            templateData.put("time18", buildVisitTemplateTimeValue(order));
-            templateData.put("thing20", abbreviate(buildNoticeBusinessTypeText(order, approved), 20));
+            MpUser submitter = loadSubmitter(order);
+            templateData.put("name1", abbreviate(resolveSubmitterName(submitter), 10));
+            templateData.put("number36", buildVisitorCountNumber(visitorCount));
+            templateData.put("date3", buildVisitTemplateTimeValue(order));
             int successCount = 0;
             int skippedCount = 0;
             int failedCount = 0;
@@ -184,8 +187,11 @@
                     continue;
                 }
                 Map<String, String> templateData = new LinkedHashMap<>();
-                templateData.put("thing1", abbreviate(buildTodoTaskName(order), 20));
-                templateData.put("thing3", abbreviate(String.join("、", entry.getValue()), 20));
+                MpUser submitter = loadSubmitter(order);
+                templateData.put("name1", abbreviate(resolveSubmitterName(submitter), 10));
+                templateData.put("number15", buildVisitorCountNumber(countVisitors(order)));
+                templateData.put("time16", buildVisitTemplateTimeValue(order));
+                templateData.put("thing5", abbreviate(buildVisitReasonText(order), 20));
                 try {
                     wechatMiniGateway.sendSubscribeMessage(
                             user.getOpenId(), APPROVAL_TODO_TEMPLATE_ID, APPROVAL_TODO_PAGE, templateData);
@@ -210,6 +216,9 @@
     }
 
     private String resolveSubmitterName(MpUser submitter) {
+        if (submitter == null) {
+            return "预约人";
+        }
         if (StringUtils.hasText(submitter.getNickName())) {
             return submitter.getNickName().trim();
         }
@@ -217,6 +226,17 @@
             return submitter.getMobile().trim();
         }
         return "预约人";
+    }
+
+    private MpUser loadSubmitter(AppointmentOrder order) {
+        if (order == null || order.getSubmitterId() == null) {
+            return null;
+        }
+        return mpUserMapper.selectOne(
+                new LambdaQueryWrapper<MpUser>()
+                        .eq(MpUser::getDelFlag, 0)
+                        .eq(MpUser::getId, order.getSubmitterId())
+                        .last("limit 1"));
     }
 
     private String buildVisitTimeText(AppointmentOrder order) {
@@ -276,16 +296,20 @@
         return reasonCode;
     }
 
-    private String buildVisitorCountText(int visitorCount) {
-        return Math.max(visitorCount, 0) + "人";
+    private String buildVisitorCountNumber(int visitorCount) {
+        return String.valueOf(Math.max(visitorCount, 0));
     }
 
-    private String buildNoticeBusinessTypeText(AppointmentOrder order, boolean approved) {
-        String reason = buildVisitReasonText(order);
-        if (!StringUtils.hasText(reason)) {
-            return approved ? "预约通过" : "预约驳回";
+    private int countVisitors(AppointmentOrder order) {
+        if (order == null || order.getId() == null) {
+            return 0;
         }
-        return reason;
+        Long count =
+                appointmentVisitorMapper.selectCount(
+                        new LambdaQueryWrapper<AppointmentVisitor>()
+                                .eq(AppointmentVisitor::getDelFlag, 0)
+                                .eq(AppointmentVisitor::getOrderId, order.getId()));
+        return count == null ? 0 : count.intValue();
     }
 
     private String buildTodoTaskName(AppointmentOrder order) {
diff --git a/backend/src/main/java/com/sinata/appointment/service/LocalImageUploadService.java b/backend/src/main/java/com/sinata/appointment/service/LocalImageUploadService.java
index 791279c..1c713e2 100644
--- a/backend/src/main/java/com/sinata/appointment/service/LocalImageUploadService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/LocalImageUploadService.java
@@ -9,11 +9,13 @@
 import java.nio.file.StandardCopyOption;
 import java.util.Set;
 import java.util.UUID;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
 import org.springframework.web.multipart.MultipartFile;
 
 @Service
+@Slf4j
 public class LocalImageUploadService {
 
     private static final long IMAGE_MAX_SIZE = 10L * 1024 * 1024;
@@ -45,7 +47,9 @@
         } catch (IOException ex) {
             throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "图片上传失败");
         }
-        return buildPictureUrl(fileName);
+        String url = buildPictureUrl(fileName);
+        log.info("local image uploaded. path={} url={}", target, url);
+        return url;
     }
 
     private String buildPictureUrl(String fileName) {
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniVisitorApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniVisitorApplicationService.java
index 244cef7..2f1e864 100644
--- a/backend/src/main/java/com/sinata/appointment/service/MiniVisitorApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniVisitorApplicationService.java
@@ -103,6 +103,17 @@
         mpVisitorMapper.updateById(row);
     }
 
+    @Transactional(rollbackFor = Exception.class)
+    public void delete(Long id, String uuid) {
+        MpUser current = miniAuthApplicationService.currentMpUser();
+        MpVisitor row = loadOwnedVisitor(id, uuid, current.getId());
+        boolean wasDefault = isDefault(row);
+        mpVisitorMapper.deleteById(row.getId());
+        if (wasDefault) {
+            resetEarliestVisitorAsDefault(current.getId());
+        }
+    }
+
     public MiniVisitorDetailVo detail(Long id, String uuid) {
         MpUser current = miniAuthApplicationService.currentMpUser();
         MpVisitor row = loadOwnedVisitor(id, uuid, current.getId());
@@ -165,6 +176,21 @@
                 > 0;
     }
 
+    private void resetEarliestVisitorAsDefault(Long mpUserId) {
+        MpVisitor next =
+                mpVisitorMapper.selectOne(
+                        new LambdaQueryWrapper<MpVisitor>()
+                                .eq(MpVisitor::getMpUserId, mpUserId)
+                                .eq(MpVisitor::getDelFlag, 0)
+                                .orderByAsc(MpVisitor::getCreateTime)
+                                .orderByAsc(MpVisitor::getId)
+                                .last("limit 1"));
+        if (next != null) {
+            next.setIsDefault(1);
+            mpVisitorMapper.updateById(next);
+        }
+    }
+
     private List<String> resolveMissingFieldCodes(MpVisitor row, Set<String> requiredCodes) {
         List<String> missing = new ArrayList<>();
         for (String code : requiredCodes) {
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index a9ab71d..00afbbd 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -67,8 +67,8 @@
 # 微信小程序(真实登录时设 stub: false 并配置 app-id / app-secret)
 wechat:
   miniapp:
-    app-id: ${WECHAT_MINI_APPID:wxe91f1af7638aa5dd}
-    app-secret: ${WECHAT_MINI_SECRET:a787e1a462715604e0c9528b6d8960d1}
+    app-id: ${WECHAT_MINI_APPID:wxd710abda41dee55d}
+    app-secret: ${WECHAT_MINI_SECRET:191a7eccc4f6c8d027ce47bd0c25da8f}
     stub: ${WECHAT_MINI_STUB:false}
   official-account:
     app-id: ${WECHAT_OFFICIAL_APPID:wxd312008b67bf7292}
@@ -92,9 +92,9 @@
   hikvision:
     qrcode:
       encrypt-enabled: ${HIKVISION_QRCODE_ENCRYPT_ENABLED:false}
-      aes-key: ${HIKVISION_QRCODE_AES_KEY:}
-      aes-iv: ${HIKVISION_QRCODE_AES_IV:}
+      aes-key: ${HIKVISION_QRCODE_AES_KEY:SinataHikvisionQrcodeKey01}
+      aes-iv: ${HIKVISION_QRCODE_AES_IV:SinataHikvisionQrcodeIv001}
       valid-minutes: ${HIKVISION_QRCODE_VALID_MINUTES:0}
 
-file-upload-path: /opt/web/uploads
-picture-server-address: https://ytx.hongyu.com/resources/
+file-upload-path: /opt/web/uploads/
+picture-server-address: https://yts.hongyu.com/resources/
diff --git a/backend/src/main/resources/db/migration/V41__sensitive_word_import_permission.sql b/backend/src/main/resources/db/migration/V41__sensitive_word_import_permission.sql
new file mode 100644
index 0000000..bc6e5aa
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V41__sensitive_word_import_permission.sql
@@ -0,0 +1,16 @@
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '导入', 'config:sensitive-words:import', 50, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/system/sensitive-words'
+  AND p.del_flag = 0
+  AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'config:sensitive-words:import' AND del_flag = 0);
+
+INSERT INTO sys_role_menu (uuid, version, role_id, menu_id, del_flag)
+SELECT LOWER(MD5(CONCAT(r.id, ':', m.id, ':sensitive-word-import'))), 1, r.id, m.id, 0
+FROM sys_role r
+JOIN sys_menu m ON m.del_flag = 0
+LEFT JOIN sys_role_menu rm ON rm.role_id = r.id AND rm.menu_id = m.id AND rm.del_flag = 0
+WHERE r.role_code = 'ADMIN'
+  AND r.del_flag = 0
+  AND m.perm_code = 'config:sensitive-words:import'
+  AND rm.id IS NULL;
diff --git a/backend/src/test/java/com/sinata/appointment/service/IntegrationWechatServiceTest.java b/backend/src/test/java/com/sinata/appointment/service/IntegrationWechatServiceTest.java
index 7ae2c25..1db591d 100644
--- a/backend/src/test/java/com/sinata/appointment/service/IntegrationWechatServiceTest.java
+++ b/backend/src/test/java/com/sinata/appointment/service/IntegrationWechatServiceTest.java
@@ -9,10 +9,10 @@
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.sinata.appointment.entity.AppointmentOrder;
-import com.sinata.appointment.entity.BizVisitReason;
 import com.sinata.appointment.entity.MpUser;
 import com.sinata.appointment.integration.WechatMiniGateway;
 import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.mapper.AppointmentVisitorMapper;
 import com.sinata.appointment.mapper.BizVisitReasonMapper;
 import com.sinata.appointment.mapper.MpUserMapper;
 import java.time.LocalDate;
@@ -37,6 +37,9 @@
     private ApprovalTaskMapper approvalTaskMapper;
 
     @Mock
+    private AppointmentVisitorMapper appointmentVisitorMapper;
+
+    @Mock
     private BizVisitReasonMapper bizVisitReasonMapper;
 
     private IntegrationWechatService integrationWechatService;
@@ -48,6 +51,7 @@
                         wechatMiniGateway,
                         mpUserMapper,
                         approvalTaskMapper,
+                        appointmentVisitorMapper,
                         bizVisitReasonMapper);
     }
 
@@ -66,12 +70,7 @@
         submitter.setOpenId("openid-123");
         submitter.setNickName("张三");
 
-        BizVisitReason reason = new BizVisitReason();
-        reason.setReasonCode("BUSINESS");
-        reason.setReasonName("商务洽谈");
-
         when(mpUserMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(submitter);
-        when(bizVisitReasonMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(reason);
 
         integrationWechatService.notifyApprovalFinalResult(order, true);
 
@@ -80,15 +79,14 @@
         verify(wechatMiniGateway)
                 .sendSubscribeMessage(
                         eq("openid-123"),
-                        eq("4FQovCBNFM7rth42AWHTusHajO788eT9VpplSN4ZG2s"),
+                        eq("rIJpfN51_q818RTpJAqTe5ssTwWCHnxDM5rpq64uuKM"),
                         eq("pages/record/index"),
                         dataCaptor.capture());
 
         Map<String, String> payload = dataCaptor.getValue();
-        assertEquals("审核通过", payload.get("thing1"));
-        assertEquals("张三", payload.get("thing2"));
-        assertEquals("2026-04-24 09:00", payload.get("time3"));
-        assertEquals("商务洽谈", payload.get("thing4"));
+        assertEquals("通过", payload.get("phrase4"));
+        assertEquals("2026-04-24 09:00", payload.get("time2"));
+        assertEquals("预约审核通过", payload.get("thing5"));
     }
 
     @Test
diff --git a/frontend-admin/src/views/SensitiveWordsView.vue b/frontend-admin/src/views/SensitiveWordsView.vue
index d3860a5..215f03b 100644
--- a/frontend-admin/src/views/SensitiveWordsView.vue
+++ b/frontend-admin/src/views/SensitiveWordsView.vue
@@ -2,7 +2,20 @@
   <div class="page">
     <div class="header">
       <h3 class="section-title">敏感词库</h3>
-      <el-button v-permission="'config:sensitive-words:create'" type="primary" @click="openCreate">新增</el-button>
+      <div class="header-actions">
+        <el-button v-permission="'config:sensitive-words:import'" type="primary" plain @click="downloadTemplate">
+          下载模板
+        </el-button>
+        <el-upload
+          v-permission="'config:sensitive-words:import'"
+          :show-file-list="false"
+          accept=".xls,.xlsx"
+          :http-request="importSensitiveWords"
+        >
+          <el-button type="primary" :loading="importing">导入</el-button>
+        </el-upload>
+        <el-button v-permission="'config:sensitive-words:create'" type="primary" @click="openCreate">新增</el-button>
+      </div>
     </div>
 
     <el-form class="filters" :inline="true" :model="filters" @submit.prevent>
@@ -89,6 +102,7 @@
 <script setup lang="ts">
 import { onMounted, reactive, ref } from "vue";
 import { ElMessage, ElMessageBox } from "element-plus";
+import type { UploadRequestOptions } from "element-plus";
 import http from "../api/http";
 
 type Row = {
@@ -101,6 +115,7 @@
 
 const loading = ref(false);
 const saving = ref(false);
+const importing = ref(false);
 const rows = ref<Row[]>([]);
 const page = ref(1);
 const size = ref(10);
@@ -213,6 +228,59 @@
   }
 }
 
+async function importSensitiveWords(opt: UploadRequestOptions) {
+  const file = opt.file as File;
+  if (!file) return;
+  if (!/\.(xls|xlsx)$/i.test(file.name)) {
+    ElMessage.warning("请上传 xls 或 xlsx 文件");
+    return;
+  }
+  const fd = new FormData();
+  fd.append("file", file);
+  importing.value = true;
+  try {
+    const { data } = await http.post("/config/sensitive-words/manage/import", fd, {
+      headers: { "Content-Type": "multipart/form-data" },
+    });
+    const result = data.result || {};
+    const success = Number(result.successCount) || 0;
+    const duplicate = Number(result.duplicateCount) || 0;
+    const invalid = Number(result.invalidCount) || 0;
+    const errors = Array.isArray(result.errorMessages) ? result.errorMessages : [];
+    const message = `导入完成:成功${success}条,重复跳过${duplicate}条,无效${invalid}条`;
+    if (errors.length) {
+      ElMessageBox.alert(`${message}\n${errors.join("\n")}`, "导入结果", {
+        confirmButtonText: "知道了",
+      });
+    } else {
+      ElMessage.success(message);
+    }
+    page.value = 1;
+    load();
+  } catch (e: unknown) {
+    ElMessage.error(e instanceof Error ? e.message : "导入失败");
+  } finally {
+    importing.value = false;
+  }
+}
+
+async function downloadTemplate() {
+  try {
+    const res = await http.get("/config/sensitive-words/manage/import-template", { responseType: "blob" });
+    const blob = new Blob([res.data], {
+      type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+    });
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = "sensitive-word-import-template.xlsx";
+    a.click();
+    window.URL.revokeObjectURL(url);
+  } catch (e: unknown) {
+    ElMessage.error(e instanceof Error ? e.message : "下载模板失败");
+  }
+}
+
 function toggleEnabled(row: Row) {
   const nextEnabled = row.enabled === 1 ? 0 : 1;
   const actionText = nextEnabled === 1 ? "启用" : "禁用";
@@ -269,6 +337,11 @@
   margin-bottom: 12px;
   flex: none;
 }
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
 .section-title {
   font-size: 16px;
   font-weight: 600;
diff --git a/frontend-h5/src/api/core/request.ts b/frontend-h5/src/api/core/request.ts
index 8556c59..e99ed05 100644
--- a/frontend-h5/src/api/core/request.ts
+++ b/frontend-h5/src/api/core/request.ts
@@ -67,3 +67,11 @@
   const response = await alovaInstance.Patch<ApiResult<T>>(url, data, config).send()
   return unwrapApiResult(response, '请求失败')
 }
+
+export async function deleteResult<T>(
+  url: string,
+  config?: Parameters<typeof alovaInstance.Delete>[2],
+): Promise<T> {
+  const response = await alovaInstance.Delete<ApiResult<T>>(url, undefined, config).send()
+  return unwrapApiResult(response, '请求失败')
+}
diff --git a/frontend-h5/src/api/visitor.ts b/frontend-h5/src/api/visitor.ts
index 3137b8a..c3529cb 100644
--- a/frontend-h5/src/api/visitor.ts
+++ b/frontend-h5/src/api/visitor.ts
@@ -1,4 +1,4 @@
-import { getResult, patchResult, postResult, putResult } from './core/request'
+import { deleteResult, getResult, patchResult, postResult, putResult } from './core/request'
 import type {
   MiniVisitorDetailItem,
   MiniVisitorListItem,
@@ -30,3 +30,9 @@
     params: { uuid },
   })
 }
+
+export function deleteVisitor(id: number, uuid: string) {
+  return deleteResult<void>(`/api/mini/visitors/${id}`, {
+    params: { uuid },
+  })
+}
diff --git a/frontend-h5/src/auto-imports.d.ts b/frontend-h5/src/auto-imports.d.ts
index b16c0c3..9b6700f 100644
--- a/frontend-h5/src/auto-imports.d.ts
+++ b/frontend-h5/src/auto-imports.d.ts
@@ -52,6 +52,7 @@
   const defineComponent: typeof import('vue')['defineComponent']
   const defineStore: typeof import('pinia')['defineStore']
   const deleteMyAppointment: typeof import('./api/appointment')['deleteMyAppointment']
+  const deleteVisitor: typeof import('./api/visitor')['deleteVisitor']
   const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
   const effectScope: typeof import('vue')['effectScope']
   const extendRef: typeof import('@vueuse/core')['extendRef']
@@ -466,6 +467,7 @@
     readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
     readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
     readonly deleteMyAppointment: UnwrapRef<typeof import('./api/appointment')['deleteMyAppointment']>
+    readonly deleteVisitor: UnwrapRef<typeof import('./api/visitor')['deleteVisitor']>
     readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
     readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
     readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
diff --git a/frontend-h5/src/pages/commonVisitors/index.vue b/frontend-h5/src/pages/commonVisitors/index.vue
index 0f01bf9..ce8c5f1 100644
--- a/frontend-h5/src/pages/commonVisitors/index.vue
+++ b/frontend-h5/src/pages/commonVisitors/index.vue
@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { fetchMiniFieldConfigs } from '@/api/lookup'
-import { fetchMyVisitors, setDefaultVisitor } from '@/api/visitor'
+import { deleteVisitor, fetchMyVisitors, setDefaultVisitor } from '@/api/visitor'
 import { FIELD_CODES, formatVisitorFieldLabels } from '@/constants/fieldCodes'
 import type { MiniFieldConfigItem } from '@/types/lookup'
 import type { MiniVisitorListItem } from '@/types/visitor'
@@ -29,7 +29,13 @@
 const visitors = ref<MiniVisitorListItem[]>([])
 const loading = ref(false)
 const settingDefaultId = ref<number | null>(null)
+const deletingId = ref<number | null>(null)
+const openSwipeId = ref<number | null>(null)
 const fieldConfigs = ref<MiniFieldConfigItem[]>([])
+
+let touchStartX = 0
+let touchStartY = 0
+let touchingId: number | null = null
 
 const showName = computed(() => isVisible(FIELD_CODES.VISITOR_NAME))
 const showGender = computed(() => isVisible(FIELD_CODES.GENDER))
@@ -50,6 +56,7 @@
 }
 
 function handleEdit(item: MiniVisitorListItem) {
+  openSwipeId.value = null
   uni.navigateTo({
     url: `/pages/addPerson/index?id=${item.id}&uuid=${item.uuid}`,
   })
@@ -67,6 +74,7 @@
   if (item.isDefault || settingDefaultId.value) {
     return
   }
+  openSwipeId.value = null
   settingDefaultId.value = item.id
   try {
     await setDefaultVisitor(item.id, item.uuid)
@@ -81,6 +89,73 @@
   }
   finally {
     settingDefaultId.value = null
+  }
+}
+
+function handleTouchStart(item: MiniVisitorListItem, event: any) {
+  const touch = event.touches?.[0]
+  if (!touch) {
+    return
+  }
+  touchingId = item.id
+  touchStartX = touch.clientX
+  touchStartY = touch.clientY
+}
+
+function handleTouchEnd(item: MiniVisitorListItem, event: any) {
+  if (touchingId !== item.id) {
+    return
+  }
+  const touch = event.changedTouches?.[0]
+  touchingId = null
+  if (!touch) {
+    return
+  }
+  const deltaX = touch.clientX - touchStartX
+  const deltaY = touch.clientY - touchStartY
+  if (Math.abs(deltaX) <= Math.abs(deltaY) + 10) {
+    return
+  }
+  if (deltaX < -40) {
+    openSwipeId.value = item.id
+  } else if (deltaX > 20) {
+    openSwipeId.value = null
+  }
+}
+
+function confirmDelete(item: MiniVisitorListItem) {
+  if (deletingId.value) {
+    return
+  }
+  uni.showModal({
+    title: '删除确认',
+    content: `确认删除来访人「${displayName(item)}」?`,
+    confirmText: '删除',
+    confirmColor: '#FF4D4F',
+    success: (res) => {
+      if (res.confirm) {
+        void handleDelete(item)
+      }
+    },
+  })
+}
+
+async function handleDelete(item: MiniVisitorListItem) {
+  deletingId.value = item.id
+  try {
+    await deleteVisitor(item.id, item.uuid)
+    openSwipeId.value = null
+    uni.showToast({ title: '已删除', icon: 'none' })
+    await loadVisitors()
+  }
+  catch (error) {
+    uni.showToast({
+      title: error instanceof Error ? error.message : '删除来访人失败',
+      icon: 'none',
+    })
+  }
+  finally {
+    deletingId.value = null
   }
 }
 
@@ -140,42 +215,53 @@
     <view v-else class="relative z-10 px-[24rpx] pb-[200rpx] mt-[5vh]">
       <view class="w-full flex flex-col gap-[16rpx]">
         <view
-          v-for="(item, index) in visitors"
+          v-for="item in visitors"
           :key="item.id"
-          class="relative flex items-center justify-between px-[24rpx] pt-[24rpx] pb-[64rpx] bg-white rounded-[8rpx] shadow-[0_4rpx_8rpx_rgba(6,47,108,0.1608)]"
+          class="relative overflow-hidden rounded-[8rpx] bg-[#FF4D4F]"
         >
-          <view class="flex items-center w-[160rpx] shrink-0">
-            <text class="text-[28rpx] text-[#333333] font-medium truncate">{{ displayName(item) }}</text>
-          </view>
-
-          <view class="flex flex-col justify-center flex-1 mx-[20rpx] min-w-0">
-            <view v-if="showGender || showIdCard" class="flex items-center">
-              <image :src="item.gender === '女' ? appointmentIcons.female : appointmentIcons.male" class="w-[32rpx] h-[32rpx] shrink-0" />
-              <text class="ml-[20rpx] text-[28rpx] text-[#B3B3B3] truncate">
-                {{ showIdCard ? (item.idCardMasked || '-') : '-' }}
-              </text>
-            </view>
-            <view v-if="showCompany" class="flex items-center mt-[8rpx]">
-              <image :src="appointmentIcons.company" class="w-[32rpx] h-[32rpx] shrink-0" />
-              <text class="ml-[20rpx] text-[28rpx] text-[#B3B3B3] truncate">{{ item.visitorCompany || '-' }}</text>
-            </view>
-            <text v-if="item.missingFieldCodes?.length" class="mt-[8rpx] text-[22rpx] text-[#F5222D] truncate">
-              缺失:{{ formatMissingFields(item.missingFieldCodes) }}
-            </text>
-          </view>
-
-          <view class="w-[40rpx] h-[40rpx] shrink-0 flex items-center justify-center" @click.stop="handleEdit(item)">
-            <image :src="`${choosePersonAssetBase}/edit.png`" class="w-[33rpx] h-[33rpx]" />
+          <view class="absolute right-0 top-0 bottom-0 w-[132rpx] bg-[#FF4D4F] flex items-center justify-center z-0" @click.stop="confirmDelete(item)">
+            <text class="text-[28rpx] text-white">{{ deletingId === item.id ? '删除中' : '删除' }}</text>
           </view>
 
           <view
-            class="absolute right-[24rpx] bottom-[18rpx] h-[36rpx] px-[18rpx] rounded-full flex items-center justify-center"
-            :class="item.isDefault ? 'bg-[#E6F0FF]' : 'bg-[#F5F7FA]'"
-            @click.stop="handleSetDefault(item)"
+            class="relative z-10 flex items-center justify-between px-[24rpx] pt-[24rpx] pb-[64rpx] bg-white rounded-[8rpx] shadow-[0_4rpx_8rpx_rgba(6,47,108,0.1608)] transition-transform duration-200"
+            :style="{ transform: openSwipeId === item.id ? 'translateX(-132rpx)' : 'translateX(0)' }"
+            @touchstart="handleTouchStart(item, $event)"
+            @touchend="handleTouchEnd(item, $event)"
           >
-            <text class="text-[22rpx]" :class="item.isDefault ? 'text-[#0068FF]' : 'text-[#999999]'">
-              {{ item.isDefault ? '默认来访人' : (settingDefaultId === item.id ? '设置中...' : '设为默认') }}
-            </text>
+            <view class="flex items-center w-[160rpx] shrink-0">
+              <text class="text-[28rpx] text-[#333333] font-medium truncate">{{ displayName(item) }}</text>
+            </view>
+
+            <view class="flex flex-col justify-center flex-1 mx-[20rpx] min-w-0">
+              <view v-if="showGender || showIdCard" class="flex items-center">
+                <image :src="item.gender === '女' ? appointmentIcons.female : appointmentIcons.male" class="w-[32rpx] h-[32rpx] shrink-0" />
+                <text class="ml-[20rpx] text-[28rpx] text-[#B3B3B3] truncate">
+                  {{ showIdCard ? (item.idCardMasked || '-') : '-' }}
+                </text>
+              </view>
+              <view v-if="showCompany" class="flex items-center mt-[8rpx]">
+                <image :src="appointmentIcons.company" class="w-[32rpx] h-[32rpx] shrink-0" />
+                <text class="ml-[20rpx] text-[28rpx] text-[#B3B3B3] truncate">{{ item.visitorCompany || '-' }}</text>
+              </view>
+              <text v-if="item.missingFieldCodes?.length" class="mt-[8rpx] text-[22rpx] text-[#F5222D] truncate">
+                缺失:{{ formatMissingFields(item.missingFieldCodes) }}
+              </text>
+            </view>
+
+            <view class="w-[40rpx] h-[40rpx] shrink-0 flex items-center justify-center" @click.stop="handleEdit(item)">
+              <image :src="`${choosePersonAssetBase}/edit.png`" class="w-[33rpx] h-[33rpx]" />
+            </view>
+
+            <view
+              class="absolute right-[24rpx] bottom-[18rpx] h-[36rpx] px-[18rpx] rounded-full flex items-center justify-center"
+              :class="item.isDefault ? 'bg-[#E6F0FF]' : 'bg-[#F5F7FA]'"
+              @click.stop="handleSetDefault(item)"
+            >
+              <text class="text-[22rpx]" :class="item.isDefault ? 'text-[#0068FF]' : 'text-[#999999]'">
+                {{ item.isDefault ? '默认来访人' : (settingDefaultId === item.id ? '设置中...' : '设为默认') }}
+              </text>
+            </view>
           </view>
         </view>
       </view>
diff --git a/frontend-h5/src/pages/index/index.vue b/frontend-h5/src/pages/index/index.vue
index 54496cf..b2b82f6 100644
--- a/frontend-h5/src/pages/index/index.vue
+++ b/frontend-h5/src/pages/index/index.vue
@@ -724,7 +724,7 @@
 
       <view class="mt-[48rpx] flex justify-center pb-[32rpx]">
         <text class="text-[24rpx] text-[#999999]">
-          备案号:渝ICP备16005911号-2
+          备案号:湘ICP备2026025199号-1X
         </text>
       </view>
     </view>
diff --git a/frontend-h5/src/pages/reviewDetail/index.vue b/frontend-h5/src/pages/reviewDetail/index.vue
index 7108f36..2bfa685 100644
--- a/frontend-h5/src/pages/reviewDetail/index.vue
+++ b/frontend-h5/src/pages/reviewDetail/index.vue
@@ -1328,13 +1328,13 @@
           </view>
         </view>
 
-        <view class="px-[32rpx] pb-[24rpx] flex-1 min-h-0">
+        <view class="px-[32rpx] pb-[16rpx] shrink-0">
           <view class="flex items-center justify-between mb-[16rpx]">
             <text class="text-[#333333] text-[28rpx] font-medium">已选区域</text>
             <text class="text-[#999999] text-[24rpx]">共{{ selectedAreaIds.length }}个</text>
           </view>
-          <scroll-view scroll-y class="h-full">
-            <view v-if="selectedAreaIds.length > 0" class="flex flex-wrap gap-[16rpx] pb-[16rpx]">
+          <scroll-view scroll-y class="h-[150rpx]">
+            <view v-if="selectedAreaIds.length > 0" class="flex flex-wrap gap-[14rpx] pb-[16rpx]">
               <view v-for="areaId in selectedAreaIds" :key="areaId" class="flex items-center justify-center px-[24rpx] h-[56rpx] rounded-[28rpx] border-[2rpx] border-solid border-[#E5E5E5] box-border">
                 <text class="text-[#666666] text-[24rpx]">{{ resolveAreaDisplayById(areaId) }}</text>
                 <view class="flex items-center justify-center w-[24rpx] h-[24rpx] ml-[8rpx] active:opacity-80" @click.stop="removeSelectedArea(areaId)">
@@ -1349,7 +1349,7 @@
         </view>
 
         <!-- 底部确认按钮 -->
-        <view class="px-[32rpx] pb-[68rpx] shrink-0">
+        <view class="px-[32rpx] pt-[12rpx] pb-[68rpx] bg-white shrink-0">
           <view class="w-full h-[88rpx] rounded-[44rpx] bg-[#0068FF] text-white text-[32rpx] font-medium flex items-center justify-center shadow-[0_8rpx_24rpx_rgba(0,104,255,0.2)] active:opacity-80 transition-opacity" @click="confirmRegionPicker">确认</view>
         </view>
       </view>
diff --git a/yuyueMiniProgram/manifest.config.ts b/yuyueMiniProgram/manifest.config.ts
index 8f70b67..4edfbd4 100644
--- a/yuyueMiniProgram/manifest.config.ts
+++ b/yuyueMiniProgram/manifest.config.ts
@@ -10,8 +10,8 @@
 import { defineManifestConfig } from '@uni-helper/vite-plugin-uni-manifest'
 
 export default defineManifestConfig({
-  'name': 'wot-starter',
-  'appid': 'wxe91f1af7638aa5dd',
+  'name': 'reservation',
+  'appid': 'wxd710abda41dee55d',
   'description': '',
   'versionName': '1.0.0',
   'versionCode': '100',
@@ -64,7 +64,7 @@
     optimization: {
       subPackages: true,
     },
-    appid: '',
+    appid: 'wxd710abda41dee55d',
     setting: {
       urlCheck: false,
     },
diff --git a/yuyueMiniProgram/pages.config.ts b/yuyueMiniProgram/pages.config.ts
index 7509bd3..f73bb08 100644
--- a/yuyueMiniProgram/pages.config.ts
+++ b/yuyueMiniProgram/pages.config.ts
@@ -15,7 +15,7 @@
     // 导航栏配置
     navigationBarBackgroundColor: '@navBgColor',
     navigationBarTextStyle: '@navTxtStyle',
-    navigationBarTitleText: 'Wot Starter',
+    navigationBarTitleText: 'reservation',
 
     // 页面背景配置
     backgroundColor: '@bgColor',
diff --git a/yuyueMiniProgram/src/api/core/request.ts b/yuyueMiniProgram/src/api/core/request.ts
index 8556c59..e99ed05 100644
--- a/yuyueMiniProgram/src/api/core/request.ts
+++ b/yuyueMiniProgram/src/api/core/request.ts
@@ -67,3 +67,11 @@
   const response = await alovaInstance.Patch<ApiResult<T>>(url, data, config).send()
   return unwrapApiResult(response, '请求失败')
 }
+
+export async function deleteResult<T>(
+  url: string,
+  config?: Parameters<typeof alovaInstance.Delete>[2],
+): Promise<T> {
+  const response = await alovaInstance.Delete<ApiResult<T>>(url, undefined, config).send()
+  return unwrapApiResult(response, '请求失败')
+}
diff --git a/yuyueMiniProgram/src/api/visitor.ts b/yuyueMiniProgram/src/api/visitor.ts
index 3137b8a..c3529cb 100644
--- a/yuyueMiniProgram/src/api/visitor.ts
+++ b/yuyueMiniProgram/src/api/visitor.ts
@@ -1,4 +1,4 @@
-import { getResult, patchResult, postResult, putResult } from './core/request'
+import { deleteResult, getResult, patchResult, postResult, putResult } from './core/request'
 import type {
   MiniVisitorDetailItem,
   MiniVisitorListItem,
@@ -30,3 +30,9 @@
     params: { uuid },
   })
 }
+
+export function deleteVisitor(id: number, uuid: string) {
+  return deleteResult<void>(`/api/mini/visitors/${id}`, {
+    params: { uuid },
+  })
+}
diff --git a/yuyueMiniProgram/src/auto-imports.d.ts b/yuyueMiniProgram/src/auto-imports.d.ts
index b989c14..dd25867 100644
--- a/yuyueMiniProgram/src/auto-imports.d.ts
+++ b/yuyueMiniProgram/src/auto-imports.d.ts
@@ -51,6 +51,7 @@
   const defineComponent: typeof import('vue')['defineComponent']
   const defineStore: typeof import('pinia')['defineStore']
   const deleteMyAppointment: typeof import('./api/appointment')['deleteMyAppointment']
+  const deleteVisitor: typeof import('./api/visitor')['deleteVisitor']
   const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
   const effectScope: typeof import('vue')['effectScope']
   const extendRef: typeof import('@vueuse/core')['extendRef']
@@ -460,6 +461,7 @@
     readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
     readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
     readonly deleteMyAppointment: UnwrapRef<typeof import('./api/appointment')['deleteMyAppointment']>
+    readonly deleteVisitor: UnwrapRef<typeof import('./api/visitor')['deleteVisitor']>
     readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
     readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
     readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
diff --git a/yuyueMiniProgram/src/manifest.json b/yuyueMiniProgram/src/manifest.json
index 395c898..b937b2f 100644
--- a/yuyueMiniProgram/src/manifest.json
+++ b/yuyueMiniProgram/src/manifest.json
@@ -1,6 +1,6 @@
 {
-  "name": "wot-starter",
-  "appid": "wxe91f1af7638aa5dd",
+  "name": "reservation",
+  "appid": "wxd710abda41dee55d",
   "description": "",
   "versionName": "1.0.0",
   "versionCode": "100",
@@ -48,7 +48,7 @@
   },
   "quickapp": {},
   "mp-weixin": {
-    "appid": "",
+    "appid": "wxd710abda41dee55d",
     "setting": {
       "urlCheck": false
     },
diff --git a/yuyueMiniProgram/src/pages.json b/yuyueMiniProgram/src/pages.json
index 05179ab..cd60926 100644
--- a/yuyueMiniProgram/src/pages.json
+++ b/yuyueMiniProgram/src/pages.json
@@ -2,7 +2,7 @@
   "globalStyle": {
     "navigationBarBackgroundColor": "@navBgColor",
     "navigationBarTextStyle": "@navTxtStyle",
-    "navigationBarTitleText": "Wot Starter",
+    "navigationBarTitleText": "reservation",
     "backgroundColor": "@bgColor",
     "backgroundTextStyle": "@bgTxtStyle",
     "backgroundColorTop": "@bgColorTop",
@@ -201,4 +201,4 @@
       }
     ]
   }
-}
\ No newline at end of file
+}
diff --git a/yuyueMiniProgram/src/pages/appointment/index.vue b/yuyueMiniProgram/src/pages/appointment/index.vue
index 6462aa4..1eb116c 100644
--- a/yuyueMiniProgram/src/pages/appointment/index.vue
+++ b/yuyueMiniProgram/src/pages/appointment/index.vue
@@ -30,7 +30,7 @@
 const defaultPlateTag = '新能源'
 const VISIT_TIME_MODE_SINGLE = 'SINGLE'
 const VISIT_TIME_MODE_MULTI = 'MULTI'
-const APPROVAL_RESULT_SUBSCRIBE_TEMPLATE_ID = '4FQovCBNFM7rth42AWHTusHajO788eT9VpplSN4ZG2s'
+const APPROVAL_RESULT_SUBSCRIBE_TEMPLATE_ID = 'rIJpfN51_q818RTpJAqTe5ssTwWCHnxDM5rpq64uuKM'
 const TIME_SLOT_LABEL_TO_CODE = {
   '全天00:00-23:59': 'ALL_DAY',
   '上午00:00-11:59': 'AM',
diff --git a/yuyueMiniProgram/src/pages/commonVisitors/index.vue b/yuyueMiniProgram/src/pages/commonVisitors/index.vue
index 0f01bf9..ce8c5f1 100644
--- a/yuyueMiniProgram/src/pages/commonVisitors/index.vue
+++ b/yuyueMiniProgram/src/pages/commonVisitors/index.vue
@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { fetchMiniFieldConfigs } from '@/api/lookup'
-import { fetchMyVisitors, setDefaultVisitor } from '@/api/visitor'
+import { deleteVisitor, fetchMyVisitors, setDefaultVisitor } from '@/api/visitor'
 import { FIELD_CODES, formatVisitorFieldLabels } from '@/constants/fieldCodes'
 import type { MiniFieldConfigItem } from '@/types/lookup'
 import type { MiniVisitorListItem } from '@/types/visitor'
@@ -29,7 +29,13 @@
 const visitors = ref<MiniVisitorListItem[]>([])
 const loading = ref(false)
 const settingDefaultId = ref<number | null>(null)
+const deletingId = ref<number | null>(null)
+const openSwipeId = ref<number | null>(null)
 const fieldConfigs = ref<MiniFieldConfigItem[]>([])
+
+let touchStartX = 0
+let touchStartY = 0
+let touchingId: number | null = null
 
 const showName = computed(() => isVisible(FIELD_CODES.VISITOR_NAME))
 const showGender = computed(() => isVisible(FIELD_CODES.GENDER))
@@ -50,6 +56,7 @@
 }
 
 function handleEdit(item: MiniVisitorListItem) {
+  openSwipeId.value = null
   uni.navigateTo({
     url: `/pages/addPerson/index?id=${item.id}&uuid=${item.uuid}`,
   })
@@ -67,6 +74,7 @@
   if (item.isDefault || settingDefaultId.value) {
     return
   }
+  openSwipeId.value = null
   settingDefaultId.value = item.id
   try {
     await setDefaultVisitor(item.id, item.uuid)
@@ -81,6 +89,73 @@
   }
   finally {
     settingDefaultId.value = null
+  }
+}
+
+function handleTouchStart(item: MiniVisitorListItem, event: any) {
+  const touch = event.touches?.[0]
+  if (!touch) {
+    return
+  }
+  touchingId = item.id
+  touchStartX = touch.clientX
+  touchStartY = touch.clientY
+}
+
+function handleTouchEnd(item: MiniVisitorListItem, event: any) {
+  if (touchingId !== item.id) {
+    return
+  }
+  const touch = event.changedTouches?.[0]
+  touchingId = null
+  if (!touch) {
+    return
+  }
+  const deltaX = touch.clientX - touchStartX
+  const deltaY = touch.clientY - touchStartY
+  if (Math.abs(deltaX) <= Math.abs(deltaY) + 10) {
+    return
+  }
+  if (deltaX < -40) {
+    openSwipeId.value = item.id
+  } else if (deltaX > 20) {
+    openSwipeId.value = null
+  }
+}
+
+function confirmDelete(item: MiniVisitorListItem) {
+  if (deletingId.value) {
+    return
+  }
+  uni.showModal({
+    title: '删除确认',
+    content: `确认删除来访人「${displayName(item)}」?`,
+    confirmText: '删除',
+    confirmColor: '#FF4D4F',
+    success: (res) => {
+      if (res.confirm) {
+        void handleDelete(item)
+      }
+    },
+  })
+}
+
+async function handleDelete(item: MiniVisitorListItem) {
+  deletingId.value = item.id
+  try {
+    await deleteVisitor(item.id, item.uuid)
+    openSwipeId.value = null
+    uni.showToast({ title: '已删除', icon: 'none' })
+    await loadVisitors()
+  }
+  catch (error) {
+    uni.showToast({
+      title: error instanceof Error ? error.message : '删除来访人失败',
+      icon: 'none',
+    })
+  }
+  finally {
+    deletingId.value = null
   }
 }
 
@@ -140,42 +215,53 @@
     <view v-else class="relative z-10 px-[24rpx] pb-[200rpx] mt-[5vh]">
       <view class="w-full flex flex-col gap-[16rpx]">
         <view
-          v-for="(item, index) in visitors"
+          v-for="item in visitors"
           :key="item.id"
-          class="relative flex items-center justify-between px-[24rpx] pt-[24rpx] pb-[64rpx] bg-white rounded-[8rpx] shadow-[0_4rpx_8rpx_rgba(6,47,108,0.1608)]"
+          class="relative overflow-hidden rounded-[8rpx] bg-[#FF4D4F]"
         >
-          <view class="flex items-center w-[160rpx] shrink-0">
-            <text class="text-[28rpx] text-[#333333] font-medium truncate">{{ displayName(item) }}</text>
-          </view>
-
-          <view class="flex flex-col justify-center flex-1 mx-[20rpx] min-w-0">
-            <view v-if="showGender || showIdCard" class="flex items-center">
-              <image :src="item.gender === '女' ? appointmentIcons.female : appointmentIcons.male" class="w-[32rpx] h-[32rpx] shrink-0" />
-              <text class="ml-[20rpx] text-[28rpx] text-[#B3B3B3] truncate">
-                {{ showIdCard ? (item.idCardMasked || '-') : '-' }}
-              </text>
-            </view>
-            <view v-if="showCompany" class="flex items-center mt-[8rpx]">
-              <image :src="appointmentIcons.company" class="w-[32rpx] h-[32rpx] shrink-0" />
-              <text class="ml-[20rpx] text-[28rpx] text-[#B3B3B3] truncate">{{ item.visitorCompany || '-' }}</text>
-            </view>
-            <text v-if="item.missingFieldCodes?.length" class="mt-[8rpx] text-[22rpx] text-[#F5222D] truncate">
-              缺失:{{ formatMissingFields(item.missingFieldCodes) }}
-            </text>
-          </view>
-
-          <view class="w-[40rpx] h-[40rpx] shrink-0 flex items-center justify-center" @click.stop="handleEdit(item)">
-            <image :src="`${choosePersonAssetBase}/edit.png`" class="w-[33rpx] h-[33rpx]" />
+          <view class="absolute right-0 top-0 bottom-0 w-[132rpx] bg-[#FF4D4F] flex items-center justify-center z-0" @click.stop="confirmDelete(item)">
+            <text class="text-[28rpx] text-white">{{ deletingId === item.id ? '删除中' : '删除' }}</text>
           </view>
 
           <view
-            class="absolute right-[24rpx] bottom-[18rpx] h-[36rpx] px-[18rpx] rounded-full flex items-center justify-center"
-            :class="item.isDefault ? 'bg-[#E6F0FF]' : 'bg-[#F5F7FA]'"
-            @click.stop="handleSetDefault(item)"
+            class="relative z-10 flex items-center justify-between px-[24rpx] pt-[24rpx] pb-[64rpx] bg-white rounded-[8rpx] shadow-[0_4rpx_8rpx_rgba(6,47,108,0.1608)] transition-transform duration-200"
+            :style="{ transform: openSwipeId === item.id ? 'translateX(-132rpx)' : 'translateX(0)' }"
+            @touchstart="handleTouchStart(item, $event)"
+            @touchend="handleTouchEnd(item, $event)"
           >
-            <text class="text-[22rpx]" :class="item.isDefault ? 'text-[#0068FF]' : 'text-[#999999]'">
-              {{ item.isDefault ? '默认来访人' : (settingDefaultId === item.id ? '设置中...' : '设为默认') }}
-            </text>
+            <view class="flex items-center w-[160rpx] shrink-0">
+              <text class="text-[28rpx] text-[#333333] font-medium truncate">{{ displayName(item) }}</text>
+            </view>
+
+            <view class="flex flex-col justify-center flex-1 mx-[20rpx] min-w-0">
+              <view v-if="showGender || showIdCard" class="flex items-center">
+                <image :src="item.gender === '女' ? appointmentIcons.female : appointmentIcons.male" class="w-[32rpx] h-[32rpx] shrink-0" />
+                <text class="ml-[20rpx] text-[28rpx] text-[#B3B3B3] truncate">
+                  {{ showIdCard ? (item.idCardMasked || '-') : '-' }}
+                </text>
+              </view>
+              <view v-if="showCompany" class="flex items-center mt-[8rpx]">
+                <image :src="appointmentIcons.company" class="w-[32rpx] h-[32rpx] shrink-0" />
+                <text class="ml-[20rpx] text-[28rpx] text-[#B3B3B3] truncate">{{ item.visitorCompany || '-' }}</text>
+              </view>
+              <text v-if="item.missingFieldCodes?.length" class="mt-[8rpx] text-[22rpx] text-[#F5222D] truncate">
+                缺失:{{ formatMissingFields(item.missingFieldCodes) }}
+              </text>
+            </view>
+
+            <view class="w-[40rpx] h-[40rpx] shrink-0 flex items-center justify-center" @click.stop="handleEdit(item)">
+              <image :src="`${choosePersonAssetBase}/edit.png`" class="w-[33rpx] h-[33rpx]" />
+            </view>
+
+            <view
+              class="absolute right-[24rpx] bottom-[18rpx] h-[36rpx] px-[18rpx] rounded-full flex items-center justify-center"
+              :class="item.isDefault ? 'bg-[#E6F0FF]' : 'bg-[#F5F7FA]'"
+              @click.stop="handleSetDefault(item)"
+            >
+              <text class="text-[22rpx]" :class="item.isDefault ? 'text-[#0068FF]' : 'text-[#999999]'">
+                {{ item.isDefault ? '默认来访人' : (settingDefaultId === item.id ? '设置中...' : '设为默认') }}
+              </text>
+            </view>
           </view>
         </view>
       </view>
diff --git a/yuyueMiniProgram/src/pages/index/index.vue b/yuyueMiniProgram/src/pages/index/index.vue
index 9b35857..caa2272 100644
--- a/yuyueMiniProgram/src/pages/index/index.vue
+++ b/yuyueMiniProgram/src/pages/index/index.vue
@@ -723,7 +723,7 @@
 
       <view class="mt-[48rpx] flex justify-center pb-[32rpx]">
         <text class="text-[24rpx] text-[#999999]">
-          备案号:渝ICP备16005911号-2
+          备案号:湘ICP备2026025199号-1X
         </text>
       </view>
     </view>
diff --git a/yuyueMiniProgram/src/pages/login/index.vue b/yuyueMiniProgram/src/pages/login/index.vue
index 09f5e51..99248cb 100644
--- a/yuyueMiniProgram/src/pages/login/index.vue
+++ b/yuyueMiniProgram/src/pages/login/index.vue
@@ -19,8 +19,8 @@
 const submitting = ref(false)
 const needPhoneAuthorize = ref(false)
 const EMPLOYEE_SUBSCRIBE_TEMPLATE_IDS = [
-  'BX49OBHvRcoYiCw0ZLqx04BiWQ9KaEQwuVth_Lh_M2A',
-  'GC1S3T2qHCbz8ow130u7elRczuqW6Z8q54mKRCYE-xo',
+  'QlNwG99WXWztRDgtlLcE0fHxfY2VwaX8PRMrwGpEync',
+  'Wip0ODJgybZpeFpcvev_0GK1wQXEqZw-LHOjS-UyDLw',
 ]
 const MINI_PHONE_AUTH_REQUIRED = 10008
 
diff --git a/yuyueMiniProgram/src/pages/reviewDetail/index.vue b/yuyueMiniProgram/src/pages/reviewDetail/index.vue
index 7108f36..2bfa685 100644
--- a/yuyueMiniProgram/src/pages/reviewDetail/index.vue
+++ b/yuyueMiniProgram/src/pages/reviewDetail/index.vue
@@ -1328,13 +1328,13 @@
           </view>
         </view>
 
-        <view class="px-[32rpx] pb-[24rpx] flex-1 min-h-0">
+        <view class="px-[32rpx] pb-[16rpx] shrink-0">
           <view class="flex items-center justify-between mb-[16rpx]">
             <text class="text-[#333333] text-[28rpx] font-medium">已选区域</text>
             <text class="text-[#999999] text-[24rpx]">共{{ selectedAreaIds.length }}个</text>
           </view>
-          <scroll-view scroll-y class="h-full">
-            <view v-if="selectedAreaIds.length > 0" class="flex flex-wrap gap-[16rpx] pb-[16rpx]">
+          <scroll-view scroll-y class="h-[150rpx]">
+            <view v-if="selectedAreaIds.length > 0" class="flex flex-wrap gap-[14rpx] pb-[16rpx]">
               <view v-for="areaId in selectedAreaIds" :key="areaId" class="flex items-center justify-center px-[24rpx] h-[56rpx] rounded-[28rpx] border-[2rpx] border-solid border-[#E5E5E5] box-border">
                 <text class="text-[#666666] text-[24rpx]">{{ resolveAreaDisplayById(areaId) }}</text>
                 <view class="flex items-center justify-center w-[24rpx] h-[24rpx] ml-[8rpx] active:opacity-80" @click.stop="removeSelectedArea(areaId)">
@@ -1349,7 +1349,7 @@
         </view>
 
         <!-- 底部确认按钮 -->
-        <view class="px-[32rpx] pb-[68rpx] shrink-0">
+        <view class="px-[32rpx] pt-[12rpx] pb-[68rpx] bg-white shrink-0">
           <view class="w-full h-[88rpx] rounded-[44rpx] bg-[#0068FF] text-white text-[32rpx] font-medium flex items-center justify-center shadow-[0_8rpx_24rpx_rgba(0,104,255,0.2)] active:opacity-80 transition-opacity" @click="confirmRegionPicker">确认</view>
         </view>
       </view>
diff --git a/yuyueMiniProgram/src/utils/mini-login.ts b/yuyueMiniProgram/src/utils/mini-login.ts
index bf01296..0953270 100644
--- a/yuyueMiniProgram/src/utils/mini-login.ts
+++ b/yuyueMiniProgram/src/utils/mini-login.ts
@@ -4,8 +4,8 @@
 import { useMiniAuthStore } from '@/store/auth'
 
 export const EMPLOYEE_SUBSCRIBE_TEMPLATE_IDS = [
-  'BX49OBHvRcoYiCw0ZLqx04BiWQ9KaEQwuVth_Lh_M2A',
-  'GC1S3T2qHCbz8ow130u7elRczuqW6Z8q54mKRCYE-xo',
+  'QlNwG99WXWztRDgtlLcE0fHxfY2VwaX8PRMrwGpEync',
+  'Wip0ODJgybZpeFpcvev_0GK1wQXEqZw-LHOjS-UyDLw',
 ]
 
 export const MINI_PHONE_AUTH_REQUIRED = 10008
diff --git "a/\351\242\204\347\272\246\350\256\277\345\256\242\347\263\273\347\273\237_\346\246\202\350\246\201\350\256\276\350\256\241\350\257\264\346\230\216\344\271\246_\345\217\257\347\233\226\347\253\240\347\211\210.docx" "b/\351\242\204\347\272\246\350\256\277\345\256\242\347\263\273\347\273\237_\346\246\202\350\246\201\350\256\276\350\256\241\350\257\264\346\230\216\344\271\246_\345\217\257\347\233\226\347\253\240\347\211\210.docx"
deleted file mode 100644
index e941f26..0000000
--- "a/\351\242\204\347\272\246\350\256\277\345\256\242\347\263\273\347\273\237_\346\246\202\350\246\201\350\256\276\350\256\241\350\257\264\346\230\216\344\271\246_\345\217\257\347\233\226\347\253\240\347\211\210.docx"
+++ /dev/null
Binary files differ

--
Gitblit v1.7.1