From 6ac51d998659b95e0631e0825401039c66ff0c25 Mon Sep 17 00:00:00 2001 From: Aisland Date: Wed, 19 Aug 2026 02:07:46 +0800 Subject: [PATCH] polish: smaller monochrome bind icon with hover tooltip showing bound conversation name --- 01_content/src/client/index.tsx | 17 +++- 01_content/src/client/locales.ts | 4 + 01_content/src/client/styles.ts | 18 +++-- .../worklogs/2026-08-16_worktable-v2.md | 13 ++++ 04_test/bindicon-mini.cjs | 78 +++++++++++++++++++ 5 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 04_test/bindicon-mini.cjs diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index f94774a..0443aff 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -754,6 +754,16 @@ function WorktableSection(props: any) { } }, []) +/** 绑定会话显示名(同步读会话快照;找不到回退 id)——供按钮 hover 气泡使用 */ +function boundSessionTitle(sid: string): string { + try { + const snap = sessionBridge?.list?.getSnapshot?.() + const e = snap?.byId?.[sid] + if (e) return e.displayTitle ?? e.title ?? sid + } catch {} + return sid +} + /** 绑定会话信息:文件夹(工作区)+ 会话标题(供绑定弹窗状态行展示) */ function bindInfoOf(groups: { title: string; sessions: { id: string; title: string; isCurrent: boolean }[] }[], sid: string): { folder: string; title: string } { for (const g of groups) { @@ -1134,7 +1144,9 @@ function buildCustomLayoutPrompt(req: string): string { bindBtn.setAttribute('data-wt-bind', id) const bound = projects.bindings[id] bindBtn.setAttribute('data-bound', bound ? 'true' : 'false') - bindBtn.title = bound ? t('bind.titleBound') : t('bind.titleUnbound') + const tip = bound ? t('bind.tipBound', { name: boundSessionTitle(bound) }) : t('bind.tipUnbound') + bindBtn.setAttribute('data-tip', tip) + bindBtn.setAttribute('aria-label', tip) const icon = el.children[0] as HTMLElement | null if (icon) { const ovr = projects.iconOverrides[id] @@ -1682,7 +1694,8 @@ function buildCustomLayoutPrompt(req: string): string { role="button" tabIndex={0} data-bound={projects.bindings[l.id] ? 'true' : 'false'} - title={projects.bindings[l.id] ? t('bind.titleBound') : t('bind.titleUnbound')} + data-tip={projects.bindings[l.id] ? t('bind.tipBound', { name: boundSessionTitle(projects.bindings[l.id]) }) : t('bind.tipUnbound')} + aria-label={projects.bindings[l.id] ? t('bind.tipBound', { name: boundSessionTitle(projects.bindings[l.id]) }) : t('bind.tipUnbound')} onClick={(e) => { e.stopPropagation(); openBindPick(l.id, e.currentTarget as HTMLElement) }} > › diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index 034a8d1..18d2b89 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -88,6 +88,8 @@ export const zh = { 'bind.unbound': '未绑定对话', 'bind.titleUnbound': '绑定对话(未绑定 ○○):点击选择要跟随的对话', 'bind.titleBound': '已绑定对话(●●):点击更换或解绑', + 'bind.tipUnbound': '未绑定对话:点击选择要跟随的对话', + 'bind.tipBound': '已绑定对话:{name}', 'bind.hint': '一个项目只能绑定一个对话。选择后,打开该项目时右侧对话窗会自动切换过去;已绑定的项目不会因新建/发送对话而自动改绑。', 'customLayout.add': '自定义', 'customLayout.addTitle': '自定义一个布局(生成提示词)', @@ -209,6 +211,8 @@ export const en = { 'bind.unbound': 'No conversation bound', 'bind.titleUnbound': 'Bind conversation (unbound ○○): pick the conversation to follow', 'bind.titleBound': 'Conversation bound (●●): click to change or unbind', + 'bind.tipUnbound': 'No conversation bound — click to pick one', + 'bind.tipBound': 'Bound to: {name}', 'bind.hint': 'A project can bind exactly one conversation. When bound, opening the project switches the right chat pane to it; an existing binding is never auto-changed by creating/sending conversations.', 'customLayout.add': 'Custom', 'customLayout.addTitle': 'Customize a layout (generate a prompt)', diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts index 907abd2..c72711f 100644 --- a/01_content/src/client/styles.ts +++ b/01_content/src/client/styles.ts @@ -153,15 +153,17 @@ export const css = [ '.dsh-wt_presetAdd{border-style:dashed;color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', '.dsh-wt_presetAddIcon{font-size:18px;line-height:20px}', '.dsh-wt_presetAddText{font-size:10px;line-height:13px}', - // 项目卡片上的对话绑定按钮(○○ 空心 = 未绑定 / ●● 实心 = 已绑定;CSS 双圆单色绘制; - // 布局卡与入驻卡统一绝对定位于卡片中间偏右、垂直居中,保证所有卡片竖向对齐) - '.dsh-wt_bindBtn{position:absolute;right:26px;top:50%;transform:translateY(-50%);width:18px;height:14px;margin:0;display:block;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer;user-select:none}', - '.dsh-wt_bindBtn:hover{color:var(--dsw-alias-label-primary,#e6e8eb)}', - '.dsh-wt_bindBtn[data-bound=true]{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}', - '.dsh-wt_bindCircles{position:absolute;left:0;top:50%;width:18px;height:9px;transform:translateY(-50%)}', - '.dsh-wt_bindCircles::before,.dsh-wt_bindCircles::after{content:\'\';position:absolute;top:50%;width:8px;height:8px;margin-top:-4px;border-radius:50%;box-sizing:border-box;border:1.4px solid currentColor;background:transparent}', + // 项目卡片上的对话绑定按钮(○○ 空心 = 未绑定 / ●● 实心 = 已绑定;CSS 双圆绘制,黑白单色克制: + // 未绑定次级灰空心、已绑定主色实心,不用强调色;hover 时自身出现按钮底色 + 气泡显示绑定对象) + '.dsh-wt_bindBtn{position:absolute;right:26px;top:50%;transform:translateY(-50%);width:16px;height:12px;margin:0;display:block;border-radius:4px;color:var(--dsw-alias-label-secondary,#9aa4b2);cursor:pointer;user-select:none}', + '.dsh-wt_bindBtn:hover{background:var(--dsw-alias-fill-l2,rgba(255,255,255,.09));color:var(--dsw-alias-label-primary,#e6e8eb)}', + '.dsh-wt_bindBtn[data-bound=true]{color:var(--dsw-alias-label-primary,#e6e8eb)}', + '.dsh-wt_bindBtn::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%) translateY(2px);padding:4px 8px;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:6px;background:var(--dsw-alias-fill-l2,#171b22);color:var(--dsw-alias-label-primary,#e6e8eb);font-size:11px;line-height:15px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .12s ease, transform .12s ease;z-index:90;box-shadow:0 4px 12px rgba(0,0,0,.35)}', + '.dsh-wt_bindBtn:hover::after{opacity:1;transform:translateX(-50%) translateY(0)}', + '.dsh-wt_bindCircles{position:absolute;left:0;top:50%;width:14px;height:7px;transform:translateY(-50%)}', + '.dsh-wt_bindCircles::before,.dsh-wt_bindCircles::after{content:\'\';position:absolute;top:50%;width:6px;height:6px;margin-top:-3px;border-radius:50%;box-sizing:border-box;border:1.2px solid currentColor;background:transparent}', '.dsh-wt_bindCircles::before{left:0}', - '.dsh-wt_bindCircles::after{left:9px}', + '.dsh-wt_bindCircles::after{left:7px}', '.dsh-wt_bindBtn[data-bound=true] .dsh-wt_bindCircles::before,.dsh-wt_bindBtn[data-bound=true] .dsh-wt_bindCircles::after{background:currentColor}', // 布局条目卡片 diff --git a/02_process/worklogs/2026-08-16_worktable-v2.md b/02_process/worklogs/2026-08-16_worktable-v2.md index 26cbe1c..8249571 100644 --- a/02_process/worklogs/2026-08-16_worktable-v2.md +++ b/02_process/worklogs/2026-08-16_worktable-v2.md @@ -746,6 +746,19 @@ 已去 BOM 修复,并在服务端路由加 BOM 容忍(下次重启生效)。 - 备注:动画窗/绑定按钮纯客户端,F5 即生效;服务端 BOM 容忍改动需下次重启 dsh web 后生效。 +## 补记(绑定图标微调:更小 + 黑白单色 + hover 气泡) + +- 用户反馈:图标太大;颜色要黑白克制(不要蓝色强调);hover 要有自身按钮区域效果 + + 直接显示「已绑定对话:xxx」,不必点击。 +- 实现:按钮 18×14 → 16×12、圆 8px → 6px;未绑定 = 次级灰空心圆(rgb 207,211,214), + 已绑定 = 主色实心圆(rgb 249,250,251),移除 accent 蓝;hover 自身 4px 圆角底色 + + ::after 气泡(content:attr(data-tip),显示「未绑定对话:点击选择要跟随的对话」/ + 「已绑定对话:{name}」,name 由 boundSessionTitle 同步读会话快照取 displayTitle)。 +- 验证(bindicon-mini.cjs):16×12 / 6px 圆 ✓;绑定态 color=主色非 accent、实心 ✓; + data-tip 解析「已绑定对话:Projects」✓;functional-diag ERROR_COUNT: 0。 +- 备注:纯客户端改动,F5 即生效。 + + ## 补记(第 7/8 布局 + +自定义磁贴 + 窗口改名 + 对话绑定) - 用户要求:① 新增第 7 个预设「田字格」——右聊天通高整列、左侧 2×2 四窗默认等大,横向贯通、 diff --git a/04_test/bindicon-mini.cjs b/04_test/bindicon-mini.cjs new file mode 100644 index 0000000..b58b0a3 --- /dev/null +++ b/04_test/bindicon-mini.cjs @@ -0,0 +1,78 @@ +// bindicon-mini.cjs — 绑定图标:尺寸/黑白单色/hover气泡(data-tip 解析) +const http = require('http'); +const { spawn } = require('child_process'); +const WebSocket = require('ws'); +const PORT = 9345; +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-bindicon', + 'about:blank', +], { stdio: 'ignore' }); +const getJSON = (p) => new Promise((res, rej) => { + http.get({ host: '127.0.0.1', port: PORT, path: p }, (r) => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{try{res(JSON.parse(d))}catch(e){rej(e)}}) }).on('error', rej); +}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +(async () => { + let list = null; + for (let i = 0; i < 40; i++) { try { list = await getJSON('/json/list'); if (list && list.length) break; } catch {} await sleep(500); } + const target = list.find((t) => t.type === 'page') || list[0]; + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; const pending = new Map(); + const send = (method, params) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); }); + ws.on('message', (raw) => { const m = JSON.parse(String(raw)); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }); + await new Promise((r) => ws.on('open', r)); + await send('Runtime.enable'); + await send('Page.enable'); + await send('Page.addScriptToEvaluateOnNewDocument', { + source: "try{ if(window===window.top && location.origin==='http://127.0.0.1:3080'){ localStorage.setItem('dsh.worktable.projects.v1', JSON.stringify({order:[],lastUsed:{},hidden:[],nameOverrides:{},shortcuts:[],layouts:[{id:'t-layout',title:'L',top:null,left:null,main:[{id:'p1',title:'W',min:200,content:null}],chatWidth:{default:320,min:240,max:600},chatSide:'right',chatFullHeight:false}],bindings:{}})); } }catch(e){}", + }); + await send('Page.navigate', { url: 'http://127.0.0.1:3080/' }); + const evaluate = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.result && r.result.exceptionDetails) return { __error: (r.result.exceptionDetails.exception || {}).description || r.result.exceptionDetails.text }; + return r.result && r.result.result ? r.result.result.value : undefined; + }; + let ready = null; + for (let i = 0; i < 30; i++) { await sleep(1000); ready = await evaluate("!!window.__dshWorktable && !!window.__dshSessions"); if (ready === true) break; } + await sleep(1500); + const step = await evaluate(`(async function(){ + var out = {}; + var sleep = function(ms){ return new Promise(function(r){ setTimeout(r, ms); }); }; + var vis = function(sel){ return [].slice.call(document.querySelectorAll(sel)).find(function(el){ return (getComputedStyle(el).visibility!=='hidden'&&el.getBoundingClientRect().height>0); }); }; + try { + var card = document.querySelector('.dsh-wt_layout'); + var btn = card && card.querySelector('.dsh-wt_bindBtn'); + var read = function(b){ + var cs = getComputedStyle(b); + var c4 = getComputedStyle(b.querySelector('.dsh-wt_bindCircles'), '::before'); + var aft = getComputedStyle(b, '::after'); + return { w: cs.width, h: cs.height, color: cs.color, circleW: c4.width, circleBorder: c4.borderTopWidth, circleFill: c4.backgroundColor, tip: aft.content, hoverBg: cs.backgroundColor }; + }; + // 未绑定态 + out.unbound = read(btn); + // 绑定(UI 路径:点按钮 → 选第一项) + btn.click(); + await sleep(900); + var items = [].slice.call(document.querySelectorAll('.dsh-wt_bindList .dsh-wt_selectItem')); + if (items[0]) { items[0].click(); } + await sleep(900); + var btn2 = card.querySelector('.dsh-wt_bindBtn'); + var bound = read(btn2); + out.bound = bound; + out.boundSolid = bound.circleFill !== 'rgba(0, 0, 0, 0)' && bound.circleFill !== 'transparent'; + out.boundColorIsAccent = /4f8ef7|accent/i.test(bound.color); + out.tipHasName = bound.tip && bound.tip.indexOf('\u5df2\u7ed1\u5b9a') >= 0 && bound.tip.length > 6; + // 清理:解绑 + btn2.click(); + await sleep(900); + var unbind = vis('.dsh-wt_bindUnbind'); + if (unbind) { unbind.click(); } + await sleep(400); + } catch(e) { out.err = String(e); } + return JSON.stringify(out); + })()`); + console.log('BINDICON:', JSON.stringify(step)); + ws.close(); proc.kill(); process.exit(0); +})().catch((e) => { console.error('FATAL', e && e.stack || e); try { proc.kill(); } catch {} process.exit(1); });