From a2bea0240afb56f893499ff942cd8fa7d55d305c Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sun, 2 Aug 2026 10:56:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E6=89=80=E6=9C=89?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E6=8B=96=E6=8B=BD=E7=A9=BF=E9=80=8F=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E8=AF=AF=E5=85=B3=E9=97=AD=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 用户在弹窗内容内拖选文本时, 鼠标移到遮罩松开会触发遮罩 click, 导致弹窗意外关闭(click 派发给 mousedown/mouseup 共同祖先)。 方案: 抽取共享 hook useDialogBackdrop, 用 mousedown 跟踪——只有按下和松开都在遮罩上才关闭。Modal.tsx 内置同样逻辑。所有手写遮罩弹窗统一接入。 --- .../src/components/EndpointTestDialog.tsx | 4 +- .../src/components/ListColumnCustomizer.tsx | 4 +- frontend/src/components/Modal.tsx | 13 +++++- .../src/components/StockPreviewDialog.tsx | 4 +- frontend/src/components/analysis-shared.tsx | 4 +- frontend/src/components/data/SchemaModal.tsx | 4 +- .../src/components/data/SettingsModal.tsx | 4 +- .../components/ext-data/CreateExtDialog.tsx | 4 +- .../src/components/ext-data/EditExtDialog.tsx | 4 +- .../financials/AiAnalysisDialog.tsx | 5 ++- .../components/signals/CustomSignalDialog.tsx | 7 +++- .../stock-analysis/PriceAlertDialog.tsx | 4 +- .../stock-analysis/StockAnalysisDialog.tsx | 5 ++- frontend/src/lib/useDialogBackdrop.ts | 40 +++++++++++++++++++ frontend/src/pages/LimitUpLadder.tsx | 7 +++- frontend/src/pages/Monitor.tsx | 4 +- .../backtest/components/TradeKlineModal.tsx | 4 +- 17 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 frontend/src/lib/useDialogBackdrop.ts diff --git a/frontend/src/components/EndpointTestDialog.tsx b/frontend/src/components/EndpointTestDialog.tsx index 86bca85..ca669d1 100644 --- a/frontend/src/components/EndpointTestDialog.tsx +++ b/frontend/src/components/EndpointTestDialog.tsx @@ -5,6 +5,7 @@ import { Wifi, Play, Loader2, X, Check, Crown } from 'lucide-react' import { api, type EndpointItem } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { EXPERT_RANK, tierRank } from '@/lib/capability-labels' +import { useDialogBackdrop } from '@/lib/useDialogBackdrop' interface EpResult { ok: boolean @@ -18,6 +19,7 @@ interface EpResult { export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose }: { hasKey: boolean; tierLabel: string; currentEndpoint: string; onClose: () => void }) { const qc = useQueryClient() + const backdrop = useDialogBackdrop(onClose) const [results, setResults] = useState>({}) const [testing, setTesting] = useState>({}) const [switching, setSwitching] = useState(null) @@ -78,7 +80,7 @@ export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" - onClick={onClose} + {...backdrop} /> >(new Set()) @@ -661,7 +663,7 @@ export function ListColumnCustomizer({ initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }} className="absolute inset-0 bg-black/50 backdrop-blur-sm" - onClick={onClose} + {...backdrop} /> (null) + // 记录鼠标按下时是否落在遮罩(而非面板)上。 + // 仅当 mousedown 和 mouseup 都在遮罩时才视为"点击遮罩关闭", + // 避免在面板内拖选文本时鼠标移出面板边缘导致误关 (拖拽穿透)。 + const mouseDownOnBackdrop = useRef(false) // onClose 存 ref: 焦点陷阱/ESC effect 只在挂载时装一次。否则父级每次重渲染 (或未 memo 的 // onClose) 都让 effect 重跑, requestAnimationFrame(focusFirst) 会在每次输入后把焦点抢回 // 面板首个元素, 导致对话框内文本框无法输入。 @@ -118,7 +122,14 @@ export function Modal({ return (
{ if (e.target === e.currentTarget) onClose() } : undefined} + onMouseDown={(e) => { + // 仅记录"按下时确实在遮罩上"; 在面板内按下时记 false。 + mouseDownOnBackdrop.current = e.target === e.currentTarget + }} + onClick={closeOnBackdrop ? (e) => { + // 只有按下和松开都在遮罩上才关闭, 避免拖选文本误关。 + if (mouseDownOnBackdrop.current && e.target === e.currentTarget) onClose() + } : undefined} >
{/* 弹窗主体 */} diff --git a/frontend/src/components/analysis-shared.tsx b/frontend/src/components/analysis-shared.tsx index 6680f2b..94507fe 100644 --- a/frontend/src/components/analysis-shared.tsx +++ b/frontend/src/components/analysis-shared.tsx @@ -30,6 +30,7 @@ import { cn } from '@/lib/cn' import type { DimensionGroup, QuoteMap } from '@/lib/analysis-adapter' import { computeQuoteMetrics } from '@/lib/analysis-adapter' import { fmtPct, priceColorClass } from '@/lib/format' +import { useDialogBackdrop } from '@/lib/useDialogBackdrop' // ===== 配置类型 ===== @@ -62,6 +63,7 @@ export function AnalysisConfigDialog({ showHierarchyLevel?: boolean }) { const [draft, setDraft] = useState(currentConfig) + const backdrop = useDialogBackdrop(onClose) const { data: extList } = useQuery({ queryKey: QK.extData, queryFn: api.extDataList, @@ -84,7 +86,7 @@ export function AnalysisConfigDialog({ ) return ( -
+
= { instruments: '个股维表', @@ -35,6 +36,7 @@ function categorize(name: string): string { export function EnrichedSchemaModal({ table, onClose }: { table: string | null; onClose: () => void }) { const open = !!table + const backdrop = useDialogBackdrop(onClose) const schema = useQuery({ queryKey: QK.tableSchema(table!), queryFn: () => api.enrichedSchema(table!), @@ -63,7 +65,7 @@ export function EnrichedSchemaModal({ table, onClose }: { table: string | null; exit={{ opacity: 0 }} transition={{ duration: 0.15 }} > -
+
void; children: React.ReactNode }) { + const backdrop = useDialogBackdrop(onClose) return (
-
+
void }) { const qc = useQueryClient() + const backdrop = useDialogBackdrop(onClose) const [sourceMode, setSourceMode] = useState('url') const [id, setId] = useState('') const [label, setLabel] = useState('') @@ -308,7 +310,7 @@ export function CreateExtDialog({ onClose }: { onClose: () => void }) { return (
-
+
void }) { const qc = useQueryClient() + const backdrop = useDialogBackdrop(onClose) const [label, setLabel] = useState(config.label) const [description, setDescription] = useState(config.description ?? '') const [fields, setFields] = useState([...config.fields]) @@ -79,7 +81,7 @@ export function EditExtDialog({ config, onClose }: { config: ExtDataConfig; onCl return (
-
+
setCopied(false), 2000) } + const backdrop = useDialogBackdrop(closeDialog, () => !isWorking) + if (!open) return null const error = task && 'error' in task ? task.error : '' @@ -90,7 +93,7 @@ export function AiAnalysisDialog({ task, mode, minimized }: Props) { { if (e.target === e.currentTarget && !isWorking) closeDialog() }} + {...backdrop} > ({ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose, onSaved }: Props) { const qc = useQueryClient() + const backdrop = useDialogBackdrop(onClose) const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions, enabled: open }) const [draft, setDraft] = useState(() => emptySignal(defaultKind)) @@ -71,7 +73,7 @@ export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" - onClick={onClose} + {...backdrop} > setOpen(false)) const selectedLabel = fields.find(f => f.key === value)?.label ?? value const filteredGroups = useMemo(() => { @@ -217,7 +220,7 @@ function FieldPicker({ value, fields, groups, onChange }: { animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" - onClick={() => setOpen(false)} + {...backdrop} > +
event.stopPropagation()}>
diff --git a/frontend/src/components/stock-analysis/StockAnalysisDialog.tsx b/frontend/src/components/stock-analysis/StockAnalysisDialog.tsx index e7c5e3a..f4d37a6 100644 --- a/frontend/src/components/stock-analysis/StockAnalysisDialog.tsx +++ b/frontend/src/components/stock-analysis/StockAnalysisDialog.tsx @@ -12,6 +12,7 @@ import { type ActiveTask, type HistoryReport, minimizeDialog, closeDialog, startAnalysis, } from '@/lib/stockAnalysisStore' +import { useDialogBackdrop } from '@/lib/useDialogBackdrop' /** * AI 个股分析对话框 —— 蓝色主题,与财务分析对话框区分。 @@ -80,6 +81,8 @@ export function StockAnalysisDialog({ task, mode, minimized }: Props) { setTimeout(() => setCopied(false), 2000) } + const backdrop = useDialogBackdrop(closeDialog, () => !isWorking) + if (!open) return null const error = task && 'error' in task ? task.error : '' @@ -89,7 +92,7 @@ export function StockAnalysisDialog({ task, mode, minimized }: Props) { { if (e.target === e.currentTarget && !isWorking) closeDialog() }} + {...backdrop} > + *
e.stopPropagation()}>内容
+ *
+ * + * 对于有额外条件(如 isWorking 时禁止关闭)的场景, 传 enabled 回调: + * const backdrop = useDialogBackdrop(onClose, () => !isWorking) + */ +export function useDialogBackdrop( + onClose: () => void, + enabled?: () => boolean, +) { + const mouseDownOnBackdrop = useRef(false) + + const onMouseDown = useCallback((e: React.MouseEvent) => { + mouseDownOnBackdrop.current = e.target === e.currentTarget + }, []) + + const onClick = useCallback((e: React.MouseEvent) => { + if (enabled && !enabled()) return + if (mouseDownOnBackdrop.current && e.target === e.currentTarget) { + onClose() + } + }, [onClose, enabled]) + + return { onMouseDown, onClick } +} diff --git a/frontend/src/pages/LimitUpLadder.tsx b/frontend/src/pages/LimitUpLadder.tsx index 6af2088..c9b7f3a 100644 --- a/frontend/src/pages/LimitUpLadder.tsx +++ b/frontend/src/pages/LimitUpLadder.tsx @@ -14,6 +14,7 @@ import { EmptyState } from '@/components/EmptyState' import { useTheme } from '@/lib/theme' import { useCapabilities, usePreferences } from '@/lib/useSharedQueries' import { SealedBadge } from '@/components/SealedBadge' +import { useDialogBackdrop } from '@/lib/useDialogBackdrop' import type { ExtColumnDisplayConfig } from '@/lib/watchlist-columns' // ===== Ext 字段配置 ===== @@ -407,6 +408,7 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD // 推送渠道默认值: 取偏好设置中的全局默认 (已有规则沿用其值) const { data: prefs } = usePreferences() const webhookDefaultChannels = prefs?.webhook_default_channels ?? [] + const backdrop = useDialogBackdrop(onClose) // 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元) const VOL_UNITS = [ @@ -507,7 +509,7 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD return ( <> -
+
void }) { const [draft, setDraft] = useState(fields) + const backdrop = useDialogBackdrop(onClose) const { data: schemaData } = useQuery({ queryKey: QK.extDataSchemaAll, queryFn: api.extDataSchemaAll, @@ -1377,7 +1380,7 @@ function ExtConfigDialog({ fields, onSave, onClose }: { }, [schemaData]) return ( -
+
void }) { + const backdrop = useDialogBackdrop(onClose) return ( {open && ( @@ -790,7 +792,7 @@ function RuleEditorDialog({ open, rule, onClose }: { open: boolean; rule: Monito animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-start justify-center overflow-auto bg-black/40 backdrop-blur-sm p-4" - onClick={onClose} + {...backdrop} > { if (!trade) return @@ -98,7 +100,7 @@ export function TradeKlineModal({ trade, onClose }: Props) { exit={{ opacity: 0 }} transition={{ duration: 0.15 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" - onClick={onClose} + {...backdrop} />