From 67c41f8e8811350e92cc47978ef07802e132239f Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Fri, 3 Apr 2026 10:11:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../expansion/ChannelExpansionItemDTO.java | 9 + .../dto/expansion/SalesExpansionItemDTO.java | 9 + .../crm/service/impl/WorkServiceImpl.java | 19 + .../mapper/expansion/ExpansionMapper.xml | 8 + .../crm/service/impl/WorkServiceImplTest.java | 46 +- frontend/src/components/AdaptiveSelect.tsx | 101 ++-- frontend/src/lib/auth.ts | 2 + frontend/src/pages/Expansion.tsx | 96 ++-- frontend/src/pages/Opportunities.tsx | 73 ++- frontend/src/pages/Work.tsx | 432 ++++++++++++++++-- 10 files changed, 654 insertions(+), 141 deletions(-) diff --git a/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java b/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java index 5424b534..7f0275a5 100644 --- a/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java @@ -7,6 +7,7 @@ public class ChannelExpansionItemDTO { private Long id; private Long ownerUserId; + private String owner; private String type; private String channelCode; private String name; @@ -57,6 +58,14 @@ public class ChannelExpansionItemDTO { this.ownerUserId = ownerUserId; } + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + public String getType() { return type; } diff --git a/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java b/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java index ead6ece7..1dbc3305 100644 --- a/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java @@ -7,6 +7,7 @@ public class SalesExpansionItemDTO { private Long id; private Long ownerUserId; + private String owner; private String type; private String employeeNo; private String name; @@ -48,6 +49,14 @@ public class SalesExpansionItemDTO { this.ownerUserId = ownerUserId; } + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + public String getType() { return type; } diff --git a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java index db5f8d2f..8f2bc3f5 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java @@ -15,6 +15,7 @@ import com.unis.crm.dto.work.WorkTomorrowPlanItemDTO; import com.unis.crm.dto.work.WorkTomorrowPlanItemRequest; import com.unis.crm.dto.work.WorkOverviewDTO; import com.unis.crm.dto.work.WorkSuggestedActionDTO; +import com.unis.crm.mapper.ProfileMapper; import com.unis.crm.mapper.WorkMapper; import com.unis.crm.service.WorkService; import java.io.IOException; @@ -67,6 +68,7 @@ public class WorkServiceImpl implements WorkService { private static final String PLAN_ITEMS_METADATA_PREFIX = "[[WORK_PLAN_ITEMS]]"; private static final String PLAN_ITEMS_METADATA_SUFFIX = "[[/WORK_PLAN_ITEMS]]"; 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 String TENCENT_COORD_TYPE_GPS = "1"; private static final String TENCENT_COORD_TYPE_GCJ02 = "2"; @@ -77,6 +79,7 @@ public class WorkServiceImpl implements WorkService { private static final DateTimeFormatter REPORT_DATE_TIME_TEXT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"); private final WorkMapper workMapper; + private final ProfileMapper profileMapper; private final HttpClient httpClient; private final ObjectMapper objectMapper; private final Path checkInPhotoDirectory; @@ -85,10 +88,12 @@ public class WorkServiceImpl implements WorkService { public WorkServiceImpl( WorkMapper workMapper, + ProfileMapper profileMapper, ObjectMapper objectMapper, @Value("${unisbase.app.upload-path}") String uploadPath, @Value("${unisbase.app.tencent-map.key:}") String tencentMapKey) { this.workMapper = workMapper; + this.profileMapper = profileMapper; this.objectMapper = objectMapper; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(8)) @@ -132,6 +137,7 @@ public class WorkServiceImpl implements WorkService { @Override public Long saveCheckIn(Long userId, CreateWorkCheckInRequest request) { requireUser(userId); + ensureWorkWriteAllowed(userId, "当前角色仅可查看打卡历史记录"); request.setLocationText(normalizeOptionalText(request.getLocationText())); request.setRemark(normalizeOptionalText(request.getRemark())); request.setBizType(normalizeBizType(request.getBizType())); @@ -164,6 +170,7 @@ public class WorkServiceImpl implements WorkService { @Transactional public Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request) { requireUser(userId); + ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录"); normalizeDailyReportRequest(request); request.setTomorrowPlan(request.getTomorrowPlan().trim()); request.setSourceType(normalizeOptionalText(request.getSourceType())); @@ -234,6 +241,7 @@ public class WorkServiceImpl implements WorkService { @Override public String uploadCheckInPhoto(Long userId, MultipartFile file) { requireUser(userId); + ensureWorkWriteAllowed(userId, "当前角色仅可查看打卡历史记录"); if (file == null || file.isEmpty()) { throw new BusinessException("请先选择现场照片"); } @@ -283,6 +291,17 @@ public class WorkServiceImpl implements WorkService { } } + private void ensureWorkWriteAllowed(Long userId, String deniedMessage) { + List roleCodes = profileMapper.selectUserRoleCodes(userId); + boolean onlySee = roleCodes != null && roleCodes.stream() + .filter(roleCode -> roleCode != null && !roleCode.isBlank()) + .map(roleCode -> roleCode.trim().toLowerCase()) + .anyMatch(ONLY_SEE_ROLE_CODE::equals); + if (onlySee) { + throw new BusinessException(deniedMessage); + } + } + private List loadHistoryItems(HistoryType historyType, int fetchLimit) { List historyItems = new ArrayList<>(); if (historyType == null || historyType == HistoryType.CHECK_IN) { diff --git a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml index 16b74e42..783fecb8 100644 --- a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml +++ b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml @@ -54,6 +54,7 @@ select s.id, s.owner_user_id as ownerUserId, + coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner, 'sales' as type, coalesce(s.employee_no, '无') as employeeNo, s.candidate_name as name, @@ -90,6 +91,9 @@ coalesce(to_char(s.expected_join_date, 'YYYY-MM-DD'), '无') as expectedJoinDate, coalesce(s.remark, '无') as notes from crm_sales_expansion s + left join sys_user u + on u.user_id = s.owner_user_id + and u.is_deleted = 0 left join sys_dict_item office_dict on office_dict.type_code = 'tz_bsc' and office_dict.item_value = s.office_name @@ -117,6 +121,7 @@ select c.id, c.owner_user_id as ownerUserId, + coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner, 'channel' as type, coalesce(c.channel_code, '') as channelCode, c.channel_name as name, @@ -165,6 +170,9 @@ coalesce(to_char(c.expected_sign_date, 'YYYY-MM-DD'), '无') as expectedSignDate, coalesce(c.remark, '无') as notes from crm_channel_expansion c + left join sys_user u + on u.user_id = c.owner_user_id + and u.is_deleted = 0 left join lateral ( select contact_name, diff --git a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java index ba124ea3..0afa06b9 100644 --- a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java +++ b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java @@ -8,8 +8,11 @@ import static org.mockito.Mockito.when; 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.WorkHistoryItemDTO; import com.unis.crm.dto.work.WorkHistoryPageDTO; +import com.unis.crm.mapper.ProfileMapper; import com.unis.crm.mapper.WorkMapper; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -17,6 +20,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; @ExtendWith(MockitoExtension.class) class WorkServiceImplTest { @@ -24,11 +28,14 @@ class WorkServiceImplTest { @Mock private WorkMapper workMapper; + @Mock + private ProfileMapper profileMapper; + private WorkServiceImpl workService; @BeforeEach void setUp() { - workService = new WorkServiceImpl(workMapper, new ObjectMapper(), "build/test-uploads", ""); + workService = new WorkServiceImpl(workMapper, profileMapper, new ObjectMapper(), "build/test-uploads", ""); } @Test @@ -91,6 +98,43 @@ class WorkServiceImplTest { assertEquals("历史记录类型不支持", exception.getMessage()); } + @Test + void saveCheckIn_shouldRejectOnlySeeRole() { + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("only_see", "sales")); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> workService.saveCheckIn(17L, new CreateWorkCheckInRequest())); + + assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage()); + verify(workMapper, never()).insertCheckIn(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void saveDailyReport_shouldRejectOnlySeeRole() { + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("only_see", "sales")); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> workService.saveDailyReport(17L, new CreateWorkDailyReportRequest())); + + assertEquals("当前角色仅可查看日报历史记录", exception.getMessage()); + verify(workMapper, never()).insertDailyReport(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any()); + } + + @Test + void uploadCheckInPhoto_shouldRejectOnlySeeRole() { + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("only_see")); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> workService.uploadCheckInPhoto( + 17L, + new MockMultipartFile("file", "photo.jpg", "image/jpeg", new byte[] {1, 2, 3}))); + + assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage()); + } + private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) { WorkHistoryItemDTO item = new WorkHistoryItemDTO(); item.setId(id); diff --git a/frontend/src/components/AdaptiveSelect.tsx b/frontend/src/components/AdaptiveSelect.tsx index 69f0f06d..cf842dce 100644 --- a/frontend/src/components/AdaptiveSelect.tsx +++ b/frontend/src/components/AdaptiveSelect.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { Check, ChevronDown, X } from "lucide-react"; +import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; export type AdaptiveSelectOption = { @@ -77,7 +78,9 @@ export function AdaptiveSelect({ const [open, setOpen] = useState(false); const [searchKeyword, setSearchKeyword] = useState(""); const containerRef = useRef(null); + const desktopDropdownRef = useRef(null); const isMobile = useIsMobileViewport(); + const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ top: number; left: number; width: number } | null>(null); const selectedValues = multiple ? Array.isArray(value) ? value : [] : typeof value === "string" && value ? [value] : []; @@ -96,13 +99,27 @@ export function AdaptiveSelect({ }) : options; + const updateDesktopDropdownPosition = useCallback(() => { + if (isMobile || !open || !containerRef.current) { + return; + } + + const rect = containerRef.current.getBoundingClientRect(); + setDesktopDropdownStyle({ + top: rect.bottom + 8, + left: rect.left, + width: rect.width, + }); + }, [isMobile, open]); + useEffect(() => { if (!open || isMobile) { return; } const handlePointerDown = (event: MouseEvent) => { - if (!containerRef.current?.contains(event.target as Node)) { + const targetNode = event.target as Node; + if (!containerRef.current?.contains(targetNode) && !desktopDropdownRef.current?.contains(targetNode)) { setOpen(false); } }; @@ -121,6 +138,24 @@ export function AdaptiveSelect({ }; }, [isMobile, open]); + useEffect(() => { + if (!open || isMobile) { + setDesktopDropdownStyle(null); + return; + } + + updateDesktopDropdownPosition(); + + const handleViewportChange = () => updateDesktopDropdownPosition(); + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + + return () => { + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + }; + }, [isMobile, open, updateDesktopDropdownPosition]); + useEffect(() => { if (!open || !isMobile) { return; @@ -199,33 +234,43 @@ export function AdaptiveSelect({ - {open && !isMobile ? ( - - {searchable ? ( -
- setSearchKeyword(event.target.value)} - placeholder={searchPlaceholder} - className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 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" - /> -
- ) : null} -
- {visibleOptions.length > 0 ? visibleOptions.map(renderOption) : ( -
- 未找到匹配选项 + {open && !isMobile && desktopDropdownStyle && typeof document !== "undefined" + ? createPortal( + + {searchable ? ( +
+ setSearchKeyword(event.target.value)} + placeholder={searchPlaceholder} + className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 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" + /> +
+ ) : null} +
+ {visibleOptions.length > 0 ? visibleOptions.map(renderOption) : ( +
+ 未找到匹配选项 +
+ )}
- )} -
- - ) : null} + , + document.body, + ) + : null} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index f9b7f349..7e939a14 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -349,6 +349,7 @@ export interface ExpansionFollowUp { export interface SalesExpansionItem { id: number; ownerUserId?: number; + owner?: string; type: "sales"; employeeNo?: string; name?: string; @@ -385,6 +386,7 @@ export interface RelatedProjectSummary { export interface ChannelExpansionItem { id: number; ownerUserId?: number; + owner?: string; type: "channel"; channelCode?: string; name?: string; diff --git a/frontend/src/pages/Expansion.tsx b/frontend/src/pages/Expansion.tsx index ce8f93d7..e7bb2a5a 100644 --- a/frontend/src/pages/Expansion.tsx +++ b/frontend/src/pages/Expansion.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState, type ReactNode } from "react"; -import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react"; +import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar } from "lucide-react"; import { motion, AnimatePresence } from "motion/react"; import { useLocation } from "react-router-dom"; import { @@ -54,8 +54,6 @@ type ChannelField = | "channelAttributeCustom" | "internalAttribute" | "contacts"; -const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold"; - function createEmptyChannelContact(): ChannelExpansionContact { return { name: "", @@ -192,6 +190,7 @@ function buildSalesExportHeaders(items: SalesExpansionItem[]) { const headers = [ "工号", "姓名", + "创建人", "联系方式", "代表处 / 办事处", "所属部门", @@ -218,6 +217,7 @@ function buildSalesExportData(items: SalesExpansionItem[]) { const row = [ normalizeExportText(item.employeeNo), normalizeExportText(item.name), + normalizeExportText(item.owner), normalizeExportText(item.phone), normalizeExportText(item.officeName), normalizeExportText(item.dept), @@ -249,6 +249,7 @@ function buildChannelExportHeaders(items: ChannelExpansionItem[]) { const headers = [ "编码", "渠道名称", + "创建人", "省份", "市", "办公地址", @@ -285,6 +286,7 @@ function buildChannelExportData(items: ChannelExpansionItem[]) { const row = [ normalizeExportText(item.channelCode), normalizeExportText(item.name), + normalizeExportText(item.owner), normalizeExportText(item.province), normalizeExportText(item.city), normalizeExportText(item.officeAddress), @@ -1698,25 +1700,25 @@ export default function Expansion() { {isOwnedByCurrentUser ? (
) : null} -
-
+
+

{item.name || "无"}

{item.officeName || "无"} · {item.dept || "无"} · {item.title || "无"}

-
-
- - {isOwnedByCurrentUser ? "我的" : "只读"} - - - {item.active ? "在职" : "离职"} - +
+ + {isOwnedByCurrentUser ? "我的" : "只读"} + + + {item.active ? "在职" : "离职"} + +
@@ -1728,12 +1730,10 @@ export default function Expansion() { 跟进项目金额: {formatRelatedProjectAmount(item.relatedProjects)}
-
-
- +
+ 创建人: + {item.owner || "无"} +
); @@ -1759,27 +1759,27 @@ export default function Expansion() { {isOwnedByCurrentUser ? (
) : null} -
-
+
+

{item.name || "无"}

{item.province || "无"} · {item.city || "无"} · {item.certificationLevel || "无"}

-
-
- - {isOwnedByCurrentUser ? "我的" : "只读"} - - - {item.intent ? `${item.intent}意向` : "未评估"} - +
+ + {isOwnedByCurrentUser ? "我的" : "只读"} + + + {item.intent ? `${item.intent}意向` : "未评估"} + +
@@ -1791,12 +1791,10 @@ export default function Expansion() { 跟进项目金额: {formatRelatedProjectAmount(item.relatedProjects)}
-
-
- +
+ 创建人: + {item.owner || "无"} +
); diff --git a/frontend/src/pages/Opportunities.tsx b/frontend/src/pages/Opportunities.tsx index 8373c274..24fdfd86 100644 --- a/frontend/src/pages/Opportunities.tsx +++ b/frontend/src/pages/Opportunities.tsx @@ -1,13 +1,11 @@ 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 { Search, Plus, Download, 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, 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"; -const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold"; - const CONFIDENCE_OPTIONS = [ { value: "A", label: "A" }, { value: "B", label: "B" }, @@ -1160,6 +1158,7 @@ export default function Opportunities() { const headers = [ "项目编号", "项目名称", + "创建人", "项目地", "最终用户", "建设类型", @@ -1207,6 +1206,7 @@ export default function Opportunities() { worksheet.addRow([ normalizeOpportunityExportText(item.code), normalizeOpportunityExportText(item.name), + normalizeOpportunityExportText(item.owner), normalizeOpportunityExportText(item.projectLocation), normalizeOpportunityExportText(item.client), normalizeOpportunityExportText(item.type || "新建"), @@ -1599,38 +1599,38 @@ export default function Opportunities() { {isOwnedByCurrentUser ? (
) : null} -
+

{opp.name || "未命名商机"}

项目编号:{opp.code || "待生成"}

-
-
- - {isOwnedByCurrentUser ? "我的" : "只读"} - - - {getConfidenceLabel(opp.confidence)} - - - {opp.stage || "初步沟通"} - - - {opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"} - +
+ + {isOwnedByCurrentUser ? "我的" : "只读"} + + + {getConfidenceLabel(opp.confidence)} + + + {opp.stage || "初步沟通"} + + + {opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"} + +
@@ -1651,13 +1651,12 @@ export default function Opportunities() { 后续规划: {opp.nextPlan || "暂无回写规划"}
+
+ 创建人: + {opp.owner || "无"} +
-
- -
); }) diff --git a/frontend/src/pages/Work.tsx b/frontend/src/pages/Work.tsx index f1fb50aa..a8c6fda6 100644 --- a/frontend/src/pages/Work.tsx +++ b/frontend/src/pages/Work.tsx @@ -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, FileText, ListTodo, MapPin, NotebookPen, Plus, RefreshCw, Search, Send, Trash2, X } from "lucide-react"; +import { ArrowUp, AtSign, Camera, CheckCircle2, ChevronDown, 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 { @@ -164,6 +164,10 @@ export default function Work() { const [historyHasMore, setHistoryHasMore] = useState(false); const [historyPage, setHistoryPage] = useState(1); const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false); + const [historyPresenterFilter, setHistoryPresenterFilter] = useState("all"); + const [historyPresenterKeyword, setHistoryPresenterKeyword] = useState(""); + const [historyPresenterPickerOpen, setHistoryPresenterPickerOpen] = useState(false); + const [expandedHistoryGroupKey, setExpandedHistoryGroupKey] = useState(null); const [reportStatus, setReportStatus] = useState(); const [currentUser, setCurrentUser] = useState(() => readStoredUserProfile()); const [profileOverview, setProfileOverview] = useState(null); @@ -198,6 +202,52 @@ export default function Work() { return options.filter((option) => option.label.toLowerCase().includes(keyword)); }, [objectPicker, salesOptions, channelOptions, opportunityOptions]); + const historyPresenterOptions = useMemo(() => { + const presenterMap = new Map(); + for (const item of historyData) { + const presenter = extractHistoryPresenter(item.content); + const normalizedName = presenter.name.trim(); + if (!presenter.label || !normalizedName) { + continue; + } + const existing = presenterMap.get(normalizedName); + if (existing) { + existing.count += 1; + continue; + } + presenterMap.set(normalizedName, { + label: `${presenter.label}:${normalizedName}`, + count: 1, + }); + } + + return Array.from(presenterMap.entries()) + .map(([value, meta]) => ({ value, label: meta.label, count: meta.count })) + .sort((left, right) => right.count - left.count || left.value.localeCompare(right.value, "zh-CN")); + }, [historyData]); + + const filteredHistoryData = useMemo(() => { + if (historyPresenterFilter === "all") { + return historyData; + } + return historyData.filter((item) => extractHistoryPresenter(item.content).name.trim() === historyPresenterFilter); + }, [historyData, historyPresenterFilter]); + + const historyGroups = useMemo(() => { + const groups: Array<{ key: string; date: string; items: WorkHistoryItem[] }> = []; + for (const item of filteredHistoryData) { + const groupDate = item.date || "未标注日期"; + const lastGroup = groups[groups.length - 1]; + if (lastGroup && lastGroup.date === groupDate) { + lastGroup.items.push(item); + continue; + } + const groupSeed = item.id != null ? `${item.type}-${item.id}` : `${item.type}-${groups.length}`; + groups.push({ key: `${groupDate}-${groupSeed}`, date: groupDate, items: [item] }); + } + return groups; + }, [filteredHistoryData]); + const loadReportTargets = useCallback(async () => { if (reportTargetsLoaded || reportTargetsLoading) { return; @@ -296,6 +346,36 @@ export default function Work() { setHistoryDetailItem(nextItem); }, [historyData, historyDetailItem]); + useEffect(() => { + if (historyPresenterFilter === "all") { + return; + } + const stillExists = historyPresenterOptions.some((option) => option.value === historyPresenterFilter); + if (!stillExists) { + setHistoryPresenterFilter("all"); + } + }, [historyPresenterFilter, historyPresenterOptions]); + + useEffect(() => { + if (!historyPresenterPickerOpen && historyPresenterKeyword) { + setHistoryPresenterKeyword(""); + } + }, [historyPresenterKeyword, historyPresenterPickerOpen]); + + useEffect(() => { + setExpandedHistoryGroupKey(null); + }, [historySection, historyPresenterFilter]); + + useEffect(() => { + if (!expandedHistoryGroupKey) { + return; + } + const stillExists = historyGroups.some((group) => group.key === expandedHistoryGroupKey); + if (!stillExists) { + setExpandedHistoryGroupKey(null); + } + }, [expandedHistoryGroupKey, historyGroups]); + useEffect(() => { let cancelled = false; @@ -888,21 +968,61 @@ export default function Work() { > {historyLoading && historyData.length === 0 ? : null} - {historyData.map((item, index) => ( -
- setHistoryDetailItem(item)} - onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })} - disableMobileMotion={disableMobileMotion} - /> -
- ))} + {!historyLoading || historyData.length > 0 ? ( + setHistoryPresenterPickerOpen(true)} + onPresenterFilterChange={(value) => { + setHistoryPresenterFilter(value); + setHistoryPresenterPickerOpen(false); + }} + /> + ) : null} - {!historyLoading && historyData.length === 0 ? ( + {historyGroups.map((group) => { + const groupExpanded = historyPresenterFilter !== "all" || expandedHistoryGroupKey === group.key; + const canToggleGroup = historyPresenterFilter === "all"; + + return ( +
+ { + if (!canToggleGroup) { + return; + } + setExpandedHistoryGroupKey((current) => (current === group.key ? null : group.key)); + }} + /> + {groupExpanded + ? group.items.map((item, index) => ( +
+ setHistoryDetailItem(item)} + onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })} + disableMobileMotion={disableMobileMotion} + /> +
+ )) + : null} +
+ ); + })} + + {!historyLoading && filteredHistoryData.length === 0 ? (
- 当前没有{getHistoryLabelBySection(historySection)}记录 + {historyPresenterFilter === "all" + ? `当前没有${getHistoryLabelBySection(historySection)}记录` + : `当前筛选条件下没有${getHistoryLabelBySection(historySection)}记录`}
) : null} @@ -1041,6 +1161,21 @@ export default function Work() { /> ) : null} + {historyPresenterPickerOpen ? ( + setHistoryPresenterPickerOpen(false)} + onPresenterFilterChange={(value) => { + setHistoryPresenterFilter(value); + setHistoryPresenterPickerOpen(false); + }} + onPresenterKeywordChange={setHistoryPresenterKeyword} + /> + ) : null} + {previewPhoto ? ( ; + filteredCount: number; + totalCount: number; + onOpenPresenterPicker: () => void; + onPresenterFilterChange: (value: string) => void; +}) { + const currentPresenterLabel = presenterFilter === "all" + ? "全部人员" + : presenterOptions.find((option) => option.value === presenterFilter)?.value ?? presenterFilter; + + return ( +
+
+
+
+ + + 人员搜索 + + {presenterOptions.length} 人可选 +
+ +
+
+
+ ); +} + +function HistoryPresenterPickerModal({ + section, + presenterFilter, + presenterKeyword, + presenterOptions, + onClose, + onPresenterFilterChange, + onPresenterKeywordChange, +}: { + section: WorkSection; + presenterFilter: string; + presenterKeyword: string; + presenterOptions: Array<{ value: string; label: string; count: number }>; + onClose: () => void; + onPresenterFilterChange: (value: string) => void; + onPresenterKeywordChange: (value: string) => void; +}) { + const isMobileViewport = useIsMobileViewport(); + const normalizedKeyword = presenterKeyword.trim().toLowerCase(); + const visiblePresenterOptions = normalizedKeyword + ? presenterOptions.filter((option) => option.label.toLowerCase().includes(normalizedKeyword) || option.value.toLowerCase().includes(normalizedKeyword)) + : presenterOptions; + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, []); + + return ( +
+ +
+
+ + onPresenterKeywordChange(event.target.value)} + placeholder="搜索打卡人或提交人" + className="h-11 w-full rounded-2xl border border-slate-200 bg-slate-50 pl-10 pr-3 text-sm text-slate-900 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-white" + /> +
+
+ +
+
+ + + {visiblePresenterOptions.map((option) => ( + + ))} + + {normalizedKeyword && visiblePresenterOptions.length === 0 ? ( +
未找到匹配人员
+ ) : null} +
+
+
+
+ ); +} + +function HistoryDateGroup({ + date, + count, + expanded, + canToggle, + onToggle, +}: { + date: string; + count: number; + expanded: boolean; + canToggle: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + function HistoryCard({ item, index, @@ -2131,18 +2486,25 @@ function HistoryDetailModal({ : "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 ( -
-
-
-
+
+
+

+ {[item.date, item.time].filter(Boolean).join(" ") || "无时间信息"} +

+
+ +
{item.status ? ( ) : null}
-
-
+ + ); } @@ -2326,6 +2694,18 @@ function extractHistoryPresenter(content: string | undefined) { }; } +function formatHistoryGroupDate(date: string) { + if (!date || date === "未标注日期") { + return "未标注日期"; + } + + const parsed = new Date(`${date}T00:00:00`); + if (Number.isNaN(parsed.getTime())) { + return date; + } + return format(parsed, "M月d日 EEEE", { locale: zhCN }); +} + function SummaryCell({ label, value }: { label: string; value: string }) { return (