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.InternalAuthProperties;
|
||||
import com.unis.crm.config.OmsProperties;
|
||||
import com.unis.crm.config.WorkReportProperties;
|
||||
import java.util.TimeZone;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
|
|
@ -12,7 +13,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||
|
||||
@SpringBootApplication(scanBasePackages = "com.unis.crm")
|
||||
@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
|
||||
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.WorkCheckInExportDTO;
|
||||
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.WorkHistoryPageDTO;
|
||||
import com.unis.crm.dto.work.WorkOverviewDTO;
|
||||
|
|
@ -52,6 +53,11 @@ public class WorkController {
|
|||
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")
|
||||
public ApiResponse<List<WorkReportToUserDTO>> listReportMessageUsers(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package com.unis.crm.dto.work;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.ArrayList;
|
||||
|
|
@ -9,11 +7,9 @@ import java.util.List;
|
|||
|
||||
public class CreateWorkDailyReportRequest {
|
||||
|
||||
@NotBlank(message = "今日工作内容不能为空")
|
||||
@Size(max = 4000, message = "今日工作内容不能超过4000字符")
|
||||
private String workContent;
|
||||
|
||||
@NotEmpty(message = "请至少填写一条今日工作内容")
|
||||
@Valid
|
||||
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;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.ArrayList;
|
||||
|
|
@ -8,10 +7,8 @@ import java.util.List;
|
|||
|
||||
public class WorkReportLineItemRequest {
|
||||
|
||||
@NotBlank(message = "工作日期不能为空")
|
||||
private String workDate;
|
||||
|
||||
@NotBlank(message = "跟进对象类型不能为空")
|
||||
private String bizType;
|
||||
|
||||
private Long bizId;
|
||||
|
|
@ -19,11 +16,9 @@ public class WorkReportLineItemRequest {
|
|||
@Size(max = 200, message = "对象名称不能超过200字符")
|
||||
private String bizName;
|
||||
|
||||
@NotBlank(message = "日报内容不能为空")
|
||||
@Size(max = 4000, message = "日报内容不能超过4000字符")
|
||||
private String editorText;
|
||||
|
||||
@NotBlank(message = "工作内容不能为空")
|
||||
@Size(max = 1000, message = "工作内容不能超过1000字符")
|
||||
private String content;
|
||||
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.WorkHistoryPageDTO;
|
||||
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.WorkReportToUserDTO;
|
||||
import java.math.BigDecimal;
|
||||
|
|
@ -19,6 +20,8 @@ public interface WorkService {
|
|||
|
||||
WorkOverviewDTO getOverview(Long userId);
|
||||
|
||||
WorkDailyReportRequirementDTO getDailyReportRequirement(Long userId);
|
||||
|
||||
List<WorkReportToUserDTO> listReportMessageUsers(Long userId, String keyword);
|
||||
|
||||
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.fasterxml.jackson.core.type.TypeReference;
|
||||
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.CreateWorkDailyReportRequest;
|
||||
import com.unis.crm.dto.work.WorkCheckInDTO;
|
||||
import com.unis.crm.dto.work.WorkCheckInExportDTO;
|
||||
import com.unis.crm.dto.work.WorkDailyReportDTO;
|
||||
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.WorkHistoryPageDTO;
|
||||
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_TYPE_CODE = "sj_xmjd";
|
||||
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_GCJ02 = "2";
|
||||
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 UnisBaseTenantProvider tenantProvider;
|
||||
private final ReportReminderService reportReminderService;
|
||||
private final WorkReportProperties workReportProperties;
|
||||
private final Path checkInPhotoDirectory;
|
||||
private final Path reportAttachmentDirectory;
|
||||
private final String tencentMapKey;
|
||||
|
|
@ -114,6 +117,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
OpportunityMapper opportunityMapper,
|
||||
ProfileMapper profileMapper,
|
||||
ReportReminderService reportReminderService,
|
||||
WorkReportProperties workReportProperties,
|
||||
ObjectMapper objectMapper,
|
||||
UnisBaseTenantProvider tenantProvider,
|
||||
@Value("${unisbase.app.upload-path}") String uploadPath,
|
||||
|
|
@ -122,6 +126,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
this.opportunityMapper = opportunityMapper;
|
||||
this.profileMapper = profileMapper;
|
||||
this.reportReminderService = reportReminderService;
|
||||
this.workReportProperties = workReportProperties == null ? new WorkReportProperties() : workReportProperties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.tenantProvider = tenantProvider;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
|
|
@ -146,9 +151,22 @@ public class WorkServiceImpl implements WorkService {
|
|||
@Override
|
||||
public List<WorkReportToUserDTO> listReportMessageUsers(Long userId, String keyword) {
|
||||
requireUser(userId);
|
||||
if (!isDailyReportNotifyUsersVisible(loadUserRoleCodes(userId))) {
|
||||
throw new BusinessException("当前角色不可使用日报通知用户功能");
|
||||
}
|
||||
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
|
||||
public WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size) {
|
||||
requireUser(userId);
|
||||
|
|
@ -272,9 +290,12 @@ public class WorkServiceImpl implements WorkService {
|
|||
@Transactional
|
||||
public Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request) {
|
||||
requireUser(userId);
|
||||
ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
|
||||
normalizeDailyReportRequest(request);
|
||||
request.setTomorrowPlan(request.getTomorrowPlan().trim());
|
||||
List<String> roleCodes = ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
|
||||
normalizeDailyReportRequest(
|
||||
request,
|
||||
isDailyReportObjectSelectionRequired(roleCodes),
|
||||
isDailyReportNotifyUsersVisible(roleCodes),
|
||||
isDailyReportAttachmentsVisible(roleCodes));
|
||||
request.setSourceType(normalizeOptionalText(request.getSourceType()));
|
||||
if (request.getSourceType() == null) {
|
||||
request.setSourceType("manual");
|
||||
|
|
@ -405,12 +426,15 @@ public class WorkServiceImpl implements WorkService {
|
|||
@Override
|
||||
public WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file) {
|
||||
requireUser(userId);
|
||||
ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
|
||||
List<String> roleCodes = ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
|
||||
if (!isDailyReportAttachmentsVisible(roleCodes)) {
|
||||
throw new BusinessException("当前角色不可使用日报附件功能");
|
||||
}
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请先选择附件");
|
||||
}
|
||||
if (file.getSize() > REPORT_ATTACHMENT_MAX_SIZE_BYTES) {
|
||||
throw new BusinessException("单个附件不能超过20MB");
|
||||
throw new BusinessException("单个附件不能超过500MB");
|
||||
}
|
||||
|
||||
String originalName = normalizeAttachmentName(file.getOriginalFilename());
|
||||
|
|
@ -471,15 +495,77 @@ public class WorkServiceImpl implements WorkService {
|
|||
}
|
||||
}
|
||||
|
||||
private void ensureWorkWriteAllowed(Long userId, String deniedMessage) {
|
||||
List<String> roleCodes = profileMapper.selectUserRoleCodes(userId);
|
||||
private List<String> ensureWorkWriteAllowed(Long userId, String deniedMessage) {
|
||||
List<String> roleCodes = loadUserRoleCodes(userId);
|
||||
boolean onlySee = roleCodes != null && roleCodes.stream()
|
||||
.filter(roleCode -> roleCode != null && !roleCode.isBlank())
|
||||
.map(roleCode -> roleCode.trim().toLowerCase())
|
||||
.anyMatch(ONLY_SEE_ROLE_CODE::equals);
|
||||
if (onlySee) {
|
||||
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) {
|
||||
|
|
@ -1056,9 +1142,14 @@ public class WorkServiceImpl implements WorkService {
|
|||
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<>();
|
||||
for (WorkReportLineItemRequest item : request.getLineItems()) {
|
||||
List<WorkReportLineItemRequest> lineItems = request.getLineItems() == null ? List.of() : request.getLineItems();
|
||||
for (WorkReportLineItemRequest item : lineItems) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1073,18 +1164,26 @@ public class WorkServiceImpl implements WorkService {
|
|||
item.setStage(normalizeOptionalText(item.getStage()));
|
||||
item.setCommunicationTime(normalizeOptionalText(item.getCommunicationTime()));
|
||||
item.setCommunicationContent(normalizeOptionalText(item.getCommunicationContent()));
|
||||
item.setAttachments(normalizeReportAttachments(item.getAttachments()));
|
||||
item.setToUsers(normalizeReportToUsers(item.getToUsers()));
|
||||
item.setAttachments(attachmentsVisible ? normalizeReportAttachments(item.getAttachments()) : List.of());
|
||||
item.setToUsers(notifyUsersVisible ? normalizeReportToUsers(item.getToUsers()) : List.of());
|
||||
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("请完整填写每一条今日工作内容");
|
||||
}
|
||||
if (objectSelectionRequired && !hasLinkedReportTarget(item)) {
|
||||
throw new BusinessException("请使用 # 选择日报关联对象");
|
||||
}
|
||||
if (item.getEditorText() == null) {
|
||||
item.setEditorText(item.getContent());
|
||||
}
|
||||
normalizedItems.add(item);
|
||||
}
|
||||
if (normalizedItems.isEmpty()) {
|
||||
throw new BusinessException("请至少填写一条今日工作内容");
|
||||
}
|
||||
request.setAttachments(normalizeReportAttachments(request.getAttachments()));
|
||||
request.setAttachments(attachmentsVisible ? normalizeReportAttachments(request.getAttachments()) : List.of());
|
||||
List<WorkTomorrowPlanItemRequest> normalizedPlanItems = new ArrayList<>();
|
||||
List<WorkTomorrowPlanItemRequest> planItems = request.getPlanItems() == null ? List.of() : request.getPlanItems();
|
||||
for (WorkTomorrowPlanItemRequest item : planItems) {
|
||||
|
|
@ -1099,9 +1198,8 @@ public class WorkServiceImpl implements WorkService {
|
|||
}
|
||||
request.setLineItems(normalizedItems);
|
||||
request.setPlanItems(normalizedPlanItems);
|
||||
request.setWorkContent(appendReportAttachmentMetadata(
|
||||
appendReportLineMetadata(buildReportPlainText(normalizedItems), normalizedItems),
|
||||
request.getAttachments()));
|
||||
String normalizedWorkContent = appendReportLineMetadata(buildReportPlainText(normalizedItems), normalizedItems);
|
||||
request.setWorkContent(appendReportAttachmentMetadata(normalizedWorkContent, request.getAttachments()));
|
||||
request.setTomorrowPlan(normalizedPlanItems.isEmpty()
|
||||
? ""
|
||||
: appendPlanItemMetadata(buildTomorrowPlanPlainText(normalizedPlanItems), normalizedPlanItems));
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ spring:
|
|||
name: unis-crm-backend
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 20MB
|
||||
max-request-size: 25MB
|
||||
max-file-size: 500MB
|
||||
max-request-size: 600MB
|
||||
datasource:
|
||||
url: jdbc:postgresql://192.168.124.202:5432/unis_crm
|
||||
username: postgres
|
||||
|
|
@ -81,3 +81,25 @@ unisbase:
|
|||
pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前}
|
||||
connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5}
|
||||
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
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 20MB
|
||||
max-request-size: 25MB
|
||||
max-file-size: 500MB
|
||||
max-request-size: 600MB
|
||||
datasource:
|
||||
url: jdbc:postgresql://127.0.0.1:5432/unis_crm
|
||||
username: postgres
|
||||
|
|
@ -90,3 +90,25 @@ unisbase:
|
|||
pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前}
|
||||
connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5}
|
||||
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.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.config.WorkReportProperties;
|
||||
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
||||
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
||||
import com.unis.crm.dto.work.WorkDailyReportDTO;
|
||||
|
|
@ -57,9 +58,12 @@ class WorkServiceImplTest {
|
|||
|
||||
private WorkServiceImpl workService;
|
||||
|
||||
private WorkReportProperties workReportProperties;
|
||||
|
||||
@BeforeEach
|
||||
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
|
||||
|
|
@ -150,8 +154,65 @@ class WorkServiceImplTest {
|
|||
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
|
||||
void saveDailyReport_shouldAllowManualTextWithoutLinkedObject() {
|
||||
workReportProperties.getDailyReport().setOptionalRoleCodes(List.of("sales_assistant"));
|
||||
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales_assistant"));
|
||||
|
||||
CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest();
|
||||
WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest();
|
||||
lineItem.setWorkDate("2026-04-28");
|
||||
|
|
@ -203,6 +264,59 @@ class WorkServiceImplTest {
|
|||
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
|
||||
void exportCheckIns_shouldCleanPhotoMetadata() {
|
||||
WorkCheckInExportDTO row = new WorkCheckInExportDTO();
|
||||
|
|
|
|||
|
|
@ -318,6 +318,10 @@ export interface WorkReportAttachment {
|
|||
size?: number;
|
||||
}
|
||||
|
||||
export interface UploadReportAttachmentOptions {
|
||||
onProgress?: (progress: number) => void;
|
||||
}
|
||||
|
||||
export interface WorkReportToUser {
|
||||
userId: number;
|
||||
name: string;
|
||||
|
|
@ -368,6 +372,12 @@ export interface WorkOverview {
|
|||
history?: WorkHistoryItem[];
|
||||
}
|
||||
|
||||
export interface WorkDailyReportRequirement {
|
||||
objectSelectionRequired?: boolean;
|
||||
notifyUsersVisible?: boolean;
|
||||
attachmentsVisible?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkHistoryPage {
|
||||
items?: WorkHistoryItem[];
|
||||
hasMore?: boolean;
|
||||
|
|
@ -1311,6 +1321,10 @@ export async function getWorkOverview() {
|
|||
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) {
|
||||
const params = new URLSearchParams();
|
||||
const normalizedKeyword = keyword?.trim() || "";
|
||||
|
|
@ -1396,13 +1410,61 @@ export async function uploadWorkCheckInPhoto(file: File) {
|
|||
}, true);
|
||||
}
|
||||
|
||||
export async function uploadWorkReportAttachment(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return request<WorkReportAttachment>("/api/work/report-attachments", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}, true);
|
||||
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();
|
||||
formData.append("file", file);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
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 { zhCN } from "date-fns/locale";
|
||||
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 { Link, Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
getProfileOverview,
|
||||
getWorkCheckInExportData,
|
||||
getWorkDailyReportExportData,
|
||||
getWorkDailyReportRequirement,
|
||||
getWorkReportHistoryItem,
|
||||
refreshCurrentUser,
|
||||
getWorkHistory,
|
||||
|
|
@ -80,6 +81,7 @@ const LEGACY_NEXT_PLAN_LABEL = "后续规划";
|
|||
const OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划";
|
||||
const OPPORTUNITY_STAGE_LABEL = "项目阶段";
|
||||
const WORK_DETAIL_NEXT_PLAN_HEADER = "后续规划 / 下一步销售计划";
|
||||
const REPORT_ATTACHMENT_MAX_SIZE_BYTES = 500 * 1024 * 1024;
|
||||
|
||||
const reportFieldLabels = {
|
||||
sales: ["沟通内容", LEGACY_NEXT_PLAN_LABEL],
|
||||
|
|
@ -797,6 +799,9 @@ export default function Work() {
|
|||
const [submittingReport, setSubmittingReport] = useState(false);
|
||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||
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 [locationHint, setLocationHint] = useState("");
|
||||
const [locationAdjustOpen, setLocationAdjustOpen] = useState(false);
|
||||
|
|
@ -823,6 +828,10 @@ export default function Work() {
|
|||
const [historyPresenterPickerOpen, setHistoryPresenterPickerOpen] = useState(false);
|
||||
const [expandedHistoryGroupKey, setExpandedHistoryGroupKey] = useState<string | null>(null);
|
||||
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 [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
|
||||
const [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
|
||||
|
|
@ -1196,19 +1205,31 @@ export default function Work() {
|
|||
|
||||
async function loadUserContext() {
|
||||
try {
|
||||
const [userData, overviewData] = await Promise.all([
|
||||
const [userData, overviewData, requirementData] = await Promise.all([
|
||||
((currentUser?.roleCodes?.length ?? 0) > 0 ? getCurrentUser() : refreshCurrentUser()).catch(() => null),
|
||||
getProfileOverview().catch(() => null),
|
||||
getWorkDailyReportRequirement().catch(() => null),
|
||||
]);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setCurrentUser(userData);
|
||||
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 {
|
||||
if (!cancelled) {
|
||||
setCurrentUser(null);
|
||||
setProfileOverview(null);
|
||||
setDailyReportFeatureLoaded(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1925,12 +1946,31 @@ export default function Work() {
|
|||
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("");
|
||||
setReportSuccess("");
|
||||
setUploadingReportAttachmentIndex(index);
|
||||
setReportAttachmentUploadErrors((current) => {
|
||||
const next = { ...current };
|
||||
delete next[index];
|
||||
return next;
|
||||
});
|
||||
setUploadingReportAttachmentProgress((current) => ({ ...current, [index]: 0 }));
|
||||
|
||||
try {
|
||||
const attachment = await uploadWorkReportAttachment(file);
|
||||
const attachment = await uploadWorkReportAttachment(file, {
|
||||
onProgress: (progress) => {
|
||||
setUploadingReportAttachmentProgress((current) => ({ ...current, [index]: progress }));
|
||||
},
|
||||
});
|
||||
setReportForm((current) => ({
|
||||
...current,
|
||||
lineItems: current.lineItems.map((item, itemIndex) => {
|
||||
|
|
@ -1947,10 +1987,18 @@ export default function Work() {
|
|||
};
|
||||
}),
|
||||
}));
|
||||
reportAttachmentFileRef.current[index] = null;
|
||||
} catch (error) {
|
||||
setReportError(error instanceof Error ? error.message : "附件上传失败");
|
||||
const message = error instanceof Error ? error.message : "附件上传失败";
|
||||
setReportError(message);
|
||||
setReportAttachmentUploadErrors((current) => ({ ...current, [index]: message }));
|
||||
} finally {
|
||||
setUploadingReportAttachmentIndex(null);
|
||||
setUploadingReportAttachmentProgress((current) => {
|
||||
const next = { ...current };
|
||||
delete next[index];
|
||||
return next;
|
||||
});
|
||||
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) => {
|
||||
setReportForm((current) => ({
|
||||
...current,
|
||||
|
|
@ -2056,11 +2116,16 @@ export default function Work() {
|
|||
setSubmittingReport(true);
|
||||
|
||||
try {
|
||||
if (!dailyReportFeatureLoaded) {
|
||||
throw new Error("日报功能配置加载中,请稍后重试");
|
||||
}
|
||||
if (isOnlySeeRole) {
|
||||
throw new Error("当前角色仅可查看日报历史记录");
|
||||
}
|
||||
const normalizedLineItems = reportForm.lineItems.map((item) => (
|
||||
normalizeReportLineItem(item, currentWorkDate, reportOpportunityStageOptions)
|
||||
)).map((item) => (
|
||||
normalizeLineFeatureVisibility(item, dailyReportNotifyUsersVisible, dailyReportAttachmentsVisible)
|
||||
));
|
||||
const normalizedPlanItems = reportForm.planItems
|
||||
.map((item) => ({ content: item.content.trim() }))
|
||||
|
|
@ -2069,7 +2134,7 @@ export default function Work() {
|
|||
if (!normalizedLineItems.length) {
|
||||
throw new Error("请至少填写一条今日工作内容");
|
||||
}
|
||||
validateReportLineItems(normalizedLineItems, reportOpportunityStageOptions);
|
||||
validateReportLineItems(normalizedLineItems, reportOpportunityStageOptions, dailyReportObjectSelectionRequired);
|
||||
|
||||
await saveWorkDailyReport({
|
||||
workContent: buildReportSummary(normalizedLineItems),
|
||||
|
|
@ -2191,7 +2256,13 @@ export default function Work() {
|
|||
onReportLineChange={handleReportLineChange}
|
||||
onReportLineStageChange={handleReportLineStageChange}
|
||||
uploadingReportAttachmentIndex={uploadingReportAttachmentIndex}
|
||||
uploadingReportAttachmentProgress={uploadingReportAttachmentProgress}
|
||||
reportAttachmentUploadErrors={reportAttachmentUploadErrors}
|
||||
dailyReportFeatureLoaded={dailyReportFeatureLoaded}
|
||||
notifyUsersVisible={dailyReportNotifyUsersVisible}
|
||||
attachmentsVisible={dailyReportAttachmentsVisible}
|
||||
onReportAttachmentChange={(index, event) => void handleReportAttachmentChange(index, event)}
|
||||
onRetryReportAttachment={handleRetryReportAttachment}
|
||||
onRemoveReportAttachment={handleRemoveReportAttachment}
|
||||
onPreviewAttachment={setPreviewAttachment}
|
||||
onAddPlanItem={handleAddPlanItem}
|
||||
|
|
@ -3734,18 +3805,52 @@ function ReportAttachmentList({
|
|||
attachments,
|
||||
onPreview,
|
||||
onRemove,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
onRetryUpload,
|
||||
}: {
|
||||
attachments?: WorkReportAttachment[];
|
||||
onPreview: (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 (
|
||||
<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
|
||||
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"
|
||||
|
|
@ -4009,7 +4114,13 @@ function ReportPanel({
|
|||
onReportLineChange,
|
||||
onReportLineStageChange,
|
||||
uploadingReportAttachmentIndex,
|
||||
uploadingReportAttachmentProgress,
|
||||
reportAttachmentUploadErrors,
|
||||
dailyReportFeatureLoaded,
|
||||
notifyUsersVisible,
|
||||
attachmentsVisible,
|
||||
onReportAttachmentChange,
|
||||
onRetryReportAttachment,
|
||||
onRemoveReportAttachment,
|
||||
onPreviewAttachment,
|
||||
onAddPlanItem,
|
||||
|
|
@ -4035,7 +4146,13 @@ function ReportPanel({
|
|||
onReportLineChange: (index: number, value: string) => void;
|
||||
onReportLineStageChange: (index: number, value: string) => void;
|
||||
uploadingReportAttachmentIndex: number | null;
|
||||
uploadingReportAttachmentProgress: Record<number, number>;
|
||||
reportAttachmentUploadErrors: Record<number, string>;
|
||||
dailyReportFeatureLoaded: boolean;
|
||||
notifyUsersVisible: boolean;
|
||||
attachmentsVisible: boolean;
|
||||
onReportAttachmentChange: (index: number, event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onRetryReportAttachment: (index: number) => void;
|
||||
onRemoveReportAttachment: (lineIndex: number, attachmentUrl: string) => void;
|
||||
onPreviewAttachment: (attachment: WorkReportAttachment) => void;
|
||||
onAddPlanItem: () => void;
|
||||
|
|
@ -4215,6 +4332,8 @@ function ReportPanel({
|
|||
: "";
|
||||
const attachments = item.attachments ?? [];
|
||||
const isUploadingAttachment = uploadingReportAttachmentIndex === index;
|
||||
const attachmentProgress = uploadingReportAttachmentProgress[index] ?? 0;
|
||||
const attachmentUploadError = reportAttachmentUploadErrors[index];
|
||||
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">
|
||||
<span
|
||||
|
|
@ -4297,11 +4416,16 @@ function ReportPanel({
|
|||
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"
|
||||
/>
|
||||
<ReportAttachmentList
|
||||
attachments={attachments}
|
||||
onPreview={onPreviewAttachment}
|
||||
onRemove={(attachment) => onRemoveReportAttachment(index, attachment.url)}
|
||||
/>
|
||||
{attachmentsVisible ? (
|
||||
<ReportAttachmentList
|
||||
attachments={attachments}
|
||||
onPreview={onPreviewAttachment}
|
||||
onRemove={(attachment) => onRemoveReportAttachment(index, attachment.url)}
|
||||
uploadProgress={isUploadingAttachment ? attachmentProgress : undefined}
|
||||
uploadError={attachmentUploadError}
|
||||
onRetryUpload={attachmentUploadError ? () => onRetryReportAttachment(index) : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
|
|
@ -4341,22 +4465,29 @@ function ReportPanel({
|
|||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<ReportAttachmentList
|
||||
attachments={attachments}
|
||||
onPreview={onPreviewAttachment}
|
||||
onRemove={(attachment) => onRemoveReportAttachment(index, attachment.url)}
|
||||
/>
|
||||
{attachmentsVisible ? (
|
||||
<ReportAttachmentList
|
||||
attachments={attachments}
|
||||
onPreview={onPreviewAttachment}
|
||||
onRemove={(attachment) => onRemoveReportAttachment(index, attachment.url)}
|
||||
uploadProgress={isUploadingAttachment ? attachmentProgress : undefined}
|
||||
uploadError={attachmentUploadError}
|
||||
onRetryUpload={attachmentUploadError ? () => onRetryReportAttachment(index) : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</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">
|
||||
<input
|
||||
ref={(element) => {
|
||||
reportAttachmentInputRefs.current[index] = element;
|
||||
}}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => onReportAttachmentChange(index, event)}
|
||||
/>
|
||||
{attachmentsVisible ? (
|
||||
<input
|
||||
ref={(element) => {
|
||||
reportAttachmentInputRefs.current[index] = element;
|
||||
}}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => onReportAttachmentChange(index, event)}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenObjectPicker(index, item.bizType || "sales")}
|
||||
|
|
@ -4366,25 +4497,36 @@ function ReportPanel({
|
|||
>
|
||||
<Hash className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenUserPicker(index)}
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-violet-200 bg-violet-50 text-violet-600 shadow-sm transition-colors hover:bg-violet-100 hover:text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300 dark:hover:bg-violet-500/20"
|
||||
title="选择通知用户"
|
||||
aria-label="选择通知用户"
|
||||
>
|
||||
<UserRoundPlus className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reportAttachmentInputRefs.current[index]?.click()}
|
||||
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"
|
||||
title={isUploadingAttachment ? "附件上传中" : "上传附件"}
|
||||
aria-label={isUploadingAttachment ? "附件上传中" : "上传附件"}
|
||||
>
|
||||
<Paperclip className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
{notifyUsersVisible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenUserPicker(index)}
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-violet-200 bg-violet-50 text-violet-600 shadow-sm transition-colors hover:bg-violet-100 hover:text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300 dark:hover:bg-violet-500/20"
|
||||
title="选择通知用户"
|
||||
aria-label="选择通知用户"
|
||||
>
|
||||
<UserRoundPlus className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
) : null}
|
||||
{attachmentsVisible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reportAttachmentInputRefs.current[index]?.click()}
|
||||
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"
|
||||
title={isUploadingAttachment ? `附件上传中 ${attachmentProgress}%` : "上传附件"}
|
||||
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" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveReportLine(index)}
|
||||
|
|
@ -4475,7 +4617,7 @@ function ReportPanel({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={submittingReport || loading}
|
||||
disabled={submittingReport || loading || !dailyReportFeatureLoaded}
|
||||
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",
|
||||
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) {
|
||||
if (!item.bizId || !item.bizName) {
|
||||
if (objectSelectionRequired) {
|
||||
throw new Error("请使用 # 选择日报关联对象");
|
||||
}
|
||||
if (!item.editorText?.trim() && !item.content?.trim()) {
|
||||
throw new Error("请填写今日工作内容");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue