hejianhao
2026-08-18 84c81837d6465ee3f768291ea1f2859e322bd1b4
feat0806: 提交省代理管理
1个文件已删除
12个文件已添加
6个文件已修改
4049 ■■■■■ 已修改文件
tcm-training-admin-backend/sql/20260806_admin.sql 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/2026_0806_tcm_region.sql 3574 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/controller/ProvincialAgentController.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/dto/ProvincialAgentSaveRequest.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/entity/AgentRegion.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/entity/ProvincialAgent.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/mapper/AgentRegionMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/mapper/ProvincialAgentMapper.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/service/ProvincialAgentAppService.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/vo/ProvincialAgentDetailVO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/vo/ProvincialAgentListItemVO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/Clinic.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/config/routes.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/access.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/components/RegionCascader/index.tsx 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/broadcast/index.tsx 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/agent/index.tsx 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/member/agent/service.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/20260806_admin.sql
@@ -32,3 +32,25 @@
ALTER TABLE tcm_clinic
ADD COLUMN agent_id BIGINT COMMENT '负责该诊所的省代理ID,tcm_provincial_agent.id'
AFTER name;
INSERT INTO sys_menu (id, parent_id, name, menu_code, type, path, component, sort, status, visible, permission_key, remark, deleted) VALUES
(2119, 2100, '省代理管理', 'member_agent_manage', 'C', '/member/user/agent', 'Member/AgentList', 12, 1, 1, 'member:agent:view', '省代理管理菜单', 0),
(2120, 2119, '新增省代理', 'member_agent_add', 'B', NULL, NULL, 1, 1, 1, 'member:agent:add', '新增省代理权限', 0),
(2121, 2119, '编辑省代理', 'member_agent_edit', 'B', NULL, NULL, 2, 1, 1, 'member:agent:edit', '编辑省代理权限', 0),
(2122, 2119, '删除省代理', 'member_agent_delete', 'B', NULL, NULL, 3, 1, 1, 'member:agent:delete', '删除省代理权限', 0),
(2123, 2119, '省代理详情', 'member_agent_detail', 'B', NULL, NULL, 4, 1, 1, 'member:agent:detail', '省代理详情权限', 0);
-- 养生直播:新增设置直播间密码按钮权限
INSERT INTO sys_menu (id, parent_id, name, menu_code, type, path, component, icon, sort, status, visible, permission_key, remark, deleted)
VALUES (3064, 3060, '设置直播间密码', 'live_course_password', 'B', NULL, NULL, NULL, 4, 1, 1, 'live:course:password', '设置直播间密码按钮', 0)
ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id), name = VALUES(name),
menu_code = VALUES(menu_code), type = VALUES(type), sort = VALUES(sort), status = VALUES(status),
visible = VALUES(visible), permission_key = VALUES(permission_key), remark = VALUES(remark), deleted = VALUES(deleted);
-- 为 tcm_clinic 表新增银行信息字段(全部加在 status 字段后面)
ALTER TABLE tcm_clinic
ADD COLUMN bank_name VARCHAR(100) COMMENT '开户银行名称(如:中国建设银行)' AFTER status,
ADD COLUMN account_number VARCHAR(30) COMMENT '银行账号' AFTER bank_name,
ADD COLUMN account_holder VARCHAR(50) COMMENT '账户名称/户名(即开户人姓名或公司名称)' AFTER account_number,
ADD COLUMN branch_name VARCHAR(100) COMMENT '开户支行名称(如:成都高新支行)' AFTER account_holder;
tcm-training-admin-backend/sql/2026_0806_tcm_region.sql
File was deleted
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/controller/ProvincialAgentController.java
New file
@@ -0,0 +1,19 @@
package com.tcm.training.admin.agent.controller;
import com.tcm.training.admin.agent.dto.ProvincialAgentSaveRequest;
import com.tcm.training.admin.agent.service.ProvincialAgentAppService;
import com.tcm.training.admin.agent.vo.ProvincialAgentDetailVO;
import com.tcm.training.admin.agent.vo.ProvincialAgentListItemVO;
import com.tcm.training.admin.common.ApiResponse;
import com.tcm.training.admin.common.PageResponse;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@Validated @RestController @RequestMapping({"/api/v1","/api"})
public class ProvincialAgentController {
 private final ProvincialAgentAppService service; public ProvincialAgentController(ProvincialAgentAppService service){this.service=service;}
 @GetMapping("/provincial-agents") public ApiResponse<PageResponse<ProvincialAgentListItemVO>> page(@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="10") int pageSize,@RequestParam(required=false) String agentName,@RequestParam(required=false) String province){return ApiResponse.ok(service.page(page,pageSize,agentName,province));}
 @GetMapping("/provincial-agents/{id}") public ApiResponse<ProvincialAgentDetailVO> detail(@PathVariable Long id){return ApiResponse.ok(service.detail(id));}
 @PostMapping("/provincial-agents") public ApiResponse<ProvincialAgentDetailVO> create(@Valid @RequestBody ProvincialAgentSaveRequest r){return ApiResponse.ok(service.create(r));}
 @PutMapping("/provincial-agents/{id}") public ApiResponse<ProvincialAgentDetailVO> update(@PathVariable Long id,@Valid @RequestBody ProvincialAgentSaveRequest r){return ApiResponse.ok(service.update(id,r));}
 @DeleteMapping("/provincial-agents/{id}") public ApiResponse<Void> delete(@PathVariable Long id){service.delete(id);return ApiResponse.ok(null);}
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/dto/ProvincialAgentSaveRequest.java
New file
@@ -0,0 +1,34 @@
package com.tcm.training.admin.agent.dto;
import lombok.Getter;
import lombok.Setter;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.util.ArrayList;
import java.util.List;
@Getter
@Setter
public class ProvincialAgentSaveRequest {
    @NotBlank(message = "请输入代理负责人")
    @Size(max = 50, message = "代理负责人不能超过50个字")
    private String agentName;
    @NotBlank(message = "请选择负责省份")
    private String province;
    @Size(max = 100) private String bankName;
    @Size(max = 30) private String accountNumber;
    @Size(max = 50) private String accountHolder;
    @Size(max = 100) private String branchName;
    @Size(max = 200) private String contactInfo;
    @Size(max = 255) private String address;
    @Valid private List<RegionItem> regions = new ArrayList<RegionItem>();
    @Getter @Setter
    public static class RegionItem {
        @NotBlank(message = "区域编码不能为空") private String regionCode;
        @Size(max = 100) private String regionName;
        private Integer regionLevel;
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/entity/AgentRegion.java
New file
@@ -0,0 +1,40 @@
package com.tcm.training.admin.agent.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@TableName("tcm_agent_region")
@Getter
@Setter
@NoArgsConstructor
public class AgentRegion {
    @TableId(type = IdType.AUTO)
    private Long id;
    @TableField("agent_id")
    private Long agentId;
    @TableField("region_code")
    private String regionCode;
    @TableField("region_name")
    private String regionName;
    @TableField("agent_name")
    private String agentName;
    @TableField("region_level")
    private Integer regionLevel;
    @TableField("created_at")
    private LocalDateTime createdAt;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/entity/ProvincialAgent.java
New file
@@ -0,0 +1,57 @@
package com.tcm.training.admin.agent.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@TableName("tcm_provincial_agent")
@Getter
@Setter
@NoArgsConstructor
public class ProvincialAgent {
    @TableId(type = IdType.AUTO)
    private Long id;
    @TableField("agent_name")
    private String agentName;
    @TableField("province")
    private String province;
    @TableField("bank_name")
    private String bankName;
    @TableField("account_number")
    private String accountNumber;
    @TableField("account_holder")
    private String accountHolder;
    @TableField("branch_name")
    private String branchName;
    @TableField("contact_info")
    private String contactInfo;
    @TableField("address")
    private String address;
    @TableLogic
    private Integer deleted;
    @TableField(value = "created_by", fill = FieldFill.INSERT)
    private String createdBy;
    @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
    private String updatedBy;
    @TableField("created_at")
    private LocalDateTime createdAt;
    @TableField("updated_at")
    private LocalDateTime updatedAt;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/mapper/AgentRegionMapper.java
New file
@@ -0,0 +1,9 @@
package com.tcm.training.admin.agent.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tcm.training.admin.agent.entity.AgentRegion;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AgentRegionMapper extends BaseMapper<AgentRegion> {
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/mapper/ProvincialAgentMapper.java
New file
@@ -0,0 +1,10 @@
package com.tcm.training.admin.agent.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tcm.training.admin.agent.entity.ProvincialAgent;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ProvincialAgentMapper extends BaseMapper<ProvincialAgent> {
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/service/ProvincialAgentAppService.java
New file
@@ -0,0 +1,44 @@
package com.tcm.training.admin.agent.service;
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.tcm.training.admin.agent.dto.ProvincialAgentSaveRequest;
import com.tcm.training.admin.agent.entity.AgentRegion;
import com.tcm.training.admin.agent.entity.ProvincialAgent;
import com.tcm.training.admin.agent.mapper.AgentRegionMapper;
import com.tcm.training.admin.agent.mapper.ProvincialAgentMapper;
import com.tcm.training.admin.agent.vo.ProvincialAgentDetailVO;
import com.tcm.training.admin.agent.vo.ProvincialAgentListItemVO;
import com.tcm.training.admin.common.PageResponse;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
public class ProvincialAgentAppService {
    private final ProvincialAgentMapper agentMapper; private final AgentRegionMapper regionMapper;
    public ProvincialAgentAppService(ProvincialAgentMapper agentMapper, AgentRegionMapper regionMapper) { this.agentMapper = agentMapper; this.regionMapper = regionMapper; }
    public PageResponse<ProvincialAgentListItemVO> page(int page, int pageSize, String agentName, String province) {
        LambdaQueryWrapper<ProvincialAgent> q = new LambdaQueryWrapper<ProvincialAgent>().eq(ProvincialAgent::getDeleted, 0)
            .like(agentName != null && !agentName.trim().isEmpty(), ProvincialAgent::getAgentName, trim(agentName))
            .like(province != null && !province.trim().isEmpty(), ProvincialAgent::getProvince, trim(province))
            .orderByDesc(ProvincialAgent::getCreatedAt).orderByDesc(ProvincialAgent::getId);
        IPage<ProvincialAgent> result = agentMapper.selectPage(new Page<ProvincialAgent>(page, pageSize), q);
        List<ProvincialAgentListItemVO> list = new ArrayList<ProvincialAgentListItemVO>();
        for (ProvincialAgent item : result.getRecords()) list.add(toList(item));
        return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize());
    }
    public ProvincialAgentDetailVO detail(Long id) { return toDetail(require(id)); }
    @Transactional public ProvincialAgentDetailVO create(ProvincialAgentSaveRequest request) { ProvincialAgent a = new ProvincialAgent(); fill(a, request); a.setDeleted(0); agentMapper.insert(a); saveRegions(a, request); return detail(a.getId()); }
    @Transactional public ProvincialAgentDetailVO update(Long id, ProvincialAgentSaveRequest request) { ProvincialAgent a = require(id); fill(a, request); agentMapper.updateById(a); regionMapper.delete(new LambdaQueryWrapper<AgentRegion>().eq(AgentRegion::getAgentId, id)); saveRegions(a, request); return detail(id); }
    @Transactional public void delete(Long id) { require(id); agentMapper.deleteById(id); }
    private void fill(ProvincialAgent a, ProvincialAgentSaveRequest r) { a.setAgentName(trim(r.getAgentName())); a.setProvince(trim(r.getProvince())); a.setBankName(trim(r.getBankName())); a.setAccountNumber(trim(r.getAccountNumber())); a.setAccountHolder(trim(r.getAccountHolder())); a.setBranchName(trim(r.getBranchName())); a.setContactInfo(trim(r.getContactInfo())); a.setAddress(trim(r.getAddress())); }
    private void saveRegions(ProvincialAgent a, ProvincialAgentSaveRequest r) { if (r.getRegions() == null) return; for (ProvincialAgentSaveRequest.RegionItem x : r.getRegions()) { if (x == null || trim(x.getRegionCode()) == null) continue; AgentRegion ar = new AgentRegion(); ar.setAgentId(a.getId()); ar.setAgentName(a.getAgentName()); ar.setRegionCode(trim(x.getRegionCode())); ar.setRegionName(trim(x.getRegionName())); ar.setRegionLevel(x.getRegionLevel()); regionMapper.insert(ar); } }
    private ProvincialAgent require(Long id) { ProvincialAgent a = agentMapper.selectOne(new LambdaQueryWrapper<ProvincialAgent>().eq(ProvincialAgent::getId,id).eq(ProvincialAgent::getDeleted,0).last("limit 1")); if (a == null) throw new IllegalArgumentException("省代理不存在"); return a; }
    private ProvincialAgentListItemVO toList(ProvincialAgent a) { ProvincialAgentListItemVO v = new ProvincialAgentListItemVO(); v.setId(a.getId()); v.setAgentName(a.getAgentName()); v.setProvince(a.getProvince()); v.setContactInfo(a.getContactInfo()); v.setAddress(a.getAddress()); List<AgentRegion> rs = regions(a.getId()); v.setRegionCount(rs.size()); List<String> names = new ArrayList<String>(); for (AgentRegion r:rs) names.add(r.getRegionName() == null ? r.getRegionCode() : r.getRegionName()); v.setRegionNames(names); v.setCreatedAt(a.getCreatedAt()); return v; }
    private ProvincialAgentDetailVO toDetail(ProvincialAgent a) { ProvincialAgentDetailVO v = new ProvincialAgentDetailVO(); ProvincialAgentListItemVO base=toList(a); v.setId(base.getId()); v.setAgentName(base.getAgentName()); v.setProvince(base.getProvince()); v.setContactInfo(base.getContactInfo()); v.setAddress(base.getAddress()); v.setRegionCount(base.getRegionCount()); v.setRegionNames(base.getRegionNames()); v.setCreatedAt(base.getCreatedAt()); v.setBankName(a.getBankName()); v.setAccountNumber(a.getAccountNumber()); v.setAccountHolder(a.getAccountHolder()); v.setBranchName(a.getBranchName()); v.setUpdatedAt(a.getUpdatedAt()); List<ProvincialAgentDetailVO.RegionVO> out=new ArrayList<ProvincialAgentDetailVO.RegionVO>(); for(AgentRegion r:regions(a.getId())) { ProvincialAgentDetailVO.RegionVO x=new ProvincialAgentDetailVO.RegionVO(); x.setRegionCode(r.getRegionCode()); x.setRegionName(r.getRegionName()); x.setRegionLevel(r.getRegionLevel()); out.add(x); } v.setRegions(out); return v; }
    private List<AgentRegion> regions(Long id) { return regionMapper.selectList(new LambdaQueryWrapper<AgentRegion>().eq(AgentRegion::getAgentId,id).orderByAsc(AgentRegion::getId)); }
    private String trim(String s) { if(s==null)return null; String x=s.trim(); return x.isEmpty()?null:x; }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/vo/ProvincialAgentDetailVO.java
New file
@@ -0,0 +1,13 @@
package com.tcm.training.admin.agent.vo;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
@Getter @Setter
public class ProvincialAgentDetailVO extends ProvincialAgentListItemVO {
    private String bankName; private String accountNumber; private String accountHolder;
    private String branchName; private LocalDateTime updatedAt; private List<RegionVO> regions;
    @Getter @Setter public static class RegionVO { private String regionCode; private String regionName; private Integer regionLevel; }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/agent/vo/ProvincialAgentListItemVO.java
New file
@@ -0,0 +1,13 @@
package com.tcm.training.admin.agent.vo;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
@Getter @Setter
public class ProvincialAgentListItemVO {
    private Long id; private String agentName; private String province;
    private String contactInfo; private String address; private Integer regionCount;
    private List<String> regionNames; private LocalDateTime createdAt;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
@@ -227,6 +227,16 @@
                null, null, null, 6, "member:user:export", "导出用户按钮"));
        menuIds.add(saveMenu(2117L, 2110L, "解绑微信", "member_user_unbind_wechat", "B",
                null, null, null, 7, "member:user:unbind-wechat", "解绑微信按钮"));
        menuIds.add(saveMenu(2119L, 2100L, "省代理管理", "member_agent_manage", "C",
                "/member/user/agent", "Member/AgentList", null, 12, "member:agent:view", "省代理管理菜单"));
        menuIds.add(saveMenu(2120L, 2119L, "新增省代理", "member_agent_add", "B",
                null, null, null, 1, "member:agent:add", "新增省代理按钮"));
        menuIds.add(saveMenu(2121L, 2119L, "编辑省代理", "member_agent_edit", "B",
                null, null, null, 2, "member:agent:edit", "编辑省代理按钮"));
        menuIds.add(saveMenu(2122L, 2119L, "删除省代理", "member_agent_delete", "B",
                null, null, null, 3, "member:agent:delete", "删除省代理按钮"));
        menuIds.add(saveMenu(2123L, 2119L, "省代理详情", "member_agent_detail", "B",
                null, null, null, 4, "member:agent:detail", "省代理详情按钮"));
        menuIds.add(saveMenu(3000L, 0L, "课程与学习", "live_root", "M",
                "/live", null, "read", 300, null, "课程与学习模块"));
        menuIds.add(saveMenu(3050L, 3000L, "课程管理", "live_course_manage_root", "M",
@@ -239,6 +249,8 @@
                null, null, null, 2, "live:course:edit", "养生直播编辑按钮"));
        menuIds.add(saveMenu(3063L, 3060L, "启用禁用直播", "live_course_status", "B",
                null, null, null, 3, "live:course:status", "养生直播状态按钮"));
        menuIds.add(saveMenu(3064L, 3060L, "设置直播间密码", "live_course_password", "B",
                null, null, null, 4, "live:course:password", "设置直播间密码按钮"));
        menuIds.add(saveMenu(3070L, 3060L, "场次管理", "live_session_manage", "B",
                null, null, null, 10, "live:session:detail", "直播场次管理权限"));
        menuIds.add(saveMenu(3071L, 3060L, "新增场次", "live_session_add", "B",
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/Clinic.java
@@ -58,6 +58,18 @@
    /** 状态:1启用 0禁用 */
    private Integer status;
    @TableField("bank_name")
    private String bankName;
    @TableField("account_number")
    private String accountNumber;
    @TableField("account_holder")
    private String accountHolder;
    @TableField("branch_name")
    private String branchName;
    @TableLogic
    private Integer deleted;
tcm-training-admin/config/routes.ts
@@ -1,4 +1,4 @@
/**
/**
 * @name umi 的路由配置
 * @description 只支持 path,component,routes,redirect,wrappers,name,icon 的配置
 * @param path  path 只支持两种占位符配置,第一种是动态参数 :id 的形式,第二种是 * 通配符,通配符只能出现路由字符串的最后。
@@ -204,6 +204,12 @@
            component: './member/clinic',
          },
          {
            name: '省代理管理',
            path: '/member/user/agent',
            access: 'member:agent:view',
            component: './member/agent',
          },
          {
            name: '诊所详情',
            path: '/member/user/clinic/:id',
            hideInMenu: true,
tcm-training-admin/src/access.js
@@ -6,6 +6,7 @@
  const canViewMemberUserGroup =
    !!permission?.['member:user:view'] ||
    !!permission?.['member:clinic:view'] ||
    !!permission?.['member:agent:view'] ||
    !!permission?.['member:user:member:view'];
  const canViewMemberVipGroup =
    !!permission?.['member:vip-price:view'] || !!permission?.['member:vip-intro:view'];
tcm-training-admin/src/components/RegionCascader/index.tsx
New file
@@ -0,0 +1,155 @@
import { request } from '@umijs/max';
import { Cascader } from 'antd';
import type { CascaderProps } from 'antd/es/cascader';
import React, { useCallback, useEffect, useRef, useState } from 'react';
type RegionRecord = {
  id: number;
  code: string;
  name: string;
  parentId: number;
  level: number;
};
export type RegionCascaderOption = {
  label: string;
  value: string;
  level: number;
  isLeaf: boolean;
  loading?: boolean;
  children?: RegionCascaderOption[];
};
type RegionResponse = {
  data: RegionRecord[];
};
export type RegionCascaderProps = Omit<
  CascaderProps<RegionCascaderOption, 'value', false>,
  'loadData' | 'options'
>;
const ROOT_CACHE_KEY = '__root__';
const toOptions = (regions: RegionRecord[]): RegionCascaderOption[] =>
  regions.map((region) => ({
    label: region.name,
    value: region.code,
    level: region.level,
    isLeaf: region.level >= 3,
  }));
const replaceOption = (
  options: RegionCascaderOption[],
  value: string,
  replacement: RegionCascaderOption,
): RegionCascaderOption[] =>
  options.map((option) => {
    if (option.value === value) {
      return replacement;
    }
    if (!option.children?.length) {
      return option;
    }
    return { ...option, children: replaceOption(option.children, value, replacement) };
  });
const RegionCascader: React.FC<RegionCascaderProps> = ({ value, ...cascaderProps }) => {
  const [options, setOptions] = useState<RegionCascaderOption[]>([]);
  const [loading, setLoading] = useState(true);
  const cacheRef = useRef(new Map<string, RegionCascaderOption[]>());
  const fetchRegions = useCallback(async (parentCode?: string) => {
    const cacheKey = parentCode || ROOT_CACHE_KEY;
    const cached = cacheRef.current.get(cacheKey);
    if (cached) {
      return cached;
    }
    const response = await request<RegionResponse>('/app/register/regions', {
      method: 'GET',
      params: parentCode ? { parentCode } : undefined,
    });
    const nextOptions = toOptions(response.data || []);
    cacheRef.current.set(cacheKey, nextOptions);
    return nextOptions;
  }, []);
  useEffect(() => {
    let active = true;
    const initialize = async () => {
      setLoading(true);
      try {
        let rootOptions = await fetchRegions();
        // 编辑表单传入完整编码路径时,预加载路径以正确显示各级名称。
        if (Array.isArray(value)) {
          for (const parentCode of value.slice(0, -1)) {
            const children = await fetchRegions(parentCode);
            const attachChildren = (items: RegionCascaderOption[]): RegionCascaderOption[] =>
              items.map((item) => {
                if (item.value === parentCode) {
                  return { ...item, children, isLeaf: children.length === 0 };
                }
                return item.children?.length
                  ? { ...item, children: attachChildren(item.children) }
                  : item;
              });
            rootOptions = attachChildren(rootOptions);
          }
        }
        if (active) {
          setOptions(rootOptions);
        }
      } finally {
        if (active) {
          setLoading(false);
        }
      }
    };
    void initialize();
    return () => {
      active = false;
    };
  }, [fetchRegions, value]);
  const loadData = async (selectedOptions: RegionCascaderOption[]) => {
    const target = selectedOptions[selectedOptions.length - 1];
    if (!target || target.isLeaf) {
      return;
    }
    setOptions((current) => replaceOption(current, target.value, { ...target, loading: true }));
    try {
      const children = await fetchRegions(target.value);
      setOptions((current) =>
        replaceOption(current, target.value, {
          ...target,
          loading: false,
          isLeaf: children.length === 0,
          children,
        }),
      );
    } catch (error) {
      setOptions((current) => replaceOption(current, target.value, { ...target, loading: false }));
      throw error;
    }
  };
  return (
    <Cascader<RegionCascaderOption, 'value'>
      {...cascaderProps}
      value={value}
      options={options}
      loadData={loadData}
      loading={loading || cascaderProps.loading}
      placeholder={cascaderProps.placeholder || '请选择省市区'}
    />
  );
};
export default RegionCascader;
tcm-training-admin/src/pages/live/broadcast/index.tsx
@@ -46,6 +46,7 @@
  const canDetail = access['live:course:detail'];
  const canEdit = access['live:course:edit'];
  const canStatus = access['live:course:status'];
  const canPassword = access['live:course:password'];
  const loadList = async () => {
    setLoading(true);
@@ -182,7 +183,7 @@
              {record.status === 1 ? '禁用' : '启用'}
            </button>
          ) : null}
          {index === 0 && canEdit ? (
          {index === 0 && canPassword ? (
            <button type="button" onClick={() => openPasswordModal(record)}>
              直播间密码
            </button>
tcm-training-admin/src/pages/member/agent/index.tsx
New file
@@ -0,0 +1,17 @@
import RegionCascader from '@/components/RegionCascader';
import { PlusOutlined } from '@ant-design/icons';
import { history, useAccess } from '@umijs/max';
import { Button, Descriptions, Form, Input, Modal, Pagination, Space, Table, message } from 'antd';
import React, { useEffect, useState } from 'react';
import { createAgent, deleteAgent, getAgentDetail, getAgentPage, updateAgent } from './service';
type Region={regionCode:string|string[];regionName?:string;regionLevel?:number}; type Item={id:number;agentName:string;province:string;contactInfo?:string;address?:string;regionNames?:string[];regionCount:number;createdAt?:string};
const AgentPage:React.FC=()=>{const access=useAccess();const [search]=Form.useForm();const [form]=Form.useForm();const [list,setList]=useState<Item[]>([]);const [pagination,setPagination]=useState({page:1,pageSize:10,total:0});const [loading,setLoading]=useState(false);const [open,setOpen]=useState(false);const [detail,setDetail]=useState<any>();const [editing,setEditing]=useState<number>();const canView=access['member:agent:view'];
 const load=async(page=1,pageSize=pagination.pageSize)=>{setLoading(true);try{const r=await getAgentPage({page,pageSize,...search.getFieldsValue()});setList(r.data?.list||[]);setPagination({page:Number(r.data?.page||page),pageSize:Number(r.data?.pageSize||pageSize),total:Number(r.data?.total||0)});}finally{setLoading(false);}};useEffect(()=>{if(!canView){history.replace('/Welcome');return;}load();},[canView]);
 const edit=async(id?:number)=>{setEditing(id);setOpen(true);if(id){const r=await getAgentDetail(id);const d=r.data;setDetail(d);form.setFieldsValue({...d,regions:d.regions||[]});}else{setDetail(undefined);form.resetFields();form.setFieldsValue({regions:[{}]});}};
 const submit=async()=>{const v=await form.validateFields();const regions=(v.regions||[]).filter((x:Region)=>x?.regionCode).map((x:Region)=>{const path=Array.isArray(x.regionCode)?x.regionCode:[x.regionCode];return {...x,regionCode:path[path.length-1],regionName:x.regionName||path[path.length-1],regionLevel:path.length};});const data={...v,regions};try{if(editing)await updateAgent(editing,data);else await createAgent(data);message.success(editing?'保存成功':'新增成功');setOpen(false);load(editing?pagination.page:1);}catch(e){message.error((e as Error).message||'保存失败');}};
 const columns=[{title:'序号',width:70,render:(_:any,__ :any,i:number)=>(pagination.page-1)*pagination.pageSize+i+1},{title:'代理负责人',dataIndex:'agentName'},{title:'负责省份',dataIndex:'province'},{title:'负责区域',render:(_:any,r:Item)=>(r.regionNames||[]).join('、')||'-'},{title:'联系信息',dataIndex:'contactInfo',render:(v:string)=>v||'-'},{title:'创建时间',dataIndex:'createdAt',width:180},{title:'操作',render:(_:any,r:Item)=><Space><Button type="link" onClick={()=>{getAgentDetail(r.id).then(x=>{setDetail(x.data);setOpen(true);setEditing(undefined);});}}>详情</Button>{access['member:agent:edit']&&<Button type="link" onClick={()=>edit(r.id)}>编辑</Button>}{access['member:agent:delete']&&<Button type="link" danger onClick={()=>Modal.confirm({title:'确定删除该省代理吗?',onOk:async()=>{await deleteAgent(r.id);message.success('删除成功');load();}})}>删除</Button>}</Space>}];
 return <div style={{padding:24}}><h2>省代理管理</h2><Form form={search} layout="inline" onFinish={()=>load(1)}><Form.Item name="agentName" label="代理负责人"><Input allowClear placeholder="请输入"/></Form.Item><Form.Item name="province" label="负责省份"><Input allowClear placeholder="请输入"/></Form.Item><Button onClick={()=>{search.resetFields();load(1)}}>重置</Button><Button type="primary" htmlType="submit" style={{marginLeft:8}}>查询</Button></Form><div style={{margin:'16px 0'}}>{access['member:agent:add']&&<Button type="primary" icon={<PlusOutlined/>} onClick={()=>edit()}>新增</Button>}</div><Table rowKey="id" columns={columns as any} dataSource={list} loading={loading} pagination={false}/><Pagination style={{marginTop:16,textAlign:'right'}} current={pagination.page} pageSize={pagination.pageSize} total={pagination.total} showSizeChanger onChange={load}/>
 <Modal open={open} title={detail&&!editing?'省代理详情':editing?'编辑省代理':'新增省代理'} width={760} onCancel={()=>{setOpen(false);setDetail(undefined)}} footer={detail&&!editing?null:<><Button onClick={()=>setOpen(false)}>取消</Button><Button type="primary" onClick={submit}>确定</Button></>}>
 {detail&&!editing?<Descriptions bordered column={2} items={[['代理负责人',detail.agentName],['负责省份',detail.province],['负责区域',(detail.regionNames||[]).join('、')||'-'],['开户银行',detail.bankName],['银行账号',detail.accountNumber],['账户名称',detail.accountHolder],['开户支行',detail.branchName],['联系信息',detail.contactInfo],['详细地址',detail.address]].map(([label,children])=>({label,children:children||'-'}))}/>:<Form form={form} labelCol={{span:5}}><Form.Item name="agentName" label="代理负责人" rules={[{required:true,message:'请输入代理负责人'}]}><Input maxLength={50}/></Form.Item><Form.Item name="province" label="负责省份" rules={[{required:true,message:'请输入负责省份'}]}><Input maxLength={20}/></Form.Item><Form.List name="regions">{(fields,{add,remove})=><>{fields.map(({key,name,...rest})=><Space key={key} align="baseline"><Form.Item {...rest} name={[name,'regionCode']} rules={[{required:true,message:'请选择区域'}]}><RegionCascader/></Form.Item><Form.Item {...rest} name={[name,'regionName']} hidden><Input/></Form.Item><Button onClick={()=>remove(name)}>删除</Button></Space>)}<Button type="dashed" onClick={()=>add({})}>添加区域</Button></>}</Form.List><Form.Item name="bankName" label="开户银行"><Input/></Form.Item><Form.Item name="accountNumber" label="银行账号"><Input/></Form.Item><Form.Item name="accountHolder" label="账户名称"><Input/></Form.Item><Form.Item name="branchName" label="开户支行"><Input/></Form.Item><Form.Item name="contactInfo" label="联系信息"><Input/></Form.Item><Form.Item name="address" label="详细地址"><Input.TextArea rows={3}/></Form.Item></Form>}
 </Modal></div>};export default AgentPage;
tcm-training-admin/src/pages/member/agent/service.ts
New file
@@ -0,0 +1,6 @@
import { request } from '@umijs/max';
export const getAgentPage=(params:any)=>request('/provincial-agents',{method:'GET',params});
export const getAgentDetail=(id:number|string)=>request(`/provincial-agents/${id}`,{method:'GET'});
export const createAgent=(data:any)=>request('/provincial-agents',{method:'POST',data});
export const updateAgent=(id:number|string,data:any)=>request(`/provincial-agents/${id}`,{method:'PUT',data});
export const deleteAgent=(id:number|string)=>request(`/provincial-agents/${id}`,{method:'DELETE'});