main
kangwenjing 2026-03-23 09:03:27 +08:00
parent 9f4d30f933
commit f37b7e8dda
46 changed files with 1488 additions and 455 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package com.unis.crm.service;
import com.unis.crm.dto.profile.ProfileOverviewDTO;
public interface ProfileService {
ProfileOverviewDTO getOverview(Long userId);
}

View File

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

View File

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

View File

@ -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, "house_name"))
);
String buildingPart = firstNonBlank(
textValue(addressNode, "building"),
textValue(addressNode, "city_block"),
textValue(addressNode, "amenity"),
textValue(addressNode, "office"),
textValue(addressNode, "shop"))
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) {}
}

View File

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

View File

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

View File

@ -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) &lt;&gt; '' then ',岗位:' || s.title
else ''
@ -61,7 +63,7 @@
case
when s.intent_level is not null and btrim(s.intent_level) &lt;&gt; '' 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) &lt;&gt; '' then ',地区:' || c.province
else ''
@ -79,7 +81,7 @@
case
when c.industry is not null and btrim(c.industry) &lt;&gt; '' 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) &lt;&gt; '' 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) &lt;&gt; '' then ',方式:' || f.followup_type
else ''
@ -116,7 +115,7 @@
case
when f.content is not null and btrim(f.content) &lt;&gt; '' 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) &lt;&gt; '' then ',方式:' || f.followup_type
else ''
@ -135,7 +134,7 @@
case
when f.content is not null and btrim(f.content) &lt;&gt; '' 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) &lt;&gt; '' then ',方式:' || f.followup_type
else ''
@ -154,13 +153,14 @@
case
when f.content is not null and btrim(f.content) &lt;&gt; '' 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">

View File

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

View File

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

View File

@ -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) &lt;&gt; '' then ',岗位:' || s.title
else ''
@ -61,7 +63,7 @@
case
when s.intent_level is not null and btrim(s.intent_level) &lt;&gt; '' 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) &lt;&gt; '' then ',地区:' || c.province
else ''
@ -79,7 +81,7 @@
case
when c.industry is not null and btrim(c.industry) &lt;&gt; '' 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) &lt;&gt; '' 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) &lt;&gt; '' then ',方式:' || f.followup_type
else ''
@ -116,7 +115,7 @@
case
when f.content is not null and btrim(f.content) &lt;&gt; '' 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) &lt;&gt; '' then ',方式:' || f.followup_type
else ''
@ -135,7 +134,7 @@
case
when f.content is not null and btrim(f.content) &lt;&gt; '' 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) &lt;&gt; '' then ',方式:' || f.followup_type
else ''
@ -154,13 +153,14 @@
case
when f.content is not null and btrim(f.content) &lt;&gt; '' 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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,36 +82,23 @@ export default function Work() {
void handleRefreshLocation();
}, [loading]);
const handleRecord = () => {
if (isRecording) {
setIsRecording(false);
return;
}
setIsRecording(true);
window.setTimeout(() => {
setReportForm((prev) => ({
...prev,
workContent: prev.workContent + (prev.workContent ? "\n" : "") + "今天拜访了A市第一人民医院信息科主任沟通了云桌面扩容需求对方表示下个月会启动招标流程。",
sourceType: "voice_assist",
}));
setIsRecording(false);
}, 2000);
};
const handleRefreshLocation = async () => {
if (!navigator.geolocation) {
setLocationHint("当前浏览器不支持定位,请手动填写当前位置。");
return;
}
setCheckInError("");
setRefreshingLocation(true);
setLocationLocked(false);
if (!window.isSecureContext && location.hostname !== "localhost" && location.hostname !== "127.0.0.1") {
setLocationHint("当前是通过 HTTP 地址在手机端访问,浏览器会直接禁止定位。请改用 HTTPS 地址打开,或先手动填写当前位置。");
setRefreshingLocation(false);
return;
}
setLocationHint("正在获取当前位置...");
navigator.geolocation.getCurrentPosition(
async (position) => {
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) => ({
@ -116,7 +107,10 @@ export default function Work() {
latitude,
longitude,
}));
setLocationHint("定位已刷新,已为你填入具体地点名称。");
setLocationLocked(Boolean(displayName));
setLocationHint(displayName
? "定位已刷新并锁定当前位置,如需变更请点击“刷新定位”。"
: "已获取定位坐标,如需更精确地址可再次刷新定位。");
} catch {
setCheckInForm((prev) => ({
...prev,
@ -124,20 +118,44 @@ export default function Work() {
latitude,
longitude,
}));
setLocationLocked(false);
setLocationHint("已获取坐标,但地点名称解析失败,你也可以手动补充。");
}
} catch (error) {
setLocationLocked(false);
setLocationHint(error instanceof Error ? error.message : "定位获取失败,请手动填写当前位置。");
} finally {
setRefreshingLocation(false);
}
},
() => {
setLocationHint("定位获取失败,请手动填写当前位置。");
setRefreshingLocation(false);
},
{
enableHighAccuracy: true,
timeout: 10000,
},
);
};
const handlePickPhoto = () => {
photoInputRef.current?.click();
};
const handlePhotoChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
setCheckInError("");
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"
<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">
{refreshingLocation ? "正在刷新定位..." : "点击拍照"}
{uploadingPhoto ? "上传中..." : "点击拍照"}
</span>
</div>
</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));
}
}
}