fix: 修复退群监控列表错误, 界面左侧滚动条

This commit is contained in:
Wxw-Gu
2026-09-24 10:36:10 +08:00
parent 4b83aaed61
commit 77a6912ade
7 changed files with 173 additions and 12 deletions
+24 -3
View File
@@ -14,7 +14,8 @@ import {
Menu, Menu,
Tray, Tray,
dialog, dialog,
protocol protocol,
screen
} from 'electron' } from 'electron'
import { dirname, extname, join } from 'path' import { dirname, extname, join } from 'path'
import { existsSync, promises as fsPromises } from 'fs' import { existsSync, promises as fsPromises } from 'fs'
@@ -504,9 +505,29 @@ async function createLocalMediaResponse(request: Request, filePath: string): Pro
function createWindow(): void { 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({ const mainWindow = new BrowserWindow({
width: 1400, width: initialWidth,
height: 800, height: initialHeight,
minWidth: 960,
minHeight: 640,
center: true,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
icon: appIconPath, icon: appIconPath,
@@ -112,8 +112,9 @@ class GroupExitMonitorService {
getState(): GroupExitMonitorState { getState(): GroupExitMonitorState {
this.ensureLoaded() this.ensureLoaded()
return { return {
// this.events 恒为「新在前」,所以这里取到的就是**最新**的 MAX_EVENTS 条。
events: this.events.slice(0, MAX_EVENTS), events: this.events.slice(0, MAX_EVENTS),
/** 永久保留的事件总数(`events` 只是最近一批)。 */ /** 永久保留的事件总数(`events` 只是最新的一批)。 */
totalEventCount: this.events.length, totalEventCount: this.events.length,
enabled: this.enabled, enabled: this.enabled,
running: this.enabled && this.active && chat.isReady(), running: this.enabled && this.active && chat.isReady(),
@@ -794,10 +795,16 @@ class GroupExitMonitorService {
const legacy = normalizeEvents(stored.events) const legacy = normalizeEvents(stored.events)
if (legacy.length && !fromDisk.length) { if (legacy.length && !fromDisk.length) {
// 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。 // 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。
this.rewriteEventsToDisk(legacy) // 落盘按时间**升序**(旧 → 新),与之后 append 的方向一致,避免在
this.events = legacy // append-only 文件开头留下一段方向相反的旧历史(历史行序错乱的来源)。
this.rewriteEventsToDisk(
[...legacy].sort((left, right) => left.detectedAt - right.detectedAt)
)
this.events = sortEventsNewestFirst(legacy)
} else { } else {
this.events = normalizeEvents(fromDisk) // 磁盘行序不保证时间有序(迁移段与追加段方向相反),读回后必须显式重建
// 「新在前」这个内存不变量,否则列表顶部会恒为最旧的一批。
this.events = sortEventsNewestFirst(normalizeEvents(fromDisk))
} }
this.actionGateway.registerMemberEvents?.(this.events) this.actionGateway.registerMemberEvents?.(this.events)
this.lastReadAt = Number(stored.lastReadAt) || 0 this.lastReadAt = Number(stored.lastReadAt) || 0
@@ -817,7 +824,7 @@ class GroupExitMonitorService {
} catch { } catch {
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里, // 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
// 不该被状态文件的问题连累,仍然读回来。 // 不该被状态文件的问题连累,仍然读回来。
this.events = normalizeEvents(this.readEventsFromDisk()) this.events = sortEventsNewestFirst(normalizeEvents(this.readEventsFromDisk()))
this.enabled = true this.enabled = true
this.lastReadAt = 0 this.lastReadAt = 0
this.monitorSelectionConfigured = true this.monitorSelectionConfigured = true
@@ -1053,6 +1060,21 @@ function normalizeEvents(
return normalized 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 { function normalizeNotificationStatus(value: unknown): GroupExitNotificationStatus {
const status = String(value || '').trim() const status = String(value || '').trim()
return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed' return status === 'pending' || status === 'sent' || status === 'blocked' || status === 'failed'
+1 -1
View File
@@ -18,7 +18,7 @@
--wxex-danger: hsl(var(--tm-destructive)); --wxex-danger: hsl(var(--tm-destructive));
--wxex-font: --wxex-font:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; -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-shell-content-top: 12px;
--wxex-radius-sm: 4px; --wxex-radius-sm: 4px;
--wxex-radius-md: 8px; --wxex-radius-md: 8px;
+32
View File
@@ -90,6 +90,38 @@ body {
width: 100%; width: 100%;
margin-top: 18px; margin-top: 18px;
-webkit-app-region: no-drag; -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 { .primary-nav-item {
@@ -669,7 +669,7 @@
.app-shell { .app-shell {
&.is-compact { &.is-compact {
--wxex-nav-width: 68px; --wxex-nav-width: 80px;
--wxex-shell-content-top: 8px; --wxex-shell-content-top: 8px;
} }
} }
+5 -1
View File
@@ -1322,7 +1322,11 @@ for (const channel of [
app.whenReady().then(() => { app.whenReady().then(() => {
const window = new BrowserWindow({ 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, width: 1400,
height: 800, height: 800,
show: false, show: false,
+83 -1
View File
@@ -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 { tmpdir } from 'os'
import { join } from 'path' import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -49,6 +57,37 @@ function installGroupDb(): void {
mocks.chat.getChatDb.mockReturnValue({ getWcdb4Client: () => client }) 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<string, unknown> {
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( function writeBaseline(
snapshots: Array<{ snapshots: Array<{
roomId: string roomId: string
@@ -820,4 +859,47 @@ describe('GroupExitMonitorService', () => {
expect(mocks.sender.send).toHaveBeenCalledOnce() expect(mocks.sender.send).toHaveBeenCalledOnce()
expect(service.getState().events).toHaveLength(1) 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)
})
}) })