diff --git a/src/main/index.ts b/src/main/index.ts index 4cd895e..ee92840 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,7 +14,8 @@ import { Menu, Tray, dialog, - protocol + protocol, + screen } from 'electron' import { dirname, extname, join } from 'path' import { existsSync, promises as fsPromises } from 'fs' @@ -504,9 +505,29 @@ async function createLocalMediaResponse(request: Request, filePath: string): Pro function createWindow(): void { // 创建浏览器窗口 + /* + * 初始尺寸按当前屏幕工作区的 60% 计算,不再用固定 1400×800。 + * 两个夹逼都是必要的: + * - 下限:小屏上 60% 会算出比原固定值更小的窗口(1920×1080 的工作区高度 + * 只有 ~985,60% 才 591 高),反而比改动前更糟;高度下限取 900, + * 即「高度拉大一点」的诉求。 + * - 上限:不能超过工作区本身,否则初始尺寸会把标题栏顶出屏幕。 + */ + const { workAreaSize } = screen.getPrimaryDisplay() + const initialWidth = Math.min( + Math.max(Math.round(workAreaSize.width * 0.6), 1400), + workAreaSize.width + ) + const initialHeight = Math.min( + Math.max(Math.round(workAreaSize.height * 0.6), 900), + workAreaSize.height + ) const mainWindow = new BrowserWindow({ - width: 1400, - height: 800, + width: initialWidth, + height: initialHeight, + minWidth: 960, + minHeight: 640, + center: true, show: false, autoHideMenuBar: true, icon: appIconPath, diff --git a/src/main/services/group-exit-monitor-service.ts b/src/main/services/group-exit-monitor-service.ts index 98ca774..aa156ee 100644 --- a/src/main/services/group-exit-monitor-service.ts +++ b/src/main/services/group-exit-monitor-service.ts @@ -112,8 +112,9 @@ class GroupExitMonitorService { getState(): GroupExitMonitorState { this.ensureLoaded() return { + // this.events 恒为「新在前」,所以这里取到的就是**最新**的 MAX_EVENTS 条。 events: this.events.slice(0, MAX_EVENTS), - /** 永久保留的事件总数(`events` 只是最近一批)。 */ + /** 永久保留的事件总数(`events` 只是最新的一批)。 */ totalEventCount: this.events.length, enabled: this.enabled, running: this.enabled && this.active && chat.isReady(), @@ -794,10 +795,16 @@ class GroupExitMonitorService { const legacy = normalizeEvents(stored.events) if (legacy.length && !fromDisk.length) { // 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。 - this.rewriteEventsToDisk(legacy) - this.events = legacy + // 落盘按时间**升序**(旧 → 新),与之后 append 的方向一致,避免在 + // append-only 文件开头留下一段方向相反的旧历史(历史行序错乱的来源)。 + this.rewriteEventsToDisk( + [...legacy].sort((left, right) => left.detectedAt - right.detectedAt) + ) + this.events = sortEventsNewestFirst(legacy) } else { - this.events = normalizeEvents(fromDisk) + // 磁盘行序不保证时间有序(迁移段与追加段方向相反),读回后必须显式重建 + // 「新在前」这个内存不变量,否则列表顶部会恒为最旧的一批。 + this.events = sortEventsNewestFirst(normalizeEvents(fromDisk)) } this.actionGateway.registerMemberEvents?.(this.events) this.lastReadAt = Number(stored.lastReadAt) || 0 @@ -817,7 +824,7 @@ class GroupExitMonitorService { } catch { // 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里, // 不该被状态文件的问题连累,仍然读回来。 - this.events = normalizeEvents(this.readEventsFromDisk()) + this.events = sortEventsNewestFirst(normalizeEvents(this.readEventsFromDisk())) this.enabled = true this.lastReadAt = 0 this.monitorSelectionConfigured = true @@ -1053,6 +1060,21 @@ function normalizeEvents( return normalized } +/** + * 事件在内存里恒定保持「**新在前**」。 + * + * 这个不变量有三个依赖方:`recordExit` 的 `[event, ...this.events]` 写入方向、 + * `listEvents()` 的 `.reverse()`(它假定内存是倒序,反转后得到升序)、 + * 以及 `getState()` 的 `slice(0, MAX_EVENTS)`(要求取到的是**最新**的一批)。 + * + * 必须显式重建它:磁盘是 append-only,行序由「迁移写入的历史 + 之后追加的新事件」 + * 决定,两段方向相反,整体不保证时间有序。直接信任文件行序会让列表顶部恒为最旧的 + * 一批,并让 `slice(0, MAX_EVENTS)` 恰好把最新的事件截掉。 + */ +function sortEventsNewestFirst(events: GroupExitMonitorEvent[]): GroupExitMonitorEvent[] { + return [...events].sort((left, right) => right.detectedAt - left.detectedAt) +} + function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus { const status = String(value || '').trim() return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed' diff --git a/src/renderer/src/styles/_tokens.scss b/src/renderer/src/styles/_tokens.scss index e4146a3..fc162fa 100644 --- a/src/renderer/src/styles/_tokens.scss +++ b/src/renderer/src/styles/_tokens.scss @@ -18,7 +18,7 @@ --wxex-danger: hsl(var(--tm-destructive)); --wxex-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; - --wxex-nav-width: 76px; + --wxex-nav-width: 88px; --wxex-shell-content-top: 12px; --wxex-radius-sm: 4px; --wxex-radius-md: 8px; diff --git a/src/renderer/src/styles/foundation.scss b/src/renderer/src/styles/foundation.scss index 5e9f42a..03231ad 100644 --- a/src/renderer/src/styles/foundation.scss +++ b/src/renderer/src/styles/foundation.scss @@ -90,6 +90,38 @@ body { width: 100%; margin-top: 18px; -webkit-app-region: no-drag; + /* + * 导航项是固定高(58px × 9 项 + gap ≈ 570px),窗口高度不足时外层 + * `.app-primary-rail` 是 overflow: hidden,溢出的按钮会被直接裁掉、点不到。 + * `min-height: 0` 是让本行能在垂直方向收缩(flex 子项默认 min-height: auto, + * 会拒绝收缩到内容高度以下),否则 overflow-y 不会生效。 + * `overflow-x: hidden` 确保不产生横向滚动条。 + */ + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + /* + * 刻意**不用** `scrollbar-gutter: stable`(项目其他滚动容器在用): + * 预留滚动条槽位会永久吃掉 ~15px,把最宽 48px 的「问问微信」标签裁掉。 + * 这里改用自定义细滚动条 —— classic 默认滚动条在这个窄列里非常显眼。 + * 注意**不要**再设置 `scrollbar-width`:标准属性一旦存在会覆盖 `::-webkit-scrollbar`。 + */ + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.16); + border-radius: 2px; + + &:hover { + background: rgba(0, 0, 0, 0.28); + } + } } .primary-nav-item { diff --git a/src/renderer/src/styles/settings-preferences.scss b/src/renderer/src/styles/settings-preferences.scss index 7efccc6..7d6f3c1 100644 --- a/src/renderer/src/styles/settings-preferences.scss +++ b/src/renderer/src/styles/settings-preferences.scss @@ -669,7 +669,7 @@ .app-shell { &.is-compact { - --wxex-nav-width: 68px; + --wxex-nav-width: 80px; --wxex-shell-content-top: 8px; } } diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index 9473668..72d7079 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -1322,7 +1322,11 @@ for (const channel of [ app.whenReady().then(() => { const window = new BrowserWindow({ - // Keep the E2E window aligned with createWindow() in src/main/index.ts. + /* + * E2E 窗口**刻意固定** 1400×800,不再跟随 `createWindow()` + * (后者已改为按屏幕工作区 60% 计算初始尺寸):visual 基线是按这个尺寸 + * 评审入库的,若跟随真实逻辑,截图尺寸会随 runner 屏幕浮动、基线永远对不上。 + */ width: 1400, height: 800, show: false, diff --git a/tests/unit/group-exit-monitor-service.test.ts b/tests/unit/group-exit-monitor-service.test.ts index 20b2931..d0e37fa 100644 --- a/tests/unit/group-exit-monitor-service.test.ts +++ b/tests/unit/group-exit-monitor-service.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdtempSync, readFileSync, readJsonSync, rmSync, writeJsonSync } from 'fs-extra' +import { + existsSync, + mkdtempSync, + readFileSync, + readJsonSync, + rmSync, + writeFileSync, + writeJsonSync +} from 'fs-extra' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -49,6 +57,37 @@ function installGroupDb(): void { mocks.chat.getChatDb.mockReturnValue({ getWcdb4Client: () => client }) } +/** 写真实形态的 append-only 事件文件:一行一条,文件顺序即传入顺序。 */ +function writePersistedEvents(detectedAtList: number[]): void { + writeFileSync( + join(mocks.userData, 'group-exit-monitor-events.jsonl'), + detectedAtList + .map((detectedAt, index) => `${JSON.stringify(persistedEvent(index, detectedAt))}\n`) + .join(''), + 'utf8' + ) +} + +/** 一条能通过 normalizeEvents 校验的持久化事件(人数必须是净减少)。 */ +function persistedEvent(index: number, detectedAt: number): Record { + return { + id: `room@chatroom:wxid_member_${index}:${detectedAt}:${index}`, + contactId: 'room@chatroom-md5', + roomId: 'room@chatroom', + groupName: '测试群', + memberWxid: `wxid_member_${index}`, + memberName: `成员${index}`, + wechatName: `成员${index}`, + groupRemark: '', + contactRemark: '', + previousCount: 40 + index, + currentCount: 39 + index, + delta: -1, + message: `成员${index}退出了测试群`, + detectedAt + } +} + function writeBaseline( snapshots: Array<{ roomId: string @@ -820,4 +859,47 @@ describe('GroupExitMonitorService', () => { expect(mocks.sender.send).toHaveBeenCalledOnce() expect(service.getState().events).toHaveLength(1) }) + + /* + * 回归:append-only 文件的物理行序不保证时间有序 —— 迁移写入的一段是倒序 + * (新 → 旧),之后 append 的新事件是正序。内存必须显式重建「新在前」, + * 否则列表顶部恒为最旧的一批(实测:09-20 的事件一直占第一条, + * 09-21~09-23 的 46 条被排到第 211 位之后,看起来像「新事件全丢了」)。 + */ + it('returns newest events first even when the append-only file mixes both directions', () => { + const base = Date.parse('2026-09-20T19:00:00+08:00') + // 迁移段:倒序(新 → 旧) + const migrated = [base, base - 60_000, base - 120_000] + // 追加段:正序(旧 → 新) + const appended = [base + 86_400_000, base + 172_800_000] + writeBaseline([]) + writePersistedEvents([...migrated, ...appended]) + + const state = new GroupExitMonitorService().getState() + + expect(state.events.map((event) => event.detectedAt)).toEqual( + [...migrated, ...appended].sort((left, right) => right - left) + ) + // 修复前这里会是迁移段的首条(base),正是用户看到的「顶部恒为 09-20」。 + expect(state.events[0].detectedAt).toBe(base + 172_800_000) + }) + + /* + * 回归:state 回传上限必须截「最新」而不是「最旧」。 + * 磁盘是 append-only,新事件在文件末尾;未排序时 slice(0, 500) 会恰好把 + * 最新的事件全部挡在界面之外(按 11 条/天估算约 2026-10-16 触发)。 + */ + it('keeps the newest events when history exceeds the state payload limit', () => { + const base = Date.parse('2026-09-20T00:00:00+08:00') + const total = 520 + writeBaseline([]) + writePersistedEvents(Array.from({ length: total }, (_, index) => base + index * 60_000)) + + const state = new GroupExitMonitorService().getState() + + expect(state.totalEventCount).toBe(total) + expect(state.events).toHaveLength(500) + expect(state.events[0].detectedAt).toBe(base + (total - 1) * 60_000) + expect(state.events[state.events.length - 1].detectedAt).toBe(base + (total - 500) * 60_000) + }) })