From cb8a7e64665581cc1fcaff45865ab98af7401b84 Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Sat, 17 Oct 2026 07:57:17 +0800 Subject: [PATCH] feat(sidebar): replicate WorkBuddy account menu (TODO 09) --- docs/复刻待办清单-2026-10-17.md | 44 +- scripts/rea/probe-account-menu-20261017.json | 18 + .../probe-account-menu-close-20261017.json | 14 + .../rea/probe-account-menu-open-20261017.json | 14 + scripts/verify-account-menu.cjs | 314 +++++++++++++++ src/renderer/src/components/Sidebar.tsx | 24 +- .../src/workbuddy/user-menu/AccountMenu.tsx | 156 ++++++++ .../user-menu/account-menu-icons.tsx | 80 ++++ .../user-menu/account-menu-source.css | 377 ++++++++++++++++++ 9 files changed, 1035 insertions(+), 6 deletions(-) create mode 100644 scripts/rea/probe-account-menu-20261017.json create mode 100644 scripts/rea/probe-account-menu-close-20261017.json create mode 100644 scripts/rea/probe-account-menu-open-20261017.json create mode 100644 scripts/verify-account-menu.cjs create mode 100644 src/renderer/src/workbuddy/user-menu/AccountMenu.tsx create mode 100644 src/renderer/src/workbuddy/user-menu/account-menu-icons.tsx create mode 100644 src/renderer/src/workbuddy/user-menu/account-menu-source.css diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index ca9ead6..c7a2c85 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -258,12 +258,46 @@ ### 09. 账户菜单与页面 -- [ ] 通过 REA 对齐点击当前登录账户后的入口、弹层位置、布局、交互及页面风格。 -- [ ] 内容按用户指定范围:头像与名称、套餐、余额、升级套餐、购买积分、设置、外观、记忆与进化、检查更新、退出登录。 -- [ ] 逐项盘点现有数据和功能,区分可直接接入、待实现与依赖服务端;不以虚构余额、套餐或成功提示代替真实能力。 -- [ ] 验收:已接功能实际生效,弹层关闭与导航返回正常,浅色/深色一致;内容不要求逐项照搬原版账户菜单。 +- [x] 通过 REA 对齐点击当前登录账户后的入口、弹层位置、布局、交互及页面风格。 +- [x] 内容按用户指定范围:头像与名称、套餐、余额、升级套餐、购买积分、设置、外观、记忆与进化、检查更新、退出登录。 +- [x] 逐项盘点现有数据和功能,区分可直接接入、待实现与依赖服务端;不以虚构余额、套餐或成功提示代替真实能力。 +- [x] 验收:已接功能实际生效,弹层关闭与导航返回正常,浅色/深色一致;内容不要求逐项照搬原版账户菜单。 -状态:待 REA 拆解;菜单内容范围已确定。 +状态:已按回收几何/样式实现并通过真实窗口回归(63 项断言);设置与外观接入真实设置窗口,账号/计费/更新类入口明确标注未接入。 + +原版证据: +- 打开态运行时布局 REA Evidence `ev_95a39af88081a641961779c55d5855e0e3d57889e5d2ccad2c3dc15e26e1c8bb`(WorkBuddy 5.8.3,视口 1336×1240,736 个节点):popover 根 `[8,607,320,577]`、`placement-top`、宽度 320、`max-width:calc(100vw - 24px)`(计算值 1312px)、`max-height:calc(100vh - 32px)`、`overflow:hidden auto`、padding 8、gap 8、圆角 16、背景 `rgb(247,247,247)`、`border-top-width` 计算值 1px(声明 `.5px`)、边框色 `rgb(222,222,222)`、阴影 `rgba(0,0,0,.04) 0 6px 24px 0, rgba(0,0,0,.04) 0 4px 6px 0`、字体 14/400/22、`role="menu"`;条目窄条 `.user-menu-item` 显式 `role="button"`(attributes 仅记录 class/id/role)。 +- 同证据结构:`.user-menu-section` `[17,616,302,72]`(白卡 radius 8 / padding 8);`.user-menu-header` `[25,624,286,56]`(min-height 56 / padding 8 / gap 8)、头像框 `.user-menu-header-avatar` `[33,632,40,40]`、名称 `.user-menu-header-name-text` 16/500/24、`.user-menu-header-copy-uid` 20×20 默认 `opacity:0`;`.user-menu-billing-summary` `[17,696,302,102]` + 两个 `.user-menu-billing-cell` `[25|170,704,141,86]`(padding `4px 8px 8px`、title 12/400/20 `rgba(0,0,0,.5)`、value 14/500/22 + `tabular-nums`、`.user-menu-billing-cell-action` margin-top 8 / 高 24);`.user-menu-items` `[25,814,286,307]` gap 2、条目 `[25,*,286,32]`(padding `5px 8px`、radius 8、gap 8、`justify-content:space-between`、图标 16×16);`.user-menu-footer` `[25,1127,286,40]`(margin-top 6 + padding-top 8 + `:before` 1px `var(--wb-border-subtle)`);`::-webkit-scrollbar` 6px / thumb `var(--wb-scrollbar-thumb)` / radius 999。 +- 同证据反证:736 个节点中没有全屏遮罩节点,也没有 overlay/mask/scrim/dismiss 类;因此原版不是靠全屏遮罩关闭,本实现不做 overlay(否则会吞掉外部点击的第一次交互)。 +- 辅助证据:`ev_4162472c…`(5.8.2 账户菜单截图,`.tmp/rea-assessment-user-menu/frame-000.png`)、`ev_c9144986…`(5.8.3 打开→Escape 帧,`.tmp/rea-account-menu-20261017/`)、`ev_1e775995…`(只打开不关闭,`.tmp/rea-account-menu-open-20261017/`)。 +- 回收样式表:`UserMenu-CpHz-0rF.css`(`.user-menu-popover`、`.user-menu-section`、`.user-menu-header*`、`.user-menu-billing-cell*`、`.user-menu-items`、`.user-menu-item*`、`.user-menu-separator`、`.user-menu-footer`、`.user-menu-avatar-initial{background-color:#0056d4}`)与 `tokens-Df-i-zsO.css` / `src-CBaV_A3S.css`(`--wb-bg-popover`:浅 `var(--wb-palette-white-100)` / 深 `#242424`,`.user-menu-popover` 内改写为 `var(--wb-bg-secondary)`;`--wb-popover-radius:var(--wb-radius-2xl)`=16px;`--wb-bg-card` 浅 `white-100` / 深 `#242424`;`--wb-border-subtle` 浅 `gray-3` / 深 `color-mix(in srgb, white 6%, transparent)`;`--wb-dropdown-item-bg-hover` 浅 `#f2f2f2` / 深 `#2e2e2e`;`--wb-scrollbar-thumb` 浅 `black-20` / 深 `white-20`、`--wb-scrollbar-thumb-hover` 浅 `black-30` / 深 `white-30`;`--wb-popover-shadow` 浅 `0 6px 24px 0 #0000000a, 0 4px 6px 0 #0000000a` / 深 `0 6px 24px -4px #0006, 0 4px 6px -8px #0003`)。 +- 探针(可复跑):`scripts/rea/probe-account-menu-20261017.json`(打开 + 稳定帧)、`probe-account-menu-open-20261017.json`、`probe-account-menu-close-20261017.json`(Escape 关闭)。REA 的 layout 快照在 inspect 开始时抓取,早于探针动作,因此打开态证据需要「菜单已处于打开态再采集」。 +- 观察范围:桌面端 5.8.3 浅色主题、zoom=1、1336 宽窗口,账户菜单打开态。未覆盖:原版深色主题运行时像素、原版条目 SVG 路径、账号/计费服务返回的真实数值、外部点击与 Escape 之外的关闭路径。 + +实现: +- `workbuddy/user-menu/AccountMenu.tsx`(新增):`createPortal` 到 `document.body`,根节点 `.ycode-account-menu` 类名按证据拼成 `wb-popover wb-popover--placement-top user-menu-popover user-menu-popover--local user-menu-popover--portal`;`useLayoutEffect` 按 `placement-top` 落位(左边缘对齐触发器、底边距触发器上沿 8px,并按视口夹紧,随 resize 重算);关闭沿用仓库既有 popover 约定(`document` 上的 `pointerdown` 外部判定 + 捕获阶段 Escape,Escape 后焦点回到触发器)。 +- `workbuddy/user-menu/account-menu-source.css`(新增):逐条落地回收规则并把 8 个侧栏作用域缺失的 token 按回收取值补齐;所有规则收敛在 `.ycode-account-menu-scope` 下,避免与其它 `.wb-popover` 作用域互相污染。 +- `workbuddy/user-menu/account-menu-icons.tsx`(新增):设置/外观/记忆与进化/检查更新/退出登录 5 个 16×16 线性图标,暂用 Lucide(MIT)占位并在文件头注明原版路径待回收。 +- `components/Sidebar.tsx`:底部账号触发器改为打开账户菜单(`aria-haspopup="menu"`、`aria-expanded`,记录触发器几何供 popover 落位);收起侧栏时关闭菜单;菜单渲染在 footer 内(portal)。 +- 内容与真实性:头部=mock 账号头像与名称(副标题标注「账号为 mock 数据,尚未接入登录」);计费摘要=套餐/余额两格,值显示「未接入」,升级套餐/购买积分按钮 `disabled` 并带「尚未接入账号与计费服务」提示;设置 → `openSettings('model')`、外观 → `openSettings('appearance')`(值跟随当前主题,显示浅色/深色);记忆与进化、检查更新、退出登录标注「待拆解」/「未接入」,点击只提示,不假装成功。 + +验证:新增 `scripts/verify-account-menu.cjs`(真实隔离 Electron + 真实 preload/IPC/渲染进程 + 真实设置窗口)63 项断言全 PASS: +- 几何/样式:popover 320 宽、左边缘与触发器对齐(实测 x=8)、底边距触发器上沿 8px(popover bottom 846 / 触发器 top 854)、padding 8 / gap 8 / radius 16、浅色背景 `rgb(247,247,247)`、边框计算值 1px `rgb(222,222,222)`、双层阴影、`overflow:hidden auto`、`max-width` 1312px、`max-height` 870px(视口 902)、`role=menu`;三张卡片 302 宽 / padding 8 / radius 8 / 白底。 +- 头部与计费:min-height 56 / padding 8 / gap 8、头像框 40×40、初始头像圆形 `rgb(0,86,212)`、名称 16/500/24 且等于 mock 账号名;两个计费单元各 141×86、padding `4px 8px 8px`、title 12/400/20、value 14/500/22、动作行 margin-top 8 / 按钮高 24 / `disabled`、文案「升级套餐」「购买积分」;值恰为「未接入」,整份菜单文本不含任何数字(不虚构套餐/余额/积分)。 +- 条目与 footer:4 个条目各 32 高 / `5px 8px` / radius 8 / gap 8 / space-between / 14/400/22 / 图标 16×16 / `role="button"`;设置显示 `Ctrl+,`、外观显示当前主题、未接入行显示「待拆解」「未接入」且 `aria-disabled="true"`;footer margin-top 6 + padding-top 8 + 1px 分隔线(`rgb(242,242,242)`)、退出登录行 32 高。 +- 交互:触发器点击开/再点关、Escape 关闭并把焦点交还触发器(`aria-expanded` 同步为 false)、外部真实鼠标点击关闭且同一次点击不被吞(点通知铃铛 → 菜单关 + 通知面板开)、设置行复用并导航原生设置窗口到模型面板、外观点亮外观面板。 +- 浅色/深色:通过设置窗口切深色后 popover 背景 `rgb(41,41,41)`、卡片 `rgb(36,36,36)`、边框 `rgb(61,61,61)`、阴影切换为深色 token,「外观」行值同步为「深色」;切回浅色后 `documentElement.dataset.theme` 与 `localStorage['ycode:theme']` 均为 light。 +- 失败路径:未接入行点击后只显示提示、不新开窗口。 +- 回归(单独串行执行,避免多进程 Electron 抢焦点):`npm run typecheck`、`npm run build`、`npm run test:chat`、`npm run test:conversation`、`npm run test:conversation-ipc`、`node scripts/verify-sidebar-footer.cjs`、`node scripts/verify-user-message-edit.cjs` 全部通过。 +- 报告与截图:`.tmp/account-menu-Y1FW3I/report.json`、`account-menu-light.png`、`account-menu-dark.png`。 + +遗留: +1. 原版条目 SVG 路径未回收,5 个图标先用 Lucide(MIT)占位,待 REA 回收后替换。 +2. 账号、套餐、余额、升级套餐、购买积分、检查更新、退出登录均依赖 YCode 不存在的账号/计费/更新服务,按「不渲染无效能力」原则保持未接入并显式标注;原版这些位置显示服务返回的真实数值。 +3. 用户指定范围之外的原版组件未实现:Buddy 加油站 / 去邀约 / 成长计划 / 意见反馈、`.user-menu-header-copy-uid`(复制 UID,默认 `opacity:0`)、子菜单(`.user-menu-popover--portal .user-menu-submenu-wrapper`)。 +4. 头部比原版高 4.19px(原版 56 只有名称行,本实现多一行「账号为 mock 数据」副标题),条目区比原版矮(原版 369 / 本实现 196,因未实现上述范围外条目),popover 总高因此为 408 而非 577。 +5. 原版深色主题未做运行时像素采集,深色按回收 token 对齐(popover `#292929`、卡片 `#242424`)。 +6. 原版外部点击与 Escape 之外的其他关闭路径(滚动、窗口失焦等)未采集。 ## 设置 diff --git a/scripts/rea/probe-account-menu-20261017.json b/scripts/rea/probe-account-menu-20261017.json new file mode 100644 index 0000000..4920696 --- /dev/null +++ b/scripts/rea/probe-account-menu-20261017.json @@ -0,0 +1,18 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + ".user-menu-trigger--workbuddy", + ".user-menu-popover", + ".user-menu-section", + ".user-menu-billing-summary", + ".user-menu-billing-cell", + ".user-menu-trigger--workbuddy-collapsed" + ], + "actions": [ + { "kind": "click", "step_id": "open-user-menu", "selector": ".user-menu-trigger--workbuddy", "index": 0, "frames": 1 }, + { "kind": "wait", "step_id": "menu-stable", "duration_ms": 400, "frames": 1 }, + { "kind": "key", "step_id": "close-user-menu", "key": "Escape", "frames": 0 }, + { "kind": "wait", "step_id": "closed", "duration_ms": 250, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-account-menu-close-20261017.json b/scripts/rea/probe-account-menu-close-20261017.json new file mode 100644 index 0000000..af2d774 --- /dev/null +++ b/scripts/rea/probe-account-menu-close-20261017.json @@ -0,0 +1,14 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + ".user-menu-popover", + ".user-menu-section", + ".user-menu-billing-summary", + ".user-menu-items" + ], + "actions": [ + { "kind": "key", "step_id": "close-user-menu", "key": "Escape", "frames": 1 }, + { "kind": "wait", "step_id": "closed", "duration_ms": 250, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-account-menu-open-20261017.json b/scripts/rea/probe-account-menu-open-20261017.json new file mode 100644 index 0000000..ae8d50f --- /dev/null +++ b/scripts/rea/probe-account-menu-open-20261017.json @@ -0,0 +1,14 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + ".user-menu-popover", + ".user-menu-section", + ".user-menu-billing-summary", + ".user-menu-items" + ], + "actions": [ + { "kind": "click", "step_id": "open-user-menu", "selector": ".user-menu-trigger--workbuddy", "index": 0, "frames": 1 }, + { "kind": "wait", "step_id": "menu-stable", "duration_ms": 500, "frames": 1 } + ] +} diff --git a/scripts/verify-account-menu.cjs b/scripts/verify-account-menu.cjs new file mode 100644 index 0000000..e13679b --- /dev/null +++ b/scripts/verify-account-menu.cjs @@ -0,0 +1,314 @@ +// 真实隔离 YCode App + 真实 preload/IPC/渲染进程,验证侧栏账户菜单(TODO 09)。 +// 原版期望值来自固定 REA 归档(ev_95a39af88081a641961779c55d5855e0e3d57889e5d2ccad2c3dc15e26e1c8bb: +// WorkBuddy 5.8.3 账户菜单打开状态,视口 1336×1240;辅助证据 ev_4162472c…/ev_c9144986…)。 +// 几何契约:popover [8,607,320,577]、placement-top(左边缘与触发器对齐、底边距触发器上沿 8px)、 +// padding 8、radius 16、gap 8、背景 rgb(247,247,247)(深色 #292929); +// 卡片 .user-menu-section 302 宽 padding 8 radius 8 背景 rgb(255,255,255)(深色 #242424); +// 头部 56 高(头像 40×40、名称 16/500/24);计费单元 141×86(title 12/20、value 14/500/22、动作按钮 24 高); +// 条目 32 高 padding 5px 8px radius 8 gap 8 space-between(图标 16×16);footer margin-top 6 + padding-top 8 + 1px 线。 +// 内容范围按用户指定:头像与名称、套餐、余额、升级套餐、购买积分、设置、外观、记忆与进化、检查更新、退出登录。 +// 原版 736 个布局节点里没有遮罩层节点,因此本实现不做全屏 overlay:外部点击既不吞第一次交互,也要关闭菜单。 +const fs = require('node:fs/promises') +const path = require('node:path') +const assert = require('node:assert/strict') + +if (!process.versions.electron) { + ;(async () => { + const directory = await fs.mkdtemp(path.resolve('.tmp/account-menu-')) + const data = path.join(directory, 'data') + const home = path.join(directory, 'home') + const cwd = path.join(directory, 'fixture-workspace') + await fs.mkdir(data); await fs.mkdir(home); await fs.mkdir(cwd) + const now = Date.now() + const sessions = [{ id: 'account-menu-live', title: 'Account Menu Live', cwd, createdAt: now, updatedAt: now + 1, messages: [ + { id: 'account-menu-user', role: 'user', content: '账户菜单验收', createdAt: now, status: 'done', requestId: 'account-menu-run' }, + { id: 'account-menu-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'account-menu-run', tools: [], parts: [] } + ] }] + await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions })) + const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, + YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1' } + delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL + const child = require('node:child_process').spawn(require('electron'), + ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('ACCOUNT_MENU_')) process.stdout.write(bytes) }) + child.stderr.on('data', bytes => { output += bytes }) + const timer = setTimeout(() => child.kill(), 240000) + child.once('exit', async code => { + clearTimeout(timer) + await fs.writeFile(path.join(directory, 'process.log'), output) + console.log(JSON.stringify({ directory, exitCode: code })) + if (code) console.error(output) + process.exitCode = code ? 1 : 0 + }) + })() +} else { + const { app, BrowserWindow } = require('electron') + app.disableHardwareAcceleration() + const directory = process.env.YCODE_QA_DIRECTORY + assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('account-menu-')) + app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data')) + app.setPath('home', path.join(directory, 'home')) + let main, settings, phase = 'boot' + const checks = [], snapshots = {}, rendererErrors = [] + const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) + async function read(source, win = main) { + let timer + try { + return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => { + timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 100))), 15000) + })]) + } catch (error) { throw Error(error.message + ' - ' + source.slice(0, 240)) } finally { clearTimeout(timer) } + } + async function wait(source, label, win = main) { + for (let index = 0; index < 250; index++) { const value = await read(source, win); if (value) return value; await pause(30) } + throw Error('Timeout: ' + label) + } + async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) } + function check(label, actual, expected) { + const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) + checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) + } + function observe(win) { win.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push({ windowId: win.id, message }) }) } + async function shot(name, win = main) { + if (!win.webContents.debugger.isAttached()) win.webContents.debugger.attach('1.3') + const image = await win.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }) + await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(image.data, 'base64')) + } + async function openSettings(section) { + await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`) + for (let index = 0; index < 200; index++) { + settings = BrowserWindow.getAllWindows().find(win => win !== main) + if (settings && !settings.webContents.isLoading()) break + await pause(25) + } + assert.ok(settings, 'native settings window') + await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings) + return settings + } + async function clickText(selector, text, win = main) { + await read(`(()=>{const node=[...document.querySelectorAll(${JSON.stringify(selector)})].find(item=>item.textContent.trim()===${JSON.stringify(text)});if(!node)throw Error('Missing text button: '+${JSON.stringify(text)});node.click()})()`, win) + await pause(80) + } + // 真实鼠标事件(CDP Input):会依次派发 pointerdown/mousedown/click,用于验证「外部点击」这类依赖 pointerdown 的关闭逻辑。 + async function mouseClick(selector, win = main) { + const point = await read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)return null;const box=node.getBoundingClientRect();return [box.x+box.width/2,box.y+box.height/2]})()`, win) + assert.ok(point, 'mouse target: ' + selector) + if (!win.webContents.debugger.isAttached()) win.webContents.debugger.attach('1.3') + await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x: point[0], y: point[1], button: 'left', clickCount: 1 }) + await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point[0], y: point[1], button: 'left', clickCount: 1 }) + await pause(80) + } + + const openMenu = `(()=>{const trigger=document.querySelector('.user-menu-trigger--workbuddy');if(!trigger)return false;trigger.click();return true})()` + const clickRow = label => `(()=>{const rows=[...document.querySelectorAll('.ycode-account-menu .user-menu-item')];const match=rows.find(row=>row.textContent.includes(${JSON.stringify(label)}));if(!match)return false;match.click();return true})()` + const rowSource = `(()=>{const rows=[...document.querySelectorAll('.ycode-account-menu .user-menu-item')];return rows.map(row=>({text:row.textContent.replace(/\\s+/g,' ').trim(),ariaDisabled:row.getAttribute('aria-disabled'),role:row.getAttribute('role')}))})()` + + const popoverSource = `(()=>{const trigger=document.querySelector('.user-menu-trigger--workbuddy');const pop=document.querySelector('.ycode-account-menu');if(!trigger||!pop)return null; + const style=getComputedStyle(pop);const rect=pop.getBoundingClientRect();const t=trigger.getBoundingClientRect(); + const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]}; + const font=node=>{const s=getComputedStyle(node);return s.fontSize+'/'+s.fontWeight+'/'+s.lineHeight}; + const sections=[...pop.querySelectorAll(':scope > .user-menu-section')]; + const header=pop.querySelector('.user-menu-header'),avatar=pop.querySelector('.user-menu-header-avatar'),avatarInitial=pop.querySelector('.user-menu-header-avatar .user-menu-avatar-initial'),name=pop.querySelector('.user-menu-header-name-text'); + const cells=[...pop.querySelectorAll('.user-menu-billing-cell')]; + const items=[...pop.querySelectorAll('.user-menu-items .user-menu-item')]; + const footer=pop.querySelector('.user-menu-footer'),logout=pop.querySelector('.user-menu-item--logout'); + return {viewport:[innerWidth,innerHeight],theme:document.documentElement.dataset.theme??'light', + trigger:{box:box(trigger),left:t.left,top:t.top,haspopup:trigger.getAttribute('aria-haspopup'),expanded:trigger.getAttribute('aria-expanded')}, + popover:{box:box(pop),left:rect.left,bottom:rect.bottom,width:rect.width,height:rect.height,padding:style.padding,gap:style.rowGap,radius:style.borderRadius, + background:style.backgroundColor,borderWidth:style.borderTopWidth,borderColor:style.borderTopColor,shadow:style.boxShadow,overflowY:style.overflowY,overflowX:style.overflowX, + maxHeight:style.maxHeight,maxWidth:style.maxWidth,position:style.position,zIndex:style.zIndex,display:style.display,role:pop.getAttribute('role'),label:pop.getAttribute('aria-label')}, + sections:sections.map(node=>({box:box(node),padding:getComputedStyle(node).padding,radius:getComputedStyle(node).borderRadius,background:getComputedStyle(node).backgroundColor})), + header:header?{box:box(header),minHeight:getComputedStyle(header).minHeight,padding:getComputedStyle(header).padding,gap:getComputedStyle(header).gap,display:getComputedStyle(header).display}:null, + avatar:avatar?{box:box(avatar),radius:getComputedStyle(avatar).borderRadius}:null, + avatarInitial:avatarInitial?{box:box(avatarInitial),radius:getComputedStyle(avatarInitial).borderRadius,background:getComputedStyle(avatarInitial).backgroundColor}:null, + name:name?{text:name.textContent,font:font(name),color:getComputedStyle(name).color}:null, + cells:cells.map(cell=>{const title=cell.querySelector('.user-menu-billing-cell-title'),value=cell.querySelector('.user-menu-billing-cell-value'), + action=cell.querySelector('.user-menu-billing-cell-action'),button=cell.querySelector('button'); + return {box:box(cell),padding:getComputedStyle(cell).padding, + title:title?{text:title.textContent,font:font(title),color:getComputedStyle(title).color}:null, + value:value?{text:value.textContent,font:font(value)}:null, + action:action?{box:box(action),marginTop:getComputedStyle(action).marginTop}:null, + button:button?{text:button.textContent.trim(),box:box(button),height:parseFloat(getComputedStyle(button).height),disabled:button.disabled,title:button.getAttribute('title')}:null}}), + items:items.map(item=>{const icon=item.querySelector('.user-menu-item-icon'),svg=item.querySelector('.user-menu-item-icon svg'), + label=item.querySelector('.user-menu-item-label'),value=item.querySelector('.user-menu-item-value'),s=getComputedStyle(item); + return {label:label?label.textContent:null,value:value?value.textContent:null,box:box(item),padding:s.padding,radius:s.borderRadius,gap:s.gap,justify:s.justifyContent,font:font(item), + icon:icon?box(icon):null,svg:svg?box(svg):null,role:item.getAttribute('role'),ariaDisabled:item.getAttribute('aria-disabled'),disabled:item.disabled===true}}), + itemsGap:getComputedStyle(pop.querySelector('.user-menu-items')).gap, + footer:footer?{box:box(footer),marginTop:getComputedStyle(footer).marginTop,paddingTop:getComputedStyle(footer).paddingTop,ruleHeight:getComputedStyle(footer,'::before').height,ruleColor:getComputedStyle(footer,'::before').backgroundColor}:null, + logout:logout?{text:logout.textContent.replace(/\\s+/g,' ').trim(),box:box(logout),font:font(logout)}:null, + digits:/[0-9]/.test(pop.textContent.replace(/Ctrl,\\s*/g,'')), + text:pop.textContent.replace(/\\s+/g,' ').trim()}})()` + + ;(async () => { + const { pathToFileURL } = require('node:url') + await import(pathToFileURL(path.resolve('out/main/index.mjs')).href) + await app.whenReady() + main = BrowserWindow.getAllWindows()[0] + main.setSize(1336, 902) + main.showInactive() + main.webContents.debugger.attach('1.3') + observe(main) + await wait('!!document.querySelector("#root")', 'renderer root') + await pause(1500) + await wait(`!!document.querySelector('.user-menu-trigger--workbuddy')`, 'account trigger') + check('account trigger exposes the menu semantics', await read(`(()=>{const trigger=document.querySelector('.user-menu-trigger--workbuddy');return [trigger.getAttribute('aria-haspopup'),trigger.getAttribute('aria-expanded')]})()`), ['menu', 'false']) + + await step('menu-open-light') + assert.equal(await read(openMenu), true) + await wait('!!document.querySelector(".ycode-account-menu")', 'account menu open') + await pause(200) + const light = await read(popoverSource) + snapshots.light = light + check('popover keeps the recovered 320px width', light.popover.width, 320) + check('popover is placement-top: left edge aligned with the trigger', Math.abs(light.popover.left - light.trigger.left) < 0.5, true) + check('popover sits 8px above the trigger', Math.round(light.trigger.top - light.popover.bottom), 8) + check('popover keeps padding/radius/gap', [light.popover.padding, light.popover.radius, light.popover.gap], ['8px', '16px', '8px']) + check('popover keeps the recovered light background', light.popover.background, 'rgb(247, 247, 247)') + // 原版声明为 .5px,Chromium 计算值即 1px(ev_95a39af8… 记录 border-top-width=1px)。 + check('popover keeps the recovered hairline border', [light.popover.borderWidth, light.popover.borderColor], ['1px', 'rgb(222, 222, 222)']) + check('popover keeps the recovered two-layer shadow', light.popover.shadow, 'rgba(0, 0, 0, 0.04) 0px 6px 24px 0px, rgba(0, 0, 0, 0.04) 0px 4px 6px 0px') + check('popover scrolls like the original', light.popover.overflowY === 'auto' && light.popover.overflowX === 'hidden', true) + check('popover keeps max-height calc(100vh - 32px)', light.popover.maxHeight, `${light.viewport[1] - 32}px`) + check('popover keeps max-width calc(100vw - 24px)', light.popover.maxWidth, `${light.viewport[0] - 24}px`) + check('popover is a fixed menu layer above the shell', light.popover.position === 'fixed' && Number(light.popover.zIndex) >= 9999, true) + check('popover is exposed as an accessible menu', [light.popover.role, light.popover.label], ['menu', '账户菜单']) + check('popover has three 302-wide cards with 8px padding', light.sections.map(section => [Math.round(section.box[2]), section.padding]), [[302, '8px'], [302, '8px'], [302, '8px']]) + check('cards keep radius 8 on the recovered white fill', light.sections.map(section => [section.radius, section.background]), [['8px', 'rgb(255, 255, 255)'], ['8px', 'rgb(255, 255, 255)'], ['8px', 'rgb(255, 255, 255)']]) + check('header keeps the 56px min-height with 8px padding', [light.header.minHeight, light.header.padding, light.header.gap], ['56px', '8px', '8px']) + check('header avatar box is 40x40 like the original wrapper', [light.avatar.box[2], light.avatar.box[3]], [40, 40]) + check('header avatar initial is round on the recovered #0056d4 fill', [light.avatarInitial.radius, light.avatarInitial.background], ['50%', 'rgb(0, 86, 212)']) + check('header shows the signed-in mock account name', light.name.text, 'xiaohan.yuan') + check('header name keeps 16/500/24 typography', light.name.font, '16px/500/24px') + check('billing summary keeps two 141-wide cells', light.cells.map(cell => Math.round(cell.box[2])), [141, 141]) + check('billing cells keep the recovered padding/typography', light.cells.map(cell => [cell.padding, cell.title.font, cell.value.font]), [['4px 8px 8px', '12px/400/20px', '14px/500/22px'], ['4px 8px 8px', '12px/400/20px', '14px/500/22px']]) + check('billing cells are titled 套餐/余额', light.cells.map(cell => cell.title.text), ['套餐', '余额']) + check('billing actions keep the 24px row with an 8px top margin', light.cells.map(cell => [cell.action.marginTop, Math.round(cell.button.height)]), [['8px', 24], ['8px', 24]]) + check('upgrade/purchase entries say 升级套餐/购买积分', light.cells.map(cell => cell.button.text), ['升级套餐', '购买积分']) + check('unwired billing actions are disabled, not fake-successful', light.cells.map(cell => cell.button.disabled), [true, true]) + check('billing values do not invent 套餐/余额 numbers', light.cells.map(cell => cell.value.text), ['未接入', '未接入']) + check('menu items keep the recovered 32px row', light.items.map(item => [item.label, Math.round(item.box[3]), item.padding, item.radius, item.gap, item.justify]), + [['设置', 32, '5px 8px', '8px', '8px', 'space-between'], ['外观', 32, '5px 8px', '8px', '8px', 'space-between'], + ['记忆与进化', 32, '5px 8px', '8px', '8px', 'space-between'], ['检查更新', 32, '5px 8px', '8px', '8px', 'space-between']]) + check('menu items keep 14/400/22 typography', light.items.map(item => item.font), ['14px/400/22px', '14px/400/22px', '14px/400/22px', '14px/400/22px']) + check('menu items keep 16x16 icon boxes', light.items.map(item => item.icon && [item.icon[2], item.icon[3]]), [[16, 16], [16, 16], [16, 16], [16, 16]]) + // 原版容器 role="menu",条目显式 role="button"(ev_95a39af8… 的 attributes 只记录 class/id/role)。 + check('menu items keep the recovered role=button', light.items.map(item => item.role), ['button', 'button', 'button', 'button']) + check('menu items keep the recovered 2px gap', light.itemsGap, '2px') + check('settings row shows the Ctrl+, shortcut', light.items[0].value, 'Ctrl+,') + check('appearance row reports the current light theme', light.items[1].value, '浅色') + check('unwired rows stay honest about their state', [light.items[2].value, light.items[3].value], ['待拆解', '未接入']) + check('footer keeps margin-top 6 + padding-top 8 + 1px rule', [light.footer.marginTop, light.footer.paddingTop, light.footer.ruleHeight], ['6px', '8px', '1px']) + check('logout row keeps the 32px row inside the footer', [Math.round(light.logout.box[3]), light.logout.font], [32, '14px/400/22px']) + check('logout entry is present and honest', /退出登录.*未接入/.test(light.logout.text), true) + check('menu content matches the requested scope', light.text, + 'Xxiaohan.yuan账号为 mock 数据,尚未接入登录套餐未接入升级套餐余额未接入购买积分设置Ctrl+,外观浅色记忆与进化待拆解检查更新未接入退出登录未接入') + check('menu shows no fabricated 套餐/余额/积分 numbers', light.digits, false) + await shot('account-menu-light') + + await step('menu-escape-close') + await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`) + await wait('!document.querySelector(".ycode-account-menu")', 'menu closed by Escape') + check('Escape closes the account menu', await read('!document.querySelector(".ycode-account-menu")'), true) + check('Escape returns focus to the account trigger', await read(`document.activeElement===document.querySelector('.user-menu-trigger--workbuddy')`), true) + check('trigger reports collapsed state after close', await read(`document.querySelector('.user-menu-trigger--workbuddy').getAttribute('aria-expanded')`), 'false') + + await step('menu-trigger-toggle') + await read(openMenu) + await wait('!!document.querySelector(".ycode-account-menu")', 'menu reopened') + await read(openMenu) + await wait('!document.querySelector(".ycode-account-menu")', 'menu toggled closed') + check('clicking the trigger again closes the menu', await read('!document.querySelector(".ycode-account-menu")'), true) + + await step('menu-outside-click-close') + await read(openMenu) + await wait('!!document.querySelector(".ycode-account-menu")', 'menu reopened for outside click') + await mouseClick('.msg-center-bell-btn') + await wait('!document.querySelector(".ycode-account-menu")', 'menu closed by outside click') + await wait('!!document.querySelector(".notif-panel")', 'notification panel opened by the same click') + check('outside click closes the menu without swallowing the click', [await read('!document.querySelector(".ycode-account-menu")'), await read('!!document.querySelector(".notif-panel")')], [true, true]) + await read(`document.querySelector('.notif-panel button[title="关闭"]').click()`) + await wait('!document.querySelector(".notif-panel")', 'notification panel closed') + + await step('menu-appearance-navigation') + await read(openMenu) + await wait('!!document.querySelector(".ycode-account-menu")', 'menu open for appearance') + assert.equal(await read(clickRow('外观')), true) + await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after appearance click') + await openSettings('appearance') + observe(settings) + await wait('!!document.querySelector(".segmented")', 'appearance panel', settings) + check('外观 row opens the real appearance panel', await read(`[...document.querySelectorAll('.segmented__item')].map(node=>node.textContent)`, settings), ['浅色', '深色']) + check('appearance panel marks the current light theme', await read(`[...document.querySelectorAll('.segmented__item')].map(node=>[node.textContent,node.dataset.active])`, settings), [['浅色', 'true'], ['深色', 'false']]) + + await step('menu-dark-theme') + await clickText('.segmented__item', '深色', settings) + await wait(`document.documentElement.dataset.theme==='dark'`, 'main window receives dark theme') + check('appearance row drives the shared theme', await read(`[document.documentElement.dataset.theme,localStorage.getItem('ycode:theme')]`), ['dark', 'dark']) + await read(openMenu) + await wait('!!document.querySelector(".ycode-account-menu")', 'menu open in dark theme') + await pause(200) + const dark = await read(popoverSource) + snapshots.dark = dark + check('dark popover uses the recovered #292929 fill', dark.popover.background, 'rgb(41, 41, 41)') + check('dark cards use the recovered #242424 fill', dark.sections.map(section => section.background), ['rgb(36, 36, 36)', 'rgb(36, 36, 36)', 'rgb(36, 36, 36)']) + check('dark cards keep radius 8 and 8px padding', dark.sections.map(section => [section.radius, section.padding]), [['8px', '8px'], ['8px', '8px'], ['8px', '8px']]) + check('appearance row follows the live dark theme', dark.items[1].value, '深色') + check('dark menu keeps the same geometry', [dark.popover.width, Math.round(dark.trigger.top - dark.popover.bottom)], [320, 8]) + await shot('account-menu-dark') + + await step('menu-settings-navigation') + assert.equal(await read(clickRow('设置')), true) + await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after settings click') + await wait('!!document.querySelector(".models-settings-panel")', 'models panel in the reused settings window', settings) + check('设置 row reuses the native settings window', BrowserWindow.getAllWindows().filter(win => win !== main).length, 1) + check('设置 row lands on the models panel', await read(`!!document.querySelector('.models-settings-panel')&&!document.querySelector('.segmented')`, settings), true) + + await step('menu-light-restore') + await read(`document.querySelector('[data-settings-tab="appearance"]').click()`, settings) + await wait('!!document.querySelector(".segmented")', 'appearance panel again', settings) + await clickText('.segmented__item', '浅色', settings) + await wait(`document.documentElement.dataset.theme==='light'`, 'main window receives light theme') + check('light theme is restored through the same settings window', await read(`[document.documentElement.dataset.theme,localStorage.getItem('ycode:theme')]`), ['light', 'light']) + const settingsId = settings.id + await read(`document.querySelector('.settings-window-close').click()`, settings) + for (let index = 0; index < 200 && !settings.isDestroyed(); index++) await pause(20) + check('settings window closes normally', settings.isDestroyed(), true) + check('closed settings window is gone from the app windows', BrowserWindow.getAllWindows().filter(win => win.id === settingsId).length, 0) + + await step('menu-unwired-rows') + await read(openMenu) + await wait('!!document.querySelector(".ycode-account-menu")', 'menu open for unwired rows') + snapshots.unwiredRows = await read(rowSource) + assert.equal(await read(clickRow('记忆与进化')), true) + await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after unwired row click') + const hint = await wait(`(()=>{const node=document.querySelector('.sidebar__hint');return node?node.textContent:null})()`, 'sidebar hint for the unwired row') + check('记忆与进化 reports a pending state instead of fake success', /待拆解|尚未接入/.test(hint), true) + check('unwired row does not open an extra window', BrowserWindow.getAllWindows().filter(win => win !== main).length, 0) + await wait('!document.querySelector(".sidebar__hint")', 'hint auto-dismissed') + await read(openMenu) + await wait('!!document.querySelector(".ycode-account-menu")', 'menu reopened for remaining rows') + assert.equal(await read(clickRow('检查更新')), true) + await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after 检查更新') + check('检查更新 reports an unwired state', /尚未接入/.test(await wait(`(()=>{const node=document.querySelector('.sidebar__hint');return node?node.textContent:null})()`, 'hint for 检查更新')), true) + await wait('!document.querySelector(".sidebar__hint")', 'second hint auto-dismissed') + await read(openMenu) + await wait('!!document.querySelector(".ycode-account-menu")', 'menu open for logout') + await shot('account-menu-final') + // 记忆与进化 / 检查更新 / 退出登录 三行都要对辅助技术保持 disabled,不假装可用。 + check('unwired rows stay disabled for assistive tech', snapshots.unwiredRows.slice(2).map(row => row.ariaDisabled), ['true', 'true', 'true']) + check('no renderer error logs', rendererErrors, []) + + const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', + scope: 'Real isolated YCode App/preload/IPC/renderer; account popover DOM, computed styles, geometry, dismissal, settings navigation and theme interactivity measured in the live window.', + sourceEvidence: ['ev_95a39af88081a641961779c55d5855e0e3d57889e5d2ccad2c3dc15e26e1c8bb', 'ev_4162472c…', 'ev_c9144986…', 'UserMenu-CpHz-0rF.css'], + snapshots, checks, rendererErrors } + await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) + console.log('ACCOUNT_MENU_' + report.result + ' ' + directory) + for (const failure of checks.filter(item => !item.pass)) console.log('ACCOUNT_MENU_FAILURE ' + JSON.stringify(failure)) + app.exit(report.result === 'PASS' ? 0 : 1) + })().catch(async error => { + await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2)) + console.error(error); app.exit(1) + }) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 283f1df..ba064c0 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import { import { IconSearch } from './icons-extra' import NotificationsPanel from './NotificationsPanel' import WorkspaceDialog from './WorkspaceDialog' +import { AccountMenu } from '../workbuddy/user-menu/AccountMenu' import { TaskContextMenu } from './task-menu' import { IconTaskMore as TaskMoreIcon, IconTaskPinBadge as TaskPinIcon } from './task-menu-icons' import type { TaskMenuAction, TaskMenuAnchor } from './task-menu' @@ -116,6 +117,7 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch const taskStartLock = useRef(false) const [notifOpen, setNotifOpen] = useState(false) const [unread, setUnread] = useState(MOCK_UNREAD_COUNT) + const [accountMenu, setAccountMenu] = useState<{ left: number; top: number; trigger: HTMLElement | null } | null>(null) const [hint, setHint] = useState(null) const hintTimer = useRef(null) const searchRef = useRef(null) @@ -126,6 +128,8 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch window.addEventListener('keydown', close) return () => window.removeEventListener('keydown', close) }, [spaceMenu]) + // 收起侧栏时不存在触发器,账户菜单随之关闭(原版触发器就在该行内)。 + useEffect(() => { if (collapsed) setAccountMenu(null) }, [collapsed]) useLayoutEffect(() => { if (!spaceMenu || !spaceMenuRef.current) return const rect = spaceMenuRef.current.getBoundingClientRect() @@ -447,7 +451,13 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch + + + ))} + + +
+
+ + + + +
+
+ +
+
+ + , + document.body + ) +} + +export default AccountMenu diff --git a/src/renderer/src/workbuddy/user-menu/account-menu-icons.tsx b/src/renderer/src/workbuddy/user-menu/account-menu-icons.tsx new file mode 100644 index 0000000..3a95f57 --- /dev/null +++ b/src/renderer/src/workbuddy/user-menu/account-menu-icons.tsx @@ -0,0 +1,80 @@ +/** + * 账户菜单条目图标。 + * + * 说明:WorkBuddy 账户菜单条目的原始 SVG 路径尚未通过 REA 回收(本轮只采集了菜单的 + * 结构、几何与计算样式,见 docs/复刻待办清单-2026-10-17.md TODO 09)。这里先用 + * Lucide(MIT)同族线性图标的路径占位,几何尺寸按原版 16×16 对齐; + * 回收图标后应替换为原版路径,或在正式发布前替换为自有图标集。 + */ + +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +function StrokeIcon({ children, ...props }: IconProps & { children: React.ReactNode }): React.JSX.Element { + return ( + + ) +} + +/** 设置 */ +export function IconAccountSettings(props: IconProps): React.JSX.Element { + return ( + + + + + ) +} + +/** 外观 */ +export function IconAccountAppearance(props: IconProps): React.JSX.Element { + return ( + + + + + + + + ) +} + +/** 记忆与进化 */ +export function IconAccountMemory(props: IconProps): React.JSX.Element { + return ( + + + + + + + + ) +} + +/** 检查更新 */ +export function IconAccountUpdate(props: IconProps): React.JSX.Element { + return ( + + + + + + + ) +} + +/** 退出登录 */ +export function IconAccountLogout(props: IconProps): React.JSX.Element { + return ( + + + + + + ) +} diff --git a/src/renderer/src/workbuddy/user-menu/account-menu-source.css b/src/renderer/src/workbuddy/user-menu/account-menu-source.css new file mode 100644 index 0000000..1175063 --- /dev/null +++ b/src/renderer/src/workbuddy/user-menu/account-menu-source.css @@ -0,0 +1,377 @@ +/* ---- 账户菜单(TODO 09)---- + 结构与几何来自 REA ev_95a39af88081a641961779c55d5855e0e3d57889e5d2ccad2c3dc15e26e1c8bb + (WorkBuddy 5.8.3 账户菜单打开状态,视口 1336×1240): + 根节点 [8,607,320,577]、placement-top(底边距触发器上沿 8px、左边缘与触发器对齐、宽 320); + 头部区 [17,616,302,72](.user-menu-header 56 高、头像 40×40); + 计费摘要 [17,696,302,102](两个 141×86 单元,title 12/20、value 14/500/22、action 24 高); + 条目区 [17,806,302,369](.user-menu-items 内 32 高条目、1px 分隔线、footer 顶部 1px)。 + 规则逐条取自回收表 UserMenu-CpHz-0rF.css,popover 基类取自 src-Bh3YZ35H.css。 + 侧栏作用域(.workspace-source)没有 popover/卡片相关 token,按回收 token 的浅/深色取值在此补齐。 */ +.ycode-account-menu-scope { + /* 以下 5 个 token 按回收表 tokens-Df-i-zsO.css / src-CBaV_A3S.css 的根作用域取值补齐: + --wb-bg-popover、--wb-popover-radius(--wb-radius-2xl)、--wb-bg-card、--wb-border-subtle、 + --wb-scrollbar-thumb、--wb-scrollbar-thumb-hover、--wb-dropdown-item-bg-hover、 + --wb-todo-menu-text-view-more(侧栏 .workspace-source 作用域未定义它们)。 */ + --wb-bg-popover: var(--wb-palette-white-100); + --wb-popover-padding: 8px; + --wb-radius-2xl: 16px; + --wb-popover-radius: var(--wb-radius-2xl); + --wb-popover-text-color: var(--wb-color-text-primary); + --wb-popover-font-size: var(--wb-font-body-size); + --wb-popover-line-height: var(--wb-font-body-line-height); + --wb-popover-border-color: var(--wb-color-border-solid); + --wb-popover-shadow: 0px 6px 24px 0px #0000000a, 0px 4px 6px 0px #0000000a; + --wb-bg-card: var(--wb-palette-white-100); + --wb-border-subtle: var(--wb-palette-gray-3); + --wb-dropdown-item-bg-hover: var(--wb-color-palette-gray-3, #f2f2f2); + --wb-scrollbar-thumb: var(--wb-palette-black-20); + --wb-scrollbar-thumb-hover: var(--wb-palette-black-30); + --wb-todo-menu-text-view-more: var(--wb-palette-black-50); +} + +[data-theme='dark'] .ycode-account-menu-scope { + --wb-bg-popover: #242424; + --wb-bg-card: #242424; + --wb-border-subtle: color-mix(in srgb, var(--wb-palette-white-100) 6%, transparent); + --wb-dropdown-item-bg-hover: #2e2e2e; + --wb-scrollbar-thumb: var(--wb-palette-white-20); + --wb-scrollbar-thumb-hover: var(--wb-palette-white-30); + --wb-todo-menu-text-view-more: var(--wb-palette-white-50); + --wb-popover-shadow: 0px 6px 24px -4px #0006, 0px 4px 6px -8px #0003; +} + +.ycode-account-menu { + z-index: 10000; +} + +/* .wb-popover 基类(src-Bh3YZ35H.css) */ +.ycode-account-menu-scope .wb-popover { + background: var(--wb-bg-popover); + border-radius: var(--wb-popover-radius); + color: var(--wb-popover-text-color); + padding: var(--wb-popover-padding); + font-size: var(--wb-popover-font-size); + line-height: var(--wb-popover-line-height); + border: .5px solid var(--wb-popover-border-color); + box-shadow: var(--wb-popover-shadow); + outline: none; + flex-direction: column; + display: flex; +} + +/* .user-menu-popover(UserMenu-CpHz-0rF.css) */ +.ycode-account-menu-scope .user-menu-popover { + /* 原版把 popover 底色在本规则内改写为 --wb-bg-secondary(浅色 rgb(247,247,247) / 深色 #292929) */ + --wb-bg-popover: var(--wb-bg-secondary); + --wb-popover-padding: 8px; + --user-menu-hover-bg: var(--wb-bg-hover); + box-sizing: border-box; + -webkit-app-region: no-drag; + scrollbar-width: thin; + scrollbar-color: var(--wb-scrollbar-thumb) transparent; + gap: 8px; + width: max-content; + min-width: 270px; + max-width: 320px; + max-height: calc(100vh - 32px); + overflow: hidden auto; +} + +.ycode-account-menu-scope .user-menu-popover::-webkit-scrollbar { + background: 0 0 !important; + width: 6px !important; + height: 6px !important; +} + +.ycode-account-menu-scope .user-menu-popover::-webkit-scrollbar-track { + background: 0 0 !important; + border: none !important; +} + +.ycode-account-menu-scope .user-menu-popover::-webkit-scrollbar-thumb { + background-color: var(--wb-scrollbar-thumb) !important; + background-clip: padding-box !important; + border: 1px solid #0000 !important; + border-radius: 999px !important; +} + +.ycode-account-menu-scope .user-menu-popover::-webkit-scrollbar-thumb:hover { + background-color: var(--wb-scrollbar-thumb-hover) !important; +} + +.ycode-account-menu-scope .user-menu-popover::-webkit-scrollbar-corner { + background: 0 0 !important; +} + +.ycode-account-menu-scope .user-menu-popover--local { + width: 320px; + max-width: calc(100vw - 24px); +} + +.ycode-account-menu-scope .user-menu-section { + background: var(--wb-bg-card); + box-sizing: border-box; + border-radius: 8px; + padding: 8px; +} + +/* 头部 */ +.ycode-account-menu-scope .user-menu-header { + box-sizing: border-box; + align-items: center; + gap: 8px; + min-height: 56px; + padding: 8px; + display: flex; +} + +.ycode-account-menu-scope .user-menu-header-avatar { + flex-shrink: 0; + width: 40px; + height: 40px; + display: flex; +} + +/* .user-menu-avatar-initial{background-color:#0056d4}(回收规则)。 */ +.ycode-account-menu-scope .user-menu-avatar-initial { + background-color: #0056d4; +} + +/* YCode 没有账号头像图片:用初始字当头像,尺寸对齐原版 .user-menu-header-avatar 的 40×40。 */ +.ycode-account-menu-scope .user-menu-header-avatar .user-menu-avatar-initial { + width: 40px; + height: 40px; + border-radius: 50%; + color: var(--yc-white-100); + font-size: 14px; + font-weight: 500; + line-height: 40px; + text-align: center; +} + +.ycode-account-menu-scope .user-menu-header-info { + flex: 1; + min-width: 0; +} + +.ycode-account-menu-scope .user-menu-header-name { + color: var(--wb-color-text-primary); + text-overflow: ellipsis; + white-space: nowrap; + align-items: center; + gap: 4px; + min-width: 0; + font-size: 16px; + font-weight: 500; + line-height: 24px; + display: flex; + overflow: hidden; +} + +.ycode-account-menu-scope .user-menu-header-name-text { + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + overflow: hidden; +} + +.ycode-account-menu-scope .user-menu-header-subtitle { + color: var(--wb-color-text-tertiary); + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 2px; + font-size: 13px; + line-height: 1.4; + overflow: hidden; +} + +/* 计费摘要(套餐 / 余额) */ +.ycode-account-menu-scope .user-menu-billing-summary { + align-items: stretch; + gap: 4px; + display: flex; +} + +.ycode-account-menu-scope .user-menu-billing-cell { + box-sizing: border-box; + border-radius: 8px; + flex-direction: column; + flex: 1 1 0; + min-width: 0; + padding: 4px 8px 8px; + display: flex; +} + +.ycode-account-menu-scope .user-menu-billing-cell-title { + color: var(--wb-color-text-tertiary); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + line-height: 20px; + overflow: hidden; +} + +.ycode-account-menu-scope .user-menu-billing-cell-value { + min-width: 0; + min-height: 22px; + color: var(--wb-color-text-primary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + align-items: center; + gap: 4px; + font-size: 14px; + font-weight: 500; + line-height: 22px; + display: flex; +} + +.ycode-account-menu-scope .user-menu-billing-cell-value-main { + text-overflow: ellipsis; + min-width: 0; + overflow: hidden; +} + +.ycode-account-menu-scope .user-menu-billing-cell-action { + margin-top: 8px; +} + +/* 未接入账号/计费服务:按钮保持原版尺寸,但明确禁用而不是假装可用。 */ +.ycode-account-menu .user-menu-billing-cell-action .wb-button { + box-sizing: border-box; + width: 100%; + height: 24px; + border: none; + border-radius: 6px; + justify-content: center; + align-items: center; + font-size: 12px; + line-height: 20px; + display: inline-flex; +} + +.ycode-account-menu .user-menu-billing-cell-action .wb-button:disabled { + background: var(--wb-palette-black-20); + color: var(--wb-color-text-disabled); + cursor: default; +} + +/* 条目区 */ +.ycode-account-menu-scope .user-menu-items { + flex-direction: column; + gap: 2px; + display: flex; + position: relative; + overflow: visible; +} + +.ycode-account-menu-scope .user-menu-separator { + border-bottom: 1px solid var(--wb-border-subtle); + border-radius: 0; + margin: 6px 0; + padding: 0; +} + +.ycode-account-menu-scope .user-menu-item { + width: 100%; + min-height: 32px; + color: var(--wb-color-text-primary); + cursor: pointer; + text-align: left; + box-sizing: border-box; + background: 0 0; + border: none; + border-radius: 8px; + justify-content: space-between; + align-items: center; + gap: 8px; + margin: 0; + padding: 5px 8px; + font-size: 14px; + line-height: 22px; + transition: background-color .15s; + display: flex; +} + +.ycode-account-menu-scope .user-menu-item:hover { + background-color: var(--wb-dropdown-item-bg-hover); +} + +.ycode-account-menu-scope .user-menu-item svg { + flex-shrink: 0; +} + +.ycode-account-menu-scope .user-menu-item--disabled { + cursor: default; +} + +.ycode-account-menu-scope .user-menu-item--disabled:hover { + background-color: #0000; +} + +.ycode-account-menu-scope .user-menu-item-icon { + flex-shrink: 0; + justify-content: center; + align-items: center; + width: 16px; + height: 16px; + display: flex; +} + +.ycode-account-menu-scope .user-menu-item-icon svg { + width: 16px; + height: 16px; +} + +.ycode-account-menu-scope .user-menu-item-label { + flex: 1; +} + +.ycode-account-menu-scope .user-menu-item-value { + align-items: center; + display: flex; +} + +.ycode-account-menu-scope .user-menu-item-shortcut { + color: var(--wb-color-text-tertiary, #00000080); + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 20px; + text-decoration-line: none; +} + +.ycode-account-menu-scope .user-menu-item--appearance .user-menu-item-value, +.ycode-account-menu-scope .user-menu-item--pending .user-menu-item-value { + color: var(--wb-todo-menu-text-view-more); + white-space: nowrap; + flex-shrink: 0; + justify-content: flex-end; + gap: 2px; + font-size: 12px; + line-height: 20px; +} + +.ycode-account-menu-scope .user-menu-footer { + margin-top: 6px; + padding: 8px 0 0; + position: relative; +} + +.ycode-account-menu-scope .user-menu-footer:before { + content: ""; + background: var(--wb-border-subtle); + height: 1px; + position: absolute; + top: 0; + left: 0; + right: 0; +} + +.ycode-account-menu-scope .user-menu-footer .user-menu-item--logout { + color: var(--wb-color-text-primary); +} + +.ycode-account-menu-scope .user-menu-footer .user-menu-item--logout .user-menu-item-label { + align-items: center; + display: flex; +}