From ca7801dc14eb620f4366adaa76e208323dbc2db5 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Wed, 14 Oct 2026 15:11:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E7=95=8C=E9=9D=A2=E7=BC=A9=E6=94=BE?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20transform=20scale=20=E2=80=94=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=BC=A9=E6=94=BE=E9=9D=9E=20100%=20=E6=97=B6?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=E9=BC=A0=E6=A0=87=E5=91=BD=E4=B8=AD=E5=81=8F?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/index.html | 29 +++++++---- frontend/src/components/Layout.tsx | 11 ++-- .../components/dashboard/DashboardGrid.tsx | 20 +++++++- .../components/financials/AiReportBubble.tsx | 23 ++++++--- .../stock-analysis/StockAnalysisBubble.tsx | 23 ++++++--- .../custom/assistant/ui/AssistantDrawer.tsx | 3 +- .../assistant/ui/AssistantFloatingButton.tsx | 15 +++--- frontend/src/index.css | 12 +++-- frontend/src/lib/pageZoom.ts | 50 ++++++++++++++++--- 9 files changed, 139 insertions(+), 47 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 09852bd..d7b4aaf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,6 @@ @@ -32,6 +23,26 @@
+ diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c910ad8..5f0bb9e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -21,6 +21,7 @@ import { useToggleRealtimeQuotes, } from '@/lib/useSharedMutations' import { QK } from '@/lib/queryKeys' +import { getPageScale } from '@/lib/pageZoom' import { compareByDefaultNav, defaultNavIndex, seedHiddenForNewUser } from '@/lib/navOrder' import { Settings, @@ -228,10 +229,14 @@ function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefine const openPop = () => { window.clearTimeout(closeTimer.current) const rect = linkRef.current?.getBoundingClientRect() - if (rect) setPopPos({ left: rect.right, top: rect.top + rect.height / 2 }) + // rect 是视觉坐标, fixed 定位值是 #root 布局坐标 (缩放层), 需 ÷ scale 换算。 + if (rect) { + const z = getPageScale() + setPopPos({ left: rect.right / z, top: (rect.top + rect.height / 2) / z }) + } } const closePop = () => { - closeTimer.current = window.setTimeout(() => setPopPos(null), 80) + window.setTimeout(() => setPopPos(null), 80) } useEffect(() => () => window.clearTimeout(closeTimer.current), []) useLayoutEffect(() => { @@ -239,7 +244,7 @@ function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefine const h = popRef.current.offsetHeight const margin = 8 const minCenter = margin + h / 2 - const maxCenter = window.innerHeight - margin - h / 2 + const maxCenter = window.innerHeight / getPageScale() - margin - h / 2 const clamped = Math.min(maxCenter, Math.max(minCenter, popPos.top)) if (clamped !== popPos.top) setPopPos({ ...popPos, top: clamped }) }, [popPos]) diff --git a/frontend/src/components/dashboard/DashboardGrid.tsx b/frontend/src/components/dashboard/DashboardGrid.tsx index d0be269..c695eb4 100644 --- a/frontend/src/components/dashboard/DashboardGrid.tsx +++ b/frontend/src/components/dashboard/DashboardGrid.tsx @@ -16,7 +16,24 @@ * 非编辑状态下布局意外写回)。 */ import { useCallback, useMemo, type ReactNode } from 'react' -import GridLayout, { useContainerWidth, type Layout } from 'react-grid-layout' +import GridLayout, { useContainerWidth, setTransform, type Layout } from 'react-grid-layout' +import { getPageScale } from '@/lib/pageZoom' + +/** RGL 2.x 的「容器被 transform: scale 缩放」定位策略 (包根未导出 createScaledStrategy, + * 按 dist 内官方实现等价构造): 定位走 transform, 拖拽像素位移按 scale 换算回布局坐标。 + * 界面缩放挂在 #root (见 lib/pageZoom.ts), dashboard 网格在缩放层内, 不补偿则 + * 缩放≠100% 时拖拽跟手速度偏差 1/scale。 */ +function scaledStrategy(scale: number) { + return { + type: 'transform' as const, + scale, + calcStyle: (pos: Parameters[0]) => setTransform(pos), + calcDragPosition: (clientX: number, clientY: number, offsetX: number, offsetY: number) => ({ + left: (clientX - offsetX) / scale, + top: (clientY - offsetY) / scale, + }), + } +} import 'react-grid-layout/css/styles.css' import './grid.css' import { X } from 'lucide-react' @@ -93,6 +110,7 @@ export function DashboardGrid({ ctx, items, onItemsChange, editing }: DashboardG { - const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN - const maxY = window.innerHeight - 80 + // innerWidth 是视觉像素, translate3d 定位值是 #root 布局坐标 (缩放层), 需 ÷ scale。 + const z = getPageScale() + const maxX = window.innerWidth / z - BUBBLE_W - EDGE_MARGIN + const maxY = window.innerHeight / z - 80 return { x: Math.max(EDGE_MARGIN, Math.min(maxX, x)), y: Math.max(EDGE_MARGIN, Math.min(maxY, y)), @@ -61,8 +64,10 @@ export function AiReportBubble() { const onPointerMove = useCallback((e: React.PointerEvent) => { if (!draggingRef.current) return - const dx = e.clientX - dragData.current.mx - const dy = e.clientY - dragData.current.my + // clientX 是视觉像素, 位移需 ÷ scale 换算回布局坐标, 否则缩放下拖拽跟手速度偏差 1/z。 + const z = getPageScale() + const dx = (e.clientX - dragData.current.mx) / z + const dy = (e.clientY - dragData.current.my) / z if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true const nx = dragData.current.ox + dx const ny = dragData.current.oy + dy @@ -232,8 +237,10 @@ function BubbleItem({ task, isLast, onPointerDown }: { const POS_KEY = 'ai_bubble_pos' function loadPos(): { x: number; y: number } { // 默认:右下角(距右边缘 EDGE_MARGIN,距底部留出空间避开右下角元素) - const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN) - const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 200) + // innerWidth 视觉像素 ÷ scale = #root 布局坐标 (缩放层)。 + const z = getPageScale() + const defaultX = Math.max(EDGE_MARGIN, window.innerWidth / z - BUBBLE_W - EDGE_MARGIN) + const defaultY = Math.max(EDGE_MARGIN, window.innerHeight / z - 200) try { const v = localStorage.getItem(POS_KEY) if (v) { @@ -241,8 +248,8 @@ function loadPos(): { x: number; y: number } { if (typeof p.x === 'number' && typeof p.y === 'number') { // 钳制到当前视口(防止保存的位置在缩小后的窗口外) return { - x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)), - y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)), + x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth / z - BUBBLE_W - EDGE_MARGIN, p.x)), + y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight / z - 80, p.y)), } } } diff --git a/frontend/src/components/stock-analysis/StockAnalysisBubble.tsx b/frontend/src/components/stock-analysis/StockAnalysisBubble.tsx index e5927b7..6ea95b4 100644 --- a/frontend/src/components/stock-analysis/StockAnalysisBubble.tsx +++ b/frontend/src/components/stock-analysis/StockAnalysisBubble.tsx @@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { Loader2, Check, AlertCircle } from 'lucide-react' import { useBubbleTasks, restoreDialog } from '@/lib/stockAnalysisStore' import type { ActiveTask } from '@/lib/stockAnalysisStore' +import { getPageScale } from '@/lib/pageZoom' /** * AI 个股分析任务全局气泡 —— 与财务分析胶囊并列,蓝色主题区分。 @@ -30,8 +31,10 @@ export function StockAnalysisBubble() { }, []) const clamp = useCallback((x: number, y: number) => { - const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN - const maxY = window.innerHeight - 80 + // innerWidth 是视觉像素, translate3d 定位值是 #root 布局坐标 (缩放层), 需 ÷ scale。 + const z = getPageScale() + const maxX = window.innerWidth / z - BUBBLE_W - EDGE_MARGIN + const maxY = window.innerHeight / z - 80 return { x: Math.max(EDGE_MARGIN, Math.min(maxX, x)), y: Math.max(EDGE_MARGIN, Math.min(maxY, y)), @@ -49,8 +52,10 @@ export function StockAnalysisBubble() { const onPointerMove = useCallback((e: React.PointerEvent) => { if (!draggingRef.current) return - const dx = e.clientX - dragData.current.mx - const dy = e.clientY - dragData.current.my + // clientX 是视觉像素, 位移需 ÷ scale 换算回布局坐标, 否则缩放下拖拽跟手速度偏差 1/z。 + const z = getPageScale() + const dx = (e.clientX - dragData.current.mx) / z + const dy = (e.clientY - dragData.current.my) / z if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true const c = clamp(dragData.current.ox + dx, dragData.current.oy + dy) applyTransform(c.x, c.y) @@ -186,16 +191,18 @@ function BubbleItem({ task, isLast, onPointerDown }: { const POS_KEY = 'sa_bubble_pos' function loadPos(): { x: number; y: number } { // 默认右下,偏上一点(避开财务胶囊的右下位置) - const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN) - const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 320) + // innerWidth 视觉像素 ÷ scale = #root 布局坐标 (缩放层)。 + const z = getPageScale() + const defaultX = Math.max(EDGE_MARGIN, window.innerWidth / z - BUBBLE_W - EDGE_MARGIN) + const defaultY = Math.max(EDGE_MARGIN, window.innerHeight / z - 320) try { const v = localStorage.getItem(POS_KEY) if (v) { const p = JSON.parse(v) if (typeof p.x === 'number' && typeof p.y === 'number') { return { - x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)), - y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)), + x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth / z - BUBBLE_W - EDGE_MARGIN, p.x)), + y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight / z - 80, p.y)), } } } diff --git a/frontend/src/custom/assistant/ui/AssistantDrawer.tsx b/frontend/src/custom/assistant/ui/AssistantDrawer.tsx index c7ac44d..913ea26 100644 --- a/frontend/src/custom/assistant/ui/AssistantDrawer.tsx +++ b/frontend/src/custom/assistant/ui/AssistantDrawer.tsx @@ -90,7 +90,8 @@ export function AssistantDrawer() { )} , - document.body, + // #root (缩放层): 抽屉随界面缩放, 且与 Modal 同层叠上下文保持 z 序设计。 + document.getElementById('root') ?? document.body, ) } diff --git a/frontend/src/custom/assistant/ui/AssistantFloatingButton.tsx b/frontend/src/custom/assistant/ui/AssistantFloatingButton.tsx index e6c0bb1..9605d4e 100644 --- a/frontend/src/custom/assistant/ui/AssistantFloatingButton.tsx +++ b/frontend/src/custom/assistant/ui/AssistantFloatingButton.tsx @@ -10,6 +10,7 @@ import { createPortal } from 'react-dom' import { motion } from 'framer-motion' import { Sparkles } from 'lucide-react' import { cn } from '@/lib/cn' +import { getPageScale } from '@/lib/pageZoom' import { toggleAssistant, useAssistantStore } from '../store' const POS_STORAGE_KEY = 'assistant.fab.pos.v1' @@ -21,12 +22,11 @@ interface BallPos { y: number } -/** 页面 CSS zoom (html 上): 视觉坐标(getBoundingClientRect/clientX) 与 - * 布局坐标(style.left) 在 zoom≠1 时相差 zoom 倍, 拖拽换算必须经过它, +/** 页面缩放 (#root transform scale): 视觉坐标(getBoundingClientRect/clientX) 与 + * 布局坐标(style.left) 在 scale≠1 时相差 scale 倍, 拖拽换算必须经过它, * 否则 90% 缩放下每次拖动球会向左上跳约 11%。 */ function pageZoom(): number { - const v = parseFloat(getComputedStyle(document.documentElement).zoom) - return Number.isFinite(v) && v > 0 ? v : 1 + return getPageScale() } function clampPos(pos: BallPos): BallPos { @@ -124,8 +124,9 @@ export function AssistantFloatingButton() { toggleAssistant() } - // Portal 到 body: 挂载点在侧栏 aside 内, 移动端 aside 带 transform, - // 会让 fixed 相对 aside 定位并被 overflow-hidden 裁剪。 + // Portal 到 #root (缩放层): 与 Modal 同层叠上下文, z 序不变 (弹窗 z-50 盖住球 z-30)。 + // 挂载点在侧栏 aside 内, 移动端 aside 带 transform 会让 fixed 相对 aside 定位并被 + // overflow-hidden 裁剪; #root 的 transform 同理, 但 #root 占满视口且不裁剪, 等价于视口。 return createPortal( )} , - document.body, + document.getElementById('root') ?? document.body, ) } diff --git a/frontend/src/index.css b/frontend/src/index.css index d29ef9b..0db0ecc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -68,9 +68,9 @@ html.dark { /* 默认 1px 细分隔线(暗色用边框区分层次,不用 shadow) */ * { border-color: hsl(var(--border)); } -/* 页面缩放 (设置→系统→界面缩放) 用 CSS zoom 作用于 : - * vh 单位不随 zoom 缩放 (zoom<1 底部露底 / zoom>1 溢出), 百分比高度链才会 — - * 根链路统一 % 高度, 主布局与加载占位用 h-full/min-h-full 而非 h-screen。 */ +/* 页面缩放 (设置→系统→界面缩放) 用 #root transform: scale 实现 (见 lib/pageZoom.ts): + * #root 宽高按 1/z 百分比补偿, 根链路必须是 % 高度链 (vh 不参与换算) — + * 主布局与加载占位用 h-full/min-h-full 而非 h-screen。 */ html, body, #root { height: 100%; } body { @@ -84,6 +84,12 @@ body { } /* 选区用主色 (默认蓝底破坏暗色氛围) */ +/* 联想下拉 (SuggestInput portal) 进入动画: 2px 上浮 + 淡入, 120ms 快进快出 */ +@keyframes sg-drop-in { + from { opacity: 0; transform: translateY(-2px) scale(0.995); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + ::selection { background: hsl(var(--accent) / 0.3); } diff --git a/frontend/src/lib/pageZoom.ts b/frontend/src/lib/pageZoom.ts index 0f416d0..b157391 100644 --- a/frontend/src/lib/pageZoom.ts +++ b/frontend/src/lib/pageZoom.ts @@ -1,10 +1,19 @@ /** * 页面整体缩放 — 桌面客户端 (pywebview) 里没有浏览器缩放快捷键, - * 由 设置→系统→界面缩放 控制。CSS zoom 作用于 , 全组件等比缩放。 + * 由 设置→系统→界面缩放 控制。 * - * - 值域 0.8–1.5, 步进 0.05, localStorage 持久化 (storage.pageZoom); 默认 0.9。 - * - index.html 的预渲染脚本会在首屏前应用, 避免加载后尺寸跳变。 + * 实现: #root 上 transform: scale(z) + origin 0 0 + 宽高按 1/z 补偿。 + * 不用 CSS zoom: zoom≠1 时视觉坐标 (clientX/rect) 与布局坐标分离, + * ECharts/zrender 的鼠标命中不做换算 (echarts#15103), 十字光标/tooltip + * 会偏移约 (1-1/z); 而 transform 下浏览器规范对 offsetX 自动逆变换回 + * 元素本地坐标系, 图表与拖拽类库零改动即正确。 + * + * - 值域 0.8–1.5, 步进 0.05, localStorage 持久化 (storage.pageZoom); 默认 1。 + * - JS 定位的浮层 (#root 内 fixed/absolute) 需把视觉坐标换算回布局坐标: + * 统一用 getPageScale() 相除 (÷ scale)。 + * - index.html 在 #root 之后有预渲染内联脚本, 首帧绘制前应用, 无跳变。 * - 应用后派发 window resize, echarts 等监听 resize 的图表随之重排。 + * - z=1 时清空 transform/宽高, 与未开启缩放的状态完全一致。 */ import { storage } from '@/lib/storage' @@ -12,22 +21,49 @@ export const ZOOM_MIN = 0.8 export const ZOOM_MAX = 1.5 export const ZOOM_STEP = 0.05 -export const ZOOM_DEFAULT = 0.9 +export const ZOOM_DEFAULT = 1 function clamp(v: number): number { if (!Number.isFinite(v)) return ZOOM_DEFAULT return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(v * 100) / 100)) } -/** 把缩放应用到 并通知图表重排, 返回 clamp 后的实际值。 */ +/** 把缩放应用到 #root 并通知图表重排, 返回 clamp 后的实际值。 */ export function applyPageZoom(v: number): number { const z = clamp(v) - document.documentElement.style.zoom = String(z) + const root = document.getElementById('root') + if (root) { + if (z === 1) { + root.style.transform = '' + root.style.width = '' + root.style.height = '' + } else { + root.style.transformOrigin = '0 0' + root.style.transform = `scale(${z})` + // 布局占位 = 视口/z, 缩放后视觉正好铺满视口; 不补偿会留白 (z<1) 或溢出 (z>1)。 + root.style.width = `${100 / z}%` + root.style.height = `${100 / z}%` + } + } window.dispatchEvent(new Event('resize')) return z } -/** 读取持久化的缩放值 (越界/缺失回落默认 0.9)。 */ +/** 当前生效的缩放 (读 #root transform 矩阵, 未缩放时 1)。 + * 浮层定位/拖拽做视觉→布局坐标换算时用。 */ +export function getPageScale(): number { + const root = document.getElementById('root') + const m = root ? getComputedStyle(root).transform : '' + if (m && m !== 'none') { + try { + const s = new DOMMatrixReadOnly(m).a + if (Number.isFinite(s) && s > 0) return s + } catch { /* matrix 解析失败按未缩放处理 */ } + } + return 1 +} + +/** 读取持久化的缩放值 (越界/缺失回落默认 1)。 */ export function getPageZoom(): number { return clamp(storage.pageZoom.get(ZOOM_DEFAULT)) }