mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 21:24:16 +08:00
fix(ui): 修复所有弹窗拖拽穿透导致误关闭的问题
问题: 用户在弹窗内容内拖选文本时, 鼠标移到遮罩松开会触发遮罩 click, 导致弹窗意外关闭(click 派发给 mousedown/mouseup 共同祖先)。 方案: 抽取共享 hook useDialogBackdrop, 用 mousedown 跟踪——只有按下和松开都在遮罩上才关闭。Modal.tsx 内置同样逻辑。所有手写遮罩弹窗统一接入。
This commit is contained in:
@@ -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%' }}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
{/* 弹窗主体 */}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user