添加归属人转移功能与部分下拉框展示值的调整,系统弹出框样式调整

main
kangwenjing 2026-04-21 17:16:04 +08:00
parent 25787cbabe
commit b71197fc1c
85 changed files with 5063 additions and 156 deletions

View File

@ -57,6 +57,14 @@ public class ExpansionController {
return ApiResponse.success(expansionService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword));
}
@GetMapping("/opportunity-form-options")
public ApiResponse<ExpansionOverviewDTO> getOpportunityFormOptions(
@RequestHeader("X-User-Id") Long userId,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "limit", required = false) Integer limit) {
return ApiResponse.success(expansionService.getOpportunityFormOptions(CurrentUserUtils.requireCurrentUserId(userId), keyword, limit));
}
@RequestMapping(value = "/sales/duplicate-check", method = {RequestMethod.GET, RequestMethod.POST})
public ApiResponse<ExpansionDuplicateCheckDTO> checkSalesDuplicate(
@RequestHeader("X-User-Id") Long userId,

View File

@ -0,0 +1,39 @@
package com.unis.crm.controller;
import com.unis.crm.common.ApiResponse;
import com.unis.crm.dto.ownertransfer.OwnerTransferPreviewDTO;
import com.unis.crm.dto.ownertransfer.OwnerTransferRequest;
import com.unis.crm.dto.ownertransfer.OwnerTransferResultDTO;
import com.unis.crm.service.OwnerTransferAdminService;
import com.unisbase.common.annotation.Log;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/sys/api/admin/owner-transfer")
public class OwnerTransferAdminController {
private final OwnerTransferAdminService ownerTransferAdminService;
public OwnerTransferAdminController(OwnerTransferAdminService ownerTransferAdminService) {
this.ownerTransferAdminService = ownerTransferAdminService;
}
@GetMapping("/preview")
public ApiResponse<OwnerTransferPreviewDTO> preview(
@RequestParam("fromUserId") Long fromUserId,
@RequestParam("toUserId") Long toUserId) {
return ApiResponse.success(ownerTransferAdminService.preview(fromUserId, toUserId));
}
@PostMapping("/execute")
@Log(type = "系统管理", value = "批量转移CRM归属人")
public ApiResponse<OwnerTransferResultDTO> execute(@Valid @RequestBody OwnerTransferRequest request) {
return ApiResponse.success(ownerTransferAdminService.transfer(request));
}
}

View File

@ -0,0 +1,49 @@
package com.unis.crm.controller;
import com.unis.crm.common.ApiResponse;
import com.unis.crm.common.CurrentUserUtils;
import com.unis.crm.dto.ownertransfer.OwnerTransferPreviewDTO;
import com.unis.crm.dto.ownertransfer.OwnerTransferRequest;
import com.unis.crm.dto.ownertransfer.OwnerTransferResultDTO;
import com.unis.crm.dto.ownertransfer.OwnerTransferUserDTO;
import com.unis.crm.service.OwnerTransferAdminService;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/owner-transfer")
public class OwnerTransferController {
private final OwnerTransferAdminService ownerTransferAdminService;
public OwnerTransferController(OwnerTransferAdminService ownerTransferAdminService) {
this.ownerTransferAdminService = ownerTransferAdminService;
}
@GetMapping("/target-users")
public ApiResponse<List<OwnerTransferUserDTO>> listTargetUsers(@RequestHeader("X-User-Id") Long userId) {
return ApiResponse.success(ownerTransferAdminService.listTargetUsersForCurrentUser(CurrentUserUtils.requireCurrentUserId(userId)));
}
@GetMapping("/preview")
public ApiResponse<OwnerTransferPreviewDTO> preview(
@RequestHeader("X-User-Id") Long userId,
@RequestParam("fromUserId") Long fromUserId,
@RequestParam("toUserId") Long toUserId) {
return ApiResponse.success(ownerTransferAdminService.previewForCurrentUser(CurrentUserUtils.requireCurrentUserId(userId), fromUserId, toUserId));
}
@PostMapping("/execute")
public ApiResponse<OwnerTransferResultDTO> execute(
@RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody OwnerTransferRequest request) {
return ApiResponse.success(ownerTransferAdminService.transferForCurrentUser(CurrentUserUtils.requireCurrentUserId(userId), request));
}
}

View File

@ -0,0 +1,23 @@
package com.unis.crm.dto.opportunity;
public class OpportunityCustomerSnapshotDTO {
private Long customerId;
private String customerName;
public Long getCustomerId() {
return customerId;
}
public void setCustomerId(Long customerId) {
this.customerId = customerId;
}
public String getCustomerName() {
return customerName;
}
public void setCustomerName(String customerName) {
this.customerName = customerName;
}
}

View File

@ -0,0 +1,23 @@
package com.unis.crm.dto.ownertransfer;
public class OwnerTransferConflictDTO {
private String employeeNo;
private String candidateName;
public String getEmployeeNo() {
return employeeNo;
}
public void setEmployeeNo(String employeeNo) {
this.employeeNo = employeeNo;
}
public String getCandidateName() {
return candidateName;
}
public void setCandidateName(String candidateName) {
this.candidateName = candidateName;
}
}

View File

@ -0,0 +1,41 @@
package com.unis.crm.dto.ownertransfer;
public class OwnerTransferItemDTO {
private Long id;
private String name;
private String code;
private boolean conflict;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public boolean isConflict() {
return conflict;
}
public void setConflict(boolean conflict) {
this.conflict = conflict;
}
}

View File

@ -0,0 +1,116 @@
package com.unis.crm.dto.ownertransfer;
import java.util.ArrayList;
import java.util.List;
public class OwnerTransferPreviewDTO {
private Long tenantId;
private Long fromUserId;
private String fromUserName;
private Long toUserId;
private String toUserName;
private int opportunityCount;
private int salesExpansionCount;
private int channelExpansionCount;
private List<OwnerTransferConflictDTO> salesConflicts = new ArrayList<>();
private List<OwnerTransferItemDTO> opportunities = new ArrayList<>();
private List<OwnerTransferItemDTO> salesExpansions = new ArrayList<>();
private List<OwnerTransferItemDTO> channelExpansions = new ArrayList<>();
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
public Long getFromUserId() {
return fromUserId;
}
public void setFromUserId(Long fromUserId) {
this.fromUserId = fromUserId;
}
public String getFromUserName() {
return fromUserName;
}
public void setFromUserName(String fromUserName) {
this.fromUserName = fromUserName;
}
public Long getToUserId() {
return toUserId;
}
public void setToUserId(Long toUserId) {
this.toUserId = toUserId;
}
public String getToUserName() {
return toUserName;
}
public void setToUserName(String toUserName) {
this.toUserName = toUserName;
}
public int getOpportunityCount() {
return opportunityCount;
}
public void setOpportunityCount(int opportunityCount) {
this.opportunityCount = opportunityCount;
}
public int getSalesExpansionCount() {
return salesExpansionCount;
}
public void setSalesExpansionCount(int salesExpansionCount) {
this.salesExpansionCount = salesExpansionCount;
}
public int getChannelExpansionCount() {
return channelExpansionCount;
}
public void setChannelExpansionCount(int channelExpansionCount) {
this.channelExpansionCount = channelExpansionCount;
}
public List<OwnerTransferConflictDTO> getSalesConflicts() {
return salesConflicts;
}
public void setSalesConflicts(List<OwnerTransferConflictDTO> salesConflicts) {
this.salesConflicts = salesConflicts;
}
public List<OwnerTransferItemDTO> getOpportunities() {
return opportunities;
}
public void setOpportunities(List<OwnerTransferItemDTO> opportunities) {
this.opportunities = opportunities;
}
public List<OwnerTransferItemDTO> getSalesExpansions() {
return salesExpansions;
}
public void setSalesExpansions(List<OwnerTransferItemDTO> salesExpansions) {
this.salesExpansions = salesExpansions;
}
public List<OwnerTransferItemDTO> getChannelExpansions() {
return channelExpansions;
}
public void setChannelExpansions(List<OwnerTransferItemDTO> channelExpansions) {
this.channelExpansions = channelExpansions;
}
}

View File

@ -0,0 +1,65 @@
package com.unis.crm.dto.ownertransfer;
import jakarta.validation.constraints.NotNull;
public class OwnerTransferRequest {
@NotNull(message = "原归属人不能为空")
private Long fromUserId;
@NotNull(message = "新归属人不能为空")
private Long toUserId;
private boolean transferOpportunities;
private boolean transferSalesExpansions;
private boolean transferChannelExpansions;
private OwnerTransferSelection selection;
public Long getFromUserId() {
return fromUserId;
}
public void setFromUserId(Long fromUserId) {
this.fromUserId = fromUserId;
}
public Long getToUserId() {
return toUserId;
}
public void setToUserId(Long toUserId) {
this.toUserId = toUserId;
}
public boolean isTransferOpportunities() {
return transferOpportunities;
}
public void setTransferOpportunities(boolean transferOpportunities) {
this.transferOpportunities = transferOpportunities;
}
public boolean isTransferSalesExpansions() {
return transferSalesExpansions;
}
public void setTransferSalesExpansions(boolean transferSalesExpansions) {
this.transferSalesExpansions = transferSalesExpansions;
}
public boolean isTransferChannelExpansions() {
return transferChannelExpansions;
}
public void setTransferChannelExpansions(boolean transferChannelExpansions) {
this.transferChannelExpansions = transferChannelExpansions;
}
public OwnerTransferSelection getSelection() {
return selection;
}
public void setSelection(OwnerTransferSelection selection) {
this.selection = selection;
}
}

View File

@ -0,0 +1,59 @@
package com.unis.crm.dto.ownertransfer;
public class OwnerTransferResultDTO {
private Long tenantId;
private Long fromUserId;
private Long toUserId;
private int transferredOpportunityCount;
private int transferredSalesExpansionCount;
private int transferredChannelExpansionCount;
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
public Long getFromUserId() {
return fromUserId;
}
public void setFromUserId(Long fromUserId) {
this.fromUserId = fromUserId;
}
public Long getToUserId() {
return toUserId;
}
public void setToUserId(Long toUserId) {
this.toUserId = toUserId;
}
public int getTransferredOpportunityCount() {
return transferredOpportunityCount;
}
public void setTransferredOpportunityCount(int transferredOpportunityCount) {
this.transferredOpportunityCount = transferredOpportunityCount;
}
public int getTransferredSalesExpansionCount() {
return transferredSalesExpansionCount;
}
public void setTransferredSalesExpansionCount(int transferredSalesExpansionCount) {
this.transferredSalesExpansionCount = transferredSalesExpansionCount;
}
public int getTransferredChannelExpansionCount() {
return transferredChannelExpansionCount;
}
public void setTransferredChannelExpansionCount(int transferredChannelExpansionCount) {
this.transferredChannelExpansionCount = transferredChannelExpansionCount;
}
}

View File

@ -0,0 +1,35 @@
package com.unis.crm.dto.ownertransfer;
import java.util.ArrayList;
import java.util.List;
public class OwnerTransferSelection {
private List<Long> opportunityIds = new ArrayList<>();
private List<Long> salesExpansionIds = new ArrayList<>();
private List<Long> channelExpansionIds = new ArrayList<>();
public List<Long> getOpportunityIds() {
return opportunityIds;
}
public void setOpportunityIds(List<Long> opportunityIds) {
this.opportunityIds = opportunityIds;
}
public List<Long> getSalesExpansionIds() {
return salesExpansionIds;
}
public void setSalesExpansionIds(List<Long> salesExpansionIds) {
this.salesExpansionIds = salesExpansionIds;
}
public List<Long> getChannelExpansionIds() {
return channelExpansionIds;
}
public void setChannelExpansionIds(List<Long> channelExpansionIds) {
this.channelExpansionIds = channelExpansionIds;
}
}

View File

@ -0,0 +1,50 @@
package com.unis.crm.dto.ownertransfer;
public class OwnerTransferUserDTO {
private Long userId;
private String username;
private String displayName;
private Integer status;
private Long tenantId;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
}

View File

@ -35,6 +35,16 @@ public interface ExpansionMapper {
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
List<ChannelExpansionItemDTO> selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
List<SalesExpansionItemDTO> selectSalesExpansionsForTenant(
@Param("tenantId") Long tenantId,
@Param("keyword") String keyword,
@Param("limit") Integer limit);
List<ChannelExpansionItemDTO> selectChannelExpansionsForTenant(
@Param("tenantId") Long tenantId,
@Param("keyword") String keyword,
@Param("limit") Integer limit);
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
List<ExpansionFollowUpDTO> selectSalesFollowUps(@Param("userId") Long userId, @Param("bizIds") List<Long> bizIds);

View File

@ -3,6 +3,7 @@ package com.unis.crm.mapper;
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.CurrentUserAccountDTO;
import com.unis.crm.dto.opportunity.OpportunityCustomerSnapshotDTO;
import com.unis.crm.dto.opportunity.OpportunityDictOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO;
import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO;
@ -43,6 +44,11 @@ public interface OpportunityMapper {
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
Long selectOwnedCustomerIdByName(@Param("userId") Long userId, @Param("customerName") String customerName);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
OpportunityCustomerSnapshotDTO selectOpportunityCustomerSnapshot(
@Param("userId") Long userId,
@Param("opportunityId") Long opportunityId);
int insertCustomer(
@Param("id") Long id,
@Param("userId") Long userId,
@ -63,6 +69,9 @@ public interface OpportunityMapper {
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
Boolean selectArchived(@Param("userId") Long userId, @Param("id") Long id);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
Boolean selectPushedToOms(@Param("userId") Long userId, @Param("id") Long id);

View File

@ -19,6 +19,8 @@ public interface ExpansionService {
ExpansionOverviewDTO getOverview(Long userId, String keyword);
ExpansionOverviewDTO getOpportunityFormOptions(Long userId, String keyword, Integer limit);
ExpansionDuplicateCheckDTO checkSalesEmployeeNoDuplicate(Long userId, String employeeNo, Long excludeId);
ExpansionDuplicateCheckDTO checkChannelNameDuplicate(Long userId, String channelName, Long excludeId);

View File

@ -0,0 +1,486 @@
package com.unis.crm.service;
import com.unis.crm.common.BusinessException;
import com.unis.crm.common.UnauthorizedException;
import com.unis.crm.dto.ownertransfer.OwnerTransferConflictDTO;
import com.unis.crm.dto.ownertransfer.OwnerTransferItemDTO;
import com.unis.crm.dto.ownertransfer.OwnerTransferPreviewDTO;
import com.unis.crm.dto.ownertransfer.OwnerTransferRequest;
import com.unis.crm.dto.ownertransfer.OwnerTransferResultDTO;
import com.unis.crm.dto.ownertransfer.OwnerTransferSelection;
import com.unis.crm.dto.ownertransfer.OwnerTransferUserDTO;
import com.unisbase.security.PermissionService;
import com.unisbase.security.SpringSecurityTenantProvider;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OwnerTransferAdminService {
private static final String VIEW_PERM = "owner_transfer:view";
private static final String EXECUTE_PERM = "owner_transfer:execute";
private static final String OPPORTUNITY_TABLE = "crm_opportunity";
private static final String SALES_EXPANSION_TABLE = "crm_sales_expansion";
private static final String CHANNEL_EXPANSION_TABLE = "crm_channel_expansion";
private final JdbcTemplate jdbcTemplate;
private final PermissionService permissionService;
private final SpringSecurityTenantProvider tenantProvider;
public OwnerTransferAdminService(
JdbcTemplate jdbcTemplate,
PermissionService permissionService,
SpringSecurityTenantProvider tenantProvider) {
this.jdbcTemplate = jdbcTemplate;
this.permissionService = permissionService;
this.tenantProvider = tenantProvider;
}
public OwnerTransferPreviewDTO preview(Long fromUserId, Long toUserId) {
Long tenantId = requireTenantId();
requirePermission(VIEW_PERM, "无权查看归属人转移配置");
return loadPreview(tenantId, fromUserId, toUserId);
}
public OwnerTransferPreviewDTO previewForCurrentUser(Long currentUserId, Long fromUserId, Long toUserId) {
Long tenantId = requireTenantId();
Long effectiveFromUserId = requireSelfSourceUser(currentUserId, fromUserId);
return loadPreview(tenantId, effectiveFromUserId, toUserId);
}
private OwnerTransferPreviewDTO loadPreview(Long tenantId, Long fromUserId, Long toUserId) {
TenantUser fromUser = requireUserInTenant(tenantId, fromUserId, false, "原归属人不存在或不属于当前租户");
TenantUser toUser = requireUserInTenant(tenantId, toUserId, true, "新归属人不存在、已停用或不属于当前租户");
validateDifferentUsers(fromUser.userId(), toUser.userId());
return buildPreview(tenantId, fromUser, toUser);
}
private OwnerTransferPreviewDTO buildPreview(Long tenantId, TenantUser fromUser, TenantUser toUser) {
OwnerTransferPreviewDTO dto = new OwnerTransferPreviewDTO();
dto.setTenantId(tenantId);
dto.setFromUserId(fromUser.userId());
dto.setFromUserName(fromUser.displayName());
dto.setToUserId(toUser.userId());
dto.setToUserName(toUser.displayName());
dto.setOpportunityCount(countByOwner(OPPORTUNITY_TABLE, "o", fromUser.userId(), tenantId));
dto.setSalesExpansionCount(countByOwner(SALES_EXPANSION_TABLE, "s", fromUser.userId(), tenantId));
dto.setChannelExpansionCount(countByOwner(CHANNEL_EXPANSION_TABLE, "c", fromUser.userId(), tenantId));
dto.setSalesConflicts(findSalesConflicts(fromUser.userId(), toUser.userId(), tenantId));
dto.setOpportunities(findOpportunityItems(fromUser.userId(), tenantId));
dto.setSalesExpansions(findSalesExpansionItems(fromUser.userId(), dto.getSalesConflicts(), tenantId));
dto.setChannelExpansions(findChannelExpansionItems(fromUser.userId(), tenantId));
return dto;
}
public List<OwnerTransferUserDTO> listTargetUsersForCurrentUser(Long currentUserId) {
if (currentUserId == null || currentUserId <= 0) {
throw new UnauthorizedException("登录已失效,请重新登录");
}
Long tenantId = requireTenantId();
return jdbcTemplate.query("""
select
u.user_id,
coalesce(nullif(u.username, ''), cast(u.user_id as varchar)) as username,
coalesce(nullif(u.display_name, ''), nullif(u.username, ''), cast(u.user_id as varchar)) as display_name,
coalesce(u.status, 1) as status,
tu.tenant_id
from sys_user u
join sys_tenant_user tu on tu.user_id = u.user_id
where tu.tenant_id = ?
and coalesce(u.is_deleted, 0) = 0
and coalesce(tu.is_deleted, 0) = 0
and coalesce(u.is_platform_admin, false) = false
and not exists (
select 1
from sys_user_role ur
join sys_role r on r.role_id = ur.role_id
where ur.user_id = u.user_id
and coalesce(ur.is_deleted, 0) = 0
and coalesce(r.is_deleted, 0) = 0
and r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
)
order by
case when coalesce(u.status, 1) = 1 then 0 else 1 end,
display_name asc,
u.user_id asc
""",
(resultSet, rowNum) -> {
OwnerTransferUserDTO dto = new OwnerTransferUserDTO();
dto.setUserId(resultSet.getLong("user_id"));
dto.setUsername(resultSet.getString("username"));
dto.setDisplayName(resultSet.getString("display_name"));
dto.setStatus(resultSet.getInt("status"));
dto.setTenantId(resultSet.getLong("tenant_id"));
return dto;
},
tenantId);
}
@Transactional
public OwnerTransferResultDTO transfer(OwnerTransferRequest request) {
if (request == null) {
throw new BusinessException("转移请求不能为空");
}
if (!request.isTransferOpportunities() && !request.isTransferSalesExpansions() && !request.isTransferChannelExpansions()) {
throw new BusinessException("请至少选择一类转移对象");
}
requirePermission(EXECUTE_PERM, "无权执行归属人转移");
Long tenantId = requireTenantId();
OwnerTransferPreviewDTO preview = loadPreview(tenantId, request.getFromUserId(), request.getToUserId());
OwnerTransferSelection selection = sanitizeSelection(request.getSelection());
validateSelection(preview, request, selection);
return doTransfer(request, preview, selection);
}
@Transactional
public OwnerTransferResultDTO transferForCurrentUser(Long currentUserId, OwnerTransferRequest request) {
if (request == null) {
throw new BusinessException("转移请求不能为空");
}
if (!request.isTransferOpportunities() && !request.isTransferSalesExpansions() && !request.isTransferChannelExpansions()) {
throw new BusinessException("请至少选择一类转移对象");
}
Long tenantId = requireTenantId();
Long effectiveFromUserId = requireSelfSourceUser(currentUserId, request.getFromUserId());
OwnerTransferPreviewDTO preview = loadPreview(tenantId, effectiveFromUserId, request.getToUserId());
OwnerTransferSelection selection = sanitizeSelection(request.getSelection());
validateSelection(preview, request, selection);
return doTransfer(request, preview, selection);
}
private OwnerTransferResultDTO doTransfer(
OwnerTransferRequest request,
OwnerTransferPreviewDTO preview,
OwnerTransferSelection selection) {
OwnerTransferResultDTO result = new OwnerTransferResultDTO();
result.setTenantId(preview.getTenantId());
result.setFromUserId(preview.getFromUserId());
result.setToUserId(preview.getToUserId());
if (request.isTransferOpportunities()) {
result.setTransferredOpportunityCount(updateOwner(
OPPORTUNITY_TABLE,
preview.getTenantId(),
preview.getFromUserId(),
preview.getToUserId(),
selection.getOpportunityIds()));
}
if (request.isTransferSalesExpansions()) {
result.setTransferredSalesExpansionCount(updateOwner(
SALES_EXPANSION_TABLE,
preview.getTenantId(),
preview.getFromUserId(),
preview.getToUserId(),
selection.getSalesExpansionIds()));
}
if (request.isTransferChannelExpansions()) {
result.setTransferredChannelExpansionCount(updateOwner(
CHANNEL_EXPANSION_TABLE,
preview.getTenantId(),
preview.getFromUserId(),
preview.getToUserId(),
selection.getChannelExpansionIds()));
}
return result;
}
private int countByOwner(String tableName, String tableAlias, Long ownerUserId, Long tenantId) {
Integer count = jdbcTemplate.queryForObject(
"select count(1) from " + tableName + " " + tableAlias
+ " where " + tableAlias + ".owner_user_id = ? and "
+ ownerInTenantClause(tableAlias + ".owner_user_id"),
Integer.class,
ownerUserId,
tenantId);
return count == null ? 0 : count;
}
private int updateOwner(String tableName, Long tenantId, Long fromUserId, Long toUserId, List<Long> ids) {
List<Long> normalizedIds = normalizeIds(ids);
if (normalizedIds.isEmpty()) {
return jdbcTemplate.update(
"update " + tableName + " set owner_user_id = ?, updated_at = now() where owner_user_id = ? and "
+ ownerInTenantClause(tableName + ".owner_user_id"),
toUserId,
fromUserId,
tenantId);
}
List<Object> params = new ArrayList<>();
params.add(toUserId);
params.add(fromUserId);
params.add(tenantId);
params.addAll(normalizedIds);
return jdbcTemplate.update(
"update " + tableName + " set owner_user_id = ?, updated_at = now() where owner_user_id = ? and "
+ ownerInTenantClause(tableName + ".owner_user_id")
+ " and id in ("
+ buildPlaceholders(normalizedIds.size()) + ")",
params.toArray());
}
private List<OwnerTransferConflictDTO> findSalesConflicts(Long fromUserId, Long toUserId, Long tenantId) {
return jdbcTemplate.query("""
select distinct
s.employee_no as employee_no,
coalesce(s.candidate_name, '') as candidate_name
from crm_sales_expansion s
where s.owner_user_id = ?
and """ + ownerInTenantClause("s.owner_user_id") + """
and exists (
select 1
from crm_sales_expansion t
where t.owner_user_id = ?
and """ + ownerInTenantClause("t.owner_user_id") + """
and t.employee_no = s.employee_no
)
order by s.employee_no asc
""",
(resultSet, rowNum) -> {
OwnerTransferConflictDTO dto = new OwnerTransferConflictDTO();
dto.setEmployeeNo(resultSet.getString("employee_no"));
dto.setCandidateName(resultSet.getString("candidate_name"));
return dto;
},
fromUserId,
tenantId,
toUserId,
tenantId);
}
private List<OwnerTransferItemDTO> findOpportunityItems(Long fromUserId, Long tenantId) {
return jdbcTemplate.query("""
select
o.id as id,
coalesce(nullif(o.opportunity_name, ''), concat('#', o.id)) as item_name
from crm_opportunity o
where o.owner_user_id = ?
and """ + ownerInTenantClause("o.owner_user_id") + """
order by coalesce(o.updated_at, o.created_at) desc, o.id desc
""",
(resultSet, rowNum) -> item(
resultSet.getLong("id"),
resultSet.getString("item_name"),
null,
false),
fromUserId,
tenantId);
}
private List<OwnerTransferItemDTO> findSalesExpansionItems(Long fromUserId, List<OwnerTransferConflictDTO> conflicts, Long tenantId) {
Set<String> conflictEmployeeNos = conflicts.stream()
.map(OwnerTransferConflictDTO::getEmployeeNo)
.filter(Objects::nonNull)
.map(String::trim)
.filter(value -> !value.isEmpty())
.collect(Collectors.toCollection(HashSet::new));
return jdbcTemplate.query("""
select
s.id as id,
coalesce(nullif(s.candidate_name, ''), concat('#', s.id)) as item_name,
coalesce(s.employee_no, '') as employee_no
from crm_sales_expansion s
where s.owner_user_id = ?
and """ + ownerInTenantClause("s.owner_user_id") + """
order by coalesce(s.updated_at, s.created_at) desc, s.id desc
""",
(resultSet, rowNum) -> {
String employeeNo = resultSet.getString("employee_no");
return item(
resultSet.getLong("id"),
resultSet.getString("item_name"),
employeeNo,
employeeNo != null && conflictEmployeeNos.contains(employeeNo.trim()));
},
fromUserId,
tenantId);
}
private List<OwnerTransferItemDTO> findChannelExpansionItems(Long fromUserId, Long tenantId) {
return jdbcTemplate.query("""
select
c.id as id,
coalesce(nullif(c.channel_name, ''), concat('#', c.id)) as item_name
from crm_channel_expansion c
where c.owner_user_id = ?
and """ + ownerInTenantClause("c.owner_user_id") + """
order by coalesce(c.updated_at, c.created_at) desc, c.id desc
""",
(resultSet, rowNum) -> item(
resultSet.getLong("id"),
resultSet.getString("item_name"),
null,
false),
fromUserId,
tenantId);
}
private OwnerTransferItemDTO item(Long id, String name, String code, boolean conflict) {
OwnerTransferItemDTO dto = new OwnerTransferItemDTO();
dto.setId(id);
dto.setName(name);
dto.setCode(code);
dto.setConflict(conflict);
return dto;
}
private void validateSelection(OwnerTransferPreviewDTO preview, OwnerTransferRequest request, OwnerTransferSelection selection) {
validateSelectionIds(selection.getOpportunityIds(), preview.getOpportunities(), "所选商机不存在或不属于原归属人");
validateSelectionIds(selection.getSalesExpansionIds(), preview.getSalesExpansions(), "所选拓展销售人员不存在或不属于原归属人");
validateSelectionIds(selection.getChannelExpansionIds(), preview.getChannelExpansions(), "所选拓展渠道不存在或不属于原归属人");
if (!request.isTransferSalesExpansions()) {
return;
}
if (selection.getSalesExpansionIds().isEmpty()) {
if (!preview.getSalesConflicts().isEmpty()) {
throw new BusinessException(buildSalesConflictMessage(preview.getSalesConflicts()));
}
return;
}
Set<Long> conflictIds = preview.getSalesExpansions().stream()
.filter(OwnerTransferItemDTO::isConflict)
.map(OwnerTransferItemDTO::getId)
.collect(Collectors.toSet());
boolean hasConflictSelection = selection.getSalesExpansionIds().stream().anyMatch(conflictIds::contains);
if (hasConflictSelection) {
throw new BusinessException(buildSalesConflictMessage(preview.getSalesConflicts()));
}
}
private void validateSelectionIds(List<Long> selectedIds, List<OwnerTransferItemDTO> availableItems, String message) {
if (selectedIds.isEmpty()) {
return;
}
Set<Long> availableIds = availableItems.stream()
.map(OwnerTransferItemDTO::getId)
.collect(Collectors.toSet());
boolean invalid = selectedIds.stream().anyMatch(id -> !availableIds.contains(id));
if (invalid) {
throw new BusinessException(message);
}
}
private OwnerTransferSelection sanitizeSelection(OwnerTransferSelection selection) {
OwnerTransferSelection normalized = selection == null ? new OwnerTransferSelection() : selection;
normalized.setOpportunityIds(normalizeIds(normalized.getOpportunityIds()));
normalized.setSalesExpansionIds(normalizeIds(normalized.getSalesExpansionIds()));
normalized.setChannelExpansionIds(normalizeIds(normalized.getChannelExpansionIds()));
return normalized;
}
private List<Long> normalizeIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
return ids.stream()
.filter(Objects::nonNull)
.filter(id -> id > 0)
.distinct()
.collect(Collectors.toList());
}
private String buildPlaceholders(int size) {
return java.util.stream.IntStream.range(0, size)
.mapToObj(index -> "?")
.collect(Collectors.joining(", "));
}
private String ownerInTenantClause(String ownerColumnExpression) {
// Keep surrounding spaces so the fragment remains valid when concatenated
// into both plain strings and text blocks.
return " exists (select 1 from sys_tenant_user tu where tu.user_id = " + ownerColumnExpression
+ " and tu.tenant_id = ? and coalesce(tu.is_deleted, 0) = 0) ";
}
private TenantUser requireUserInTenant(Long tenantId, Long userId, boolean requireEnabled, String message) {
if (userId == null || userId <= 0) {
throw new BusinessException(message);
}
List<TenantUser> users = jdbcTemplate.query("""
select
u.user_id,
coalesce(nullif(u.display_name, ''), nullif(u.username, ''), cast(u.user_id as varchar)) as display_name,
coalesce(u.status, 1) as status
from sys_user u
join sys_tenant_user tu on tu.user_id = u.user_id
where tu.tenant_id = ?
and u.user_id = ?
and coalesce(u.is_deleted, 0) = 0
and coalesce(tu.is_deleted, 0) = 0
limit 1
""", (resultSet, rowNum) -> new TenantUser(
resultSet.getLong("user_id"),
resultSet.getString("display_name"),
resultSet.getInt("status")),
tenantId,
userId);
TenantUser user = users.isEmpty() ? null : users.get(0);
if (user == null) {
throw new BusinessException(message);
}
if (requireEnabled && user.status() == 0) {
throw new BusinessException("新归属人已停用,无法接收归属数据");
}
return user;
}
private Long requireSelfSourceUser(Long currentUserId, Long fromUserId) {
if (currentUserId == null || currentUserId <= 0) {
throw new UnauthorizedException("登录已失效,请重新登录");
}
if (!Objects.equals(currentUserId, fromUserId)) {
throw new BusinessException("原归属人仅支持当前登录账号");
}
return currentUserId;
}
private void validateDifferentUsers(Long fromUserId, Long toUserId) {
if (Objects.equals(fromUserId, toUserId)) {
throw new BusinessException("原归属人和新归属人不能相同");
}
}
private Long requireTenantId() {
Long tenantId = tenantProvider.getCurrentTenantId();
if (tenantId == null || tenantId <= 0) {
throw new BusinessException("请先切换到具体租户后再操作归属人转移");
}
return tenantId;
}
private void requirePermission(String perm, String message) {
if (!permissionService.hasPermi(perm)) {
throw new UnauthorizedException(message);
}
}
private String buildSalesConflictMessage(List<OwnerTransferConflictDTO> conflicts) {
String summary = conflicts.stream()
.map(item -> {
String employeeNo = item.getEmployeeNo() == null ? "" : item.getEmployeeNo().trim();
String candidateName = item.getCandidateName() == null ? "" : item.getCandidateName().trim();
if (!candidateName.isEmpty()) {
return employeeNo + "(" + candidateName + ")";
}
return employeeNo;
})
.filter(item -> !item.isEmpty())
.limit(10)
.collect(Collectors.joining("、"));
if (summary.isEmpty()) {
return "目标归属人下已存在相同工号的拓展销售人员,请先处理冲突后再试";
}
return String.format(Locale.ROOT, "目标归属人下已存在相同工号的拓展销售人员,请先处理冲突后再试:%s", summary);
}
private record TenantUser(Long userId, String displayName, int status) {
}
}

View File

@ -18,6 +18,7 @@ import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
import com.unis.crm.mapper.ExpansionMapper;
import com.unis.crm.service.ExpansionService;
import com.unisbase.security.SpringSecurityTenantProvider;
import java.math.BigDecimal;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
@ -38,6 +39,7 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class ExpansionServiceImpl implements ExpansionService {
private static final int OPPORTUNITY_FORM_DEFAULT_LIMIT = 20;
private static final String SALES_DUPLICATE_MESSAGE = "工号重复,请确认该人员是否已存在!";
private static final String CHANNEL_DUPLICATE_MESSAGE = "渠道重复,请确认该渠道是否已存在!";
private static final String OFFICE_TYPE_CODE = "tz_bsc";
@ -50,9 +52,11 @@ public class ExpansionServiceImpl implements ExpansionService {
private static final Logger log = LoggerFactory.getLogger(ExpansionServiceImpl.class);
private final ExpansionMapper expansionMapper;
private final SpringSecurityTenantProvider tenantProvider;
public ExpansionServiceImpl(ExpansionMapper expansionMapper) {
public ExpansionServiceImpl(ExpansionMapper expansionMapper, SpringSecurityTenantProvider tenantProvider) {
this.expansionMapper = expansionMapper;
this.tenantProvider = tenantProvider;
}
@Override
@ -103,6 +107,19 @@ public class ExpansionServiceImpl implements ExpansionService {
return new ExpansionOverviewDTO(salesItems, channelItems);
}
@Override
public ExpansionOverviewDTO getOpportunityFormOptions(Long userId, String keyword, Integer limit) {
if (userId == null || userId <= 0) {
throw new BusinessException("登录用户不存在");
}
Long tenantId = requireTenantId();
String normalizedKeyword = normalizeKeyword(keyword);
int normalizedLimit = normalizeOpportunityFormLimit(limit);
List<SalesExpansionItemDTO> salesItems = expansionMapper.selectSalesExpansionsForTenant(tenantId, normalizedKeyword, normalizedLimit);
List<ChannelExpansionItemDTO> channelItems = expansionMapper.selectChannelExpansionsForTenant(tenantId, normalizedKeyword, normalizedLimit);
return new ExpansionOverviewDTO(salesItems, channelItems);
}
@Override
public ExpansionDuplicateCheckDTO checkSalesEmployeeNoDuplicate(Long userId, String employeeNo, Long excludeId) {
String normalizedEmployeeNo = trimToNull(employeeNo);
@ -303,6 +320,21 @@ public class ExpansionServiceImpl implements ExpansionService {
return expansionMapper.selectDictItems(typeCode);
}
private Long requireTenantId() {
Long tenantId = tenantProvider.getCurrentTenantId();
if (tenantId == null || tenantId <= 0) {
throw new BusinessException("未找到当前租户");
}
return tenantId;
}
private int normalizeOpportunityFormLimit(Integer limit) {
if (limit == null || limit <= 0) {
return OPPORTUNITY_FORM_DEFAULT_LIMIT;
}
return Math.min(limit, 100);
}
private void fillChannelDisplayFields(List<ChannelExpansionItemDTO> channelItems) {
if (channelItems == null || channelItems.isEmpty()) {
return;

View File

@ -6,6 +6,7 @@ import com.unis.crm.dto.opportunity.CurrentUserAccountDTO;
import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityCustomerSnapshotDTO;
import com.unis.crm.dto.opportunity.OpportunityDictOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
import com.unis.crm.dto.opportunity.OpportunityFollowUpDTO;
@ -36,6 +37,8 @@ public class OpportunityServiceImpl implements OpportunityService {
private static final String OPERATOR_TYPE_CODE = "sj_yzf";
private static final String OPPORTUNITY_TYPE_CODE = "sj_jslx";
private static final String CONFIDENCE_TYPE_CODE = "sj_xmbwd";
private static final String ARCHIVED_OPPORTUNITY_EDIT_MESSAGE = "已签单商机不允许编辑";
private static final String ARCHIVED_OPPORTUNITY_PUSH_MESSAGE = "已签单商机不允许推送";
private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class);
private final OpportunityMapper opportunityMapper;
@ -100,11 +103,7 @@ public class OpportunityServiceImpl implements OpportunityService {
@Transactional
public Long createOpportunity(Long userId, CreateOpportunityRequest request) {
fillDefaults(request);
Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, request.getCustomerName().trim());
if (customerId == null) {
customerId = IdWorker.getId();
opportunityMapper.insertCustomer(customerId, userId, request.getCustomerName().trim(), request.getSource());
}
Long customerId = resolveOrCreateOwnedCustomerId(userId, request.getCustomerName().trim(), request.getSource());
opportunityMapper.insertOpportunity(userId, customerId, request);
if (request.getId() == null) {
@ -124,13 +123,11 @@ public class OpportunityServiceImpl implements OpportunityService {
if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) {
throw new BusinessException("无权编辑该商机");
}
ensureOpportunityNotArchived(userId, opportunityId, ARCHIVED_OPPORTUNITY_EDIT_MESSAGE);
fillDefaults(request);
Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, request.getCustomerName().trim());
if (customerId == null) {
customerId = IdWorker.getId();
opportunityMapper.insertCustomer(customerId, userId, request.getCustomerName().trim(), request.getSource());
}
OpportunityCustomerSnapshotDTO currentCustomer = opportunityMapper.selectOpportunityCustomerSnapshot(userId, opportunityId);
Long customerId = resolveCustomerIdForUpdate(userId, request, currentCustomer);
int updated = opportunityMapper.updateOpportunity(userId, opportunityId, customerId, request);
if (updated <= 0) {
@ -162,6 +159,7 @@ public class OpportunityServiceImpl implements OpportunityService {
if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) {
throw new BusinessException("无权操作该商机");
}
ensureOpportunityNotArchived(userId, opportunityId, ARCHIVED_OPPORTUNITY_PUSH_MESSAGE);
OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId);
if (pushData == null) {
@ -348,6 +346,38 @@ public class OpportunityServiceImpl implements OpportunityService {
validateOperatorRelations(request.getOperatorName(), request.getSalesExpansionId(), request.getChannelExpansionId());
}
private void ensureOpportunityNotArchived(Long userId, Long opportunityId, String message) {
if (Boolean.TRUE.equals(opportunityMapper.selectArchived(userId, opportunityId))) {
throw new BusinessException(message);
}
}
private Long resolveCustomerIdForUpdate(
Long userId,
CreateOpportunityRequest request,
OpportunityCustomerSnapshotDTO currentCustomer) {
String requestedCustomerName = request.getCustomerName().trim();
String currentCustomerName = currentCustomer == null ? null : normalizeOptionalText(currentCustomer.getCustomerName());
if (currentCustomer != null
&& currentCustomer.getCustomerId() != null
&& Objects.equals(requestedCustomerName, currentCustomerName)) {
return currentCustomer.getCustomerId();
}
return resolveOrCreateOwnedCustomerId(userId, requestedCustomerName, request.getSource());
}
private Long resolveOrCreateOwnedCustomerId(Long userId, String customerName, String source) {
Long customerId = opportunityMapper.selectOwnedCustomerIdByName(userId, customerName);
if (customerId != null) {
return customerId;
}
Long createdCustomerId = IdWorker.getId();
opportunityMapper.insertCustomer(createdCustomerId, userId, customerName, source);
return createdCustomerId;
}
private CurrentUserAccountDTO requireCurrentUserAccount(Long userId) {
CurrentUserAccountDTO currentUser = opportunityMapper.selectCurrentUserAccount(userId);
if (currentUser == null || isBlank(currentUser.getUsername())) {

View File

@ -269,6 +269,241 @@
order by c.updated_at desc, c.id desc
</select>
<select id="selectSalesExpansionsForTenant" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
select
s.id,
s.owner_user_id as ownerUserId,
coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner,
'sales' as type,
coalesce(s.employee_no, '无') as employeeNo,
s.candidate_name as name,
coalesce(office_dict.item_value, s.office_name, '') as officeCode,
coalesce(office_dict.item_label, nullif(s.office_name, ''), '无') as officeName,
coalesce(s.mobile, '无') as phone,
coalesce(s.email, '无') as email,
coalesce(s.target_dept, '') as targetDept,
coalesce(nullif(s.target_dept, ''), '无') as dept,
coalesce(industry_dict.item_value, s.industry, '') as industryCode,
coalesce(industry_dict.item_label, nullif(s.industry, ''), '无') as industry,
coalesce(s.title, '无') as title,
s.intent_level as intentLevel,
case s.intent_level
when 'high' then '高'
when 'medium' then '中'
when 'low' then '低'
else '无'
end as intent,
s.stage as stageCode,
case s.stage
when 'initial_contact' then '初步沟通'
when 'solution_discussion' then '方案交流'
when 'bidding' then '招投标'
when 'business_negotiation' then '商务谈判'
when 'won' then '已成交'
when 'lost' then '已放弃'
else coalesce(s.stage, '无')
end as stage,
s.has_desktop_exp as hasExp,
s.in_progress as inProgress,
(s.employment_status = 'active') as active,
s.employment_status as employmentStatus,
coalesce(to_char(s.expected_join_date, 'YYYY-MM-DD'), '无') as expectedJoinDate,
coalesce(
to_char(
case
when sales_followup.latest_followup_time is null then s.updated_at
else greatest(s.updated_at, sales_followup.latest_followup_time)
end,
'YYYY-MM-DD HH24:MI'
),
'无'
) as updatedAt,
coalesce(s.remark, '无') as notes
from crm_sales_expansion s
left join (
select
f.biz_id,
max(f.followup_time) as latest_followup_time
from crm_expansion_followup f
where f.biz_type = 'sales'
group by f.biz_id
) sales_followup on sales_followup.biz_id = s.id
left join sys_user u
on u.user_id = s.owner_user_id
and u.is_deleted = 0
left join sys_dict_item office_dict
on office_dict.type_code = 'tz_bsc'
and office_dict.item_value = s.office_name
and office_dict.status = 1
and coalesce(office_dict.is_deleted, 0) = 0
left join sys_dict_item industry_dict
on industry_dict.type_code = 'tz_sshy'
and industry_dict.item_value = s.industry
and industry_dict.status = 1
and coalesce(industry_dict.is_deleted, 0) = 0
where exists (
select 1
from sys_tenant_user tu
where tu.user_id = s.owner_user_id
and tu.tenant_id = #{tenantId}
and coalesce(tu.is_deleted, 0) = 0
)
<if test="keyword != null and keyword != ''">
and (
coalesce(s.employee_no, '') ilike concat('%', #{keyword}, '%')
or coalesce(s.candidate_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(office_dict.item_label, '') ilike concat('%', #{keyword}, '%')
or coalesce(s.target_dept, '') ilike concat('%', #{keyword}, '%')
or coalesce(industry_dict.item_label, '') ilike concat('%', #{keyword}, '%')
or coalesce(s.mobile, '') ilike concat('%', #{keyword}, '%')
or coalesce(s.title, '') ilike concat('%', #{keyword}, '%')
)
</if>
order by s.updated_at desc, s.id desc
limit #{limit}
</select>
<select id="selectChannelExpansionsForTenant" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
select
c.id,
c.owner_user_id as ownerUserId,
coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner,
'channel' as type,
coalesce(c.channel_code, '') as channelCode,
c.channel_name as name,
coalesce(c.province, '') as provinceCode,
coalesce(province_area.name, nullif(c.province, ''), '无') as province,
coalesce(c.city, '') as cityCode,
coalesce(city_area.name, nullif(c.city, ''), '无') as city,
coalesce(c.office_address, '无') as officeAddress,
coalesce(c.channel_industry, c.industry, '') as channelIndustryCode,
coalesce(c.channel_industry, c.industry, '无') as channelIndustry,
coalesce(c.certification_level, '无') as certificationLevel,
coalesce(trim(to_char(c.annual_revenue, 'FM999999990.##')), '') as annualRevenue,
case
when c.annual_revenue is null then '无'
else trim(to_char(c.annual_revenue, 'FM999999990.##')) || '万元'
end as revenue,
coalesce(c.staff_size, 0) as size,
coalesce(primary_contact.contact_name, c.contact_name, '无') as primaryContactName,
coalesce(primary_contact.contact_title, c.contact_title, '无') as primaryContactTitle,
coalesce(primary_contact.contact_mobile, c.contact_mobile, '无') as primaryContactMobile,
coalesce(to_char(c.contact_established_date, 'YYYY-MM-DD'), '无') as establishedDate,
c.intent_level as intentLevel,
case c.intent_level
when 'high' then '高'
when 'medium' then '中'
when 'low' then '低'
else '无'
end as intent,
coalesce(c.has_desktop_exp, false) as hasDesktopExp,
coalesce(c.channel_attribute, '') as channelAttributeCode,
coalesce(channel_attribute_dict.item_label, c.channel_attribute, '无') as channelAttribute,
coalesce(c.internal_attribute, '') as internalAttributeCode,
coalesce(internal_attribute_dict.item_label, c.internal_attribute, '无') as internalAttribute,
c.stage as stageCode,
case c.stage
when 'initial_contact' then '初步接触'
when 'solution_discussion' then '方案交流'
when 'bidding' then '招投标'
when 'business_negotiation' then '合作洽谈'
when 'won' then '已合作'
when 'lost' then '已终止'
else coalesce(c.stage, '无')
end as stage,
c.landed_flag as landed,
coalesce(to_char(c.expected_sign_date, 'YYYY-MM-DD'), '无') as expectedSignDate,
coalesce(
to_char(
case
when channel_followup.latest_followup_time is null then c.updated_at
else greatest(c.updated_at, channel_followup.latest_followup_time)
end,
'YYYY-MM-DD HH24:MI'
),
'无'
) as updatedAt,
coalesce(c.remark, '无') as notes
from crm_channel_expansion c
left join (
select
f.biz_id,
max(f.followup_time) as latest_followup_time
from crm_expansion_followup f
where f.biz_type = 'channel'
group by f.biz_id
) channel_followup on channel_followup.biz_id = c.id
left join sys_user u
on u.user_id = c.owner_user_id
and u.is_deleted = 0
left join lateral (
select
contact_name,
contact_title,
contact_mobile
from crm_channel_expansion_contact cc
where cc.channel_expansion_id = c.id
order by cc.sort_order asc nulls last, cc.id asc
limit 1
) primary_contact on true
left join cnarea province_area
on province_area.level = 1
and (province_area.name = c.province or province_area.area_code = c.province)
left join cnarea city_area
on city_area.level = 2
and (city_area.name = c.city or city_area.area_code = c.city)
left join sys_dict_item channel_attribute_dict
on channel_attribute_dict.type_code = 'tz_qdsx'
and channel_attribute_dict.item_value = c.channel_attribute
and channel_attribute_dict.status = 1
and coalesce(channel_attribute_dict.is_deleted, 0) = 0
left join sys_dict_item internal_attribute_dict
on internal_attribute_dict.type_code = 'tz_xhsnbsx'
and internal_attribute_dict.item_value = c.internal_attribute
and internal_attribute_dict.status = 1
and coalesce(internal_attribute_dict.is_deleted, 0) = 0
where exists (
select 1
from sys_tenant_user tu
where tu.user_id = c.owner_user_id
and tu.tenant_id = #{tenantId}
and coalesce(tu.is_deleted, 0) = 0
)
<if test="keyword != null and keyword != ''">
and (
c.channel_name ilike concat('%', #{keyword}, '%')
or coalesce(c.channel_code, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.channel_industry, c.industry, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.province, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.city, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.certification_level, '') ilike concat('%', #{keyword}, '%')
or coalesce(province_area.name, '') ilike concat('%', #{keyword}, '%')
or coalesce(city_area.name, '') ilike concat('%', #{keyword}, '%')
or exists (
select 1
from sys_dict_item industry_dict
where industry_dict.type_code = 'tz_sshy'
and industry_dict.status = 1
and coalesce(industry_dict.is_deleted, 0) = 0
and industry_dict.item_value = any(string_to_array(coalesce(c.channel_industry, c.industry, ''), ','))
and industry_dict.item_label ilike concat('%', #{keyword}, '%')
)
or exists (
select 1
from crm_channel_expansion_contact cc
where cc.channel_expansion_id = c.id
and (
coalesce(cc.contact_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(cc.contact_mobile, '') ilike concat('%', #{keyword}, '%')
or coalesce(cc.contact_title, '') ilike concat('%', #{keyword}, '%')
)
)
)
</if>
order by c.updated_at desc, c.id desc
limit #{limit}
</select>
<select id="selectSalesFollowUps" resultType="com.unis.crm.dto.expansion.ExpansionFollowUpDTO">
select
f.id,

View File

@ -203,6 +203,16 @@
limit 1
</select>
<select id="selectOpportunityCustomerSnapshot" resultType="com.unis.crm.dto.opportunity.OpportunityCustomerSnapshotDTO">
select
o.customer_id as customerId,
coalesce(c.customer_name, '') as customerName
from crm_opportunity o
left join crm_customer c on c.id = o.customer_id
where o.id = #{opportunityId}
limit 1
</select>
<insert id="insertCustomer">
insert into crm_customer (
id,
@ -285,6 +295,13 @@
where o.id = #{id}
</select>
<select id="selectArchived" resultType="java.lang.Boolean">
select coalesce(archived, false)
from crm_opportunity o
where o.id = #{id}
limit 1
</select>
<select id="selectPushedToOms" resultType="java.lang.Boolean">
select coalesce(pushed_to_oms, false)
from crm_opportunity o

View File

@ -0,0 +1,322 @@
package com.unis.crm.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.unis.crm.common.BusinessException;
import com.unis.crm.dto.ownertransfer.OwnerTransferConflictDTO;
import com.unis.crm.dto.ownertransfer.OwnerTransferItemDTO;
import com.unis.crm.dto.ownertransfer.OwnerTransferPreviewDTO;
import com.unis.crm.dto.ownertransfer.OwnerTransferRequest;
import com.unis.crm.dto.ownertransfer.OwnerTransferResultDTO;
import com.unis.crm.dto.ownertransfer.OwnerTransferSelection;
import com.unis.crm.dto.ownertransfer.OwnerTransferUserDTO;
import com.unisbase.security.PermissionService;
import com.unisbase.security.SpringSecurityTenantProvider;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@ExtendWith(MockitoExtension.class)
class OwnerTransferAdminServiceTest {
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private PermissionService permissionService;
@Mock
private SpringSecurityTenantProvider tenantProvider;
@InjectMocks
private OwnerTransferAdminService ownerTransferAdminService;
@Test
void transfer_shouldRejectSalesConflict() {
when(permissionService.hasPermi("owner_transfer:execute")).thenReturn(true);
when(tenantProvider.getCurrentTenantId()).thenReturn(1L);
mockUsers();
mockCounts();
mockItems();
when(jdbcTemplate.query(
contains("from crm_sales_expansion s"),
org.mockito.ArgumentMatchers.<RowMapper<OwnerTransferConflictDTO>>any(),
eq(10L),
eq(1L),
eq(20L),
eq(1L)))
.thenReturn(List.of(conflict("EMP001", "张三")));
OwnerTransferRequest request = new OwnerTransferRequest();
request.setFromUserId(10L);
request.setToUserId(20L);
request.setTransferSalesExpansions(true);
BusinessException exception = assertThrows(BusinessException.class, () -> ownerTransferAdminService.transfer(request));
assertEquals("目标归属人下已存在相同工号的拓展销售人员请先处理冲突后再试EMP001(张三)", exception.getMessage());
verify(jdbcTemplate, never()).update(contains("update crm_sales_expansion"), anyLong(), anyLong());
verify(permissionService, never()).hasPermi("owner_transfer:view");
}
@Test
void transfer_shouldReturnUpdatedCounts() {
when(permissionService.hasPermi("owner_transfer:execute")).thenReturn(true);
when(tenantProvider.getCurrentTenantId()).thenReturn(1L);
mockUsers();
mockCounts();
mockItems();
when(jdbcTemplate.query(
contains("from crm_sales_expansion s"),
org.mockito.ArgumentMatchers.<RowMapper<OwnerTransferConflictDTO>>any(),
eq(10L),
eq(1L),
eq(20L),
eq(1L)))
.thenReturn(List.of());
when(jdbcTemplate.update(contains("update crm_opportunity"), eq(20L), eq(10L), eq(1L))).thenReturn(3);
when(jdbcTemplate.update(contains("update crm_channel_expansion"), eq(20L), eq(10L), eq(1L))).thenReturn(2);
OwnerTransferRequest request = new OwnerTransferRequest();
request.setFromUserId(10L);
request.setToUserId(20L);
request.setTransferOpportunities(true);
request.setTransferChannelExpansions(true);
OwnerTransferResultDTO result = ownerTransferAdminService.transfer(request);
assertEquals(3, result.getTransferredOpportunityCount());
assertEquals(0, result.getTransferredSalesExpansionCount());
assertEquals(2, result.getTransferredChannelExpansionCount());
}
@Test
void transfer_opportunityShouldOnlyUpdateOwnerField() {
when(permissionService.hasPermi("owner_transfer:execute")).thenReturn(true);
when(tenantProvider.getCurrentTenantId()).thenReturn(1L);
mockUsers();
mockCounts();
mockItems();
when(jdbcTemplate.query(
contains("from crm_sales_expansion s"),
org.mockito.ArgumentMatchers.<RowMapper<OwnerTransferConflictDTO>>any(),
eq(10L),
eq(1L),
eq(20L),
eq(1L)))
.thenReturn(List.of());
when(jdbcTemplate.update(contains("update crm_opportunity"), eq(20L), eq(10L), eq(1L))).thenReturn(3);
OwnerTransferRequest request = new OwnerTransferRequest();
request.setFromUserId(10L);
request.setToUserId(20L);
request.setTransferOpportunities(true);
ownerTransferAdminService.transfer(request);
verify(jdbcTemplate).update(
argThat(sql -> sql.contains("update crm_opportunity set owner_user_id = ?, updated_at = now()")
&& !sql.contains("customer_id")
&& !sql.contains("sales_expansion_id")
&& !sql.contains("channel_expansion_id")),
eq(20L),
eq(10L),
eq(1L));
}
@Test
void preview_shouldReturnCountsAndConflicts() {
when(permissionService.hasPermi("owner_transfer:view")).thenReturn(true);
when(tenantProvider.getCurrentTenantId()).thenReturn(1L);
mockUsers();
mockCounts();
mockItems();
when(jdbcTemplate.query(
contains("from crm_sales_expansion s"),
org.mockito.ArgumentMatchers.<RowMapper<OwnerTransferConflictDTO>>any(),
eq(10L),
eq(1L),
eq(20L),
eq(1L)))
.thenReturn(List.of(conflict("EMP001", "张三")));
OwnerTransferPreviewDTO preview = ownerTransferAdminService.preview(10L, 20L);
assertEquals(5, preview.getOpportunityCount());
assertEquals(4, preview.getSalesExpansionCount());
assertEquals(2, preview.getChannelExpansionCount());
assertEquals(1, preview.getSalesConflicts().size());
assertEquals(1, preview.getOpportunities().size());
assertEquals("商机A", preview.getOpportunities().get(0).getName());
assertEquals(1, preview.getSalesExpansions().size());
assertEquals("张三", preview.getSalesExpansions().get(0).getName());
assertEquals(1, preview.getChannelExpansions().size());
assertEquals("渠道A", preview.getChannelExpansions().get(0).getName());
verify(jdbcTemplate).query(
argThat(sql -> sql.contains(" and exists (")
&& !sql.contains("andexists")),
org.mockito.ArgumentMatchers.<RowMapper<OwnerTransferConflictDTO>>any(),
eq(10L),
eq(1L),
eq(20L),
eq(1L));
}
@Test
void listTargetUsers_shouldReturnTenantScopedNonAdminUsers() {
when(tenantProvider.getCurrentTenantId()).thenReturn(1L);
when(jdbcTemplate.query(contains("from sys_user u"), org.mockito.ArgumentMatchers.<RowMapper<OwnerTransferUserDTO>>any(), eq(1L)))
.thenReturn(List.of(targetUser(30L, "lisi", "李四", 1, 1L)));
List<OwnerTransferUserDTO> result = ownerTransferAdminService.listTargetUsersForCurrentUser(10L);
assertEquals(1, result.size());
assertEquals(30L, result.get(0).getUserId());
assertEquals("lisi", result.get(0).getUsername());
assertEquals("李四", result.get(0).getDisplayName());
}
@Test
void transfer_shouldSupportSingleRecordSelection() {
when(permissionService.hasPermi("owner_transfer:execute")).thenReturn(true);
when(tenantProvider.getCurrentTenantId()).thenReturn(1L);
mockUsers();
mockCounts();
mockItems();
when(jdbcTemplate.query(
contains("from crm_sales_expansion s"),
org.mockito.ArgumentMatchers.<RowMapper<OwnerTransferConflictDTO>>any(),
eq(10L),
eq(1L),
eq(20L),
eq(1L)))
.thenReturn(List.of());
when(jdbcTemplate.update(contains("update crm_opportunity"), eq(20L), eq(10L), eq(1L), eq(101L))).thenReturn(1);
OwnerTransferSelection selection = new OwnerTransferSelection();
selection.setOpportunityIds(List.of(101L));
OwnerTransferRequest request = new OwnerTransferRequest();
request.setFromUserId(10L);
request.setToUserId(20L);
request.setTransferOpportunities(true);
request.setSelection(selection);
OwnerTransferResultDTO result = ownerTransferAdminService.transfer(request);
assertEquals(1, result.getTransferredOpportunityCount());
}
@Test
void transfer_shouldRejectConflictedSingleSalesSelection() {
when(permissionService.hasPermi("owner_transfer:execute")).thenReturn(true);
when(tenantProvider.getCurrentTenantId()).thenReturn(1L);
mockUsers();
mockCounts();
mockItems();
when(jdbcTemplate.query(
contains("from crm_sales_expansion s"),
org.mockito.ArgumentMatchers.<RowMapper<OwnerTransferConflictDTO>>any(),
eq(10L),
eq(1L),
eq(20L),
eq(1L)))
.thenReturn(List.of(conflict("EMP001", "张三")));
OwnerTransferSelection selection = new OwnerTransferSelection();
selection.setSalesExpansionIds(List.of(201L));
OwnerTransferRequest request = new OwnerTransferRequest();
request.setFromUserId(10L);
request.setToUserId(20L);
request.setTransferSalesExpansions(true);
request.setSelection(selection);
BusinessException exception = assertThrows(BusinessException.class, () -> ownerTransferAdminService.transfer(request));
assertEquals("目标归属人下已存在相同工号的拓展销售人员请先处理冲突后再试EMP001(张三)", exception.getMessage());
}
@Test
void previewForCurrentUser_shouldRejectOtherSourceUser() {
when(tenantProvider.getCurrentTenantId()).thenReturn(1L);
BusinessException exception = assertThrows(BusinessException.class, () -> ownerTransferAdminService.previewForCurrentUser(10L, 20L, 30L));
assertEquals("原归属人仅支持当前登录账号", exception.getMessage());
}
private void mockUsers() {
when(jdbcTemplate.query(contains("from sys_user u"), org.mockito.ArgumentMatchers.<RowMapper<Object>>any(), eq(1L), eq(10L)))
.thenReturn(List.of(newUser(10L, "离职员工", 0)));
when(jdbcTemplate.query(contains("from sys_user u"), org.mockito.ArgumentMatchers.<RowMapper<Object>>any(), eq(1L), eq(20L)))
.thenReturn(List.of(newUser(20L, "接收人", 1)));
}
private void mockCounts() {
when(jdbcTemplate.queryForObject(contains("from crm_opportunity o"), eq(Integer.class), eq(10L), eq(1L))).thenReturn(5);
when(jdbcTemplate.queryForObject(contains("from crm_sales_expansion s"), eq(Integer.class), eq(10L), eq(1L))).thenReturn(4);
when(jdbcTemplate.queryForObject(contains("from crm_channel_expansion c"), eq(Integer.class), eq(10L), eq(1L))).thenReturn(2);
}
private void mockItems() {
when(jdbcTemplate.query(contains("from crm_opportunity o"), org.mockito.ArgumentMatchers.<RowMapper<OwnerTransferItemDTO>>any(), eq(10L), eq(1L)))
.thenReturn(List.of(item(101L, "商机A", null, false)));
when(jdbcTemplate.query(contains("from crm_sales_expansion s"), org.mockito.ArgumentMatchers.<RowMapper<OwnerTransferItemDTO>>any(), eq(10L), eq(1L)))
.thenReturn(List.of(item(201L, "张三", "EMP001", true)));
when(jdbcTemplate.query(contains("from crm_channel_expansion c"), org.mockito.ArgumentMatchers.<RowMapper<OwnerTransferItemDTO>>any(), eq(10L), eq(1L)))
.thenReturn(List.of(item(301L, "渠道A", null, false)));
}
private OwnerTransferConflictDTO conflict(String employeeNo, String candidateName) {
OwnerTransferConflictDTO dto = new OwnerTransferConflictDTO();
dto.setEmployeeNo(employeeNo);
dto.setCandidateName(candidateName);
return dto;
}
private OwnerTransferItemDTO item(Long id, String name, String code, boolean conflict) {
OwnerTransferItemDTO dto = new OwnerTransferItemDTO();
dto.setId(id);
dto.setName(name);
dto.setCode(code);
dto.setConflict(conflict);
return dto;
}
private Object newUser(Long userId, String displayName, int status) {
try {
Class<?> clazz = Class.forName("com.unis.crm.service.OwnerTransferAdminService$TenantUser");
var constructor = clazz.getDeclaredConstructors()[0];
constructor.setAccessible(true);
return constructor.newInstance(userId, displayName, status);
} catch (Exception exception) {
throw new RuntimeException(exception);
}
}
private OwnerTransferUserDTO targetUser(Long userId, String username, String displayName, int status, Long tenantId) {
OwnerTransferUserDTO dto = new OwnerTransferUserDTO();
dto.setUserId(userId);
dto.setUsername(username);
dto.setDisplayName(displayName);
dto.setStatus(status);
dto.setTenantId(tenantId);
return dto;
}
}

View File

@ -12,9 +12,12 @@ import com.unis.crm.common.BusinessException;
import com.unis.crm.dto.expansion.ChannelExpansionContactRequest;
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
import com.unis.crm.dto.expansion.SalesExpansionItemDTO;
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
import com.unis.crm.mapper.ExpansionMapper;
import com.unisbase.security.SpringSecurityTenantProvider;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@ -30,6 +33,9 @@ class ExpansionServiceImplTest {
@Mock
private ExpansionMapper expansionMapper;
@Mock
private SpringSecurityTenantProvider tenantProvider;
@InjectMocks
private ExpansionServiceImpl expansionService;
@ -85,6 +91,37 @@ class ExpansionServiceImplTest {
verify(expansionMapper, never()).countChannelExpansionByChannelNameExcludingId(any(), any());
}
@Test
void getOpportunityFormOptions_shouldReturnTenantScopedInitialOptions() {
SalesExpansionItemDTO salesItem = new SalesExpansionItemDTO();
salesItem.setId(11L);
salesItem.setName("张三");
when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
when(expansionMapper.selectSalesExpansionsForTenant(100L, null, 20)).thenReturn(List.of(salesItem));
when(expansionMapper.selectChannelExpansionsForTenant(100L, null, 20)).thenReturn(List.of());
ExpansionOverviewDTO result = expansionService.getOpportunityFormOptions(1L, null, null);
assertEquals(1, result.getSalesItems().size());
assertEquals(0, result.getChannelItems().size());
verify(expansionMapper).selectSalesExpansionsForTenant(100L, null, 20);
verify(expansionMapper).selectChannelExpansionsForTenant(100L, null, 20);
verify(expansionMapper, never()).selectSalesExpansions(any(), any());
verify(expansionMapper, never()).selectChannelExpansions(any(), any());
}
@Test
void getOpportunityFormOptions_shouldPassKeywordAndLimit() {
when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
when(expansionMapper.selectSalesExpansionsForTenant(100L, "张", 15)).thenReturn(List.of());
when(expansionMapper.selectChannelExpansionsForTenant(100L, "张", 15)).thenReturn(List.of());
expansionService.getOpportunityFormOptions(1L, " 张 ", 15);
verify(expansionMapper).selectSalesExpansionsForTenant(100L, "张", 15);
verify(expansionMapper).selectChannelExpansionsForTenant(100L, "张", 15);
}
private CreateSalesExpansionRequest buildCreateSalesRequest() {
CreateSalesExpansionRequest request = new CreateSalesExpansionRequest();
request.setEmployeeNo("EMP001");

View File

@ -0,0 +1,166 @@
package com.unis.crm.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.unis.crm.common.BusinessException;
import com.unis.crm.dto.opportunity.CurrentUserAccountDTO;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityCustomerSnapshotDTO;
import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO;
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
import com.unis.crm.mapper.OpportunityMapper;
import com.unis.crm.service.OmsClient;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class OpportunityServiceImplTest {
@Mock
private OpportunityMapper opportunityMapper;
@Mock
private OmsClient omsClient;
@InjectMocks
private OpportunityServiceImpl opportunityService;
@Test
void updateOpportunity_shouldRejectArchivedOpportunity() {
when(opportunityMapper.countOwnedOpportunity(1L, 10L)).thenReturn(1);
when(opportunityMapper.selectArchived(1L, 10L)).thenReturn(Boolean.TRUE);
BusinessException exception = assertThrows(
BusinessException.class,
() -> opportunityService.updateOpportunity(1L, 10L, new CreateOpportunityRequest()));
assertEquals("已签单商机不允许编辑", exception.getMessage());
verify(opportunityMapper, never()).updateOpportunity(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
}
@Test
void pushToOms_shouldRejectArchivedOpportunity() {
when(opportunityMapper.countOwnedOpportunity(1L, 10L)).thenReturn(1);
when(opportunityMapper.selectArchived(1L, 10L)).thenReturn(Boolean.TRUE);
BusinessException exception = assertThrows(
BusinessException.class,
() -> opportunityService.pushToOms(1L, 10L, new PushOpportunityToOmsRequest()));
assertEquals("已签单商机不允许推送", exception.getMessage());
verify(opportunityMapper, never()).selectOpportunityOmsPushData(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
}
@Test
void createOpportunity_shouldRejectWhenCustomerDoesNotExist() {
when(opportunityMapper.selectOwnedCustomerIdByName(1L, "客户A")).thenReturn(null);
when(opportunityMapper.insertOpportunity(eq(1L), any(), any(CreateOpportunityRequest.class))).thenAnswer(invocation -> {
CreateOpportunityRequest request = invocation.getArgument(2);
request.setId(10L);
return 1;
});
when(opportunityMapper.selectOpportunityOmsPushData(1L, 10L)).thenReturn(opportunityPushData("客户A"));
when(opportunityMapper.selectCurrentUserAccount(1L)).thenReturn(currentUserAccount());
when(omsClient.ensureUserExists("zhangsan", "张三")).thenReturn(omsUser());
when(omsClient.createProject(any(OpportunityOmsPushDataDTO.class), eq("99"))).thenReturn("OPP-CODE-001");
when(opportunityMapper.updateOpportunityCode(1L, 10L, "OPP-CODE-001")).thenReturn(1);
Long result = opportunityService.createOpportunity(1L, updateRequest("客户A"));
assertEquals(10L, result);
verify(opportunityMapper).insertCustomer(any(), eq(1L), eq("客户A"), any());
}
@Test
void updateOpportunity_shouldKeepCurrentCustomerWhenCustomerNameUnchanged() {
when(opportunityMapper.countOwnedOpportunity(1L, 10L)).thenReturn(1);
when(opportunityMapper.selectArchived(1L, 10L)).thenReturn(Boolean.FALSE);
when(opportunityMapper.selectOpportunityCustomerSnapshot(1L, 10L)).thenReturn(customerSnapshot(200L, "客户A"));
when(opportunityMapper.updateOpportunity(eq(1L), eq(10L), eq(200L), any(CreateOpportunityRequest.class))).thenReturn(1);
when(opportunityMapper.selectOpportunityOmsPushData(1L, 10L)).thenReturn(null);
Long result = opportunityService.updateOpportunity(1L, 10L, updateRequest("客户A"));
assertEquals(10L, result);
verify(opportunityMapper, never()).selectOwnedCustomerIdByName(1L, "客户A");
}
@Test
void updateOpportunity_shouldAutoCreateCustomerWhenCustomerNameChangedToUnknownCustomer() {
when(opportunityMapper.countOwnedOpportunity(1L, 10L)).thenReturn(1);
when(opportunityMapper.selectArchived(1L, 10L)).thenReturn(Boolean.FALSE);
when(opportunityMapper.selectOpportunityCustomerSnapshot(1L, 10L)).thenReturn(customerSnapshot(200L, "客户A"));
when(opportunityMapper.selectOwnedCustomerIdByName(1L, "客户B")).thenReturn(null);
when(opportunityMapper.updateOpportunity(eq(1L), eq(10L), any(), any(CreateOpportunityRequest.class))).thenReturn(1);
when(opportunityMapper.selectOpportunityOmsPushData(1L, 10L)).thenReturn(null);
Long result = opportunityService.updateOpportunity(1L, 10L, updateRequest("客户B"));
assertEquals(10L, result);
verify(opportunityMapper).insertCustomer(any(), eq(1L), eq("客户B"), any());
}
private OpportunityCustomerSnapshotDTO customerSnapshot(Long customerId, String customerName) {
OpportunityCustomerSnapshotDTO dto = new OpportunityCustomerSnapshotDTO();
dto.setCustomerId(customerId);
dto.setCustomerName(customerName);
return dto;
}
private OpportunityOmsPushDataDTO opportunityPushData(String customerName) {
OpportunityOmsPushDataDTO dto = new OpportunityOmsPushDataDTO();
dto.setOpportunityId(10L);
dto.setOpportunityName("商机A");
dto.setCustomerName(customerName);
dto.setOperatorName("直营");
dto.setAmount(new BigDecimal("100"));
dto.setExpectedCloseDate("2026-04-30");
dto.setConfidencePct("A");
dto.setStage("初步沟通");
dto.setOpportunityType("新建");
return dto;
}
private CurrentUserAccountDTO currentUserAccount() {
CurrentUserAccountDTO dto = new CurrentUserAccountDTO();
dto.setUserId(1L);
dto.setUsername("zhangsan");
dto.setDisplayName("张三");
return dto;
}
private OmsPreSalesOptionDTO omsUser() {
OmsPreSalesOptionDTO dto = new OmsPreSalesOptionDTO();
dto.setUserId(99L);
dto.setUserName("OMS张三");
dto.setLoginName("zhangsan");
return dto;
}
private CreateOpportunityRequest updateRequest(String customerName) {
CreateOpportunityRequest request = new CreateOpportunityRequest();
request.setCustomerName(customerName);
request.setOpportunityName("商机A");
request.setProjectLocation("上海");
request.setOperatorName("直营");
request.setAmount(new BigDecimal("100"));
request.setExpectedCloseDate(LocalDate.of(2026, 4, 30));
request.setConfidencePct("A");
request.setStage("初步沟通");
request.setOpportunityType("新建");
request.setCompetitorName("竞争对手A");
return request;
}
}

View File

@ -11,6 +11,7 @@ import Expansion from "./pages/Expansion";
import Opportunities from "./pages/Opportunities";
import Work from "./pages/Work";
import Profile from "./pages/Profile";
import OwnerTransfer from "./pages/OwnerTransfer";
import { ThemeProvider } from "./components/ThemeProvider";
import LoginPage from "./pages/Login";
import WecomLoginCallbackPage from "./pages/WecomLoginCallback";
@ -44,6 +45,7 @@ export default function App() {
<Route path="opportunities" element={<Opportunities />} />
<Route path="work/*" element={<Work />} />
<Route path="profile" element={<Profile />} />
<Route path="owner-transfer" element={<OwnerTransfer />} />
</Route>
</Routes>
</BrowserRouter>

View File

@ -0,0 +1,165 @@
import { useEffect, useId, type ReactNode } from "react";
import { motion } from "motion/react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
type DialogTone = "violet" | "amber" | "emerald" | "slate";
const TONE_STYLES: Record<DialogTone, {
halo: string;
iconWrap: string;
accentText: string;
confirmButton: string;
}> = {
violet: {
halo: "from-violet-500/18 via-fuchsia-500/10 to-cyan-400/10 dark:from-violet-400/16 dark:via-fuchsia-400/10 dark:to-cyan-300/10",
iconWrap: "bg-violet-100 text-violet-700 ring-violet-200 dark:bg-violet-500/15 dark:text-violet-200 dark:ring-violet-400/20",
accentText: "text-violet-600 dark:text-violet-300",
confirmButton: "bg-violet-600 text-white shadow-[0_14px_34px_rgba(124,58,237,0.28)] hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-400",
},
amber: {
halo: "from-amber-400/20 via-orange-400/12 to-rose-400/10 dark:from-amber-300/18 dark:via-orange-300/12 dark:to-rose-300/10",
iconWrap: "bg-amber-100 text-amber-700 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-400/20",
accentText: "text-amber-700 dark:text-amber-200",
confirmButton: "bg-amber-500 text-slate-950 shadow-[0_14px_34px_rgba(245,158,11,0.28)] hover:bg-amber-400 dark:bg-amber-400 dark:hover:bg-amber-300",
},
emerald: {
halo: "from-emerald-400/20 via-teal-400/12 to-cyan-400/10 dark:from-emerald-300/18 dark:via-teal-300/12 dark:to-cyan-300/10",
iconWrap: "bg-emerald-100 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/20",
accentText: "text-emerald-700 dark:text-emerald-200",
confirmButton: "bg-emerald-600 text-white shadow-[0_14px_34px_rgba(5,150,105,0.28)] hover:bg-emerald-700 dark:bg-emerald-500 dark:hover:bg-emerald-400",
},
slate: {
halo: "from-slate-400/18 via-slate-300/12 to-slate-200/8 dark:from-slate-500/18 dark:via-slate-400/12 dark:to-slate-300/8",
iconWrap: "bg-slate-100 text-slate-700 ring-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:ring-slate-700",
accentText: "text-slate-700 dark:text-slate-200",
confirmButton: "bg-slate-900 text-white shadow-[0_14px_34px_rgba(15,23,42,0.24)] hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-950 dark:hover:bg-white",
},
};
export default function ActionDialog({
title,
description,
icon,
tone = "violet",
children,
confirmText = "确认",
cancelText = "取消",
onConfirm,
onClose,
loading = false,
hideCancel = false,
}: {
title: string;
description?: string;
icon?: ReactNode;
tone?: DialogTone;
children?: ReactNode;
confirmText?: string;
cancelText?: string;
onConfirm: () => void | Promise<void>;
onClose: () => void;
loading?: boolean;
hideCancel?: boolean;
}) {
const titleId = useId();
const descriptionId = useId();
const toneStyles = TONE_STYLES[tone];
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !loading) {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [loading, onClose]);
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={loading ? undefined : onClose}
className="fixed inset-0 z-[160] bg-slate-900/45 backdrop-blur-sm dark:bg-slate-950/78"
/>
<div className="fixed inset-0 z-[170] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 18, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 18, scale: 0.98 }}
transition={{ duration: 0.18 }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
className="relative w-full max-w-lg overflow-hidden rounded-[30px] border border-white/60 bg-white/95 shadow-[0_28px_90px_rgba(15,23,42,0.28)] dark:border-slate-700/80 dark:bg-slate-950/96"
>
<div className={cn("pointer-events-none absolute inset-x-6 top-0 h-32 rounded-full bg-gradient-to-r blur-3xl", toneStyles.halo)} />
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/80 to-transparent dark:via-slate-400/30" />
<div className="relative px-5 pb-5 pt-5 sm:px-6 sm:pb-6 sm:pt-6">
<div className="flex items-start gap-4">
<div className={cn("flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl ring-1", toneStyles.iconWrap)}>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className={cn("text-xs font-semibold uppercase tracking-[0.22em]", toneStyles.accentText)}></p>
<h3 id={titleId} className="mt-2 text-xl font-semibold tracking-tight text-slate-900 dark:text-white">
{title}
</h3>
{description ? (
<p id={descriptionId} className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
{description}
</p>
) : null}
</div>
<button
type="button"
aria-label="关闭弹窗"
onClick={loading ? undefined : onClose}
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-slate-800 dark:hover:text-slate-200"
disabled={loading}
>
<X className="h-5 w-5" />
</button>
</div>
{children ? (
<div className="mt-5 rounded-[24px] border border-slate-200/80 bg-slate-50/90 p-4 shadow-inner shadow-white/40 dark:border-slate-800/80 dark:bg-slate-900/70 dark:shadow-none">
{children}
</div>
) : null}
<div className="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
{!hideCancel ? (
<button
type="button"
onClick={onClose}
disabled={loading}
className="inline-flex h-11 items-center justify-center rounded-2xl border border-slate-200 bg-white px-4 text-sm font-medium text-slate-700 transition-colors hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-800"
>
{cancelText}
</button>
) : null}
<button
type="button"
onClick={() => void onConfirm()}
disabled={loading}
className={cn(
"inline-flex h-11 items-center justify-center rounded-2xl px-5 text-sm font-semibold transition-all disabled:cursor-not-allowed disabled:opacity-60",
toneStyles.confirmButton,
)}
>
{loading ? "处理中..." : confirmText}
</button>
</div>
</div>
</motion.div>
</div>
</>
);
}

View File

@ -46,6 +46,18 @@ export interface UserRole {
roleName?: string;
}
export interface AdminUserSummary {
userId: number;
username: string;
displayName: string;
status?: number;
tenantId?: number;
orgId?: number;
isAdmin?: boolean;
isPlatformAdmin?: boolean;
roleCodes?: string[];
}
export interface PlatformConfig {
projectName?: string;
logoUrl?: string;
@ -140,6 +152,57 @@ export interface UpdateCurrentUserPasswordPayload {
newPassword: string;
}
export interface OwnerTransferConflict {
employeeNo: string;
candidateName: string;
}
export interface OwnerTransferItem {
id: number;
name: string;
code?: string;
conflict: boolean;
}
export interface OwnerTransferPreview {
tenantId: number;
fromUserId: number;
fromUserName: string;
toUserId: number;
toUserName: string;
opportunityCount: number;
salesExpansionCount: number;
channelExpansionCount: number;
salesConflicts: OwnerTransferConflict[];
opportunities: OwnerTransferItem[];
salesExpansions: OwnerTransferItem[];
channelExpansions: OwnerTransferItem[];
}
export interface OwnerTransferSelection {
opportunityIds?: number[];
salesExpansionIds?: number[];
channelExpansionIds?: number[];
}
export interface OwnerTransferPayload {
fromUserId: number;
toUserId: number;
transferOpportunities: boolean;
transferSalesExpansions: boolean;
transferChannelExpansions: boolean;
selection?: OwnerTransferSelection;
}
export interface OwnerTransferResult {
tenantId: number;
fromUserId: number;
toUserId: number;
transferredOpportunityCount: number;
transferredSalesExpansionCount: number;
transferredChannelExpansionCount: number;
}
export interface WorkCheckIn {
id: number;
date?: string;
@ -739,7 +802,7 @@ async function request<T>(input: string, init?: RequestInit, withAuth = false):
headers,
});
if (response.status === 401 || response.status === 403) {
if (response.status === 401) {
handleUnauthorizedResponse();
throw new Error("登录已失效,请重新登录");
}
@ -768,7 +831,7 @@ export async function fetchWithAuth(input: string, init?: RequestInit) {
headers: applyAuthHeaders(new Headers(init?.headers)),
});
if (response.status === 401 || response.status === 403) {
if (response.status === 401) {
handleUnauthorizedResponse();
throw new Error("登录已失效,请重新登录");
}
@ -936,6 +999,37 @@ export async function updateUserProfileById(userId: number, payload: UpdateCurre
}, true);
}
export async function listAdminUsers(params?: { tenantId?: number; orgId?: number }) {
const searchParams = new URLSearchParams();
if (params?.tenantId !== undefined) {
searchParams.set("tenantId", String(params.tenantId));
}
if (params?.orgId !== undefined) {
searchParams.set("orgId", String(params.orgId));
}
const query = searchParams.toString();
return request<AdminUserSummary[]>(`/api/sys/api/users${query ? `?${query}` : ""}`, undefined, true);
}
export async function previewOwnerTransfer(fromUserId: number, toUserId: number) {
const params = new URLSearchParams({
fromUserId: String(fromUserId),
toUserId: String(toUserId),
});
return request<OwnerTransferPreview>(`/api/owner-transfer/preview?${params.toString()}`, undefined, true);
}
export async function listOwnerTransferTargetUsers() {
return request<AdminUserSummary[]>("/api/owner-transfer/target-users", undefined, true);
}
export async function executeOwnerTransfer(payload: OwnerTransferPayload) {
return request<OwnerTransferResult>("/api/owner-transfer/execute", {
method: "POST",
body: JSON.stringify(payload),
}, true);
}
export async function getWorkOverview() {
return request<WorkOverview>("/api/work/overview", undefined, true);
}
@ -1053,6 +1147,18 @@ export async function getExpansionOverview(keyword?: string) {
return request<ExpansionOverview>(`/api/expansion/overview${query ? `?${query}` : ""}`, undefined, true);
}
export async function getOpportunityExpansionOptions(params?: { keyword?: string; limit?: number }) {
const searchParams = new URLSearchParams();
if (params?.keyword && params.keyword.trim()) {
searchParams.set("keyword", params.keyword.trim());
}
if (params?.limit !== undefined) {
searchParams.set("limit", String(params.limit));
}
const query = searchParams.toString();
return request<ExpansionOverview>(`/api/expansion/opportunity-form-options${query ? `?${query}` : ""}`, undefined, true);
}
export async function getExpansionMeta() {
return request<ExpansionMeta>("/api/expansion/meta", undefined, true);
}

View File

@ -11,8 +11,8 @@ import {
createSalesExpansion,
getExpansionCityOptions,
getExpansionMeta,
getExpansionOverview,
getOpportunityMeta,
getOpportunityExpansionOptions,
getOpportunityOmsPreSalesOptions,
getOpportunityOverview,
getStoredCurrentUserId,
@ -61,6 +61,9 @@ const FALLBACK_OPPORTUNITY_TYPE_OPTIONS = [
{ value: "替换", label: "替换" },
] as const;
const OPPORTUNITY_EXPANSION_OPTION_LIMIT = 20;
const OPPORTUNITY_EXPANSION_SEARCH_DEBOUNCE_MS = 300;
const COMPETITOR_OPTIONS = [
"深信服",
"锐捷",
@ -864,6 +867,15 @@ function getSearchableOptionLabel(option: SearchableOption) {
return String(option.value ?? "");
}
function formatSalesExpansionOptionLabel(item?: Pick<SalesExpansionItem, "id" | "name" | "phone"> | null) {
if (!item) {
return "";
}
const normalizedName = item.name?.trim() || `拓展人员#${item.id}`;
const normalizedPhone = item.phone?.trim() || "";
return normalizedPhone ? `${normalizedName}${normalizedPhone}` : normalizedName;
}
function dedupeSearchableOptions(options: SearchableOption[]) {
const seenValues = new Set<SearchableOption["value"]>();
return options.filter((option) => {
@ -875,6 +887,13 @@ function dedupeSearchableOptions(options: SearchableOption[]) {
});
}
function appendSearchableOptionIfMissing(options: SearchableOption[], fallbackOption?: SearchableOption | null) {
if (!fallbackOption) {
return options;
}
return dedupeSearchableOptions([...options, fallbackOption]);
}
function useIsMobileViewport() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") {
@ -910,6 +929,7 @@ function SearchableSelect({
placeholder,
searchPlaceholder,
emptyText,
loading = false,
createActionLabel,
className,
onChange,
@ -921,6 +941,7 @@ function SearchableSelect({
placeholder: string;
searchPlaceholder: string;
emptyText: string;
loading?: boolean;
createActionLabel?: string;
className?: string;
onChange: (value?: number) => void;
@ -950,6 +971,11 @@ function SearchableSelect({
return haystacks.some((entry) => entry.includes(normalizedQuery));
});
const resetQuery = () => {
setQuery("");
onQueryChange?.("");
};
useEffect(() => {
if (!open || isMobile) {
setDesktopDropdownStyle(null);
@ -984,7 +1010,7 @@ function SearchableSelect({
const targetNode = event.target as Node;
if (!containerRef.current?.contains(targetNode) && !desktopDropdownRef.current?.contains(targetNode)) {
setOpen(false);
setQuery("");
resetQuery();
}
};
@ -1037,7 +1063,7 @@ function SearchableSelect({
onClick={() => {
onChange(undefined);
setOpen(false);
setQuery("");
resetQuery();
}}
className="w-full rounded-xl px-3 py-2 text-left text-sm text-slate-500 transition-colors hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
@ -1052,7 +1078,7 @@ function SearchableSelect({
onClick={() => {
onChange(Number(item.value));
setOpen(false);
setQuery("");
resetQuery();
}}
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors ${
item.value === value
@ -1064,6 +1090,10 @@ function SearchableSelect({
{item.value === value ? <Check className="h-4 w-4 shrink-0" /> : null}
</button>
))
) : loading ? (
<div className="crm-empty-state px-3 py-6">
<p>...</p>
</div>
) : (
<div className="crm-empty-state px-3 py-6">
<p>{emptyText}</p>
@ -1073,7 +1103,7 @@ function SearchableSelect({
onClick={() => {
onCreate(query);
setOpen(false);
setQuery("");
resetQuery();
}}
className="mt-3 rounded-xl bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
>
@ -1094,7 +1124,7 @@ function SearchableSelect({
setOpen((current) => {
const next = !current;
if (!next) {
setQuery("");
resetQuery();
}
return next;
});
@ -1468,8 +1498,16 @@ export default function Opportunities() {
const [quickChannelDuplicateMessage, setQuickChannelDuplicateMessage] = useState("");
const [salesExpansionQuery, setSalesExpansionQuery] = useState("");
const [channelExpansionQuery, setChannelExpansionQuery] = useState("");
const [loadingSalesExpansionOptions, setLoadingSalesExpansionOptions] = useState(false);
const [loadingChannelExpansionOptions, setLoadingChannelExpansionOptions] = useState(false);
const hasLoadedOpportunityExpansionOptionsRef = useRef(false);
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen || exportFilterOpen || quickCreateOpen;
const fetchOpportunityExpansionOptions = async (query?: string) => getOpportunityExpansionOptions({
keyword: query,
limit: OPPORTUNITY_EXPANSION_OPTION_LIMIT,
});
useEffect(() => {
let cancelled = false;
@ -1519,9 +1557,12 @@ export default function Opportunities() {
let cancelled = false;
async function loadSalesExpansionOptions() {
setLoadingSalesExpansionOptions(true);
setLoadingChannelExpansionOptions(true);
try {
const data = await getExpansionOverview("");
const data = await fetchOpportunityExpansionOptions();
if (!cancelled) {
hasLoadedOpportunityExpansionOptionsRef.current = true;
setSalesExpansionOptions(data.salesItems ?? []);
setChannelExpansionOptions(data.channelItems ?? []);
}
@ -1530,6 +1571,11 @@ export default function Opportunities() {
setSalesExpansionOptions([]);
setChannelExpansionOptions([]);
}
} finally {
if (!cancelled) {
setLoadingSalesExpansionOptions(false);
setLoadingChannelExpansionOptions(false);
}
}
}
@ -1540,12 +1586,72 @@ export default function Opportunities() {
}, []);
const refreshExpansionOptions = async () => {
const data = await getExpansionOverview("");
const data = await fetchOpportunityExpansionOptions();
setSalesExpansionOptions(data.salesItems ?? []);
setChannelExpansionOptions(data.channelItems ?? []);
return data;
};
useEffect(() => {
if (!hasLoadedOpportunityExpansionOptionsRef.current) {
return;
}
let cancelled = false;
const timer = window.setTimeout(async () => {
setLoadingSalesExpansionOptions(true);
try {
const data = await fetchOpportunityExpansionOptions(salesExpansionQuery);
if (!cancelled) {
setSalesExpansionOptions(data.salesItems ?? []);
}
} catch {
if (!cancelled) {
setSalesExpansionOptions([]);
}
} finally {
if (!cancelled) {
setLoadingSalesExpansionOptions(false);
}
}
}, OPPORTUNITY_EXPANSION_SEARCH_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [salesExpansionQuery]);
useEffect(() => {
if (!hasLoadedOpportunityExpansionOptionsRef.current) {
return;
}
let cancelled = false;
const timer = window.setTimeout(async () => {
setLoadingChannelExpansionOptions(true);
try {
const data = await fetchOpportunityExpansionOptions(channelExpansionQuery);
if (!cancelled) {
setChannelExpansionOptions(data.channelItems ?? []);
}
} catch {
if (!cancelled) {
setChannelExpansionOptions([]);
}
} finally {
if (!cancelled) {
setLoadingChannelExpansionOptions(false);
}
}
}, OPPORTUNITY_EXPANSION_SEARCH_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [channelExpansionQuery]);
useEffect(() => {
let cancelled = false;
@ -1727,27 +1833,37 @@ export default function Opportunities() {
const selectedChannelExpansion = selectedItem?.channelExpansionId
? channelExpansionOptions.find((item) => item.id === selectedItem.channelExpansionId) ?? null
: null;
const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || "";
const selectedSalesExpansionName = formatSalesExpansionOptionLabel(selectedSalesExpansion)
|| selectedItem?.salesExpansionName
|| "";
const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || "";
const hasSelectedSalesExpansion = Boolean(selectedSalesExpansionName);
const hasSelectedChannelExpansion = Boolean(selectedChannelExpansionName);
const selectedPreSalesName = selectedItem?.preSalesName || "无";
const canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId);
const canPushSelectedItem = canEditSelectedItem;
const isSelectedItemOwnedByCurrentUser = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId);
const isSelectedItemArchived = Boolean(selectedItem?.archived);
const canEditSelectedItem = isSelectedItemOwnedByCurrentUser && !isSelectedItemArchived;
const canPushSelectedItem = isSelectedItemOwnedByCurrentUser && !isSelectedItemArchived;
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both";
const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both";
const showCustomCompetitorInput = selectedCompetitors.includes("其他");
const salesExpansionSearchOptions: SearchableOption[] = salesExpansionOptions.map((item) => ({
const selectedSalesExpansionFallbackOption = form.salesExpansionId && selectedSalesExpansionName
? { value: form.salesExpansionId, label: selectedSalesExpansionName, keywords: [] }
: null;
const selectedChannelExpansionFallbackOption = form.channelExpansionId && selectedChannelExpansionName
? { value: form.channelExpansionId, label: selectedChannelExpansionName, keywords: [] }
: null;
const salesExpansionSearchOptions: SearchableOption[] = appendSearchableOptionIfMissing(salesExpansionOptions.map((item) => ({
value: item.id,
label: item.name || `拓展人员#${item.id}`,
label: formatSalesExpansionOptionLabel(item),
keywords: [item.employeeNo || "", item.officeName || "", item.phone || "", item.title || ""],
}));
const channelExpansionSearchOptions: SearchableOption[] = channelExpansionOptions.map((item) => ({
})), selectedSalesExpansionFallbackOption);
const channelExpansionSearchOptions: SearchableOption[] = appendSearchableOptionIfMissing(channelExpansionOptions.map((item) => ({
value: item.id,
label: item.name || `渠道#${item.id}`,
keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""],
}));
})), selectedChannelExpansionFallbackOption);
const quickChannelOtherOptionValue = quickChannelAttributeOptions.find(isSharedOtherOption)?.value;
const omsPreSalesSearchOptions: SearchableOption[] = [
...(pushPreSalesId && pushPreSalesName && !omsPreSalesOptions.some((item) => item.userId === pushPreSalesId)
@ -2156,10 +2272,14 @@ export default function Opportunities() {
if (!selectedItem) {
return;
}
if (!canEditSelectedItem) {
if (!isSelectedItemOwnedByCurrentUser) {
setError("仅可编辑本人负责的商机");
return;
}
if (isSelectedItemArchived) {
setError("已签单商机不允许编辑");
return;
}
setError("");
setFieldErrors({});
setForm(toFormFromItem(selectedItem, effectiveConfidenceOptions));
@ -2173,10 +2293,14 @@ export default function Opportunities() {
if (!selectedItem || submitting) {
return;
}
if (!canEditSelectedItem) {
if (!isSelectedItemOwnedByCurrentUser) {
setError("仅可编辑本人负责的商机");
return;
}
if (isSelectedItemArchived) {
setError("已签单商机不允许编辑");
return;
}
setError("");
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
@ -2202,10 +2326,14 @@ export default function Opportunities() {
if (!selectedItem || pushingOms) {
return;
}
if (!canPushSelectedItem) {
if (!isSelectedItemOwnedByCurrentUser) {
setError("仅可推送本人负责的商机");
return;
}
if (isSelectedItemArchived) {
setError("已签单商机不允许推送");
return;
}
setPushingOms(true);
setError("");
@ -2257,10 +2385,14 @@ export default function Opportunities() {
if (!selectedItem || pushingOms) {
return;
}
if (!canPushSelectedItem) {
if (!isSelectedItemOwnedByCurrentUser) {
setError("仅可推送本人负责的商机");
return;
}
if (isSelectedItemArchived) {
setError("已签单商机不允许推送");
return;
}
setError("");
syncPushPreSalesSelection(selectedItem, omsPreSalesOptions);
@ -2280,10 +2412,14 @@ export default function Opportunities() {
};
const handleConfirmPushToOms = async () => {
if (!canPushSelectedItem) {
if (!isSelectedItemOwnedByCurrentUser) {
setError("仅可推送本人负责的商机");
return;
}
if (isSelectedItemArchived) {
setError("已签单商机不允许推送");
return;
}
if (!pushPreSalesId && !pushPreSalesName.trim()) {
setError("请选择售前人员");
return;
@ -2607,6 +2743,7 @@ export default function Opportunities() {
placeholder="请选择新华三负责人"
searchPlaceholder="搜索姓名、工号、办事处、电话"
emptyText="未找到匹配的销售拓展人员"
loading={loadingSalesExpansionOptions}
createActionLabel={salesExpansionQuery.trim() ? `新增负责人“${salesExpansionQuery.trim()}”并选中` : "新增负责人并选中"}
className={cn(
fieldErrors.salesExpansionId ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
@ -2615,7 +2752,7 @@ export default function Opportunities() {
onQueryChange={setSalesExpansionQuery}
onCreate={(query) => void openQuickCreateModal("sales", query)}
/>
<p className="text-xs text-slate-400 dark:text-slate-500"></p>
<p className="text-xs text-slate-400 dark:text-slate-500"> 20 </p>
{fieldErrors.salesExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.salesExpansionId}</p> : null}
</label>
) : null}
@ -2628,6 +2765,7 @@ export default function Opportunities() {
placeholder="请选择渠道名称"
searchPlaceholder="搜索渠道名称、编码、省份、联系人"
emptyText="未找到匹配的渠道"
loading={loadingChannelExpansionOptions}
createActionLabel={channelExpansionQuery.trim() ? `新增渠道“${channelExpansionQuery.trim()}”并选中` : "新增渠道并选中"}
className={cn(
fieldErrors.channelExpansionId ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "",
@ -2636,7 +2774,7 @@ export default function Opportunities() {
onQueryChange={setChannelExpansionQuery}
onCreate={(query) => void openQuickCreateModal("channel", query)}
/>
<p className="text-xs text-slate-400 dark:text-slate-500"></p>
<p className="text-xs text-slate-400 dark:text-slate-500"> 20 </p>
{fieldErrors.channelExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.channelExpansionId}</p> : null}
</label>
) : null}
@ -3090,17 +3228,21 @@ export default function Opportunities() {
<div className="px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 sm:p-4">
{error ? <div className="crm-alert crm-alert-error mb-3">{error}</div> : null}
{!canEditSelectedItem ? (
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500"></p>
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500">
{!isSelectedItemOwnedByCurrentUser
? "当前商机非本人负责,仅支持查看详情,不能编辑或推送。"
: "当前商机已签单,不允许编辑或推送。"}
</p>
) : null}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={handleOpenEdit}
disabled={!canEditSelectedItem}
title={canEditSelectedItem ? "编辑商机" : "仅本人可操作"}
title={canEditSelectedItem ? "编辑商机" : !isSelectedItemOwnedByCurrentUser ? "仅本人可操作" : "已签单不可编辑"}
className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{canEditSelectedItem ? "编辑商机" : "仅本人可操作"}
{canEditSelectedItem ? "编辑商机" : !isSelectedItemOwnedByCurrentUser ? "仅本人可操作" : "已签单不可编辑"}
</button>
<button
type="button"
@ -3108,7 +3250,9 @@ export default function Opportunities() {
disabled={!canPushSelectedItem || pushingOms}
title={
!canPushSelectedItem
? "仅本人可操作"
? !isSelectedItemOwnedByCurrentUser
? "仅本人可操作"
: "已签单不可推送"
: selectedItem.pushedToOms
? "重新推送 OMS"
: "推送 OMS"
@ -3120,7 +3264,15 @@ export default function Opportunities() {
: "crm-btn-primary",
)}
>
{!canPushSelectedItem ? "仅本人可操作" : pushingOms ? "推送中..." : selectedItem.pushedToOms ? "重新推送 OMS" : "推送 OMS"}
{!canPushSelectedItem
? !isSelectedItemOwnedByCurrentUser
? "仅本人可操作"
: "已签单不可推送"
: pushingOms
? "推送中..."
: selectedItem.pushedToOms
? "重新推送 OMS"
: "推送 OMS"}
</button>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@ import {
X,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import ActionDialog from "@/components/ActionDialog";
import { useTheme } from "@/components/ThemeProvider";
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
@ -33,9 +34,10 @@ import {
type UpdateCurrentUserPasswordPayload,
type UserProfile,
} from "@/lib/auth";
import OwnerTransfer from "./OwnerTransfer";
type MenuItem = {
key: "personal" | "security" | "help";
key: "personal" | "security" | "ownerTransfer" | "help";
icon: typeof User;
label: string;
color: string;
@ -47,6 +49,7 @@ type EditableProfileForm = UpdateCurrentUserProfilePayload;
const MENU_ITEMS: MenuItem[] = [
{ key: "personal", icon: User, label: "个人资料", color: "text-blue-500 dark:text-blue-400", bg: "bg-blue-50 dark:bg-blue-500/10" },
{ key: "security", icon: Shield, label: "账号安全", color: "text-emerald-500 dark:text-emerald-400", bg: "bg-emerald-50 dark:bg-emerald-500/10" },
{ key: "ownerTransfer", icon: BriefcaseBusiness, label: "项目归属转移", color: "text-amber-500 dark:text-amber-400", bg: "bg-amber-50 dark:bg-amber-500/10" },
{ key: "help", icon: HelpCircle, label: "帮助中心", color: "text-violet-500 dark:text-violet-400", bg: "bg-violet-50 dark:bg-violet-500/10" },
];
@ -136,6 +139,8 @@ export default function Profile() {
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
const [profileDrawerOpen, setProfileDrawerOpen] = useState(false);
const [securityDrawerOpen, setSecurityDrawerOpen] = useState(false);
const [ownerTransferDrawerOpen, setOwnerTransferDrawerOpen] = useState(false);
const [ownerTransferFooter, setOwnerTransferFooter] = useState<ReactNode>(null);
const [form, setForm] = useState<EditableProfileForm>(EMPTY_FORM);
const [detailLoading, setDetailLoading] = useState(false);
const [saving, setSaving] = useState(false);
@ -147,6 +152,7 @@ export default function Profile() {
const [showOldPassword, setShowOldPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [helpDialogOpen, setHelpDialogOpen] = useState(false);
useEffect(() => {
let ignore = false;
@ -289,7 +295,16 @@ export default function Profile() {
};
const handleOpenHelp = () => {
window.alert("请联系系统管理员!");
setHelpDialogOpen(true);
};
const handleOpenOwnerTransfer = () => {
setOwnerTransferDrawerOpen(true);
};
const handleCloseOwnerTransfer = () => {
setOwnerTransferDrawerOpen(false);
setOwnerTransferFooter(null);
};
const handleUpdatePassword = async () => {
@ -409,6 +424,8 @@ export default function Profile() {
? () => void handleOpenProfile()
: item.key === "security"
? handleOpenSecurity
: item.key === "ownerTransfer"
? handleOpenOwnerTransfer
: item.key === "help"
? handleOpenHelp
: undefined
@ -679,6 +696,49 @@ export default function Profile() {
</div>
</PageModal>
) : null}
{ownerTransferDrawerOpen ? (
<PageModal
title="项目归属转移"
subtitle="先确认归属双方,再查看预检结果并执行转移。"
onClose={handleCloseOwnerTransfer}
footer={ownerTransferFooter || undefined}
>
<OwnerTransfer
embedded
onClose={handleCloseOwnerTransfer}
onFooterChange={setOwnerTransferFooter}
/>
</PageModal>
) : null}
{helpDialogOpen ? (
<ActionDialog
title="帮助中心"
tone="violet"
icon={<HelpCircle className="h-6 w-6" />}
confirmText="我知道了"
hideCancel
onClose={() => setHelpDialogOpen(false)}
onConfirm={() => setHelpDialogOpen(false)}
>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-slate-200/80 bg-white/90 p-4 dark:border-slate-800 dark:bg-slate-950/60">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<Shield className="h-4 w-4 text-violet-500" />
</div>
<p className="mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400">
访
</p>
</div>
</div>
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/90 px-4 py-3 text-sm leading-6 text-slate-600 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300">
线
</div>
</div>
</ActionDialog>
) : null}
</AnimatePresence>
</div>
);

View File

@ -162,6 +162,15 @@ type OpportunityQuickField =
| "competitorName";
type OpportunityOperatorMode = "none" | "h3c" | "channel" | "both";
function formatSalesRelationLabel(item?: Pick<SalesExpansionItem, "id" | "name" | "phone"> | null) {
if (!item) {
return "";
}
const normalizedName = item.name?.trim() || `人员拓展#${item.id}`;
const normalizedPhone = item.phone?.trim() || "";
return normalizedPhone ? `${normalizedName}${normalizedPhone}` : normalizedName;
}
const defaultQuickOpportunityForm: CreateOpportunityPayload = {
opportunityName: "",
customerName: "",
@ -1426,7 +1435,15 @@ export default function Work() {
const createdId = await createSalesExpansion(normalizeSharedSalesPayload(quickSalesForm));
const latestTargets = await refreshReportTargets();
const createdItem = latestTargets.salesItems.find((item) => item.id === createdId);
createdOption = { id: createdId, label: createdItem?.name || quickSalesForm.candidateName.trim() };
createdOption = {
id: createdId,
label: formatSalesRelationLabel(createdItem)
|| formatSalesRelationLabel({
id: createdId,
name: quickSalesForm.candidateName.trim(),
phone: quickSalesForm.mobile,
}),
};
} else if (quickCreateType === "channel") {
const duplicateResult = await checkChannelExpansionDuplicate(quickChannelForm.channelName.trim());
if (duplicateResult.duplicated) {
@ -4216,7 +4233,7 @@ function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
seenIds.add(item.id);
return true;
})
.map((item) => ({ id: item.id, label: item.name || `人员拓展#${item.id}` }));
.map((item) => ({ id: item.id, label: formatSalesRelationLabel(item) }));
}
function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[] {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{u as P,r as t,f as b,s as y,j as e,T as E,R,a as B,b as L,B as w,c as z,g as U,d as q,l as F,e as M}from"./index-D2cxrzyv.js";import{F as r}from"./index-KuslyOWx.js";import{I as u}from"./index-4zw5l6IX.js";import{C as O}from"./index-Iw46KAPi.js";import"./useForm-CaItC8xb.js";import"./row-C5EMotZ4.js";import"./index-COTHVrjr.js";const{Text:$}=E;function N(a){return a instanceof Error&&a.message.toLowerCase().includes("captcha disabled")}function Q(){const{t:a}=P(),[l,o]=t.useState(null),[d,m]=t.useState(!0),[h,p]=t.useState(!1),[n,I]=t.useState(null),[v]=r.useForm(),C="汇智CRM管理后台",f=t.useCallback(async()=>{const s=await b();o(s)},[]),x=t.useCallback(async()=>{try{await f()}catch(s){o(null),N(s)&&m(!1)}},[f]);t.useEffect(()=>{let s=!1;return(async()=>{const[c,T]=await Promise.all([U("security.captcha.enabled","true").catch(()=>"true"),q().catch(()=>null)]);if(s)return;I(T);const j=c!=="false";if(m(j),!j){o(null);return}try{const g=await b();if(s)return;o(g)}catch(g){if(s)return;o(null),N(g)&&m(!1)}})(),()=>{s=!0}},[]),t.useEffect(()=>{new URLSearchParams(window.location.search).get("timeout")==="1"&&(y.warning(a("login.loginTimeout")),window.history.replaceState({},document.title,window.location.pathname))},[a]);const S=async s=>{p(!0);try{const i=await F({username:s.username,password:s.password,tenantCode:s.tenantCode,captchaId:d?l==null?void 0:l.captchaId:void 0,captchaCode:d?s.captchaCode:void 0});if(localStorage.setItem("accessToken",i.accessToken),localStorage.setItem("refreshToken",i.refreshToken),localStorage.setItem("username",s.username),i.availableTenants){localStorage.setItem("availableTenants",JSON.stringify(i.availableTenants));const c=JSON.parse(atob(i.accessToken.split(".")[1]));localStorage.setItem("activeTenantId",String(c.tenantId))}try{const c=await M();sessionStorage.setItem("userProfile",JSON.stringify(c))}catch{sessionStorage.removeItem("userProfile")}y.success(a("common.success")),window.location.href="/"}catch{d&&await x()}finally{p(!1)}},k=n!=null&&n.loginBgUrl?{backgroundImage:`url(${n.loginBgUrl})`,backgroundSize:"cover",backgroundPosition:"center",position:"relative"}:{};return e.jsxs("div",{className:"login-page",style:k,children:[e.jsx("div",{className:"login-page-backdrop"}),e.jsx("div",{className:"login-page-grid",children:e.jsx("section",{className:"login-panel",children:e.jsx("div",{className:"login-panel-card",children:e.jsxs("div",{className:"login-panel-layout",children:[e.jsx("div",{className:"login-left",children:e.jsxs("div",{className:"login-brand",children:[e.jsx("div",{className:"brand-logo-wrap",children:e.jsx("img",{src:(n==null?void 0:n.logoUrl)||"/logo.svg",alt:"Logo",className:"brand-logo-img"})}),e.jsxs("div",{className:"brand-copy",children:[e.jsx("p",{className:"brand-kicker",children:"智慧销售协同平台"}),e.jsx("span",{className:"brand-name",children:C})]})]})}),e.jsx("div",{className:"login-right",children:e.jsxs("div",{className:"login-container",children:[e.jsxs("div",{className:"login-header",children:[e.jsx("p",{className:"login-panel-eyebrow",children:a("login.welcome")}),e.jsx($,{type:"secondary",children:a("login.subtitle")})]}),e.jsxs(r,{form:v,layout:"vertical",onFinish:S,className:"login-form",requiredMark:!1,autoComplete:"off",children:[e.jsx(r.Item,{name:"username",rules:[{required:!0,message:a("login.username")}],children:e.jsx(u,{size:"large",prefix:e.jsx(R,{className:"text-gray-400","aria-hidden":"true"}),placeholder:a("login.username"),autoComplete:"username",spellCheck:!1,"aria-label":a("login.username")})}),e.jsx(r.Item,{name:"password",rules:[{required:!0,message:a("login.password")}],children:e.jsx(u.Password,{size:"large",prefix:e.jsx(B,{className:"text-gray-400","aria-hidden":"true"}),placeholder:a("login.password"),autoComplete:"current-password","aria-label":a("login.password")})}),d?e.jsx(r.Item,{name:"captchaCode",rules:[{required:!0,message:a("login.captcha")}],children:e.jsxs("div",{className:"captcha-wrapper",children:[e.jsx(u,{size:"large",prefix:e.jsx(L,{className:"text-gray-400","aria-hidden":"true"}),placeholder:a("login.captcha"),maxLength:6,"aria-label":a("login.captcha")}),e.jsx(w,{className:"captcha-image-btn",onClick:()=>void x(),icon:l?null:e.jsx(z,{spin:!0}),"aria-label":"刷新验证码",children:l?e.jsx("img",{src:l.imageBase64,alt:"验证码"}):null})]})}):null,e.jsx("div",{className:"login-extra",children:e.jsx(r.Item,{name:"remember",valuePropName:"checked",noStyle:!0,children:e.jsx(O,{children:a("login.rememberMe")})})}),e.jsx(r.Item,{children:e.jsx(w,{type:"primary",htmlType:"submit",loading:h,block:!0,size:"large",className:"login-submit-btn",children:a(h?"login.loggingIn":"login.submit")})})]})]})})]})})})})]})}export{Q as default};
import{u as P,r as t,f as b,s as y,j as e,T as E,R,a as B,b as L,B as w,c as z,g as U,d as q,l as F,e as M}from"./index-DIy3NosD.js";import{F as r}from"./index-C8zAgrXu.js";import{I as u}from"./index-ChEzJj4c.js";import{C as O}from"./index-D0LR2CrS.js";import"./useForm-Ee9FUUVh.js";import"./row-BFbQ_b2k.js";import"./index-CUzdQnnk.js";const{Text:$}=E;function N(a){return a instanceof Error&&a.message.toLowerCase().includes("captcha disabled")}function Q(){const{t:a}=P(),[l,o]=t.useState(null),[d,m]=t.useState(!0),[h,p]=t.useState(!1),[n,I]=t.useState(null),[v]=r.useForm(),C="汇智CRM管理后台",f=t.useCallback(async()=>{const s=await b();o(s)},[]),x=t.useCallback(async()=>{try{await f()}catch(s){o(null),N(s)&&m(!1)}},[f]);t.useEffect(()=>{let s=!1;return(async()=>{const[c,T]=await Promise.all([U("security.captcha.enabled","true").catch(()=>"true"),q().catch(()=>null)]);if(s)return;I(T);const j=c!=="false";if(m(j),!j){o(null);return}try{const g=await b();if(s)return;o(g)}catch(g){if(s)return;o(null),N(g)&&m(!1)}})(),()=>{s=!0}},[]),t.useEffect(()=>{new URLSearchParams(window.location.search).get("timeout")==="1"&&(y.warning(a("login.loginTimeout")),window.history.replaceState({},document.title,window.location.pathname))},[a]);const S=async s=>{p(!0);try{const i=await F({username:s.username,password:s.password,tenantCode:s.tenantCode,captchaId:d?l==null?void 0:l.captchaId:void 0,captchaCode:d?s.captchaCode:void 0});if(localStorage.setItem("accessToken",i.accessToken),localStorage.setItem("refreshToken",i.refreshToken),localStorage.setItem("username",s.username),i.availableTenants){localStorage.setItem("availableTenants",JSON.stringify(i.availableTenants));const c=JSON.parse(atob(i.accessToken.split(".")[1]));localStorage.setItem("activeTenantId",String(c.tenantId))}try{const c=await M();sessionStorage.setItem("userProfile",JSON.stringify(c))}catch{sessionStorage.removeItem("userProfile")}y.success(a("common.success")),window.location.href="/"}catch{d&&await x()}finally{p(!1)}},k=n!=null&&n.loginBgUrl?{backgroundImage:`url(${n.loginBgUrl})`,backgroundSize:"cover",backgroundPosition:"center",position:"relative"}:{};return e.jsxs("div",{className:"login-page",style:k,children:[e.jsx("div",{className:"login-page-backdrop"}),e.jsx("div",{className:"login-page-grid",children:e.jsx("section",{className:"login-panel",children:e.jsx("div",{className:"login-panel-card",children:e.jsxs("div",{className:"login-panel-layout",children:[e.jsx("div",{className:"login-left",children:e.jsxs("div",{className:"login-brand",children:[e.jsx("div",{className:"brand-logo-wrap",children:e.jsx("img",{src:(n==null?void 0:n.logoUrl)||"/logo.svg",alt:"Logo",className:"brand-logo-img"})}),e.jsxs("div",{className:"brand-copy",children:[e.jsx("p",{className:"brand-kicker",children:"智慧销售协同平台"}),e.jsx("span",{className:"brand-name",children:C})]})]})}),e.jsx("div",{className:"login-right",children:e.jsxs("div",{className:"login-container",children:[e.jsxs("div",{className:"login-header",children:[e.jsx("p",{className:"login-panel-eyebrow",children:a("login.welcome")}),e.jsx($,{type:"secondary",children:a("login.subtitle")})]}),e.jsxs(r,{form:v,layout:"vertical",onFinish:S,className:"login-form",requiredMark:!1,autoComplete:"off",children:[e.jsx(r.Item,{name:"username",rules:[{required:!0,message:a("login.username")}],children:e.jsx(u,{size:"large",prefix:e.jsx(R,{className:"text-gray-400","aria-hidden":"true"}),placeholder:a("login.username"),autoComplete:"username",spellCheck:!1,"aria-label":a("login.username")})}),e.jsx(r.Item,{name:"password",rules:[{required:!0,message:a("login.password")}],children:e.jsx(u.Password,{size:"large",prefix:e.jsx(B,{className:"text-gray-400","aria-hidden":"true"}),placeholder:a("login.password"),autoComplete:"current-password","aria-label":a("login.password")})}),d?e.jsx(r.Item,{name:"captchaCode",rules:[{required:!0,message:a("login.captcha")}],children:e.jsxs("div",{className:"captcha-wrapper",children:[e.jsx(u,{size:"large",prefix:e.jsx(L,{className:"text-gray-400","aria-hidden":"true"}),placeholder:a("login.captcha"),maxLength:6,"aria-label":a("login.captcha")}),e.jsx(w,{className:"captcha-image-btn",onClick:()=>void x(),icon:l?null:e.jsx(z,{spin:!0}),"aria-label":"刷新验证码",children:l?e.jsx("img",{src:l.imageBase64,alt:"验证码"}):null})]})}):null,e.jsx("div",{className:"login-extra",children:e.jsx(r.Item,{name:"remember",valuePropName:"checked",noStyle:!0,children:e.jsx(O,{children:a("login.rememberMe")})})}),e.jsx(r.Item,{children:e.jsx(w,{type:"primary",htmlType:"submit",loading:h,block:!0,size:"large",className:"login-submit-btn",children:a(h?"login.loggingIn":"login.submit")})})]})]})})]})})})})]})}export{Q as default};

View File

@ -1 +1 @@
import{cs as F,r as i,an as I,ce as S,ct as j,cu as v,cv as E,cw as $,ao as M,cx as N,cy as _,cz as A,cA as c,cB as V,cC as W,cD as z,cE as B,cF as x,cG as D,cH as G}from"./index-D2cxrzyv.js";var H=function(e,n){var l={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&n.indexOf(r)<0&&(l[r]=e[r]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var o=0,r=Object.getOwnPropertySymbols(e);o<r.length;o++)n.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(l[r[o]]=e[r[o]]);return l};const R=e=>{const{prefixCls:n,className:l,closeIcon:r,closable:o,type:a,title:b,children:d,footer:h}=e,y=H(e,["prefixCls","className","closeIcon","closable","type","title","children","footer"]),{getPrefixCls:m}=i.useContext(I),C=m(),s=n||m("modal"),p=S(C),[w,O,g]=j(s,p),f=`${s}-confirm`;let u={};return a?u={closable:o??!1,title:"",footer:"",children:i.createElement(v,Object.assign({},e,{prefixCls:s,confirmPrefixCls:f,rootPrefixCls:C,content:d}))}:u={closable:o??!0,title:b,footer:h!==null&&i.createElement(E,Object.assign({},e)),children:d},w(i.createElement($,Object.assign({prefixCls:s,className:M(O,`${s}-pure-panel`,a&&f,a&&`${f}-${a}`,l,g,p)},y,{closeIcon:N(s,r),closable:o},u)))},T=F(R);function P(e){return c(G(e))}const t=_;t.useModal=A;t.info=function(n){return c(V(n))};t.success=function(n){return c(W(n))};t.error=function(n){return c(z(n))};t.warning=P;t.warn=P;t.confirm=function(n){return c(B(n))};t.destroyAll=function(){for(;x.length;){const n=x.pop();n&&n()}};t.config=D;t._InternalPanelDoNotUseOrYouWillBeFired=T;export{t as M};
import{cs as F,r as i,an as I,ce as S,ct as j,cu as v,cv as E,cw as $,ao as M,cx as N,cy as _,cz as A,cA as c,cB as V,cC as W,cD as z,cE as B,cF as x,cG as D,cH as G}from"./index-DIy3NosD.js";var H=function(e,n){var l={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&n.indexOf(r)<0&&(l[r]=e[r]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var o=0,r=Object.getOwnPropertySymbols(e);o<r.length;o++)n.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(e,r[o])&&(l[r[o]]=e[r[o]]);return l};const R=e=>{const{prefixCls:n,className:l,closeIcon:r,closable:o,type:a,title:b,children:d,footer:h}=e,y=H(e,["prefixCls","className","closeIcon","closable","type","title","children","footer"]),{getPrefixCls:m}=i.useContext(I),C=m(),s=n||m("modal"),p=S(C),[w,O,g]=j(s,p),f=`${s}-confirm`;let u={};return a?u={closable:o??!1,title:"",footer:"",children:i.createElement(v,Object.assign({},e,{prefixCls:s,confirmPrefixCls:f,rootPrefixCls:C,content:d}))}:u={closable:o??!0,title:b,footer:h!==null&&i.createElement(E,Object.assign({},e)),children:d},w(i.createElement($,Object.assign({prefixCls:s,className:M(O,`${s}-pure-panel`,a&&f,a&&`${f}-${a}`,l,g,p)},y,{closeIcon:N(s,r),closable:o},u)))},T=F(R);function P(e){return c(G(e))}const t=_;t.useModal=A;t.info=function(n){return c(V(n))};t.success=function(n){return c(W(n))};t.error=function(n){return c(z(n))};t.warning=P;t.warn=P;t.confirm=function(n){return c(B(n))};t.destroyAll=function(){for(;x.length;){const n=x.pop();n&&n()}};t.config=D;t._InternalPanelDoNotUseOrYouWillBeFired=T;export{t as M};

View File

@ -0,0 +1 @@
.owner-transfer-page{--owner-transfer-bg: linear-gradient(180deg, #f7fbff 0%, #ffffff 100%);--owner-transfer-border: #dbe7f3;--owner-transfer-accent: #1677ff;--owner-transfer-soft: #eef5ff;--owner-transfer-warning: #fff7e6}.owner-transfer-page .owner-transfer-preview-card,.owner-transfer-page .owner-transfer-form-card{border-radius:18px;border:1px solid var(--owner-transfer-border);background:var(--owner-transfer-bg);box-shadow:0 14px 32px #1f385814}.owner-transfer-page .owner-transfer-form-card .ant-card-body,.owner-transfer-page .owner-transfer-preview-card .ant-card-body{padding:20px}.owner-transfer-page .owner-transfer-panel-title{display:flex;flex-direction:column;gap:4px;margin-bottom:20px}.owner-transfer-page .owner-transfer-user-flow{display:grid;grid-template-columns:1fr auto 1fr;gap:12px;align-items:center;margin-bottom:18px}.owner-transfer-page .owner-transfer-user-card,.owner-transfer-page .owner-transfer-checkbox-card,.owner-transfer-page .owner-transfer-summary-card{border-radius:14px;border:1px solid var(--owner-transfer-border);background:#ffffffe6}.owner-transfer-page .owner-transfer-user-card{min-width:0;padding:14px;display:flex;flex-direction:column;gap:6px}.owner-transfer-page .owner-transfer-flow-icon{color:var(--owner-transfer-accent);font-size:18px}.owner-transfer-page .owner-transfer-checkbox-card{padding:16px;margin-bottom:16px}.owner-transfer-page .owner-transfer-summary-card{padding:14px 16px;margin-bottom:16px;background:var(--owner-transfer-soft)}.owner-transfer-page .owner-transfer-summary-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:8px}.owner-transfer-page .owner-transfer-alert{margin-bottom:16px}.owner-transfer-page .owner-transfer-submit{height:46px;border-radius:12px}.owner-transfer-page .owner-transfer-preview-loading{min-height:320px;display:grid;place-items:center}.owner-transfer-page .owner-transfer-overview{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;padding:18px;border-radius:16px;background:linear-gradient(135deg,#edf5ff,#f8fbff);border:1px solid #d8e8ff}.owner-transfer-page .owner-transfer-overview-copy{display:flex;flex-direction:column;gap:4px}.owner-transfer-page .owner-transfer-overview-tags{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.owner-transfer-page .owner-transfer-stats{margin-bottom:0}.owner-transfer-page .owner-transfer-stat-card{border-radius:14px;border:1px solid var(--owner-transfer-border)}.owner-transfer-page .owner-transfer-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px}.owner-transfer-page .owner-transfer-toolbar-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:8px}.owner-transfer-page .owner-transfer-search{max-width:320px}.owner-transfer-page .owner-transfer-conflict-card,.owner-transfer-page .owner-transfer-preview-card .ant-tabs-content-holder,.owner-transfer-page .owner-transfer-preview-card .ant-card-small{border-radius:14px}.owner-transfer-page .owner-transfer-row-conflict>td{background:var(--owner-transfer-warning)!important}.owner-transfer-page .owner-transfer-preview-card .ant-tabs-nav{margin-bottom:12px}.owner-transfer-page .owner-transfer-preview-card .ant-table-wrapper{overflow:hidden;border:1px solid #eef2f7;border-radius:14px;background:#fff}@media (max-width: 1199px){.owner-transfer-page .owner-transfer-search{max-width:none;width:100%}.owner-transfer-page .owner-transfer-toolbar,.owner-transfer-page .owner-transfer-overview{flex-direction:column;align-items:stretch}.owner-transfer-page .owner-transfer-toolbar-actions,.owner-transfer-page .owner-transfer-overview-tags{justify-content:flex-start}}@media (max-width: 767px){.owner-transfer-page .owner-transfer-user-flow{grid-template-columns:1fr}.owner-transfer-page .owner-transfer-flow-icon{justify-self:center;transform:rotate(90deg)}}

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{j as e,m as N,n as I,u as R,T as $,o as c,p as k,B as o,q as w,t as T,v as C,w as M,R as S,S as _}from"./index-D2cxrzyv.js";import{P as D}from"./index-DICUnuN_.js";import{T as l}from"./index-C8Xnkbw4.js";import{R as h,C as r}from"./row-C5EMotZ4.js";import{C as p}from"./index-BFDbUxK1.js";import{F as E}from"./Table-BLvbgkX9.js";import"./iconUtil-CAY5Il88.js";import"./index-COTHVrjr.js";import"./useForm-CaItC8xb.js";import"./index-Iw46KAPi.js";import"./Pagination-lXX40XmA.js";function n({title:a,value:d,icon:i,color:s="blue",trend:t,suffix:u="",layout:b="column",gridColumn:m,className:f="",onClick:g,style:v={}}){const x={blue:"#1677ff",green:"#52c41a",orange:"#faad14",red:"#ff4d4f",purple:"#722ed1",gray:"#8c8c8c"}[s]||s,y={...m?{gridColumn:m}:{},...v};return e.jsxs("div",{className:`stat-card stat-card-${b} ${f}`,style:y,onClick:g,children:[e.jsxs("div",{className:"stat-card-header",children:[e.jsx("span",{className:"stat-card-title",children:a}),i&&e.jsx("span",{className:"stat-card-icon",style:{color:x},"aria-hidden":"true",children:i})]}),e.jsxs("div",{className:"stat-card-body",children:[e.jsxs("div",{className:"stat-card-value tabular-nums",style:{color:x},children:[d,u&&e.jsx("span",{className:"stat-card-suffix",children:u})]}),t&&e.jsxs("div",{className:`stat-card-trend ${t.direction==="up"?"trend-up":"trend-down"} tabular-nums`,"aria-label":`${t.direction==="up"?"Increase":"Decrease"} of ${t.value}%`,children:[t.direction==="up"?e.jsx(N,{"aria-hidden":"true"}):e.jsx(I,{"aria-hidden":"true"}),e.jsxs("span",{children:[Math.abs(t.value),"%"]})]})]})]})}const{Text:j}=$;function O(){const{t:a}=R(),d=[{key:"1",name:"Product Sync",time:"2024-02-10 14:00",duration:"45min",status:"processing"},{key:"2",name:"Tech Review",time:"2024-02-10 10:00",duration:"60min",status:"success"},{key:"3",name:"Daily Standup",time:"2024-02-10 09:00",duration:"15min",status:"success"},{key:"4",name:"Client Call",time:"2024-02-10 16:30",duration:"30min",status:"default"}],i=[{title:a("dashboard.meetingName"),dataIndex:"name",key:"name",render:s=>e.jsx(j,{strong:!0,children:s})},{title:a("dashboard.startTime"),dataIndex:"time",key:"time",className:"tabular-nums",render:s=>e.jsx(j,{type:"secondary",children:s})},{title:a("dashboard.duration"),dataIndex:"duration",key:"duration",width:100,className:"tabular-nums"},{title:a("common.status"),dataIndex:"status",key:"status",width:120,render:s=>s==="processing"?e.jsx(l,{icon:e.jsx(c,{spin:!0,"aria-hidden":"true"}),color:"processing",children:a("dashboardExt.processing")}):s==="success"?e.jsx(l,{icon:e.jsx(k,{"aria-hidden":"true"}),color:"success",children:a("dashboardExt.completed")}):e.jsx(l,{color:"default",children:a("dashboardExt.pending")})},{title:a("common.action"),key:"action",width:80,render:()=>e.jsx(o,{type:"link",size:"small",icon:e.jsx(w,{"aria-hidden":"true"}),"aria-label":a("dashboard.viewAll")})}];return e.jsxs("div",{className:"app-page dashboard-page",children:[e.jsx(D,{title:a("dashboard.title"),subtitle:a("dashboard.subtitle")}),e.jsx("div",{className:"app-page__page-actions",children:e.jsx(o,{icon:e.jsx(c,{"aria-hidden":"true"}),size:"small",children:a("common.refresh")})}),e.jsxs(h,{gutter:[24,24],children:[e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(n,{title:a("dashboard.todayMeetings"),value:12,icon:e.jsx(T,{"aria-hidden":"true"}),color:"blue",trend:{value:8,direction:"up"}})}),e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(n,{title:a("dashboard.activeDevices"),value:45,icon:e.jsx(C,{"aria-hidden":"true"}),color:"green",trend:{value:2,direction:"up"}})}),e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(n,{title:a("dashboard.transcriptionDuration"),value:1280,suffix:"min",icon:e.jsx(M,{"aria-hidden":"true"}),color:"orange",trend:{value:5,direction:"down"}})}),e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(n,{title:a("dashboard.totalUsers"),value:320,icon:e.jsx(S,{"aria-hidden":"true"}),color:"purple",trend:{value:12,direction:"up"}})})]}),e.jsxs(h,{gutter:[24,24],className:"mt-6",children:[e.jsx(r,{xs:24,xl:16,children:e.jsx(p,{title:a("dashboard.recentMeetings"),variant:"borderless",className:"app-page__content-card",extra:e.jsx(o,{type:"link",size:"small",children:a("dashboard.viewAll")}),styles:{body:{padding:0}},children:e.jsx(E,{dataSource:d,columns:i,pagination:!1,size:"middle",className:"roles-table"})})}),e.jsx(r,{xs:24,xl:8,children:e.jsx(p,{title:a("dashboard.deviceLoad"),variant:"borderless",className:"app-page__content-card",children:e.jsxs("div",{className:"flex flex-col items-center justify-center py-12",children:[e.jsx(_,{active:!0,paragraph:{rows:4}}),e.jsxs("div",{className:"mt-4 text-gray-400 flex items-center gap-2",children:[e.jsx(c,{spin:!0,"aria-hidden":"true"}),e.jsx("span",{children:a("dashboardExt.chartLoading")})]})]})})})]})]})}export{O as default};
import{j as e,m as N,n as I,u as R,T as $,o as c,p as k,B as o,q as w,t as T,v as C,w as M,R as S,S as _}from"./index-DIy3NosD.js";import{P as D}from"./index-x0_qMNAh.js";import{T as l}from"./index-Do3ElwMr.js";import{R as h,C as r}from"./row-BFbQ_b2k.js";import{C as p}from"./index-JMVQVIid.js";import{F as E}from"./Table-C0wBOmw3.js";import"./iconUtil-DNX53dK0.js";import"./index-CUzdQnnk.js";import"./useForm-Ee9FUUVh.js";import"./index-D0LR2CrS.js";import"./Pagination-C_3XJ-9Y.js";function n({title:a,value:d,icon:i,color:s="blue",trend:t,suffix:u="",layout:b="column",gridColumn:m,className:f="",onClick:g,style:v={}}){const x={blue:"#1677ff",green:"#52c41a",orange:"#faad14",red:"#ff4d4f",purple:"#722ed1",gray:"#8c8c8c"}[s]||s,y={...m?{gridColumn:m}:{},...v};return e.jsxs("div",{className:`stat-card stat-card-${b} ${f}`,style:y,onClick:g,children:[e.jsxs("div",{className:"stat-card-header",children:[e.jsx("span",{className:"stat-card-title",children:a}),i&&e.jsx("span",{className:"stat-card-icon",style:{color:x},"aria-hidden":"true",children:i})]}),e.jsxs("div",{className:"stat-card-body",children:[e.jsxs("div",{className:"stat-card-value tabular-nums",style:{color:x},children:[d,u&&e.jsx("span",{className:"stat-card-suffix",children:u})]}),t&&e.jsxs("div",{className:`stat-card-trend ${t.direction==="up"?"trend-up":"trend-down"} tabular-nums`,"aria-label":`${t.direction==="up"?"Increase":"Decrease"} of ${t.value}%`,children:[t.direction==="up"?e.jsx(N,{"aria-hidden":"true"}):e.jsx(I,{"aria-hidden":"true"}),e.jsxs("span",{children:[Math.abs(t.value),"%"]})]})]})]})}const{Text:j}=$;function O(){const{t:a}=R(),d=[{key:"1",name:"Product Sync",time:"2024-02-10 14:00",duration:"45min",status:"processing"},{key:"2",name:"Tech Review",time:"2024-02-10 10:00",duration:"60min",status:"success"},{key:"3",name:"Daily Standup",time:"2024-02-10 09:00",duration:"15min",status:"success"},{key:"4",name:"Client Call",time:"2024-02-10 16:30",duration:"30min",status:"default"}],i=[{title:a("dashboard.meetingName"),dataIndex:"name",key:"name",render:s=>e.jsx(j,{strong:!0,children:s})},{title:a("dashboard.startTime"),dataIndex:"time",key:"time",className:"tabular-nums",render:s=>e.jsx(j,{type:"secondary",children:s})},{title:a("dashboard.duration"),dataIndex:"duration",key:"duration",width:100,className:"tabular-nums"},{title:a("common.status"),dataIndex:"status",key:"status",width:120,render:s=>s==="processing"?e.jsx(l,{icon:e.jsx(c,{spin:!0,"aria-hidden":"true"}),color:"processing",children:a("dashboardExt.processing")}):s==="success"?e.jsx(l,{icon:e.jsx(k,{"aria-hidden":"true"}),color:"success",children:a("dashboardExt.completed")}):e.jsx(l,{color:"default",children:a("dashboardExt.pending")})},{title:a("common.action"),key:"action",width:80,render:()=>e.jsx(o,{type:"link",size:"small",icon:e.jsx(w,{"aria-hidden":"true"}),"aria-label":a("dashboard.viewAll")})}];return e.jsxs("div",{className:"app-page dashboard-page",children:[e.jsx(D,{title:a("dashboard.title"),subtitle:a("dashboard.subtitle")}),e.jsx("div",{className:"app-page__page-actions",children:e.jsx(o,{icon:e.jsx(c,{"aria-hidden":"true"}),size:"small",children:a("common.refresh")})}),e.jsxs(h,{gutter:[24,24],children:[e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(n,{title:a("dashboard.todayMeetings"),value:12,icon:e.jsx(T,{"aria-hidden":"true"}),color:"blue",trend:{value:8,direction:"up"}})}),e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(n,{title:a("dashboard.activeDevices"),value:45,icon:e.jsx(C,{"aria-hidden":"true"}),color:"green",trend:{value:2,direction:"up"}})}),e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(n,{title:a("dashboard.transcriptionDuration"),value:1280,suffix:"min",icon:e.jsx(M,{"aria-hidden":"true"}),color:"orange",trend:{value:5,direction:"down"}})}),e.jsx(r,{xs:24,sm:12,lg:6,children:e.jsx(n,{title:a("dashboard.totalUsers"),value:320,icon:e.jsx(S,{"aria-hidden":"true"}),color:"purple",trend:{value:12,direction:"up"}})})]}),e.jsxs(h,{gutter:[24,24],className:"mt-6",children:[e.jsx(r,{xs:24,xl:16,children:e.jsx(p,{title:a("dashboard.recentMeetings"),variant:"borderless",className:"app-page__content-card",extra:e.jsx(o,{type:"link",size:"small",children:a("dashboard.viewAll")}),styles:{body:{padding:0}},children:e.jsx(E,{dataSource:d,columns:i,pagination:!1,size:"middle",className:"roles-table"})})}),e.jsx(r,{xs:24,xl:8,children:e.jsx(p,{title:a("dashboard.deviceLoad"),variant:"borderless",className:"app-page__content-card",children:e.jsxs("div",{className:"flex flex-col items-center justify-center py-12",children:[e.jsx(_,{active:!0,paragraph:{rows:4}}),e.jsxs("div",{className:"mt-4 text-gray-400 flex items-center gap-2",children:[e.jsx(c,{spin:!0,"aria-hidden":"true"}),e.jsx("span",{children:a("dashboardExt.chartLoading")})]})]})})})]})]})}export{O as default};

View File

@ -1 +1 @@
import{r as u,h as p,j as e,L as f,a as o,T as j,B as l,i as w,k as y,s as g}from"./index-D2cxrzyv.js";import{F as s}from"./index-KuslyOWx.js";import{C as h}from"./index-BFDbUxK1.js";import{I as a}from"./index-4zw5l6IX.js";import"./useForm-CaItC8xb.js";import"./row-C5EMotZ4.js";const{Title:P,Text:b}=j;function F(){const[d,t]=u.useState(!1),c=p(),[m]=s.useForm(),n=()=>{localStorage.clear(),sessionStorage.clear(),c("/login")},x=async r=>{t(!0);try{await y({oldPassword:r.oldPassword,newPassword:r.newPassword}),g.success("密码已更新,请重新登录"),n()}finally{t(!1)}};return e.jsx(f,{style:{minHeight:"100vh",background:"#f0f2f5",display:"flex",alignItems:"center",justifyContent:"center"},children:e.jsxs(h,{style:{width:420,borderRadius:8,boxShadow:"0 4px 12px rgba(0,0,0,0.1)"},children:[e.jsxs("div",{className:"text-center mb-6",children:[e.jsx(o,{style:{fontSize:40,color:"#1890ff"}}),e.jsx(P,{level:3,style:{marginTop:16},children:"首次登录请修改密码"}),e.jsx(b,{type:"secondary",children:"当前账号被要求更新初始密码,提交成功后会跳转到登录页。"})]}),e.jsxs(s,{form:m,layout:"vertical",onFinish:x,children:[e.jsx(s.Item,{label:"当前密码",name:"oldPassword",rules:[{required:!0,message:"请输入当前密码"}],children:e.jsx(a.Password,{prefix:e.jsx(o,{})})}),e.jsx(s.Item,{label:"新密码",name:"newPassword",rules:[{required:!0,min:6,message:"新密码至少 6 位"}],children:e.jsx(a.Password,{prefix:e.jsx(o,{})})}),e.jsx(s.Item,{label:"确认新密码",name:"confirmPassword",dependencies:["newPassword"],rules:[{required:!0,message:"请再次输入新密码"},({getFieldValue:r})=>({validator(T,i){return!i||r("newPassword")===i?Promise.resolve():Promise.reject(new Error("两次输入的新密码不一致"))}})],children:e.jsx(a.Password,{prefix:e.jsx(o,{})})}),e.jsx(l,{type:"primary",htmlType:"submit",block:!0,size:"large",loading:d,style:{marginTop:8},children:"提交并重新登录"}),e.jsx(l,{type:"link",block:!0,icon:e.jsx(w,{}),onClick:n,style:{marginTop:8},children:"退出登录"})]})]})})}export{F as default};
import{r as u,h as p,j as e,L as f,a as o,T as j,B as l,i as w,k as y,s as g}from"./index-DIy3NosD.js";import{F as s}from"./index-C8zAgrXu.js";import{C as h}from"./index-JMVQVIid.js";import{I as a}from"./index-ChEzJj4c.js";import"./useForm-Ee9FUUVh.js";import"./row-BFbQ_b2k.js";const{Title:P,Text:b}=j;function F(){const[d,t]=u.useState(!1),c=p(),[m]=s.useForm(),n=()=>{localStorage.clear(),sessionStorage.clear(),c("/login")},x=async r=>{t(!0);try{await y({oldPassword:r.oldPassword,newPassword:r.newPassword}),g.success("密码已更新,请重新登录"),n()}finally{t(!1)}};return e.jsx(f,{style:{minHeight:"100vh",background:"#f0f2f5",display:"flex",alignItems:"center",justifyContent:"center"},children:e.jsxs(h,{style:{width:420,borderRadius:8,boxShadow:"0 4px 12px rgba(0,0,0,0.1)"},children:[e.jsxs("div",{className:"text-center mb-6",children:[e.jsx(o,{style:{fontSize:40,color:"#1890ff"}}),e.jsx(P,{level:3,style:{marginTop:16},children:"首次登录请修改密码"}),e.jsx(b,{type:"secondary",children:"当前账号被要求更新初始密码,提交成功后会跳转到登录页。"})]}),e.jsxs(s,{form:m,layout:"vertical",onFinish:x,children:[e.jsx(s.Item,{label:"当前密码",name:"oldPassword",rules:[{required:!0,message:"请输入当前密码"}],children:e.jsx(a.Password,{prefix:e.jsx(o,{})})}),e.jsx(s.Item,{label:"新密码",name:"newPassword",rules:[{required:!0,min:6,message:"新密码至少 6 位"}],children:e.jsx(a.Password,{prefix:e.jsx(o,{})})}),e.jsx(s.Item,{label:"确认新密码",name:"confirmPassword",dependencies:["newPassword"],rules:[{required:!0,message:"请再次输入新密码"},({getFieldValue:r})=>({validator(T,i){return!i||r("newPassword")===i?Promise.resolve():Promise.reject(new Error("两次输入的新密码不一致"))}})],children:e.jsx(a.Password,{prefix:e.jsx(o,{})})}),e.jsx(l,{type:"primary",htmlType:"submit",block:!0,size:"large",loading:d,style:{marginTop:8},children:"提交并重新登录"}),e.jsx(l,{type:"link",block:!0,icon:e.jsx(w,{}),onClick:n,style:{marginTop:8},children:"退出登录"})]})]})})}export{F as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
import{r as t,aW as I,bv as j,aS as L,ao as M,bc as v,aX as N,aV as y,am as T,bM as x,ar as H,aq as O,at as u,as as S,e0 as W}from"./index-D2cxrzyv.js";var q=["prefixCls","className","style","checked","disabled","defaultChecked","type","title","onChange"],G=t.forwardRef(function(e,r){var a=e.prefixCls,i=a===void 0?"rc-checkbox":a,b=e.className,l=e.style,w=e.checked,s=e.disabled,p=e.defaultChecked,R=p===void 0?!1:p,h=e.type,f=h===void 0?"checkbox":h,P=e.title,d=e.onChange,_=I(e,q),c=t.useRef(null),g=t.useRef(null),D=j(R,{value:w}),$=L(D,2),C=$[0],z=$[1];t.useImperativeHandle(r,function(){return{focus:function(o){var n;(n=c.current)===null||n===void 0||n.focus(o)},blur:function(){var o;(o=c.current)===null||o===void 0||o.blur()},input:c.current,nativeElement:g.current}});var B=M(i,b,v(v({},"".concat(i,"-checked"),C),"".concat(i,"-disabled"),s)),E=function(o){s||("checked"in e||z(o.target.checked),d==null||d({target:y(y({},e),{},{type:f,checked:o.target.checked}),stopPropagation:function(){o.stopPropagation()},preventDefault:function(){o.preventDefault()},nativeEvent:o.nativeEvent}))};return t.createElement("span",{className:B,title:P,style:l,ref:g},t.createElement("input",N({},_,{className:"".concat(i,"-input"),ref:c,onChange:E,disabled:s,checked:!!C,type:f})),t.createElement("span",{className:"".concat(i,"-inner")}))});function V(e){const r=T.useRef(null),a=()=>{x.cancel(r.current),r.current=null};return[()=>{a(),r.current=x(()=>{r.current=null})},l=>{r.current&&(l.stopPropagation(),a()),e==null||e(l)}]}const F=e=>{const{checkboxCls:r}=e,a=`${r}-wrapper`;return[{[`${r}-group`]:Object.assign(Object.assign({},u(e)),{display:"inline-flex",flexWrap:"wrap",columnGap:e.marginXS,[`> ${e.antCls}-row`]:{flex:1}}),[a]:Object.assign(Object.assign({},u(e)),{display:"inline-flex",alignItems:"baseline",cursor:"pointer","&:after":{display:"inline-block",width:0,overflow:"hidden",content:"'\\a0'"},[`& + ${a}`]:{marginInlineStart:0},[`&${a}-in-form-item`]:{'input[type="checkbox"]':{width:14,height:14}}}),[r]:Object.assign(Object.assign({},u(e)),{position:"relative",whiteSpace:"nowrap",lineHeight:1,cursor:"pointer",borderRadius:e.borderRadiusSM,alignSelf:"center",[`${r}-input`]:{position:"absolute",inset:0,zIndex:1,cursor:"pointer",opacity:0,margin:0,[`&:focus-visible + ${r}-inner`]:W(e)},[`${r}-inner`]:{boxSizing:"border-box",display:"block",width:e.checkboxSize,height:e.checkboxSize,direction:"ltr",backgroundColor:e.colorBgContainer,border:`${S(e.lineWidth)} ${e.lineType} ${e.colorBorder}`,borderRadius:e.borderRadiusSM,borderCollapse:"separate",transition:`all ${e.motionDurationSlow}`,"&:after":{boxSizing:"border-box",position:"absolute",top:"50%",insetInlineStart:"25%",display:"table",width:e.calc(e.checkboxSize).div(14).mul(5).equal(),height:e.calc(e.checkboxSize).div(14).mul(8).equal(),border:`${S(e.lineWidthBold)} solid ${e.colorWhite}`,borderTop:0,borderInlineStart:0,transform:"rotate(45deg) scale(0) translate(-50%,-50%)",opacity:0,content:'""',transition:`all ${e.motionDurationFast} ${e.motionEaseInBack}, opacity ${e.motionDurationFast}`}},"& + span":{paddingInlineStart:e.paddingXS,paddingInlineEnd:e.paddingXS}})},{[`
import{r as t,aW as I,bv as j,aS as L,ao as M,bc as v,aX as N,aV as y,am as T,bM as x,ar as H,aq as O,at as u,as as S,e2 as W}from"./index-DIy3NosD.js";var q=["prefixCls","className","style","checked","disabled","defaultChecked","type","title","onChange"],G=t.forwardRef(function(e,r){var a=e.prefixCls,i=a===void 0?"rc-checkbox":a,b=e.className,l=e.style,w=e.checked,s=e.disabled,p=e.defaultChecked,R=p===void 0?!1:p,h=e.type,f=h===void 0?"checkbox":h,P=e.title,d=e.onChange,_=I(e,q),c=t.useRef(null),g=t.useRef(null),D=j(R,{value:w}),$=L(D,2),C=$[0],z=$[1];t.useImperativeHandle(r,function(){return{focus:function(o){var n;(n=c.current)===null||n===void 0||n.focus(o)},blur:function(){var o;(o=c.current)===null||o===void 0||o.blur()},input:c.current,nativeElement:g.current}});var B=M(i,b,v(v({},"".concat(i,"-checked"),C),"".concat(i,"-disabled"),s)),E=function(o){s||("checked"in e||z(o.target.checked),d==null||d({target:y(y({},e),{},{type:f,checked:o.target.checked}),stopPropagation:function(){o.stopPropagation()},preventDefault:function(){o.preventDefault()},nativeEvent:o.nativeEvent}))};return t.createElement("span",{className:B,title:P,style:l,ref:g},t.createElement("input",N({},_,{className:"".concat(i,"-input"),ref:c,onChange:E,disabled:s,checked:!!C,type:f})),t.createElement("span",{className:"".concat(i,"-inner")}))});function V(e){const r=T.useRef(null),a=()=>{x.cancel(r.current),r.current=null};return[()=>{a(),r.current=x(()=>{r.current=null})},l=>{r.current&&(l.stopPropagation(),a()),e==null||e(l)}]}const F=e=>{const{checkboxCls:r}=e,a=`${r}-wrapper`;return[{[`${r}-group`]:Object.assign(Object.assign({},u(e)),{display:"inline-flex",flexWrap:"wrap",columnGap:e.marginXS,[`> ${e.antCls}-row`]:{flex:1}}),[a]:Object.assign(Object.assign({},u(e)),{display:"inline-flex",alignItems:"baseline",cursor:"pointer","&:after":{display:"inline-block",width:0,overflow:"hidden",content:"'\\a0'"},[`& + ${a}`]:{marginInlineStart:0},[`&${a}-in-form-item`]:{'input[type="checkbox"]':{width:14,height:14}}}),[r]:Object.assign(Object.assign({},u(e)),{position:"relative",whiteSpace:"nowrap",lineHeight:1,cursor:"pointer",borderRadius:e.borderRadiusSM,alignSelf:"center",[`${r}-input`]:{position:"absolute",inset:0,zIndex:1,cursor:"pointer",opacity:0,margin:0,[`&:focus-visible + ${r}-inner`]:W(e)},[`${r}-inner`]:{boxSizing:"border-box",display:"block",width:e.checkboxSize,height:e.checkboxSize,direction:"ltr",backgroundColor:e.colorBgContainer,border:`${S(e.lineWidth)} ${e.lineType} ${e.colorBorder}`,borderRadius:e.borderRadiusSM,borderCollapse:"separate",transition:`all ${e.motionDurationSlow}`,"&:after":{boxSizing:"border-box",position:"absolute",top:"50%",insetInlineStart:"25%",display:"table",width:e.calc(e.checkboxSize).div(14).mul(5).equal(),height:e.calc(e.checkboxSize).div(14).mul(8).equal(),border:`${S(e.lineWidthBold)} solid ${e.colorWhite}`,borderTop:0,borderInlineStart:0,transform:"rotate(45deg) scale(0) translate(-50%,-50%)",opacity:0,content:'""',transition:`all ${e.motionDurationFast} ${e.motionEaseInBack}, opacity ${e.motionDurationFast}`}},"& + span":{paddingInlineStart:e.paddingXS,paddingInlineEnd:e.paddingXS}})},{[`
${a}:not(${a}-disabled),
${r}:not(${r}-disabled)
`]:{[`&:hover ${r}-inner`]:{borderColor:e.colorPrimary}},[`${a}:not(${a}-disabled)`]:{[`&:hover ${r}-checked:not(${r}-disabled) ${r}-inner`]:{backgroundColor:e.colorPrimaryHover,borderColor:"transparent"},[`&:hover ${r}-checked:not(${r}-disabled):after`]:{borderColor:e.colorPrimaryHover}}},{[`${r}-checked`]:{[`${r}-inner`]:{backgroundColor:e.colorPrimary,borderColor:e.colorPrimary,"&:after":{opacity:1,transform:"rotate(45deg) scale(1) translate(-50%,-50%)",transition:`all ${e.motionDurationMid} ${e.motionEaseOutBack} ${e.motionDurationFast}`}}},[`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{am as X,r as a,an as A,cf as Z,bu as J,cM as Q,ce as B,ao as w,dZ as U,d4 as Y,a$ as ee,ax as z}from"./index-D2cxrzyv.js";import{u as D,a as te,C as se}from"./index-COTHVrjr.js";const T=X.createContext(null);var ae=function(l,u){var o={};for(var t in l)Object.prototype.hasOwnProperty.call(l,t)&&u.indexOf(t)<0&&(o[t]=l[t]);if(l!=null&&typeof Object.getOwnPropertySymbols=="function")for(var n=0,t=Object.getOwnPropertySymbols(l);n<t.length;n++)u.indexOf(t[n])<0&&Object.prototype.propertyIsEnumerable.call(l,t[n])&&(o[t[n]]=l[t[n]]);return o};const le=(l,u)=>{var o;const{prefixCls:t,className:n,rootClassName:$,children:x,indeterminate:h=!1,style:j,onMouseEnter:y,onMouseLeave:c,skipGroup:O=!1,disabled:I}=l,r=ae(l,["prefixCls","className","rootClassName","children","indeterminate","style","onMouseEnter","onMouseLeave","skipGroup","disabled"]),{getPrefixCls:k,direction:_,checkbox:d}=a.useContext(A),s=a.useContext(T),{isFormItemInput:E}=a.useContext(Z),N=a.useContext(J),g=(o=(s==null?void 0:s.disabled)||I)!==null&&o!==void 0?o:N,m=a.useRef(r.value),p=a.useRef(null),V=Q(u,p);a.useEffect(()=>{s==null||s.registerValue(r.value)},[]),a.useEffect(()=>{if(!O)return r.value!==m.current&&(s==null||s.cancelValue(m.current),s==null||s.registerValue(r.value),m.current=r.value),()=>s==null?void 0:s.cancelValue(r.value)},[r.value]),a.useEffect(()=>{var f;!((f=p.current)===null||f===void 0)&&f.input&&(p.current.input.indeterminate=h)},[h]);const i=k("checkbox",t),S=B(i),[R,P,G]=D(i,S),v=Object.assign({},r);s&&!O&&(v.onChange=(...f)=>{r.onChange&&r.onChange.apply(r,f),s.toggleOption&&s.toggleOption({label:x,value:r.value})},v.name=s.name,v.checked=s.value.includes(r.value));const M=w(`${i}-wrapper`,{[`${i}-rtl`]:_==="rtl",[`${i}-wrapper-checked`]:v.checked,[`${i}-wrapper-disabled`]:g,[`${i}-wrapper-in-form-item`]:E},d==null?void 0:d.className,n,$,G,S,P),e=w({[`${i}-indeterminate`]:h},U,P),[C,b]=te(v.onClick);return R(a.createElement(Y,{component:"Checkbox",disabled:g},a.createElement("label",{className:M,style:Object.assign(Object.assign({},d==null?void 0:d.style),j),onMouseEnter:y,onMouseLeave:c,onClick:C},a.createElement(se,Object.assign({},v,{onClick:b,prefixCls:i,className:e,disabled:g,ref:V})),x!=null&&a.createElement("span",{className:`${i}-label`},x))))},q=a.forwardRef(le);var ne=function(l,u){var o={};for(var t in l)Object.prototype.hasOwnProperty.call(l,t)&&u.indexOf(t)<0&&(o[t]=l[t]);if(l!=null&&typeof Object.getOwnPropertySymbols=="function")for(var n=0,t=Object.getOwnPropertySymbols(l);n<t.length;n++)u.indexOf(t[n])<0&&Object.prototype.propertyIsEnumerable.call(l,t[n])&&(o[t[n]]=l[t[n]]);return o};const re=a.forwardRef((l,u)=>{const{defaultValue:o,children:t,options:n=[],prefixCls:$,className:x,rootClassName:h,style:j,onChange:y}=l,c=ne(l,["defaultValue","children","options","prefixCls","className","rootClassName","style","onChange"]),{getPrefixCls:O,direction:I}=a.useContext(A),[r,k]=a.useState(c.value||o||[]),[_,d]=a.useState([]);a.useEffect(()=>{"value"in c&&k(c.value||[])},[c.value]);const s=a.useMemo(()=>n.map(e=>typeof e=="string"||typeof e=="number"?{label:e,value:e}:e),[n]),E=e=>{d(C=>C.filter(b=>b!==e))},N=e=>{d(C=>[].concat(z(C),[e]))},g=e=>{const C=r.indexOf(e.value),b=z(r);C===-1?b.push(e.value):b.splice(C,1),"value"in c||k(b),y==null||y(b.filter(f=>_.includes(f)).sort((f,H)=>{const K=s.findIndex(L=>L.value===f),W=s.findIndex(L=>L.value===H);return K-W}))},m=O("checkbox",$),p=`${m}-group`,V=B(m),[i,S,R]=D(m,V),P=ee(c,["value","disabled"]),G=n.length?s.map(e=>a.createElement(q,{prefixCls:m,key:e.value.toString(),disabled:"disabled"in e?e.disabled:c.disabled,value:e.value,checked:r.includes(e.value),onChange:e.onChange,className:w(`${p}-item`,e.className),style:e.style,title:e.title,id:e.id,required:e.required},e.label)):t,v=a.useMemo(()=>({toggleOption:g,value:r,disabled:c.disabled,name:c.name,registerValue:N,cancelValue:E}),[g,r,c.disabled,c.name,N,E]),M=w(p,{[`${p}-rtl`]:I==="rtl"},x,h,R,V,S);return i(a.createElement("div",Object.assign({className:M,style:j},P,{ref:u}),a.createElement(T.Provider,{value:v},G)))}),F=q;F.Group=re;F.__ANT_CHECKBOX=!0;export{F as C};
import{am as X,r as a,an as A,cf as Z,bu as J,d0 as Q,ce as B,ao as w,d$ as U,cZ as Y,a$ as ee,ax as z}from"./index-DIy3NosD.js";import{u as D,a as te,C as se}from"./index-CUzdQnnk.js";const T=X.createContext(null);var ae=function(l,u){var o={};for(var t in l)Object.prototype.hasOwnProperty.call(l,t)&&u.indexOf(t)<0&&(o[t]=l[t]);if(l!=null&&typeof Object.getOwnPropertySymbols=="function")for(var n=0,t=Object.getOwnPropertySymbols(l);n<t.length;n++)u.indexOf(t[n])<0&&Object.prototype.propertyIsEnumerable.call(l,t[n])&&(o[t[n]]=l[t[n]]);return o};const le=(l,u)=>{var o;const{prefixCls:t,className:n,rootClassName:$,children:x,indeterminate:h=!1,style:j,onMouseEnter:y,onMouseLeave:c,skipGroup:O=!1,disabled:I}=l,r=ae(l,["prefixCls","className","rootClassName","children","indeterminate","style","onMouseEnter","onMouseLeave","skipGroup","disabled"]),{getPrefixCls:k,direction:_,checkbox:d}=a.useContext(A),s=a.useContext(T),{isFormItemInput:E}=a.useContext(Z),N=a.useContext(J),g=(o=(s==null?void 0:s.disabled)||I)!==null&&o!==void 0?o:N,m=a.useRef(r.value),p=a.useRef(null),V=Q(u,p);a.useEffect(()=>{s==null||s.registerValue(r.value)},[]),a.useEffect(()=>{if(!O)return r.value!==m.current&&(s==null||s.cancelValue(m.current),s==null||s.registerValue(r.value),m.current=r.value),()=>s==null?void 0:s.cancelValue(r.value)},[r.value]),a.useEffect(()=>{var f;!((f=p.current)===null||f===void 0)&&f.input&&(p.current.input.indeterminate=h)},[h]);const i=k("checkbox",t),S=B(i),[R,P,G]=D(i,S),v=Object.assign({},r);s&&!O&&(v.onChange=(...f)=>{r.onChange&&r.onChange.apply(r,f),s.toggleOption&&s.toggleOption({label:x,value:r.value})},v.name=s.name,v.checked=s.value.includes(r.value));const M=w(`${i}-wrapper`,{[`${i}-rtl`]:_==="rtl",[`${i}-wrapper-checked`]:v.checked,[`${i}-wrapper-disabled`]:g,[`${i}-wrapper-in-form-item`]:E},d==null?void 0:d.className,n,$,G,S,P),e=w({[`${i}-indeterminate`]:h},U,P),[C,b]=te(v.onClick);return R(a.createElement(Y,{component:"Checkbox",disabled:g},a.createElement("label",{className:M,style:Object.assign(Object.assign({},d==null?void 0:d.style),j),onMouseEnter:y,onMouseLeave:c,onClick:C},a.createElement(se,Object.assign({},v,{onClick:b,prefixCls:i,className:e,disabled:g,ref:V})),x!=null&&a.createElement("span",{className:`${i}-label`},x))))},q=a.forwardRef(le);var ne=function(l,u){var o={};for(var t in l)Object.prototype.hasOwnProperty.call(l,t)&&u.indexOf(t)<0&&(o[t]=l[t]);if(l!=null&&typeof Object.getOwnPropertySymbols=="function")for(var n=0,t=Object.getOwnPropertySymbols(l);n<t.length;n++)u.indexOf(t[n])<0&&Object.prototype.propertyIsEnumerable.call(l,t[n])&&(o[t[n]]=l[t[n]]);return o};const re=a.forwardRef((l,u)=>{const{defaultValue:o,children:t,options:n=[],prefixCls:$,className:x,rootClassName:h,style:j,onChange:y}=l,c=ne(l,["defaultValue","children","options","prefixCls","className","rootClassName","style","onChange"]),{getPrefixCls:O,direction:I}=a.useContext(A),[r,k]=a.useState(c.value||o||[]),[_,d]=a.useState([]);a.useEffect(()=>{"value"in c&&k(c.value||[])},[c.value]);const s=a.useMemo(()=>n.map(e=>typeof e=="string"||typeof e=="number"?{label:e,value:e}:e),[n]),E=e=>{d(C=>C.filter(b=>b!==e))},N=e=>{d(C=>[].concat(z(C),[e]))},g=e=>{const C=r.indexOf(e.value),b=z(r);C===-1?b.push(e.value):b.splice(C,1),"value"in c||k(b),y==null||y(b.filter(f=>_.includes(f)).sort((f,H)=>{const K=s.findIndex(L=>L.value===f),W=s.findIndex(L=>L.value===H);return K-W}))},m=O("checkbox",$),p=`${m}-group`,V=B(m),[i,S,R]=D(m,V),P=ee(c,["value","disabled"]),G=n.length?s.map(e=>a.createElement(q,{prefixCls:m,key:e.value.toString(),disabled:"disabled"in e?e.disabled:c.disabled,value:e.value,checked:r.includes(e.value),onChange:e.onChange,className:w(`${p}-item`,e.className),style:e.style,title:e.title,id:e.id,required:e.required},e.label)):t,v=a.useMemo(()=>({toggleOption:g,value:r,disabled:c.disabled,name:c.name,registerValue:N,cancelValue:E}),[g,r,c.disabled,c.name,N,E]),M=w(p,{[`${p}-rtl`]:I==="rtl"},x,h,R,V,S);return i(a.createElement("div",Object.assign({className:M,style:j},P,{ref:u}),a.createElement(T.Provider,{value:v},G)))}),F=q;F.Group=re;F.__ANT_CHECKBOX=!0;export{F as C};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{u as M,r as t,j as e,B as $,x as F,H,z as c,R as v,T as K,J as P,a9 as W,_ as Y,Z as D,a0 as G,s as w,a4 as J}from"./index-D2cxrzyv.js";import{P as Z}from"./index-DICUnuN_.js";import{R as N,C as d}from"./row-C5EMotZ4.js";import{C as b}from"./index-BFDbUxK1.js";import{I as q}from"./index-4zw5l6IX.js";import{F as A}from"./Table-BLvbgkX9.js";import{T as u}from"./index-C8Xnkbw4.js";import{C as S}from"./index-Iw46KAPi.js";import"./iconUtil-CAY5Il88.js";import"./index-COTHVrjr.js";import"./useForm-CaItC8xb.js";import"./Pagination-lXX40XmA.js";const{Text:C}=K;function ne(){const{t:a}=M(),[o,I]=t.useState([]),[h,U]=t.useState([]),[T,m]=t.useState(!1),[p,x]=t.useState(!1),[f,g]=t.useState(!1),[l,y]=t.useState(null),[j,i]=t.useState([]),[n,_]=t.useState(""),R=t.useMemo(()=>o.find(s=>s.userId===l)||null,[o,l]),z=async()=>{m(!0);try{const s=await Y();I(s||[])}finally{m(!1)}},k=async()=>{x(!0);try{const s=await D();U(s||[])}finally{x(!1)}},E=async s=>{try{const r=await G(s);i(r||[])}catch{i([])}};t.useEffect(()=>{z(),k()},[]),t.useEffect(()=>{l?E(l):i([])},[l]);const L=t.useMemo(()=>{if(!n)return o;const s=n.toLowerCase();return o.filter(r=>r.username.toLowerCase().includes(s)||r.displayName.toLowerCase().includes(s))},[o,n]),B=async()=>{if(!l){w.warning(a("userRole.selectUser"));return}g(!0);try{await J(l,j),w.success(a("common.success"))}finally{g(!1)}};return e.jsxs("div",{className:"app-page",children:[e.jsx(Z,{title:a("userRole.title"),subtitle:a("userRole.subtitle")}),e.jsx("div",{className:"app-page__page-actions",children:e.jsx($,{type:"primary",icon:e.jsx(F,{"aria-hidden":"true"}),onClick:B,loading:f,disabled:!l,children:a(f?"common.loading":"common.save")})}),e.jsxs(N,{gutter:24,className:"app-page__split",style:{height:"calc(100vh - 180px)"},children:[e.jsx(d,{xs:24,lg:12,style:{height:"100%"},children:e.jsxs(b,{title:e.jsxs(c,{children:[e.jsx(v,{"aria-hidden":"true"}),e.jsx("span",{children:a("userRole.userList")})]}),className:"app-page__panel-card full-height-card",children:[e.jsx("div",{className:"mb-4",children:e.jsx(q,{placeholder:a("userRole.searchUser"),prefix:e.jsx(H,{"aria-hidden":"true",className:"text-gray-400"}),value:n,onChange:s=>_(s.target.value),allowClear:!0,"aria-label":a("userRole.searchUser")})}),e.jsx("div",{style:{height:"calc(100% - 60px)",overflowY:"auto"},children:e.jsx(A,{rowKey:"userId",size:"middle",loading:T,dataSource:L,rowSelection:{type:"radio",selectedRowKeys:l?[l]:[],onChange:s=>y(s[0])},onRow:s=>({onClick:()=>y(s.userId),className:"cursor-pointer"}),pagination:{pageSize:10,showTotal:s=>a("common.total",{total:s})},columns:[{title:a("users.userInfo"),key:"user",render:(s,r)=>e.jsxs("div",{className:"min-w-0",children:[e.jsx("div",{style:{fontWeight:500},className:"truncate",children:r.displayName}),e.jsxs("div",{style:{fontSize:12,color:"#8c8c8c"},className:"truncate",children:["@",r.username]})]})},{title:a("common.status"),dataIndex:"status",width:80,render:s=>s===1?e.jsx(u,{color:"green",className:"m-0",children:"Enabled"}):e.jsx(u,{className:"m-0",children:"Disabled"})}]})})]})}),e.jsx(d,{xs:24,lg:12,style:{height:"100%"},children:e.jsx(b,{title:e.jsxs(c,{children:[e.jsx(W,{"aria-hidden":"true"}),e.jsx("span",{children:a("userRole.grantRoles")})]}),className:"app-page__panel-card full-height-card",extra:R?e.jsxs(u,{color:"blue",children:[a("userRole.editing"),": ",R.displayName]}):null,children:l?e.jsxs("div",{style:{padding:"8px 0",height:"100%",overflowY:"auto"},children:[e.jsx(S.Group,{style:{width:"100%"},value:j,onChange:s=>i(s),disabled:p,children:e.jsx(N,{gutter:[16,16],children:h.map(s=>e.jsx(d,{span:12,children:e.jsx(S,{value:s.roleId,className:"w-full",children:e.jsxs(c,{direction:"vertical",size:0,children:[e.jsx("span",{style:{fontWeight:500},children:s.roleName}),e.jsx(C,{type:"secondary",style:{fontSize:12},className:"tabular-nums",children:s.roleCode})]})})},s.roleId))})}),!h.length&&!p&&e.jsx(P,{description:"No roles available"})]}):e.jsxs("div",{className:"flex flex-col items-center justify-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200",children:[e.jsx(v,{style:{fontSize:40,color:"#bfbfbf",marginBottom:16},"aria-hidden":"true"}),e.jsx(C,{type:"secondary",children:a("userRole.selectUser")})]})})})]})]})}export{ne as default};
import{u as M,r as t,j as e,B as $,x as F,H,z as c,R as v,T as K,J as P,a9 as W,_ as Y,Z as D,a0 as G,s as w,a4 as J}from"./index-DIy3NosD.js";import{P as Z}from"./index-x0_qMNAh.js";import{R as N,C as d}from"./row-BFbQ_b2k.js";import{C as b}from"./index-JMVQVIid.js";import{I as q}from"./index-ChEzJj4c.js";import{F as A}from"./Table-C0wBOmw3.js";import{T as u}from"./index-Do3ElwMr.js";import{C as S}from"./index-D0LR2CrS.js";import"./iconUtil-DNX53dK0.js";import"./index-CUzdQnnk.js";import"./useForm-Ee9FUUVh.js";import"./Pagination-C_3XJ-9Y.js";const{Text:C}=K;function ne(){const{t:a}=M(),[o,I]=t.useState([]),[h,U]=t.useState([]),[T,m]=t.useState(!1),[p,x]=t.useState(!1),[f,g]=t.useState(!1),[l,y]=t.useState(null),[j,i]=t.useState([]),[n,_]=t.useState(""),R=t.useMemo(()=>o.find(s=>s.userId===l)||null,[o,l]),z=async()=>{m(!0);try{const s=await Y();I(s||[])}finally{m(!1)}},k=async()=>{x(!0);try{const s=await D();U(s||[])}finally{x(!1)}},E=async s=>{try{const r=await G(s);i(r||[])}catch{i([])}};t.useEffect(()=>{z(),k()},[]),t.useEffect(()=>{l?E(l):i([])},[l]);const L=t.useMemo(()=>{if(!n)return o;const s=n.toLowerCase();return o.filter(r=>r.username.toLowerCase().includes(s)||r.displayName.toLowerCase().includes(s))},[o,n]),B=async()=>{if(!l){w.warning(a("userRole.selectUser"));return}g(!0);try{await J(l,j),w.success(a("common.success"))}finally{g(!1)}};return e.jsxs("div",{className:"app-page",children:[e.jsx(Z,{title:a("userRole.title"),subtitle:a("userRole.subtitle")}),e.jsx("div",{className:"app-page__page-actions",children:e.jsx($,{type:"primary",icon:e.jsx(F,{"aria-hidden":"true"}),onClick:B,loading:f,disabled:!l,children:a(f?"common.loading":"common.save")})}),e.jsxs(N,{gutter:24,className:"app-page__split",style:{height:"calc(100vh - 180px)"},children:[e.jsx(d,{xs:24,lg:12,style:{height:"100%"},children:e.jsxs(b,{title:e.jsxs(c,{children:[e.jsx(v,{"aria-hidden":"true"}),e.jsx("span",{children:a("userRole.userList")})]}),className:"app-page__panel-card full-height-card",children:[e.jsx("div",{className:"mb-4",children:e.jsx(q,{placeholder:a("userRole.searchUser"),prefix:e.jsx(H,{"aria-hidden":"true",className:"text-gray-400"}),value:n,onChange:s=>_(s.target.value),allowClear:!0,"aria-label":a("userRole.searchUser")})}),e.jsx("div",{style:{height:"calc(100% - 60px)",overflowY:"auto"},children:e.jsx(A,{rowKey:"userId",size:"middle",loading:T,dataSource:L,rowSelection:{type:"radio",selectedRowKeys:l?[l]:[],onChange:s=>y(s[0])},onRow:s=>({onClick:()=>y(s.userId),className:"cursor-pointer"}),pagination:{pageSize:10,showTotal:s=>a("common.total",{total:s})},columns:[{title:a("users.userInfo"),key:"user",render:(s,r)=>e.jsxs("div",{className:"min-w-0",children:[e.jsx("div",{style:{fontWeight:500},className:"truncate",children:r.displayName}),e.jsxs("div",{style:{fontSize:12,color:"#8c8c8c"},className:"truncate",children:["@",r.username]})]})},{title:a("common.status"),dataIndex:"status",width:80,render:s=>s===1?e.jsx(u,{color:"green",className:"m-0",children:"Enabled"}):e.jsx(u,{className:"m-0",children:"Disabled"})}]})})]})}),e.jsx(d,{xs:24,lg:12,style:{height:"100%"},children:e.jsx(b,{title:e.jsxs(c,{children:[e.jsx(W,{"aria-hidden":"true"}),e.jsx("span",{children:a("userRole.grantRoles")})]}),className:"app-page__panel-card full-height-card",extra:R?e.jsxs(u,{color:"blue",children:[a("userRole.editing"),": ",R.displayName]}):null,children:l?e.jsxs("div",{style:{padding:"8px 0",height:"100%",overflowY:"auto"},children:[e.jsx(S.Group,{style:{width:"100%"},value:j,onChange:s=>i(s),disabled:p,children:e.jsx(N,{gutter:[16,16],children:h.map(s=>e.jsx(d,{span:12,children:e.jsx(S,{value:s.roleId,className:"w-full",children:e.jsxs(c,{direction:"vertical",size:0,children:[e.jsx("span",{style:{fontWeight:500},children:s.roleName}),e.jsx(C,{type:"secondary",style:{fontSize:12},className:"tabular-nums",children:s.roleCode})]})})},s.roleId))})}),!h.length&&!p&&e.jsx(P,{description:"No roles available"})]}):e.jsxs("div",{className:"flex flex-col items-center justify-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200",children:[e.jsx(v,{style:{fontSize:40,color:"#bfbfbf",marginBottom:16},"aria-hidden":"true"}),e.jsx(C,{type:"secondary",children:a("userRole.selectUser")})]})})})]})]})}export{ne as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{j as e,T as d}from"./index-D2cxrzyv.js";const{Title:i,Text:n}=d,c=({title:r,subtitle:s,extra:a,className:l=""})=>e.jsxs("div",{className:`page-header flex justify-between items-end mb-6 ${l}`,children:[e.jsxs("div",{children:[e.jsx(i,{level:4,className:"mb-1",style:{margin:0},children:r}),s&&e.jsx(n,{type:"secondary",style:{display:"block"},children:s})]}),a&&e.jsx("div",{className:"page-header-extra",children:a})]});export{c as P};
import{j as e,T as d}from"./index-DIy3NosD.js";const{Title:i,Text:n}=d,c=({title:r,subtitle:s,extra:a,className:l=""})=>e.jsxs("div",{className:`page-header flex justify-between items-end mb-6 ${l}`,children:[e.jsxs("div",{children:[e.jsx(i,{level:4,className:"mb-1",style:{margin:0},children:r}),s&&e.jsx(n,{type:"secondary",style:{display:"block"},children:s})]}),a&&e.jsx("div",{className:"page-header-extra",children:a})]});export{c as P};

View File

@ -1 +1 @@
import{aq as A,r as s,cO as $,an as w,bw as F,bx as D,d9 as E,B as M,da as U,db as Y,dc as G,ao as S,au as J,bv as K,dd as Q,a$ as Z}from"./index-D2cxrzyv.js";const ee=e=>{const{componentCls:n,iconCls:a,antCls:t,zIndexPopup:o,colorText:u,colorWarning:f,marginXXS:c,marginXS:i,fontSize:g,fontWeightStrong:v,colorTextHeading:y}=e;return{[n]:{zIndex:o,[`&${t}-popover`]:{fontSize:g},[`${n}-message`]:{marginBottom:i,display:"flex",flexWrap:"nowrap",alignItems:"start",[`> ${n}-message-icon ${a}`]:{color:f,fontSize:g,lineHeight:1,marginInlineEnd:i},[`${n}-title`]:{fontWeight:v,color:y,"&:only-child":{fontWeight:"normal"}},[`${n}-description`]:{marginTop:c,color:u}},[`${n}-buttons`]:{textAlign:"end",whiteSpace:"nowrap",button:{marginInlineStart:i}}}}},te=e=>{const{zIndexPopupBase:n}=e;return{zIndexPopup:n+60}},I=A("Popconfirm",e=>ee(e),te,{resetStyle:!1});var ne=function(e,n){var a={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&n.indexOf(t)<0&&(a[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var o=0,t=Object.getOwnPropertySymbols(e);o<t.length;o++)n.indexOf(t[o])<0&&Object.prototype.propertyIsEnumerable.call(e,t[o])&&(a[t[o]]=e[t[o]]);return a};const k=e=>{const{prefixCls:n,okButtonProps:a,cancelButtonProps:t,title:o,description:u,cancelText:f,okText:c,okType:i="primary",icon:g=s.createElement($,null),showCancel:v=!0,close:y,onConfirm:C,onCancel:O,onPopupClick:m}=e,{getPrefixCls:p}=s.useContext(w),[d]=F("Popconfirm",D.Popconfirm),b=E(o),x=E(u);return s.createElement("div",{className:`${n}-inner-content`,onClick:m},s.createElement("div",{className:`${n}-message`},g&&s.createElement("span",{className:`${n}-message-icon`},g),s.createElement("div",{className:`${n}-message-text`},b&&s.createElement("div",{className:`${n}-title`},b),x&&s.createElement("div",{className:`${n}-description`},x))),s.createElement("div",{className:`${n}-buttons`},v&&s.createElement(M,Object.assign({onClick:O,size:"small"},t),f||(d==null?void 0:d.cancelText)),s.createElement(U,{buttonProps:Object.assign(Object.assign({size:"small"},Y(i)),a),actionFn:C,close:y,prefixCls:p("btn"),quitOnNullishReturnValue:!0,emitEvent:!0},c||(d==null?void 0:d.okText))))},oe=e=>{const{prefixCls:n,placement:a,className:t,style:o}=e,u=ne(e,["prefixCls","placement","className","style"]),{getPrefixCls:f}=s.useContext(w),c=f("popconfirm",n),[i]=I(c);return i(s.createElement(G,{placement:a,className:S(c,t),style:o,content:s.createElement(k,Object.assign({prefixCls:c},u))}))};var se=function(e,n){var a={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&n.indexOf(t)<0&&(a[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var o=0,t=Object.getOwnPropertySymbols(e);o<t.length;o++)n.indexOf(t[o])<0&&Object.prototype.propertyIsEnumerable.call(e,t[o])&&(a[t[o]]=e[t[o]]);return a};const ae=s.forwardRef((e,n)=>{var a,t;const{prefixCls:o,placement:u="top",trigger:f="click",okType:c="primary",icon:i=s.createElement($,null),children:g,overlayClassName:v,onOpenChange:y,onVisibleChange:C,overlayStyle:O,styles:m,classNames:p}=e,d=se(e,["prefixCls","placement","trigger","okType","icon","children","overlayClassName","onOpenChange","onVisibleChange","overlayStyle","styles","classNames"]),{getPrefixCls:b,className:x,style:T,classNames:j,styles:h}=J("popconfirm"),[_,z]=K(!1,{value:(a=e.open)!==null&&a!==void 0?a:e.visible,defaultValue:(t=e.defaultOpen)!==null&&t!==void 0?t:e.defaultVisible}),P=(l,r)=>{z(l,!0),C==null||C(l),y==null||y(l,r)},B=l=>{P(!1,l)},V=l=>{var r;return(r=e.onConfirm)===null||r===void 0?void 0:r.call(void 0,l)},W=l=>{var r;P(!1,l),(r=e.onCancel)===null||r===void 0||r.call(void 0,l)},R=(l,r)=>{const{disabled:q=!1}=e;q||P(l,r)},N=b("popconfirm",o),H=S(N,x,v,j.root,p==null?void 0:p.root),L=S(j.body,p==null?void 0:p.body),[X]=I(N);return X(s.createElement(Q,Object.assign({},Z(d,["title"]),{trigger:f,placement:u,onOpenChange:R,open:_,ref:n,classNames:{root:H,body:L},styles:{root:Object.assign(Object.assign(Object.assign(Object.assign({},h.root),T),O),m==null?void 0:m.root),body:Object.assign(Object.assign({},h.body),m==null?void 0:m.body)},content:s.createElement(k,Object.assign({okType:c,icon:i},e,{prefixCls:N,close:B,onConfirm:V,onCancel:W})),"data-popover-inject":!0}),g))}),le=ae;le._InternalPanelDoNotUseOrYouWillBeFired=oe;export{le as P};
import{aq as A,r as s,d2 as $,an as w,bw as F,bx as D,db as E,B as M,dc as U,dd as Y,de as G,ao as S,au as J,bv as K,df as Q,a$ as Z}from"./index-DIy3NosD.js";const ee=e=>{const{componentCls:n,iconCls:a,antCls:t,zIndexPopup:o,colorText:u,colorWarning:f,marginXXS:c,marginXS:i,fontSize:g,fontWeightStrong:v,colorTextHeading:y}=e;return{[n]:{zIndex:o,[`&${t}-popover`]:{fontSize:g},[`${n}-message`]:{marginBottom:i,display:"flex",flexWrap:"nowrap",alignItems:"start",[`> ${n}-message-icon ${a}`]:{color:f,fontSize:g,lineHeight:1,marginInlineEnd:i},[`${n}-title`]:{fontWeight:v,color:y,"&:only-child":{fontWeight:"normal"}},[`${n}-description`]:{marginTop:c,color:u}},[`${n}-buttons`]:{textAlign:"end",whiteSpace:"nowrap",button:{marginInlineStart:i}}}}},te=e=>{const{zIndexPopupBase:n}=e;return{zIndexPopup:n+60}},I=A("Popconfirm",e=>ee(e),te,{resetStyle:!1});var ne=function(e,n){var a={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&n.indexOf(t)<0&&(a[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var o=0,t=Object.getOwnPropertySymbols(e);o<t.length;o++)n.indexOf(t[o])<0&&Object.prototype.propertyIsEnumerable.call(e,t[o])&&(a[t[o]]=e[t[o]]);return a};const k=e=>{const{prefixCls:n,okButtonProps:a,cancelButtonProps:t,title:o,description:u,cancelText:f,okText:c,okType:i="primary",icon:g=s.createElement($,null),showCancel:v=!0,close:y,onConfirm:C,onCancel:O,onPopupClick:m}=e,{getPrefixCls:d}=s.useContext(w),[p]=F("Popconfirm",D.Popconfirm),b=E(o),x=E(u);return s.createElement("div",{className:`${n}-inner-content`,onClick:m},s.createElement("div",{className:`${n}-message`},g&&s.createElement("span",{className:`${n}-message-icon`},g),s.createElement("div",{className:`${n}-message-text`},b&&s.createElement("div",{className:`${n}-title`},b),x&&s.createElement("div",{className:`${n}-description`},x))),s.createElement("div",{className:`${n}-buttons`},v&&s.createElement(M,Object.assign({onClick:O,size:"small"},t),f||(p==null?void 0:p.cancelText)),s.createElement(U,{buttonProps:Object.assign(Object.assign({size:"small"},Y(i)),a),actionFn:C,close:y,prefixCls:d("btn"),quitOnNullishReturnValue:!0,emitEvent:!0},c||(p==null?void 0:p.okText))))},oe=e=>{const{prefixCls:n,placement:a,className:t,style:o}=e,u=ne(e,["prefixCls","placement","className","style"]),{getPrefixCls:f}=s.useContext(w),c=f("popconfirm",n),[i]=I(c);return i(s.createElement(G,{placement:a,className:S(c,t),style:o,content:s.createElement(k,Object.assign({prefixCls:c},u))}))};var se=function(e,n){var a={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&n.indexOf(t)<0&&(a[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var o=0,t=Object.getOwnPropertySymbols(e);o<t.length;o++)n.indexOf(t[o])<0&&Object.prototype.propertyIsEnumerable.call(e,t[o])&&(a[t[o]]=e[t[o]]);return a};const ae=s.forwardRef((e,n)=>{var a,t;const{prefixCls:o,placement:u="top",trigger:f="click",okType:c="primary",icon:i=s.createElement($,null),children:g,overlayClassName:v,onOpenChange:y,onVisibleChange:C,overlayStyle:O,styles:m,classNames:d}=e,p=se(e,["prefixCls","placement","trigger","okType","icon","children","overlayClassName","onOpenChange","onVisibleChange","overlayStyle","styles","classNames"]),{getPrefixCls:b,className:x,style:T,classNames:j,styles:h}=J("popconfirm"),[_,z]=K(!1,{value:(a=e.open)!==null&&a!==void 0?a:e.visible,defaultValue:(t=e.defaultOpen)!==null&&t!==void 0?t:e.defaultVisible}),P=(l,r)=>{z(l,!0),C==null||C(l),y==null||y(l,r)},B=l=>{P(!1,l)},V=l=>{var r;return(r=e.onConfirm)===null||r===void 0?void 0:r.call(void 0,l)},W=l=>{var r;P(!1,l),(r=e.onCancel)===null||r===void 0||r.call(void 0,l)},R=(l,r)=>{const{disabled:q=!1}=e;q||P(l,r)},N=b("popconfirm",o),H=S(N,x,v,j.root,d==null?void 0:d.root),L=S(j.body,d==null?void 0:d.body),[X]=I(N);return X(s.createElement(Q,Object.assign({},Z(p,["title"]),{trigger:f,placement:u,onOpenChange:R,open:_,ref:n,classNames:{root:H,body:L},styles:{root:Object.assign(Object.assign(Object.assign(Object.assign({},h.root),T),O),m==null?void 0:m.root),body:Object.assign(Object.assign({},h.body),m==null?void 0:m.body)},content:s.createElement(k,Object.assign({okType:c,icon:i},e,{prefixCls:N,close:B,onConfirm:V,onCancel:W})),"data-popover-inject":!0}),g))}),le=ae;le._InternalPanelDoNotUseOrYouWillBeFired=oe;export{le as P};

View File

@ -1 +1 @@
import{aC as t}from"./index-D2cxrzyv.js";async function n(a){return(await t.get("/sys/api/orgs",{params:{tenantId:a}})).data.data}async function p(a){return(await t.post("/sys/api/orgs",a)).data.data}async function o(a,s){return(await t.put(`/sys/api/orgs/${a}`,s)).data.data}async function c(a){return(await t.delete(`/sys/api/orgs/${a}`)).data.data}export{p as c,c as d,n as l,o as u};
import{aC as t}from"./index-DIy3NosD.js";async function n(a){return(await t.get("/sys/api/orgs",{params:{tenantId:a}})).data.data}async function p(a){return(await t.post("/sys/api/orgs",a)).data.data}async function o(a,s){return(await t.put(`/sys/api/orgs/${a}`,s)).data.data}async function c(a){return(await t.delete(`/sys/api/orgs/${a}`)).data.data}export{p as c,c as d,n as l,o as u};

View File

@ -1 +1 @@
import{cm as r}from"./index-D2cxrzyv.js";const s=(t,o,a,e)=>({total:t,current:o,pageSize:a,onChange:e,showSizeChanger:!0,showQuickJumper:!0,showTotal:n=>r.t("common.total",{total:n}),pageSizeOptions:["10","20","50","100"]});export{s as g};
import{cm as r}from"./index-DIy3NosD.js";const s=(t,o,a,e)=>({total:t,current:o,pageSize:a,onChange:e,showSizeChanger:!0,showQuickJumper:!0,showTotal:n=>r.t("common.total",{total:n}),pageSizeOptions:["10","20","50","100"]});export{s as g};

View File

@ -1 +1 @@
import{r as f,an as A,e8 as I,ao as G,az as S,ay as _,e9 as z}from"./index-D2cxrzyv.js";const k=f.createContext({});var J=function(e,l){var n={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&l.indexOf(t)<0&&(n[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var r=0,t=Object.getOwnPropertySymbols(e);r<t.length;r++)l.indexOf(t[r])<0&&Object.prototype.propertyIsEnumerable.call(e,t[r])&&(n[t[r]]=e[t[r]]);return n};function R(e){return e==="auto"?"1 1 auto":typeof e=="number"?`${e} ${e} auto`:/^\d+(\.\d+)?(px|em|rem|%)$/.test(e)?`0 0 ${e}`:e}const M=["xs","sm","md","lg","xl","xxl"],F=f.forwardRef((e,l)=>{const{getPrefixCls:n,direction:t}=f.useContext(A),{gutter:r,wrap:c}=f.useContext(k),{prefixCls:p,span:i,order:g,offset:m,push:h,pull:O,className:E,children:x,flex:b,style:C}=e,d=J(e,["prefixCls","span","order","offset","push","pull","className","children","flex","style"]),o=n("col",p),[N,P,y]=I(o),j={};let $={};M.forEach(a=>{let s={};const v=e[a];typeof v=="number"?s.span=v:typeof v=="object"&&(s=v||{}),delete d[a],$=Object.assign(Object.assign({},$),{[`${o}-${a}-${s.span}`]:s.span!==void 0,[`${o}-${a}-order-${s.order}`]:s.order||s.order===0,[`${o}-${a}-offset-${s.offset}`]:s.offset||s.offset===0,[`${o}-${a}-push-${s.push}`]:s.push||s.push===0,[`${o}-${a}-pull-${s.pull}`]:s.pull||s.pull===0,[`${o}-rtl`]:t==="rtl"}),s.flex&&($[`${o}-${a}-flex`]=!0,j[`--${o}-${a}-flex`]=R(s.flex))});const w=G(o,{[`${o}-${i}`]:i!==void 0,[`${o}-order-${g}`]:g,[`${o}-offset-${m}`]:m,[`${o}-push-${h}`]:h,[`${o}-pull-${O}`]:O},E,$,P,y),u={};if(r!=null&&r[0]){const a=typeof r[0]=="number"?`${r[0]/2}px`:`calc(${r[0]} / 2)`;u.paddingLeft=a,u.paddingRight=a}return b&&(u.flex=R(b),c===!1&&!u.minWidth&&(u.minWidth=0)),N(f.createElement("div",Object.assign({},d,{style:Object.assign(Object.assign(Object.assign({},u),C),j),className:w,ref:l}),x))});function B(e,l){const n=[void 0,void 0],t=Array.isArray(e)?e:[e,void 0],r=l||{xs:!0,sm:!0,md:!0,lg:!0,xl:!0,xxl:!0};return t.forEach((c,p)=>{if(typeof c=="object"&&c!==null)for(let i=0;i<S.length;i++){const g=S[i];if(r[g]&&c[g]!==void 0){n[p]=c[g];break}}else n[p]=c}),n}var L=function(e,l){var n={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&l.indexOf(t)<0&&(n[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var r=0,t=Object.getOwnPropertySymbols(e);r<t.length;r++)l.indexOf(t[r])<0&&Object.prototype.propertyIsEnumerable.call(e,t[r])&&(n[t[r]]=e[t[r]]);return n};function V(e,l){const[n,t]=f.useState(typeof e=="string"?e:""),r=()=>{if(typeof e=="string"&&t(e),typeof e=="object")for(let c=0;c<S.length;c++){const p=S[c];if(!l||!l[p])continue;const i=e[p];if(i!==void 0){t(i);return}}};return f.useEffect(()=>{r()},[JSON.stringify(e),l]),n}const H=f.forwardRef((e,l)=>{const{prefixCls:n,justify:t,align:r,className:c,style:p,children:i,gutter:g=0,wrap:m}=e,h=L(e,["prefixCls","justify","align","className","style","children","gutter","wrap"]),{getPrefixCls:O,direction:E}=f.useContext(A),x=_(!0,null),b=V(r,x),C=V(t,x),d=O("row",n),[o,N,P]=z(d),y=B(g,x),j=G(d,{[`${d}-no-wrap`]:m===!1,[`${d}-${C}`]:C,[`${d}-${b}`]:b,[`${d}-rtl`]:E==="rtl"},c,N,P),$={};if(y!=null&&y[0]){const s=typeof y[0]=="number"?`${y[0]/-2}px`:`calc(${y[0]} / -2)`;$.marginLeft=s,$.marginRight=s}const[w,u]=y;$.rowGap=u;const a=f.useMemo(()=>({gutter:[w,u],wrap:m}),[w,u,m]);return o(f.createElement(k.Provider,{value:a},f.createElement("div",Object.assign({},h,{className:j,style:Object.assign(Object.assign({},$),p),ref:l}),i)))});export{F as C,H as R};
import{r as f,an as A,ea as I,ao as G,az as S,ay as _,eb as z}from"./index-DIy3NosD.js";const k=f.createContext({});var J=function(e,l){var n={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&l.indexOf(t)<0&&(n[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var r=0,t=Object.getOwnPropertySymbols(e);r<t.length;r++)l.indexOf(t[r])<0&&Object.prototype.propertyIsEnumerable.call(e,t[r])&&(n[t[r]]=e[t[r]]);return n};function R(e){return e==="auto"?"1 1 auto":typeof e=="number"?`${e} ${e} auto`:/^\d+(\.\d+)?(px|em|rem|%)$/.test(e)?`0 0 ${e}`:e}const M=["xs","sm","md","lg","xl","xxl"],F=f.forwardRef((e,l)=>{const{getPrefixCls:n,direction:t}=f.useContext(A),{gutter:r,wrap:c}=f.useContext(k),{prefixCls:p,span:i,order:g,offset:m,push:h,pull:O,className:E,children:b,flex:x,style:C}=e,d=J(e,["prefixCls","span","order","offset","push","pull","className","children","flex","style"]),o=n("col",p),[N,P,y]=I(o),j={};let $={};M.forEach(a=>{let s={};const v=e[a];typeof v=="number"?s.span=v:typeof v=="object"&&(s=v||{}),delete d[a],$=Object.assign(Object.assign({},$),{[`${o}-${a}-${s.span}`]:s.span!==void 0,[`${o}-${a}-order-${s.order}`]:s.order||s.order===0,[`${o}-${a}-offset-${s.offset}`]:s.offset||s.offset===0,[`${o}-${a}-push-${s.push}`]:s.push||s.push===0,[`${o}-${a}-pull-${s.pull}`]:s.pull||s.pull===0,[`${o}-rtl`]:t==="rtl"}),s.flex&&($[`${o}-${a}-flex`]=!0,j[`--${o}-${a}-flex`]=R(s.flex))});const w=G(o,{[`${o}-${i}`]:i!==void 0,[`${o}-order-${g}`]:g,[`${o}-offset-${m}`]:m,[`${o}-push-${h}`]:h,[`${o}-pull-${O}`]:O},E,$,P,y),u={};if(r!=null&&r[0]){const a=typeof r[0]=="number"?`${r[0]/2}px`:`calc(${r[0]} / 2)`;u.paddingLeft=a,u.paddingRight=a}return x&&(u.flex=R(x),c===!1&&!u.minWidth&&(u.minWidth=0)),N(f.createElement("div",Object.assign({},d,{style:Object.assign(Object.assign(Object.assign({},u),C),j),className:w,ref:l}),b))});function B(e,l){const n=[void 0,void 0],t=Array.isArray(e)?e:[e,void 0],r=l||{xs:!0,sm:!0,md:!0,lg:!0,xl:!0,xxl:!0};return t.forEach((c,p)=>{if(typeof c=="object"&&c!==null)for(let i=0;i<S.length;i++){const g=S[i];if(r[g]&&c[g]!==void 0){n[p]=c[g];break}}else n[p]=c}),n}var L=function(e,l){var n={};for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&l.indexOf(t)<0&&(n[t]=e[t]);if(e!=null&&typeof Object.getOwnPropertySymbols=="function")for(var r=0,t=Object.getOwnPropertySymbols(e);r<t.length;r++)l.indexOf(t[r])<0&&Object.prototype.propertyIsEnumerable.call(e,t[r])&&(n[t[r]]=e[t[r]]);return n};function V(e,l){const[n,t]=f.useState(typeof e=="string"?e:""),r=()=>{if(typeof e=="string"&&t(e),typeof e=="object")for(let c=0;c<S.length;c++){const p=S[c];if(!l||!l[p])continue;const i=e[p];if(i!==void 0){t(i);return}}};return f.useEffect(()=>{r()},[JSON.stringify(e),l]),n}const H=f.forwardRef((e,l)=>{const{prefixCls:n,justify:t,align:r,className:c,style:p,children:i,gutter:g=0,wrap:m}=e,h=L(e,["prefixCls","justify","align","className","style","children","gutter","wrap"]),{getPrefixCls:O,direction:E}=f.useContext(A),b=_(!0,null),x=V(r,b),C=V(t,b),d=O("row",n),[o,N,P]=z(d),y=B(g,b),j=G(d,{[`${d}-no-wrap`]:m===!1,[`${d}-${C}`]:C,[`${d}-${x}`]:x,[`${d}-rtl`]:E==="rtl"},c,N,P),$={};if(y!=null&&y[0]){const s=typeof y[0]=="number"?`${y[0]/-2}px`:`calc(${y[0]} / -2)`;$.marginLeft=s,$.marginRight=s}const[w,u]=y;$.rowGap=u;const a=f.useMemo(()=>({gutter:[w,u],wrap:m}),[w,u,m]);return o(f.createElement(k.Provider,{value:a},f.createElement("div",Object.assign({},h,{className:j,style:Object.assign(Object.assign({},$),p),ref:l}),i)))});export{F as C,H as R};

View File

@ -1 +1 @@
import{aC as n}from"./index-D2cxrzyv.js";async function r(a){return(await n.get("/sys/api/tenants",{params:a})).data.data}async function p(a){return(await n.post("/sys/api/tenants",a)).data.data}async function c(a,t){return(await n.put(`/sys/api/tenants/${a}`,t)).data.data}async function i(a){return(await n.delete(`/sys/api/tenants/${a}`)).data.data}export{p as c,i as d,r as l,c as u};
import{aC as n}from"./index-DIy3NosD.js";async function r(a){return(await n.get("/sys/api/tenants",{params:a})).data.data}async function p(a){return(await n.post("/sys/api/tenants",a)).data.data}async function c(a,t){return(await n.put(`/sys/api/tenants/${a}`,t)).data.data}async function i(a){return(await n.delete(`/sys/api/tenants/${a}`)).data.data}export{p as c,i as d,r as l,c as u};

View File

@ -1 +1 @@
import{aC as s,r}from"./index-D2cxrzyv.js";async function f(t){return(await s.get("/sys/api/dict-types",{params:t})).data.data}async function l(t){return(await s.post("/sys/api/dict-types",t)).data.data}async function m(t,a){return(await s.put(`/sys/api/dict-types/${t}`,a)).data.data}async function w(t){return(await s.delete(`/sys/api/dict-types/${t}`)).data.data}async function D(t){return(await s.get("/sys/api/dict-items",{params:{typeCode:t}})).data.data}async function h(t){return(await s.post("/sys/api/dict-items",t)).data.data}async function g(t,a){return(await s.put(`/sys/api/dict-items/${t}`,a)).data.data}async function I(t){return(await s.delete(`/sys/api/dict-items/${t}`)).data.data}async function o(t){return(await s.get(`/sys/api/dict-items/type/${t}`)).data.data}const e={};function $(t){const[a,c]=r.useState(e[t]||[]),[d,p]=r.useState(!e[t]);return r.useEffect(()=>{if(e[t]){c(e[t]),p(!1);return}let n=!0;return(async()=>{try{const i=await o(t);n&&(e[t]=i,c(i))}catch(i){console.error(`Failed to fetch dictionary ${t}:`,i)}finally{n&&p(!1)}})(),()=>{n=!1}},[t]),{items:a,loading:d}}export{D as a,m as b,l as c,w as d,I as e,f,g,h,$ as u};
import{aC as s,r}from"./index-DIy3NosD.js";async function f(t){return(await s.get("/sys/api/dict-types",{params:t})).data.data}async function l(t){return(await s.post("/sys/api/dict-types",t)).data.data}async function m(t,a){return(await s.put(`/sys/api/dict-types/${t}`,a)).data.data}async function w(t){return(await s.delete(`/sys/api/dict-types/${t}`)).data.data}async function D(t){return(await s.get("/sys/api/dict-items",{params:{typeCode:t}})).data.data}async function h(t){return(await s.post("/sys/api/dict-items",t)).data.data}async function g(t,a){return(await s.put(`/sys/api/dict-items/${t}`,a)).data.data}async function I(t){return(await s.delete(`/sys/api/dict-items/${t}`)).data.data}async function o(t){return(await s.get(`/sys/api/dict-items/type/${t}`)).data.data}const e={};function $(t){const[a,c]=r.useState(e[t]||[]),[d,p]=r.useState(!e[t]);return r.useEffect(()=>{if(e[t]){c(e[t]),p(!1);return}let n=!0;return(async()=>{try{const i=await o(t);n&&(e[t]=i,c(i))}catch(i){console.error(`Failed to fetch dictionary ${t}:`,i)}finally{n&&p(!1)}})(),()=>{n=!1}},[t]),{items:a,loading:d}}export{D as a,m as b,l as c,w as d,I as e,f,g,h,$ as u};

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UnisBase - 智能会议系统</title>
<script type="module" crossorigin src="/assets/index-D2cxrzyv.js"></script>
<script type="module" crossorigin src="/assets/index-DIy3NosD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
</head>
<body>

View File

@ -0,0 +1,12 @@
import http from "@/api/http";
import type { OwnerTransferPayload, OwnerTransferPreview, OwnerTransferResult } from "./types";
export async function previewOwnerTransfer(fromUserId: number, toUserId: number) {
const resp = await http.get("/sys/api/admin/owner-transfer/preview", { params: { fromUserId, toUserId } });
return resp.data.data as OwnerTransferPreview;
}
export async function executeOwnerTransfer(payload: OwnerTransferPayload) {
const resp = await http.post("/sys/api/admin/owner-transfer/execute", payload);
return resp.data.data as OwnerTransferResult;
}

View File

@ -0,0 +1 @@
export { default } from "./pages/owner-transfer";

View File

@ -0,0 +1,69 @@
const ownerTransferEnUS = {
ownerTransfer: {
title: "Ownership Transfer",
subtitle: "Admins can transfer opportunities, sales expansions, and channel expansions from one user to another after previewing counts and conflicts.",
noViewPermission: "You do not have permission to view ownership transfer.",
selectTenantFirst: "Switch to a specific tenant before running ownership transfer.",
selectTenantHint: "This action runs within the active tenant only.",
sourceUser: "Current owner",
targetUser: "New owner",
sourcePlaceholder: "Select the user to transfer from",
targetPlaceholder: "Select the user to transfer to",
unselected: "Not selected",
transferTypes: "Transfer objects",
transferSetup: "Transfer setup",
transferSetupHint: "Pick both users first, then choose which record types to move in bulk.",
batchSelection: "Bulk transfer scope",
transferOpportunities: "Opportunities",
transferSales: "Sales expansions",
transferChannels: "Channel expansions",
previewTitle: "Transfer preview",
previewSubTitle: "Previewing records moving from {{fromUserName}} to {{toUserName}}",
sourceSummary: "Transferable records under the current owner",
opportunityCount: "Opportunities",
salesCount: "Sales expansions",
channelCount: "Channel expansions",
salesConflictTitle: "Sales employee number conflicts",
salesConflictHint: "Sales expansion transfer is blocked when the target owner already has the same employee number.",
noConflict: "No employee number conflicts were found.",
conflictEmployeeNo: "Employee No.",
conflictCandidateName: "Name",
itemName: "Name",
status: "Status",
hasConflict: "Conflict",
canTransfer: "Ready",
actions: "Actions",
singleTransfer: "Transfer",
multiTransfer: "Transfer selected",
searchPlaceholder: "Search by name or employee no.",
selectedCount: "{{count}} selected",
conflictCount: "{{count}} conflicts",
salesConflictListTitle: "Conflict details",
execute: "Transfer",
executing: "Transferring...",
previewLoading: "Loading preview...",
previewEmpty: "Select both users to load counts and conflict checks.",
loadUsersError: "Failed to load users. Please try again later.",
previewError: "Failed to load the transfer preview. Please verify the selected users or try again later.",
executeError: "Failed to execute the ownership transfer. Please try again later.",
noItems: "No data",
selectAtLeastOne: "Select at least one object type.",
selectAtLeastOneItem: "Select at least one record to transfer.",
selectUsersFirst: "Select both users first.",
sameUserError: "The current owner and new owner must be different.",
confirmTitle: "Confirm ownership transfer?",
confirmContent: "The selected records will be transferred from {{fromUserName}} to {{toUserName}} immediately.",
singleConfirmTitle: "Confirm single transfer?",
singleConfirmContent: "{{itemName}} will be transferred to {{toUserName}} immediately.",
multiConfirmTitle: "Confirm selected transfer?",
multiConfirmContent: "{{count}} selected records will be transferred to {{toUserName}} immediately.",
success: "Ownership transfer completed.",
singleSuccess: "Single ownership transfer completed.",
multiSuccess: "Selected ownership transfer completed.",
resultSummary: "Transferred {{opportunityCount}} opportunities, {{salesCount}} sales expansions, and {{channelCount}} channel expansions.",
unchangedHint: "Unchecked object types will be left unchanged.",
disabledActionHint: "Resolve the sales employee number conflicts or uncheck that transfer type before continuing."
}
} as const;
export default ownerTransferEnUS;

View File

@ -0,0 +1,69 @@
const ownerTransferZhCN = {
ownerTransfer: {
title: "归属人转移",
subtitle: "管理员可将离职人员名下的商机、拓展销售人员、拓展渠道一键转移给新的归属人,并在执行前查看预检结果。",
noViewPermission: "你当前没有查看归属人转移的权限。",
selectTenantFirst: "请先切换到具体租户后再操作归属人转移。",
selectTenantHint: "当前功能按租户维度执行,只有在具体租户下才能准确识别转移范围。",
sourceUser: "原归属人",
targetUser: "新归属人",
sourcePlaceholder: "请选择离职或待转移人员",
targetPlaceholder: "请选择新的归属人",
unselected: "未选择",
transferTypes: "转移对象",
transferSetup: "转移设置",
transferSetupHint: "先选择转移双方,再决定本次批量转移的对象类型。",
batchSelection: "本次批量转移",
transferOpportunities: "商机",
transferSales: "拓展销售人员",
transferChannels: "拓展渠道",
previewTitle: "转移预检",
previewSubTitle: "从 {{fromUserName}} 转移到 {{toUserName}} 的预检结果",
sourceSummary: "原归属人当前可转移数据",
opportunityCount: "商机数量",
salesCount: "拓展销售人员数量",
channelCount: "拓展渠道数量",
salesConflictTitle: "拓展销售人员工号冲突",
salesConflictHint: "目标归属人名下已存在相同工号时,系统会阻止执行“拓展销售人员”转移,避免唯一约束冲突。",
noConflict: "未发现工号冲突,可安全转移拓展销售人员。",
conflictEmployeeNo: "工号",
conflictCandidateName: "人员姓名",
itemName: "名称",
status: "状态",
hasConflict: "有冲突",
canTransfer: "可转移",
actions: "操作",
singleTransfer: "单条转移",
multiTransfer: "多条转移",
searchPlaceholder: "搜索名称或工号",
selectedCount: "已选 {{count}} 条",
conflictCount: "冲突 {{count}} 条",
salesConflictListTitle: "冲突明细",
execute: "执行转移",
executing: "执行中...",
previewLoading: "正在加载预检结果...",
previewEmpty: "选择原归属人和新归属人后,系统会显示当前可转移数量及冲突检查结果。",
loadUsersError: "加载用户列表失败,请稍后重试。",
previewError: "加载转移预检失败,请检查当前选择或稍后重试。",
executeError: "执行归属人转移失败,请稍后重试。",
noItems: "暂无数据",
selectAtLeastOne: "请至少选择一类转移对象。",
selectAtLeastOneItem: "请至少选择一条需要转移的数据。",
selectUsersFirst: "请先选择原归属人和新归属人。",
sameUserError: "原归属人和新归属人不能相同。",
confirmTitle: "确认执行归属人转移?",
confirmContent: "系统将把所选对象从 {{fromUserName}} 转移给 {{toUserName}},该操作会立即生效。",
singleConfirmTitle: "确认执行单条转移?",
singleConfirmContent: "系统将把 {{itemName}} 转移给 {{toUserName}},该操作会立即生效。",
multiConfirmTitle: "确认执行多条转移?",
multiConfirmContent: "系统将把选中的 {{count}} 条数据转移给 {{toUserName}},该操作会立即生效。",
success: "归属人转移已完成。",
singleSuccess: "单条归属人转移已完成。",
multiSuccess: "多条归属人转移已完成。",
resultSummary: "本次共转移:商机 {{opportunityCount}} 条、拓展销售人员 {{salesCount}} 条、拓展渠道 {{channelCount}} 条。",
unchangedHint: "未勾选的对象不会被转移。",
disabledActionHint: "存在拓展销售人员工号冲突时,请取消该项或先处理冲突后再执行。"
}
} as const;
export default ownerTransferZhCN;

View File

@ -0,0 +1,197 @@
.owner-transfer-page {
--owner-transfer-bg: linear-gradient(180deg, #f7fbff 0%, #ffffff 100%);
--owner-transfer-border: #dbe7f3;
--owner-transfer-accent: #1677ff;
--owner-transfer-soft: #eef5ff;
--owner-transfer-warning: #fff7e6;
.owner-transfer-preview-card,
.owner-transfer-form-card {
border-radius: 18px;
border: 1px solid var(--owner-transfer-border);
background: var(--owner-transfer-bg);
box-shadow: 0 14px 32px rgba(31, 56, 88, 0.08);
}
.owner-transfer-form-card .ant-card-body,
.owner-transfer-preview-card .ant-card-body {
padding: 20px;
}
.owner-transfer-panel-title {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 20px;
}
.owner-transfer-user-flow {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px;
align-items: center;
margin-bottom: 18px;
}
.owner-transfer-user-card,
.owner-transfer-checkbox-card,
.owner-transfer-summary-card {
border-radius: 14px;
border: 1px solid var(--owner-transfer-border);
background: rgba(255, 255, 255, 0.9);
}
.owner-transfer-user-card {
min-width: 0;
padding: 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.owner-transfer-flow-icon {
color: var(--owner-transfer-accent);
font-size: 18px;
}
.owner-transfer-checkbox-card {
padding: 16px;
margin-bottom: 16px;
}
.owner-transfer-summary-card {
padding: 14px 16px;
margin-bottom: 16px;
background: var(--owner-transfer-soft);
}
.owner-transfer-summary-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.owner-transfer-alert {
margin-bottom: 16px;
}
.owner-transfer-submit {
height: 46px;
border-radius: 12px;
}
.owner-transfer-preview-loading {
min-height: 320px;
display: grid;
place-items: center;
}
.owner-transfer-overview {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
padding: 18px;
border-radius: 16px;
background: linear-gradient(135deg, #edf5ff 0%, #f8fbff 100%);
border: 1px solid #d8e8ff;
}
.owner-transfer-overview-copy {
display: flex;
flex-direction: column;
gap: 4px;
}
.owner-transfer-overview-tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.owner-transfer-stats {
margin-bottom: 0;
}
.owner-transfer-stat-card {
border-radius: 14px;
border: 1px solid var(--owner-transfer-border);
}
.owner-transfer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.owner-transfer-toolbar-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.owner-transfer-search {
max-width: 320px;
}
.owner-transfer-conflict-card,
.owner-transfer-preview-card .ant-tabs-content-holder,
.owner-transfer-preview-card .ant-card-small {
border-radius: 14px;
}
.owner-transfer-row-conflict > td {
background: var(--owner-transfer-warning) !important;
}
.owner-transfer-preview-card {
.ant-tabs-nav {
margin-bottom: 12px;
}
.ant-table-wrapper {
overflow: hidden;
border: 1px solid #eef2f7;
border-radius: 14px;
background: #fff;
}
}
@media (max-width: 1199px) {
.owner-transfer-search {
max-width: none;
width: 100%;
}
.owner-transfer-toolbar,
.owner-transfer-overview {
flex-direction: column;
align-items: stretch;
}
.owner-transfer-toolbar-actions {
justify-content: flex-start;
}
.owner-transfer-overview-tags {
justify-content: flex-start;
}
}
@media (max-width: 767px) {
.owner-transfer-user-flow {
grid-template-columns: 1fr;
}
.owner-transfer-flow-icon {
justify-self: center;
transform: rotate(90deg);
}
}
}

View File

@ -0,0 +1,819 @@
import {
Alert,
App,
Button,
Card,
Checkbox,
Col,
Empty,
Form,
Input,
Row,
Select,
Space,
Spin,
Statistic,
Table,
Tabs,
Tag,
Typography,
} from "antd";
import { ExclamationCircleOutlined, SearchOutlined, SwapOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ColumnsType } from "antd/es/table";
import { listUsers } from "@/api";
import PageHeader from "@/components/shared/PageHeader";
import { usePermission } from "@/hooks/usePermission";
import type { SysUser } from "@/types";
import { executeOwnerTransfer, previewOwnerTransfer } from "@/features/owner-transfer/api";
import type { OwnerTransferItem, OwnerTransferPayload, OwnerTransferPreview } from "@/features/owner-transfer/types";
import "./index.less";
interface FormValues {
fromUserId?: number;
toUserId?: number;
transferOpportunities: boolean;
transferSalesExpansions: boolean;
transferChannelExpansions: boolean;
}
const DEFAULT_VALUES: Pick<
FormValues,
"transferOpportunities" | "transferSalesExpansions" | "transferChannelExpansions"
> = {
transferOpportunities: true,
transferSalesExpansions: true,
transferChannelExpansions: true,
};
type TransferKind = "opportunity" | "sales" | "channel";
type DetailTabKey = "opportunities" | "sales" | "channels";
const BLOCKED_TRANSFER_ROLE_CODES = new Set([
"TENANT_ADMIN",
"ADMIN",
"SYS_ADMIN",
"PLATFORM_ADMIN",
"SUPER_ADMIN",
]);
function filterItems(items: OwnerTransferItem[], keyword: string) {
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) {
return items;
}
return items.filter((item) => `${item.name || ""} ${item.code || ""}`.toLowerCase().includes(normalizedKeyword));
}
function hasBlockedTransferRole(user: SysUser) {
return (user.roles ?? []).some((role) => {
const normalizedRoleCode = role.roleCode?.trim().toUpperCase();
return Boolean(normalizedRoleCode && BLOCKED_TRANSFER_ROLE_CODES.has(normalizedRoleCode));
});
}
function canBeTransferSourceUser(user: SysUser) {
if ((user.isDeleted ?? 0) !== 0) {
return false;
}
if (user.isPlatformAdmin) {
return false;
}
if (hasBlockedTransferRole(user)) {
return false;
}
return true;
}
function canBeTransferTargetUser(user: SysUser) {
if (!canBeTransferSourceUser(user)) {
return false;
}
return (user.status ?? 1) !== 0;
}
export default function OwnerTransferPage() {
const { t } = useTranslation();
const { message, modal } = App.useApp();
const { can } = usePermission();
const [form] = Form.useForm<FormValues>();
const [users, setUsers] = useState<SysUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [executing, setExecuting] = useState(false);
const [preview, setPreview] = useState<OwnerTransferPreview | null>(null);
const [pageError, setPageError] = useState("");
const [activeTab, setActiveTab] = useState<DetailTabKey>("opportunities");
const [searchKeyword, setSearchKeyword] = useState("");
const [selectedOpportunityRowKeys, setSelectedOpportunityRowKeys] = useState<React.Key[]>([]);
const [selectedSalesRowKeys, setSelectedSalesRowKeys] = useState<React.Key[]>([]);
const [selectedChannelRowKeys, setSelectedChannelRowKeys] = useState<React.Key[]>([]);
const activeTenantId = Number(localStorage.getItem("activeTenantId") || 0);
const isTenantUnselected = activeTenantId <= 0;
const canView = can("owner_transfer:view");
const canExecute = can("owner_transfer:execute");
const fromUserId = Form.useWatch("fromUserId", form);
const toUserId = Form.useWatch("toUserId", form);
const transferOpportunities = Form.useWatch("transferOpportunities", form) ?? true;
const transferSalesExpansions = Form.useWatch("transferSalesExpansions", form) ?? true;
const transferChannelExpansions = Form.useWatch("transferChannelExpansions", form) ?? true;
const selectedTransferCount = selectedOpportunityRowKeys.length + selectedSalesRowKeys.length + selectedChannelRowKeys.length;
const hasRowSelection = selectedTransferCount > 0;
const userOptions = useMemo(
() =>
users.map((user) => ({
label: `${user.displayName || user.username} (${user.username})`,
value: user.userId,
})),
[users]
);
const sourceUserOptions = useMemo(
() =>
users
.filter(canBeTransferSourceUser)
.map((user) => ({
label: `${user.displayName || user.username} (${user.username})`,
value: user.userId,
})),
[users]
);
const fromUserLabel = useMemo(
() => sourceUserOptions.find((option) => option.value === fromUserId)?.label,
[fromUserId, sourceUserOptions]
);
const toUserLabel = useMemo(
() => userOptions.find((option) => option.value === toUserId)?.label,
[toUserId, userOptions]
);
const targetUserOptions = useMemo(
() => sourceUserOptions.filter((option) => {
if (option.value === fromUserId) {
return false;
}
const matchedUser = users.find((user) => user.userId === option.value);
return Boolean(matchedUser && canBeTransferTargetUser(matchedUser));
}),
[fromUserId, sourceUserOptions, users]
);
const selectedPreviewHasSalesConflict = !hasRowSelection && !!(transferSalesExpansions && preview?.salesConflicts.length);
const loadUsers = useCallback(async () => {
if (isTenantUnselected || !canView) {
setUsers([]);
return;
}
setLoadingUsers(true);
setPageError("");
try {
const nextUsers = await listUsers({ tenantId: activeTenantId });
setUsers(nextUsers || []);
} catch (error) {
setUsers([]);
const nextMessage = error instanceof Error ? error.message : t("ownerTransfer.loadUsersError");
setPageError(nextMessage);
message.error(nextMessage);
} finally {
setLoadingUsers(false);
}
}, [activeTenantId, canView, isTenantUnselected, message, t]);
const loadPreview = useCallback(
async (sourceUserId?: number, targetUserId?: number) => {
if (
!canView ||
isTenantUnselected ||
!sourceUserId ||
!targetUserId ||
sourceUserId <= 0 ||
targetUserId <= 0 ||
sourceUserId === targetUserId
) {
setPreview(null);
return;
}
setPreviewLoading(true);
setPageError("");
try {
const data = await previewOwnerTransfer(sourceUserId, targetUserId);
setPreview(data);
} catch (error) {
setPreview(null);
const nextMessage = error instanceof Error ? error.message : t("ownerTransfer.previewError");
setPageError(nextMessage);
message.error(nextMessage);
} finally {
setPreviewLoading(false);
}
},
[canView, isTenantUnselected, message, t]
);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
useEffect(() => {
form.setFieldsValue(DEFAULT_VALUES);
}, [form]);
useEffect(() => {
void loadPreview(fromUserId, toUserId);
}, [fromUserId, loadPreview, toUserId]);
useEffect(() => {
setSearchKeyword("");
}, [activeTab, preview?.fromUserId, preview?.toUserId]);
useEffect(() => {
setSelectedOpportunityRowKeys([]);
setSelectedSalesRowKeys([]);
setSelectedChannelRowKeys([]);
}, [preview?.fromUserId, preview?.toUserId]);
const runTransfer = useCallback(
async (payload: OwnerTransferPayload, successMessage: string) => {
setExecuting(true);
setPageError("");
try {
const result = await executeOwnerTransfer(payload);
message.success(successMessage);
message.info(
t("ownerTransfer.resultSummary", {
opportunityCount: result.transferredOpportunityCount,
salesCount: result.transferredSalesExpansionCount,
channelCount: result.transferredChannelExpansionCount,
})
);
await loadUsers();
await loadPreview(payload.fromUserId, payload.toUserId);
} catch (error) {
const nextMessage = error instanceof Error ? error.message : t("ownerTransfer.executeError");
setPageError(nextMessage);
message.error(nextMessage);
} finally {
setExecuting(false);
}
},
[loadPreview, loadUsers, message, t]
);
const handleExecute = useCallback(async () => {
const values = await form.validateFields();
if (!hasRowSelection && !values.transferOpportunities && !values.transferSalesExpansions && !values.transferChannelExpansions) {
message.warning(t("ownerTransfer.selectAtLeastOne"));
return;
}
if (!values.fromUserId || !values.toUserId || values.fromUserId === values.toUserId) {
message.warning(t("ownerTransfer.sameUserError"));
return;
}
const payload: OwnerTransferPayload = {
fromUserId: values.fromUserId,
toUserId: values.toUserId,
transferOpportunities: hasRowSelection ? selectedOpportunityRowKeys.length > 0 : values.transferOpportunities,
transferSalesExpansions: hasRowSelection ? selectedSalesRowKeys.length > 0 : values.transferSalesExpansions,
transferChannelExpansions: hasRowSelection ? selectedChannelRowKeys.length > 0 : values.transferChannelExpansions,
selection: hasRowSelection
? {
opportunityIds: selectedOpportunityRowKeys.map((id) => Number(id)),
salesExpansionIds: selectedSalesRowKeys.map((id) => Number(id)),
channelExpansionIds: selectedChannelRowKeys.map((id) => Number(id)),
}
: undefined,
};
modal.confirm({
title: hasRowSelection ? t("ownerTransfer.multiConfirmTitle") : t("ownerTransfer.confirmTitle"),
icon: <ExclamationCircleOutlined />,
content: hasRowSelection
? t("ownerTransfer.multiConfirmContent", {
count: selectedTransferCount,
toUserName: preview?.toUserName || values.toUserId,
})
: t("ownerTransfer.confirmContent", {
fromUserName: preview?.fromUserName || values.fromUserId,
toUserName: preview?.toUserName || values.toUserId,
}),
onOk: async () => runTransfer(payload, hasRowSelection ? t("ownerTransfer.multiSuccess") : t("ownerTransfer.success")),
});
}, [
form,
hasRowSelection,
message,
modal,
preview,
runTransfer,
selectedChannelRowKeys,
selectedOpportunityRowKeys,
selectedSalesRowKeys,
selectedTransferCount,
t,
]);
const handleSingleTransfer = useCallback(
(kind: TransferKind, item: OwnerTransferItem) => {
const values = form.getFieldsValue();
if (!values.fromUserId || !values.toUserId || values.fromUserId === values.toUserId) {
message.warning(t("ownerTransfer.selectUsersFirst"));
return;
}
const payload: OwnerTransferPayload = {
fromUserId: values.fromUserId,
toUserId: values.toUserId,
transferOpportunities: kind === "opportunity",
transferSalesExpansions: kind === "sales",
transferChannelExpansions: kind === "channel",
selection: {
opportunityIds: kind === "opportunity" ? [item.id] : [],
salesExpansionIds: kind === "sales" ? [item.id] : [],
channelExpansionIds: kind === "channel" ? [item.id] : [],
},
};
modal.confirm({
title: t("ownerTransfer.singleConfirmTitle"),
icon: <ExclamationCircleOutlined />,
content: t("ownerTransfer.singleConfirmContent", {
itemName: item.name,
toUserName: preview?.toUserName || values.toUserId,
}),
onOk: async () => runTransfer(payload, t("ownerTransfer.singleSuccess")),
});
},
[form, message, modal, preview, runTransfer, t]
);
const handleMultiTransfer = useCallback(() => {
const values = form.getFieldsValue();
if (!values.fromUserId || !values.toUserId || values.fromUserId === values.toUserId) {
message.warning(t("ownerTransfer.selectUsersFirst"));
return;
}
if (!selectedTransferCount) {
message.warning(t("ownerTransfer.selectAtLeastOneItem"));
return;
}
const payload: OwnerTransferPayload = {
fromUserId: values.fromUserId,
toUserId: values.toUserId,
transferOpportunities: selectedOpportunityRowKeys.length > 0,
transferSalesExpansions: selectedSalesRowKeys.length > 0,
transferChannelExpansions: selectedChannelRowKeys.length > 0,
selection: {
opportunityIds: selectedOpportunityRowKeys.map((id) => Number(id)),
salesExpansionIds: selectedSalesRowKeys.map((id) => Number(id)),
channelExpansionIds: selectedChannelRowKeys.map((id) => Number(id)),
},
};
modal.confirm({
title: t("ownerTransfer.multiConfirmTitle"),
icon: <ExclamationCircleOutlined />,
content: t("ownerTransfer.multiConfirmContent", {
count: selectedTransferCount,
toUserName: preview?.toUserName || values.toUserId,
}),
onOk: async () => {
await runTransfer(payload, t("ownerTransfer.multiSuccess"));
setSelectedOpportunityRowKeys([]);
setSelectedSalesRowKeys([]);
setSelectedChannelRowKeys([]);
},
});
}, [
form,
message,
modal,
preview?.toUserName,
runTransfer,
selectedChannelRowKeys,
selectedOpportunityRowKeys,
selectedSalesRowKeys,
selectedTransferCount,
t,
]);
const opportunityRowSelection = useMemo(
() => ({
selectedRowKeys: selectedOpportunityRowKeys,
onChange: (nextSelectedRowKeys: React.Key[]) => setSelectedOpportunityRowKeys(nextSelectedRowKeys),
}),
[selectedOpportunityRowKeys]
);
const salesRowSelection = useMemo(
() => ({
selectedRowKeys: selectedSalesRowKeys,
onChange: (nextSelectedRowKeys: React.Key[]) => setSelectedSalesRowKeys(nextSelectedRowKeys),
getCheckboxProps: (record: OwnerTransferItem) => ({
disabled: record.conflict,
}),
}),
[selectedSalesRowKeys]
);
const channelRowSelection = useMemo(
() => ({
selectedRowKeys: selectedChannelRowKeys,
onChange: (nextSelectedRowKeys: React.Key[]) => setSelectedChannelRowKeys(nextSelectedRowKeys),
}),
[selectedChannelRowKeys]
);
const opportunityColumns = useMemo<ColumnsType<OwnerTransferItem>>(
() => [
{
title: t("ownerTransfer.itemName"),
dataIndex: "name",
},
{
title: t("ownerTransfer.actions"),
key: "actions",
width: 120,
render: (_, record) => (
<Button
type="link"
size="small"
onClick={() => handleSingleTransfer("opportunity", record)}
disabled={!canExecute || executing}
>
{t("ownerTransfer.singleTransfer")}
</Button>
),
},
],
[canExecute, executing, handleSingleTransfer, t]
);
const salesColumns = useMemo<ColumnsType<OwnerTransferItem>>(
() => [
{
title: t("ownerTransfer.itemName"),
dataIndex: "name",
},
{
title: t("ownerTransfer.conflictEmployeeNo"),
dataIndex: "code",
width: 150,
render: (value?: string) => value || "-",
},
{
title: t("ownerTransfer.status"),
dataIndex: "conflict",
width: 120,
render: (value: boolean) =>
value ? (
<Tag color="warning">{t("ownerTransfer.hasConflict")}</Tag>
) : (
<Tag color="success">{t("ownerTransfer.canTransfer")}</Tag>
),
},
{
title: t("ownerTransfer.actions"),
key: "actions",
width: 120,
render: (_, record) => (
<Button
type="link"
size="small"
onClick={() => handleSingleTransfer("sales", record)}
disabled={!canExecute || executing || record.conflict}
>
{t("ownerTransfer.singleTransfer")}
</Button>
),
},
],
[canExecute, executing, handleSingleTransfer, t]
);
const channelColumns = useMemo<ColumnsType<OwnerTransferItem>>(
() => [
{
title: t("ownerTransfer.itemName"),
dataIndex: "name",
},
{
title: t("ownerTransfer.actions"),
key: "actions",
width: 120,
render: (_, record) => (
<Button
type="link"
size="small"
onClick={() => handleSingleTransfer("channel", record)}
disabled={!canExecute || executing}
>
{t("ownerTransfer.singleTransfer")}
</Button>
),
},
],
[canExecute, executing, handleSingleTransfer, t]
);
const filteredOpportunities = useMemo(
() => filterItems(preview?.opportunities || [], searchKeyword),
[preview?.opportunities, searchKeyword]
);
const filteredSales = useMemo(
() => filterItems(preview?.salesExpansions || [], searchKeyword),
[preview?.salesExpansions, searchKeyword]
);
const filteredChannels = useMemo(
() => filterItems(preview?.channelExpansions || [], searchKeyword),
[preview?.channelExpansions, searchKeyword]
);
if (!canView) {
return <Alert type="warning" showIcon message={t("ownerTransfer.noViewPermission")} />;
}
if (isTenantUnselected) {
return (
<Alert
type="info"
showIcon
message={t("ownerTransfer.selectTenantFirst")}
description={t("ownerTransfer.selectTenantHint")}
/>
);
}
return (
<div className="owner-transfer-page">
<PageHeader title={t("ownerTransfer.title")} subtitle={t("ownerTransfer.subtitle")} />
{pageError ? (
<Alert
type="error"
showIcon
closable
style={{ marginBottom: 16 }}
message={pageError}
onClose={() => setPageError("")}
/>
) : null}
<Row gutter={[16, 16]} align="stretch">
<Col xs={24} xl={8}>
<Card className="owner-transfer-form-card">
<div className="owner-transfer-panel-title">
<Typography.Title level={5} style={{ margin: 0 }}>
{t("ownerTransfer.transferSetup")}
</Typography.Title>
<Typography.Text type="secondary">{t("ownerTransfer.transferSetupHint")}</Typography.Text>
</div>
<Form form={form} layout="vertical" initialValues={DEFAULT_VALUES}>
<Form.Item
label={t("ownerTransfer.sourceUser")}
name="fromUserId"
rules={[{ required: true, message: t("ownerTransfer.sourcePlaceholder") }]}
>
<Select
allowClear
showSearch
placeholder={t("ownerTransfer.sourcePlaceholder")}
options={sourceUserOptions}
loading={loadingUsers}
optionFilterProp="label"
/>
</Form.Item>
<Form.Item
label={t("ownerTransfer.targetUser")}
name="toUserId"
rules={[{ required: true, message: t("ownerTransfer.targetPlaceholder") }]}
>
<Select
allowClear
showSearch
placeholder={t("ownerTransfer.targetPlaceholder")}
options={targetUserOptions}
loading={loadingUsers}
optionFilterProp="label"
/>
</Form.Item>
<div className="owner-transfer-user-flow">
<div className="owner-transfer-user-card">
<Typography.Text type="secondary">{t("ownerTransfer.sourceUser")}</Typography.Text>
<Typography.Text strong ellipsis>
{fromUserLabel || t("ownerTransfer.unselected")}
</Typography.Text>
</div>
<SwapOutlined className="owner-transfer-flow-icon" />
<div className="owner-transfer-user-card">
<Typography.Text type="secondary">{t("ownerTransfer.targetUser")}</Typography.Text>
<Typography.Text strong ellipsis>
{toUserLabel || t("ownerTransfer.unselected")}
</Typography.Text>
</div>
</div>
<div className="owner-transfer-checkbox-card">
<Typography.Text strong>{t("ownerTransfer.transferTypes")}</Typography.Text>
<Space direction="vertical" size={10} style={{ width: "100%", marginTop: 12 }}>
<Form.Item name="transferOpportunities" valuePropName="checked" noStyle>
<Checkbox>{t("ownerTransfer.transferOpportunities")}</Checkbox>
</Form.Item>
<Form.Item name="transferSalesExpansions" valuePropName="checked" noStyle>
<Checkbox>{t("ownerTransfer.transferSales")}</Checkbox>
</Form.Item>
<Form.Item name="transferChannelExpansions" valuePropName="checked" noStyle>
<Checkbox>{t("ownerTransfer.transferChannels")}</Checkbox>
</Form.Item>
</Space>
</div>
<div className="owner-transfer-summary-card">
<div className="owner-transfer-summary-row">
<span>{t("ownerTransfer.batchSelection")}</span>
<Tag color={transferOpportunities ? "blue" : "default"}>{t("ownerTransfer.transferOpportunities")}</Tag>
<Tag color={transferSalesExpansions ? "blue" : "default"}>{t("ownerTransfer.transferSales")}</Tag>
<Tag color={transferChannelExpansions ? "blue" : "default"}>{t("ownerTransfer.transferChannels")}</Tag>
</div>
<Typography.Text type="secondary">{t("ownerTransfer.unchangedHint")}</Typography.Text>
</div>
{selectedPreviewHasSalesConflict ? (
<Alert className="owner-transfer-alert" type="warning" showIcon message={t("ownerTransfer.disabledActionHint")} />
) : null}
<Button
block
size="large"
type="primary"
icon={<SwapOutlined />}
onClick={() => void handleExecute()}
loading={executing}
disabled={!canExecute || selectedPreviewHasSalesConflict}
className="owner-transfer-submit"
>
{executing ? t("ownerTransfer.executing") : t("ownerTransfer.execute")}
</Button>
</Form>
</Card>
</Col>
<Col xs={24} xl={16}>
<Card className="owner-transfer-preview-card">
{previewLoading ? (
<div className="owner-transfer-preview-loading">
<Space direction="vertical" align="center">
<Spin />
<Typography.Text type="secondary">{t("ownerTransfer.previewLoading")}</Typography.Text>
</Space>
</div>
) : !preview ? (
<Empty description={t("ownerTransfer.previewEmpty")} />
) : (
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<div className="owner-transfer-overview">
<div className="owner-transfer-overview-copy">
<Typography.Title level={5} style={{ margin: 0 }}>
{t("ownerTransfer.previewTitle")}
</Typography.Title>
<Typography.Text type="secondary">
{t("ownerTransfer.previewSubTitle", {
fromUserName: preview.fromUserName,
toUserName: preview.toUserName,
})}
</Typography.Text>
</div>
<div className="owner-transfer-overview-tags">
<Tag color="blue">{t("ownerTransfer.transferOpportunities")} {preview.opportunityCount}</Tag>
<Tag color="gold">{t("ownerTransfer.transferSales")} {preview.salesExpansionCount}</Tag>
<Tag color="cyan">{t("ownerTransfer.transferChannels")} {preview.channelExpansionCount}</Tag>
</div>
</div>
<Row gutter={[12, 12]} className="owner-transfer-stats">
<Col xs={24} sm={8}>
<Card size="small" className="owner-transfer-stat-card">
<Statistic title={t("ownerTransfer.opportunityCount")} value={preview.opportunityCount} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small" className="owner-transfer-stat-card">
<Statistic title={t("ownerTransfer.salesCount")} value={preview.salesExpansionCount} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small" className="owner-transfer-stat-card">
<Statistic title={t("ownerTransfer.channelCount")} value={preview.channelExpansionCount} />
</Card>
</Col>
</Row>
<div className="owner-transfer-toolbar">
<Input
allowClear
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
prefix={<SearchOutlined />}
placeholder={t("ownerTransfer.searchPlaceholder")}
className="owner-transfer-search"
/>
<div className="owner-transfer-toolbar-actions">
{selectedTransferCount ? (
<Tag color="processing">
{t("ownerTransfer.selectedCount", { count: selectedTransferCount })}
</Tag>
) : null}
{preview.salesConflicts.length ? (
<Tag color="warning">
{t("ownerTransfer.conflictCount", { count: preview.salesConflicts.length })}
</Tag>
) : null}
<Button onClick={handleMultiTransfer} disabled={!canExecute || executing || !selectedTransferCount}>
{t("ownerTransfer.multiTransfer")}
</Button>
</div>
</div>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as DetailTabKey)}
items={[
{
key: "opportunities",
label: `${t("ownerTransfer.transferOpportunities")} (${preview.opportunityCount})`,
children: (
<Table
rowKey="id"
size="middle"
dataSource={filteredOpportunities}
columns={opportunityColumns}
rowSelection={opportunityRowSelection}
locale={{ emptyText: t("ownerTransfer.noItems") }}
pagination={{ pageSize: 8, showSizeChanger: false }}
/>
),
},
{
key: "sales",
label: `${t("ownerTransfer.transferSales")} (${preview.salesExpansionCount})`,
children: (
<Table
rowKey="id"
size="middle"
dataSource={filteredSales}
columns={salesColumns}
rowSelection={salesRowSelection}
locale={{ emptyText: t("ownerTransfer.noItems") }}
pagination={{ pageSize: 8, showSizeChanger: false }}
rowClassName={(record) => (record.conflict ? "owner-transfer-row-conflict" : "")}
/>
),
},
{
key: "channels",
label: `${t("ownerTransfer.transferChannels")} (${preview.channelExpansionCount})`,
children: (
<Table
rowKey="id"
size="middle"
dataSource={filteredChannels}
columns={channelColumns}
rowSelection={channelRowSelection}
locale={{ emptyText: t("ownerTransfer.noItems") }}
pagination={{ pageSize: 8, showSizeChanger: false }}
/>
),
},
]}
/>
{activeTab === "sales" && preview.salesConflicts.length ? (
<Card size="small" title={t("ownerTransfer.salesConflictListTitle")} className="owner-transfer-conflict-card">
<Table
rowKey={(record) => `${record.employeeNo}-${record.candidateName}`}
pagination={false}
size="small"
dataSource={preview.salesConflicts}
columns={[
{ title: t("ownerTransfer.conflictEmployeeNo"), dataIndex: "employeeNo" },
{ title: t("ownerTransfer.conflictCandidateName"), dataIndex: "candidateName" },
]}
/>
</Card>
) : null}
</Space>
)}
</Card>
</Col>
</Row>
</div>
);
}

View File

@ -0,0 +1,50 @@
export interface OwnerTransferConflict {
employeeNo: string;
candidateName: string;
}
export interface OwnerTransferItem {
id: number;
name: string;
code?: string;
conflict: boolean;
}
export interface OwnerTransferPreview {
tenantId: number;
fromUserId: number;
fromUserName: string;
toUserId: number;
toUserName: string;
opportunityCount: number;
salesExpansionCount: number;
channelExpansionCount: number;
salesConflicts: OwnerTransferConflict[];
opportunities: OwnerTransferItem[];
salesExpansions: OwnerTransferItem[];
channelExpansions: OwnerTransferItem[];
}
export interface OwnerTransferSelection {
opportunityIds?: number[];
salesExpansionIds?: number[];
channelExpansionIds?: number[];
}
export interface OwnerTransferPayload {
fromUserId: number;
toUserId: number;
transferOpportunities: boolean;
transferSalesExpansions: boolean;
transferChannelExpansions: boolean;
selection?: OwnerTransferSelection;
}
export interface OwnerTransferResult {
tenantId: number;
fromUserId: number;
toUserId: number;
transferredOpportunityCount: number;
transferredSalesExpansionCount: number;
transferredChannelExpansionCount: number;
}

View File

@ -5,6 +5,8 @@ import zhCN from './locales/zh-CN.json';
import enUS from './locales/en-US.json';
import reportReminderZhCN from './features/report-reminder/locales/zh-CN';
import reportReminderEnUS from './features/report-reminder/locales/en-US';
import ownerTransferZhCN from './features/owner-transfer/locales/zh-CN';
import ownerTransferEnUS from './features/owner-transfer/locales/en-US';
i18n
.use(LanguageDetector)
@ -15,12 +17,14 @@ i18n
translation: {
...zhCN,
...reportReminderZhCN,
...ownerTransferZhCN,
},
},
'en-US': {
translation: {
...enUS,
...reportReminderEnUS,
...ownerTransferEnUS,
},
},
},

View File

@ -14,6 +14,7 @@ const PlatformSettings = lazy(() => import("@/pages/system/platform-settings"));
const Dictionaries = lazy(() => import("@/pages/system/dictionaries"));
const Logs = lazy(() => import("@/pages/system/logs"));
const ReportReminderSettings = lazy(() => import("@/features/report-reminder"));
const OwnerTransfer = lazy(() => import("@/features/owner-transfer"));
const Devices = lazy(() => import("@/pages/devices"));
const UserRoleBinding = lazy(() => import("@/pages/bindings/user-role"));
const RolePermissionBinding = lazy(() => import("@/pages/bindings/role-permission"));
@ -43,6 +44,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/dictionaries", label: "字典管理", element: <LazyPage><Dictionaries /></LazyPage>, perm: "menu:dict" },
{ path: "/logs", label: "日志管理", element: <LazyPage><Logs /></LazyPage>, perm: "menu:logs" },
{ path: "/report-reminder-settings", label: "日报提醒设置", element: <LazyPage><ReportReminderSettings /></LazyPage>, perm: "menu:report-reminder-settings" },
{ path: "/owner-transfer", label: "归属人转移", element: <LazyPage><OwnerTransfer /></LazyPage>, perm: "menu:owner-transfer" },
{ path: "/devices", label: "设备管理", element: <LazyPage><Devices /></LazyPage>, perm: "menu:devices" },
{ path: "/user-roles", label: "用户角色绑定", element: <LazyPage><UserRoleBinding /></LazyPage>, perm: "menu:user-roles" },
{ path: "/role-permissions", label: "角色权限绑定", element: <LazyPage><RolePermissionBinding /></LazyPage>, perm: "menu:role-permissions" }

View File

@ -0,0 +1,291 @@
begin;
set search_path to public;
do $$
declare
v_system_parent_id bigint;
v_menu_perm_id bigint;
v_view_perm_id bigint;
v_execute_perm_id bigint;
v_has_role_permission_tenant boolean;
begin
-- Align sequences before any upsert-style inserts so the script stays rerunnable.
perform setval('sys_permission_perm_id_seq', coalesce((select max(perm_id) from sys_permission), 0) + 1, false);
perform setval('sys_role_permission_id_seq', coalesce((select max(id) from sys_role_permission), 0) + 1, false);
select exists (
select 1
from information_schema.columns
where table_schema = current_schema()
and table_name = 'sys_role_permission'
and column_name = 'tenant_id'
) into v_has_role_permission_tenant;
select perm_id
into v_system_parent_id
from sys_permission
where code = 'system'
and coalesce(is_deleted, 0) = 0
order by perm_id
limit 1;
if v_system_parent_id is null then
insert into sys_permission (
parent_id, name, code, perm_type, level, path, component, icon,
sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
) values (
null, '系统管理', 'system', 'directory', 1, null, null, 'SettingOutlined',
110, 1, 1, '系统管理目录', '{}'::jsonb, 0, now(), now()
)
returning perm_id into v_system_parent_id;
end if;
select perm_id
into v_menu_perm_id
from sys_permission
where code = 'menu:owner-transfer'
order by perm_id
limit 1;
if v_menu_perm_id is null then
insert into sys_permission (
parent_id, name, code, perm_type, level, path, component, icon,
sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
) values (
v_system_parent_id, '归属人转移', 'menu:owner-transfer', 'menu', 2,
'/owner-transfer', null, 'SwapOutlined', 7, 1, 1,
'管理员批量转移商机、拓展销售人员、拓展渠道归属人的页面', jsonb_build_object('tenantScoped', true), 0, now(), now()
)
returning perm_id into v_menu_perm_id;
else
update sys_permission
set parent_id = v_system_parent_id,
name = '归属人转移',
perm_type = 'menu',
level = 2,
path = '/owner-transfer',
component = null,
icon = 'SwapOutlined',
sort_order = 7,
is_visible = 1,
status = 1,
description = '管理员批量转移商机、拓展销售人员、拓展渠道归属人的页面',
meta = jsonb_build_object('tenantScoped', true),
is_deleted = 0,
updated_at = now()
where perm_id = v_menu_perm_id;
end if;
select perm_id
into v_view_perm_id
from sys_permission
where code = 'owner_transfer:view'
order by perm_id
limit 1;
if v_view_perm_id is null then
insert into sys_permission (
parent_id, name, code, perm_type, level, path, component, icon,
sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
) values (
v_menu_perm_id, '查看归属人转移', 'owner_transfer:view', 'button', 3,
null, null, null, 1, 1, 1, '查看归属人转移页面和预检结果', '{}'::jsonb, 0, now(), now()
)
returning perm_id into v_view_perm_id;
else
update sys_permission
set parent_id = v_menu_perm_id,
name = '查看归属人转移',
perm_type = 'button',
level = 3,
sort_order = 1,
is_visible = 1,
status = 1,
description = '查看归属人转移页面和预检结果',
meta = '{}'::jsonb,
is_deleted = 0,
updated_at = now()
where perm_id = v_view_perm_id;
end if;
select perm_id
into v_execute_perm_id
from sys_permission
where code = 'owner_transfer:execute'
order by perm_id
limit 1;
if v_execute_perm_id is null then
insert into sys_permission (
parent_id, name, code, perm_type, level, path, component, icon,
sort_order, is_visible, status, description, meta, is_deleted, created_at, updated_at
) values (
v_menu_perm_id, '执行归属人转移', 'owner_transfer:execute', 'button', 3,
null, null, null, 2, 1, 1, '执行商机、拓展销售人员、拓展渠道归属人转移', '{}'::jsonb, 0, now(), now()
)
returning perm_id into v_execute_perm_id;
else
update sys_permission
set parent_id = v_menu_perm_id,
name = '执行归属人转移',
perm_type = 'button',
level = 3,
sort_order = 2,
is_visible = 1,
status = 1,
description = '执行商机、拓展销售人员、拓展渠道归属人转移',
meta = '{}'::jsonb,
is_deleted = 0,
updated_at = now()
where perm_id = v_execute_perm_id;
end if;
-- Grant the menu to:
-- 1. Built-in admin/platform roles
-- 2. Any role currently held by username = 'admin' (fallback for custom role naming)
if v_has_role_permission_tenant then
insert into sys_role_permission (role_id, perm_id, tenant_id, is_deleted, created_at, updated_at)
select
role_source.role_id,
perm_source.perm_id,
role_source.tenant_id,
0,
now(),
now()
from (
select distinct role_id, tenant_id
from (
select r.role_id, r.tenant_id
from sys_role r
where coalesce(r.is_deleted, 0) = 0
and (
r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
or r.role_name ilike '%管理员%'
or r.role_name ilike '%admin%'
)
union
select r.role_id, r.tenant_id
from sys_user u
join sys_user_role ur
on ur.user_id = u.user_id
and coalesce(ur.is_deleted, 0) = 0
join sys_role r
on r.role_id = ur.role_id
and coalesce(r.is_deleted, 0) = 0
where coalesce(u.is_deleted, 0) = 0
and u.username = 'admin'
) granted_roles
) role_source
cross join (
select unnest(array[v_menu_perm_id, v_view_perm_id, v_execute_perm_id]) as perm_id
) perm_source
where perm_source.perm_id is not null
and not exists (
select 1
from sys_role_permission rp
where rp.role_id = role_source.role_id
and rp.perm_id = perm_source.perm_id
);
update sys_role_permission rp
set tenant_id = coalesce(rp.tenant_id, r.tenant_id),
is_deleted = 0,
updated_at = now()
from sys_role r,
sys_permission p
where rp.role_id = r.role_id
and p.perm_id = rp.perm_id
and coalesce(r.is_deleted, 0) = 0
and p.code in ('menu:owner-transfer', 'owner_transfer:view', 'owner_transfer:execute')
and (
r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
or r.role_name ilike '%管理员%'
or r.role_name ilike '%admin%'
or exists (
select 1
from sys_user u
join sys_user_role ur
on ur.user_id = u.user_id
and coalesce(ur.is_deleted, 0) = 0
where coalesce(u.is_deleted, 0) = 0
and u.username = 'admin'
and ur.role_id = r.role_id
)
);
else
insert into sys_role_permission (role_id, perm_id, is_deleted, created_at, updated_at)
select role_source.role_id, perm_source.perm_id, 0, now(), now()
from (
select distinct role_id
from (
select r.role_id
from sys_role r
where coalesce(r.is_deleted, 0) = 0
and (
r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
or r.role_name ilike '%管理员%'
or r.role_name ilike '%admin%'
)
union
select r.role_id
from sys_user u
join sys_user_role ur
on ur.user_id = u.user_id
and coalesce(ur.is_deleted, 0) = 0
join sys_role r
on r.role_id = ur.role_id
and coalesce(r.is_deleted, 0) = 0
where coalesce(u.is_deleted, 0) = 0
and u.username = 'admin'
) granted_roles
) role_source
cross join (
select unnest(array[v_menu_perm_id, v_view_perm_id, v_execute_perm_id]) as perm_id
) perm_source
where perm_source.perm_id is not null
and not exists (
select 1
from sys_role_permission rp
where rp.role_id = role_source.role_id
and rp.perm_id = perm_source.perm_id
);
update sys_role_permission
set is_deleted = 0,
updated_at = now()
where perm_id in (v_menu_perm_id, v_view_perm_id, v_execute_perm_id)
and role_id in (
select distinct role_id
from (
select r.role_id
from sys_role r
where coalesce(r.is_deleted, 0) = 0
and (
r.role_code in ('TENANT_ADMIN', 'ADMIN', 'SYS_ADMIN', 'PLATFORM_ADMIN', 'SUPER_ADMIN')
or r.role_name ilike '%管理员%'
or r.role_name ilike '%admin%'
)
union
select r.role_id
from sys_user u
join sys_user_role ur
on ur.user_id = u.user_id
and coalesce(ur.is_deleted, 0) = 0
join sys_role r
on r.role_id = ur.role_id
and coalesce(r.is_deleted, 0) = 0
where coalesce(u.is_deleted, 0) = 0
and u.username = 'admin'
) granted_roles
);
end if;
end $$;
commit;