mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
fix: 修复退群监控列表错误, 界面左侧滚动条
This commit is contained in:
+24
-3
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
+32
@@ -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 {
|
||||
|
||||
@@ -669,7 +669,7 @@
|
||||
|
||||
.app-shell {
|
||||
&.is-compact {
|
||||
--wxex-nav-width: 68px;
|
||||
--wxex-nav-width: 80px;
|
||||
--wxex-shell-content-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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(
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user