From 0d4a3eea8b7e7a6e9cf3713dbf66634a8ab4f09f Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Fri, 3 Apr 2026 14:35:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=B8=8B=E6=8B=89=E6=A1=86?= =?UTF-8?q?=E7=94=B5=E8=84=91=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/workspace.xml | 13 +- .../crm/controller/DashboardController.java | 2 + .../crm/controller/ExpansionController.java | 6 + .../crm/controller/OpportunityController.java | 5 + .../OpportunityIntegrationController.java | 2 + .../unis/crm/controller/WorkController.java | 4 + frontend/src/components/AdaptiveSelect.tsx | 69 +++++--- frontend/src/pages/Opportunities.tsx | 153 +++++++++++++----- 8 files changed, 189 insertions(+), 65 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index deb827d5..05f6d2ad 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,7 +4,15 @@ - + + + + + + + + + @@ -84,6 +92,9 @@ + + + diff --git a/backend/src/main/java/com/unis/crm/controller/DashboardController.java b/backend/src/main/java/com/unis/crm/controller/DashboardController.java index 6946b325..ee0c2331 100644 --- a/backend/src/main/java/com/unis/crm/controller/DashboardController.java +++ b/backend/src/main/java/com/unis/crm/controller/DashboardController.java @@ -3,6 +3,7 @@ package com.unis.crm.controller; import com.unis.crm.common.ApiResponse; import com.unis.crm.dto.dashboard.DashboardHomeDTO; import com.unis.crm.service.DashboardService; +import com.unisbase.common.annotation.Log; import jakarta.validation.constraints.Min; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; @@ -30,6 +31,7 @@ public class DashboardController { } @PostMapping("/todos/{todoId}/complete") + @Log(type = "工作台", value = "完成待办") public ApiResponse completeTodo( @RequestHeader("X-User-Id") @Min(1) Long userId, @PathVariable("todoId") @Min(1) Long todoId) { diff --git a/backend/src/main/java/com/unis/crm/controller/ExpansionController.java b/backend/src/main/java/com/unis/crm/controller/ExpansionController.java index 2f869613..a7f5423c 100644 --- a/backend/src/main/java/com/unis/crm/controller/ExpansionController.java +++ b/backend/src/main/java/com/unis/crm/controller/ExpansionController.java @@ -12,6 +12,7 @@ import com.unis.crm.dto.expansion.ExpansionOverviewDTO; import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest; import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest; import com.unis.crm.service.ExpansionService; +import com.unisbase.common.annotation.Log; import jakarta.validation.Valid; import java.util.List; import org.springframework.web.bind.annotation.GetMapping; @@ -75,6 +76,7 @@ public class ExpansionController { } @PostMapping("/sales") + @Log(type = "拓展管理", value = "新增售前拓展") public ApiResponse createSales( @RequestHeader("X-User-Id") Long userId, @Valid @RequestBody CreateSalesExpansionRequest request) { @@ -82,6 +84,7 @@ public class ExpansionController { } @PostMapping("/channel") + @Log(type = "拓展管理", value = "新增渠道拓展") public ApiResponse createChannel( @RequestHeader("X-User-Id") Long userId, @Valid @RequestBody CreateChannelExpansionRequest request) { @@ -89,6 +92,7 @@ public class ExpansionController { } @PutMapping("/sales/{id}") + @Log(type = "拓展管理", value = "编辑售前拓展") public ApiResponse updateSales( @RequestHeader("X-User-Id") Long userId, @PathVariable("id") Long id, @@ -98,6 +102,7 @@ public class ExpansionController { } @PutMapping("/channel/{id}") + @Log(type = "拓展管理", value = "编辑渠道拓展") public ApiResponse updateChannel( @RequestHeader("X-User-Id") Long userId, @PathVariable("id") Long id, @@ -107,6 +112,7 @@ public class ExpansionController { } @PostMapping("/{bizType}/{bizId}/followups") + @Log(type = "拓展管理", value = "新增拓展跟进") public ApiResponse createFollowUp( @RequestHeader("X-User-Id") Long userId, @PathVariable("bizType") String bizType, diff --git a/backend/src/main/java/com/unis/crm/controller/OpportunityController.java b/backend/src/main/java/com/unis/crm/controller/OpportunityController.java index 51e5b6e6..4446b9a7 100644 --- a/backend/src/main/java/com/unis/crm/controller/OpportunityController.java +++ b/backend/src/main/java/com/unis/crm/controller/OpportunityController.java @@ -9,6 +9,7 @@ import com.unis.crm.dto.opportunity.OpportunityMetaDTO; import com.unis.crm.dto.opportunity.OpportunityOverviewDTO; import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest; import com.unis.crm.service.OpportunityService; +import com.unisbase.common.annotation.Log; import jakarta.validation.Valid; import java.util.List; import org.springframework.web.bind.annotation.GetMapping; @@ -50,6 +51,7 @@ public class OpportunityController { } @PostMapping + @Log(type = "商机管理", value = "新增商机") public ApiResponse createOpportunity( @RequestHeader("X-User-Id") Long userId, @Valid @RequestBody CreateOpportunityRequest request) { @@ -57,6 +59,7 @@ public class OpportunityController { } @PutMapping("/{opportunityId}") + @Log(type = "商机管理", value = "编辑商机") public ApiResponse updateOpportunity( @RequestHeader("X-User-Id") Long userId, @PathVariable("opportunityId") Long opportunityId, @@ -65,6 +68,7 @@ public class OpportunityController { } @PostMapping("/{opportunityId}/push-oms") + @Log(type = "商机管理", value = "推送商机到OMS") public ApiResponse pushToOms( @RequestHeader("X-User-Id") Long userId, @PathVariable("opportunityId") Long opportunityId, @@ -73,6 +77,7 @@ public class OpportunityController { } @PostMapping("/{opportunityId}/followups") + @Log(type = "商机管理", value = "新增商机跟进") public ApiResponse createFollowUp( @RequestHeader("X-User-Id") Long userId, @PathVariable("opportunityId") Long opportunityId, diff --git a/backend/src/main/java/com/unis/crm/controller/OpportunityIntegrationController.java b/backend/src/main/java/com/unis/crm/controller/OpportunityIntegrationController.java index 9f4312f6..544d30d0 100644 --- a/backend/src/main/java/com/unis/crm/controller/OpportunityIntegrationController.java +++ b/backend/src/main/java/com/unis/crm/controller/OpportunityIntegrationController.java @@ -5,6 +5,7 @@ import com.unis.crm.common.UnauthorizedException; import com.unis.crm.config.InternalAuthProperties; import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest; import com.unis.crm.service.OpportunityService; +import com.unisbase.common.annotation.Log; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.util.Objects; @@ -30,6 +31,7 @@ public class OpportunityIntegrationController { } @PutMapping("/update") + @Log(type = "商机集成", value = "同步更新商机") public ApiResponse updateOpportunity( HttpServletRequest httpServletRequest, @Valid @RequestBody UpdateOpportunityIntegrationRequest request) { diff --git a/backend/src/main/java/com/unis/crm/controller/WorkController.java b/backend/src/main/java/com/unis/crm/controller/WorkController.java index 81fbe78c..35b08449 100644 --- a/backend/src/main/java/com/unis/crm/controller/WorkController.java +++ b/backend/src/main/java/com/unis/crm/controller/WorkController.java @@ -8,6 +8,7 @@ import com.unis.crm.dto.work.CreateWorkDailyReportRequest; import com.unis.crm.dto.work.WorkHistoryPageDTO; import com.unis.crm.dto.work.WorkOverviewDTO; import com.unis.crm.service.WorkService; +import com.unisbase.common.annotation.Log; import jakarta.validation.Valid; import java.math.BigDecimal; import org.springframework.core.io.Resource; @@ -66,6 +67,7 @@ public class WorkController { } @PostMapping("/checkins") + @Log(type = "工作管理", value = "提交打卡") public ApiResponse saveCheckIn( @RequestHeader("X-User-Id") Long userId, @Valid @RequestBody CreateWorkCheckInRequest request) { @@ -73,6 +75,7 @@ public class WorkController { } @PostMapping(path = "/checkin-photos", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Log(type = "工作管理", value = "上传打卡照片") public ApiResponse uploadCheckInPhoto( @RequestHeader("X-User-Id") Long userId, @RequestPart("file") MultipartFile file) { @@ -89,6 +92,7 @@ public class WorkController { } @PostMapping("/daily-reports") + @Log(type = "工作管理", value = "提交日报") public ApiResponse saveDailyReport( @RequestHeader("X-User-Id") Long userId, @Valid @RequestBody CreateWorkDailyReportRequest request) { diff --git a/frontend/src/components/AdaptiveSelect.tsx b/frontend/src/components/AdaptiveSelect.tsx index cf842dce..c17a3d3c 100644 --- a/frontend/src/components/AdaptiveSelect.tsx +++ b/frontend/src/components/AdaptiveSelect.tsx @@ -10,6 +10,14 @@ export type AdaptiveSelectOption = { disabled?: boolean; }; +function getOptionLabel(option: AdaptiveSelectOption) { + const normalizedLabel = typeof option.label === "string" ? option.label.trim() : ""; + if (normalizedLabel) { + return normalizedLabel; + } + return option.value; +} + type AdaptiveSelectBaseProps = { options: AdaptiveSelectOption[]; placeholder?: string; @@ -80,20 +88,25 @@ export function AdaptiveSelect({ const containerRef = useRef(null); const desktopDropdownRef = useRef(null); const isMobile = useIsMobileViewport(); - const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ top: number; left: number; width: number } | null>(null); + const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom"); + const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(288); + const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ left: number; width: number; top: number } | null>(null); const selectedValues = multiple ? Array.isArray(value) ? value : [] : typeof value === "string" && value ? [value] : []; const selectedLabel = selectedValues.length > 0 ? selectedValues - .map((selectedValue) => options.find((option) => option.value === selectedValue)?.label) + .map((selectedValue) => { + const matchedOption = options.find((option) => option.value === selectedValue); + return matchedOption ? getOptionLabel(matchedOption) : selectedValue; + }) .filter((label): label is string => Boolean(label)) .join("、") : placeholder; const normalizedSearchKeyword = searchKeyword.trim().toLowerCase(); const visibleOptions = searchable && normalizedSearchKeyword ? options.filter((option) => { - const label = option.label.toLowerCase(); + const label = getOptionLabel(option).toLowerCase(); const optionValue = option.value.toLowerCase(); return label.includes(normalizedSearchKeyword) || optionValue.includes(normalizedSearchKeyword); }) @@ -105,12 +118,24 @@ export function AdaptiveSelect({ } const rect = containerRef.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const safePadding = 24; + const panelChromeHeight = (searchable ? 72 : 16) + 24; + const availableBelow = Math.max(180, viewportHeight - rect.bottom - safePadding - panelChromeHeight); + const availableAbove = Math.max(180, rect.top - safePadding - panelChromeHeight); + const shouldOpenUpward = availableBelow < 280 && availableAbove > availableBelow; + const popupContentHeight = Math.min(360, shouldOpenUpward ? availableAbove : availableBelow); + + setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom"); + setDesktopDropdownMaxHeight(popupContentHeight); setDesktopDropdownStyle({ - top: rect.bottom + 8, left: rect.left, width: rect.width, + top: shouldOpenUpward + ? Math.max(safePadding, rect.top - 8 - (popupContentHeight + panelChromeHeight)) + : rect.bottom + 8, }); - }, [isMobile, open]); + }, [isMobile, multiple, open, searchable]); useEffect(() => { if (!open || isMobile) { @@ -192,9 +217,10 @@ export function AdaptiveSelect({ const isSelected = multiple ? selectedValues.includes(option.value) : option.value === value; + const optionLabel = getOptionLabel(option); return ( handleSelect(option.value)} @@ -206,7 +232,7 @@ export function AdaptiveSelect({ option.disabled ? "cursor-not-allowed opacity-50" : "", )} > - {option.label} + {optionLabel} {isSelected ? : null} ); @@ -233,21 +259,18 @@ export function AdaptiveSelect({ - - {open && !isMobile && desktopDropdownStyle && typeof document !== "undefined" - ? createPortal( - + {searchable ? ( @@ -260,18 +283,18 @@ export function AdaptiveSelect({ /> ) : null} - + {visibleOptions.length > 0 ? visibleOptions.map(renderOption) : ( 未找到匹配选项 )} - , - document.body, - ) - : null} - + + , + document.body, + ) + : null} {open && isMobile ? ( diff --git a/frontend/src/pages/Opportunities.tsx b/frontend/src/pages/Opportunities.tsx index 24fdfd86..0c4529b8 100644 --- a/frontend/src/pages/Opportunities.tsx +++ b/frontend/src/pages/Opportunities.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from "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 { createPortal } from "react-dom"; 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"; @@ -452,6 +453,14 @@ type SearchableOption = { keywords?: string[]; }; +function getSearchableOptionLabel(option: SearchableOption) { + const normalizedLabel = typeof option.label === "string" ? option.label.trim() : ""; + if (normalizedLabel) { + return normalizedLabel; + } + return String(option.value ?? ""); +} + function dedupeSearchableOptions(options: SearchableOption[]) { const seenValues = new Set(); return options.filter((option) => { @@ -514,7 +523,9 @@ function SearchableSelect({ const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom"); const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256); const containerRef = useRef(null); + const desktopDropdownRef = useRef(null); const isMobile = useIsMobileViewport(); + const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ top: number; left: number; width: number } | null>(null); const normalizedOptions = dedupeSearchableOptions(options); const selectedOption = normalizedOptions.find((item) => item.value === value); const normalizedQuery = query.trim().toLowerCase(); @@ -523,7 +534,7 @@ function SearchableSelect({ return true; } - const haystacks = [item.label, ...(item.keywords ?? [])] + const haystacks = [getSearchableOptionLabel(item), ...(item.keywords ?? [])] .filter(Boolean) .map((entry) => entry.toLowerCase()); @@ -532,6 +543,7 @@ function SearchableSelect({ useEffect(() => { if (!open || isMobile) { + setDesktopDropdownStyle(null); return; } @@ -550,12 +562,18 @@ function SearchableSelect({ setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom"); setDesktopDropdownMaxHeight(Math.min(320, shouldOpenUpward ? availableAbove : availableBelow)); + setDesktopDropdownStyle({ + top: shouldOpenUpward ? Math.max(safePadding, rect.top - 8) : rect.bottom + 8, + left: rect.left, + width: rect.width, + }); }; updateDesktopDropdownLayout(); 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); setQuery(""); } @@ -629,7 +647,7 @@ function SearchableSelect({ : "text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800" }`} > - {item.label} + {getSearchableOptionLabel(item)} {item.value === value ? : null} )) @@ -659,31 +677,37 @@ function SearchableSelect({ )} > - {selectedOption?.label || placeholder} + {selectedOption ? getSearchableOptionLabel(selectedOption) : placeholder} - - {open && !isMobile ? ( - - - {renderSearchBody()} - - - ) : null} - + {open && !isMobile && desktopDropdownStyle && typeof document !== "undefined" + ? createPortal( + + + + {renderSearchBody()} + + + , + document.body, + ) + : null} {open && isMobile ? ( @@ -748,23 +772,61 @@ function CompetitorMultiSelect({ }) { const [open, setOpen] = useState(false); const containerRef = useRef(null); + const desktopDropdownRef = useRef(null); const isMobile = useIsMobileViewport(); + const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom"); + const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(320); + const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ left: number; width: number; top: number } | null>(null); const summary = value.length > 0 ? value.join("、") : placeholder; useEffect(() => { if (!open || isMobile) { + setDesktopDropdownStyle(null); return; } + const updateDesktopDropdownPosition = () => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) { + return; + } + + const viewportHeight = window.innerHeight; + const safePadding = 24; + const panelChromeHeight = 132; + const availableBelow = Math.max(180, viewportHeight - rect.bottom - safePadding - panelChromeHeight); + const availableAbove = Math.max(180, rect.top - safePadding - panelChromeHeight); + const shouldOpenUpward = availableBelow < 300 && availableAbove > availableBelow; + const popupContentHeight = Math.min(360, shouldOpenUpward ? availableAbove : availableBelow); + + setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom"); + setDesktopDropdownMaxHeight(popupContentHeight); + + setDesktopDropdownStyle({ + left: rect.left, + width: rect.width, + top: shouldOpenUpward + ? Math.max(safePadding, rect.top - 8 - (popupContentHeight + panelChromeHeight)) + : rect.bottom + 8, + }); + }; + + updateDesktopDropdownPosition(); + 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); } }; + window.addEventListener("resize", updateDesktopDropdownPosition); + window.addEventListener("scroll", updateDesktopDropdownPosition, true); document.addEventListener("mousedown", handlePointerDown); return () => { + window.removeEventListener("resize", updateDesktopDropdownPosition); + window.removeEventListener("scroll", updateDesktopDropdownPosition, true); document.removeEventListener("mousedown", handlePointerDown); }; }, [isMobile, open]); @@ -852,22 +914,31 @@ function CompetitorMultiSelect({ - - {open && !isMobile ? ( - - - 竞争对手 - 支持多选,选择“其他”后可手动录入。 - - {renderOptions()} - - ) : null} - + {open && !isMobile && desktopDropdownStyle && typeof document !== "undefined" + ? createPortal( + + + + 竞争对手 + 支持多选,选择“其他”后可手动录入。 + + + {renderOptions()} + + + , + document.body, + ) + : null} {open && isMobile ? (
竞争对手
支持多选,选择“其他”后可手动录入。