kangwenjing 2026-06-30 15:04:30 +08:00
parent f23ef0a712
commit 8a7ee5f066
16 changed files with 715 additions and 87 deletions

View File

@ -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 {

View File

@ -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;
}
}
}

View File

@ -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,

View File

@ -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<>();

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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);

View File

@ -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));

View File

@ -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]

View File

@ -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]

View File

@ -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();

View File

@ -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) {

View File

@ -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("请填写今日工作内容");
}