diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 9755db9..3083cf8 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -66,10 +66,12 @@ const OVER_SIDE_PX = 80 /** 拓扑预设(聊天窗恒贴右,PRD §13.2 硬约束):左右/三栏/上一下二/井字 */ const PRESET_DEFS = [ - { id: '2h', topCount: 0, contentCount: 1 }, - { id: '3h', topCount: 0, contentCount: 2 }, - { id: 't2', topCount: 1, contentCount: 1 }, - { id: 'grid', topCount: 2, contentCount: 1 }, + { id: '2h', leftCount: 0, topCount: 0, contentCount: 1 }, + { id: '3h', leftCount: 0, topCount: 0, contentCount: 2 }, + { id: 'l2', leftCount: 1, topCount: 1, contentCount: 0 }, + { id: 't2', leftCount: 0, topCount: 1, contentCount: 1 }, + { id: 'grid', leftCount: 0, topCount: 2, contentCount: 1 }, + { id: 't3', leftCount: 0, topCount: 1, contentCount: 2 }, ] as const function buildLayout(presetId: string, name: string): LayoutSpec { @@ -80,13 +82,16 @@ function buildLayout(presetId: string, name: string): LayoutSpec { min: 200, content: null, }) - const top = Array.from({ length: def.topCount }, (_, i) => mk(i)) - const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.topCount + i)) + const left = def.leftCount > 0 ? mk(0) : null + const top = Array.from({ length: def.topCount }, (_, i) => mk(def.leftCount + i)) + const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.leftCount + def.topCount + i)) return { id: 'layout-' + Date.now().toString(36), title: name, + left: left ?? null, top: top.length > 0 ? top : null, main, + leftWidth: { default: 260, min: 160, max: 480 }, chatWidth: { default: 360, min: 240, max: 600 }, topHeight: { default: 200, min: 120, max: 480 }, chatSide: 'right', @@ -112,10 +117,29 @@ function presetThumb(defId: string) { ) } + if (defId === 'grid') { + return ( + + {cell(false, 'a')}{cell(false, 'b')} + {cell(false, 'c')}{cell(true, 'd')} + + ) + } + if (defId === 'l2') { + return ( + + {cell(false, 'a')} + + {cell(false, 'b')} + {cell(true, 'c')} + + + ) + } return ( - {cell(false, 'a')}{cell(false, 'b')} - {cell(false, 'c')}{cell(true, 'd')} + {cell(false, 'a')} + {cell(false, 'b')}{cell(false, 'c')}{cell(true, 'd')} ) } @@ -225,10 +249,6 @@ function WorktableSection(props: any) { const [managing, setManaging] = useState(false) const [addOpen, setAddOpen] = useState(false) const [viewOptionsOpen, setViewOptionsOpen] = useState(false) - const [scName, setScName] = useState('') - const [scIcon, setScIcon] = useState('') - const [scHref, setScHref] = useState('') - const [scError, setScError] = useState(false) const [wsPreset, setWsPreset] = useState('2h') const [wsName, setWsName] = useState('') const [wsError, setWsError] = useState(false) @@ -535,16 +555,7 @@ function WorktableSection(props: any) { persistProjects((prev) => ({ ...prev, order: [], hidden: [], nameOverrides: {} })) } - // ── 快捷方式 ── - const addShortcut = () => { - const name = scName.trim() - const href = scHref.trim() - if (!name || !/^https?:\/\//i.test(href)) { setScError(true); return } - const shortcut: Shortcut = { id: 'sc-' + Date.now().toString(36), name, icon: scIcon.trim() || '🔗', href } - persistProjects((prev) => ({ ...prev, shortcuts: [...prev.shortcuts, shortcut] })) - setScName(''); setScIcon(''); setScHref(''); setScError(false) - } - + // ── 快捷方式(表单已移除,仅保留存量条目的删除能力) ── const removeShortcut = (id: string) => { persistProjects((prev) => ({ ...prev, shortcuts: prev.shortcuts.filter((s) => s.id !== id) })) } @@ -760,19 +771,6 @@ function WorktableSection(props: any) { {wsError &&

{t('add.layoutInvalid')}

} -
- {t('add.shortcutTitle')} -

{t('add.shortcutDesc')}

-
- { setScName(e.target.value); setScError(false) }} /> - setScIcon(e.target.value)} /> - { setScHref(e.target.value); setScError(false) }} /> - -
- {scError &&

{t('add.shortcutInvalid')}

}
)} diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index 1ea9ae2..b94529f 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -47,8 +47,10 @@ export const zh = { 'add.layoutInvalid': '请填写布局名称', 'preset.2h': '左右两栏', 'preset.3h': '三栏横排', + 'preset.l2': '左一右二', 'preset.t2': '上一下二', 'preset.grid': '井字四栏', + 'preset.t3': '上一下三', 'manage.deleteLayout': '删除布局', 'pane.browser': '浏览器', 'pane.explorer': '资源管理器', @@ -110,8 +112,10 @@ export const en = { 'add.layoutInvalid': 'Enter a layout name', 'preset.2h': 'Side by side', 'preset.3h': 'Three columns', + 'preset.l2': 'Left + stacked', 'preset.t2': 'Top + two', 'preset.grid': '2×2 grid', + 'preset.t3': 'Top + three', 'manage.deleteLayout': 'Delete layout', 'pane.browser': 'Browser', 'pane.explorer': 'Explorer', diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 3fb1d9d..4a217a4 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -23,6 +23,9 @@ export type LayoutSpec = { title: string top: SplitPane[] | null main: SplitPane[] + /** 左列整高内容窗(可选;存在时右侧列 = top 行 + 底部聊天,chatSide 固定 right) */ + left?: SplitPane | null + leftWidth?: { default: number; min: number; max: number } chatWidth: { default: number; min: number; max: number } topHeight?: { default: number; min: number; max: number } /** 聊天窗贴边位置:'right'(右列/右下,默认)| 'left'(左列/左下) */ @@ -31,7 +34,7 @@ export type LayoutSpec = { type Geom = { left: number; top: number; right: number; bottom: number } -type PaneRow = 'top' | 'main' +type PaneRow = 'left' | 'top' | 'main' type SplitState = { active: boolean @@ -39,8 +42,10 @@ type SplitState = { geom: Geom | null chatW: number topH: number + leftW: number paneWs: number[] topWs: number[] + leftWs: number[] root: HTMLElement | null header: HTMLElement | null viewArea: HTMLElement | null @@ -62,6 +67,7 @@ type SplitState = { applyMargin(): void setChatW(w: number): void setTopH(h: number): void + setLeftW(w: number): void setPaneW(i: number, w: number): void setTopW(i: number, w: number): void setPaneContent(row: PaneRow, i: number, content: SplitContent | null): void @@ -105,7 +111,7 @@ function findConversationRoot(): HTMLElement | null { ?? null } -function loadSaved(layoutId: string): { chatW: number; topH: number; paneWs: number[]; topWs: number[] } | null { +function loadSaved(layoutId: string): { chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[]; leftWs: number[] } | null { try { const raw = localStorage.getItem(PERSIST_KEY) if (!raw) return null @@ -114,15 +120,17 @@ function loadSaved(layoutId: string): { chatW: number; topH: number; paneWs: num return { chatW: Number.isFinite(s.chatW) ? s.chatW : -1, topH: Number.isFinite(s.topH) ? s.topH : -1, + leftW: Number.isFinite(s.leftW) ? s.leftW : -1, paneWs: Array.isArray(s.paneWs) ? s.paneWs : [], topWs: Array.isArray(s.topWs) ? s.topWs : [], + leftWs: Array.isArray(s.leftWs) ? s.leftWs : [], } } catch { return null } } -function persistSaved(layoutId: string, s: { chatW: number; topH: number; paneWs: number[]; topWs: number[] }) { +function persistSaved(layoutId: string, s: { chatW: number; topH: number; leftW: number; paneWs: number[]; topWs: number[]; leftWs: number[] }) { try { const raw = localStorage.getItem(PERSIST_KEY) const all = raw ? JSON.parse(raw) : {} @@ -143,8 +151,10 @@ export const splitStore: SplitState = { geom: null, chatW: 320, topH: 200, + leftW: 260, paneWs: [], topWs: [], + leftWs: [], root: null, header: null, viewArea: null, @@ -186,11 +196,14 @@ export const splitStore: SplitState = { this.spec = { ...spec, chatSide: spec.chatSide === 'left' ? 'left' : 'right' } const main = spec.main ?? [] const top = spec.top ?? [] + const left = spec.left ?? null const saved = loadSaved(spec.id) this.chatW = saved && saved.chatW >= 0 ? saved.chatW : spec.chatWidth.default this.topH = saved && saved.topH >= 0 ? saved.topH : (spec.topHeight?.default ?? 200) + this.leftW = saved && saved.leftW >= 0 ? saved.leftW : (spec.leftWidth?.default ?? 260) this.paneWs = saved && saved.paneWs.length === main.length ? [...saved.paneWs] : main.map((p) => p.min) this.topWs = saved && saved.topWs.length === top.length ? [...saved.topWs] : top.map((p) => p.min) + this.leftWs = saved && saved.leftWs.length === (left ? 1 : 0) ? [...saved.leftWs] : (left ? [left.min] : []) this.root = root this.header = header this.viewArea = viewArea @@ -288,16 +301,20 @@ export const splitStore: SplitState = { if (!viewArea || !g || !spec) return const colW = g.right - g.left const rowH = g.bottom - g.top + const hasLeft = !!spec.left const hasTop = !!(spec.top && spec.top.length > 0) const chatW = clamp(this.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60)) const topH = hasTop ? clamp(this.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80)) : 0 + const leftW = hasLeft + ? clamp(this.leftW, spec.leftWidth?.min ?? 160, Math.max(spec.leftWidth?.min ?? 160, colW - 260)) + : 0 const gap = Math.max(0, colW - chatW) + 'px' const mt = (BAR_H + topH) + 'px' - const chatLeft = spec.chatSide === 'left' - this.lastMarginLeft = chatLeft ? '' : gap - this.lastMarginRight = chatLeft ? gap : '' + const chatLeft = !hasLeft && spec.chatSide === 'left' + this.lastMarginLeft = hasLeft ? leftW + 'px' : (chatLeft ? '' : gap) + this.lastMarginRight = hasLeft ? '' : (chatLeft ? gap : '') this.lastMarginTop = mt viewArea.style.marginLeft = this.lastMarginLeft viewArea.style.marginRight = this.lastMarginRight @@ -331,6 +348,19 @@ export const splitStore: SplitState = { this.notify() }, + setLeftW(w) { + const g = this.geom + const spec = this.spec + if (!g || !spec || !spec.left) return + const colW = g.right - g.left + const lo = spec.leftWidth?.min ?? 160 + const hi = Math.max(lo, colW - 260) + this.leftW = clamp(Math.round(w), lo, hi) + this.applyMargin() + this.persist() + this.notify() + }, + setPaneW(i, w) { const g = this.geom const spec = this.spec @@ -370,6 +400,14 @@ export const splitStore: SplitState = { setPaneContent(row, i, content) { const spec = this.spec if (!spec) return + if (row === 'left') { + if (!spec.left || i !== 0) return + this.spec = { ...spec, left: { ...spec.left, content } } + this.onSpecMutated?.(this.spec) + this.persist() + this.notify() + return + } const top = [...(spec.top ?? [])] const main = [...spec.main] const arr = row === 'top' ? top : main @@ -386,28 +424,29 @@ export const splitStore: SplitState = { if (!spec) return const top = [...(spec.top ?? [])] const main = [...spec.main] - const arrA = aRow === 'top' ? top : main - const arrB = bRow === 'top' ? top : main - const a = arrA[aI] - const b = arrB[bI] - if (!a || !b) return - arrA[aI] = b - arrB[bI] = a - if (aRow === bRow) { - const ws = aRow === 'top' ? this.topWs.slice() : this.paneWs.slice() - const t = ws[aI]; ws[aI] = ws[bI]; ws[bI] = t - if (aRow === 'top') this.topWs = ws - else this.paneWs = ws - } else { - const aWs = aRow === 'top' ? this.topWs.slice() : this.paneWs.slice() - const bWs = bRow === 'top' ? this.topWs.slice() : this.paneWs.slice() - const ta = aWs[aI]; const tb = bWs[bI] - aWs[aI] = tb - bWs[bI] = ta - if (aRow === 'top') { this.topWs = aWs; this.paneWs = bWs } - else { this.topWs = bWs; this.paneWs = aWs } + const left = spec.left ? { ...spec.left } : null + const arrOf = (row: PaneRow): SplitPane[] => (row === 'left' ? (left ? [left] : []) : row === 'top' ? top : main) + const setOf = (row: PaneRow, i: number, pane: SplitPane) => { + if (row === 'left') spec.left = pane + else if (row === 'top') top[i] = pane + else main[i] = pane } - this.spec = { ...spec, top: top.length > 0 ? top : null, main } + const a = arrOf(aRow)[aI] + const b = arrOf(bRow)[bI] + if (!a || !b) return + setOf(aRow, aI, b) + setOf(bRow, bI, a) + const wsOf = (row: PaneRow): number[] => (row === 'left' ? this.leftWs : row === 'top' ? this.topWs : this.paneWs) + const setWs = (row: PaneRow, i: number, v: number) => { + if (row === 'left') { const n = this.leftWs.slice(); n[i] = v; this.leftWs = n } + else if (row === 'top') { const n = this.topWs.slice(); n[i] = v; this.topWs = n } + else { const n = this.paneWs.slice(); n[i] = v; this.paneWs = n } + } + const aW = wsOf(aRow)[aI] + const bW = wsOf(bRow)[bI] + setWs(aRow, aI, bW) + setWs(bRow, bI, aW) + this.spec = { ...spec, left: left ?? null, top: top.length > 0 ? top : null, main } this.onSpecMutated?.(this.spec) this.persist() this.notify() @@ -416,6 +455,7 @@ export const splitStore: SplitState = { setChatSide(side) { const spec = this.spec if (!spec) return + if (spec.left) return // 左列布局:聊天固定右下 this.spec = { ...spec, chatSide: side } this.onSpecMutated?.(this.spec) this.applyMargin() @@ -425,7 +465,7 @@ export const splitStore: SplitState = { persist() { if (!this.spec) return - persistSaved(this.spec.id, { chatW: this.chatW, topH: this.topH, paneWs: this.paneWs, topWs: this.topWs }) + persistSaved(this.spec.id, { chatW: this.chatW, topH: this.topH, leftW: this.leftW, paneWs: this.paneWs, topWs: this.topWs, leftWs: this.leftWs }) }, close() { @@ -474,7 +514,7 @@ function allocate(panes: SplitPane[], ws: number[], total: number) { } /** 通用分隔线拖拽(chat/top/pane/topPane) */ -function makeDividerHandler(kind: 'chat' | 'top' | 'pane' | 'topPane', index?: number) { +function makeDividerHandler(kind: 'left' | 'chat' | 'top' | 'pane' | 'topPane', index?: number) { return (e: any) => { e.preventDefault() const target = e.currentTarget as HTMLElement @@ -482,7 +522,9 @@ function makeDividerHandler(kind: 'chat' | 'top' | 'pane' | 'topPane', index?: n const onMove = (ev: PointerEvent) => { const g = splitStore.geom if (!g) return - if (kind === 'chat') { + if (kind === 'left') { + splitStore.setLeftW(ev.clientX - g.left) + } else if (kind === 'chat') { splitStore.setChatW(g.right - ev.clientX) } else if (kind === 'top') { splitStore.setTopH(ev.clientY - g.top - BAR_H) @@ -637,19 +679,26 @@ function SplitWorkspace() { const spec = snap.spec const top = spec.top ?? [] const main = spec.main ?? [] + const hasLeft = !!spec.left const hasTop = top.length > 0 - const chatLeft = spec.chatSide === 'left' + const chatLeft = !hasLeft && spec.chatSide === 'left' const colW = g.right - g.left const rowH = g.bottom - g.top const chatW = clamp(snap.chatW, spec.chatWidth.min, Math.max(spec.chatWidth.min, colW - 60)) const topH = hasTop ? clamp(snap.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80)) : 0 + const leftW = hasLeft + ? clamp(snap.leftW, spec.leftWidth?.min ?? 160, Math.max(spec.leftWidth?.min ?? 160, colW - 260)) + : 0 const contentW = Math.max(0, colW - chatW) - const contentX = chatLeft ? g.left + chatW : g.left + const contentX = hasLeft ? g.left + leftW : (chatLeft ? g.left + chatW : g.left) + const topRowX = hasLeft ? g.left + leftW : g.left + const topRowW = hasLeft ? Math.max(0, colW - leftW) : colW - const topItems = allocate(top, snap.topWs, colW) + const topItems = allocate(top, snap.topWs, topRowW) const mainItems = allocate(main, snap.paneWs, contentW) + const leftItem = spec.left ? { pane: spec.left, left: 0, width: leftW } : null const barTop = g.top const bodyTop = barTop + BAR_H + topH @@ -682,18 +731,22 @@ function SplitWorkspace() { return ( <> {/* 标题栏 */} -
+
{spec.title} - + {!hasLeft && ( + + )}
- {/* 顶部通栏行 */} - {hasTop && topItems.map((it, i) => renderPane(it, 'top', i, g.left, topY, topH))} + {/* 左列整高内容窗 */} + {leftItem && renderPane(leftItem, 'left', 0, g.left, barTop + BAR_H, g.bottom - barTop - BAR_H)} + {/* 顶部通栏行(左列布局时为右侧列顶行) */} + {hasTop && topItems.map((it, i) => renderPane(it, 'top', i, topRowX, topY, topH))} {/* 主行内容窗 */} {mainItems.map((it, i) => renderPane(it, 'main', i, contentX, bodyTop, mainH))} {/* 顶部/主行水平分隔线 */} @@ -702,7 +755,7 @@ function SplitWorkspace() { className="dsh-wt_splitDivider dsh-wt_splitDividerH" role="separator" title="拖动调整上下分区" - style={{ position: 'fixed', left: g.left, top: bodyTop - DIVIDER / 2, width: colW, height: DIVIDER, zIndex: 72 }} + style={{ position: 'fixed', left: topRowX, top: bodyTop - DIVIDER / 2, width: topRowW, height: DIVIDER, zIndex: 72 }} onPointerDown={makeDividerHandler('top')} /> )} @@ -713,7 +766,7 @@ function SplitWorkspace() { className="dsh-wt_splitDivider" role="separator" title="拖动调整宽度" - style={{ position: 'fixed', left: g.left + it.left + it.width + DIVIDER / 2, top: topY, width: DIVIDER, height: topH, zIndex: 72 }} + style={{ position: 'fixed', left: topRowX + it.left + it.width + DIVIDER / 2, top: topY, width: DIVIDER, height: topH, zIndex: 72 }} onPointerDown={makeDividerHandler('topPane', i)} /> ))} @@ -728,13 +781,20 @@ function SplitWorkspace() { onPointerDown={makeDividerHandler('pane', i)} /> ))} - {/* 聊天分隔线(主行内容与聊天窗之间) */} + {/* 聊天分隔线(左列布局 = 左/右列边界;其余 = 内容与聊天之间) */}
) diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts index b9ecafd..58f8938 100644 --- a/01_content/src/client/styles.ts +++ b/01_content/src/client/styles.ts @@ -83,12 +83,14 @@ export const css = [ '.dsh-wt_splitDividerH{cursor:row-resize}', '.dsh-wt_splitDivider:hover,.dsh-wt_splitDividerH:hover{background:var(--dsw-alias-state-accent-primary,#4f8ef7)}', // 新建工作区(拓扑预设) - '.dsh-wt_presets{display:flex;gap:6px;padding:2px 8px 6px}', + '.dsh-wt_presets{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;padding:2px 8px 6px}', '.dsh-wt_preset{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:6px 2px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:10px;line-height:13px;cursor:pointer}', '.dsh-wt_preset:hover{border-color:var(--dsw-alias-border-l2,#3a4150)}', '.dsh-wt_preset[data-on=true]{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', '.dsh-wt_thumb{display:flex;flex-direction:column;width:54px;height:36px;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:3px;overflow:hidden;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03))}', '.dsh-wt_thumbRow{flex:1;display:flex;min-height:0}', + '.dsh-wt_thumbCols{flex-direction:row}', + '.dsh-wt_thumbCol{flex:1;min-width:0;display:flex;flex-direction:column}', '.dsh-wt_thumbRow:last-child .dsh-wt_thumbCell{border-bottom:none}', '.dsh-wt_thumbCell{flex:1;min-width:0;border-right:1px solid var(--dsw-alias-border-l1,#262b36);border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);display:flex;align-items:center;justify-content:center;font-size:8px;line-height:1}', '.dsh-wt_thumbRow .dsh-wt_thumbCell:last-child{border-right:none}', diff --git a/02_process/PRD.md b/02_process/PRD.md index bf17003..8e7319a 100644 --- a/02_process/PRD.md +++ b/02_process/PRD.md @@ -332,9 +332,11 @@ type SplitPane = { ### 13.5 「+」面板改版 - 「+」点击后**向右侧弹出悬浮面板**(fixed 锚定 sidebar 右边缘与工作台区块顶部,320px 宽、 - 视口内钳制;透明遮罩点击关闭),不再使用侧边栏内展开式下拉; - 两个入口:1. **新建工作区**:可视化拓扑缩略图选择 → 只填布局名称 → 进入; - 2. **快捷方式**:本地条目功能保留(§5.6,接入指引已移除); + 视口内钳制;透明遮罩点击关闭),不再使用侧边栏内展开式下拉;面板内容仅「选择布局 + 填名称」 + (快捷方式表单暂移除,存量快捷方式条目仍保留展示/删除); +- **拓扑预设六个**(2 行 × 3 列缩略图网格,聊天窗蓝色 💬 标注): + 左右两栏 / 三栏横排 / 左一右二 / 上一下二 / 井字四栏 / 上一下三(2026-08-16 定案); + 左一右二 = 左列整高内容窗 + 右列「上内容 + 下聊天」(左列布局聊天固定右下,无 ⇄ 翻转); - 保存的布局以「布局条目」形式出现在项目区(布局 = 一种工作台项目),点击打开 tiling 工作区, 再次点击 / ✕ / Esc 关闭。 diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index edcc36a..cee1856 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -310,3 +310,17 @@ top = 工作台区块顶部(钳制 56..视口-540);内容不变(布局缩略图 → 名称 → 进入 / 快捷方式表单);样式 .dsh-wt_pop 限高滚动 + 阴影。 - 构建 + node --check 通过;服务端实时下发(含 popBackdrop/dsh-wt_pop),无需重启,用户刷新生效。 + +## 补记(+ 弹窗精简 + 布局扩为 6 个 + 左列布局引擎支持) + +- 用户定案:① + 弹窗去掉「本地快捷方式」表单(只留布局选择 + 名称;存量快捷方式条目仍保留); + ② 布局扩为 6 个:新增「左一右二」(左侧一个整高窗,右侧竖排两个,右下为聊天)与 + 「上一下三」(上面一个通栏,下面横分三个,最右为聊天);6 个布局 2 行 × 3 列排布。 +- 引擎(split.tsx):LayoutSpec 新增 left(左列整高内容窗,可选)+ leftWidth;左列布局下 + 聊天固定右下(marginLeft=leftW + marginTop 组合挤法),⇄ 翻转按钮隐藏;新增 setLeftW 与 + 'left' 分隔线(左/右列边界拖拽);setPaneContent/swapPanes 泛化支持 left 行;leftW 持久化。 +- 预设与 UI(index.tsx/locales/styles):PRESET_DEFS 增 leftCount 字段与 l2/t3 两项; + 缩略图新增左列样式(thumbCols/thumbCol);预设网格 3 列;弹窗移除快捷方式表单及对应状态/函数 + (removeShortcut 保留);词典新增 preset.l2/preset.t3。 +- 验证:构建 + node --check 通过;服务端实时下发(含 preset.l2/leftWidth/setLeftW); + 待用户刷新实测。