From b59194a6a910d492afaf8a316e74aa6f613e911c Mon Sep 17 00:00:00 2001 From: Aisland Date: Mon, 17 Aug 2026 21:22:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B9=E7=9B=AE=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=8A=A0=E6=A1=86+=E5=B0=8F=E5=AD=97?= =?UTF-8?q?=E7=B2=BE=E7=AE=80=20+=20=E5=9B=BE=E6=A0=87=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E5=8F=AF=E9=80=89(18emoji)=20+=20=E6=96=B0=E5=BB=BA=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E4=B8=8Etravelatlas=E5=94=AF=E4=B8=80=E4=BA=92?= =?UTF-8?q?=E6=96=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/src/client/index.tsx | 92 ++++++++++++++++++- 01_content/src/client/locales.ts | 4 + 01_content/src/client/split.tsx | 12 +++ 01_content/src/client/styles.ts | 16 ++++ .../worklogs/2026-08-16_worktable-v2.md | 24 +++++ 04_test/functional-diag.cjs | 64 ++++++++++++- 6 files changed, 206 insertions(+), 6 deletions(-) diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index e81f882..bcb9cbd 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -74,6 +74,9 @@ const PRESET_DEFS = [ { id: 't3', leftCount: 0, topCount: 1, contentCount: 2, chatFull: true }, ] as const +/** 侧栏图标备选集(emoji):布局/快捷方式的图标,点击可换(首项 🧱 为布局默认) */ +const EMOJI_SET = ['🧱', '🏠', '🎓', '🚗', '✈️', '🌍', '🏥', '📚', '✏️', '⚙️', '🎨', '🎮', '🌏', '📐', '🧪', '🤖', '📦', '💬'] + function buildLayout(presetId: string, name: string): LayoutSpec { const def = PRESET_DEFS.find((d) => d.id === presetId) ?? PRESET_DEFS[0] const mk = (i: number): SplitPane => ({ @@ -283,6 +286,8 @@ function WorktableSection(props: any) { const [wsPreset, setWsPreset] = useState('2h') const [wsName, setWsName] = useState('') const [wsError, setWsError] = useState(false) + /** 图标选择器:kind + 目标 id + 弹窗锚点坐标(fixed 定位) */ + const [iconPick, setIconPick] = useState<{ kind: 'layout' | 'shortcut'; id: string; x: number; y: number } | null>(null) const [float, setFloat] = useState(() => view.dock === 'float' && view.floatTop != null ? { top: view.floatTop } : null, ) @@ -622,6 +627,28 @@ function WorktableSection(props: any) { persistProjects((prev) => ({ ...prev, layouts: prev.layouts.filter((l) => l.id !== id) })) } + // ── 图标选择器(布局 / 快捷方式的侧栏 emoji 点击可换) ── + const openIconPick = (kind: 'layout' | 'shortcut', id: string, e: any) => { + e.stopPropagation() + e.preventDefault() + const r = (e.currentTarget as HTMLElement).getBoundingClientRect() + const x = Math.min(r.right + 8, Math.max(16, window.innerWidth - 284)) + const y = Math.max(MIN_TOP, Math.min(r.top - 4, window.innerHeight - 316)) + setIconPick({ kind, id, x, y }) + } + const setLayoutIcon = (id: string, icon: string) => { + persistProjects((prev) => ({ + ...prev, + layouts: prev.layouts.map((l) => (l.id === id ? { ...l, icon } : l)), + })) + } + const setShortcutIcon = (id: string, icon: string) => { + persistProjects((prev) => ({ + ...prev, + shortcuts: prev.shortcuts.map((s) => (s.id === id ? { ...s, icon } : s)), + })) + } + const query = view.query.trim() const queryLower = query.toLowerCase() const visibleShortcuts = projects.shortcuts.filter((s) => @@ -705,7 +732,7 @@ function WorktableSection(props: any) { if (!wide) { const projectIcons = registeredIds.map((id) => metas[id]?.icon ?? '📦') const shortcutIcons = projects.shortcuts.map((s) => s.icon) - const layoutIcons = projects.layouts.map(() => '🧱') + const layoutIcons = projects.layouts.map((l) => l.icon ?? '🧱') const icons = [...projectIcons, ...shortcutIcons, ...layoutIcons] const railNames = [ ...registeredIds.map((id) => projects.nameOverrides[id] ?? metas[id]?.name ?? id), @@ -819,6 +846,35 @@ function WorktableSection(props: any) { )} + {iconPick && ( + <> +
setIconPick(null)} /> +
+
{t('icons.title')}
+
+ {EMOJI_SET.map((em) => { + const cur = iconPick.kind === 'layout' + ? (projects.layouts.find((l) => l.id === iconPick.id)?.icon ?? '🧱') + : (projects.shortcuts.find((s) => s.id === iconPick.id)?.icon ?? '🔗') + return ( + + ) + })} +
+
+ + )} + {viewOptionsOpen && (
{t('sort.label')} @@ -858,7 +914,15 @@ function WorktableSection(props: any) { onDragEnd={() => { dragIdRef.current = null }} > ≡ - {layout ? '🧱' : (meta?.icon ?? '📦')} + {layout + ? openIconPick('layout', id, e)} + >{layout.icon ?? '🧱'} + : {meta?.icon ?? '📦'}} (
🔗 - {s.icon} + openIconPick('shortcut', s.id, e)} + >{s.icon} {s.name}
@@ -903,7 +973,13 @@ function WorktableSection(props: any) { style={{ order: effectiveOrder.indexOf(l.id) + 1000 }} onClick={() => { openSplit(l); reportUsed(l.id) }} > - 🧱 + openIconPick('layout', l.id, e)} + >{l.icon ?? '🧱'} {projects.nameOverrides[l.id] ?? l.title} {t('layout.desc', { n: String(paneCount) })} @@ -926,7 +1002,13 @@ function WorktableSection(props: any) { rel="noreferrer noopener" title={s.href} > - {s.icon} + openIconPick('shortcut', s.id, e)} + >{s.icon} {s.name} {t('shortcut.badge')} diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index 1384090..162a05a 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -67,6 +67,8 @@ export const zh = { 'pane.subagentsEmpty': '当前会话暂无子代理', 'split.flip': '切换对话窗位置(左⇄右)', 'split.dragSwap': '拖动标题栏与其他窗交换位置', + 'icons.title': '选择图标', + 'icons.change': '更换图标', 'empty': '暂无项目', } satisfies Record @@ -136,6 +138,8 @@ export const en = { 'pane.subagentsEmpty': 'No subagents in the current session', 'split.flip': 'Flip chat side (left ⇄ right)', 'split.dragSwap': 'Drag the title bar to swap panes', + 'icons.title': 'Pick an icon', + 'icons.change': 'Change icon', 'empty': 'No projects yet', } satisfies Record diff --git a/01_content/src/client/split.tsx b/01_content/src/client/split.tsx index c3c4a3b..e6e65d6 100644 --- a/01_content/src/client/split.tsx +++ b/01_content/src/client/split.tsx @@ -45,6 +45,8 @@ export type LayoutSpec = { topHeight?: { default: number; min: number; max: number } /** 聊天窗贴边位置:'right'(右列/右下,默认)| 'left'(左列/左下) */ chatSide?: 'left' | 'right' + /** 布局条目图标(emoji;工作台侧栏展示,点击可换) */ + icon?: string /** 聊天窗通高(整列):为 true 时聊天占整条右/左列,内容区(含 top 行)全部排在其另一侧 */ chatFullHeight?: boolean } @@ -729,6 +731,16 @@ export const splitStore: SplitState = { }, } +/** 兼容桥:travelatlas 分栏浮层(.ta_split)出现 = 其引擎打开 → 本引擎让位。 + * 不改动 travelatlas 代码,仅在 DOM 层观察其浮层挂载。 */ +if (typeof document !== 'undefined' && document.body) { + const taObserver = new MutationObserver(() => { + if (!splitStore.active) return + if (document.querySelector('.ta_split')) splitStore.close() + }) + taObserver.observe(document.body, { childList: true, subtree: true }) +} + /** 分配各窗宽度(最后一个拿余量) */ function allocate(panes: SplitPane[], ws: number[], total: number) { const out: { pane: SplitPane; left: number; width: number }[] = [] diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts index 5757f32..81140d5 100644 --- a/01_content/src/client/styles.ts +++ b/01_content/src/client/styles.ts @@ -107,6 +107,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}', + // 项目卡片统一:入驻插件卡片(travelatlas/planreview)与布局卡片同款常显框(静止态可见边框+底) + '.dsh-wt_projects .ta_card,.dsh-wt_projects .pr_card{border-color:var(--dsw-alias-border-l1,#262b36);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02))}', + '.dsh-wt_projects .ta_card:hover,.dsh-wt_projects .pr_card:hover{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}', + '.dsh-wt_projects .ta_card[data-on=true],.dsh-wt_projects .pr_card[data-on=true]{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}', + // 标签精简:项目卡片只留名称一行,小字描述行去掉 + '.dsh-wt_projects .ta_cardDesc,.dsh-wt_projects .pr_cardDesc{display:none}', + // 图标选择弹窗(固定定位,点击项目/布局/快捷方式的图标唤出) + '.dsh-wt_iconPop{position:fixed;z-index:81;width:256px;padding:8px;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:10px;background:var(--dsw-alias-bg-base,#0b0e14);box-shadow:var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4))}', + '.dsh-wt_iconPopTitle{font-size:10px;letter-spacing:.05em;color:var(--dsw-alias-label-tertiary,#6e7683);padding:1px 4px 6px}', + '.dsh-wt_iconGrid{display:grid;grid-template-columns:repeat(6,1fr);gap:4px}', + '.dsh-wt_iconCell{display:flex;align-items:center;justify-content:center;aspect-ratio:1;border:1px solid transparent;border-radius:6px;background:transparent;font-size:17px;line-height:1;cursor:pointer;padding:0}', + '.dsh-wt_iconCell:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05));border-color:var(--dsw-alias-border-l2,#3a4150)}', + '.dsh-wt_iconCell[data-on=true]{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.08));border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', + // 可点图标(布局卡 / 管理行 / 快捷方式上的图标,点击换 emoji) + '.dsh-wt_iconPick{flex:none;display:inline-flex;align-items:center;justify-content:center;padding:1px;border:none;background:transparent;font:inherit;cursor:pointer;border-radius:4px}', + '.dsh-wt_iconPick:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.08))}', // 窗内容:选择器 / 自定义 / 浏览器 / 占位 '.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}', diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index a838e52..656a4bd 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -487,3 +487,27 @@ {w:92,h:78};STEP3-5 回归不变;ERRORS_COUNT: 0。bundle-eval 补桩 (navigator.userAgent/platform、canvas getContext)后 PASS。 - 备注:本次为纯客户端改动,F5 即生效,无需重启 dsh web。 +## 补记(项目卡片统一加框 + 小字精简 + 图标可选 + 新建布局唯一性互斥) + +- 用户反馈(四条命令合并执行): + ① 加号新建的项目侧栏卡片有框、两个入驻项目(建筑审图/旅行 Atlas)没框 → 统一加框; + ② 新建布局与旅行 Atlas 可同时打开 → 全部项目共用唯一互斥(开一个关前一个); + ③ 项目标签只留名字一行,去掉描述小字(建筑审图/旅行 Atlas); + ④ 左侧 emoji 要能点开一套完整图标列表换选(新项目一直是砖墙 🧱 换不了)。 +- 处理(不改动 travelatlas/building-agent 代码,全部在本插件侧): + ① styles.ts:.dsh-wt_projects 内 .ta_card/.pr_card 静止态常显边框+底色(与布局卡同款), + 静止态带框 + hover 高亮;同时 .ta_cardDesc/.pr_cardDesc display:none(小字去掉); + ② split.tsx:新增 MutationObserver——.ta_split 浮层(travelatlas 引擎打开)出现即 close() 本引擎; + 反向(本引擎打开 → 点旅行 Atlas)沿用既有 .ta_splitClose 桥;互斥全项目(含 + 新建布局)成立; + ③ EMOJI_SET 18 项图标集(🧱🏠🎓🚗✈️🌍🏥📚✏️⚙️🎨🎮🌏📐🧪🤖📦💬); + 布局卡/管理行/快捷方式的图标都变可点(stopPropagation,不触发卡片本身), + 弹固定定位图标选择器(6 列网格、当前项高亮);选中持久化到 projects.v1(LayoutSpec 加 icon 字段; + 快捷方式复用 icon 字段);rail 图标同步使用所选 emoji;locale 加 icons.title/icons.change。 +- 验证(04_test/functional-diag.cjs 扩展:--window-size=1440,900 + dsf=1 + 新 Chrome profile + + Page.addScriptToEvaluateOnNewDocument 种子测试布局 + 点击展开侧栏): + STEP6 图标链路:icon0=🧱 → 点图标 popup=true(18 cells) → 点🏠 → icon1=🏠 且 projects.v1 + saved=🏠;ta/pr 边框色 rgba(255,255,255,.06)(非 transparent)+ desc display:none; + STEP7 互斥:本引擎打开时合成挂 .ta_split → engineClosedByFakeTa=true(观察器关闭引擎)。 + (travelatlas 在无会话 headless 页面不自开分栏,taSplitPresent=false 属预期,改合成验证。) + STEP1-5 回归不变(paneWs 均分、picker 自适应、拖标签);ERRORS_COUNT: 0。 +- 备注:纯客户端改动,F5 即生效,无需重启 dsh web。 diff --git a/04_test/functional-diag.cjs b/04_test/functional-diag.cjs index 2c3af41..5d21667 100644 --- a/04_test/functional-diag.cjs +++ b/04_test/functional-diag.cjs @@ -5,8 +5,9 @@ const WebSocket = require('ws'); const PORT = 9335; const proc = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [ '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', + '--window-size=1440,900', '--force-device-scale-factor=1', '--remote-debugging-port=' + PORT, - '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-func2', + '--user-data-dir=C:\\Users\\SJL\\AppData\\Local\\Temp\\wt-chrome-func3', 'about:blank', ], { stdio: 'ignore' }); @@ -46,6 +47,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); await send('Runtime.enable'); await send('Log.enable'); await send('Page.enable'); + await send('Page.addScriptToEvaluateOnNewDocument', { + source: "try{ if(location.origin==='http://127.0.0.1:3080'){ localStorage.setItem('dsh.worktable.view.v1', JSON.stringify({query:'',searchOpen:false,orderBy:'manual',dock:'footer',floatTop:null,sortMigratedV2:true})); localStorage.setItem('dsh.worktable.projects.v1', JSON.stringify({order:[],lastUsed:{},hidden:[],nameOverrides:{},shortcuts:[],layouts:[{id:'t-layout',title:'\u6d4b\u8bd5\u5e03\u5c40',top:null,left:null,main:[{id:'p1',title:'\u5185\u5bb91',min:200,content:null}],leftWidth:{default:260,min:160,max:480},chatWidth:{default:360,min:240,max:600},topHeight:{default:200,min:120,max:480},chatSide:'right',chatFullHeight:false}]})); } }catch(e){}", + }); await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); await sleep(11000); @@ -55,6 +59,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); return r.result && r.result.result ? r.result.result.value : undefined; }; + // 展开侧栏(宿主默认折叠,宽侧栏下工作台才渲染项目卡片) + const expanded = await evaluate("(function(){ var b=document.querySelector('button[title=\"Open sidebar\"],button[aria-label=\"Open sidebar\"]'); if(b){b.click(); return true} return false })()"); + console.log('SIDEBAR_EXPAND:', JSON.stringify(expanded)); + await sleep(900); + 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); @@ -73,6 +82,59 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const step5 = await evaluate("(function(){ var out={}; out.pane0PickerBack=document.querySelectorAll('.dsh-wt_panePicker').length; var st=window.__dshWorktable.splitStore; st.close(); out.closed=st.active===false; return JSON.stringify(out) })()"); console.log('STEP5:', JSON.stringify(step5)); + const step6 = await evaluate(`(async function(){ + var out={}; + out.cards=document.querySelectorAll('.dsh-wt_layout').length; + var card=document.querySelector('.dsh-wt_layout'); + if(card){ + var ic=card.querySelector('.dsh-wt_layoutIcon'); + out.icon0=ic?ic.textContent:null; + if(ic){ ic.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true})); } + await new Promise(function(r){setTimeout(r,250)}); + out.popup=!!document.querySelector('.dsh-wt_iconPop'); + out.cells=document.querySelectorAll('.dsh-wt_iconCell').length; + var cells=document.querySelectorAll('.dsh-wt_iconCell'); + if(cells[1]){ cells[1].dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true})); } + await new Promise(function(r){setTimeout(r,250)}); + out.icon1=card.querySelector('.dsh-wt_layoutIcon').textContent; + try{ out.saved=JSON.parse(localStorage.getItem('dsh.worktable.projects.v1')).layouts[0].icon }catch(e){ out.saved='ERR:'+e.message } + } + var ta=document.querySelector('.dsh-wt_projects .ta_card'); + if(ta){ var cs=getComputedStyle(ta); out.taBorder=cs.borderTopColor; var desc=ta.querySelector('.ta_cardDesc'); out.taDescDisplay=desc?getComputedStyle(desc).display:null; } + var pr=document.querySelector('.dsh-wt_projects .pr_card'); + if(pr){ var cs2=getComputedStyle(pr); out.prBorder=cs2.borderTopColor; var desc2=pr.querySelector('.pr_cardDesc'); out.prDescDisplay=desc2?getComputedStyle(desc2).display:null; } + return JSON.stringify(out); + })()`); + console.log('STEP6:', JSON.stringify(step6)); + + // 唯一性互斥:本引擎布局打开时,点开旅行 Atlas(其浮层 .ta_split 出现)→ 本引擎自动关闭 + const step7 = await evaluate(`(async function(){ + var out={}; + var st=window.__dshWorktable.splitStore; + st.open({id:'t-mutex',title:'mutex',top:null,main:[{id:'m1',title:'m',min:200,content:null}],chatWidth:{default:320,min:240,max:600}}); + out.engineOpenAfterOpen=st.active; + var ta=document.querySelector('.ta_card'); + if(ta){ ta.click(); } + await new Promise(function(r){setTimeout(r,700)}); + out.taSplitPresent=!!document.querySelector('.ta_split'); + out.engineClosedByTa=st.active===false; + // 合成验证:直接向 body 挂 .ta_split(模拟 travelatlas 浮层出现)→ 观察器应关闭本引擎 + if(st.active){ + var fake=document.createElement('div'); + fake.className='ta_split'; + document.body.appendChild(fake); + await new Promise(function(r){setTimeout(r,400)}); + out.engineClosedByFakeTa=st.active===false; + fake.remove(); + } + if(st.active){ st.close(); } + var taClose=document.querySelector('.ta_splitClose'); + if(taClose){ taClose.click(); } + await new Promise(function(r){setTimeout(r,400)}); + return JSON.stringify(out); + })()`); + console.log('STEP7:', JSON.stringify(step7)); + const errors = events.filter((e) => e.method === 'Runtime.exceptionThrown' || (e.method === 'Log.entryAdded' && e.params.entry.level === 'error') ||