feat: + 面板可视化布局选择 + 窗内6选1内容 + 聊天左右切换与窗位拖换

This commit is contained in:
Aisland
2026-08-17 01:19:03 +08:00
parent 36c561fa7e
commit 573eea3be9
6 changed files with 395 additions and 99 deletions
+73 -56
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { css } from './styles'
import { NS, zh, en, type WorktableKey } from './locales'
import { splitStore, SplitWorkspace, type LayoutSpec } from './split'
import { splitStore, SplitWorkspace, setSplitT, type LayoutSpec, type SplitPane } from './split'
/**
* dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。
@@ -72,20 +72,16 @@ const PRESET_DEFS = [
{ id: 'grid', topCount: 2, contentCount: 1 },
] as const
function presetTotal(def: { topCount: number; contentCount: number }): number {
return def.topCount + def.contentCount
}
function buildLayout(presetId: string, name: string, urls: string[]): LayoutSpec {
function buildLayout(presetId: string, name: string): LayoutSpec {
const def = PRESET_DEFS.find((d) => d.id === presetId) ?? PRESET_DEFS[0]
const mk = (i: number, url: string) => ({
const mk = (i: number): SplitPane => ({
id: 'p' + (i + 1),
title: '内容' + (i + 1),
min: 200,
content: { kind: 'iframe' as const, url },
content: null,
})
const top = Array.from({ length: def.topCount }, (_, i) => mk(i, urls[i] ?? 'about:blank'))
const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.topCount + i, urls[def.topCount + i] ?? 'about:blank'))
const top = Array.from({ length: def.topCount }, (_, i) => mk(i))
const main = Array.from({ length: def.contentCount }, (_, i) => mk(def.topCount + i))
return {
id: 'layout-' + Date.now().toString(36),
title: name,
@@ -93,9 +89,37 @@ function buildLayout(presetId: string, name: string, urls: string[]): LayoutSpec
main,
chatWidth: { default: 360, min: 240, max: 600 },
topHeight: { default: 200, min: 120, max: 480 },
chatSide: 'right',
}
}
/** 布局缩略图(迷你窗格示意;聊天窗蓝色 💬) */
function presetThumb(defId: string) {
const cell = (chat: boolean, key: string) => (
<span key={key} className={'dsh-wt_thumbCell' + (chat ? ' dsh-wt_thumbChat' : '')}>{chat ? '💬' : ''}</span>
)
if (defId === '2h') {
return <span className="dsh-wt_thumb"><span className="dsh-wt_thumbRow">{cell(false, 'a')}{cell(true, 'b')}</span></span>
}
if (defId === '3h') {
return <span className="dsh-wt_thumb"><span className="dsh-wt_thumbRow">{cell(false, 'a')}{cell(false, 'b')}{cell(true, 'c')}</span></span>
}
if (defId === 't2') {
return (
<span className="dsh-wt_thumb">
<span className="dsh-wt_thumbRow">{cell(false, 'a')}</span>
<span className="dsh-wt_thumbRow">{cell(false, 'b')}{cell(true, 'c')}</span>
</span>
)
}
return (
<span className="dsh-wt_thumb">
<span className="dsh-wt_thumbRow">{cell(false, 'a')}{cell(false, 'b')}</span>
<span className="dsh-wt_thumbRow">{cell(false, 'c')}{cell(true, 'd')}</span>
</span>
)
}
const DEFAULT_VIEW: ViewState = {
query: '',
searchOpen: false,
@@ -207,7 +231,6 @@ function WorktableSection(props: any) {
const [scError, setScError] = useState(false)
const [wsPreset, setWsPreset] = useState<string>('2h')
const [wsName, setWsName] = useState('')
const [wsUrls, setWsUrls] = useState<string[]>(['', '', ''])
const [wsError, setWsError] = useState(false)
const [float, setFloat] = useState<FloatRect | null>(() =>
view.dock === 'float' && view.floatTop != null ? { top: view.floatTop } : null,
@@ -252,6 +275,20 @@ function WorktableSection(props: any) {
setActiveSplitId(splitStore.active && splitStore.spec ? splitStore.spec.id : null)
}), [])
// 引擎内 spec 变更(窗内容/聊天位置/窗位互换)→ 回写布局条目持久化
useEffect(() => {
splitStore.onSpecMutated = (spec) => {
persistProjects((prev) => ({ ...prev, layouts: prev.layouts.map((l) => (l.id === spec.id ? spec : l)) }))
}
return () => { splitStore.onSpecMutated = null }
}, [])
// 分栏引擎 UI 文案(窗选择器等)
useEffect(() => {
setSplitT((k) => t(k as WorktableKey))
return () => setSplitT(null)
}, [t])
// 侧边栏折叠/展开:保持原停靠位置(不再回弹 footer);折叠态由项目图标框承载。
// 折叠且浮动时:等折叠动画结束(320/750ms 双次重测,取收敛后的几何)再以 fixed 定位
// 图标框到拖前高度——避免在过渡帧上测得展开态宽度导致图标框偏离窄栏中心。
@@ -513,13 +550,10 @@ function WorktableSection(props: any) {
// ── 布局(新建工作区) ──
const saveLayout = () => {
const name = wsName.trim()
const def = PRESET_DEFS.find((d) => d.id === wsPreset) ?? PRESET_DEFS[0]
const total = presetTotal(def)
const urls = wsUrls.slice(0, total).map((u) => u.trim())
if (!name || urls.some((u) => !/^(\/|https?:\/\/)/i.test(u))) { setWsError(true); return }
const layout = buildLayout(wsPreset, name, urls)
if (!name) { setWsError(true); return }
const layout = buildLayout(wsPreset, name)
persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout] }))
setWsName(''); setWsUrls(['', '', '']); setWsError(false)
setWsName(''); setWsError(false)
setAddOpen(false)
openSplit(layout)
reportUsed(layout.id)
@@ -695,11 +729,28 @@ function WorktableSection(props: any) {
{addOpen && (
<div className="dsh-wt_menu dsh-wt_add">
<span className="dsh-wt_menuLabel">{t('add.guideTitle')}</span>
<p className="dsh-wt_addText">{t('add.guideBody')}</p>
<a className="dsh-wt_menuItem dsh-wt_addLink" href={MARKET_URL} target="_blank" rel="noreferrer noopener">
{t('add.market')} ↗
</a>
<span className="dsh-wt_menuLabel">{t('add.chooseLayout')}</span>
<div className="dsh-wt_presets">
{PRESET_DEFS.map((def) => (
<button
key={def.id}
type="button"
className="dsh-wt_preset"
data-on={wsPreset === def.id ? 'true' : 'false'}
onClick={() => { setWsPreset(def.id); setWsError(false) }}
>
{presetThumb(def.id)}
<span className="dsh-wt_presetLabel">{t('preset.' + def.id)}</span>
</button>
))}
</div>
<div className="dsh-wt_addForm">
<input type="text" placeholder={t('add.layoutNamePh')} value={wsName}
onChange={(e) => { setWsName(e.target.value); setWsError(false) }} />
<button type="button" className="dsh-wt_addBtn" onClick={saveLayout}>{t('add.layoutSave')}</button>
</div>
{wsError && <p className="dsh-wt_addError">{t('add.layoutInvalid')}</p>}
<div className="dsh-wt_menuSep" />
<span className="dsh-wt_menuLabel">{t('add.shortcutTitle')}</span>
<p className="dsh-wt_addText">{t('add.shortcutDesc')}</p>
<div className="dsh-wt_addForm">
@@ -712,40 +763,6 @@ function WorktableSection(props: any) {
<button type="button" className="dsh-wt_addBtn" onClick={addShortcut}>{t('add.shortcutAdd')}</button>
</div>
{scError && <p className="dsh-wt_addError">{t('add.shortcutInvalid')}</p>}
<div className="dsh-wt_menuSep" />
<span className="dsh-wt_menuLabel">{t('add.workspaceTitle')}</span>
<p className="dsh-wt_addText">{t('add.workspaceDesc')}</p>
<div className="dsh-wt_presets">
{PRESET_DEFS.map((def) => (
<button
key={def.id}
type="button"
className="dsh-wt_preset"
data-on={wsPreset === def.id ? 'true' : 'false'}
onClick={() => { setWsPreset(def.id); setWsError(false) }}
>
{t('preset.' + def.id)}
</button>
))}
</div>
<div className="dsh-wt_addForm">
<input type="text" placeholder={t('add.layoutNamePh')} value={wsName}
onChange={(e) => { setWsName(e.target.value); setWsError(false) }} />
{Array.from({ length: presetTotal(PRESET_DEFS.find((d) => d.id === wsPreset) ?? PRESET_DEFS[0]) }, (_, i) => (
<input
key={i}
type="text"
placeholder={t('add.paneUrlPh')}
value={wsUrls[i] ?? ''}
onChange={(e) => {
setWsUrls((prev) => { const next = [...prev]; next[i] = e.target.value; return next })
setWsError(false)
}}
/>
))}
<button type="button" className="dsh-wt_addBtn" onClick={saveLayout}>{t('add.layoutSave')}</button>
</div>
{wsError && <p className="dsh-wt_addError">{t('add.layoutInvalid')}</p>}
</div>
)}
+28 -10
View File
@@ -41,17 +41,26 @@ export const zh = {
'shortcut.badge': '本地',
'layout.badge': '布局',
'layout.desc': '{n} 内容窗 · 对话',
'add.workspaceTitle': '新建工作区',
'add.workspaceDesc': '选一个拓扑,填好各窗内容,保存为工作台布局(其中一窗恒为对话)。',
'add.chooseLayout': '选择布局模式',
'add.layoutNamePh': '布局名称',
'add.paneUrlPh': '内容地址(/xxx/ 或 https://)',
'add.layoutSave': '保存并打开',
'add.layoutInvalid': '请填写布局名称,且每个内容地址以 / 或 http(s) 开头',
'add.layoutSave': '进入工作区',
'add.layoutInvalid': '请填写布局名称',
'preset.2h': '左右两栏',
'preset.3h': '三栏横排',
'preset.t2': '上一下二',
'preset.grid': '井字四栏',
'manage.deleteLayout': '删除布局',
'pane.browser': '浏览器',
'pane.explorer': '资源管理器',
'pane.scm': '源代码管理',
'pane.tasks': '任务管理',
'pane.terminal': '终端',
'pane.custom': '自定义',
'pane.customUrlPh': '地址(/xxx/ 或 https://)',
'pane.open': '打开',
'pane.wip': '开发中(后续版本接入)',
'split.flip': '切换对话窗位置(左⇄右)',
'split.dragSwap': '拖动标题栏与其他窗交换位置',
'empty': '暂无项目',
} satisfies Record<string, string>
@@ -95,17 +104,26 @@ export const en = {
'shortcut.badge': 'Local',
'layout.badge': 'Layout',
'layout.desc': '{n} content panes · chat',
'add.workspaceTitle': 'New workspace',
'add.workspaceDesc': 'Pick a topology, fill in the pane contents, save as a worktable layout (one pane is always the chat).',
'add.chooseLayout': 'Choose a layout',
'add.layoutNamePh': 'Layout name',
'add.paneUrlPh': 'Content URL (/xxx/ or https://)',
'add.layoutSave': 'Save & open',
'add.layoutInvalid': 'Enter a layout name, and each content URL must start with / or http(s)',
'add.layoutSave': 'Enter workspace',
'add.layoutInvalid': 'Enter a layout name',
'preset.2h': 'Side by side',
'preset.3h': 'Three columns',
'preset.t2': 'Top + two',
'preset.grid': '2×2 grid',
'manage.deleteLayout': 'Delete layout',
'pane.browser': 'Browser',
'pane.explorer': 'Explorer',
'pane.scm': 'Source control',
'pane.tasks': 'Tasks',
'pane.terminal': 'Terminal',
'pane.custom': 'Custom',
'pane.customUrlPh': 'URL (/xxx/ or https://)',
'pane.open': 'Open',
'pane.wip': 'Coming in a later version',
'split.flip': 'Flip chat side (left ⇄ right)',
'split.dragSwap': 'Drag the title bar to swap panes',
'empty': 'No projects yet',
} satisfies Record<WorktableKey, string>
+243 -28
View File
@@ -2,29 +2,37 @@ import { useEffect, useState } from 'react'
/**
* dsh-worktable 乐高式工作区 M1:通用分栏引擎(PRD §13)。
* 布局模型:标题栏 + 顶部通栏行(可选)+ 主行(内容窗,从左到右)+ 右下聊天窗(官方会话视图区整体)。
* 聊天窗几何 = marginLeft + marginTop 组合挤法;会话切换重新锚定(§12.4,切会话不关闭)。
* 宽度持久化:dsh.worktable.split.v1 = { [layoutId]: { chatW, topH, paneWs, topWs } }。
* 布局模型:标题栏 + 顶部通栏行(可选) + 主行内容窗 + 聊天窗(官方会话视图区整体,
* 贴右或贴左,由 chatSide 决定;marginLeft/marginRight + marginTop 组合挤法)。
* 内容三态:null(未指派 → 6 选 1 选择器)/ iframe / builtin(浏览器/资源管理器/SCM/任务/终端)。
* 窗位调整:标题栏拖拽换位(同行或跨行);工具栏 ⇄ 切换聊天窗左右。
* 会话切换重新锚定不关闭;宽度按 layoutId 持久化 dsh.worktable.split.v1;
* 内容与 chatSide 的变更经 onSpecMutated 回调交给工作台持久化(布局条目)。
*/
export type SplitContent = { kind: 'iframe'; url: string }
export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal'
export type SplitPane = { id: string; title: string; min: number; content: SplitContent }
export type SplitContent =
| { kind: 'iframe'; url: string }
| { kind: 'builtin'; type: BuiltinType }
export type SplitPane = { id: string; title: string; min: number; content: SplitContent | null }
export type LayoutSpec = {
id: string
title: string
/** 顶部通栏行(可选;每项为内容窗,横跨整列宽) */
top: SplitPane[] | null
/** 主行内容窗(从左到右;聊天窗恒在最后、由引擎自动追加) */
main: SplitPane[]
chatWidth: { default: number; min: number; max: number }
/** 顶部行高度(存在 top 行时生效) */
topHeight?: { default: number; min: number; max: number }
/** 聊天窗贴边位置:'right'(右列/右下,默认)| 'left'(左列/左下) */
chatSide?: 'left' | 'right'
}
type Geom = { left: number; top: number; right: number; bottom: number }
type PaneRow = 'top' | 'main'
type SplitState = {
active: boolean
spec: LayoutSpec | null
@@ -37,12 +45,15 @@ type SplitState = {
header: HTMLElement | null
viewArea: HTMLElement | null
savedMarginLeft: string
savedMarginRight: string
savedMarginTop: string
observer: ResizeObserver | null
fallback: MutationObserver | null
yieldObserver: MutationObserver | null
lastMarginLeft: string
lastMarginRight: string
lastMarginTop: string
onSpecMutated: ((spec: LayoutSpec) => void) | null
listeners: Set<() => void>
open(spec: LayoutSpec): boolean
close(): void
@@ -53,6 +64,9 @@ type SplitState = {
setTopH(h: number): void
setPaneW(i: number, w: number): void
setTopW(i: number, w: number): void
setPaneContent(row: PaneRow, i: number, content: SplitContent | null): void
swapPanes(aRow: PaneRow, aI: number, bRow: PaneRow, bI: number): void
setChatSide(side: 'left' | 'right'): void
persist(): void
subscribe(fn: () => void): () => void
notify(): void
@@ -63,6 +77,25 @@ const DIVIDER = 6
const BAR_H = 26
const PERSIST_KEY = 'dsh.worktable.split.v1'
/** 内置内容窗图标 */
const BUILTIN_ICONS: Record<BuiltinType, string> = {
browser: '🌐',
explorer: '📁',
scm: '🔀',
tasks: '✅',
terminal: '▸_',
}
/** 分栏 UI 文案提供者(由工作台注入 locale t) */
let uiT: ((key: string) => string) | null = null
export function setSplitT(fn: ((key: string) => string) | null) {
uiT = fn
}
const T = (key: string): string => (uiT ? uiT(key) : key)
/** 拖拽换位暂存 */
let dragPane: { row: PaneRow; index: number } | null = null
/** 找到会话根容器:data-phase 元素中排除输入框、取含子元素者;优先 phase=active;无活动会话返回 null */
function findConversationRoot(): HTMLElement | null {
const candidates = Array.from(document.querySelectorAll<HTMLElement>('[data-phase]'))
@@ -116,12 +149,15 @@ export const splitStore: SplitState = {
header: null,
viewArea: null,
savedMarginLeft: '',
savedMarginRight: '',
savedMarginTop: '',
observer: null,
fallback: null,
yieldObserver: null,
lastMarginLeft: '',
lastMarginRight: '',
lastMarginTop: '',
onSpecMutated: null,
listeners: new Set(),
open(spec) {
@@ -147,7 +183,7 @@ export const splitStore: SplitState = {
const header = root.children[0] as HTMLElement | undefined
const viewArea = root.children[1] as HTMLElement | undefined
if (!header || !viewArea) return false
this.spec = spec
this.spec = { ...spec, chatSide: spec.chatSide === 'left' ? 'left' : 'right' }
const main = spec.main ?? []
const top = spec.top ?? []
const saved = loadSaved(spec.id)
@@ -159,6 +195,7 @@ export const splitStore: SplitState = {
this.header = header
this.viewArea = viewArea
this.savedMarginLeft = viewArea.style.marginLeft
this.savedMarginRight = viewArea.style.marginRight
this.savedMarginTop = viewArea.style.marginTop
this.refreshGeom()
this.applyMargin()
@@ -183,7 +220,9 @@ export const splitStore: SplitState = {
// 让位观察器:会话视图区 margin 被外部改写(其他未接入协议的分栏引擎接管)时关闭自身
this.yieldObserver = new MutationObserver(() => {
if (!this.active || !this.viewArea) return
if (this.viewArea.style.marginLeft !== this.lastMarginLeft || this.viewArea.style.marginTop !== this.lastMarginTop) {
if (this.viewArea.style.marginLeft !== this.lastMarginLeft
|| this.viewArea.style.marginRight !== this.lastMarginRight
|| this.viewArea.style.marginTop !== this.lastMarginTop) {
this.close()
}
})
@@ -217,12 +256,14 @@ export const splitStore: SplitState = {
// 恢复旧视图区 margin(若仍连接),锚定到新会话根
if (this.viewArea && this.viewArea.isConnected && this.viewArea !== viewArea) {
this.viewArea.style.marginLeft = this.savedMarginLeft
this.viewArea.style.marginRight = this.savedMarginRight
this.viewArea.style.marginTop = this.savedMarginTop
}
this.root = next
this.header = header
this.viewArea = viewArea
this.savedMarginLeft = viewArea.style.marginLeft
this.savedMarginRight = viewArea.style.marginRight
this.savedMarginTop = viewArea.style.marginTop
this.observer?.disconnect()
this.observer.observe(next)
@@ -252,11 +293,14 @@ export const splitStore: SplitState = {
const topH = hasTop
? clamp(this.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
: 0
const ml = Math.max(0, colW - chatW) + 'px'
const gap = Math.max(0, colW - chatW) + 'px'
const mt = (BAR_H + topH) + 'px'
this.lastMarginLeft = ml
const chatLeft = spec.chatSide === 'left'
this.lastMarginLeft = chatLeft ? '' : gap
this.lastMarginRight = chatLeft ? gap : ''
this.lastMarginTop = mt
viewArea.style.marginLeft = ml
viewArea.style.marginLeft = this.lastMarginLeft
viewArea.style.marginRight = this.lastMarginRight
viewArea.style.marginTop = mt
},
@@ -323,6 +367,62 @@ export const splitStore: SplitState = {
this.notify()
},
setPaneContent(row, i, content) {
const spec = this.spec
if (!spec) return
const top = [...(spec.top ?? [])]
const main = [...spec.main]
const arr = row === 'top' ? top : main
if (!arr[i]) return
arr[i] = { ...arr[i], content }
this.spec = { ...spec, top: top.length > 0 ? top : null, main }
this.onSpecMutated?.(this.spec)
this.persist()
this.notify()
},
swapPanes(aRow, aI, bRow, bI) {
const spec = this.spec
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 }
}
this.spec = { ...spec, top: top.length > 0 ? top : null, main }
this.onSpecMutated?.(this.spec)
this.persist()
this.notify()
},
setChatSide(side) {
const spec = this.spec
if (!spec) return
this.spec = { ...spec, chatSide: side }
this.onSpecMutated?.(this.spec)
this.applyMargin()
this.persist()
this.notify()
},
persist() {
if (!this.spec) return
persistSaved(this.spec.id, { chatW: this.chatW, topH: this.topH, paneWs: this.paneWs, topWs: this.topWs })
@@ -331,6 +431,7 @@ export const splitStore: SplitState = {
close() {
if (this.viewArea) {
this.viewArea.style.marginLeft = this.savedMarginLeft
this.viewArea.style.marginRight = this.savedMarginRight
this.viewArea.style.marginTop = this.savedMarginTop
}
this.observer?.disconnect()
@@ -404,6 +505,100 @@ function makeDividerHandler(kind: 'chat' | 'top' | 'pane' | 'topPane', index?: n
}
}
/** 浏览器内置窗:地址栏 + iframe */
function BrowserPane() {
const [url, setUrl] = useState('https://www.bing.com')
const [src, setSrc] = useState('https://www.bing.com')
const go = () => {
const u = url.trim()
setSrc(/^(\/|https?:\/\/)/i.test(u) ? u : 'about:blank')
}
return (
<>
<div className="dsh-wt_browserBar">
<input
className="dsh-wt_browserInput"
value={url}
placeholder="https://"
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') go() }}
/>
<button type="button" className="dsh-wt_browserGo" onClick={go}>↗</button>
</div>
<iframe className="dsh-wt_paneFrame" src={src} title="browser" />
</>
)
}
/** 窗内容三态渲染 */
function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
const { pane, row, index } = props
const content = pane.content
if (content && content.kind === 'iframe') {
return <iframe className="dsh-wt_paneFrame" src={content.url} title={pane.title} />
}
if (content && content.kind === 'builtin' && content.type === 'browser') {
return <BrowserPane />
}
if (content && content.kind === 'builtin') {
return (
<div className="dsh-wt_paneWip">
<span className="dsh-wt_paneWipIcon" aria-hidden>{BUILTIN_ICONS[content.type]}</span>
<span className="dsh-wt_paneWipText">{T('pane.wip')}</span>
</div>
)
}
return <PanePicker row={row} index={index} />
}
/** 未指派内容:6 选 1 选择器(better-sidebar 风格 5 项 + 自定义) */
function PanePicker(props: { row: PaneRow; index: number }) {
const [custom, setCustom] = useState(false)
const [url, setUrl] = useState('')
const pick = (content: SplitContent) => splitStore.setPaneContent(props.row, props.index, content)
const applyCustom = () => {
const u = url.trim()
if (/^(\/|https?:\/\/)/i.test(u)) pick({ kind: 'iframe', url: u })
}
if (custom) {
return (
<div className="dsh-wt_paneCustom">
<input
autoFocus
type="text"
placeholder={T('pane.customUrlPh')}
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') applyCustom() }}
/>
<button type="button" className="dsh-wt_paneCustomGo" onClick={applyCustom}>{T('pane.open')}</button>
</div>
)
}
return (
<div className="dsh-wt_panePicker">
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'browser' })}>
<span aria-hidden>🌐</span>{T('pane.browser')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'explorer' })}>
<span aria-hidden>📁</span>{T('pane.explorer')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'scm' })}>
<span aria-hidden>🔀</span>{T('pane.scm')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'tasks' })}>
<span aria-hidden>✅</span>{T('pane.tasks')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => pick({ kind: 'builtin', type: 'terminal' })}>
<span aria-hidden>▸_</span>{T('pane.terminal')}
</button>
<button type="button" className="dsh-wt_panePick" onClick={() => setCustom(true)}>
<span aria-hidden>✨</span>{T('pane.custom')}
</button>
</div>
)
}
/** 分栏工作区浮层(shell.overlay 座位;订阅 splitStore 快照渲染) */
function SplitWorkspace() {
const [snap, setSnap] = useState({
@@ -443,6 +638,7 @@ function SplitWorkspace() {
const top = spec.top ?? []
const main = spec.main ?? []
const hasTop = top.length > 0
const chatLeft = 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))
@@ -450,6 +646,7 @@ function SplitWorkspace() {
? clamp(snap.topH, spec.topHeight?.min ?? 80, Math.max(spec.topHeight?.min ?? 80, rowH - BAR_H - 80))
: 0
const contentW = Math.max(0, colW - chatW)
const contentX = chatLeft ? g.left + chatW : g.left
const topItems = allocate(top, snap.topWs, colW)
const mainItems = allocate(main, snap.paneWs, contentW)
@@ -460,27 +657,45 @@ function SplitWorkspace() {
const mainH = paneBottom - bodyTop
const topY = barTop + BAR_H
const renderPane = (it: { pane: SplitPane; left: number; width: number }, row: PaneRow, index: number, x: number, y: number, h: number) => (
<div key={it.pane.id} className="dsh-wt_pane" style={{ position: 'fixed', left: x + it.left, top: y, width: it.width, height: h, zIndex: 68 }}>
<div
className="dsh-wt_paneBar"
title={T('split.dragSwap')}
draggable
onDragStart={(e: any) => { dragPane = { row, index }; try { e.dataTransfer.effectAllowed = 'move' } catch {} }}
onDragOver={(e: any) => e.preventDefault()}
onDrop={(e: any) => {
e.preventDefault()
const s = dragPane
if (s && (s.row !== row || s.index !== index)) splitStore.swapPanes(s.row, s.index, row, index)
dragPane = null
}}
onDragEnd={() => { dragPane = null }}
>
<span className="dsh-wt_paneTitle">{it.pane.title}</span>
</div>
<PaneBody pane={it.pane} row={row} index={index} />
</div>
)
return (
<>
{/* 标题栏 */}
<div className="dsh-wt_splitBar" style={{ position: 'fixed', left: g.left, top: barTop, width: hasTop ? colW : contentW, zIndex: 70 }}>
<div className="dsh-wt_splitBar" style={{ position: 'fixed', left: contentX, top: barTop, width: hasTop ? colW : contentW, zIndex: 70 }}>
<span className="dsh-wt_splitTitle">{spec.title}</span>
<button
type="button"
className="dsh-wt_splitFlip"
title={T('split.flip')}
onClick={() => splitStore.setChatSide(chatLeft ? 'right' : 'left')}
>⇄</button>
<button type="button" className="dsh-wt_splitClose" aria-label="退出分栏(Esc)" onClick={() => splitStore.close()}>✕</button>
</div>
{/* 顶部通栏行 */}
{hasTop && topItems.map((it) => (
<div key={it.pane.id} className="dsh-wt_pane" style={{ position: 'fixed', left: g.left + it.left, top: topY, width: it.width, height: topH, zIndex: 68 }}>
<div className="dsh-wt_paneBar"><span className="dsh-wt_paneTitle">{it.pane.title}</span></div>
<iframe className="dsh-wt_paneFrame" src={it.pane.content.url} title={it.pane.title} />
</div>
))}
{hasTop && topItems.map((it, i) => renderPane(it, 'top', i, g.left, topY, topH))}
{/* 主行内容窗 */}
{mainItems.map((it) => (
<div key={it.pane.id} className="dsh-wt_pane" style={{ position: 'fixed', left: g.left + it.left, top: bodyTop, width: it.width, height: mainH, zIndex: 68 }}>
<div className="dsh-wt_paneBar"><span className="dsh-wt_paneTitle">{it.pane.title}</span></div>
<iframe className="dsh-wt_paneFrame" src={it.pane.content.url} title={it.pane.title} />
</div>
))}
{mainItems.map((it, i) => renderPane(it, 'main', i, contentX, bodyTop, mainH))}
{/* 顶部/主行水平分隔线 */}
{hasTop && (
<div
@@ -509,7 +724,7 @@ function SplitWorkspace() {
className="dsh-wt_splitDivider"
role="separator"
title="拖动调整宽度"
style={{ position: 'fixed', left: g.left + it.left + it.width + DIVIDER / 2, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
style={{ position: 'fixed', left: contentX + it.left + it.width + DIVIDER / 2, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
onPointerDown={makeDividerHandler('pane', i)}
/>
))}
@@ -518,7 +733,7 @@ function SplitWorkspace() {
className="dsh-wt_splitDivider"
role="separator"
title="拖动调整聊天宽度"
style={{ position: 'fixed', left: g.right - chatW - DIVIDER / 2, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
style={{ position: 'fixed', left: (chatLeft ? g.left + chatW : g.right - chatW) - DIVIDER / 2, top: bodyTop, width: DIVIDER, height: mainH, zIndex: 72 }}
onPointerDown={makeDividerHandler('chat')}
/>
</>
+26 -2
View File
@@ -83,10 +83,16 @@ 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:5px;padding:2px 8px 4px}',
'.dsh-wt_preset{flex:1;padding:5px 4px;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:11px;line-height:15px;cursor:pointer}',
'.dsh-wt_presets{display:flex;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_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}',
'.dsh-wt_thumbChat{background:var(--dsw-alias-state-accent-primary,#4f8ef7);color:#fff}',
// 布局条目卡片
'.dsh-wt_layout{display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02));color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;cursor:pointer;text-align:left}',
'.dsh-wt_layout:hover{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
@@ -98,4 +104,22 @@ export const css = [
'.dsh-wt_layoutDesc{font-size:10px;line-height:14px;color:var(--dsw-alias-label-tertiary,#6e7683);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_layoutBadge{flex:none;font-size:9px;line-height:14px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06));color:var(--dsw-alias-label-tertiary,#6e7683)}',
'.dsh-wt_layoutArrow{flex:none;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:14px;line-height:20px}',
// 窗内容:选择器 / 自定义 / 浏览器 / 占位
'.dsh-wt_panePicker{flex:1;min-height:0;display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:10px;overflow:auto;align-content:start}',
'.dsh-wt_panePick{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;padding:10px 4px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02));color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11px;line-height:15px;cursor:pointer}',
'.dsh-wt_panePick:hover{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_panePick span{font-size:15px;line-height:18px}',
'.dsh-wt_paneCustom{flex:1;min-height:0;display:flex;align-items:center;gap:6px;padding:10px}',
'.dsh-wt_paneCustom input{flex:1;min-width:0;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;padding:5px 8px;outline:none}',
'.dsh-wt_paneCustom input:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
'.dsh-wt_paneCustomGo{flex:none;padding:5px 10px;border:none;border-radius:6px;background:var(--dsw-alias-state-accent-primary,#4f8ef7);color:#fff;font:inherit;font-size:12px;cursor:pointer}',
'.dsh-wt_browserBar{flex:none;display:flex;align-items:center;gap:5px;padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}',
'.dsh-wt_browserInput{flex:1;min-width:0;background:transparent;border:none;outline:none;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:11px;line-height:16px}',
'.dsh-wt_browserGo{flex:none;width:20px;height:20px;padding:0;border:none;border-radius:4px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:12px;cursor:pointer}',
'.dsh-wt_browserGo:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
'.dsh-wt_paneWip{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--dsw-alias-label-tertiary,#6e7683)}',
'.dsh-wt_paneWipIcon{font-size:26px;line-height:30px}',
'.dsh-wt_paneWipText{font-size:11px}',
'.dsh-wt_splitFlip{flex:none;display:flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:none;border-radius:4px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:13px;cursor:pointer}',
'.dsh-wt_splitFlip:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
].join('\n')