From f37b7e8ddae9981287370d0a339cbf23672b3be8 Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Mon, 23 Mar 2026 09:03:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../crm/controller/ProfileController.java | 26 ++ .../unis/crm/controller/WorkController.java | 26 ++ .../crm/dto/profile/ProfileOverviewDTO.java | 86 ++++ .../dto/work/CreateWorkCheckInRequest.java | 10 + .../com/unis/crm/dto/work/WorkCheckInDTO.java | 10 + .../unis/crm/dto/work/WorkHistoryItemDTO.java | 11 + .../crm/dto/work/WorkSuggestedActionDTO.java | 23 + .../com/unis/crm/mapper/ProfileMapper.java | 22 + .../java/com/unis/crm/mapper/WorkMapper.java | 3 +- .../com/unis/crm/service/ProfileService.java | 8 + .../com/unis/crm/service/WorkService.java | 6 + .../crm/service/impl/ProfileServiceImpl.java | 59 +++ .../crm/service/impl/WorkServiceImpl.java | 400 ++++++++++++++++-- backend/src/main/resources/application.yml | 4 + .../mapper/profile/ProfileMapper.xml | 84 ++++ .../main/resources/mapper/work/WorkMapper.xml | 46 +- backend/target/classes/application.yml | 4 + .../crm/controller/ProfileController.class | Bin 0 -> 1553 bytes .../unis/crm/controller/WorkController.class | Bin 3754 -> 6301 bytes .../crm/dto/profile/ProfileOverviewDTO.class | Bin 0 -> 2517 bytes .../dto/work/CreateWorkCheckInRequest.class | Bin 1707 -> 2178 bytes .../unis/crm/dto/work/WorkCheckInDTO.class | Bin 2141 -> 2612 bytes .../crm/dto/work/WorkHistoryItemDTO.class | Bin 2108 -> 2579 bytes .../crm/dto/work/WorkSuggestedActionDTO.class | Bin 0 -> 829 bytes .../com/unis/crm/mapper/ProfileMapper.class | Bin 0 -> 958 bytes .../com/unis/crm/mapper/WorkMapper.class | Bin 1986 -> 2016 bytes .../com/unis/crm/service/ProfileService.class | Bin 0 -> 257 bytes .../com/unis/crm/service/WorkService.class | Bin 642 -> 891 bytes .../crm/service/impl/ProfileServiceImpl.class | Bin 0 -> 4846 bytes .../impl/WorkServiceImpl$PhotoMetadata.class | Bin 0 -> 2051 bytes .../crm/service/impl/WorkServiceImpl.class | Bin 14177 -> 26981 bytes .../classes/mapper/profile/ProfileMapper.xml | 84 ++++ .../target/classes/mapper/work/WorkMapper.xml | 46 +- .../compile/default-compile/createdFiles.lst | 7 + .../compile/default-compile/inputFiles.lst | 34 +- frontend/.cert/dev.crt | 19 + frontend/.cert/dev.key | 28 ++ frontend/.cert/openssl.cnf | 18 + frontend/dist/assets/index-BCZw0F7c.js | 279 ------------ frontend/dist/assets/index-Ba78XVP4.js | 298 +++++++++++++ frontend/dist/assets/index-D3WIva4A.css | 1 + frontend/dist/assets/index-D6vWgqCF.css | 1 - frontend/dist/index.html | 4 +- frontend/src/lib/auth.ts | 14 +- frontend/src/pages/Profile.tsx | 24 +- frontend/src/pages/Work.tsx | 258 +++++++---- 46 files changed, 1488 insertions(+), 455 deletions(-) create mode 100644 backend/src/main/java/com/unis/crm/controller/ProfileController.java create mode 100644 backend/src/main/java/com/unis/crm/dto/profile/ProfileOverviewDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/work/WorkSuggestedActionDTO.java create mode 100644 backend/src/main/java/com/unis/crm/mapper/ProfileMapper.java create mode 100644 backend/src/main/java/com/unis/crm/service/ProfileService.java create mode 100644 backend/src/main/java/com/unis/crm/service/impl/ProfileServiceImpl.java create mode 100644 backend/src/main/resources/mapper/profile/ProfileMapper.xml create mode 100644 backend/target/classes/com/unis/crm/controller/ProfileController.class create mode 100644 backend/target/classes/com/unis/crm/dto/profile/ProfileOverviewDTO.class create mode 100644 backend/target/classes/com/unis/crm/dto/work/WorkSuggestedActionDTO.class create mode 100644 backend/target/classes/com/unis/crm/mapper/ProfileMapper.class create mode 100644 backend/target/classes/com/unis/crm/service/ProfileService.class create mode 100644 backend/target/classes/com/unis/crm/service/impl/ProfileServiceImpl.class create mode 100644 backend/target/classes/com/unis/crm/service/impl/WorkServiceImpl$PhotoMetadata.class create mode 100644 backend/target/classes/mapper/profile/ProfileMapper.xml create mode 100644 frontend/.cert/dev.crt create mode 100644 frontend/.cert/dev.key create mode 100644 frontend/.cert/openssl.cnf delete mode 100644 frontend/dist/assets/index-BCZw0F7c.js create mode 100644 frontend/dist/assets/index-Ba78XVP4.js create mode 100644 frontend/dist/assets/index-D3WIva4A.css delete mode 100644 frontend/dist/assets/index-D6vWgqCF.css diff --git a/backend/src/main/java/com/unis/crm/controller/ProfileController.java b/backend/src/main/java/com/unis/crm/controller/ProfileController.java new file mode 100644 index 0000000..3598c94 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/controller/ProfileController.java @@ -0,0 +1,26 @@ +package com.unis.crm.controller; + +import com.unis.crm.common.ApiResponse; +import com.unis.crm.common.CurrentUserUtils; +import com.unis.crm.dto.profile.ProfileOverviewDTO; +import com.unis.crm.service.ProfileService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/profile") +public class ProfileController { + + private final ProfileService profileService; + + public ProfileController(ProfileService profileService) { + this.profileService = profileService; + } + + @GetMapping("/overview") + public ApiResponse getOverview(@RequestHeader("X-User-Id") Long userId) { + return ApiResponse.success(profileService.getOverview(CurrentUserUtils.requireCurrentUserId(userId))); + } +} diff --git a/backend/src/main/java/com/unis/crm/controller/WorkController.java b/backend/src/main/java/com/unis/crm/controller/WorkController.java index 9c1f3d3..5a9ebcb 100644 --- a/backend/src/main/java/com/unis/crm/controller/WorkController.java +++ b/backend/src/main/java/com/unis/crm/controller/WorkController.java @@ -10,13 +10,23 @@ import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.Valid; import java.math.BigDecimal; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; + +import java.util.concurrent.TimeUnit; @RestController @RequestMapping("/api/work") @@ -49,6 +59,22 @@ public class WorkController { return ApiResponse.success(workService.saveCheckIn(CurrentUserUtils.requireCurrentUserId(userId), request)); } + @PostMapping(path = "/checkin-photos", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadCheckInPhoto( + @RequestHeader("X-User-Id") Long userId, + @RequestPart("file") MultipartFile file) { + return ApiResponse.success(workService.uploadCheckInPhoto(CurrentUserUtils.requireCurrentUserId(userId), file)); + } + + @GetMapping("/checkin-photos/{fileName:.+}") + public ResponseEntity getCheckInPhoto(@PathVariable("fileName") String fileName) { + Resource resource = workService.loadCheckInPhoto(fileName); + return ResponseEntity.ok() + .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) + .cacheControl(CacheControl.maxAge(7, TimeUnit.DAYS)) + .body(resource); + } + @PostMapping("/daily-reports") public ApiResponse saveDailyReport( @RequestHeader("X-User-Id") Long userId, diff --git a/backend/src/main/java/com/unis/crm/dto/profile/ProfileOverviewDTO.java b/backend/src/main/java/com/unis/crm/dto/profile/ProfileOverviewDTO.java new file mode 100644 index 0000000..528133b --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/profile/ProfileOverviewDTO.java @@ -0,0 +1,86 @@ +package com.unis.crm.dto.profile; + +public class ProfileOverviewDTO { + + private Long userId; + private Long monthlyOpportunityCount; + private Long monthlyExpansionCount; + private Integer averageScore; + private Long onboardingDays; + private String realName; + private String jobTitle; + private String deptName; + private String accountStatus; + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Long getMonthlyOpportunityCount() { + return monthlyOpportunityCount; + } + + public void setMonthlyOpportunityCount(Long monthlyOpportunityCount) { + this.monthlyOpportunityCount = monthlyOpportunityCount; + } + + public Long getMonthlyExpansionCount() { + return monthlyExpansionCount; + } + + public void setMonthlyExpansionCount(Long monthlyExpansionCount) { + this.monthlyExpansionCount = monthlyExpansionCount; + } + + public Integer getAverageScore() { + return averageScore; + } + + public void setAverageScore(Integer averageScore) { + this.averageScore = averageScore; + } + + public Long getOnboardingDays() { + return onboardingDays; + } + + public void setOnboardingDays(Long onboardingDays) { + this.onboardingDays = onboardingDays; + } + + public String getRealName() { + return realName; + } + + public void setRealName(String realName) { + this.realName = realName; + } + + public String getJobTitle() { + return jobTitle; + } + + public void setJobTitle(String jobTitle) { + this.jobTitle = jobTitle; + } + + public String getDeptName() { + return deptName; + } + + public void setDeptName(String deptName) { + this.deptName = deptName; + } + + public String getAccountStatus() { + return accountStatus; + } + + public void setAccountStatus(String accountStatus) { + this.accountStatus = accountStatus; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/work/CreateWorkCheckInRequest.java b/backend/src/main/java/com/unis/crm/dto/work/CreateWorkCheckInRequest.java index d23d773..51deaa9 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/CreateWorkCheckInRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/work/CreateWorkCheckInRequest.java @@ -3,6 +3,7 @@ package com.unis.crm.dto.work; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import java.math.BigDecimal; +import java.util.List; public class CreateWorkCheckInRequest { @@ -15,6 +16,7 @@ public class CreateWorkCheckInRequest { private BigDecimal longitude; private BigDecimal latitude; + private List photoUrls; public String getLocationText() { return locationText; @@ -47,4 +49,12 @@ public class CreateWorkCheckInRequest { public void setLatitude(BigDecimal latitude) { this.latitude = latitude; } + + public List getPhotoUrls() { + return photoUrls; + } + + public void setPhotoUrls(List photoUrls) { + this.photoUrls = photoUrls; + } } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInDTO.java index 690e27e..fbf0098 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInDTO.java @@ -1,6 +1,7 @@ package com.unis.crm.dto.work; import java.math.BigDecimal; +import java.util.List; public class WorkCheckInDTO { @@ -12,6 +13,7 @@ public class WorkCheckInDTO { private String status; private BigDecimal longitude; private BigDecimal latitude; + private List photoUrls; public Long getId() { return id; @@ -76,4 +78,12 @@ public class WorkCheckInDTO { public void setLatitude(BigDecimal latitude) { this.latitude = latitude; } + + public List getPhotoUrls() { + return photoUrls; + } + + public void setPhotoUrls(List photoUrls) { + this.photoUrls = photoUrls; + } } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkHistoryItemDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkHistoryItemDTO.java index fec76b4..ad6b5d7 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkHistoryItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkHistoryItemDTO.java @@ -1,5 +1,7 @@ package com.unis.crm.dto.work; +import java.util.List; + public class WorkHistoryItemDTO { private Long id; @@ -10,6 +12,7 @@ public class WorkHistoryItemDTO { private String status; private Integer score; private String comment; + private List photoUrls; public Long getId() { return id; @@ -74,4 +77,12 @@ public class WorkHistoryItemDTO { public void setComment(String comment) { this.comment = comment; } + + public List getPhotoUrls() { + return photoUrls; + } + + public void setPhotoUrls(List photoUrls) { + this.photoUrls = photoUrls; + } } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkSuggestedActionDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkSuggestedActionDTO.java new file mode 100644 index 0000000..7078f53 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkSuggestedActionDTO.java @@ -0,0 +1,23 @@ +package com.unis.crm.dto.work; + +public class WorkSuggestedActionDTO { + + private String groupName; + private String detail; + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public String getDetail() { + return detail; + } + + public void setDetail(String detail) { + this.detail = detail; + } +} diff --git a/backend/src/main/java/com/unis/crm/mapper/ProfileMapper.java b/backend/src/main/java/com/unis/crm/mapper/ProfileMapper.java new file mode 100644 index 0000000..bc51c32 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/mapper/ProfileMapper.java @@ -0,0 +1,22 @@ +package com.unis.crm.mapper; + +import com.unis.crm.dto.profile.ProfileOverviewDTO; +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ProfileMapper { + + ProfileOverviewDTO selectProfileOverview(@Param("userId") Long userId); + + List selectUserRoleNames(@Param("userId") Long userId); + + List selectUserOrgNames(@Param("userId") Long userId); + + Long selectMonthlyOpportunityCount(@Param("userId") Long userId); + + Long selectMonthlyExpansionCount(@Param("userId") Long userId); + + Integer selectAverageScore(@Param("userId") Long userId); +} diff --git a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java index 2c98f1a..db5f6b7 100644 --- a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java @@ -5,6 +5,7 @@ import com.unis.crm.dto.work.CreateWorkDailyReportRequest; import com.unis.crm.dto.work.WorkCheckInDTO; import com.unis.crm.dto.work.WorkDailyReportDTO; import com.unis.crm.dto.work.WorkHistoryItemDTO; +import com.unis.crm.dto.work.WorkSuggestedActionDTO; import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -16,7 +17,7 @@ public interface WorkMapper { WorkDailyReportDTO selectTodayReport(@Param("userId") Long userId); - List selectTodayWorkContentLines(@Param("userId") Long userId); + List selectTodayWorkContentActions(@Param("userId") Long userId); List selectHistory(@Param("userId") Long userId); diff --git a/backend/src/main/java/com/unis/crm/service/ProfileService.java b/backend/src/main/java/com/unis/crm/service/ProfileService.java new file mode 100644 index 0000000..72be393 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/ProfileService.java @@ -0,0 +1,8 @@ +package com.unis.crm.service; + +import com.unis.crm.dto.profile.ProfileOverviewDTO; + +public interface ProfileService { + + ProfileOverviewDTO getOverview(Long userId); +} diff --git a/backend/src/main/java/com/unis/crm/service/WorkService.java b/backend/src/main/java/com/unis/crm/service/WorkService.java index bca612a..dfa61f0 100644 --- a/backend/src/main/java/com/unis/crm/service/WorkService.java +++ b/backend/src/main/java/com/unis/crm/service/WorkService.java @@ -4,6 +4,8 @@ import com.unis.crm.dto.work.CreateWorkCheckInRequest; import com.unis.crm.dto.work.CreateWorkDailyReportRequest; import com.unis.crm.dto.work.WorkOverviewDTO; import java.math.BigDecimal; +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; public interface WorkService { @@ -14,4 +16,8 @@ public interface WorkService { Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request); String resolveLocationName(BigDecimal latitude, BigDecimal longitude); + + String uploadCheckInPhoto(Long userId, MultipartFile file); + + Resource loadCheckInPhoto(String fileName); } diff --git a/backend/src/main/java/com/unis/crm/service/impl/ProfileServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/ProfileServiceImpl.java new file mode 100644 index 0000000..6a897cf --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/impl/ProfileServiceImpl.java @@ -0,0 +1,59 @@ +package com.unis.crm.service.impl; + +import com.unis.crm.common.BusinessException; +import com.unis.crm.dto.profile.ProfileOverviewDTO; +import com.unis.crm.mapper.ProfileMapper; +import com.unis.crm.service.ProfileService; +import java.util.List; +import org.springframework.stereotype.Service; + +@Service +public class ProfileServiceImpl implements ProfileService { + + private final ProfileMapper profileMapper; + + public ProfileServiceImpl(ProfileMapper profileMapper) { + this.profileMapper = profileMapper; + } + + @Override + public ProfileOverviewDTO getOverview(Long userId) { + ProfileOverviewDTO overview = profileMapper.selectProfileOverview(userId); + if (overview == null) { + throw new BusinessException("未找到当前用户资料"); + } + + overview.setRealName(defaultText(overview.getRealName())); + overview.setJobTitle(defaultText(joinTexts(profileMapper.selectUserRoleNames(userId)))); + overview.setDeptName(defaultText(joinTexts(profileMapper.selectUserOrgNames(userId)))); + overview.setAccountStatus(defaultText(overview.getAccountStatus())); + overview.setOnboardingDays(defaultLong(overview.getOnboardingDays())); + overview.setMonthlyOpportunityCount(defaultLong(profileMapper.selectMonthlyOpportunityCount(userId))); + overview.setMonthlyExpansionCount(defaultLong(profileMapper.selectMonthlyExpansionCount(userId))); + overview.setAverageScore(defaultInteger(profileMapper.selectAverageScore(userId))); + return overview; + } + + private String joinTexts(List values) { + if (values == null || values.isEmpty()) { + return null; + } + return values.stream() + .filter(value -> value != null && !value.trim().isEmpty()) + .distinct() + .reduce((left, right) -> left + "、" + right) + .orElse(null); + } + + private String defaultText(String value) { + return value == null || value.trim().isEmpty() ? "无" : value; + } + + private Long defaultLong(Long value) { + return value == null ? 0L : value; + } + + private Integer defaultInteger(Integer value) { + return value == null ? 0 : value; + } +} diff --git a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java index c0090b4..29952e2 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java @@ -8,46 +8,73 @@ import com.unis.crm.dto.work.WorkCheckInDTO; import com.unis.crm.dto.work.WorkDailyReportDTO; import com.unis.crm.dto.work.WorkHistoryItemDTO; import com.unis.crm.dto.work.WorkOverviewDTO; +import com.unis.crm.dto.work.WorkSuggestedActionDTO; import com.unis.crm.mapper.WorkMapper; import com.unis.crm.service.WorkService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; import java.math.BigDecimal; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; @Service public class WorkServiceImpl implements WorkService { private static final String NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org/reverse"; + private static final String PHOTO_METADATA_PREFIX = "[[CHECKIN_PHOTOS]]"; + private static final String PHOTO_METADATA_SUFFIX = "[[/CHECKIN_PHOTOS]]"; + private static final Pattern PHOTO_METADATA_PATTERN = Pattern.compile("\\[\\[CHECKIN_PHOTOS]](.*?)\\[\\[/CHECKIN_PHOTOS]]", Pattern.DOTALL); private final WorkMapper workMapper; private final HttpClient httpClient; private final ObjectMapper objectMapper; + private final Path checkInPhotoDirectory; - public WorkServiceImpl(WorkMapper workMapper, ObjectMapper objectMapper) { + public WorkServiceImpl( + WorkMapper workMapper, + ObjectMapper objectMapper, + @Value("${unisbase.app.upload-path}") String uploadPath) { this.workMapper = workMapper; this.objectMapper = objectMapper; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(8)) .build(); + this.checkInPhotoDirectory = Paths.get(uploadPath, "work-checkin"); } @Override public WorkOverviewDTO getOverview(Long userId) { requireUser(userId); WorkCheckInDTO todayCheckIn = workMapper.selectTodayCheckIn(userId); + normalizeCheckInMetadata(todayCheckIn); enrichCheckInLocation(todayCheckIn); WorkDailyReportDTO todayReport = workMapper.selectTodayReport(userId); - String suggestedWorkContent = buildSuggestedWorkContent(workMapper.selectTodayWorkContentLines(userId)); + String suggestedWorkContent = buildSuggestedWorkContent(workMapper.selectTodayWorkContentActions(userId)); List history = workMapper.selectHistory(userId); + normalizeHistoryMetadata(history); return new WorkOverviewDTO(todayCheckIn, todayReport, suggestedWorkContent, history); } @@ -55,7 +82,12 @@ public class WorkServiceImpl implements WorkService { public Long saveCheckIn(Long userId, CreateWorkCheckInRequest request) { requireUser(userId); request.setLocationText(request.getLocationText().trim()); - request.setRemark(normalizeOptionalText(request.getRemark())); + List photoUrls = normalizePhotoUrls(request.getPhotoUrls()); + if (photoUrls.isEmpty()) { + throw new BusinessException("请先拍摄并上传现场照片"); + } + request.setRemark(appendPhotoMetadata(normalizeOptionalText(request.getRemark()), photoUrls)); + request.setPhotoUrls(photoUrls); int affectedRows = workMapper.insertCheckIn(userId, request); Long checkInId = workMapper.selectTodayCheckInId(userId); @@ -100,7 +132,7 @@ public class WorkServiceImpl implements WorkService { try { String requestUrl = NOMINATIM_BASE_URL - + "?format=jsonv2&addressdetails=1&namedetails=1&extratags=1&zoom=18" + + "?format=jsonv2&addressdetails=1&namedetails=1&extratags=1&zoom=19" + "&lat=" + URLEncoder.encode(latitude.stripTrailingZeros().toPlainString(), StandardCharsets.UTF_8) + "&lon=" + URLEncoder.encode(longitude.stripTrailingZeros().toPlainString(), StandardCharsets.UTF_8) + "&accept-language=" + URLEncoder.encode("zh-CN,zh;q=0.9,en;q=0.8", StandardCharsets.UTF_8); @@ -120,7 +152,7 @@ public class WorkServiceImpl implements WorkService { JsonNode root = objectMapper.readTree(response.body()); JsonNode addressNode = root.path("address"); - String orderedLocation = buildOrderedLocationName(addressNode); + String orderedLocation = buildOrderedLocationName(root, addressNode); if (orderedLocation != null) { return orderedLocation; } @@ -142,33 +174,233 @@ public class WorkServiceImpl implements WorkService { throw new BusinessException("未能解析出具体地点名称"); } + @Override + public String uploadCheckInPhoto(Long userId, MultipartFile file) { + requireUser(userId); + if (file == null || file.isEmpty()) { + throw new BusinessException("请先选择现场照片"); + } + + String contentType = normalizeOptionalText(file.getContentType()); + if (contentType == null || !contentType.startsWith("image/")) { + throw new BusinessException("仅支持上传图片文件"); + } + + String extension = resolveFileExtension(contentType, file.getOriginalFilename()); + String fileName = userId + "-" + UUID.randomUUID().toString().replace("-", "") + extension; + + try { + Files.createDirectories(checkInPhotoDirectory); + Path targetPath = checkInPhotoDirectory.resolve(fileName).normalize(); + if (!targetPath.startsWith(checkInPhotoDirectory)) { + throw new BusinessException("图片路径非法"); + } + Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING); + return "/api/work/checkin-photos/" + fileName; + } catch (IOException exception) { + throw new BusinessException("现场照片上传失败,请稍后重试"); + } + } + + @Override + public Resource loadCheckInPhoto(String fileName) { + String normalizedFileName = normalizeOptionalText(fileName); + if (normalizedFileName == null || normalizedFileName.contains("..") || normalizedFileName.contains("/") || normalizedFileName.contains("\\")) { + throw new BusinessException("图片不存在"); + } + + try { + Path filePath = checkInPhotoDirectory.resolve(normalizedFileName).normalize(); + if (!filePath.startsWith(checkInPhotoDirectory) || !Files.exists(filePath)) { + throw new BusinessException("图片不存在"); + } + return new UrlResource(filePath.toUri()); + } catch (IOException exception) { + throw new BusinessException("图片读取失败"); + } + } + private void requireUser(Long userId) { if (userId == null || userId <= 0) { throw new BusinessException("未获取到当前登录用户"); } } - private String buildSuggestedWorkContent(List lines) { - if (lines == null || lines.isEmpty()) { + private String buildSuggestedWorkContent(List actions) { + if (actions == null || actions.isEmpty()) { + return ""; + } + + Map> groupedActions = new LinkedHashMap<>(); + for (WorkSuggestedActionDTO action : actions) { + if (action == null) { + continue; + } + String groupName = normalizeOptionalText(action.getGroupName()); + String detail = normalizeOptionalText(action.getDetail()); + if (groupName == null || detail == null) { + continue; + } + groupedActions.computeIfAbsent(groupName, key -> new ArrayList<>()).add(detail); + } + + if (groupedActions.isEmpty()) { return ""; } StringBuilder builder = new StringBuilder(); int index = 1; - for (String line : lines) { - String normalized = normalizeOptionalText(line); - if (normalized == null) { - continue; - } + for (Map.Entry> entry : groupedActions.entrySet()) { if (builder.length() > 0) { builder.append('\n'); } - builder.append(index).append(". ").append(normalized); + builder.append(index) + .append(". ") + .append(formatGroupedSentence(entry.getKey(), entry.getValue())); index++; } return builder.toString(); } + private String formatGroupedSentence(String groupName, List details) { + List sentenceParts = new ArrayList<>(); + for (String detail : details) { + String phrase = buildActionPhrase(detail); + if (phrase != null) { + sentenceParts.add(phrase); + } + } + + if (sentenceParts.isEmpty()) { + return groupName; + } + + StringBuilder builder = new StringBuilder("今天围绕").append(groupName).append(","); + for (int index = 0; index < sentenceParts.size(); index++) { + if (index == 0) { + builder.append(sentenceParts.get(index)); + } else { + builder.append(";并").append(sentenceParts.get(index)); + } + } + return builder.toString(); + } + + private String buildActionPhrase(String detail) { + if (detail == null) { + return null; + } + + if (detail.startsWith("新增销售拓展")) { + return "新增了销售拓展" + formatAttributes(detail.substring("新增销售拓展".length())); + } + if (detail.startsWith("新增渠道拓展")) { + return "新增了渠道拓展" + formatAttributes(detail.substring("新增渠道拓展".length())); + } + if (detail.startsWith("新增商机:")) { + return buildOpportunityCreatePhrase(detail.substring("新增商机:".length())); + } + if (detail.startsWith("销售拓展跟进")) { + return buildFollowUpPhrase("销售拓展", detail.substring("销售拓展跟进".length())); + } + if (detail.startsWith("渠道拓展跟进")) { + return buildFollowUpPhrase("渠道拓展", detail.substring("渠道拓展跟进".length())); + } + if (detail.startsWith("商机跟进")) { + return buildFollowUpPhrase("商机", detail.substring("商机跟进".length())); + } + return detail; + } + + private String buildOpportunityCreatePhrase(String rawDetail) { + List parts = splitDetailParts(rawDetail); + if (parts.isEmpty()) { + return "新增了商机"; + } + + String opportunityName = parts.get(0); + List attributes = new ArrayList<>(); + for (int index = 1; index < parts.size(); index++) { + String normalized = normalizeOptionalText(parts.get(index)); + if (normalized != null) { + attributes.add(normalized.replace(":", "为")); + } + } + + StringBuilder builder = new StringBuilder("新增了商机“").append(opportunityName).append("”"); + if (!attributes.isEmpty()) { + builder.append(",").append(String.join(",", attributes)); + } + return builder.toString(); + } + + private String buildFollowUpPhrase(String followUpLabel, String rawDetail) { + List parts = splitDetailParts(rawDetail); + String followupType = null; + String followupContent = null; + List extras = new ArrayList<>(); + + for (String part : parts) { + String normalized = normalizeOptionalText(part); + if (normalized == null) { + continue; + } + if (normalized.startsWith("方式:")) { + followupType = normalizeOptionalText(normalized.substring("方式:".length())); + } else if (normalized.startsWith("内容:")) { + followupContent = normalizeOptionalText(normalized.substring("内容:".length())); + } else { + extras.add(normalized.replace(":", "为")); + } + } + + StringBuilder builder = new StringBuilder(); + if (followupType != null) { + builder.append("通过").append(followupType).append("进行了").append(followUpLabel).append("跟进"); + } else { + builder.append("进行了").append(followUpLabel).append("跟进"); + } + if (followupContent != null) { + builder.append(",沟通内容为").append(followupContent); + } + if (!extras.isEmpty()) { + builder.append(",").append(String.join(",", extras)); + } + return builder.toString(); + } + + private String formatAttributes(String rawAttributes) { + List parts = splitDetailParts(rawAttributes); + if (parts.isEmpty()) { + return ""; + } + + List normalizedParts = new ArrayList<>(); + for (String part : parts) { + String normalized = normalizeOptionalText(part); + if (normalized != null) { + normalizedParts.add(normalized.replace(":", "为")); + } + } + return normalizedParts.isEmpty() ? "" : "," + String.join(",", normalizedParts); + } + + private List splitDetailParts(String rawDetail) { + List parts = new ArrayList<>(); + if (rawDetail == null) { + return parts; + } + + String normalized = rawDetail.replaceFirst("^,", ""); + for (String part : normalized.split(",")) { + String value = normalizeOptionalText(part); + if (value != null) { + parts.add(value); + } + } + return parts; + } + private String normalizeOptionalText(String value) { if (value == null) { return null; @@ -177,6 +409,90 @@ public class WorkServiceImpl implements WorkService { return trimmed.isEmpty() ? null : trimmed; } + private List normalizePhotoUrls(List photoUrls) { + List normalizedUrls = new ArrayList<>(); + if (photoUrls == null) { + return normalizedUrls; + } + for (String photoUrl : photoUrls) { + String normalized = normalizeOptionalText(photoUrl); + if (normalized != null && !normalizedUrls.contains(normalized)) { + normalizedUrls.add(normalized); + } + } + return normalizedUrls; + } + + private String appendPhotoMetadata(String remark, List photoUrls) { + if (photoUrls == null || photoUrls.isEmpty()) { + return remark; + } + String metadata = PHOTO_METADATA_PREFIX + String.join("||", photoUrls) + PHOTO_METADATA_SUFFIX; + return remark == null ? metadata : remark + "\n" + metadata; + } + + private void normalizeCheckInMetadata(WorkCheckInDTO todayCheckIn) { + if (todayCheckIn == null) { + return; + } + PhotoMetadata photoMetadata = extractPhotoMetadata(todayCheckIn.getRemark()); + todayCheckIn.setRemark(photoMetadata.cleanText()); + todayCheckIn.setPhotoUrls(photoMetadata.photoUrls()); + } + + private void normalizeHistoryMetadata(List historyItems) { + if (historyItems == null) { + return; + } + for (WorkHistoryItemDTO historyItem : historyItems) { + if (historyItem == null || historyItem.getContent() == null) { + continue; + } + PhotoMetadata photoMetadata = extractPhotoMetadata(historyItem.getContent()); + historyItem.setContent(photoMetadata.cleanText()); + historyItem.setPhotoUrls(photoMetadata.photoUrls()); + } + } + + private PhotoMetadata extractPhotoMetadata(String rawText) { + String normalized = rawText == null ? "" : rawText; + Matcher matcher = PHOTO_METADATA_PATTERN.matcher(normalized); + List photoUrls = new ArrayList<>(); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + String rawUrls = normalizeOptionalText(matcher.group(1)); + if (rawUrls != null) { + photoUrls.addAll(normalizePhotoUrls(Arrays.asList(rawUrls.split("\\|\\|")))); + } + matcher.appendReplacement(buffer, ""); + } + matcher.appendTail(buffer); + + String cleanText = buffer.toString() + .replaceAll("\\n备注:\\s*(\\n|$)", "$1") + .replaceAll("\\n{3,}", "\n\n") + .trim(); + return new PhotoMetadata(cleanText, photoUrls); + } + + private String resolveFileExtension(String contentType, String originalFileName) { + String originalName = normalizeOptionalText(originalFileName); + if (originalName != null) { + int index = originalName.lastIndexOf('.'); + if (index >= 0 && index < originalName.length() - 1) { + return "." + originalName.substring(index + 1).toLowerCase(); + } + } + + if ("image/png".equalsIgnoreCase(contentType)) { + return ".png"; + } + if ("image/webp".equalsIgnoreCase(contentType)) { + return ".webp"; + } + return ".jpg"; + } + private String textValue(JsonNode node, String fieldName) { if (node == null || node.isMissingNode()) { return null; @@ -200,12 +516,12 @@ public class WorkServiceImpl implements WorkService { return builder.length() == 0 ? null : builder.toString(); } - private String buildOrderedLocationName(JsonNode addressNode) { + private String buildOrderedLocationName(JsonNode rootNode, JsonNode addressNode) { if (addressNode == null || addressNode.isMissingNode()) { return null; } - return joinNonBlank( + String regionPart = joinNonBlank( textValue(addressNode, "state"), firstNonBlank( textValue(addressNode, "province"), @@ -219,35 +535,49 @@ public class WorkServiceImpl implements WorkService { firstNonBlank( textValue(addressNode, "district"), textValue(addressNode, "city_district"), - textValue(addressNode, "borough")), + textValue(addressNode, "borough")) + ); + + String streetPart = joinNonBlank( firstNonBlank( textValue(addressNode, "suburb"), textValue(addressNode, "township"), - textValue(addressNode, "residential"), - textValue(addressNode, "commercial"), - textValue(addressNode, "industrial"), - textValue(addressNode, "retail"), textValue(addressNode, "quarter"), - textValue(addressNode, "neighbourhood"), - textValue(addressNode, "village"), - textValue(addressNode, "hamlet")), - firstNonBlank( - textValue(addressNode, "city_block"), - textValue(addressNode, "residential"), - textValue(addressNode, "commercial"), - textValue(addressNode, "allotments")), + textValue(addressNode, "neighbourhood")), firstNonBlank( textValue(addressNode, "road"), textValue(addressNode, "street"), textValue(addressNode, "pedestrian")), joinNonBlank( textValue(addressNode, "house_number"), - textValue(addressNode, "house_name")), - firstNonBlank( - textValue(addressNode, "building"), - textValue(addressNode, "amenity"), - textValue(addressNode, "office"), - textValue(addressNode, "shop")) + textValue(addressNode, "house_name")) + ); + + String buildingPart = firstNonBlank( + textValue(addressNode, "building"), + textValue(addressNode, "city_block"), + textValue(addressNode, "amenity"), + textValue(addressNode, "office"), + textValue(addressNode, "shop"), + textValue(addressNode, "commercial"), + textValue(addressNode, "residential"), + textValue(addressNode, "industrial"), + textValue(addressNode, "retail"), + textValue(addressNode, "village"), + textValue(addressNode, "hamlet"), + textValue(rootNode.path("namedetails"), "name"), + textValue(rootNode.path("namedetails"), "official_name"), + textValue(rootNode.path("namedetails"), "short_name") + ); + + String result = joinNonBlank(regionPart, streetPart, buildingPart); + if (result != null) { + return result; + } + + return joinNonBlank( + regionPart, + streetPart ); } @@ -330,4 +660,6 @@ public class WorkServiceImpl implements WorkService { } return null; } + + private record PhotoMetadata(String cleanText, List photoUrls) {} } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 49479e7..1851c13 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -4,6 +4,10 @@ server: spring: application: name: unis-crm-backend + servlet: + multipart: + max-file-size: 20MB + max-request-size: 25MB datasource: url: jdbc:postgresql://127.0.0.1:5432/nex_auth username: postgres diff --git a/backend/src/main/resources/mapper/profile/ProfileMapper.xml b/backend/src/main/resources/mapper/profile/ProfileMapper.xml new file mode 100644 index 0000000..729b6cd --- /dev/null +++ b/backend/src/main/resources/mapper/profile/ProfileMapper.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/mapper/work/WorkMapper.xml b/backend/src/main/resources/mapper/work/WorkMapper.xml index 3c219e7..44a1ca0 100644 --- a/backend/src/main/resources/mapper/work/WorkMapper.xml +++ b/backend/src/main/resources/mapper/work/WorkMapper.xml @@ -47,13 +47,15 @@ limit 1 - + select + group_name as groupName, + detail from ( select coalesce(s.created_at, now()) as action_time, - '新增销售拓展:' || - coalesce(s.candidate_name, '未命名') || + coalesce(s.candidate_name, '销售拓展') as group_name, + '新增销售拓展' || case when s.title is not null and btrim(s.title) <> '' then ',岗位:' || s.title else '' @@ -61,7 +63,7 @@ case when s.intent_level is not null and btrim(s.intent_level) <> '' then ',意向:' || s.intent_level else '' - end as line + end as detail from crm_sales_expansion s where s.owner_user_id = #{userId} and s.created_at::date = current_date @@ -70,8 +72,8 @@ select coalesce(c.created_at, now()) as action_time, - '新增渠道拓展:' || - coalesce(c.channel_name, '未命名渠道') || + coalesce(c.channel_name, '渠道拓展') as group_name, + '新增渠道拓展' || case when c.province is not null and btrim(c.province) <> '' then ',地区:' || c.province else '' @@ -79,7 +81,7 @@ case when c.industry is not null and btrim(c.industry) <> '' then ',行业:' || c.industry else '' - end as line + end as detail from crm_channel_expansion c where c.owner_user_id = #{userId} and c.created_at::date = current_date @@ -88,16 +90,13 @@ select coalesce(o.created_at, now()) as action_time, + coalesce(nullif(btrim(cust.customer_name), ''), nullif(btrim(o.opportunity_name), ''), '商机客户') as group_name, '新增商机:' || coalesce(o.opportunity_name, '未命名商机') || - case - when cust.customer_name is not null and btrim(cust.customer_name) <> '' then ',客户:' || cust.customer_name - else '' - end || case when o.amount is not null then ',金额:¥' || trim(to_char(o.amount, 'FM9999999999990.00')) else '' - end as line + end as detail from crm_opportunity o left join crm_customer cust on cust.id = o.customer_id where o.owner_user_id = #{userId} @@ -107,8 +106,8 @@ select coalesce(f.followup_time, now()) as action_time, - '销售拓展跟进:' || - coalesce(s.candidate_name, '未命名') || + coalesce(s.candidate_name, '销售拓展') as group_name, + '销售拓展跟进' || case when f.followup_type is not null and btrim(f.followup_type) <> '' then ',方式:' || f.followup_type else '' @@ -116,7 +115,7 @@ case when f.content is not null and btrim(f.content) <> '' then ',内容:' || f.content else '' - end as line + end as detail from crm_expansion_followup f join crm_sales_expansion s on s.id = f.biz_id and f.biz_type = 'sales' where f.followup_user_id = #{userId} @@ -126,8 +125,8 @@ select coalesce(f.followup_time, now()) as action_time, - '渠道拓展跟进:' || - coalesce(c.channel_name, '未命名渠道') || + coalesce(c.channel_name, '渠道拓展') as group_name, + '渠道拓展跟进' || case when f.followup_type is not null and btrim(f.followup_type) <> '' then ',方式:' || f.followup_type else '' @@ -135,7 +134,7 @@ case when f.content is not null and btrim(f.content) <> '' then ',内容:' || f.content else '' - end as line + end as detail from crm_expansion_followup f join crm_channel_expansion c on c.id = f.biz_id and f.biz_type = 'channel' where f.followup_user_id = #{userId} @@ -145,8 +144,8 @@ select coalesce(f.followup_time, now()) as action_time, - '商机跟进:' || - coalesce(o.opportunity_name, '未命名商机') || + coalesce(nullif(btrim(cust.customer_name), ''), nullif(btrim(o.opportunity_name), ''), '商机客户') as group_name, + '商机跟进' || case when f.followup_type is not null and btrim(f.followup_type) <> '' then ',方式:' || f.followup_type else '' @@ -154,13 +153,14 @@ case when f.content is not null and btrim(f.content) <> '' then ',内容:' || f.content else '' - end as line + end as detail from crm_opportunity_followup f join crm_opportunity o on o.id = f.opportunity_id + left join crm_customer cust on cust.id = o.customer_id where f.followup_user_id = #{userId} and f.followup_time::date = current_date ) work_lines - order by action_time asc, line asc + order by action_time asc, group_name asc, detail asc + select + u.user_id as userId, + u.display_name as realName, + case + when u.created_at is null then 0 + else greatest((current_date - u.created_at::date)::bigint, 0) + end as onboardingDays, + case + when u.status = 1 then '正常' + else '停用' + end as accountStatus + from sys_user u + where u.user_id = #{userId} + and u.is_deleted = 0 + limit 1 + + + + + + + + + + + + diff --git a/backend/target/classes/mapper/work/WorkMapper.xml b/backend/target/classes/mapper/work/WorkMapper.xml index 3c219e7..44a1ca0 100644 --- a/backend/target/classes/mapper/work/WorkMapper.xml +++ b/backend/target/classes/mapper/work/WorkMapper.xml @@ -47,13 +47,15 @@ limit 1 - + select + group_name as groupName, + detail from ( select coalesce(s.created_at, now()) as action_time, - '新增销售拓展:' || - coalesce(s.candidate_name, '未命名') || + coalesce(s.candidate_name, '销售拓展') as group_name, + '新增销售拓展' || case when s.title is not null and btrim(s.title) <> '' then ',岗位:' || s.title else '' @@ -61,7 +63,7 @@ case when s.intent_level is not null and btrim(s.intent_level) <> '' then ',意向:' || s.intent_level else '' - end as line + end as detail from crm_sales_expansion s where s.owner_user_id = #{userId} and s.created_at::date = current_date @@ -70,8 +72,8 @@ select coalesce(c.created_at, now()) as action_time, - '新增渠道拓展:' || - coalesce(c.channel_name, '未命名渠道') || + coalesce(c.channel_name, '渠道拓展') as group_name, + '新增渠道拓展' || case when c.province is not null and btrim(c.province) <> '' then ',地区:' || c.province else '' @@ -79,7 +81,7 @@ case when c.industry is not null and btrim(c.industry) <> '' then ',行业:' || c.industry else '' - end as line + end as detail from crm_channel_expansion c where c.owner_user_id = #{userId} and c.created_at::date = current_date @@ -88,16 +90,13 @@ select coalesce(o.created_at, now()) as action_time, + coalesce(nullif(btrim(cust.customer_name), ''), nullif(btrim(o.opportunity_name), ''), '商机客户') as group_name, '新增商机:' || coalesce(o.opportunity_name, '未命名商机') || - case - when cust.customer_name is not null and btrim(cust.customer_name) <> '' then ',客户:' || cust.customer_name - else '' - end || case when o.amount is not null then ',金额:¥' || trim(to_char(o.amount, 'FM9999999999990.00')) else '' - end as line + end as detail from crm_opportunity o left join crm_customer cust on cust.id = o.customer_id where o.owner_user_id = #{userId} @@ -107,8 +106,8 @@ select coalesce(f.followup_time, now()) as action_time, - '销售拓展跟进:' || - coalesce(s.candidate_name, '未命名') || + coalesce(s.candidate_name, '销售拓展') as group_name, + '销售拓展跟进' || case when f.followup_type is not null and btrim(f.followup_type) <> '' then ',方式:' || f.followup_type else '' @@ -116,7 +115,7 @@ case when f.content is not null and btrim(f.content) <> '' then ',内容:' || f.content else '' - end as line + end as detail from crm_expansion_followup f join crm_sales_expansion s on s.id = f.biz_id and f.biz_type = 'sales' where f.followup_user_id = #{userId} @@ -126,8 +125,8 @@ select coalesce(f.followup_time, now()) as action_time, - '渠道拓展跟进:' || - coalesce(c.channel_name, '未命名渠道') || + coalesce(c.channel_name, '渠道拓展') as group_name, + '渠道拓展跟进' || case when f.followup_type is not null and btrim(f.followup_type) <> '' then ',方式:' || f.followup_type else '' @@ -135,7 +134,7 @@ case when f.content is not null and btrim(f.content) <> '' then ',内容:' || f.content else '' - end as line + end as detail from crm_expansion_followup f join crm_channel_expansion c on c.id = f.biz_id and f.biz_type = 'channel' where f.followup_user_id = #{userId} @@ -145,8 +144,8 @@ select coalesce(f.followup_time, now()) as action_time, - '商机跟进:' || - coalesce(o.opportunity_name, '未命名商机') || + coalesce(nullif(btrim(cust.customer_name), ''), nullif(btrim(o.opportunity_name), ''), '商机客户') as group_name, + '商机跟进' || case when f.followup_type is not null and btrim(f.followup_type) <> '' then ',方式:' || f.followup_type else '' @@ -154,13 +153,14 @@ case when f.content is not null and btrim(f.content) <> '' then ',内容:' || f.content else '' - end as line + end as detail from crm_opportunity_followup f join crm_opportunity o on o.id = f.opportunity_id + left join crm_customer cust on cust.id = o.customer_id where f.followup_user_id = #{userId} and f.followup_time::date = current_date ) work_lines - order by action_time asc, line asc + order by action_time asc, group_name asc, detail asc without a
');let p=a.getAttribute("formaction")||m.getAttribute("action");if(r=p?qa(p,n):null,l=a.getAttribute("formmethod")||m.getAttribute("method")||eo,o=nd(a.getAttribute("formenctype"))||nd(m.getAttribute("enctype"))||to,f=new FormData(m,a),!mw()){let{name:g,type:y,value:v}=a;if(y==="image"){let S=g?`${g}.`:"";f.append(`${S}x`,"0"),f.append(`${S}y`,"0")}else g&&f.append(g,v)}}else{if(Ao(a))throw new Error('Cannot submit element that is not , +

{numericValue(overview?.averageScore)}

平均分

diff --git a/frontend/src/pages/Work.tsx b/frontend/src/pages/Work.tsx index 63c8f78..ef0775d 100644 --- a/frontend/src/pages/Work.tsx +++ b/frontend/src/pages/Work.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { MapPin, Camera, Mic, Send, CalendarDays, CheckCircle2, FileText, ListTodo, Filter, RefreshCw } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; +import { MapPin, Camera, Send, CalendarDays, CheckCircle2, FileText, ListTodo, Filter, RefreshCw, X } from "lucide-react"; import { format } from "date-fns"; import { motion } from "motion/react"; import { @@ -7,6 +7,7 @@ import { reverseWorkGeocode, saveWorkCheckIn, saveWorkDailyReport, + uploadWorkCheckInPhoto, type CreateWorkCheckInPayload, type CreateWorkDailyReportPayload, type WorkHistoryItem, @@ -41,13 +42,15 @@ function getReportStatus(status?: string) { export default function Work() { const hasAutoRefreshedLocation = useRef(false); + const photoInputRef = useRef(null); const [loading, setLoading] = useState(true); const [refreshingLocation, setRefreshingLocation] = useState(false); - const [isRecording, setIsRecording] = useState(false); const [submittingCheckIn, setSubmittingCheckIn] = useState(false); const [submittingReport, setSubmittingReport] = useState(false); + const [uploadingPhoto, setUploadingPhoto] = useState(false); const [historyFilter, setHistoryFilter] = useState<(typeof historyFilters)[number]>("全部"); const [locationHint, setLocationHint] = useState(""); + const [locationLocked, setLocationLocked] = useState(false); const [pageError, setPageError] = useState(""); const [checkInError, setCheckInError] = useState(""); const [reportError, setReportError] = useState(""); @@ -56,6 +59,7 @@ export default function Work() { const [historyData, setHistoryData] = useState([]); const [checkInStatus, setCheckInStatus] = useState(); const [reportStatus, setReportStatus] = useState(); + const [checkInPhotoUrls, setCheckInPhotoUrls] = useState([]); const [checkInForm, setCheckInForm] = useState(defaultCheckInForm); const [reportForm, setReportForm] = useState(defaultReportForm); @@ -78,66 +82,80 @@ export default function Work() { void handleRefreshLocation(); }, [loading]); - const handleRecord = () => { - if (isRecording) { - setIsRecording(false); + const handleRefreshLocation = async () => { + setCheckInError(""); + setRefreshingLocation(true); + setLocationLocked(false); + if (!window.isSecureContext && location.hostname !== "localhost" && location.hostname !== "127.0.0.1") { + setLocationHint("当前是通过 HTTP 地址在手机端访问,浏览器会直接禁止定位。请改用 HTTPS 地址打开,或先手动填写当前位置。"); + setRefreshingLocation(false); return; } - setIsRecording(true); - window.setTimeout(() => { - setReportForm((prev) => ({ - ...prev, - workContent: prev.workContent + (prev.workContent ? "\n" : "") + "今天拜访了A市第一人民医院信息科主任,沟通了云桌面扩容需求,对方表示下个月会启动招标流程。", - sourceType: "voice_assist", - })); - setIsRecording(false); - }, 2000); + setLocationHint("正在获取当前位置..."); + + try { + const position = await resolveCurrentPosition(); + const latitude = Number(position.coords.latitude.toFixed(6)); + const longitude = Number(position.coords.longitude.toFixed(6)); + + try { + const displayName = await reverseWorkGeocode(latitude, longitude); + setCheckInForm((prev) => ({ + ...prev, + locationText: displayName || `定位坐标:${latitude}, ${longitude}`, + latitude, + longitude, + })); + setLocationLocked(Boolean(displayName)); + setLocationHint(displayName + ? "定位已刷新并锁定当前位置,如需变更请点击“刷新定位”。" + : "已获取定位坐标,如需更精确地址可再次刷新定位。"); + } catch { + setCheckInForm((prev) => ({ + ...prev, + locationText: `定位坐标:${latitude}, ${longitude}`, + latitude, + longitude, + })); + setLocationLocked(false); + setLocationHint("已获取坐标,但地点名称解析失败,你也可以手动补充。"); + } + } catch (error) { + setLocationLocked(false); + setLocationHint(error instanceof Error ? error.message : "定位获取失败,请手动填写当前位置。"); + } finally { + setRefreshingLocation(false); + } }; - const handleRefreshLocation = async () => { - if (!navigator.geolocation) { - setLocationHint("当前浏览器不支持定位,请手动填写当前位置。"); + const handlePickPhoto = () => { + photoInputRef.current?.click(); + }; + + const handlePhotoChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { return; } setCheckInError(""); - setRefreshingLocation(true); - setLocationHint("正在获取当前位置..."); - navigator.geolocation.getCurrentPosition( - async (position) => { - const latitude = Number(position.coords.latitude.toFixed(6)); - const longitude = Number(position.coords.longitude.toFixed(6)); - try { - const displayName = await reverseWorkGeocode(latitude, longitude); - setCheckInForm((prev) => ({ - ...prev, - locationText: displayName || `定位坐标:${latitude}, ${longitude}`, - latitude, - longitude, - })); - setLocationHint("定位已刷新,已为你填入具体地点名称。"); - } catch { - setCheckInForm((prev) => ({ - ...prev, - locationText: `定位坐标:${latitude}, ${longitude}`, - latitude, - longitude, - })); - setLocationHint("已获取坐标,但地点名称解析失败,你也可以手动补充。"); - } finally { - setRefreshingLocation(false); - } - }, - () => { - setLocationHint("定位获取失败,请手动填写当前位置。"); - setRefreshingLocation(false); - }, - { - enableHighAccuracy: true, - timeout: 10000, - }, - ); + setCheckInSuccess(""); + setUploadingPhoto(true); + + try { + const uploadedUrl = await uploadWorkCheckInPhoto(file); + setCheckInPhotoUrls([uploadedUrl]); + } catch (error) { + setCheckInError(error instanceof Error ? error.message : "现场照片上传失败"); + } finally { + setUploadingPhoto(false); + event.target.value = ""; + } + }; + + const handleRemovePhoto = () => { + setCheckInPhotoUrls([]); }; const handleCheckInSubmit = async () => { @@ -150,14 +168,20 @@ export default function Work() { setSubmittingCheckIn(true); try { + if (!checkInPhotoUrls.length) { + throw new Error("请先拍摄并上传现场照片"); + } await saveWorkCheckIn({ locationText: checkInForm.locationText.trim(), remark: checkInForm.remark?.trim() || undefined, longitude: checkInForm.longitude, latitude: checkInForm.latitude, + photoUrls: checkInPhotoUrls, }); await loadOverview(); setCheckInForm(defaultCheckInForm); + setCheckInPhotoUrls([]); + setLocationLocked(false); setCheckInSuccess("打卡已记录,本日可继续新增打卡。"); } catch (error) { setCheckInError(error instanceof Error ? error.message : "打卡提交失败"); @@ -212,6 +236,8 @@ export default function Work() { longitude: undefined, latitude: undefined, }); + setCheckInPhotoUrls([]); + setLocationLocked(false); setReportForm({ workContent: data.suggestedWorkContent || data.todayReport?.workContent || "", tomorrowPlan: data.todayReport?.tomorrowPlan ?? "", @@ -279,7 +305,8 @@ export default function Work() { value={checkInForm.locationText} onChange={(e) => setCheckInForm((prev) => ({ ...prev, locationText: e.target.value }))} placeholder="请输入当前位置,手机端可点击“刷新定位”获取具体地点名称..." - className="w-full rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900/50 p-3 text-sm text-slate-900 dark:text-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-500" + readOnly={locationLocked} + className={`w-full rounded-xl border border-slate-200 dark:border-slate-800 p-3 text-sm text-slate-900 dark:text-white outline-none transition-all placeholder:text-slate-400 dark:placeholder:text-slate-500 ${locationLocked ? "bg-slate-50 dark:bg-slate-800/40 cursor-not-allowed" : "bg-white dark:bg-slate-900/50 focus:border-violet-500 focus:ring-1 focus:ring-violet-500"}`} /> {locationHint ? (

{locationHint}

@@ -299,15 +326,45 @@ export default function Work() {

现场照片 (必填)

-
void handleRefreshLocation()} - className="group flex flex-1 min-h-[120px] w-full cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 transition-all hover:border-violet-400 dark:hover:border-violet-500 hover:bg-violet-50 dark:hover:bg-violet-500/10" - > - - - {refreshingLocation ? "正在刷新定位..." : "点击拍照"} - -
+ void handlePhotoChange(event)} + /> + {checkInPhotoUrls.length ? ( +
+ 现场照片 + +
+ ) : ( + + )} +

+ 手机端会优先调用后置相机;如相机不可用,也可从相册选择现场照片。 +

{checkInError ?

{checkInError}

: null} {checkInSuccess ?

{checkInSuccess}

: null}
@@ -346,15 +403,6 @@ export default function Work() { 今日工作内容 -