导出功能调整

main
kangwenjing 2026-04-08 17:41:17 +08:00
parent 4f8c4bbd70
commit 9733de9f22
15 changed files with 1972 additions and 39 deletions

View File

@ -90,6 +90,7 @@
<workItem from="1775197343053" duration="963000" />
<workItem from="1775198320186" duration="1451000" />
<workItem from="1775200047785" duration="3902000" />
<workItem from="1775541962012" duration="805000" />
</task>
<task id="LOCAL-00001" summary="修改定位信息 0323">
<option name="closed" value="true" />

View File

@ -5,12 +5,17 @@ import com.unis.crm.common.BusinessException;
import com.unis.crm.common.CurrentUserUtils;
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
import com.unis.crm.dto.work.WorkCheckInExportDTO;
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
import com.unis.crm.dto.work.WorkHistoryPageDTO;
import com.unis.crm.dto.work.WorkOverviewDTO;
import com.unis.crm.service.WorkService;
import com.unisbase.common.annotation.Log;
import jakarta.validation.Valid;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
@ -53,6 +58,44 @@ public class WorkController {
return ApiResponse.success(workService.getHistory(CurrentUserUtils.requireCurrentUserId(userId), type, page, size));
}
@GetMapping("/checkins/export-data")
public ApiResponse<List<WorkCheckInExportDTO>> exportCheckIns(
@RequestHeader("X-User-Id") Long userId,
@RequestParam(value = "startDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(value = "endDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "deptName", required = false) String deptName,
@RequestParam(value = "bizType", required = false) String bizType,
@RequestParam(value = "status", required = false) String status) {
return ApiResponse.success(workService.exportCheckIns(
CurrentUserUtils.requireCurrentUserId(userId),
startDate,
endDate,
keyword,
deptName,
bizType,
status));
}
@GetMapping("/daily-reports/export-data")
public ApiResponse<List<WorkDailyReportExportDTO>> exportDailyReports(
@RequestHeader("X-User-Id") Long userId,
@RequestParam(value = "startDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(value = "endDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "deptName", required = false) String deptName,
@RequestParam(value = "bizType", required = false) String bizType,
@RequestParam(value = "status", required = false) String status) {
return ApiResponse.success(workService.exportDailyReports(
CurrentUserUtils.requireCurrentUserId(userId),
startDate,
endDate,
keyword,
deptName,
bizType,
status));
}
@GetMapping("/reverse-geocode")
public ApiResponse<String> reverseGeocode(
@RequestHeader(value = "X-User-Id", required = false) Long userId,

View File

@ -0,0 +1,135 @@
package com.unis.crm.dto.work;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
public class WorkCheckInExportDTO {
private String checkinDate;
private String checkinTime;
private String userName;
private String deptName;
private String bizType;
private String bizName;
private String locationText;
private BigDecimal longitude;
private BigDecimal latitude;
private String remark;
private String status;
private String createdAt;
private String updatedAt;
private List<String> photoUrls = new ArrayList<>();
public String getCheckinDate() {
return checkinDate;
}
public void setCheckinDate(String checkinDate) {
this.checkinDate = checkinDate;
}
public String getCheckinTime() {
return checkinTime;
}
public void setCheckinTime(String checkinTime) {
this.checkinTime = checkinTime;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getDeptName() {
return deptName;
}
public void setDeptName(String deptName) {
this.deptName = deptName;
}
public String getBizType() {
return bizType;
}
public void setBizType(String bizType) {
this.bizType = bizType;
}
public String getBizName() {
return bizName;
}
public void setBizName(String bizName) {
this.bizName = bizName;
}
public String getLocationText() {
return locationText;
}
public void setLocationText(String locationText) {
this.locationText = locationText;
}
public BigDecimal getLongitude() {
return longitude;
}
public void setLongitude(BigDecimal longitude) {
this.longitude = longitude;
}
public BigDecimal getLatitude() {
return latitude;
}
public void setLatitude(BigDecimal latitude) {
this.latitude = latitude;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
public String getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(String updatedAt) {
this.updatedAt = updatedAt;
}
public List<String> getPhotoUrls() {
return photoUrls;
}
public void setPhotoUrls(List<String> photoUrls) {
this.photoUrls = photoUrls;
}
}

View File

@ -0,0 +1,152 @@
package com.unis.crm.dto.work;
import java.util.ArrayList;
import java.util.List;
public class WorkDailyReportExportDTO {
private String reportDate;
private String submitTime;
private String userName;
private String deptName;
private String sourceType;
private String status;
private String workContent;
private String tomorrowPlan;
private Integer score;
private String comment;
private String reviewerName;
private String reviewedAt;
private String createdAt;
private String updatedAt;
private List<WorkReportLineItemDTO> lineItems = new ArrayList<>();
private List<WorkTomorrowPlanItemDTO> planItems = new ArrayList<>();
public String getReportDate() {
return reportDate;
}
public void setReportDate(String reportDate) {
this.reportDate = reportDate;
}
public String getSubmitTime() {
return submitTime;
}
public void setSubmitTime(String submitTime) {
this.submitTime = submitTime;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getDeptName() {
return deptName;
}
public void setDeptName(String deptName) {
this.deptName = deptName;
}
public String getSourceType() {
return sourceType;
}
public void setSourceType(String sourceType) {
this.sourceType = sourceType;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getWorkContent() {
return workContent;
}
public void setWorkContent(String workContent) {
this.workContent = workContent;
}
public String getTomorrowPlan() {
return tomorrowPlan;
}
public void setTomorrowPlan(String tomorrowPlan) {
this.tomorrowPlan = tomorrowPlan;
}
public Integer getScore() {
return score;
}
public void setScore(Integer score) {
this.score = score;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public String getReviewerName() {
return reviewerName;
}
public void setReviewerName(String reviewerName) {
this.reviewerName = reviewerName;
}
public String getReviewedAt() {
return reviewedAt;
}
public void setReviewedAt(String reviewedAt) {
this.reviewedAt = reviewedAt;
}
public String getCreatedAt() {
return createdAt;
}
public void setCreatedAt(String createdAt) {
this.createdAt = createdAt;
}
public String getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(String updatedAt) {
this.updatedAt = updatedAt;
}
public List<WorkReportLineItemDTO> getLineItems() {
return lineItems;
}
public void setLineItems(List<WorkReportLineItemDTO> lineItems) {
this.lineItems = lineItems;
}
public List<WorkTomorrowPlanItemDTO> getPlanItems() {
return planItems;
}
public void setPlanItems(List<WorkTomorrowPlanItemDTO> planItems) {
this.planItems = planItems;
}
}

View File

@ -3,10 +3,13 @@ package com.unis.crm.mapper;
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
import com.unis.crm.dto.work.WorkCheckInDTO;
import com.unis.crm.dto.work.WorkCheckInExportDTO;
import com.unis.crm.dto.work.WorkDailyReportDTO;
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
import com.unis.crm.dto.work.WorkHistoryItemDTO;
import com.unis.crm.dto.work.WorkSuggestedActionDTO;
import com.unisbase.annotation.DataScope;
import java.time.LocalDate;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@ -26,6 +29,25 @@ public interface WorkMapper {
@DataScope(tableAlias = "r", ownerColumn = "user_id")
List<WorkHistoryItemDTO> selectReportHistory(@Param("limit") int limit);
@DataScope(tableAlias = "c", ownerColumn = "user_id")
List<WorkCheckInExportDTO> selectCheckInExportRows(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("keyword") String keyword,
@Param("deptName") String deptName,
@Param("bizType") String bizType,
@Param("status") String status,
@Param("limit") int limit);
@DataScope(tableAlias = "r", ownerColumn = "user_id")
List<WorkDailyReportExportDTO> selectDailyReportExportRows(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("keyword") String keyword,
@Param("deptName") String deptName,
@Param("status") String status,
@Param("limit") int limit);
Long selectTodayCheckInId(@Param("userId") Long userId);
int insertCheckIn(@Param("userId") Long userId, @Param("request") CreateWorkCheckInRequest request);

View File

@ -2,9 +2,13 @@ package com.unis.crm.service;
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
import com.unis.crm.dto.work.WorkCheckInExportDTO;
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
import com.unis.crm.dto.work.WorkHistoryPageDTO;
import com.unis.crm.dto.work.WorkOverviewDTO;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
@ -14,6 +18,24 @@ public interface WorkService {
WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size);
List<WorkCheckInExportDTO> exportCheckIns(
Long userId,
LocalDate startDate,
LocalDate endDate,
String keyword,
String deptName,
String bizType,
String status);
List<WorkDailyReportExportDTO> exportDailyReports(
Long userId,
LocalDate startDate,
LocalDate endDate,
String keyword,
String deptName,
String bizType,
String status);
Long saveCheckIn(Long userId, CreateWorkCheckInRequest request);
Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request);

View File

@ -6,7 +6,9 @@ import com.unis.crm.common.BusinessException;
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
import com.unis.crm.dto.work.WorkCheckInDTO;
import com.unis.crm.dto.work.WorkCheckInExportDTO;
import com.unis.crm.dto.work.WorkDailyReportDTO;
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
import com.unis.crm.dto.work.WorkHistoryItemDTO;
import com.unis.crm.dto.work.WorkHistoryPageDTO;
import com.unis.crm.dto.work.WorkReportLineItemDTO;
@ -70,6 +72,7 @@ public class WorkServiceImpl implements WorkService {
private static final Pattern PLAN_ITEMS_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_PLAN_ITEMS]](.*?)\\[\\[/WORK_PLAN_ITEMS]]", Pattern.DOTALL);
private static final String ONLY_SEE_ROLE_CODE = "only_see";
private static final String WORK_REPORT_FOLLOW_UP_TYPE = "工作日报";
private static final int EXPORT_LIMIT = 5000;
private static final String TENCENT_COORD_TYPE_GPS = "1";
private static final String TENCENT_COORD_TYPE_GCJ02 = "2";
private static final String OPEN_STREET_MAP_USER_AGENT = "unis-crm/1.0";
@ -134,6 +137,58 @@ public class WorkServiceImpl implements WorkService {
return new WorkHistoryPageDTO(pagedItems, hasMore, safePage, safeSize);
}
@Override
public List<WorkCheckInExportDTO> exportCheckIns(
Long userId,
LocalDate startDate,
LocalDate endDate,
String keyword,
String deptName,
String bizType,
String status) {
requireUser(userId);
validateDateRange(startDate, endDate);
String normalizedBizType = normalizeBizType(bizType);
List<WorkCheckInExportDTO> rows = workMapper.selectCheckInExportRows(
startDate,
endDate,
normalizeOptionalText(keyword),
normalizeOptionalText(deptName),
normalizedBizType,
normalizeOptionalText(status),
EXPORT_LIMIT);
normalizeCheckInExportRows(rows);
return rows;
}
@Override
public List<WorkDailyReportExportDTO> exportDailyReports(
Long userId,
LocalDate startDate,
LocalDate endDate,
String keyword,
String deptName,
String bizType,
String status) {
requireUser(userId);
validateDateRange(startDate, endDate);
String normalizedBizType = normalizeBizType(bizType);
List<WorkDailyReportExportDTO> rows = workMapper.selectDailyReportExportRows(
startDate,
endDate,
normalizeOptionalText(keyword),
normalizeOptionalText(deptName),
normalizeOptionalText(status),
EXPORT_LIMIT);
normalizeDailyReportExportRows(rows);
if (normalizedBizType == null) {
return rows;
}
return rows.stream()
.filter(row -> hasReportLineOfType(row, normalizedBizType))
.toList();
}
@Override
public Long saveCheckIn(Long userId, CreateWorkCheckInRequest request) {
requireUser(userId);
@ -593,6 +648,51 @@ public class WorkServiceImpl implements WorkService {
}
}
private void normalizeCheckInExportRows(List<WorkCheckInExportDTO> rows) {
if (rows == null) {
return;
}
for (WorkCheckInExportDTO row : rows) {
if (row == null) {
continue;
}
PhotoMetadata photoMetadata = extractPhotoMetadata(row.getRemark());
row.setRemark(photoMetadata.cleanText());
row.setPhotoUrls(photoMetadata.photoUrls());
}
}
private void normalizeDailyReportExportRows(List<WorkDailyReportExportDTO> rows) {
if (rows == null) {
return;
}
for (WorkDailyReportExportDTO row : rows) {
if (row == null) {
continue;
}
ReportLineMetadata reportLineMetadata = extractReportLineMetadata(row.getWorkContent());
row.setWorkContent(stripVisitTimeFromReportText(reportLineMetadata.cleanText()));
row.setLineItems(reportLineMetadata.lineItems());
PlanItemMetadata planItemMetadata = extractPlanItemMetadata(row.getTomorrowPlan());
row.setTomorrowPlan(planItemMetadata.cleanText());
row.setPlanItems(planItemMetadata.planItems());
}
}
private boolean hasReportLineOfType(WorkDailyReportExportDTO row, String bizType) {
if (row == null || bizType == null || row.getLineItems() == null) {
return false;
}
return row.getLineItems().stream()
.anyMatch(item -> item != null && bizType.equals(item.getBizType()));
}
private void validateDateRange(LocalDate startDate, LocalDate endDate) {
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
throw new BusinessException("开始日期不能晚于结束日期");
}
}
private String stripVisitTimeFromReportText(String rawText) {
String normalized = rawText == null ? "" : rawText;
return normalized

View File

@ -242,6 +242,124 @@
limit #{limit}
</select>
<select id="selectCheckInExportRows" resultType="com.unis.crm.dto.work.WorkCheckInExportDTO">
select
to_char(c.checkin_date, 'YYYY-MM-DD') as checkinDate,
to_char(c.checkin_time, 'HH24:MI') as checkinTime,
coalesce(nullif(btrim(c.user_name), ''), nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') as deptName,
coalesce(c.biz_type, '') as bizType,
coalesce(c.biz_name, '') as bizName,
coalesce(c.location_text, '') as locationText,
c.longitude,
c.latitude,
coalesce(c.remark, '') as remark,
coalesce(c.status, 'normal') as status,
to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
to_char(c.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt
from work_checkin c
left join sys_user u on u.user_id = c.user_id and u.is_deleted = 0
left join lateral (
select string_agg(distinct o.org_name, '、' order by o.org_name) as org_names
from sys_tenant_user tu
join sys_org o on o.id = tu.org_id
where tu.user_id = c.user_id
and tu.is_deleted = 0
and o.is_deleted = 0
) org_info on true
where 1 = 1
<if test="startDate != null">
and c.checkin_date &gt;= #{startDate}
</if>
<if test="endDate != null">
and c.checkin_date &lt;= #{endDate}
</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(c.user_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.biz_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.location_text, '') ilike concat('%', #{keyword}, '%')
or coalesce(c.remark, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="deptName != null and deptName != ''">
and coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
</if>
<if test="bizType != null and bizType != ''">
and c.biz_type = #{bizType}
</if>
<if test="status != null and status != ''">
and coalesce(c.status, 'normal') = #{status}
</if>
order by coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) desc nulls last, c.id desc
limit #{limit}
</select>
<select id="selectDailyReportExportRows" resultType="com.unis.crm.dto.work.WorkDailyReportExportDTO">
select
to_char(r.report_date, 'YYYY-MM-DD') as reportDate,
to_char(r.submit_time, 'YYYY-MM-DD HH24:MI') as submitTime,
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
coalesce(nullif(btrim(org_info.org_names), ''), '') as deptName,
coalesce(r.source_type, 'manual') as sourceType,
coalesce(r.status, 'submitted') as status,
coalesce(r.work_content, '') as workContent,
coalesce(r.tomorrow_plan, '') as tomorrowPlan,
rc.score,
rc.comment_content as comment,
coalesce(nullif(btrim(reviewer.display_name), ''), nullif(btrim(reviewer.username), ''), '') as reviewerName,
to_char(rc.reviewed_at, 'YYYY-MM-DD HH24:MI') as reviewedAt,
to_char(r.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
to_char(r.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt
from work_daily_report r
left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
left join lateral (
select string_agg(distinct o.org_name, '、' order by o.org_name) as org_names
from sys_tenant_user tu
join sys_org o on o.id = tu.org_id
where tu.user_id = r.user_id
and tu.is_deleted = 0
and o.is_deleted = 0
) org_info on true
left join (
select distinct on (report_id)
report_id,
reviewer_user_id,
score,
comment_content,
reviewed_at
from work_daily_report_comment
order by report_id, reviewed_at desc nulls last, id desc
) rc on rc.report_id = r.id
left join sys_user reviewer on reviewer.user_id = rc.reviewer_user_id and reviewer.is_deleted = 0
where 1 = 1
<if test="startDate != null">
and r.report_date &gt;= #{startDate}
</if>
<if test="endDate != null">
and r.report_date &lt;= #{endDate}
</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
or coalesce(r.work_content, '') ilike concat('%', #{keyword}, '%')
or coalesce(r.tomorrow_plan, '') ilike concat('%', #{keyword}, '%')
or coalesce(rc.comment_content, '') ilike concat('%', #{keyword}, '%')
)
</if>
<if test="deptName != null and deptName != ''">
and coalesce(nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
</if>
<if test="status != null and status != ''">
and coalesce(r.status, 'submitted') = #{status}
</if>
order by coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) desc nulls last, r.id desc
limit #{limit}
</select>
<select id="selectTodayCheckInId" resultType="java.lang.Long">
select id
from work_checkin

View File

@ -30,4 +30,41 @@ class WorkMapperDataScopeTest {
assertEquals("r", dataScope.tableAlias());
assertEquals("user_id", dataScope.ownerColumn());
}
@Test
void selectCheckInExportRows_shouldUseDataScopeOnCheckInUserId() throws Exception {
Method method = WorkMapper.class.getMethod(
"selectCheckInExportRows",
java.time.LocalDate.class,
java.time.LocalDate.class,
String.class,
String.class,
String.class,
String.class,
int.class);
DataScope dataScope = method.getAnnotation(DataScope.class);
assertNotNull(dataScope);
assertEquals("c", dataScope.tableAlias());
assertEquals("user_id", dataScope.ownerColumn());
}
@Test
void selectDailyReportExportRows_shouldUseDataScopeOnReportUserId() throws Exception {
Method method = WorkMapper.class.getMethod(
"selectDailyReportExportRows",
java.time.LocalDate.class,
java.time.LocalDate.class,
String.class,
String.class,
String.class,
int.class);
DataScope dataScope = method.getAnnotation(DataScope.class);
assertNotNull(dataScope);
assertEquals("r", dataScope.tableAlias());
assertEquals("user_id", dataScope.ownerColumn());
}
}

View File

@ -10,6 +10,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.unis.crm.common.BusinessException;
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
import com.unis.crm.dto.work.WorkCheckInExportDTO;
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
import com.unis.crm.dto.work.WorkHistoryItemDTO;
import com.unis.crm.dto.work.WorkHistoryPageDTO;
import com.unis.crm.mapper.ProfileMapper;
@ -135,6 +137,53 @@ class WorkServiceImplTest {
assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage());
}
@Test
void exportCheckIns_shouldCleanPhotoMetadata() {
WorkCheckInExportDTO row = new WorkCheckInExportDTO();
row.setRemark("已完成现场拜访\n[[CHECKIN_PHOTOS]]/api/work/checkin-photos/a.jpg||/api/work/checkin-photos/b.jpg[[/CHECKIN_PHOTOS]]");
when(workMapper.selectCheckInExportRows(null, null, null, null, null, null, 5000))
.thenReturn(List.of(row));
List<WorkCheckInExportDTO> result = workService.exportCheckIns(17L, null, null, null, null, null, null);
assertEquals(1, result.size());
assertEquals("已完成现场拜访", result.get(0).getRemark());
assertEquals(List.of("/api/work/checkin-photos/a.jpg", "/api/work/checkin-photos/b.jpg"), result.get(0).getPhotoUrls());
}
@Test
void exportDailyReports_shouldCleanMetadataAndFilterByBizType() {
WorkDailyReportExportDTO opportunityReport = new WorkDailyReportExportDTO();
opportunityReport.setWorkContent("""
1. 2026-04-02 A
[[WORK_REPORT_LINES]][{"workDate":"2026-04-02","bizType":"opportunity","bizId":1,"bizName":"项目A","content":"项目最新进展:推进中\\n后续规划继续推进","latestProgress":"推进中","nextPlan":"继续推进","editorText":"@商机 项目A\\n# 项目最新进展:推进中\\n# 后续规划:继续推进"}][[/WORK_REPORT_LINES]]
""");
opportunityReport.setTomorrowPlan("""
1.
[[WORK_PLAN_ITEMS]][{"content":"跟进客户"}][[/WORK_PLAN_ITEMS]]
""");
WorkDailyReportExportDTO salesReport = new WorkDailyReportExportDTO();
salesReport.setWorkContent("""
1. 2026-04-02
[[WORK_REPORT_LINES]][{"workDate":"2026-04-02","bizType":"sales","bizId":2,"bizName":"张三","content":"沟通内容:已沟通","evaluationContent":"已沟通"}][[/WORK_REPORT_LINES]]
""");
salesReport.setTomorrowPlan("""
1.
[[WORK_PLAN_ITEMS]][{"content":"继续沟通"}][[/WORK_PLAN_ITEMS]]
""");
when(workMapper.selectDailyReportExportRows(null, null, null, null, null, 5000))
.thenReturn(List.of(opportunityReport, salesReport));
List<WorkDailyReportExportDTO> result = workService.exportDailyReports(17L, null, null, null, null, "opportunity", null);
assertEquals(1, result.size());
assertEquals("1. 2026-04-02 跟进商机“项目A”项目最新进展推进中后续规划继续推进", result.get(0).getWorkContent());
assertEquals(1, result.get(0).getLineItems().size());
assertEquals("项目A", result.get(0).getLineItems().get(0).getBizName());
assertEquals(1, result.get(0).getPlanItems().size());
assertEquals("跟进客户", result.get(0).getPlanItems().get(0).getContent());
}
private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) {
WorkHistoryItemDTO item = new WorkHistoryItemDTO();
item.setId(id);

View File

@ -0,0 +1 @@
mock-maker-subclass

View File

@ -180,6 +180,42 @@ export interface WorkHistoryItem {
photoUrls?: string[];
}
export interface WorkCheckInExportRow {
checkinDate?: string;
checkinTime?: string;
userName?: string;
deptName?: string;
bizType?: "sales" | "channel" | "opportunity" | string;
bizName?: string;
locationText?: string;
longitude?: number;
latitude?: number;
photoUrls?: string[];
remark?: string;
status?: string;
createdAt?: string;
updatedAt?: string;
}
export interface WorkDailyReportExportRow {
reportDate?: string;
submitTime?: string;
userName?: string;
deptName?: string;
sourceType?: string;
status?: string;
workContent?: string;
tomorrowPlan?: string;
score?: number;
comment?: string;
reviewerName?: string;
reviewedAt?: string;
createdAt?: string;
updatedAt?: string;
lineItems?: WorkReportLineItem[];
planItems?: WorkTomorrowPlanItem[];
}
export interface WorkOverview {
todayCheckIn?: WorkCheckIn;
todayReport?: WorkDailyReport;
@ -194,6 +230,15 @@ export interface WorkHistoryPage {
size?: number;
}
export interface WorkExportQuery {
startDate?: string;
endDate?: string;
keyword?: string;
deptName?: string;
bizType?: string;
status?: string;
}
export interface CreateWorkCheckInPayload {
locationText: string;
remark?: string;
@ -836,6 +881,26 @@ export async function getWorkHistory(type: "checkin" | "report", page = 1, size
return request<WorkHistoryPage>(`/api/work/history?${params.toString()}`, undefined, true);
}
function buildWorkExportQuery(params?: WorkExportQuery) {
const searchParams = new URLSearchParams();
Object.entries(params ?? {}).forEach(([key, value]) => {
const normalized = value?.trim();
if (normalized) {
searchParams.set(key, normalized);
}
});
const query = searchParams.toString();
return query ? `?${query}` : "";
}
export async function getWorkCheckInExportData(params?: WorkExportQuery) {
return request<WorkCheckInExportRow[]>(`/api/work/checkins/export-data${buildWorkExportQuery(params)}`, undefined, true);
}
export async function getWorkDailyReportExportData(params?: WorkExportQuery) {
return request<WorkDailyReportExportRow[]>(`/api/work/daily-reports/export-data${buildWorkExportQuery(params)}`, undefined, true);
}
export async function reverseWorkGeocode(latitude: number, longitude: number) {
return request<string>(`/api/work/reverse-geocode?lat=${latitude}&lon=${longitude}`, undefined, true);
}

View File

@ -29,6 +29,20 @@ import { cn } from "@/lib/utils";
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
type ExpansionTab = "sales" | "channel";
type ExpansionExportFilters = {
keyword?: string;
intent?: string;
officeName?: string;
industry?: string;
employmentStatus?: string;
province?: string;
certificationLevel?: string;
channelIndustry?: string;
channelAttribute?: string;
establishedStartDate?: string;
establishedEndDate?: string;
hasRelatedProject?: string;
};
type SalesCreateField =
| "employeeNo"
| "officeName"
@ -137,6 +151,131 @@ function normalizeExportText(value?: string | number | boolean | null) {
return normalized;
}
function normalizeExportFilterText(value?: string | number | boolean | null) {
return normalizeExportText(value).toLowerCase();
}
function matchesExportKeyword(value: string, keyword?: string) {
const normalizedKeyword = normalizeExportFilterText(keyword);
return !normalizedKeyword || value.toLowerCase().includes(normalizedKeyword);
}
function matchesTextFilter(value: string | undefined, filterValue?: string) {
const normalizedFilter = normalizeExportFilterText(filterValue);
if (!normalizedFilter) {
return true;
}
return normalizeExportFilterText(value).includes(normalizedFilter);
}
function matchesDateRange(value?: string, startDate?: string, endDate?: string) {
const normalizedValue = normalizeExportText(value).slice(0, 10);
if (!normalizedValue) {
return !startDate && !endDate;
}
if (startDate && normalizedValue < startDate) {
return false;
}
if (endDate && normalizedValue > endDate) {
return false;
}
return true;
}
function hasRelatedProjects(projects?: Array<{ amount?: number }>) {
return Boolean(projects?.length);
}
function matchesRelatedProjectFilter(projects: Array<{ amount?: number }> | undefined, filterValue?: string) {
if (filterValue === "yes") {
return hasRelatedProjects(projects);
}
if (filterValue === "no") {
return !hasRelatedProjects(projects);
}
return true;
}
function matchesSalesExportFilters(item: SalesExpansionItem, filters: ExpansionExportFilters) {
const keywordText = [
item.employeeNo,
item.name,
item.owner,
item.phone,
item.officeName,
item.dept,
item.title,
item.industry,
item.intent,
item.relatedProjects?.map((project) => `${project.opportunityCode ?? ""} ${project.opportunityName ?? ""}`).join(" "),
item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.evaluationContent ?? ""} ${followUp.nextPlan ?? ""}`).join(" "),
].map(normalizeExportText).filter(Boolean).join(" ");
if (!matchesExportKeyword(keywordText, filters.keyword)) {
return false;
}
if (!matchesTextFilter(item.intent || item.intentLevel, filters.intent)) {
return false;
}
if (!matchesTextFilter(item.officeName, filters.officeName)) {
return false;
}
if (!matchesTextFilter(item.industry, filters.industry)) {
return false;
}
if (filters.employmentStatus === "active" && item.active !== true && item.employmentStatus !== "active") {
return false;
}
if (filters.employmentStatus === "inactive" && item.active !== false && item.employmentStatus !== "inactive") {
return false;
}
return matchesRelatedProjectFilter(item.relatedProjects, filters.hasRelatedProject);
}
function matchesChannelExportFilters(item: ChannelExpansionItem, filters: ExpansionExportFilters) {
const keywordText = [
item.channelCode,
item.name,
item.owner,
item.province,
item.city,
item.officeAddress,
item.certificationLevel,
item.channelIndustry,
item.channelAttribute,
item.internalAttribute,
item.intent,
item.primaryContactName,
item.primaryContactMobile,
item.contacts?.map((contact) => `${contact.name ?? ""} ${contact.mobile ?? ""} ${contact.title ?? ""}`).join(" "),
item.relatedProjects?.map((project) => `${project.opportunityCode ?? ""} ${project.opportunityName ?? ""}`).join(" "),
item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.evaluationContent ?? ""} ${followUp.nextPlan ?? ""}`).join(" "),
].map(normalizeExportText).filter(Boolean).join(" ");
if (!matchesExportKeyword(keywordText, filters.keyword)) {
return false;
}
if (!matchesTextFilter(item.intent || item.intentLevel, filters.intent)) {
return false;
}
if (!matchesTextFilter(item.province, filters.province)) {
return false;
}
if (!matchesTextFilter(item.certificationLevel, filters.certificationLevel)) {
return false;
}
if (!matchesTextFilter(item.channelIndustry, filters.channelIndustry)) {
return false;
}
if (!matchesTextFilter(item.channelAttribute, filters.channelAttribute)) {
return false;
}
if (!matchesDateRange(item.establishedDate, filters.establishedStartDate, filters.establishedEndDate)) {
return false;
}
return matchesRelatedProjectFilter(item.relatedProjects, filters.hasRelatedProject);
}
function formatExportBoolean(value?: boolean, trueLabel = "是", falseLabel = "否") {
if (value === null || value === undefined) {
return "";
@ -568,6 +707,248 @@ function RequiredMark() {
return <span className="ml-1 text-rose-500">*</span>;
}
function ExpansionExportFilterModal({
activeTab,
initialFilters,
exporting,
exportError,
officeOptions,
industryOptions,
provinceOptions,
certificationLevelOptions,
channelAttributeOptions,
onClose,
onConfirm,
}: {
activeTab: ExpansionTab;
initialFilters: ExpansionExportFilters;
exporting: boolean;
exportError: string;
officeOptions: ExpansionDictOption[];
industryOptions: ExpansionDictOption[];
provinceOptions: ExpansionDictOption[];
certificationLevelOptions: ExpansionDictOption[];
channelAttributeOptions: ExpansionDictOption[];
onClose: () => void;
onConfirm: (filters: ExpansionExportFilters) => void;
}) {
const [draftFilters, setDraftFilters] = useState<ExpansionExportFilters>(initialFilters);
const isSalesTab = activeTab === "sales";
const hasDraftFilters = Boolean(
draftFilters.keyword
|| draftFilters.intent
|| draftFilters.officeName
|| draftFilters.industry
|| draftFilters.employmentStatus
|| draftFilters.province
|| draftFilters.certificationLevel
|| draftFilters.channelIndustry
|| draftFilters.channelAttribute
|| draftFilters.establishedStartDate
|| draftFilters.establishedEndDate
|| draftFilters.hasRelatedProject,
);
const handleFilterChange = (key: keyof ExpansionExportFilters, value: string) => {
setDraftFilters((current) => ({ ...current, [key]: value }));
};
const renderOption = (option: ExpansionDictOption) => {
const value = option.label || option.value || "";
return value ? <option key={value} value={value}>{value}</option> : null;
};
const toSearchableOptions = (options: ExpansionDictOption[], allLabel: string) => [
{ value: "", label: allLabel },
...options
.map((option) => {
const value = option.label || option.value || "";
return value ? { value, label: value } : null;
})
.filter((option): option is { value: string; label: string } => Boolean(option)),
];
return (
<ModalShell
title={`导出${isSalesTab ? "销售人员拓展" : "渠道拓展"}记录`}
subtitle="选择条件后导出 Excel不填条件则导出全部可见权限范围内的数据。"
onClose={onClose}
footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<button
type="button"
onClick={() => setDraftFilters({})}
disabled={!hasDraftFilters}
className="crm-btn crm-btn-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
</button>
<button
type="button"
onClick={() => onConfirm(draftFilters)}
disabled={exporting}
className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{exporting ? "导出中..." : "确认导出"}
</button>
</div>
)}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1.5 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
value={draftFilters.keyword ?? ""}
onChange={(event) => handleFilterChange("keyword", event.target.value)}
placeholder={isSalesTab ? "搜索姓名、工号、电话、行业、项目" : "搜索渠道、联系人、地区、项目"}
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.intent ?? ""}
options={[
{ value: "", label: "全部合作意向" },
{ value: "高", label: "高意向" },
{ value: "中", label: "中意向" },
{ value: "低", label: "低意向" },
]}
placeholder="全部合作意向"
sheetTitle="选择合作意向"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("intent", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.hasRelatedProject ?? ""}
options={[
{ value: "", label: "全部" },
{ value: "yes", label: "有关联项目" },
{ value: "no", label: "无关联项目" },
]}
placeholder="全部"
sheetTitle="选择关联项目"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("hasRelatedProject", value)}
/>
</label>
{isSalesTab ? (
<>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"> / </span>
<AdaptiveSelect
value={draftFilters.officeName ?? ""}
options={toSearchableOptions(officeOptions, "全部代表处 / 办事处")}
placeholder="全部代表处 / 办事处"
sheetTitle="选择代表处 / 办事处"
searchable
searchPlaceholder="搜索代表处 / 办事处"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("officeName", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.industry ?? ""}
options={toSearchableOptions(industryOptions, "全部行业")}
placeholder="全部行业"
sheetTitle="选择所属行业"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("industry", value)}
/>
</label>
<label className="space-y-1.5 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.employmentStatus ?? ""}
options={[
{ value: "", label: "全部" },
{ value: "active", label: "在职" },
{ value: "inactive", label: "离职" },
]}
placeholder="全部"
sheetTitle="选择在职状态"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("employmentStatus", value)}
/>
</label>
</>
) : (
<>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.province ?? ""}
options={toSearchableOptions(provinceOptions, "全部省份")}
placeholder="全部省份"
sheetTitle="选择省份"
searchable
searchPlaceholder="搜索省份"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("province", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.certificationLevel ?? ""}
options={toSearchableOptions(certificationLevelOptions, "全部认证级别")}
placeholder="全部认证级别"
sheetTitle="选择认证级别"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("certificationLevel", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.channelIndustry ?? ""}
options={toSearchableOptions(industryOptions, "全部行业")}
placeholder="全部行业"
sheetTitle="选择聚焦行业"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("channelIndustry", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.channelAttribute ?? ""}
options={toSearchableOptions(channelAttributeOptions, "全部渠道属性")}
placeholder="全部渠道属性"
sheetTitle="选择渠道属性"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("channelAttribute", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
type="date"
value={draftFilters.establishedStartDate ?? ""}
onChange={(event) => handleFilterChange("establishedStartDate", event.target.value)}
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
type="date"
value={draftFilters.establishedEndDate ?? ""}
onChange={(event) => handleFilterChange("establishedEndDate", event.target.value)}
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
</label>
</>
)}
</div>
{exportError ? <div className="crm-alert crm-alert-error mt-4">{exportError}</div> : null}
</ModalShell>
);
}
export default function Expansion() {
const currentUserId = getStoredCurrentUserId();
const location = useLocation();
@ -595,6 +976,8 @@ export default function Expansion() {
const [editOpen, setEditOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportFilterOpen, setExportFilterOpen] = useState(false);
const [exportFilters, setExportFilters] = useState<ExpansionExportFilters>({});
const [salesDuplicateChecking, setSalesDuplicateChecking] = useState(false);
const [channelDuplicateChecking, setChannelDuplicateChecking] = useState(false);
const [createError, setCreateError] = useState("");
@ -616,7 +999,7 @@ export default function Expansion() {
const [channelForm, setChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
const [editSalesForm, setEditSalesForm] = useState<CreateSalesExpansionPayload>(defaultSalesForm);
const [editChannelForm, setEditChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
const hasForegroundModal = createOpen || editOpen;
const hasForegroundModal = createOpen || editOpen || exportFilterOpen;
const loadMeta = useCallback(async () => {
const data = await getExpansionMeta();
@ -1170,27 +1553,30 @@ export default function Expansion() {
setExportError("");
};
const handleExport = async () => {
const handleExport = async (filters: ExpansionExportFilters) => {
if (exporting) {
return;
}
const isSalesTab = activeTab === "sales";
const items = isSalesTab ? salesData : channelData;
if (items.length <= 0) {
setExportError(`当前${isSalesTab ? "销售人员拓展" : "渠道拓展"}暂无可导出数据`);
return;
}
setExporting(true);
setExportError("");
setExportFilters(filters);
try {
const overview = await getExpansionOverview("");
const exportSalesItems = dedupeExpansionItemsById(overview.salesItems ?? []).filter((item) => matchesSalesExportFilters(item, filters));
const exportChannelItems = dedupeExpansionItemsById(overview.channelItems ?? []).filter((item) => matchesChannelExportFilters(item, filters));
const exportItems = isSalesTab ? exportSalesItems : exportChannelItems;
if (exportItems.length <= 0) {
throw new Error(`当前筛选条件下暂无可导出的${isSalesTab ? "销售人员拓展" : "渠道拓展"}数据`);
}
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(isSalesTab ? "销售人员拓展" : "渠道拓展");
const headers = isSalesTab ? buildSalesExportHeaders(salesData) : buildChannelExportHeaders(channelData);
const rows = isSalesTab ? buildSalesExportData(salesData) : buildChannelExportData(channelData);
const headers = isSalesTab ? buildSalesExportHeaders(exportSalesItems) : buildChannelExportHeaders(exportChannelItems);
const rows = isSalesTab ? buildSalesExportData(exportSalesItems) : buildChannelExportData(exportChannelItems);
worksheet.addRow(headers);
rows.forEach((row) => {
@ -1242,6 +1628,7 @@ export default function Expansion() {
const buffer = await workbook.xlsx.writeBuffer();
const filename = `${isSalesTab ? "销售人员拓展" : "渠道拓展"}_${formatExportFilenameTime()}.xlsx`;
downloadExcelFile(filename, buffer);
setExportFilterOpen(false);
} catch (error) {
setExportError(error instanceof Error ? error.message : "导出失败,请稍后重试");
} finally {
@ -1626,7 +2013,10 @@ export default function Expansion() {
</div>
<div className="flex items-center gap-2">
<button
onClick={handleExport}
onClick={() => {
setExportError("");
setExportFilterOpen(true);
}}
disabled={exporting}
className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
>
@ -1803,6 +2193,22 @@ export default function Expansion() {
</div>
<AnimatePresence>
{exportFilterOpen && (
<ExpansionExportFilterModal
activeTab={activeTab}
initialFilters={exportFilters}
exporting={exporting}
exportError={exportError}
officeOptions={officeOptions}
industryOptions={industryOptions}
provinceOptions={provinceOptions}
certificationLevelOptions={certificationLevelOptions}
channelAttributeOptions={channelAttributeOptions}
onClose={() => setExportFilterOpen(false)}
onConfirm={(filters) => void handleExport(filters)}
/>
)}
{createOpen && (
<ModalShell
title={`新增${activeTab === "sales" ? "销售人员拓展" : "渠道拓展"}`}

View File

@ -34,6 +34,18 @@ const COMPETITOR_OPTIONS = [
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
type OperatorMode = "none" | "h3c" | "channel" | "both";
type OpportunityArchiveTab = "active" | "archived";
type OpportunityExportFilters = {
keyword?: string;
expectedStartDate?: string;
expectedEndDate?: string;
stage?: string;
confidence?: string;
projectLocation?: string;
opportunityType?: string;
operatorName?: string;
hasSalesExpansion?: string;
hasChannelExpansion?: string;
};
type OpportunityField =
| "projectLocation"
| "opportunityName"
@ -106,6 +118,94 @@ function formatOpportunityExportFilenameTime(date = new Date()) {
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
}
function normalizeOpportunityExportFilterText(value?: string | number | boolean | null) {
return normalizeOpportunityExportText(value).toLowerCase();
}
function matchesOpportunityExportKeyword(value: string, keyword?: string) {
const normalizedKeyword = normalizeOpportunityExportFilterText(keyword);
return !normalizedKeyword || value.toLowerCase().includes(normalizedKeyword);
}
function matchesOpportunityTextFilter(values: Array<string | number | boolean | null | undefined>, filterValue?: string) {
const normalizedFilter = normalizeOpportunityExportFilterText(filterValue);
if (!normalizedFilter) {
return true;
}
return values.some((value) => normalizeOpportunityExportFilterText(value).includes(normalizedFilter));
}
function matchesOpportunityDateRange(value?: string, startDate?: string, endDate?: string) {
const normalizedValue = normalizeOpportunityExportText(value).slice(0, 10);
if (!normalizedValue) {
return !startDate && !endDate;
}
if (startDate && normalizedValue < startDate) {
return false;
}
if (endDate && normalizedValue > endDate) {
return false;
}
return true;
}
function matchesOpportunityRelationFilter(hasRelation: boolean, filterValue?: string) {
if (filterValue === "yes") {
return hasRelation;
}
if (filterValue === "no") {
return !hasRelation;
}
return true;
}
function matchesOpportunityExportFilters(item: OpportunityItem, filters: OpportunityExportFilters) {
const keywordText = [
item.code,
item.name,
item.client,
item.owner,
item.projectLocation,
item.operatorName,
item.stage,
item.type,
item.salesExpansionName,
item.channelExpansionName,
item.preSalesName,
item.competitorName,
item.latestProgress,
item.nextPlan,
item.notes,
item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.latestProgress ?? ""} ${followUp.nextAction ?? ""}`).join(" "),
].map(normalizeOpportunityExportText).filter(Boolean).join(" ");
if (!matchesOpportunityExportKeyword(keywordText, filters.keyword)) {
return false;
}
if (!matchesOpportunityDateRange(item.date, filters.expectedStartDate, filters.expectedEndDate)) {
return false;
}
if (!matchesOpportunityTextFilter([item.stageCode, item.stage], filters.stage)) {
return false;
}
if (filters.confidence && normalizeConfidenceGrade(item.confidence) !== filters.confidence) {
return false;
}
if (!matchesOpportunityTextFilter([item.projectLocation], filters.projectLocation)) {
return false;
}
if (!matchesOpportunityTextFilter([item.type], filters.opportunityType)) {
return false;
}
if (!matchesOpportunityTextFilter([item.operatorCode, item.operatorName], filters.operatorName)) {
return false;
}
if (!matchesOpportunityRelationFilter(Boolean(item.salesExpansionId || item.salesExpansionName), filters.hasSalesExpansion)) {
return false;
}
return matchesOpportunityRelationFilter(Boolean(item.channelExpansionId || item.channelExpansionName), filters.hasChannelExpansion);
}
function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
return {
opportunityName: item.name || "",
@ -447,6 +547,211 @@ function DetailItem({
);
}
function OpportunityExportFilterModal({
initialFilters,
exporting,
exportError,
archiveTab,
stageOptions,
projectLocationOptions,
opportunityTypeOptions,
operatorOptions,
onClose,
onConfirm,
}: {
initialFilters: OpportunityExportFilters;
exporting: boolean;
exportError: string;
archiveTab: OpportunityArchiveTab;
stageOptions: OpportunityDictOption[];
projectLocationOptions: OpportunityDictOption[];
opportunityTypeOptions: OpportunityDictOption[];
operatorOptions: OpportunityDictOption[];
onClose: () => void;
onConfirm: (filters: OpportunityExportFilters) => void;
}) {
const [draftFilters, setDraftFilters] = useState<OpportunityExportFilters>(initialFilters);
const hasDraftFilters = Boolean(
draftFilters.keyword
|| draftFilters.expectedStartDate
|| draftFilters.expectedEndDate
|| draftFilters.stage
|| draftFilters.confidence
|| draftFilters.projectLocation
|| draftFilters.opportunityType
|| draftFilters.operatorName
|| draftFilters.hasSalesExpansion
|| draftFilters.hasChannelExpansion,
);
const handleFilterChange = (key: keyof OpportunityExportFilters, value: string) => {
setDraftFilters((current) => ({ ...current, [key]: value }));
};
const renderOption = (option: OpportunityDictOption) => {
const value = option.value || option.label || "";
const label = option.label || option.value || "";
return value ? <option key={value} value={value}>{label}</option> : null;
};
const toSearchableOptions = (options: OpportunityDictOption[], allLabel: string) => [
{ value: "", label: allLabel },
...options
.map((option) => {
const value = option.value || option.label || "";
const label = option.label || option.value || "";
return value ? { value, label } : null;
})
.filter((option): option is { value: string; label: string } => Boolean(option)),
];
return (
<ModalShell
title={`导出${archiveTab === "active" ? "未归档" : "已归档"}商机`}
subtitle="选择条件后导出 Excel不填条件则导出当前归档页签下的全部可见商机。"
onClose={onClose}
footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<button
type="button"
onClick={() => setDraftFilters({})}
disabled={!hasDraftFilters}
className="crm-btn crm-btn-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
</button>
<button
type="button"
onClick={() => onConfirm(draftFilters)}
disabled={exporting}
className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{exporting ? "导出中..." : "确认导出"}
</button>
</div>
)}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1.5 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
value={draftFilters.keyword ?? ""}
onChange={(event) => handleFilterChange("keyword", event.target.value)}
placeholder="搜索项目编号、项目名称、最终用户、负责人、售前"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
type="date"
value={draftFilters.expectedStartDate ?? ""}
onChange={(event) => handleFilterChange("expectedStartDate", event.target.value)}
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<input
type="date"
value={draftFilters.expectedEndDate ?? ""}
onChange={(event) => handleFilterChange("expectedEndDate", event.target.value)}
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.stage ?? ""}
options={toSearchableOptions(stageOptions, "全部阶段")}
placeholder="全部阶段"
sheetTitle="选择项目阶段"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("stage", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.confidence ?? ""}
options={[
{ value: "", label: "全部把握度" },
...CONFIDENCE_OPTIONS.map((option) => ({ value: option.value, label: option.label })),
]}
placeholder="全部把握度"
sheetTitle="选择项目把握度"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("confidence", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.projectLocation ?? ""}
options={toSearchableOptions(projectLocationOptions, "全部项目地")}
placeholder="全部项目地"
sheetTitle="选择项目地"
searchable
searchPlaceholder="搜索项目地"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("projectLocation", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.opportunityType ?? ""}
options={toSearchableOptions(opportunityTypeOptions, "全部建设类型")}
placeholder="全部建设类型"
sheetTitle="选择建设类型"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("opportunityType", value)}
/>
</label>
<label className="space-y-1.5 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.operatorName ?? ""}
options={toSearchableOptions(operatorOptions, "全部运作方")}
placeholder="全部运作方"
sheetTitle="选择运作方"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("operatorName", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.hasSalesExpansion ?? ""}
options={[
{ value: "", label: "全部" },
{ value: "yes", label: "已关联" },
{ value: "no", label: "未关联" },
]}
placeholder="全部"
sheetTitle="选择新华三负责人关联状态"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("hasSalesExpansion", value)}
/>
</label>
<label className="space-y-1.5">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
<AdaptiveSelect
value={draftFilters.hasChannelExpansion ?? ""}
options={[
{ value: "", label: "全部" },
{ value: "yes", label: "已关联" },
{ value: "no", label: "未关联" },
]}
placeholder="全部"
sheetTitle="选择拓展渠道关联状态"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
onChange={(value) => handleFilterChange("hasChannelExpansion", value)}
/>
</label>
</div>
{exportError ? <div className="crm-alert crm-alert-error mt-4">{exportError}</div> : null}
</ModalShell>
);
}
type SearchableOption = {
value: number | string;
label: string;
@ -998,6 +1303,8 @@ export default function Opportunities() {
const [submitting, setSubmitting] = useState(false);
const [pushingOms, setPushingOms] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportFilterOpen, setExportFilterOpen] = useState(false);
const [exportFilters, setExportFilters] = useState<OpportunityExportFilters>({});
const [error, setError] = useState("");
const [exportError, setExportError] = useState("");
const [items, setItems] = useState<OpportunityItem[]>([]);
@ -1016,7 +1323,7 @@ export default function Opportunities() {
const [customCompetitorName, setCustomCompetitorName] = useState("");
const [fieldErrors, setFieldErrors] = useState<Partial<Record<OpportunityField, string>>>({});
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen;
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen || exportFilterOpen;
useEffect(() => {
let cancelled = false;
@ -1209,20 +1516,24 @@ export default function Opportunities() {
return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
};
const handleExport = async () => {
const handleExport = async (filters: OpportunityExportFilters) => {
if (exporting) {
return;
}
if (visibleItems.length <= 0) {
setExportError(`当前${archiveTab === "active" ? "未归档" : "已归档"}商机暂无可导出数据`);
return;
}
setExporting(true);
setExportError("");
setExportFilters(filters);
try {
const overview = await getOpportunityOverview("", "全部");
const exportItems = (overview.items ?? [])
.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)))
.filter((item) => matchesOpportunityExportFilters(item, filters));
if (exportItems.length <= 0) {
throw new Error(`当前筛选条件下暂无可导出的${archiveTab === "active" ? "未归档" : "已归档"}商机`);
}
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("商机储备");
@ -1255,7 +1566,7 @@ export default function Opportunities() {
worksheet.addRow(headers);
visibleItems.forEach((item) => {
exportItems.forEach((item) => {
const relatedSales = item.salesExpansionId
? salesExpansionOptions.find((option) => option.id === item.salesExpansionId) ?? null
: null;
@ -1345,6 +1656,7 @@ export default function Opportunities() {
const buffer = await workbook.xlsx.writeBuffer();
const filename = `商机储备_${archiveTab === "active" ? "未归档" : "已归档"}_${formatOpportunityExportFilenameTime()}.xlsx`;
downloadOpportunityExcelFile(filename, buffer);
setExportFilterOpen(false);
} catch (exportErr) {
setExportError(exportErr instanceof Error ? exportErr.message : "导出失败,请稍后重试");
} finally {
@ -1571,7 +1883,10 @@ export default function Opportunities() {
</div>
<div className="flex items-center gap-2">
<button
onClick={() => void handleExport()}
onClick={() => {
setExportError("");
setExportFilterOpen(true);
}}
disabled={exporting}
className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
>
@ -1735,6 +2050,21 @@ export default function Opportunities() {
</div>
<AnimatePresence>
{exportFilterOpen ? (
<OpportunityExportFilterModal
initialFilters={exportFilters}
exporting={exporting}
exportError={exportError}
archiveTab={archiveTab}
stageOptions={stageOptions}
projectLocationOptions={projectLocationOptions}
opportunityTypeOptions={opportunityTypeOptions}
operatorOptions={operatorOptions}
onClose={() => setExportFilterOpen(false)}
onConfirm={(filters) => void handleExport(filters)}
/>
) : null}
{stageFilterOpen ? (
<>
<motion.div

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { motion } from "motion/react";
import { ArrowUp, AtSign, Camera, CheckCircle2, ChevronDown, FileText, ListTodo, MapPin, NotebookPen, Plus, RefreshCw, Search, Send, Trash2, X } from "lucide-react";
import { ArrowUp, AtSign, Camera, CheckCircle2, ChevronDown, Download, FileText, ListTodo, MapPin, NotebookPen, Plus, RefreshCw, Search, Send, Trash2, X } from "lucide-react";
import { flushSync } from "react-dom";
import { Link, Navigate, useLocation } from "react-router-dom";
import {
@ -10,6 +10,8 @@ import {
getExpansionOverview,
getOpportunityOverview,
getProfileOverview,
getWorkCheckInExportData,
getWorkDailyReportExportData,
refreshCurrentUser,
getWorkHistory,
getWorkOverview,
@ -25,9 +27,13 @@ import {
type SalesExpansionItem,
type UserProfile,
type WorkHistoryItem,
type WorkCheckInExportRow,
type WorkDailyReportExportRow,
type WorkExportQuery,
type WorkReportLineItem,
type WorkTomorrowPlanItem,
} from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { ProtectedImage } from "@/components/ProtectedImage";
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
@ -124,6 +130,112 @@ function createEmptyPlanItem(): WorkTomorrowPlanItem {
return { content: "" };
}
async function exportCheckInRowsToExcel(rows: WorkCheckInExportRow[]) {
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("打卡记录");
const headers = [
"序号",
"打卡日期",
"打卡时间",
"打卡人",
"所属部门",
"关联类型",
"关联对象",
"打卡地点",
"现场照片",
"备注",
"打卡状态",
];
worksheet.addRow(headers);
rows.forEach((row, index) => {
worksheet.addRow([
index + 1,
normalizeWorkExportText(row.checkinDate),
normalizeWorkExportText(row.checkinTime),
normalizeWorkExportText(row.userName),
normalizeWorkExportText(row.deptName),
formatWorkBizType(row.bizType),
normalizeWorkExportText(row.bizName),
normalizeWorkExportText(row.locationText),
row.photoUrls?.map(normalizeWorkExportText).filter(Boolean).join("\n") || "",
normalizeWorkExportText(row.remark),
formatCheckInStatus(row.status),
]);
});
styleWorkExportWorksheet(worksheet, headers, ["现场照片", "备注", "打卡地点"]);
const buffer = await workbook.xlsx.writeBuffer();
await downloadWorkExcelFile(`打卡记录_${formatWorkExportFilenameTime()}.xlsx`, buffer);
}
async function exportDailyReportRowsToExcel(rows: WorkDailyReportExportRow[]) {
const ExcelJS = await import("exceljs");
const workbook = new ExcelJS.Workbook();
const summaryWorksheet = workbook.addWorksheet("日报汇总");
const detailWorksheet = workbook.addWorksheet("日报明细");
const summaryHeaders = [
"序号",
"日报日期",
"提交时间",
"提交人",
"所属部门",
"今日工作内容",
"明日工作计划",
"今日工作条数",
"明日计划条数",
];
const detailHeaders = [
"序号",
"日报日期",
"提交时间",
"提交人",
"明细序号",
"工作日期",
"跟进对象类型",
"跟进对象名称",
"工作内容/项目进展",
"后续规划",
];
summaryWorksheet.addRow(summaryHeaders);
detailWorksheet.addRow(detailHeaders);
rows.forEach((row, index) => {
summaryWorksheet.addRow([
index + 1,
normalizeWorkExportText(row.reportDate),
normalizeWorkExportText(row.submitTime),
normalizeWorkExportText(row.userName),
normalizeWorkExportText(row.deptName),
normalizeWorkExportText(row.workContent),
normalizeWorkExportText(row.tomorrowPlan),
row.lineItems?.length ?? 0,
row.planItems?.length ?? 0,
]);
const lineItems = row.lineItems?.length ? row.lineItems : [undefined];
lineItems.forEach((lineItem, lineIndex) => {
detailWorksheet.addRow([
detailWorksheet.rowCount,
normalizeWorkExportText(row.reportDate),
normalizeWorkExportText(row.submitTime),
normalizeWorkExportText(row.userName),
lineIndex + 1,
normalizeWorkExportText(lineItem?.workDate),
formatWorkBizType(lineItem?.bizType),
normalizeWorkExportText(lineItem?.bizName),
normalizeWorkExportDetailContent(lineItem),
normalizeWorkExportText(lineItem?.nextPlan || extractContentByLabel(lineItem?.content, "后续规划")),
]);
});
});
styleWorkExportWorksheet(summaryWorksheet, summaryHeaders, ["今日工作内容", "明日工作计划"]);
styleWorkExportWorksheet(detailWorksheet, detailHeaders, ["工作内容/项目进展", "后续规划"]);
const buffer = await workbook.xlsx.writeBuffer();
await downloadWorkExcelFile(`销售日报_${formatWorkExportFilenameTime()}.xlsx`, buffer);
}
function getReportStatus(status?: string) {
if (!status) {
return "待提交";
@ -131,6 +243,155 @@ function getReportStatus(status?: string) {
return status === "reviewed" || status === "已点评" ? "已点评" : "已提交";
}
function normalizeWorkExportText(value?: string | number | null) {
if (value === null || value === undefined) {
return "";
}
const normalized = String(value).replace(/\r\n/g, "\n").trim();
return !normalized || normalized === "无" ? "" : normalized;
}
function normalizeWorkExportDetailContent(lineItem?: WorkReportLineItem) {
return normalizeWorkExportText(
lineItem?.latestProgress
|| extractContentByLabel(lineItem?.content, "项目最新进展")
|| lineItem?.evaluationContent
|| extractContentByLabel(lineItem?.content, "沟通内容")
|| lineItem?.editorText
|| lineItem?.content,
);
}
function formatWorkBizType(value?: string) {
if (value === "sales") {
return "销售人员拓展";
}
if (value === "channel") {
return "渠道拓展";
}
if (value === "opportunity") {
return "商机";
}
return normalizeWorkExportText(value);
}
function formatCheckInStatus(value?: string) {
if (value === "normal") {
return "正常";
}
if (value === "abnormal") {
return "异常";
}
if (value === "reissue") {
return "补卡";
}
if (value === "updated") {
return "已更新";
}
return normalizeWorkExportText(value);
}
function formatWorkExportFilenameTime(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}${month}${day}_${hours}${minutes}`;
}
async function downloadWorkExcelFile(filename: string, content: BlobPart) {
const blob = new Blob([content], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
if (shouldUseDataUrlDownloadFallback()) {
const dataUrl = await readBlobAsDataUrl(blob);
triggerWorkExcelDownload(filename, dataUrl);
return;
}
const objectUrl = window.URL.createObjectURL(blob);
triggerWorkExcelDownload(filename, objectUrl);
window.URL.revokeObjectURL(objectUrl);
}
function triggerWorkExcelDownload(filename: string, url: string) {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function shouldUseDataUrlDownloadFallback() {
if (typeof window === "undefined") {
return false;
}
const hostname = window.location.hostname;
const localHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
return window.location.protocol === "http:" && !localHosts.has(hostname);
}
function readBlobAsDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("导出文件生成失败,请稍后重试"));
}
};
reader.onerror = () => reject(reader.error ?? new Error("导出文件生成失败,请稍后重试"));
reader.readAsDataURL(blob);
});
}
function styleWorkExportWorksheet(worksheet: any, headers: string[], wrapHeaders: string[]) {
worksheet.views = [{ state: "frozen", ySplit: 1 }];
worksheet.getRow(1).height = 24;
worksheet.getRow(1).font = { bold: true };
worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" };
headers.forEach((header, index) => {
const column = worksheet.getColumn(index + 1);
if (wrapHeaders.includes(header)) {
column.width = 32;
} else if (header.includes("内容") || header.includes("计划") || header.includes("地点")) {
column.width = 24;
} else if (header.includes("时间") || header.includes("日期")) {
column.width = 18;
} else {
column.width = 16;
}
});
worksheet.eachRow((row: any, rowNumber: number) => {
row.eachCell((cell: any, columnNumber: number) => {
cell.border = {
top: { style: "thin", color: { argb: "FFE2E8F0" } },
left: { style: "thin", color: { argb: "FFE2E8F0" } },
bottom: { style: "thin", color: { argb: "FFE2E8F0" } },
right: { style: "thin", color: { argb: "FFE2E8F0" } },
};
cell.alignment = {
vertical: "top",
horizontal: rowNumber === 1 ? "center" : "left",
wrapText: wrapHeaders.includes(headers[columnNumber - 1]),
};
});
if (rowNumber > 1) {
const maxLines = headers.reduce((lineCount, header, index) => {
if (!wrapHeaders.includes(header)) {
return lineCount;
}
const text = normalizeWorkExportText(row.getCell(index + 1).value as string | number | null | undefined);
return Math.max(lineCount, text ? text.split("\n").length : 1);
}, 1);
row.height = Math.max(22, maxLines * 16);
}
});
}
export default function Work() {
const routerLocation = useLocation();
const isMobileViewport = useIsMobileViewport();
@ -163,6 +424,10 @@ export default function Work() {
const [historyLoadingMore, setHistoryLoadingMore] = useState(false);
const [historyHasMore, setHistoryHasMore] = useState(false);
const [historyPage, setHistoryPage] = useState(1);
const [exportingHistory, setExportingHistory] = useState(false);
const [historyExportError, setHistoryExportError] = useState("");
const [historyExportFilters, setHistoryExportFilters] = useState<WorkExportQuery>({});
const [historyExportModalOpen, setHistoryExportModalOpen] = useState(false);
const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false);
const [historyPresenterFilter, setHistoryPresenterFilter] = useState("all");
const [historyPresenterKeyword, setHistoryPresenterKeyword] = useState("");
@ -864,6 +1129,37 @@ export default function Work() {
}
};
const handleHistoryExport = async (filters: WorkExportQuery) => {
if (exportingHistory) {
return;
}
setExportingHistory(true);
setHistoryExportError("");
setHistoryExportFilters(filters);
try {
if (historySection === "checkin") {
const rows = await getWorkCheckInExportData(filters);
if (!rows.length) {
throw new Error("当前筛选条件下暂无可导出数据");
}
await exportCheckInRowsToExcel(rows);
} else {
const rows = await getWorkDailyReportExportData(filters);
if (!rows.length) {
throw new Error("当前筛选条件下暂无可导出数据");
}
await exportDailyReportRowsToExcel(rows);
}
setHistoryExportModalOpen(false);
} catch (error) {
setHistoryExportError(error instanceof Error ? error.message : "导出失败,请稍后重试");
} finally {
setExportingHistory(false);
}
};
if (!activeWorkSection || !activeWorkMeta) {
return <Navigate to="/work/checkin" replace />;
}
@ -954,9 +1250,31 @@ export default function Work() {
: "block lg:col-span-12 xl:col-span-12",
)}
>
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle title={showEntryPanel ? "历史记录" : `${activeWorkMeta.label}历史记录`} accent="bg-slate-300 dark:bg-slate-700" compact />
{showEntryPanel ? <MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} /> : null}
<div className="mb-2 flex items-start justify-between gap-3">
<div className="min-w-0">
<SectionTitle title={showEntryPanel ? "历史记录" : `${activeWorkMeta.label}历史记录`} accent="bg-slate-300 dark:bg-slate-700" compact />
{!isMobileViewport && historyExportError ? (
<p className="mt-1 text-xs text-rose-500 dark:text-rose-300">{historyExportError}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{!isMobileViewport ? (
<button
type="button"
onClick={() => {
setHistoryExportError("");
setHistoryExportModalOpen(true);
}}
disabled={exportingHistory}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition-colors hover:border-violet-200 hover:bg-violet-50 hover:text-violet-600 disabled:cursor-not-allowed disabled:border-slate-100 disabled:bg-slate-50 disabled:text-slate-300 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-400 dark:hover:border-violet-500/40 dark:hover:bg-slate-800 dark:hover:text-violet-300 dark:disabled:border-slate-800 dark:disabled:bg-slate-900/50 dark:disabled:text-slate-700"
aria-label={exportingHistory ? "导出中" : "导出 Excel"}
title={exportingHistory ? "导出中" : "导出 Excel"}
>
<Download className="h-4 w-4" />
</button>
) : null}
{showEntryPanel ? <MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} /> : null}
</div>
</div>
<div
@ -970,16 +1288,9 @@ export default function Work() {
{!historyLoading || historyData.length > 0 ? (
<HistoryToolbar
section={historySection}
presenterFilter={historyPresenterFilter}
presenterOptions={historyPresenterOptions}
filteredCount={filteredHistoryData.length}
totalCount={historyData.length}
onOpenPresenterPicker={() => setHistoryPresenterPickerOpen(true)}
onPresenterFilterChange={(value) => {
setHistoryPresenterFilter(value);
setHistoryPresenterPickerOpen(false);
}}
/>
) : null}
@ -1176,6 +1487,17 @@ export default function Work() {
/>
) : null}
{historyExportModalOpen && !isMobileViewport ? (
<HistoryExportFilterModal
section={historySection}
initialFilters={historyExportFilters}
exporting={exportingHistory}
exportError={historyExportError}
onClose={() => setHistoryExportModalOpen(false)}
onConfirm={(filters) => void handleHistoryExport(filters)}
/>
) : null}
{previewPhoto ? (
<PhotoPreviewModal
url={previewPhoto.url}
@ -1599,21 +1921,13 @@ function WorkHistorySkeleton() {
}
function HistoryToolbar({
section,
presenterFilter,
presenterOptions,
filteredCount,
totalCount,
onOpenPresenterPicker,
onPresenterFilterChange,
}: {
section: WorkSection;
presenterFilter: string;
presenterOptions: Array<{ value: string; label: string; count: number }>;
filteredCount: number;
totalCount: number;
onOpenPresenterPicker: () => void;
onPresenterFilterChange: (value: string) => void;
}) {
const currentPresenterLabel = presenterFilter === "all"
? "全部人员"
@ -1646,6 +1960,144 @@ function HistoryToolbar({
);
}
function HistoryExportFilterModal({
section,
initialFilters,
exporting,
exportError,
onClose,
onConfirm,
}: {
section: WorkSection;
initialFilters: WorkExportQuery;
exporting: boolean;
exportError: string;
onClose: () => void;
onConfirm: (filters: WorkExportQuery) => void;
}) {
const [draftFilters, setDraftFilters] = useState<WorkExportQuery>({ ...initialFilters, deptName: undefined, status: undefined });
const hasDraftFilters = Boolean(
draftFilters.keyword
|| draftFilters.startDate
|| draftFilters.endDate
|| draftFilters.bizType,
);
const handleFilterChange = (key: keyof WorkExportQuery, value: string) => {
setDraftFilters((current) => ({
...current,
[key]: value,
}));
};
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
return (
<div className="fixed inset-0 z-[92]">
<button
type="button"
onClick={onClose}
className="absolute inset-0 bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
aria-label="关闭导出条件"
/>
<div className="absolute left-1/2 top-1/2 flex max-h-[82vh] w-[min(560px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="shrink-0 border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div className="flex items-start justify-between gap-4">
<div className="px-1">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{getHistoryLabelBySection(section)}</h3>
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400"> Excel</p>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1.5 sm:col-span-2">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">//</span>
<input
type="text"
value={draftFilters.keyword ?? ""}
onChange={(event) => handleFilterChange("keyword", event.target.value)}
placeholder="输入人员、对象、地点或内容关键词"
className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100 dark:placeholder:text-slate-500"
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400"></span>
<input
type="date"
value={draftFilters.startDate ?? ""}
onChange={(event) => handleFilterChange("startDate", event.target.value)}
className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
aria-label="导出开始日期"
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400"></span>
<input
type="date"
value={draftFilters.endDate ?? ""}
onChange={(event) => handleFilterChange("endDate", event.target.value)}
className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
aria-label="导出结束日期"
/>
</label>
<label className="space-y-1.5 sm:col-span-2">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400"></span>
<AdaptiveSelect
value={draftFilters.bizType ?? ""}
options={[
{ value: "", label: "全部对象类型" },
{ value: "sales", label: "销售人员拓展" },
{ value: "channel", label: "渠道拓展" },
{ value: "opportunity", label: "商机" },
]}
placeholder="全部对象类型"
sheetTitle="选择关联对象类型"
className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
onChange={(value) => handleFilterChange("bizType", value)}
/>
</label>
</div>
{exportError ? <p className="mt-3 text-xs text-rose-500 dark:text-rose-300">{exportError}</p> : null}
</div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-100 px-5 py-4 dark:border-slate-800">
<button
type="button"
onClick={() => setDraftFilters({})}
disabled={!hasDraftFilters}
className="h-10 rounded-xl border border-slate-200 bg-white px-3 text-sm font-medium text-slate-600 transition-colors hover:border-violet-200 hover:text-violet-600 disabled:cursor-not-allowed disabled:text-slate-300 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300 dark:hover:border-violet-500/40 dark:hover:text-violet-200 dark:disabled:text-slate-600"
>
</button>
<button
type="button"
onClick={() => onConfirm(draftFilters)}
disabled={exporting}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-violet-600 px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:bg-violet-300 dark:bg-violet-500 dark:hover:bg-violet-400 dark:disabled:bg-violet-500/40"
>
<Download className="h-4 w-4" />
{exporting ? "导出中..." : "确认导出"}
</button>
</div>
</div>
</div>
);
}
function HistoryPresenterPickerModal({
section,
presenterFilter,