longz
2026-06-29 9d4cbff7464ebfd7803c2ce53ded49d0f73fe011
初步调整fix
5个文件已删除
2个文件已添加
37个文件已修改
1624 ■■■■■ 已修改文件
backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/dto/DeptCreateRequest.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/dto/DeptTreeNodeVo.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/dto/DeptUpdateRequest.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/dto/StaffListVo.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/entity/SysDept.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/ApprovalEngineService.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/DeptManageApplicationService.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/LocalUnitManageApplicationService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/MiniLookupApplicationService.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/PassAreaManageApplicationService.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/StaffManageApplicationService.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/SysRoleManageApplicationService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/db/migration/V42__sys_dept_leader_ids.sql 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/sinata/appointment/service/ApprovalEngineServiceTest.java 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/sinata/appointment/service/ApprovalFlowViewApplicationServiceTest.java 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/sinata/appointment/service/IntegrationWechatServiceTest.java 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/sinata/appointment/service/MiniHandlerApprovalApplicationServiceTest.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/test/java/com/sinata/appointment/service/PostApprovalOrchestrationServiceTest.java 156 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/dist.zip 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/utils/formHelper.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/ApprovalSettingsView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/BookingSettingsView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/ContentSettingsView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/DeptManageView.vue 242 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/PassAreasView.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/RegionLocalUnitsView.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/RoleFormView.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/SensitiveWordsView.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/StaffEmployeesView.vue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/StaffGroupDetailView.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/StaffGroupsView.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/UsersView.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/VisitReasonsSettingsView.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-admin/src/views/VisitorInfoView.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/pages/index/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/pages/mine/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
frontend-h5/src/types/auth.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/pages/appointment/index.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/pages/index/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/pages/mine/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/pages/reviewDetail/index.vue 318 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yuyueMiniProgram/src/types/auth.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java
@@ -112,7 +112,7 @@
        m.put("deptId", u.getDeptId());
        SysDept dept = u.getDeptId() == null ? null : sysDeptMapper.selectById(u.getDeptId());
        m.put("deptName", dept == null ? null : dept.getDeptName());
        m.put("deptLeaderFlag", Boolean.TRUE.equals(u.getDeptLeaderFlag()));
        m.put("deptLeaderFlag", false);
        m.put("nickName", u.getNickName());
        m.put("avatarUrl", u.getAvatarUrl());
        m.put("mobile", u.getMobile());
backend/src/main/java/com/sinata/appointment/dto/DeptCreateRequest.java
@@ -4,6 +4,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import lombok.Data;
@Data
@@ -20,4 +21,7 @@
    @NotNull
    @Schema(description = "1 启用 0 禁用")
    private Integer status;
    @Schema(description = "部门负责人 ids")
    private List<Long> leaderIds;
}
backend/src/main/java/com/sinata/appointment/dto/DeptTreeNodeVo.java
@@ -15,6 +15,8 @@
    private String deptName;
    private Integer sortOrder;
    private Integer status;
    private List<Long> leaderIds = new ArrayList<>();
    private String leaderNames;
    @Schema(description = "从根算起层级,根为 1,最大 5")
    private int level;
backend/src/main/java/com/sinata/appointment/dto/DeptUpdateRequest.java
@@ -4,6 +4,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import lombok.Data;
@Data
@@ -24,4 +25,7 @@
    @Schema(description = "上级部门 id(仅非一级部门有效;不传则保持原上级)")
    private Long parentId;
    @Schema(description = "部门负责人 ids")
    private List<Long> leaderIds;
}
backend/src/main/java/com/sinata/appointment/dto/StaffListVo.java
@@ -16,6 +16,7 @@
    private String mobileMasked;
    private Long deptId;
    private String deptName;
    private String deptPath;
    private String positionLabel;
    private String groupDisplay;
    private Integer status;
backend/src/main/java/com/sinata/appointment/entity/SysDept.java
@@ -20,6 +20,9 @@
    @Schema(description = "排序")
    private Integer sortOrder;
    @Schema(description = "部门负责人 ids,逗号分隔")
    private String leaderIds;
    /** 1 启用 0 禁用 */
    @Schema(description = "部门状态:1启用 0禁用")
    private Integer status;
backend/src/main/java/com/sinata/appointment/service/ApprovalEngineService.java
@@ -17,6 +17,7 @@
import com.sinata.appointment.entity.MpPassLocalUnitMember;
import com.sinata.appointment.entity.MpStaffGroupMember;
import com.sinata.appointment.entity.MpUser;
import com.sinata.appointment.entity.SysDept;
import com.sinata.appointment.exception.BizException;
import com.sinata.appointment.mapper.ApprovalInstanceMapper;
import com.sinata.appointment.mapper.ApprovalTaskMapper;
@@ -25,6 +26,7 @@
import com.sinata.appointment.mapper.MpPassLocalUnitMemberMapper;
import com.sinata.appointment.mapper.MpStaffGroupMemberMapper;
import com.sinata.appointment.mapper.MpUserMapper;
import com.sinata.appointment.mapper.SysDeptMapper;
import com.sinata.appointment.constants.BizErrorCode;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -54,6 +56,7 @@
    private final ApprovalInstanceMapper approvalInstanceMapper;
    private final ApprovalTaskMapper approvalTaskMapper;
    private final MpUserMapper mpUserMapper;
    private final SysDeptMapper sysDeptMapper;
    private final MpPassLocalUnitMapper mpPassLocalUnitMapper;
    private final MpPassLocalUnitMemberMapper mpPassLocalUnitMemberMapper;
    private final MpStaffGroupMemberMapper mpStaffGroupMemberMapper;
@@ -119,7 +122,7 @@
        }
        if (ApprovalNodeCode.LEVEL2_DEPT_LEADER.equals(nodeCode)) {
            Long deptId = resolveVisitedDeptId(order);
            return listEmployeeIdsByDeptAndLeaderFlag(deptId, true);
            return getDeptLeaderIds(deptId);
        }
        if (ApprovalNodeCode.LEVEL4_VISIT_DEPT.equals(nodeCode)) {
            Long competentDeptId = getVisitCompetentDeptId();
@@ -127,7 +130,7 @@
        }
        if (ApprovalNodeCode.LEVEL5_VISIT_DEPT_LEADER.equals(nodeCode)) {
            Long competentDeptId = getVisitCompetentDeptId();
            return listEmployeeIdsByDeptAndLeaderFlag(competentDeptId, true);
            return getDeptLeaderIds(competentDeptId);
        }
        if (ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER.equals(nodeCode)) {
            return List.of();
@@ -162,19 +165,56 @@
        }
    }
    private List<Long> listEmployeeIdsByDeptAndLeaderFlag(Long deptId, boolean leaderFlag) {
    private List<Long> getDeptLeaderIds(Long deptId) {
        if (deptId == null || deptId <= 0) {
            return List.of();
        }
        List<MpUser> users =
        SysDept dept = sysDeptMapper.selectById(deptId);
        if (dept == null || dept.getDelFlag() != null && dept.getDelFlag() == 1) {
            return List.of();
        }
        List<Long> leaderIds = parseDeptLeaderIds(dept);
        if (leaderIds.isEmpty()) {
            return List.of();
        }
        List<MpUser> leaders =
                mpUserMapper.selectList(
                        new LambdaQueryWrapper<MpUser>()
                                .eq(MpUser::getDelFlag, 0)
                                .eq(MpUser::getStatus, 1)
                                .eq(MpUser::getUserType, MiniUserType.EMPLOYEE)
                                .eq(MpUser::getDeptId, deptId)
                                .eq(MpUser::getDeptLeaderFlag, leaderFlag));
        return users.stream().map(MpUser::getId).filter(Objects::nonNull).toList();
                                .in(MpUser::getId, leaderIds));
        Map<Long, MpUser> leaderMap = new LinkedHashMap<>();
        for (MpUser leader : leaders) {
            leaderMap.put(leader.getId(), leader);
        }
        List<Long> result = new ArrayList<>();
        for (Long id : leaderIds) {
            if (leaderMap.containsKey(id)) {
                result.add(id);
            }
        }
        return result;
    }
    private List<Long> parseDeptLeaderIds(SysDept dept) {
        List<Long> ids = new ArrayList<>();
        Set<Long> seen = new LinkedHashSet<>();
        if (StringUtils.hasText(dept.getLeaderIds())) {
            for (String part : dept.getLeaderIds().split(",")) {
                if (!StringUtils.hasText(part)) {
                    continue;
                }
                try {
                    Long id = Long.parseLong(part.trim());
                    if (id > 0 && seen.add(id)) {
                        ids.add(id);
                    }
                } catch (NumberFormatException ignored) {
                }
            }
        }
        return ids;
    }
    private List<Long> listAllActiveEmployeeIdsByDept(Long deptId) {
@@ -192,11 +232,7 @@
    }
    private List<Long> listEmployeeIdsByDeptWithLeaderFallback(Long deptId) {
        List<Long> normalEmployees = listEmployeeIdsByDeptAndLeaderFlag(deptId, false);
        if (normalEmployees != null && !normalEmployees.isEmpty()) {
            return normalEmployees;
        }
        return listEmployeeIdsByDeptAndLeaderFlag(deptId, true);
        return listAllActiveEmployeeIdsByDept(deptId);
    }
    @Transactional(rollbackFor = Exception.class)
backend/src/main/java/com/sinata/appointment/service/DeptManageApplicationService.java
@@ -2,6 +2,7 @@
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sinata.appointment.constants.BizErrorCode;
import com.sinata.appointment.constants.MiniUserType;
import com.sinata.appointment.dto.DeptCreateRequest;
import com.sinata.appointment.dto.DeptTreeNodeVo;
import com.sinata.appointment.dto.DeptUpdateRequest;
@@ -86,6 +87,7 @@
        d.setDeptName(trimName(req.getDeptName()));
        d.setStatus(normalizeStatus(req.getStatus()));
        d.setSortOrder(0);
        applyDeptLeaders(d, req.getLeaderIds());
        sysDeptMapper.insert(d);
        SysDept fresh = sysDeptMapper.selectById(d.getId());
        if (fresh == null || !StringUtils.hasText(fresh.getUuid())) {
@@ -102,6 +104,7 @@
        SysDept d = loadActiveWithUuid(id, req.getUuid());
        d.setDeptName(trimName(req.getDeptName()));
        d.setStatus(normalizeStatus(req.getStatus()));
        applyDeptLeaders(d, req.getLeaderIds());
        Long oldParent = d.getParentId() == null ? 0L : d.getParentId();
        if (oldParent == 0L) {
@@ -183,6 +186,47 @@
            cur = p;
        }
        return false;
    }
    private void applyDeptLeaders(SysDept dept, List<Long> leaderIds) {
        List<Long> normalized = normalizeLeaderIds(leaderIds);
        validateLeaders(normalized);
        dept.setLeaderIds(joinIds(normalized));
    }
    private List<Long> normalizeLeaderIds(List<Long> leaderIds) {
        List<Long> raw = leaderIds == null ? new ArrayList<>() : new ArrayList<>(leaderIds);
        List<Long> out = new ArrayList<>();
        Set<Long> seen = new HashSet<>();
        for (Long id : raw) {
            if (id == null || id <= 0 || !seen.add(id)) {
                continue;
            }
            out.add(id);
        }
        return out;
    }
    private void validateLeaders(List<Long> leaderIds) {
        if (leaderIds == null || leaderIds.isEmpty()) {
            return;
        }
        List<MpUser> users =
                mpUserMapper.selectList(
                        new LambdaQueryWrapper<MpUser>()
                                .eq(MpUser::getDelFlag, 0)
                                .eq(MpUser::getStatus, 1)
                                .eq(MpUser::getUserType, MiniUserType.EMPLOYEE)
                                .in(MpUser::getId, leaderIds));
        Set<Long> validIds = new HashSet<>();
        for (MpUser user : users) {
            validIds.add(user.getId());
        }
        for (Long id : leaderIds) {
            if (!validIds.contains(id)) {
                throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "部门负责人不存在或已禁用: " + id);
            }
        }
    }
    private int subtreeHeight(long id, Map<Long, List<SysDept>> byParent) {
@@ -284,9 +328,67 @@
        v.setDeptName(d.getDeptName());
        v.setSortOrder(d.getSortOrder());
        v.setStatus(d.getStatus() == null ? 1 : d.getStatus());
        List<Long> leaderIds = parseLeaderIds(d);
        v.setLeaderIds(leaderIds);
        v.setLeaderNames(resolveLeaderNames(leaderIds));
        return v;
    }
    private List<Long> parseLeaderIds(SysDept dept) {
        return parseIds(dept.getLeaderIds());
    }
    private List<Long> parseIds(String raw) {
        if (!StringUtils.hasText(raw)) {
            return List.of();
        }
        List<Long> ids = new ArrayList<>();
        Set<Long> seen = new HashSet<>();
        for (String part : raw.split(",")) {
            if (!StringUtils.hasText(part)) {
                continue;
            }
            try {
                Long id = Long.parseLong(part.trim());
                if (id > 0 && seen.add(id)) {
                    ids.add(id);
                }
            } catch (NumberFormatException ignored) {
            }
        }
        return ids;
    }
    private String joinIds(List<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            return null;
        }
        return ids.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining(","));
    }
    private String resolveLeaderNames(List<Long> leaderIds) {
        if (leaderIds == null || leaderIds.isEmpty()) {
            return null;
        }
        List<MpUser> users =
                mpUserMapper.selectList(
                        new LambdaQueryWrapper<MpUser>()
                                .eq(MpUser::getDelFlag, 0)
                                .in(MpUser::getId, leaderIds));
        Map<Long, String> names = new HashMap<>();
        for (MpUser user : users) {
            names.put(user.getId(), user.getNickName());
        }
        List<String> ordered = new ArrayList<>();
        for (Long id : leaderIds) {
            String name = names.get(id);
            if (StringUtils.hasText(name)) {
                ordered.add(name.trim());
            }
        }
        return ordered.isEmpty() ? null : String.join("、", ordered);
    }
    private SysDept loadActive(long id) {
        SysDept d = sysDeptMapper.selectById(id);
        if (d == null || d.getDelFlag() != null && d.getDelFlag() == 1) {
backend/src/main/java/com/sinata/appointment/service/LocalUnitManageApplicationService.java
@@ -93,7 +93,7 @@
        validateApprover(req.getApproverType(), req.getApproverDeptIds(), req.getApproverGroupId(), req.getMemberIds());
        MpPassLocalUnit e = new MpPassLocalUnit();
        e.setUnitName(req.getUnitName().trim());
        e.setRemark(trimToNull(req.getRemark()));
        e.setRemark(trimToEmpty(req.getRemark()));
        e.setApproverType(req.getApproverType());
        applyApproverColumns(e, req.getApproverType(), req.getApproverDeptIds(), req.getApproverGroupId());
        mpPassLocalUnitMapper.insert(e);
@@ -113,7 +113,7 @@
        validateApprover(req.getApproverType(), req.getApproverDeptIds(), req.getApproverGroupId(), req.getMemberIds());
        mpPassLocalUnitMemberMapper.deletePhysicalByUnitId(e.getId());
        e.setUnitName(req.getUnitName().trim());
        e.setRemark(trimToNull(req.getRemark()));
        e.setRemark(trimToEmpty(req.getRemark()));
        e.setApproverType(req.getApproverType());
        applyApproverColumns(e, req.getApproverType(), req.getApproverDeptIds(), req.getApproverGroupId());
        mpPassLocalUnitMapper.updateById(e);
@@ -433,4 +433,8 @@
        String t = s.trim();
        return t.isEmpty() ? null : t;
    }
    private static String trimToEmpty(String s) {
        return s == null ? "" : s.trim();
    }
}
backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
@@ -447,17 +447,13 @@
            SysDept dept = sysDeptMapper.selectById(user.getDeptId());
            deptName = dept == null ? null : dept.getDeptName();
        }
        Boolean leader =
                MiniUserType.EMPLOYEE.equals(user.getUserType())
                        ? Boolean.TRUE.equals(user.getDeptLeaderFlag())
                        : null;
        return MiniLoginResponse.builder()
                .token(MiniStpUtil.getTokenValue())
                .mpUserId(user.getId())
                .userType(user.getUserType())
                .deptId(user.getDeptId())
                .deptName(deptName)
                .deptLeader(leader)
                .deptLeader(MiniUserType.EMPLOYEE.equals(user.getUserType()) ? false : null)
                .avatarUrl(user.getAvatarUrl())
                .companyName(user.getCompanyName())
                .displayName(name)
backend/src/main/java/com/sinata/appointment/service/MiniLookupApplicationService.java
@@ -21,6 +21,7 @@
import com.sinata.appointment.mapper.MpUserMapper;
import com.sinata.appointment.mapper.SysDeptMapper;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -72,6 +73,7 @@
                        new LambdaQueryWrapper<MpPassAccessArea>()
                                .eq(MpPassAccessArea::getDelFlag, 0)
                                .orderByAsc(MpPassAccessArea::getPid)
                                .orderByAsc(MpPassAccessArea::getAreaName)
                                .orderByAsc(MpPassAccessArea::getId));
        if (rows.isEmpty()) {
            return List.of();
@@ -97,9 +99,21 @@
            }
            parent.getChildren().add(current);
        }
        sortAreaTree(roots);
        return roots;
    }
    private void sortAreaTree(List<MiniPassAccessAreaTreeVo> nodes) {
        nodes.sort(
                Comparator.comparing(
                                MiniPassAccessAreaTreeVo::getAreaName,
                                Comparator.nullsLast(String::compareTo))
                        .thenComparing(MiniPassAccessAreaTreeVo::getId));
        for (MiniPassAccessAreaTreeVo node : nodes) {
            sortAreaTree(node.getChildren());
        }
    }
    public List<MiniVisitedUserVo> searchVisitedUsersByExactName(String realName) {
        String name = realName == null ? "" : realName.trim();
        if (name.isEmpty()) {
backend/src/main/java/com/sinata/appointment/service/PassAreaManageApplicationService.java
@@ -39,7 +39,8 @@
                        .eq(MpPassAccessArea::getDelFlag, 0)
                        .eq(MpPassAccessArea::getPid, p)
                        .like(StringUtils.hasText(areaName), MpPassAccessArea::getAreaName, trim(areaName))
                        .orderByDesc(MpPassAccessArea::getCreateTime);
                        .orderByAsc(MpPassAccessArea::getAreaName)
                        .orderByAsc(MpPassAccessArea::getId);
        Page<MpPassAccessArea> pg = mpPassAccessAreaMapper.selectPage(new Page<>(page, size), q);
        return pg.convert(this::toListVo);
    }
@@ -73,8 +74,8 @@
        MpPassAccessArea e = new MpPassAccessArea();
        e.setPid(pid);
        e.setAreaName(name);
        e.setRemark(trimToNull(req.getRemark()));
        e.setAreaDesc(trimToNull(req.getAreaDesc()));
        e.setRemark(trimToEmpty(req.getRemark()));
        e.setAreaDesc(trimToEmpty(req.getAreaDesc()));
        mpPassAccessAreaMapper.insert(e);
        MpPassAccessArea fresh = mpPassAccessAreaMapper.selectById(e.getId());
        if (fresh == null || !StringUtils.hasText(fresh.getUuid())) {
@@ -99,9 +100,9 @@
        e.setPid(targetPid);
        e.setAreaName(name);
        if (req.getRemark() != null) {
            e.setRemark(trimToNull(req.getRemark()));
            e.setRemark(trimToEmpty(req.getRemark()));
        }
        e.setAreaDesc(trimToNull(req.getAreaDesc()));
        e.setAreaDesc(trimToEmpty(req.getAreaDesc()));
        mpPassAccessAreaMapper.updateById(e);
        evictMiniPassAccessAreaTreeCache();
    }
@@ -189,4 +190,8 @@
        String t = s.trim();
        return t.isEmpty() ? null : t;
    }
    private static String trimToEmpty(String s) {
        return s == null ? "" : s.trim();
    }
}
backend/src/main/java/com/sinata/appointment/service/StaffManageApplicationService.java
@@ -132,7 +132,7 @@
        q.like(StringUtils.hasText(staffNo), MpUser::getStaffNo, staffNo != null ? staffNo.trim() : null);
        q.like(StringUtils.hasText(mobile), MpUser::getMobile, mobile != null ? mobile.trim() : null);
        if ("LEADER".equalsIgnoreCase(position)) {
            q.eq(MpUser::getDeptLeaderFlag, true);
            q.apply("1 = 0");
        } else if ("STAFF".equalsIgnoreCase(position)) {
            q.and(
                    w ->
@@ -166,9 +166,12 @@
            }
            userIds.add(u.getId());
        }
        List<SysDept> allDepts = sysDeptMapper.selectList(
                new LambdaQueryWrapper<SysDept>().eq(SysDept::getDelFlag, 0));
        Map<Long, String> deptPaths = buildDeptPathById(allDepts);
        Map<Long, String> deptNames = deptNamesByIds(deptIds);
        Map<Long, String> groupDisplay = groupDisplayByMpUserIds(userIds);
        return p.convert(u -> toListVo(u, deptNames, groupDisplay.getOrDefault(u.getId(), "—")));
        return p.convert(u -> toListVo(u, deptNames, deptPaths, groupDisplay.getOrDefault(u.getId(), "—")));
    }
    private Set<Long> mpUserIdsInStaffGroup(Long staffGroupId) {
@@ -364,7 +367,7 @@
        u.setMobile(mobileRaw);
        u.setAvatarUrl(trimToNull(req.getAvatarUrl()));
        u.setDeptId(req.getDeptId());
        u.setDeptLeaderFlag(Boolean.TRUE.equals(req.getDeptLeader()));
        u.setDeptLeaderFlag(false);
        u.setStatus(1);
        mpUserMapper.insert(u);
        MpUser fresh = mpUserMapper.selectById(u.getId());
@@ -399,7 +402,7 @@
        u.setAvatarUrl(trimToNull(req.getAvatarUrl()));
        u.setMobile(mobileRaw);
        u.setDeptId(req.getDeptId());
        u.setDeptLeaderFlag(Boolean.TRUE.equals(req.getDeptLeader()));
        u.setDeptLeaderFlag(false);
        mpUserMapper.updateById(u);
    }
@@ -610,14 +613,14 @@
        }
        Long deptId = parseDeptId(dept, deptPathToId, deptNameToIds);
        boolean leader = parseDeptLeader(position);
        parseDeptLeader(position);
        StaffCreateRequest req = new StaffCreateRequest();
        req.setNickName(name);
        req.setStaffNo(StringUtils.hasText(staffNo) ? staffNo : null);
        req.setMobile(mobile);
        req.setDeptId(deptId);
        req.setDeptLeader(leader);
        req.setDeptLeader(false);
        Map<String, Object> created = create(req);
        Object idObj = created.get("id");
        Long uid = idObj instanceof Number ? ((Number) idObj).longValue() : null;
@@ -633,7 +636,7 @@
        List<List<String>> rows = new ArrayList<>();
        for (int i = 0; i < max; i++) {
            String dept = i < deptPaths.size() ? deptPaths.get(i) : "";
            String pos = i == 0 ? "部门负责人" : (i == 1 ? "员工" : "");
            String pos = i == 0 ? "员工" : "";
            String grp = i < groupNames.size() ? groupNames.get(i) : "";
            rows.add(java.util.Arrays.asList(dept, pos, grp));
        }
@@ -672,7 +675,7 @@
            }
            Name posRange = wb.createName();
            posRange.setNameName("pos_list");
            posRange.setRefersToFormula("字典!$B$1:$B$2");
            posRange.setRefersToFormula("字典!$B$1:$B$1");
            if (!groupNames.isEmpty()) {
                Name groupRange = wb.createName();
                groupRange.setNameName("group_list");
@@ -782,9 +785,6 @@
            return false;
        }
        String t = position.trim();
        if ("部门负责人".equals(t) || "LEADER".equalsIgnoreCase(t)) {
            return true;
        }
        if ("员工".equals(t) || "STAFF".equalsIgnoreCase(t)) {
            return false;
        }
@@ -800,7 +800,7 @@
        exist.setAvatarUrl(trimToNull(req.getAvatarUrl()));
        exist.setMobile(mobileRaw);
        exist.setDeptId(req.getDeptId());
        exist.setDeptLeaderFlag(Boolean.TRUE.equals(req.getDeptLeader()));
        exist.setDeptLeaderFlag(false);
        exist.setStatus(1);
        mpUserMapper.updateById(exist);
    }
@@ -878,7 +878,7 @@
        return m;
    }
    private StaffListVo toListVo(MpUser u, Map<Long, String> deptNames, String groupDisplay) {
    private StaffListVo toListVo(MpUser u, Map<Long, String> deptNames, Map<Long, String> deptPaths, String groupDisplay) {
        StaffListVo vo = new StaffListVo();
        vo.setId(u.getId());
        vo.setUuid(u.getUuid());
@@ -889,7 +889,9 @@
        vo.setDeptId(u.getDeptId());
        vo.setDeptName(
                u.getDeptId() == null ? "—" : deptNames.getOrDefault(u.getDeptId(), "—"));
        vo.setPositionLabel(Boolean.TRUE.equals(u.getDeptLeaderFlag()) ? "部门负责人" : "员工");
        vo.setDeptPath(
                u.getDeptId() == null ? "—" : deptPaths.getOrDefault(u.getDeptId(), "—"));
        vo.setPositionLabel("员工");
        vo.setGroupDisplay(groupDisplay);
        vo.setStatus(u.getStatus() == null ? 1 : u.getStatus());
        vo.setCreateTime(u.getCreateTime());
@@ -905,7 +907,7 @@
        vo.setAvatarUrl(u.getAvatarUrl());
        vo.setMobile(u.getMobile());
        vo.setDeptId(u.getDeptId());
        vo.setDeptLeader(Boolean.TRUE.equals(u.getDeptLeaderFlag()));
        vo.setDeptLeader(false);
        vo.setStatus(u.getStatus() == null ? 1 : u.getStatus());
        vo.setGroupNames(loadGroupNamesForUser(u.getId()));
        return vo;
backend/src/main/java/com/sinata/appointment/service/SysRoleManageApplicationService.java
@@ -125,7 +125,7 @@
        SysRole r = new SysRole();
        r.setRoleCode(code);
        r.setRoleName(roleName);
        r.setRemark(trimToNull(req.getRemark()));
        r.setRemark(trimToEmpty(req.getRemark()));
        sysRoleMapper.insert(r);
        SysRole fresh = sysRoleMapper.selectById(r.getId());
        if (fresh == null || !StringUtils.hasText(fresh.getUuid())) {
@@ -144,7 +144,7 @@
        String roleName = req.getRoleName().trim();
        validateRoleNameUnique(roleName, id);
        r.setRoleName(roleName);
        r.setRemark(trimToNull(req.getRemark()));
        r.setRemark(trimToEmpty(req.getRemark()));
        sysRoleMapper.updateById(r);
        replaceRoleMenus(id, req.getMenuIds());
    }
@@ -264,4 +264,8 @@
        String t = s.trim();
        return t.isEmpty() ? null : t;
    }
    private static String trimToEmpty(String s) {
        return s == null ? "" : s.trim();
    }
}
backend/src/main/resources/db/migration/V42__sys_dept_leader_ids.sql
New file
@@ -0,0 +1,2 @@
ALTER TABLE sys_dept
    ADD COLUMN leader_ids VARCHAR(512) NULL COMMENT '部门负责人ids,逗号分隔' AFTER sort_order;
backend/src/test/java/com/sinata/appointment/service/ApprovalEngineServiceTest.java
File was deleted
backend/src/test/java/com/sinata/appointment/service/ApprovalFlowViewApplicationServiceTest.java
File was deleted
backend/src/test/java/com/sinata/appointment/service/IntegrationWechatServiceTest.java
File was deleted
backend/src/test/java/com/sinata/appointment/service/MiniHandlerApprovalApplicationServiceTest.java
File was deleted
backend/src/test/java/com/sinata/appointment/service/PostApprovalOrchestrationServiceTest.java
File was deleted
frontend-admin/dist.zip
Binary files differ
frontend-admin/src/utils/formHelper.ts
New file
@@ -0,0 +1,23 @@
/**
 * 处理表单提交数据
 * 将字符串类型的 null/undefined 转换为空字符串
 *
 * @param data 表单数据对象
 * @returns 处理后的数据对象
 */
export function normalizeFormData<T extends Record<string, any>>(data: T): T {
  const result: any = {};
  for (const key in data) {
    const value = data[key];
    // 字符串类型:null/undefined 转为空字符串
    if (typeof value === 'string' || value == null) {
      result[key] = value || '';
    } else {
      result[key] = value;
    }
  }
  return result as T;
}
frontend-admin/src/views/ApprovalSettingsView.vue
@@ -47,6 +47,7 @@
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
const UI_NODE_CODES = [
  "LEVEL2_DEPT_LEADER",
@@ -185,9 +186,10 @@
  }
  savingDept.value = true;
  try {
    await http.put("/config/visitor-competent-dept", {
    const payload = normalizeFormData({
      deptId: competentDeptId.value ?? null,
    });
    await http.put("/config/visitor-competent-dept", payload);
    ElMessage.success("已保存");
  } catch (e: unknown) {
    ElMessage.error(e instanceof Error ? e.message : "保存失败");
frontend-admin/src/views/BookingSettingsView.vue
@@ -95,6 +95,7 @@
import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
const LOCKED_CODES = new Set(["respondent", "visit_start_time"]);
@@ -270,12 +271,13 @@
  }
  extrasSaving.value = true;
  try {
    await http.put("/config/booking-settings", {
    const payload = normalizeFormData({
      rangeMode: rangeMode.value,
      maxConsecutiveDays: rangeMode.value === "MULTI" ? maxConsecutiveDays.value : maxConsecutiveDays.value ?? 7,
      holidayDates: holidayDates.value,
      vehicleBarrierEnabled: vehicleBarrierEnabled.value,
    });
    await http.put("/config/booking-settings", payload);
    ElMessage.success("已保存");
    await loadExtras();
  } catch (e: unknown) {
frontend-admin/src/views/ContentSettingsView.vue
@@ -46,6 +46,7 @@
import { ElMessage } from "element-plus";
import http from "../api/http";
import type { IEditorConfig } from "@wangeditor/editor";
import { normalizeFormData } from "../utils/formHelper";
type TabKey = "ABOUT" | "NOTICE" | "FLOW";
@@ -135,12 +136,13 @@
async function save() {
  saving.value = true;
  try {
    await http.put("/config/content-settings", {
    const payload = normalizeFormData({
      aboutUsHtml: form.value.aboutUsHtml,
      parkNoticeHtml: form.value.parkNoticeHtml,
      parkFlowHtml: form.value.parkFlowHtml,
      aboutUsEnabled: form.value.aboutUsEnabled,
    });
    await http.put("/config/content-settings", payload);
    await load();
    ElMessage.success("已保存");
  } catch (e: unknown) {
frontend-admin/src/views/DeptManageView.vue
@@ -59,6 +59,23 @@
        <el-form-item label="部门名称" required>
          <el-input v-model="form.deptName" maxlength="128" placeholder="请输入" />
        </el-form-item>
        <el-form-item label="部门负责人">
          <div class="leader-picker">
            <el-button @click="openLeaderPicker">选择负责人</el-button>
            <el-button v-if="selectedLeaders.length" link type="primary" @click="clearLeaders">清空</el-button>
            <div v-if="selectedLeaders.length" class="leader-tags">
              <el-tag
                v-for="leader in selectedLeaders"
                :key="leader.id"
                closable
                @close="removeLeader(leader.id)"
              >
                {{ leader.realName }}
              </el-tag>
            </div>
            <div v-else class="leader-empty">未选择负责人</div>
          </div>
        </el-form-item>
        <el-form-item label="部门状态" required>
          <el-radio-group v-model="form.status">
            <el-radio :label="1">启用</el-radio>
@@ -78,13 +95,60 @@
        </el-button>
      </template>
    </el-dialog>
    <el-dialog v-model="leaderDlg" title="选择部门负责人" width="760px" destroy-on-close @closed="resetLeaderDialog">
      <el-form class="leader-search" @submit.prevent="loadLeaderCandidates">
        <el-form-item label="姓名">
          <el-input v-model="leaderFilters.realName" placeholder="请输入姓名" clearable />
        </el-form-item>
        <el-form-item label="联系方式">
          <el-input v-model="leaderFilters.mobile" placeholder="请输入联系方式" clearable />
        </el-form-item>
        <el-form-item>
          <el-button @click="resetLeaderSearch">重置</el-button>
          <el-button type="primary" native-type="submit">搜索</el-button>
        </el-form-item>
      </el-form>
      <el-table
        ref="leaderTableRef"
        :data="leaderRows"
        v-loading="leaderLoading"
        row-key="id"
        border
        height="360"
        @selection-change="onLeaderSelectionChange"
      >
        <el-table-column type="selection" width="48" reserve-selection />
        <el-table-column prop="realName" label="姓名" min-width="120" show-overflow-tooltip />
        <el-table-column prop="mobileMasked" label="联系方式" width="140" />
        <el-table-column prop="deptPath" label="所属部门" min-width="220" show-overflow-tooltip />
      </el-table>
      <div class="leader-pager">
        <div>已选 {{ leaderSelection.length }} 人</div>
        <el-pagination
          background
          layout="sizes, prev, pager, next"
          :total="leaderTotal"
          :page-size="leaderSize"
          :page-sizes="[10, 20, 50, 100]"
          v-model:current-page="leaderPage"
          @current-change="loadLeaderCandidates"
          @size-change="onLeaderSizeChange"
        />
      </div>
      <template #footer>
        <el-button @click="leaderDlg = false">取消</el-button>
        <el-button type="primary" @click="confirmLeaders">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, nextTick, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
type DeptNode = {
  id: number;
@@ -94,6 +158,8 @@
  sortOrder?: number;
  status: number;
  level: number;
  leaderIds?: number[];
  leaderNames?: string;
  children?: DeptNode[];
};
@@ -116,12 +182,25 @@
const parentDeptName = ref("");
const childParentId = ref<number | null>(null);
const pendingExpandRowKey = ref<string | null>(null);
type StaffOption = { id: number; realName: string; deptPath: string; mobileMasked?: string };
const staffOptions = ref<StaffOption[]>([]);
const selectedLeaders = ref<StaffOption[]>([]);
const leaderDlg = ref(false);
const leaderTableRef = ref();
const leaderLoading = ref(false);
const leaderRows = ref<StaffOption[]>([]);
const leaderSelection = ref<StaffOption[]>([]);
const leaderPage = ref(1);
const leaderSize = ref(10);
const leaderTotal = ref(0);
const leaderFilters = ref({ realName: "", mobile: "" });
const form = ref({
  uuid: "",
  deptName: "",
  status: 1,
  parentId: undefined as number | undefined,
  leaderIds: [] as number[],
});
const dlgTitle = computed(() => {
@@ -210,13 +289,38 @@
  expandedRowKeys.value = expandedRowKeys.value.filter(id => !collapsedKeys.has(id));
}
async function loadStaffOptionsByIds(ids: number[]) {
  const missingIds = ids.filter(id => !staffOptions.value.some(s => s.id === id));
  if (!missingIds.length) return;
  try {
    const { data } = await http.get("/sys/staff", { params: { page: 1, size: 1000, status: 1 } });
    const staffList = (data.result || []) as { id: number; realName: string; deptPath?: string }[];
    staffOptions.value = staffList.map(s => ({
      id: s.id,
      realName: s.realName,
      deptPath: s.deptPath || '未分配部门',
    }));
  } catch (e: unknown) {
    console.error("加载员工列表失败", e);
  }
}
function syncSelectedLeadersFromIds(ids: number[], namesText?: string) {
  const names = (namesText || "").split("、").filter(Boolean);
  selectedLeaders.value = ids.map((id, index) => {
    const cached = staffOptions.value.find(s => s.id === id);
    return cached || { id, realName: names[index] || `员工${id}`, deptPath: "" };
  });
}
function openAddRoot() {
  mode.value = "root";
  editingId.value = null;
  editingIsRoot.value = true;
  parentDeptName.value = "";
  childParentId.value = null;
  form.value = { uuid: "", deptName: "", status: 1, parentId: undefined };
  form.value = { uuid: "", deptName: "", status: 1, parentId: undefined, leaderIds: [] };
  selectedLeaders.value = [];
  dlg.value = true;
}
@@ -227,23 +331,28 @@
  editingIsRoot.value = false;
  parentDeptName.value = row.deptName;
  childParentId.value = row.id;
  form.value = { uuid: "", deptName: "", status: 1, parentId: row.id };
  form.value = { uuid: "", deptName: "", status: 1, parentId: row.id, leaderIds: [] };
  selectedLeaders.value = [];
  dlg.value = true;
}
function openEdit(row: DeptNode) {
async function openEdit(row: DeptNode) {
  mode.value = "edit";
  editingId.value = row.id;
  const root = !row.parentId || row.parentId === 0;
  editingIsRoot.value = root;
  parentDeptName.value = "";
  childParentId.value = null;
  const leaderIds = Array.isArray(row.leaderIds) ? row.leaderIds : [];
  await loadStaffOptionsByIds(leaderIds);
  form.value = {
    uuid: row.uuid,
    deptName: row.deptName,
    status: row.status,
    parentId: root ? undefined : row.parentId,
    leaderIds,
  };
  syncSelectedLeadersFromIds(leaderIds, row.leaderNames);
  if (!root) {
    const disabled = new Set(collectDescendantIds(row));
    parentTreeSelectData.value = toTreeSelectData(treeData.value, disabled);
@@ -258,6 +367,89 @@
  parentTreeSelectData.value = [];
}
function openLeaderPicker() {
  leaderSelection.value = [...selectedLeaders.value];
  leaderDlg.value = true;
  loadLeaderCandidates();
}
async function loadLeaderCandidates() {
  leaderLoading.value = true;
  try {
    const { data } = await http.get("/sys/staff", {
      params: {
        page: leaderPage.value,
        size: leaderSize.value,
        status: 1,
        realName: leaderFilters.value.realName.trim() || undefined,
        mobile: leaderFilters.value.mobile.trim() || undefined,
      },
    });
    leaderRows.value = ((data.result || []) as StaffOption[]).map(s => ({
      ...s,
      deptPath: s.deptPath || "未分配部门",
    }));
    leaderTotal.value = Number(data.total) || 0;
    await nextTick();
    syncLeaderTableSelection();
  } catch (e: unknown) {
    ElMessage.error(e instanceof Error ? e.message : "加载员工失败");
  } finally {
    leaderLoading.value = false;
  }
}
function syncLeaderTableSelection() {
  const selectedIds = new Set(leaderSelection.value.map(s => s.id));
  leaderRows.value.forEach(row => {
    leaderTableRef.value?.toggleRowSelection(row, selectedIds.has(row.id));
  });
}
function onLeaderSelectionChange(selection: StaffOption[]) {
  const currentPageIds = new Set(leaderRows.value.map(s => s.id));
  const kept = leaderSelection.value.filter(s => !currentPageIds.has(s.id));
  const merged = [...kept, ...selection];
  const seen = new Set<number>();
  leaderSelection.value = merged.filter(item => {
    if (seen.has(item.id)) return false;
    seen.add(item.id);
    return true;
  });
}
function resetLeaderSearch() {
  leaderFilters.value = { realName: "", mobile: "" };
  leaderPage.value = 1;
  loadLeaderCandidates();
}
function onLeaderSizeChange(v: number) {
  leaderSize.value = v;
  leaderPage.value = 1;
  loadLeaderCandidates();
}
function confirmLeaders() {
  selectedLeaders.value = [...leaderSelection.value];
  form.value.leaderIds = selectedLeaders.value.map(s => s.id);
  leaderDlg.value = false;
}
function resetLeaderDialog() {
  leaderRows.value = [];
}
function removeLeader(id: number) {
  selectedLeaders.value = selectedLeaders.value.filter(s => s.id !== id);
  form.value.leaderIds = selectedLeaders.value.map(s => s.id);
}
function clearLeaders() {
  selectedLeaders.value = [];
  form.value.leaderIds = [];
}
async function submitDlg() {
  if (!form.value.deptName.trim()) {
    ElMessage.warning("请填写部门名称");
@@ -266,11 +458,13 @@
  saving.value = true;
  try {
    if (mode.value === "root") {
      await http.post("/sys/dept-manage", {
      const payload = normalizeFormData({
        parentId: 0,
        deptName: form.value.deptName.trim(),
        status: form.value.status,
        leaderIds: form.value.leaderIds,
      });
      await http.post("/sys/dept-manage", payload);
      ElMessage.success("已创建");
    } else if (mode.value === "child") {
      if (childParentId.value == null) {
@@ -278,17 +472,20 @@
        return;
      }
      pendingExpandRowKey.value = toRowKey(childParentId.value);
      await http.post("/sys/dept-manage", {
      const payload = normalizeFormData({
        parentId: childParentId.value,
        deptName: form.value.deptName.trim(),
        status: form.value.status,
        leaderIds: form.value.leaderIds,
      });
      await http.post("/sys/dept-manage", payload);
      ElMessage.success("已创建");
    } else if (editingId.value != null) {
      const body: Record<string, unknown> = {
        uuid: form.value.uuid,
        deptName: form.value.deptName.trim(),
        status: form.value.status,
        leaderIds: form.value.leaderIds,
      };
      if (!editingIsRoot.value) {
        if (form.value.parentId == null) {
@@ -297,7 +494,8 @@
        }
        body.parentId = form.value.parentId;
      }
      await http.put(`/sys/dept-manage/${editingId.value}`, body);
      const payload = normalizeFormData(body);
      await http.put(`/sys/dept-manage/${editingId.value}`, payload);
      ElMessage.success("已保存");
    }
    dlg.value = false;
@@ -344,6 +542,36 @@
  width: 100%;
}
.leader-picker {
  width: 100%;
}
.leader-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 8px;
}
.leader-empty {
  color: #909399;
  font-size: 13px;
  margin-top: 8px;
}
.leader-search {
  display: grid;
  grid-template-columns: 1fr 1fr auto;
  gap: 12px;
}
.leader-pager {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 12px;
}
:deep(.table-ops .el-button) {
  font-size: 14px;
}
frontend-admin/src/views/PassAreasView.vue
@@ -95,6 +95,7 @@
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
type Row = {
  id: number;
@@ -291,20 +292,21 @@
  saving.value = true;
  try {
    if (isCreate.value) {
      const body: { pid: number; areaName: string; areaDesc?: string } = {
      const body = normalizeFormData({
        pid: listPid.value > 0 ? listPid.value : 0,
        areaName: form.areaName.trim(),
        areaDesc: form.areaDesc.trim() || undefined,
      };
        areaDesc: form.areaDesc.trim(),
      });
      await http.post("/sys/pass-access-areas", body);
      ElMessage.success("已添加");
    } else if (editingId.value != null) {
      await http.put(`/sys/pass-access-areas/${editingId.value}`, {
      const body = normalizeFormData({
        uuid: form.uuid,
        pid: form.pid > 0 ? form.pid : 0,
        areaName: form.areaName.trim(),
        areaDesc: form.areaDesc.trim() || undefined,
        areaDesc: form.areaDesc.trim(),
      });
      await http.put(`/sys/pass-access-areas/${editingId.value}`, body);
      ElMessage.success("已保存");
    }
    dlg.value = false;
frontend-admin/src/views/RegionLocalUnitsView.vue
@@ -183,6 +183,7 @@
import { computed, nextTick, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
type UnitRow = {
  id: number;
@@ -380,7 +381,7 @@
  const t = form.approverType;
  const base: Record<string, unknown> = {
    unitName: form.unitName.trim(),
    remark: form.remark?.trim() || undefined,
    remark: form.remark?.trim() || "",
    approverType: t,
  };
  if (t === 1) {
@@ -421,7 +422,7 @@
  if (!validateForm()) return;
  saving.value = true;
  try {
    const body = buildSubmitBody();
    const body = normalizeFormData(buildSubmitBody());
    if (!editing.value) {
      await http.post("/sys/local-units", body);
      ElMessage.success("已创建");
frontend-admin/src/views/RoleFormView.vue
@@ -38,6 +38,7 @@
import RolePermissionMatrix from "../components/RolePermissionMatrix.vue";
import type { MenuNode } from "../types/sysMenu";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
const route = useRoute();
const router = useRouter();
@@ -134,20 +135,22 @@
  saving.value = true;
  try {
    if (isCreate.value) {
      await http.post("/sys/role-manage", {
      const payload = normalizeFormData({
        roleName: form.roleName.trim(),
        remark: form.remark.trim() || undefined,
        remark: form.remark.trim(),
        menuIds: submitMenuIds,
      });
      await http.post("/sys/role-manage", payload);
      ElMessage.success("已创建");
    } else {
      const uuid = route.query.uuid as string;
      await http.put(`/sys/role-manage/${roleId.value}`, {
      const payload = normalizeFormData({
        uuid,
        roleName: form.roleName.trim(),
        remark: form.remark.trim() || undefined,
        remark: form.remark.trim(),
        menuIds: submitMenuIds,
      });
      await http.put(`/sys/role-manage/${roleId.value}`, payload);
      ElMessage.success("已保存");
    }
    router.push("/roles");
frontend-admin/src/views/SensitiveWordsView.vue
@@ -104,6 +104,7 @@
import { ElMessage, ElMessageBox } from "element-plus";
import type { UploadRequestOptions } from "element-plus";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
type Row = {
  id: number;
@@ -208,14 +209,15 @@
  saving.value = true;
  try {
    const payload = normalizeFormData({ word });
    if (editingId.value == null) {
      await http.post("/config/sensitive-words/manage", { word });
      await http.post("/config/sensitive-words/manage", payload);
      ElMessage.success("已新增");
      page.value = 1;
    } else {
      await http.put(`/config/sensitive-words/manage/${editingId.value}`, {
        uuid: form.uuid,
        word,
        ...payload,
      });
      ElMessage.success("已保存");
    }
frontend-admin/src/views/StaffEmployeesView.vue
@@ -62,12 +62,6 @@
              />
            </el-select>
          </el-form-item>
          <el-form-item label="职位">
            <el-select v-model="filters.position" placeholder="请选择" clearable>
              <el-option label="部门负责人" value="LEADER" />
              <el-option label="员工" value="STAFF" />
            </el-select>
          </el-form-item>
          <el-form-item label="状态">
            <el-select v-model="filters.status" placeholder="请选择" clearable>
              <el-option label="启用" :value="1" />
@@ -117,7 +111,6 @@
          <el-table-column prop="staffNo" label="员工编号" width="120" show-overflow-tooltip />
          <el-table-column prop="mobileMasked" label="联系方式" width="130" />
          <el-table-column prop="deptName" label="所属部门" min-width="120" show-overflow-tooltip />
          <el-table-column prop="positionLabel" label="所属职位" width="120" />
          <el-table-column prop="groupDisplay" label="所属分组" min-width="170" show-overflow-tooltip />
          <el-table-column label="状态" width="92">
            <template #default="{ row }">
@@ -208,12 +201,6 @@
              class="fullw"
            />
          </el-form-item>
          <el-form-item label="职位" required>
            <el-radio-group v-model="form.deptLeader">
              <el-radio :label="false">员工</el-radio>
              <el-radio :label="true">部门负责人</el-radio>
            </el-radio-group>
          </el-form-item>
          <el-form-item v-if="detailGroupNames.length" label="所属分组">
            <div class="group-tags">
              <el-tag v-for="g in detailGroupNames" :key="g" size="small" class="g-tag">{{ g }}</el-tag>
@@ -242,6 +229,7 @@
import { Refresh, Search } from "@element-plus/icons-vue";
import type { UploadRequestOptions } from "element-plus";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
type DeptTree = { id: number; label: string; children?: DeptTree[] };
type StaffRow = {
@@ -276,7 +264,6 @@
  realName: "",
  staffNo: "",
  mobile: "",
  position: "" as string,
  status: undefined as number | undefined,
  staffGroupId: undefined as number | undefined,
});
@@ -384,7 +371,6 @@
    realName: filters.realName?.trim() || undefined,
    staffNo: filters.staffNo?.trim() || undefined,
    mobile: filters.mobile?.trim() || undefined,
    position: filters.position || undefined,
    status: filters.status,
    staffGroupId: filters.staffGroupId,
  };
@@ -412,7 +398,6 @@
  filters.realName = "";
  filters.staffNo = "";
  filters.mobile = "";
  filters.position = "";
  filters.status = undefined;
  filters.staffGroupId = undefined;
  page.value = 1;
@@ -478,14 +463,14 @@
  }
  saving.value = true;
  try {
    const body = {
    const body = normalizeFormData({
      nickName: form.nickName.trim(),
      staffNo: form.staffNo.trim() || undefined,
      avatarUrl: form.avatarUrl.trim() || undefined,
      mobile: form.mobile.trim(),
      deptId: form.deptId,
      deptLeader: form.deptLeader,
    };
      deptLeader: false,
    });
    if (editingId.value == null) {
      await http.post("/sys/staff", body);
      ElMessage.success("已添加");
@@ -627,6 +612,9 @@
.dept-panel {
  padding: 14px 12px;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.content-panel {
@@ -642,14 +630,18 @@
  font-size: 18px;
  font-weight: 700;
  margin-bottom: 14px;
  flex-shrink: 0;
}
.dept-search {
  margin-bottom: 14px;
  flex-shrink: 0;
}
.dept-tree-wrap {
  min-height: 520px;
  flex: 1;
  overflow-y: auto;
  min-height: 0;
}
.dept-all {
frontend-admin/src/views/StaffGroupDetailView.vue
@@ -138,6 +138,7 @@
import { ElMessage, ElMessageBox } from "element-plus";
import { useRoute } from "vue-router";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
type DeptTree = { id: number; label: string; children?: DeptTree[] };
type StaffRow = { id: number; uuid: string; realName?: string; staffNo?: string; mobileMasked?: string; deptId?: number; deptName?: string };
@@ -357,7 +358,8 @@
  }
  saving.value = true;
  try {
    await http.post(`/sys/staff-groups/${id}/members`, { uuid, memberIds });
    const payload = normalizeFormData({ uuid, memberIds });
    await http.post(`/sys/staff-groups/${id}/members`, payload);
    ElMessage.success("已追加成员");
    picker.value = false;
    pickedMap.value = new Map();
frontend-admin/src/views/StaffGroupsView.vue
@@ -206,6 +206,7 @@
import { ElMessage, ElMessageBox } from "element-plus";
import { useRouter } from "vue-router";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
type GroupRow = {
  id: number;
@@ -459,19 +460,21 @@
  saving.value = true;
  try {
    if (!editing.value) {
      await http.post("/sys/staff-groups", {
      const payload = normalizeFormData({
        groupName: form.groupName.trim(),
        leaderMpUserId: form.leaderMpUserId,
        memberIds,
      });
      await http.post("/sys/staff-groups", payload);
      ElMessage.success("已创建");
    } else {
      await http.put(`/sys/staff-groups/${editing.value.id}`, {
      const payload = normalizeFormData({
        uuid: form.uuid,
        groupName: form.groupName.trim(),
        leaderMpUserId: form.leaderMpUserId,
        memberIds,
      });
      await http.put(`/sys/staff-groups/${editing.value.id}`, payload);
      ElMessage.success("已保存");
    }
    dlg.value = false;
frontend-admin/src/views/UsersView.vue
@@ -280,6 +280,7 @@
import { ElMessage, ElMessageBox } from "element-plus";
import type { UploadRequestOptions } from "element-plus";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
type RoleRow = { id: number; roleCode: string; roleName: string };
@@ -446,7 +447,7 @@
  const nextStatus = isAdminUsername(createForm.username) ? 1 : (createForm.statusEnabled ? 1 : 0);
  saving.value = true;
  try {
    await http.post("/sys/users", {
    const payload = normalizeFormData({
      username: createForm.username.trim(),
      password: createForm.password,
      realName: createForm.realName || undefined,
@@ -455,6 +456,7 @@
      roleIds: createForm.roleIds,
      status: nextStatus,
    });
    await http.post("/sys/users", payload);
    ElMessage.success("已创建");
    dlgCreate.value = false;
    load();
@@ -493,7 +495,7 @@
  const nextStatus = isAdminUsername(editForm.username) ? 1 : (editForm.statusEnabled ? 1 : 0);
  saving.value = true;
  try {
    await http.put(`/sys/users/${editingId.value}`, {
    const payload = normalizeFormData({
      uuid: editForm.uuid,
      realName: editForm.realName || undefined,
      mobile: editForm.mobile || undefined,
@@ -501,6 +503,7 @@
      roleIds: editForm.roleIds,
      status: nextStatus,
    });
    await http.put(`/sys/users/${editingId.value}`, payload);
    ElMessage.success("已保存");
    dlgEdit.value = false;
    load();
@@ -565,11 +568,12 @@
  }
  saving.value = true;
  try {
    await http.post(`/sys/users/${pwdUserId.value}/password`, {
    const payload = normalizeFormData({
      uuid: pwdUserUuid.value,
      newPassword: pwdForm.newPassword,
      newPasswordConfirm: pwdForm.newPasswordConfirm,
    });
    await http.post(`/sys/users/${pwdUserId.value}/password`, payload);
    ElMessage.success("密码已重置");
    dlgPwd.value = false;
  } catch (e: unknown) {
frontend-admin/src/views/VisitReasonsSettingsView.vue
@@ -88,6 +88,7 @@
import { onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
type Row = {
  id: number;
@@ -182,18 +183,20 @@
  }
  saving.value = true;
  try {
    const payload = {
      reasonName,
    // 处理表单数据:字符串 null/undefined 转为空字符串
    const normalized = normalizeFormData({
      reasonName: form.reasonName,
      sortOrder: Number(form.sortOrder) || 0,
    };
    });
    if (editingId.value == null) {
      await http.post("/config/visit-reasons/manage", payload);
      await http.post("/config/visit-reasons/manage", normalized);
      ElMessage.success("已新增");
      page.value = 1;
    } else {
      await http.put(`/config/visit-reasons/manage/${editingId.value}`, {
        ...payload,
        uuid: form.uuid,
        ...normalized,
      });
      ElMessage.success("已保存");
    }
frontend-admin/src/views/VisitorInfoView.vue
@@ -146,6 +146,7 @@
import { ElMessage, ElMessageBox } from "element-plus";
import { Refresh, Search } from "@element-plus/icons-vue";
import http from "../api/http";
import { normalizeFormData } from "../utils/formHelper";
import { useAuthStore } from "../stores/auth";
type VisitorRow = {
@@ -348,19 +349,21 @@
  }
  saving.value = true;
  try {
    await http.put(`/sys/visitor-users/${editingId.value}`, {
    const payload = normalizeFormData({
      uuid: form.uuid,
      userName: form.userName.trim(),
      mobile: form.mobile.trim(),
      gender: form.gender || undefined,
      companyName: form.companyName.trim() || undefined,
    });
    await http.put(`/sys/visitor-users/${editingId.value}`, payload);
    const nextStatus = form.statusEnabled ? 1 : 0;
    if (nextStatus !== originalStatus.value) {
      await http.patch(`/sys/visitor-users/${editingId.value}/status`, {
      const statusPayload = normalizeFormData({
        uuid: form.uuid,
        status: nextStatus,
      });
      await http.patch(`/sys/visitor-users/${editingId.value}/status`, statusPayload);
    }
    ElMessage.success("已保存");
    dlg.value = false;
frontend-h5/src/pages/index/index.vue
@@ -51,7 +51,7 @@
    return ''
  }
  if (profile.value.userType === 'EMPLOYEE') {
    return profile.value.deptLeaderFlag ? '部门领导' : '员工'
    return '员工'
  }
  return ''
})
frontend-h5/src/pages/mine/index.vue
@@ -30,7 +30,7 @@
const userRole = computed<'employee' | 'normal'>(() => profile.value?.userType === 'EMPLOYEE' ? 'employee' : 'normal')
const roleLabel = computed(() => {
  if (userRole.value === 'employee') {
    return profile.value?.deptLeaderFlag ? '部门领导' : '员工'
    return '员工'
  }
  return '普通访客'
})
frontend-h5/src/types/auth.ts
@@ -63,7 +63,7 @@
    userType: data.userType,
    deptId: data.deptId ?? null,
    deptName: data.deptName ?? null,
    deptLeaderFlag: Boolean(data.deptLeader),
    deptLeaderFlag: false,
    avatarUrl: data.avatarUrl ?? null,
    displayName: normalizeProfileDisplayName(data.displayName),
    mobile: null,
@@ -78,7 +78,7 @@
    userType: data.userType,
    deptId: data.deptId ?? null,
    deptName: data.deptName ?? null,
    deptLeaderFlag: Boolean(data.deptLeaderFlag),
    deptLeaderFlag: false,
    avatarUrl: data.avatarUrl ?? null,
    displayName: normalizeProfileDisplayName(data.nickName),
    mobile: data.mobile ?? null,
yuyueMiniProgram/src/pages/appointment/index.vue
@@ -913,9 +913,26 @@
  }
}
function sortVisitReasons(reasons: MiniVisitReasonItem[]) {
  return [...reasons].sort((left, right) => {
    const leftIsOther = left.reasonCode === 'OTHER' || left.reasonName === '其他'
    const rightIsOther = right.reasonCode === 'OTHER' || right.reasonName === '其他'
    if (leftIsOther !== rightIsOther) {
      return leftIsOther ? 1 : -1
    }
    const leftOrder = left.sortOrder ?? Number.MAX_SAFE_INTEGER
    const rightOrder = right.sortOrder ?? Number.MAX_SAFE_INTEGER
    if (leftOrder !== rightOrder) {
      return leftOrder - rightOrder
    }
    return left.id - right.id
  })
}
async function loadVisitReasons() {
  try {
    visitReasons.value = await fetchVisitReasons()
    visitReasons.value = sortVisitReasons(await fetchVisitReasons())
    if (selectedVisitReason.value) {
      const nextReason = visitReasons.value.find(item => item.reasonCode === selectedVisitReason.value?.reasonCode) || null
      selectedVisitReason.value = nextReason
yuyueMiniProgram/src/pages/index/index.vue
@@ -50,7 +50,7 @@
    return ''
  }
  if (profile.value.userType === 'EMPLOYEE') {
    return profile.value.deptLeaderFlag ? '部门领导' : '员工'
    return '员工'
  }
  return ''
})
yuyueMiniProgram/src/pages/mine/index.vue
@@ -30,7 +30,7 @@
const userRole = computed<'employee' | 'normal'>(() => profile.value?.userType === 'EMPLOYEE' ? 'employee' : 'normal')
const roleLabel = computed(() => {
  if (userRole.value === 'employee') {
    return profile.value?.deptLeaderFlag ? '部门领导' : '员工'
    return '员工'
  }
  return '普通访客'
})
yuyueMiniProgram/src/pages/reviewDetail/index.vue
@@ -99,12 +99,7 @@
const selectedAreaIds = ref<number[]>([])
const selectedAreaParentId = ref<number | null>(null)
const selectedAreaChildId = ref<number | null>(null)
const regionColumns = ref<string[][]>([
  ['东区', '西区', 'AA区'],
  ['A部', 'B部', 'C部'],
])
const regionPickerIndex = ref([1, 1])
const regionValue = ref(['西区', 'B部'])
const activeAreaParentId = ref<number | null>(null)
const unitInput = ref('')
const unitList = ref<string[]>([])
@@ -396,24 +391,68 @@
}
function buildRegionColumns(parentIndex: number, childIndex: number) {
  // 树形展开模式不需要此方法
}
function isAreaSelected(areaId: number) {
  return selectedAreaIds.value.includes(areaId)
}
const activeAreaParent = computed(() => {
  if (areaTree.value.length === 0) {
    return null
  }
  return areaTree.value.find(item => item.id === activeAreaParentId.value) || areaTree.value[0]
})
const activeAreaChildren = computed(() => activeAreaParent.value?.children || [])
function getAreaSelectableIds(parent: MiniPassAccessAreaTreeItem) {
  const childIds = (parent.children || []).map(child => child.id)
  return childIds.length > 0 ? childIds : [parent.id]
}
function isParentAreaSelected(parent: MiniPassAccessAreaTreeItem) {
  const ids = getAreaSelectableIds(parent)
  return ids.length > 0 && ids.every(id => selectedAreaIds.value.includes(id))
}
function isParentAreaPartialSelected(parent: MiniPassAccessAreaTreeItem) {
  const ids = getAreaSelectableIds(parent)
  return ids.some(id => selectedAreaIds.value.includes(id)) && !ids.every(id => selectedAreaIds.value.includes(id))
}
function selectActiveParent(parentId: number) {
  activeAreaParentId.value = parentId
}
function toggleParentAreaSelection(parent: MiniPassAccessAreaTreeItem) {
  if (isHandlerReadOnly()) {
    return
  }
  const safeParentIndex = Math.min(Math.max(parentIndex, 0), areaTree.value.length - 1)
  const parent = areaTree.value[safeParentIndex]
  const childOptions = parent.children && parent.children.length > 0
    ? parent.children
    : [{ id: parent.id, areaName: parent.areaName, children: [] }]
  const safeChildIndex = Math.min(Math.max(childIndex, 0), childOptions.length - 1)
  regionColumns.value = [
    areaTree.value.map(item => item.areaName),
    childOptions.map(item => item.areaName),
  ]
  regionPickerIndex.value = [safeParentIndex, safeChildIndex]
  regionValue.value = [
    regionColumns.value[0][safeParentIndex] || '',
    regionColumns.value[1][safeChildIndex] || '',
  ]
  activeAreaParentId.value = parent.id
  const ids = getAreaSelectableIds(parent)
  if (isParentAreaSelected(parent)) {
    const removeIds = new Set(ids)
    selectedAreaIds.value = selectedAreaIds.value.filter(id => !removeIds.has(id))
  }
  else {
    selectedAreaIds.value = Array.from(new Set([...selectedAreaIds.value, ...ids]))
  }
  syncSelectedAreaDisplay()
}
function toggleChildAreaSelection(child: MiniPassAccessAreaTreeItem) {
  if (isHandlerReadOnly()) {
    return
  }
  if (selectedAreaIds.value.includes(child.id)) {
    selectedAreaIds.value = selectedAreaIds.value.filter(id => id !== child.id)
  }
  else {
    selectedAreaIds.value = [...selectedAreaIds.value, child.id]
  }
  syncSelectedAreaDisplay()
}
function resolveAreaDisplayById(targetId: number) {
@@ -470,30 +509,18 @@
  if (areaTree.value.length === 0) {
    return
  }
  let parentIndex = 0
  let childIndex = 0
  const targetId = selectedAreaIds.value[selectedAreaIds.value.length - 1]
  if (targetId) {
    areaTree.value.some((parent, pIndex) => {
      if (parent.id === targetId) {
        parentIndex = pIndex
        childIndex = 0
        selectedAreaParentId.value = parent.id
        selectedAreaChildId.value = null
        return true
  // 自动展开已选中区域的父节点
  areaTree.value.forEach((parent) => {
    const hasSelectedChild = (parent.children || []).some(child => selectedAreaIds.value.includes(child.id))
    if (hasSelectedChild || selectedAreaIds.value.includes(parent.id)) {
      if (activeAreaParentId.value == null) {
        activeAreaParentId.value = parent.id
      }
      const cIndex = (parent.children || []).findIndex(child => child.id === targetId)
      if (cIndex >= 0) {
        parentIndex = pIndex
        childIndex = cIndex
        selectedAreaParentId.value = parent.id
        selectedAreaChildId.value = parent.children[cIndex].id
        return true
      }
      return false
    })
    }
  })
  if (activeAreaParentId.value == null && areaTree.value.length > 0) {
    activeAreaParentId.value = areaTree.value[0].id
  }
  buildRegionColumns(parentIndex, childIndex)
  syncSelectedAreaDisplay()
}
@@ -504,74 +531,15 @@
  try {
    areaTree.value = await fetchPassAccessAreaTree()
    if (areaTree.value.length > 0) {
      if (activeAreaParentId.value == null || !areaTree.value.some(parent => parent.id === activeAreaParentId.value)) {
        activeAreaParentId.value = areaTree.value[0].id
      }
      syncRegionFromSelectedIds()
    }
  }
  catch (error) {
    uni.showToast({ title: error instanceof Error ? error.message : '加载通行区域失败', icon: 'none' })
  }
}
function onRegionConfirm(e: any) {
  if (isHandlerReadOnly()) {
    return
  }
  const value = e.detail?.value || []
  if (value.length < 2 || areaTree.value.length === 0) {
    return
  }
  const parentChanged = Number(value[0]) !== regionPickerIndex.value[0]
  buildRegionColumns(Number(value[0]), parentChanged ? 0 : Number(value[1]))
}
function resolveCurrentRegionSelection() {
  if (areaTree.value.length === 0) {
    return null
  }
  const parent = areaTree.value[regionPickerIndex.value[0]]
  if (!parent) {
    return null
  }
  const childOptions = parent.children && parent.children.length > 0
    ? parent.children
    : [{ id: parent.id, areaName: parent.areaName, children: [] }]
  const child = childOptions[regionPickerIndex.value[1]]
  const pickedId = child && child.id !== parent.id ? child.id : parent.id
  const selectedNames = child && child.areaName !== parent.areaName
    ? [parent.areaName, child.areaName]
    : [parent.areaName]
  return {
    parentId: parent.id,
    childId: child?.id ?? null,
    pickedId,
    value: [selectedNames[0], selectedNames[selectedNames.length - 1]],
  }
}
const currentRegionPickedId = computed(() => resolveCurrentRegionSelection()?.pickedId ?? null)
const isCurrentRegionSelected = computed(() => {
  const pickedId = currentRegionPickedId.value
  return pickedId != null && selectedAreaIds.value.includes(pickedId)
})
function toggleCurrentRegionSelection() {
  if (isHandlerReadOnly()) {
    return
  }
  const current = resolveCurrentRegionSelection()
  if (!current) {
    return
  }
  selectedAreaParentId.value = current.parentId
  selectedAreaChildId.value = current.childId
  regionValue.value = current.value
  if (selectedAreaIds.value.includes(current.pickedId)) {
    selectedAreaIds.value = selectedAreaIds.value.filter(item => item !== current.pickedId)
  }
  else {
    selectedAreaIds.value = [...selectedAreaIds.value, current.pickedId]
  }
  syncSelectedAreaDisplay()
}
function confirmRegionPicker() {
@@ -1284,68 +1252,71 @@
    <!-- 通行区域选择器 -->
    <wd-popup v-model="showRegionPicker" position="bottom" custom-style="border-radius: 24rpx 24rpx 0 0; z-index: 9999;">
      <view class="w-full bg-white rounded-t-[24rpx] flex flex-col h-[860rpx]">
      <view class="w-full bg-white rounded-t-[24rpx] flex flex-col h-[900rpx]">
        <!-- 头部:关闭图标和标题 -->
        <view class="flex items-center justify-between h-[108rpx] px-[32rpx] border-b border-solid border-[#F0F0F0] shrink-0">
          <view class="w-[48rpx] h-[48rpx] flex items-center justify-center" @click="showRegionPicker = false">
            <image src="/static/review/close@2x.png" class="w-[48rpx] h-[48rpx]" mode="aspectFit" />
          </view>
          <text class="text-[#333333] text-[32rpx] font-medium flex-1 text-center pr-[48rpx]">选择通行区域</text>
        </view>
        <!-- 选择器区域 -->
        <view class="flex flex-col justify-center px-[32rpx] h-[330rpx] relative shrink-0">
          <view class="absolute left-[32rpx] right-[32rpx] top-1/2 -translate-y-1/2 h-[96rpx] bg-[#F4F8FF] rounded-[8rpx] z-0"></view>
          <picker-view
            class="custom-picker-view region-picker-view relative z-10"
            :value="regionPickerIndex"
            indicator-style="height: 48px; background-color: transparent;"
            style="width: 100%; height: 192px;"
            @change="onRegionConfirm"
          >
            <picker-view-column v-for="(col, colIndex) in regionColumns" :key="colIndex">
              <view
                v-for="(item, itemIndex) in col"
                :key="itemIndex"
                class="flex items-center justify-center h-[48px] wd-picker-view-column__item"
                :class="{ 'wd-picker-view-column__item--active': regionPickerIndex[colIndex] === itemIndex }"
              >
                {{ item }}
              </view>
            </picker-view-column>
          </picker-view>
        </view>
        <view class="px-[32rpx] pb-[24rpx] shrink-0">
          <view
            class="w-full h-[72rpx] rounded-[36rpx] flex items-center justify-center border-[2rpx] border-solid active:opacity-80"
            :class="isCurrentRegionSelected ? 'bg-[#F0F6FF] border-[#0068FF]' : 'bg-white border-[#0068FF]'"
            @click="toggleCurrentRegionSelection"
          >
            <text class="text-[28rpx]" :class="isCurrentRegionSelected ? 'text-[#0068FF]' : 'text-[#0068FF]'">
              {{ isCurrentRegionSelected ? '已选择,点击取消' : '添加当前区域' }}
            </text>
        <view class="relative h-[108rpx] px-[32rpx] border-b border-solid border-[#F0F0F0] shrink-0 flex items-center justify-center">
          <text class="text-[#333333] text-[32rpx] font-medium">选择访问区域</text>
          <view class="absolute right-[32rpx] top-[30rpx] w-[48rpx] h-[48rpx] flex items-center justify-center rounded-full bg-[#F5F7FB]" @click="showRegionPicker = false">
            <text class="text-[#9AA4B2] text-[32rpx] leading-none">×</text>
          </view>
        </view>
        <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-[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)">
                  <image src="/static/review/close.png" class="w-[24rpx] h-[24rpx]" mode="aspectFit" />
        <!-- 左右两栏选择区域 -->
        <view class="relative flex-1 mx-[24rpx] mt-[24rpx] rounded-[24rpx] border border-solid border-[#EDF1F7] overflow-hidden flex min-h-0">
          <scroll-view scroll-y class="w-[350rpx] bg-white py-[12rpx] px-[12rpx] box-border">
            <view
              v-for="parent in areaTree"
              :key="parent.id"
              class="h-[78rpx] mb-[10rpx] rounded-[12rpx] px-[14rpx] flex items-center justify-between"
              :class="activeAreaParent?.id === parent.id ? 'bg-[#EEF4FF]' : 'bg-white'"
              @click="selectActiveParent(parent.id)"
            >
              <view class="flex items-center min-w-0 flex-1" @click.stop="toggleParentAreaSelection(parent)">
                <view
                  class="w-[34rpx] h-[34rpx] rounded-[8rpx] border-[2rpx] border-solid flex items-center justify-center mr-[14rpx] shrink-0"
                  :class="isParentAreaSelected(parent) || isParentAreaPartialSelected(parent) ? 'bg-[#2F74FF] border-[#2F74FF]' : 'bg-white border-[#E2E8F0]'"
                >
                  <view v-if="isParentAreaSelected(parent)" class="checkbox-icon"></view>
                  <view v-else-if="isParentAreaPartialSelected(parent)" class="w-[16rpx] h-[4rpx] bg-white rounded-full"></view>
                </view>
                <text class="text-[26rpx] font-medium truncate" :class="isParentAreaSelected(parent) || isParentAreaPartialSelected(parent) ? 'text-[#2F74FF]' : 'text-[#4A5568]'">{{ parent.areaName }}</text>
              </view>
            </view>
            <view v-else class="h-[96rpx] flex items-center justify-center">
              <text class="text-[#B3B3B3] text-[24rpx]">请选择至少一个通行区域</text>
              <text class="text-[30rpx]" :class="activeAreaParent?.id === parent.id ? 'text-[#2F74FF]' : 'text-[#B8C0CC]'">›</text>
            </view>
          </scroll-view>
          <scroll-view scroll-y class="flex-1 bg-white py-[12rpx] px-[12rpx] box-border border-l border-solid border-[#F2F5FA]">
            <view
              v-for="child in activeAreaChildren"
              :key="child.id"
              class="h-[78rpx] mb-[12rpx] rounded-[12rpx] px-[14rpx] flex items-center bg-[#EEF4FF]"
              @click="toggleChildAreaSelection(child)"
            >
              <view
                class="w-[34rpx] h-[34rpx] rounded-[8rpx] border-[2rpx] border-solid flex items-center justify-center mr-[14rpx] shrink-0"
                :class="isAreaSelected(child.id) ? 'bg-[#2F74FF] border-[#2F74FF]' : 'bg-white border-[#E2E8F0]'"
              >
                <view v-if="isAreaSelected(child.id)" class="checkbox-icon"></view>
              </view>
              <text class="text-[26rpx] font-medium truncate" :class="isAreaSelected(child.id) ? 'text-[#2F74FF]' : 'text-[#4A5568]'">{{ child.areaName }}</text>
            </view>
            <view v-if="activeAreaParent && activeAreaChildren.length === 0" class="h-[180rpx] flex items-center justify-center">
              <text class="text-[#B3B3B3] text-[26rpx]">当前区域无子区域</text>
            </view>
          </scroll-view>
          <view v-if="areaTree.length === 0" class="absolute inset-0 bg-white flex items-center justify-center">
            <text class="text-[#B3B3B3] text-[28rpx]">暂无通行区域</text>
          </view>
        </view>
        <!-- 已选区域统计 -->
        <view class="px-[32rpx] py-[16rpx] border-t border-solid border-[#F0F0F0] shrink-0">
          <view class="flex items-center justify-between">
            <text class="text-[#333333] text-[28rpx]">已选择</text>
            <text class="text-[#0068FF] text-[28rpx] font-medium">{{ selectedAreaIds.length }} 个区域</text>
          </view>
        </view>
        <!-- 底部确认按钮 -->
@@ -1459,6 +1430,35 @@
  border-radius: 28rpx;
  background: linear-gradient(180deg, #FFFFFF 0%, rgba(255, 255, 255, 0) 69%);
}
/* 复选框勾选图标 - 使用CSS绘制 */
.checkbox-icon {
  width: 16rpx;
  height: 10rpx;
  border-left: 3rpx solid #FFFFFF;
  border-bottom: 3rpx solid #FFFFFF;
  transform: rotate(-45deg);
  margin-top: -4rpx;
}
/* 箭头图标 - 使用CSS绘制 */
.arrow-icon {
  width: 16rpx;
  height: 16rpx;
  border-top: 3rpx solid #666666;
  border-right: 3rpx solid #666666;
  transition: transform 0.3s ease;
}
.arrow-down {
  transform: rotate(135deg);
  margin-top: -6rpx;
}
.arrow-up {
  transform: rotate(-45deg);
  margin-bottom: -6rpx;
}
</style>
<style>
yuyueMiniProgram/src/types/auth.ts
@@ -53,7 +53,7 @@
    userType: data.userType,
    deptId: data.deptId ?? null,
    deptName: data.deptName ?? null,
    deptLeaderFlag: Boolean(data.deptLeader),
    deptLeaderFlag: false,
    avatarUrl: data.avatarUrl ?? null,
    displayName: normalizeProfileDisplayName(data.displayName),
    mobile: null,
@@ -68,7 +68,7 @@
    userType: data.userType,
    deptId: data.deptId ?? null,
    deptName: data.deptName ?? null,
    deptLeaderFlag: Boolean(data.deptLeaderFlag),
    deptLeaderFlag: false,
    avatarUrl: data.avatarUrl ?? null,
    displayName: normalizeProfileDisplayName(data.nickName),
    mobile: data.mobile ?? null,