feat: 6预设默认窗格比例均衡化 + 空窗4按钮居中自适应(宽横/方2×2/竖竖)

This commit is contained in:
Aisland
2026-08-17 21:04:17 +08:00
parent 3fe012132e
commit 25c5cb84d0
5 changed files with 126 additions and 46 deletions
+93 -37
View File
@@ -8,7 +8,7 @@ import 'xterm/css/xterm.css'
* 贴右或贴左,由 chatSide 决定;marginLeft/marginRight + marginTop 组合挤法)。
* 内容三态:null(未指派 → 6 选 1 选择器)/ iframe / builtin(浏览器/资源管理器/SCM/任务/终端)。
* 窗位调整:标题栏拖拽换位(同行或跨行);工具栏 ⇄ 切换聊天窗左右。
* 会话切换重新锚定不关闭;宽度按 layoutId 持久化 dsh.worktable.split.v1;
* 会话切换重新锚定不关闭;宽度按 layoutId 持久化 dsh.worktable.split.v2;
* 内容与 chatSide 的变更经 onSpecMutated 回调交给工作台持久化(布局条目)。
*/
@@ -102,7 +102,7 @@ type SplitState = {
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
const DIVIDER = 6
const BAR_H = 26
const PERSIST_KEY = 'dsh.worktable.split.v1'
const PERSIST_KEY = 'dsh.worktable.split.v2'
/** 内置内容窗图标 */
const BUILTIN_ICONS: Record<BuiltinType, string> = {
@@ -297,12 +297,18 @@ export const splitStore: SplitState = {
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] : [])
const hasChatW = !!saved && saved.chatW >= 0
const hasTopH = !!saved && saved.topH >= 0
const hasLeftW = !!saved && saved.leftW >= 0
const hasPaneWs = !!saved && saved.paneWs.length === main.length
const hasTopWs = !!saved && saved.topWs.length === top.length
const hasLeftWs = !!saved && saved.leftWs.length === (left ? 1 : 0)
this.chatW = hasChatW ? saved!.chatW : spec.chatWidth.default
this.topH = hasTopH ? saved!.topH : (spec.topHeight?.default ?? 200)
this.leftW = hasLeftW ? saved!.leftW : (spec.leftWidth?.default ?? 260)
this.paneWs = hasPaneWs ? [...saved!.paneWs] : main.map((p) => p.min)
this.topWs = hasTopWs ? [...saved!.topWs] : top.map((p) => p.min)
this.leftWs = hasLeftWs ? [...saved!.leftWs] : (left ? [left.min] : [])
this.root = root
this.header = header
this.viewArea = viewArea
@@ -310,6 +316,38 @@ export const splitStore: SplitState = {
this.savedMarginRight = viewArea.style.marginRight
this.savedMarginTop = viewArea.style.marginTop
this.refreshGeom()
// 均衡默认:无存档尺寸时按当前可用空间比例分配,
// 不再出现“其余窗全部贴 min、最后一个吃掉全部余量”的悬殊观感。
const g0 = this.geom
if (g0) {
const colW0 = g0.right - g0.left
const rowH0 = g0.bottom - g0.top
if (!hasChatW) {
const hi = Math.max(spec.chatWidth.min, colW0 - 60)
this.chatW = clamp(Math.round(colW0 * 0.3), spec.chatWidth.min, hi)
}
if (left && !hasLeftW) {
const lo = spec.leftWidth?.min ?? 160
this.leftW = clamp(Math.round(colW0 * 0.38), lo, Math.max(lo, colW0 - 260))
}
if (top.length > 0 && !hasTopH) {
const lo = spec.topHeight?.min ?? 80
this.topH = clamp(Math.round(rowH0 * 0.35), lo, Math.max(lo, rowH0 - BAR_H - 80))
}
if (!hasPaneWs) {
const contentW = Math.max(0, colW0 - this.chatW)
const avail = Math.max(main.length * 120, contentW - Math.max(0, main.length - 1) * DIVIDER)
const share = Math.round(avail / main.length)
this.paneWs = main.map((p) => Math.max(p.min, share))
}
if (!hasTopWs) {
const rowW = Math.max(0, colW0 - (left ? this.leftW : 0))
const avail = Math.max(top.length * 120, rowW - Math.max(0, top.length - 1) * DIVIDER)
const share = Math.round(avail / top.length)
this.topWs = top.map((p) => Math.max(p.min, share))
}
if (left && !hasLeftWs) this.leftWs = [Math.max(left.min, this.leftW)]
}
this.applyMargin()
this.observer = new ResizeObserver(() => {
const r = this.root
@@ -1075,44 +1113,62 @@ function PaneBody(props: { pane: SplitPane; row: PaneRow; index: number }) {
)
}
/** 未指派内容:6 选 1 选择器(better-sidebar 风格 5 项 + 自定义) */
/** 未指派内容:4 选 1 选择器。按钮固定大小、整体居中;
* 按窗位宽高比自适应排列:宽窗横排 4 连 / 方窗 2×2 / 竖窗竖排。 */
function PanePicker(props: { row: PaneRow; index: number }) {
const [custom, setCustom] = useState(false)
const [url, setUrl] = useState('')
const [mode, setMode] = useState<'row' | 'grid' | 'col'>('grid')
const hostRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const el = hostRef.current
if (!el) return
const update = () => {
const w = el.clientWidth
const h = el.clientHeight
const aspect = h > 0 ? w / h : 1
setMode(aspect > 1.4 ? 'row' : aspect > 0.72 ? 'grid' : 'col')
}
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
const pick = (content: SplitContent) => splitStore.openTab(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: '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 ref={hostRef} className={'dsh-wt_panePicker dsh-wt_panePicker-' + mode}>
{custom ? (
<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>
) : (
<>
<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: '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>
)
}
+8 -5
View File
@@ -108,11 +108,14 @@ export const css = [
'.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_panePicker{flex:1;min-height:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:12px;overflow:auto}',
'.dsh-wt_panePicker-row{flex-direction:row;flex-wrap:wrap}',
'.dsh-wt_panePicker-grid{display:grid;grid-template-columns:auto auto;grid-template-rows:auto auto;gap:10px;place-content:center}',
'.dsh-wt_panePicker-col{flex-direction:column}',
'.dsh-wt_panePick{flex:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;width:92px;height:78px;padding:8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:10px;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;transition:border-color .15s,background .15s,transform .15s}',
'.dsh-wt_panePick:hover{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05));transform:translateY(-1px)}',
'.dsh-wt_panePick span{font-size:22px;line-height:24px}',
'.dsh-wt_paneCustom{flex:none;width:100%;max-width:320px;display:flex;align-items:center;gap:6px;padding:4px}',
'.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}',
@@ -467,3 +467,23 @@
- 验证:harness(PowerShell 壳)marker 回显 PASS(含 PSReadLine 着色码,确认 PowerShell);
实机 cmd 壳 marker 回显 PASS;file 路由直调 200。
- 生效:终端 shell 与 file 路由 = 服务端,**需重启 dsh web**;浏览器默认页 = 客户端,F5 即生效。
## 补记(6 预设默认比例均衡化 + 窗内 4 按钮居中自适应)
- 用户反馈:① 6 个布局预设默认窗格比例失衡(最后一个窗吃掉全部余量,其余全贴 min);
② 空窗里的 4 个内容选项(浏览器/资源管理器/终端/自定义)贴顶部且按钮拉伸,要求:
按钮固定大小、整体居中,并按窗位形状自适应排列(宽窗横排 / 方窗 2×2 / 竖窗竖排)。
- 处理:
① 均衡默认(split.tsx open()):无存档尺寸时不再 panes.map(p => p.min),
而是按当前几何均分:内容窗宽度均分、顶行均分、左列 38%、顶行高 35%、聊天 30%
(全部按各自 min/max 夹取)。根因是 allocate() 最后一个窗拿余量的退化模型。
② 持久化键 dsh.worktable.split.v1 → v2:旧存档里全是失衡宽度,升级键位让新默认生效
(用户手调过的宽度一次性重置,属预期)。
③ Picker 自适应(split.tsx PanePicker + styles.ts):容器 flex 居中,
ResizeObserver 按宽高比切换三态:aspect>1.4 → row(横排)/ 0.72~1.4 → grid(2×2)/
<0.72 → col(竖排);按钮固定 92×78 圆角卡片(图标 22px + 标签),不再拉伸铺满;
自定义表单同步居中(max-width 320px)。
- 验证(04_test/functional-diag.cjs 扩展断言):STEP1 paneWs=[228,228] 均分
(旧逻辑为 [200,262]);STEP2 pickerModes 均为 -col(无头视口竖窗)+ pickSize
{w:92,h:78};STEP3-5 回归不变;ERRORS_COUNT: 0。bundle-eval 补桩
(navigator.userAgent/platform、canvas getContext)后 PASS。
- 备注:本次为纯客户端改动,F5 即生效,无需重启 dsh web。
+3 -2
View File
@@ -1,10 +1,11 @@
const fs = require("fs");
const fs = require("fs");
const vm = require("vm");
const code = fs.readFileSync("E:/AI_Workspace/DeepseekHarness/Projects/dsh-worktable/01_content/lib/client.js", "utf8");
function makeEl() {
return {
setAttribute(){}, removeAttribute(){}, appendChild(){}, remove(){}, addEventListener(){},
removeEventListener(){}, classList: { add(){}, remove(){}, contains(){ return false } },
getContext(){ return null }, getBoundingClientRect(){ return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 } },
style: {}, dataset: {}, textContent: "", children: [],
};
}
@@ -29,7 +30,7 @@ const sandbox = { self: null,
CustomEvent: class { constructor(t, o){ this.type = t; this.detail = o && o.detail } },
ResizeObserver: class { observe(){} disconnect(){} },
MutationObserver: class { observe(){} disconnect(){} },
navigator: {}, location: { protocol: "http:", host: "127.0.0.1:3080" },
navigator: { userAgent: "bundle-eval/1.0", platform: "Win32" }, location: { protocol: "http:", host: "127.0.0.1:3080" },
fetch: async () => { throw new Error("no fetch") },
WebSocket: class {},
URL: require("url").URL, URLSearchParams: require("url").URLSearchParams,
+2 -2
View File
@@ -55,11 +55,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
return r.result && r.result.result ? r.result.result.value : undefined;
};
const step1 = await evaluate("(function(){ var out={}; out.debugExport=!!window.__dshWorktable; var st=window.__dshWorktable&&window.__dshWorktable.splitStore; if(!st) return JSON.stringify(out); out.openResult=st.open({id:'t-diag',title:'diag',top:null,main:[{id:'p1',title:'1',min:200,content:null},{id:'p2',title:'2',min:200,content:null}],chatWidth:{default:320,min:240,max:600}}); out.afterOpen={active:st.active,spec:st.spec&&st.spec.id}; return JSON.stringify(out) })()");
const step1 = await evaluate("(function(){ var out={}; out.debugExport=!!window.__dshWorktable; var st=window.__dshWorktable&&window.__dshWorktable.splitStore; if(!st) return JSON.stringify(out); out.openResult=st.open({id:'t-diag',title:'diag',top:null,main:[{id:'p1',title:'1',min:200,content:null},{id:'p2',title:'2',min:200,content:null}],chatWidth:{default:320,min:240,max:600}}); out.afterOpen={active:st.active,spec:st.spec&&st.spec.id}; out.paneWs=st.paneWs&&st.paneWs.slice(); out.chatW=st.chatW; out.geom=st.geom; return JSON.stringify(out) })()");
console.log('STEP1:', JSON.stringify(step1));
await sleep(900);
const step2 = await evaluate("(function(){ var out={}; out.pickers=document.querySelectorAll('.dsh-wt_panePicker').length; out.pickButtons=document.querySelectorAll('.dsh-wt_panePick').length; var st=window.__dshWorktable.splitStore; st.openTab('main',0,{kind:'builtin',type:'browser'}); st.openTab('main',1,{kind:'builtin',type:'explorer'}); return JSON.stringify(out) })()");
const step2 = await evaluate("(function(){ var out={}; out.pickers=document.querySelectorAll('.dsh-wt_panePicker').length; out.pickButtons=document.querySelectorAll('.dsh-wt_panePick').length; out.pickerModes=Array.prototype.map.call(document.querySelectorAll('.dsh-wt_panePicker'),function(el){return el.className}); out.pickSize=(function(){var b=document.querySelector('.dsh-wt_panePick'); if(!b) return null; var r=b.getBoundingClientRect(); return {w:r.width,h:r.height};})(); var st=window.__dshWorktable.splitStore; st.openTab('main',0,{kind:'builtin',type:'browser'}); st.openTab('main',1,{kind:'builtin',type:'explorer'}); return JSON.stringify(out) })()");
console.log('STEP2:', JSON.stringify(step2));
await sleep(900);