fix(ui): 修复所有弹窗拖拽穿透导致误关闭的问题

问题: 用户在弹窗内容内拖选文本时, 鼠标移到遮罩松开会触发遮罩 click, 导致弹窗意外关闭(click 派发给 mousedown/mouseup 共同祖先)。

方案: 抽取共享 hook useDialogBackdrop, 用 mousedown 跟踪——只有按下和松开都在遮罩上才关闭。Modal.tsx 内置同样逻辑。所有手写遮罩弹窗统一接入。
This commit is contained in:
shy3130
2026-08-02 10:56:24 +08:00
parent 4696fef959
commit a2bea0240a
17 changed files with 103 additions and 18 deletions
@@ -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<Record<string, EpResult | null>>({})
const [testing, setTesting] = useState<Record<string, boolean>>({})
const [switching, setSwitching] = useState<string | null>(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}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
@@ -23,6 +23,7 @@ import { useQuery } from '@tanstack/react-query'
import { QK } from '@/lib/queryKeys'
import type { ColumnConfig, ColumnGroup, ExtColumnDisplayConfig, CandleColumnConfig, IntradayColumnConfig } from '@/lib/list-columns'
import { resolveCandleConfig, resolveIntradayConfig } from '@/lib/list-columns'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
interface ListColumnCustomizerProps {
columns: ColumnConfig[]
@@ -142,6 +143,7 @@ export function ListColumnCustomizer({
enabled: open && showExtColumns,
staleTime: 60_000,
})
const backdrop = useDialogBackdrop(onClose)
const [searchQuery, setSearchQuery] = useState('')
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(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}
/>
<motion.div
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
+12 -1
View File
@@ -48,6 +48,10 @@ export function Modal({
closeOnBackdrop = true,
}: ModalProps) {
const panelRef = useRef<HTMLDivElement>(null)
// 记录鼠标按下时是否落在遮罩(而非面板)上。
// 仅当 mousedown 和 mouseup 都在遮罩时才视为"点击遮罩关闭",
// 避免在面板内拖选文本时鼠标移出面板边缘导致误关 (拖拽穿透)。
const mouseDownOnBackdrop = useRef(false)
// onClose 存 ref: 焦点陷阱/ESC effect 只在挂载时装一次。否则父级每次重渲染 (或未 memo 的
// onClose) 都让 effect 重跑, requestAnimationFrame(focusFirst) 会在每次输入后把焦点抢回
// 面板首个元素, 导致对话框内文本框无法输入。
@@ -118,7 +122,14 @@ export function Modal({
return (
<div
className={overlayClassName}
onClick={closeOnBackdrop ? (e) => { 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}
>
<div
ref={panelRef}
@@ -10,6 +10,7 @@ import { DatePicker } from '@/components/DatePicker'
import { RuleEditor } from '@/components/monitor/RuleEditor'
import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
interface Props {
symbol: string | null
@@ -45,6 +46,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
const [dateRange, setDateRange] = useState(getDefaultRange)
const [showMonitorEditor, setShowMonitorEditor] = useState(false)
const qc = useQueryClient()
const backdrop = useDialogBackdrop(onClose)
const watchlist = useQuery({
queryKey: QK.watchlist,
@@ -109,7 +111,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
{...backdrop}
/>
{/* 弹窗主体 */}
+3 -1
View File
@@ -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<AnalysisFieldConfig>(currentConfig)
const backdrop = useDialogBackdrop(onClose)
const { data: extList } = useQuery({
queryKey: QK.extData,
queryFn: api.extDataList,
@@ -84,7 +86,7 @@ export function AnalysisConfigDialog({
)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" {...backdrop}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
+3 -1
View File
@@ -2,6 +2,7 @@ import { AnimatePresence, motion } from 'framer-motion'
import { useQuery } from '@tanstack/react-query'
import { api, type EnrichedField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
const TABLE_TITLES: Record<string, string> = {
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 }}
>
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="absolute inset-0 bg-black/40" {...backdrop} />
<motion.div
className="relative w-full max-w-xl max-h-[70vh] rounded-card border border-border bg-surface shadow-xl overflow-hidden mx-4"
initial={{ opacity: 0, scale: 0.95, y: 8 }}
@@ -1,10 +1,12 @@
import { motion } from 'framer-motion'
import { X } from 'lucide-react'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
export function SettingsModal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
const backdrop = useDialogBackdrop(onClose)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" {...backdrop} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -16,6 +16,7 @@ import {
} from 'lucide-react'
import { api, type ExtDataDetectUrlResult, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
type SourceMode = 'url' | 'file' | 'manual'
@@ -26,6 +27,7 @@ type MappingChoice = {
export function CreateExtDialog({ onClose }: { onClose: () => void }) {
const qc = useQueryClient()
const backdrop = useDialogBackdrop(onClose)
const [sourceMode, setSourceMode] = useState<SourceMode>('url')
const [id, setId] = useState('')
const [label, setLabel] = useState('')
@@ -308,7 +310,7 @@ export function CreateExtDialog({ onClose }: { onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" {...backdrop} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -4,9 +4,11 @@ import { motion } from 'framer-motion'
import { X, Loader2, Upload } from 'lucide-react'
import { api, type ExtDataConfig, type ExtDataField } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
export function EditExtDialog({ config, onClose }: { config: ExtDataConfig; onClose: () => void }) {
const qc = useQueryClient()
const backdrop = useDialogBackdrop(onClose)
const [label, setLabel] = useState(config.label)
const [description, setDescription] = useState(config.description ?? '')
const [fields, setFields] = useState<ExtDataField[]>([...config.fields])
@@ -79,7 +81,7 @@ export function EditExtDialog({ config, onClose }: { config: ExtDataConfig; onCl
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" {...backdrop} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
@@ -12,6 +12,7 @@ import {
type ActiveTask, type HistoryReport,
minimizeDialog, closeDialog, startAnalysis,
} from '@/lib/aiReportStore'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
interface Props {
/** 当前展示的任务;活跃任务或历史报告 */
@@ -81,6 +82,8 @@ export function AiAnalysisDialog({ 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 : ''
@@ -90,7 +93,7 @@ export function AiAnalysisDialog({ task, mode, minimized }: Props) {
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
{...backdrop}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowRight, Plus, Save, Search, X } from 'lucide-react'
import { api, type CustomSignal, type CustomSignalCondition, type CustomSignalFieldGroup } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
interface Props {
open: boolean
@@ -21,6 +22,7 @@ const emptySignal = (kind: CustomSignal['kind'] = 'exit'): CustomSignal => ({
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<CustomSignal>(() => 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}
>
<motion.div
role="dialog"
@@ -184,6 +186,7 @@ function FieldPicker({ value, fields, groups, onChange }: {
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const backdrop = useDialogBackdrop(() => 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}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
@@ -7,6 +7,7 @@ import { LEVEL_GROUPS } from './AnalysisKChart'
import { api, genRuleId, type MonitorRule, type PriceLevel } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences } from '@/lib/useSharedQueries'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
interface Props {
symbol: string
@@ -37,6 +38,7 @@ function levelGroupLabel(level: PriceLevel) {
export function PriceAlertDialog({ symbol, name, onClose }: Props) {
const qc = useQueryClient()
const backdrop = useDialogBackdrop(onClose)
const { data: prefs } = usePreferences()
const levelsQuery = useQuery({
queryKey: QK.stockLevels(symbol),
@@ -189,7 +191,7 @@ export function PriceAlertDialog({ symbol, name, onClose }: Props) {
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-3 backdrop-blur-sm sm:p-4" onClick={onClose}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-3 backdrop-blur-sm sm:p-4" {...backdrop}>
<div role="dialog" aria-modal="true" aria-labelledby="price-alert-title" className="flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-lg border border-border bg-surface shadow-2xl" onClick={event => event.stopPropagation()}>
<header className="flex items-center gap-3 border-b border-border/60 px-5 py-3.5">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-sky-400/25 bg-sky-400/10 text-sky-400">
@@ -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) {
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
{...backdrop}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
+40
View File
@@ -0,0 +1,40 @@
import { useRef, useCallback } from 'react'
/**
* 对话框遮罩"点击外部关闭"的共享逻辑, 防止拖拽穿透。
* 问题: 用户在对话框内容内按下鼠标拖选文本, 鼠标移到遮罩上松开时,
* 浏览器仍会触发遮罩的 click 事件 (click 派发给 mousedown/mouseup 的共同祖先),
* 导致对话框意外关闭。
* 解法: 记录 mousedown 时是否落在遮罩本身上; 仅当 mousedown 和 mouseup(click)
* 都发生在遮罩上时才触发关闭。
* 用法:
* const backdrop = useDialogBackdrop(onClose)
* <div className="fixed inset-0 ..." {...backdrop}>
* <div onClick={e => e.stopPropagation()}>内容</div>
* </div>
*
* 对于有额外条件(如 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 }
}
+5 -2
View File
@@ -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 (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div className="fixed inset-0 z-40" {...backdrop} />
<div
className="fixed z-50 w-60 rounded-lg bg-surface border border-border shadow-xl text-xs overflow-hidden"
style={{ left, top }}
@@ -1357,6 +1359,7 @@ function ExtConfigDialog({ fields, onSave, onClose }: {
onClose: () => 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" {...backdrop}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
+3 -1
View File
@@ -8,6 +8,7 @@ import { Skeleton } from '@/components/data/Skeleton'
import { api, type MonitorRule, type AlertEvent, type MonitorCondition, type MonitorExtFieldItem } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtPrice, fmtPct } from '@/lib/format'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
import { cn } from '@/lib/cn'
import { cnSignal } from '@/lib/signals'
import { LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS, strategyEventMeta, strategyName } from '@/lib/strategyMonitorEvents'
@@ -782,6 +783,7 @@ function RulesList({ rulesQuery, onEdit }: {
// ── 规则编辑对话框 ────────────────────────────────────
function RuleEditorDialog({ open, rule, onClose }: { open: boolean; rule: MonitorRule | null; onClose: () => void }) {
const backdrop = useDialogBackdrop(onClose)
return (
<AnimatePresence>
{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}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
@@ -5,6 +5,7 @@ import { StockPanel } from '@/components/StockPanel'
import type { ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
import type { StrategyBacktestTrade } from '@/lib/api'
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
interface Props {
trade: StrategyBacktestTrade | null
@@ -34,6 +35,7 @@ function fmtSignedMoney(v: number | null | undefined): string {
export function TradeKlineModal({ trade, onClose }: Props) {
const [showIntraday, setShowIntraday] = useState(false)
const backdrop = useDialogBackdrop(onClose)
useEffect(() => {
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}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}