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}