main
parent
f23ef0a712
commit
8a7ee5f066
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.unis.crm;
|
||||||
import com.unis.crm.config.WecomProperties;
|
import com.unis.crm.config.WecomProperties;
|
||||||
import com.unis.crm.config.InternalAuthProperties;
|
import com.unis.crm.config.InternalAuthProperties;
|
||||||
import com.unis.crm.config.OmsProperties;
|
import com.unis.crm.config.OmsProperties;
|
||||||
|
import com.unis.crm.config.WorkReportProperties;
|
||||||
import java.util.TimeZone;
|
import java.util.TimeZone;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
|
|
@ -12,7 +13,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication(scanBasePackages = "com.unis.crm")
|
@SpringBootApplication(scanBasePackages = "com.unis.crm")
|
||||||
@MapperScan({"com.unis.crm.mapper", "com.unis.crm.llm.mapper"})
|
@MapperScan({"com.unis.crm.mapper", "com.unis.crm.llm.mapper"})
|
||||||
@EnableConfigurationProperties({WecomProperties.class, OmsProperties.class, InternalAuthProperties.class})
|
@EnableConfigurationProperties({WecomProperties.class, OmsProperties.class, InternalAuthProperties.class, WorkReportProperties.class})
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
public class UnisCrmBackendApplication {
|
public class UnisCrmBackendApplication {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
package com.unis.crm.config;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "unisbase.app.work-report")
|
||||||
|
public class WorkReportProperties {
|
||||||
|
|
||||||
|
private DailyReport dailyReport = new DailyReport();
|
||||||
|
|
||||||
|
public DailyReport getDailyReport() {
|
||||||
|
return dailyReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDailyReport(DailyReport dailyReport) {
|
||||||
|
this.dailyReport = dailyReport == null ? new DailyReport() : dailyReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DailyReport {
|
||||||
|
private boolean requiredByDefault = true;
|
||||||
|
private List<String> requiredRoleCodes = new ArrayList<>();
|
||||||
|
private List<String> optionalRoleCodes = new ArrayList<>();
|
||||||
|
private FeatureVisibility notifyUsers = new FeatureVisibility();
|
||||||
|
private FeatureVisibility attachments = new FeatureVisibility();
|
||||||
|
|
||||||
|
public boolean isRequiredByDefault() {
|
||||||
|
return requiredByDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequiredByDefault(boolean requiredByDefault) {
|
||||||
|
this.requiredByDefault = requiredByDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getRequiredRoleCodes() {
|
||||||
|
return requiredRoleCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequiredRoleCodes(List<String> requiredRoleCodes) {
|
||||||
|
this.requiredRoleCodes = requiredRoleCodes == null ? new ArrayList<>() : requiredRoleCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getOptionalRoleCodes() {
|
||||||
|
return optionalRoleCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOptionalRoleCodes(List<String> optionalRoleCodes) {
|
||||||
|
this.optionalRoleCodes = optionalRoleCodes == null ? new ArrayList<>() : optionalRoleCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FeatureVisibility getNotifyUsers() {
|
||||||
|
return notifyUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNotifyUsers(FeatureVisibility notifyUsers) {
|
||||||
|
this.notifyUsers = notifyUsers == null ? new FeatureVisibility() : notifyUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FeatureVisibility getAttachments() {
|
||||||
|
return attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAttachments(FeatureVisibility attachments) {
|
||||||
|
this.attachments = attachments == null ? new FeatureVisibility() : attachments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class FeatureVisibility {
|
||||||
|
private boolean visibleByDefault = true;
|
||||||
|
private List<String> visibleRoleCodes = new ArrayList<>();
|
||||||
|
private List<String> hiddenRoleCodes = new ArrayList<>();
|
||||||
|
|
||||||
|
public boolean isVisibleByDefault() {
|
||||||
|
return visibleByDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVisibleByDefault(boolean visibleByDefault) {
|
||||||
|
this.visibleByDefault = visibleByDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getVisibleRoleCodes() {
|
||||||
|
return visibleRoleCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVisibleRoleCodes(List<String> visibleRoleCodes) {
|
||||||
|
this.visibleRoleCodes = visibleRoleCodes == null ? new ArrayList<>() : visibleRoleCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getHiddenRoleCodes() {
|
||||||
|
return hiddenRoleCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHiddenRoleCodes(List<String> hiddenRoleCodes) {
|
||||||
|
this.hiddenRoleCodes = hiddenRoleCodes == null ? new ArrayList<>() : hiddenRoleCodes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
||||||
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
||||||
import com.unis.crm.dto.work.WorkCheckInExportDTO;
|
import com.unis.crm.dto.work.WorkCheckInExportDTO;
|
||||||
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
|
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
|
||||||
|
import com.unis.crm.dto.work.WorkDailyReportRequirementDTO;
|
||||||
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
||||||
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
||||||
import com.unis.crm.dto.work.WorkOverviewDTO;
|
import com.unis.crm.dto.work.WorkOverviewDTO;
|
||||||
|
|
@ -52,6 +53,11 @@ public class WorkController {
|
||||||
return ApiResponse.success(workService.getOverview(CurrentUserUtils.requireCurrentUserId(userId)));
|
return ApiResponse.success(workService.getOverview(CurrentUserUtils.requireCurrentUserId(userId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/daily-report-requirement")
|
||||||
|
public ApiResponse<WorkDailyReportRequirementDTO> getDailyReportRequirement(@RequestHeader("X-User-Id") Long userId) {
|
||||||
|
return ApiResponse.success(workService.getDailyReportRequirement(CurrentUserUtils.requireCurrentUserId(userId)));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/report-message-users")
|
@GetMapping("/report-message-users")
|
||||||
public ApiResponse<List<WorkReportToUserDTO>> listReportMessageUsers(
|
public ApiResponse<List<WorkReportToUserDTO>> listReportMessageUsers(
|
||||||
@RequestHeader("X-User-Id") Long userId,
|
@RequestHeader("X-User-Id") Long userId,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package com.unis.crm.dto.work;
|
package com.unis.crm.dto.work;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
@ -9,11 +7,9 @@ import java.util.List;
|
||||||
|
|
||||||
public class CreateWorkDailyReportRequest {
|
public class CreateWorkDailyReportRequest {
|
||||||
|
|
||||||
@NotBlank(message = "今日工作内容不能为空")
|
|
||||||
@Size(max = 4000, message = "今日工作内容不能超过4000字符")
|
@Size(max = 4000, message = "今日工作内容不能超过4000字符")
|
||||||
private String workContent;
|
private String workContent;
|
||||||
|
|
||||||
@NotEmpty(message = "请至少填写一条今日工作内容")
|
|
||||||
@Valid
|
@Valid
|
||||||
private List<WorkReportLineItemRequest> lineItems = new ArrayList<>();
|
private List<WorkReportLineItemRequest> lineItems = new ArrayList<>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.unis.crm.dto.work;
|
||||||
|
|
||||||
|
public class WorkDailyReportRequirementDTO {
|
||||||
|
|
||||||
|
private boolean objectSelectionRequired;
|
||||||
|
private boolean notifyUsersVisible;
|
||||||
|
private boolean attachmentsVisible;
|
||||||
|
|
||||||
|
public WorkDailyReportRequirementDTO() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkDailyReportRequirementDTO(boolean objectSelectionRequired, boolean notifyUsersVisible, boolean attachmentsVisible) {
|
||||||
|
this.objectSelectionRequired = objectSelectionRequired;
|
||||||
|
this.notifyUsersVisible = notifyUsersVisible;
|
||||||
|
this.attachmentsVisible = attachmentsVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isObjectSelectionRequired() {
|
||||||
|
return objectSelectionRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setObjectSelectionRequired(boolean objectSelectionRequired) {
|
||||||
|
this.objectSelectionRequired = objectSelectionRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNotifyUsersVisible() {
|
||||||
|
return notifyUsersVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNotifyUsersVisible(boolean notifyUsersVisible) {
|
||||||
|
this.notifyUsersVisible = notifyUsersVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAttachmentsVisible() {
|
||||||
|
return attachmentsVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAttachmentsVisible(boolean attachmentsVisible) {
|
||||||
|
this.attachmentsVisible = attachmentsVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.unis.crm.dto.work;
|
package com.unis.crm.dto.work;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
@ -8,10 +7,8 @@ import java.util.List;
|
||||||
|
|
||||||
public class WorkReportLineItemRequest {
|
public class WorkReportLineItemRequest {
|
||||||
|
|
||||||
@NotBlank(message = "工作日期不能为空")
|
|
||||||
private String workDate;
|
private String workDate;
|
||||||
|
|
||||||
@NotBlank(message = "跟进对象类型不能为空")
|
|
||||||
private String bizType;
|
private String bizType;
|
||||||
|
|
||||||
private Long bizId;
|
private Long bizId;
|
||||||
|
|
@ -19,11 +16,9 @@ public class WorkReportLineItemRequest {
|
||||||
@Size(max = 200, message = "对象名称不能超过200字符")
|
@Size(max = 200, message = "对象名称不能超过200字符")
|
||||||
private String bizName;
|
private String bizName;
|
||||||
|
|
||||||
@NotBlank(message = "日报内容不能为空")
|
|
||||||
@Size(max = 4000, message = "日报内容不能超过4000字符")
|
@Size(max = 4000, message = "日报内容不能超过4000字符")
|
||||||
private String editorText;
|
private String editorText;
|
||||||
|
|
||||||
@NotBlank(message = "工作内容不能为空")
|
|
||||||
@Size(max = 1000, message = "工作内容不能超过1000字符")
|
@Size(max = 1000, message = "工作内容不能超过1000字符")
|
||||||
private String content;
|
private String content;
|
||||||
private String visitStartTime;
|
private String visitStartTime;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import com.unis.crm.dto.work.WorkDailyReportExportDTO;
|
||||||
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
||||||
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
||||||
import com.unis.crm.dto.work.WorkOverviewDTO;
|
import com.unis.crm.dto.work.WorkOverviewDTO;
|
||||||
|
import com.unis.crm.dto.work.WorkDailyReportRequirementDTO;
|
||||||
import com.unis.crm.dto.work.WorkReportAttachmentDTO;
|
import com.unis.crm.dto.work.WorkReportAttachmentDTO;
|
||||||
import com.unis.crm.dto.work.WorkReportToUserDTO;
|
import com.unis.crm.dto.work.WorkReportToUserDTO;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
|
@ -19,6 +20,8 @@ public interface WorkService {
|
||||||
|
|
||||||
WorkOverviewDTO getOverview(Long userId);
|
WorkOverviewDTO getOverview(Long userId);
|
||||||
|
|
||||||
|
WorkDailyReportRequirementDTO getDailyReportRequirement(Long userId);
|
||||||
|
|
||||||
List<WorkReportToUserDTO> listReportMessageUsers(Long userId, String keyword);
|
List<WorkReportToUserDTO> listReportMessageUsers(Long userId, String keyword);
|
||||||
|
|
||||||
WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size);
|
WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size);
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ package com.unis.crm.service.impl;
|
||||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.unis.crm.common.BusinessException;
|
import com.unis.crm.common.BusinessException;
|
||||||
|
import com.unis.crm.config.WorkReportProperties;
|
||||||
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
||||||
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
||||||
import com.unis.crm.dto.work.WorkCheckInDTO;
|
import com.unis.crm.dto.work.WorkCheckInDTO;
|
||||||
import com.unis.crm.dto.work.WorkCheckInExportDTO;
|
import com.unis.crm.dto.work.WorkCheckInExportDTO;
|
||||||
import com.unis.crm.dto.work.WorkDailyReportDTO;
|
import com.unis.crm.dto.work.WorkDailyReportDTO;
|
||||||
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
|
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
|
||||||
|
import com.unis.crm.dto.work.WorkDailyReportRequirementDTO;
|
||||||
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
||||||
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
||||||
import com.unis.crm.dto.work.WorkReportLineItemDTO;
|
import com.unis.crm.dto.work.WorkReportLineItemDTO;
|
||||||
|
|
@ -87,7 +89,7 @@ public class WorkServiceImpl implements WorkService {
|
||||||
private static final String OPPORTUNITY_STAGE_LABEL = "项目阶段";
|
private static final String OPPORTUNITY_STAGE_LABEL = "项目阶段";
|
||||||
private static final String OPPORTUNITY_STAGE_TYPE_CODE = "sj_xmjd";
|
private static final String OPPORTUNITY_STAGE_TYPE_CODE = "sj_xmjd";
|
||||||
private static final int EXPORT_LIMIT = 5000;
|
private static final int EXPORT_LIMIT = 5000;
|
||||||
private static final long REPORT_ATTACHMENT_MAX_SIZE_BYTES = 20L * 1024 * 1024;
|
private static final long REPORT_ATTACHMENT_MAX_SIZE_BYTES = 500L * 1024 * 1024;
|
||||||
private static final String TENCENT_COORD_TYPE_GPS = "1";
|
private static final String TENCENT_COORD_TYPE_GPS = "1";
|
||||||
private static final String TENCENT_COORD_TYPE_GCJ02 = "2";
|
private static final String TENCENT_COORD_TYPE_GCJ02 = "2";
|
||||||
private static final String OPEN_STREET_MAP_USER_AGENT = "unis-crm/1.0";
|
private static final String OPEN_STREET_MAP_USER_AGENT = "unis-crm/1.0";
|
||||||
|
|
@ -104,6 +106,7 @@ public class WorkServiceImpl implements WorkService {
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final UnisBaseTenantProvider tenantProvider;
|
private final UnisBaseTenantProvider tenantProvider;
|
||||||
private final ReportReminderService reportReminderService;
|
private final ReportReminderService reportReminderService;
|
||||||
|
private final WorkReportProperties workReportProperties;
|
||||||
private final Path checkInPhotoDirectory;
|
private final Path checkInPhotoDirectory;
|
||||||
private final Path reportAttachmentDirectory;
|
private final Path reportAttachmentDirectory;
|
||||||
private final String tencentMapKey;
|
private final String tencentMapKey;
|
||||||
|
|
@ -114,6 +117,7 @@ public class WorkServiceImpl implements WorkService {
|
||||||
OpportunityMapper opportunityMapper,
|
OpportunityMapper opportunityMapper,
|
||||||
ProfileMapper profileMapper,
|
ProfileMapper profileMapper,
|
||||||
ReportReminderService reportReminderService,
|
ReportReminderService reportReminderService,
|
||||||
|
WorkReportProperties workReportProperties,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
UnisBaseTenantProvider tenantProvider,
|
UnisBaseTenantProvider tenantProvider,
|
||||||
@Value("${unisbase.app.upload-path}") String uploadPath,
|
@Value("${unisbase.app.upload-path}") String uploadPath,
|
||||||
|
|
@ -122,6 +126,7 @@ public class WorkServiceImpl implements WorkService {
|
||||||
this.opportunityMapper = opportunityMapper;
|
this.opportunityMapper = opportunityMapper;
|
||||||
this.profileMapper = profileMapper;
|
this.profileMapper = profileMapper;
|
||||||
this.reportReminderService = reportReminderService;
|
this.reportReminderService = reportReminderService;
|
||||||
|
this.workReportProperties = workReportProperties == null ? new WorkReportProperties() : workReportProperties;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.tenantProvider = tenantProvider;
|
this.tenantProvider = tenantProvider;
|
||||||
this.httpClient = HttpClient.newBuilder()
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
|
@ -146,9 +151,22 @@ public class WorkServiceImpl implements WorkService {
|
||||||
@Override
|
@Override
|
||||||
public List<WorkReportToUserDTO> listReportMessageUsers(Long userId, String keyword) {
|
public List<WorkReportToUserDTO> listReportMessageUsers(Long userId, String keyword) {
|
||||||
requireUser(userId);
|
requireUser(userId);
|
||||||
|
if (!isDailyReportNotifyUsersVisible(loadUserRoleCodes(userId))) {
|
||||||
|
throw new BusinessException("当前角色不可使用日报通知用户功能");
|
||||||
|
}
|
||||||
return workMapper.selectReportMessageUsers(resolveCurrentTenantId(), normalizeOptionalText(keyword), 50);
|
return workMapper.selectReportMessageUsers(resolveCurrentTenantId(), normalizeOptionalText(keyword), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WorkDailyReportRequirementDTO getDailyReportRequirement(Long userId) {
|
||||||
|
requireUser(userId);
|
||||||
|
List<String> roleCodes = loadUserRoleCodes(userId);
|
||||||
|
return new WorkDailyReportRequirementDTO(
|
||||||
|
isDailyReportObjectSelectionRequired(roleCodes),
|
||||||
|
isDailyReportNotifyUsersVisible(roleCodes),
|
||||||
|
isDailyReportAttachmentsVisible(roleCodes));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size) {
|
public WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size) {
|
||||||
requireUser(userId);
|
requireUser(userId);
|
||||||
|
|
@ -272,9 +290,12 @@ public class WorkServiceImpl implements WorkService {
|
||||||
@Transactional
|
@Transactional
|
||||||
public Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request) {
|
public Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request) {
|
||||||
requireUser(userId);
|
requireUser(userId);
|
||||||
ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
|
List<String> roleCodes = ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
|
||||||
normalizeDailyReportRequest(request);
|
normalizeDailyReportRequest(
|
||||||
request.setTomorrowPlan(request.getTomorrowPlan().trim());
|
request,
|
||||||
|
isDailyReportObjectSelectionRequired(roleCodes),
|
||||||
|
isDailyReportNotifyUsersVisible(roleCodes),
|
||||||
|
isDailyReportAttachmentsVisible(roleCodes));
|
||||||
request.setSourceType(normalizeOptionalText(request.getSourceType()));
|
request.setSourceType(normalizeOptionalText(request.getSourceType()));
|
||||||
if (request.getSourceType() == null) {
|
if (request.getSourceType() == null) {
|
||||||
request.setSourceType("manual");
|
request.setSourceType("manual");
|
||||||
|
|
@ -405,12 +426,15 @@ public class WorkServiceImpl implements WorkService {
|
||||||
@Override
|
@Override
|
||||||
public WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file) {
|
public WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file) {
|
||||||
requireUser(userId);
|
requireUser(userId);
|
||||||
ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
|
List<String> roleCodes = ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
|
||||||
|
if (!isDailyReportAttachmentsVisible(roleCodes)) {
|
||||||
|
throw new BusinessException("当前角色不可使用日报附件功能");
|
||||||
|
}
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new BusinessException("请先选择附件");
|
throw new BusinessException("请先选择附件");
|
||||||
}
|
}
|
||||||
if (file.getSize() > REPORT_ATTACHMENT_MAX_SIZE_BYTES) {
|
if (file.getSize() > REPORT_ATTACHMENT_MAX_SIZE_BYTES) {
|
||||||
throw new BusinessException("单个附件不能超过20MB");
|
throw new BusinessException("单个附件不能超过500MB");
|
||||||
}
|
}
|
||||||
|
|
||||||
String originalName = normalizeAttachmentName(file.getOriginalFilename());
|
String originalName = normalizeAttachmentName(file.getOriginalFilename());
|
||||||
|
|
@ -471,15 +495,77 @@ public class WorkServiceImpl implements WorkService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureWorkWriteAllowed(Long userId, String deniedMessage) {
|
private List<String> ensureWorkWriteAllowed(Long userId, String deniedMessage) {
|
||||||
List<String> roleCodes = profileMapper.selectUserRoleCodes(userId);
|
List<String> roleCodes = loadUserRoleCodes(userId);
|
||||||
boolean onlySee = roleCodes != null && roleCodes.stream()
|
boolean onlySee = roleCodes != null && roleCodes.stream()
|
||||||
.filter(roleCode -> roleCode != null && !roleCode.isBlank())
|
|
||||||
.map(roleCode -> roleCode.trim().toLowerCase())
|
|
||||||
.anyMatch(ONLY_SEE_ROLE_CODE::equals);
|
.anyMatch(ONLY_SEE_ROLE_CODE::equals);
|
||||||
if (onlySee) {
|
if (onlySee) {
|
||||||
throw new BusinessException(deniedMessage);
|
throw new BusinessException(deniedMessage);
|
||||||
}
|
}
|
||||||
|
return roleCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> loadUserRoleCodes(Long userId) {
|
||||||
|
List<String> roleCodes = profileMapper.selectUserRoleCodes(userId);
|
||||||
|
if (roleCodes == null || roleCodes.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return roleCodes.stream()
|
||||||
|
.map(this::normalizeRoleCode)
|
||||||
|
.filter(roleCode -> roleCode != null)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDailyReportObjectSelectionRequired(List<String> roleCodes) {
|
||||||
|
WorkReportProperties.DailyReport dailyReport = workReportProperties.getDailyReport();
|
||||||
|
if (matchesConfiguredRole(roleCodes, dailyReport.getRequiredRoleCodes())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (matchesConfiguredRole(roleCodes, dailyReport.getOptionalRoleCodes())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return dailyReport.isRequiredByDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDailyReportNotifyUsersVisible(List<String> roleCodes) {
|
||||||
|
return isFeatureVisible(roleCodes, workReportProperties.getDailyReport().getNotifyUsers());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDailyReportAttachmentsVisible(List<String> roleCodes) {
|
||||||
|
return isFeatureVisible(roleCodes, workReportProperties.getDailyReport().getAttachments());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isFeatureVisible(List<String> roleCodes, WorkReportProperties.FeatureVisibility visibility) {
|
||||||
|
WorkReportProperties.FeatureVisibility safeVisibility = visibility == null
|
||||||
|
? new WorkReportProperties.FeatureVisibility()
|
||||||
|
: visibility;
|
||||||
|
if (matchesConfiguredRole(roleCodes, safeVisibility.getHiddenRoleCodes())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (matchesConfiguredRole(roleCodes, safeVisibility.getVisibleRoleCodes())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return safeVisibility.isVisibleByDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesConfiguredRole(List<String> userRoleCodes, List<String> configuredRoleCodes) {
|
||||||
|
if (userRoleCodes == null || userRoleCodes.isEmpty() || configuredRoleCodes == null || configuredRoleCodes.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
List<String> normalizedConfiguredRoles = configuredRoleCodes.stream()
|
||||||
|
.map(this::normalizeRoleCode)
|
||||||
|
.filter(roleCode -> roleCode != null)
|
||||||
|
.toList();
|
||||||
|
if (normalizedConfiguredRoles.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return userRoleCodes.stream().anyMatch(normalizedConfiguredRoles::contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRoleCode(String roleCode) {
|
||||||
|
String normalized = normalizeOptionalText(roleCode);
|
||||||
|
return normalized == null ? null : normalized.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<WorkHistoryItemDTO> loadHistoryItems(HistoryType historyType, int fetchLimit) {
|
private List<WorkHistoryItemDTO> loadHistoryItems(HistoryType historyType, int fetchLimit) {
|
||||||
|
|
@ -1056,9 +1142,14 @@ public class WorkServiceImpl implements WorkService {
|
||||||
return new PlanItemMetadata(buffer.toString().trim(), planItems);
|
return new PlanItemMetadata(buffer.toString().trim(), planItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void normalizeDailyReportRequest(CreateWorkDailyReportRequest request) {
|
private void normalizeDailyReportRequest(
|
||||||
|
CreateWorkDailyReportRequest request,
|
||||||
|
boolean objectSelectionRequired,
|
||||||
|
boolean notifyUsersVisible,
|
||||||
|
boolean attachmentsVisible) {
|
||||||
List<WorkReportLineItemRequest> normalizedItems = new ArrayList<>();
|
List<WorkReportLineItemRequest> normalizedItems = new ArrayList<>();
|
||||||
for (WorkReportLineItemRequest item : request.getLineItems()) {
|
List<WorkReportLineItemRequest> lineItems = request.getLineItems() == null ? List.of() : request.getLineItems();
|
||||||
|
for (WorkReportLineItemRequest item : lineItems) {
|
||||||
if (item == null) {
|
if (item == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -1073,18 +1164,26 @@ public class WorkServiceImpl implements WorkService {
|
||||||
item.setStage(normalizeOptionalText(item.getStage()));
|
item.setStage(normalizeOptionalText(item.getStage()));
|
||||||
item.setCommunicationTime(normalizeOptionalText(item.getCommunicationTime()));
|
item.setCommunicationTime(normalizeOptionalText(item.getCommunicationTime()));
|
||||||
item.setCommunicationContent(normalizeOptionalText(item.getCommunicationContent()));
|
item.setCommunicationContent(normalizeOptionalText(item.getCommunicationContent()));
|
||||||
item.setAttachments(normalizeReportAttachments(item.getAttachments()));
|
item.setAttachments(attachmentsVisible ? normalizeReportAttachments(item.getAttachments()) : List.of());
|
||||||
item.setToUsers(normalizeReportToUsers(item.getToUsers()));
|
item.setToUsers(notifyUsersVisible ? normalizeReportToUsers(item.getToUsers()) : List.of());
|
||||||
hydrateLineItemFromEditorText(item);
|
hydrateLineItemFromEditorText(item);
|
||||||
if (item.getWorkDate() == null || item.getContent() == null || item.getEditorText() == null) {
|
item.setContent(normalizeOptionalText(item.getContent()));
|
||||||
|
item.setEditorText(normalizeOptionalText(item.getEditorText()));
|
||||||
|
if (item.getWorkDate() == null || item.getContent() == null) {
|
||||||
throw new BusinessException("请完整填写每一条今日工作内容");
|
throw new BusinessException("请完整填写每一条今日工作内容");
|
||||||
}
|
}
|
||||||
|
if (objectSelectionRequired && !hasLinkedReportTarget(item)) {
|
||||||
|
throw new BusinessException("请使用 # 选择日报关联对象");
|
||||||
|
}
|
||||||
|
if (item.getEditorText() == null) {
|
||||||
|
item.setEditorText(item.getContent());
|
||||||
|
}
|
||||||
normalizedItems.add(item);
|
normalizedItems.add(item);
|
||||||
}
|
}
|
||||||
if (normalizedItems.isEmpty()) {
|
if (normalizedItems.isEmpty()) {
|
||||||
throw new BusinessException("请至少填写一条今日工作内容");
|
throw new BusinessException("请至少填写一条今日工作内容");
|
||||||
}
|
}
|
||||||
request.setAttachments(normalizeReportAttachments(request.getAttachments()));
|
request.setAttachments(attachmentsVisible ? normalizeReportAttachments(request.getAttachments()) : List.of());
|
||||||
List<WorkTomorrowPlanItemRequest> normalizedPlanItems = new ArrayList<>();
|
List<WorkTomorrowPlanItemRequest> normalizedPlanItems = new ArrayList<>();
|
||||||
List<WorkTomorrowPlanItemRequest> planItems = request.getPlanItems() == null ? List.of() : request.getPlanItems();
|
List<WorkTomorrowPlanItemRequest> planItems = request.getPlanItems() == null ? List.of() : request.getPlanItems();
|
||||||
for (WorkTomorrowPlanItemRequest item : planItems) {
|
for (WorkTomorrowPlanItemRequest item : planItems) {
|
||||||
|
|
@ -1099,9 +1198,8 @@ public class WorkServiceImpl implements WorkService {
|
||||||
}
|
}
|
||||||
request.setLineItems(normalizedItems);
|
request.setLineItems(normalizedItems);
|
||||||
request.setPlanItems(normalizedPlanItems);
|
request.setPlanItems(normalizedPlanItems);
|
||||||
request.setWorkContent(appendReportAttachmentMetadata(
|
String normalizedWorkContent = appendReportLineMetadata(buildReportPlainText(normalizedItems), normalizedItems);
|
||||||
appendReportLineMetadata(buildReportPlainText(normalizedItems), normalizedItems),
|
request.setWorkContent(appendReportAttachmentMetadata(normalizedWorkContent, request.getAttachments()));
|
||||||
request.getAttachments()));
|
|
||||||
request.setTomorrowPlan(normalizedPlanItems.isEmpty()
|
request.setTomorrowPlan(normalizedPlanItems.isEmpty()
|
||||||
? ""
|
? ""
|
||||||
: appendPlanItemMetadata(buildTomorrowPlanPlainText(normalizedPlanItems), normalizedPlanItems));
|
: appendPlanItemMetadata(buildTomorrowPlanPlainText(normalizedPlanItems), normalizedPlanItems));
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ spring:
|
||||||
name: unis-crm-backend
|
name: unis-crm-backend
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 20MB
|
max-file-size: 500MB
|
||||||
max-request-size: 25MB
|
max-request-size: 600MB
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://192.168.124.202:5432/unis_crm
|
url: jdbc:postgresql://192.168.124.202:5432/unis_crm
|
||||||
username: postgres
|
username: postgres
|
||||||
|
|
@ -81,3 +81,25 @@ unisbase:
|
||||||
pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前}
|
pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前}
|
||||||
connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5}
|
connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5}
|
||||||
read-timeout-seconds: ${OMS_READ_TIMEOUT_SECONDS:15}
|
read-timeout-seconds: ${OMS_READ_TIMEOUT_SECONDS:15}
|
||||||
|
work-report:
|
||||||
|
daily-report:
|
||||||
|
# 未命中下方任何角色配置时,是否默认要求日报使用 # 选择并关联对象。
|
||||||
|
required-by-default: false
|
||||||
|
# 明确要求使用 # 选择并关联对象的角色编码;命中后优先级高于 optional-role-codes。
|
||||||
|
required-role-codes: [crm_xs]
|
||||||
|
# 允许不使用 #,可直接填写文字日报的角色编码。
|
||||||
|
optional-role-codes: []
|
||||||
|
notify-users:
|
||||||
|
# 未命中下方任何角色配置时,是否默认显示“选择通知用户”按钮。
|
||||||
|
visible-by-default: true
|
||||||
|
# 明确显示“选择通知用户”按钮的角色编码。
|
||||||
|
visible-role-codes: []
|
||||||
|
# 明确隐藏“选择通知用户”按钮的角色编码;命中后优先级高于 visible-role-codes。
|
||||||
|
hidden-role-codes: []
|
||||||
|
attachments:
|
||||||
|
# 未命中下方任何角色配置时,是否默认显示日报附件功能。
|
||||||
|
visible-by-default: true
|
||||||
|
# 明确显示日报附件功能的角色编码。
|
||||||
|
visible-role-codes: []
|
||||||
|
# 明确隐藏日报附件功能的角色编码;命中后优先级高于 visible-role-codes。
|
||||||
|
hidden-role-codes: [crm_xs]
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ spring:
|
||||||
time-zone: Asia/Shanghai
|
time-zone: Asia/Shanghai
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 20MB
|
max-file-size: 500MB
|
||||||
max-request-size: 25MB
|
max-request-size: 600MB
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://127.0.0.1:5432/unis_crm
|
url: jdbc:postgresql://127.0.0.1:5432/unis_crm
|
||||||
username: postgres
|
username: postgres
|
||||||
|
|
@ -90,3 +90,25 @@ unisbase:
|
||||||
pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前}
|
pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前}
|
||||||
connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5}
|
connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5}
|
||||||
read-timeout-seconds: ${OMS_READ_TIMEOUT_SECONDS:15}
|
read-timeout-seconds: ${OMS_READ_TIMEOUT_SECONDS:15}
|
||||||
|
work-report:
|
||||||
|
daily-report:
|
||||||
|
# 未命中下方任何角色配置时,是否默认要求日报使用 # 选择并关联对象。
|
||||||
|
required-by-default: false
|
||||||
|
# 明确要求使用 # 选择并关联对象的角色编码;命中后优先级高于 optional-role-codes。
|
||||||
|
required-role-codes: [crm_xs]
|
||||||
|
# 允许不使用 #,可直接填写文字日报的角色编码。
|
||||||
|
optional-role-codes: []
|
||||||
|
notify-users:
|
||||||
|
# 未命中下方任何角色配置时,是否默认显示“选择通知用户”按钮。
|
||||||
|
visible-by-default: true
|
||||||
|
# 明确显示“选择通知用户”按钮的角色编码。
|
||||||
|
visible-role-codes: []
|
||||||
|
# 明确隐藏“选择通知用户”按钮的角色编码;命中后优先级高于 visible-role-codes。
|
||||||
|
hidden-role-codes: []
|
||||||
|
attachments:
|
||||||
|
# 未命中下方任何角色配置时,是否默认显示日报附件功能。
|
||||||
|
visible-by-default: true
|
||||||
|
# 明确显示日报附件功能的角色编码。
|
||||||
|
visible-role-codes: []
|
||||||
|
# 明确隐藏日报附件功能的角色编码;命中后优先级高于 visible-role-codes。
|
||||||
|
hidden-role-codes: [crm_xs]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.unis.crm.common.BusinessException;
|
import com.unis.crm.common.BusinessException;
|
||||||
|
import com.unis.crm.config.WorkReportProperties;
|
||||||
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
||||||
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
||||||
import com.unis.crm.dto.work.WorkDailyReportDTO;
|
import com.unis.crm.dto.work.WorkDailyReportDTO;
|
||||||
|
|
@ -57,9 +58,12 @@ class WorkServiceImplTest {
|
||||||
|
|
||||||
private WorkServiceImpl workService;
|
private WorkServiceImpl workService;
|
||||||
|
|
||||||
|
private WorkReportProperties workReportProperties;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
workService = new WorkServiceImpl(workMapper, opportunityMapper, profileMapper, reportReminderService, new ObjectMapper(), tenantProvider, "build/test-uploads", "");
|
workReportProperties = new WorkReportProperties();
|
||||||
|
workService = new WorkServiceImpl(workMapper, opportunityMapper, profileMapper, reportReminderService, workReportProperties, new ObjectMapper(), tenantProvider, "build/test-uploads", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -150,8 +154,65 @@ class WorkServiceImplTest {
|
||||||
org.mockito.ArgumentMatchers.any());
|
org.mockito.ArgumentMatchers.any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listReportMessageUsers_shouldRejectHiddenRole() {
|
||||||
|
workReportProperties.getDailyReport().getNotifyUsers().setHiddenRoleCodes(List.of("sales"));
|
||||||
|
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales"));
|
||||||
|
|
||||||
|
BusinessException exception = assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> workService.listReportMessageUsers(17L, ""));
|
||||||
|
|
||||||
|
assertEquals("当前角色不可使用日报通知用户功能", exception.getMessage());
|
||||||
|
verify(workMapper, never()).selectReportMessageUsers(any(), any(), eq(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveDailyReport_shouldRejectEmptyContentByDefault() {
|
||||||
|
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales"));
|
||||||
|
|
||||||
|
BusinessException exception = assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> workService.saveDailyReport(17L, new CreateWorkDailyReportRequest()));
|
||||||
|
|
||||||
|
assertEquals("请至少填写一条今日工作内容", exception.getMessage());
|
||||||
|
verify(workMapper, never()).insertDailyReport(
|
||||||
|
org.mockito.ArgumentMatchers.anyLong(),
|
||||||
|
org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void saveDailyReport_shouldRejectManualTextWhenObjectSelectionRequiredByDefault() {
|
||||||
|
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales"));
|
||||||
|
|
||||||
|
CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest();
|
||||||
|
WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest();
|
||||||
|
lineItem.setWorkDate("2026-04-28");
|
||||||
|
lineItem.setBizType("sales");
|
||||||
|
lineItem.setBizId(0L);
|
||||||
|
lineItem.setEditorText("整理客户资料,完成重点项目复盘");
|
||||||
|
lineItem.setContent("整理客户资料,完成重点项目复盘");
|
||||||
|
request.setLineItems(List.of(lineItem));
|
||||||
|
|
||||||
|
BusinessException exception = assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> workService.saveDailyReport(17L, request));
|
||||||
|
|
||||||
|
assertEquals("请使用 # 选择日报关联对象", exception.getMessage());
|
||||||
|
verify(workMapper, never()).insertDailyReport(
|
||||||
|
org.mockito.ArgumentMatchers.anyLong(),
|
||||||
|
org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.any(),
|
||||||
|
org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void saveDailyReport_shouldAllowManualTextWithoutLinkedObject() {
|
void saveDailyReport_shouldAllowManualTextWithoutLinkedObject() {
|
||||||
|
workReportProperties.getDailyReport().setOptionalRoleCodes(List.of("sales_assistant"));
|
||||||
|
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales_assistant"));
|
||||||
|
|
||||||
CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest();
|
CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest();
|
||||||
WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest();
|
WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest();
|
||||||
lineItem.setWorkDate("2026-04-28");
|
lineItem.setWorkDate("2026-04-28");
|
||||||
|
|
@ -203,6 +264,59 @@ class WorkServiceImplTest {
|
||||||
assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage());
|
assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadReportAttachment_shouldAllowLargeZipWithinLimit() {
|
||||||
|
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales"));
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file",
|
||||||
|
"project-pack.zip",
|
||||||
|
"application/zip",
|
||||||
|
new byte[] {1, 2, 3, 4});
|
||||||
|
|
||||||
|
WorkReportAttachmentDTO attachment = workService.uploadReportAttachment(17L, file);
|
||||||
|
|
||||||
|
assertEquals("project-pack.zip", attachment.getName());
|
||||||
|
assertEquals("application/zip", attachment.getContentType());
|
||||||
|
assertEquals(4L, attachment.getSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadReportAttachment_shouldRejectHiddenRole() {
|
||||||
|
workReportProperties.getDailyReport().getAttachments().setHiddenRoleCodes(List.of("sales"));
|
||||||
|
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales"));
|
||||||
|
|
||||||
|
BusinessException exception = assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> workService.uploadReportAttachment(
|
||||||
|
17L,
|
||||||
|
new MockMultipartFile("file", "project-pack.zip", "application/zip", new byte[] {1, 2, 3})));
|
||||||
|
|
||||||
|
assertEquals("当前角色不可使用日报附件功能", exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadReportAttachment_shouldRejectOverLimitFiles() {
|
||||||
|
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales"));
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file",
|
||||||
|
"project-pack.zip",
|
||||||
|
"application/zip",
|
||||||
|
new byte[] {1}) {
|
||||||
|
@Override
|
||||||
|
public long getSize() {
|
||||||
|
return 500L * 1024 * 1024 + 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
BusinessException exception = assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> workService.uploadReportAttachment(17L, file));
|
||||||
|
|
||||||
|
assertEquals("单个附件不能超过500MB", exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void exportCheckIns_shouldCleanPhotoMetadata() {
|
void exportCheckIns_shouldCleanPhotoMetadata() {
|
||||||
WorkCheckInExportDTO row = new WorkCheckInExportDTO();
|
WorkCheckInExportDTO row = new WorkCheckInExportDTO();
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,10 @@ export interface WorkReportAttachment {
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadReportAttachmentOptions {
|
||||||
|
onProgress?: (progress: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkReportToUser {
|
export interface WorkReportToUser {
|
||||||
userId: number;
|
userId: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -368,6 +372,12 @@ export interface WorkOverview {
|
||||||
history?: WorkHistoryItem[];
|
history?: WorkHistoryItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkDailyReportRequirement {
|
||||||
|
objectSelectionRequired?: boolean;
|
||||||
|
notifyUsersVisible?: boolean;
|
||||||
|
attachmentsVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkHistoryPage {
|
export interface WorkHistoryPage {
|
||||||
items?: WorkHistoryItem[];
|
items?: WorkHistoryItem[];
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
|
|
@ -1311,6 +1321,10 @@ export async function getWorkOverview() {
|
||||||
return request<WorkOverview>("/api/work/overview", undefined, true);
|
return request<WorkOverview>("/api/work/overview", undefined, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWorkDailyReportRequirement() {
|
||||||
|
return request<WorkDailyReportRequirement>("/api/work/daily-report-requirement", undefined, true);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listWorkReportMessageUsers(keyword?: string) {
|
export async function listWorkReportMessageUsers(keyword?: string) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
const normalizedKeyword = keyword?.trim() || "";
|
const normalizedKeyword = keyword?.trim() || "";
|
||||||
|
|
@ -1396,13 +1410,61 @@ export async function uploadWorkCheckInPhoto(file: File) {
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadWorkReportAttachment(file: File) {
|
export async function uploadWorkReportAttachment(file: File, options?: UploadReportAttachmentOptions) {
|
||||||
|
const token = localStorage.getItem("accessToken");
|
||||||
|
const userId = getStoredUserId();
|
||||||
|
const activeTenantId = localStorage.getItem("activeTenantId");
|
||||||
|
|
||||||
|
return new Promise<WorkReportAttachment>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", "/api/work/report-attachments");
|
||||||
|
if (token) {
|
||||||
|
xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
if (userId !== undefined) {
|
||||||
|
xhr.setRequestHeader("X-User-Id", String(userId));
|
||||||
|
}
|
||||||
|
if (activeTenantId?.trim()) {
|
||||||
|
xhr.setRequestHeader("X-Tenant-Id", activeTenantId.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (!event.lengthComputable || !options?.onProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error("附件上传失败,请稍后重试"));
|
||||||
|
xhr.onabort = () => reject(new Error("附件上传已取消"));
|
||||||
|
xhr.onload = () => {
|
||||||
|
const rawText = xhr.responseText || "";
|
||||||
|
const body = tryParseApiBody<WorkReportAttachment>(rawText, xhr.getResponseHeader("content-type"));
|
||||||
|
const errorMessage = buildApiErrorMessage(rawText, xhr.status, body);
|
||||||
|
|
||||||
|
if (xhr.status === 401 || (xhr.status >= 300 && xhr.status < 400)) {
|
||||||
|
handleUnauthorizedResponse();
|
||||||
|
reject(new Error("登录已失效,请重新登录"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
|
reject(new Error(errorMessage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body || !isSuccessCode(body.code)) {
|
||||||
|
reject(new Error(errorMessage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(body.data);
|
||||||
|
};
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
return request<WorkReportAttachment>("/api/work/report-attachments", {
|
xhr.send(formData);
|
||||||
method: "POST",
|
});
|
||||||
body: formData,
|
|
||||||
}, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload) {
|
export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { zhCN } from "date-fns/locale";
|
import { zhCN } from "date-fns/locale";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { ArrowUp, Camera, CheckCircle2, ChevronDown, Download, FileText, Hash, ListTodo, MapPin, NotebookPen, Paperclip, Plus, RefreshCw, Search, Send, Trash2, UserRoundPlus, X } from "lucide-react";
|
import { ArrowUp, Camera, CheckCircle2, ChevronDown, Download, FileText, Hash, ListTodo, MapPin, NotebookPen, Paperclip, Plus, RefreshCw, RotateCcw, Search, Send, Trash2, UserRoundPlus, X } from "lucide-react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
|
import { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
getProfileOverview,
|
getProfileOverview,
|
||||||
getWorkCheckInExportData,
|
getWorkCheckInExportData,
|
||||||
getWorkDailyReportExportData,
|
getWorkDailyReportExportData,
|
||||||
|
getWorkDailyReportRequirement,
|
||||||
getWorkReportHistoryItem,
|
getWorkReportHistoryItem,
|
||||||
refreshCurrentUser,
|
refreshCurrentUser,
|
||||||
getWorkHistory,
|
getWorkHistory,
|
||||||
|
|
@ -80,6 +81,7 @@ const LEGACY_NEXT_PLAN_LABEL = "后续规划";
|
||||||
const OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划";
|
const OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划";
|
||||||
const OPPORTUNITY_STAGE_LABEL = "项目阶段";
|
const OPPORTUNITY_STAGE_LABEL = "项目阶段";
|
||||||
const WORK_DETAIL_NEXT_PLAN_HEADER = "后续规划 / 下一步销售计划";
|
const WORK_DETAIL_NEXT_PLAN_HEADER = "后续规划 / 下一步销售计划";
|
||||||
|
const REPORT_ATTACHMENT_MAX_SIZE_BYTES = 500 * 1024 * 1024;
|
||||||
|
|
||||||
const reportFieldLabels = {
|
const reportFieldLabels = {
|
||||||
sales: ["沟通内容", LEGACY_NEXT_PLAN_LABEL],
|
sales: ["沟通内容", LEGACY_NEXT_PLAN_LABEL],
|
||||||
|
|
@ -797,6 +799,9 @@ export default function Work() {
|
||||||
const [submittingReport, setSubmittingReport] = useState(false);
|
const [submittingReport, setSubmittingReport] = useState(false);
|
||||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||||
const [uploadingReportAttachmentIndex, setUploadingReportAttachmentIndex] = useState<number | null>(null);
|
const [uploadingReportAttachmentIndex, setUploadingReportAttachmentIndex] = useState<number | null>(null);
|
||||||
|
const [uploadingReportAttachmentProgress, setUploadingReportAttachmentProgress] = useState<Record<number, number>>({});
|
||||||
|
const [reportAttachmentUploadErrors, setReportAttachmentUploadErrors] = useState<Record<number, string>>({});
|
||||||
|
const reportAttachmentFileRef = useRef<Record<number, File | null>>({});
|
||||||
const [mobilePanel, setMobilePanel] = useState<"entry" | "history">("entry");
|
const [mobilePanel, setMobilePanel] = useState<"entry" | "history">("entry");
|
||||||
const [locationHint, setLocationHint] = useState("");
|
const [locationHint, setLocationHint] = useState("");
|
||||||
const [locationAdjustOpen, setLocationAdjustOpen] = useState(false);
|
const [locationAdjustOpen, setLocationAdjustOpen] = useState(false);
|
||||||
|
|
@ -823,6 +828,10 @@ export default function Work() {
|
||||||
const [historyPresenterPickerOpen, setHistoryPresenterPickerOpen] = useState(false);
|
const [historyPresenterPickerOpen, setHistoryPresenterPickerOpen] = useState(false);
|
||||||
const [expandedHistoryGroupKey, setExpandedHistoryGroupKey] = useState<string | null>(null);
|
const [expandedHistoryGroupKey, setExpandedHistoryGroupKey] = useState<string | null>(null);
|
||||||
const [reportStatus, setReportStatus] = useState<string>();
|
const [reportStatus, setReportStatus] = useState<string>();
|
||||||
|
const [dailyReportFeatureLoaded, setDailyReportFeatureLoaded] = useState(false);
|
||||||
|
const [dailyReportObjectSelectionRequired, setDailyReportObjectSelectionRequired] = useState(true);
|
||||||
|
const [dailyReportNotifyUsersVisible, setDailyReportNotifyUsersVisible] = useState(false);
|
||||||
|
const [dailyReportAttachmentsVisible, setDailyReportAttachmentsVisible] = useState(false);
|
||||||
const [currentUser, setCurrentUser] = useState<UserProfile | null>(() => readStoredUserProfile());
|
const [currentUser, setCurrentUser] = useState<UserProfile | null>(() => readStoredUserProfile());
|
||||||
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
|
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
|
||||||
const [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
|
const [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
|
||||||
|
|
@ -1196,19 +1205,31 @@ export default function Work() {
|
||||||
|
|
||||||
async function loadUserContext() {
|
async function loadUserContext() {
|
||||||
try {
|
try {
|
||||||
const [userData, overviewData] = await Promise.all([
|
const [userData, overviewData, requirementData] = await Promise.all([
|
||||||
((currentUser?.roleCodes?.length ?? 0) > 0 ? getCurrentUser() : refreshCurrentUser()).catch(() => null),
|
((currentUser?.roleCodes?.length ?? 0) > 0 ? getCurrentUser() : refreshCurrentUser()).catch(() => null),
|
||||||
getProfileOverview().catch(() => null),
|
getProfileOverview().catch(() => null),
|
||||||
|
getWorkDailyReportRequirement().catch(() => null),
|
||||||
]);
|
]);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCurrentUser(userData);
|
setCurrentUser(userData);
|
||||||
setProfileOverview(overviewData);
|
setProfileOverview(overviewData);
|
||||||
|
if (requirementData && typeof requirementData.objectSelectionRequired === "boolean") {
|
||||||
|
setDailyReportObjectSelectionRequired(requirementData.objectSelectionRequired);
|
||||||
|
}
|
||||||
|
if (requirementData && typeof requirementData.notifyUsersVisible === "boolean") {
|
||||||
|
setDailyReportNotifyUsersVisible(requirementData.notifyUsersVisible);
|
||||||
|
}
|
||||||
|
if (requirementData && typeof requirementData.attachmentsVisible === "boolean") {
|
||||||
|
setDailyReportAttachmentsVisible(requirementData.attachmentsVisible);
|
||||||
|
}
|
||||||
|
setDailyReportFeatureLoaded(true);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
setProfileOverview(null);
|
setProfileOverview(null);
|
||||||
|
setDailyReportFeatureLoaded(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1925,12 +1946,31 @@ export default function Work() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (file.size > REPORT_ATTACHMENT_MAX_SIZE_BYTES) {
|
||||||
|
const message = `单个附件不能超过${formatAttachmentSize(REPORT_ATTACHMENT_MAX_SIZE_BYTES)}`;
|
||||||
|
setReportError(message);
|
||||||
|
setReportAttachmentUploadErrors((current) => ({ ...current, [index]: message }));
|
||||||
|
event.target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reportAttachmentFileRef.current[index] = file;
|
||||||
setReportError("");
|
setReportError("");
|
||||||
setReportSuccess("");
|
setReportSuccess("");
|
||||||
setUploadingReportAttachmentIndex(index);
|
setUploadingReportAttachmentIndex(index);
|
||||||
|
setReportAttachmentUploadErrors((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[index];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setUploadingReportAttachmentProgress((current) => ({ ...current, [index]: 0 }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const attachment = await uploadWorkReportAttachment(file);
|
const attachment = await uploadWorkReportAttachment(file, {
|
||||||
|
onProgress: (progress) => {
|
||||||
|
setUploadingReportAttachmentProgress((current) => ({ ...current, [index]: progress }));
|
||||||
|
},
|
||||||
|
});
|
||||||
setReportForm((current) => ({
|
setReportForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
lineItems: current.lineItems.map((item, itemIndex) => {
|
lineItems: current.lineItems.map((item, itemIndex) => {
|
||||||
|
|
@ -1947,10 +1987,18 @@ export default function Work() {
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
reportAttachmentFileRef.current[index] = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setReportError(error instanceof Error ? error.message : "附件上传失败");
|
const message = error instanceof Error ? error.message : "附件上传失败";
|
||||||
|
setReportError(message);
|
||||||
|
setReportAttachmentUploadErrors((current) => ({ ...current, [index]: message }));
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingReportAttachmentIndex(null);
|
setUploadingReportAttachmentIndex(null);
|
||||||
|
setUploadingReportAttachmentProgress((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[index];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1966,6 +2014,18 @@ export default function Work() {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRetryReportAttachment = (index: number) => {
|
||||||
|
const file = reportAttachmentFileRef.current[index];
|
||||||
|
if (!file) {
|
||||||
|
setReportError("请重新选择附件后再试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fakeEvent = {
|
||||||
|
target: { files: [file], value: "" },
|
||||||
|
} as unknown as ChangeEvent<HTMLInputElement>;
|
||||||
|
void handleReportAttachmentChange(index, fakeEvent);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePlanItemChange = (index: number, value: string) => {
|
const handlePlanItemChange = (index: number, value: string) => {
|
||||||
setReportForm((current) => ({
|
setReportForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
|
|
@ -2056,11 +2116,16 @@ export default function Work() {
|
||||||
setSubmittingReport(true);
|
setSubmittingReport(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!dailyReportFeatureLoaded) {
|
||||||
|
throw new Error("日报功能配置加载中,请稍后重试");
|
||||||
|
}
|
||||||
if (isOnlySeeRole) {
|
if (isOnlySeeRole) {
|
||||||
throw new Error("当前角色仅可查看日报历史记录");
|
throw new Error("当前角色仅可查看日报历史记录");
|
||||||
}
|
}
|
||||||
const normalizedLineItems = reportForm.lineItems.map((item) => (
|
const normalizedLineItems = reportForm.lineItems.map((item) => (
|
||||||
normalizeReportLineItem(item, currentWorkDate, reportOpportunityStageOptions)
|
normalizeReportLineItem(item, currentWorkDate, reportOpportunityStageOptions)
|
||||||
|
)).map((item) => (
|
||||||
|
normalizeLineFeatureVisibility(item, dailyReportNotifyUsersVisible, dailyReportAttachmentsVisible)
|
||||||
));
|
));
|
||||||
const normalizedPlanItems = reportForm.planItems
|
const normalizedPlanItems = reportForm.planItems
|
||||||
.map((item) => ({ content: item.content.trim() }))
|
.map((item) => ({ content: item.content.trim() }))
|
||||||
|
|
@ -2069,7 +2134,7 @@ export default function Work() {
|
||||||
if (!normalizedLineItems.length) {
|
if (!normalizedLineItems.length) {
|
||||||
throw new Error("请至少填写一条今日工作内容");
|
throw new Error("请至少填写一条今日工作内容");
|
||||||
}
|
}
|
||||||
validateReportLineItems(normalizedLineItems, reportOpportunityStageOptions);
|
validateReportLineItems(normalizedLineItems, reportOpportunityStageOptions, dailyReportObjectSelectionRequired);
|
||||||
|
|
||||||
await saveWorkDailyReport({
|
await saveWorkDailyReport({
|
||||||
workContent: buildReportSummary(normalizedLineItems),
|
workContent: buildReportSummary(normalizedLineItems),
|
||||||
|
|
@ -2191,7 +2256,13 @@ export default function Work() {
|
||||||
onReportLineChange={handleReportLineChange}
|
onReportLineChange={handleReportLineChange}
|
||||||
onReportLineStageChange={handleReportLineStageChange}
|
onReportLineStageChange={handleReportLineStageChange}
|
||||||
uploadingReportAttachmentIndex={uploadingReportAttachmentIndex}
|
uploadingReportAttachmentIndex={uploadingReportAttachmentIndex}
|
||||||
|
uploadingReportAttachmentProgress={uploadingReportAttachmentProgress}
|
||||||
|
reportAttachmentUploadErrors={reportAttachmentUploadErrors}
|
||||||
|
dailyReportFeatureLoaded={dailyReportFeatureLoaded}
|
||||||
|
notifyUsersVisible={dailyReportNotifyUsersVisible}
|
||||||
|
attachmentsVisible={dailyReportAttachmentsVisible}
|
||||||
onReportAttachmentChange={(index, event) => void handleReportAttachmentChange(index, event)}
|
onReportAttachmentChange={(index, event) => void handleReportAttachmentChange(index, event)}
|
||||||
|
onRetryReportAttachment={handleRetryReportAttachment}
|
||||||
onRemoveReportAttachment={handleRemoveReportAttachment}
|
onRemoveReportAttachment={handleRemoveReportAttachment}
|
||||||
onPreviewAttachment={setPreviewAttachment}
|
onPreviewAttachment={setPreviewAttachment}
|
||||||
onAddPlanItem={handleAddPlanItem}
|
onAddPlanItem={handleAddPlanItem}
|
||||||
|
|
@ -3734,18 +3805,52 @@ function ReportAttachmentList({
|
||||||
attachments,
|
attachments,
|
||||||
onPreview,
|
onPreview,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
uploadProgress,
|
||||||
|
uploadError,
|
||||||
|
onRetryUpload,
|
||||||
}: {
|
}: {
|
||||||
attachments?: WorkReportAttachment[];
|
attachments?: WorkReportAttachment[];
|
||||||
onPreview: (attachment: WorkReportAttachment) => void;
|
onPreview: (attachment: WorkReportAttachment) => void;
|
||||||
onRemove?: (attachment: WorkReportAttachment) => void;
|
onRemove?: (attachment: WorkReportAttachment) => void;
|
||||||
|
uploadProgress?: number;
|
||||||
|
uploadError?: string;
|
||||||
|
onRetryUpload?: () => void;
|
||||||
}) {
|
}) {
|
||||||
if (!attachments?.length) {
|
if (!attachments?.length && typeof uploadProgress !== "number" && !uploadError) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{attachments.map((attachment) => (
|
{typeof uploadProgress === "number" ? (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-xl border border-violet-200 bg-violet-50 px-2.5 py-1.5 text-xs text-violet-700 shadow-sm dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-200">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
<span>上传中 {uploadProgress}%</span>
|
||||||
|
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-violet-100 dark:bg-violet-500/20">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-violet-600 transition-[width] dark:bg-violet-300"
|
||||||
|
style={{ width: `${Math.max(3, uploadProgress)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{uploadError ? (
|
||||||
|
<div className="inline-flex max-w-full items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 px-2.5 py-1.5 text-xs text-rose-700 shadow-sm dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200">
|
||||||
|
<span className="max-w-[180px] truncate">{uploadError}</span>
|
||||||
|
{onRetryUpload ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetryUpload}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 font-medium transition-colors hover:bg-rose-100 dark:hover:bg-rose-500/20"
|
||||||
|
title="重试上传"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{(attachments ?? []).map((attachment) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.url}
|
key={attachment.url}
|
||||||
className="inline-flex max-w-full items-center gap-2 rounded-xl border border-slate-200 bg-white px-2.5 py-1.5 text-xs text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300"
|
className="inline-flex max-w-full items-center gap-2 rounded-xl border border-slate-200 bg-white px-2.5 py-1.5 text-xs text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300"
|
||||||
|
|
@ -4009,7 +4114,13 @@ function ReportPanel({
|
||||||
onReportLineChange,
|
onReportLineChange,
|
||||||
onReportLineStageChange,
|
onReportLineStageChange,
|
||||||
uploadingReportAttachmentIndex,
|
uploadingReportAttachmentIndex,
|
||||||
|
uploadingReportAttachmentProgress,
|
||||||
|
reportAttachmentUploadErrors,
|
||||||
|
dailyReportFeatureLoaded,
|
||||||
|
notifyUsersVisible,
|
||||||
|
attachmentsVisible,
|
||||||
onReportAttachmentChange,
|
onReportAttachmentChange,
|
||||||
|
onRetryReportAttachment,
|
||||||
onRemoveReportAttachment,
|
onRemoveReportAttachment,
|
||||||
onPreviewAttachment,
|
onPreviewAttachment,
|
||||||
onAddPlanItem,
|
onAddPlanItem,
|
||||||
|
|
@ -4035,7 +4146,13 @@ function ReportPanel({
|
||||||
onReportLineChange: (index: number, value: string) => void;
|
onReportLineChange: (index: number, value: string) => void;
|
||||||
onReportLineStageChange: (index: number, value: string) => void;
|
onReportLineStageChange: (index: number, value: string) => void;
|
||||||
uploadingReportAttachmentIndex: number | null;
|
uploadingReportAttachmentIndex: number | null;
|
||||||
|
uploadingReportAttachmentProgress: Record<number, number>;
|
||||||
|
reportAttachmentUploadErrors: Record<number, string>;
|
||||||
|
dailyReportFeatureLoaded: boolean;
|
||||||
|
notifyUsersVisible: boolean;
|
||||||
|
attachmentsVisible: boolean;
|
||||||
onReportAttachmentChange: (index: number, event: ChangeEvent<HTMLInputElement>) => void;
|
onReportAttachmentChange: (index: number, event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onRetryReportAttachment: (index: number) => void;
|
||||||
onRemoveReportAttachment: (lineIndex: number, attachmentUrl: string) => void;
|
onRemoveReportAttachment: (lineIndex: number, attachmentUrl: string) => void;
|
||||||
onPreviewAttachment: (attachment: WorkReportAttachment) => void;
|
onPreviewAttachment: (attachment: WorkReportAttachment) => void;
|
||||||
onAddPlanItem: () => void;
|
onAddPlanItem: () => void;
|
||||||
|
|
@ -4215,6 +4332,8 @@ function ReportPanel({
|
||||||
: "";
|
: "";
|
||||||
const attachments = item.attachments ?? [];
|
const attachments = item.attachments ?? [];
|
||||||
const isUploadingAttachment = uploadingReportAttachmentIndex === index;
|
const isUploadingAttachment = uploadingReportAttachmentIndex === index;
|
||||||
|
const attachmentProgress = uploadingReportAttachmentProgress[index] ?? 0;
|
||||||
|
const attachmentUploadError = reportAttachmentUploadErrors[index];
|
||||||
return (
|
return (
|
||||||
<div key={`report-line-${index}`} className="relative rounded-2xl border border-slate-100/90 bg-slate-50/30 p-3 dark:border-slate-800/70 dark:bg-slate-900/20">
|
<div key={`report-line-${index}`} className="relative rounded-2xl border border-slate-100/90 bg-slate-50/30 p-3 dark:border-slate-800/70 dark:bg-slate-900/20">
|
||||||
<span
|
<span
|
||||||
|
|
@ -4297,11 +4416,16 @@ function ReportPanel({
|
||||||
placeholder="输入工作内容,或输入 # 选择对象生成字段。"
|
placeholder="输入工作内容,或输入 # 选择对象生成字段。"
|
||||||
className="crm-input-box crm-input-text min-h-[88px] w-full resize-none overflow-hidden rounded-2xl border border-slate-200 bg-white leading-7 text-slate-900 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
className="crm-input-box crm-input-text min-h-[88px] w-full resize-none overflow-hidden rounded-2xl border border-slate-200 bg-white leading-7 text-slate-900 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
||||||
/>
|
/>
|
||||||
|
{attachmentsVisible ? (
|
||||||
<ReportAttachmentList
|
<ReportAttachmentList
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
onPreview={onPreviewAttachment}
|
onPreview={onPreviewAttachment}
|
||||||
onRemove={(attachment) => onRemoveReportAttachment(index, attachment.url)}
|
onRemove={(attachment) => onRemoveReportAttachment(index, attachment.url)}
|
||||||
|
uploadProgress={isUploadingAttachment ? attachmentProgress : undefined}
|
||||||
|
uploadError={attachmentUploadError}
|
||||||
|
onRetryUpload={attachmentUploadError ? () => onRetryReportAttachment(index) : undefined}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
|
@ -4341,14 +4465,20 @@ function ReportPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{attachmentsVisible ? (
|
||||||
<ReportAttachmentList
|
<ReportAttachmentList
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
onPreview={onPreviewAttachment}
|
onPreview={onPreviewAttachment}
|
||||||
onRemove={(attachment) => onRemoveReportAttachment(index, attachment.url)}
|
onRemove={(attachment) => onRemoveReportAttachment(index, attachment.url)}
|
||||||
|
uploadProgress={isUploadingAttachment ? attachmentProgress : undefined}
|
||||||
|
uploadError={attachmentUploadError}
|
||||||
|
onRetryUpload={attachmentUploadError ? () => onRetryReportAttachment(index) : undefined}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full shrink-0 items-center justify-end gap-2 sm:w-auto sm:self-center lg:grid lg:w-[88px] lg:grid-cols-2 lg:self-start">
|
<div className="flex w-full shrink-0 items-center justify-end gap-2 sm:w-auto sm:self-center lg:grid lg:w-[88px] lg:grid-cols-2 lg:self-start">
|
||||||
|
{attachmentsVisible ? (
|
||||||
<input
|
<input
|
||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
reportAttachmentInputRefs.current[index] = element;
|
reportAttachmentInputRefs.current[index] = element;
|
||||||
|
|
@ -4357,6 +4487,7 @@ function ReportPanel({
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(event) => onReportAttachmentChange(index, event)}
|
onChange={(event) => onReportAttachmentChange(index, event)}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenObjectPicker(index, item.bizType || "sales")}
|
onClick={() => onOpenObjectPicker(index, item.bizType || "sales")}
|
||||||
|
|
@ -4366,6 +4497,7 @@ function ReportPanel({
|
||||||
>
|
>
|
||||||
<Hash className="h-4.5 w-4.5" />
|
<Hash className="h-4.5 w-4.5" />
|
||||||
</button>
|
</button>
|
||||||
|
{notifyUsersVisible ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenUserPicker(index)}
|
onClick={() => onOpenUserPicker(index)}
|
||||||
|
|
@ -4375,16 +4507,26 @@ function ReportPanel({
|
||||||
>
|
>
|
||||||
<UserRoundPlus className="h-4.5 w-4.5" />
|
<UserRoundPlus className="h-4.5 w-4.5" />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
{attachmentsVisible ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => reportAttachmentInputRefs.current[index]?.click()}
|
onClick={() => reportAttachmentInputRefs.current[index]?.click()}
|
||||||
disabled={isUploadingAttachment}
|
disabled={isUploadingAttachment}
|
||||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition-colors hover:border-violet-200 hover:bg-violet-50 hover:text-violet-600 disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300 dark:hover:border-violet-500/30 dark:hover:bg-violet-500/10 dark:hover:text-violet-300"
|
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition-colors hover:border-violet-200 hover:bg-violet-50 hover:text-violet-600 disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300 dark:hover:border-violet-500/30 dark:hover:bg-violet-500/10 dark:hover:text-violet-300"
|
||||||
title={isUploadingAttachment ? "附件上传中" : "上传附件"}
|
title={isUploadingAttachment ? `附件上传中 ${attachmentProgress}%` : "上传附件"}
|
||||||
aria-label={isUploadingAttachment ? "附件上传中" : "上传附件"}
|
aria-label={isUploadingAttachment ? `附件上传中 ${attachmentProgress}%` : "上传附件"}
|
||||||
>
|
>
|
||||||
|
{isUploadingAttachment ? (
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<RefreshCw className="h-4.5 w-4.5 animate-spin" />
|
||||||
|
<span className="text-[10px] font-medium leading-none text-slate-400">{attachmentProgress}%</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Paperclip className="h-4.5 w-4.5" />
|
<Paperclip className="h-4.5 w-4.5" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onRemoveReportLine(index)}
|
onClick={() => onRemoveReportLine(index)}
|
||||||
|
|
@ -4475,7 +4617,7 @@ function ReportPanel({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={submittingReport || loading}
|
disabled={submittingReport || loading || !dailyReportFeatureLoaded}
|
||||||
className={cn(
|
className={cn(
|
||||||
"crm-btn crm-btn-primary mt-6 flex w-full items-center justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-60",
|
"crm-btn crm-btn-primary mt-6 flex w-full items-center justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
disableMobileMotion ? "active:scale-100" : "active:scale-[0.98]",
|
disableMobileMotion ? "active:scale-100" : "active:scale-[0.98]",
|
||||||
|
|
@ -5335,9 +5477,35 @@ function normalizeReportLineItem(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateReportLineItems(lineItems: WorkReportLineItem[], opportunityStageOptions: OpportunityDictOption[]) {
|
function normalizeLineFeatureVisibility(
|
||||||
|
item: WorkReportLineItem,
|
||||||
|
notifyUsersVisible: boolean,
|
||||||
|
attachmentsVisible: boolean,
|
||||||
|
) {
|
||||||
|
if (notifyUsersVisible && attachmentsVisible) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
const nextItem: WorkReportLineItem = { ...item };
|
||||||
|
if (!notifyUsersVisible) {
|
||||||
|
nextItem.toUsers = [];
|
||||||
|
nextItem.editorText = stripReportToLines(nextItem.editorText);
|
||||||
|
}
|
||||||
|
if (!attachmentsVisible) {
|
||||||
|
nextItem.attachments = [];
|
||||||
|
}
|
||||||
|
return nextItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReportLineItems(
|
||||||
|
lineItems: WorkReportLineItem[],
|
||||||
|
opportunityStageOptions: OpportunityDictOption[],
|
||||||
|
objectSelectionRequired: boolean,
|
||||||
|
) {
|
||||||
for (const item of lineItems) {
|
for (const item of lineItems) {
|
||||||
if (!item.bizId || !item.bizName) {
|
if (!item.bizId || !item.bizName) {
|
||||||
|
if (objectSelectionRequired) {
|
||||||
|
throw new Error("请使用 # 选择日报关联对象");
|
||||||
|
}
|
||||||
if (!item.editorText?.trim() && !item.content?.trim()) {
|
if (!item.editorText?.trim() && !item.content?.trim()) {
|
||||||
throw new Error("请填写今日工作内容");
|
throw new Error("请填写今日工作内容");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue