feat(sidebar): replicate WorkBuddy account menu (TODO 09)
This commit is contained in:
1 parent
6173a77918
commit
cb8a7e6466
9 files changed
+1035
-6
No files matched your search
@@ -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 之外的其他关闭路径(滚动、窗口失焦等)未采集。
|
||||
|
||||
## 设置
|
||||
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const hintTimer = useRef<number | null>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(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
|
||||
<button
|
||||
className="user-menu-trigger user-menu-trigger--workbuddy"
|
||||
title={`${MOCK_ACCOUNT.name}(账号为 mock 数据)`}
|
||||
onClick={() => setHint('账号为 mock 数据,尚未接入登录')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={accountMenu !== null}
|
||||
onClick={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
setNotifOpen(false)
|
||||
setAccountMenu((current) => current ? null : { left: rect.left, top: rect.top, trigger: event.currentTarget })
|
||||
}}
|
||||
>
|
||||
<span className="user-menu-trigger-left">
|
||||
<span className="user-menu-trigger-avatar">
|
||||
@@ -481,6 +491,18 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
|
||||
</div>
|
||||
|
||||
{notifOpen ? <NotificationsPanel onClose={() => setNotifOpen(false)} /> : null}
|
||||
|
||||
{accountMenu ? <AccountMenu
|
||||
anchor={accountMenu}
|
||||
name={MOCK_ACCOUNT.name}
|
||||
initial={MOCK_ACCOUNT.initial}
|
||||
onClose={() => setAccountMenu(null)}
|
||||
onOpenSettings={(section) => {
|
||||
setAccountMenu(null)
|
||||
if (window.ycode.app.openSettings) void window.ycode.app.openSettings(section)
|
||||
}}
|
||||
onUnavailable={(message) => { setAccountMenu(null); setHint(message) }}
|
||||
/> : null}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 账户菜单(TODO 09)。
|
||||
*
|
||||
* 结构、几何与样式来自 REA ev_95a39af88081a641961779c55d5855e0e3d57889e5d2ccad2c3dc15e26e1c8bb
|
||||
* (WorkBuddy 5.8.3,账户菜单打开状态,视口 1336×1240):
|
||||
* 根节点 [8,607,320,577]、placement-top(底边距触发器上沿 8px、左边缘与触发器对齐、宽 320)、
|
||||
* 头部 56 高(头像 40×40)、计费摘要两个 141×86 单元、条目 32 高 + footer 顶部 1px。
|
||||
*
|
||||
* 内容按用户指定范围:头像与名称、套餐、余额、升级套餐、购买积分、设置、外观、记忆与进化、
|
||||
* 检查更新、退出登录(不逐项照搬原版的 Buddy加油站/去邀约/成长计划/意见反馈)。
|
||||
* YCode 没有账号与计费服务:套餐/余额/升级套餐/购买积分/退出登录/记忆与进化/检查更新
|
||||
* 明确标注未接入,不用虚构数值或成功提示代替真实能力;设置与外观接入真实设置窗口。
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { IconAccountAppearance, IconAccountLogout, IconAccountMemory, IconAccountSettings, IconAccountUpdate } from './account-menu-icons'
|
||||
import './account-menu-source.css'
|
||||
|
||||
export interface AccountMenuAnchor {
|
||||
/** 触发器左边缘(原版 popover 左边缘与触发器对齐) */
|
||||
left: number
|
||||
/** 触发器上边缘(原版 popover 底边距其 8px) */
|
||||
top: number
|
||||
/** 触发器元素:外部点击判定与 Escape 后焦点返回都用它 */
|
||||
trigger: HTMLElement | null
|
||||
}
|
||||
|
||||
export interface AccountMenuProps {
|
||||
anchor: AccountMenuAnchor
|
||||
name: string
|
||||
initial: string
|
||||
onClose(): void
|
||||
onOpenSettings(section: 'model' | 'appearance'): void
|
||||
onUnavailable(message: string): void
|
||||
}
|
||||
|
||||
export function AccountMenu({ anchor, name, initial, onClose, onOpenSettings, onUnavailable }: AccountMenuProps): React.JSX.Element {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState<{ left: number; bottom: number }>({ left: anchor.left, bottom: 0 })
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')
|
||||
|
||||
// 「外观」行显示当前主题;主题切换由 useSharedTheme 写在 documentElement.dataset.theme 上。
|
||||
useEffect(() => {
|
||||
const update = (): void => setTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')
|
||||
const observer = new MutationObserver(update)
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// 关闭由文档级监听完成:REA 证据 ev_95a39af8… 的 736 个节点里没有遮罩层节点,
|
||||
// 因此不做全屏 overlay(那会吃掉外部点击的第一次交互),与 SourceSelectPopover 同款约定。
|
||||
useEffect(() => {
|
||||
const outside = (event: PointerEvent): void => {
|
||||
if (event.button !== 0 || !(event.target instanceof Node)) return
|
||||
if (ref.current?.contains(event.target) || anchor.trigger?.contains(event.target)) return
|
||||
onClose()
|
||||
}
|
||||
const escape = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault(); event.stopPropagation(); onClose()
|
||||
anchor.trigger?.focus({ preventScroll: true })
|
||||
}
|
||||
document.addEventListener('pointerdown', outside)
|
||||
document.addEventListener('keydown', escape, true)
|
||||
return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape, true) }
|
||||
}, [anchor.trigger, onClose])
|
||||
|
||||
// 原版为 placement-top:先按锚点落位,再按视口夹紧(与任务菜单同一套做法)。
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element) return
|
||||
const place = (): void => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const reference = anchor.trigger?.getBoundingClientRect()
|
||||
const left = Math.max(4, Math.min(reference?.left ?? anchor.left, window.innerWidth - rect.width - 4))
|
||||
const bottom = Math.max(4, window.innerHeight - (reference?.top ?? anchor.top) + 8)
|
||||
setPosition({ left, bottom })
|
||||
}
|
||||
place()
|
||||
window.addEventListener('resize', place)
|
||||
return () => window.removeEventListener('resize', place)
|
||||
}, [anchor])
|
||||
|
||||
return createPortal(
|
||||
<div className="workspace-source ycode-account-menu-scope">
|
||||
<div ref={ref} role="menu" aria-label="账户菜单"
|
||||
className="wb-popover wb-popover--placement-top user-menu-popover user-menu-popover--local user-menu-popover--portal ycode-account-menu"
|
||||
style={{ position: 'fixed', left: position.left, bottom: position.bottom }}
|
||||
onClick={(event) => event.stopPropagation()}>
|
||||
<section className="user-menu-section">
|
||||
<div className="user-menu-header">
|
||||
<div className="user-menu-header-avatar"><span className="user-menu-avatar-initial">{initial}</span></div>
|
||||
<div className="user-menu-header-info">
|
||||
<div className="user-menu-header-name"><span className="user-menu-header-name-text">{name}</span></div>
|
||||
<div className="user-menu-header-subtitle">账号为 mock 数据,尚未接入登录</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="user-menu-section user-menu-billing-summary">
|
||||
{[{ title: '套餐', action: '升级套餐' }, { title: '余额', action: '购买积分' }].map((cell) => (
|
||||
<div className="user-menu-billing-cell" key={cell.title}>
|
||||
<div className="user-menu-billing-cell-title">{cell.title}</div>
|
||||
<div className="user-menu-billing-cell-value"><span className="user-menu-billing-cell-value-main">未接入</span></div>
|
||||
<div className="user-menu-billing-cell-action">
|
||||
<button type="button" className="wb-button" disabled title="尚未接入账号与计费服务">{cell.action}</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="user-menu-section">
|
||||
<div className="user-menu-items">
|
||||
<button type="button" className="user-menu-item" role="button" onClick={() => onOpenSettings('model')}>
|
||||
<span className="user-menu-item-icon"><IconAccountSettings /></span>
|
||||
<span className="user-menu-item-label">设置</span>
|
||||
<span className="user-menu-item-value"><span className="user-menu-item-shortcut">Ctrl+,</span></span>
|
||||
</button>
|
||||
<button type="button" className="user-menu-item user-menu-item--appearance" role="button" onClick={() => onOpenSettings('appearance')}>
|
||||
<span className="user-menu-item-icon"><IconAccountAppearance /></span>
|
||||
<span className="user-menu-item-label">外观</span>
|
||||
<span className="user-menu-item-value">{theme === 'dark' ? '深色' : '浅色'}</span>
|
||||
</button>
|
||||
<button type="button" aria-disabled="true" title="记忆与进化尚未拆解"
|
||||
className="user-menu-item user-menu-item--pending user-menu-item--disabled" role="button"
|
||||
onClick={() => onUnavailable('「记忆与进化」尚未接入(待拆解)')}>
|
||||
<span className="user-menu-item-icon"><IconAccountMemory /></span>
|
||||
<span className="user-menu-item-label">记忆与进化</span>
|
||||
<span className="user-menu-item-value">待拆解</span>
|
||||
</button>
|
||||
<button type="button" aria-disabled="true" title="尚未接入更新检查"
|
||||
className="user-menu-item user-menu-item--pending user-menu-item--disabled" role="button"
|
||||
onClick={() => onUnavailable('「检查更新」尚未接入')}>
|
||||
<span className="user-menu-item-icon"><IconAccountUpdate /></span>
|
||||
<span className="user-menu-item-label">检查更新</span>
|
||||
<span className="user-menu-item-value">未接入</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="user-menu-footer">
|
||||
<button type="button" aria-disabled="true" title="尚未接入账号服务"
|
||||
className="user-menu-item user-menu-item--logout user-menu-item--pending user-menu-item--disabled" role="button"
|
||||
onClick={() => onUnavailable('尚未接入账号服务,无法退出登录')}>
|
||||
<span className="user-menu-item-icon"><IconAccountLogout /></span>
|
||||
<span className="user-menu-item-label">退出登录</span>
|
||||
<span className="user-menu-item-value">未接入</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountMenu
|
||||
@@ -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<SVGSVGElement>
|
||||
|
||||
function StrokeIcon({ children, ...props }: IconProps & { children: React.ReactNode }): React.JSX.Element {
|
||||
return (
|
||||
<svg {...props} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 设置 */
|
||||
export function IconAccountSettings(props: IconProps): React.JSX.Element {
|
||||
return (
|
||||
<StrokeIcon {...props}>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</StrokeIcon>
|
||||
)
|
||||
}
|
||||
|
||||
/** 外观 */
|
||||
export function IconAccountAppearance(props: IconProps): React.JSX.Element {
|
||||
return (
|
||||
<StrokeIcon {...props}>
|
||||
<circle cx="13.5" cy="6.5" r=".5" />
|
||||
<circle cx="17.5" cy="10.5" r=".5" />
|
||||
<circle cx="8.5" cy="7.5" r=".5" />
|
||||
<circle cx="6.5" cy="12.5" r=".5" />
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
|
||||
</StrokeIcon>
|
||||
)
|
||||
}
|
||||
|
||||
/** 记忆与进化 */
|
||||
export function IconAccountMemory(props: IconProps): React.JSX.Element {
|
||||
return (
|
||||
<StrokeIcon {...props}>
|
||||
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
|
||||
<path d="M20 3v4" />
|
||||
<path d="M22 5h-4" />
|
||||
<path d="M4 17v2" />
|
||||
<path d="M5 18H3" />
|
||||
</StrokeIcon>
|
||||
)
|
||||
}
|
||||
|
||||
/** 检查更新 */
|
||||
export function IconAccountUpdate(props: IconProps): React.JSX.Element {
|
||||
return (
|
||||
<StrokeIcon {...props}>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</StrokeIcon>
|
||||
)
|
||||
}
|
||||
|
||||
/** 退出登录 */
|
||||
export function IconAccountLogout(props: IconProps): React.JSX.Element {
|
||||
return (
|
||||
<StrokeIcon {...props}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</StrokeIcon>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user