修改下拉框电脑端

main
kangwenjing 2026-04-03 14:35:30 +08:00
parent eda76e84c0
commit 0d4a3eea8b
8 changed files with 189 additions and 65 deletions

View File

@ -4,7 +4,15 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323" /> <list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323">
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/DashboardController.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/DashboardController.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/ExpansionController.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/ExpansionController.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OpportunityController.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OpportunityController.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OpportunityIntegrationController.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/OpportunityIntegrationController.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/WorkController.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/controller/WorkController.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/components/AdaptiveSelect.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/components/AdaptiveSelect.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" /> <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
@ -84,6 +92,9 @@
<workItem from="1774576185724" duration="1651000" /> <workItem from="1774576185724" duration="1651000" />
<workItem from="1774763863609" duration="16084000" /> <workItem from="1774763863609" duration="16084000" />
<workItem from="1775116718799" duration="3143000" /> <workItem from="1775116718799" duration="3143000" />
<workItem from="1775182869187" duration="639000" />
<workItem from="1775195780460" duration="622000" />
<workItem from="1775197297892" duration="43000" />
</task> </task>
<task id="LOCAL-00001" summary="修改定位信息 0323"> <task id="LOCAL-00001" summary="修改定位信息 0323">
<option name="closed" value="true" /> <option name="closed" value="true" />

View File

@ -3,6 +3,7 @@ package com.unis.crm.controller;
import com.unis.crm.common.ApiResponse; import com.unis.crm.common.ApiResponse;
import com.unis.crm.dto.dashboard.DashboardHomeDTO; import com.unis.crm.dto.dashboard.DashboardHomeDTO;
import com.unis.crm.service.DashboardService; import com.unis.crm.service.DashboardService;
import com.unisbase.common.annotation.Log;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -30,6 +31,7 @@ public class DashboardController {
} }
@PostMapping("/todos/{todoId}/complete") @PostMapping("/todos/{todoId}/complete")
@Log(type = "工作台", value = "完成待办")
public ApiResponse<Void> completeTodo( public ApiResponse<Void> completeTodo(
@RequestHeader("X-User-Id") @Min(1) Long userId, @RequestHeader("X-User-Id") @Min(1) Long userId,
@PathVariable("todoId") @Min(1) Long todoId) { @PathVariable("todoId") @Min(1) Long todoId) {

View File

@ -12,6 +12,7 @@ import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest; import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest; import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
import com.unis.crm.service.ExpansionService; import com.unis.crm.service.ExpansionService;
import com.unisbase.common.annotation.Log;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.List; import java.util.List;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -75,6 +76,7 @@ public class ExpansionController {
} }
@PostMapping("/sales") @PostMapping("/sales")
@Log(type = "拓展管理", value = "新增售前拓展")
public ApiResponse<Long> createSales( public ApiResponse<Long> createSales(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody CreateSalesExpansionRequest request) { @Valid @RequestBody CreateSalesExpansionRequest request) {
@ -82,6 +84,7 @@ public class ExpansionController {
} }
@PostMapping("/channel") @PostMapping("/channel")
@Log(type = "拓展管理", value = "新增渠道拓展")
public ApiResponse<Long> createChannel( public ApiResponse<Long> createChannel(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody CreateChannelExpansionRequest request) { @Valid @RequestBody CreateChannelExpansionRequest request) {
@ -89,6 +92,7 @@ public class ExpansionController {
} }
@PutMapping("/sales/{id}") @PutMapping("/sales/{id}")
@Log(type = "拓展管理", value = "编辑售前拓展")
public ApiResponse<Void> updateSales( public ApiResponse<Void> updateSales(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@PathVariable("id") Long id, @PathVariable("id") Long id,
@ -98,6 +102,7 @@ public class ExpansionController {
} }
@PutMapping("/channel/{id}") @PutMapping("/channel/{id}")
@Log(type = "拓展管理", value = "编辑渠道拓展")
public ApiResponse<Void> updateChannel( public ApiResponse<Void> updateChannel(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@PathVariable("id") Long id, @PathVariable("id") Long id,
@ -107,6 +112,7 @@ public class ExpansionController {
} }
@PostMapping("/{bizType}/{bizId}/followups") @PostMapping("/{bizType}/{bizId}/followups")
@Log(type = "拓展管理", value = "新增拓展跟进")
public ApiResponse<Long> createFollowUp( public ApiResponse<Long> createFollowUp(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@PathVariable("bizType") String bizType, @PathVariable("bizType") String bizType,

View File

@ -9,6 +9,7 @@ import com.unis.crm.dto.opportunity.OpportunityMetaDTO;
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO; import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest; import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
import com.unis.crm.service.OpportunityService; import com.unis.crm.service.OpportunityService;
import com.unisbase.common.annotation.Log;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.List; import java.util.List;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -50,6 +51,7 @@ public class OpportunityController {
} }
@PostMapping @PostMapping
@Log(type = "商机管理", value = "新增商机")
public ApiResponse<Long> createOpportunity( public ApiResponse<Long> createOpportunity(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody CreateOpportunityRequest request) { @Valid @RequestBody CreateOpportunityRequest request) {
@ -57,6 +59,7 @@ public class OpportunityController {
} }
@PutMapping("/{opportunityId}") @PutMapping("/{opportunityId}")
@Log(type = "商机管理", value = "编辑商机")
public ApiResponse<Long> updateOpportunity( public ApiResponse<Long> updateOpportunity(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@PathVariable("opportunityId") Long opportunityId, @PathVariable("opportunityId") Long opportunityId,
@ -65,6 +68,7 @@ public class OpportunityController {
} }
@PostMapping("/{opportunityId}/push-oms") @PostMapping("/{opportunityId}/push-oms")
@Log(type = "商机管理", value = "推送商机到OMS")
public ApiResponse<Long> pushToOms( public ApiResponse<Long> pushToOms(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@PathVariable("opportunityId") Long opportunityId, @PathVariable("opportunityId") Long opportunityId,
@ -73,6 +77,7 @@ public class OpportunityController {
} }
@PostMapping("/{opportunityId}/followups") @PostMapping("/{opportunityId}/followups")
@Log(type = "商机管理", value = "新增商机跟进")
public ApiResponse<Long> createFollowUp( public ApiResponse<Long> createFollowUp(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@PathVariable("opportunityId") Long opportunityId, @PathVariable("opportunityId") Long opportunityId,

View File

@ -5,6 +5,7 @@ import com.unis.crm.common.UnauthorizedException;
import com.unis.crm.config.InternalAuthProperties; import com.unis.crm.config.InternalAuthProperties;
import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest; import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
import com.unis.crm.service.OpportunityService; import com.unis.crm.service.OpportunityService;
import com.unisbase.common.annotation.Log;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.Objects; import java.util.Objects;
@ -30,6 +31,7 @@ public class OpportunityIntegrationController {
} }
@PutMapping("/update") @PutMapping("/update")
@Log(type = "商机集成", value = "同步更新商机")
public ApiResponse<Long> updateOpportunity( public ApiResponse<Long> updateOpportunity(
HttpServletRequest httpServletRequest, HttpServletRequest httpServletRequest,
@Valid @RequestBody UpdateOpportunityIntegrationRequest request) { @Valid @RequestBody UpdateOpportunityIntegrationRequest request) {

View File

@ -8,6 +8,7 @@ import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
import com.unis.crm.dto.work.WorkHistoryPageDTO; import com.unis.crm.dto.work.WorkHistoryPageDTO;
import com.unis.crm.dto.work.WorkOverviewDTO; import com.unis.crm.dto.work.WorkOverviewDTO;
import com.unis.crm.service.WorkService; import com.unis.crm.service.WorkService;
import com.unisbase.common.annotation.Log;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.math.BigDecimal; import java.math.BigDecimal;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
@ -66,6 +67,7 @@ public class WorkController {
} }
@PostMapping("/checkins") @PostMapping("/checkins")
@Log(type = "工作管理", value = "提交打卡")
public ApiResponse<Long> saveCheckIn( public ApiResponse<Long> saveCheckIn(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody CreateWorkCheckInRequest request) { @Valid @RequestBody CreateWorkCheckInRequest request) {
@ -73,6 +75,7 @@ public class WorkController {
} }
@PostMapping(path = "/checkin-photos", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(path = "/checkin-photos", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Log(type = "工作管理", value = "上传打卡照片")
public ApiResponse<String> uploadCheckInPhoto( public ApiResponse<String> uploadCheckInPhoto(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@RequestPart("file") MultipartFile file) { @RequestPart("file") MultipartFile file) {
@ -89,6 +92,7 @@ public class WorkController {
} }
@PostMapping("/daily-reports") @PostMapping("/daily-reports")
@Log(type = "工作管理", value = "提交日报")
public ApiResponse<Long> saveDailyReport( public ApiResponse<Long> saveDailyReport(
@RequestHeader("X-User-Id") Long userId, @RequestHeader("X-User-Id") Long userId,
@Valid @RequestBody CreateWorkDailyReportRequest request) { @Valid @RequestBody CreateWorkDailyReportRequest request) {

View File

@ -10,6 +10,14 @@ export type AdaptiveSelectOption = {
disabled?: boolean; disabled?: boolean;
}; };
function getOptionLabel(option: AdaptiveSelectOption) {
const normalizedLabel = typeof option.label === "string" ? option.label.trim() : "";
if (normalizedLabel) {
return normalizedLabel;
}
return option.value;
}
type AdaptiveSelectBaseProps = { type AdaptiveSelectBaseProps = {
options: AdaptiveSelectOption[]; options: AdaptiveSelectOption[];
placeholder?: string; placeholder?: string;
@ -80,20 +88,25 @@ export function AdaptiveSelect({
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const desktopDropdownRef = useRef<HTMLDivElement | null>(null); const desktopDropdownRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport(); 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 const selectedValues = multiple
? Array.isArray(value) ? value : [] ? Array.isArray(value) ? value : []
: typeof value === "string" && value ? [value] : []; : typeof value === "string" && value ? [value] : [];
const selectedLabel = selectedValues.length > 0 const selectedLabel = selectedValues.length > 0
? selectedValues ? 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)) .filter((label): label is string => Boolean(label))
.join("、") .join("、")
: placeholder; : placeholder;
const normalizedSearchKeyword = searchKeyword.trim().toLowerCase(); const normalizedSearchKeyword = searchKeyword.trim().toLowerCase();
const visibleOptions = searchable && normalizedSearchKeyword const visibleOptions = searchable && normalizedSearchKeyword
? options.filter((option) => { ? options.filter((option) => {
const label = option.label.toLowerCase(); const label = getOptionLabel(option).toLowerCase();
const optionValue = option.value.toLowerCase(); const optionValue = option.value.toLowerCase();
return label.includes(normalizedSearchKeyword) || optionValue.includes(normalizedSearchKeyword); return label.includes(normalizedSearchKeyword) || optionValue.includes(normalizedSearchKeyword);
}) })
@ -105,12 +118,24 @@ export function AdaptiveSelect({
} }
const rect = containerRef.current.getBoundingClientRect(); 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({ setDesktopDropdownStyle({
top: rect.bottom + 8,
left: rect.left, left: rect.left,
width: rect.width, width: rect.width,
top: shouldOpenUpward
? Math.max(safePadding, rect.top - 8 - (popupContentHeight + panelChromeHeight))
: rect.bottom + 8,
}); });
}, [isMobile, open]); }, [isMobile, multiple, open, searchable]);
useEffect(() => { useEffect(() => {
if (!open || isMobile) { if (!open || isMobile) {
@ -192,9 +217,10 @@ export function AdaptiveSelect({
const isSelected = multiple const isSelected = multiple
? selectedValues.includes(option.value) ? selectedValues.includes(option.value)
: option.value === value; : option.value === value;
const optionLabel = getOptionLabel(option);
return ( return (
<button <button
key={`${option.value}-${option.label}`} key={`${option.value}-${optionLabel}`}
type="button" type="button"
disabled={option.disabled} disabled={option.disabled}
onClick={() => handleSelect(option.value)} onClick={() => handleSelect(option.value)}
@ -206,7 +232,7 @@ export function AdaptiveSelect({
option.disabled ? "cursor-not-allowed opacity-50" : "", option.disabled ? "cursor-not-allowed opacity-50" : "",
)} )}
> >
<span className="break-anywhere">{option.label}</span> <span className="break-anywhere">{optionLabel}</span>
{isSelected ? <Check className="h-4 w-4 shrink-0" /> : null} {isSelected ? <Check className="h-4 w-4 shrink-0" /> : null}
</button> </button>
); );
@ -233,21 +259,18 @@ export function AdaptiveSelect({
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open ? "rotate-180" : "")} /> <ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open ? "rotate-180" : "")} />
</button> </button>
<AnimatePresence> {open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined" ? createPortal(
? createPortal( <div className="pointer-events-none fixed inset-0 z-[220]">
<motion.div <div
ref={desktopDropdownRef} ref={desktopDropdownRef}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
style={{ style={{
position: "fixed", position: "absolute",
top: desktopDropdownStyle.top,
left: desktopDropdownStyle.left, left: desktopDropdownStyle.left,
width: desktopDropdownStyle.width, width: desktopDropdownStyle.width,
top: desktopDropdownStyle.top,
}} }}
className="z-[160] rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl dark:border-slate-800 dark:bg-slate-900" className="pointer-events-auto rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
> >
{searchable ? ( {searchable ? (
<div className="mb-2"> <div className="mb-2">
@ -260,18 +283,18 @@ export function AdaptiveSelect({
/> />
</div> </div>
) : null} ) : null}
<div className="max-h-72 space-y-1 overflow-y-auto pr-1"> <div style={{ maxHeight: `${desktopDropdownMaxHeight}px` }} className="space-y-1 overflow-y-auto pr-1">
{visibleOptions.length > 0 ? visibleOptions.map(renderOption) : ( {visibleOptions.length > 0 ? visibleOptions.map(renderOption) : (
<div className="rounded-xl px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400"> <div className="rounded-xl px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
</div> </div>
)} )}
</div> </div>
</motion.div>, </div>
document.body, </div>,
) document.body,
: null} )
</AnimatePresence> : null}
<AnimatePresence> <AnimatePresence>
{open && isMobile ? ( {open && isMobile ? (

View File

@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; 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 { 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 { 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 { 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 { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser"; import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
@ -452,6 +453,14 @@ type SearchableOption = {
keywords?: string[]; 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[]) { function dedupeSearchableOptions(options: SearchableOption[]) {
const seenValues = new Set<SearchableOption["value"]>(); const seenValues = new Set<SearchableOption["value"]>();
return options.filter((option) => { return options.filter((option) => {
@ -514,7 +523,9 @@ function SearchableSelect({
const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom"); const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom");
const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256); const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const desktopDropdownRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport(); const isMobile = useIsMobileViewport();
const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ top: number; left: number; width: number } | null>(null);
const normalizedOptions = dedupeSearchableOptions(options); const normalizedOptions = dedupeSearchableOptions(options);
const selectedOption = normalizedOptions.find((item) => item.value === value); const selectedOption = normalizedOptions.find((item) => item.value === value);
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
@ -523,7 +534,7 @@ function SearchableSelect({
return true; return true;
} }
const haystacks = [item.label, ...(item.keywords ?? [])] const haystacks = [getSearchableOptionLabel(item), ...(item.keywords ?? [])]
.filter(Boolean) .filter(Boolean)
.map((entry) => entry.toLowerCase()); .map((entry) => entry.toLowerCase());
@ -532,6 +543,7 @@ function SearchableSelect({
useEffect(() => { useEffect(() => {
if (!open || isMobile) { if (!open || isMobile) {
setDesktopDropdownStyle(null);
return; return;
} }
@ -550,12 +562,18 @@ function SearchableSelect({
setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom"); setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom");
setDesktopDropdownMaxHeight(Math.min(320, shouldOpenUpward ? availableAbove : availableBelow)); 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(); updateDesktopDropdownLayout();
const handlePointerDown = (event: MouseEvent) => { 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); setOpen(false);
setQuery(""); setQuery("");
} }
@ -629,7 +647,7 @@ function SearchableSelect({
: "text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800" : "text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
}`} }`}
> >
<span>{item.label}</span> <span>{getSearchableOptionLabel(item)}</span>
{item.value === value ? <Check className="h-4 w-4 shrink-0" /> : null} {item.value === value ? <Check className="h-4 w-4 shrink-0" /> : null}
</button> </button>
)) ))
@ -659,31 +677,37 @@ function SearchableSelect({
)} )}
> >
<span className={selectedOption ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}> <span className={selectedOption ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}>
{selectedOption?.label || placeholder} {selectedOption ? getSearchableOptionLabel(selectedOption) : placeholder}
</span> </span>
<ChevronDown className={`h-4 w-4 shrink-0 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} /> <ChevronDown className={`h-4 w-4 shrink-0 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button> </button>
<AnimatePresence> {open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
{open && !isMobile ? ( ? createPortal(
<motion.div <div className="pointer-events-none fixed inset-0 z-[220]">
initial={{ opacity: 0, y: 8 }} <div
animate={{ opacity: 1, y: 0 }} ref={desktopDropdownRef}
exit={{ opacity: 0, y: 8 }} style={{
className={cn( position: "absolute",
"absolute z-20 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-800 dark:bg-slate-900", top: desktopDropdownPlacement === "top"
desktopDropdownPlacement === "top" ? "bottom-full mb-2" : "top-full mt-2", ? Math.max(24, desktopDropdownStyle.top - Math.min(desktopDropdownMaxHeight, 320))
)} : desktopDropdownStyle.top,
> left: desktopDropdownStyle.left,
<div width: desktopDropdownStyle.width,
style={{ maxHeight: `${desktopDropdownMaxHeight}px` }} }}
className="overflow-y-auto overscroll-contain pr-1" className="pointer-events-auto rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
> >
{renderSearchBody()} <div
</div> style={{ maxHeight: `${desktopDropdownMaxHeight}px` }}
</motion.div> className="overflow-y-auto overscroll-contain pr-1"
) : null} >
</AnimatePresence> {renderSearchBody()}
</div>
</div>
</div>,
document.body,
)
: null}
<AnimatePresence> <AnimatePresence>
{open && isMobile ? ( {open && isMobile ? (
@ -748,23 +772,61 @@ function CompetitorMultiSelect({
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const desktopDropdownRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport(); 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; const summary = value.length > 0 ? value.join("、") : placeholder;
useEffect(() => { useEffect(() => {
if (!open || isMobile) { if (!open || isMobile) {
setDesktopDropdownStyle(null);
return; 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) => { 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); setOpen(false);
} }
}; };
window.addEventListener("resize", updateDesktopDropdownPosition);
window.addEventListener("scroll", updateDesktopDropdownPosition, true);
document.addEventListener("mousedown", handlePointerDown); document.addEventListener("mousedown", handlePointerDown);
return () => { return () => {
window.removeEventListener("resize", updateDesktopDropdownPosition);
window.removeEventListener("scroll", updateDesktopDropdownPosition, true);
document.removeEventListener("mousedown", handlePointerDown); document.removeEventListener("mousedown", handlePointerDown);
}; };
}, [isMobile, open]); }, [isMobile, open]);
@ -852,22 +914,31 @@ function CompetitorMultiSelect({
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open && "rotate-180")} /> <ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open && "rotate-180")} />
</button> </button>
<AnimatePresence> {open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
{open && !isMobile ? ( ? createPortal(
<motion.div <div className="pointer-events-none fixed inset-0 z-[220]">
initial={{ opacity: 0, y: 8 }} <div
animate={{ opacity: 1, y: 0 }} ref={desktopDropdownRef}
exit={{ opacity: 0, y: 8 }} style={{
className="absolute z-20 mt-2 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-800 dark:bg-slate-900" position: "absolute",
> left: desktopDropdownStyle.left,
<div className="mb-3"> width: desktopDropdownStyle.width,
<p className="text-sm font-semibold text-slate-900 dark:text-white"></p> top: desktopDropdownStyle.top,
<p className="crm-field-note mt-1"></p> }}
</div> className="pointer-events-auto rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
{renderOptions()} >
</motion.div> <div className="mb-3 shrink-0">
) : null} <p className="text-sm font-semibold text-slate-900 dark:text-white"></p>
</AnimatePresence> <p className="crm-field-note mt-1"></p>
</div>
<div style={{ maxHeight: `${desktopDropdownMaxHeight}px` }} className="overflow-y-auto pr-1">
{renderOptions()}
</div>
</div>
</div>,
document.body,
)
: null}
<AnimatePresence> <AnimatePresence>
{open && isMobile ? ( {open && isMobile ? (