1.0
longz
2026-06-24 e45e74e5ae207f7642be9a88ed99b593de89fb3b
1.0
1个文件已删除
2个文件已添加
28个文件已修改
764 ■■■■■ 已修改文件
backend/src/main/java/com/sinata/appointment/controller/ConfigController.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/controller/MiniVisitorController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/dto/SensitiveWordImportResultVo.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/ConfigApplicationService.java 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/LocalImageUploadService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/MiniVisitorApplicationService.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/application.yml 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/db/migration/V41__sensitive_word_import_permission.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/sinata/appointment/service/IntegrationWechatServiceTest.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/SensitiveWordsView.vue 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/api/core/request.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/api/visitor.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/auto-imports.d.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/pages/commonVisitors/index.vue 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/pages/index/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/pages/reviewDetail/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/manifest.config.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/pages.config.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/api/core/request.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/api/visitor.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/auto-imports.d.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/manifest.json 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/pages.json 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/pages/appointment/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/pages/commonVisitors/index.vue 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/pages/index/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/pages/login/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/pages/reviewDetail/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/utils/mini-login.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
预约访客系统_概要设计说明书_可盖章版.docx 补丁 | 查看 | 原始文档 | blame | 历史
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() {
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();
    }
}
backend/src/main/java/com/sinata/appointment/dto/SensitiveWordImportResultVo.java
New file
@@ -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<>();
}
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>()
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) {
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) {
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) {
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/
backend/src/main/resources/db/migration/V41__sensitive_word_import_permission.sql
New file
@@ -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;
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
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;
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, '请求失败')
}
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 },
  })
}
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']>
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>
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>
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>
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,
    },
yuyueMiniProgram/pages.config.ts
@@ -15,7 +15,7 @@
    // 导航栏配置
    navigationBarBackgroundColor: '@navBgColor',
    navigationBarTextStyle: '@navTxtStyle',
    navigationBarTitleText: 'Wot Starter',
    navigationBarTitleText: 'reservation',
    // 页面背景配置
    backgroundColor: '@bgColor',
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, '请求失败')
}
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 },
  })
}
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']>
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
    },
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 @@
      }
    ]
  }
}
}
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',
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>
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>
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
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>
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
预约访客系统_概要设计说明书_可盖章版.docx
Binary files differ