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