From cbde6ec092dcfd0a346d276eab853706a4f1577a Mon Sep 17 00:00:00 2001 From: ganpig Date: Sun, 25 Oct 2026 13:07:48 +0800 Subject: [PATCH 001/185] =?UTF-8?q?fix(chat):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=A6=96=E5=B1=8F=E8=AF=B7=E6=B1=82=E5=A4=B1=E6=95=88=E5=90=8E?= =?UTF-8?q?=E6=8C=81=E7=BB=AD=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../src/renderer/src/lib/conversationEntry.ts | 109 ++++++++++------- .../src/renderer/src/views/MainView.tsx | 53 ++++---- apps/desktop/test/conversation_entry.test.ts | 115 ++++++++++++++++++ docs/develop/conversation-loading-20261025.md | 18 +++ 5 files changed, 224 insertions(+), 72 deletions(-) create mode 100644 docs/develop/conversation-loading-20261025.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f78c003d..a3cf53d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -540,3 +540,4 @@ - 修复旧版 QQ 本地备份的群成员、群状态、文件查找和可选功能表不兼容的问题,避免缺少新版字段导致页面加载失败或误判已退群。 - 修复旧图片缺少文件名时,多条消息串用同一张缩略图的问题;按图片 MD5 精确查找,缺失资源不再误用其它图片,并刷新旧显示缓存。 - 修复阅读旧消息时意外跳向新消息的问题:后台刷新保留已加载历史,向上翻页按可见消息保持位置,延迟贴底不再覆盖用户滚动。 +- 修复聊天消息区在首屏请求失效后一直显示加载骨架的问题,同一会话自动结束加载并重新校准当前窗口,切换账号或会话不受旧请求影响。 diff --git a/apps/desktop/src/renderer/src/lib/conversationEntry.ts b/apps/desktop/src/renderer/src/lib/conversationEntry.ts index d3c63a421..b925d5682 100644 --- a/apps/desktop/src/renderer/src/lib/conversationEntry.ts +++ b/apps/desktop/src/renderer/src/lib/conversationEntry.ts @@ -18,6 +18,9 @@ export async function loadConversationEntry({ current, show, cursor = (rows) => rows.at(-1)?.msgSeq, + resolveReadSeq, + active = current, + settled, }: { cached?: EntryWindow; dirty: boolean; @@ -30,54 +33,76 @@ export async function loadConversationEntry({ current: () => boolean; show: (window: EntryWindow, first: boolean) => void; cursor?: (rows: T[]) => string | undefined; + resolveReadSeq?: () => Promise; + /** 窗口已改变时可拒收结果,但仍由原会话负责结束加载;切号/切会话后则不收尾。 */ + active?: () => boolean; + settled?: () => void; }): Promise { - if (!readFromFirstUnread) { - cached = undefined; - readSeq = undefined; - } - if (cached?.rows.length === 0) cached = undefined; - let window = cached; - if (!window) { - if (readSeq && /^\d+$/.test(readSeq)) { - const rows = await before(String(BigInt(readSeq) + 1n), limit); + try { + if (!current()) return; + if (!readFromFirstUnread) { + cached = undefined; + readSeq = undefined; + } + if (cached?.rows.length === 0) cached = undefined; + if (readFromFirstUnread && !cached && !readSeq && resolveReadSeq) { + readSeq = await resolveReadSeq(); + if (!current()) return; + } + let window = cached; + if (!window) { + if (readSeq && /^\d+$/.test(readSeq)) { + const rows = await before(String(BigInt(readSeq) + 1n), limit); + if (!current()) return; + window = { + rows: rows.reverse(), + anchored: false, + hasOlder: rows.length >= limit, + hasNewer: true, + }; + show(window, true); + } else { + const rows = await latest(limit); + if (current()) + show( + { + rows: rows.reverse(), + anchored: true, + hasOlder: rows.length >= limit, + hasNewer: false, + }, + true, + ); + return; + } + } else if (dirty && /^[1-9]\d*$/.test(cursor(window.rows) ?? '')) { + const size = Math.min(200, Math.max(limit, window.rows.length)); + const rows = await before(String(BigInt(cursor(window.rows)!) + 1n), size); if (!current()) return; window = { + ...window, rows: rows.reverse(), - anchored: false, - hasOlder: rows.length >= limit, - hasNewer: true, + hasOlder: window.hasOlder || rows.length >= size, }; - show(window, true); - } else { - const rows = await latest(limit); - if (current()) - show( - { rows: rows.reverse(), anchored: true, hasOlder: rows.length >= limit, hasNewer: false }, - true, - ); - return; + show(window, false); } - } else if (dirty && /^[1-9]\d*$/.test(cursor(window.rows) ?? '')) { - const size = Math.min(200, Math.max(limit, window.rows.length)); - const rows = await before(String(BigInt(cursor(window.rows)!) + 1n), size); + if (!current() || (!window.anchored && !window.hasNewer)) return; + // 历史窗口保留原边界,后续分页由用户向下阅读触发。 + if (cached && !cached.anchored) return; + const tail = cursor(window.rows) ?? readSeq; + if (!tail || !/^\d+$/.test(tail)) return; + const rows = await after(tail, limit); if (!current()) return; - window = { ...window, rows: rows.reverse(), hasOlder: window.hasOlder || rows.length >= size }; - show(window, false); + show( + { + ...window, + rows: [...window.rows, ...rows], + anchored: rows.length < limit, + hasNewer: rows.length >= limit, + }, + false, + ); + } finally { + if (active()) settled?.(); } - if (!current() || (!window.anchored && !window.hasNewer)) return; - // 历史窗口保留原边界,后续分页由用户向下阅读触发。 - if (cached && !cached.anchored) return; - const tail = cursor(window.rows) ?? readSeq; - if (!tail || !/^\d+$/.test(tail)) return; - const rows = await after(tail, limit); - if (!current()) return; - show( - { - ...window, - rows: [...window.rows, ...rows], - anchored: rows.length < limit, - hasNewer: rows.length >= limit, - }, - false, - ); } diff --git a/apps/desktop/src/renderer/src/views/MainView.tsx b/apps/desktop/src/renderer/src/views/MainView.tsx index f63855883..bdd70dfba 100644 --- a/apps/desktop/src/renderer/src/views/MainView.tsx +++ b/apps/desktop/src/renderer/src/views/MainView.tsx @@ -6999,11 +6999,9 @@ export function MainView(): ReactElement { revision = dbRevisionRef.current; let expectedWindow = windowRef.current; const limit = isGroup ? GROUP_INITIAL_PAGE_SIZE : PAGE_SIZE; - const valid = () => - !cancelled && - messageScopeRef.current === scope && - selectionRef.current === selection && - windowRef.current === expectedWindow; + const active = () => + !cancelled && messageScopeRef.current === scope && selectionRef.current === selection; + const valid = () => active() && windowRef.current === expectedWindow; const jump = pendingSearchJumpRef.current; if (jump && jump.conv === conv) { pendingSearchJumpRef.current = null; @@ -7040,25 +7038,6 @@ export function MainView(): ReactElement { ? conversationWindows.getPosition(conversationWindowKey(scope, kind, conv)) : undefined; let readSeq = saved?.anchorSeq; - if (displaySettings.data?.readFromFirstUnread && !cached && !readSeq) { - try { - const [info] = await client.account.getUnreadInfos.query({ - ...binding, - targets: [ - { - chatType: entryChatType, - uid: conv, - }, - ], - }); - if (!valid()) return; - if (info && !info.readError && (info.unreadCount ?? 0) > 0) - readSeq = info.readSeq ?? undefined; - } catch (error) { - console.error('[msgs] entry unread snapshot failed', error); - } - } - if (!valid()) return; await loadConversationEntry({ cached, dirty, @@ -7066,6 +7045,26 @@ export function MainView(): ReactElement { readFromFirstUnread: displaySettings.data?.readFromFirstUnread ?? false, limit, current: valid, + active, + resolveReadSeq: async () => { + try { + const [info] = await client.account.getUnreadInfos.query({ + ...binding, + targets: [{ chatType: entryChatType, uid: conv }], + }); + if (info && !info.readError && (info.unreadCount ?? 0) > 0) + readSeq = info.readSeq ?? undefined; + } catch (error) { + console.error('[msgs] entry unread snapshot failed', error); + } + return readSeq; + }, + settled: () => { + entryLoadingRef.current = false; + setMessagesLoading(false); + setLoadingNewerMessages(false); + if (!valid() || dbRevisionRef.current !== revision) void refreshWindow(); + }, cursor: (rows) => rows .filter( @@ -7142,12 +7141,6 @@ export function MainView(): ReactElement { if (!valid()) return; console.error('[msgs] entry load failed', err); setMessagesLoading(false); - } finally { - if (!cancelled && messageScopeRef.current === scope && selectionRef.current === selection) { - entryLoadingRef.current = false; - setLoadingNewerMessages(false); - if (dbRevisionRef.current !== revision) void refreshWindow(); - } } })(); return () => { diff --git a/apps/desktop/test/conversation_entry.test.ts b/apps/desktop/test/conversation_entry.test.ts index 77d183045..c5ac679d4 100644 --- a/apps/desktop/test/conversation_entry.test.ts +++ b/apps/desktop/test/conversation_entry.test.ts @@ -155,4 +155,119 @@ describe('会话分阶段打开', () => { expect(f.show).toHaveBeenCalledOnce(); expect(f.show.mock.calls[0]![0].hasNewer).toBe(true); }); + + it('首屏结果因窗口变化被丢弃时,同一会话仍结束加载', async () => { + const f = fixture(); + let loading = true; + const settled = vi.fn(() => { + loading = false; + }); + f.latest.mockImplementation(async () => { + f.current.mockReturnValue(false); + return [row('12')]; + }); + await loadConversationEntry({ ...f, active: () => true, settled }); + expect(f.show).not.toHaveBeenCalled(); + expect(settled).toHaveBeenCalledOnce(); + expect(loading).toBe(false); + }); + + it('切换账号或会话后,旧请求不能结束新会话的加载', async () => { + const f = fixture(); + let active = true; + const settled = vi.fn(); + f.latest.mockImplementation(async () => { + active = false; + f.current.mockReturnValue(false); + return [row('12')]; + }); + await loadConversationEntry({ ...f, active: () => active, settled }); + expect(f.show).not.toHaveBeenCalled(); + expect(settled).not.toHaveBeenCalled(); + }); + + it('首屏读取失败也结束加载,但不将失败伪装成空消息页', async () => { + const f = fixture(); + const settled = vi.fn(); + f.latest.mockRejectedValue(new Error('synthetic failure')); + await expect(loadConversationEntry({ ...f, active: () => true, settled })).rejects.toThrow( + 'synthetic failure', + ); + expect(settled).toHaveBeenCalledOnce(); + expect(f.show).not.toHaveBeenCalled(); + }); + + it('未读快照返回时窗口已变化,不再查询消息,但仍结束本会话加载', async () => { + const f = fixture(); + const settled = vi.fn(); + const resolveReadSeq = vi.fn(async () => { + f.current.mockReturnValue(false); + return '10'; + }); + await loadConversationEntry({ ...f, active: () => true, settled, resolveReadSeq }); + expect(resolveReadSeq).toHaveBeenCalledOnce(); + expect(f.before).not.toHaveBeenCalled(); + expect(f.latest).not.toHaveBeenCalled(); + expect(f.show).not.toHaveBeenCalled(); + expect(settled).toHaveBeenCalledOnce(); + }); + + it('正常返回消息时只收尾一次,不影响首次和后续两阶段显示', async () => { + const f = fixture(); + const settled = vi.fn(); + const resolveReadSeq = vi.fn(async () => '10'); + await loadConversationEntry({ ...f, active: () => true, settled, resolveReadSeq }); + expect(f.before).toHaveBeenCalledWith('11', f.limit); + expect(f.after).toHaveBeenCalledWith('10', f.limit); + expect(f.show).toHaveBeenCalledTimes(2); + expect(settled).toHaveBeenCalledOnce(); + }); + + it.each(['before', 'after'] as const)( + '%s 阶段失败都能收尾,并向调用方保留错误', + async (phase) => { + const f = fixture(); + const settled = vi.fn(); + f[phase].mockRejectedValue(new Error('synthetic failure')); + await expect( + loadConversationEntry({ ...f, readSeq: '10', active: () => true, settled }), + ).rejects.toThrow('synthetic failure'); + expect(settled).toHaveBeenCalledOnce(); + expect(f.show).toHaveBeenCalledTimes(phase === 'before' ? 0 : 1); + }, + ); + + it('旧请求切走后才失败,不结束新会话的加载', async () => { + const f = fixture(); + const settled = vi.fn(); + let active = true; + f.latest.mockImplementation(async () => { + active = false; + f.current.mockReturnValue(false); + throw new Error('synthetic failure'); + }); + await expect(loadConversationEntry({ ...f, active: () => active, settled })).rejects.toThrow( + 'synthetic failure', + ); + expect(f.show).not.toHaveBeenCalled(); + expect(settled).not.toHaveBeenCalled(); + }); + + it('关闭阅读偏好时不读取未读快照', async () => { + const f = fixture(); + const resolveReadSeq = vi.fn(async () => '10'); + await loadConversationEntry({ ...f, readFromFirstUnread: false, resolveReadSeq }); + expect(resolveReadSeq).not.toHaveBeenCalled(); + expect(f.latest).toHaveBeenCalledOnce(); + }); + + it('已有缓存或已存阅读位置时不重复读取未读快照', async () => { + for (const entry of [{ cached: cached([row('10')]) }, { readSeq: '10' }]) { + const f = fixture(); + const resolveReadSeq = vi.fn(async () => '20'); + await loadConversationEntry({ ...f, ...entry, resolveReadSeq }); + expect(resolveReadSeq).not.toHaveBeenCalled(); + expect(f.after).toHaveBeenCalledWith('10', f.limit); + } + }); }); diff --git a/docs/develop/conversation-loading-20261025.md b/docs/develop/conversation-loading-20261025.md new file mode 100644 index 000000000..7339e3e9e --- /dev/null +++ b/docs/develop/conversation-loading-20261025.md @@ -0,0 +1,18 @@ +# 聊天首屏加载收尾修复 + +## 现场与边界 + +2026-10-25(北京时间)观察到桌面开发版的会话列表、群资料已经显示,但消息区持续显示骨架。只读采样中主线程仍响应,渲染诊断没有退出记录;同一群的消息服务约 0.84 秒返回 25 条消息。现场没有捕获窗口身份变化的瞬间,不能将下面的状态泄漏认定为该次现象的唯一根因。 + +## 修复 + +- 接收消息结果仍检查账号来源、会话及窗口身份,防止过期结果覆盖搜索定位或另一个会话。 +- 区分“窗口仍可接收结果”和“请求仍属于当前会话”:同一会话的窗口变化后,即使结果被丢弃,仍结束首屏及后续加载;切号、切会话、取消请求后不改新页面的状态。 +- 未读水位读取移入现有 `loadConversationEntry` 生命周期,提前返回、正常完成和抛错都经过同一处收尾。失败继续向调用方抛出,不伪装成成功的空消息页。 +- 同会话窗口失效时复用已有 `refreshWindow` 校准;继续遵守历史窗口不自动贴底、账号绑定及在途窗口检查,不反复重试失败请求。 + +## 验证范围 + +离线回归使用合成消息和受控异步返回,覆盖窗口失效、旧会话迟到成功/失败、未读快照提前返回、首次及后续页失败,以及现有分阶段显示、阅读位置和缓存行为。不读写真实 QQ 数据库、不联网,不替代真实聊天长时间运行验收。类型与针对性检查结果随本轮交付回执说明;没有运行全仓 CI、打包、安装或发布。 + +本轮 5 个相关测试文件共 42 项通过,渲染层 `typecheck:web`、3 个改动源码/测试文件的 Biome 和差异空白检查通过。修复前新增回归出现预期失败,修复后转绿;现有开发服务器已能提供新模块,但没有通过切换真实会话来制造竞态,也不声称已完成真实聊天端到端复现验收。 From 13d2ebc3c320e68d81154935ff713d02655fc668 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Sun, 25 Oct 2026 13:16:38 +0800 Subject: [PATCH 002/185] =?UTF-8?q?fix(scripts):=20=E4=BF=AE=E5=A4=8Dnativ?= =?UTF-8?q?e-fetch=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/fetch-native.mjs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/scripts/fetch-native.mjs b/scripts/fetch-native.mjs index 43bcb6aa4..66f7b3b86 100644 --- a/scripts/fetch-native.mjs +++ b/scripts/fetch-native.mjs @@ -55,7 +55,7 @@ import https from 'node:https'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; -import { Readable, Transform } from 'node:stream'; +import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { connect as tlsConnect } from 'node:tls'; import { fileURLToPath } from 'node:url'; @@ -379,11 +379,14 @@ async function readBody(res) { } async function download(url, dest, { label, size } = {}) { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok || !res.body) { - throw new Error(`下载失败 ${res.status} ${res.statusText}:${url}`); + // 必须走 httpGet 而不是内置 fetch:内置 fetch 不认 HTTP(S)_PROXY, + // 在只挂本地代理(如 Clash 7897)的环境里会直连超时,只报一句 `fetch failed`。 + const res = await httpGet(url); + if (res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); + throw new Error(`下载失败 ${res.statusCode} ${res.statusMessage ?? ''}:${url}`); } - const contentLength = Number(res.headers.get('content-length')) || 0; + const contentLength = Number(res.headers['content-length']) || 0; const total = size || contentLength; const progress = quiet ? undefined : new Progress(label ?? url); let done = 0; @@ -395,7 +398,7 @@ async function download(url, dest, { label, size } = {}) { }, }); try { - await pipeline(Readable.fromWeb(res.body), counter, createWriteStream(dest)); + await pipeline(res, counter, createWriteStream(dest)); } catch (err) { if (progress && IS_TTY) process.stdout.write('\n'); throw err; From 22368225a16762e12286ca30ba75f9a89a8688bc Mon Sep 17 00:00:00 2001 From: ganpig Date: Sun, 25 Oct 2026 13:17:42 +0800 Subject: [PATCH 003/185] =?UTF-8?q?fix(account):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=BC=9A=E8=AF=9D=E5=87=AD=E6=8D=AE=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E8=BF=9B=E5=85=A5=E4=B8=AD=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + apps/desktop/src/main/context/app_context.ts | 7 ++ apps/desktop/src/main/ipc/routers/account.ts | 31 +++-- .../src/components/RestrictedMode.tsx | 96 +++++++++++--- .../src/renderer/src/lib/restrictedMode.ts | 12 +- .../src/renderer/src/state/restrictedMode.ts | 6 + .../renderer/src/styles/restricted-mode.css | 27 ++++ .../online_credentials_subscription.test.ts | 71 ++++++++++- apps/desktop/test/restricted_mode.test.ts | 88 ++++++++++--- docs/develop/nt-helper-interface.md | 3 + docs/guide/settings.md | 4 + packages/service/src/account/monitor.ts | 79 ++++++++---- packages/service/src/account/sso_session.ts | 8 +- packages/service/src/bootstrap/attach.ts | 8 +- .../service/src/common/session_material.ts | 16 +++ packages/service/src/index.ts | 1 + packages/service/test/attach_cache.test.ts | 39 +++++- packages/service/test/monitor.test.ts | 119 ++++++++++++++++-- 18 files changed, 526 insertions(+), 90 deletions(-) create mode 100644 packages/service/src/common/session_material.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a3cf53d27..f8d875a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -540,4 +540,5 @@ - 修复旧版 QQ 本地备份的群成员、群状态、文件查找和可选功能表不兼容的问题,避免缺少新版字段导致页面加载失败或误判已退群。 - 修复旧图片缺少文件名时,多条消息串用同一张缩略图的问题;按图片 MD5 精确查找,缺失资源不再误用其它图片,并刷新旧显示缓存。 - 修复阅读旧消息时意外跳向新消息的问题:后台刷新保留已加载历史,向上翻页按可见消息保持位置,延迟贴底不再覆盖用户滚动。 +- 修复会话凭据不完整时反复复用旧结果的问题;凭据或设备身份稍晚就绪后可自动补齐,无需重开账号,真正就绪前持续显示「正在进入完整模式」。 - 修复聊天消息区在首屏请求失效后一直显示加载骨架的问题,同一会话自动结束加载并重新校准当前窗口,切换账号或会话不受旧请求影响。 diff --git a/apps/desktop/src/main/context/app_context.ts b/apps/desktop/src/main/context/app_context.ts index abaecea60..3f67f6eac 100644 --- a/apps/desktop/src/main/context/app_context.ts +++ b/apps/desktop/src/main/context/app_context.ts @@ -993,6 +993,7 @@ export interface AppContext { * no account is open / QQ is offline / harvest failed. */ refreshRkeysNow(): Promise; + onlineCredentialsPending(): boolean; /** * Transcribe a SILK file on disk with the globally selected model. Account * independent (the model is a global setting), so callers that already have a @@ -1046,6 +1047,9 @@ function unavailableContext(nativeError: NativeInitError | null, reason: string) refreshRkeysNow(): Promise { return Promise.resolve(false); }, + onlineCredentialsPending(): boolean { + return false; + }, transcribeSilk(): Promise { return Promise.resolve({ ok: false, error: reason }); }, @@ -3031,6 +3035,9 @@ export function initAppContext(fileRoot?: string): AppContext { }); return accountMonitor?.harvestRkeysNow() ?? Promise.resolve(false); }, + onlineCredentialsPending(): boolean { + return accountMonitor?.credentialsPending ?? false; + }, }; cached = ctx; diff --git a/apps/desktop/src/main/ipc/routers/account.ts b/apps/desktop/src/main/ipc/routers/account.ts index 3319eec0d..7380d4806 100644 --- a/apps/desktop/src/main/ipc/routers/account.ts +++ b/apps/desktop/src/main/ipc/routers/account.ts @@ -18,6 +18,7 @@ import { exportOutputFileName, describeExportOutput, resolveVipBadge, + isCompleteSessionMaterial, } from '@weq/service'; import { exportDownloadName, @@ -4719,15 +4720,31 @@ export const accountRouter = router({ }), /** Live prerequisites for group album list/media/export. */ - getGroupAlbumAccessState: procedure.query(() => { - const record = requireServices().accountConfig.getRecord(); + getGroupAlbumAccessState: procedure.query(async () => { + const services = requireServices(); + const record = services.accountConfig.getRecord(); const session = record?.session; - return { - ...albumAccessState(), - needsMemoryRead: Boolean( + const access = albumAccessState(services); + const needsMemoryRead = Boolean( + record?.qqPid && (session?.pid !== record.qqPid || !isCompleteSessionMaterial(session)), + ); + // 只查询原生登记表,不读内存或发包;配置写盘可能早于原生登记完成。 + const ssoReady = Boolean( + access.qqOnline && + access.attachEnabled && + !needsMemoryRead && record?.qqPid && - (session?.pid !== record.qqPid || !session?.a2 || !session?.d2 || !session?.d2Key), - ), + record.uid && + record.guid && + (await getAppContext().platform?.native.ntHelper.hasSsoSession(record.qqPid)), + ); + if (getAppContext().services !== services) throw new Error('账号已切换,请重新检查在线状态。'); + return { + ...access, + hasUid: Boolean(record?.uid), + needsMemoryRead, + ssoReady, + credentialsPending: getAppContext().onlineCredentialsPending(), }; }), diff --git a/apps/desktop/src/renderer/src/components/RestrictedMode.tsx b/apps/desktop/src/renderer/src/components/RestrictedMode.tsx index 661d7fbb2..ac12bc1da 100644 --- a/apps/desktop/src/renderer/src/components/RestrictedMode.tsx +++ b/apps/desktop/src/renderer/src/components/RestrictedMode.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useRef, useState } from 'react'; -import { KeyRound, RefreshCw, ShieldAlert, X } from 'lucide-react'; +import { KeyRound, LoaderCircle, RefreshCw, ShieldAlert, X } from 'lucide-react'; import { client, trpc } from '../trpc/client'; import { restrictionReasons } from '../lib/restrictedMode'; import { useRestrictedMode, type AuthorizationRequest } from '../state/restrictedMode'; @@ -58,18 +58,24 @@ export function RestrictedModeIndicator({ startup = false }: { startup?: boolean const reasons = useRestrictedMode((s) => s.reasons); const requests = useRestrictedMode((s) => s.requests); const open = useRestrictedMode((s) => s.open); - if (!reasons.length && !requests.length) return null; + const entering = useRestrictedMode((s) => s.enteringSince !== null); + if (!reasons.length && !requests.length && !entering) return null; return ( ); } @@ -85,6 +91,9 @@ export function AuthorizationForm({ request }: { request: AuthorizationRequest } const [password, setPassword] = useState(''); const id = useId(); function respond(cancel = false, retry = false) { + const view = useViewState.getState(); + if (!cancel && view.view === 'main' && view.openedUin) + useRestrictedMode.getState().beginEntering(); if (request.kind === 'password') ipc()?.send('elev:respond-password', { token: request.token, @@ -144,6 +153,9 @@ export function RestrictedModeHost() { const reasons = useRestrictedMode((s) => s.reasons); const requests = useRestrictedMode((s) => s.requests); const open = useRestrictedMode((s) => s.open); + const enteringSince = useRestrictedMode((s) => s.enteringSince); + const entering = enteringSince !== null; + const previousUin = useRef(uin); const panel = useRef(null); const previous = useRef(''); const recoveryPending = useRef(false); @@ -193,10 +205,6 @@ export function RestrictedModeHost() { }); useRestrictedMode.getState().setReasons(next); }, [active, uin, access.data, access.isError, sip.data, online.data, online.isError]); - const signature = [ - ...reasons.map((r) => r.code), - ...(requests.length ? ['authorization'] : []), - ].join(','); const fullModeConfirmed = active ? Boolean( access.data && @@ -204,20 +212,61 @@ export function RestrictedModeHost() { restrictionReasons({ access: access.data, sipEnabled: sip.data }).length === 0, ) : Boolean(online.data && !online.isError && online.data.count > 0); + useEffect(() => { + if (!active) return; + const blocked = + !access.data?.qqOnline || + !access.data.attachEnabled || + access.isError || + restrictionReasons({ access: access.data, sipEnabled: sip.data }).some( + (reason) => reason.code === 'sip', + ); + if (access.data?.credentialsPending && !fullModeConfirmed && !blocked) { + if (enteringSince === null) useRestrictedMode.getState().beginEntering(); + return; + } + // 提交授权后必须拿到新查询结果,不能用之前缓存的完整状态提前结束。 + const checkedAt = Math.max(access.dataUpdatedAt, access.errorUpdatedAt ?? 0); + if (enteringSince !== null && !busy && !requests.length && checkedAt > enteringSince) + useRestrictedMode.getState().endEntering(); + }, [ + active, + access.data, + access.isError, + access.dataUpdatedAt, + access.errorUpdatedAt, + sip.data, + fullModeConfirmed, + enteringSince, + busy, + requests.length, + ]); + useEffect(() => { + if (active && enteringSince !== null) void utils.account.getGroupAlbumAccessState.invalidate(); + }, [active, uin, enteringSince, utils]); + const signature = [ + ...reasons.map((r) => r.code), + ...(requests.length ? ['authorization'] : []), + ...(entering ? ['entering'] : []), + ].join(','); useEffect(() => { if (signature) recoveryPending.current = true; if (signature && signature !== previous.current) { const toast = useToast.getState(); if (notice.current !== undefined) toast.dismiss(notice.current); notice.current = toast.push({ - tone: 'warning', - title: `已进入受限模式:${requests.length ? '待提权' : reasons.map((r) => r.title).join('、')}`, + tone: entering ? 'info' : 'warning', + title: entering + ? '正在进入完整模式' + : `已进入受限模式:${requests.length ? '待提权' : reasons.map((r) => r.title).join('、')}`, + ...(entering ? { loading: true, ttl: 0 } : {}), action: { - label: '查看受限模式详情', + label: entering ? '查看进入进度' : '查看受限模式详情', run: () => { if ( useRestrictedMode.getState().reasons.length || - useRestrictedMode.getState().requests.length + useRestrictedMode.getState().requests.length || + useRestrictedMode.getState().enteringSince !== null ) useRestrictedMode.getState().setOpen(true); }, @@ -234,8 +283,12 @@ export function RestrictedModeHost() { } } previous.current = signature; - }, [signature, reasons, requests, fullModeConfirmed]); + }, [signature, reasons, requests, fullModeConfirmed, entering]); useEffect(() => { + if (previousUin.current !== uin) { + previousUin.current = uin; + useRestrictedMode.getState().endEntering(); + } setError(''); useRestrictedMode.getState().setOpen(false); }, [uin]); @@ -265,6 +318,7 @@ export function RestrictedModeHost() { async function refresh(enable = false) { setBusy(true); setError(''); + if (active) useRestrictedMode.getState().beginEntering(); try { if (enable) await client.bootstrap.setAutoAttachQq.mutate({ enabled: true }); if (active && (enable || (access.data?.qqOnline && access.data.attachEnabled))) { @@ -274,6 +328,7 @@ export function RestrictedModeHost() { await Promise.all([active ? access.refetch() : online.refetch(), sip.refetch()]); } catch (e) { setError(e instanceof Error ? e.message : String(e)); + useRestrictedMode.getState().endEntering(); } finally { setBusy(false); } @@ -287,12 +342,16 @@ export function RestrictedModeHost() { role="dialog" aria-modal="false" aria-labelledby={titleId} - className="weq-restricted-panel" + className={`weq-restricted-panel${entering ? ' is-entering' : ''}`} >

- - 受限模式 + {entering ? ( + + ) : ( + + )} + {entering ? '正在进入完整模式' : '受限模式'}

+ {entering &&

正在准备在线会话,请稍候。

} {reasons.map((reason) => (

{reason.title}

diff --git a/apps/desktop/src/renderer/src/lib/restrictedMode.ts b/apps/desktop/src/renderer/src/lib/restrictedMode.ts index 54cae5d51..14fa4e534 100644 --- a/apps/desktop/src/renderer/src/lib/restrictedMode.ts +++ b/apps/desktop/src/renderer/src/lib/restrictedMode.ts @@ -5,6 +5,10 @@ export interface OnlineAccess { hasD2: boolean; hasD2Key: boolean; hasGuid: boolean; + hasUid?: boolean; + needsMemoryRead?: boolean; + ssoReady?: boolean; + credentialsPending?: boolean; } export interface Restriction { @@ -37,8 +41,10 @@ export function restrictionReasons(input: { title: '完全离线模式', guidance: '自动读取 QQ 内存已关闭。可启用完整功能后重新读取。', }); - const sessionReady = access.hasA2 && access.hasD2 && access.hasD2Key; - const ready = sessionReady && access.hasGuid; + const sessionReady = + access.hasA2 && access.hasD2 && access.hasD2Key && access.needsMemoryRead !== true; + const ready = + sessionReady && access.hasGuid && access.hasUid !== false && access.ssoReady !== false; if (access.qqOnline && access.attachEnabled && !ready) { if (input.sipEnabled === true && !sessionReady) reasons.push({ @@ -52,7 +58,7 @@ export function restrictionReasons(input: { code: 'credentials', title: '会话凭据不完整', guidance: - '在线功能所需的会话凭据或设备身份尚未就绪。请确认 QQ 已登录后重新读取,或使用扫码登录。', + '在线功能所需的会话凭据或设备身份尚未就绪,后台会自动重试。请保持 QQ 登录;也可重新检查或使用扫码登录。', }); } if (input.failed) diff --git a/apps/desktop/src/renderer/src/state/restrictedMode.ts b/apps/desktop/src/renderer/src/state/restrictedMode.ts index df736d78d..17ced18b6 100644 --- a/apps/desktop/src/renderer/src/state/restrictedMode.ts +++ b/apps/desktop/src/renderer/src/state/restrictedMode.ts @@ -12,8 +12,11 @@ interface RestrictedModeState { reasons: Restriction[]; requests: AuthorizationRequest[]; open: boolean; + enteringSince: number | null; setReasons(reasons: Restriction[]): void; setOpen(open: boolean): void; + beginEntering(): void; + endEntering(): void; request(request: AuthorizationRequest): void; remove(kind: AuthorizationRequest['kind'], token: number): void; } @@ -22,6 +25,7 @@ export const useRestrictedMode = create((set) => ({ reasons: [], requests: [], open: false, + enteringSince: null, setReasons: (reasons) => set((state) => state.reasons.length === reasons.length && @@ -35,6 +39,8 @@ export const useRestrictedMode = create((set) => ({ : { reasons }, ), setOpen: (open) => set({ open }), + beginEntering: () => set({ enteringSince: Date.now() }), + endEntering: () => set({ enteringSince: null }), request: (request) => set((state) => ({ requests: [...state.requests.filter((item) => item.kind !== request.kind), request], diff --git a/apps/desktop/src/renderer/src/styles/restricted-mode.css b/apps/desktop/src/renderer/src/styles/restricted-mode.css index 95b10b219..561cbcd81 100644 --- a/apps/desktop/src/renderer/src/styles/restricted-mode.css +++ b/apps/desktop/src/renderer/src/styles/restricted-mode.css @@ -17,6 +17,22 @@ .weq-restricted-indicator:hover { background: color-mix(in srgb, var(--destructive) 10%, transparent); } +.weq-restricted-indicator.is-entering { + color: var(--weq-fg-primary); +} +.weq-restricted-indicator.is-entering:hover { + background: color-mix(in srgb, var(--weq-accent-effective) 10%, transparent); +} +.weq-restricted-progress-icon { + flex-shrink: 0; + color: var(--weq-accent-effective); + animation: weq-restricted-progress 1s linear infinite; +} +@keyframes weq-restricted-progress { + to { + transform: rotate(360deg); + } +} .weq-restricted-indicator.is-startup { position: fixed; top: 16px; @@ -55,6 +71,9 @@ font-weight: 600; margin: 0; } +.weq-restricted-panel.is-entering h2 { + color: var(--weq-fg-primary); +} .weq-restricted-body { padding: 16px; overflow: auto; @@ -107,3 +126,11 @@ html[data-theme='dark'] .weq-restricted-panel { background: var(--weq-bg-elevated); border-color: var(--weq-border-strong); } +html[data-theme='dark'] .weq-restricted-indicator.is-entering { + color: var(--weq-fg-primary); +} +@media (prefers-reduced-motion: reduce) { + .weq-restricted-progress-icon { + animation: none; + } +} diff --git a/apps/desktop/test/online_credentials_subscription.test.ts b/apps/desktop/test/online_credentials_subscription.test.ts index 0cf10cde0..60b72d8c1 100644 --- a/apps/desktop/test/online_credentials_subscription.test.ts +++ b/apps/desktop/test/online_credentials_subscription.test.ts @@ -2,17 +2,25 @@ import { beforeEach, expect, it, vi } from 'vitest'; import { accountRouter } from '../src/main/ipc/routers/account'; import { accountEventBus } from '../src/main/context/app_context'; -const fixture = vi.hoisted(() => ({ services: {} as object | null })); +const fixture = vi.hoisted(() => ({ + services: {} as object | null, + hasSsoSession: vi.fn(async (_pid: number) => true), +})); vi.mock('../src/main/context/app_context', async () => { const { EventEmitter } = await import('node:events'); return { - getAppContext: () => ({ services: fixture.services }), + getAppContext: () => ({ + services: fixture.services, + platform: { native: { ntHelper: { hasSsoSession: fixture.hasSsoSession } } }, + onlineCredentialsPending: () => false, + }), accountEventBus: new EventEmitter(), }; }); beforeEach(() => { fixture.services = {}; + fixture.hasSsoSession.mockReset().mockResolvedValue(true); accountEventBus.removeAllListeners(); }); @@ -43,3 +51,62 @@ it('账号关闭或同号换数据源后,旧订阅不接收新会话通知', a expect(next).not.toHaveBeenCalled(); subscription.unsubscribe(); }); + +it.each([ + { pid: 42, d2Key: 'cc'.repeat(16), needsMemoryRead: false }, + { pid: 41, d2Key: 'cc'.repeat(16), needsMemoryRead: true }, + { pid: 42, d2Key: 'zz'.repeat(16), needsMemoryRead: true }, + { pid: 42, d2Key: 'cc', needsMemoryRead: true }, + { pid: 42, d2Key: undefined, needsMemoryRead: true }, +])('能力查询核对当前进程与凭据有效性:%j', async ({ pid, d2Key, needsMemoryRead }) => { + fixture.services = { + accountConfig: { + getRecord: () => ({ + qqOnline: true, + qqPid: 42, + uid: '', + guid: 'ab'.repeat(16), + session: { pid, a2: 'aa', d2: 'bb', d2Key }, + }), + }, + }; + const state = await accountRouter.createCaller({}).getGroupAlbumAccessState(); + expect(state.needsMemoryRead).toBe(needsMemoryRead); + expect(state.hasUid).toBe(false); + expect(state.ssoReady).toBe(false); + expect(fixture.hasSsoSession).not.toHaveBeenCalled(); +}); + +function completeCredentials() { + fixture.services = { + accountConfig: { + getRecord: () => ({ + qqOnline: true, + qqPid: 42, + uid: 'u_fixture', + guid: 'ab'.repeat(16), + session: { pid: 42, a2: 'aa', d2: 'bb', d2Key: 'cc'.repeat(16) }, + }), + }, + }; +} + +it.each([false, true])('凭据写盘后仍核对实际原生登记状态:%j', async (registered) => { + completeCredentials(); + fixture.hasSsoSession.mockResolvedValue(registered); + const state = await accountRouter.createCaller({}).getGroupAlbumAccessState(); + expect(state.needsMemoryRead).toBe(false); + expect(state.hasUid).toBe(true); + expect(state.ssoReady).toBe(registered); + expect(state.credentialsPending).toBe(false); + expect(fixture.hasSsoSession).toHaveBeenCalledWith(42); +}); + +it('查询原生登记期间切换账号,不返回旧账号的完整模式状态', async () => { + completeCredentials(); + fixture.hasSsoSession.mockImplementationOnce(async () => { + fixture.services = {}; + return true; + }); + await expect(accountRouter.createCaller({}).getGroupAlbumAccessState()).rejects.toThrow('账号'); +}); diff --git a/apps/desktop/test/restricted_mode.test.ts b/apps/desktop/test/restricted_mode.test.ts index 3c90c909d..ad07bc8b9 100644 --- a/apps/desktop/test/restricted_mode.test.ts +++ b/apps/desktop/test/restricted_mode.test.ts @@ -20,6 +20,7 @@ const ready: OnlineAccess = { }; const mock = vi.hoisted(() => ({ access: undefined as OnlineAccess | undefined, + updatedAt: 0, failed: false, sip: null as boolean | null, refetch: vi.fn(async () => ({})), @@ -44,7 +45,12 @@ vi.mock('../src/renderer/src/trpc/client', () => ({ useUtils: () => utils, account: { getGroupAlbumAccessState: { - useQuery: () => ({ data: mock.access, isError: mock.failed, refetch: mock.refetch }), + useQuery: () => ({ + data: mock.access, + dataUpdatedAt: mock.updatedAt, + isError: mock.failed, + refetch: mock.refetch, + }), }, }, bootstrap: { @@ -81,6 +87,16 @@ describe('受限原因按实际能力判定', () => { expect(restrictionReasons({ failed: true })[0]?.code).toBe('checking'); expect(restrictionReasons({ access: ready, failed: true })[0]?.code).toBe('checking'); }); + it.each([{ hasUid: false }, { needsMemoryRead: true }, { ssoReady: false }])( + '三项旧凭据非空也不冒充完整模式,仍需当前会话与账号身份可用:%j', + (state) => { + expect( + restrictionReasons({ access: { ...ready, ...state }, sipEnabled: false }).map( + (r) => r.code, + ), + ).toEqual(['credentials']); + }, + ); }); let cleanup = () => {}; @@ -99,6 +115,7 @@ beforeEach(() => { }, }); mock.access = { ...ready }; + mock.updatedAt = Date.now(); mock.failed = false; mock.sip = null; mock.send.mockClear(); @@ -107,7 +124,7 @@ beforeEach(() => { mock.unsubscribe.mockClear(); mock.invalidateAccess.mockReset(); useViewState.setState({ view: 'main', openedUin: '10001' }); - useRestrictedMode.setState({ reasons: [], requests: [], open: false }); + useRestrictedMode.setState({ reasons: [], requests: [], open: false, enteringSince: null }); useToast.setState({ toasts: [], leaving: [], seq: 0 }); }); afterEach(() => { @@ -132,23 +149,61 @@ function event(channel: string, payload: unknown) { } describe('非阻塞提权入口', () => { - it('提交授权不冒充成功,凭据就绪后立即刷新共享状态并恢复,无需手动刷新', async () => { - mock.access = { ...ready, hasD2Key: false }; + it('部分凭据和原生登记等待期间始终保留进入中状态,真正就绪后才结束', async () => { + mock.access = { ...ready, ssoReady: false, credentialsPending: true }; const f = fixture(); - event('elev:request-password', { token: 41 }); - event('elev:cancel-password', { token: 41 }); - expect(f.host.textContent).toContain('受限模式'); - expect(mock.invalidateAccess).not.toHaveBeenCalled(); - expect(useToast.getState().toasts.some((t) => t.tone === 'success')).toBe(false); - mock.invalidateAccess.mockImplementation(async () => { - mock.access = { ...ready }; - f.render(); + expect(f.host.querySelector('.weq-restricted-indicator')?.textContent).toBe('正在进入完整模式'); + expect(f.host.querySelector('.weq-restricted-indicator')?.getAttribute('aria-busy')).toBe( + 'true', + ); + expect(useToast.getState().toasts.at(-1)).toMatchObject({ + title: '正在进入完整模式', + loading: true, + ttl: 0, }); - await act(async () => mock.credentialsReady?.()); - expect(mock.invalidateAccess).toHaveBeenCalledOnce(); + mock.access = { ...ready, hasD2Key: false, ssoReady: false, credentialsPending: true }; + mock.updatedAt = Date.now() + 1; + f.render(); + expect(f.host.textContent).toContain('正在进入完整模式'); + expect(useToast.getState().toasts.some((t) => t.tone === 'success')).toBe(false); + mock.access = { ...ready, ssoReady: true, credentialsPending: false }; + mock.updatedAt += 1; + f.render(); expect(f.host.querySelector('.weq-restricted-indicator')).toBeNull(); expect(useToast.getState().toasts.filter((t) => t.tone === 'success')).toHaveLength(1); }); + it('进入中不能被旧的完整模式缓存提前清掉,失败或取消后可重新检查', () => { + const f = fixture(); + act(() => useRestrictedMode.getState().beginEntering()); + expect(f.host.textContent).toContain('正在进入完整模式'); + expect(useToast.getState().toasts.some((t) => t.tone === 'success')).toBe(false); + mock.access = { ...ready, ssoReady: false, credentialsPending: false }; + mock.updatedAt = Date.now() + 1; + f.render(); + expect(f.host.querySelector('.weq-restricted-indicator')?.textContent).toBe('受限模式'); + expect(useRestrictedMode.getState().enteringSince).toBeNull(); + expect(useToast.getState().toasts.some((t) => t.tone === 'success')).toBe(false); + }); + it.each([{ hasD2Key: false }, { ssoReady: false }])( + '提交授权不冒充成功,凭据就绪后立即刷新共享状态并恢复:%j', + async (pending) => { + mock.access = { ...ready, ...pending }; + const f = fixture(); + event('elev:request-password', { token: 41 }); + event('elev:cancel-password', { token: 41 }); + expect(f.host.textContent).toContain('受限模式'); + expect(mock.invalidateAccess).not.toHaveBeenCalled(); + expect(useToast.getState().toasts.some((t) => t.tone === 'success')).toBe(false); + mock.invalidateAccess.mockImplementation(async () => { + mock.access = { ...ready }; + f.render(); + }); + await act(async () => mock.credentialsReady?.()); + expect(mock.invalidateAccess).toHaveBeenCalledOnce(); + expect(f.host.querySelector('.weq-restricted-indicator')).toBeNull(); + expect(useToast.getState().toasts.filter((t) => t.tone === 'success')).toHaveLength(1); + }, + ); it('就绪信号只触发实际状态重查,不掩盖凭据缺失或未结束的授权', async () => { mock.access = { ...ready, hasD2Key: false }; const f = fixture(); @@ -263,6 +318,11 @@ describe('非阻塞提权入口', () => { }); expect(f.host.querySelector('input')).toBeNull(); expect(useRestrictedMode.getState().requests).toHaveLength(0); + expect(f.host.textContent).toContain('正在进入完整模式'); + mock.access = { ...ready, ssoReady: true }; + mock.updatedAt = Date.now() + 1; + f.render(); + expect(f.host.querySelector('.weq-restricted-indicator')).toBeNull(); }); it('Linux ptrace 请求提供免密重试;超时与取消只移除对应请求且清理密码', () => { const f = fixture(); diff --git a/docs/develop/nt-helper-interface.md b/docs/develop/nt-helper-interface.md index 7cf83744b..3ac87cee3 100644 --- a/docs/develop/nt-helper-interface.md +++ b/docs/develop/nt-helper-interface.md @@ -321,6 +321,9 @@ native 不自己猜**;级别 0 时读取与今天逐字节相同。 | `hasSsoSession(pid)` / `clearSsoSession(pid)` | pid | `Promise` | 诊断 / 忘掉物料(账号下线、QQ 重启时调用,会一并关掉可能存在的连接)。 | - `SessionMaterial = { a2?: string; d2?: string; d2Key?: string }` +- 扫描 resolve 不代表物料完整:attach 缓存只保留三项均为有效 hex 且 d2key 为 16 字节的结果,部分结果原样返回、不跨扫描合并。账号 monitor 即使已有未过期的 rkey/clientKey,也会补齐当前 PID 的物料;失败按 30 秒起、最长 5 分钟退避重试。uid/guid 暂缺只检查账号配置及补读离线设备身份,不读内存、不弹密码框;仅用户明确取消授权会暂停本 PID 的自动重试。原生登记完成后仍通过 `onlineCredentialsReady` 通知界面重查实际能力,不把提交密码当作成功。 +- 桌面端能力查询同时返回账号身份是否齐全、是否需要重新读内存,以及 `hasSsoSession` 确认的 `ssoReady`。受限模式仅在当前 PID 的有效物料、账号/设备身份和原生登记都就绪后解除;查询不触发内存读取或网络请求,异步查询期间切号会拒绝旧结果。 +- `credentialsPending` 包括正在读取、等待授权、原生登记和未取消的自动重试,不包括登记完成后的网络取票。界面保留「正在进入完整模式」及常驻进度;提交授权后须等新的状态查询完成,不用提交前缓存的就绪状态提前结束。 - `SsoSessionConfig = { uin; a2: Buffer; d2: Buffer; d2Key: Buffer; guid; uid; subAppId; clientConnSeq?; traceParent? }`(`traceParent` 只给离线比对用)。 ⚠️ `subAppId` **必须等于当前运行 QQ 构建的 appid**(`major.node` 里 `QQAppId/537xxxxxx`,`resolveAppidFromMajor` 扫出来的那个值);QQ 每次更新 diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 5d1357403..634ab8167 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -58,6 +58,10 @@ QQ 不在线、完整功能被关闭或在线会话尚未就绪时,标题栏 详情列出当前原因及对应引导,可重新检查状态或启用完整功能。真实的管理员授权请求会在详情内提供密码框;关闭详情不取消请求,需明确选择「取消本次授权」,超时后请求及密码框自动清理。密码不保存到配置。授权后会在会话凭据就绪时自动重新检查并恢复在线功能,无需手动刷新;仅提交密码不会被视为已恢复。受影响功能的悬停提示以「受限模式下无法……」说明不可用的操作。 +若 QQ 刚登录时只读到部分凭据,或设备身份暂未就绪,后台会延迟重试,补齐后自动恢复,无需重新打开账号。已有在线票据仍有效也不会阻止补齐当前会话;身份暂缺本身不会反复请求管理员密码。明确取消授权后则保持安静,直到手动重新检查或 QQ 进程更换。 + +提交授权后,等待凭据读取、原生会话登记或自动重试期间,标题栏和常驻进度提示持续显示「正在进入完整模式」。不会因密码框关闭、刷新请求结束或旧的完整状态缓存而提前报成功;当前会话真正就绪后才结束进度并显示恢复成功。明确取消或确认失败时回到可重新检查的受限状态。 + 系统原因按实际能力显示:macOS 在缺少会话凭据且 SIP 开启时解释内存读取限制,密码不能绕过 SIP;Linux 遇到 ptrace 权限限制时提供临时提权及免密重试;Windows 不显示 SIP 或 Linux ptrace 引导。凭据完整且功能可用时,不会仅因进程不是 root 或 SIP 开启而显示受限。状态检查失败单独提示,不冒充 QQ 离线或待提权。 应用锁支持 TOTP 及平台提供的系统认证,可设置空闲自动上锁;不能把锁屏当数据库加密。 diff --git a/packages/service/src/account/monitor.ts b/packages/service/src/account/monitor.ts index e61b5b618..049c514de 100644 --- a/packages/service/src/account/monitor.ts +++ b/packages/service/src/account/monitor.ts @@ -33,6 +33,7 @@ import { queueSsoWork, ssoWorkState, type SsoWorkState } from './sso_lifecycle'; import { fetchHomeDress, type HomeDressSnapshot } from './home_dress'; import { fetchClientKey, fetchDownloadRkeys } from './online_ticket'; import { getLogger, logErrorContext } from '../common/logger'; +import { isCompleteSessionMaterial } from '../common/session_material'; /** How often to poll for the account becoming logged in. */ const LOGIN_POLL_MS = 5000; @@ -45,9 +46,8 @@ const CLIENTKEY_REFRESH_SKEW_MS = 5 * 60 * 1000; const HARVEST_RETRY_MS = 30_000; const HARVEST_MAX_RETRY_MS = 5 * 60 * 1000; /** - * 「身份不完整」(缺 uid / 设备 guid)是**不会靠重试自愈**的终态:再读几次进程 - * 内存也变不出 guid,逐轮重试只会刷屏。命中这条就停掉自动重试,等显式触发 - * (用户重开会话 / 手动「立即重新获取」)或下次 `start()`。 + * 身份暂缺时仅退避重查配置及离线设备身份,不读内存、不请求密码。 + * 与用户明确取消授权区分,避免身份稍晚就绪后永远无法自动恢复。 */ const IDENTITY_INCOMPLETE_SENTINEL = 'SSO 会话身份不完整'; @@ -92,6 +92,7 @@ export class AccountMonitorService { failures: number; nextAt: number; cancelled: boolean; + credentialsPending: boolean; } | null = null; private readonly ssoRegistrations = new Map(); private readonly ssoInflight = new Map>(); @@ -126,6 +127,16 @@ export class AccountMonitorService { }); } + /** 包含等待授权、登记和自动重试;取票等网络请求不延长进入完整模式的状态。 */ + get credentialsPending(): boolean { + return ( + !this.stopped && + this.shouldAutoAttach() && + (this.ssoInflight.size > 0 || + Boolean(this.harvestRetry?.credentialsPending && !this.harvestRetry.cancelled)) + ); + } + start(): void { if (this.running) return; this.running = true; @@ -147,8 +158,7 @@ export class AccountMonitorService { ...logErrorContext(error), }); } - // 设备 guid 每次开会话读一次就够:它从 QQ 数据根路径离线算/读(不需权限、 - // 不碰进程),且大概率不变 —— 但网络上偶尔会变,所以每次启动都刷新一次。 + // 设备 guid 从数据根路径离线读,不需权限;启动刷新,暂缺时在恢复流程里补读。 this.refreshDeviceGuid(); // 老版本存下的会话物料没有 pid,补一个(见方法说明)。 this.backfillStoredSessionPid(); @@ -188,9 +198,11 @@ export class AccountMonitorService { if (pid === null || !this.isCurrent(generation)) return false; // An explicit refresh is the user's request to retry, including after cancellation. this.harvestRetry = null; + let credentialsReady = false; try { await this.ensureAttached(pid, generation); this.assertCurrent(generation, pid); + credentialsReady = true; const rkeys = imageRkeys(await fetchDownloadRkeys(this.nt, pid)); this.assertCurrent(generation, pid); if (rkeys.length === 0) return false; @@ -204,7 +216,7 @@ export class AccountMonitorService { return true; } catch (error) { if (!this.isCurrent(generation, pid)) return false; - this.deferHarvest(pid, error); + this.deferHarvest(pid, error, !credentialsReady); this.logger.warn('failed to harvest rkeys on demand', { event: 'harvest-rkeys-now-failed', pid, @@ -407,13 +419,17 @@ export class AccountMonitorService { } private async ensureSsoSession(pid: number, generation: number): Promise { - const record = this.accountConfig.getRecord(); - // Missing identity cannot be fixed by asking for a password again. + let record = this.accountConfig.getRecord(); + if (!record?.guid) { + this.refreshDeviceGuid(); + record = this.accountConfig.getRecord(); + } + // 身份仍缺失时不请求密码,等下一轮退避检查。 if (!record?.uid || !record.guid) { throw new Error('SSO 会话身份不完整(缺少账号 uid 或设备 guid)。'); } const stored = record.session; - if (stored?.pid === pid && stored.a2 && stored.d2 && stored.d2Key) { + if (stored?.pid === pid && isCompleteSessionMaterial(stored)) { await this.storeSsoSession(pid, stored, generation); this.logger.info('registered the native sso session from stored material', { event: 'sso-session-reused', @@ -430,6 +446,8 @@ export class AccountMonitorService { d2Key: material.d2Key, pid, }); + if (!isCompleteSessionMaterial(material)) + throw new Error('SSO 会话凭据不完整(缺少有效的 a2 / d2 / d2key),稍后将自动重试。'); await this.storeSsoSession(pid, material, generation); } @@ -555,9 +573,11 @@ export class AccountMonitorService { if (!this.isCurrent(generation, pid) || !this.shouldAutoAttach()) return; const retry = this.harvestRetry; if (retry?.pid === pid && (retry.cancelled || Date.now() < retry.nextAt)) return; + let credentialsReady = false; try { await this.ensureAttached(pid, generation); this.assertCurrent(generation, pid); + credentialsReady = true; const rkeys = imageRkeys(await fetchDownloadRkeys(this.nt, pid)); this.assertCurrent(generation, pid); if (rkeys.length > 0) this.accountConfig.setRkeys(rkeys); @@ -605,10 +625,8 @@ export class AccountMonitorService { }); } catch (error) { if (!this.isCurrent(generation, pid)) return; - this.deferHarvest(pid, error); + this.deferHarvest(pid, error, !credentialsReady); if (error instanceof Error && error.message.startsWith(IDENTITY_INCOMPLETE_SENTINEL)) { - // 身份不完整(缺 uid / guid)不是可重试错误:material 齐了也没用, - // 停下来等显式重试,避免每 5s 刷一轮 harvest-failed。 this.logger.warn('background harvest skipped: session identity incomplete', { event: 'harvest-identity-incomplete', pid, @@ -625,17 +643,21 @@ export class AccountMonitorService { } } - private deferHarvest(pid: number, error: unknown): void { + private deferHarvest(pid: number, error: unknown, credentialsPending = true): void { const failures = (this.harvestRetry?.pid === pid ? this.harvestRetry.failures : 0) + 1; const cancelled = - error instanceof Error && - (error.message === '已取消授权,未读取 QQ 进程内存。' || - error.message.startsWith(IDENTITY_INCOMPLETE_SENTINEL)); + error instanceof Error && error.message === '已取消授权,未读取 QQ 进程内存。'; const delayMs = Math.min( HARVEST_RETRY_MS * 2 ** Math.min(failures - 1, 4), HARVEST_MAX_RETRY_MS, ); - this.harvestRetry = { pid, failures, nextAt: Date.now() + delayMs, cancelled }; + this.harvestRetry = { + pid, + failures, + nextAt: Date.now() + delayMs, + cancelled, + credentialsPending, + }; this.logger.warn('background harvest deferred', { event: cancelled ? 'harvest-cancelled-until-explicit-retry' : 'harvest-retry-deferred', pid, @@ -651,18 +673,21 @@ export class AccountMonitorService { const retry = this.harvestRetry; if (retry?.pid === pid && (retry.cancelled || Date.now() < retry.nextAt)) return; const stored = rec?.session; + // 在线票据尚未过期,也不代表当前 QQ 进程的 SSO 凭据与身份已经可用。 + if (!rec?.uid || !rec.guid || stored?.pid !== pid || !isCompleteSessionMaterial(stored)) { + await this.harvest(pid, generation); + return; + } // Check the owning monitor's metadata even while HTTP tickets remain fresh. // Repair a lost native registration using saved material without another attach. - if (stored?.pid === pid && stored.a2 && stored.d2 && stored.d2Key) { - try { - const owner = this.ssoOwners.get(pid); - if ((owner && this.ssoState(pid).owner === owner) || !(await this.nt.hasSsoSession(pid))) - await this.storeSsoSession(pid, stored, generation); - this.assertCurrent(generation, pid); - } catch (error) { - if (this.isCurrent(generation, pid)) this.deferHarvest(pid, error); - return; - } + try { + const owner = this.ssoOwners.get(pid); + if ((owner && this.ssoState(pid).owner === owner) || !(await this.nt.hasSsoSession(pid))) + await this.storeSsoSession(pid, stored, generation); + this.assertCurrent(generation, pid); + } catch (error) { + if (this.isCurrent(generation, pid)) this.deferHarvest(pid, error); + return; } const now = Date.now(); const rkeys = rec?.rkeys ?? []; diff --git a/packages/service/src/account/sso_session.ts b/packages/service/src/account/sso_session.ts index 1990f611e..9075842f5 100644 --- a/packages/service/src/account/sso_session.ts +++ b/packages/service/src/account/sso_session.ts @@ -17,6 +17,7 @@ import type { AppidInfo, NtHelperBinding, SessionMaterial } from '@weq/native'; import type { Platform } from '@weq/platform'; import type { AttachHook } from '../bootstrap/attach'; import { getLogger } from '../common/logger'; +import { isCompleteSessionMaterial } from '../common/session_material'; import { queueSsoWork, ssoWorkState } from './sso_lifecycle'; /** @@ -195,10 +196,7 @@ export function resolveDeviceGuid( function materialToBuffers( material: SessionMaterial, ): { a2: Buffer; d2: Buffer; d2Key: Buffer } | null { - const validHex = (value: string | undefined): value is string => - typeof value === 'string' && /^(?:[0-9a-fA-F]{2})+$/.test(value); - if (!validHex(material.a2) || !validHex(material.d2) || !validHex(material.d2Key)) return null; - if (material.d2Key.length !== 32) return null; + if (!isCompleteSessionMaterial(material)) return null; return { a2: Buffer.from(material.a2, 'hex'), d2: Buffer.from(material.d2, 'hex'), @@ -340,7 +338,7 @@ export async function registerSsoSessionFromStored( stored: StoredSsoMaterial, ): Promise { const { material } = stored; - if (!material?.a2 || !material.d2 || !material.d2Key) return false; + if (!isCompleteSessionMaterial(material)) return false; // session.pid 是权威判据;老记录没有它时退到调用方给的 pid(见 StoredSsoMaterial.pid)。 if ((material.pid ?? stored.pid ?? null) !== pid) return false; const uid = stored.uid ?? ''; diff --git a/packages/service/src/bootstrap/attach.ts b/packages/service/src/bootstrap/attach.ts index 76d4fe0ac..6ecebd0e9 100644 --- a/packages/service/src/bootstrap/attach.ts +++ b/packages/service/src/bootstrap/attach.ts @@ -13,7 +13,7 @@ * 离线算,见 `readDeviceGuid`)。 * * 调用方(`AccountMonitorService`、bootstrap 路由)只依赖这个接口,因此不带任何 - * 按平台的分支。幂等由 hook 自己负责:`ensure` 对已经读过的 pid 是空操作;`reset` + * 按平台的分支。幂等由 hook 自己负责:`ensure` 复用同一账号与 pid 的完整物料;`reset` * 忘掉某个 pid,好让失败后的下一次 `ensure` 重新读一遍(QQ 重启 / 换账号都会让 * 上一轮的物料失效)。 * @@ -25,12 +25,13 @@ import type { NtHelperBinding, SessionMaterial } from '@weq/native'; import { getLogger } from '../common/logger'; +import { isCompleteSessionMaterial } from '../common/session_material'; export interface AttachHook { /** * 读 `pid` 的内存并返回该会话物料。平台差异(是否提权)由实现自己决定; * `uin` 只用于日志与归属,读取本身不需要它。 - * 幂等 —— 同一个 pid 已经读过就直接返回缓存。 + * 幂等 —— 同一账号和 pid 已读到完整凭据时直接返回缓存,部分结果允许重读。 */ attach(pid: number, uin: string, signal?: AbortSignal): Promise; /** @@ -139,7 +140,8 @@ export function createCachedAttachHook( controller.signal.throwIfAborted(); if ((generations.get(pid) ?? 0) !== generation) throw new Error('QQ 会话读取已失效,请重新读取。'); - cached.set(pid, { uin, generation, material }); + // 扫描成功也可能只拿到部分字段,不能让暂缺凭据一直命中缓存。 + if (isCompleteSessionMaterial(material)) cached.set(pid, { uin, generation, material }); return material; }); const entry: ReadEntry = { generation, task, controller, users: 0 }; diff --git a/packages/service/src/common/session_material.ts b/packages/service/src/common/session_material.ts new file mode 100644 index 000000000..1e6011392 --- /dev/null +++ b/packages/service/src/common/session_material.ts @@ -0,0 +1,16 @@ +import type { SessionMaterial } from '@weq/native'; + +/** 与原生 SSO 登记共用校验,避免将非空但不可用的凭据当作就绪。 */ +export function isCompleteSessionMaterial( + material: T | null | undefined, +): material is T & Required { + const validHex = (value: string | undefined): value is string => + typeof value === 'string' && /^(?:[0-9a-fA-F]{2})+$/.test(value); + return Boolean( + material && + validHex(material.a2) && + validHex(material.d2) && + validHex(material.d2Key) && + material.d2Key.length === 32, + ); +} diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index c8b1cde53..8885f150a 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -179,6 +179,7 @@ export { } from './account/web/dress_mall'; export type { DressMallItem } from './account/web/dress_mall'; export { AccountMonitorService } from './account/monitor'; +export { isCompleteSessionMaterial } from './common/session_material'; export { attachAndRegisterSsoSession, registerSsoSession, diff --git a/packages/service/test/attach_cache.test.ts b/packages/service/test/attach_cache.test.ts index f8681a6fd..3bec3b69b 100644 --- a/packages/service/test/attach_cache.test.ts +++ b/packages/service/test/attach_cache.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import type { SessionMaterial } from '@weq/native'; import { createCachedAttachHook } from '../src/bootstrap/attach'; const material = { a2: 'aa', d2: 'bb', d2Key: 'cc'.repeat(16) }; @@ -21,11 +22,43 @@ describe('account and generation scoped attach cache', () => { expect(await hook.ensure(42, '10001')).toBe(material); expect(read).toHaveBeenCalledOnce(); }); + it.each([ + {}, + { ...material, a2: undefined }, + { ...material, d2: '' }, + { ...material, a2: 'a' }, + { ...material, d2: 'not-hex' }, + { ...material, d2Key: 'cc' }, + { ...material, d2Key: 'zz'.repeat(16) }, + ])('不缓存不完整或无效的扫描结果,下一次可重新读取:%j', async (partial) => { + const read = vi.fn().mockResolvedValueOnce(partial).mockResolvedValue(material); + const hook = createCachedAttachHook(read); + expect(await hook.ensure(42, '10001')).toEqual(partial); + expect(await hook.ensure(42, '10001')).toEqual(material); + expect(await hook.ensure(42, '10001')).toEqual(material); + expect(read).toHaveBeenCalledTimes(2); + }); + it('不将不同轮次的部分凭据拼成一份完整会话', async () => { + const read = vi + .fn() + .mockResolvedValueOnce({ a2: material.a2, d2: material.d2 }) + .mockResolvedValueOnce({ d2Key: material.d2Key }) + .mockResolvedValue(material); + const hook = createCachedAttachHook(read); + expect(await hook.ensure(42, '10001')).toEqual({ a2: material.a2, d2: material.d2 }); + expect(await hook.ensure(42, '10001')).toEqual({ d2Key: material.d2Key }); + expect(await hook.ensure(42, '10001')).toEqual(material); + expect(read).toHaveBeenCalledTimes(3); + }); it('does not reuse one account material for another account on the same PID', async () => { - const read = vi.fn(async (_pid, uin) => ({ ...material, a2: uin })); + const read = vi + .fn() + .mockResolvedValueOnce(material) + .mockResolvedValueOnce({ ...material, a2: 'dd' }); const hook = createCachedAttachHook(read); - expect((await hook.ensure(42, '10001')).a2).toBe('10001'); - expect((await hook.ensure(42, '10002')).a2).toBe('10002'); + expect((await hook.ensure(42, '10001')).a2).toBe(material.a2); + expect((await hook.ensure(42, '10002')).a2).toBe('dd'); + expect((await hook.ensure(42, '10002')).a2).toBe('dd'); expect(read).toHaveBeenCalledTimes(2); }); it('rejects a late result after reset and waits before reading the next generation', async () => { diff --git a/packages/service/test/monitor.test.ts b/packages/service/test/monitor.test.ts index dca041f35..8de4c9267 100644 --- a/packages/service/test/monitor.test.ts +++ b/packages/service/test/monitor.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AccountSession } from '@weq/account'; import type { Platform } from '@weq/platform'; import type { AccountConfigService } from '../src/account/user_config'; -import type { AttachHook } from '../src/bootstrap/attach'; +import { createCachedAttachHook, type AttachHook } from '../src/bootstrap/attach'; import { AccountMonitorService } from '../src/account/monitor'; import { registerSsoSession } from '../src/account/sso_session'; @@ -66,8 +66,8 @@ function fixture(stored = false, onCredentialsReady?: () => void) { setHomeDress: vi.fn(), }; const hook = { - attach: vi.fn(async () => ({ ...material })), - ensure: vi.fn(async () => ({ ...material })), + attach: vi.fn(async () => ({ ...material })), + ensure: vi.fn(async () => ({ ...material })), reset: vi.fn(), }; const platform = { @@ -127,6 +127,7 @@ describe('account monitor authorization and SSO readiness', () => { tickets.rkeys.mockReturnValueOnce(rkeys.promise); f.monitor.start(); await vi.advanceTimersByTimeAsync(0); + expect(f.monitor.credentialsPending).toBe(true); expect(ready).not.toHaveBeenCalled(); expect(f.record.session).toBeNull(); scan.resolve(material); @@ -136,6 +137,7 @@ describe('account monitor authorization and SSO readiness', () => { registration.resolve(); await flushPromises(); expect(ready).toHaveBeenCalledOnce(); + expect(f.monitor.credentialsPending).toBe(false); expect(tickets.rkeys).toHaveBeenCalledOnce(); expect(f.config.setRkeys).not.toHaveBeenCalled(); rkeys.resolve([]); @@ -156,6 +158,18 @@ describe('account monitor authorization and SSO readiness', () => { await vi.advanceTimersByTimeAsync(60_000); expect(ready).toHaveBeenCalledOnce(); }); + it('登记完成后的网络取票失败不继续冒充正在准备凭据', async () => { + const ready = vi.fn(); + const f = fixture(true, ready); + tickets.rkeys.mockRejectedValue(new Error('合成取票失败')); + f.monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(ready).toHaveBeenCalledOnce(); + expect(f.monitor.credentialsPending).toBe(false); + expect(await f.monitor.harvestRkeysNow()).toBe(false); + expect(f.monitor.credentialsPending).toBe(false); + f.monitor.stop(); + }); it('uses the asynchronous account probe instead of the blocking native probe', async () => { const f = fixture(); f.platform.resolveQqPidAsync = vi.fn(async () => 42); @@ -214,6 +228,7 @@ describe('account monitor authorization and SSO readiness', () => { f.monitor.start(); await vi.advanceTimersByTimeAsync(3_600_000); expect(f.hook.ensure).toHaveBeenCalledTimes(1); + expect(f.monitor.credentialsPending).toBe(false); expect(await f.monitor.harvestRkeysNow()).toBe(true); expect(f.hook.ensure).toHaveBeenCalledTimes(2); f.monitor.stop(); @@ -241,6 +256,62 @@ describe('account monitor authorization and SSO readiness', () => { f.monitor.stop(); }); + it('首次扫描不完整时自动重读并通知恢复,不被仍有效的在线票据阻止', async () => { + const ready = vi.fn(); + const f = fixture(false, ready); + f.record.rkeys = [{ type: 10, expiresAt: Date.now() + 3_600_000 }]; + f.record.clientKey = { expiresAt: Date.now() + 3_600_000 }; + const scan = vi + .fn() + .mockResolvedValueOnce({ a2: material.a2, d2: material.d2 }) + .mockResolvedValue(material); + const cached = createCachedAttachHook(scan); + f.hook.ensure.mockImplementation(cached.ensure); + f.monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(scan).toHaveBeenCalledOnce(); + expect(f.monitor.credentialsPending).toBe(true); + expect(ready).not.toHaveBeenCalled(); + expect(tickets.rkeys).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(25_000); + expect(scan).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(5000); + expect(scan).toHaveBeenCalledTimes(2); + expect(f.record.session).toMatchObject({ ...material, pid: 42 }); + expect(f.nt.setSsoSession).toHaveBeenCalledOnce(); + expect(ready).toHaveBeenCalledOnce(); + expect(f.monitor.credentialsPending).toBe(false); + f.monitor.stop(); + expect(f.monitor.credentialsPending).toBe(false); + }); + + it('已有三项非空但无效的凭据时重新扫描,而不是永久重试登记旧值', async () => { + const ready = vi.fn(); + const f = fixture(true, ready); + f.record.session!.d2Key = 'zz'.repeat(16); + f.monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(f.hook.ensure).toHaveBeenCalledOnce(); + expect(f.record.session).toMatchObject(material); + expect(ready).toHaveBeenCalledOnce(); + f.monitor.stop(); + }); + + it('重新启用自动读取后补齐凭据,不等旧票据过期', async () => { + const f = fixture(); + f.record.rkeys = [{ type: 10, expiresAt: Date.now() + 3_600_000 }]; + f.record.clientKey = { expiresAt: Date.now() + 3_600_000 }; + f.setEnabled(false); + f.monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(f.hook.ensure).not.toHaveBeenCalled(); + f.setEnabled(true); + await vi.advanceTimersByTimeAsync(5000); + expect(f.hook.ensure).toHaveBeenCalledOnce(); + expect(f.nt.setSsoSession).toHaveBeenCalledOnce(); + f.monitor.stop(); + }); + it('reports an in-flight failure to the manual caller and retries after it settles', async () => { const f = fixture(); let rejectScan!: (error: Error) => void; @@ -269,24 +340,56 @@ describe('account monitor authorization and SSO readiness', () => { expect(tickets.rkeys).not.toHaveBeenCalled(); }); - it('stops background retries when the device guid is missing (non-recoverable)', async () => { + it('设备 guid 持续缺失时有界退避检查,不反复提权或发包', async () => { const f = fixture(); - // 缺 guid(例如全新装机上三条路都读不到)不会靠重试自愈:起一轮后台 poll - // 之后应当停手,而不是每 5s 刷一次 harvest。 f.record.guid = ''; f.monitor.start(); await vi.advanceTimersByTimeAsync(0); expect(f.hook.ensure).not.toHaveBeenCalled(); - // 之后的轮询也不该再尝试(不读内存、不发包)。 await vi.advanceTimersByTimeAsync(3_600_000); expect(f.hook.ensure).not.toHaveBeenCalled(); expect(tickets.rkeys).not.toHaveBeenCalled(); - // 显式重试(用户手动「立即重新获取」)仍要放行一次。 + expect(f.nt.readDeviceGuid.mock.calls.length).toBeGreaterThan(1); + expect(f.nt.readDeviceGuid.mock.calls.length).toBeLessThan(20); expect(await f.monitor.harvestRkeysNow()).toBe(false); expect(f.hook.ensure).not.toHaveBeenCalled(); f.monitor.stop(); }); + it('设备身份启动时暂不可读,恢复后自动补齐并复用完整凭据,无需重开账号', async () => { + const ready = vi.fn(); + const f = fixture(true, ready); + f.record.guid = ''; + let guid = ''; + f.nt.readDeviceGuid.mockImplementation(() => guid); + f.record.rkeys = [{ type: 10, expiresAt: Date.now() + 3_600_000 }]; + f.record.clientKey = { expiresAt: Date.now() + 3_600_000 }; + f.monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(f.nt.setSsoSession).not.toHaveBeenCalled(); + guid = 'ab'.repeat(16); + await vi.advanceTimersByTimeAsync(30_000); + expect(f.record.guid).toBe(guid); + expect(f.hook.ensure).not.toHaveBeenCalled(); + expect(f.nt.setSsoSession).toHaveBeenCalledOnce(); + expect(ready).toHaveBeenCalledOnce(); + f.monitor.stop(); + }); + + it('缺失的账号身份补齐后自动恢复,不把身份暂缺当作用户取消授权', async () => { + const ready = vi.fn(); + const f = fixture(false, ready); + f.record.uid = ''; + f.monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(f.hook.ensure).not.toHaveBeenCalled(); + f.record.uid = 'u_fixture'; + await vi.advanceTimersByTimeAsync(30_000); + expect(f.hook.ensure).toHaveBeenCalledOnce(); + expect(ready).toHaveBeenCalledOnce(); + f.monitor.stop(); + }); + it('propagates stored-material registration failure without rescanning', async () => { const f = fixture(true); f.nt.setSsoSession.mockRejectedValue(new Error('registration failed')); From b0579a7bc964f0f7f1b0513146ce4441a9101a8c Mon Sep 17 00:00:00 2001 From: ganpig Date: Sun, 25 Oct 2026 13:22:27 +0800 Subject: [PATCH 004/185] =?UTF-8?q?fix(chat):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E9=87=8D=E6=8E=92=E5=90=8E=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E9=94=99=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../src/hooks/useConversationClickTarget.ts | 119 ++++++++++ .../template/conversationLists.tsx | 21 +- .../test/conversation_selection_order.test.ts | 223 ++++++++++++++++++ .../conversation-click-target-20261025.md | 29 +++ 5 files changed, 387 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/renderer/src/hooks/useConversationClickTarget.ts create mode 100644 apps/desktop/test/conversation_selection_order.test.ts create mode 100644 docs/develop/conversation-click-target-20261025.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d875a52..a086c199d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -542,3 +542,4 @@ - 修复阅读旧消息时意外跳向新消息的问题:后台刷新保留已加载历史,向上翻页按可见消息保持位置,延迟贴底不再覆盖用户滚动。 - 修复会话凭据不完整时反复复用旧结果的问题;凭据或设备身份稍晚就绪后可自动补齐,无需重开账号,真正就绪前持续显示「正在进入完整模式」。 - 修复聊天消息区在首屏请求失效后一直显示加载骨架的问题,同一会话自动结束加载并重新校准当前窗口,切换账号或会话不受旧请求影响。 +- 修复会话列表加载或刷新重排时可能点开另一会话的问题,点击绑定按下时的会话,列表仍实时更新排序。 diff --git a/apps/desktop/src/renderer/src/hooks/useConversationClickTarget.ts b/apps/desktop/src/renderer/src/hooks/useConversationClickTarget.ts new file mode 100644 index 000000000..329cda533 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/useConversationClickTarget.ts @@ -0,0 +1,119 @@ +import { useEffect, useRef, type MouseEvent, type PointerEvent } from 'react'; +import { useRenderDocument } from '../lib/renderDocument'; + +const CLICK_SLOP = 8; + +/** 排序可以随时更新,但同一次轻点始终使用按下时的会话身份。 */ +export function useConversationClickTarget({ + rows, + enabled, + resetKey, + onSelect, +}: { + rows: readonly { id: string }[]; + enabled: boolean; + resetKey: string; + onSelect: (id: string, event: MouseEvent) => void; +}) { + const document = useRenderDocument(); + const pressed = useRef<{ + id: string; + index: number; + pointerId: number; + x: number; + y: number; + handled: boolean; + } | null>(null); + + useEffect(() => { + const clear = (event?: Event) => { + if (event?.type === 'scroll' && pressed.current?.handled) return; + pressed.current = null; + }; + const move = (event: globalThis.PointerEvent) => { + const target = pressed.current; + if ( + target?.pointerId === event.pointerId && + Math.hypot(event.clientX - target.x, event.clientY - target.y) > CLICK_SLOP + ) + clear(); + }; + document.addEventListener('pointerdown', clear, true); + document.addEventListener('pointercancel', clear, true); + document.addEventListener('pointermove', move, true); + document.addEventListener('scroll', clear, true); + document.defaultView?.addEventListener('blur', clear); + return () => { + clear(); + document.removeEventListener('pointerdown', clear, true); + document.removeEventListener('pointercancel', clear, true); + document.removeEventListener('pointermove', move, true); + document.removeEventListener('scroll', clear, true); + document.defaultView?.removeEventListener('blur', clear); + }; + }, [document, enabled, resetKey]); + + const rowId = (event: MouseEvent) => { + const ElementClass = document.defaultView?.Element ?? Element; + return event.target instanceof ElementClass + ? event.target.closest('[data-conversation-id]')?.dataset.conversationId + : undefined; + }; + const targetFor = (event: MouseEvent) => { + const target = pressed.current; + return enabled && + target && + (target.handled || rows.findIndex((row) => row.id === target.id) !== target.index) && + event.button === 0 && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + Math.hypot(event.clientX - target.x, event.clientY - target.y) <= CLICK_SLOP + ? target + : null; + }; + + return { + onPointerDownCapture: (event: PointerEvent) => { + if ( + !enabled || + !event.isPrimary || + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) + return; + const ElementClass = document.defaultView?.Element ?? Element; + const row = + event.target instanceof ElementClass + ? event.target.closest('[data-conversation-id]') + : null; + if (!row?.dataset.conversationId || !event.currentTarget.contains(row)) return; + pressed.current = { + id: row.dataset.conversationId, + index: rows.findIndex((item) => item.id === row.dataset.conversationId), + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + handled: false, + }; + }, + onPointerUpCapture: (event: PointerEvent) => { + const target = targetFor(event); + if (!target || target.pointerId !== event.pointerId || rowId(event) === target.id) return; + // React 移动按下节点时浏览器可能不再派发 click;松开时先完成改换目标的轻点。 + target.handled = true; + onSelect(target.id, event); + }, + onClickCapture: (event: MouseEvent) => { + const target = targetFor(event); + pressed.current = null; + if (!target || event.detail === 0) return; + if (!target.handled && rowId(event) === target.id) return; + // 重排后 click 可能落到另一行或公共祖先,不能再让行级处理器选择新目标。 + event.stopPropagation(); + if (!target.handled) onSelect(target.id, event); + }, + }; +} diff --git a/apps/desktop/src/renderer/src/im-template/template/conversationLists.tsx b/apps/desktop/src/renderer/src/im-template/template/conversationLists.tsx index 303f3bb08..7719e4a42 100644 --- a/apps/desktop/src/renderer/src/im-template/template/conversationLists.tsx +++ b/apps/desktop/src/renderer/src/im-template/template/conversationLists.tsx @@ -40,6 +40,7 @@ import { } from 'react'; import { conversationPinned } from '@weq/service/conversation-order'; import { sortConversationRows } from '../../lib/conversationListOrder'; +import { useConversationClickTarget } from '../../hooks/useConversationClickTarget'; import { ConversationDirectoryContext, ContactDirectoryContext, @@ -241,6 +242,18 @@ export function ConversationList({ keyword, scope, ]); + const selectConversation = (id: string, event: React.MouseEvent) => { + if (!filtered.some((row) => row.id === id)) return; + const virtual = directory?.virtual.find((item) => item.id === id); + if (virtual) directory.onVirtual(virtual); + else onSelect(id, event); + }; + const clickTargetProps = useConversationClickTarget({ + rows: filtered, + enabled: !selectionMode, + resetKey: `${scope}:${keyword}`, + onSelect: selectConversation, + }); const assistantIds = useMemo( () => (directory?.real ?? conversations) @@ -556,7 +569,7 @@ export function ConversationList({ {!loading && !filtered.length ? ( } /> ) : null} -
+
{ - const virtual = directory?.virtual.find((item) => item.id === conversation.id); - if (virtual) directory.onVirtual(virtual); - else onSelect(conversation.id, e); - }} + onClick={(e) => selectConversation(conversation.id, e)} > {conversation.type === 'merged' ? ( diff --git a/apps/desktop/test/conversation_selection_order.test.ts b/apps/desktop/test/conversation_selection_order.test.ts new file mode 100644 index 000000000..c8726c63c --- /dev/null +++ b/apps/desktop/test/conversation_selection_order.test.ts @@ -0,0 +1,223 @@ +import { act, createElement as h } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Storage } from 'happy-dom'; +import { afterEach, expect, it, vi } from 'vitest'; +import { ConversationList } from '../src/renderer/src/im-template/template/conversationLists'; +import { createTemplateDemoData } from '../src/renderer/src/im-template/template/demoData'; +import type { Conversation } from '../src/renderer/src/im-template/template/types'; +import { + ContentSelectionProvider, + useCurrentContentSelection, +} from '../src/renderer/src/state/contentSelection'; + +vi.mock('../src/renderer/src/trpc/client', () => ({ client: {}, trpc: {} })); +afterEach(() => vi.unstubAllGlobals()); + +function fixture() { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('localStorage', new Storage()); + const base = createTemplateDemoData().conversations.find((row) => row.type === 'group')!; + const row = (id: string, day: number): Conversation => ({ + ...base, + id, + updatedAt: `2030-01-${String(day).padStart(2, '0')}T00:00:00Z`, + lastMessage: null, + unreadCount: 0, + preference: { pinned: false, muted: false, blocked: false }, + }); + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + const onSelect = vi.fn(); + function SelectionProbe() { + return h('output', null, JSON.stringify(useCurrentContentSelection())); + } + return { + host, + row, + onSelect, + render(rows: Conversation[]) { + act(() => + root.render( + h( + ContentSelectionProvider, + null, + h(ConversationList, { + conversations: rows, + activeConversationId: null, + preferences: {}, + drafts: {}, + onSelect, + }), + h(SelectionProbe), + ), + ), + ); + }, + rows: () => [...host.querySelectorAll('[data-conversation-id]')], + ids: () => + [...host.querySelectorAll('[data-conversation-id]')].map( + (element) => element.dataset.conversationId, + ), + selected: () => JSON.parse(host.querySelector('output')!.textContent!).scope.ids as string[], + dispose() { + act(() => root.unmount()); + host.remove(); + }, + }; +} + +function pointer(target: Element, type: string, options: PointerEventInit = {}) { + act(() => + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerType: 'mouse', + pointerId: 1, + isPrimary: true, + button: 0, + ...options, + }), + ), + ); +} + +function click(target: Element, options: MouseEventInit = {}) { + act(() => + target.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true, detail: 1, ...options }), + ), + ); +} + +it.each(['row', 'ancestor'] as const)( + '重排继续立即生效,尾随 click 落在 %s 时仍打开按下的会话', + (target) => { + const f = fixture(); + try { + f.render([f.row('a', 2), f.row('b', 1)]); + pointer(f.rows()[0]!, 'pointerdown'); + f.render([f.row('a', 2), { ...f.row('b', 3), unreadCount: 8 }]); + expect(f.ids()).toEqual(['b', 'a']); + expect(f.rows()[0]!.textContent).toContain('8'); + pointer(f.rows()[0]!, 'pointerup'); + click(target === 'row' ? f.rows()[0]! : f.rows()[0]!.parentElement!.parentElement!); + expect(f.onSelect.mock.calls.map(([id]) => id)).toEqual(['a']); + expect(f.ids()).toEqual(['b', 'a']); + } finally { + f.dispose(); + } + }, +); + +it('鼠标悬停不影响排序,没有重排的普通点击只打开一次', () => { + const f = fixture(); + try { + f.render([f.row('a', 2), f.row('b', 1)]); + pointer(f.rows()[0]!, 'pointerover'); + f.render([f.row('a', 2), f.row('b', 3)]); + expect(f.ids()).toEqual(['b', 'a']); + pointer(f.rows()[0]!, 'pointerdown'); + pointer(f.rows()[0]!, 'pointerup'); + click(f.rows()[0]!); + expect(f.onSelect.mock.calls.map(([id]) => id)).toEqual(['b']); + } finally { + f.dispose(); + } +}); + +it('重排移动了按下节点,浏览器省略 click 时也能在松开后打开原会话', () => { + const f = fixture(); + try { + f.render([f.row('a', 2), f.row('b', 1)]); + pointer(f.rows()[0]!, 'pointerdown'); + f.render([f.row('a', 2), f.row('b', 3)]); + pointer(f.rows()[0]!, 'pointerup'); + expect(f.onSelect.mock.calls.map(([id]) => id)).toEqual(['a']); + expect(f.ids()).toEqual(['b', 'a']); + } finally { + f.dispose(); + } +}); + +it('没有重排时,跨到其他行松开不额外触发会话选择', () => { + const f = fixture(); + try { + f.render([f.row('a', 2), f.row('b', 1)]); + pointer(f.rows()[0]!, 'pointerdown'); + pointer(f.rows()[1]!, 'pointerup'); + click(f.rows()[0]!.parentElement!.parentElement!); + expect(f.onSelect).not.toHaveBeenCalled(); + } finally { + f.dispose(); + } +}); + +it.each(['pointercancel', 'scroll', 'move', 'removed'] as const)( + '%s 后不把公共祖先上的 click 误当作原会话点击', + (reason) => { + const f = fixture(); + try { + f.render([f.row('a', 2), f.row('b', 1)]); + pointer(f.rows()[0]!, 'pointerdown', { pointerType: 'touch' }); + f.render(reason === 'removed' ? [f.row('b', 3)] : [f.row('a', 2), f.row('b', 3)]); + if (reason === 'pointercancel') pointer(f.rows()[0]!, 'pointercancel'); + if (reason === 'scroll') + act(() => f.rows()[0]!.parentElement!.dispatchEvent(new Event('scroll'))); + if (reason === 'move') pointer(document.body, 'pointermove', { clientY: 80 }); + click(f.rows()[0]!.parentElement!.parentElement!); + expect(f.onSelect).not.toHaveBeenCalled(); + } finally { + f.dispose(); + } + }, +); + +it('触摸轻点按稳定 ID 打开,下一次按压不会复用上次目标', () => { + const f = fixture(); + try { + f.render([f.row('a', 2), f.row('b', 1)]); + pointer(f.rows()[0]!, 'pointerdown', { pointerType: 'touch' }); + f.render([f.row('a', 2), f.row('b', 3)]); + pointer(f.rows()[0]!, 'pointerup', { pointerType: 'touch' }); + click(f.rows()[0]!); + pointer(f.rows()[0]!, 'pointerdown'); + click(f.rows()[0]!); + expect(f.onSelect.mock.calls.map(([id]) => id)).toEqual(['a', 'b']); + } finally { + f.dispose(); + } +}); + +it('键盘点击不被未完成的指针目标覆盖,修饰键仍交给多选', () => { + const f = fixture(); + try { + f.render([f.row('a', 2), f.row('b', 1)]); + pointer(f.rows()[0]!, 'pointerdown'); + f.render([f.row('a', 2), f.row('b', 3)]); + click(f.rows()[0]!, { detail: 0 }); + expect(f.onSelect.mock.calls.map(([id]) => id)).toEqual(['b']); + pointer(f.rows()[1]!, 'pointerdown', { ctrlKey: true }); + click(f.rows()[1]!, { ctrlKey: true }); + expect(f.selected()).toEqual(['a']); + expect(f.onSelect).toHaveBeenCalledOnce(); + } finally { + f.dispose(); + } +}); + +it('多指触摸不把次指当作新的会话点击目标', () => { + const f = fixture(); + try { + f.render([f.row('a', 2), f.row('b', 1)]); + pointer(f.rows()[0]!, 'pointerdown', { pointerType: 'touch' }); + pointer(f.rows()[1]!, 'pointerdown', { pointerType: 'touch', pointerId: 2, isPrimary: false }); + f.render([f.row('a', 2), f.row('b', 3)]); + pointer(f.rows()[0]!, 'pointerup', { pointerType: 'touch', pointerId: 2, isPrimary: false }); + click(f.rows()[0]!.parentElement!.parentElement!); + expect(f.onSelect).not.toHaveBeenCalled(); + } finally { + f.dispose(); + } +}); diff --git a/docs/develop/conversation-click-target-20261025.md b/docs/develop/conversation-click-target-20261025.md new file mode 100644 index 000000000..f44c408d4 --- /dev/null +++ b/docs/develop/conversation-click-target-20261025.md @@ -0,0 +1,29 @@ +# 会话列表重排与点击目标 + +## 行为边界 + +列表继续实时按活动时间、置顶状态排序,不因鼠标悬停、按压或操作而暂缓更新。 +只在一次普通轻点内部保存 `pointerdown` 命中的会话 ID;仅当原会话的列表位置确实变化,且后续 +`click` 落到其他行或列表公共祖先时,才将点击交给原会话的选择入口。目标行未改变时保留原有行级点击路径。 +React 移动按下节点时,浏览器也可能不再派发 `click`;松开目标已改变时先完成原会话选择, +随后若仍收到 `click` 则只消费事件,不重复打开。 +点击时原会话已不在结果中则不打开替代项;克隆会话仍使用克隆会话入口。 + +键盘和程序化点击不使用指针快照,修饰键与多选仍交给既有多选逻辑。滚动、超过轻点容差的移动、 +`pointercancel`、窗口失焦、搜索或范围切换会清理快照;下一次按压也不会沿用上一次目标。 + +## 核验 + +2026-10-25(北京时间)使用本机 Chromium 系浏览器和两条合成行核对事件: +在 A 行按下、不移动指针、将 B 行移到同一位置再松开,原生 `click` 的目标可以是列表公共父元素, +不能假设它一定是行按钮。离线回归同时覆盖公共祖先和另一行两类点击落点,断言排序立即变化而选择仍为 A。 +真实 `ConversationList` 的 React 重排还核对了移动按下节点后没有 `click` 的情形。 + +回归使用合成会话,不访问真实 QQ 数据库,不联网;覆盖触摸取消、滚动、拖动、会话移除、 +连续点击、键盘与修饰键多选。不将这些检查表述为真实 QQ 会话的端到端验收。 + +最终 9 个相关测试文件共 56 项通过,3 个改动源码/测试文件的 Biome 检查和新 hook 的独立严格类型检查通过。 +使用真实 `ConversationList` 的无头浏览器验证中,排序立即变为 B、A,原次点击仍选择 A,下一次点击正常选择 B; +键盘 Enter 与手机触摸重排也通过。本轮浏览器使用独立临时上下文并已关闭,没有操作用户浏览器或真实 QQ 会话。 +完整渲染层 `typecheck:web` 未通过,报错为本轮未修改的 `ExportViewer.tsx` 中 `Source.id` 与 UUID 模板类型不匹配。 +没有运行全仓 CI、打包、安装或发布。 From d5207ef2e997bb9497ccc5a15c7ea34377c2396b Mon Sep 17 00:00:00 2001 From: ganpig Date: Sun, 25 Oct 2026 13:28:12 +0800 Subject: [PATCH 005/185] =?UTF-8?q?fix(desktop):=20=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E5=BE=85=E6=8F=90=E6=9D=83=E4=B8=8E=E8=BF=9B=E5=85=A5=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=A8=A1=E5=BC=8F=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未完成的管理员授权优先显示待提权,最后一项授权提交后才恢复进入中;横幅原地更新,避免退场动画残留相反状态。 验证:42 项针对性测试和修改文件 Biome 检查通过,合成界面深浅主题、窄窗口及授权后阶段切换检查通过。整体 typecheck:web 仍受其他在途 static_backup.ts 与 ExportViewer.tsx 类型错误影响,未改动这些文件。 --- CHANGELOG.md | 1 + .../src/components/RestrictedMode.tsx | 61 +++++++++----- apps/desktop/test/restricted_mode.test.ts | 81 ++++++++++++++++++- docs/develop/nt-helper-interface.md | 1 + docs/guide/settings.md | 2 + 5 files changed, 123 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a086c199d..e75ff869f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -543,3 +543,4 @@ - 修复会话凭据不完整时反复复用旧结果的问题;凭据或设备身份稍晚就绪后可自动补齐,无需重开账号,真正就绪前持续显示「正在进入完整模式」。 - 修复聊天消息区在首屏请求失效后一直显示加载骨架的问题,同一会话自动结束加载并重新校准当前窗口,切换账号或会话不受旧请求影响。 - 修复会话列表加载或刷新重排时可能点开另一会话的问题,点击绑定按下时的会话,列表仍实时更新排序。 +- 修复「待提权」与「正在进入完整模式」同时显示的问题,未完成授权时统一显示待提权,最后一项授权提交后再恢复进入中进度。 diff --git a/apps/desktop/src/renderer/src/components/RestrictedMode.tsx b/apps/desktop/src/renderer/src/components/RestrictedMode.tsx index ac12bc1da..33f353c67 100644 --- a/apps/desktop/src/renderer/src/components/RestrictedMode.tsx +++ b/apps/desktop/src/renderer/src/components/RestrictedMode.tsx @@ -58,24 +58,28 @@ export function RestrictedModeIndicator({ startup = false }: { startup?: boolean const reasons = useRestrictedMode((s) => s.reasons); const requests = useRestrictedMode((s) => s.requests); const open = useRestrictedMode((s) => s.open); - const entering = useRestrictedMode((s) => s.enteringSince !== null); + const entering = useRestrictedMode((s) => s.enteringSince !== null && s.requests.length === 0); + const awaitingAuthorization = requests.length > 0; + const title = awaitingAuthorization ? '待提权' : entering ? '正在进入完整模式' : '受限模式'; if (!reasons.length && !requests.length && !entering) return null; return ( ); } @@ -154,7 +158,8 @@ export function RestrictedModeHost() { const requests = useRestrictedMode((s) => s.requests); const open = useRestrictedMode((s) => s.open); const enteringSince = useRestrictedMode((s) => s.enteringSince); - const entering = enteringSince !== null; + const awaitingAuthorization = requests.length > 0; + const entering = enteringSince !== null && !awaitingAuthorization; const previousUin = useRef(uin); const panel = useRef(null); const previous = useRef(''); @@ -222,7 +227,7 @@ export function RestrictedModeHost() { (reason) => reason.code === 'sip', ); if (access.data?.credentialsPending && !fullModeConfirmed && !blocked) { - if (enteringSince === null) useRestrictedMode.getState().beginEntering(); + if (enteringSince === null && !requests.length) useRestrictedMode.getState().beginEntering(); return; } // 提交授权后必须拿到新查询结果,不能用之前缓存的完整状态提前结束。 @@ -253,13 +258,16 @@ export function RestrictedModeHost() { if (signature) recoveryPending.current = true; if (signature && signature !== previous.current) { const toast = useToast.getState(); - if (notice.current !== undefined) toast.dismiss(notice.current); - notice.current = toast.push({ + const message: Parameters[0] = { tone: entering ? 'info' : 'warning', - title: entering - ? '正在进入完整模式' - : `已进入受限模式:${requests.length ? '待提权' : reasons.map((r) => r.title).join('、')}`, - ...(entering ? { loading: true, ttl: 0 } : {}), + title: awaitingAuthorization + ? '待提权:请完成管理员授权' + : entering + ? '正在进入完整模式' + : `已进入受限模式:${reasons.map((r) => r.title).join('、')}`, + loading: entering, + durationKind: entering ? 'long' : 'failure', + ...(entering ? { ttl: 0 } : {}), action: { label: entering ? '查看进入进度' : '查看受限模式详情', run: () => { @@ -271,7 +279,12 @@ export function RestrictedModeHost() { useRestrictedMode.getState().setOpen(true); }, }, - }); + }; + // 原地切换状态,避免旧的进入中横幅在退场动画里与待授权提示并存。 + const currentNotice = toast.toasts.find((item) => item.id === notice.current); + if (currentNotice) toast.update(currentNotice.id, message); + if (!currentNotice || toast.leaving.includes(currentNotice.id)) + notice.current = toast.push(message); } if (!signature) { useRestrictedMode.getState().setOpen(false); @@ -283,7 +296,7 @@ export function RestrictedModeHost() { } } previous.current = signature; - }, [signature, reasons, requests, fullModeConfirmed, entering]); + }, [signature, reasons, requests, fullModeConfirmed, entering, awaitingAuthorization]); useEffect(() => { if (previousUin.current !== uin) { previousUin.current = uin; @@ -346,12 +359,14 @@ export function RestrictedModeHost() { >

- {entering ? ( + {awaitingAuthorization ? ( + + ) : entering ? ( ) : ( )} - {entering ? '正在进入完整模式' : '受限模式'} + {awaitingAuthorization ? '待提权' : entering ? '正在进入完整模式' : '受限模式'}

- {picking ? 正在读取… : null} + {picking ? ( + scanning ? ( + + ) : ( + + + 正在读取… + + ) + ) : null} {sources.length ? ( - - {open && ( - +
    + {accounts.map((acc) => { + const Icon = acc.mobile ? Smartphone : acc.static ? Database : Laptop; + const openedAt = new Date(acc.lastLoginAt ?? 0); + const lastOpened = Number.isFinite(openedAt.getTime()) + ? formatConversationTimestamp(openedAt.toISOString()) + : ''; + return ( +
  • -
    -
    - {accounts.map((acc) => { - const active = acc.key === selected?.key; - const canDelete = onDeleteAccount && acc.configId; - return ( - - ); - })} -
    - {footer &&
    {footer}
    } -
    - - )} -
- )} - {ctxMenu && ( + + {onDeleteAccount && acc.configId && ( + + )} + + ); + })} + + {menu && ( -
e.stopPropagation()} - > +
)} -
+ ); } diff --git a/apps/desktop/src/renderer/src/views/bootstrap/HomeScreen.tsx b/apps/desktop/src/renderer/src/views/bootstrap/HomeScreen.tsx deleted file mode 100644 index c4194254b..000000000 --- a/apps/desktop/src/renderer/src/views/bootstrap/HomeScreen.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Landing screen: brand logo + the two entry buttons. - * - * 左 — 现有账号配置 (disabled when no saved config exists) - * 右 — 新的开始 - * - * The "本地备份导入" path lives INSIDE 「新的开始」 as a sub-tab — see - * `LoginPanel`. There's no top-level entry card for it. - */ - -import type { ReactElement } from 'react'; -import { FolderClock, Sparkles } from 'lucide-react'; -import logoUrl from '@resources/brand/logo.png'; -import '../../styles/startup-home.css'; - -export function HomeScreen({ - hasConfigs, - onExisting, - onNew, -}: { - hasConfigs: boolean; - onExisting: () => void; - onNew: () => void; -}): ReactElement { - return ( -
-
- WeQ -

WeQ Desktop

-

QQ NT 本地数据工具

-
- -
- - - -
-
- ); -} diff --git a/apps/desktop/src/renderer/src/views/bootstrap/KeyField.tsx b/apps/desktop/src/renderer/src/views/bootstrap/KeyField.tsx index dc367cade..1cb325a4f 100644 --- a/apps/desktop/src/renderer/src/views/bootstrap/KeyField.tsx +++ b/apps/desktop/src/renderer/src/views/bootstrap/KeyField.tsx @@ -35,6 +35,7 @@ export function KeyField({ onAction, busy, online, + showAction = true, }: { mode: 'new' | 'existing'; value: string; @@ -43,6 +44,7 @@ export function KeyField({ busy: boolean; /** new mode only: whether the selected account appears online (affects label). */ online?: boolean; + showAction?: boolean; }): ReactElement { const [revealed, setRevealed] = useState(false); const [copied, setCopied] = useState(false); @@ -77,6 +79,8 @@ export function KeyField({ readOnly={mode === 'existing'} spellCheck={false} autoComplete="off" + aria-label="数据库密钥" + disabled={busy} placeholder={placeholder} onChange={(e) => onChange(e.target.value.trim())} onKeyDown={(e) => { @@ -116,21 +120,23 @@ export function KeyField({ )}
- + {showAction && ( + + )} {mode === 'new' && !empty && !complete && (

diff --git a/apps/desktop/src/renderer/src/views/bootstrap/LoginPanel.tsx b/apps/desktop/src/renderer/src/views/bootstrap/LoginPanel.tsx index 8f0d2c130..bc916dc90 100644 --- a/apps/desktop/src/renderer/src/views/bootstrap/LoginPanel.tsx +++ b/apps/desktop/src/renderer/src/views/bootstrap/LoginPanel.tsx @@ -1,5 +1,5 @@ /** - * Left-pane login panel. Owns the per-account key lifecycle: + * 账号打开与密钥子功能,维护当前账号的密钥生命周期: * * 获取密钥 (new mode) — pure protocol, identical on all three platforms: * has a1 → quick login (unusual device ⇒ the user taps 确认 on the phone) @@ -15,14 +15,14 @@ */ import { useEffect, useRef, useState, type ReactElement } from 'react'; -import { ArrowRight, Loader2, UserPlus } from 'lucide-react'; +import { ArrowRight, KeyRound, Loader2, QrCode, X } from 'lucide-react'; import { client } from '../../trpc/client'; -import { useDialog } from '../../components/Dialog'; +import { Modal, useDialog } from '../../components/Dialog'; import type { AutoEnterTarget } from '@weq/service'; -import { AccountSelector } from './AccountSelector'; +import { accountSource } from './AccountSelector'; +import { QqAvatar } from '../../components/QqAvatar'; import { KeyField, isCompleteKey } from './KeyField'; import { QrDialog } from './QrDialog'; -import { StaticBackupPanel } from './StaticBackupPanel'; import type { UiAccount } from './types'; type Sub = { unsubscribe: () => void }; @@ -33,6 +33,7 @@ function errMsg(e: unknown): string { function sameTarget(target: AutoEnterTarget | null, acc: UiAccount | null): boolean { if (!target || !acc) return false; + if (target.configId && acc.configId) return target.configId === acc.configId; return target.uin === acc.uin && (target.dataDir ?? '') === (acc.dataDir ?? ''); } @@ -41,19 +42,19 @@ export function LoginPanel({ accounts, selected, onSelect, - installRoot: _installRoot, autoTarget, onEntered, - onDeleteAccount, + onBusyChange, + disabled = false, }: { mode: 'new' | 'existing'; accounts: UiAccount[]; selected: UiAccount | null; onSelect: (acc: UiAccount) => void; - installRoot: string | null; autoTarget: AutoEnterTarget | null; onEntered: (uin: string) => void; - onDeleteAccount?: (acc: UiAccount) => void; + onBusyChange?: (busy: boolean) => void; + disabled?: boolean; }): ReactElement { const showError = useDialog((s) => s.showError); @@ -66,9 +67,26 @@ export function LoginPanel({ const pskeyRef = useRef | null>(null); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(''); - const [autoEnter, setAutoEnter] = useState(false); - /** new mode only: which source to drive the wizard from. */ - const [source, setSource] = useState<'online' | 'backup'>('online'); + const [autoChoice, setAutoChoice] = useState<{ key: string; value: boolean } | null>(null); + const choiceKey = `${mode}:${selected?.key ?? ''}`; + const autoEnter = + autoChoice?.key === choiceKey ? autoChoice.value : sameTarget(autoTarget, selected); + const working = busy || disabled; + const [error, setError] = useState(''); + const [keyDetails, setKeyDetails] = useState(false); + const pendingKey = useRef<{ + accountKey: string; + key: string; + pskey: Record | null; + } | null>(null); + const alive = useRef(true); + const request = useRef(0); + const accountsRef = useRef(accounts); + accountsRef.current = accounts; + useEffect(() => { + onBusyChange?.(busy); + return () => onBusyChange?.(false); + }, [busy, onBusyChange]); // QR dialog state. `anonymous` = the "登录新的账号" flow, where the currently // selected account is irrelevant, so its identity must not be shown. @@ -84,19 +102,28 @@ export function LoginPanel({ // Reset the key + flags whenever the selected account changes. useEffect(() => { - setKey(mode === 'existing' ? (selected?.dbKey ?? '') : ''); + const pending = pendingKey.current?.accountKey === selected?.key ? pendingKey.current : null; + setKey(pending?.key ?? (mode === 'existing' ? (selected?.dbKey ?? '') : '')); // The ticket belongs to the account that was just logged in — never carry // it over to a different one. - pskeyRef.current = null; - setStatus(''); - setAutoEnter(sameTarget(autoTarget, selected)); - setSource('online'); - }, [selected?.key, mode, selected?.dbKey, autoTarget, selected]); + pskeyRef.current = pending?.pskey ?? null; + pendingKey.current = null; + setStatus(pending ? '密钥已就绪' : ''); + setError(''); + }, [selected?.key, mode, selected?.dbKey]); // Tear down any live subscription on unmount. - useEffect(() => () => subRef.current?.unsubscribe(), []); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + request.current++; + subRef.current?.unsubscribe(); + }; + }, []); function closeSub(): void { + request.current++; subRef.current?.unsubscribe(); subRef.current = null; } @@ -104,7 +131,8 @@ export function LoginPanel({ // ---- key acquisition (new mode) ---- function acquire(): void { - if (!selected) return; + if (!selected || working) return; + setError(''); setBusy(true); // Pick the flow off the one thing that matters: does the account have a // cached a1? (No online check, no platform branch.) @@ -118,10 +146,12 @@ export function LoginPanel({ function startQuickLogin(acc: UiAccount): void { setStatus('正在快速登录…'); closeSub(); + const id = request.current; subRef.current = client.bootstrap.quickLogin.subscribe( { uin: acc.uin }, { onData(event) { + if (!alive.current || id !== request.current) return; if (event.kind === 'state') { // Progress, including the "confirm on your phone" prompt. setStatus(event.message || event.state); @@ -140,6 +170,7 @@ export function LoginPanel({ } }, onError() { + if (!alive.current || id !== request.current) return; closeSub(); setStatus('快速登录失败,转二维码…'); startQrLogin(acc); @@ -148,24 +179,26 @@ export function LoginPanel({ ); } - function startQrLogin(acc: UiAccount, anonymous = false): void { + function startQrLogin(acc: UiAccount | null, anonymous = false): void { setStatus('正在获取二维码…'); setQr({ - uin: acc.uin, - name: acc.name, - avatarUrl: acc.avatarUrl, + uin: acc?.uin ?? '', + name: acc?.name ?? '', + avatarUrl: acc?.avatarUrl ?? null, status: '正在获取二维码…', url: null, anonymous, }); closeSub(); - let seenUin = acc.uin; + const id = request.current; + let seenUin = anonymous ? '' : (acc?.uin ?? ''); // 已知账号的扫码:把 uin 传给主进程,好让它定位该账号的 nt_msg.db 读 // key_meta(否则 native 会因缺 key_meta 拿不到 dbkey)。「登录新的账号」是 // 匿名扫码——当前选中的账号不是要登的那个,绝不能把它的 key_meta 带进去。 - const input = anonymous ? undefined : { uin: acc.uin }; + const input = anonymous ? undefined : { uin: acc?.uin }; subRef.current = client.bootstrap.qrLogin.subscribe(input, { onData(event) { + if (!alive.current || id !== request.current) return; if (event.kind === 'state') { setQr((q) => (q ? { ...q, status: event.message || q.status } : q)); } else if (event.kind === 'qrcode') { @@ -177,8 +210,24 @@ export function LoginPanel({ closeSub(); setQr(null); if (event.result.success && event.result.dbkey) { - if (seenUin && seenUin !== selected?.uin) onSelectByUin(seenUin); - if (event.result.pskey) pskeyRef.current = event.result.pskey; + const match = anonymous ? accountsRef.current.find((a) => a.uin === seenUin) : acc; + if (!match || match.uin !== seenUin) { + setBusy(false); + setStatus(''); + setError( + '未发现扫码账号的本地记录。请先在本机 QQ 登录并同步记录,或导入该账号的备份。', + ); + return; + } + if (match.key !== selected?.key) { + pendingKey.current = { + accountKey: match.key, + key: event.result.dbkey, + pskey: event.result.pskey ?? null, + }; + onSelect(match); + } + pskeyRef.current = event.result.pskey ?? null; setKey(event.result.dbkey); setStatus('已获取密钥'); setBusy(false); @@ -190,6 +239,7 @@ export function LoginPanel({ } }, onError(e) { + if (!alive.current || id !== request.current) return; closeSub(); setQr(null); setBusy(false); @@ -199,14 +249,11 @@ export function LoginPanel({ }); } - function onSelectByUin(uin: string): void { - const match = accounts.find((a) => a.uin === uin); - if (match) onSelect(match); - } - /** 「登录新的账号」:直接走匿名扫码(登录本身不再需要任何安装步骤)。 */ function startNewAccountQr(): void { - if (!selected) return; + if (working) return; + setError(''); + setBusy(true); startQrLogin(selected, true); } @@ -220,7 +267,8 @@ export function LoginPanel({ // ---- enter (test then open) ---- async function enter(): Promise { - if (!selected) return; + if (!selected || working) return; + setError(''); // Static (offline) accounts have no live key gate — re-open them directly // from their saved decrypted-db directory + (optional) stored key. @@ -243,6 +291,7 @@ export function LoginPanel({ ...(selected.algos?.['nt_msg.db'] ? { algo: selected.algos['nt_msg.db'] } : {}), ...(selected.mobile ? { mobile: true } : {}), }); + if (!alive.current) return; if (autoEnter) { await client.bootstrap.setAutoEnter.mutate({ uin: selected.uin, @@ -253,9 +302,10 @@ export function LoginPanel({ } onEntered(selected.uin); } catch (e) { + if (!alive.current) return; setBusy(false); setStatus(''); - showError('进入失败', errMsg(e)); + setError(errMsg(e)); } return; } @@ -269,10 +319,11 @@ export function LoginPanel({ setStatus('正在验证密钥…'); try { const test = await client.bootstrap.testDatabaseKey.mutate({ uin: selected.uin, dbKey: k }); + if (!alive.current) return; if (!test.success) { setBusy(false); setStatus(''); - showError('密钥验证失败', test.error ?? '数据库密钥不正确,无法进入。'); + setError(test.error ?? '数据库密钥不正确,请重新获取密钥。'); return; } await client.bootstrap.openAccount.mutate({ @@ -284,6 +335,7 @@ export function LoginPanel({ ...(selected.dataDir ? { dataDir: selected.dataDir } : {}), ...(pskeyRef.current ? { pskey: pskeyRef.current } : {}), }); + if (!alive.current) return; if (autoEnter) { await client.bootstrap.setAutoEnter.mutate({ @@ -296,9 +348,10 @@ export function LoginPanel({ onEntered(selected.uin); } catch (e) { + if (!alive.current) return; setBusy(false); setStatus(''); - showError('进入失败', errMsg(e)); + setError(errMsg(e)); } } @@ -313,47 +366,31 @@ export function LoginPanel({ return (

- {mode === 'new' && ( - - )} - - {mode === 'new' && source === 'backup' ? ( - - ) : selected ? ( + {selected ? ( <> - startNewAccountQr()}> - - 登录新的账号 - - ) : undefined - } - /> +
+ +
+

{selected.name}

+ + {selected.uin} · {accountSource(selected)} + +
+ {!selected.static && ( + + )} +
+
+ {selected.static ? '本地备份已保存' : key ? '密钥已就绪' : '尚未获取密钥'} +
{status && (
@@ -364,47 +401,127 @@ export function LoginPanel({
)} - {selected?.static ? ( - )} - 进入(静态离线账号) - - ) : ( - +
+ )} + + + + {!selected.static && keyDetails && ( + setKeyDetails(false)} + width={360} + className="weq-key-recovery" + > +
+

+ 密钥与恢复 +

+ +
+
+ {selected.sourceDir &&
{selected.sourceDir}
} + + {mode === 'existing' && ( + + )} +
+
)} - - {qr && ( - - )} - ) : ( -
未选择账号
+ ) : null} + {mode === 'new' && ( + + )} + {!selected && error && ( +
+ {error} +
+ )} + {qr && ( + )}
); diff --git a/apps/desktop/src/renderer/src/views/bootstrap/SelectScreen.tsx b/apps/desktop/src/renderer/src/views/bootstrap/SelectScreen.tsx index 3aa2c5af4..2143a68c7 100644 --- a/apps/desktop/src/renderer/src/views/bootstrap/SelectScreen.tsx +++ b/apps/desktop/src/renderer/src/views/bootstrap/SelectScreen.tsx @@ -1,37 +1,44 @@ -/** - * Two-pane account/key selection screen. - * - * left (~34%) — login panel: account selector + key field + enter - * right (~66%) — diagnostics + storage stats - * - * Normalizes both sources (saved configs / historical accounts) into one - * `UiAccount[]`, owns the selected account (so switching it drives the right - * pane), and threads counts + auto-enter target down. - */ +/** 启动账号工作区:最近使用、本机账号与备份导入共用一个入口。 */ -import { useEffect, useMemo, useState, type ReactElement } from 'react'; -import { ArrowLeft, Loader2, RefreshCw } from 'lucide-react'; +import { useMemo, useState, type ReactElement } from 'react'; +import { + ChevronDown, + FolderOpen, + FolderInput, + History, + Loader2, + RefreshCw, + Settings2, + UserPlus, + ArrowLeft, +} from 'lucide-react'; import { trpc, client } from '../../trpc/client'; import { useDialog } from '../../components/Dialog'; import { useViewState } from '../../state/view'; import type { GlobalInstallInfo } from '@weq/service'; +import { AccountSelector } from './AccountSelector'; import { LoginPanel } from './LoginPanel'; +import { StaticBackupPanel } from './StaticBackupPanel'; import { StatsPanel } from './StatsPanel'; import type { UiAccount } from './types'; +import logoUrl from '@resources/brand/logo.png'; import '../../styles/bootstrap-select.css'; export function SelectScreen({ install }: { install: GlobalInstallInfo }): ReactElement { const mode = useViewState((s) => s.selectMode); - const backHome = useViewState((s) => s.backHome); + const enterSelect = useViewState((s) => s.enterSelect); const goTo = useViewState((s) => s.goTo); const setOpenedUin = useViewState((s) => s.setOpenedUin); const showError = useDialog((s) => s.showError); + const confirm = useDialog((s) => s.confirm); const utils = trpc.useUtils(); + const [busy, setBusy] = useState(false); + const [diagnostics, setDiagnostics] = useState(false); + const [selection, setSelection] = useState<{ existing: string | null; new: string | null }>({ + existing: null, + new: null, + }); - const root = install.tencentFilesRoot; - - // Always refetch on mount: configs get written by openAccount, so the copy - // cached before entering an account is stale by the time we come back here. const savedConfigs = trpc.bootstrap.listAccountConfigs.useQuery(undefined, { refetchOnWindowFocus: false, staleTime: 0, @@ -41,30 +48,11 @@ export function SelectScreen({ install }: { install: GlobalInstallInfo }): React retry: false, refetchOnWindowFocus: false, }); - const userDataDirs = trpc.bootstrap.countUserDataDirs.useQuery(undefined, { - refetchOnWindowFocus: false, - }); const autoTarget = trpc.bootstrap.getAutoEnter.useQuery(undefined, { refetchOnWindowFocus: false, }); - - const allUins = useMemo( - () => Array.from(new Set((historical.data ?? []).map((a) => a.uin))), - [historical.data], - ); - // Online-instance count must track QQ launching/quitting, so poll it (and - // refetch on window focus) instead of probing once on mount. - const online = trpc.bootstrap.probeOnline.useQuery( - { knownUins: allUins }, - { - enabled: !historical.isLoading, - refetchInterval: 4000, - refetchOnWindowFocus: true, - }, - ); - const accounts: UiAccount[] = useMemo(() => { - if (mode === 'existing') { + if (mode === 'existing') return (savedConfigs.data ?? []).map((cfg) => ({ key: cfg.configId, uin: cfg.uin, @@ -74,15 +62,13 @@ export function SelectScreen({ install }: { install: GlobalInstallInfo }): React configId: cfg.configId, dbKey: cfg.dbKey, algos: cfg.algos, - // Only thread a saved absolute dataDir; never synthesize `/` - // here — that assumed win32's numeric-uin layout + `\` separator and - // was wrong on linux (hashed dir, `/`). Main resolves it from uin. - ...(cfg.dataDir ? { dataDir: cfg.dataDir } : {}), + dataDir: cfg.dataDir, + sourceDir: cfg.backupAccountDir ?? cfg.dataDir, lastLoginAt: cfg.lastLoginAt, - ...(cfg.static ? { static: true } : {}), - ...(cfg.mobile ? { mobile: true } : {}), + static: cfg.static, + mobile: cfg.mobile, + a1Key: historical.data?.find((a) => a.uin === cfg.uin)?.a1Key, })); - } return (historical.data ?? []).map((a) => ({ key: a.uin, uin: a.uin, @@ -90,139 +76,264 @@ export function SelectScreen({ install }: { install: GlobalInstallInfo }): React hasName: !!a.userName, avatarUrl: a.avatarUrl || null, a1Key: a.a1Key, - // No dataDir: main derives the account dir from uin via the platform. })); - }, [mode, savedConfigs.data, historical.data, root]); - - const [selectedKey, setSelectedKey] = useState(null); - useEffect(() => { - // Default to the first account; keep selection if still present. - if (accounts.length === 0) { - setSelectedKey(null); - return; - } - setSelectedKey((prev) => - prev && accounts.some((a) => a.key === prev) ? prev : accounts[0]!.key, - ); - }, [accounts]); - - const selected = accounts.find((a) => a.key === selectedKey) ?? null; - + }, [mode, savedConfigs.data, historical.data]); + const accountMode = mode === 'existing' ? 'existing' : 'new'; + const selected = accounts.find((a) => a.key === selection[accountMode]) ?? accounts[0] ?? null; const accountQuery = mode === 'existing' ? savedConfigs : historical; - const loading = accountQuery.isLoading; function onEntered(uin: string): void { setOpenedUin(uin); + useViewState.getState().backHome(); goTo('main'); } async function onDeleteAccount(acc: UiAccount): Promise { - if (!acc.configId) return; + if (!acc.configId || busy) return; + if ( + !(await confirm( + '移除已保存账号?', + `从 WeQ 移除「${acc.name}」的配置,QQ 聊天记录和备份文件不会删除。`, + { okLabel: '移除配置' }, + )) + ) + return; + setBusy(true); try { await client.bootstrap.deleteAccountConfig.mutate({ configId: acc.configId }); + await utils.bootstrap.listAccountConfigs.invalidate(); await savedConfigs.refetch(); - // Also invalidate the account config list for RailAccountFooter - void utils.bootstrap.listAccountConfigs.invalidate(); - } catch { - // silently ignore + } catch (e) { + showError('移除失败', e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); } } async function pickRoot(): Promise { - const res = await client.bootstrap.pickTencentFilesRoot.mutate(); - if (res.ok) { - // pickTencentFilesRoot already persisted the override + re-probed install. - await utils.bootstrap.describeInstall.invalidate(); - await Promise.all([userDataDirs.refetch(), savedConfigs.refetch(), historical.refetch()]); - return; - } - // Cancel returns no error; a rejection carries a human-readable reason. - if (res.error) { - showError('目录不正确', res.error); + if (busy) return; + setBusy(true); + try { + const res = await client.bootstrap.pickTencentFilesRoot.mutate(); + if (res.ok) { + await utils.bootstrap.describeInstall.invalidate(); + await Promise.all([savedConfigs.refetch(), historical.refetch()]); + } else if (res.error) showError('目录不正确', res.error); + } catch (e) { + showError('选择目录失败', e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); } } return ( -
-
- -
- - {mode === 'existing' ? '现有账号配置' : '新的开始'} - -

- {mode === 'new' ? '账号登录' : '选择账号 · 验证密钥'} -

+
+
+
+ + WeQ
- {/* Drag handle is a dedicated spacer so the title + back button remain - normal hit targets; the popover's outside-click handler then fires - when the user clicks anywhere on the header. */} -
+ {mode !== 'existing' && ( + + )} +

+ {mode === 'existing' ? '打开账号' : mode === 'new' ? '添加账号' : '导入备份'} +

- -
-
- {accountQuery.error ? ( -
- 读取账号失败:{accountQuery.error.message} - -
- ) : null} - {loading ? ( -
- - 正在准备账号… -
- ) : accounts.length === 0 && mode === 'existing' ? ( - accountQuery.error ? null : ( -
暂无保存的账号配置
- ) +
+
+ {mode === 'backup' ? ( + ) : ( - setSelectedKey(a.key)} - onDeleteAccount={mode === 'existing' ? (a) => void onDeleteAccount(a) : undefined} - installRoot={root} - autoTarget={autoTarget.data ?? null} - onEntered={onEntered} - /> + <> +
+ {!accountQuery.isLoading && (selected || mode === 'new') ? ( + setSelection((prev) => ({ ...prev, [accountMode]: a.key }))} + autoTarget={autoTarget.data ?? null} + onEntered={onEntered} + onBusyChange={setBusy} + disabled={busy} + /> + ) : ( +
+ + {accountQuery.isLoading ? '正在读取账号' : '暂无可打开的账号'} +
+ )} +
+
+
+

{mode === 'existing' ? '最近使用' : '本机 QQ 账号'}

+ +
+ {accountQuery.error && ( +
+ 读取账号失败:{accountQuery.error.message} +
+ )} + {accountQuery.isLoading ? ( +
+ + 正在准备账号… +
+ ) : accounts.length ? ( + setSelection((prev) => ({ ...prev, [accountMode]: a.key }))} + onDeleteAccount={ + mode === 'existing' ? (a) => void onDeleteAccount(a) : undefined + } + /> + ) : ( + !accountQuery.error && ( +
+ +

{mode === 'existing' ? '还没有保存的账号' : '未发现本机 QQ 账号'}

+ {mode === 'existing' ? ( + + ) : ( + + )} +
+ ) + )} +
+ )}
-
- void pickRoot()} - /> + {mode === 'existing' && ( + + )} + +
+ + {diagnostics && ( +
+ void pickRoot()} + /> +
+ )}
); } + +/** 仅展开时挂载,避免打开账号前就扫描整个目录或轮询 QQ 进程。 */ +function Diagnostics({ + install, + selectedUin, + busy, + onPickRoot, +}: { + install: GlobalInstallInfo; + selectedUin: string | null; + busy: boolean; + onPickRoot: () => void; +}): ReactElement { + const historical = trpc.bootstrap.listAccounts.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + const dirs = trpc.bootstrap.countUserDataDirs.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + const online = trpc.bootstrap.probeOnline.useQuery( + { knownUins: (historical.data ?? []).map((a) => a.uin) }, + { enabled: !historical.isLoading, refetchInterval: 4000, refetchOnWindowFocus: true }, + ); + return ( + + ); +} diff --git a/apps/desktop/src/renderer/src/views/bootstrap/StaticBackupPanel.tsx b/apps/desktop/src/renderer/src/views/bootstrap/StaticBackupPanel.tsx index e95ffceba..1457fee30 100644 --- a/apps/desktop/src/renderer/src/views/bootstrap/StaticBackupPanel.tsx +++ b/apps/desktop/src/renderer/src/views/bootstrap/StaticBackupPanel.tsx @@ -5,12 +5,15 @@ import { ArrowRight, Database, FolderSearch, KeyRound, Loader2, Smartphone, X } import { client } from '../../trpc/client'; import { Modal, useDialog } from '../../components/Dialog'; import { QqAvatar } from '../../components/QqAvatar'; +import { FolderScanStatus } from '../../components/FolderScanStatus'; import { KEY_LEN_HINT } from './KeyField'; import qqLogoUrl from '@resources/img/QQ.png'; import '../../styles/static-backup-scan.css'; -type ScanResult = Awaited>; -type Candidate = ScanResult['candidates'][number]; +import type { + BackupScanCandidate as Candidate, + BackupScanEvent, +} from '../../../../main/static_backup'; function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e); @@ -36,8 +39,10 @@ type Stage = 'picking' | 'probing' | 'entering' | 'done'; export function StaticBackupPanel({ onEntered, + onBusyChange, }: { onEntered: (uin: string) => void; + onBusyChange?: (busy: boolean) => void; }): ReactElement { const showError = useDialog((s) => s.showError); const confirm = useDialog((s) => s.confirm); @@ -50,17 +55,29 @@ export function StaticBackupPanel({ const [notice, setNotice] = useState(''); const [pickerOpen, setPickerOpen] = useState(false); const [draftPath, setDraftPath] = useState(''); + const [scanning, setScanning] = useState(false); + const scanId = useRef(0); + const scanSub = useRef<{ unsubscribe(): void } | null>(null); + const scanReject = useRef<((error: Error) => void) | null>(null); const pickerTitleId = useId(); const busy = stage === 'probing' || stage === 'entering'; + useEffect(() => { + onBusyChange?.(busy); + return () => onBusyChange?.(false); + }, [busy, onBusyChange]); const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; + scanId.current++; + scanSub.current?.unsubscribe(); + scanReject.current?.(new Error('扫描已取消')); }; }, []); function selectCandidate(candidate: Candidate) { + if (!candidate.probe) return; setDirPath(candidate.dirPath); setKey(''); const r = candidate.probe; @@ -90,58 +107,123 @@ export function StaticBackupPanel({ function confirmDirectory() { const candidate = candidates.find((item) => item.dirPath === draftPath); - if (!candidate || busy) return; + if (!candidate?.probe) return; + stopScan(); selectCandidate(candidate); setPickerOpen(false); } + function stopScan() { + scanId.current++; + scanSub.current?.unsubscribe(); + scanSub.current = null; + scanReject.current?.(new Error('扫描已取消')); + scanReject.current = null; + setScanning(false); + setStage(probe.kind === 'ready' ? 'done' : 'picking'); + } + + function closePicker() { + if (scanning) stopScan(); + setPickerOpen(false); + } + + function scan( + dirPath: string, + recursive: boolean, + id: number, + ): Promise<{ candidates: Candidate[]; truncated: boolean; unreadable: number }> { + const found = new Map(); + return new Promise((resolve, reject) => { + scanReject.current = reject; + scanSub.current = client.bootstrap.scanStaticDirsProgress.subscribe( + { dirPath, recursive }, + { + onData(event: BackupScanEvent) { + if (!mounted.current || id !== scanId.current) return; + if (event.kind === 'candidate') { + found.set(event.candidate.dirPath, event.candidate); + setCandidates([...found.values()]); + setDraftPath((current) => current || event.candidate.dirPath); + setPickerOpen(true); + } else { + scanSub.current?.unsubscribe(); + scanSub.current = null; + scanReject.current = null; + resolve({ + candidates: [...found.values()], + truncated: event.truncated, + unreadable: event.unreadable, + }); + } + }, + onError(error) { + if (!mounted.current || id !== scanId.current) return; + scanReject.current = null; + reject(error); + }, + }, + ); + }); + } + async function pickDir(): Promise { setStage('probing'); + const id = ++scanId.current; try { const picked = await client.bootstrap.pickStaticDbDir.mutate(); if (!picked || !mounted.current) return; - setDirPath(''); - setKey(''); setCandidates([]); setPickerOpen(false); setDraftPath(''); setNotice(''); - setProbe({ kind: 'probing' }); - let result = await client.bootstrap.scanStaticDirs.mutate({ dirPath: picked }); - if (!mounted.current) return; + setScanning(true); + let result = await scan(picked, false, id); + if (!mounted.current || id !== scanId.current) return; if (!result.candidates.length) { const recursive = await confirm( '递归扫描账号目录?', '当前文件夹及下一级未找到 QQ 数据库,是否扫描所有子文件夹?大目录可能需要较长时间。', { okLabel: '递归扫描', cancelLabel: '取消' }, ); - if (!mounted.current) return; + if (!mounted.current || id !== scanId.current) return; if (!recursive) { - setProbe({ kind: 'idle' }); + if (!dirPath) setProbe({ kind: 'idle' }); return; } - result = await client.bootstrap.scanStaticDirs.mutate({ dirPath: picked, recursive: true }); + result = await scan(picked, true, id); } - if (!mounted.current) return; + if (!mounted.current || id !== scanId.current) return; + setScanning(false); setCandidates(result.candidates); if (result.truncated || result.unreadable) setNotice('部分目录未扫描,请选择更具体的文件夹。'); - if (result.candidates.length === 1) selectCandidate(result.candidates[0]!); - else { - setProbe( - result.candidates.length - ? { kind: 'idle' } - : { kind: 'badDb', error: '没有找到 QQ 数据库目录。' }, - ); + if (result.candidates.length === 1) { + selectCandidate(result.candidates[0]!); + setPickerOpen(false); + } else { + if (!dirPath) + setProbe( + result.candidates.length + ? { kind: 'idle' } + : { kind: 'badDb', error: '没有找到 QQ 数据库目录。' }, + ); + if (!result.candidates.length) setNotice('没有找到 QQ 数据库目录。'); if (result.candidates.length) { - setDraftPath(result.candidates[0]!.dirPath); + setDraftPath((current) => current || result.candidates[0]!.dirPath); setPickerOpen(true); } } } catch (e) { - if (mounted.current) setProbe({ kind: 'badDb', error: errMsg(e) }); + if (mounted.current && id === scanId.current) { + setNotice(`扫描未完成:${errMsg(e)}`); + if (!dirPath) setProbe({ kind: 'badDb', error: errMsg(e) }); + } } finally { - if (mounted.current) setStage((s) => (s === 'probing' ? 'picking' : s)); + if (mounted.current && id === scanId.current) { + setScanning(false); + setStage((s) => (s === 'probing' ? 'picking' : s)); + } } } @@ -229,7 +311,10 @@ export function StaticBackupPanel({ )} -
+
{probe.preview.displayName || probe.preview.uin}
@@ -271,10 +356,15 @@ export function StaticBackupPanel({ - {stage === 'probing' && ( -
- 正在扫描与验证… -
+ {scanning && !pickerOpen ? ( + + ) : ( + stage === 'probing' && + !scanning && ( +
+ 正在扫描与验证… +
+ ) )} {candidates.length > 0 && (
+ {scanning && }
-
+
{candidates.map((candidate) => { const preview = - candidate.probe.ok && !candidate.probe.needKey ? candidate.probe.preview : null; + candidate.probe?.ok && !candidate.probe.needKey ? candidate.probe.preview : null; return (
- ) : ( - @@ -84,9 +91,9 @@ export function StatsPanel({ {/* Stat cards */}
- + - +
{/* Language-style database bar */} @@ -144,9 +151,11 @@ function useAnimatedNumber(target: number, durationMs = 320): number { */ function DirSizeCard({ uin }: { uin: string | null }): ReactElement { const [progress, setProgress] = useState(null); + const [failed, setFailed] = useState(false); const bytes = useAnimatedNumber(progress?.bytes ?? 0); useEffect(() => { + setFailed(false); if (!uin) { setProgress(null); return; @@ -158,17 +167,23 @@ function DirSizeCard({ uin }: { uin: string | null }): ReactElement { onData: setProgress, onError: (err) => { console.error('[stats] account dir size progress error', err); - setProgress({ bytes: 0, done: true }); + setFailed(true); }, }, ); return () => sub.unsubscribe(); }, [uin]); - const scanning = progress === null || !progress.done; - const value = uin === null ? '—' : progress === null ? '…' : formatBytes(bytes); + const scanning = uin !== null && !failed && (progress === null || !progress.done); + const value = uin === null || failed ? '—' : progress === null ? '…' : formatBytes(bytes); - return ; + return ( + + ); } function BigNumber({ diff --git a/apps/desktop/src/renderer/src/views/bootstrap/types.ts b/apps/desktop/src/renderer/src/views/bootstrap/types.ts index 71450404b..98a150fa8 100644 --- a/apps/desktop/src/renderer/src/views/bootstrap/types.ts +++ b/apps/desktop/src/renderer/src/views/bootstrap/types.ts @@ -23,6 +23,8 @@ export interface UiAccount { dbKey?: string; algos?: Record; dataDir?: string; + /** 展示备份根目录,不替代打开账号时使用的数据库目录。 */ + sourceDir?: string; lastLoginAt?: number; /** True for static / offline (decrypted-db directory) accounts. */ static?: boolean; diff --git a/apps/desktop/src/renderer/src/views/cache/ResourceAnalyticsDialog.tsx b/apps/desktop/src/renderer/src/views/cache/ResourceAnalyticsDialog.tsx index 7eb77499d..3539e93e5 100644 --- a/apps/desktop/src/renderer/src/views/cache/ResourceAnalyticsDialog.tsx +++ b/apps/desktop/src/renderer/src/views/cache/ResourceAnalyticsDialog.tsx @@ -3,8 +3,7 @@ * charts the totals. The scan is the slow part (each file is `stat`-ed for its * size), so it runs ONE tree at a time via `mediaResource.analyzeTree`, showing a * progress banner + per-category cards that fill in as each tree finishes. The - * cross-category charts (the ranked category bars, the by-month bars, the - * 源文件/缩略图 split) render once the whole sweep completes. + * 跨分类图表随已完成目录持续更新,扫描提示保留到整个遍历结束。 * * Visuals are dependency-free inline bar charts whose colours all come from * {@link ACCENT_SERIES}, so everything follows 设置里的主题色. @@ -108,6 +107,12 @@ export function ResourceAnalyticsDialog({ const [error, setError] = useState(null); // Guards against a stale scan (dialog closed + reopened) writing late results. const runRef = useRef(0); + useEffect( + () => () => { + runRef.current += 1; + }, + [], + ); const runScan = useCallback(async (): Promise => { const run = ++runRef.current; @@ -200,13 +205,13 @@ export function ResourceAnalyticsDialog({
} - value={fmtBytes(totals.bytes)} - label="总占用空间" + value={stats.length || done ? fmtBytes(totals.bytes) : '—'} + label={done ? '总占用空间' : '已统计占用空间'} /> } - value={formatNumber(totals.files)} - label="资源文件总数" + value={stats.length || done ? formatNumber(totals.files) : '—'} + label={done ? '资源文件总数' : '已统计文件数'} /> } @@ -224,7 +229,7 @@ export function ResourceAnalyticsDialog({ (toggle), so every category name shows in full without the cramped legend the old donuts had. */} - {done && present.length > 0 ? ( + {present.length > 0 ? ( ) : ( @@ -233,7 +238,7 @@ export function ResourceAnalyticsDialog({ {/* 源文件 / 缩略图 占比 as a slim 100% stacked bar. */} - {done && totals.files > 0 ? ( + {totals.files > 0 ? ( - {done && Object.keys(totals.byMonth).length > 0 ? ( + {Object.keys(totals.byMonth).length > 0 ? ( ) : ( diff --git a/apps/desktop/test/bootstrap_account_surface.test.ts b/apps/desktop/test/bootstrap_account_surface.test.ts index 670370188..f64516917 100644 --- a/apps/desktop/test/bootstrap_account_surface.test.ts +++ b/apps/desktop/test/bootstrap_account_surface.test.ts @@ -1,26 +1,23 @@ import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { AccountSelector } from '../src/renderer/src/views/bootstrap/AccountSelector'; const accounts = Array.from({ length: 24 }, (_, i) => ({ key: `synthetic-${i}`, configId: `synthetic-${i}`, - uin: String(10000 + i), + uin: '10001', name: `合成账号${i + 1}的较长显示名称`, hasName: true, avatarUrl: null, + sourceDir: `/synthetic/snapshot-${i}`, + static: true, + lastLoginAt: Date.parse('2026-10-25T02:00:00Z'), })); let host: HTMLDivElement, root: Root; const select = vi.fn(), remove = vi.fn(); -beforeEach(() => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); - select.mockClear(); - remove.mockClear(); - host = document.createElement('div'); - document.body.append(host); - root = createRoot(host); +function render(disabled = false) { act(() => root.render( createElement(AccountSelector, { @@ -28,108 +25,77 @@ beforeEach(() => { selected: accounts[0]!, onSelect: select, onDeleteAccount: remove, - footer: createElement('button', { type: 'button' }, '登录新的账号'), + disabled, }), ), ); +} +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.clearAllMocks(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + render(); }); afterEach(() => { act(() => root.unmount()); host.remove(); vi.unstubAllGlobals(); }); -function trigger() { - return host.querySelector('.weq-acct-switch')!; -} -function open() { +function openMenu() { + const trigger = host.querySelector('.weq-account-more')!; act(() => { - trigger().focus(); - trigger().click(); + trigger.focus(); + trigger.click(); }); + return trigger; } -function popup() { - return document.querySelector('.weq-acct-pop')!; -} -function context() { +it('所有账号直接显示,同 QQ 的备份以目录区分并显示北京时间', () => { + expect(host.querySelectorAll('.weq-account-select')).toHaveLength(accounts.length); + expect(host.textContent).toContain('/synthetic/snapshot-23'); + expect(host.textContent).toContain('本地备份'); + expect(host.textContent).toContain('10-25 10:00'); + expect(host.querySelector('.weq-account-select')!.title).toContain( + accounts[0]!.sourceDir, + ); +}); +it('选择账号只回传对应来源,不因同 QQ 号误选', () => { + act(() => host.querySelectorAll('.weq-account-select')[3]!.click()); + expect(select).toHaveBeenCalledWith(accounts[3]); + expect(remove).not.toHaveBeenCalled(); +}); +it('配置操作可直接点按,Escape 收起菜单并归还焦点', () => { + const trigger = openMenu(); + expect( + document.querySelector('.weq-acct-ctx-menu')?.closest('.is-anchored-popover'), + ).not.toBeNull(); + act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))); + expect(document.querySelector('.weq-acct-ctx-menu')).not.toBeNull(); act(() => - popup() - .querySelector('.weq-acct-row')! - .dispatchEvent( - new MouseEvent('contextmenu', { - bubbles: true, - cancelable: true, - clientX: 1000, - clientY: 1000, - }), - ), + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })), ); -} - -describe('启动账号选择器浮层', () => { - it('离开滚动裁切树,保留所有账号、完整名称提示和底部入口', () => { - open(); - expect(host.contains(popup())).toBe(false); - expect(popup().closest('.is-anchored-popover')).not.toBeNull(); - expect(popup().querySelectorAll('.weq-acct-row')).toHaveLength(accounts.length); - expect(popup().querySelector('.weq-acct-row')!.title).toBe( - accounts[0]!.name, - ); - expect(popup().querySelector('.weq-acct-foot')!.textContent).toBe('登录新的账号'); - expect(trigger().getAttribute('aria-haspopup')).toBe('menu'); - }); - it('非关闭按键不关闭右键菜单,Escape逐层关闭并归还焦点', () => { - open(); - context(); - const menu = () => document.querySelector('.weq-acct-ctx-menu'); - expect(menu()!.closest('.is-anchored-popover')).not.toBeNull(); - act(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))); - expect(menu()).not.toBeNull(); - act(() => - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })), - ); - expect(menu()).toBeNull(); - expect(popup()).not.toBeNull(); - expect(popup().contains(document.activeElement)).toBe(true); - act(() => - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })), - ); - expect(popup()).toBeNull(); - expect(document.activeElement).toBe(trigger()); - expect(select).not.toHaveBeenCalled(); - expect(remove).not.toHaveBeenCalled(); - }); - it('再次点击触发按钮只关闭菜单,不误开', () => { - open(); - act(() => { - trigger().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); - trigger().click(); - }); - expect(popup()).toBeNull(); - }); - it('选择账号只回传对应对象并关闭', () => { - open(); - act(() => popup().querySelectorAll('.weq-acct-row')[3]!.click()); - expect(select).toHaveBeenCalledOnce(); - expect(select).toHaveBeenCalledWith(accounts[3]); - expect(popup()).toBeNull(); - }); - it('明确删除才回传原账号并关闭两层菜单', () => { - open(); - context(); - act(() => document.querySelector('.weq-acct-ctx-menu button')!.click()); - expect(remove).toHaveBeenCalledOnce(); - expect(remove).toHaveBeenCalledWith(accounts[0]); - expect(select).not.toHaveBeenCalled(); - expect(document.querySelector('.weq-acct-ctx-menu')).toBeNull(); - expect(popup()).toBeNull(); - }); - it('窗口失焦同时关闭两层,不触发账号操作', () => { - open(); - context(); - act(() => window.dispatchEvent(new Event('blur'))); - expect(popup()).toBeNull(); - expect(document.querySelector('.weq-acct-ctx-menu')).toBeNull(); - expect(remove).not.toHaveBeenCalled(); - expect(select).not.toHaveBeenCalled(); - }); + expect(document.querySelector('.weq-acct-ctx-menu')).toBeNull(); + expect(document.activeElement).toBe(trigger); + expect(remove).not.toHaveBeenCalled(); +}); +it('明确移除才回传原配置,保留右键入口', () => { + act(() => + host + .querySelectorAll('.weq-account-select')[2]! + .dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })), + ); + act(() => document.querySelector('.weq-acct-ctx-menu button')!.click()); + expect(remove).toHaveBeenCalledWith(accounts[2]); + expect(select).not.toHaveBeenCalled(); + expect(document.querySelector('.weq-acct-ctx-menu')).toBeNull(); +}); +it('失焦收起菜单,在途禁用账号和配置操作', () => { + openMenu(); + act(() => window.dispatchEvent(new Event('blur'))); + expect(document.querySelector('.weq-acct-ctx-menu')).toBeNull(); + openMenu(); + render(true); + expect(document.querySelector('.weq-acct-ctx-menu')).toBeNull(); + expect([...host.querySelectorAll('button')].every((button) => button.disabled)).toBe(true); }); diff --git a/apps/desktop/test/bootstrap_login_actions.test.ts b/apps/desktop/test/bootstrap_login_actions.test.ts new file mode 100644 index 000000000..c3cb819a3 --- /dev/null +++ b/apps/desktop/test/bootstrap_login_actions.test.ts @@ -0,0 +1,147 @@ +import { act, createElement as h, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { KeyEvent } from '@weq/service'; +import { LoginPanel } from '../src/renderer/src/views/bootstrap/LoginPanel'; +import type { UiAccount } from '../src/renderer/src/views/bootstrap/types'; + +const f = vi.hoisted(() => ({ + test: vi.fn(), + open: vi.fn(), + openStatic: vi.fn(), + setAuto: vi.fn(), + clearAuto: vi.fn(), + unsubscribe: vi.fn(), + qr: vi.fn(), + event: (_event: KeyEvent) => {}, +})); +vi.mock('../src/renderer/src/trpc/client', () => ({ + client: { + bootstrap: { + testDatabaseKey: { mutate: f.test }, + openAccount: { mutate: f.open }, + openStaticAccount: { mutate: f.openStatic }, + setAutoEnter: { mutate: f.setAuto }, + clearAutoEnter: { mutate: f.clearAuto }, + qrLogin: { + subscribe: (input: unknown, handlers: { onData(event: KeyEvent): void }) => { + f.qr(input); + f.event = handlers.onData; + return { unsubscribe: f.unsubscribe }; + }, + }, + }, + }, +})); +vi.mock('../src/renderer/src/views/bootstrap/QrDialog', () => ({ + QrDialog: ({ onClose }: { onClose(): void }) => h('button', { onClick: onClose }, '取消扫码'), +})); +const accounts: UiAccount[] = ['10001', '10002'].map((uin) => ({ + key: uin, + configId: uin, + uin, + name: `合成账号${uin}`, + hasName: true, + avatarUrl: null, + dbKey: '0123456789abcdef', +})); +let host: HTMLDivElement, root: Root; +const entered = vi.fn(), + busy = vi.fn(); +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.clearAllMocks(); + f.test.mockResolvedValue({ + success: true, + algo: { pageHmacAlgorithm: 'sha1', kdfHmacAlgorithm: 'sha1' }, + }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); +afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); +}); +function Harness({ mode, empty = false }: { mode: 'new' | 'existing'; empty?: boolean }) { + const [selected, setSelected] = useState(empty ? null : accounts[0]!); + return h(LoginPanel, { + mode, + selected, + accounts: empty ? [] : accounts, + onSelect: setSelected, + autoTarget: null, + onEntered: entered, + onBusyChange: busy, + }); +} +async function mount(mode: 'new' | 'existing', empty = false) { + await act(async () => root.render(h(Harness, { mode, empty }))); +} +async function click(label: string) { + const button = [...host.querySelectorAll('button')].find((item) => item.textContent === label); + if (!button) throw new Error(`missing ${label}`); + await act(async () => button.click()); +} +function keyValue() { + act(() => host.querySelector('[aria-label="密钥与恢复"]')!.click()); + const value = document.querySelector('.weq-keyfield-text')?.value; + act(() => document.querySelector('[aria-label="关闭密钥详情"]')!.click()); + return value; +} +function qrSuccess(uin: string, key = 'abcdefghijklmnop') { + act(() => { + f.event({ kind: 'qrcode-state', state: 'confirmed', uin }); + f.event({ kind: 'result', result: { success: true, dbkey: key } }); + }); +} +it('已保存账号一键打开,仍先验证密钥,密钥详情默认折叠', async () => { + await mount('existing'); + expect(document.querySelector('.weq-key-recovery')).toBeNull(); + await click('打开账号'); + expect(f.test).toHaveBeenCalledWith({ uin: '10001', dbKey: accounts[0]!.dbKey }); + expect(f.open).toHaveBeenCalledWith( + expect.objectContaining({ uin: '10001', dbKey: accounts[0]!.dbKey }), + ); + expect(f.test.mock.invocationCallOrder[0]).toBeLessThan(f.open.mock.invocationCallOrder[0]!); + expect(entered).toHaveBeenCalledWith('10001'); +}); +it('密钥失败留在当前账号并提供恢复,不打开数据库', async () => { + f.test.mockResolvedValueOnce({ success: false, error: '合成失效密钥' }); + await mount('existing'); + await click('打开账号'); + expect(host.querySelector('[role=alert]')?.textContent).toContain('合成失效密钥'); + expect(host.querySelector('[role=alert]')?.textContent).toContain('重新获取密钥'); + expect(f.open).not.toHaveBeenCalled(); + expect(busy).toHaveBeenLastCalledWith(false); +}); +it('没有历史账号也能发起匿名扫码;未发现对应本地库时不冒用其他账号', async () => { + await mount('new', true); + await click('扫码登录其他账号'); + expect(f.qr).toHaveBeenCalledWith(undefined); + qrSuccess('99999'); + expect(host.querySelector('[role=alert]')?.textContent).toContain('未发现扫码账号的本地记录'); + expect(f.open).not.toHaveBeenCalled(); + expect(busy).toHaveBeenLastCalledWith(false); +}); +it('扫码选中另一个已发现账号时保留该账号的新密钥,不能绑到原账号', async () => { + await mount('new'); + await click('扫码登录其他账号'); + qrSuccess('10002'); + expect(host.querySelector('.weq-open-identity h2')?.textContent).toBe('合成账号10002'); + expect(keyValue()).toBe('abcdefghijklmnop'); + await click('打开账号'); + expect(f.test).toHaveBeenCalledWith({ uin: '10002', dbKey: 'abcdefghijklmnop' }); + expect(entered).toHaveBeenCalledWith('10002'); +}); +it('取消扫码释放忙碌状态,旧订阅晚到回执不能改变密钥', async () => { + await mount('new'); + await click('扫码登录其他账号'); + await click('取消扫码'); + qrSuccess('10002'); + expect(host.querySelector('.weq-open-identity h2')?.textContent).toBe('合成账号10001'); + expect(keyValue()).toBe(''); + expect(f.open).not.toHaveBeenCalled(); + expect(busy).toHaveBeenLastCalledWith(false); +}); diff --git a/apps/desktop/test/bootstrap_login_states.test.ts b/apps/desktop/test/bootstrap_login_states.test.ts index 5c4b8baca..a4d04b17b 100644 --- a/apps/desktop/test/bootstrap_login_states.test.ts +++ b/apps/desktop/test/bootstrap_login_states.test.ts @@ -75,25 +75,48 @@ function render() { it('未发现在线账号时仍能切到已有的本地备份入口,不显示无效的密钥操作', () => { render(); expect(host.querySelector('.weq-keyfield-action')).toBeNull(); - const backup = host.querySelector('[role="tab"]:last-child'); + const back = host.querySelector('[aria-label="返回账号主页"]'); + act(() => back!.click()); + const backup = host.querySelector('[data-mode="backup"]'); expect(backup).not.toBeNull(); act(() => backup!.click()); expect(host.querySelector('.weq-static-backup')).not.toBeNull(); expect(host.textContent).toContain('导入本地数据库'); }); +it('最近账号和添加、导入入口直接可见,诊断默认不扫描账号目录', () => { + api.mode = 'stale'; + useViewState.getState().enterSelect('existing'); + render(); + expect(host.querySelector('.weq-account-list')?.textContent).toContain('合成缓存账号'); + expect(host.querySelector('[data-mode="new"]')?.textContent).toContain('添加账号'); + expect(host.querySelector('[data-mode="backup"]')?.textContent).toContain('导入备份'); + expect(host.querySelector('.weq-stats')).toBeNull(); + expect(host.querySelector('[role="tablist"]')).toBeNull(); + expect(document.querySelector('.weq-key-recovery')).toBeNull(); +}); +it('添加与导入是独立子页面,可返回主页且不丢失账号选择', () => { + api.mode = 'stale'; + useViewState.getState().enterSelect('existing'); + render(); + act(() => host.querySelector('[data-mode="new"]')!.click()); + expect(host.querySelector('h1')?.textContent).toBe('添加账号'); + expect(host.querySelector('.weq-account-actions')).toBeNull(); + act(() => host.querySelector('[aria-label="返回账号主页"]')!.click()); + expect(host.querySelector('.weq-open-identity h2')?.textContent).toBe('合成缓存账号'); +}); it.each(['new', 'existing'] as const)('%s 读取失败不冒充空配置,并能重试', (mode) => { api.mode = 'error'; useViewState.getState().enterSelect(mode); render(); expect(host.querySelector('[role="alert"]')?.textContent).toContain('合成账号读取失败'); - expect(host.textContent).not.toContain('暂无保存的账号配置'); + expect(host.textContent).not.toContain('还没有保存的账号'); act(() => host.querySelector('[aria-label="重新读取账号"]')!.click()); expect(api.retry).toHaveBeenCalledOnce(); }); it('已有配置确实为空时保留空提示,没有错误提示', () => { useViewState.getState().enterSelect('existing'); render(); - expect(host.textContent).toContain('暂无保存的账号配置'); + expect(host.textContent).toContain('还没有保存的账号'); expect(host.querySelector('[role="alert"]')).toBeNull(); }); it('账号查询进行中仍显示加载态,不冒充空或失败', () => { @@ -108,8 +131,9 @@ it('重新读取失败时仍保留已有账号,不隐藏密钥入口', () => { useViewState.getState().enterSelect('existing'); render(); expect(host.querySelector('[role="alert"]')?.textContent).toContain('合成账号读取失败'); - expect(host.querySelector('.weq-acct-name')?.textContent).toBe('合成缓存账号'); - expect(host.querySelector('.weq-keyfield-text')?.value).toBe( + expect(host.querySelector('.weq-open-identity h2')?.textContent).toBe('合成缓存账号'); + act(() => host.querySelector('[aria-label="密钥与恢复"]')!.click()); + expect(document.querySelector('.weq-keyfield-text')?.value).toBe( '0123456789abcdef', ); }); diff --git a/apps/desktop/test/bootstrap_style_isolation.test.ts b/apps/desktop/test/bootstrap_style_isolation.test.ts new file mode 100644 index 000000000..b5dd3e813 --- /dev/null +++ b/apps/desktop/test/bootstrap_style_isolation.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it } from 'vitest'; +import { WorkspaceContent } from '../src/renderer/src/components/WorkspacePages'; + +it('账号页样式不改变主工作区的宽度、留白或间距', () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const host = document.createElement('div'), + style = document.createElement('style'); + document.body.append(host); + const root = createRoot(host); + try { + act(() => root.render(createElement(WorkspaceContent, null, '合成主页面'))); + const workspace = host.querySelector('.weq-workspace-content')!; + const snapshot = () => { + const computed = getComputedStyle(workspace); + return { + width: computed.width, + margin: computed.margin, + padding: computed.padding, + gap: computed.gap, + }; + }; + const before = snapshot(); + style.textContent = readFileSync('src/renderer/src/styles/bootstrap-select.css', 'utf8'); + document.head.append(style); + expect(snapshot()).toEqual(before); + const account = document.createElement('div'); + account.className = 'weq-account-workspace'; + account.innerHTML = ''; + host.append(account); + expect(getComputedStyle(account.firstElementChild!).paddingLeft).toBe('24px'); + } finally { + act(() => root.unmount()); + host.remove(); + style.remove(); + } +}); diff --git a/apps/desktop/test/context_menu_dismiss.test.ts b/apps/desktop/test/context_menu_dismiss.test.ts index b16e1c7af..77f8b1f14 100644 --- a/apps/desktop/test/context_menu_dismiss.test.ts +++ b/apps/desktop/test/context_menu_dismiss.test.ts @@ -146,11 +146,9 @@ describe('右键菜单外部点击', () => { }), ); }); - press(host.querySelector('.weq-acct-switch')!); act(() => { - // 账号列表挂在 OverlayLayer 里,而它 portal 到 document.body —— 不在 host 下。 document - .querySelector('.weq-acct-row')! + .querySelector('.weq-account-select')! .dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); }); expect(document.querySelector('.weq-acct-ctx-menu')).not.toBeNull(); diff --git a/apps/desktop/test/export_viewer_scan.test.ts b/apps/desktop/test/export_viewer_scan.test.ts index fc11bbf1c..dcb0d015d 100644 --- a/apps/desktop/test/export_viewer_scan.test.ts +++ b/apps/desktop/test/export_viewer_scan.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { afterEach, expect, it, vi } from 'vitest'; import { setHost, type HostBridge } from '@weq/service'; import { exportViewerRouter } from '../src/main/ipc/routers/export_viewer'; -import { closeViewerSources } from '../src/main/export_viewer'; +import { closeViewerSources, viewerSource } from '../src/main/export_viewer'; vi.mock('../src/main/context/app_context', () => ({ getAppContext: () => ({ services: null }) })); const roots: string[] = [], @@ -64,3 +64,49 @@ it('取消递归撤销授权,不读取深层文件', async () => { expect(await caller.scan({ token: result!.scanToken!, recursive: false })).toBeNull(); await expect(caller.scan({ token: result!.scanToken!, recursive: true })).rejects.toThrow('过期'); }); +it('文件夹选择先返回授权,订阅逐项发送可读来源并最后报告完成', async () => { + const root = fixture(); + oldChat(join(root, 'first')); + oldChat(join(root, 'second')); + const picked = await caller.pick({ directory: true, deferScan: true }); + expect(picked?.sources).toHaveLength(0); + expect(picked?.scanToken).toBeTruthy(); + const events: string[] = []; + const stream = await caller.scanProgress({ token: picked!.scanToken!, recursive: false }); + await new Promise((resolve, reject) => + stream.subscribe({ + next(event) { + events.push(event.kind); + if (event.kind === 'source') { + ids.push(event.source.id); + expect(viewerSource(event.source.id).path).toBe(event.source.path); + } + }, + error: reject, + complete: resolve, + }), + ); + expect(events).toEqual(['source', 'source', 'done']); + await expect(caller.scan({ token: picked!.scanToken!, recursive: true })).rejects.toThrow('过期'); +}); +it('关闭未完成的进度订阅会撤销这次扫描的媒体访问能力', async () => { + const root = fixture(); + oldChat(join(root, 'first')); + oldChat(join(root, 'second')); + const picked = await caller.pick({ directory: true, deferScan: true }); + const stream = await caller.scanProgress({ token: picked!.scanToken!, recursive: false }); + let subscription: { unsubscribe(): void } | undefined, + id = ''; + await new Promise((resolve, reject) => { + subscription = stream.subscribe({ + next(event) { + if (event.kind !== 'source') return; + id = event.source.id; + subscription?.unsubscribe(); + resolve(); + }, + error: reject, + }); + }); + expect(() => viewerSource(id)).toThrow('关闭'); +}); diff --git a/apps/desktop/test/export_viewer_ui.test.ts b/apps/desktop/test/export_viewer_ui.test.ts index 20dd873e7..809eb01c2 100644 --- a/apps/desktop/test/export_viewer_ui.test.ts +++ b/apps/desktop/test/export_viewer_ui.test.ts @@ -3,10 +3,12 @@ import { createRoot } from 'react-dom/client'; import { beforeEach, afterEach, expect, it, vi } from 'vitest'; import { ExportViewer } from '../src/renderer/src/views/ExportViewer'; import { useDialog } from '../src/renderer/src/components/Dialog'; +import type { ViewerScanEvent } from '../src/main/ipc/routers/export_viewer'; const f = vi.hoisted(() => ({ pick: vi.fn(), scan: vi.fn(), + event: (_event: ViewerScanEvent) => {}, close: vi.fn(async () => true), jump: vi.fn(async () => true), local: { kind: 'group', conv: '777', uin: '10001', configId: 'source-a' } as object | null, @@ -46,6 +48,30 @@ vi.mock('../src/renderer/src/trpc/client', () => ({ exportViewer: { pick: { mutate: f.pick }, scan: { mutate: f.scan }, + scanProgress: { + subscribe: ( + input: unknown, + handlers: { onData(event: ViewerScanEvent): void; onError(error: Error): void }, + ) => { + let active = true; + f.event = handlers.onData; + void f.scan(input).then((result) => { + if (!active) return; + for (const source of result.sources ?? []) handlers.onData({ kind: 'source', source }); + handlers.onData({ + kind: 'done', + truncated: result.truncated ?? false, + unreadable: result.unreadable ?? 0, + scanToken: result.scanToken ?? null, + }); + }, handlers.onError); + return { + unsubscribe() { + active = false; + }, + }; + }, + }, close: { mutate: f.close }, jump: { mutate: f.jump }, }, @@ -108,7 +134,7 @@ async function click(label: string) { it('只提供文件夹与 JSON/JSONL 打开入口,显示嵌套正文且不执行文本 HTML', async () => { await mount(); await click('打开文件夹'); - expect(f.pick).toHaveBeenCalledWith({ directory: true }); + expect(f.pick).toHaveBeenCalledWith({ directory: true, deferScan: true }); expect(host.textContent).toContain('合成归档'); expect(host.textContent).toContain('嵌套正文'); expect(host.querySelector('script')).toBeNull(); @@ -173,19 +199,22 @@ it('关闭撤销已打开来源', async () => { it('浅层空目录提示递归,确认后使用选择授权继续扫描', async () => { const confirm = vi.spyOn(useDialog.getState(), 'confirm').mockResolvedValue(true); f.pick.mockResolvedValueOnce({ sources: [], scanToken: 'synthetic-token', truncated: false }); + f.scan.mockResolvedValueOnce({ sources: [], scanToken: 'recursive-token', truncated: false }); await mount(); await click('打开文件夹'); expect(confirm).toHaveBeenCalledOnce(); - expect(f.scan).toHaveBeenCalledWith({ token: 'synthetic-token', recursive: true }); + expect(f.scan).toHaveBeenCalledWith({ token: 'recursive-token', recursive: true }); expect(host.textContent).toContain('合成归档'); }); it('取消递归会释放选择授权而不替换原列表', async () => { vi.spyOn(useDialog.getState(), 'confirm').mockResolvedValue(false); f.pick.mockResolvedValueOnce({ sources: [], scanToken: 'synthetic-token', truncated: false }); - f.scan.mockResolvedValueOnce(null); + f.scan + .mockResolvedValueOnce({ sources: [], scanToken: 'recursive-token', truncated: false }) + .mockResolvedValueOnce(null); await mount(); await click('打开文件夹'); - expect(f.scan).toHaveBeenCalledWith({ token: 'synthetic-token', recursive: false }); + expect(f.scan).toHaveBeenCalledWith({ token: 'recursive-token', recursive: false }); expect(host.textContent).toContain('尚未打开导出'); }); it('卸载后迟到的文件选择结果也会释放,不保留后台媒体能力', async () => { @@ -202,3 +231,35 @@ it('卸载后迟到的文件选择结果也会释放,不保留后台媒体能 await act(async () => finish(result)); expect(f.close).toHaveBeenCalledWith({ ids: [source.id] }); }); +it('目录尚未扫完即显示来源和消息,继续显示扫描动画;追加结果不抢当前选择', async () => { + f.pick.mockResolvedValueOnce({ sources: [], scanToken: 'synthetic-token' }); + f.scan.mockImplementationOnce(() => new Promise(() => {})); + await mount(); + await click('打开文件夹'); + await act(async () => f.event({ kind: 'source', source } as ViewerScanEvent)); + expect(host.textContent).toContain('合成归档'); + expect(host.textContent).toContain('嵌套正文'); + expect(host.querySelector('[role=status]')?.textContent).toContain('扫描中'); + expect(host.querySelector('[role=status] svg.weq-spin')).not.toBeNull(); + await act(async () => + f.event({ + kind: 'source', + source: { ...source, id: 'second', name: '第二个导出' }, + } as ViewerScanEvent), + ); + const second = host.querySelectorAll('.weq-ev-sources button')[1]!; + await act(async () => second.click()); + await act(async () => + f.event({ + kind: 'source', + source: { ...source, id: 'third', name: '第三个导出' }, + } as ViewerScanEvent), + ); + expect(host.querySelectorAll('.weq-ev-sources button')[1]?.getAttribute('aria-pressed')).toBe( + 'true', + ); + await act(async () => + f.event({ kind: 'done', truncated: false, unreadable: 0, scanToken: null }), + ); + expect(host.querySelector('[role=status]')).toBeNull(); +}); diff --git a/apps/desktop/test/remote_account_startup.test.ts b/apps/desktop/test/remote_account_startup.test.ts index 4e5bace06..0a04240ef 100644 --- a/apps/desktop/test/remote_account_startup.test.ts +++ b/apps/desktop/test/remote_account_startup.test.ts @@ -26,7 +26,6 @@ vi.mock('../src/renderer/src/components/Dialog', () => ({ })); vi.mock('../src/renderer/src/lib/windowLayout', () => ({ setWindowLayout: vi.fn() })); vi.mock('../src/renderer/src/lib/accountWarmup', () => ({ runAccountWarmup: api.warmup })); -vi.mock('../src/renderer/src/views/bootstrap/HomeScreen', () => ({ HomeScreen: () => null })); vi.mock('../src/renderer/src/views/bootstrap/SelectScreen', () => ({ SelectScreen: () => null })); vi.mock('../src/renderer/src/components/settings/BackupSection', () => ({ BackupSection: () => null, diff --git a/apps/desktop/test/resource_analysis_progress.test.ts b/apps/desktop/test/resource_analysis_progress.test.ts new file mode 100644 index 000000000..7e363c2e2 --- /dev/null +++ b/apps/desktop/test/resource_analysis_progress.test.ts @@ -0,0 +1,47 @@ +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +import type { ResourceStat } from '@weq/service'; +import { ResourceAnalyticsDialog } from '../src/renderer/src/views/cache/ResourceAnalyticsDialog'; + +const query = vi.hoisted(() => vi.fn()); +vi.mock('../src/renderer/src/trpc/client', () => ({ + client: { account: { mediaResource: { analyzeTree: { query } } } }, +})); +vi.mock('../src/renderer/src/components/WorkspacePages', () => ({ + WorkspacePage: ({ children }: { children: React.ReactNode }) => children, +})); +it('后续目录还在扫描时,已完成目录立即进入图表,并明确标记累计值', async () => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const stat: ResourceStat = { + key: 'pic', + present: true, + files: 3, + bytes: 3072, + byMonth: { '2026-10': { files: 3, bytes: 3072 } }, + ori: { files: 3, bytes: 3072 }, + thumb: { files: 0, bytes: 0 }, + other: { files: 0, bytes: 0 }, + }; + query + .mockReset() + .mockResolvedValueOnce(stat) + .mockImplementationOnce(() => new Promise(() => {})); + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + try { + await act(async () => + root.render(createElement(ResourceAnalyticsDialog, { open: true, onClose() {} })), + ); + expect(host.textContent).toContain('已统计占用空间'); + expect(host.textContent).toContain('已统计文件数'); + expect(host.querySelector('.weq-ra-progress')?.textContent).toContain('视频'); + const cards = [...host.querySelectorAll('.weq-ra-card')]; + expect(cards.slice(0, 3).every((card) => !card.querySelector('.weq-ra-waiting'))).toBe(true); + expect(query).toHaveBeenCalledTimes(2); + } finally { + act(() => root.unmount()); + host.remove(); + } +}); diff --git a/apps/desktop/test/static_backup_discovery.test.ts b/apps/desktop/test/static_backup_discovery.test.ts index 789bb5876..c59140949 100644 --- a/apps/desktop/test/static_backup_discovery.test.ts +++ b/apps/desktop/test/static_backup_discovery.test.ts @@ -11,6 +11,8 @@ import { discoverBackups, matchingBackupConfigs, probeBackup, + scanBackupCandidates, + type BackupScanEvent, } from '../src/main/static_backup'; import { inspectBackupDatabases } from '../src/main/backup_probe_core'; @@ -102,6 +104,101 @@ it('深层账号需要手动递归,同路径不重复列出', async () => { expect((await discoverBackups(root)).candidates).toHaveLength(0); expect((await discoverBackups(root, true)).candidates).toHaveLength(1); }); +it('目录发现后文件被移走时保留已发现项与失败状态,不把未知大小冒充零', async () => { + const root = db(fixture()); + let removed = false; + const result = await discoverBackups(root, false, { + onCandidate(info) { + if (info.databaseCount === 3 && info.databaseBytes === null && !removed) { + removed = true; + for (const name of ['nt_msg.db', 'profile_info.db', 'group_info.db']) + rmSync(join(root, name)); + } + }, + }); + expect(result.candidates[0]).toMatchObject({ + databaseCount: 3, + databaseBytes: null, + sizePending: false, + sizeError: '3 个数据库大小未能读取', + }); +}); +it('先回传目录,再逐步补齐大小;原生识别未结束时结果已可见', async () => { + const root = db(fixture()); + const events: BackupScanEvent[] = []; + let release!: () => void; + f.derive.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(undefined); + }), + ); + let done = false; + const task = scanBackupCandidates(platform, [], root, false, { allowOnline: false }, (event) => + events.push(event), + ).then((result) => { + done = true; + return result; + }); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + expect(done).toBe(false); + expect(events[0]).toMatchObject({ + kind: 'candidate', + candidate: { + dirPath: root, + databaseCount: null, + databaseBytes: null, + sizePending: true, + probe: null, + }, + }); + const updates = events.filter((event) => event.kind === 'candidate'); + expect( + updates.some((event) => event.candidate.databaseCount === 3 && event.candidate.sizePending), + ).toBe(true); + expect(updates.at(-1)?.candidate).toMatchObject({ + databaseCount: 3, + databaseBytes: 27, + sizePending: false, + probe: null, + }); + release(); + await task; + expect(events.at(-1)).toMatchObject({ kind: 'done' }); + expect(events.at(-2)).toMatchObject({ + kind: 'candidate', + candidate: { probe: { ok: true, needKey: true } }, + }); +}); +it('取消扫描后不再探测下一个目录或发送完成回执', async () => { + const root = fixture(); + db(join(root, 'first')); + db(join(root, 'second')); + const events: BackupScanEvent[] = [], + controller = new AbortController(); + let release!: () => void; + f.derive.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(undefined); + }), + ); + const task = scanBackupCandidates( + platform, + [], + root, + false, + { allowOnline: false }, + (event) => events.push(event), + controller.signal, + ); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + controller.abort(); + release(); + await expect(task).rejects.toThrow(); + expect(f.derive).toHaveBeenCalledOnce(); + expect(events.some((event) => event.kind === 'done')).toBe(false); +}); it.each([ { system: 'Windows', root: ['Documents', 'Tencent Files'], account: ['12345', 'nt_qq'] }, { system: 'Linux', root: ['.config', 'QQ'], account: ['nt_qq_synthetic'] }, diff --git a/apps/desktop/test/static_backup_panel.test.ts b/apps/desktop/test/static_backup_panel.test.ts index 4d7109ca4..fdcc19f42 100644 --- a/apps/desktop/test/static_backup_panel.test.ts +++ b/apps/desktop/test/static_backup_panel.test.ts @@ -3,13 +3,43 @@ import { createRoot } from 'react-dom/client'; import { beforeEach, afterEach, expect, it, vi } from 'vitest'; import { StaticBackupPanel } from '../src/renderer/src/views/bootstrap/StaticBackupPanel'; import { useDialog } from '../src/renderer/src/components/Dialog'; +import type { BackupScanEvent } from '../src/main/static_backup'; -const f = vi.hoisted(() => ({ pick: vi.fn(), scan: vi.fn(), test: vi.fn(), open: vi.fn() })); +const f = vi.hoisted(() => ({ + pick: vi.fn(), + scan: vi.fn(), + test: vi.fn(), + open: vi.fn(), + event: (_event: BackupScanEvent) => {}, +})); vi.mock('../src/renderer/src/trpc/client', () => ({ client: { bootstrap: { pickStaticDbDir: { mutate: f.pick }, - scanStaticDirs: { mutate: f.scan }, + scanStaticDirsProgress: { + subscribe: ( + input: unknown, + handlers: { onData(event: BackupScanEvent): void; onError(error: Error): void }, + ) => { + let active = true; + f.event = handlers.onData; + void f.scan(input).then((result) => { + if (!active) return; + for (const candidate of result.candidates) + handlers.onData({ kind: 'candidate', candidate }); + handlers.onData({ + kind: 'done', + truncated: result.truncated, + unreadable: result.unreadable, + }); + }, handlers.onError); + return { + unsubscribe: () => { + active = false; + }, + }; + }, + }, testStaticDir: { mutate: f.test }, openStaticAccount: { mutate: f.open }, }, @@ -20,6 +50,7 @@ const candidate = (uin: string) => ({ dbDir: `/synthetic/${uin}/nt_db`, databaseCount: 3, databaseBytes: 1048576, + sizePending: false, mediaDir: `/synthetic/${uin}/nt_data`, probe: { ok: true, @@ -155,3 +186,56 @@ it('弹窗切换是草稿,取消和 Escape 保留原目录;重新打开不 expect(document.querySelector('[role=dialog]')).toBeNull(); expect(f.scan).toHaveBeenCalledOnce(); }); +it('扫描未结束即展示已发现目录,未知大小不显示为零,后续更新不重置选择', async () => { + f.scan.mockImplementationOnce(() => new Promise(() => {})); + await mount(); + await click('浏览…'); + await act(async () => + f.event({ + kind: 'candidate', + candidate: { + ...candidate('12345'), + databaseCount: null, + databaseBytes: null, + sizePending: true, + probe: null, + }, + }), + ); + expect(document.querySelector('[role=dialog]')?.textContent).toContain('扫描中'); + expect(document.querySelector('[role=dialog]')?.textContent).toContain('大小统计中'); + expect(document.querySelector('[role=dialog]')?.textContent).not.toContain('0.0 MB'); + expect( + [...document.querySelectorAll('button')].find((button) => button.textContent === '使用此目录') + ?.disabled, + ).toBe(true); + await act(async () => f.event({ kind: 'candidate', candidate: candidate('12345') })); + expect(document.querySelector('[role=dialog]')?.textContent).toContain('合成账号12345'); + expect(document.querySelector('input[type=radio]')?.checked).toBe(true); + await click('使用此目录'); + await click('进入'); + expect(entered).toHaveBeenCalledWith('12345'); + await act(async () => f.event({ kind: 'candidate', candidate: candidate('67890') })); + expect(document.querySelector('[role=dialog]')).toBeNull(); + expect(host.querySelector('.weq-static-input')?.value).toBe( + candidate('12345').dirPath, + ); +}); +it('已有目录重新浏览其他文件夹后取消,原目录、身份和已验证密钥仍可打开', async () => { + await mount(); + await click('浏览…'); + await click('使用此目录'); + f.scan.mockResolvedValueOnce({ + candidates: [candidate('11111'), candidate('22222')], + truncated: false, + unreadable: 0, + }); + await click('浏览…'); + await click('取消'); + await click('进入'); + expect(f.open).toHaveBeenCalledWith({ + dirPath: candidate('12345').dirPath, + dbKey: 'key-12345', + preview: candidate('12345').probe.preview, + }); +}); diff --git a/apps/desktop/test/window_layout.test.ts b/apps/desktop/test/window_layout.test.ts new file mode 100644 index 000000000..11eefaab4 --- /dev/null +++ b/apps/desktop/test/window_layout.test.ts @@ -0,0 +1,76 @@ +import { expect, it, vi } from 'vitest'; +import { WINDOW_LAYOUTS, applyWindowLayout } from '../src/main/window_layout'; + +it('回到登录页先放宽最小宽度,再收成竖版;返回聊天恢复原边界', () => { + let bounds = { x: 100, y: 100, width: 1180, height: 760 }; + const calls: string[] = []; + const win = { + isMaximized: () => false, + isFullScreen: () => false, + getBounds: () => bounds, + getContentSize: (): [number, number] => [bounds.width, bounds.height], + setMinimumSize: vi.fn(() => { + calls.push('minimum'); + }), + setContentSize: vi.fn((width: number, height: number) => { + calls.push('size'); + bounds = { ...bounds, width, height }; + }), + setPosition: vi.fn(), + }; + applyWindowLayout(win, 'home', { x: 0, y: 0, width: 1440, height: 900 }); + expect(calls).toEqual(['minimum', 'size']); + expect(win.setContentSize).toHaveBeenLastCalledWith( + WINDOW_LAYOUTS.home.width, + WINDOW_LAYOUTS.home.height, + ); + expect(win.setMinimumSize).toHaveBeenLastCalledWith( + WINDOW_LAYOUTS.home.minWidth, + WINDOW_LAYOUTS.home.minHeight, + ); + applyWindowLayout(win, 'chat', { x: 0, y: 0, width: 1024, height: 700 }); + expect(win.setContentSize).toHaveBeenLastCalledWith(1024, 700); + expect(win.setMinimumSize).toHaveBeenLastCalledWith( + WINDOW_LAYOUTS.chat.minWidth, + WINDOW_LAYOUTS.chat.minHeight, + ); + expect(win.setPosition).toHaveBeenLastCalledWith(0, 0); +}); +it('最大化或全屏时不改变用户窗口', () => { + const change = vi.fn(); + const win = { + isMaximized: () => true, + isFullScreen: () => false, + getBounds: () => ({ x: 0, y: 0, width: 1440, height: 900 }), + getContentSize: (): [number, number] => [1440, 900], + setMinimumSize: change, + setContentSize: change, + setPosition: change, + }; + applyWindowLayout(win, 'home', win.getBounds()); + expect(change).not.toHaveBeenCalled(); + applyWindowLayout( + { ...win, isMaximized: () => false, isFullScreen: () => true }, + 'home', + win.getBounds(), + ); + expect(change).not.toHaveBeenCalled(); +}); +it('可用空间不足时为原生标题栏和边框预留空间,不把底部推到屏幕外', () => { + let bounds = { x: 400, y: 200, width: 1180, height: 788 }; + const win = { + isMaximized: () => false, + isFullScreen: () => false, + getBounds: () => bounds, + getContentSize: (): [number, number] => [bounds.width, bounds.height - 28], + setMinimumSize: vi.fn(), + setContentSize: vi.fn((width: number, height: number) => { + bounds = { ...bounds, width, height: height + 28 }; + }), + setPosition: vi.fn(), + }; + applyWindowLayout(win, 'chat', { x: 0, y: 0, width: 1024, height: 700 }); + expect(win.setContentSize).toHaveBeenCalledWith(1024, 672); + expect(bounds.height).toBe(700); + expect(win.setPosition).toHaveBeenCalledWith(0, 0); +}); diff --git a/docs/develop/account-workspace-20261025.md b/docs/develop/account-workspace-20261025.md new file mode 100644 index 000000000..b31a276e8 --- /dev/null +++ b/docs/develop/account-workspace-20261025.md @@ -0,0 +1,33 @@ +# 账号入口与渐进目录扫描(2026-10-25) + +## 账号入口 + +- 启动完成后直接展示账号主页,默认窗口为 440×680,最小 360×480;进入聊天后恢复原聊天尺寸与最小边界。切换尺寸前先更新最小约束,围绕原中心定位并限制在当前屏幕工作区内;最大化和全屏不强制调整。 +- 最近账号、添加账号和备份导入分别展示,密钥复制与恢复使用现有 Modal。密钥验证、静态账号打开和自动进入存储接口不变。 +- 账号类型、完整来源目录与北京时间的上次打开时间帮助区分同 QQ 的多个快照。移除配置需要明确确认,不删除数据库。 +- 登录订阅按请求代次隔离;取消、卸载后的回执不修改界面。匿名扫码只把密钥交给实际匹配的本机账号,不冒用原选择。 +- 账号专用容器统一使用 `weq-account-*`,不复用主工作区的 `weq-workspace-content`。已用真实组件复现过同名样式导致的左右额外 102px 与上下留白,加入样式隔离回归。 + +## 目录进度 + +- `bootstrap.scanStaticDirsProgress` 回传 `candidate` 和 `done`。目录先出现;数量、大小使用 `null` 表示未知,`sizePending` 区分累计与完成,`probe: null` 表示身份尚未验证。旧 `scanStaticDirs` mutation 保留并复用相同实现。 +- 目录遍历仍限制 4096 个目录、64 个结果,不跟随符号链接;无浅层结果才询问递归。停止订阅后不继续下一次目录或身份探测;已经运行中的不可中断文件/原生调用允许自行结束,其结果不再回传。 +- `exportViewer.pick({ directory: true, deferScan: true })` 先返回一次性目录授权;`scanProgress` 逐项注册并回传可读来源。已发现来源立即可以浏览,追加结果不重置用户选择,结束时才撤掉扫描提示。 +- 浅层无结果时重新签发递归授权;拒绝递归、过期与取消均不扩大读取范围。未完成订阅关闭时撤销该次注册的媒体访问能力,正常结束或读盘失败后已显示来源由查看器显式关闭。 +- 导出来源最多每批 64 项,注册表允许旧、新两批短暂交接;搜索、消息读取和媒体沙箱不变。 +- 本地资源统计的分类、源文件比例与月份图表随已完成目录更新,不再等待全部分类结束;完成前明确显示「已统计」累计值,并保留当前目录的扫描动画。 + +## 高度与验证边界 + +- 首页与群交集重新填满主工作区;短窗口保留滚动,不裁掉内容。图谱至少保留 280px 画布,控制区通过外层滚动到达。 +- 导出查看器使用宿主完整高度;窄屏给来源和消息区保留最小工作空间,短窗口整体可滚动。 +- 验证使用合成账号、临时导出夹具、真实 React 组件和隔离 RPC,不读取私人 QQ 数据、不提交登录、不发送消息、不安装或替换客户端。 +- 本机按定向单测、桌面类型检查、Biome 和浏览器布局/状态检查验收,不以此宣称完成全量 CI、真机登录、Native 或安装包验收。 + +## 本轮回归结果 + +- desktop 16 文件 89 项、service 导出查看与基线目录 2 文件 26 项定向测试通过,共 115 项。 +- desktop node/web 类型检查、涉及源码与样式的 Biome 检查通过,未运行全量 CI。 +- 账号、备份与查看器 90 个浏览器状态/布局场景通过;首页与群交集、画布像素、控制区可达性及跟随系统主题 17 个场景通过。视口覆盖 320×360、360×600、390×600、440×680、940×520、1120×800 和 1180×760。 +- 深浅模式共 12 组主题配色采样最低对比度 5.33;默认 440×680 账号主页的添加/导入操作区在首屏内,另验证密钥详情小窗边界与预览打开/返回交接。 +- 浏览器均使用隔离 RPC、合成账号与导出内容并阻断外网;没有读取个人 QQ 数据、真实登录、发送消息或替换已安装客户端。 diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index fe52dc319..188849519 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -15,19 +15,16 @@ macOS 的拖入安装与首次系统放行见 [macOS 安装与首次打开](./macos-install.md),不需要终端命令或关闭 SIP。 -## 2. 首页:Bootstrap 界面 +## 2. 账号主页 -启动后先看到的是 Bootstrap 首页。左上角是品牌 Logo 与版本名,右上角固定两个小按钮: +启动后直接进入紧凑的竖版账号主页,不再先选择「现有账号配置」或「新的开始」。右上角保留窗口级操作: - **打开日志目录**:出问题时把最新日志一并附上; - **关闭**(非 macOS):直接关窗。 -页面正中只有两张卡片: +主页显示当前账号与「打开账号」,下方列出最近使用的账号。每项显示来源、目录和上次打开时间;同一 QQ 的不同备份不会合并。账号操作菜单可以移除保存配置,确认后只删除 WeQ 配置,不动 QQ 数据或备份文件。 -| 卡片 | 作用 | -| --- | --- | -| **现有账号配置** | 打开之前已经确认过的本地密钥,无需重新取密钥。没有保存过配置时按钮置灰 | -| **新的开始** | 检测账号并获取数据库密钥 | +「添加账号」和「导入备份」分别进入独立子页面,左上角返回账号主页;进入账号后窗口切换为聊天尺寸。密钥查看、复制与重新获取在头像旁的「密钥与恢复」中,正常打开不必展开。 > [!important] > @@ -41,28 +38,16 @@ macOS 的拖入安装与首次系统放行见 [macOS 安装与首次打开](./ma 失败才退回首页并提示原因。自动进入前会先把窗口调整成聊天尺寸并预热首屏查询,所以不会先闪 一下空白头像。 -## 3. 选择账号与验证密钥 +## 3. 打开账号与环境诊断 -点任意一张卡片进入**双栏选择页**: - -``` -┌──────────────────────────┬──────────────────────────────────────┐ -│ 左栏 登录面板 │ 右栏 诊断与统计 │ -│ 账号列表 + 密钥输入 │ QQ 版本 / 数据目录 │ -│ 获取密钥 / 进入 │ 本地账号 · 目录大小 · 在线实例 │ -│ 下次自动进入 │ 数据库占用(彩色条形图) │ -└──────────────────────────┴──────────────────────────────────────┘ -``` - -- **现有账号配置**模式:左栏列出已保存的账号,密钥已自动填好,点「进入」即可。右键账号可以删除 - 该配置(只删 WeQ 保存的配置,不动 QQ 本体数据)。 -- **新的开始**模式:左栏额外有两个来源页签——「在线账号」与「本地备份」。 - 未选择在线账号时仍可切到「本地备份」导入,不需要先有本机 QQ 登录记录。 +- 在主页选择已保存账号后点「打开账号」;密钥仍会先验证,失败留在当前账号并提供重新获取入口。 +- 「添加账号」展示本机发现的 QQ 账号,也可独立发起扫码。扫码账号没有对应本地记录时会明确提示先在本机 QQ 同步记录或导入备份,不把它的密钥绑定到原账号。 +- 「导入备份」不要求先选在线账号,也不要求本机有 QQ 登录历史。 - 账号列表读取失败会显示原因,可点刷新图标重试;已有账号不会因为重新读取失败被隐藏。 -### 右栏统计 +### 环境诊断 -右栏顶部是 QQ 身份卡:版本号 + 数据目录路径。**点击路径**可以随时重新指定 QQ 数据目录 +页面底部的「环境诊断」默认折叠,展开后才开始目录大小扫描与在线实例轮询。顶部显示 QQ 版本与数据目录。**点击路径**可以重新指定 QQ 数据目录 (例如把 QQ 数据挪到了别的盘)。文件夹叫什么都行——老版本 QQ 迁移过来的目录通常不叫 `Tencent Files`——只要选到「存放 QQ 号子目录 / `nt_qq` 的那一层」即可;选成某个 QQ 号 子目录时会自动上溯一层。下方三张统计卡: @@ -75,27 +60,13 @@ macOS 的拖入安装与首次系统放行见 [macOS 安装与首次打开](./ma ## 4. 获取数据库密钥 -在「新的开始 → 在线账号」下选中一个账号,点「获取密钥」。WeQ 会**自动探测 QQ 在线情况并动态 -选择方案**,无需你手动指定: - -1. **QQ 在线实例** —— 直接从运行中的 QQ 取密钥。 - - Windows:直接进程内注入; - - Linux:先弹出授权(`pkexec` 或 root 下的进程内注入); - - macOS:受 SIP 限制不注入,改为**提权扫描进程内存**恢复密钥;扫不到会引导「解除 SIP 重试 / - 杀掉 QQ 重启取密钥」。 -2. **可快速登录** —— 走快速登录(复用本地 `a1Key`)。 -3. **否则** —— 走**扫码登录**,用手机 QQ 扫码。 - -> [!note] -> -> macOS / Linux 上登录流程需要安装 **NineBird** 才能重启 QQ 取密钥,WeQ 会在需要时弹出管理员 -> 密码框自动完成安装。 +在「添加账号」中选中本机账号,点「获取密钥」。有可复用的快速登录物料时先快速登录,否则显示二维码;快速登录失败也会转为扫码。确认提示按实际返回显示,不要求手动选择平台方案。 -也可以用手机扫码**登录一个新的账号**:在左栏底部点「登录新的账号」。 +「扫码登录其他账号」是独立入口,无已发现账号时也可发起;手动填写密钥可打开头像旁的「密钥与恢复」。 ### 验证后进入 -无论密钥从哪来,点「进入」时 WeQ 都会**先测试密钥**(`testDatabaseKey`): +无论密钥从哪来,点「打开账号」时 WeQ 都会**先测试密钥**(`testDatabaseKey`): - 密钥不正确 → 弹出错误框,拒绝进入; - 正确 → 打开账号、写入配置,进入主界面。 @@ -104,10 +75,11 @@ macOS 的拖入安装与首次系统放行见 [macOS 安装与首次打开](./ma ## 5. 本地备份(静态离线账号) -在「新的开始」页签切到 **本地备份**,选择包含 `nt_msg.db`、`profile_info.db`、`group_info.db` 的目录,导入一份**静态离线账号**: +从账号主页进入 **导入备份**,选择包含 `nt_msg.db`、`profile_info.db`、`group_info.db` 的目录,导入一份**静态离线账号**: - 支持平铺数据库目录、Tencent Files 根目录、Windows 的 QQ 号目录,以及 Linux/macOS 的 `nt_qq_` 账号目录;所选目录没有账号时自动检查下一级,仍未找到则询问是否递归扫描。 - 找到多个账号目录时自动弹出可滚动的选择窗口,逐项展示可验证的账号昵称、QQ 号、数据库数量和体积、媒体目录状态;点击「使用此目录」确认,之后可通过「切换账号目录」重新选择。取消或按 Escape 不改变已选目录。加密库尚无法识别身份时会标记待验证,不把目录名冒充实际身份。 +- 扫描发现目录后立即显示,数据库数量、大小与身份随后逐步补齐;未知大小不显示为零。扫描未结束时保留动画和已发现数量,已完成验证的目录可提前选择;停止扫描后不再追加结果。 - 明文数据库无需密钥;Android 备份优先尝试自动推导,再验证已有配置密钥。需要其他设备密钥时,先复用同账号已有且仍匹配当前 QQ 进程的会话物料;必要时读取该账号 QQ 内存并按平台流程请求提权,再请求和验证密钥。完全离线或获取失败时可手动输入密钥。 - 备份包含标准 `nt_data` 目录时直接使用其中的图片、语音、视频、文件和表情;没有时保留原有的同账号本机媒体关联与在线补全能力。 - 扫描结果显示包含数据库和媒体的完整账号文件夹,而不是仅显示 `nt_db`;内部会分别定位数据库与媒体。旧备份的 `profile_info_v2` 等资料表也可识别,自动取密钥成功后会显示对应状态。 diff --git a/packages/service/src/account/export/viewer.ts b/packages/service/src/account/export/viewer.ts index da3cb9cac..2b75d9811 100644 --- a/packages/service/src/account/export/viewer.ts +++ b/packages/service/src/account/export/viewer.ts @@ -477,14 +477,20 @@ export async function describeViewerFile(path: string): Promise void; signal?: AbortSignal } = {}, +) { + options.signal?.throwIfAborted(); const root = await realpath(directory); const catalog = listExportBaselines(root); const sources: ExportViewerSource[] = []; for (const entry of catalog.items) { + options.signal?.throwIfAborted(); if (entry.kind !== 'group' && entry.kind !== 'c2c') continue; const info = await stat(entry.journalPath); - sources.push({ + const source: ExportViewerSource = { path: entry.journalPath, root: basename(entry.directory).startsWith('.weq-export') ? dirname(entry.directory) @@ -499,13 +505,16 @@ export async function discoverViewerSources(directory: string, allowEmpty = fals conversation: { kind: entry.kind, conv: entry.conv }, baselineDirectory: entry.directory, committedOnly: true, - }); + }; + sources.push(source); + options.onSource?.(source); } // 老导出可能没有基线;只探索当前目录,不递归读取未知文件树。 const files = (await readdir(root, { withFileTypes: true })).filter( (entry) => entry.isFile() && /\.jsonl?$/i.test(entry.name), ); for (const entry of files.slice(0, 64)) { + options.signal?.throwIfAborted(); if (sources.some((source) => source.path === join(root, entry.name))) continue; const source = await describeViewerFile(join(root, entry.name)).catch(() => null); if (!source) continue; @@ -520,9 +529,11 @@ export async function discoverViewerSources(directory: string, allowEmpty = fals ) continue; sources.push(source); + options.onSource?.(source); } const journals = await hiddenViewerJournals(root); for (const path of journals.paths) { + options.signal?.throwIfAborted(); if (sources.some((source) => source.path === path)) continue; const inspected = await inspectViewerFile(path, true).catch(() => null); if (!inspected) continue; @@ -538,6 +549,7 @@ export async function discoverViewerSources(directory: string, allowEmpty = fals continue; source.root = dirname(path) === join(root, '.weq-export') ? root : source.root; sources.push(source); + options.onSource?.(source); } if (!sources.length && !allowEmpty) throw new Error('没有找到可查看的 WeQ 消息基线或聊天 JSON/JSONL 文件。'); From ea427f6c17938be1a2b4095ebb9fc0898e41f3c4 Mon Sep 17 00:00:00 2001 From: ganpig Date: Sun, 25 Oct 2026 14:19:55 +0800 Subject: [PATCH 009/185] =?UTF-8?q?fix(desktop):=20=E8=AE=A9=20macOS=20?= =?UTF-8?q?=E5=BD=BB=E5=BA=95=E9=80=80=E5=87=BA=E6=97=B6=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + apps/desktop/src/main/app_quit_lifecycle.ts | 35 ++++++ apps/desktop/src/main/daemon/runtime.ts | 51 +++++++- apps/desktop/src/main/index.ts | 33 +++-- apps/desktop/test/app_quit_lifecycle.test.ts | 66 +++++++++- apps/desktop/test/daemon_quit.test.ts | 126 +++++++++++++++++++ docs/guide/settings.md | 5 + packages/daemon/README.md | 7 ++ 8 files changed, 310 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/test/daemon_quit.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fb5a29f22..d2ba73d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -549,3 +549,4 @@ - 修复首页、群交集等页面的异常留白,短窗口保留可用的图谱与消息区域,避免内容被压扁或裁掉。 - 修复 macOS 无证书版本更新时报签名身份不匹配的问题;核验官方更新包后退出安装,失败时保留原应用。 - 修复聊天消息及回复引用中的 @ 无法点击查看名片的问题,有有效 UID 或 QQ 号即可打开。 +- 修复 macOS 按 Cmd+Q 后后台守护进程仍运行的问题,彻底退出会停止本轮后台服务,关闭窗口最小化到托盘的行为不变。 diff --git a/apps/desktop/src/main/app_quit_lifecycle.ts b/apps/desktop/src/main/app_quit_lifecycle.ts index a475a158a..4aa274f2f 100644 --- a/apps/desktop/src/main/app_quit_lifecycle.ts +++ b/apps/desktop/src/main/app_quit_lifecycle.ts @@ -2,6 +2,41 @@ interface QuitEventSource { on(event: E, listener: () => void): unknown; } +/** 暂缓真正退出,等后台清理结束后重走正常退出,不绕过更新安装的握手。 */ +export function registerAppQuitCleanup( + app: { + on(event: 'before-quit', listener: (event: { preventDefault(): void }) => void): unknown; + quit(): void; + }, + cleanup: () => Promise, + failed: (error: unknown) => void, + updater?: QuitEventSource<'before-quit-for-update'>, +): void { + let cleaning = false; + let cleaned = false; + let updating = false; + // 原生更新先关闭窗口再交给安装器,不把这次交接改成普通 app.quit。 + updater?.on('before-quit-for-update', () => { + updating = true; + }); + app.on('before-quit', (event) => { + if (cleaned || updating) return; + event.preventDefault(); + if (cleaning) return; + cleaning = true; + void (async () => { + try { + await cleanup(); + } catch (error) { + failed(error); + } finally { + cleaned = true; + app.quit(); + } + })(); + }); +} + /** 普通退出与更新退出共享准备动作,避免重复保存和释放资源。 */ export function registerAppQuitPreparation( app: QuitEventSource<'before-quit'>, diff --git a/apps/desktop/src/main/daemon/runtime.ts b/apps/desktop/src/main/daemon/runtime.ts index b22791c27..b366e7599 100644 --- a/apps/desktop/src/main/daemon/runtime.ts +++ b/apps/desktop/src/main/daemon/runtime.ts @@ -9,8 +9,8 @@ * 指向它,直接注册守护进程本体的话登录时会挂一个关不掉的控制台窗口。) * - 保证守护进程「在,且是磁盘上那个版本」:探活 + 比版本,一致就什么都不做; * 版本变了才 `stop` 掉旧的、覆盖落位、再拉起新的;不在则 detached 拉起。 - * - 平时【不主动杀掉守护进程】—— 它的生命周期属于系统自启 / 用户,不属于 GUI; - * 设置开关 OFF 只发 `http_stop`(进程继续活着、记忆清空)。 + * - 设置开关 OFF 只发 `http_stop`(进程继续活着、记忆清空);macOS 彻底退出 + * 另走 stopDaemonForAppQuit,停止本轮后台但保留自启与服务记忆。 * - 替调用方把 http_start / http_stop 讲给守护进程(含端口回落试探)。 * - 二进制路径解析:与 native/ 完全同构的 `daemon/-/` 布局。 * @@ -58,6 +58,9 @@ const VERSION_PROBE_TIMEOUT_MS = 3000; const execFileAsync = promisify(execFile); const logger = getLogger().child({ scope: 'daemon-runtime' }); +let quitting = false; +let quitTask: Promise | null = null; +const startups = new Set>(); /** 从 `weq-daemon 1.0.0` 里取出 `1.0.0`;解析不出返回 null。 */ export function parseDaemonVersion(output: string): string | null { @@ -123,7 +126,9 @@ async function spawnDaemonAndWait(exe: string, pipeName: string): Promise= deadline) return false; if (now >= nextSpawnAt) { @@ -246,13 +251,24 @@ async function ensureLauncherStaged(bundledDaemon: string, refresh = false): Pro * 临时目录),所以真正被拉起、被注册的永远是稳定落位那份。 * * 单例由守护进程自己兜底(重复 `serve` 会立刻退出),这里只负责「版本对齐」。 - * 守护进程的生命周期属系统自启 / 用户:平时从不主动杀它,只有版本变化才替换。 + * 平时只在版本变化时替换;macOS 彻底退出会停止本轮进程,不清除服务记忆。 * 旧进程 `stop` 前会保留状态文件,新进程 `serve` 启动时按记忆自行恢复 HTTP * (同端口 / 同 docroot),并用新路径幂等重写自启注册。 * * 返回 true = 管道可用了(本来就是它 / 刚被拉起 / 刚被换新)。 */ -export async function ensureDaemonRunning(pipeName: string = DAEMON_PIPE_NAME): Promise { +export function ensureDaemonRunning(pipeName: string = DAEMON_PIPE_NAME): Promise { + if (quitting) return Promise.resolve(false); + const task = ensureDaemonRunningNow(pipeName); + startups.add(task); + void task.then( + () => startups.delete(task), + () => startups.delete(task), + ); + return task; +} + +async function ensureDaemonRunningNow(pipeName: string): Promise { const bundled = resolveDaemonBinary(); if (!bundled) { throw new Error( @@ -271,6 +287,7 @@ export async function ensureDaemonRunning(pipeName: string = DAEMON_PIPE_NAME): readDaemonBinaryVersion(bundled), existsSync(staged) ? readDaemonBinaryVersion(staged) : Promise.resolve(null), ]); + if (quitting) return false; if (bundledVersion !== null && bundledVersion !== stagedVersion) { // 版本变了(装了新包 / 重新 build):先停掉旧实例(它可能正占着 staged // 文件),再覆盖落位。之后 daemon 的 `serve` 会用新路径幂等重写自启注册。 @@ -294,6 +311,7 @@ export async function ensureDaemonRunning(pipeName: string = DAEMON_PIPE_NAME): readDaemonBinaryVersion(exe), pingDaemon(pipeName), ]); + if (quitting) return false; if (running) { // 版本读不出来时保守处理:只保证「在跑」,不做替换。 @@ -309,6 +327,27 @@ export async function ensureDaemonRunning(pipeName: string = DAEMON_PIPE_NAME): return spawnDaemonAndWait(exe, pipeName); } +/** 阻止在途启动重拉后台;bootout 只卸载本轮服务,不删除 plist 或禁用下次登录自启。 */ +export function stopDaemonForAppQuit(pipeName: string = DAEMON_PIPE_NAME): Promise { + quitting = true; + quitTask ??= (async () => { + await Promise.allSettled([...startups]); + const uid = process.getuid?.(); + if (process.platform === 'darwin' && uid !== undefined && !process.env.WEQ_DAEMON_DIR) { + const label = pipeName === DAEMON_PIPE_NAME ? 'weq-daemon' : `weq-daemon-${pipeName}`; + try { + await execFileAsync('/bin/launchctl', ['bootout', `gui/${uid}/${label}`], { + timeout: VERSION_PROBE_TIMEOUT_MS, + }); + } catch { + // 开发态或手动拉起时可能没有加载 LaunchAgent,仍需停止独立进程。 + } + } + await stopDaemonAndWait(pipeName); + })(); + return quitTask; +} + export interface DaemonHttpStartResult { ok: boolean; /** 实际监听端口(ok 时有效)。 */ @@ -329,6 +368,7 @@ export async function startDaemonHttp( ): Promise { const PORT_FALLBACK_ATTEMPTS = 20; for (let attempt = 0; attempt < PORT_FALLBACK_ATTEMPTS; attempt += 1) { + if (quitting) return { ok: false, message: 'WeQ 正在退出,未启动后台服务' }; const candidate = Math.min(port + attempt, 65535); const result = await daemonHttpStart(candidate, docroot, pipeName); if (result === null) { @@ -352,6 +392,7 @@ export async function startDaemonHttp( * 一个不存在的路径。 */ export async function syncGuiAutostartIntent(pipeName: string = DAEMON_PIPE_NAME): Promise { + if (quitting) return; if (!getHost().canAutostart) return; // 开发态 / 浏览器版:GUI 没有正确的 exe 语义 const status = await daemonAutostartStatus(pipeName); if (!status?.enabled) return; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 3c636e835..8281c3f4d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -53,8 +53,12 @@ import { stopMcpServer } from './mcp/server'; import { remoteRuntime } from './remote/runtime'; import { desktopWebRuntime } from './web/runtime'; import { registerWeqAssistantIpc } from './weq_assistant/ipc'; -import { startReleaseMonitor } from './daemon/release_monitor'; -import { ensureDaemonRunning, syncGuiAutostartIntent } from './daemon/runtime'; +import { startReleaseMonitor, stopReleaseMonitor } from './daemon/release_monitor'; +import { + ensureDaemonRunning, + stopDaemonForAppQuit, + syncGuiAutostartIntent, +} from './daemon/runtime'; import { disposeExternalMcp } from './mcp/external'; import { disposeElevatedCaptures } from './ipc/routers/wonderful_tools'; import { registerAnalyticsExportIpc } from './analytics_export'; @@ -63,7 +67,7 @@ import { registerQzoneIpc } from './qzone'; import { registerFlashShareIpc } from './flash_share'; import { getMainWindow, setMainWindow } from './main_window'; import { createMainWindowLifecycle } from './main_window_lifecycle'; -import { registerAppQuitPreparation } from './app_quit_lifecycle'; +import { registerAppQuitCleanup, registerAppQuitPreparation } from './app_quit_lifecycle'; import { createRendererRecovery } from './renderer_recovery'; import { startRendererDiagnostics } from './renderer_diagnostics_runtime'; import { macApplicationMenu, nativeMenuLabels } from './application_menu'; @@ -675,6 +679,7 @@ void app.whenReady().then(async () => { if (process.platform !== 'linux') { await probeQqProtocolHandler(); } + if (isQuitting) return; // Order matters: AppContext (loads native + platform) before IPC handler. // The host bridge must be installed before any router runs. @@ -836,12 +841,24 @@ registerAppQuitPreparation(app, autoUpdater, () => { } }); -// Best-effort: stop the account-bound MCP server on quit even if the account -// was never explicitly closed (clearAccount also stops it). The weq-daemon -// companion process is deliberately NOT touched on quit — its lifecycle -// belongs to autostart / the user, and it keeps serving the QQ cards after -// WeQ is gone. +// Cmd+Q / 托盘退出 / 完全退出均停止 macOS 本轮后台。次实例退出不能停掉主实例的服务。 +if (hasSingleInstanceLock && process.platform === 'darwin') { + registerAppQuitCleanup( + app, + stopDaemonForAppQuit, + (error) => { + logger.warn('failed to stop daemon on quit', { + event: 'daemon-quit-failed', + error: String(error), + }); + }, + autoUpdater, + ); +} + +// 账号没有显式关闭时,也释放本轮 Web、Remote、MCP 与提权辅助进程。 app.on('will-quit', () => { + stopReleaseMonitor(); remoteRuntime.stop(); desktopWebRuntime.stop(); void stopMcpServer(); diff --git a/apps/desktop/test/app_quit_lifecycle.test.ts b/apps/desktop/test/app_quit_lifecycle.test.ts index d7ba8af8a..ccaf98dee 100644 --- a/apps/desktop/test/app_quit_lifecycle.test.ts +++ b/apps/desktop/test/app_quit_lifecycle.test.ts @@ -1,6 +1,70 @@ import { EventEmitter } from 'node:events'; import { describe, expect, it, vi } from 'vitest'; -import { registerAppQuitPreparation } from '../src/main/app_quit_lifecycle'; +import { registerAppQuitCleanup, registerAppQuitPreparation } from '../src/main/app_quit_lifecycle'; + +describe('彻底退出的异步清理', () => { + it('原生更新退出交给安装器,不拦截或改成普通退出', () => { + const app = Object.assign(new EventEmitter(), { quit: vi.fn() }); + const updater = new EventEmitter(); + const cleanup = vi.fn(); + registerAppQuitCleanup(app, cleanup, vi.fn(), updater); + updater.emit('before-quit-for-update'); + const preventDefault = vi.fn(); + app.emit('before-quit', { preventDefault }); + expect(preventDefault).not.toHaveBeenCalled(); + expect(cleanup).not.toHaveBeenCalled(); + expect(app.quit).not.toHaveBeenCalled(); + }); + it('Cmd+Q 发起退出后先等待后台停止,重复退出不会重复清理', async () => { + const app = Object.assign(new EventEmitter(), { quit: vi.fn() }); + let finish = () => {}; + const cleanup = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const failed = vi.fn(); + registerAppQuitCleanup(app, cleanup, failed); + const preventDefault = vi.fn(); + app.emit('before-quit', { preventDefault }); + app.emit('before-quit', { preventDefault }); + expect(preventDefault).toHaveBeenCalledTimes(2); + expect(cleanup).toHaveBeenCalledOnce(); + expect(app.quit).not.toHaveBeenCalled(); + finish(); + await vi.waitFor(() => expect(app.quit).toHaveBeenCalledOnce()); + preventDefault.mockClear(); + app.emit('before-quit', { preventDefault }); + expect(preventDefault).not.toHaveBeenCalled(); + expect(failed).not.toHaveBeenCalled(); + }); + + it('清理失败会记录原因并继续退出,不把 GUI 永久留在后台', async () => { + const app = Object.assign(new EventEmitter(), { quit: vi.fn() }); + const error = new Error('合成停止失败'); + const failed = vi.fn(); + registerAppQuitCleanup(app, () => Promise.reject(error), failed); + app.emit('before-quit', { preventDefault: vi.fn() }); + await vi.waitFor(() => expect(app.quit).toHaveBeenCalledOnce()); + expect(failed).toHaveBeenCalledWith(error); + }); + + it('清理后仍尊重更新安装在 will-quit 的取消,不额外强制退出', async () => { + const app = Object.assign(new EventEmitter(), { quit: vi.fn() }); + registerAppQuitCleanup(app, async () => {}, vi.fn()); + const commit = vi.fn((event: { preventDefault(): void }) => event.preventDefault()); + app.once('will-quit', commit); + app.quit.mockImplementation(() => { + const event = { preventDefault: vi.fn() }; + app.emit('before-quit', event); + if (!event.preventDefault.mock.calls.length) app.emit('will-quit', event); + }); + app.quit(); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + expect(app.quit).toHaveBeenCalledTimes(2); + }); +}); describe('更新安装的退出顺序', () => { it.each(['ask', 'tray', 'quit'] as const)( diff --git a/apps/desktop/test/daemon_quit.test.ts b/apps/desktop/test/daemon_quit.test.ts new file mode 100644 index 000000000..d38e9d76d --- /dev/null +++ b/apps/desktop/test/daemon_quit.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fixture = vi.hoisted(() => ({ + run: vi.fn(), + spawn: vi.fn(), + ping: vi.fn(), + stop: vi.fn(), + httpStart: vi.fn(), + write: vi.fn(), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); +vi.mock('node:child_process', async () => { + const { promisify } = await import('node:util'); + return { + execFile: Object.assign(vi.fn(), { [promisify.custom]: fixture.run }), + spawn: fixture.spawn, + }; +}); +vi.mock('node:fs', () => ({ existsSync: () => true })); +vi.mock('node:fs/promises', () => ({ + chmod: fixture.write, + copyFile: fixture.write, + mkdir: fixture.write, + rename: fixture.write, + rm: fixture.write, +})); +vi.mock('@weq/service', () => ({ + DAEMON_PIPE_NAME: 'weq-daemon', + getLogger: () => ({ child: () => fixture.logger }), + getHost: () => ({ canAutostart: false }), + pingDaemon: fixture.ping, + daemonStop: fixture.stop, + daemonHttpStart: fixture.httpStart, + callDaemon: vi.fn(), + daemonAutostartSet: vi.fn(), + daemonAutostartStatus: vi.fn(), +})); +vi.mock('../src/main/resource', () => ({ resolveResource: () => '/synthetic/weq-daemon' })); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + fixture.run.mockReset().mockResolvedValue({ stdout: 'weq-daemon 1.0.0' }); + fixture.ping.mockReset().mockResolvedValue(null); + fixture.stop.mockReset().mockResolvedValue(false); + fixture.spawn.mockReturnValue({ unref: vi.fn() }); + vi.stubEnv('WEQ_DAEMON_DIR', ''); +}); +afterEach(() => vi.unstubAllEnvs()); + +describe('退出时停止守护进程', () => { + it.runIf(process.platform === 'darwin')( + '先 bootout 本轮服务再停止管道,不删除自启或业务记忆', + async () => { + const { stopDaemonForAppQuit } = await import('../src/main/daemon/runtime'); + await stopDaemonForAppQuit(); + expect(fixture.run).toHaveBeenCalledWith( + '/bin/launchctl', + ['bootout', `gui/${process.getuid()}/weq-daemon`], + { timeout: 3000 }, + ); + expect(fixture.run.mock.invocationCallOrder[0]).toBeLessThan( + fixture.stop.mock.invocationCallOrder[0]!, + ); + expect(fixture.stop).toHaveBeenCalledWith('weq-daemon'); + expect(fixture.write).not.toHaveBeenCalled(); + expect(fixture.spawn).not.toHaveBeenCalled(); + }, + ); + + it('没有加载 LaunchAgent 时仍停止独立守护进程,重复调用只停止一次', async () => { + fixture.run.mockRejectedValue(new Error('合成未加载服务')); + const { stopDaemonForAppQuit } = await import('../src/main/daemon/runtime'); + await Promise.all([stopDaemonForAppQuit(), stopDaemonForAppQuit()]); + expect(fixture.stop).toHaveBeenCalledOnce(); + }); + + it('停止命令之后继续等管道消失,不能只发送后就结束退出清理', async () => { + fixture.stop.mockResolvedValue(true); + fixture.ping.mockResolvedValueOnce({ version: '1.0.0' }).mockResolvedValueOnce(null); + const { stopDaemonForAppQuit } = await import('../src/main/daemon/runtime'); + await stopDaemonForAppQuit(); + expect(fixture.ping).toHaveBeenCalledTimes(2); + expect(fixture.logger.warn).not.toHaveBeenCalled(); + }); + + it('启动探测未完成便退出时不再拉起后台,退出后也不能重新启动', async () => { + vi.stubEnv('WEQ_DAEMON_DIR', '/synthetic'); + let finish = (_result: { stdout: string }) => {}; + fixture.run.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { ensureDaemonRunning, stopDaemonForAppQuit, startDaemonHttp } = await import( + '../src/main/daemon/runtime' + ); + const starting = ensureDaemonRunning(); + const stopping = stopDaemonForAppQuit(); + expect(fixture.stop).not.toHaveBeenCalled(); + finish({ stdout: 'weq-daemon 1.0.0' }); + expect(await starting).toBe(false); + await stopping; + expect(await ensureDaemonRunning()).toBe(false); + expect((await startDaemonHttp(17690, '/synthetic/cards')).ok).toBe(false); + expect(fixture.spawn).not.toHaveBeenCalled(); + expect(fixture.httpStart).not.toHaveBeenCalled(); + expect(fixture.stop).toHaveBeenCalledOnce(); + }); + + it('已经拉起但尚未就绪时退出,终止后续启动重试并等待停止完成', async () => { + vi.stubEnv('WEQ_DAEMON_DIR', '/synthetic'); + const { ensureDaemonRunning, stopDaemonForAppQuit } = await import( + '../src/main/daemon/runtime' + ); + const starting = ensureDaemonRunning(); + await vi.waitFor(() => expect(fixture.spawn).toHaveBeenCalledOnce()); + const stopping = stopDaemonForAppQuit(); + expect(await starting).toBe(false); + await stopping; + expect(fixture.spawn).toHaveBeenCalledOnce(); + expect(fixture.stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/docs/guide/settings.md b/docs/guide/settings.md index ab9b219f1..abca4d3dd 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -23,6 +23,11 @@ 窗口行为控制关闭按钮是退出、最小化到托盘还是询问。开机启动与后台服务联动,具体能力依平台显示。 +macOS 的 `Cmd+Q`、应用菜单「退出 WeQ」及托盘退出均为彻底退出,不受关闭按钮偏好影响: +退出桌面端并停止本轮后台守护进程、Web、Remote 与 MCP 服务,不继续在后台运行。 +已保存的账号、配对和开机启动设置保留;重新打开 WeQ 或下次登录系统时可按原设置恢复。 +只关闭窗口并选择最小化到托盘时,后台仍正常运行。 + 「常规 → 窗口行为 → 发送正在输入状态」可设置全局默认开关(初始关闭),并查看所有已设置的会话特例。私聊输入框键盘按钮的切换会保存为特例,重开会话或重启后仍保留;特例按账号与数据源隔离。特例列表同时保留开启和关闭项,即使与当前默认值相同也不隐藏,可直接切换或删除;删除后该会话恢复跟随全局默认。 「常规 → 窗口行为 → 通知」可分别设置成功和失败通知的停留时长,单位为秒;成功默认 3 秒,失败默认 -1(不自动关闭)。可填 -1 或 1 至 86400 的整数,保存后立即生效并在重启后保留。确认超时等警告使用失败时长;仍在运行的任务进度不受完成态时长影响,完成后才开始计时。悬停或聚焦横幅时暂停自动关闭。 diff --git a/packages/daemon/README.md b/packages/daemon/README.md index e700ec834..3bd66c1a3 100644 --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -86,6 +86,13 @@ sock.on('connect', () => sock.end(frame({ cmd: 'http_start', port: 17690, docroo 注意 `stop` 与 `http_stop` 的区别:`stop` 只关停 HTTP 再退出进程、**状态文件保留**; `http_stop` 才是「遗忘」入口(清状态文件)。所以版本替换不会丢推送卡片的服务地址。 +**macOS 彻底退出。** 桌面端的 `Cmd+Q`、应用菜单与托盘退出会先阻止在途启动继续拉起 +后台,再用 `launchctl bootout gui//weq-daemon` 卸载本轮 LaunchAgent,并通过控制 +管道停止独立实例,等待清理后退出 GUI。只卸载本轮服务,不删除 plist、不执行 `disable` +或清除服务记忆,下次登录仍按原设置自启。最小化到托盘不触发此流程。Windows/Linux +仍沿用独立后台生命周期。桌面版与 Web 共用默认管道时,退出 macOS 桌面端也会停止 +这份共享守护进程的卡片服务。 + 同一台机器同时跑桌面版、网页版、`pnpm dev` 时,它们连的是同一条默认管道,因此只会 有一份守护进程 —— 代价是它们必须用**同一个版本**,否则会互相 `stop` 换新。开发时想 隔离多套实例就用 `--pipe `(GUI 侧目前固定用默认管道名)。 From 358441ace5a5e49775062fdd538edf5b8a30ce4a Mon Sep 17 00:00:00 2001 From: ganpig Date: Sun, 25 Oct 2026 14:21:51 +0800 Subject: [PATCH 010/185] =?UTF-8?q?feat(search):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD=E5=92=8C=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B9=E5=B9=B6=E8=B0=83=E6=95=B4=E6=A0=87=E9=A2=98=E6=A0=8F?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当前工作区定向测试 101 项通过;渲染类型检查通过,13 项浏览器布局检查通过。 工具入口衔接当前在途工作菜单;共享文件仅保存本轮增量,其余菜单改动保留在工作区。 旧助手搜索宿主测试仍依赖菜单重构迁移,未放宽或跳过其断言。 --- CHANGELOG.md | 1 + .../src/components/RestrictedMode.tsx | 14 +- .../src/components/SettingsDialog.tsx | 12 +- .../src/components/TitleBarSearch.tsx | 19 +- .../src/components/WorkspaceTools.tsx | 22 ++ .../components/search/AppSearchResults.tsx | 52 ++++ .../src/components/search/SearchDropdown.tsx | 77 ++++-- .../src/components/search/appSearchCatalog.ts | 223 ++++++++++++++++++ .../src/components/search/searchPosition.ts | 1 + .../src/components/settings/searchTarget.ts | 56 +++++ .../src/im-template/template/TitleBar.tsx | 15 +- .../src/im-template/template/chatShell.tsx | 1 + .../src/renderer/src/styles/app-search.css | 52 ++++ .../renderer/src/styles/titlebar-search.css | 88 +++++++ .../renderer/src/styles/workspace-tools.css | 6 +- .../src/renderer/src/views/MainView.tsx | 55 +++++ apps/desktop/test/feature_search.test.ts | 50 ++++ apps/desktop/test/feature_search_ui.test.ts | 182 ++++++++++++++ apps/desktop/test/feature_tool_search.test.ts | 94 ++++++++ .../test/settings_reorganization.test.ts | 25 +- apps/desktop/test/title_bar_status.test.ts | 57 ++++- apps/desktop/test/workspace_controls.test.ts | 50 ++++ docs/guide/chat-view.md | 8 +- 23 files changed, 1126 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/search/AppSearchResults.tsx create mode 100644 apps/desktop/src/renderer/src/components/search/appSearchCatalog.ts create mode 100644 apps/desktop/src/renderer/src/components/settings/searchTarget.ts create mode 100644 apps/desktop/src/renderer/src/styles/app-search.css create mode 100644 apps/desktop/src/renderer/src/styles/titlebar-search.css create mode 100644 apps/desktop/test/feature_search.test.ts create mode 100644 apps/desktop/test/feature_search_ui.test.ts create mode 100644 apps/desktop/test/feature_tool_search.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ba73d1a..0378870f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -550,3 +550,4 @@ - 修复 macOS 无证书版本更新时报签名身份不匹配的问题;核验官方更新包后退出安装,失败时保留原应用。 - 修复聊天消息及回复引用中的 @ 无法点击查看名片的问题,有有效 UID 或 QQ 号即可打开。 - 修复 macOS 按 Cmd+Q 后后台守护进程仍运行的问题,彻底退出会停止本轮后台服务,关闭窗口最小化到托盘的行为不变。 +- 全局搜索支持查找功能与设置项并直达对应位置;搜索栏常驻标题栏右侧,受限提示平滑收放,过长个性签名悬停显示全文。 diff --git a/apps/desktop/src/renderer/src/components/RestrictedMode.tsx b/apps/desktop/src/renderer/src/components/RestrictedMode.tsx index 33f353c67..fe79ace7f 100644 --- a/apps/desktop/src/renderer/src/components/RestrictedMode.tsx +++ b/apps/desktop/src/renderer/src/components/RestrictedMode.tsx @@ -54,14 +54,21 @@ function useAuthorizationBridge() { }, []); } -export function RestrictedModeIndicator({ startup = false }: { startup?: boolean }) { +export function RestrictedModeIndicator({ + startup = false, + persistent = false, +}: { + startup?: boolean; + persistent?: boolean; +}) { const reasons = useRestrictedMode((s) => s.reasons); const requests = useRestrictedMode((s) => s.requests); const open = useRestrictedMode((s) => s.open); const entering = useRestrictedMode((s) => s.enteringSince !== null && s.requests.length === 0); const awaitingAuthorization = requests.length > 0; const title = awaitingAuthorization ? '待提权' : entering ? '正在进入完整模式' : '受限模式'; - if (!reasons.length && !requests.length && !entering) return null; + const visible = Boolean(reasons.length || requests.length || entering); + if (!visible && !persistent) return null; return ( ))} -
+
{active.id === 'anti-recall' ? ( void; onSearchFocus?: () => void; results?: ReactNode; onReturn?: () => boolean; + persistent?: boolean; }) { const [pinned, setPinned] = useState(false); const [lookupOpen, setLookupOpen] = useState(null); @@ -34,7 +36,7 @@ export function TitleBarSearch({ const input = useRef(null); const panel = useRef(null); const returning = useRef(false); - const expanded = pinned || (hovered && !suppressHover); + const expanded = pinned || (!persistent && hovered && !suppressHover); const hasPanel = Boolean(results) || pinned; const layer = useOverlayLayer(expanded && hasPanel); @@ -94,7 +96,7 @@ export function TitleBarSearch({ return (
{ setHovered(true); setSuppressHover(false); @@ -153,10 +155,10 @@ export function TitleBarSearch({ ref={input} type="search" aria-label="搜索全部内容" - tabIndex={expanded ? 0 : -1} - disabled={!expanded} + tabIndex={persistent || expanded ? 0 : -1} + disabled={!persistent && !expanded} value={query} - placeholder="搜索全部内容" + placeholder={persistent ? '搜索内容、功能与设置' : '搜索全部内容'} onChange={(event) => onQueryChange(event.target.value)} onFocus={() => { setPinned(true); @@ -179,6 +181,13 @@ export function TitleBarSearch({ style={{ ...position, zIndex: layer }} onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)} + onClick={(event) => { + if (!(event.target instanceof Element) || !event.target.closest('[role="option"]')) + return; + setPinned(false); + setHovered(false); + setSuppressHover(true); + }} > {results} {pinned && diff --git a/apps/desktop/src/renderer/src/components/WorkspaceTools.tsx b/apps/desktop/src/renderer/src/components/WorkspaceTools.tsx index 0ce78dcdc..0b25f08ad 100644 --- a/apps/desktop/src/renderer/src/components/WorkspaceTools.tsx +++ b/apps/desktop/src/renderer/src/components/WorkspaceTools.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { getCloneTasks, subscribeCloneTasks } from '../views/agentlab/cloneTaskStore'; import type { GeneratedSearchHit } from './search/types'; +import type { ToolSearchRequest } from './search/appSearchCatalog'; import { BarChart3, Bot, @@ -65,6 +66,7 @@ export function WorkspaceTools({ onOpenAnalysis, onOpenComposition, assistantSearchTarget, + searchRequest, hidden = false, currentConversation, workspace, @@ -75,6 +77,7 @@ export function WorkspaceTools({ onOpenAnalysis: (report: AnalysisReportRequest) => void; onOpenComposition?: () => void; assistantSearchTarget?: GeneratedSearchHit | null; + searchRequest?: ToolSearchRequest | null; hidden?: boolean; currentConversation?: Conversation | null; /** 当前界面上下文:主按钮据此决定进入哪个工具、带哪份内容。 */ @@ -264,6 +267,25 @@ export function WorkspaceTools({ close(); action(); }; + const selectRef = useRef(select); + selectRef.current = select; + const handledSearchRequest = useRef(null); + useEffect(() => { + if ( + !searchRequest || + searchRequest.scope !== accountScope || + handledSearchRequest.current === searchRequest + ) + return; + handledSearchRequest.current = searchRequest; + if (searchRequest.tool === 'tasks' || searchRequest.tool === 'assistant') { + setOpen(false); + closeMenu(); + setPanel(searchRequest.tool); + setExpanded(true); + setAssistantSettings(false); + } else selectRef.current(searchRequest.tool); + }, [searchRequest, closeMenu, accountScope]); const fixedExportScope = exportFixedScope && (exportDraft?.draft.selection.owner.kind === 'messages' || diff --git a/apps/desktop/src/renderer/src/components/search/AppSearchResults.tsx b/apps/desktop/src/renderer/src/components/search/AppSearchResults.tsx new file mode 100644 index 000000000..24bdf48db --- /dev/null +++ b/apps/desktop/src/renderer/src/components/search/AppSearchResults.tsx @@ -0,0 +1,52 @@ +import { ArrowRight, ChevronDown, Settings2 } from 'lucide-react'; +import { highlightText } from './SearchResultCard'; +import { searchAppEntries, type AppSearchEntry } from './appSearchCatalog'; +import '../../styles/app-search.css'; + +export function AppSearchResults({ + keyword, + onSelect, + expanded = false, + onExpand, +}: { + keyword: string; + onSelect: (entry: AppSearchEntry) => void; + expanded?: boolean; + onExpand: () => void; +}) { + const entries = searchAppEntries(keyword); + return ( +
+
+ 功能与设置 + {entries.length} +
+ {!entries.length ?
无结果
: null} + {(expanded ? entries : entries.slice(0, 6)).map((entry) => ( + + ))} + {entries.length > 6 && !expanded ? ( + + ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/search/SearchDropdown.tsx b/apps/desktop/src/renderer/src/components/search/SearchDropdown.tsx index c888e6e46..3d70e4d28 100644 --- a/apps/desktop/src/renderer/src/components/search/SearchDropdown.tsx +++ b/apps/desktop/src/renderer/src/components/search/SearchDropdown.tsx @@ -1,11 +1,13 @@ /** The unified search dropdown under the sidebar search box. */ -import { useCallback, useRef, type ReactElement } from 'react'; +import { useCallback, useRef, useState, type ReactElement } from 'react'; import { RotateCw } from 'lucide-react'; import { useMultiSelection } from '../../hooks/useMultiSelection'; import '../../styles/search-scope.css'; import { SearchResultCard } from './SearchResultCard'; import { SearchSectionSkeleton } from './SearchSkeleton'; +import { AppSearchResults } from './AppSearchResults'; +import type { AppSearchEntry } from './appSearchCatalog'; import { restoreSearchPosition, type SearchPosition } from './searchPosition'; import { CATEGORY_META, @@ -58,6 +60,9 @@ export function SearchDropdown({ onMore, initialPosition, onNavigate, + appSearchEnabled = false, + onAppSearchEnabledChange, + onSelectAppEntry, }: { keyword: string; quick: QuickSearchResult | null; @@ -73,9 +78,22 @@ export function SearchDropdown({ onMore: (category: SearchCategory) => void; initialPosition?: SearchPosition; onNavigate?: (position: SearchPosition) => void; + appSearchEnabled?: boolean; + onAppSearchEnabledChange?: (enabled: boolean) => void; + onSelectAppEntry?: (entry: AppSearchEntry) => void; }): ReactElement { const root = useRef(null); const position = useRef(initialPosition); + const [expandedAppKeyword, setExpandedAppKeyword] = useState( + initialPosition?.appResultsExpanded ? keyword : null, + ); + const appResultsExpanded = expandedAppKeyword === keyword; + const rememberPosition = (focusKey: string) => + onNavigate?.({ + scrollTop: root.current?.scrollTop ?? 0, + focusKey, + ...(appResultsExpanded ? { appResultsExpanded: true } : {}), + }); const attachRoot = useCallback((node: HTMLDivElement | null) => { root.current = node; if (node && position.current) { @@ -172,18 +190,36 @@ export function SearchDropdown({ { if (node) node.indeterminate = - categories.length > 0 && categories.length < SEARCH_CATEGORY_ORDER.length; + (categories.length > 0 || appSearchEnabled) && + !( + categories.length === SEARCH_CATEGORY_ORDER.length && + (!onSelectAppEntry || appSearchEnabled) + ); + }} + onChange={(event) => { + onCategoriesChange(event.target.checked ? [...SEARCH_CATEGORY_ORDER] : []); + onAppSearchEnabledChange?.(event.target.checked); }} - onChange={(event) => - onCategoriesChange(event.target.checked ? [...SEARCH_CATEGORY_ORDER] : []) - } /> 全部 + {onSelectAppEntry ? ( + + ) : null} {scopeOptions.map((option) => (
-
来源:当前账号 · 本地可检索记录
+
+ 来源:{appSearchEnabled ? '应用功能与设置 · ' : ''}当前账号 · 本地可检索记录 +
{indexBuilding ? (
正在建立本地搜索索引,聊天记录/文件首次查询较慢…
) : null}
- {categories.length === 0 ?
未选择搜索范围
: null} + {categories.length === 0 && !appSearchEnabled ? ( +
未选择搜索范围
+ ) : null} + {appSearchEnabled && onSelectAppEntry ? ( + setExpandedAppKeyword(keyword)} + onSelect={(entry) => { + rememberPosition(`app:${entry.id}`); + onSelectAppEntry(entry); + }} + /> + ) : null} {orderedCategories.map((category) => { const section = sections.get(category)!; const hits = section.hits; @@ -220,10 +271,7 @@ export function SearchDropdown({ className="weq-search-more" data-search-hit-key={`more:${category}`} onClick={() => { - onNavigate?.({ - scrollTop: root.current?.scrollTop ?? 0, - focusKey: `more:${category}`, - }); + rememberPosition(`more:${category}`); onMore(category); }} > @@ -257,10 +305,7 @@ export function SearchDropdown({ hit={hit} keyword={keyword} onSelect={(selected) => { - onNavigate?.({ - scrollTop: root.current?.scrollTop ?? 0, - focusKey: searchHitKey(selected), - }); + rememberPosition(searchHitKey(selected)); onSelect(selected); }} /> diff --git a/apps/desktop/src/renderer/src/components/search/appSearchCatalog.ts b/apps/desktop/src/renderer/src/components/search/appSearchCatalog.ts new file mode 100644 index 000000000..b0097ea80 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/search/appSearchCatalog.ts @@ -0,0 +1,223 @@ +import { searchNameRank } from '@weq/service/search-policy'; +import type { MainView } from '../../im-template/template/types'; +import { settingsGroupFor, type SectionId } from '../settings/navigation'; + +export type SearchableTool = 'export' | 'viewer' | 'analysis' | 'tasks' | 'assistant'; +export interface ToolSearchRequest { + scope: string; + tool: SearchableTool; + revision: number; +} +export type AppSearchTarget = + | { kind: 'view'; view: MainView } + | { kind: 'tool'; tool: SearchableTool } + | { kind: 'setting'; section: SectionId; item?: string } + | { kind: 'composition' } + | { kind: 'dressup' }; + +export interface AppSearchEntry { + id: string; + title: string; + path: string; + keywords: readonly string[]; + target: AppSearchTarget; +} + +// 这里只保存公开的界面名称与入口,不读取设置值、账号资料或凭据。 +export const SETTINGS_PAGE_LABELS = { + global: '窗口行为', + startup: '开机启动', + saving: '默认保存', + appearance: '主题与背景', + 'message-display': '消息显示', + applock: '隐私与应用锁', + accounts: '账号管理', + account: '接入与监听', + 'anti-recall': '防撤回', + qzone: 'QQ 空间', + mail: 'QQ 邮箱', + storage: '存储与网络', + backup: '备份与恢复', + media: '媒体来源', + database: '损坏处理与容错', + 'database-status': '容错记录', + maintenance: '维护工具', + agentlab: 'API 配置', + 'intent-sense': '意图感知', + 'reply-suggestions': '推荐回复', + 'clone-settings': '好友克隆', + 'interaction-profile': '互动画像', + voice: '语音识别', + ocr: '图片识别', + 'model-usage': 'API 用量', + mcp: 'MCP 服务器', + remote: 'Web 与 Remote', + rkey: '外部 RKEY', + sse: 'SSE 推送', + daemon: '后台服务', + credentials: '账号凭据', + diagnostics: '高级诊断', + faq: '常见问题', + feedback: '反馈', + logs: '日志', + version: '版本与更新', +} satisfies Partial>; + +type SettingDefinition = readonly [ + section: keyof typeof SETTINGS_PAGE_LABELS, + item: string, + ...aliases: string[], +]; +const SETTING_ITEMS: readonly SettingDefinition[] = [ + ['global', '关闭按钮行为', '关闭窗口', '最小化到托盘', '退出应用'], + ['global', '成功通知停留时长', '通知时间'], + ['global', '失败通知停留时长', '通知时间'], + ['global', '默认发送正在输入状态', '正在输入', '打字状态'], + ['startup', '开机自动启动 WeQ', '自启动'], + ['saving', '默认保存方式', '导出保存方式'], + ['saving', '保存位置', '默认导出目录', '默认保存目录'], + ['appearance', '基础模式', '浅色模式', '深色模式', '暗色模式', '跟随系统'], + ['appearance', '主题色', '强调色', '主色', '颜色'], + ['appearance', '界面背景', '聊天背景', '壁纸'], + ['appearance', '组件风格', '皮肤'], + ['message-display', '从第一条新消息开始阅读', '第一条未读'], + ['message-display', '发送消息后定位到最新消息', '发送后跳转'], + ['message-display', '群助手显示总消息数'], + ['message-display', '显示消息汇总红点', '未读红点'], + ['message-display', '纯文本消息渲染 Markdown', '富文本'], + ['message-display', '聊天头像挂件'], + ['message-display', '显示消息序号(seq)', '消息序号'], + ['message-display', '显示发送时间(精确到秒)', '消息时间'], + ['message-display', '消息装扮', '气泡', '字体'], + ['message-display', '链接预览卡片', '网页预览'], + ['message-display', '无封面时网页截图', '链接截图'], + ['applock', '启用应用锁', '锁屏', '隐私锁'], + ['applock', '隐私模式', '隐藏个人信息'], + ['applock', '解锁方式', 'Touch ID', 'Windows Hello', '验证器'], + ['applock', '无操作自动上锁', '空闲自动锁定'], + ['accounts', '现有配置', '自动进入账号'], + ['account', '启用数据库监听', '自动刷新消息'], + ['account', '同步已读状态到 QQ'], + ['account', '手动标记时上报已读位置'], + ['account', '自动读取 QQ 内存(完整功能)', '完整模式'], + ['qzone', '在线翻页限速', '空间翻页频率'], + ['mail', '显示未读邮件数'], + ['mail', '新邮件提醒'], + ['storage', '优先使用 CDN', '下载加速'], + ['storage', '缓存目录', '缓存位置'], + ['storage', 'WeQ 存储管理', '清理数据', '释放空间'], + ['media', '关联本机媒体目录', '本地图片目录'], + ['media', '允许使用外部安卓图片缓存', 'Android 图片'], + ['backup', '导出备份', '备份配置'], + ['backup', '导入备份', '恢复备份'], + ['backup', '恢复默认设置', '重置设置', '恢复出厂'], + ['database', '数据库异常时弹窗提醒', '损坏提醒'], + ['database', '数据库宽容级别', '容错级别', '严格模式'], + ['database-status', '降级账本'], + ['database-status', '已放弃的表(隔离中)'], + ['agentlab', 'TTS 语音合成', '语音合成', '朗读', '音色'], + ['agentlab', '已保存的 provider', 'API Key', 'Base URL', '服务商'], + ['clone-settings', '克隆体配置', '克隆提示词', '克隆语音', '发言意愿'], + ['intent-sense', '启用意图感知'], + ['intent-sense', '意图感知运行方式', '本地意图模型'], + ['intent-sense', '自己发送后分析'], + ['reply-suggestions', '启用推荐回复'], + ['reply-suggestions', '推荐回复 API 模型'], + ['reply-suggestions', '常驻显示'], + ['reply-suggestions', '悬停显示'], + ['reply-suggestions', '自己发送后推荐回复'], + ['interaction-profile', '启用互动画像'], + ['voice', '本地语音模型', '语音转文字', 'ASR', '转写'], + ['ocr', 'QQ 在线 OCR', '文字识别'], + ['ocr', '识别模型'], + ['ocr', '筛图模型'], + ['mcp', '启用 MCP 服务器'], + ['mcp', 'MCP 端口'], + ['remote', '启用 Web 服务', '浏览器访问'], + ['remote', '启动 WeQ 时自动开启 Web 服务'], + ['sse', '推送调优', '防抖毫秒', '大量消息阈值'], + ['daemon', '静态文件服务'], + ['version', '监控新版本发布', 'GitHub Release'], + ['credentials', '数据库密钥'], + ['credentials', '会话密钥'], + ['credentials', '下载 rKey'], + ['credentials', 'ClientKey'], + ['logs', '日志保留时长'], + ['logs', '日志文件夹'], + ['maintenance', '本地缓存'], + ['maintenance', '数据库管理', '数据库浏览'], + ['maintenance', '存储占用'], + ['maintenance', '清理缓存'], + ['maintenance', '数据库修复', '修复数据库'], + ['maintenance', '导出明文数据库', '数据库解密'], + ['maintenance', '密钥扫描'], + ['maintenance', '其它设备密钥', '其他设备密钥'], + ['diagnostics', 'ntqq 抓包'], + ['diagnostics', 'Protobuf / JCE 逆向'], +]; + +function settingEntry( + section: keyof typeof SETTINGS_PAGE_LABELS, + item?: string, + keywords: string[] = [], +): AppSearchEntry { + const page = SETTINGS_PAGE_LABELS[section]; + return { + id: `setting:${section}:${item ?? ''}`, + title: item ?? page, + path: ['设置', settingsGroupFor(section).label, page].join(' / '), + keywords, + target: { kind: 'setting', section, ...(item ? { item } : {}) }, + }; +} + +const feature = ( + title: string, + target: AppSearchTarget, + ...keywords: string[] +): AppSearchEntry => ({ + id: `feature:${title}`, + title, + path: '功能', + target, + keywords, +}); + +export const APP_SEARCH_ENTRIES: readonly AppSearchEntry[] = [ + feature('首页', { kind: 'view', view: 'home' }), + feature('消息', { kind: 'view', view: 'messages' }, '聊天', '会话'), + feature('联系人', { kind: 'view', view: 'contacts' }, '好友', '群聊'), + feature('收藏', { kind: 'view', view: 'collections' }), + feature('QQ 空间', { kind: 'view', view: 'qzone' }, '说说', '相册'), + feature('QQ 邮箱', { kind: 'view', view: 'mail' }, '邮件'), + feature('频道', { kind: 'view', view: 'channel' }, '频道私聊'), + feature('好友克隆', { kind: 'view', view: 'agentlab' }, '克隆聊天'), + feature('年度报告', { kind: 'view', view: 'annual' }, '年度总结'), + feature('导出', { kind: 'tool', tool: 'export' }, '导出聊天记录', '备份聊天'), + feature('导出查看器', { kind: 'tool', tool: 'viewer' }, '打开导出', 'JSON', 'JSONL'), + feature('分析与报告', { kind: 'tool', tool: 'analysis' }, '聊天分析', '统计'), + feature('任务', { kind: 'tool', tool: 'tasks' }, '导出进度', '下载任务'), + feature('WeQ 助手', { kind: 'tool', tool: 'assistant' }, 'AI 助手', '智能助手'), + feature('合成聊天记录', { kind: 'composition' }, '合并转发'), + feature('个性装扮', { kind: 'dressup' }, '气泡', '字体', '商城表情', '通话背景'), + ...Object.keys(SETTINGS_PAGE_LABELS).map((section) => + settingEntry(section as keyof typeof SETTINGS_PAGE_LABELS, undefined, [section]), + ), + ...SETTING_ITEMS.map(([section, item, ...aliases]) => settingEntry(section, item, aliases)), +]; + +export function searchAppEntries(keyword: string): AppSearchEntry[] { + const query = keyword.trim(); + if (!query) return []; + return APP_SEARCH_ENTRIES.map((entry, index) => ({ + entry, + index, + rank: + searchNameRank(query, entry.title) * 10 || + searchNameRank(query, ...entry.keywords) * 2 || + searchNameRank(query, entry.path), + })) + .filter(({ rank }) => rank > 0) + .sort((a, b) => b.rank - a.rank || a.index - b.index) + .map(({ entry }) => entry); +} diff --git a/apps/desktop/src/renderer/src/components/search/searchPosition.ts b/apps/desktop/src/renderer/src/components/search/searchPosition.ts index 4743de7a4..62709ad8d 100644 --- a/apps/desktop/src/renderer/src/components/search/searchPosition.ts +++ b/apps/desktop/src/renderer/src/components/search/searchPosition.ts @@ -4,6 +4,7 @@ import { useTabActive } from '../RetainedTabs'; export interface SearchPosition { scrollTop: number; focusKey: string | null; + appResultsExpanded?: boolean; } export function restoreSearchPosition(node: HTMLElement, position: SearchPosition): void { diff --git a/apps/desktop/src/renderer/src/components/settings/searchTarget.ts b/apps/desktop/src/renderer/src/components/settings/searchTarget.ts new file mode 100644 index 000000000..48f9afb1d --- /dev/null +++ b/apps/desktop/src/renderer/src/components/settings/searchTarget.ts @@ -0,0 +1,56 @@ +import type { SectionId } from './navigation'; + +export interface SettingsSearchTarget { + section: SectionId; + item: string; + revision: number; +} + +/** 只在当前设置正文内按界面标签定位,不匹配配置值,不触发按钮。 */ +export function findSettingElement(root: HTMLElement, item: string): HTMLElement | null { + const nodes = root.querySelectorAll( + '[aria-label], .weq-set-row-label, .weq-set-card-title, strong, h3, h4, .weq-maintenance-entry span', + ); + for (const node of nodes) { + if (node.getAttribute('aria-label') !== item && node.textContent?.trim() !== item) continue; + return ( + node.closest( + '.weq-set-row, .weq-settings-appearance-card, .weq-maintenance-entry, .weq-set-card', + ) ?? node + ); + } + return null; +} + +/** 异步设置加载后仍可定位;切页或关闭时撤销观察与高亮。 */ +export function revealSetting(root: HTMLElement, item: string): () => void { + let highlighted: HTMLElement | null = null; + let previousTabIndex: string | null = null; + const locate = () => { + const element = findSettingElement(root, item); + if (!element) return; + highlighted = element; + previousTabIndex = element.getAttribute('tabindex'); + element.setAttribute('data-search-highlight', 'true'); + element.setAttribute('tabindex', '-1'); + element.scrollIntoView?.({ block: 'center', behavior: 'instant' }); + element.focus({ preventScroll: true }); + observer.disconnect(); + }; + const observer = new MutationObserver(locate); + observer.observe(root, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ['aria-label'], + }); + locate(); + return () => { + observer.disconnect(); + if (!highlighted) return; + highlighted.removeAttribute('data-search-highlight'); + if (previousTabIndex === null) highlighted.removeAttribute('tabindex'); + else highlighted.setAttribute('tabindex', previousTabIndex); + }; +} diff --git a/apps/desktop/src/renderer/src/im-template/template/TitleBar.tsx b/apps/desktop/src/renderer/src/im-template/template/TitleBar.tsx index 5c89e5545..23b8aa2f6 100644 --- a/apps/desktop/src/renderer/src/im-template/template/TitleBar.tsx +++ b/apps/desktop/src/renderer/src/im-template/template/TitleBar.tsx @@ -6,6 +6,7 @@ import { OnlineStatus } from '../../components/OnlineStatus'; import { VipBadge } from '../../components/VipBadge'; import { RestrictedModeIndicator } from '../../components/RestrictedMode'; import '../../styles/self-profile.css'; +import '../../styles/titlebar-search.css'; import { Avatar } from './primitives'; import type { User } from './types'; import { displayUserName } from './user'; @@ -91,7 +92,6 @@ export function TitleBar({ > 主页 - {search}
{displayUserName(user)} @@ -105,10 +105,19 @@ export function TitleBar({ refetchInterval={15_000} /> )} - {user.signature && {user.signature}} + {user.signature && ( + + {user.signature} + + )}
- + {search} +
+
+ +
+
{/* macOS 有原生红黄绿三键,自绘的 最小化/最大化/关闭 会重复,隐藏掉。 */} {!IS_MAC && ( diff --git a/apps/desktop/src/renderer/src/im-template/template/chatShell.tsx b/apps/desktop/src/renderer/src/im-template/template/chatShell.tsx index b19323487..6e26416bb 100644 --- a/apps/desktop/src/renderer/src/im-template/template/chatShell.tsx +++ b/apps/desktop/src/renderer/src/im-template/template/chatShell.tsx @@ -153,6 +153,7 @@ export function ChatShell({ onGoHome={onGoHome} search={ div { + min-width: 0; + overflow: hidden; +} + +.weq-titlebar-restriction .weq-restricted-indicator { + width: max-content; + margin: 0; +} + +.weq-titlebar-restriction:has(.weq-restricted-indicator:not([aria-hidden='true'])) { + grid-template-columns: 1fr; + margin-left: 8px; + opacity: 1; +} + +@media (max-width: 600px) { + .app-title-bar .weq-titlebar-search.is-persistent { + flex-basis: 160px; + width: 160px; + min-width: 120px; + } + + .app-title-bar:has(.weq-titlebar-search.is-persistent) .app-title-bar-user { + padding-inline: 4px; + gap: 4px; + } + + .app-title-bar:has(.weq-titlebar-search.is-persistent) .app-title-bar-signature { + display: block; + margin-left: 0; + } + + .app-title-bar:has(.weq-titlebar-search.is-persistent) .weq-title-online-status { + display: none; + } + + .app-title-bar:has(.weq-titlebar-search.is-persistent) .app-title-bar-nick, + .app-title-bar:has(.weq-titlebar-search.is-persistent) .app-title-bar-vip-badge, + .weq-titlebar-restriction .weq-restricted-indicator span { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .weq-titlebar-restriction { + transition: none; + } +} diff --git a/apps/desktop/src/renderer/src/styles/workspace-tools.css b/apps/desktop/src/renderer/src/styles/workspace-tools.css index 316fdd722..135fc0741 100644 --- a/apps/desktop/src/renderer/src/styles/workspace-tools.css +++ b/apps/desktop/src/renderer/src/styles/workspace-tools.css @@ -787,9 +787,9 @@ html[data-theme='dark'] .weq-tools-trigger { } /* 窄屏展开搜索时,用户块只剩头像 + 在线状态;会员徽标一起让位,别把搜索框挤回去。 */ - .app-title-bar:has(.weq-titlebar-search.is-expanded) .app-title-bar-signature, - .app-title-bar:has(.weq-titlebar-search.is-expanded) .app-title-bar-vip-badge, - .app-title-bar:has(.weq-titlebar-search.is-expanded) .app-title-bar-nick { + .app-title-bar:has(.weq-titlebar-search.is-expanded:not(.is-persistent)) .app-title-bar-signature, + .app-title-bar:has(.weq-titlebar-search.is-expanded:not(.is-persistent)) .app-title-bar-vip-badge, + .app-title-bar:has(.weq-titlebar-search.is-expanded:not(.is-persistent)) .app-title-bar-nick { display: none; } } diff --git a/apps/desktop/src/renderer/src/views/MainView.tsx b/apps/desktop/src/renderer/src/views/MainView.tsx index bdd70dfba..81ca0b756 100644 --- a/apps/desktop/src/renderer/src/views/MainView.tsx +++ b/apps/desktop/src/renderer/src/views/MainView.tsx @@ -225,6 +225,8 @@ import { import { displayUserName } from '../im-template/template/user'; import { QzoneCardNavigationContext } from '../lib/qzoneNavigation'; import { WorkspaceTools } from '../components/WorkspaceTools'; +import type { AppSearchEntry, ToolSearchRequest } from '../components/search/appSearchCatalog'; +import type { SettingsSearchTarget } from '../components/settings/searchTarget'; import { WorkspaceContent, WorkspacePages } from '../components/WorkspacePages'; import { displayGroupMemberName } from '../im-template/template/user'; import { ChatNavigationContext, type ProfileChatTarget } from '../lib/chatNavigation'; @@ -2720,6 +2722,18 @@ export function MainView(): ReactElement { */ const flushDraftRef = useRef<(conversationId: string) => void>(() => {}); const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsSearchTarget, setSettingsSearchTarget] = useState( + null, + ); + const [toolSearchRequest, setToolSearchRequest] = useState(null); + const [appSearchEnabled, setAppSearchEnabled] = useState(true); + useEffect(() => { + if (!settingsOpen) setSettingsSearchTarget(null); + }, [settingsOpen]); + useEffect(() => { + setToolSearchRequest(null); + setSettingsSearchTarget(null); + }, [messageScope]); const [surfaceNavigation, setSurfaceNavigation] = useState(0); /** 打开设置时要先落到哪一屏(损坏弹窗 → 数据库宽容)。 */ const [settingsSection, setSettingsSection] = useState( @@ -8975,6 +8989,39 @@ export function MainView(): ReactElement { /> ) : null; + const openAppSearchEntry = (entry: AppSearchEntry) => { + setSearchDismissed(true); + setSearchFocused(false); + setSettingsOpen(false); + setSettingsSearchTarget(null); + setMaintenanceTool(null); + setDressUpOpen(false); + const target = entry.target; + if (target.kind === 'setting') { + setSettingsSection(target.section); + if (target.item) + setSettingsSearchTarget({ + section: target.section, + item: target.item, + revision: Date.now(), + }); + setSettingsOpen(true); + } else if (target.kind === 'tool') { + setToolSearchRequest((previous) => ({ + scope: messageScope, + tool: target.tool, + revision: (previous?.revision ?? 0) + 1, + })); + } else if (target.kind === 'composition') compositionWindow.open(); + else if (target.kind === 'dressup') { + setDressUpEmojiTarget(null); + setDressUpOpen(true); + } else { + if (shell.view === 'messages' && target.view !== 'messages') flushAllDirtyDrafts(); + shell.switchView(target.view); + } + }; + const chatContent = ( @@ -9112,6 +9159,12 @@ export function MainView(): ReactElement { onCategoriesChange={setSearchCategories} onRetry={globalSearch.retry} onSelect={openSearchHit} + appSearchEnabled={appSearchEnabled} + onAppSearchEnabledChange={(enabled) => { + searchReturn.clear(); + setAppSearchEnabled(enabled); + }} + onSelectAppEntry={openAppSearchEntry} initialPosition={ searchReturn.target?.kind === 'dropdown' ? searchReturn.target.position @@ -9540,6 +9593,7 @@ export function MainView(): ReactElement { open={settingsOpen} onClose={() => setSettingsOpen(false)} initialSection={settingsSection} + searchTarget={settingsSearchTarget} onOpenMaintenance={openMaintenance} onOpenRecalls={handleOpenRecallsAt} onCreateClone={handleCreateClone} @@ -9597,6 +9651,7 @@ export function MainView(): ReactElement { roamingRequest={roamingRequest} onOpenComposition={compositionWindow.open} assistantSearchTarget={assistantSearchTarget} + searchRequest={toolSearchRequest} onOpenAnalysis={(request) => setAnalysisReports((previous) => [...previous, request])} /> { + it('空白和未知关键词没有结果,名称匹配优先于别名', () => { + expect(searchAppEntries(' ')).toEqual([]); + expect(searchAppEntries('不存在的合成功能')).toEqual([]); + expect(searchAppEntries('导出')[0]?.title).toBe('导出'); + expect(searchAppEntries(' oCr ').some((entry) => entry.title === '图片识别')).toBe(true); + }); + + it('功能指向真实入口,设置别名指向具体控件而不是执行配置变更', () => { + expect(searchAppEntries('年度报告')[0]?.target).toEqual({ kind: 'view', view: 'annual' }); + expect(searchAppEntries('导出查看器')[0]?.target).toEqual({ kind: 'tool', tool: 'viewer' }); + expect(searchAppEntries('深色模式')[0]?.target).toEqual({ + kind: 'setting', + section: 'appearance', + item: '基础模式', + }); + expect(searchAppEntries('消息序号')[0]?.target).toEqual({ + kind: 'setting', + section: 'message-display', + item: '显示消息序号(seq)', + }); + expect(searchAppEntries('缓存目录')[0]?.target).toEqual({ + kind: 'setting', + section: 'storage', + item: '缓存目录', + }); + }); + + it('所有可见设置分区均可搜索,条目身份唯一且排名稳定', () => { + const sections = new Set( + APP_SEARCH_ENTRIES.flatMap((entry) => + entry.target.kind === 'setting' ? [entry.target.section] : [], + ), + ); + for (const section of SETTINGS_GROUPS.flatMap((group) => group.sections)) { + expect(sections.has(section), section).toBe(true); + } + expect(new Set(APP_SEARCH_ENTRIES.map((entry) => entry.id)).size).toBe( + APP_SEARCH_ENTRIES.length, + ); + expect(searchAppEntries('设置')).toEqual(searchAppEntries('设置')); + }); +}); diff --git a/apps/desktop/test/feature_search_ui.test.ts b/apps/desktop/test/feature_search_ui.test.ts new file mode 100644 index 000000000..9e88f7359 --- /dev/null +++ b/apps/desktop/test/feature_search_ui.test.ts @@ -0,0 +1,182 @@ +import { act, createElement as h, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SearchDropdown } from '../src/renderer/src/components/search/SearchDropdown'; +import { + findSettingElement, + revealSetting, +} from '../src/renderer/src/components/settings/searchTarget'; +import { Row, Toggle } from '../src/renderer/src/components/settings/controls'; +import type { SearchCategory } from '../src/renderer/src/components/search/types'; +import type { SearchPosition } from '../src/renderer/src/components/search/searchPosition'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +vi.mock('../src/renderer/src/trpc/client', () => ({ client: {}, trpc: {} })); +beforeEach(() => vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)); +afterEach(() => vi.unstubAllGlobals()); + +describe('功能与设置搜索界面', () => { + it('展开更多后跳到内容,返回时保留功能列表的展开状态', () => { + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + const navigate = vi.fn(); + const props = { + keyword: '设置', + quick: { + conversations: [], + groupMembers: [], + friends: [ + { + category: 'friend' as const, + uid: 'u_fixture', + uin: '10001', + nick: '合成设置', + remark: '', + avatarUrl: null, + }, + ], + }, + quickLoading: false, + slow: null, + slowLoading: false, + categories: ['friend'] as SearchCategory[], + onCategoriesChange: vi.fn(), + onSelect: vi.fn(), + onMore: vi.fn(), + appSearchEnabled: true, + onSelectAppEntry: vi.fn(), + onNavigate: navigate, + }; + try { + act(() => root.render(h(SearchDropdown, props))); + expect(host.querySelectorAll('.weq-app-search [role="option"]')).toHaveLength(6); + act(() => host.querySelector('.weq-app-search-expand')!.click()); + const count = host.querySelectorAll('.weq-app-search [role="option"]').length; + expect(count).toBeGreaterThan(6); + act(() => + host.querySelector('[data-search-hit-key="friend:u_fixture"]')!.click(), + ); + const position = navigate.mock.calls[0]![0] as SearchPosition; + expect(position.appResultsExpanded).toBe(true); + act(() => root.render(null)); + act(() => root.render(h(SearchDropdown, { ...props, initialPosition: position }))); + expect(host.querySelectorAll('.weq-app-search [role="option"]')).toHaveLength(count); + } finally { + act(() => root.unmount()); + host.remove(); + } + }); + it('内容请求尚未返回时已可打开设置,独立范围与全选、全取消一致', () => { + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + const select = vi.fn(); + function Harness() { + const [categories, setCategories] = useState([]); + const [enabled, setEnabled] = useState(true); + return h(SearchDropdown, { + keyword: '深色模式', + quick: null, + quickLoading: false, + slow: null, + slowLoading: false, + categories, + onCategoriesChange: setCategories, + onSelect: vi.fn(), + onMore: vi.fn(), + appSearchEnabled: enabled, + onAppSearchEnabledChange: setEnabled, + onSelectAppEntry: select, + }); + } + try { + act(() => root.render(h(Harness))); + expect(host.textContent).not.toContain('未选择搜索范围'); + const output = process.env.WEQ_FEATURE_SEARCH_LAYOUT_DIR; + if (output) { + mkdirSync(output, { recursive: true }); + const styles = ['index.css', 'workspace-tools.css', 'search-scope.css', 'app-search.css'] + .map((name) => readFileSync(resolve('src/renderer/src/styles', name), 'utf8')) + .join('\n'); + writeFileSync( + resolve(output, 'search.html'), + `
${host.innerHTML}
`, + ); + } + act(() => + host + .querySelector( + '[data-search-hit-key="app:setting:appearance:基础模式"]', + )! + .click(), + ); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: 'setting', section: 'appearance', item: '基础模式' }, + }), + ); + const all = host.querySelector('[aria-label="全部范围"]')!; + expect(all.indeterminate).toBe(true); + act(() => all.click()); + expect(all.checked).toBe(true); + act(() => all.click()); + expect(host.textContent).toContain('未选择搜索范围'); + expect(host.querySelector('.weq-app-search')).toBeNull(); + } finally { + act(() => root.unmount()); + host.remove(); + } + }); + + it('定位真实设置行并聚焦、高亮,不点击开关;撤销后恢复属性', () => { + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + const change = vi.fn(); + act(() => + root.render( + h(Row, { + label: '显示消息序号(seq)', + control: h(Toggle, { checked: false, label: '显示消息序号(seq)', onChange: change }), + }), + ), + ); + const row = host.querySelector('.weq-set-row')!; + try { + expect(findSettingElement(host, '显示消息序号(seq)')).toBe(row); + expect(findSettingElement(host, '显示消息序号')).toBeNull(); + const dispose = revealSetting(host, '显示消息序号(seq)'); + expect(row.dataset.searchHighlight).toBe('true'); + expect(document.activeElement).toBe(row); + expect(change).not.toHaveBeenCalled(); + dispose(); + expect(row.hasAttribute('tabindex')).toBe(false); + expect(row.hasAttribute('data-search-highlight')).toBe(false); + } finally { + act(() => root.unmount()); + host.remove(); + } + }); + + it('异步加载后可定位,离开后不再定位迟到的控件', async () => { + const host = document.createElement('div'); + document.body.append(host); + const dispose = revealSetting(host, '缓存目录'); + const title = document.createElement('div'); + title.className = 'weq-set-card-title'; + title.textContent = '缓存目录'; + host.append(title); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(title.dataset.searchHighlight).toBe('true'); + dispose(); + title.remove(); + const cancel = revealSetting(host, '缓存目录'); + cancel(); + host.append(title); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(title.hasAttribute('data-search-highlight')).toBe(false); + host.remove(); + }); +}); diff --git a/apps/desktop/test/feature_tool_search.test.ts b/apps/desktop/test/feature_tool_search.test.ts new file mode 100644 index 000000000..75bd7a8bf --- /dev/null +++ b/apps/desktop/test/feature_tool_search.test.ts @@ -0,0 +1,94 @@ +import { act, createElement as h } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Storage } from 'happy-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WorkspaceTools } from '../src/renderer/src/components/WorkspaceTools'; +import type { SearchableTool } from '../src/renderer/src/components/search/appSearchCatalog'; + +const fixture = vi.hoisted(() => ({ clones: [] })); +vi.mock('../src/renderer/src/views/agentlab/cloneTaskStore', () => ({ + getCloneTasks: () => fixture.clones, + subscribeCloneTasks: () => () => {}, +})); +vi.mock('../src/renderer/src/state/view', () => ({ + useViewState: (select: (state: { openedUin: string }) => unknown) => + select({ openedUin: 'fixture' }), +})); +vi.mock('../src/renderer/src/state/contentSelection', () => ({ + useContentExportRequest: () => null, + useContentAnalysisRequest: () => null, + useCurrentContentSelection: () => null, +})); +vi.mock('../src/renderer/src/hooks/useQQAutoRefresh', () => ({ + useQQReadActivity: () => ({ enabled: false }), +})); +vi.mock('../src/renderer/src/trpc/client', () => ({ + trpc: { + account: { + listExportTasks: { useQuery: () => ({ data: [] }) }, + roamingBackfill: { list: { useQuery: () => ({ data: { jobs: [] } }) } }, + flashListDownloadTasks: { useQuery: () => ({ data: [] }) }, + }, + }, +})); +vi.mock('../src/renderer/src/views/ExportView', () => ({ + ExportTool: ({ surface }: { surface?: string }) => + h('div', { 'data-search-tool': surface === 'tasks' ? 'tasks' : 'export' }), +})); +vi.mock('../src/renderer/src/views/ExportViewer', () => ({ + ExportViewer: () => h('div', { 'data-search-tool': 'viewer' }), +})); +vi.mock('../src/renderer/src/views/analysis/AnalysisTool', () => ({ + AnalysisTool: () => h('div', { 'data-search-tool': 'analysis' }), +})); +vi.mock('../src/renderer/src/views/agentlab/AssistantMenuWorkspace', () => ({ + AssistantMenuWorkspace: () => h('div', { 'data-search-tool': 'assistant' }), +})); + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('localStorage', new Storage()); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('搜索直达现有工作工具', () => { + it.each(['export', 'viewer', 'analysis', 'tasks', 'assistant'] as const)( + '%s 仅处理当前账号来源的请求,关闭后可再次打开', + (tool: SearchableTool) => { + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + const render = (scope: string, revision: number) => + act(() => + root.render( + h(WorkspaceTools, { + accountScope: 'fixture/config', + footerControls: null, + onOpenAnalysis: vi.fn(), + searchRequest: { scope, revision, tool }, + }), + ), + ); + try { + render('other/config', 1); + expect(document.querySelector(`[data-search-tool="${tool}"]`)).toBeNull(); + render('fixture/config', 2); + expect(document.querySelector(`[data-search-tool="${tool}"]`)).not.toBeNull(); + const closeSelector = + tool === 'tasks' || tool === 'assistant' + ? '[aria-label="关闭完整面板"]' + : '[aria-label="关闭工具"]'; + const close = document.querySelector(closeSelector)!; + act(() => close.click()); + const closedButton = document.querySelector(closeSelector); + expect(!closedButton || Boolean(closedButton.closest('[hidden], [inert]'))).toBe(true); + render('fixture/config', 3); + expect(document.querySelector(`[data-search-tool="${tool}"]`)).not.toBeNull(); + expect(document.querySelector(closeSelector)?.closest('[hidden], [inert]')).toBeNull(); + } finally { + act(() => root.unmount()); + host.remove(); + } + }, + ); +}); diff --git a/apps/desktop/test/settings_reorganization.test.ts b/apps/desktop/test/settings_reorganization.test.ts index 1d3a34041..b93e65d2a 100644 --- a/apps/desktop/test/settings_reorganization.test.ts +++ b/apps/desktop/test/settings_reorganization.test.ts @@ -4,6 +4,7 @@ import { Storage } from 'happy-dom'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SettingsDialog, type SectionId } from '../src/renderer/src/components/SettingsDialog'; import { HelpSection } from '../src/renderer/src/components/settings/HelpSection'; +import type { SettingsSearchTarget } from '../src/renderer/src/components/settings/searchTarget'; vi.mock('../src/renderer/src/trpc/client', () => { const queryData = (name: string) => { @@ -53,12 +54,13 @@ function mount(initialSection: SectionId, onCreateClone?: () => void) { const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); - const render = (section: SectionId) => + const render = (section: SectionId, searchTarget?: SettingsSearchTarget) => act(() => root.render( createElement(SettingsDialog, { open: true, initialSection: section, + searchTarget, onClose: vi.fn(), onOpenMaintenance: vi.fn(), onOpenRecalls: vi.fn(), @@ -79,6 +81,27 @@ function mount(initialSection: SectionId, onCreateClone?: () => void) { } describe('设置归位后的实际页面', () => { + it('从搜索打开设置时切换分区并定位真实项目,离开分区撤销高亮', () => { + const f = mount('qzone'); + try { + for (const [section, item] of [ + ['appearance', '基础模式'], + ['message-display', '显示消息序号(seq)'], + ['storage', '缓存目录'], + ['global', '成功通知停留时长'], + ['version', '监控新版本发布'], + ] as const) { + f.render(section, { section, item, revision: Date.now() }); + const highlighted = f.body().querySelector('[data-search-highlight="true"]'); + expect(highlighted, `${section}: ${item}`).not.toBeNull(); + expect(document.activeElement).toBe(highlighted); + } + f.render('qzone'); + expect(f.host.querySelector('[data-search-highlight]')).toBeNull(); + } finally { + f.close(); + } + }); it('旧独立帮助内容仍能切换日志、常见问题和反馈', () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/apps/desktop/test/title_bar_status.test.ts b/apps/desktop/test/title_bar_status.test.ts index d4c09762c..07c450fe9 100644 --- a/apps/desktop/test/title_bar_status.test.ts +++ b/apps/desktop/test/title_bar_status.test.ts @@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TitleBar } from '../src/renderer/src/im-template/template/TitleBar'; import type { User } from '../src/renderer/src/im-template/template/types'; +import { useRestrictedMode } from '../src/renderer/src/state/restrictedMode'; +import { TitleBarSearch } from '../src/renderer/src/components/TitleBarSearch'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; const state = vi.hoisted(() => ({ status: { uid: 'u_self', type: 10, typeName: '在线', subType: 0, displayStatus: '在线' } as { @@ -51,7 +55,15 @@ function fixture() { avatarUrl: null, signature: '个性签名', }; - const render = () => act(() => root.render(h(TitleBar, { user: { ...user } }))); + const render = () => + act(() => + root.render( + h(TitleBar, { + user: { ...user }, + search: h(TitleBarSearch, { persistent: true, query: '', onQueryChange: vi.fn() }), + }), + ), + ); cleanup = () => { act(() => root.unmount()); host.remove(); @@ -63,6 +75,7 @@ beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); state.status = { uid: 'u_self', type: 10, typeName: '在线', subType: 0, displayStatus: '在线' }; state.query.mockClear(); + useRestrictedMode.setState({ reasons: [], requests: [], enteringSince: null }); }); afterEach(() => { cleanup(); @@ -70,6 +83,48 @@ afterEach(() => { }); describe('标题栏在线状态', () => { + it('搜索在签名右侧、受限提示左侧,签名保留全文提示且受限状态可收拢', () => { + const f = fixture(); + const signature = f.host.querySelector('.app-title-bar-signature')!; + const search = f.host.querySelector('.weq-titlebar-search')!; + const restriction = f.host.querySelector('.weq-titlebar-restriction')!; + expect(signature.getAttribute('title')).toBe(f.user.signature); + expect( + signature.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(search.nextElementSibling).toBe(restriction); + const indicator = restriction.querySelector('button')!; + expect(indicator.getAttribute('aria-hidden')).toBe('true'); + expect(indicator.disabled).toBe(true); + act(() => useRestrictedMode.setState({ enteringSince: Date.now() })); + expect(indicator.getAttribute('aria-hidden')).toBeNull(); + expect(indicator.textContent).toContain('正在进入完整模式'); + act(() => useRestrictedMode.setState({ enteringSince: null })); + expect(indicator.getAttribute('aria-hidden')).toBe('true'); + expect(restriction.querySelector('button')).toBe(indicator); + const output = process.env.WEQ_FEATURE_SEARCH_LAYOUT_DIR; + if (output) { + f.user.signature = + '这是一段很长的合成个性签名,用来检查搜索栏常驻时省略和悬停全文是否正确。'.repeat(3); + f.render(); + const logo = f.host.querySelector('.app-title-bar-logo')!; + logo.src = `data:image/png;base64,${readFileSync(resolve('../../resources/brand/logo.png')).toString('base64')}`; + mkdirSync(output, { recursive: true }); + const styles = [ + 'index.css', + 'workspace-tools.css', + 'self-profile.css', + 'restricted-mode.css', + 'titlebar-search.css', + ] + .map((name) => readFileSync(resolve('src/renderer/src/styles', name), 'utf8')) + .join('\n'); + writeFileSync( + resolve(output, 'titlebar.html'), + `${f.host.innerHTML}`, + ); + } + }); it('在昵称与签名之间显示本人状态,并定期刷新本地记录', () => { const f = fixture(); const nick = f.host.querySelector('.app-title-bar-nick')!; diff --git a/apps/desktop/test/workspace_controls.test.ts b/apps/desktop/test/workspace_controls.test.ts index c67a99f30..8d3c199f1 100644 --- a/apps/desktop/test/workspace_controls.test.ts +++ b/apps/desktop/test/workspace_controls.test.ts @@ -38,6 +38,34 @@ function pointer(target: Element, type: string, options: PointerEventInit = {}) } describe('标题栏全局搜索', () => { + it('常驻模式无需悬停即可输入,Escape 只关闭结果而不收起搜索栏', () => { + const view = mount( + createElement(TitleBarSearch, { + persistent: true, + query: '主题色', + onQueryChange: vi.fn(), + results: createElement('div', { id: 'persistent-result' }, '结果'), + }), + ); + try { + const input = view.host.querySelector('input')!; + expect(input.disabled).toBe(false); + expect(input.tabIndex).toBe(0); + expect(view.host.firstElementChild?.classList.contains('is-expanded')).toBe(true); + expect(document.getElementById('persistent-result')).toBeNull(); + act(() => input.focus()); + expect(document.getElementById('persistent-result')).not.toBeNull(); + act(() => + input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' })), + ); + expect(document.getElementById('persistent-result')).toBeNull(); + expect(input.value).toBe('主题色'); + expect(input.disabled).toBe(false); + expect(view.host.firstElementChild?.classList.contains('is-expanded')).toBe(true); + } finally { + view.dispose(); + } + }); it('返回更多页后再次打开搜索仍正常聚焦输入框', () => { const view = mount( createElement(TitleBarSearch, { @@ -130,6 +158,28 @@ describe('标题栏全局搜索', () => { view.dispose(); } }); + + it('点击功能命中后关闭浮层,常驻搜索框和关键词仍保留', () => { + const select = vi.fn(); + const view = mount( + createElement(TitleBarSearch, { + persistent: true, + query: '导出', + onQueryChange: vi.fn(), + results: createElement('button', { role: 'option', onClick: select }, '导出'), + }), + ); + try { + act(() => view.host.querySelector('input')!.focus()); + act(() => document.querySelector('[role="option"]')!.click()); + expect(select).toHaveBeenCalledOnce(); + expect(document.querySelector('.weq-titlebar-search-results')).toBeNull(); + expect(view.host.querySelector('input')!.value).toBe('导出'); + expect(view.host.querySelector('input')!.disabled).toBe(false); + } finally { + view.dispose(); + } + }); }); describe('原地悬浮工具条', () => { diff --git a/docs/guide/chat-view.md b/docs/guide/chat-view.md index 789430948..96384a8cd 100644 --- a/docs/guide/chat-view.md +++ b/docs/guide/chat-view.md @@ -5,7 +5,7 @@ ## 1. 会话列表 -全局搜索位于窗口标题栏 WeQ 图标与用户头像之间,默认只显示图标,悬停临时展开,点击可固定展开或收起;不会因当前内容页改变搜索语义。创建群聊、添加好友仍属于内容侧的操作,不进入全局工具条。 +桌面全局搜索常驻在窗口标题栏右侧,位于个性签名右侧、受限提示左侧;聚焦或点击搜索图标打开结果,Esc 或点击外部关闭结果但保留搜索栏与关键词。受限提示出现或消失时平滑收放,不预留空位;签名过长时截断显示,悬停可查看全文。搜索不会因当前内容页改变语义。创建群聊、添加好友仍属于内容侧的操作,不进入全局工具条。 桌面左栏底部的工具吸附入口、快捷控制与设置使用统一样式,设置位于快捷控制下方、账号头像上方,打开设置或维护工具时保持高亮,并保留新版本提醒。工具拖离吸附位置后恢复圆形悬浮按钮,运行任务数量继续显示;窄屏设置入口仍位于导航栏。 @@ -195,6 +195,7 @@ WeQ 高仿 QQ 渲染,支持几乎全部消息类型: | 类别 | 内容 | | --- | --- | +| 功能与设置 | 应用页面、工具入口、设置分区和具体设置项,支持常用别名 | | 会话 | 会话名匹配 | | 好友 | 好友资料匹配 | | 群友 | 群成员匹配 | @@ -215,7 +216,9 @@ WeQ 高仿 QQ 渲染,支持几乎全部消息类型: 名称完全匹配优先于前缀、包含匹配;空间等缓存来源的标题匹配优先于仅正文匹配。同档文件与缓存内容优先较新的记录,聊天记录分组再按命中数排序。会话、好友和群友在分页前排序,同分使用稳定身份确定顺序。聊天记录与文件仍在有界候选池内排名,不代表任意历史命中都已参加排序。下拉先显示有命中的类别,名称相关度相同按会话、好友、聊天记录、文件、收藏等常用顺序排列,失败、加载中和空结果靠后;生成对话与商城在范围列表末尾。 -可独立勾选类别;全部取消时不执行查询。切换范围不会改变关键词,也不会随当前页面自动改成局部搜索。查询失败会显示重试按钮,不把失败当作无结果。 +「功能与设置」默认勾选,在本地即时匹配,不读取设置值或凭据,也不等待聊天索引。可搜索「导出」「年度报告」「深色模式」「消息序号」「缓存目录」等名称或别名;设置结果显示分类路径,点击打开对应分区并定位、高亮具体控件,不自动改变设置。未就绪或有条件显示的控件仍受原页面限制;维护项目先打开所在设置页,不自动执行修复、清理等操作。命中较多时可在当前下拉中展开更多功能与设置。 + +可独立勾选类别;「全部」包括功能与设置,全部取消时不执行查询。切换范围不会改变关键词,也不会随当前页面自动改成局部搜索。查询失败会显示重试按钮,不把失败当作无结果。 这里的「全部」表示上述已接入类别,不代表完整云端历史。空间、相册、群文件和邮件采用当前账号的已观察文本索引,类别旁显示「已缓存内容」、记录数及是否有回收或截断;本版本浏览接口取得的内容才会进入该索引,不扫描所有旧导出目录或原始缓存。关闭账号会保存索引,同号不同数据来源互相隔离。索引最多保留约 16 MiB / 两万条,回收会明确标注;旧格式或无法保存时仍可使用当前内存内容,并提示缓存尚未保存。 @@ -268,6 +271,7 @@ WeQ 高仿 QQ 渲染,支持几乎全部消息类型: - 拖动中实时高亮将被选中的消息、系统卡片与缺口,不等到松开才显示;这是临时预览,取消手势会恢复原选区,松开后才提交最终选择。预览不改变消息行的尺寸,边缘滚动和反向缩小范围继续有效。 - 收藏等表情图发送成功及重新编辑后继续按表情尺寸显示,会话预览显示「表情」;普通 GIF 仍按图片处理。 - 输入框内的图片和大表情可点击打开图片预览,复用缩放、平移与保存功能;关闭后恢复原草稿光标,桌面与展开输入框一致,不会因此发送或移除图片。 +- 图片上传中即可点击气泡查看本地预览,不必等发送完成;收到回执但消息尚未同步时仍可查看,打开中的大图不会因发送结束或切换会话而丢失。原图暂不可用时回退到可用来源(包括气泡缩略图)。 - 群待办在侧栏按群公告样式显示摘要,打开后才显示完成操作;已确认完成的事项继续显示并禁用重复完成。本地完成记录按账号、数据源和群隔离保存,即使服务端不再返回该横幅,刷新或重新打开后仍可查看;未确认回执不会伪造成完成。 - 窄屏多选操作以图标呈现,仍提供可访问名称和提示。 - 多选消息中的发送占位持久化为本地协议缓存或同步为正式记录后,选区与 Shift 锚点一并迁移。自己的正式消息与其他消息一样可以导出、分析和删除;普通漫游与自发缓存均可导出、右键或多选删除,删除标记按账号和会话隔离,不修改 QQ 原库或服务端,删除列表可恢复。删除确认可勾选“删除后不再显示”,否则保留已删除遮罩;隐藏造成的中间缺口即使只缺一条也显示拉取入口,重新拉取成功后恢复实际收到的记录,失败不丢失恢复记录。多选仅在包含可恢复的已删除消息时显示“恢复所选已删除消息”,只恢复其中已删除的条目,失败项保留选中。以后 QQ 同步出的正式原库记录不会被缓存删除标记自动改写。混入当前操作确实不支持的记录时,操作按钮仍可点击;弹窗说明条数,可用与「预览私聊」相同的消息视图逐条核对原因,再仅处理支持的消息。全部不支持时仍可预览,但不能继续;取消保留原选择,批量删除后保留未处理项。 From 2ec474f1a34910ec415ed7a0caa9075abdd0b3cd Mon Sep 17 00:00:00 2001 From: ganpig Date: Sun, 25 Oct 2026 14:22:28 +0800 Subject: [PATCH 011/185] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=8F=91=E9=80=81=E6=9C=9F=E9=97=B4=E7=9A=84=E5=A4=A7?= =?UTF-8?q?=E5=9B=BE=E9=A2=84=E8=A7=88=E4=B8=8E=E8=B5=84=E6=BA=90=E9=87=8A?= =?UTF-8?q?=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../renderer/src/components/ImageLightbox.tsx | 56 +++++++- .../src/renderer/src/components/QqMedia.tsx | 19 ++- .../src/im-template/template/chatPane.tsx | 24 ++-- .../src/im-template/template/composerMedia.ts | 5 +- .../desktop/src/renderer/src/lib/objectUrl.ts | 25 ++++ apps/desktop/test/composer_send.test.ts | 51 +++++++ .../test/image_lightbox_pending.test.ts | 133 ++++++++++++++++++ apps/desktop/test/media_thumbnail.test.ts | 2 +- apps/desktop/test/object_url.test.ts | 34 +++++ 10 files changed, 325 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/renderer/src/lib/objectUrl.ts create mode 100644 apps/desktop/test/image_lightbox_pending.test.ts create mode 100644 apps/desktop/test/object_url.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0378870f2..f7ff71243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -551,3 +551,4 @@ - 修复聊天消息及回复引用中的 @ 无法点击查看名片的问题,有有效 UID 或 QQ 号即可打开。 - 修复 macOS 按 Cmd+Q 后后台守护进程仍运行的问题,彻底退出会停止本轮后台服务,关闭窗口最小化到托盘的行为不变。 - 全局搜索支持查找功能与设置项并直达对应位置;搜索栏常驻标题栏右侧,受限提示平滑收放,过长个性签名悬停显示全文。 +- 修复图片尚未发送完成时打开大图显示失败的问题;优先使用本地预览,发送回执和消息同步期间保持图片可用。 diff --git a/apps/desktop/src/renderer/src/components/ImageLightbox.tsx b/apps/desktop/src/renderer/src/components/ImageLightbox.tsx index f702fbb1d..a3e42fb42 100644 --- a/apps/desktop/src/renderer/src/components/ImageLightbox.tsx +++ b/apps/desktop/src/renderer/src/components/ImageLightbox.tsx @@ -35,6 +35,7 @@ import { useToast } from './Toast'; import { useZoomPan } from './useZoomPan'; import { resourceForRenderDocument } from '../lib/renderDocument'; import { MediaSourceSave } from './MediaSaveControls'; +import { retainObjectUrl } from '../lib/objectUrl'; /** * 图片的「可 OCR」上下文 —— 只有聊天消息里的图片拿得到(元素里带 `fileToken`)。 @@ -60,6 +61,8 @@ interface LightboxStore { alt: string; ocr: LightboxOcrContext | null; ownerDocument: Document | null; + fallbackSources: string[]; + releaseSources?: () => void; onClosed?: () => void; open( src: string, @@ -67,7 +70,9 @@ interface LightboxStore { ocr?: LightboxOcrContext | null, ownerDocument?: Document, onClosed?: () => void, + fallbackSources?: string[], ): void; + nextSource(failed: string): void; close(): void; } @@ -76,13 +81,41 @@ const useLightbox = create((set, get) => ({ alt: '', ocr: null, ownerDocument: null, - open(src, alt = '', ocr = null, ownerDocument = document, onClosed) { + fallbackSources: [], + open(src, alt = '', ocr = null, ownerDocument = document, onClosed, fallbacks = []) { if (!src || (get().src === src && get().ownerDocument === ownerDocument)) return; - set({ src, alt, ocr, ownerDocument, onClosed }); + const sources = [...new Set([src, ...fallbacks].filter(Boolean))]; + const releases = sources.map(retainObjectUrl); + get().releaseSources?.(); + set({ + src, + alt, + ocr, + ownerDocument, + onClosed, + fallbackSources: sources.slice(1), + releaseSources: () => { + for (const release of releases) release(); + }, + }); + }, + nextSource(failed) { + const { src, fallbackSources } = get(); + if (src !== failed || !fallbackSources.length) return; + set({ src: fallbackSources[0], fallbackSources: fallbackSources.slice(1) }); }, close() { - const onClosed = get().onClosed; - set({ src: null, alt: '', ocr: null, ownerDocument: null, onClosed: undefined }); + const { onClosed, releaseSources } = get(); + set({ + src: null, + alt: '', + ocr: null, + ownerDocument: null, + onClosed: undefined, + fallbackSources: [], + releaseSources: undefined, + }); + releaseSources?.(); onClosed?.(); }, })); @@ -94,9 +127,17 @@ export function openLightbox( ocr?: LightboxOcrContext | null, ownerDocument?: Document, onClosed?: () => void, + fallbackSources: string[] = [], ): void { const target = ownerDocument ?? document; - useLightbox.getState().open(resourceForRenderDocument(src, target), alt, ocr, target, onClosed); + useLightbox.getState().open( + resourceForRenderDocument(src, target), + alt, + ocr, + target, + onClosed, + fallbackSources.map((source) => resourceForRenderDocument(source, target)), + ); } /** Mount once. Renders the active large-image overlay, if any. */ @@ -107,6 +148,7 @@ export function ImageLightbox(): ReactElement | null { const alt = useLightbox((s) => s.alt); const ocrContext = useLightbox((s) => s.ocr); const close = useLightbox((s) => s.close); + const nextSource = useLightbox((s) => s.nextSource); const zoom = useZoomPan(src); const layer = useOverlayLayer(src !== null); @@ -130,6 +172,8 @@ export function ImageLightbox(): ReactElement | null { const [panelOpen, setPanelOpen] = useState(false); const [busy, setBusy] = useState(false); + useEffect(() => () => useLightbox.getState().close(), []); + useEffect(() => { if (src) layerRef.current?.focus({ preventScroll: true }); }, [src, document]); @@ -341,12 +385,14 @@ export function ImageLightbox(): ReactElement | null { onMouseDown={zoom.onMouseDown} > {alt} nextSource(src)} /> {overlayVisible ? (
diff --git a/apps/desktop/src/renderer/src/components/QqMedia.tsx b/apps/desktop/src/renderer/src/components/QqMedia.tsx index c335145eb..6009d21cb 100644 --- a/apps/desktop/src/renderer/src/components/QqMedia.tsx +++ b/apps/desktop/src/renderer/src/components/QqMedia.tsx @@ -150,20 +150,24 @@ export function QqImage({ // 乐观渲染的图片还会带 `localPreviewUrl`(blob: 地址,剪贴板截图没有落盘路径)。 // 真实消息的渲染元素不带这两个字段,所以主时间线行为不变。 // - // 源按优先级排队,加载失败换下一个:渲染层 `` 选进来的路径不会 - // 被主进程 `trustPath` 登记,`localfile` 会 404 —— 这时必须回退到乐观渲染的 blob - // `localPreviewUrl`,否则明明有本地预览却画成「未找到」。 + // 已有 Blob 预览优先:文件选择器拿到的路径可能尚未被主进程信任,上传也可能未完成。 + // 本地路径、CDN 与缓存依次兜底;灯箱沿用这条回退链,不依赖气泡先加载成功。 const localPath = str(data, 'localPath'); const localPreviewUrl = str(data, 'localPreviewUrl'); const candidates: string[] = []; + if (localPreviewUrl) candidates.push(localPreviewUrl); if (localPath) candidates.push(localFileUrl(localPath)); - if (localPreviewUrl && !candidates.includes(localPreviewUrl)) candidates.push(localPreviewUrl); if (cdnSrc) candidates.push(cdnSrc); if (!candidates.includes(proxySrc)) candidates.push(proxySrc); if (!isAnimatedEmoji && !candidates.includes(originalProxySrc)) candidates.push(originalProxySrc); const [srcIndex, setSrcIndex] = useState(0); const src = candidates[Math.min(srcIndex, candidates.length - 1)] ?? proxySrc; + useEffect(() => { + setSrcIndex(0); + setBroken(false); + }, [localPath, localPreviewUrl, originalProxySrc, cdnSrc]); + if (broken) { return ; } @@ -205,6 +209,13 @@ export function QqImage({ name || '[图片]', ocrContext, event.currentTarget.ownerDocument, + undefined, + [ + ...candidates + .slice(srcIndex + 1) + .map((candidate) => (candidate === proxySrc ? originalProxySrc : candidate)), + proxySrc, + ], ) : undefined } diff --git a/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx b/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx index 0d786a179..1bb864c9b 100644 --- a/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx +++ b/apps/desktop/src/renderer/src/im-template/template/chatPane.tsx @@ -93,6 +93,7 @@ import { } from 'react'; import { ReplyJumpContext } from '../../components/QqMessageContent'; import { openLightbox } from '../../components/ImageLightbox'; +import { releaseObjectUrl } from '../../lib/objectUrl'; import '../../styles/composer-image-preview.css'; import { ChatBackdrop } from '../../components/ChatBackdrop'; import { useChatBackdrop } from '../../hooks/useDressSkin'; @@ -1960,7 +1961,7 @@ export function ChatPane({ const releaseInlineImages = useCallback(() => { for (const item of inlineImagesRef.current) releaseAttachment(item); inlineImagesRef.current = []; - for (const url of reeditObjectUrlsRef.current) URL.revokeObjectURL(url); + for (const url of reeditObjectUrlsRef.current) releaseObjectUrl(url); reeditObjectUrlsRef.current = []; }, []); useEffect(() => { @@ -1972,7 +1973,7 @@ export function ChatPane({ ); for (const url of retainedSendUrlsRef.current) { if (referenced.has(url)) continue; - URL.revokeObjectURL(url); + releaseObjectUrl(url); retainedSendUrlsRef.current.delete(url); } }, [messages, sending]); @@ -2003,7 +2004,7 @@ export function ChatPane({ useLayoutEffect( () => () => { releaseInlineImages(); - for (const url of retainedSendUrlsRef.current) URL.revokeObjectURL(url); + for (const url of retainedSendUrlsRef.current) releaseObjectUrl(url); retainedSendUrlsRef.current.clear(); if (mediaAttachmentRef.current) releaseAttachment(mediaAttachmentRef.current); }, @@ -2929,7 +2930,6 @@ export function ChatPane({ const clearsBody = !singleToken && !options.keepBody; const sentImages = clearsBody ? [...inlineImagesRef.current] : []; const sentReeditUrls = clearsBody ? [...reeditObjectUrlsRef.current] : []; - let failed = false; const initialFocus = document.activeElement; const isCurrentComposer = () => mentionConversation.current === request.conversationId && @@ -2998,17 +2998,15 @@ export function ChatPane({ setPokeOpen(false); if (singleToken || options.keepBody) window.requestAnimationFrame(restoreSendFocus); } catch (error) { - failed = true; // 失败只保留消息上的重新编辑入口,不回填或覆盖下一条草稿。 + console.error('[composer] send failed:', error); + } finally { + // 回执不等于真实消息已接管;占位或灯箱仍引用的地址不能随发送结束撤销。 for (const url of [...sentImages.map((image) => image.url), ...sentReeditUrls]) { if (!url) continue; if (composerEditorRef.current) retainedSendUrlsRef.current.add(url); - else URL.revokeObjectURL(url); + else releaseObjectUrl(url); } - console.error('[composer] send failed:', error); - } finally { - if (!failed) for (const image of sentImages) releaseAttachment(image); - if (!failed) for (const url of sentReeditUrls) URL.revokeObjectURL(url); if (composerSendRef.current === request) { composerSendRef.current = null; setSending(false); @@ -3062,7 +3060,7 @@ export function ChatPane({ await sendVoiceClip(clip); } finally { // 乐观预览的 blob 要留到发送完成后再释放。 - window.setTimeout(() => URL.revokeObjectURL(url), 30_000); + window.setTimeout(() => releaseObjectUrl(url), 30_000); } } @@ -3866,12 +3864,12 @@ export function ChatPane({ return false; } if (controller.signal.aborted || !unchanged()) { - for (const url of prepared.objectUrls) URL.revokeObjectURL(url); + for (const url of prepared.objectUrls) releaseObjectUrl(url); return false; } const text = prepared.body; if (!text) { - for (const url of prepared.objectUrls) URL.revokeObjectURL(url); + for (const url of prepared.objectUrls) releaseObjectUrl(url); return false; } releaseInlineImages(); diff --git a/apps/desktop/src/renderer/src/im-template/template/composerMedia.ts b/apps/desktop/src/renderer/src/im-template/template/composerMedia.ts index 35f2c34dd..1b1738ac8 100644 --- a/apps/desktop/src/renderer/src/im-template/template/composerMedia.ts +++ b/apps/desktop/src/renderer/src/im-template/template/composerMedia.ts @@ -12,6 +12,7 @@ */ import { elementToToken } from './draftElements'; +import { releaseObjectUrl } from '../../lib/objectUrl'; export type ComposerAttachmentKind = 'image' | 'video' | 'file'; @@ -178,11 +179,11 @@ export async function createAttachments(files: Iterable): Promise(); + +/** 灯箱等临时消费者借用附件地址,附件交接结束也不能提前撤销它。 */ +export function retainObjectUrl(url: string): () => void { + if (!url.startsWith('blob:')) return () => {}; + const use = uses.get(url) ?? { count: 0, releaseRequested: false }; + uses.set(url, use); + use.count++; + let released = false; + return () => { + if (released) return; + released = true; + if (--use.count > 0) return; + uses.delete(url); + if (use.releaseRequested) URL.revokeObjectURL(url); + }; +} + +/** 原持有者放弃地址;有预览仍在使用时,等最后一个消费者退出再释放。 */ +export function releaseObjectUrl(url: string): void { + const use = uses.get(url); + if (use) use.releaseRequested = true; + else URL.revokeObjectURL(url); +} diff --git a/apps/desktop/test/composer_send.test.ts b/apps/desktop/test/composer_send.test.ts index a5c6e71fd..e9482b444 100644 --- a/apps/desktop/test/composer_send.test.ts +++ b/apps/desktop/test/composer_send.test.ts @@ -216,6 +216,57 @@ describe('输入区发送交接', () => { f.close(); } }); + + it('成功回执先于消息同步时保留图片预览,真实消息接管后再释放', async () => { + const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:pending-sync'); + vi.stubGlobal( + 'Image', + class { + naturalWidth = 1; + naturalHeight = 1; + onload: (() => void) | null = null; + set src(_value: string) { + queueMicrotask(() => this.onload?.()); + } + }, + ); + const f = mount(); + try { + type(f.editor(), ''); + const input = f.host.querySelector('input[type="file"][accept="image/*"]')!; + await act(async () => { + Object.defineProperty(input, 'files', { + configurable: true, + value: [new File(['fixture'], 'image.png', { type: 'image/png' })], + }); + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + await act(async () => f.host.querySelector('.composer-send')!.click()); + const [body, locals] = f.onSend.mock.calls[0]!; + f.render({ + messages: [ + { + id: 'optimistic-pending-image', + conversationId: f.props.conversation.id, + senderId: f.props.user.id, + body: '[图片]', + createdAt: '2026-10-25T01:00:00Z', + optimistic: 'sent', + composerDraft: { body, locals }, + qqElements: buildOptimisticRender({ body, locals: locals!, superStickers: new Map() }) + .elements, + }, + ], + }); + await act(async () => f.finish()); + expect(revoke).not.toHaveBeenCalledWith('blob:pending-sync'); + f.render({ messages: [] }); + expect(revoke.mock.calls).toEqual([['blob:pending-sync']]); + } finally { + f.close(); + } + }); it('撤回的图文消息恢复图片资源和前后顺序,可再次生成图片发送计划', async () => { vi.stubGlobal( 'fetch', diff --git a/apps/desktop/test/image_lightbox_pending.test.ts b/apps/desktop/test/image_lightbox_pending.test.ts new file mode 100644 index 000000000..a8c050128 --- /dev/null +++ b/apps/desktop/test/image_lightbox_pending.test.ts @@ -0,0 +1,133 @@ +import { act, createElement as h, Fragment } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ImageLightbox, openLightbox } from '../src/renderer/src/components/ImageLightbox'; +import { QqImage } from '../src/renderer/src/components/QqMedia'; +import { + releaseAttachment, + type ComposerAttachment, +} from '../src/renderer/src/im-template/template/composerMedia'; + +vi.mock('../src/renderer/src/hooks/useExportSavePolicy', () => ({ + useExportSavePolicy: () => ({ + binding: null, + settings: { isLoading: false }, + isCurrent: () => true, + }), +})); +vi.mock('../src/renderer/src/trpc/client', () => ({ + trpc: { + account: { + getGroupAlbumAccessState: { useQuery: () => ({ data: undefined }) }, + ocrCacheLookup: { useQuery: () => ({ data: undefined }) }, + ocrChatImage: { useMutation: () => ({ mutateAsync: vi.fn() }) }, + }, + }, +})); + +let root: Root; +let host: HTMLDivElement; +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))); + await act(async () => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +async function mount(data: Record) { + await act(async () => + root.render(h(Fragment, null, h(QqImage, { data, sendTimeMs: 1000 }), h(ImageLightbox))), + ); +} +function preview() { + return document.querySelector('.weq-lightbox-image')!; +} +function attachment(url: string): ComposerAttachment { + const file = new File(['fixture'], 'image.png', { type: 'image/png' }); + return { id: url, kind: 'image', name: file.name, size: file.size, mime: file.type, url, file }; +} + +it('上传未完成时直接打开本地 Blob,不等待本机路径或服务端资源就绪', async () => { + await mount({ + fileName: 'image.png', + localPath: '/synthetic/not-trusted/image.png', + localPreviewUrl: 'blob:pending-image', + }); + expect(host.querySelector('img')!.src).toBe('blob:pending-image'); + await act(async () => host.querySelector('img')!.click()); + expect(preview().src).toBe('blob:pending-image'); +}); + +it('原图尚未就绪时回退到气泡缩略图', async () => { + await mount({ fileName: 'image.png', fileToken: 'synthetic-token' }); + const thumbnail = host.querySelector('img')!.src; + await act(async () => host.querySelector('img')!.click()); + expect(new URL(preview().src).searchParams.has('v')).toBe(false); + await act(async () => preview().dispatchEvent(new Event('error'))); + expect(preview().src).toBe(thumbnail); + await act(async () => preview().dispatchEvent(new Event('load'))); + expect(preview().src).toBe(thumbnail); +}); + +it('发送完成或切换会话释放附件时,已打开的预览保留 Blob 到关闭', async () => { + const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + await mount({ fileName: 'image.png', localPreviewUrl: 'blob:pending-image' }); + await act(async () => host.querySelector('img')!.click()); + releaseAttachment(attachment('blob:pending-image')); + expect(revoke).not.toHaveBeenCalled(); + expect(preview().src).toBe('blob:pending-image'); + await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))); + expect(revoke.mock.calls).toEqual([['blob:pending-image']]); +}); + +it('重复打开不会多占引用,换图后释放旧预览但不释放仍属于附件的地址', async () => { + const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + await mount({ localPreviewUrl: 'blob:pending-image' }); + await act(async () => host.querySelector('img')!.click()); + await act(async () => host.querySelector('img')!.click()); + releaseAttachment(attachment('blob:pending-image')); + await act(async () => openLightbox('blob:next-image')); + expect(revoke.mock.calls).toEqual([['blob:pending-image']]); + await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))); + expect(revoke).not.toHaveBeenCalledWith('blob:next-image'); + releaseAttachment(attachment('blob:next-image')); + expect(revoke).toHaveBeenCalledWith('blob:next-image'); +}); + +it('尚未加载的本机路径失败后灯箱独立回退,不要求气泡先收到失败事件', async () => { + await mount({ fileName: 'image.png', localPath: '/synthetic/pending/image.png' }); + await act(async () => host.querySelector('img')!.click()); + expect(preview().src).toContain('localfile'); + await act(async () => preview().dispatchEvent(new Event('error'))); + expect(new URL(preview().src).hostname).toBe('pic'); + expect(new URL(preview().src).searchParams.has('v')).toBe(false); + await act(async () => preview().dispatchEvent(new Event('error'))); + expect(new URL(preview().src).searchParams.get('v')).toBe('thumb'); +}); + +it('占位图片失败后换成真实消息时重新取图,不继承旧的缺图状态', async () => { + await mount({ fileName: 'image.png', localPreviewUrl: 'blob:expired-preview' }); + for (let index = 0; index < 3; index++) { + await act(async () => host.querySelector('img')!.dispatchEvent(new Event('error'))); + } + expect(host.textContent).toContain('未找到'); + await mount({ fileName: 'image.png', fileToken: 'ready-token' }); + expect(host.textContent).not.toContain('未找到'); + expect(new URL(host.querySelector('img')!.src).searchParams.get('token')).toBe('ready-token'); +}); + +it('预览根组件卸载时归还待释放的 Blob,不留下资源引用', async () => { + const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + await mount({ localPreviewUrl: 'blob:unmount-image' }); + await act(async () => host.querySelector('img')!.click()); + releaseAttachment(attachment('blob:unmount-image')); + await act(async () => root.render(null)); + expect(revoke.mock.calls).toEqual([['blob:unmount-image']]); +}); diff --git a/apps/desktop/test/media_thumbnail.test.ts b/apps/desktop/test/media_thumbnail.test.ts index 850107b80..32a234560 100644 --- a/apps/desktop/test/media_thumbnail.test.ts +++ b/apps/desktop/test/media_thumbnail.test.ts @@ -47,7 +47,7 @@ it('动画表情不切换成静态缩略图,也不打开原图灯箱', async ( }); it('编辑器本地预览仍优先使用本地资源', async () => { const host = await render({ localPath: '/synthetic/image.png', localPreviewUrl: 'blob:fixture' }); - expect(host.querySelector('img')!.src).toContain('localfile'); + expect(host.querySelector('img')!.src).toBe('blob:fixture'); }); it('缩略图损坏时回退原图,不直接显示缺图', async () => { diff --git a/apps/desktop/test/object_url.test.ts b/apps/desktop/test/object_url.test.ts new file mode 100644 index 000000000..95037beba --- /dev/null +++ b/apps/desktop/test/object_url.test.ts @@ -0,0 +1,34 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { releaseObjectUrl, retainObjectUrl } from '../src/renderer/src/lib/objectUrl'; + +afterEach(() => vi.restoreAllMocks()); + +it('多个消费者共享 Blob,最后一个退出才执行已请求的释放,退出可重复调用', () => { + const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + const first = retainObjectUrl('blob:shared'); + const second = retainObjectUrl('blob:shared'); + releaseObjectUrl('blob:shared'); + releaseObjectUrl('blob:shared'); + first(); + first(); + expect(revoke).not.toHaveBeenCalled(); + second(); + second(); + expect(revoke.mock.calls).toEqual([['blob:shared']]); +}); + +it('消费者退出不擅自释放仍属于草稿的 Blob,也不保留无消费者的地址', () => { + const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + retainObjectUrl('blob:draft')(); + expect(revoke).not.toHaveBeenCalled(); + releaseObjectUrl('blob:draft'); + releaseObjectUrl('blob:unused'); + expect(revoke.mock.calls).toEqual([['blob:draft'], ['blob:unused']]); +}); + +it('远程或本机协议地址不作为 Blob 保留或释放', () => { + const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + retainObjectUrl('weq-media://pic?name=fixture.png')(); + retainObjectUrl('https://synthetic.invalid/image.png')(); + expect(revoke).not.toHaveBeenCalled(); +}); From 568d5634b4e4d958ed52b616ca2bc8b440215943 Mon Sep 17 00:00:00 2001 From: ganpig Date: Sun, 25 Oct 2026 14:27:04 +0800 Subject: [PATCH 012/185] =?UTF-8?q?feat(desktop):=20=E5=8F=97=E9=99=90?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81=E5=90=AF=E5=8A=A8=E7=A6=BB?= =?UTF-8?q?=E7=BA=BF=20QQ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../desktop/src/main/ipc/routers/bootstrap.ts | 7 ++ apps/desktop/src/main/qq_launch.ts | 45 ++++++++ .../src/components/RestrictedMode.tsx | 35 +++++- apps/desktop/test/qq_launch.test.ts | 101 ++++++++++++++++++ apps/desktop/test/restricted_mode.test.ts | 83 +++++++++++++- docs/guide/settings.md | 2 +- 7 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/main/qq_launch.ts create mode 100644 apps/desktop/test/qq_launch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ff71243..7e77ae975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -552,3 +552,4 @@ - 修复 macOS 按 Cmd+Q 后后台守护进程仍运行的问题,彻底退出会停止本轮后台服务,关闭窗口最小化到托盘的行为不变。 - 全局搜索支持查找功能与设置项并直达对应位置;搜索栏常驻标题栏右侧,受限提示平滑收放,过长个性签名悬停显示全文。 - 修复图片尚未发送完成时打开大图显示失败的问题;优先使用本地预览,发送回执和消息同步期间保持图片可用。 +- QQ 不在线时,受限模式详情新增「启动 QQ」按钮,启动失败可查看原因并重试。 diff --git a/apps/desktop/src/main/ipc/routers/bootstrap.ts b/apps/desktop/src/main/ipc/routers/bootstrap.ts index 6cc917bb8..4d523d331 100644 --- a/apps/desktop/src/main/ipc/routers/bootstrap.ts +++ b/apps/desktop/src/main/ipc/routers/bootstrap.ts @@ -93,6 +93,7 @@ import { import { normalizeQqDataRoot } from '@weq/platform'; import { intentSenseModelProcedures, isIntentModelDownloading } from './intent_sense'; import { isDbRepairBusy } from './db_repair'; +import { launchQq } from '../../qq_launch'; /** Result of the QQ data-directory picker. */ export interface PickRootResult { @@ -339,6 +340,12 @@ export const bootstrapRouter = router({ return requireBootstrap().globalConfig.refresh(); }), + /** 启动宿主上已安装的 QQ,不自动登录或更改在线功能设置。 */ + launchQq: procedure.mutation(async () => { + await launchQq(requirePlatform()); + return true; + }), + listAccounts: procedure.query(() => { return requireBootstrap().detect.listAccounts(); }), diff --git a/apps/desktop/src/main/qq_launch.ts b/apps/desktop/src/main/qq_launch.ts new file mode 100644 index 000000000..00e9397d0 --- /dev/null +++ b/apps/desktop/src/main/qq_launch.ts @@ -0,0 +1,45 @@ +import { execFile, spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { posix, win32 } from 'node:path'; +import type { Platform } from '@weq/platform'; + +/** 只启动检测到的 QQ 客户端;启动成功不代表账号已登录或在线凭据已就绪。 */ +export async function launchQq(platform: Pick): Promise { + const exe = platform.qqExePath(); + const path = platform.kind === 'win32' ? win32 : posix; + if (!exe || !path.isAbsolute(exe) || !existsSync(exe)) + throw new Error('未检测到 QQ 安装,请先安装 QQ。'); + + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + const app = path.dirname(path.dirname(path.dirname(exe))); + if ( + platform.kind === 'darwin' && + path.extname(app) === '.app' && + path.basename(path.dirname(exe)) === 'MacOS' && + path.basename(path.dirname(path.dirname(exe))) === 'Contents' + ) { + // 通过系统打开应用,复用已运行实例并激活登录窗口。 + await new Promise((resolve, reject) => { + execFile('/usr/bin/open', ['-a', app], { timeout: 10_000, env }, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + return; + } + + await new Promise((resolve, reject) => { + const child = spawn(exe, [], { + cwd: path.dirname(exe), + detached: true, + stdio: 'ignore', + env, + }); + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(); + }); + }); +} diff --git a/apps/desktop/src/renderer/src/components/RestrictedMode.tsx b/apps/desktop/src/renderer/src/components/RestrictedMode.tsx index fe79ace7f..3c13f82d0 100644 --- a/apps/desktop/src/renderer/src/components/RestrictedMode.tsx +++ b/apps/desktop/src/renderer/src/components/RestrictedMode.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useRef, useState } from 'react'; -import { KeyRound, LoaderCircle, RefreshCw, ShieldAlert, X } from 'lucide-react'; +import { KeyRound, LoaderCircle, Play, RefreshCw, ShieldAlert, X } from 'lucide-react'; import { client, trpc } from '../trpc/client'; import { restrictionReasons } from '../lib/restrictedMode'; import { useRestrictedMode, type AuthorizationRequest } from '../state/restrictedMode'; @@ -177,6 +177,7 @@ export function RestrictedModeHost() { const notice = useRef(undefined); const titleId = useId(); const [busy, setBusy] = useState(false); + const [launching, setLaunching] = useState(false); const [error, setError] = useState(''); const utils = trpc.useUtils(); useEffect(() => { @@ -338,6 +339,18 @@ export function RestrictedModeHost() { if (before instanceof HTMLElement && before.isConnected) before.focus(); }; }, [open]); + async function startQq() { + setLaunching(true); + setError(''); + try { + await client.bootstrap.launchQq.mutate(); + await (active ? access.refetch() : online.refetch()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLaunching(false); + } + } async function refresh(enable = false) { setBusy(true); setError(''); @@ -403,10 +416,26 @@ export function RestrictedModeHost() { ))} {error &&

{error}

}
+ {reasons.some((reason) => reason.code === 'offline') && ( + + )} - - + const controls = ( +
+ {settingsButton} + {typeof children === 'function' ? null : children} + - - - - - {children} +
- {open ? ( - setOpen(false)} - > -
-
- 切换账号 +
+ + + + +
+ ); + + return ( + <> + {typeof children === 'function' ? children(controls) : controls} + {open ? ( + setOpen(false)} + > +
+
+ 切换账号 +
+ {configs.error ? ( +
+ 账号列表读取失败:{configs.error.message} + + {configs.data?.length ? 以下为上次读取的记录。 : null}
- {configs.error ? ( -
- 账号列表读取失败:{configs.error.message} - - {configs.data?.length ? 以下为上次读取的记录。 : null} -
- ) : null} - {configs.isLoading ? ( -
加载中…
- ) : others.length === 0 ? ( - configs.error ? null : ( -
暂无其它账号
- ) - ) : ( -
    - {others.map((cfg) => ( -
  • - -
  • - ))} -
- )} - -
-
- ) : null} - {ctxMenu && ( - {cfg.uin} + + + + ))} + + )} + + + + ) : null} + {ctxMenu && ( + +
e.stopPropagation()} > -
e.stopPropagation()} + -
- - )} -
+ + 删除账号 + +
+ + )} ); } diff --git a/apps/desktop/src/renderer/src/components/SettingsDialog.tsx b/apps/desktop/src/renderer/src/components/SettingsDialog.tsx index 9baf99753..4627191f1 100644 --- a/apps/desktop/src/renderer/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsDialog.tsx @@ -92,6 +92,7 @@ import { type ThemeComponentStyle, type ThemePreference, } from '../state/theme'; +import { useUpdateStore } from '../state/update'; /** 左侧导航的分区 id。导出出去是为了让别的界面能"直接打开某一屏"(例如损坏弹窗 → 数据库宽容)。 */ interface SettingsSection { @@ -131,7 +132,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ }, { id: 'applock', - label: '应用锁', + label: '隐私与应用锁', icon: , render: () => , }, @@ -392,6 +393,7 @@ export function SettingsDialog({ const [activeId, setActiveId] = useState(() => settingsSectionFor(initialSection ?? 'global'), ); + const updateAvailable = useUpdateStore((state) => state.available); const navRef = useRef(null); const [mobileCategoryOpen, setMobileCategoryOpen] = useState(Boolean(initialSection)); const subnavRef = useRef(null); @@ -476,6 +478,9 @@ export function SettingsDialog({ {sections.find((section) => section.id === s.sections[0])!.icon} {s.label} + {updateAvailable && s.sections.includes('version') ? ( + + ) : null} {IS_WEB && } @@ -493,6 +498,9 @@ export function SettingsDialog({ onClick={() => setActiveId(section.id)} > {section.label} + {updateAvailable && section.id === 'version' ? ( + + ) : null} ))} diff --git a/apps/desktop/src/renderer/src/components/WorkspaceMenuButton.tsx b/apps/desktop/src/renderer/src/components/WorkspaceMenuButton.tsx new file mode 100644 index 000000000..976a6d023 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/WorkspaceMenuButton.tsx @@ -0,0 +1,46 @@ +import { Menu, Settings } from 'lucide-react'; +import { useWorkspaceMenu } from '../state/workspaceMenu'; +import { useUpdateStore } from '../state/update'; +import '../styles/workspace-menu.css'; + +export function WorkspaceMenuButton() { + const anchor = useWorkspaceMenu((state) => state.anchor); + const toggle = useWorkspaceMenu((state) => state.toggle); + const running = useWorkspaceMenu((state) => state.running); + const failed = useWorkspaceMenu((state) => state.failed); + const update = useUpdateStore((state) => state.available); + return ( + + ); +} + +export function WorkspaceSettingsButton({ onClick }: { onClick: () => void }) { + const update = useUpdateStore((state) => state.available); + return ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/WorkspaceTools.tsx b/apps/desktop/src/renderer/src/components/WorkspaceTools.tsx index 0b25f08ad..f7fd815a2 100644 --- a/apps/desktop/src/renderer/src/components/WorkspaceTools.tsx +++ b/apps/desktop/src/renderer/src/components/WorkspaceTools.tsx @@ -1,10 +1,9 @@ -import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'; import { getCloneTasks, subscribeCloneTasks } from '../views/agentlab/cloneTaskStore'; import type { GeneratedSearchHit } from './search/types'; import type { ToolSearchRequest } from './search/appSearchCatalog'; import { BarChart3, - Bot, Download, FileStack, FolderOpen, @@ -12,14 +11,17 @@ import { Settings, ShieldAlert, X, + Bot as AssistantIcon, + Maximize2, + Minimize2, } from 'lucide-react'; import type { ExportAuthorizationIndicatorState } from '../views/export/authorization'; import { Modal } from './Dialog'; -import { FloatingToolsBar, type FloatingToolItem } from './FloatingToolsBar'; import { RetainedTabs } from './RetainedTabs'; import { ExportTool } from '../views/ExportView'; import { ExportViewer } from '../views/ExportViewer'; -import { AgentLabView } from '../views/AgentLabView'; +import { AssistantMenuWorkspace } from '../views/agentlab/AssistantMenuWorkspace'; +import { useWorkspaceMenu } from '../state/workspaceMenu'; import { trpc } from '../trpc/client'; import { useContentExportRequest, @@ -27,6 +29,7 @@ import { useCurrentContentSelection, } from '../state/contentSelection'; import '../styles/workspace-tools.css'; +import '../styles/workspace-menu.css'; import { RoamingBackfillPanel } from './RoamingBackfillPanel'; import { DaemonSection } from './settings/DaemonSection'; import { useViewState } from '../state/view'; @@ -52,17 +55,23 @@ import { type WorkspaceContext, } from '../lib/toolTargets'; -type Tool = 'export' | 'viewer' | 'tasks' | 'assistant' | 'backfill' | 'analysis'; +type Tool = 'export' | 'viewer' | 'backfill' | 'analysis'; const TITLES: Record = { export: '导出', viewer: '导出查看器', - tasks: '任务', - assistant: 'WeQ 助手', backfill: '拉取漫游消息', analysis: '分析与报告', }; +const ACTION_LABELS: Record = { + analysis: '分析', + export: '导出', + viewer: '查看', + composition: '合成', +}; export function WorkspaceTools({ + accountScope, + footerControls, onOpenAnalysis, onOpenComposition, assistantSearchTarget, @@ -74,6 +83,8 @@ export function WorkspaceTools({ roamingRequest, onOpenClone, }: { + accountScope: string; + footerControls: ReactNode; onOpenAnalysis: (report: AnalysisReportRequest) => void; onOpenComposition?: () => void; assistantSearchTarget?: GeneratedSearchHit | null; @@ -87,6 +98,21 @@ export function WorkspaceTools({ onOpenClone?: (id: string) => void; }) { const [open, setOpen] = useState(false); + const anchor = useWorkspaceMenu((state) => state.anchor); + const closeMenu = useWorkspaceMenu((state) => state.close); + const setTaskSummary = useWorkspaceMenu((state) => state.setTasks); + const [expanded, setExpanded] = useState(false); + const lastAnchor = useRef(null); + if (anchor) lastAnchor.current = anchor; + const [panel, setPanel] = useState<'tasks' | 'assistant'>(() => { + try { + return window.localStorage.getItem(`weq:workspace-panel:${accountScope}`) === 'assistant' + ? 'assistant' + : 'tasks'; + } catch { + return 'tasks'; + } + }); const [exportAuthorization, setExportAuthorization] = useState(null); const [tool, setTool] = useState('export'); @@ -101,7 +127,6 @@ export function WorkspaceTools({ const [roamingTargets, setRoamingTargets] = useState([]); const [analysisSeed, setAnalysisSeed] = useState(0); const [roamingSeed, setRoamingSeed] = useState(0); - const [taskAnchor, setTaskAnchor] = useState(null); const accountKey = useViewState((state) => state.openedUin); const [exportDraft, setExportDraft] = useState<{ key: string; draft: ToolDraft } | null>(null); const exportRequest = useContentExportRequest(); @@ -119,6 +144,8 @@ export function WorkspaceTools({ ) return; handledContentAnalysis.current = contentAnalysisRequest; + closeMenu(); + setExpanded(false); setAnalysisFixedTarget(true); setAnalysisTarget({ ...contentAnalysisRequest.target }); setAnalysisMessageIds( @@ -127,12 +154,14 @@ export function WorkspaceTools({ setAnalysisSeed((previous) => previous + 1); setTool('analysis'); setOpen(true); - }, [accountKey, contentAnalysisRequest]); + }, [accountKey, contentAnalysisRequest, closeMenu]); const handledRoamingRequest = useRef(null); useEffect(() => { if (!accountKey || !analysisRequest || handledAnalysisRequest.current === analysisRequest) return; handledAnalysisRequest.current = analysisRequest; + closeMenu(); + setExpanded(false); setAnalysisFixedTarget(true); setAnalysisTarget({ ...analysisRequest.target }); setAnalysisMessageIds(undefined); @@ -140,19 +169,23 @@ export function WorkspaceTools({ setTool('analysis'); setAssistantSettings(false); setOpen(true); - }, [accountKey, analysisRequest]); + }, [accountKey, analysisRequest, closeMenu]); useEffect(() => { if (!accountKey || !roamingRequest || handledRoamingRequest.current === roamingRequest) return; handledRoamingRequest.current = roamingRequest; + closeMenu(); + setExpanded(false); setRoamingTargets(roamingRequest.targets.map((target) => ({ ...target }))); setRoamingSeed((previous) => previous + 1); setTool('backfill'); setAssistantSettings(false); setOpen(true); - }, [accountKey, roamingRequest]); + }, [accountKey, roamingRequest, closeMenu]); useEffect(() => { if (!accountKey || !exportRequest || handledExportRequest.current === exportRequest) return; handledExportRequest.current = exportRequest; + closeMenu(); + setExpanded(false); // 记录这次显式请求的来源选择,供「关闭后再点主按钮」复用同一面板现场。 requestedSelection.current = exportRequest.selection; setExportFixedScope(true); @@ -165,14 +198,16 @@ export function WorkspaceTools({ setTool('export'); setAssistantSettings(false); setOpen(true); - }, [accountKey, exportRequest]); + }, [accountKey, exportRequest, closeMenu]); useEffect(() => { if (assistantSearchTarget) { - setTool('assistant'); + setOpen(false); + setPanel('assistant'); + closeMenu(); + setExpanded(true); setAssistantSettings(false); - setOpen(true); } - }, [assistantSearchTarget]); + }, [assistantSearchTarget, closeMenu]); const selection = useCurrentContentSelection(); const { enabled: readTasks } = useQQReadActivity(!hidden, { onlineOnly: false }); const taskQueryOptions = { @@ -193,6 +228,35 @@ export function WorkspaceTools({ (roamingTasks.data?.scanning ? 1 : 0) + (flashTasks.data?.filter((task) => task.status === 'resolving' || task.status === 'downloading') .length ?? 0); + const failed = + (tasks.data?.filter( + (task) => + task.status === 'failed' || + task.save?.status === 'failed' || + task.save?.status === 'interrupted', + ).length ?? 0) + + cloneTasks.filter((task) => task.accountId === accountKey && task.status === 'error').length + + (roamingTasks.data?.jobs?.filter((task) => task.status === 'failed').length ?? 0) + + (flashTasks.data?.filter((task) => task.status === 'failed').length ?? 0); + useEffect(() => { + setTaskSummary(running, failed); + }, [running, failed, setTaskSummary]); + useEffect( + () => () => { + closeMenu(); + setTaskSummary(0, 0); + }, + [closeMenu, setTaskSummary], + ); + useEffect(() => { + try { + window.localStorage.setItem(`weq:workspace-panel:${accountScope}`, panel); + } catch {} + }, [accountScope, panel]); + const closeWorkspace = () => { + closeMenu(); + setExpanded(false); + }; const close = () => setOpen(false); const ctx: WorkspaceContext = workspace ?? { view: 'messages', @@ -205,6 +269,7 @@ export function WorkspaceTools({ nextSelection: ContentSelection | null | undefined, options: { fixed?: boolean; mode?: ExportMode; baselineDirectory?: string } = {}, ) => { + closeWorkspace(); setExportBaseline(options.baselineDirectory); const key = nextSelection && accountKey ? JSON.stringify([accountKey, nextSelection]) : null; // 同一份选择重新打开工具时保留面板现场(输入过的草稿、已选格式都还在)。 @@ -233,8 +298,8 @@ export function WorkspaceTools({ }; /** 主按钮:按当前界面直接进入对应工具。 */ - const select = (next: Tool, anchor?: HTMLElement) => { - if (next === 'tasks') setTaskAnchor(anchor ?? null); + const select = (next: Tool) => { + closeWorkspace(); if (next === 'export') { // 主按钮=按当前界面直接进入。注意不能把 `entry.selection`(带 selected 的对象) // 直接放进 React 依赖里:每次渲染都是新引用,会触发导出面板反复重建。 @@ -265,6 +330,7 @@ export function WorkspaceTools({ }; const leave = (action: () => void) => { close(); + closeWorkspace(); action(); }; const selectRef = useRef(select); @@ -293,26 +359,13 @@ export function WorkspaceTools({ exportDraft.draft.selection.scope.type === 'items' && exportDraft.draft.selection.scope.ids.length > 0)); - const items: FloatingToolItem[] = [ + const items = [ { id: 'export', label: '导出', - hint: entry.label, icon: , action: () => select('export'), }, - { - id: 'viewer', - label: '导出查看器', - icon: , - action: () => select('viewer'), - }, - { - id: 'tasks', - label: '任务', - icon: , - action: (anchor) => select('tasks', anchor), - }, { id: 'analysis', label: '分析与报告', @@ -320,10 +373,10 @@ export function WorkspaceTools({ action: () => select('analysis'), }, { - id: 'assistant', - label: 'WeQ 助手', - icon: , - action: () => select('assistant'), + id: 'viewer', + label: '导出查看器', + icon: , + action: () => select('viewer'), }, ]; if (onOpenComposition) @@ -335,11 +388,139 @@ export function WorkspaceTools({ }); return ( <> -
- {open && tool !== 'tasks' && tool !== 'backfill' ? ( + {open && tool !== 'backfill' ? ( tool === 'export' ? ( - ) : tool === 'assistant' ? ( - <> - {/* 助手面板自己要撑满高度:模态体是 flex 列,缺 min-height:0 时 - 输入框会被顶到可视区外(高度溢出被裁掉)。 */} - - {assistantSettings && ( -
- -
- )} - ) : tool === 'analysis' ? ( - -
-

任务

- -
- - {open && tool === 'tasks' ? ( - { - close(); - onOpenClone?.(id); - }} - /> - ) : null} - -
= [ ]; export function AppLockSection(): ReactElement { + const privacyEnabled = usePrivacyStore((state) => state.enabled); + const setPrivacy = usePrivacyStore((state) => state.set); const showError = useDialog((s) => s.showError); const confirm = useDialog((s) => s.confirm); const pushToast = useToast((s) => s.push); @@ -161,17 +164,24 @@ export function AppLockSection(): ReactElement { return (
} /> + + } + /> + + void; onSaveAll: () => void; autoSelectId?: string | null; + layout?: 'inline' | 'compact' | 'split'; + onOpenDetail?: () => void; }) { const { enabled: active } = useQQReadActivity(true, { onlineOnly: false }); const accountId = useViewState((state) => state.openedUin); @@ -218,6 +222,10 @@ export function CurrentTaskList({ }); } const ordered = orderLongTasks(items); + useEffect(() => { + if (selected !== null && !ordered.some((task) => task.id === selected) && ordered[0]) + setSelected(ordered[0].id); + }, [selected, ordered]); const selectedId = selected === null ? null @@ -228,7 +236,7 @@ export function CurrentTaskList({ const loading = loadingExports || roaming.isLoading || flash.isLoading; const hasError = exportError || roaming.error || flash.error; return ( -
+
{items.length} 项任务{running ? ` · ${running} 项进行中` : ''} @@ -258,18 +266,32 @@ export function CurrentTaskList({ {!items.length && !hasError ? (

{loading ? '读取任务中…' : '暂无任务'}

) : null} - {ordered.map((task) => ( - - setSelected(task.id === selectedId ? null : task.id)} - /> - {task.id === selectedId ? ( -
{task.detail}
- ) : null} -
- ))} +
+
+ {ordered.map((task) => ( + + { + setSelected(layout === 'inline' && task.id === selectedId ? null : task.id); + if (layout === 'compact') onOpenDetail?.(); + }} + /> + {layout === 'inline' && task.id === selectedId ? ( +
{task.detail}
+ ) : null} +
+ ))} +
+ {layout === 'split' ? ( +
+ {ordered.find((task) => task.id === selectedId)?.detail ?? ( +

请选择任务

+ )} +
+ ) : null} +
); } diff --git a/apps/desktop/src/renderer/src/components/tasks/ScheduleSettings.tsx b/apps/desktop/src/renderer/src/components/tasks/ScheduleSettings.tsx new file mode 100644 index 000000000..d4025de61 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/tasks/ScheduleSettings.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { Save } from 'lucide-react'; +import { trpc } from '../../trpc/client'; +import { useAppDialog } from '../../lib/dialogUtils'; +import { + FULL_FORMATS, + CHATLAB_FORMATS, + isBackendFormat, + type BackendFormat, + type ScheduledTask, +} from '../../views/export/types'; + +export function ScheduleSettings({ schedule }: { schedule: ScheduledTask }) { + const allowedFormats = (schedule.chatlab ? CHATLAB_FORMATS : FULL_FORMATS) + .map((item) => item.value) + .filter( + (format): format is BackendFormat | 'html' => isBackendFormat(format) || format === 'html', + ); + const dialog = useAppDialog(); + const utils = trpc.useUtils(); + const [name, setName] = useState(schedule.name); + const [cadence, setCadence] = useState(schedule.schedule); + const [formats, setFormats] = useState( + (schedule.formats?.length ? schedule.formats : [schedule.format]).filter( + (format): format is BackendFormat | 'html' => + allowedFormats.some((allowed) => allowed === format), + ), + ); + const update = trpc.account.updateSchedule.useMutation({ + onSuccess: (result) => { + if (!result) { + dialog.error('保存定时任务失败', '该计划已不存在,请刷新任务列表。'); + return; + } + void utils.account.listSchedules.invalidate(); + dialog.success('已保存', '定时任务设置已更新'); + }, + onError: (error) => dialog.error('保存定时任务失败', error.message), + }); + return ( +
{ + event.preventDefault(); + if ( + !name.trim() || + !formats.length || + (cadence.mode === 'daily' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(cadence.time)) || + (cadence.mode === 'interval' && + (!Number.isInteger(cadence.intervalHours) || + cadence.intervalHours < 1 || + cadence.intervalHours > 168)) + ) + return; + update.mutate({ + id: schedule.id, + patch: { + name: name.trim(), + format: formats[0], + formats, + ...(cadence.mode !== schedule.schedule.mode || + cadence.time !== schedule.schedule.time || + cadence.intervalHours !== schedule.schedule.intervalHours + ? { schedule: cadence } + : {}), + }, + }); + }} + > + + + {cadence.mode === 'daily' ? ( + + ) : ( + + )} +
+ 导出格式 + {allowedFormats.map((format) => ( + + ))} +
+
+ 导出会话 + {schedule.conversations.map((conversation) => ( +
{conversation.name}
+ ))} +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/src/lib/assistantWorkspace.ts b/apps/desktop/src/renderer/src/lib/assistantWorkspace.ts new file mode 100644 index 000000000..2f6315f1b --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/assistantWorkspace.ts @@ -0,0 +1,41 @@ +const key = (account: string, kind: string) => + `weq:assistant:${encodeURIComponent(account)}:${kind}`; + +export function readAssistantSession(account: string): string | null { + try { + return window.localStorage.getItem(key(account, 'selected')) || null; + } catch { + return null; + } +} + +export function rememberAssistantSession(account: string, sessionId: string | null): void { + try { + if (sessionId) window.localStorage.setItem(key(account, 'selected'), sessionId); + else window.localStorage.removeItem(key(account, 'selected')); + } catch { + /* 存储不可用时仍可在当前窗口使用。 */ + } +} + +export function readAssistantDraft(account: string, sessionId: string | null): string { + try { + return window.localStorage.getItem(key(account, `draft:${sessionId ?? 'new'}`)) ?? ''; + } catch { + return ''; + } +} + +export function rememberAssistantDraft( + account: string, + sessionId: string | null, + draft: string, +): void { + try { + const storageKey = key(account, `draft:${sessionId ?? 'new'}`); + if (draft) window.localStorage.setItem(storageKey, draft); + else window.localStorage.removeItem(storageKey); + } catch { + /* 不让浏览器配额或隐私设置打断输入。 */ + } +} diff --git a/apps/desktop/src/renderer/src/state/workspaceMenu.ts b/apps/desktop/src/renderer/src/state/workspaceMenu.ts new file mode 100644 index 000000000..3d330a86c --- /dev/null +++ b/apps/desktop/src/renderer/src/state/workspaceMenu.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand'; + +interface WorkspaceMenuState { + anchor: HTMLElement | null; + running: number; + failed: number; + toggle: (anchor: HTMLElement) => void; + close: () => void; + setTasks: (running: number, failed: number) => void; +} + +export const useWorkspaceMenu = create((set) => ({ + anchor: null, + running: 0, + failed: 0, + toggle: (anchor) => set((state) => ({ anchor: state.anchor ? null : anchor })), + close: () => set({ anchor: null }), + setTasks: (running, failed) => set({ running, failed }), +})); diff --git a/apps/desktop/src/renderer/src/styles/workspace-menu.css b/apps/desktop/src/renderer/src/styles/workspace-menu.css new file mode 100644 index 000000000..85bb7b803 --- /dev/null +++ b/apps/desktop/src/renderer/src/styles/workspace-menu.css @@ -0,0 +1,587 @@ +.weq-workspace-menu-trigger, +.weq-workspace-control { + position: relative; +} + +.weq-workspace-dot { + position: absolute; + top: 5px; + right: 5px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--destructive); +} + +.weq-workspace-count { + position: absolute; + right: -3px; + bottom: -2px; + min-width: 15px; + padding: 0 3px; + border-radius: 3px; + background: var(--weq-bg-surface); + color: var(--weq-fg-secondary); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.weq-workspace-menu.weq-modal { + display: flex; + flex-direction: column; + max-width: calc(100vw - 16px); + max-height: calc(100dvh - 24px); + padding: 0; + overflow: hidden; + border: 1px solid var(--weq-border-strong); + border-radius: 8px; + background: color-mix(in srgb, var(--weq-bg-surface) 97%, transparent); + color: var(--weq-fg-primary); + box-shadow: var(--weq-shadow-card); + letter-spacing: 0; +} + +.weq-workspace-menu.is-compact { + height: min(560px, calc(100dvh - 32px)); +} + +.weq-dialog-layer.is-anchored-popover[tabindex] > .weq-workspace-menu.is-compact { + height: min(560px, calc(100dvh - 32px)); + overflow: hidden; +} + +.weq-workspace-menu.is-expanded { + height: min(800px, calc(100dvh - 48px)); +} + +.weq-workspace-menu-head { + display: flex; + flex: none; + align-items: center; + gap: 6px; + min-height: 40px; + padding: 4px 10px; + border-bottom: 1px solid var(--weq-border-subtle); +} + +.weq-workspace-menu-head h2 { + margin: 0; + font-size: 14px; + font-weight: 600; + white-space: nowrap; +} + +.weq-workspace-menu-head small { + color: var(--weq-fg-secondary); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.weq-workspace-menu-head-actions { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +.weq-workspace-menu-head button, +.weq-workspace-control { + position: relative; + display: flex; + flex: none; + align-items: center; + justify-content: center; + gap: 3px; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--weq-fg-secondary); + cursor: pointer; +} + +.weq-workspace-menu button:hover:not(:disabled) { + background: color-mix(in srgb, var(--weq-accent-effective) 9%, transparent); +} + +.weq-workspace-menu button:focus-visible { + outline: 2px solid var(--weq-accent-effective); + outline-offset: -2px; +} + +.weq-workspace-menu button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.weq-workspace-menu-body, +.weq-menu-assistant, +.weq-assistant-workspace, +.weq-assistant-conversation, +.weq-task-workspace { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.weq-workspace-menu-body, +.weq-assistant-conversation, +.weq-task-workspace { + flex-direction: column; +} + +.weq-workspace-menu [data-retained-panel] { + height: auto !important; +} + +.weq-workspace-actions { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + flex: none; + padding: 8px; + border-block: 1px solid var(--weq-border-subtle); +} + +.weq-workspace-actions > button { + display: flex; + min-width: 0; + height: 46px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + padding: 4px 2px; + border: 1px solid var(--weq-border-strong); + border-radius: 4px; + background: transparent; + color: var(--weq-fg-primary); + font-size: 11px; + line-height: 14px; + cursor: pointer; +} + +.weq-workspace-actions > button span { + min-height: 14px; + text-align: center; + overflow-wrap: anywhere; +} + +.weq-workspace-actions > button svg { + width: 16px; + height: 16px; +} + +.weq-workspace-controls { + display: flex; + flex: none; + align-items: center; + justify-content: space-around; + gap: 8px; + min-height: 50px; + padding: 6px 14px; +} + +.weq-workspace-controls > .weq-workspace-control { + width: 32px; + height: 32px; +} + +.weq-workspace-controls .weq-rail-account-avatar { + width: 38px; + height: 38px; + padding: 1px; + border: 0; + border-radius: 50%; + background: transparent; +} + +.weq-workspace-controls .weq-rail-account-footer { + margin: 0; +} + +.weq-workspace-controls .weq-rail-account-avatar :is(img, .weq-avatar-fallback) { + width: 36px; + height: 36px; + border-radius: 50%; +} + +.weq-workspace-menu .weq-export-tool.is-tasks { + min-height: 0; + padding: 0; + overflow: hidden; +} + +.weq-task-workspace-tabs { + flex: none; + width: 280px; + max-width: 100%; + padding: 8px; +} + +.weq-current-task-columns { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; +} + +.weq-current-tasks.is-compact, +.weq-current-tasks.is-split { + overflow: hidden; +} + +.weq-current-tasks.is-compact .weq-current-task-list { + min-height: 0; + overflow-y: auto; +} + +.weq-current-tasks.is-split .weq-current-task-columns, +.weq-schedule-workspace { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + flex: 1; + min-height: 0; + overflow: hidden; +} + +.weq-current-tasks.is-split .weq-current-task-list, +.weq-schedule-workspace .weq-exp-sched-list { + min-width: 0; + min-height: 0; + overflow-y: auto; + border-right: 1px solid var(--weq-border-subtle); +} + +.weq-current-task-details, +.weq-schedule-details { + min-width: 0; + min-height: 0; + padding: 16px; + overflow-y: auto; + overflow-wrap: anywhere; +} + +.weq-current-tasks.is-split .weq-long-task-state, +.weq-current-tasks.is-compact .weq-long-task-state { + width: 80px; +} + +.weq-workspace-menu .weq-long-task progress { + height: 4px; + overflow: hidden; + appearance: none; + border: 0; + border-radius: 2px; + background: var(--muted); +} + +.weq-workspace-menu .weq-long-task progress::-webkit-progress-bar { + background: var(--muted); +} + +.weq-workspace-menu .weq-long-task progress::-webkit-progress-value { + background: var(--weq-accent-effective); +} + +.weq-workspace-menu .weq-long-task progress::-moz-progress-bar { + background: var(--weq-accent-effective); +} + +.weq-schedule-select { + display: flex; + width: 100%; + flex-direction: column; + gap: 6px; + padding: 12px; + border: 0; + border-bottom: 1px solid var(--weq-border-subtle); + background: transparent; + color: var(--weq-fg-primary); + text-align: left; + overflow-wrap: anywhere; + cursor: pointer; +} + +.weq-schedule-select.is-selected, +.weq-assistant-session-row [aria-current] { + background: color-mix(in srgb, var(--weq-accent-effective) 10%, transparent); +} + +.weq-schedule-select :is(span, small) { + font-size: 12px; + color: var(--weq-fg-secondary); +} + +.weq-schedule-settings { + display: flex; + align-items: stretch; + flex-direction: column; + gap: 16px; + margin-top: 20px; +} + +.weq-schedule-settings > label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} + +.weq-schedule-settings :is(input:not([type='checkbox']), select) { + min-width: 0; + min-height: 36px; + padding: 6px 8px; + border: 1px solid var(--weq-border-strong); + border-radius: 4px; + background: var(--weq-bg-surface); + color: var(--weq-fg-primary); + font: inherit; +} + +.weq-schedule-settings fieldset { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 0; + padding: 8px 0; + border: 0; + font-size: 13px; +} + +.weq-schedule-settings fieldset label { + display: flex; + align-items: center; + gap: 5px; +} + +.weq-schedule-settings > button { + align-self: flex-start; +} + +.weq-assistant-sessions { + width: 240px; + flex: none; + padding: 10px; + overflow-y: auto; + border-right: 1px solid var(--weq-border-subtle); +} + +.weq-assistant-sessions[hidden], +.weq-menu-assistant[hidden] { + display: none; +} + +.weq-assistant-session-row { + display: flex; + margin-top: 6px; +} + +.weq-assistant-session-row button { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + min-height: 40px; + padding: 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--weq-fg-primary); + cursor: pointer; +} + +.weq-assistant-session-row button:first-child { + flex: 1; + text-align: left; +} + +.weq-assistant-session-row button span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.weq-assistant-session-row svg { + flex: none; +} + +.weq-assistant-conversation .weq-agentlab-chat { + flex: 1; + min-width: 0; + min-height: 0; + height: 100%; +} + +.weq-assistant-conversation .weq-agentlab-head { + min-height: 44px; + padding: 6px 12px; +} + +.weq-assistant-conversation .weq-agentlab-head-left { + flex: 1; + min-width: 0; +} + +.weq-assistant-conversation .weq-agentlab-head-left > strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + +.weq-assistant-conversation .weq-agentlab-head-actions { + flex: none; +} + +.weq-agentlab-chat.is-menu-compact > .weq-agentlab-head { + display: none; +} + +.weq-agentlab-chat.is-menu-compact .weq-agentlab-transcript { + padding: 10px; +} + +.weq-agentlab-chat.is-menu-compact .weq-asst-empty { + gap: 8px; + padding: 8px; +} + +.weq-agentlab-chat.is-menu-compact .weq-asst-empty > :is(span, strong) { + display: none; +} + +.weq-agentlab-chat.is-menu-compact .weq-asst-presets > button:nth-child(n + 3) { + display: none; +} + +.weq-agentlab-chat.is-menu-compact .weq-asst-composer { + padding: 8px; +} + +.weq-asst-menu-meta { + display: flex; + flex: none; + align-items: center; + gap: 8px; + min-height: 26px; + padding: 3px 10px; + border-bottom: 1px solid var(--weq-border-subtle); + color: var(--weq-fg-secondary); + font-size: 11px; +} + +.weq-asst-menu-meta span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.weq-asst-menu-meta span:first-child { + flex: 1; +} + +.weq-asst-menu-meta span:nth-child(2) { + max-width: 40%; +} + +.weq-asst-menu-meta span:last-child { + flex: none; +} + +.weq-agentlab-chat.is-menu-compact .weq-agentlab-composer { + padding: 0; + gap: 6px; +} + +.weq-agentlab-chat.is-menu-compact .weq-agentlab-composer textarea { + min-height: 34px; + max-height: 90px; + padding: 7px 9px; + border-radius: 4px; + font-size: 13px; + line-height: 18px; +} + +.weq-agentlab-chat.is-menu-compact .weq-agentlab-composer > button { + min-width: 34px; + height: 34px; + padding: 0 8px; + border-radius: 4px; +} + +.weq-agentlab-chat.is-menu-compact .weq-asst-composer-tools { + gap: 4px; +} + +.weq-agentlab-chat.is-menu-compact .weq-asst-select { + max-width: 190px; +} + +.weq-workspace-empty { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 20px; + color: var(--weq-fg-secondary); + font-size: 13px; +} + +.weq-settings-update-mark { + display: inline-block; + width: 6px; + height: 6px; + margin-left: 8px; + flex: none; + border-radius: 50%; + background: var(--destructive); +} + +html[data-theme='dark'] .weq-workspace-menu.weq-modal { + border-color: var(--weq-border-strong); + background: color-mix(in srgb, var(--weq-bg-surface) 99%, transparent); +} + +html[data-theme='dark'] .weq-workspace-controls .weq-rail-account-avatar { + background: transparent; +} + +@media (max-width: 700px) { + .weq-current-tasks.is-split .weq-current-task-columns, + .weq-schedule-workspace { + grid-template-columns: minmax(0, 1fr); + overflow-y: auto; + } + + .weq-current-tasks.is-split .weq-current-task-list, + .weq-schedule-workspace .weq-exp-sched-list { + max-height: 210px; + overflow-y: auto; + border-right: 0; + border-bottom: 1px solid var(--weq-border-subtle); + } + + .weq-assistant-workspace:not(.is-compact) { + flex-direction: column; + } + + .weq-assistant-sessions { + width: auto; + max-height: 130px; + border-right: 0; + border-bottom: 1px solid var(--weq-border-subtle); + } +} + +@media (pointer: coarse) { + .weq-workspace-menu-head button { + min-width: 40px; + min-height: 40px; + } +} diff --git a/apps/desktop/src/renderer/src/views/ExportView.tsx b/apps/desktop/src/renderer/src/views/ExportView.tsx index a4f799406..7411dac90 100644 --- a/apps/desktop/src/renderer/src/views/ExportView.tsx +++ b/apps/desktop/src/renderer/src/views/ExportView.tsx @@ -97,7 +97,8 @@ import { GeneratedExportPane } from './export/GeneratedExportPane'; import { MailExportPane } from './export/MailExportPane'; import { QzoneScopePicker } from './export/QzoneScopePicker'; import '../styles/unified-export.css'; -import { useTabActive } from '../components/RetainedTabs'; +import { RetainedTabs, useTabActive } from '../components/RetainedTabs'; +import { ScheduleSettings } from '../components/tasks/ScheduleSettings'; interface ModeDef { id: ExportMode; @@ -337,8 +338,14 @@ export function ExportTool({ contentListRevision, onOpenClone, onAuthorizationChange, + taskLayout = 'inline', + onTaskExpand, + onNewSchedule, }: { surface?: 'export' | 'tasks'; + taskLayout?: 'inline' | 'compact' | 'split'; + onTaskExpand?: () => void; + onNewSchedule?: () => void; initialSelection?: ContentSelection; initialMode?: ExportMode; initialBaselineDirectory?: string; @@ -406,6 +413,9 @@ export function ExportTool({ initialMode === 'scheduled' ? 'scheduled' : 'now', ); const [taskTab, setTaskTab] = useState<'tasks' | 'schedules'>('tasks'); + const [selectedSchedule, setSelectedSchedule] = useState(null); + const activeScheduleId = + schedules.data?.find((item) => item.id === selectedSchedule)?.id ?? schedules.data?.[0]?.id; // 频道私聊会话列表(guild_msg.db;只在该模式下加载)。 const guildSessions = trpc.account.guildDirectListSessions.useQuery(undefined, { enabled: active && surface === 'export' && mode === 'guild', @@ -2213,140 +2223,182 @@ export function ExportTool({ {/* 聚合任务与定时计划,共用原有任务操作。 */} {surface === 'tasks' && !saveLocationTask ? ( - <> - - value={taskTab} - onChange={setTaskTab} - options={[ - { value: 'tasks', label: '当前任务' }, - { value: 'schedules', label: '定时任务' }, - ]} - /> - {taskTab === 'schedules' ? ( -
- {schedules.isLoading ? ( - - ) : schedules.isError ? ( -
- 定时任务加载失败 - +
+ {taskLayout !== 'compact' ? ( +
+ + value={taskTab} + onChange={setTaskTab} + options={[ + { value: 'tasks', label: '当前任务' }, + { value: 'schedules', label: '定时任务' }, + ]} + /> +
+ ) : null} + + {taskTab === 'schedules' && taskLayout !== 'compact' ? ( +
+
+ {onNewSchedule ? ( + + ) : null} + {schedules.isLoading ? ( + + ) : schedules.isError ? ( +
+ 定时任务加载失败 + +
+ ) : (schedules.data ?? []).length === 0 ? ( +
+ 暂无定时任务 +
+ ) : ( + (schedules.data as ScheduledTask[]).map((s) => ( + + )) + )}
- ) : (schedules.data ?? []).length === 0 ? ( -
- 暂无定时任务 +
+ + {(schedules.data as ScheduledTask[] | undefined) + ?.filter((s) => s.id === activeScheduleId) + .map((s) => ( +
+ void onToggleSchedule(s)} + onDelete={() => void onDeleteSchedule(s)} + onRunNow={() => void onRunScheduleNow(s)} + /> + +
+ ))} +
- ) : ( - (schedules.data as ScheduledTask[]).map((s) => ( - void onToggleSchedule(s)} - onDelete={() => void onDeleteSchedule(s)} - onRunNow={() => void onRunScheduleNow(s)} - /> - )) - )} -
- ) : ( - void onSaveAll()} - autoSelectId={autoSelectTaskId} - renderExport={(task) => ( - { - void client.account.finalizeExportTask - .mutate({ taskId: t.id }) - .then((ok) => { - if (!ok) - dialog.error('收尾未完成', '任务仍在暂停或收尾失败,请查看任务日志。'); - refetchTasks(); - }) - .catch((e) => dialog.error('收尾失败', String(e))); - refetchTasks(); - }} - onIncremental={(t) => { - void client.account.resumeExportTask - .mutate({ taskId: t.id, incremental: true }) - .then((ok) => { - if (!ok) dialog.error('无法增量导出', '任务仍在收尾,请稍后重试。'); - refetchTasks(); - }) - .catch((e) => dialog.error('增量导出失败', String(e))); - }} - onSkipStage={(t, stage) => { - void client.account.skipExportTaskStage - .mutate({ taskId: t.id, stage }) - .then((ok) => { - if (!ok) dialog.error('无法跳过', '此项已完成或任务状态已变化。'); - refetchTasks(); - }) - .catch((e) => dialog.error('跳过失败', String(e))); - }} - onResume={(t) => { - void client.account.resumeExportTask - .mutate({ taskId: t.id }) - .then((ok) => { - if (!ok) - dialog.error( - t.resumeMode === 'restart' ? '无法重新执行' : '无法继续', - t.resumeMode === 'restart' - ? '任务仍在收尾,请稍后重试。' - : '任务仍在收尾,或没有可验证的断点。', - ); - refetchTasks(); - }) - .catch((e) => dialog.error('续作失败', String(e))); - }} - onReport={(t) => { - void client.account.revealExportReport - .mutate({ taskId: t.id }) - .catch((e) => dialog.error('打开报告失败', String(e))); - }} - onCancel={onCancel} - onDownload={(t) => void onDownload(t)} - onOpenSaved={ - !IS_WEB - ? (t, reveal) => { - const account = exportAccount.data; - if (!account?.uin || !account.configId) return; - void client.account.openSavedExportOutput - .mutate({ - taskId: t.id, - reveal, - expectedAccountUin: account.uin, - expectedAccountConfigId: account.configId, - }) - .catch((error) => - dialog.error( - '打开保存结果失败', - error instanceof Error ? error.message : String(error), - ), +
+ ) : ( + { + setTaskTab('tasks'); + onTaskExpand?.(); + }} + exports={uiTasks} + exportError={tasks.error?.message} + loadingExports={tasks.isLoading} + onOpenClone={onOpenClone} + onSaveAll={() => void onSaveAll()} + autoSelectId={autoSelectTaskId} + renderExport={(task) => ( + { + void client.account.finalizeExportTask + .mutate({ taskId: t.id }) + .then((ok) => { + if (!ok) + dialog.error('收尾未完成', '任务仍在暂停或收尾失败,请查看任务日志。'); + refetchTasks(); + }) + .catch((e) => dialog.error('收尾失败', String(e))); + refetchTasks(); + }} + onIncremental={(t) => { + void client.account.resumeExportTask + .mutate({ taskId: t.id, incremental: true }) + .then((ok) => { + if (!ok) dialog.error('无法增量导出', '任务仍在收尾,请稍后重试。'); + refetchTasks(); + }) + .catch((e) => dialog.error('增量导出失败', String(e))); + }} + onSkipStage={(t, stage) => { + void client.account.skipExportTaskStage + .mutate({ taskId: t.id, stage }) + .then((ok) => { + if (!ok) dialog.error('无法跳过', '此项已完成或任务状态已变化。'); + refetchTasks(); + }) + .catch((e) => dialog.error('跳过失败', String(e))); + }} + onResume={(t) => { + void client.account.resumeExportTask + .mutate({ taskId: t.id }) + .then((ok) => { + if (!ok) + dialog.error( + t.resumeMode === 'restart' ? '无法重新执行' : '无法继续', + t.resumeMode === 'restart' + ? '任务仍在收尾,请稍后重试。' + : '任务仍在收尾,或没有可验证的断点。', ); - } - : undefined - } - onBrowserDownload={IS_WEB ? (t) => void downloadInBrowser(t) : undefined} - onDelete={onDelete} - onShowFailures={(t, failures) => setFailureView({ name: t.name, failures })} - /> - )} - /> - )} - + refetchTasks(); + }) + .catch((e) => dialog.error('续作失败', String(e))); + }} + onReport={(t) => { + void client.account.revealExportReport + .mutate({ taskId: t.id }) + .catch((e) => dialog.error('打开报告失败', String(e))); + }} + onCancel={onCancel} + onDownload={(t) => void onDownload(t)} + onOpenSaved={ + !IS_WEB + ? (t, reveal) => { + const account = exportAccount.data; + if (!account?.uin || !account.configId) return; + void client.account.openSavedExportOutput + .mutate({ + taskId: t.id, + reveal, + expectedAccountUin: account.uin, + expectedAccountConfigId: account.configId, + }) + .catch((error) => + dialog.error( + '打开保存结果失败', + error instanceof Error ? error.message : String(error), + ), + ); + } + : undefined + } + onBrowserDownload={IS_WEB ? (t) => void downloadInBrowser(t) : undefined} + onDelete={onDelete} + onShowFailures={(t, failures) => setFailureView({ name: t.name, failures })} + /> + )} + /> + )} +
+
) : null} {failureView ? ( @@ -2746,8 +2798,14 @@ function CollectionScope({ function fmtRunAt(sec: number | null): string { if (sec == null) return '—'; const d = new Date(sec * 1000); - const pad = (n: number): string => n.toString().padStart(2, '0'); - return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + return new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(d); } /** Compact relative-time hint for the "下次运行" label. */ diff --git a/apps/desktop/src/renderer/src/views/MainView.tsx b/apps/desktop/src/renderer/src/views/MainView.tsx index 81ca0b756..15ebd1252 100644 --- a/apps/desktop/src/renderer/src/views/MainView.tsx +++ b/apps/desktop/src/renderer/src/views/MainView.tsx @@ -109,6 +109,7 @@ import { useGroupMemberResolver } from '../hooks/useGroupMemberResolver'; import { useGroupMemberSearch } from '../hooks/useGroupMemberSearch'; import { useDressSkin } from '../hooks/useDressSkin'; import { RailAccountFooter } from '../components/RailAccountFooter'; +import { WorkspaceMenuButton, WorkspaceSettingsButton } from '../components/WorkspaceMenuButton'; import { SettingsDialog, type SectionId as SettingsDialogSectionId, @@ -9064,15 +9065,7 @@ export function MainView(): ReactElement { }} mailBadgeCount={mailPreferences.data?.showUnread === false ? 0 : mailUnread} showTools={false} - railFooterContent={(settingsButton: React.ReactNode) => ( - - {settingsButton} - - )} + railFooterContent={() => } friendNoticeCount={friendNoticeCount} groupNoticeCount={groupNoticeCount} onViewChange={(view) => { @@ -9617,43 +9610,66 @@ export function MainView(): ReactElement { setSettingsOpen(true); }} /> - { - flushAllDirtyDrafts(); - setCloneSearchTarget(null); - setVirtualOpen({ kind: 'persona', id, revision: Date.now() }); - shell.switchView('agentlab'); - }} - currentConversation={shell.view === 'messages' ? activeConversation : null} - workspace={{ - view: shell.view, - conversation: shell.view === 'messages' ? activeConversation : null, - officialFeed: shell.view === 'messages' ? arkFeedState : null, - contact: shell.view === 'contacts' ? (shell.selectedContact ?? null) : null, - groupConversation: - shell.view === 'contacts' ? (shell.selectedGroupConversation ?? null) : null, - spaceUin: shell.view === 'qzone' ? currentQzoneUin : null, - virtual: - shell.view === 'agentlab' && virtualOpen - ? { - kind: virtualOpen.kind, - ownerId: virtualOpen.id, - name: - virtualRows.find( - (row) => row.kind === virtualOpen.kind && row.ownerId === virtualOpen.id, - )?.name ?? '', - } - : null, - guildNodeId: shell.view === 'channel' ? activeGuildNodeId : null, - }} - analysisRequest={analysisRequest} - roamingRequest={roamingRequest} - onOpenComposition={compositionWindow.open} - assistantSearchTarget={assistantSearchTarget} - searchRequest={toolSearchRequest} - onOpenAnalysis={(request) => setAnalysisReports((previous) => [...previous, request])} - /> + { + setDressUpOpen(false); + setMaintenanceTool(null); + setSettingsSection(undefined); + setSettingsOpen(true); + }} + /> + } + > + {(controls) => ( + { + flushAllDirtyDrafts(); + setCloneSearchTarget(null); + setVirtualOpen({ kind: 'persona', id, revision: Date.now() }); + shell.switchView('agentlab'); + }} + currentConversation={shell.view === 'messages' ? activeConversation : null} + workspace={{ + view: shell.view, + conversation: shell.view === 'messages' ? activeConversation : null, + officialFeed: shell.view === 'messages' ? arkFeedState : null, + contact: shell.view === 'contacts' ? (shell.selectedContact ?? null) : null, + groupConversation: + shell.view === 'contacts' ? (shell.selectedGroupConversation ?? null) : null, + spaceUin: shell.view === 'qzone' ? currentQzoneUin : null, + virtual: + shell.view === 'agentlab' && virtualOpen + ? { + kind: virtualOpen.kind, + ownerId: virtualOpen.id, + name: + virtualRows.find( + (row) => + row.kind === virtualOpen.kind && row.ownerId === virtualOpen.id, + )?.name ?? '', + } + : null, + guildNodeId: shell.view === 'channel' ? activeGuildNodeId : null, + }} + analysisRequest={analysisRequest} + roamingRequest={roamingRequest} + onOpenComposition={compositionWindow.open} + assistantSearchTarget={assistantSearchTarget} + searchRequest={toolSearchRequest} + onOpenAnalysis={(request) => + setAnalysisReports((previous) => [...previous, request]) + } + /> + )} + ({ + sessionId: readAssistantSession(accountScope), + key: 'initial', + searchTarget: undefined as GeneratedSearchHit | undefined, + })); + const sequence = useRef(0); + const currentSelection = useRef(selection); + currentSelection.current = selection; + const keys = useRef(new Map()); + const appliedSearch = useRef(undefined); + const models = useMemo( + () => + (providers.data ?? []).flatMap((provider) => + provider.models + .filter((model) => model.capabilities.includes('chat')) + .map((model) => ({ + key: `${provider.id}::${model.id}`, + providerId: provider.id, + model: model.id, + label: `${provider.name} · ${model.label ?? model.id}`, + })), + ), + [providers.data], + ); + + function select(sessionId: string | null, target?: GeneratedSearchHit) { + if (sessionId === selection.sessionId && !target) return; + const nextKey = sessionId ? keys.current.get(sessionId) : undefined; + const panelKey = nextKey ?? `session-${++sequence.current}`; + if (sessionId) keys.current.set(sessionId, panelKey); + rememberAssistantSession(accountScope, sessionId); + setSelection({ sessionId, key: panelKey, searchTarget: target }); + } + function newSession() { + rememberAssistantDraft(accountScope, null, ''); + rememberAssistantSession(accountScope, null); + setSelection({ sessionId: null, key: `draft-${++sequence.current}`, searchTarget: undefined }); + } + useEffect(() => { + if (selection.sessionId) keys.current.set(selection.sessionId, selection.key); + }, [selection.sessionId, selection.key]); + useEffect(() => { + if ( + searchTarget?.kind !== 'assistant' || + !searchTarget.sessionId || + appliedSearch.current === searchTarget + ) + return; + appliedSearch.current = searchTarget; + const sessionId = searchTarget.sessionId; + const panelKey = keys.current.get(sessionId) ?? `search-${++sequence.current}`; + keys.current.set(sessionId, panelKey); + rememberAssistantSession(accountScope, sessionId); + setSelection({ sessionId, key: panelKey, searchTarget }); + }, [accountScope, searchTarget]); + useEffect(() => { + if ( + !sessions.isSuccess || + sessions.isFetching || + !selection.sessionId || + sessions.data.some((session) => session.id === selection.sessionId) + ) + return; + rememberAssistantSession(accountScope, null); + setSelection({ sessionId: null, key: `draft-${++sequence.current}`, searchTarget: undefined }); + }, [sessions.data, sessions.isSuccess, sessions.isFetching, selection.sessionId, accountScope]); + + async function deleteSession(sessionId: string) { + if ( + !(await dialog.confirm('删除会话', '确认删除这段对话?删除后无法恢复。', { + okLabel: '删除', + tone: 'warning', + })) + ) + return; + try { + await remove.mutateAsync({ sessionId }); + rememberAssistantDraft(accountScope, sessionId, ''); + if (selection.sessionId === sessionId) select(null); + await utils.account.listAssistantSessions.invalidate(); + } catch (error) { + dialog.error('删除会话失败', error instanceof Error ? error.message : String(error)); + } + } + const ready = + sessions.isSuccess && + (!selection.sessionId || sessions.data.some((session) => session.id === selection.sessionId)); + return ( +
+ +
+ {!ready ? ( +
+ {sessions.error ? ( + <> + 读取助手会话失败 + + + ) : ( + '读取会话中…' + )} +
+ ) : null} + + {ready ? ( + session.id === selection.sessionId)?.title ?? + '新会话' + } + onBack={newSession} + onSessionCreated={(id) => { + keys.current.set(id, selection.key); + if (currentSelection.current.key !== selection.key) return; + rememberAssistantSession(accountScope, id); + setSelection((current) => + current.key === selection.key ? { ...current, sessionId: id } : current, + ); + }} + /> + ) : null} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/views/agentlab/AssistantPanel.tsx b/apps/desktop/src/renderer/src/views/agentlab/AssistantPanel.tsx index cc9a0a334..98cc8ddcd 100644 --- a/apps/desktop/src/renderer/src/views/agentlab/AssistantPanel.tsx +++ b/apps/desktop/src/renderer/src/views/agentlab/AssistantPanel.tsx @@ -32,6 +32,7 @@ import { Settings, Sparkles, Square, + Trash2, } from 'lucide-react'; import { trpc, client } from '../../trpc/client'; import { useAppDialog } from '../../lib/dialogUtils'; @@ -52,6 +53,7 @@ import type { FlatModels } from './NewCloneModal'; import type { GeneratedSearchHit } from '../../components/search/types'; import { GeneratedSearchNotice, useGeneratedSearchJump } from './messageSearch'; import { useGeneratedSelection } from './useGeneratedSelection'; +import { readAssistantDraft, rememberAssistantDraft } from '../../lib/assistantWorkspace'; interface Turn { id?: string; @@ -466,6 +468,9 @@ export function AssistantPanel({ chatModels, onBack, onSessionCreated, + compact = false, + draftAccount, + workspaceTitle, }: { /** 真实会话 id;`null` 表示草稿会话——用户真正发第一条消息时才落库。 */ sessionId: string | null; @@ -474,18 +479,22 @@ export function AssistantPanel({ onBack: () => void; /** 草稿会话首次发送、后端建好真会话后回调(父组件据此刷新列表 + 更新选中态)。 */ onSessionCreated: (sessionId: string) => void; + compact?: boolean; + draftAccount?: string; + workspaceTitle?: string; }): ReactElement { const dialog = useAppDialog(); const utils = trpc.useUtils(); // 挂载时快照初始 id:草稿(null) 与既有会话在本组件生命周期内身份固定, // 后续父组件把草稿升级成真会话(sessionId 由 null 变真)不重新拉历史,避免闪断。 const initialSessionId = useRef(sessionId).current; + const conversationSessionId = searchTarget?.sessionId ?? initialSessionId; // 可变的当前会话 id:草稿首次发送时被写成真 id,之后所有读写都走它。 const sessionIdRef = useRef(sessionId); const conversation = trpc.account.getAssistantConversation.useQuery( - { sessionId: initialSessionId ?? '' }, + { sessionId: conversationSessionId ?? '' }, { - enabled: !!initialSessionId, + enabled: !!conversationSessionId, ...(searchTarget ? { staleTime: 0, refetchOnMount: 'always' as const } : {}), }, ); @@ -499,12 +508,17 @@ export function AssistantPanel({ const saveConfig = trpc.account.setAssistantConfig.useMutation(); const [turns, setTurns] = useState([]); - const [input, setInput] = useState(''); + const [input, setInput] = useState(() => + draftAccount ? readAssistantDraft(draftAccount, sessionId) : '', + ); const [settingsOpen, setSettingsOpen] = useState(false); // 已请求停止、正在等后端收尾(在途 LLM 请求要等它返回/被 signal 掐断才 emit aborted)。 const [stopping, setStopping] = useState(false); // 当前真实会话 id(草稿升级后跟随更新;resume 查询依赖它,不能用挂载时的快照)。 const [currentSessionId, setCurrentSessionId] = useState(sessionId); + useEffect(() => { + if (draftAccount) rememberAssistantDraft(draftAccount, currentSessionId, input); + }, [draftAccount, currentSessionId, input]); const seeded = useRef(false); const lastPersisted = useRef(conversation.data); const runIdRef = useRef(null); @@ -513,6 +527,12 @@ export function AssistantPanel({ /** 是否贴底跟随:用户往上翻阅历史时不抢滚动条,回到底部附近才恢复。 */ const stickRef = useRef(true); const [searchMessageId, setSearchMessageId] = useState(searchTarget?.messageId ?? null); + useEffect(() => { + if (!searchTarget) return; + setSearchMessageId(searchTarget.messageId); + if (conversationSessionId) + void utils.account.getAssistantConversation.invalidate({ sessionId: conversationSessionId }); + }, [searchTarget, conversationSessionId, utils]); const missingSearch = useGeneratedSearchJump( transcriptRef, searchMessageId, @@ -543,6 +563,12 @@ export function AssistantPanel({ : ''; const effort: EffortValue = assistantConfig.data?.reasoningEffort ?? 'medium'; const modelGroups = buildModelGroups(chatModels); + const currentModel = chatModels.find((model) => model.key === modelSel); + const modelTitle = currentModel?.label ?? assistantConfig.data?.model?.model ?? '未选择模型'; + const modelName = + currentModel?.label.split(' · ').slice(1).join(' · ') || + assistantConfig.data?.model?.model || + '未选择模型'; /** 让当前会话的持久化对话缓存失效(会话 id 走 ref,草稿升级后仍指向真会话)。 */ function invalidateConversation(): void { @@ -653,6 +679,16 @@ export function AssistantPanel({ const el = transcriptRef.current; if (el) stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48; } + useLayoutEffect(() => { + const element = transcriptRef.current; + if (!element) return; + const observer = new ResizeObserver(() => { + if (element.clientHeight > 0 && stickRef.current && !searchMessageId) + element.scrollTop = element.scrollHeight; + }); + observer.observe(element); + return () => observer.disconnect(); + }, [searchMessageId]); async function onSend(preset?: string): Promise { setSearchMessageId(null); @@ -663,9 +699,11 @@ export function AssistantPanel({ if (!assistantConfig.data?.model) { await dialog.confirm( '还没配置聊天模型', - chatModels.length === 0 - ? '请先在 AgentLab 里添加一个带「聊天」能力的模型,再回到助手这里选择它。' - : '请先在输入框上方选择一个聊天模型。', + compact + ? '请先展开助手面板配置并选择聊天模型。' + : chatModels.length === 0 + ? '请先在 AgentLab 里添加一个带「聊天」能力的模型,再回到助手这里选择它。' + : '请先在输入框上方选择一个聊天模型。', { okLabel: '知道了', tone: 'warning' }, ); return; @@ -783,41 +821,63 @@ export function AssistantPanel({ } return ( -
+
- -
- WeQ 助手 - 把问题交给它:会自己查聊天记录、找联系人、多轮推进直到给出结论。 -
+ {draftAccount ? ( + {workspaceTitle ?? '新会话'} + ) : ( + <> + +
+ WeQ 助手 + 把问题交给它:会自己查聊天记录、找联系人、多轮推进直到给出结论。 +
+ + )}
{selection.control}
+ {compact ? ( +
+ {workspaceTitle ?? '新会话'} + {modelName} + {EFFORT_OPTIONS.find((option) => option.value === effort)?.label ?? effort} +
+ ) : null} + {resumable ? (
@@ -852,26 +912,28 @@ export function AssistantPanel({ {searchMessageId && conversation.isFetching ? (
加载记录中…
) : turns.length === 0 ? ( -
- - - - 把任务交给 WeQ 助手 - 它会自己查聊天记录、找联系人、多轮推进直到给出结论。试试: -
- {PRESET_QUESTIONS.map((q) => ( - - ))} + compact ? null : ( +
+ + + + 把任务交给 WeQ 助手 + 它会自己查聊天记录、找联系人、多轮推进直到给出结论。试试: +
+ {PRESET_QUESTIONS.map((q) => ( + + ))} +
-
+ ) ) : ( turns.map((t, i) => (
-
- } - title="聊天模型" - placeholder="选择模型…" - value={modelSel} - groups={modelGroups} - disabled={busy || saveConfig.isLoading} - onChange={(v) => void onQuickConfig({ modelKey: v })} - /> - } - title="思考等级" - placeholder="思考等级" - value={effort} - groups={[ - { - label: '', - options: EFFORT_OPTIONS.map((o) => ({ value: o.value, label: o.label })), - }, - ]} - disabled={busy || saveConfig.isLoading} - onChange={(v) => void onQuickConfig({ effort: v as EffortValue })} - /> -
+ {!compact ? ( +
+ } + title="聊天模型" + placeholder="选择模型…" + value={modelSel} + groups={modelGroups} + disabled={busy || saveConfig.isLoading} + onChange={(v) => void onQuickConfig({ modelKey: v })} + /> + } + title="思考等级" + placeholder="思考等级" + value={effort} + groups={[ + { + label: '', + options: EFFORT_OPTIONS.map((o) => ({ value: o.value, label: o.label })), + }, + ]} + disabled={busy || saveConfig.isLoading} + onChange={(v) => void onQuickConfig({ effort: v as EffortValue })} + /> +
+ ) : null}