权限与日报

main
kangwenjing 2026-04-02 17:26:35 +08:00
parent 50c1dceccc
commit 19d8cf7e9b
22 changed files with 1059 additions and 357 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -5,10 +5,21 @@
</component>
<component name="ChangeListManager">
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323">
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ProfileMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ProfileMapper.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/profile/ProfileMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/profile/ProfileMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/work/WorkMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/work/WorkMapper.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/lib/auth.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/lib/auth.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Expansion.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Expansion.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Work.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Work.tsx" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -87,6 +98,7 @@
<workItem from="1774244004381" duration="10126000" />
<workItem from="1774574410059" duration="200000" />
<workItem from="1774576185724" duration="1651000" />
<workItem from="1774763863609" duration="16084000" />
</task>
<task id="LOCAL-00001" summary="修改定位信息 0323">
<option name="closed" value="true" />

View File

@ -0,0 +1,106 @@
package com.unis.crm.common;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Controller;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
@ControllerAdvice(annotations = Controller.class)
public class CurrentUserRoleCodesResponseAdvice implements ResponseBodyAdvice<Object> {
private static final String CURRENT_USER_PATH = "/sys/api/users/me";
private static final String USER_ROLE_CODES_SQL = """
select r.role_code
from sys_user_role ur
join sys_role r on r.role_id = ur.role_id
where ur.user_id = ?
and ur.is_deleted = 0
and r.is_deleted = 0
order by r.role_id asc
""";
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
public CurrentUserRoleCodesResponseAdvice(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.objectMapper = objectMapper;
}
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
if (!isCurrentUserRequest(request) || body == null) {
return body;
}
Map<String, Object> responseBody = objectMapper.convertValue(
body,
new TypeReference<LinkedHashMap<String, Object>>() {
});
if (!"0".equals(String.valueOf(responseBody.get("code")))) {
return body;
}
Object dataValue = responseBody.get("data");
if (dataValue == null) {
return body;
}
Map<String, Object> data = objectMapper.convertValue(
dataValue,
new TypeReference<LinkedHashMap<String, Object>>() {
});
Long userId = parseUserId(data.get("userId"));
if (userId == null) {
return body;
}
List<String> roleCodes = jdbcTemplate.queryForList(USER_ROLE_CODES_SQL, String.class, userId);
data.put("roleCodes", roleCodes == null ? List.of() : roleCodes);
responseBody.put("data", data);
return responseBody;
}
private boolean isCurrentUserRequest(ServerHttpRequest request) {
if (!(request instanceof ServletServerHttpRequest servletRequest)) {
return false;
}
return CURRENT_USER_PATH.equals(servletRequest.getServletRequest().getRequestURI());
}
private Long parseUserId(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof String text) {
try {
return Long.parseLong(text.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
}

View File

@ -6,6 +6,7 @@ import java.util.List;
public class ChannelExpansionItemDTO {
private Long id;
private Long ownerUserId;
private String type;
private String channelCode;
private String name;
@ -48,6 +49,14 @@ public class ChannelExpansionItemDTO {
this.id = id;
}
public Long getOwnerUserId() {
return ownerUserId;
}
public void setOwnerUserId(Long ownerUserId) {
this.ownerUserId = ownerUserId;
}
public String getType() {
return type;
}

View File

@ -6,6 +6,7 @@ import java.util.List;
public class SalesExpansionItemDTO {
private Long id;
private Long ownerUserId;
private String type;
private String employeeNo;
private String name;
@ -39,6 +40,14 @@ public class SalesExpansionItemDTO {
this.id = id;
}
public Long getOwnerUserId() {
return ownerUserId;
}
public void setOwnerUserId(Long ownerUserId) {
this.ownerUserId = ownerUserId;
}
public String getType() {
return type;
}

View File

@ -7,6 +7,7 @@ import java.util.List;
public class OpportunityItemDTO {
private Long id;
private Long ownerUserId;
private String code;
private String name;
private String client;
@ -44,6 +45,14 @@ public class OpportunityItemDTO {
this.id = id;
}
public Long getOwnerUserId() {
return ownerUserId;
}
public void setOwnerUserId(Long ownerUserId) {
this.ownerUserId = ownerUserId;
}
public String getCode() {
return code;
}

View File

@ -12,6 +12,8 @@ public interface ProfileMapper {
List<String> selectUserRoleNames(@Param("userId") Long userId);
List<String> selectUserRoleCodes(@Param("userId") Long userId);
List<String> selectUserOrgNames(@Param("userId") Long userId);
Long selectMonthlyOpportunityCount(@Param("userId") Long userId);

View File

@ -6,6 +6,7 @@ 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 com.unisbase.annotation.DataScope;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@ -19,13 +20,11 @@ public interface WorkMapper {
List<WorkSuggestedActionDTO> selectTodayWorkContentActions(@Param("userId") Long userId);
List<WorkHistoryItemDTO> selectHistory(@Param("userId") Long userId);
@DataScope(tableAlias = "c", ownerColumn = "user_id")
List<WorkHistoryItemDTO> selectCheckInHistory(@Param("limit") int limit);
List<WorkHistoryItemDTO> selectHistoryPage(
@Param("userId") Long userId,
@Param("historyType") String historyType,
@Param("limit") int limit,
@Param("offset") int offset);
@DataScope(tableAlias = "r", ownerColumn = "user_id")
List<WorkHistoryItemDTO> selectReportHistory(@Param("limit") int limit);
Long selectTodayCheckInId(@Param("userId") Long userId);

View File

@ -156,7 +156,6 @@ public class ExpansionServiceImpl implements ExpansionService {
@Override
public void updateSalesExpansion(Long userId, Long id, UpdateSalesExpansionRequest request) {
fillSalesDefaults(request);
ensureUniqueEmployeeNo(userId, request.getEmployeeNo(), id);
int updated = expansionMapper.updateSalesExpansion(userId, id, request);
if (updated <= 0) {
throw new BusinessException("未找到可编辑的销售拓展记录");
@ -167,7 +166,6 @@ public class ExpansionServiceImpl implements ExpansionService {
@Transactional
public void updateChannelExpansion(Long userId, Long id, UpdateChannelExpansionRequest request) {
fillChannelDefaults(request);
ensureUniqueChannelName(userId, request.getChannelName(), id);
int updated = expansionMapper.updateChannelExpansion(userId, id, request);
if (updated <= 0) {
throw new BusinessException("未找到可编辑的渠道拓展记录");

View File

@ -152,9 +152,6 @@ public class OpportunityServiceImpl implements OpportunityService {
if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) {
throw new BusinessException("无权操作该商机");
}
if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) {
throw new BusinessException("该商机已推送 OMS请勿重复操作");
}
OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId);
if (pushData == null) {

View File

@ -38,6 +38,7 @@ import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -112,17 +113,20 @@ public class WorkServiceImpl implements WorkService {
requireUser(userId);
int safePage = Math.max(page, 1);
int safeSize = Math.min(Math.max(size, 1), 20);
int querySize = safeSize + 1;
int offset = (safePage - 1) * safeSize;
String historyType = mapHistoryType(type);
List<WorkHistoryItemDTO> historyItems = workMapper.selectHistoryPage(userId, historyType, querySize, offset);
normalizeHistoryMetadata(historyItems);
HistoryType historyType = mapHistoryType(type);
int fetchLimit = offset + safeSize + 1;
List<WorkHistoryItemDTO> historyItems = loadHistoryItems(historyType, fetchLimit);
boolean hasMore = historyItems.size() > safeSize;
if (hasMore) {
historyItems = new ArrayList<>(historyItems.subList(0, safeSize));
boolean hasMore = historyItems.size() > offset + safeSize;
if (offset >= historyItems.size()) {
return new WorkHistoryPageDTO(List.of(), hasMore, safePage, safeSize);
}
return new WorkHistoryPageDTO(historyItems, hasMore, safePage, safeSize);
int toIndex = Math.min(offset + safeSize, historyItems.size());
List<WorkHistoryItemDTO> pagedItems = new ArrayList<>(historyItems.subList(offset, toIndex));
normalizeHistoryMetadata(pagedItems);
return new WorkHistoryPageDTO(pagedItems, hasMore, safePage, safeSize);
}
@Override
@ -279,18 +283,55 @@ public class WorkServiceImpl implements WorkService {
}
}
private String mapHistoryType(String type) {
private List<WorkHistoryItemDTO> loadHistoryItems(HistoryType historyType, int fetchLimit) {
List<WorkHistoryItemDTO> historyItems = new ArrayList<>();
if (historyType == null || historyType == HistoryType.CHECK_IN) {
historyItems.addAll(workMapper.selectCheckInHistory(fetchLimit));
}
if (historyType == null || historyType == HistoryType.REPORT) {
historyItems.addAll(workMapper.selectReportHistory(fetchLimit));
}
historyItems.sort(buildHistoryComparator());
return historyItems;
}
private HistoryType mapHistoryType(String type) {
String normalizedType = normalizeOptionalText(type);
if (normalizedType == null) {
return null;
}
return switch (normalizedType) {
case "checkin" -> "外勤打卡";
case "report" -> "日报";
case "checkin" -> HistoryType.CHECK_IN;
case "report" -> HistoryType.REPORT;
default -> throw new BusinessException("历史记录类型不支持");
};
}
private Comparator<WorkHistoryItemDTO> buildHistoryComparator() {
return Comparator
.comparing(this::buildHistorySortKey, Comparator.reverseOrder())
.thenComparing(WorkHistoryItemDTO::getId, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(WorkHistoryItemDTO::getType, Comparator.nullsLast(Comparator.naturalOrder()));
}
private String buildHistorySortKey(WorkHistoryItemDTO item) {
if (item == null) {
return "";
}
String date = normalizeOptionalText(item.getDate());
String time = normalizeOptionalText(item.getTime());
if (date == null && time == null) {
return "";
}
if (date == null) {
return time;
}
if (time == null) {
return date;
}
return date + " " + time;
}
private String buildSuggestedWorkContent(List<WorkSuggestedActionDTO> actions) {
if (actions == null || actions.isEmpty()) {
return "";
@ -510,7 +551,7 @@ public class WorkServiceImpl implements WorkService {
return;
}
ReportLineMetadata metadata = extractReportLineMetadata(todayReport.getWorkContent());
todayReport.setWorkContent(metadata.cleanText());
todayReport.setWorkContent(stripVisitTimeFromReportText(metadata.cleanText()));
todayReport.setLineItems(metadata.lineItems());
PlanItemMetadata planMetadata = extractPlanItemMetadata(todayReport.getTomorrowPlan());
todayReport.setTomorrowPlan(planMetadata.cleanText());
@ -528,11 +569,20 @@ public class WorkServiceImpl implements WorkService {
PhotoMetadata photoMetadata = extractPhotoMetadata(historyItem.getContent());
ReportLineMetadata reportLineMetadata = extractReportLineMetadata(photoMetadata.cleanText());
PlanItemMetadata planMetadata = extractPlanItemMetadata(reportLineMetadata.cleanText());
historyItem.setContent(planMetadata.cleanText());
historyItem.setContent(stripVisitTimeFromReportText(planMetadata.cleanText()));
historyItem.setPhotoUrls(photoMetadata.photoUrls());
}
}
private String stripVisitTimeFromReportText(String rawText) {
String normalized = rawText == null ? "" : rawText;
return normalized
.replaceAll(":拜访时间:[^\\n]+", "")
.replaceAll("\\n拜访时间[^\\n]+", "")
.replaceAll("\\n{3,}", "\n\n")
.trim();
}
private PhotoMetadata extractPhotoMetadata(String rawText) {
String normalized = rawText == null ? "" : rawText;
Matcher matcher = PHOTO_METADATA_PATTERN.matcher(normalized);
@ -684,16 +734,12 @@ public class WorkServiceImpl implements WorkService {
private String buildExpansionLineContent(WorkReportLineItemRequest item) {
String communication = normalizeOptionalText(item.getEvaluationContent());
String visitStartTime = normalizeOptionalText(item.getVisitStartTime());
String nextPlan = normalizeOptionalText(item.getNextPlan());
if (communication == null && visitStartTime == null && nextPlan == null) {
if (communication == null && nextPlan == null) {
return normalizeOptionalText(item.getContent());
}
List<String> parts = new ArrayList<>();
if (visitStartTime != null) {
parts.add("拜访时间:" + visitStartTime);
}
if (communication != null) {
parts.add("沟通内容:" + communication);
}
@ -1557,5 +1603,10 @@ public class WorkServiceImpl implements WorkService {
private record PlanItemMetadata(String cleanText, List<WorkTomorrowPlanItemDTO> planItems) {}
private enum HistoryType {
CHECK_IN,
REPORT
}
private record TencentLocationCandidate(String coordType, String locationText, int score) {}
}

View File

@ -53,6 +53,7 @@
<select id="selectSalesExpansions" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
select
s.id,
s.owner_user_id as ownerUserId,
'sales' as type,
coalesce(s.employee_no, '无') as employeeNo,
s.candidate_name as name,
@ -115,6 +116,7 @@
<select id="selectChannelExpansions" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
select
c.id,
c.owner_user_id as ownerUserId,
'channel' as type,
coalesce(c.channel_code, '') as channelCode,
c.channel_name as name,

View File

@ -38,6 +38,7 @@
<select id="selectOpportunities" resultType="com.unis.crm.dto.opportunity.OpportunityItemDTO">
select
o.id,
o.owner_user_id as ownerUserId,
o.opportunity_code as code,
o.opportunity_name as name,
coalesce(c.customer_name, '未填写最终客户') as client,
@ -449,11 +450,10 @@
<update id="markOpportunityOmsPushed">
update crm_opportunity o
set pushed_to_oms = true,
oms_push_time = coalesce(oms_push_time, now()),
oms_push_time = now(),
opportunity_code = #{opportunityCode},
updated_at = now()
where o.id = #{opportunityId}
and coalesce(pushed_to_oms, false) = false
</update>
<insert id="insertOpportunityFollowUp">

View File

@ -38,6 +38,16 @@
order by r.role_id asc
</select>
<select id="selectUserRoleCodes" resultType="java.lang.String">
select r.role_code
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

View File

@ -168,161 +168,78 @@
order by action_time asc, group_name asc, detail asc
</select>
<select id="selectHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
<select id="selectCheckInHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
id,
type,
date,
time,
content,
status,
score,
comment
from (
select
c.id,
'外勤打卡' as type,
to_char(c.checkin_date, 'YYYY-MM-DD') as date,
to_char(c.checkin_time, 'HH24:MI') as time,
case
when c.biz_name is not null and btrim(c.biz_name) &lt;&gt; '' then '关联对象:' || c.biz_name || E'\n'
else ''
end ||
case
when c.user_name is not null and btrim(c.user_name) &lt;&gt; '' then '打卡人:' || c.user_name || E'\n'
else ''
end ||
case
when c.dept_name is not null and btrim(c.dept_name) &lt;&gt; '' then '所属部门:' || c.dept_name || E'\n'
else ''
end ||
coalesce(c.location_text, '') ||
case
when c.remark is not null and btrim(c.remark) &lt;&gt; '' then E'\n备注' || c.remark
else ''
end as content,
case coalesce(c.status, 'normal')
when 'normal' then '正常'
when 'updated' then '已更新'
else coalesce(c.status, '正常')
end as status,
null::integer as score,
null::text as comment,
coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sort_time
from work_checkin c
where c.user_id = #{userId}
union all
select
r.id,
'日报' as type,
to_char(r.report_date, 'YYYY-MM-DD') as date,
to_char(r.submit_time, 'HH24:MI') as time,
coalesce(r.work_content, '') ||
case
when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) &lt;&gt; '' then E'\n明日计划' || r.tomorrow_plan
else ''
end as content,
case coalesce(rc.comment_content, '')
when '' then
case coalesce(r.status, 'submitted')
when 'submitted' then '已提交'
when 'reviewed' then '已点评'
else coalesce(r.status, '已提交')
end
else '已点评'
end as status,
rc.score,
rc.comment_content as comment,
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
from work_daily_report r
left join (
select distinct on (report_id)
report_id,
score,
comment_content
from work_daily_report_comment
order by report_id, reviewed_at desc nulls last, id desc
) rc on rc.report_id = r.id
where r.user_id = #{userId}
) history
order by sort_time desc nulls last, id desc
</select>
<select id="selectHistoryPage" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
id,
type,
date,
time,
content,
status,
score,
comment
from (
select
c.id,
'外勤打卡' as type,
to_char(c.checkin_date, 'YYYY-MM-DD') as date,
to_char(c.checkin_time, 'HH24:MI') as time,
coalesce(c.location_text, '') ||
case
when c.remark is not null and btrim(c.remark) &lt;&gt; '' then E'\n备注' || c.remark
else ''
end as content,
case coalesce(c.status, 'normal')
when 'normal' then '正常'
when 'updated' then '已更新'
else coalesce(c.status, '正常')
end as status,
null::integer as score,
null::text as comment,
coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sort_time
from work_checkin c
where c.user_id = #{userId}
union all
select
r.id,
'日报' as type,
to_char(r.report_date, 'YYYY-MM-DD') as date,
to_char(r.submit_time, 'HH24:MI') as time,
coalesce(r.work_content, '') ||
case
when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) &lt;&gt; '' then E'\n明日计划' || r.tomorrow_plan
else ''
end as content,
case coalesce(rc.comment_content, '')
when '' then
case coalesce(r.status, 'submitted')
when 'submitted' then '已提交'
when 'reviewed' then '已点评'
else coalesce(r.status, '已提交')
end
else '已点评'
end as status,
rc.score,
rc.comment_content as comment,
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
from work_daily_report r
left join (
select distinct on (report_id)
report_id,
score,
comment_content
from work_daily_report_comment
order by report_id, reviewed_at desc nulls last, id desc
) rc on rc.report_id = r.id
where r.user_id = #{userId}
) history
<if test="historyType != null and historyType != ''">
where history.type = #{historyType}
</if>
c.id,
'外勤打卡' as type,
to_char(c.checkin_date, 'YYYY-MM-DD') as date,
to_char(c.checkin_time, 'HH24:MI') as time,
case
when c.biz_name is not null and btrim(c.biz_name) &lt;&gt; '' then '关联对象:' || c.biz_name || E'\n'
else ''
end ||
case
when c.user_name is not null and btrim(c.user_name) &lt;&gt; '' then '打卡人:' || c.user_name || E'\n'
else ''
end ||
coalesce(c.location_text, '') ||
case
when c.remark is not null and btrim(c.remark) &lt;&gt; '' then E'\n备注' || c.remark
else ''
end as content,
case coalesce(c.status, 'normal')
when 'normal' then '正常'
when 'updated' then '已更新'
else coalesce(c.status, '正常')
end as status,
null::integer as score,
null::text as comment,
coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sort_time
from work_checkin c
order by sort_time desc nulls last, id desc
limit #{limit}
</select>
<select id="selectReportHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
r.id,
'日报' as type,
to_char(r.report_date, 'YYYY-MM-DD') as date,
to_char(r.submit_time, 'HH24:MI') as time,
case
when coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) is not null
then '提交人:' || coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) || E'\n'
else ''
end ||
coalesce(r.work_content, '') ||
case
when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) &lt;&gt; '' then E'\n明日计划' || r.tomorrow_plan
else ''
end as content,
case coalesce(rc.comment_content, '')
when '' then
case coalesce(r.status, 'submitted')
when 'submitted' then '已提交'
when 'reviewed' then '已点评'
else coalesce(r.status, '已提交')
end
else '已点评'
end as status,
rc.score,
rc.comment_content as comment,
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
from work_daily_report r
left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
left join (
select distinct on (report_id)
report_id,
score,
comment_content
from work_daily_report_comment
order by report_id, reviewed_at desc nulls last, id desc
) rc on rc.report_id = r.id
order by sort_time desc nulls last, id desc
limit #{limit}
offset #{offset}
</select>
<select id="selectTodayCheckInId" resultType="java.lang.Long">

View File

@ -0,0 +1,33 @@
package com.unis.crm.mapper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import com.unisbase.annotation.DataScope;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Test;
class WorkMapperDataScopeTest {
@Test
void selectCheckInHistory_shouldUseDataScopeOnCheckInUserId() throws Exception {
Method method = WorkMapper.class.getMethod("selectCheckInHistory", int.class);
DataScope dataScope = method.getAnnotation(DataScope.class);
assertNotNull(dataScope);
assertEquals("c", dataScope.tableAlias());
assertEquals("user_id", dataScope.ownerColumn());
}
@Test
void selectReportHistory_shouldUseDataScopeOnReportUserId() throws Exception {
Method method = WorkMapper.class.getMethod("selectReportHistory", int.class);
DataScope dataScope = method.getAnnotation(DataScope.class);
assertNotNull(dataScope);
assertEquals("r", dataScope.tableAlias());
assertEquals("user_id", dataScope.ownerColumn());
}
}

View File

@ -0,0 +1,161 @@
package com.unis.crm.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.unis.crm.common.BusinessException;
import com.unis.crm.dto.expansion.ChannelExpansionContactRequest;
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
import com.unis.crm.mapper.ExpansionMapper;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class ExpansionServiceImplTest {
@Mock
private ExpansionMapper expansionMapper;
@InjectMocks
private ExpansionServiceImpl expansionService;
@Test
void createSalesExpansion_shouldRejectDuplicateEmployeeNo() {
CreateSalesExpansionRequest request = buildCreateSalesRequest();
when(expansionMapper.countSalesExpansionByEmployeeNo("EMP001")).thenReturn(1);
BusinessException exception = assertThrows(
BusinessException.class,
() -> expansionService.createSalesExpansion(1L, request));
assertEquals("工号重复,请确认该人员是否已存在!", exception.getMessage());
verify(expansionMapper, never()).insertSalesExpansion(any(), any());
}
@Test
void updateSalesExpansion_shouldSkipDuplicateEmployeeNoCheck() {
UpdateSalesExpansionRequest request = buildUpdateSalesRequest();
when(expansionMapper.updateSalesExpansion(1L, 10L, request)).thenReturn(1);
expansionService.updateSalesExpansion(1L, 10L, request);
verify(expansionMapper).updateSalesExpansion(1L, 10L, request);
verify(expansionMapper, never()).countSalesExpansionByEmployeeNo(any());
verify(expansionMapper, never()).countSalesExpansionByEmployeeNoExcludingId(any(), any());
}
@Test
void createChannelExpansion_shouldRejectDuplicateChannelName() {
CreateChannelExpansionRequest request = buildCreateChannelRequest();
when(expansionMapper.countChannelExpansionByChannelName("渠道A")).thenReturn(1);
BusinessException exception = assertThrows(
BusinessException.class,
() -> expansionService.createChannelExpansion(1L, request));
assertEquals("渠道重复,请确认该渠道是否已存在!", exception.getMessage());
verify(expansionMapper, never()).insertChannelExpansion(any(), any());
}
@Test
void updateChannelExpansion_shouldSkipDuplicateChannelNameCheck() {
UpdateChannelExpansionRequest request = buildUpdateChannelRequest();
when(expansionMapper.updateChannelExpansion(1L, 20L, request)).thenReturn(1);
expansionService.updateChannelExpansion(1L, 20L, request);
verify(expansionMapper).updateChannelExpansion(1L, 20L, request);
verify(expansionMapper).deleteChannelContacts(20L);
verify(expansionMapper).insertChannelContact(eq(20L), eq(1), any(ChannelExpansionContactRequest.class));
verify(expansionMapper, never()).countChannelExpansionByChannelName(any());
verify(expansionMapper, never()).countChannelExpansionByChannelNameExcludingId(any(), any());
}
private CreateSalesExpansionRequest buildCreateSalesRequest() {
CreateSalesExpansionRequest request = new CreateSalesExpansionRequest();
request.setEmployeeNo("EMP001");
request.setCandidateName("张三");
request.setIntentLevel("medium");
request.setStage("initial_contact");
request.setHasDesktopExp(Boolean.FALSE);
request.setInProgress(Boolean.TRUE);
request.setEmploymentStatus("active");
return request;
}
private UpdateSalesExpansionRequest buildUpdateSalesRequest() {
UpdateSalesExpansionRequest request = new UpdateSalesExpansionRequest();
request.setEmployeeNo("EMP001");
request.setCandidateName("张三");
request.setIntentLevel("medium");
request.setStage("initial_contact");
request.setHasDesktopExp(Boolean.FALSE);
request.setInProgress(Boolean.TRUE);
request.setEmploymentStatus("active");
return request;
}
private CreateChannelExpansionRequest buildCreateChannelRequest() {
CreateChannelExpansionRequest request = new CreateChannelExpansionRequest();
request.setChannelName("渠道A");
request.setProvince("浙江省");
request.setCity("杭州市");
request.setCertificationLevel("A");
request.setOfficeAddress("未来科技城");
request.setChannelIndustry("制造业");
request.setAnnualRevenue(BigDecimal.valueOf(100));
request.setStaffSize(20);
request.setContactEstablishedDate(LocalDate.of(2024, 1, 1));
request.setChannelAttribute("direct");
request.setInternalAttribute("partner");
request.setIntentLevel("medium");
request.setStage("initial_contact");
request.setHasDesktopExp(Boolean.FALSE);
request.setLandedFlag(Boolean.FALSE);
request.setContacts(List.of(buildContact()));
return request;
}
private UpdateChannelExpansionRequest buildUpdateChannelRequest() {
UpdateChannelExpansionRequest request = new UpdateChannelExpansionRequest();
request.setChannelName("渠道A");
request.setProvince("浙江省");
request.setCity("杭州市");
request.setCertificationLevel("A");
request.setOfficeAddress("未来科技城");
request.setChannelIndustry("制造业");
request.setAnnualRevenue(BigDecimal.valueOf(100));
request.setStaffSize(20);
request.setContactEstablishedDate(LocalDate.of(2024, 1, 1));
request.setChannelAttribute("direct");
request.setInternalAttribute("partner");
request.setIntentLevel("medium");
request.setStage("initial_contact");
request.setHasDesktopExp(Boolean.FALSE);
request.setLandedFlag(Boolean.FALSE);
request.setContacts(List.of(buildContact()));
return request;
}
private ChannelExpansionContactRequest buildContact() {
ChannelExpansionContactRequest contact = new ChannelExpansionContactRequest();
contact.setName("李四");
contact.setMobile("13800138000");
contact.setTitle("负责人");
return contact;
}
}

View File

@ -0,0 +1,104 @@
package com.unis.crm.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unis.crm.common.BusinessException;
import com.unis.crm.dto.work.WorkHistoryItemDTO;
import com.unis.crm.dto.work.WorkHistoryPageDTO;
import com.unis.crm.mapper.WorkMapper;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class WorkServiceImplTest {
@Mock
private WorkMapper workMapper;
private WorkServiceImpl workService;
@BeforeEach
void setUp() {
workService = new WorkServiceImpl(workMapper, new ObjectMapper(), "build/test-uploads", "");
}
@Test
void getHistory_shouldMergeSortAndPaginateCheckInsAndReports() {
when(workMapper.selectCheckInHistory(3)).thenReturn(List.of(
historyItem(11L, "外勤打卡", "2026-04-02", "08:30", "打卡"),
historyItem(10L, "外勤打卡", "2026-04-01", "18:00", "打卡")));
when(workMapper.selectReportHistory(3)).thenReturn(List.of(
historyItem(21L, "日报", "2026-04-02", "09:15", "日报")));
WorkHistoryPageDTO result = workService.getHistory(17L, null, 1, 2);
assertEquals(2, result.getItems().size());
assertEquals("日报", result.getItems().get(0).getType());
assertEquals(21L, result.getItems().get(0).getId());
assertEquals("外勤打卡", result.getItems().get(1).getType());
assertEquals(11L, result.getItems().get(1).getId());
assertEquals(true, result.isHasMore());
verify(workMapper).selectCheckInHistory(3);
verify(workMapper).selectReportHistory(3);
}
@Test
void getHistory_shouldOnlyQueryCheckInHistoryWhenTypeIsCheckin() {
when(workMapper.selectCheckInHistory(2)).thenReturn(List.of(
historyItem(11L, "外勤打卡", "2026-04-02", "08:30", "打卡")));
WorkHistoryPageDTO result = workService.getHistory(17L, "checkin", 1, 1);
assertEquals(1, result.getItems().size());
assertEquals("外勤打卡", result.getItems().get(0).getType());
assertEquals(false, result.isHasMore());
verify(workMapper).selectCheckInHistory(2);
verify(workMapper, never()).selectReportHistory(2);
}
@Test
void getHistory_shouldReturnSecondPageAfterGlobalSort() {
when(workMapper.selectCheckInHistory(3)).thenReturn(List.of(
historyItem(11L, "外勤打卡", "2026-04-02", "08:30", "打卡"),
historyItem(10L, "外勤打卡", "2026-04-01", "18:00", "打卡")));
when(workMapper.selectReportHistory(3)).thenReturn(List.of(
historyItem(21L, "日报", "2026-04-02", "09:15", "日报")));
WorkHistoryPageDTO result = workService.getHistory(17L, null, 2, 1);
assertEquals(1, result.getItems().size());
assertEquals(11L, result.getItems().get(0).getId());
assertEquals(true, result.isHasMore());
verify(workMapper).selectCheckInHistory(3);
verify(workMapper).selectReportHistory(3);
}
@Test
void getHistory_shouldRejectUnsupportedType() {
BusinessException exception = assertThrows(
BusinessException.class,
() -> workService.getHistory(17L, "todo", 1, 10));
assertEquals("历史记录类型不支持", exception.getMessage());
}
private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) {
WorkHistoryItemDTO item = new WorkHistoryItemDTO();
item.setId(id);
item.setType(type);
item.setDate(date);
item.setTime(time);
item.setContent(content);
item.setStatus("已提交");
return item;
}
}

View File

@ -36,6 +36,14 @@ export interface UserProfile {
isAdmin?: boolean;
isPlatformAdmin?: boolean;
pwdResetRequired?: number;
roleCodes?: string[];
roles?: UserRole[];
}
export interface UserRole {
roleId?: number;
roleCode?: string;
roleName?: string;
}
export interface PlatformConfig {
@ -241,6 +249,7 @@ export interface OpportunityFollowUp {
export interface OpportunityItem {
id: number;
ownerUserId?: number;
code?: string;
name?: string;
client?: string;
@ -339,6 +348,7 @@ export interface ExpansionFollowUp {
export interface SalesExpansionItem {
id: number;
ownerUserId?: number;
type: "sales";
employeeNo?: string;
name?: string;
@ -374,6 +384,7 @@ export interface RelatedProjectSummary {
export interface ChannelExpansionItem {
id: number;
ownerUserId?: number;
type: "channel";
channelCode?: string;
name?: string;
@ -713,6 +724,10 @@ function getStoredUserId() {
return undefined;
}
export function getStoredCurrentUserId() {
return getStoredUserId();
}
export async function fetchCaptcha() {
return request<CaptchaResponse>("/api/sys/auth/captcha");
}
@ -751,10 +766,21 @@ export async function getOpenPlatformConfig() {
export async function getCurrentUser() {
return getCachedAuthedRequest<UserProfile>(
CURRENT_USER_CACHE_KEY,
() => request<UserProfile>("/api/sys/api/users/me", undefined, true),
async () => {
const profile = await request<UserProfile>("/api/sys/api/users/me", undefined, true);
persistStoredUserProfile(profile);
return profile;
},
);
}
export async function refreshCurrentUser() {
clearCachedAuthContext();
const profile = await request<UserProfile>("/api/sys/api/users/me", undefined, true);
persistStoredUserProfile(profile);
return profile;
}
export async function getDashboardHome() {
return request<DashboardHome>("/api/dashboard/home", undefined, true);
}
@ -1006,6 +1032,14 @@ function clearCachedAuthContext() {
});
}
function persistStoredUserProfile(profile: UserProfile) {
try {
sessionStorage.setItem("userProfile", JSON.stringify(profile));
} catch {
// Ignore session storage failures and keep request cache only.
}
}
async function getCachedAuthedRequest<T>(
cacheKey: string,
fetcher: () => Promise<T>,

View File

@ -11,6 +11,7 @@ import {
getExpansionCityOptions,
getExpansionMeta,
getExpansionOverview,
getStoredCurrentUserId,
updateChannelExpansion,
updateSalesExpansion,
type ChannelExpansionContact,
@ -566,6 +567,7 @@ function RequiredMark() {
}
export default function Expansion() {
const currentUserId = getStoredCurrentUserId();
const location = useLocation();
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
@ -606,6 +608,7 @@ export default function Expansion() {
const [invalidEditChannelContactRows, setInvalidEditChannelContactRows] = useState<number[]>([]);
const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "followups">("projects");
const [channelDetailTab, setChannelDetailTab] = useState<"projects" | "contacts" | "followups">("projects");
const canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId);
const [salesForm, setSalesForm] = useState<CreateSalesExpansionPayload>(defaultSalesForm);
const [channelForm, setChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
@ -945,6 +948,9 @@ export default function Expansion() {
if (!selectedItem) {
return;
}
if (!canEditSelectedItem) {
return;
}
setEditError("");
let latestIndustryOptions = industryOptions;
@ -1078,6 +1084,10 @@ export default function Expansion() {
if (!selectedItem || submitting) {
return;
}
if (!canEditSelectedItem) {
setEditError("仅可编辑本人创建的数据");
return;
}
setEditError("");
if (selectedItem.type === "sales") {
@ -1669,24 +1679,46 @@ export default function Expansion() {
<div className="crm-list-stack">
{activeTab === "sales" ? (
salesData.length > 0 ? (
salesData.map((item, i) => (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={item.id}
onClick={() => setSelectedItem(item)}
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{item.officeName || "无"} · {item.dept || "无"} · {item.title || "无"}</p>
</div>
<span className={`crm-pill shrink-0 ${item.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
{item.active ? "在职" : "离职"}
</span>
</div>
salesData.map((item, i) => {
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
return (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={item.id}
onClick={() => setSelectedItem(item)}
className={cn(
"crm-card crm-card-pad relative rounded-2xl transition-shadow transition-colors",
isOwnedByCurrentUser
? "cursor-pointer hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
: "cursor-pointer border-slate-100 bg-slate-50/45 hover:border-slate-200 hover:shadow-sm dark:border-slate-800/80 dark:bg-slate-900/35 dark:hover:border-slate-700",
)}
>
{isOwnedByCurrentUser ? (
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
) : null}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{item.officeName || "无"} · {item.dept || "无"} · {item.title || "无"}</p>
</div>
<div className="shrink-0 flex items-center gap-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={`crm-pill shrink-0 ${item.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
{item.active ? "在职" : "离职"}
</span>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
@ -1703,30 +1735,53 @@ export default function Expansion() {
<ChevronRight className="crm-icon-sm" />
</button>
</div>
</motion.div>
))
</motion.div>
);
})
) : renderEmpty()
) : channelData.length > 0 ? (
channelData.map((item, i) => (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={item.id}
onClick={() => setSelectedItem(item)}
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
{item.province || "无"} · {item.city || "无"} · {item.certificationLevel || "无"}
</p>
channelData.map((item, i) => {
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
return (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={item.id}
onClick={() => setSelectedItem(item)}
className={cn(
"crm-card crm-card-pad relative rounded-2xl transition-shadow transition-colors",
isOwnedByCurrentUser
? "cursor-pointer hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
: "cursor-pointer border-slate-100 bg-slate-50/45 hover:border-slate-200 hover:shadow-sm dark:border-slate-800/80 dark:bg-slate-900/35 dark:hover:border-slate-700",
)}
>
{isOwnedByCurrentUser ? (
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
) : null}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
{item.province || "无"} · {item.city || "无"} · {item.certificationLevel || "无"}
</p>
</div>
<div className="shrink-0 flex items-center gap-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
{item.intent ? `${item.intent}意向` : "未评估"}
</span>
</div>
</div>
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
{item.intent ? `${item.intent}意向` : "未评估"}
</span>
</div>
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
@ -1743,8 +1798,9 @@ export default function Expansion() {
<ChevronRight className="crm-icon-sm" />
</button>
</div>
</motion.div>
))
</motion.div>
);
})
) : renderEmpty()}
</div>
@ -2022,9 +2078,18 @@ export default function Expansion() {
</div>
<div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90">
{!canEditSelectedItem ? (
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500"></p>
) : null}
<div className="flex">
<button onClick={handleOpenEdit} className="crm-btn-sm crm-btn-secondary flex-1">
<button
type="button"
onClick={() => void handleOpenEdit()}
disabled={!canEditSelectedItem}
title={canEditSelectedItem ? "编辑资料" : "仅本人可操作"}
className="crm-btn-sm crm-btn-secondary flex-1 disabled:cursor-not-allowed disabled:opacity-60"
>
{canEditSelectedItem ? "编辑资料" : "仅本人可操作"}
</button>
</div>
</div>

View File

@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Search, Plus, Download, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, getStoredCurrentUserId, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
import { cn } from "@/lib/utils";
@ -331,7 +331,7 @@ function validateOpportunityForm(
errors.opportunityName = "请填写项目名称";
}
if (!form.customerName?.trim()) {
errors.customerName = "请填写最终户";
errors.customerName = "请填写最终户";
}
if (!form.operatorName?.trim()) {
errors.operatorName = "请选择运作方";
@ -914,6 +914,7 @@ function CompetitorMultiSelect({
}
export default function Opportunities() {
const currentUserId = getStoredCurrentUserId();
const isMobileViewport = useIsMobileViewport();
const isWecomBrowser = useIsWecomBrowser();
const disableMobileMotion = isMobileViewport || isWecomBrowser;
@ -1089,6 +1090,8 @@ export default function Opportunities() {
const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || "";
const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || "";
const selectedPreSalesName = selectedItem?.preSalesName || "无";
const canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId);
const canPushSelectedItem = canEditSelectedItem;
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both";
const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both";
@ -1158,7 +1161,7 @@ export default function Opportunities() {
"项目编号",
"项目名称",
"项目地",
"最终户",
"最终户",
"建设类型",
"运作方",
"项目阶段",
@ -1240,7 +1243,7 @@ export default function Opportunities() {
column.width = 42;
} else if (header.includes("项目最新进展") || header.includes("后续规划") || header.includes("备注")) {
column.width = 24;
} else if (header.includes("项目名称") || header.includes("最终客户")) {
} else if (header.includes("项目名称") || header.includes("最终客户") || header.includes("最终用户")) {
column.width = 20;
} else {
column.width = 16;
@ -1347,6 +1350,10 @@ export default function Opportunities() {
if (!selectedItem) {
return;
}
if (!canEditSelectedItem) {
setError("仅可编辑本人负责的商机");
return;
}
setError("");
setFieldErrors({});
setForm(toFormFromItem(selectedItem));
@ -1360,6 +1367,10 @@ export default function Opportunities() {
if (!selectedItem || submitting) {
return;
}
if (!canEditSelectedItem) {
setError("仅可编辑本人负责的商机");
return;
}
setError("");
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
@ -1382,7 +1393,11 @@ export default function Opportunities() {
};
const handlePushToOms = async () => {
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
if (!selectedItem || pushingOms) {
return;
}
if (!canPushSelectedItem) {
setError("仅可推送本人负责的商机");
return;
}
@ -1433,7 +1448,11 @@ export default function Opportunities() {
};
const handleOpenPushConfirm = async () => {
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
if (!selectedItem || pushingOms) {
return;
}
if (!canPushSelectedItem) {
setError("仅可推送本人负责的商机");
return;
}
@ -1455,6 +1474,10 @@ export default function Opportunities() {
};
const handleConfirmPushToOms = async () => {
if (!canPushSelectedItem) {
setError("仅可推送本人负责的商机");
return;
}
if (!pushPreSalesId && !pushPreSalesName.trim()) {
setError("请选择售前人员");
return;
@ -1528,7 +1551,7 @@ export default function Opportunities() {
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 group-focus-within:text-violet-500 transition-colors" />
<input
type="text"
placeholder="搜索项目名称、最终户、编码..."
placeholder="搜索项目名称、最终户、编码..."
value={keyword}
onChange={(event) => {
setKeyword(event.target.value);
@ -1557,56 +1580,87 @@ export default function Opportunities() {
<div className="crm-list-stack">
{visibleItems.length > 0 ? (
visibleItems.map((opp, i) => (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={opp.id}
onClick={() => setSelectedItem(opp)}
className="crm-card crm-card-pad group relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{opp.name || "未命名商机"}</h3>
<p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{opp.code || "待生成"}</p>
visibleItems.map((opp, i) => {
const isOwnedByCurrentUser = currentUserId !== undefined && opp.ownerUserId === currentUserId;
return (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={opp.id}
onClick={() => setSelectedItem(opp)}
className={cn(
"crm-card crm-card-pad group relative rounded-2xl transition-shadow transition-colors",
isOwnedByCurrentUser
? "cursor-pointer hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
: "cursor-pointer border-slate-100 bg-slate-50/45 hover:border-slate-200 hover:shadow-sm dark:border-slate-800/80 dark:bg-slate-900/35 dark:hover:border-slate-700",
)}
>
{isOwnedByCurrentUser ? (
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
) : null}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{opp.name || "未命名商机"}</h3>
<p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{opp.code || "待生成"}</p>
</div>
<div className="shrink-0 flex flex-wrap items-center justify-end gap-2 pl-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={getConfidenceBadgeClass(opp.confidence)}>
{getConfidenceLabel(opp.confidence)}
</span>
<span className="crm-pill crm-pill-neutral">
{opp.stage || "初步沟通"}
</span>
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
opp.pushedToOms
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"}
</span>
</div>
</div>
<div className="shrink-0 flex items-center gap-2 pl-2">
<span className={getConfidenceBadgeClass(opp.confidence)}>
{getConfidenceLabel(opp.confidence)}
</span>
<span className="crm-pill crm-pill-neutral">
{opp.stage || "初步沟通"}
</span>
</div>
</div>
<div className="mt-4 space-y-3 text-xs sm:text-sm">
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.date || "待定"}</span>
<div className="mt-4 space-y-3 text-xs sm:text-sm">
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.date || "待定"}</span>
</div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">¥{formatAmount(opp.amount)}</span>
</div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.latestProgress || "暂无回写进展"}</span>
</div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.nextPlan || "暂无回写规划"}</span>
</div>
</div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">¥{formatAmount(opp.amount)}</span>
</div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.latestProgress || "暂无回写进展"}</span>
</div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.nextPlan || "暂无回写规划"}</span>
</div>
</div>
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
<button type="button" className={detailBadgeClass}>
<ChevronRight className="crm-icon-sm ml-0.5" />
</button>
</div>
</motion.div>
))
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
<button type="button" className={detailBadgeClass}>
<ChevronRight className="crm-icon-sm ml-0.5" />
</button>
</div>
</motion.div>
);
})
) : renderEmpty()}
</div>
@ -1706,7 +1760,7 @@ export default function Opportunities() {
{fieldErrors.opportunityName ? <p className="text-xs text-rose-500">{fieldErrors.opportunityName}</p> : null}
</label>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.customerName} onChange={(e) => handleChange("customerName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.customerName))} />
{fieldErrors.customerName ? <p className="text-xs text-rose-500">{fieldErrors.customerName}</p> : null}
</label>
@ -2018,7 +2072,7 @@ export default function Opportunities() {
</h4>
<div className="crm-detail-grid text-sm md:grid-cols-2">
<DetailItem label="项目地" value={selectedItem.projectLocation || "无"} />
<DetailItem label="最终户" value={selectedItem.client || "无"} icon={<Building className="h-3 w-3" />} />
<DetailItem label="最终户" value={selectedItem.client || "无"} icon={<Building className="h-3 w-3" />} />
<DetailItem label="运作方" value={selectedItem.operatorName || "无"} />
<DetailItem label="新华三负责人" value={selectedSalesExpansionName || "未关联"} icon={<User className="h-3 w-3" />} />
<DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} />
@ -2026,6 +2080,21 @@ export default function Opportunities() {
<DetailItem label="预计下单时间" value={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
<DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence)} />
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
<DetailItem
label="是否已推送OMS"
value={(
<span
className={cn(
"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold",
selectedItem.pushedToOms
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300"
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
)}
>
{selectedItem.pushedToOms ? "已推送" : "未推送"}
</span>
)}
/>
<DetailItem label="竞争对手" value={selectedItem.competitorName || "无"} />
<DetailItem label="建设类型" value={selectedItem.type || "新建"} icon={<Tag className="h-3 w-3" />} />
<DetailItem label="项目最新进展" value={selectedItem.latestProgress || "暂无日报回写进展"} className="md:col-span-2" />
@ -2138,25 +2207,38 @@ export default function Opportunities() {
</div>
<div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90">
{error ? <div className="crm-alert crm-alert-error mb-3">{error}</div> : null}
{!canEditSelectedItem ? (
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500"></p>
) : null}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={handleOpenEdit}
className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center"
disabled={!canEditSelectedItem}
title={canEditSelectedItem ? "编辑商机" : "仅本人可操作"}
className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{canEditSelectedItem ? "编辑商机" : "仅本人可操作"}
</button>
<button
type="button"
onClick={() => void handleOpenPushConfirm()}
disabled={Boolean(selectedItem.pushedToOms) || pushingOms}
disabled={!canPushSelectedItem || pushingOms}
title={
!canPushSelectedItem
? "仅本人可操作"
: selectedItem.pushedToOms
? "重新推送 OMS"
: "推送 OMS"
}
className={cn(
"crm-btn inline-flex h-11 items-center justify-center",
selectedItem.pushedToOms
"crm-btn inline-flex h-11 items-center justify-center disabled:cursor-not-allowed disabled:opacity-60",
!canPushSelectedItem
? "cursor-not-allowed rounded-2xl border border-slate-200 bg-slate-50 text-slate-400 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-500"
: "crm-btn-primary",
)}
>
{selectedItem.pushedToOms ? "已推送 OMS" : pushingOms ? "推送中..." : "推送 OMS"}
{!canPushSelectedItem ? "仅本人可操作" : pushingOms ? "推送中..." : selectedItem.pushedToOms ? "重新推送 OMS" : "推送 OMS"}
</button>
</div>
</div>

View File

@ -10,6 +10,7 @@ import {
getExpansionOverview,
getOpportunityOverview,
getProfileOverview,
refreshCurrentUser,
getWorkHistory,
getWorkOverview,
reverseWorkGeocode,
@ -164,7 +165,7 @@ export default function Work() {
const [historyPage, setHistoryPage] = useState(1);
const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false);
const [reportStatus, setReportStatus] = useState<string>();
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
const [currentUser, setCurrentUser] = useState<UserProfile | null>(() => readStoredUserProfile());
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
const [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
const [salesOptions, setSalesOptions] = useState<WorkRelationOption[]>([]);
@ -182,6 +183,8 @@ export default function Work() {
const activeWorkMeta = activeWorkSection
? workSectionItems.find((item) => item.key === activeWorkSection)
: null;
const isOnlySeeRole = hasOnlySeeRole(currentUser);
const showEntryPanel = !isOnlySeeRole;
const pickerOptions = useMemo(() => {
if (!objectPicker) {
@ -299,7 +302,7 @@ export default function Work() {
async function loadUserContext() {
try {
const [userData, overviewData] = await Promise.all([
getCurrentUser().catch(() => null),
((currentUser?.roleCodes?.length ?? 0) > 0 ? getCurrentUser() : refreshCurrentUser()).catch(() => null),
getProfileOverview().catch(() => null),
]);
if (cancelled) {
@ -510,6 +513,9 @@ export default function Work() {
};
const handlePickPhoto = () => {
if (isOnlySeeRole) {
return;
}
setCheckInError("");
if (!supportsMobileCameraCapture()) {
setCheckInError("现场照片仅支持手机端直接拍照,请使用手机打开当前页面。");
@ -555,6 +561,9 @@ export default function Work() {
};
const handleOpenObjectPicker = (mode: PickerMode, lineIndex?: number, bizType: BizType = "sales") => {
if (isOnlySeeRole) {
return;
}
const currentOptions = getOptionsByBizType(bizType, salesOptions, channelOptions, opportunityOptions);
if (!currentOptions.length && !reportTargetsLoading) {
void loadReportTargets();
@ -686,6 +695,9 @@ export default function Work() {
setSubmittingCheckIn(true);
try {
if (isOnlySeeRole) {
throw new Error("当前角色仅可查看打卡历史记录");
}
if (!checkInForm.latitude || !checkInForm.longitude || !checkInForm.locationText.trim()) {
throw new Error("请先完成定位后再提交打卡");
}
@ -737,6 +749,9 @@ export default function Work() {
setSubmittingReport(true);
try {
if (isOnlySeeRole) {
throw new Error("当前角色仅可查看日报历史记录");
}
const normalizedLineItems = reportForm.lineItems.map((item) => normalizeReportLineItem(item, currentWorkDate));
const normalizedPlanItems = reportForm.planItems
.map((item) => ({ content: item.content.trim() }))
@ -785,77 +800,91 @@ export default function Work() {
<WorkSectionNav activeWorkSection={activeWorkSection} disableMobileMotion={disableMobileMotion} />
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-12 lg:gap-6">
<div className={`min-w-0 crm-page-stack lg:col-span-7 xl:col-span-8 ${mobilePanel === "entry" ? "block lg:flex" : "hidden lg:flex"}`}>
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle title={activeWorkMeta.title} accent={activeWorkMeta.accent} compact />
<MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} />
{showEntryPanel ? (
<div className={`min-w-0 crm-page-stack lg:col-span-7 xl:col-span-8 ${mobilePanel === "entry" ? "block lg:flex" : "hidden lg:flex"}`}>
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle title={activeWorkMeta.title} accent={activeWorkMeta.accent} compact />
<MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} />
</div>
{loading ? (
<WorkEntrySkeleton section={activeWorkSection} />
) : activeWorkSection === "checkin" ? (
<CheckInPanel
loading={loading}
checkInForm={checkInForm}
refreshingLocation={refreshingLocation}
onOpenObjectPicker={() => handleOpenObjectPicker("checkin", undefined, checkInForm.bizType || "sales")}
onRefreshLocation={() => void handleRefreshLocation()}
onOpenLocationAdjust={handleOpenLocationAdjust}
locationAccuracyMeters={locationAccuracyMeters}
locationAdjustmentConfirmed={locationAdjustmentConfirmed}
requiresLocationConfirmation={Boolean(
locationAccuracyMeters
&& locationAccuracyMeters > CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS
&& !locationAdjustmentConfirmed,
)}
locationHint={locationHint}
onRemarkChange={(remark) => setCheckInForm((prev) => ({ ...prev, remark }))}
photoInputRef={photoInputRef}
onPhotoChange={(event) => void handlePhotoChange(event)}
onPickPhoto={handlePickPhoto}
uploadingPhoto={uploadingPhoto}
checkInPhotoUrls={checkInPhotoUrls}
onRemovePhoto={handleRemovePhoto}
checkInError={checkInError}
checkInSuccess={checkInSuccess}
pageError={pageError}
submittingCheckIn={submittingCheckIn}
onSubmit={() => void handleCheckInSubmit()}
disableMobileMotion={disableMobileMotion}
/>
) : (
<ReportPanel
loading={loading}
reportStatus={reportStatus}
currentWorkDate={currentWorkDate}
reportForm={reportForm}
onAddReportLine={handleAddReportLine}
onRemoveReportLine={handleRemoveReportLine}
onOpenObjectPicker={handleOpenObjectPicker}
onReportLineKeyDown={handleReportLineKeyDown}
onReportLineChange={handleReportLineChange}
onAddPlanItem={handleAddPlanItem}
onPlanItemChange={handlePlanItemChange}
onRemovePlanItem={handleRemovePlanItem}
reportError={reportError}
reportSuccess={reportSuccess}
pageError={pageError}
submittingReport={submittingReport}
onSubmit={() => void handleReportSubmit()}
disableMobileMotion={disableMobileMotion}
/>
)}
</div>
) : null}
{loading ? (
<WorkEntrySkeleton section={activeWorkSection} />
) : activeWorkSection === "checkin" ? (
<CheckInPanel
loading={loading}
checkInForm={checkInForm}
refreshingLocation={refreshingLocation}
onOpenObjectPicker={() => handleOpenObjectPicker("checkin", undefined, checkInForm.bizType || "sales")}
onRefreshLocation={() => void handleRefreshLocation()}
onOpenLocationAdjust={handleOpenLocationAdjust}
locationAccuracyMeters={locationAccuracyMeters}
locationAdjustmentConfirmed={locationAdjustmentConfirmed}
requiresLocationConfirmation={Boolean(
locationAccuracyMeters
&& locationAccuracyMeters > CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS
&& !locationAdjustmentConfirmed,
)}
locationHint={locationHint}
onRemarkChange={(remark) => setCheckInForm((prev) => ({ ...prev, remark }))}
photoInputRef={photoInputRef}
onPhotoChange={(event) => void handlePhotoChange(event)}
onPickPhoto={handlePickPhoto}
uploadingPhoto={uploadingPhoto}
checkInPhotoUrls={checkInPhotoUrls}
onRemovePhoto={handleRemovePhoto}
checkInError={checkInError}
checkInSuccess={checkInSuccess}
pageError={pageError}
submittingCheckIn={submittingCheckIn}
onSubmit={() => void handleCheckInSubmit()}
disableMobileMotion={disableMobileMotion}
/>
) : (
<ReportPanel
loading={loading}
reportStatus={reportStatus}
currentWorkDate={currentWorkDate}
reportForm={reportForm}
onAddReportLine={handleAddReportLine}
onRemoveReportLine={handleRemoveReportLine}
onOpenObjectPicker={handleOpenObjectPicker}
onReportLineKeyDown={handleReportLineKeyDown}
onReportLineChange={handleReportLineChange}
onAddPlanItem={handleAddPlanItem}
onPlanItemChange={handlePlanItemChange}
onRemovePlanItem={handleRemovePlanItem}
reportError={reportError}
reportSuccess={reportSuccess}
pageError={pageError}
submittingReport={submittingReport}
onSubmit={() => void handleReportSubmit()}
disableMobileMotion={disableMobileMotion}
/>
<div
className={cn(
"min-w-0 crm-page-stack",
showEntryPanel
? mobilePanel === "history"
? "block lg:flex lg:col-span-5 lg:sticky lg:top-6 xl:col-span-4"
: "hidden lg:flex lg:col-span-5 lg:sticky lg:top-6 xl:col-span-4"
: "block lg:col-span-12 xl:col-span-12",
)}
</div>
<div className={`min-w-0 crm-page-stack lg:col-span-5 lg:sticky lg:top-6 xl:col-span-4 ${mobilePanel === "history" ? "block lg:flex" : "hidden lg:flex"}`}>
>
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle title="历史记录" accent="bg-slate-300 dark:bg-slate-700" compact />
<MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} />
<SectionTitle title={showEntryPanel ? "历史记录" : `${activeWorkMeta.label}历史记录`} accent="bg-slate-300 dark:bg-slate-700" compact />
{showEntryPanel ? <MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} /> : null}
</div>
<div
ref={historyScrollContainerRef}
className="relative max-h-[calc(100vh-12rem)] space-y-4 overflow-y-auto pr-2 scrollbar-hide"
className={cn(
"relative space-y-4 overflow-y-auto scrollbar-hide",
showEntryPanel ? "max-h-[calc(100vh-12rem)] pr-2" : "max-h-[calc(100vh-10rem)]",
)}
>
{historyLoading && historyData.length === 0 ? <WorkHistorySkeleton /> : null}
@ -1447,6 +1476,12 @@ function HistoryCard({
onPreviewPhoto: (url: string, alt: string) => void;
disableMobileMotion: boolean;
}) {
const historyPresenter = extractHistoryPresenter(item.content);
const contentText = historyPresenter.content || "无";
const presenterTone = item.type === "日报"
? "border-violet-200 bg-violet-50/90 text-violet-900 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-100"
: "border-emerald-200 bg-emerald-50/90 text-emerald-900 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100";
return (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, x: 20 }}
@ -1489,7 +1524,13 @@ function HistoryCard({
</div>
<div className="pl-11">
<p className="whitespace-pre-line text-xs leading-relaxed text-slate-700 dark:text-slate-300">{item.content}</p>
{historyPresenter.label && historyPresenter.name ? (
<div className={cn("mb-3 rounded-xl border px-3 py-2.5", presenterTone)}>
<p className="text-[11px] font-semibold tracking-[0.08em] opacity-75">{historyPresenter.label}</p>
<p className="mt-1 text-sm font-bold">{historyPresenter.name}</p>
</div>
) : null}
<p className="whitespace-pre-line text-xs leading-relaxed text-slate-700 dark:text-slate-300">{contentText}</p>
{item.photoUrls?.length ? (
<div className="mt-3 flex gap-2 overflow-x-auto pb-1">
{item.photoUrls.map((photoUrl, photoIndex) => (
@ -2083,6 +2124,12 @@ function HistoryDetailModal({
onPreviewPhoto: (url: string, alt: string) => void;
disableMobileMotion: boolean;
}) {
const historyPresenter = extractHistoryPresenter(item.content);
const contentText = historyPresenter.content || "无";
const presenterTone = item.type === "日报"
? "border-violet-200 bg-violet-50/90 text-violet-900 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-100"
: "border-emerald-200 bg-emerald-50/90 text-emerald-900 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100";
return (
<div className="fixed inset-0 z-[95]">
<button
@ -2130,10 +2177,17 @@ function HistoryDetailModal({
) : null}
</div>
{historyPresenter.label && historyPresenter.name ? (
<section className={cn("mt-4 rounded-2xl border px-4 py-3.5", presenterTone)}>
<p className="text-xs font-semibold tracking-[0.08em] opacity-75">{historyPresenter.label}</p>
<p className="mt-1.5 text-lg font-bold">{historyPresenter.name}</p>
</section>
) : null}
<section className="mt-4 rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/40">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400"></p>
<p className="mt-2 whitespace-pre-line text-sm leading-7 text-slate-800 dark:text-slate-200">
{item.content || "无"}
{contentText}
</p>
</section>
@ -2244,6 +2298,34 @@ function buildCollapsedPreviewLines(content: string | undefined, placeholder: st
return previewLines;
}
function extractHistoryPresenter(content: string | undefined) {
if (!content?.trim()) {
return { label: "", name: "", content: "" };
}
let label = "";
let name = "";
const remainingLines = content
.replace(/\r/g, "")
.split("\n")
.filter((line) => {
const normalizedLine = line.trim();
const match = normalizedLine.match(/^(提交人|打卡人)\s*(.+)$/);
if (!match || label) {
return true;
}
label = match[1];
name = match[2].trim();
return false;
});
return {
label,
name,
content: remainingLines.join("\n").trim(),
};
}
function SummaryCell({ label, value }: { label: string; value: string }) {
return (
<div className="bg-white px-4 py-3 dark:bg-slate-900/50">
@ -2424,6 +2506,26 @@ function getHistoryLabelBySection(section: WorkSection) {
return section === "checkin" ? "打卡" : "日报";
}
function readStoredUserProfile() {
if (typeof window === "undefined") {
return null;
}
try {
const rawProfile = window.sessionStorage.getItem("userProfile");
if (!rawProfile) {
return null;
}
return JSON.parse(rawProfile) as UserProfile;
} catch {
return null;
}
}
function hasOnlySeeRole(user: UserProfile | null) {
return (user?.roleCodes ?? []).some((roleCode) => roleCode?.trim().toLowerCase() === "only_see");
}
function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
const seenIds = new Set<number>();
return items