From 25c5cb84d0740935304f1d0d6c5010d5f1d46682 Mon Sep 17 00:00:00 2001 From: Aisland Date: Mon, 17 Aug 2026 21:04:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=206=E9=A2=84=E8=AE=BE=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E7=AA=97=E6=A0=BC=E6=AF=94=E4=BE=8B=E5=9D=87=E8=A1=A1=E5=8C=96?= =?UTF-8?q?=20+=20=E7=A9=BA=E7=AA=974=E6=8C=89=E9=92=AE=E5=B1=85=E4=B8=AD?= =?UTF-8?q?=E8=87=AA=E9=80=82=E5=BA=94(=E5=AE=BD=E6=A8=AA/=E6=96=B92=C3=97?= =?UTF-8?q?2/=E7=AB=96=E7=AB=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/src/client/split.tsx | 130 +++++++++++++----- 01_content/src/client/styles.ts | 13 +- .../worklogs/2026-08-16_worktable-v2.md | 20 +++ 04_test/bundle-eval.cjs | 5 +- 04_test/functional-diag.cjs | 4 +- 5 files changed, 126 insertions(+), 46 deletions(-) diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index 18b83ff..c3c4a3b 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -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 = { @@ -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(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 ( -
- setUrl(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') applyCustom() }} - /> - -
- ) - } return ( -
- - - - +
+ {custom ? ( +
+ setUrl(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') applyCustom() }} + /> + +
+ ) : ( + <> + + + + + + )}
) } diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts index 04c1cca..5757f32 100644 --- a/01_content/src/client/styles.ts +++ b/01_content/src/client/styles.ts @@ -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}', diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index 1e538f3..a838e52 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -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。 diff --git a/04_test/bundle-eval.cjs b/04_test/bundle-eval.cjs index f55832d..4666183 100644 --- a/04_test/bundle-eval.cjs +++ b/04_test/bundle-eval.cjs @@ -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, diff --git a/04_test/functional-diag.cjs b/04_test/functional-diag.cjs index 79e8b70..2c3af41 100644 --- a/04_test/functional-diag.cjs +++ b/04_test/functional-diag.cjs @@ -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);