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,
|
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'
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user