From 11d49bb971910ed86f45f94de6ae3d241a9b3f70 Mon Sep 17 00:00:00 2001 From: wuxs Date: Mon, 27 Jul 2026 01:23:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(console-ui):=20macOS=20=E9=A3=8E=E6=A0=BC?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E7=AA=97=E5=8F=A3=E5=8C=96=EF=BC=88=E6=8B=96?= =?UTF-8?q?=E6=8B=BD/=E7=BC=A9=E6=94=BE/=E5=A4=9A=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=B9=B6=E5=AD=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 默认仍最大化;点最大化按钮或双击标题栏 restore 成浮动窗口后,可自由拖拽、四边四角缩放 - 多窗口同时可见并层叠,点击任意窗口置顶聚焦(聚焦窗口阴影更深,便于区分活动窗口) - z-order 用自增计数器;窗口几何走纯像素 state(left/top),与 minimize 的 transform x/y 分属不同 CSS 通道,互不干扰 - 拖拽/缩放期间盖全屏透明覆盖层(portal 到 body),屏蔽 iframe 吞掉 pointermove,浏览器类应用拖拽不卡 - 标题栏按钮精确作用于自身窗口(minimizeApp / closeApp by id),多窗口可见下不错对象 - Dock 行为不变:最大化时隐藏、浮动时显示 - minimize genie 飞回动画、reduced-motion 降级均保留 Co-Authored-By: Claude Opus 4.8 (1M context) --- console-ui/src/App.jsx | 121 ++++++++++++--- console-ui/src/components/AppWindow.jsx | 186 ++++++++++++++++++------ 2 files changed, 241 insertions(+), 66 deletions(-) diff --git a/console-ui/src/App.jsx b/console-ui/src/App.jsx index eadca0a..b6b5575 100644 --- a/console-ui/src/App.jsx +++ b/console-ui/src/App.jsx @@ -108,6 +108,38 @@ const PRESETS = { function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } +// ─── 自由窗口几何 ───────────────────────────────────────────── +// MIN_W/MIN_H :窗口最小可缩放尺寸;TITLEBAR_H :标题栏高度(用于 clamp y 上限,保证总可见可抓) +const MIN_W = 420, MIN_H = 300, TITLEBAR_H = 44; +// 级联偏移:每开一个新窗口往右下挪一点,回绕避免无限偏移 +const CASCADE_STEP = 28, CASCADE_MAX = 6; + +// 新窗口的默认浮动几何(基于已开窗口数级联) +function defaultGeo(openCount, vw, vh) { + const w = Math.min(1280, Math.round(vw * 0.72)); + const h = Math.min(820, Math.round(vh * 0.74)); + const k = openCount % CASCADE_MAX; + return { + x: clamp(60 + k * CASCADE_STEP, 0, Math.max(0, vw - w)), + y: clamp(40 + k * CASCADE_STEP, 0, Math.max(0, vh - h - TITLEBAR_H)), + w, h, + }; +} + +// 最大化几何:铺满主内容区 +function fullscreenGeo(vw, vh) { return { x: 0, y: 0, w: vw, h: vh }; } + +// 把任意几何夹进可行域:尺寸 ∈ [MIN, 视口];x 允许部分出屏但至少留 80px 可抓; +// y 保证标题栏始终在视口内(不被顶栏盖、也不出底) +function clampGeo(g, vw, vh) { + const w = clamp(g.w, MIN_W, Math.max(MIN_W, vw)); + const h = clamp(g.h, MIN_H, Math.max(MIN_H, vh)); + const xLo = -(w - 80), xHi = vw - 80; + const x = clamp(g.x, Math.min(xLo, xHi), Math.max(xLo, xHi)); + const y = clamp(g.y, 0, Math.max(0, vh - TITLEBAR_H)); + return { x, y, w, h }; +} + export default function App() { const [loggedIn, setLoggedIn] = useState(() => !!getAuthToken()); const [loginUser, setLoginUser] = useState(''); @@ -167,6 +199,45 @@ export default function App() { else dockIconRects.current.delete(id); }, []); const getDockIconRect = useCallback((id) => dockIconRects.current.get(id)?.getBoundingClientRect(), []); + + // ─── 自由窗口化状态 ─────────────────────────────────────────── + // geoByApp : 每个窗口浮动(framed)态的像素几何 {x,y,w,h};最大化时不用 + // zByApp : 每个窗口的 z-index,bringToFront 时自增取栈顶 + // interactingId: 当前正被拖拽/缩放的 appId → 切到「即时跟手」transition + const [geoByApp, setGeoByApp] = useState({}); + const [zByApp, setZByApp] = useState({}); + const zCounter = useRef(10); + const [interactingId, setInteractingId] = useState(null); + + // 主内容区(窗口的 offsetParent)尺寸:默认几何与 clamp 都依赖它 + const stageRef = useRef(null); + const [stageSize, setStageSize] = useState(() => ({ vw: window.innerWidth, vh: window.innerHeight })); + useEffect(() => { + const measure = () => { + const r = stageRef.current?.getBoundingClientRect?.(); + if (r && r.width > 0) setStageSize({ vw: r.width, vh: r.height }); + }; + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, []); + + // 置顶聚焦:设为 active + 自增 z-index + const bringToFront = useCallback((id) => { + if (!id) return; + setActiveId(id); + zCounter.current += 1; + setZByApp(z => ({ ...z, [id]: zCounter.current })); + }, []); + + // 拖拽 / 缩放回写几何(夹进可行域) + const setGeoFor = useCallback((id, partial) => { + setGeoByApp(g => { + const cur = g[id]; + if (!cur) return g; + return { ...g, [id]: clampGeo({ ...cur, ...partial }, stageSize.vw, stageSize.vh) }; + }); + }, [stageSize.vw, stageSize.vh]); const maximized = activeId ? (maxByApp[activeId] ?? true) : true; const setMaximized = (next) => { if (!activeId) return; @@ -193,7 +264,9 @@ export default function App() { const next = new Set(prev); next.delete(app.id); return next; }); setMaxByApp(m => (app.id in m) ? m : { ...m, [app.id]: true }); - setActiveId(app.id); + // 预分配浮动几何(首次 restore 时用);按已开窗口数级联,避免完全重叠 + setGeoByApp(g => (app.id in g) ? g : { ...g, [app.id]: defaultGeo(openApps.length, stageSize.vw, stageSize.vh) }); + bringToFront(app.id); setMgmtOpen(false); }; @@ -212,27 +285,14 @@ export default function App() { const n = new Set(prev); n.delete(id); return n; }); // Don't touch maxByApp — restore whatever mode the window was in. - setActiveId(id); + bringToFront(id); setMgmtOpen(false); }; - // Minimize the active window — keep the app in the dock - const minimizeWindow = () => { - if (!activeId) return; - setMinimized(prev => { const n = new Set(prev); n.add(activeId); return n; }); - setActiveId(null); - setMgmtOpen(false); - }; - - // Close window: remove the app from the running set - const closeWindow = () => { - if (!activeId) return; - const id = activeId; - setOpenApps(p => p.filter(x => x !== id)); - setMinimized(prev => { const n = new Set(prev); n.delete(id); return n; }); - setMaxByApp(m => { if (!(id in m)) return m; const n = { ...m }; delete n[id]; return n; }); - setActiveId(null); - setMgmtOpen(false); + // Minimize a specific window by id — 多窗口可见时,标题栏按钮须精确作用于自身 + const minimizeApp = (id) => { + setMinimized(prev => { const n = new Set(prev); n.add(id); return n; }); + if (activeId === id) { setActiveId(null); setMgmtOpen(false); } }; // Close any open app (e.g. from dock right-click / hover X) @@ -240,6 +300,8 @@ export default function App() { setOpenApps(p => p.filter(x => x !== id)); setMinimized(prev => { const n = new Set(prev); n.delete(id); return n; }); setMaxByApp(m => { if (!(id in m)) return m; const n = { ...m }; delete n[id]; return n; }); + setGeoByApp(g => { if (!(id in g)) return g; const n = { ...g }; delete n[id]; return n; }); + setZByApp(z => { if (!(id in z)) return z; const n = { ...z }; delete n[id]; return n; }); if (activeId === id) { setActiveId(null); setMgmtOpen(false); } }; @@ -375,7 +437,7 @@ export default function App() { {/* Main content area */} -
+
{ @@ -403,8 +465,11 @@ export default function App() { {openApps.map(appId => { const app = appById[appId]; if (!app) return null; - const isVisible = activeId === appId && !minimized.has(appId); + // 多窗口并存:所有未最小化的窗口都可见、可层叠;activeId 只决定谁在最前 / 高亮 + const isVisible = !minimized.has(appId); const isMax = maxByApp[appId] ?? true; + const geo = (isMax ? fullscreenGeo(stageSize.vw, stageSize.vh) : geoByApp[appId]) + || defaultGeo(0, stageSize.vw, stageSize.vh); return ( setMaxByApp(m => ({ ...m, [appId]: !(m[appId] ?? true) }))} - onMinimize={minimizeWindow} - onClose={closeWindow} + onMaximize={() => { bringToFront(appId); setMaxByApp(m => ({ ...m, [appId]: !(m[appId] ?? true) })); }} + onMinimize={() => minimizeApp(appId)} + onClose={() => closeApp(appId)} + onBringToFront={() => bringToFront(appId)} + onChangeGeo={(partial) => setGeoFor(appId, partial)} + onInteractStart={() => setInteractingId(appId)} + onInteractEnd={() => setInteractingId(null)} canManage={app.kind === 'app'} mgmtOpen={activeId === appId && mgmtOpen} onToggleMgmt={() => setMgmtOpen(o => !o)} diff --git a/console-ui/src/components/AppWindow.jsx b/console-ui/src/components/AppWindow.jsx index 902b76c..0ae4520 100644 --- a/console-ui/src/components/AppWindow.jsx +++ b/console-ui/src/components/AppWindow.jsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { createPortal } from 'react-dom' import { T } from '../tokens' import { Icon } from '../icons' import { motion, springs, useMotionPref } from '../motion' @@ -21,14 +22,26 @@ export const btnDanger = { }; const WINDOW_SHADOW = '0 24px 60px -12px rgba(15,23,42,0.32), 0 0 0 1px rgba(15,23,42,0.08)'; +const WINDOW_SHADOW_DIM = '0 10px 28px -14px rgba(15,23,42,0.20), 0 0 0 1px rgba(15,23,42,0.05)'; const WINDOW_SHADOW_CLEAR = '0 24px 60px -12px rgba(15,23,42,0), 0 0 0 1px rgba(15,23,42,0)'; -function windowFrame(maximized) { - return maximized - ? { top: '0%', left: '0%', right: '0%', bottom: '0%', borderRadius: 0, boxShadow: WINDOW_SHADOW_CLEAR } - : { top: '5.5%', left: '7.5%', right: '7.5%', bottom: '12%', borderRadius: 14, boxShadow: WINDOW_SHADOW }; -} +// 窗口最小可缩放尺寸(与 App.jsx 的 clampGeo 保持一致) +const MIN_W = 420, MIN_H = 300; + +// 8 向 resize handle:贴窗口边缘/角,cursor 按方向 +// 边 handle 留出 10px 给角;角 handle 12×12 +const HANDLES = [ + { dir: 'n', css: { top: 0, left: 10, right: 10, height: 6 }, cursor: 'ns-resize' }, + { dir: 's', css: { bottom: 0, left: 10, right: 10, height: 6 }, cursor: 'ns-resize' }, + { dir: 'e', css: { right: 0, top: 10, bottom: 10, width: 6 }, cursor: 'ew-resize' }, + { dir: 'w', css: { left: 0, top: 10, bottom: 10, width: 6 }, cursor: 'ew-resize' }, + { dir: 'ne', css: { top: 0, right: 0, width: 12, height: 12 }, cursor: 'nesw-resize' }, + { dir: 'nw', css: { top: 0, left: 0, width: 12, height: 12 }, cursor: 'nwse-resize' }, + { dir: 'se', css: { bottom: 0, right: 0, width: 12, height: 12 }, cursor: 'nwse-resize' }, + { dir: 'sw', css: { bottom: 0, left: 0, width: 12, height: 12 }, cursor: 'nesw-resize' }, +]; +// 最小化飞回 Dock 图标的 transform 偏移(genie 效果) function dockOffset(appId, windowNode, getDockIconRect) { if (!windowNode) return { x: 0, y: 0 }; @@ -49,12 +62,23 @@ function dockOffset(appId, windowNode, getDockIconRect) { }; } -export default function AppWindow({ app, active = false, visible = true, minimized = false, maximized, getDockIconRect, onMaximize, onMinimize, onClose, children, headerActions, breadcrumb, mgmtOpen, onToggleMgmt, canManage }) { +export default function AppWindow({ + app, active = false, visible = true, minimized = false, maximized, + geo, zIndex = 10, interacting, + getDockIconRect, onMaximize, onMinimize, onClose, onBringToFront, + onChangeGeo, onInteractStart, onInteractEnd, + children, headerActions, breadcrumb, mgmtOpen, onToggleMgmt, canManage, +}) { const pref = useMotionPref(); const [windowNode, setWindowNode] = useState(null); + const [overlayCursor, setOverlayCursor] = useState('default'); + // 防御:geo 万一没传,给个兜底(父级已 fallback,这里双保险) + const g = geo || { x: 40, y: 40, w: 820, h: 560 }; + + // traffic 按钮:onPointerDown stop 防冒泡到标题栏触发拖拽 const trafficBtn = (color, onClick, icon, className = 'edge-btn-secondary') => ( -
e.stopPropagation()} className={`edge-press ${className}`} style={{ width: 22, height: 22, borderRadius: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color, transition: 'background 0.15s, color 0.15s', @@ -63,12 +87,17 @@ export default function AppWindow({ app, active = false, visible = true, minimiz
); - const frame = windowFrame(maximized); + // minimize genie:transform x/y 偏移到 dock 图标 + 缩小 + // 注意:拖拽改的是 left/top,最小化改的是 transform x/y —— 两个 CSS 通道互不干扰 const minimizeOffset = minimized ? dockOffset(app.id, windowNode, getDockIconRect) : { x: 0, y: 0 }; + const activeTarget = { - ...frame, + left: g.x, + top: g.y, + width: g.w, + height: g.h, opacity: visible ? 1 : 0, visibility: 'visible', transitionEnd: visible ? { visibility: 'visible' } : { visibility: 'hidden' }, @@ -79,52 +108,118 @@ export default function AppWindow({ app, active = false, visible = true, minimiz }), }; + // 拖拽/缩放期间:几何走 duration:0(精确跟手);否则走 spring(maximize/restore 丝滑过渡) + const geoTransition = interacting + ? { left: { duration: 0 }, top: { duration: 0 }, width: { duration: 0 }, height: { duration: 0 } } + : { left: springs.gentle, top: springs.gentle, width: springs.gentle, height: springs.gentle }; const transition = pref.reduced ? { opacity: pref.fadeTransition, - top: { duration: 0 }, - left: { duration: 0 }, - right: { duration: 0 }, - bottom: { duration: 0 }, - borderRadius: { duration: 0 }, - boxShadow: { duration: 0 }, + left: { duration: 0 }, top: { duration: 0 }, + width: { duration: 0 }, height: { duration: 0 }, + borderRadius: { duration: 0 }, boxShadow: { duration: 0 }, } : { - top: springs.gentle, - left: springs.gentle, - right: springs.gentle, - bottom: springs.gentle, - borderRadius: springs.gentle, - boxShadow: springs.gentle, - x: springs.default, - y: springs.default, - scale: springs.default, + ...geoTransition, + x: springs.default, y: springs.default, scale: springs.default, opacity: { duration: 0.2 }, }; + // ─── 拖拽(标题栏) ─── 直接改 geo.x/y(→ left/top) + const startDrag = (e) => { + if (maximized || e.button !== 0) return; + onBringToFront?.(); + onInteractStart?.(); + setOverlayCursor('grabbing'); + const sx = e.clientX, sy = e.clientY; + const orig = { x: g.x, y: g.y }; + const onMove = (ev) => { + onChangeGeo?.({ x: orig.x + (ev.clientX - sx), y: orig.y + (ev.clientY - sy) }); + }; + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + setOverlayCursor('default'); + onInteractEnd?.(); + window.dispatchEvent(new Event('resize')); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }; + + // ─── 缩放(8 向) ─── 左/上方向同步移动 x/y;尺寸触底后位置不再变(更接近原生 WM) + const startResize = (dir, cursor) => (e) => { + if (maximized || e.button !== 0) return; + onBringToFront?.(); + onInteractStart?.(); + setOverlayCursor(cursor); + const sx = e.clientX, sy = e.clientY; + const orig = { x: g.x, y: g.y, w: g.w, h: g.h }; + const onMove = (ev) => { + const dx = ev.clientX - sx, dy = ev.clientY - sy; + const next = { x: orig.x, y: orig.y, w: orig.w, h: orig.h }; + if (dir.includes('e')) next.w = Math.max(MIN_W, orig.w + dx); + if (dir.includes('s')) next.h = Math.max(MIN_H, orig.h + dy); + if (dir.includes('w')) { + const nw = Math.max(MIN_W, orig.w - dx); + next.x = orig.x + (orig.w - nw); + next.w = nw; + } + if (dir.includes('n')) { + const nh = Math.max(MIN_H, orig.h - dy); + next.y = orig.y + (orig.h - nh); + next.h = nh; + } + onChangeGeo?.(next); + }; + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + setOverlayCursor('default'); + onInteractEnd?.(); + window.dispatchEvent(new Event('resize')); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }; + return ( - { - if (visible) window.dispatchEvent(new Event('resize')); - }} + onPointerDown={() => onBringToFront?.()} + onAnimationComplete={() => { if (visible) window.dispatchEvent(new Event('resize')); }} style={{ - position: 'absolute', - ...frame, - background: T.windowBg, - display: 'flex', flexDirection: 'column', overflow: 'hidden', - zIndex: active ? 21 : 20, - pointerEvents: visible ? 'auto' : 'none', - }}> - {/* Title bar */} -
+ + {/* 拖拽/缩放期间的全屏覆盖层:屏蔽 iframe 吞掉 pointermove(Browser 等应用拖拽关键)。 + portal 到 body —— 脱离本窗口的 transform 上下文,fixed 才能真正覆盖整个视口。 */} + {interacting && createPortal( +
, + document.body + )} + + {/* 标题栏:拖拽 + 双击最大化 */} +
{headerActions} {canManage && ( -
+ {/* 8 向 resize handle —— 最大化时隐藏 */} + {!maximized && HANDLES.map(h => ( +
+ ))} + {/* Body */}
{children}