修改下拉框电脑端
parent
eda76e84c0
commit
0d4a3eea8b
|
|
@ -4,7 +4,15 @@
|
|||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<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="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
|
|
@ -84,6 +92,9 @@
|
|||
<workItem from="1774576185724" duration="1651000" />
|
||||
<workItem from="1774763863609" duration="16084000" />
|
||||
<workItem from="1775116718799" duration="3143000" />
|
||||
<workItem from="1775182869187" duration="639000" />
|
||||
<workItem from="1775195780460" duration="622000" />
|
||||
<workItem from="1775197297892" duration="43000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="修改定位信息 0323">
|
||||
<option name="closed" value="true" />
|
||||
|
|
|
|||
|
|
@ -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<Void> completeTodo(
|
||||
@RequestHeader("X-User-Id") @Min(1) Long userId,
|
||||
@PathVariable("todoId") @Min(1) Long todoId) {
|
||||
|
|
|
|||
|
|
@ -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<Long> 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<Long> 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<Void> 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<Void> 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<Long> createFollowUp(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
@PathVariable("bizType") String bizType,
|
||||
|
|
|
|||
|
|
@ -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<Long> 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<Long> 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<Long> 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<Long> createFollowUp(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
@PathVariable("opportunityId") Long opportunityId,
|
||||
|
|
|
|||
|
|
@ -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<Long> updateOpportunity(
|
||||
HttpServletRequest httpServletRequest,
|
||||
@Valid @RequestBody UpdateOpportunityIntegrationRequest request) {
|
||||
|
|
|
|||
|
|
@ -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<Long> 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<String> 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<Long> saveDailyReport(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
@Valid @RequestBody CreateWorkDailyReportRequest request) {
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>(null);
|
||||
const desktopDropdownRef = useRef<HTMLDivElement | null>(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 (
|
||||
<button
|
||||
key={`${option.value}-${option.label}`}
|
||||
key={`${option.value}-${optionLabel}`}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
|
|
@ -206,7 +232,7 @@ export function AdaptiveSelect({
|
|||
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}
|
||||
</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" : "")} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<motion.div
|
||||
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[220]">
|
||||
<div
|
||||
ref={desktopDropdownRef}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: desktopDropdownStyle.top,
|
||||
position: "absolute",
|
||||
left: desktopDropdownStyle.left,
|
||||
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 ? (
|
||||
<div className="mb-2">
|
||||
|
|
@ -260,18 +283,18 @@ export function AdaptiveSelect({
|
|||
/>
|
||||
</div>
|
||||
) : 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) : (
|
||||
<div className="rounded-xl px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
未找到匹配选项
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
<AnimatePresence>
|
||||
{open && isMobile ? (
|
||||
|
|
|
|||
|
|
@ -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<SearchableOption["value"]>();
|
||||
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<HTMLDivElement | null>(null);
|
||||
const desktopDropdownRef = useRef<HTMLDivElement | null>(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"
|
||||
}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>{getSearchableOptionLabel(item)}</span>
|
||||
{item.value === value ? <Check className="h-4 w-4 shrink-0" /> : null}
|
||||
</button>
|
||||
))
|
||||
|
|
@ -659,31 +677,37 @@ function SearchableSelect({
|
|||
)}
|
||||
>
|
||||
<span className={selectedOption ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}>
|
||||
{selectedOption?.label || placeholder}
|
||||
{selectedOption ? getSearchableOptionLabel(selectedOption) : placeholder}
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && !isMobile ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
className={cn(
|
||||
"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",
|
||||
desktopDropdownPlacement === "top" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
style={{ maxHeight: `${desktopDropdownMaxHeight}px` }}
|
||||
className="overflow-y-auto overscroll-contain pr-1"
|
||||
>
|
||||
{renderSearchBody()}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[220]">
|
||||
<div
|
||||
ref={desktopDropdownRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: desktopDropdownPlacement === "top"
|
||||
? Math.max(24, desktopDropdownStyle.top - Math.min(desktopDropdownMaxHeight, 320))
|
||||
: desktopDropdownStyle.top,
|
||||
left: desktopDropdownStyle.left,
|
||||
width: desktopDropdownStyle.width,
|
||||
}}
|
||||
className="pointer-events-auto rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<div
|
||||
style={{ maxHeight: `${desktopDropdownMaxHeight}px` }}
|
||||
className="overflow-y-auto overscroll-contain pr-1"
|
||||
>
|
||||
{renderSearchBody()}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
<AnimatePresence>
|
||||
{open && isMobile ? (
|
||||
|
|
@ -748,23 +772,61 @@ function CompetitorMultiSelect({
|
|||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const desktopDropdownRef = useRef<HTMLDivElement | null>(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({
|
|||
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && !isMobile ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
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"
|
||||
>
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">竞争对手</p>
|
||||
<p className="crm-field-note mt-1">支持多选,选择“其他”后可手动录入。</p>
|
||||
</div>
|
||||
{renderOptions()}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[220]">
|
||||
<div
|
||||
ref={desktopDropdownRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: desktopDropdownStyle.left,
|
||||
width: desktopDropdownStyle.width,
|
||||
top: desktopDropdownStyle.top,
|
||||
}}
|
||||
className="pointer-events-auto rounded-2xl border border-slate-200 bg-white p-3 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<div className="mb-3 shrink-0">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">竞争对手</p>
|
||||
<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>
|
||||
{open && isMobile ? (
|
||||
|
|
|
|||
Loading…
Reference in New Issue