main
parent
9f4d30f933
commit
f37b7e8dda
|
|
@ -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<ProfileOverviewDTO> getOverview(@RequestHeader("X-User-Id") Long userId) {
|
||||
return ApiResponse.success(profileService.getOverview(CurrentUserUtils.requireCurrentUserId(userId)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> 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<Resource> 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<Long> saveDailyReport(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> photoUrls;
|
||||
|
||||
public String getLocationText() {
|
||||
return locationText;
|
||||
|
|
@ -47,4 +49,12 @@ public class CreateWorkCheckInRequest {
|
|||
public void setLatitude(BigDecimal latitude) {
|
||||
this.latitude = latitude;
|
||||
}
|
||||
|
||||
public List<String> getPhotoUrls() {
|
||||
return photoUrls;
|
||||
}
|
||||
|
||||
public void setPhotoUrls(List<String> photoUrls) {
|
||||
this.photoUrls = photoUrls;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> photoUrls;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
|
|
@ -76,4 +78,12 @@ public class WorkCheckInDTO {
|
|||
public void setLatitude(BigDecimal latitude) {
|
||||
this.latitude = latitude;
|
||||
}
|
||||
|
||||
public List<String> getPhotoUrls() {
|
||||
return photoUrls;
|
||||
}
|
||||
|
||||
public void setPhotoUrls(List<String> photoUrls) {
|
||||
this.photoUrls = photoUrls;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> photoUrls;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
|
|
@ -74,4 +77,12 @@ public class WorkHistoryItemDTO {
|
|||
public void setComment(String comment) {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public List<String> getPhotoUrls() {
|
||||
return photoUrls;
|
||||
}
|
||||
|
||||
public void setPhotoUrls(List<String> photoUrls) {
|
||||
this.photoUrls = photoUrls;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> selectUserRoleNames(@Param("userId") Long userId);
|
||||
|
||||
List<String> selectUserOrgNames(@Param("userId") Long userId);
|
||||
|
||||
Long selectMonthlyOpportunityCount(@Param("userId") Long userId);
|
||||
|
||||
Long selectMonthlyExpansionCount(@Param("userId") Long userId);
|
||||
|
||||
Integer selectAverageScore(@Param("userId") Long userId);
|
||||
}
|
||||
|
|
@ -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<String> selectTodayWorkContentLines(@Param("userId") Long userId);
|
||||
List<WorkSuggestedActionDTO> selectTodayWorkContentActions(@Param("userId") Long userId);
|
||||
|
||||
List<WorkHistoryItemDTO> selectHistory(@Param("userId") Long userId);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package com.unis.crm.service;
|
||||
|
||||
import com.unis.crm.dto.profile.ProfileOverviewDTO;
|
||||
|
||||
public interface ProfileService {
|
||||
|
||||
ProfileOverviewDTO getOverview(Long userId);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<WorkHistoryItemDTO> 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<String> 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<String> lines) {
|
||||
if (lines == null || lines.isEmpty()) {
|
||||
private String buildSuggestedWorkContent(List<WorkSuggestedActionDTO> actions) {
|
||||
if (actions == null || actions.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
Map<String, List<String>> 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<String, List<String>> 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<String> details) {
|
||||
List<String> 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<String> parts = splitDetailParts(rawDetail);
|
||||
if (parts.isEmpty()) {
|
||||
return "新增了商机";
|
||||
}
|
||||
|
||||
String opportunityName = parts.get(0);
|
||||
List<String> 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<String> parts = splitDetailParts(rawDetail);
|
||||
String followupType = null;
|
||||
String followupContent = null;
|
||||
List<String> 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<String> parts = splitDetailParts(rawAttributes);
|
||||
if (parts.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
List<String> 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<String> splitDetailParts(String rawDetail) {
|
||||
List<String> 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<String> normalizePhotoUrls(List<String> photoUrls) {
|
||||
List<String> 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<String> 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<WorkHistoryItemDTO> 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<String> 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<String> photoUrls) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.unis.crm.mapper.ProfileMapper">
|
||||
|
||||
<select id="selectProfileOverview" resultType="com.unis.crm.dto.profile.ProfileOverviewDTO">
|
||||
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
|
||||
</select>
|
||||
|
||||
<select id="selectUserRoleNames" resultType="java.lang.String">
|
||||
select r.role_name
|
||||
from sys_user_role ur
|
||||
join sys_role r on r.role_id = ur.role_id
|
||||
where ur.user_id = #{userId}
|
||||
and ur.is_deleted = 0
|
||||
and r.is_deleted = 0
|
||||
order by r.role_id asc
|
||||
</select>
|
||||
|
||||
<select id="selectUserOrgNames" resultType="java.lang.String">
|
||||
select o.org_name
|
||||
from sys_tenant_user tu
|
||||
join sys_org o on o.id = tu.org_id
|
||||
where tu.user_id = #{userId}
|
||||
and tu.is_deleted = 0
|
||||
and o.is_deleted = 0
|
||||
order by tu.id asc
|
||||
</select>
|
||||
|
||||
<select id="selectMonthlyOpportunityCount" resultType="java.lang.Long">
|
||||
select count(1)::bigint
|
||||
from crm_opportunity
|
||||
where owner_user_id = #{userId}
|
||||
and date_trunc('month', created_at) = date_trunc('month', now())
|
||||
</select>
|
||||
|
||||
<select id="selectMonthlyExpansionCount" resultType="java.lang.Long">
|
||||
select (
|
||||
coalesce((
|
||||
select count(1)
|
||||
from crm_sales_expansion
|
||||
where owner_user_id = #{userId}
|
||||
and date_trunc('month', created_at) = date_trunc('month', now())
|
||||
), 0)
|
||||
+
|
||||
coalesce((
|
||||
select count(1)
|
||||
from crm_channel_expansion
|
||||
where owner_user_id = #{userId}
|
||||
and date_trunc('month', created_at) = date_trunc('month', now())
|
||||
), 0)
|
||||
)::bigint
|
||||
</select>
|
||||
|
||||
<select id="selectAverageScore" resultType="java.lang.Integer">
|
||||
with latest_comment as (
|
||||
select distinct on (c.report_id)
|
||||
c.report_id,
|
||||
c.score
|
||||
from work_daily_report_comment c
|
||||
order by c.report_id, c.reviewed_at desc nulls last, c.id desc
|
||||
)
|
||||
select coalesce(round(avg(coalesce(lc.score, r.score))), 0)::int
|
||||
from work_daily_report r
|
||||
left join latest_comment lc on lc.report_id = r.id
|
||||
where r.user_id = #{userId}
|
||||
and date_trunc('month', r.report_date::timestamp) = date_trunc('month', now())
|
||||
</select>
|
||||
</mapper>
|
||||
|
|
@ -47,13 +47,15 @@
|
|||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectTodayWorkContentLines" resultType="java.lang.String">
|
||||
select line
|
||||
<select id="selectTodayWorkContentActions" resultType="com.unis.crm.dto.work.WorkSuggestedActionDTO">
|
||||
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>
|
||||
|
||||
<select id="selectHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,84 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.unis.crm.mapper.ProfileMapper">
|
||||
|
||||
<select id="selectProfileOverview" resultType="com.unis.crm.dto.profile.ProfileOverviewDTO">
|
||||
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
|
||||
</select>
|
||||
|
||||
<select id="selectUserRoleNames" resultType="java.lang.String">
|
||||
select r.role_name
|
||||
from sys_user_role ur
|
||||
join sys_role r on r.role_id = ur.role_id
|
||||
where ur.user_id = #{userId}
|
||||
and ur.is_deleted = 0
|
||||
and r.is_deleted = 0
|
||||
order by r.role_id asc
|
||||
</select>
|
||||
|
||||
<select id="selectUserOrgNames" resultType="java.lang.String">
|
||||
select o.org_name
|
||||
from sys_tenant_user tu
|
||||
join sys_org o on o.id = tu.org_id
|
||||
where tu.user_id = #{userId}
|
||||
and tu.is_deleted = 0
|
||||
and o.is_deleted = 0
|
||||
order by tu.id asc
|
||||
</select>
|
||||
|
||||
<select id="selectMonthlyOpportunityCount" resultType="java.lang.Long">
|
||||
select count(1)::bigint
|
||||
from crm_opportunity
|
||||
where owner_user_id = #{userId}
|
||||
and date_trunc('month', created_at) = date_trunc('month', now())
|
||||
</select>
|
||||
|
||||
<select id="selectMonthlyExpansionCount" resultType="java.lang.Long">
|
||||
select (
|
||||
coalesce((
|
||||
select count(1)
|
||||
from crm_sales_expansion
|
||||
where owner_user_id = #{userId}
|
||||
and date_trunc('month', created_at) = date_trunc('month', now())
|
||||
), 0)
|
||||
+
|
||||
coalesce((
|
||||
select count(1)
|
||||
from crm_channel_expansion
|
||||
where owner_user_id = #{userId}
|
||||
and date_trunc('month', created_at) = date_trunc('month', now())
|
||||
), 0)
|
||||
)::bigint
|
||||
</select>
|
||||
|
||||
<select id="selectAverageScore" resultType="java.lang.Integer">
|
||||
with latest_comment as (
|
||||
select distinct on (c.report_id)
|
||||
c.report_id,
|
||||
c.score
|
||||
from work_daily_report_comment c
|
||||
order by c.report_id, c.reviewed_at desc nulls last, c.id desc
|
||||
)
|
||||
select coalesce(round(avg(coalesce(lc.score, r.score))), 0)::int
|
||||
from work_daily_report r
|
||||
left join latest_comment lc on lc.report_id = r.id
|
||||
where r.user_id = #{userId}
|
||||
and date_trunc('month', r.report_date::timestamp) = date_trunc('month', now())
|
||||
</select>
|
||||
</mapper>
|
||||
|
|
@ -47,13 +47,15 @@
|
|||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectTodayWorkContentLines" resultType="java.lang.String">
|
||||
select line
|
||||
<select id="selectTodayWorkContentActions" resultType="com.unis.crm.dto.work.WorkSuggestedActionDTO">
|
||||
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>
|
||||
|
||||
<select id="selectHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
com/unis/crm/service/impl/WorkServiceImpl$PhotoMetadata.class
|
||||
com/unis/crm/service/impl/ExpansionServiceImpl.class
|
||||
com/unis/crm/dto/dashboard/UserWelcomeDTO.class
|
||||
com/unis/crm/dto/expansion/ExpansionOverviewDTO.class
|
||||
|
|
@ -12,17 +13,23 @@ com/unis/crm/service/DashboardService.class
|
|||
com/unis/crm/dto/expansion/UpdateChannelExpansionRequest.class
|
||||
com/unis/crm/common/CrmGlobalExceptionHandler.class
|
||||
com/unis/crm/dto/dashboard/DashboardStatDTO.class
|
||||
com/unis/crm/dto/work/WorkSuggestedActionDTO.class
|
||||
com/unis/crm/dto/expansion/CreateChannelExpansionRequest.class
|
||||
com/unis/crm/dto/expansion/DepartmentOptionDTO.class
|
||||
com/unis/crm/dto/expansion/SalesExpansionItemDTO.class
|
||||
com/unis/crm/dto/dashboard/DashboardActivityDTO.class
|
||||
com/unis/crm/service/ExpansionService.class
|
||||
com/unis/crm/dto/expansion/UpdateSalesExpansionRequest.class
|
||||
com/unis/crm/dto/profile/ProfileOverviewDTO.class
|
||||
com/unis/crm/dto/dashboard/DashboardTodoDTO.class
|
||||
com/unis/crm/controller/DashboardController.class
|
||||
com/unis/crm/controller/ProfileController.class
|
||||
com/unis/crm/dto/expansion/ExpansionMetaDTO.class
|
||||
com/unis/crm/service/impl/ProfileServiceImpl.class
|
||||
com/unis/crm/mapper/ExpansionMapper.class
|
||||
com/unis/crm/dto/dashboard/DashboardHomeDTO.class
|
||||
com/unis/crm/mapper/ProfileMapper.class
|
||||
com/unis/crm/service/ProfileService.class
|
||||
com/unis/crm/common/BusinessException.class
|
||||
com/unis/crm/mapper/DashboardMapper.class
|
||||
com/unis/crm/dto/expansion/CreateExpansionFollowUpRequest.class
|
||||
|
|
|
|||
|
|
@ -3,46 +3,52 @@
|
|||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardHomeDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityFollowUpRequest.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardActivityDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionOverviewDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/work/CreateWorkCheckInRequest.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/mapper/DashboardMapper.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/common/CurrentUserUtils.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardStatDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/controller/DashboardController.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/DashboardService.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/OpportunityService.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/common/ApiResponse.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/DepartmentOptionDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/controller/WorkController.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/CreateSalesExpansionRequest.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionMetaDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardTodoDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/work/WorkHistoryItemDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/WorkService.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/profile/ProfileOverviewDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/controller/OpportunityController.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/work/WorkOverviewDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/work/WorkSuggestedActionDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/dashboard/UserWelcomeDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardActivityDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/ProfileService.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/mapper/DashboardMapper.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardStatDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/DashboardService.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/OpportunityService.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/controller/WorkController.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/CreateSalesExpansionRequest.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/controller/ExpansionController.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/common/BusinessException.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/ExpansionService.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/UpdateChannelExpansionRequest.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/ExpansionFollowUpDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/WorkService.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityOverviewDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/UpdateSalesExpansionRequest.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/controller/OpportunityController.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/opportunity/CreateOpportunityRequest.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/CreateChannelExpansionRequest.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/work/WorkOverviewDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/ProfileServiceImpl.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/expansion/CreateExpansionFollowUpRequest.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/dashboard/UserWelcomeDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/controller/ProfileController.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/mapper/ProfileMapper.java
|
||||
/Users/kangwenjing/Downloads/crm/unis_crm/backend/src/main/java/com/unis/crm/common/CrmGlobalExceptionHandler.java
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIDFjCCAf6gAwIBAgIUWm8tC2dINtWKjehd3lFmrKYK5nAwDQYJKoZIhvcNAQEL
|
||||
BQAwGTEXMBUGA1UEAwwOdW5pcy1jcm0tbG9jYWwwHhcNMjYwMzIwMDk1NTU3WhcN
|
||||
MjcwMzIwMDk1NTU3WjAZMRcwFQYDVQQDDA51bmlzLWNybS1sb2NhbDCCASIwDQYJ
|
||||
KoZIhvcNAQEBBQADggEPADCCAQoCggEBAN/BMZRp3Ylboz0yIaXg4tZjyhbfCevT
|
||||
JYoZzReakdB+FMRcGWraUp0uyLM8u9i93Bo7K0GEBOqqgfB4XkmsHD5Peyf1E5Jg
|
||||
yQlexQEwLwMbQXXrITeKt1/AUFy/cfTsafCpkCPDOeaJQ5DX9cV8HV4UGx+JbfJp
|
||||
eU3cqZTOnsx6Nv9OJb2/v9xpSvgJD6jonryUZq25h9MbZu3uuFEuQFu4q7wo9PFy
|
||||
S3dzOieBg9MHI0TRNuUslVogknio0Zo01xY714S0AzcI1xz7ImJHwTTovOSRCQvG
|
||||
/CarYi+ZioXZB/AZDskdZIV4LlnRX7lE2h2v0ZldfBzcM0FVOS6iqPkCAwEAAaNW
|
||||
MFQwMwYDVR0RBCwwKoIJbG9jYWxob3N0ghFNYWNCb29rLVByby5sb2NhbIcEfwAA
|
||||
AYcEwKgC4TAdBgNVHQ4EFgQUE93wSJmbDjBQbln1J4GhpWYdRhMwDQYJKoZIhvcN
|
||||
AQELBQADggEBAAhTdIj2/U6uxkzyUDXZ8uFSDulrK2pT8ijzfjBLeFBx3Pm8yPmp
|
||||
BGlZELjM1/MX4ntZZ5CdwTxhAXuXSA0f4xPNqlvnEm31EmhbZLAn865831bEVVaE
|
||||
eQwsV5vjVZrmugK1e2UJzmYQisjcYeawEGnNE7F5fbK7clcFKTKsEwLqX5xUajUA
|
||||
+tUfX2DxqeoPSR7YxBz5mGb+TabkybIvHkjfggTLqVWWJePPEfLXDoflCYPkz/qr
|
||||
/YgDzJJ7JRToM5Ya4nSU6dcBRDw6VMS9Hp2vbcrOeTIZ4+JN7do0h5O+csosZtD5
|
||||
p7yndpNaG1ZUCDarW50V+9sYS358oe4coo0=
|
||||
-----END CERTIFICATE-----
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDfwTGUad2JW6M9
|
||||
MiGl4OLWY8oW3wnr0yWKGc0XmpHQfhTEXBlq2lKdLsizPLvYvdwaOytBhATqqoHw
|
||||
eF5JrBw+T3sn9ROSYMkJXsUBMC8DG0F16yE3irdfwFBcv3H07GnwqZAjwznmiUOQ
|
||||
1/XFfB1eFBsfiW3yaXlN3KmUzp7Mejb/TiW9v7/caUr4CQ+o6J68lGatuYfTG2bt
|
||||
7rhRLkBbuKu8KPTxckt3czongYPTByNE0TblLJVaIJJ4qNGaNNcWO9eEtAM3CNcc
|
||||
+yJiR8E06LzkkQkLxvwmq2IvmYqF2QfwGQ7JHWSFeC5Z0V+5RNodr9GZXXwc3DNB
|
||||
VTkuoqj5AgMBAAECggEACgkPgN6L/GsgaSWPHhzLT7OYuWngzLONosysHCY8lU3y
|
||||
VH7k95vkdKmdKsd14q9b6HlFjrdYbnCd9KZmuYXUm8l+ITlRWrv93ZkN25MeU1Ss
|
||||
8kL3ccGbnoRw65f9M8T0A203dRh+ck6EZJdVrePL1WUFdRFO3b5z8wALQwyj+/ux
|
||||
xif9/WL8TpEqdT1aQUK1f67cQDyDKFu2XTqf++w7g3er8AQ0LfgnU62wcFbvUbyK
|
||||
P3qMqcSIiGU/O2TfiTLUcXX/eAcFwaOHueRSB2ul8KN6acA97XsKDfW5fhJ2RfCg
|
||||
HoVcxGHSHxNQEXJgbta1jfY4CMfonWBJhTtV3MvdOQKBgQD4/2AVC6eykEvsXNfW
|
||||
yBD7wBGrVv24ORTeYIsXxQ/hrOrIeddK2fBQvR+o/VqdG+TjIVnlLFhMJssx/F55
|
||||
yeqJJCJ+BahuO5r5N/tWCVQUfBk7VrzqCuKuCD0WY0DFjgayLRR7sHJQ0hk6/g+T
|
||||
Q1Nh4G9RsgTN/WL4DOSerZHs9wKBgQDmDBXiPcRLLGZwkgjpkZ5SAygZ4tTUsYX6
|
||||
GYZ5tqj1F573byGhE7RhoVQyDgvB5yoIR759/xjyx6VN2Xjx2UIXOZgi6i/VBrKN
|
||||
eC9b2AWdItOJdazmAn3OaMVSfkGDnLNS/bTqgOX3lAIQ9mX0EerQ9ZBWL3c3RaiX
|
||||
N/cZL69NjwKBgQCX+E1tKtrkOfuMvDGjziHHdTeTipl6GaSqAOSD0mtaAo6dYvfK
|
||||
oG9y56ruGgt2/jJZid5y5/jQKZE05eHHlTwHDb3bipp9BPYWC1vj6/ecNQ21UMAm
|
||||
XBRzwWV/R36d1IwzrR3mTA/JUiHCBpP8/kdi4ncutmcGThNcb4oNasQ4iwKBgQDX
|
||||
kx+2wTb/Uv40I0Viy2jXZcngaCYVor3wHpdb24X9F0OKj4OuFG8Gxk3GEk+GR3+l
|
||||
/sVOAQ16BQitKUwjJvq+FOSyY06ri/kqhSOsJ4UrOYWN3M5HRLJsCNZQdawjD2ck
|
||||
MEenMV9PYE2HY78qttebNWMqwJQxIX/ZJULTQVI/lQKBgQDuSDqp/jSJR1Cq2F2K
|
||||
KaL6nq+ft5hNbXclqxDdr+48xk9+DyJ7D/QFQvRFRn6SHXUmpvykgM0ioBjYgFZG
|
||||
rvtGyfTZ8+LD0LdluLf1aR6NCekRV/aR5XB/oui1JeDlgsgTq9HY7Bjrnf+lUOj8
|
||||
2WAp4+fuD5Q+REWdIM7X68Qhsg==
|
||||
-----END PRIVATE KEY-----
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
x509_extensions = v3_req
|
||||
distinguished_name = dn
|
||||
|
||||
[dn]
|
||||
CN = unis-crm-local
|
||||
|
||||
[v3_req]
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
DNS.2 = MacBook-Pro.local
|
||||
IP.1 = 127.0.0.1
|
||||
IP.2 = 192.168.2.225
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/crm-favicon.svg" />
|
||||
<title>紫光汇智CRM</title>
|
||||
<script type="module" crossorigin src="/assets/index-BCZw0F7c.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D6vWgqCF.css">
|
||||
<script type="module" crossorigin src="/assets/index-Ba78XVP4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D3WIva4A.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ export interface WorkCheckIn {
|
|||
status?: string;
|
||||
longitude?: number;
|
||||
latitude?: number;
|
||||
photoUrls?: string[];
|
||||
}
|
||||
|
||||
export interface WorkDailyReport {
|
||||
|
|
@ -149,6 +150,7 @@ export interface WorkHistoryItem {
|
|||
status?: string;
|
||||
score?: number;
|
||||
comment?: string;
|
||||
photoUrls?: string[];
|
||||
}
|
||||
|
||||
export interface WorkOverview {
|
||||
|
|
@ -163,6 +165,7 @@ export interface CreateWorkCheckInPayload {
|
|||
remark?: string;
|
||||
longitude?: number;
|
||||
latitude?: number;
|
||||
photoUrls?: string[];
|
||||
}
|
||||
|
||||
export interface CreateWorkDailyReportPayload {
|
||||
|
|
@ -348,7 +351,7 @@ const LOGIN_PATH = "/login";
|
|||
|
||||
async function request<T>(input: string, init?: RequestInit, withAuth = false): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("Content-Type") && init?.body) {
|
||||
if (!headers.has("Content-Type") && init?.body && !(init.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
|
|
@ -535,6 +538,15 @@ export async function saveWorkCheckIn(payload: CreateWorkCheckInPayload) {
|
|||
}, true);
|
||||
}
|
||||
|
||||
export async function uploadWorkCheckInPhoto(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return request<string>("/api/work/checkin-photos", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}, true);
|
||||
}
|
||||
|
||||
export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload) {
|
||||
return request<number>("/api/work/daily-reports", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -181,6 +181,14 @@ export default function Profile() {
|
|||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
const handleNavigateToMonthlyOpportunity = () => {
|
||||
navigate("/opportunities");
|
||||
};
|
||||
|
||||
const handleNavigateToMonthlyExpansion = () => {
|
||||
navigate("/expansion");
|
||||
};
|
||||
|
||||
const handleOpenProfile = async () => {
|
||||
setDetailLoading(true);
|
||||
setError("");
|
||||
|
|
@ -358,14 +366,22 @@ export default function Profile() {
|
|||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-3 border-t border-slate-50 pt-6 dark:border-slate-800/50 sm:grid-cols-3 sm:gap-0 sm:divide-x sm:divide-slate-100 dark:sm:divide-slate-800">
|
||||
<div className="rounded-2xl bg-slate-50 px-4 py-4 text-center dark:bg-slate-800/40 sm:rounded-none sm:bg-transparent sm:px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigateToMonthlyOpportunity}
|
||||
className="rounded-2xl bg-slate-50 px-4 py-4 text-center transition-colors hover:bg-slate-100 active:scale-[0.99] dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2"
|
||||
>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-white">{numericValue(overview?.monthlyOpportunityCount)}</p>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">本月商机</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-50 px-4 py-4 text-center dark:bg-slate-800/40 sm:rounded-none sm:bg-transparent sm:px-2">
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigateToMonthlyExpansion}
|
||||
className="rounded-2xl bg-slate-50 px-4 py-4 text-center transition-colors hover:bg-slate-100 active:scale-[0.99] dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2"
|
||||
>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-white">{numericValue(overview?.monthlyExpansionCount)}</p>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">本月拓展</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="rounded-2xl bg-slate-50 px-4 py-4 text-center dark:bg-slate-800/40 sm:rounded-none sm:bg-transparent sm:px-2">
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-white">{numericValue(overview?.averageScore)}</p>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">平均分</p>
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement | null>(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<WorkHistoryItem[]>([]);
|
||||
const [checkInStatus, setCheckInStatus] = useState<string>();
|
||||
const [reportStatus, setReportStatus] = useState<string>();
|
||||
const [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
|
||||
const [checkInForm, setCheckInForm] = useState<CreateWorkCheckInPayload>(defaultCheckInForm);
|
||||
const [reportForm, setReportForm] = useState<CreateWorkDailyReportPayload>(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<HTMLInputElement>) => {
|
||||
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 ? (
|
||||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">{locationHint}</p>
|
||||
|
|
@ -299,15 +326,45 @@ export default function Work() {
|
|||
|
||||
<div className="space-y-4 flex flex-col">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white mb-1">现场照片 (必填)</p>
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Camera className="mb-2 h-6 w-6 text-slate-400 dark:text-slate-500 group-hover:text-violet-500 transition-colors" />
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors">
|
||||
{refreshingLocation ? "正在刷新定位..." : "点击拍照"}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={(event) => void handlePhotoChange(event)}
|
||||
/>
|
||||
{checkInPhotoUrls.length ? (
|
||||
<div className="relative overflow-hidden rounded-xl border border-slate-200 bg-slate-50 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<img
|
||||
src={checkInPhotoUrls[0]}
|
||||
alt="现场照片"
|
||||
className="h-48 w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemovePhoto}
|
||||
className="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900/70 text-white transition-colors hover:bg-slate-900"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickPhoto}
|
||||
disabled={uploadingPhoto}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Camera className="mb-2 h-6 w-6 text-slate-400 dark:text-slate-500 group-hover:text-violet-500 transition-colors" />
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 group-hover:text-violet-600 dark:group-hover:text-violet-400 transition-colors">
|
||||
{uploadingPhoto ? "上传中..." : "点击拍照"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
手机端会优先调用后置相机;如相机不可用,也可从相册选择现场照片。
|
||||
</p>
|
||||
{checkInError ? <p className="text-xs text-rose-500">{checkInError}</p> : null}
|
||||
{checkInSuccess ? <p className="text-xs text-emerald-500">{checkInSuccess}</p> : null}
|
||||
</div>
|
||||
|
|
@ -346,15 +403,6 @@ export default function Work() {
|
|||
<FileText className="h-4 w-4 text-slate-400 dark:text-slate-500" />
|
||||
今日工作内容
|
||||
</label>
|
||||
<button
|
||||
onClick={handleRecord}
|
||||
className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-all duration-300 ${
|
||||
isRecording ? "bg-rose-100 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 animate-pulse" : "bg-violet-50 dark:bg-violet-500/10 text-violet-600 dark:text-violet-400 hover:bg-violet-100 dark:hover:bg-violet-500/20"
|
||||
}`}
|
||||
>
|
||||
<Mic className="h-3.5 w-3.5" />
|
||||
{isRecording ? "正在识别..." : "语音输入 (HubMind)"}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
rows={4}
|
||||
|
|
@ -450,6 +498,19 @@ export default function Work() {
|
|||
{item.content}
|
||||
</p>
|
||||
|
||||
{item.photoUrls?.length ? (
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto pb-1">
|
||||
{item.photoUrls.map((photoUrl, photoIndex) => (
|
||||
<img
|
||||
key={`${item.id}-photo-${photoIndex}`}
|
||||
src={photoUrl}
|
||||
alt={`打卡照片${photoIndex + 1}`}
|
||||
className="h-16 w-16 rounded-lg border border-slate-200 object-cover dark:border-slate-700"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.comment ? (
|
||||
<div className="mt-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 p-2.5 border border-slate-100 dark:border-slate-800/50">
|
||||
<p className="text-[10px] font-medium text-slate-900 dark:text-white mb-0.5">主管点评:</p>
|
||||
|
|
@ -471,3 +532,54 @@ export default function Work() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getGeoErrorMessage(error: GeolocationPositionError) {
|
||||
if (!window.isSecureContext) {
|
||||
return "手机端定位需要通过安全地址访问。请使用 HTTPS,或继续手动填写当前位置。";
|
||||
}
|
||||
switch (error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
return "定位权限被拒绝,请在手机浏览器里允许位置权限后再重试。";
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
return "当前位置暂时不可用,请移动到开阔区域后重试。";
|
||||
case error.TIMEOUT:
|
||||
return "定位超时,已建议切换普通精度重试;你也可以手动填写当前位置。";
|
||||
default:
|
||||
return "定位获取失败,请手动填写当前位置。";
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentPositionAsync(options: PositionOptions) {
|
||||
return new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error("当前浏览器不支持定位,请手动填写当前位置。"));
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, options);
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveCurrentPosition() {
|
||||
try {
|
||||
return await getCurrentPositionAsync({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 12000,
|
||||
maximumAge: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof GeolocationPositionError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
return await getCurrentPositionAsync({
|
||||
enableHighAccuracy: false,
|
||||
timeout: 15000,
|
||||
maximumAge: 300000,
|
||||
});
|
||||
} catch {
|
||||
throw new Error(getGeoErrorMessage(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue