mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 新增群员统计与退群事件,并把本地索引集中到设置页
- 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送 - 退群事件永久保存,并以本地推断条目并入档案消息流 - 设置新增本地索引页,集中管理聊天记录索引与图片文字索引
This commit is contained in:
@@ -112,6 +112,7 @@ import { agentHubService } from './services/agent-hub-service'
|
||||
import { WechatConnectorService } from './services/wechat-ilink'
|
||||
import { wechatSendGateway } from './services/wechat-send-gateway'
|
||||
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
||||
import { GroupStatsService } from './services/group-stats-service'
|
||||
import { wechatActionLogService } from './services/wechat-action-log-service'
|
||||
import { wechatActionGateway } from './services/wechat-action-gateway'
|
||||
import { personalWechatSendService } from './services/personal-wechat-send-service'
|
||||
@@ -211,6 +212,7 @@ let voiceService: VoiceService | null = null
|
||||
let voiceRecognition: VoiceRecognitionUseCase | null = null
|
||||
let voiceBatchService: VoiceBatchService | null = null
|
||||
let knowledgeSearchService: KnowledgeSearchService | null = null
|
||||
let groupStatsService: GroupStatsService | null = null
|
||||
let localQueryApiService: LocalQueryApiService | null = null
|
||||
let aiSearchPipelineService: AiSearchPipelineService | null = null
|
||||
let queryAgentService: QueryAgentService | null = null
|
||||
@@ -754,6 +756,9 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
|
||||
localQueryApiService = new LocalQueryApiService(knowledgeSearchService)
|
||||
// 群员统计复用同一个 Knowledge 实例:它只是「读派生库 + 读成员名单」的编排,
|
||||
// 不持有自己的数据库,也不新建索引。
|
||||
groupStatsService = new GroupStatsService(knowledgeSearchService)
|
||||
// 图片文字索引覆盖度是**独立覆盖维度**:接到 search_messages 的 tool result 上,
|
||||
// 让 Query Agent 在图片索引没做完时不能凭 0 条证据断言"没有"。
|
||||
localQueryApiService.setImageTextCoverageProvider(() =>
|
||||
@@ -1469,6 +1474,28 @@ app.whenReady().then(async () => {
|
||||
return snapshot
|
||||
})
|
||||
|
||||
/**
|
||||
* 群员统计(单群)。
|
||||
*
|
||||
* 时间单位刻意用 epoch **毫秒**:这一路完全走 Knowledge,而 Knowledge 内部口径就是毫秒。
|
||||
* 沿用聊天消息的秒级口径会在 service 内部凭空多出一次换算 —— 而单位换错是**静默读 0 条**,
|
||||
* 不会报错。
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'group-stats:getMemberStats',
|
||||
async (_, request: { userMd5?: unknown; startTime?: unknown; endTime?: unknown }) => {
|
||||
if (!groupStatsService) throw new Error('群员统计服务尚未就绪')
|
||||
const userMd5 = String(request?.userMd5 || '').trim()
|
||||
if (!userMd5) throw new Error('缺少会话标识')
|
||||
const startTime = Number(request?.startTime)
|
||||
const endTime = Number(request?.endTime)
|
||||
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) {
|
||||
throw new Error('统计时间范围无效')
|
||||
}
|
||||
return groupStatsService.getMemberStats({ userMd5, startTime, endTime })
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('group-exit-monitor:getState', () => groupExitMonitorService.getState())
|
||||
ipcMain.handle('group-exit-monitor:setEnabled', (_, enabled: boolean) =>
|
||||
groupExitMonitorService.setEnabled(enabled === true)
|
||||
@@ -1485,6 +1512,25 @@ app.whenReady().then(async () => {
|
||||
groupExitMonitorService.setNotificationTemplate(template)
|
||||
)
|
||||
ipcMain.handle('group-exit-monitor:checkNow', () => groupExitMonitorService.checkNow())
|
||||
/**
|
||||
* 按群查退群事件(档案合并展示用)。
|
||||
*
|
||||
* 时间参数是 epoch **毫秒**,与事件的 `detectedAt` 同口径。
|
||||
*/
|
||||
ipcMain.handle('group-exit-monitor:listEvents', (_, query: unknown) => {
|
||||
const input = (query || {}) as {
|
||||
roomId?: unknown
|
||||
sinceMs?: unknown
|
||||
untilMs?: unknown
|
||||
limit?: unknown
|
||||
}
|
||||
return groupExitMonitorService.listEvents({
|
||||
roomId: typeof input.roomId === 'string' ? input.roomId : undefined,
|
||||
sinceMs: Number(input.sinceMs),
|
||||
untilMs: Number(input.untilMs),
|
||||
limit: Number(input.limit)
|
||||
})
|
||||
})
|
||||
ipcMain.handle('group-exit-monitor:clearEvents', () => groupExitMonitorService.clearEvents())
|
||||
ipcMain.handle('group-exit-monitor:resendEvent', (_, eventId: string) =>
|
||||
groupExitMonitorService.resendEvent(eventId)
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
KnowledgeImageOcrState,
|
||||
KnowledgeAttachmentMetadata,
|
||||
KnowledgeEvidence,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeMessageKind,
|
||||
KnowledgePassProgress,
|
||||
KnowledgeRuntimeState,
|
||||
@@ -498,6 +499,37 @@ export class KnowledgeSearchService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单群发言聚合(群员统计的数据来源)。
|
||||
*
|
||||
* 这一层只**如实**返回引擎能给出的东西(含 `indexLatestAt`),不做追赶决策 ——
|
||||
* 「要不要等索引、要不要把结果标成不完整」是产品判断,属于 GroupStatsService。
|
||||
* 同时返回一次 `sourceLatestAt`(同步、零额外 WCDB 调用),让调用方一次拿到
|
||||
* freshness 的两个口径,不必再发一次 status 请求。
|
||||
*/
|
||||
async memberStats(request: {
|
||||
conversationId: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
}): Promise<{ result: KnowledgeMemberStatsResult | null; sourceLatestAt: number | null }> {
|
||||
const sourceLatestAt = this.sourceLatestAt()
|
||||
const accountId = this.currentAccountId()
|
||||
if (!accountId) return { result: null, sourceLatestAt }
|
||||
try {
|
||||
const result = await this.service.memberStats({
|
||||
accountId,
|
||||
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||
conversationId: request.conversationId,
|
||||
startTime: request.startTime,
|
||||
endTime: request.endTime
|
||||
})
|
||||
return { result, sourceLatestAt }
|
||||
} catch (error) {
|
||||
console.warn('[Knowledge] member stats failed:', error)
|
||||
return { result: null, sourceLatestAt }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 源数据最新活跃时间(epoch ms)。派生索引看到不源数据,freshness 判定由它 + `indexLatestAt` 组成。
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchResult,
|
||||
@@ -53,6 +55,13 @@ export class KnowledgeService {
|
||||
return this.worker.highWaterMarks({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
/** 单个会话内「按发送者聚合」的发言统计(群员统计用)。 */
|
||||
memberStats(
|
||||
request: Omit<KnowledgeMemberStatsRequest, 'databaseRoot'>
|
||||
): Promise<KnowledgeMemberStatsResult> {
|
||||
return this.worker.memberStats({ ...request, databaseRoot: this.databaseRoot })
|
||||
}
|
||||
|
||||
/** 只中止正在跑的索引任务;查询请求不受影响。 */
|
||||
cancelIndex(): Promise<boolean> {
|
||||
return this.worker.cancelActiveIndex()
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeNormalizedMessage,
|
||||
KnowledgeQuery,
|
||||
@@ -317,6 +318,93 @@ export class KnowledgeStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个会话在时间窗内的「按发送者聚合」统计。
|
||||
*
|
||||
* 只回聚合结果、不回消息正文:群员统计只需要「谁说了几条、最后一条是什么时候」,
|
||||
* 把消息逐条搬到主进程再统计会把几十万行推过 IPC 边界。
|
||||
*
|
||||
* 三条硬规则**全部由 SQL 保证**,不指望调用方记得:
|
||||
* 1. `kind <> 'system'`:系统消息不是任何成员的发言(微信侧 10000/10002 在建库时
|
||||
* 已归一为 `kind = 'system'`,见 `knowledge-search-service` 的 kind 映射);
|
||||
* 2. `sender_id IS NULL` 的行不归给任何人,只计入 `unattributedMessages` ——
|
||||
* 硬塞给某个成员会让「未发言」名单出现错误否定;
|
||||
* 3. 时间窗口是**闭区间**,单位 **epoch 毫秒**,与 knowledge 内部口径一致,
|
||||
* 不经过 WCDB 的秒级边界(跨错单位会静默读到 0 条)。
|
||||
*/
|
||||
memberStats(request: {
|
||||
conversationId: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
}): KnowledgeMemberStatsResult {
|
||||
const { conversationId, startTime, endTime } = request
|
||||
const indexLatestAt = this.readIndexLatestAt()
|
||||
const empty: KnowledgeMemberStatsResult = {
|
||||
conversationId,
|
||||
totalMessages: 0,
|
||||
senders: [],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
earliestMessageTime: null,
|
||||
indexLatestAt
|
||||
}
|
||||
if (!conversationId) return empty
|
||||
|
||||
// 用 `(conversation_id, create_time)` 索引直接命中:这是本查询唯一的访问路径,
|
||||
// 写成全表扫描等价于把单群统计的 26ms 变成 10s。
|
||||
const scope = 'conversation_id = ? AND create_time >= ? AND create_time <= ?'
|
||||
const args = [conversationId, startTime, endTime]
|
||||
|
||||
const count = (extra: string): number => {
|
||||
const row = this.database
|
||||
.prepare(`SELECT COUNT(*) AS n FROM knowledge_messages WHERE ${scope} AND ${extra}`)
|
||||
.get(...args) as DbRow | undefined
|
||||
return Number(row?.n) || 0
|
||||
}
|
||||
|
||||
const rows = asRows(
|
||||
this.database
|
||||
.prepare(
|
||||
`SELECT sender_id,
|
||||
COUNT(*) AS message_count,
|
||||
MAX(create_time) AS last_message_time
|
||||
FROM knowledge_messages
|
||||
WHERE ${scope} AND kind <> 'system' AND sender_id IS NOT NULL
|
||||
GROUP BY sender_id
|
||||
ORDER BY message_count DESC, last_message_time DESC`
|
||||
)
|
||||
.all(...args)
|
||||
)
|
||||
|
||||
const senders: KnowledgeMemberStatsResult['senders'] = []
|
||||
for (const row of rows) {
|
||||
const senderId = String(row.sender_id ?? '').trim()
|
||||
if (!senderId) continue
|
||||
senders.push({
|
||||
senderId,
|
||||
messageCount: Number(row.message_count) || 0,
|
||||
lastMessageTime: Number(row.last_message_time) || 0
|
||||
})
|
||||
}
|
||||
|
||||
// 窗口内最早一条消息:**不过滤 kind** —— 群的第一条常常是建群通知,
|
||||
// 那才是用户认知里的「这个群第一条消息」。选「全部」时用它显示真实起点。
|
||||
const earliestRow = this.database
|
||||
.prepare(`SELECT MIN(create_time) AS m FROM knowledge_messages WHERE ${scope}`)
|
||||
.get(...args) as DbRow | undefined
|
||||
const earliestValue = Number(earliestRow?.m)
|
||||
|
||||
return {
|
||||
conversationId,
|
||||
totalMessages: count("kind <> 'system'"),
|
||||
senders,
|
||||
unattributedMessages: count("kind <> 'system' AND sender_id IS NULL"),
|
||||
excludedSystemMessages: count("kind = 'system'"),
|
||||
earliestMessageTime: Number.isFinite(earliestValue) && earliestValue > 0 ? earliestValue : null,
|
||||
indexLatestAt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引已经覆盖到的源数据时间(epoch ms)——「索引更新到哪」的权威口径。
|
||||
*
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
KnowledgeIndexProgress,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeIndexResult,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeSearchResult,
|
||||
@@ -19,6 +21,7 @@ type WorkerResult =
|
||||
| KnowledgeCapacityPreflight
|
||||
| KnowledgeSearchResult
|
||||
| KnowledgeRuntimeStatus
|
||||
| KnowledgeMemberStatsResult
|
||||
| { marks: Record<string, number> }
|
||||
| { removed: true }
|
||||
type PendingRequest = {
|
||||
@@ -76,6 +79,11 @@ export class KnowledgeWorkerHost {
|
||||
)
|
||||
}
|
||||
|
||||
/** 单个会话内「按发送者聚合」的发言统计(群员统计用,只回聚合不回正文)。 */
|
||||
memberStats(payload: KnowledgeMemberStatsRequest): Promise<KnowledgeMemberStatsResult> {
|
||||
return this.request('memberStats', payload) as Promise<KnowledgeMemberStatsResult>
|
||||
}
|
||||
|
||||
/** 只中止正在跑的索引任务,返回是否真的有任务被中止。 */
|
||||
async cancelActiveIndex(): Promise<boolean> {
|
||||
const target = this.activeIndexRequestId
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
KnowledgeCapacityPreflightRequest,
|
||||
KnowledgeIndexRequest,
|
||||
KnowledgeMemberStatsRequest,
|
||||
KnowledgeMemberStatsResult,
|
||||
KnowledgeRuntimeStatus,
|
||||
KnowledgeSearchRequest,
|
||||
KnowledgeStatusRequest,
|
||||
@@ -187,6 +189,38 @@ async function handleHighWater(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 群员统计的按发送者聚合。
|
||||
*
|
||||
* 与 `handleSearch` 同样先判库是否存在:**「还没建索引」是正常状态,不是故障**,
|
||||
* 返回空结果而不是抛错,让上层能稳定地区分「没人发言」与「索引不存在」。
|
||||
*/
|
||||
async function handleMemberStats(
|
||||
request: KnowledgeWorkerRequest,
|
||||
payload: KnowledgeMemberStatsRequest
|
||||
): Promise<void> {
|
||||
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
|
||||
if (!existsSync(path)) {
|
||||
const unavailable: KnowledgeMemberStatsResult = {
|
||||
conversationId: payload.conversationId,
|
||||
totalMessages: 0,
|
||||
senders: [],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
earliestMessageTime: null,
|
||||
indexLatestAt: null
|
||||
}
|
||||
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
|
||||
return
|
||||
}
|
||||
send({
|
||||
version: 1,
|
||||
type: 'result',
|
||||
requestId: request.requestId,
|
||||
payload: getStore(payload).memberStats(payload)
|
||||
})
|
||||
}
|
||||
|
||||
async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number): Promise<void> {
|
||||
try {
|
||||
if (request.type === 'cancel') {
|
||||
@@ -226,6 +260,10 @@ async function handle(request: KnowledgeWorkerRequest, messageReceivedAt: number
|
||||
await handleHighWater(request, request.payload as KnowledgeStatusRequest)
|
||||
return
|
||||
}
|
||||
if (request.type === 'memberStats') {
|
||||
await handleMemberStats(request, request.payload as KnowledgeMemberStatsRequest)
|
||||
return
|
||||
}
|
||||
if (request.type === 'index') {
|
||||
await handleIndex(request, request.payload as KnowledgeIndexRequest)
|
||||
return
|
||||
|
||||
@@ -1036,6 +1036,20 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把群会话 md5 解析成原生接口真正需要的 roomId(`xxx@chatroom`)。
|
||||
*
|
||||
* 存在的理由:群员统计从界面拿到的是会话 md5(与 `getGroupSnapshot(userMd5)` 同口径),
|
||||
* 而成员相关的原生接口吃的是 username。没有这层导出,调用方就得自己去碰
|
||||
* `dbRef.getWcdb4Client()`,等于让服务层绕开 chat-service 的封装。
|
||||
* 非群会话或解析不到时返回 null。
|
||||
*/
|
||||
export function resolveGroupRoomId(userMd5: string): string | null {
|
||||
if (!dbRef) return null
|
||||
const roomId = dbRef.getWcdb4Client().getUsernameByMd5(userMd5)
|
||||
return roomId && roomId.endsWith('@chatroom') ? roomId : null
|
||||
}
|
||||
|
||||
/** 退群检测专用轻量读取,不执行成员名称或头像 hydration。 */
|
||||
export async function getGroupMemberIdsAsync(
|
||||
roomId: string
|
||||
|
||||
@@ -57,6 +57,14 @@ type StoredGroupSnapshot = Pick<
|
||||
|
||||
const DB_CHANGE_DEBOUNCE_MS = 350
|
||||
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
|
||||
/**
|
||||
* **只约束「一次状态回传带多少条」**,不是存储上限。
|
||||
*
|
||||
* 事件现在写在 append-only 的 JSONL 里(`eventsPath()`),**永久保留、不做截断**。
|
||||
* 之所以必须把「存储」和「回传」分开:状态文件是整体重写的(`save()`),
|
||||
* 事件留在里面时每来一条新事件都要重写整个文件 —— 越写越慢。
|
||||
* 而 `getState()` 每次都把结果推过 IPC,也不能无上限。
|
||||
*/
|
||||
const MAX_EVENTS = 500
|
||||
|
||||
export interface GroupExitMonitorServiceDependencies {
|
||||
@@ -104,7 +112,9 @@ class GroupExitMonitorService {
|
||||
getState(): GroupExitMonitorState {
|
||||
this.ensureLoaded()
|
||||
return {
|
||||
events: [...this.events],
|
||||
events: this.events.slice(0, MAX_EVENTS),
|
||||
/** 永久保留的事件总数(`events` 只是最近一批)。 */
|
||||
totalEventCount: this.events.length,
|
||||
enabled: this.enabled,
|
||||
running: this.enabled && this.active && chat.isReady(),
|
||||
nativeMonitorActive: this.nativeMonitorActive,
|
||||
@@ -134,6 +144,7 @@ class GroupExitMonitorService {
|
||||
) {
|
||||
this.actionGateway.clearMemberEvents?.()
|
||||
this.events = []
|
||||
this.rewriteEventsToDisk([])
|
||||
this.lastReadAt = 0
|
||||
this.monitorSelectionConfigured = true
|
||||
this.monitoredRoomIds.clear()
|
||||
@@ -273,9 +284,41 @@ class GroupExitMonitorService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按群 / 时间范围查退群事件(档案合并展示用)。
|
||||
*
|
||||
* 与 `getState()` 的分工:后者只带回最近 `MAX_EVENTS` 条、且是**给管理页**看的概览;
|
||||
* 档案要的是「某个群在这段时间里的全部事件」,所以单独开一个查询入口,
|
||||
* 直接打在内存里的完整历史上(事件是永久保留的)。
|
||||
*
|
||||
* 返回**按时间升序**(旧 → 新),与档案消息流的顺序一致。
|
||||
*/
|
||||
listEvents(
|
||||
query: { roomId?: string; sinceMs?: number; untilMs?: number; limit?: number } = {}
|
||||
): GroupExitMonitorEvent[] {
|
||||
this.ensureLoaded()
|
||||
const roomId = String(query.roomId || '').trim()
|
||||
const since = Number(query.sinceMs)
|
||||
const until = Number(query.untilMs)
|
||||
const limit = Number(query.limit)
|
||||
|
||||
// this.events 是倒序(新在前)。
|
||||
let result = [...this.events].reverse()
|
||||
if (roomId) result = result.filter((event) => event.roomId === roomId)
|
||||
if (Number.isFinite(since)) result = result.filter((event) => event.detectedAt >= since)
|
||||
if (Number.isFinite(until)) result = result.filter((event) => event.detectedAt <= until)
|
||||
// 超量时保留**最近**的一批(尾部即最新)。
|
||||
if (Number.isFinite(limit) && limit > 0 && result.length > limit) {
|
||||
result = result.slice(-limit)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
clearEvents(): GroupExitMonitorState {
|
||||
this.ensureLoaded()
|
||||
this.events = []
|
||||
// 磁盘上的 append-only 历史也要清掉,否则下次启动又读回来了。
|
||||
this.rewriteEventsToDisk([])
|
||||
this.actionGateway.clearMemberEvents?.()
|
||||
this.lastReadAt = Date.now()
|
||||
this.save()
|
||||
@@ -593,7 +636,10 @@ class GroupExitMonitorService {
|
||||
notificationStatus: 'not_requested'
|
||||
}
|
||||
this.actionGateway.registerMemberEvent?.(event)
|
||||
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
|
||||
// 内存按时间倒序(新事件在前);磁盘**只追加这一条**,不重写历史。
|
||||
// 这里不再有 `.slice(0, MAX_EVENTS)` —— 事件是永久保留的。
|
||||
this.events = [event, ...this.events]
|
||||
this.appendEventsToDisk([event])
|
||||
console.log(
|
||||
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
|
||||
)
|
||||
@@ -681,13 +727,78 @@ class GroupExitMonitorService {
|
||||
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群事件的 append-only 存储(每行一条 JSON)。
|
||||
*
|
||||
* 与状态文件分开,因为两者的写入模式完全不同:
|
||||
* - **状态**(开关 / 监控范围 / 快照 / 模板)小、且总是整体重写;
|
||||
* - **事件**只增不改,且要求**永久保留**。
|
||||
* 混在一个文件里时,每来一条事件都要把整部历史重新序列化写一遍 —— 越写越慢。
|
||||
*/
|
||||
private eventsPath(): string {
|
||||
return path.join(app.getPath('userData'), 'group-exit-monitor-events.jsonl')
|
||||
}
|
||||
|
||||
/** 读全量历史事件。单行损坏只跳过该行,不让整部历史读不出来。 */
|
||||
private readEventsFromDisk(): GroupExitMonitorEvent[] {
|
||||
let raw = ''
|
||||
try {
|
||||
raw = fs.readFileSync(this.eventsPath(), 'utf8')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const events: GroupExitMonitorEvent[] = []
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
try {
|
||||
events.push(JSON.parse(trimmed) as GroupExitMonitorEvent)
|
||||
} catch {
|
||||
// 跳过坏行
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** 只追加新增的那几行,不重写历史。 */
|
||||
private appendEventsToDisk(events: GroupExitMonitorEvent[]): void {
|
||||
if (!events.length) return
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.eventsPath()))
|
||||
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
|
||||
fs.appendFileSync(this.eventsPath(), payload, 'utf8')
|
||||
} catch (error) {
|
||||
console.warn('[GroupMonitor] 追加退群事件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 整体重写事件文件(清空、切换账号、老数据迁移时使用)。 */
|
||||
private rewriteEventsToDisk(events: GroupExitMonitorEvent[]): void {
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.eventsPath()))
|
||||
const payload = events.map((event) => `${JSON.stringify(event)}\n`).join('')
|
||||
fs.writeFileSync(this.eventsPath(), payload, 'utf8')
|
||||
} catch (error) {
|
||||
console.warn('[GroupMonitor] 重写退群事件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
try {
|
||||
const stored = fs.readJsonSync(this.filePath()) as StoredState
|
||||
this.enabled = stored.enabled !== false
|
||||
this.events = normalizeEvents(stored.events)
|
||||
// 事件从 append-only 文件读;状态文件不再承载它们。
|
||||
const fromDisk = this.readEventsFromDisk()
|
||||
const legacy = normalizeEvents(stored.events)
|
||||
if (legacy.length && !fromDisk.length) {
|
||||
// 老版本把事件塞在状态文件里 —— 一次性迁移过去,避免这批历史丢失。
|
||||
this.rewriteEventsToDisk(legacy)
|
||||
this.events = legacy
|
||||
} else {
|
||||
this.events = normalizeEvents(fromDisk)
|
||||
}
|
||||
this.actionGateway.registerMemberEvents?.(this.events)
|
||||
this.lastReadAt = Number(stored.lastReadAt) || 0
|
||||
this.accountRoot = String(stored.accountRoot || '')
|
||||
@@ -704,8 +815,9 @@ class GroupExitMonitorService {
|
||||
)
|
||||
this.snapshots = normalizeSnapshots(stored.snapshots, this.monitoredRoomIds)
|
||||
} catch {
|
||||
// 首次启动或文件损坏时从空记录开始。
|
||||
this.events = []
|
||||
// 首次启动或状态文件损坏时从空记录开始 —— 但事件在独立文件里,
|
||||
// 不该被状态文件的问题连累,仍然读回来。
|
||||
this.events = normalizeEvents(this.readEventsFromDisk())
|
||||
this.enabled = true
|
||||
this.lastReadAt = 0
|
||||
this.monitorSelectionConfigured = true
|
||||
@@ -725,7 +837,8 @@ class GroupExitMonitorService {
|
||||
{
|
||||
accountRoot: this.accountRoot,
|
||||
enabled: this.enabled,
|
||||
events: this.events,
|
||||
// 事件**不在这里**:它们走 append-only 的 JSONL(见 `eventsPath()`)。
|
||||
// 放进状态文件会让每新增一条事件都把整部历史重写一遍。
|
||||
lastReadAt: this.lastReadAt,
|
||||
monitorSelectionConfigured: this.monitorSelectionConfigured,
|
||||
monitoredRoomIds: Array.from(this.monitoredRoomIds),
|
||||
@@ -935,7 +1048,7 @@ function normalizeEvents(
|
||||
? { notification: normalizeNotification(value.notification) }
|
||||
: {})
|
||||
})
|
||||
if (normalized.length >= MAX_EVENTS) break
|
||||
// 不再按 MAX_EVENTS 截断:事件是永久保留的,截在这里等于每次启动都丢掉历史。
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { isKnowledgeFresh } from '../../shared/knowledge'
|
||||
import {
|
||||
GROUP_STATS_LIMITATION,
|
||||
GROUP_STATS_STALE_LIMITATION,
|
||||
type GroupMemberStatsActiveMember,
|
||||
type GroupMemberStatsQuery,
|
||||
type GroupMemberStatsResult,
|
||||
type GroupMemberStatsSilentMember,
|
||||
type GroupStatsFreshness
|
||||
} from '../../shared/group-stats'
|
||||
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
|
||||
import * as chat from './chat-service'
|
||||
|
||||
/**
|
||||
* 触发一次追赶同步的最小间隔。
|
||||
*
|
||||
* 面板是可以反复开关的交互入口,而追赶同步跑的是真实增量 pass(会读 WCDB)。
|
||||
* 没有这个节流,连续点击就等于连续触发索引。
|
||||
*/
|
||||
const CATCH_UP_MIN_INTERVAL_MS = 30_000
|
||||
|
||||
/**
|
||||
* 追赶的有界等待预算。
|
||||
*
|
||||
* 与 Query 侧同一取舍:全量追赶可能以分钟计,交互查询绝不能无限等。
|
||||
* 预算用完之后**如实**把结果标成不完整,而不是假装完整。
|
||||
*/
|
||||
const FRESHNESS_WAIT_BUDGET_MS = 2_000
|
||||
|
||||
/**
|
||||
* 群员统计。
|
||||
*
|
||||
* 数据两路来源,职责严格分开:
|
||||
* - **当前成员名单** 走 WCDB 的轻路径(只取 wxid + 显示名,**不** hydrate 头像);
|
||||
* - **发言聚合** 走 Knowledge 派生库的 `GROUP BY sender_id`,不读 WCDB 原始消息。
|
||||
*
|
||||
* 「未发言」= 当前成员 ∖ 窗口内有发言的 sender。这个定义**不能**简化成
|
||||
* 「整段窗口都没说话」—— 入群/退群时间在源头就不存在(整个 DB 没有该字段),
|
||||
* 所以结论必须带上 limitation。
|
||||
*/
|
||||
export class GroupStatsService {
|
||||
constructor(private readonly knowledge: KnowledgeSearchService) {}
|
||||
|
||||
async getMemberStats(query: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> {
|
||||
const { userMd5, startTime, endTime } = query
|
||||
const limitations = [GROUP_STATS_LIMITATION]
|
||||
|
||||
const roomId = chat.resolveGroupRoomId(userMd5)
|
||||
if (!roomId) {
|
||||
// 不是群 / md5 解析不到:如实返回空,不编造统计。
|
||||
return this.build({
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
freshness: 'unknown',
|
||||
limitations,
|
||||
members: [],
|
||||
activeMembers: [],
|
||||
silentMembers: [],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
firstMessageTime: null
|
||||
})
|
||||
}
|
||||
|
||||
// 1) 当前群成员 —— 轻路径。只取 wxid,再按需解析显示名;绝不走
|
||||
// `getGroupSnapshotAsync`(它会 materialize 整群并 hydrate 头像,实测 8 群 ≈ 42s)。
|
||||
const membership = await chat.getGroupMemberIdsAsync(roomId)
|
||||
const memberIds = membership?.memberIds ?? []
|
||||
const members = await chat.getGroupMemberNamesAsync(userMd5, memberIds)
|
||||
|
||||
// 2) 发言聚合(只统计当前群;系统消息在 SQL 层就被排除)
|
||||
let { result, sourceLatestAt } = await this.knowledge.memberStats({
|
||||
conversationId: userMd5,
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
let freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
|
||||
|
||||
// 3) 索引没追平 → 有界追赶一次再判。追不上就如实标 stale,
|
||||
// **不允许**把落后索引算出来的「未发言」包装成完整结论。
|
||||
if (freshness !== 'fresh') {
|
||||
this.knowledge.requestCatchUp(CATCH_UP_MIN_INTERVAL_MS)
|
||||
const settled = await this.knowledge.waitForIndexingComplete(FRESHNESS_WAIT_BUDGET_MS)
|
||||
if (settled) {
|
||||
const retried = await this.knowledge.memberStats({
|
||||
conversationId: userMd5,
|
||||
startTime,
|
||||
endTime
|
||||
})
|
||||
result = retried.result
|
||||
sourceLatestAt = retried.sourceLatestAt
|
||||
freshness = judgeFreshness(result?.indexLatestAt ?? null, sourceLatestAt)
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 差集:当前成员 × 窗口内发言
|
||||
const statBySender = new Map<string, { messageCount: number; lastMessageTime: number }>()
|
||||
for (const row of result?.senders ?? []) {
|
||||
if (row.senderId) statBySender.set(row.senderId, row)
|
||||
}
|
||||
|
||||
const knownSenderIds = new Set<string>()
|
||||
const activeMembers: GroupMemberStatsActiveMember[] = []
|
||||
const silentMembers: GroupMemberStatsSilentMember[] = []
|
||||
|
||||
for (const member of members) {
|
||||
const wxid = String(member.wxid || '').trim()
|
||||
if (!wxid) continue
|
||||
knownSenderIds.add(wxid)
|
||||
// 与 Knowledge 的 Join key 必须是 wxid,不是昵称 —— 昵称会变。
|
||||
// 显示名沿用项目既有优先级(nickname 已内含 `wechatNickname || groupNickname || username`)。
|
||||
const displayName = member.nickname || member.remark || wxid
|
||||
const groupNickname = member.groupNickname || ''
|
||||
const stat = statBySender.get(wxid)
|
||||
if (stat && stat.messageCount > 0) {
|
||||
activeMembers.push({
|
||||
senderId: wxid,
|
||||
displayName,
|
||||
groupNickname,
|
||||
messageCount: stat.messageCount,
|
||||
lastMessageTime: stat.lastMessageTime
|
||||
})
|
||||
} else {
|
||||
silentMembers.push({ senderId: wxid, displayName, groupNickname })
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口内有发言、但已不在当前成员名单里的人(退群者)。
|
||||
// 默认**不**进活跃榜:那份名单读作「当前群成员」,混入已退群的人会误导。
|
||||
let formerSenderCount = 0
|
||||
for (const senderId of statBySender.keys()) {
|
||||
if (!knownSenderIds.has(senderId)) formerSenderCount += 1
|
||||
}
|
||||
|
||||
activeMembers.sort(
|
||||
(a, b) => b.messageCount - a.messageCount || b.lastMessageTime - a.lastMessageTime
|
||||
)
|
||||
silentMembers.sort((a, b) => a.displayName.localeCompare(b.displayName, 'zh-Hans-CN'))
|
||||
|
||||
if (freshness !== 'fresh') limitations.push(GROUP_STATS_STALE_LIMITATION)
|
||||
if (formerSenderCount > 0) {
|
||||
limitations.push(
|
||||
`另有 ${formerSenderCount} 位窗口内发言者已不在当前群成员名单中,未计入活跃榜。`
|
||||
)
|
||||
}
|
||||
|
||||
return this.build({
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
freshness,
|
||||
limitations,
|
||||
members,
|
||||
activeMembers,
|
||||
silentMembers,
|
||||
unattributedMessages: result?.unattributedMessages ?? 0,
|
||||
excludedSystemMessages: result?.excludedSystemMessages ?? 0,
|
||||
firstMessageTime: result?.earliestMessageTime ?? null
|
||||
})
|
||||
}
|
||||
|
||||
private build(input: {
|
||||
userMd5: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
freshness: GroupStatsFreshness
|
||||
limitations: string[]
|
||||
members: Array<{ wxid: string }>
|
||||
activeMembers: GroupMemberStatsActiveMember[]
|
||||
silentMembers: GroupMemberStatsSilentMember[]
|
||||
unattributedMessages: number
|
||||
excludedSystemMessages: number
|
||||
firstMessageTime: number | null
|
||||
}): GroupMemberStatsResult {
|
||||
return {
|
||||
conversationId: input.userMd5,
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
freshness: input.freshness,
|
||||
// 只有索引确实追平,才允许调用方把「未发言」当作完整结论。
|
||||
complete: input.freshness === 'fresh',
|
||||
memberCount: input.members.length,
|
||||
activeMemberCount: input.activeMembers.length,
|
||||
silentMemberCount: input.silentMembers.length,
|
||||
activeMembers: input.activeMembers,
|
||||
silentMembers: input.silentMembers,
|
||||
unattributedMessages: input.unattributedMessages,
|
||||
excludedSystemMessages: input.excludedSystemMessages,
|
||||
firstMessageTime: input.firstMessageTime,
|
||||
limitations: input.limitations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `isKnowledgeFresh` 返回 `boolean | null`(任一侧口径缺失即 null)。
|
||||
* 三态都要能表达:`null` 绝不能当成「新鲜」。
|
||||
*/
|
||||
function judgeFreshness(
|
||||
indexLatestAt: number | null,
|
||||
sourceLatestAt: number | null
|
||||
): GroupStatsFreshness {
|
||||
const fresh = isKnowledgeFresh({ indexLatestAt, sourceLatestAt })
|
||||
if (fresh === true) return 'fresh'
|
||||
if (fresh === false) return 'stale'
|
||||
return 'unknown'
|
||||
}
|
||||
Vendored
+9
-1
@@ -82,6 +82,7 @@ import type {
|
||||
PersonalWechatVoiceDiagnostic
|
||||
} from '../shared/personal-wechat'
|
||||
import type { PersonalWechatSendCapability } from '../shared/personal-wechat'
|
||||
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
|
||||
import type {
|
||||
ScheduledReportCreateInput,
|
||||
ScheduledReportExecution,
|
||||
@@ -101,7 +102,7 @@ import type {
|
||||
PersonalWechatVoiceEncodingEnvironmentResult
|
||||
} from '../shared/personal-wechat-voice-runtime'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||
import type { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||
import type { ActionLogEntry } from '../shared/action-log'
|
||||
import type {
|
||||
AppUpdateCheckResult,
|
||||
@@ -293,7 +294,14 @@ declare global {
|
||||
avatar: string
|
||||
}[]
|
||||
} | null>
|
||||
getGroupMemberStats: (request: GroupMemberStatsQuery) => Promise<GroupMemberStatsResult>
|
||||
getGroupExitMonitorState: () => Promise<GroupExitMonitorState>
|
||||
listGroupExitMonitorEvents: (query?: {
|
||||
roomId?: string
|
||||
sinceMs?: number
|
||||
untilMs?: number
|
||||
limit?: number
|
||||
}) => Promise<GroupExitMonitorEvent[]>
|
||||
setGroupExitMonitorEnabled: (enabled: boolean) => Promise<GroupExitMonitorState>
|
||||
setGroupExitMonitorGroups: (
|
||||
roomIds: string[],
|
||||
|
||||
+11
-1
@@ -71,7 +71,8 @@ import type {
|
||||
} from '../shared/personal-wechat-voice-runtime'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import type { AppUpdateState } from '../shared/app-update'
|
||||
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||
import type { GroupExitMonitorEvent, GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||
import type { GroupMemberStatsQuery, GroupMemberStatsResult } from '../shared/group-stats'
|
||||
import type { ActionLogEntry } from '../shared/action-log'
|
||||
import type { CacheClearScope, CacheSummary } from '../shared/cache'
|
||||
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||
@@ -167,8 +168,17 @@ const api = {
|
||||
): Promise<MessagesAroundResult> =>
|
||||
ipcRenderer.invoke('db:getMessagesAround', userMd5, messageId, anchorSeconds, radiusSeconds),
|
||||
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
||||
getGroupMemberStats: (request: GroupMemberStatsQuery): Promise<GroupMemberStatsResult> =>
|
||||
ipcRenderer.invoke('group-stats:getMemberStats', request),
|
||||
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
|
||||
ipcRenderer.invoke('group-exit-monitor:getState'),
|
||||
listGroupExitMonitorEvents: (query?: {
|
||||
roomId?: string
|
||||
sinceMs?: number
|
||||
untilMs?: number
|
||||
limit?: number
|
||||
}): Promise<GroupExitMonitorEvent[]> =>
|
||||
ipcRenderer.invoke('group-exit-monitor:listEvents', query),
|
||||
setGroupExitMonitorEnabled: (enabled: boolean): Promise<GroupExitMonitorState> =>
|
||||
ipcRenderer.invoke('group-exit-monitor:setEnabled', enabled),
|
||||
setGroupExitMonitorGroups: (
|
||||
|
||||
@@ -1639,6 +1639,17 @@ function App(): React.ReactElement {
|
||||
setActivePage('settings')
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳到「设置 · 本地索引」。
|
||||
*
|
||||
* 用在群统计这类场景:结果不完整的原因是索引没追平,用户需要一个**可操作的去处**,
|
||||
* 而不是只看到一句「结果可能不完整」却不知道去哪解决。
|
||||
*/
|
||||
const openLocalIndexSettings = (): void => {
|
||||
setSettingsCategory('local-index')
|
||||
setActivePage('settings')
|
||||
}
|
||||
|
||||
const openAgentHub = (): void => {
|
||||
setActivePage('agent-hub')
|
||||
}
|
||||
@@ -1907,6 +1918,7 @@ function App(): React.ReactElement {
|
||||
onCreateGroupReport={handleOpenReportWorkspace}
|
||||
onOpenTextToSpeechSettings={openTextToSpeechSettings}
|
||||
onOpenPersonalWechatSettings={openWechatSendSettings}
|
||||
onOpenLocalIndexSettings={openLocalIndexSettings}
|
||||
isAiLoading={reportGeneration.isGenerating}
|
||||
jumpToTime={archiveJumpTime}
|
||||
jumpToMessageId={archiveJumpMessageId}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { DataTrustBar } from './chat/DataTrustBar'
|
||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||
import { MessageList } from './chat/MessageList'
|
||||
import { PersonalWechatSendDialog } from './chat/PersonalWechatSendDialog'
|
||||
import { mergeGroupExitEvents } from '../../../shared/group-exit-event-message'
|
||||
import { useGroupExitEvents } from '../hooks/useGroupExitEvents'
|
||||
|
||||
interface ChatWindowProps {
|
||||
contact: Contact | null
|
||||
@@ -22,6 +24,8 @@ interface ChatWindowProps {
|
||||
onCreateGroupReport?: () => void
|
||||
onOpenTextToSpeechSettings?: () => void
|
||||
onOpenPersonalWechatSettings?: () => void
|
||||
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
|
||||
onOpenLocalIndexSettings?: () => void
|
||||
isAiLoading?: boolean
|
||||
jumpToTime?: number | null
|
||||
/** 精确跳转目标(消息 id)。与 jumpToTime 取或:任一存在就说明"这是一次跳转"。 */
|
||||
@@ -42,6 +46,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
onCreateGroupReport,
|
||||
onOpenTextToSpeechSettings,
|
||||
onOpenPersonalWechatSettings,
|
||||
onOpenLocalIndexSettings,
|
||||
isAiLoading = false,
|
||||
jumpToTime,
|
||||
jumpToMessageId
|
||||
@@ -122,8 +127,20 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群推断事件并入档案消息流。
|
||||
*
|
||||
* **先合并再过滤**:反过来的话搜索词就命中不了事件,而「谁退群了」
|
||||
* 恰恰是最需要能被搜到的内容之一。
|
||||
*/
|
||||
const groupExitEvents = useGroupExitEvents(contact)
|
||||
const allMessages = React.useMemo(
|
||||
() => (groupExitEvents.length ? mergeGroupExitEvents(messages, groupExitEvents) : messages),
|
||||
[messages, groupExitEvents]
|
||||
)
|
||||
|
||||
const filteredMessages = React.useMemo(() => {
|
||||
return messages.filter((msg) => {
|
||||
return allMessages.filter((msg) => {
|
||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||
.split(',')
|
||||
.map((type) => type.trim())
|
||||
@@ -132,7 +149,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const contentMatch = !contentFilter || msg.content.includes(contentFilter)
|
||||
return typeMatch && contentMatch
|
||||
})
|
||||
}, [messages, contentFilter])
|
||||
}, [allMessages, contentFilter])
|
||||
|
||||
const handleOpenPersonalWechatSend = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
@@ -168,6 +185,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
onRefreshData={onRefreshData}
|
||||
onTestSend={() => void handleOpenPersonalWechatSend()}
|
||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
|
||||
/>
|
||||
<DataTrustBar messageCount={messages.length} />
|
||||
<MessageList
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { ConversationContentSearch } from './ConversationContentSearch'
|
||||
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
|
||||
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
|
||||
import { GroupMemberStatsDialog } from '../group-stats/GroupMemberStatsDialog'
|
||||
|
||||
interface ChatHeaderProps {
|
||||
contact: Contact
|
||||
@@ -27,6 +28,8 @@ interface ChatHeaderProps {
|
||||
onRefreshData?: () => void
|
||||
onTestSend: () => void
|
||||
onOpenAiSettings: () => void
|
||||
/** 跳到「设置 · 本地索引」(群统计发现索引没追平时用)。 */
|
||||
onOpenLocalIndexSettings?: () => void
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
@@ -40,9 +43,11 @@ export function ChatHeader({
|
||||
onRefresh,
|
||||
onRefreshData,
|
||||
onTestSend,
|
||||
onOpenAiSettings
|
||||
onOpenAiSettings,
|
||||
onOpenLocalIndexSettings
|
||||
}: ChatHeaderProps): React.ReactElement {
|
||||
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
||||
const [statsOpen, setStatsOpen] = useState(false)
|
||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||
const typeLabel = isGroupChat ? '群聊' : '联系人'
|
||||
const visibleCount = contentFilter ? filteredCount : loadedCount
|
||||
@@ -93,12 +98,23 @@ export function ChatHeader({
|
||||
</IconButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton label="更多" tooltip="" variant="ghost" className="h-8 w-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="chat-header-text-action"
|
||||
aria-label="更多功能"
|
||||
title="更多功能"
|
||||
>
|
||||
<MoreIcon />
|
||||
</IconButton>
|
||||
<span>更多功能</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => onRefreshData?.()}>刷新数据</DropdownMenuItem>
|
||||
{/* 群发言统计只对群聊有意义;单聊没有「成员名单」这个概念。 */}
|
||||
{isGroupChat ? (
|
||||
<DropdownMenuItem onSelect={() => setStatsOpen(true)}>群发言统计</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{supportsPersonalWechatSend ? (
|
||||
@@ -148,6 +164,14 @@ export function ChatHeader({
|
||||
<span>{isAiLoading ? '生成中' : '生成 AI 日报'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{isGroupChat ? (
|
||||
<GroupMemberStatsDialog
|
||||
open={statsOpen}
|
||||
onOpenChange={setStatsOpen}
|
||||
contact={contact}
|
||||
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import type { Message } from '../../../../shared/types'
|
||||
|
||||
interface GroupExitEventBubbleProps {
|
||||
message: Message
|
||||
}
|
||||
|
||||
/**
|
||||
* 退群推断事件在档案里的展示。
|
||||
*
|
||||
* ⚠️ 与真实微信系统消息**必须视觉可区分**,原因不是审美:
|
||||
* 这条内容**不是微信说的话**,而是 TraceMemo 通过「群成员快照前后对比」推断出来的。
|
||||
* 两者的可信度完全不同 —— 微信系统消息是事实原文,这条是推断产物,
|
||||
* 而且它记录的是**检测时刻**(可能比真实退群时间晚几分钟到几天)。
|
||||
*
|
||||
* 所以它带一个「本地推断」标记,且样式与 `wechat-system-message` 明确区分。
|
||||
*/
|
||||
export function GroupExitEventBubble({ message }: GroupExitEventBubbleProps): React.ReactElement {
|
||||
return (
|
||||
<div className="group-exit-event-row">
|
||||
<div className="group-exit-event-bubble">
|
||||
<span className="group-exit-event-tag">本地推断</span>
|
||||
<span className="group-exit-event-text">{message.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
import { isGroupExitEventMessage } from '../../../../shared/group-exit-event-message'
|
||||
import { GroupExitEventBubble } from './GroupExitEventBubble'
|
||||
import { MessageBubble } from './MessageBubble'
|
||||
import { MessageGroupModel } from './messageGrouping'
|
||||
|
||||
@@ -26,11 +28,17 @@ export function MessageGroup({
|
||||
return (
|
||||
<>
|
||||
{group.timeLabel && <div className="chat-time-separator">{group.timeLabel}</div>}
|
||||
{group.messages.map((message) => (
|
||||
{group.messages.map((message) =>
|
||||
// 退群推断事件与真实微信系统消息都落在 isSystem 分支,
|
||||
// 但必须分开渲染:前者是本地推断,后者是微信原话。
|
||||
isGroupExitEventMessage(message) ? (
|
||||
<GroupExitEventBubble key={message.id} message={message} />
|
||||
) : (
|
||||
<div key={message.id} className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">{message.content}</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import {
|
||||
GROUP_STATS_RANGE_OPTIONS,
|
||||
formatActiveMemberStats,
|
||||
formatMemberLineName,
|
||||
formatSilentMemberStats,
|
||||
formatStatsDateTime,
|
||||
resolveGroupStatsRangeStart,
|
||||
type GroupMemberStatsResult,
|
||||
type GroupStatsRangeKey
|
||||
} from '../../../../shared/group-stats'
|
||||
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Tabs, TabsContent, TabsList, TabsTrigger } from '../ui'
|
||||
|
||||
interface GroupMemberStatsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
contact: Contact
|
||||
/** 跳到「设置 · 本地索引」,让用户能把没追平的索引处理掉。 */
|
||||
onOpenLocalIndexSettings?: () => void
|
||||
}
|
||||
|
||||
type CopyTarget = 'none' | 'active' | 'silent'
|
||||
|
||||
/**
|
||||
* 群发言统计面板(单群)。
|
||||
*
|
||||
* - **结果**由 main 的 `GroupStatsService` 给出,组件只展示,不自己拼业务文本;
|
||||
* - **可复制的文本**由 `shared/group-stats` 的 formatter 生成,保证「界面上看到的」
|
||||
* 与「复制出去的」不会各说各话;
|
||||
* - **名单不截断**:数字与列表必须能对上,否则用户会以为统计漏了人。
|
||||
* 列表靠 CSS 限高滚动,不靠丢数据。
|
||||
*/
|
||||
export function GroupMemberStatsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
contact,
|
||||
onOpenLocalIndexSettings
|
||||
}: GroupMemberStatsDialogProps): React.ReactElement {
|
||||
const [rangeKey, setRangeKey] = useState<GroupStatsRangeKey>('30d')
|
||||
const [customStart, setCustomStart] = useState('')
|
||||
const [customEnd, setCustomEnd] = useState('')
|
||||
const [result, setResult] = useState<GroupMemberStatsResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState<CopyTarget>('none')
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
/** 发送能力必须由 main 判定:renderer 侧的 platform 常量会误判 macOS Intel。 */
|
||||
const [canSendText, setCanSendText] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
const timeWindow = useMemo(() => {
|
||||
if (rangeKey === 'custom') {
|
||||
const start = customStart ? new Date(`${customStart}T00:00:00`).getTime() : NaN
|
||||
const end = customEnd ? new Date(`${customEnd}T23:59:59.999`).getTime() : NaN
|
||||
return { startTime: start, endTime: end }
|
||||
}
|
||||
const endTime = Date.now()
|
||||
return { startTime: resolveGroupStatsRangeStart(rangeKey, endTime), endTime }
|
||||
}, [rangeKey, customStart, customEnd])
|
||||
|
||||
const rangeReady = Number.isFinite(timeWindow.startTime) && Number.isFinite(timeWindow.endTime)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !rangeReady || !contact.md5) return
|
||||
let disposed = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
window.api
|
||||
.getGroupMemberStats({
|
||||
userMd5: contact.md5,
|
||||
startTime: timeWindow.startTime,
|
||||
endTime: timeWindow.endTime
|
||||
})
|
||||
.then((data) => {
|
||||
if (!disposed) setResult(data)
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!disposed) setError(loadError instanceof Error ? loadError.message : String(loadError))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [open, rangeReady, contact.md5, timeWindow.startTime, timeWindow.endTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let disposed = false
|
||||
window.api
|
||||
.getPersonalWechatSenderStatus()
|
||||
.then((status) => {
|
||||
if (!disposed) setCanSendText(status?.canSendText === true)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) setCanSendText(false)
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const silentText = useMemo(() => (result ? formatSilentMemberStats(result) : ''), [result])
|
||||
const activeText = useMemo(() => (result ? formatActiveMemberStats(result) : ''), [result])
|
||||
|
||||
const copy = useCallback(async (text: string, target: CopyTarget) => {
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(target)
|
||||
setNotice(null)
|
||||
setTimeout(() => setCopied('none'), 2000)
|
||||
} catch {
|
||||
setError('复制失败,请手动选择文本')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
if (!text || sending) return
|
||||
setSending(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
await window.api.sendPersonalWechatMessage({
|
||||
to: contact.m_nsUsrName,
|
||||
isGroup: true,
|
||||
type: 'text',
|
||||
text
|
||||
})
|
||||
setNotice('已发送到当前群')
|
||||
} catch (sendError: unknown) {
|
||||
setError(sendError instanceof Error ? sendError.message : '发送失败')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
},
|
||||
[contact.m_nsUsrName, sending]
|
||||
)
|
||||
|
||||
const stale = result !== null && result.freshness !== 'fresh'
|
||||
const groupTitle = contact.m_nsNickName || contact.m_nsUsrName
|
||||
/** 备注与群名相同时不重复展示,避免标题里出现两遍同一个人。 */
|
||||
const groupRemark = contact.remark && contact.remark !== groupTitle ? contact.remark : ''
|
||||
/** 选「全部」时 startTime 为 0,改用本机第一条消息的真实时间作为起点。 */
|
||||
const rangeStart = result
|
||||
? result.startTime > 0
|
||||
? result.startTime
|
||||
: result.firstMessageTime
|
||||
: null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>群发言统计 · {groupTitle}</DialogTitle>
|
||||
{groupRemark ? (
|
||||
<p className="group-stats-subtitle">群备注:{groupRemark}</p>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="group-stats-panel">
|
||||
<div className="group-stats-ranges">
|
||||
{GROUP_STATS_RANGE_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
variant={rangeKey === option.key ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setRangeKey(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
{rangeKey === 'custom' ? (
|
||||
<div className="group-stats-custom-range">
|
||||
<input
|
||||
type="date"
|
||||
aria-label="开始日期"
|
||||
value={customStart}
|
||||
onChange={(event) => setCustomStart(event.target.value)}
|
||||
/>
|
||||
<span>至</span>
|
||||
<input
|
||||
type="date"
|
||||
aria-label="结束日期"
|
||||
value={customEnd}
|
||||
onChange={(event) => setCustomEnd(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!rangeReady ? (
|
||||
<p className="group-stats-hint">请选择完整的开始与结束日期。</p>
|
||||
) : null}
|
||||
{stale ? (
|
||||
<div className="group-stats-stale">
|
||||
<p role="alert" className="group-stats-warning">
|
||||
本地索引尚未完全同步,以下结果可能不完整。
|
||||
</p>
|
||||
{onOpenLocalIndexSettings ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// 先关面板再跳转:否则设置页会被这个 Dialog 挡在后面。
|
||||
onOpenChange(false)
|
||||
onOpenLocalIndexSettings()
|
||||
}}
|
||||
>
|
||||
去建立索引
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="group-stats-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{notice ? <p className="group-stats-notice">{notice}</p> : null}
|
||||
|
||||
{loading ? <p className="group-stats-hint">统计中…</p> : null}
|
||||
|
||||
{result && !loading ? (
|
||||
<>
|
||||
<div className="group-stats-overview">
|
||||
<div className="group-stats-metric">
|
||||
<div className="group-stats-metric-label">当前群成员</div>
|
||||
<div className="group-stats-metric-value">{result.memberCount}</div>
|
||||
</div>
|
||||
<div className="group-stats-metric">
|
||||
<div className="group-stats-metric-label">发过言</div>
|
||||
<div className="group-stats-metric-value">{result.activeMemberCount}</div>
|
||||
</div>
|
||||
<div className="group-stats-metric">
|
||||
<div className="group-stats-metric-label">没发言</div>
|
||||
<div className="group-stats-metric-value">{result.silentMemberCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="group-stats-range-text">
|
||||
统计区间:
|
||||
{rangeStart
|
||||
? `${formatStatsDateTime(rangeStart)} ~ ${formatStatsDateTime(result.endTime)}`
|
||||
: `全部历史 ~ ${formatStatsDateTime(result.endTime)}`}
|
||||
{result.startTime <= 0 && rangeStart ? '(本机该群第一条消息起)' : null}
|
||||
</p>
|
||||
|
||||
{/* 两个名单用 tab 分开:一次只渲染一个列表,滚动压力减半,
|
||||
而且「复制/发送」跟它作用的内容处在同一个 tab 里,不会看错对象。 */}
|
||||
<Tabs defaultValue="active" className="group-stats-tabs">
|
||||
<TabsList className="group-stats-tabs-list">
|
||||
<TabsTrigger value="active">
|
||||
发言排行({result.activeMemberCount})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="silent">
|
||||
未发言统计({result.silentMemberCount})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="active">
|
||||
<div className="group-stats-section">
|
||||
<div className="group-stats-section-head">
|
||||
<h3>
|
||||
发言成员排行
|
||||
<span className="group-stats-section-count">
|
||||
(共 {result.activeMemberCount} 人)
|
||||
</span>
|
||||
</h3>
|
||||
<div className="group-stats-section-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copy(activeText, 'active')}
|
||||
>
|
||||
{copied === 'active' ? '已复制' : '复制'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{result.activeMembers.length === 0 ? (
|
||||
<p className="group-stats-empty">该区间内没有人发言。</p>
|
||||
) : (
|
||||
<ol className="group-stats-list">
|
||||
{result.activeMembers.map((member, index) => (
|
||||
<li key={member.senderId} className="group-stats-row">
|
||||
<span className="group-stats-row-name">
|
||||
<span className="group-stats-row-index">{index + 1}.</span>
|
||||
{formatMemberLineName(member)}
|
||||
</span>
|
||||
<span className="group-stats-row-meta">
|
||||
{member.messageCount} 条 ·{' '}
|
||||
{formatStatsDateTime(member.lastMessageTime)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="silent">
|
||||
<div className="group-stats-section">
|
||||
<div className="group-stats-section-head">
|
||||
<h3>
|
||||
未发言成员
|
||||
<span className="group-stats-section-count">
|
||||
(共 {result.silentMemberCount} 人)
|
||||
</span>
|
||||
</h3>
|
||||
<div className="group-stats-section-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copy(silentText, 'silent')}
|
||||
>
|
||||
{copied === 'silent' ? '已复制' : '复制'}
|
||||
</Button>
|
||||
{canSendText ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={sending}
|
||||
onClick={() => void send(silentText)}
|
||||
>
|
||||
{sending ? '发送中…' : '发送到当前群'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{result.silentMembers.length === 0 ? (
|
||||
<p className="group-stats-empty">全部成员在该区间内都发过言。</p>
|
||||
) : (
|
||||
<ol className="group-stats-list">
|
||||
{result.silentMembers.map((member, index) => (
|
||||
<li key={member.senderId} className="group-stats-row">
|
||||
<span className="group-stats-row-name">
|
||||
<span className="group-stats-row-index">{index + 1}.</span>
|
||||
{formatMemberLineName(member)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<p className="group-stats-notes">
|
||||
{result.unattributedMessages > 0
|
||||
? `另有 ${result.unattributedMessages} 条消息无法归属到具体成员(未计入任何人)。`
|
||||
: null}
|
||||
{result.excludedSystemMessages > 0
|
||||
? `已排除 ${result.excludedSystemMessages} 条系统消息。`
|
||||
: null}
|
||||
{result.limitations.join(' ')}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Progress,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '../ui'
|
||||
import {
|
||||
describeImageTextCoverage,
|
||||
imageTextCoverageState,
|
||||
imageTextPhaseLabel,
|
||||
imageTextProcessedPercent,
|
||||
imageTextStateLabel,
|
||||
imageTextStateTone
|
||||
} from '../../../../shared/image-text-index'
|
||||
import { useImageTextIndexStatus } from './hooks/useImageTextIndexStatus'
|
||||
import {
|
||||
IndexStatusBadge,
|
||||
type IndexStatusTone
|
||||
} from '../../features/settings/components/IndexStatusBadge'
|
||||
|
||||
interface ImageTextIndexSummaryProps {
|
||||
dbReady: boolean
|
||||
onNotice: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 「图片文字索引」卡片(**设置页宽版**)。
|
||||
*
|
||||
* 为什么不是直接复用 `ImageTextIndexCard`:那个组件是给**问问微信侧栏**写的,
|
||||
* 而侧栏最窄时正文只有约 145px(见 `search.scss` 的注释)。
|
||||
* 四列 metric 布局塞进 145px 会直接崩掉 —— 反过来让侧栏继续用表格布局又浪费设置页的宽度。
|
||||
* 所以两者**共享全部数据来源与文案函数**(`useImageTextIndexStatus` +
|
||||
* `shared/image-text-index`),只有**版式**不同。
|
||||
*
|
||||
* 状态徽章、统计数字、进度条都走公共组件/函数,不存在第二套计算口径。
|
||||
*/
|
||||
export function ImageTextIndexSummary({
|
||||
dbReady,
|
||||
onNotice
|
||||
}: ImageTextIndexSummaryProps): React.ReactElement {
|
||||
const {
|
||||
status,
|
||||
count,
|
||||
counting,
|
||||
pending,
|
||||
running,
|
||||
paused,
|
||||
established,
|
||||
refreshCount,
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
cancel,
|
||||
resetFailures,
|
||||
repair
|
||||
} = useImageTextIndexStatus({ dbReady, onNotice })
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [confirmCount, setConfirmCount] = useState<number | null>(null)
|
||||
/**
|
||||
* 处理范围(天)。`0` = 全部历史。
|
||||
*
|
||||
* 保留这个选择器是因为它来自真实业务能力,不是装饰:全量回填几万张图没法用来排查问题,
|
||||
* 先跑「最近 1 天」才能证明链路真的通了。
|
||||
*/
|
||||
const [rangeDays, setRangeDays] = useState('0')
|
||||
const sinceMs = useMemo(() => {
|
||||
const days = Number(rangeDays)
|
||||
return Number.isFinite(days) && days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : undefined
|
||||
}, [rangeDays])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dbReady) return
|
||||
void refreshCount(sinceMs)
|
||||
}, [dbReady, sinceMs, refreshCount])
|
||||
|
||||
const coverage = status?.coverage ?? null
|
||||
const progress = status?.progress ?? null
|
||||
const coverageState = coverage ? imageTextCoverageState(coverage) : 'not_built'
|
||||
const percent = coverage
|
||||
? imageTextProcessedPercent(coverage.processed, coverage.totalImageMessages)
|
||||
: 0
|
||||
const interrupted = paused || progress?.state === 'cancelled'
|
||||
const phaseLabel = progress?.currentPhase ? imageTextPhaseLabel(progress.currentPhase) : null
|
||||
|
||||
const labelInput = {
|
||||
progressState: progress?.state,
|
||||
running,
|
||||
paused,
|
||||
established,
|
||||
coverageState,
|
||||
percent
|
||||
}
|
||||
const stateLabel = imageTextStateLabel(labelInput)
|
||||
const tone = imageTextStateTone(labelInput) as IndexStatusTone
|
||||
|
||||
const detectedImages = count?.totalImageMessages ?? coverage?.totalImageMessages ?? null
|
||||
const nothingCounted =
|
||||
count !== null && count.scannedConversations === 0 && count.failedConversations > 0
|
||||
const failureCount = coverage?.failed ?? 0
|
||||
|
||||
const requestStart = async (): Promise<void> => {
|
||||
const fresh = await refreshCount(sinceMs)
|
||||
setConfirmCount(fresh?.totalImageMessages ?? detectedImages)
|
||||
setConfirming(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="local-index-card" aria-label="图片文字索引状态">
|
||||
<header className="local-index-card-head">
|
||||
<div className="local-index-card-title">
|
||||
<h3>图片文字索引</h3>
|
||||
<p>本地 OCR 提取图片文字,让截图、报价图也能被搜到</p>
|
||||
</div>
|
||||
<IndexStatusBadge label={stateLabel} tone={tone} />
|
||||
</header>
|
||||
|
||||
{/* 未建立:先给一个可核对的图片量,再让用户决定跑不跑 */}
|
||||
{!established && !running ? (
|
||||
<>
|
||||
<div className="local-index-stats">
|
||||
<div className="local-index-stat">
|
||||
<strong className="local-index-stat-value">
|
||||
{counting
|
||||
? '统计中…'
|
||||
: nothingCounted
|
||||
? '无法统计'
|
||||
: detectedImages === null
|
||||
? '—'
|
||||
: detectedImages.toLocaleString()}
|
||||
</strong>
|
||||
<span className="local-index-stat-label">检测到图片消息</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="local-index-inline">
|
||||
<span className="local-index-stat-label">处理范围</span>
|
||||
<Select value={rangeDays} onValueChange={setRangeDays}>
|
||||
<SelectTrigger className="h-7 w-[110px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部历史</SelectItem>
|
||||
<SelectItem value="1">近 1 天</SelectItem>
|
||||
<SelectItem value="7">近 7 天</SelectItem>
|
||||
<SelectItem value="30">近 30 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{nothingCounted ? (
|
||||
<p className="local-index-note is-error">
|
||||
{`无法统计本账号的图片消息(${count?.error || '读取消息表失败'})。这不代表没有图片,可以重新统计再试一次。`}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* 运行中 / 暂停:原地切进度,按钮按真实状态显隐,不允许重复点 */}
|
||||
{(running || paused) && progress ? (
|
||||
<div className="local-index-progress">
|
||||
<div className="local-index-progress-head">
|
||||
<span>识别图片文字</span>
|
||||
<span className="local-index-progress-count">
|
||||
{progress.processed.toLocaleString()} / {progress.totalImageMessages.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{/* 复用项目 `Progress`(更新页 / 导出任务中心同一组件),不另起一套轨道样式。 */}
|
||||
<Progress
|
||||
value={Math.min(100, Math.max(0, progress.percent))}
|
||||
aria-label="图片文字索引进度"
|
||||
/>
|
||||
<p className="local-index-note">
|
||||
{phaseLabel ?? '正在识别…'}
|
||||
{` · 识别出文字 ${progress.indexed.toLocaleString()} · 无文字 ${progress.empty.toLocaleString()}`}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 已建立:四个可核对的数字 */}
|
||||
{established && !running && !paused && coverage ? (
|
||||
<>
|
||||
<div className="local-index-stats is-four">
|
||||
<div className="local-index-stat">
|
||||
<strong className="local-index-stat-value">
|
||||
{coverage.indexed.toLocaleString()}
|
||||
</strong>
|
||||
<span className="local-index-stat-label">已识别文字</span>
|
||||
</div>
|
||||
<div className="local-index-stat">
|
||||
<strong className="local-index-stat-value">{coverage.empty.toLocaleString()}</strong>
|
||||
<span className="local-index-stat-label">无文字图片</span>
|
||||
</div>
|
||||
<div className="local-index-stat">
|
||||
<strong className="local-index-stat-value">
|
||||
{coverage.missing.toLocaleString()}
|
||||
</strong>
|
||||
<span className="local-index-stat-label">图片已清理</span>
|
||||
</div>
|
||||
<div className="local-index-stat">
|
||||
<strong className="local-index-stat-value">{coverage.failed.toLocaleString()}</strong>
|
||||
<span className="local-index-stat-label">识别失败</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{progress?.state === 'error' ? (
|
||||
<p className="local-index-note is-error">
|
||||
{progress.lastError || '图片文字索引建立失败,可以稍后重试。'}
|
||||
</p>
|
||||
) : null}
|
||||
{coverage?.systemicFailure === true ? (
|
||||
<p className="local-index-note is-error">
|
||||
{`${(coverage.failed ?? 0).toLocaleString()} 条处理失败,成功识别 0 条 —— 当前无法搜索图片中的文字。`}
|
||||
</p>
|
||||
) : null}
|
||||
{paused ? (
|
||||
<p className="local-index-note">已暂停。已识别的结果都保留了,点「继续」会从断点接着做。</p>
|
||||
) : null}
|
||||
{!dbReady ? (
|
||||
<p className="local-index-note is-error">请先连接微信数据,然后再建立图片文字索引。</p>
|
||||
) : null}
|
||||
|
||||
{/* footer:左侧覆盖量说明,右侧操作。按钮不再悬浮在 stats 右下角。 */}
|
||||
<div className="local-index-footer">
|
||||
{established && !running && !paused && coverage ? (
|
||||
<p className="local-index-note">{describeImageTextCoverage(coverage)}</p>
|
||||
) : null}
|
||||
<div className="local-index-actions">
|
||||
{running ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending !== null}
|
||||
onClick={() => void pause()}
|
||||
>
|
||||
{pending === 'pause' ? '暂停中…' : '暂停'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending !== null}
|
||||
onClick={() => void cancel()}
|
||||
>
|
||||
{pending === 'cancel' ? '取消中…' : '取消'}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* 中断过就一定要有「继续」:checkpoint 是保留的,续做不会从头开始。 */}
|
||||
{interrupted ? (
|
||||
<Button size="sm" disabled={pending !== null} onClick={() => void resume()}>
|
||||
{pending === 'resume' ? '继续中…' : '继续'}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{!running && !interrupted ? (
|
||||
<>
|
||||
{/* 修复类操作各自只在很窄的场景有用,收进「更多」;
|
||||
平铺出来会让用户面对四个都带「索引」字样的按钮。 */}
|
||||
{established || failureCount > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="更多操作"
|
||||
disabled={pending !== null}
|
||||
>
|
||||
···
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{established ? (
|
||||
<DropdownMenuItem disabled={pending !== null} onSelect={() => void repair()}>
|
||||
图片内容搜不到?修复搜索索引
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{failureCount > 0 ? (
|
||||
<DropdownMenuItem
|
||||
disabled={pending !== null}
|
||||
onSelect={() => void resetFailures()}
|
||||
>
|
||||
{`重试识别失败的图片(${failureCount.toLocaleString()} 张)`}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!dbReady || pending !== null || counting}
|
||||
onClick={() => void requestStart()}
|
||||
>
|
||||
{/* 统计图片数期间按钮是禁用的;这时如实说"统计中",
|
||||
而不是灰着一个写着「更新索引」却点不动的按钮。 */}
|
||||
{counting ? '统计中…' : established ? '更新索引' : '建立图片文字索引'}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 确认弹窗复用 `confirming` 状态;保持与侧栏卡片一致的二次确认语义。 */}
|
||||
{confirming ? (
|
||||
<div className="local-index-confirm" role="dialog" aria-label="建立图片文字索引">
|
||||
<p>
|
||||
当前账号检测到约{' '}
|
||||
<strong>
|
||||
{confirmCount === null ? '未知数量' : confirmCount.toLocaleString()} 条图片消息
|
||||
</strong>
|
||||
。
|
||||
</p>
|
||||
<p>
|
||||
识别仅在本机进行,原始图片不会因为本地识别而自动上传;可能需要较长时间,可以暂停稍后继续。
|
||||
</p>
|
||||
<div className="local-index-actions">
|
||||
<Button size="sm" variant="outline" onClick={() => setConfirming(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setConfirming(false)
|
||||
void start({ ...(sinceMs ? { sinceMs } : {}) })
|
||||
}}
|
||||
>
|
||||
开始索引
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import React from 'react'
|
||||
import { useKnowledgeStatus } from './hooks/useKnowledgeStatus'
|
||||
import {
|
||||
formatBytes,
|
||||
formatIndexDate,
|
||||
formatKnowledgeProcessed,
|
||||
knowledgeIsStale,
|
||||
knowledgeStateLabel
|
||||
} from './searchFormatters'
|
||||
import {
|
||||
IndexStatusBadge,
|
||||
type IndexStatusTone
|
||||
} from '../../features/settings/components/IndexStatusBadge'
|
||||
import { Button } from '../ui'
|
||||
|
||||
interface KnowledgeIndexCardProps {
|
||||
dbReady: boolean
|
||||
onNotice: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 「聊天记录索引」卡片 —— 问问微信背后那份索引。
|
||||
*
|
||||
* 布局与服务端语义分得很开:
|
||||
* - 数据全部来自 `useKnowledgeStatus`(与问问微信页**同一个 hook**,不存在第二套状态源);
|
||||
* - 文案全部来自 `searchFormatters`(那边的词汇表有约束:区分「正在追新」与「正在补齐历史」、
|
||||
* 不可查询时吃掉「可用」前缀、禁止笼统的「已同步」);
|
||||
* - 这里只负责**信息层级**:标题 → 状态徽章 → 三个核心数字 → 操作。
|
||||
*
|
||||
* 状态刻意从标题里拆出来单独做成徽章:混在标题行里时既没有视觉权重差、也扫不到。
|
||||
*/
|
||||
export function KnowledgeIndexCard({
|
||||
dbReady,
|
||||
onNotice
|
||||
}: KnowledgeIndexCardProps): React.ReactElement {
|
||||
const {
|
||||
knowledgeStatus,
|
||||
knowledgeSyncing,
|
||||
syncStarting,
|
||||
cancelRequested,
|
||||
startKnowledgeSync,
|
||||
cancelKnowledgeSync
|
||||
} = useKnowledgeStatus({ dbReady, onNotice })
|
||||
|
||||
const indexed = knowledgeStatus?.indexedMessageCount ?? 0
|
||||
const usable = indexed > 0 || (knowledgeStatus?.indexedChunkCount ?? 0) > 0
|
||||
const stale = knowledgeIsStale(knowledgeStatus)
|
||||
const failed = knowledgeStatus?.state === 'error'
|
||||
|
||||
/** 徽章色只用主题 token,不硬编码彩虹色。 */
|
||||
const tone: IndexStatusTone = failed
|
||||
? 'error'
|
||||
: !usable
|
||||
? 'idle'
|
||||
: stale || knowledgeSyncing
|
||||
? 'warn'
|
||||
: 'ok'
|
||||
|
||||
const cancellable = knowledgeStatus?.pass?.cancellable === true
|
||||
|
||||
return (
|
||||
<section className="local-index-card" aria-label="聊天记录索引状态">
|
||||
<header className="local-index-card-head">
|
||||
<div className="local-index-card-title">
|
||||
<h3>聊天记录索引</h3>
|
||||
<p>用于搜索、问问微信和群聊统计</p>
|
||||
</div>
|
||||
<IndexStatusBadge label={knowledgeStateLabel(knowledgeStatus)} tone={tone} />
|
||||
</header>
|
||||
|
||||
{usable ? (
|
||||
<div className="local-index-stats">
|
||||
<div className="local-index-stat">
|
||||
<strong className="local-index-stat-value">{indexed.toLocaleString()}</strong>
|
||||
<span className="local-index-stat-label">已收录消息</span>
|
||||
</div>
|
||||
<div className="local-index-stat">
|
||||
<strong className="local-index-stat-value">
|
||||
{formatBytes(knowledgeStatus?.databaseBytes ?? 0)}
|
||||
</strong>
|
||||
<span className="local-index-stat-label">占用空间</span>
|
||||
</div>
|
||||
<div className="local-index-stat">
|
||||
<strong className="local-index-stat-value">
|
||||
{knowledgeStatus?.indexLatestAt
|
||||
? formatIndexDate(knowledgeStatus.indexLatestAt)
|
||||
: '—'}
|
||||
</strong>
|
||||
<span className="local-index-stat-label">最后更新</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="local-index-note">
|
||||
还没有建立索引。建立后「问问微信」才能跨会话检索历史消息。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{failed && knowledgeStatus?.lastError ? (
|
||||
<p className="local-index-note is-error">{knowledgeStatus.lastError}</p>
|
||||
) : null}
|
||||
|
||||
{!dbReady ? (
|
||||
<p className="local-index-note is-error">请先连接微信数据,然后再建立聊天记录索引。</p>
|
||||
) : null}
|
||||
|
||||
{/* footer:左侧状态说明,右侧操作。按钮不再悬浮在 stats 右下角。 */}
|
||||
<div className="local-index-footer">
|
||||
<p className="local-index-note">
|
||||
{knowledgeSyncing && knowledgeStatus?.pass
|
||||
? formatKnowledgeProcessed(knowledgeStatus)
|
||||
: failed
|
||||
? '上次同步没有完成,可以重新同步。'
|
||||
: stale
|
||||
? '有较新的聊天记录尚未入索引,点「同步最新记录」追平。'
|
||||
: '索引数据仅保存在本机,可随时重新建立。'}
|
||||
</p>
|
||||
<div className="local-index-actions">
|
||||
{knowledgeSyncing && cancellable ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={cancelRequested}
|
||||
onClick={() => void cancelKnowledgeSync()}
|
||||
>
|
||||
{cancelRequested ? '正在取消…' : '取消同步'}
|
||||
</Button>
|
||||
) : null}
|
||||
{/* 用项目 `Button` 而不是自写 `<button>` + 自己的主色样式:
|
||||
`hsl(var(--tm-primary))` 在这套主题变量下不生效(实测背景回退成了浏览器默认灰),
|
||||
而复用组件同时还省掉了一套与全站不一致的按钮外观。 */}
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!dbReady || syncStarting || (knowledgeSyncing && !cancellable)}
|
||||
onClick={() => void startKnowledgeSync()}
|
||||
>
|
||||
{knowledgeSyncing ? '同步中…' : usable ? '同步最新记录' : '建立索引'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { AIModelPage } from './pages/AIModelPage'
|
||||
import { RecallProtectionPage } from './pages/RecallProtectionPage'
|
||||
import { AdvancedPage } from './pages/AdvancedPage'
|
||||
import { CacheCleanupPage } from './pages/CacheCleanupPage'
|
||||
import { LocalIndexPage } from './pages/LocalIndexPage'
|
||||
import { AppearancePage } from './pages/AppearancePage'
|
||||
import { AboutPage } from './pages/AboutPage'
|
||||
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
|
||||
@@ -120,6 +121,8 @@ export function SettingsWorkspace({
|
||||
return <AdvancedPage onNotice={onNotice} />
|
||||
case 'cache-cleanup':
|
||||
return <CacheCleanupPage onNotice={onNotice} />
|
||||
case 'local-index':
|
||||
return <LocalIndexPage onNotice={onNotice} dbReady={dbReady} />
|
||||
case 'storage-export':
|
||||
return (
|
||||
<SettingsEmptyState
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
|
||||
export type IndexStatusTone = 'ok' | 'warn' | 'error' | 'idle'
|
||||
|
||||
interface IndexStatusBadgeProps {
|
||||
label: string
|
||||
tone: IndexStatusTone
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引状态徽章(`● 已完成` 这种)。
|
||||
*
|
||||
* 存在的意义是把**状态**从标题里拆出来:之前状态是和卡片标题混在同一行的文字,
|
||||
* 既没有视觉权重差、也无法一眼扫到。
|
||||
*
|
||||
* 颜色只用项目既有的 theme token(`--wxex-success` / `--wxex-warning` / `--wxex-danger`),
|
||||
* 不另起一套彩虹色 —— 否则主题切换时会和整个应用脱节。
|
||||
*/
|
||||
export function IndexStatusBadge({ label, tone }: IndexStatusBadgeProps): React.ReactElement {
|
||||
return (
|
||||
<span className={`local-index-badge is-${tone}`}>
|
||||
<span className="local-index-badge-dot" aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,10 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
||||
items: [
|
||||
// “存储与导出”暂不开放;保留 category/render case 以兼容已有页面状态。
|
||||
// { id: 'storage-export', label: '存储与导出' },
|
||||
// 「本地索引」= 问问微信的聊天记录索引 + 图片文字索引。
|
||||
// 放在设置里是因为它属于「本地数据管理」,而原来只藏在问问微信页面里,
|
||||
// 用户根本不知道它是什么、更不知道还要手动同步。
|
||||
{ id: 'local-index', label: '本地索引' },
|
||||
{ id: 'cache-cleanup', label: '缓存与清理' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ export type SettingsCategoryId =
|
||||
| 'local-api'
|
||||
| 'storage-export'
|
||||
| 'cache-cleanup'
|
||||
| 'local-index'
|
||||
| 'appearance'
|
||||
| 'advanced'
|
||||
| 'about'
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import { ImageTextIndexSummary } from '../../../components/search/ImageTextIndexSummary'
|
||||
import { KnowledgeIndexCard } from '../../../components/search/KnowledgeIndexCard'
|
||||
|
||||
interface LocalIndexPageProps {
|
||||
onNotice: (message: string) => void
|
||||
dbReady: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 · 本地索引。
|
||||
*
|
||||
* 把「问问微信」背后的两份索引放到设置里集中管理 —— 之前它们只藏在搜索页,
|
||||
* 用户既不知道有这么个东西,也不知道它需要手动同步。
|
||||
*
|
||||
* 信息层级(从上到下):页面标题 → 一句话说明 → 两张索引卡片。
|
||||
* 卡片本身由 `KnowledgeIndexCard` / `ImageTextIndexSummary` 各自负责,
|
||||
* 本页只管**容器宽度与卡片间距**,不碰卡片内部布局。
|
||||
*/
|
||||
export function LocalIndexPage({ onNotice, dbReady }: LocalIndexPageProps): React.ReactElement {
|
||||
return (
|
||||
<div className="local-index-page">
|
||||
<header className="local-index-head">
|
||||
<h2>本地索引</h2>
|
||||
<p>
|
||||
TraceMemo 的搜索、问问微信和图片文字识别依赖本地索引。
|
||||
所有索引数据仅保存在本机,可随时从微信原始数据重新建立。
|
||||
</p>
|
||||
</header>
|
||||
<div className="local-index-cards">
|
||||
<KnowledgeIndexCard dbReady={dbReady} onNotice={onNotice} />
|
||||
<ImageTextIndexSummary dbReady={dbReady} onNotice={onNotice} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Contact } from '../../../shared/types'
|
||||
import type { GroupExitMonitorEvent } from '../../../shared/group-exit-monitor'
|
||||
|
||||
/**
|
||||
* 取某个群的退群推断事件(档案合并展示用)。
|
||||
*
|
||||
* 两条硬约束:
|
||||
* 1. **只对群聊请求** —— 单聊没有「群成员快照」这个概念;
|
||||
* 2. **任何失败都退化成「没有事件」** —— 退群事件是附加信息,
|
||||
* 拿不到它绝不能让档案本身的消息展示受影响。
|
||||
*/
|
||||
export function useGroupExitEvents(contact: Contact | null): GroupExitMonitorEvent[] {
|
||||
const [events, setEvents] = useState<GroupExitMonitorEvent[]>([])
|
||||
const roomId = contact?.type === 'group' ? contact.m_nsUsrName || '' : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) {
|
||||
setEvents([])
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
window.api
|
||||
.listGroupExitMonitorEvents({ roomId })
|
||||
.then((list) => {
|
||||
if (!disposed) setEvents(Array.isArray(list) ? list : [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) setEvents([])
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [roomId])
|
||||
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 档案里的「退群」推断事件。
|
||||
*
|
||||
* ⚠️ 视觉上必须与 `.wechat-system-message`(微信真实系统消息)**明确区分**:
|
||||
* 那是微信的原话,这条是 TraceMemo 通过「群成员快照前后对比」推断出来的 ——
|
||||
* 可信度不同,时间含义也不同(记录的是**检测时刻**,不是真实退群时间)。
|
||||
* 所以用虚线边框 + 「本地推断」标记,而不是套用系统消息的样式。
|
||||
*/
|
||||
|
||||
.group-exit-event-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
.group-exit-event-bubble {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
max-width: 82%;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed var(--wxex-warning);
|
||||
border-radius: var(--tm-radius-md, 6px);
|
||||
background: color-mix(in srgb, var(--wxex-warning) 8%, transparent);
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
|
||||
.group-exit-event-tag {
|
||||
flex: none;
|
||||
border-radius: 3px;
|
||||
background: var(--wxex-warning);
|
||||
color: #fff;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.group-exit-event-text {
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 群发言统计面板。
|
||||
*
|
||||
* ⚠️ 这里的样式**故意不写裸 Tailwind 布局类**:`tailwind.config.cjs` 设了
|
||||
* `preflight: false`(项目要保住既有 SCSS),于是 Tailwind 不提供任何基础重置 ——
|
||||
* 一旦用裸 Tailwind 搭结构,`<ol>` 会自带 `list-style` 与 `padding-left`、
|
||||
* `<h3>/<p>` 会自带 margin、`border` 只有 `border-width` 没有 `border-style`
|
||||
* (边框根本不显示)。所以按项目既有惯例:语义化类 + `--wxex-*` 变量。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 滚动策略:**整个面板只有一层滚动**。
|
||||
*
|
||||
* DialogContent 默认没有任何 max-height / overflow(见 `ui/dialog.tsx`),所以
|
||||
* 滚动条必须由我们自己给且**只给一处**:这里给面板容器,两个名单列表**不再各自滚动**。
|
||||
* 早先的写法是「面板 68vh 滚动 + 每个列表 320px 滚动」,三层滚动条叠在一起互相打架。
|
||||
*/
|
||||
|
||||
.group-stats-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
/* ---- 时间范围 ---- */
|
||||
|
||||
.group-stats-ranges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-stats-custom-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
|
||||
.group-stats-custom-range input {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--tm-radius-sm, 4px);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-primary);
|
||||
padding: 4px 6px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- 状态提示 ---- */
|
||||
|
||||
.group-stats-warning,
|
||||
.group-stats-error,
|
||||
.group-stats-notice,
|
||||
.group-stats-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.group-stats-warning {
|
||||
color: var(--wxex-warning);
|
||||
}
|
||||
|
||||
/** stale 提示行:左侧说明 + 右侧「去建立索引」的去处。 */
|
||||
.group-stats-stale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.group-stats-stale .group-stats-warning {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-stats-error {
|
||||
color: var(--wxex-danger);
|
||||
}
|
||||
|
||||
.group-stats-notice {
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
|
||||
.group-stats-hint {
|
||||
color: var(--wxex-text-secondary);
|
||||
}
|
||||
|
||||
/* ---- 概览 ---- */
|
||||
|
||||
.group-stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.group-stats-metric {
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--tm-radius-md, 6px);
|
||||
background: var(--wxex-bg-elevated);
|
||||
padding: 10px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.group-stats-metric-label {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.group-stats-metric-value {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.group-stats-range-text {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/** 群备注副标题:跟在标题下方,与标题同左对齐。 */
|
||||
.group-stats-subtitle {
|
||||
margin: 2px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* ---- 分节 ---- */
|
||||
|
||||
/**
|
||||
* 两个名单用 tab 分开:一次只渲染一个列表,滚动压力减半。
|
||||
* `ui/tabs.tsx` 的类不依赖 Tailwind preflight(全是属性类,不含基础重置),
|
||||
* 所以这里直接复用,只补一个对齐。
|
||||
*/
|
||||
.group-stats-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.group-stats-tabs-list {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.group-stats-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-stats-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.group-stats-section-head h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.group-stats-section-head .group-stats-section-count {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.group-stats-section-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-stats-empty {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- 名单 ---- */
|
||||
|
||||
.group-stats-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--tm-radius-md, 6px);
|
||||
background: var(--wxex-bg-app);
|
||||
/* 刻意**不**设 max-height / overflow:列表全量展开,
|
||||
滚动交给唯一的 .group-stats-panel。多一层滚动就多一条打架的滚动条。 */
|
||||
}
|
||||
|
||||
.group-stats-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 5px 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.group-stats-row + .group-stats-row {
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
}
|
||||
|
||||
.group-stats-row-name {
|
||||
color: var(--wxex-text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.group-stats-row-index {
|
||||
color: var(--wxex-text-muted);
|
||||
margin-right: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.group-stats-row-meta {
|
||||
flex: none;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.group-stats-notes {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -17,4 +17,7 @@
|
||||
@use './settings-preferences';
|
||||
@use './settings-text-to-speech';
|
||||
@use './group-exit-monitor';
|
||||
@use './group-exit-event';
|
||||
@use './group-member-stats';
|
||||
@use './local-index';
|
||||
@use './theme';
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 设置 · 本地索引页。
|
||||
*
|
||||
* 设计约束(来自页面目标):这是一个「本机索引管理面板」,不是两个独立开发工具卡片。
|
||||
* 层级自上而下:页面标题 → 一句话说明 → 卡片;卡片内部:标题 → 状态徽章 → 核心数字 → 操作。
|
||||
*
|
||||
* ⚠️ 不用裸 Tailwind:`tailwind.config.cjs` 设了 `preflight: false`,
|
||||
* 裸类拿不到浏览器默认样式重置(`<ol>` 的 list-style、`<h3>/<p>` 的 margin、
|
||||
* `border-style: none`),会直接崩掉版式。
|
||||
*/
|
||||
|
||||
.local-index-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
/* 页面级四周 padding:设置页 panel(.settings-page-panel)本身没有任何 padding,
|
||||
width:100% 会一路顶到窗口右缘 —— 右侧 gutter 必须在这里建立,而不是给卡片打 margin 补丁。
|
||||
上/左收紧、底部留足、右侧略宽(视觉重心),都在页面目标给的区间内。 */
|
||||
padding: 20px 32px 32px 24px;
|
||||
/* 内容宽度:太窄会挤,太宽会让数字和状态离得太远、读不到一行。
|
||||
1040 是 border-box 总宽(含 padding),内容区约 984px。
|
||||
用 width:100% 保证窄窗口下自然收缩,而不是硬撑着留白。 */
|
||||
width: 100%;
|
||||
max-width: 1040px;
|
||||
/* panel 比 max-width 宽时水平居中,左右 gutter 对称;
|
||||
窄窗口(panel < 1040)时它不生效,由 padding 负责两侧留白。 */
|
||||
margin-inline: auto;
|
||||
box-sizing: border-box;
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
/* ---- 页面标题 ---- */
|
||||
|
||||
.local-index-head h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.local-index-head p {
|
||||
margin: 0;
|
||||
max-width: 66ch;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ---- 卡片容器 ---- */
|
||||
|
||||
.local-index-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 标准垂直间距:两张卡片要看起来像一个连续模块,而不是两块被空白隔开的工具。 */
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.local-index-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-lg, 8px);
|
||||
background: var(--wxex-bg-elevated);
|
||||
padding: 16px 18px;
|
||||
/* 高度由内容自然撑开 —— 不设 min-height,不用空白凑版面。 */
|
||||
}
|
||||
|
||||
/* ---- 卡片头部:标题 + 状态徽章 ---- */
|
||||
|
||||
.local-index-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.local-index-card-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.local-index-card-title h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
.local-index-card-title p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---- 状态徽章(颜色全部走主题 token,不硬编码彩虹色) ---- */
|
||||
|
||||
.local-index-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: none;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 999px;
|
||||
background: var(--wxex-bg-sidebar);
|
||||
padding: 2px 10px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.local-index-badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentcolor;
|
||||
}
|
||||
|
||||
.local-index-badge.is-ok {
|
||||
border-color: color-mix(in srgb, var(--wxex-success) 45%, var(--wxex-border));
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
|
||||
.local-index-badge.is-warn {
|
||||
border-color: color-mix(in srgb, var(--wxex-warning) 55%, var(--wxex-border));
|
||||
color: var(--wxex-warning);
|
||||
}
|
||||
|
||||
.local-index-badge.is-error {
|
||||
border-color: color-mix(in srgb, var(--wxex-danger) 55%, var(--wxex-border));
|
||||
color: var(--wxex-danger);
|
||||
}
|
||||
|
||||
.local-index-badge.is-idle {
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
|
||||
/* ---- 核心数字:数字是主体,label 是注解 ---- */
|
||||
|
||||
.local-index-stats {
|
||||
display: grid;
|
||||
/* 窄窗口自动换行成单列;宽窗口排成 N 列,不写死列数。 */
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 14px 20px;
|
||||
}
|
||||
|
||||
.local-index-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.local-index-stat-value {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* 数字很长时允许收缩,不撑破卡片。 */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.local-index-stat-label {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---- 说明 / 状态行 ---- */
|
||||
|
||||
.local-index-note {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.local-index-note.is-error {
|
||||
color: var(--wxex-danger);
|
||||
}
|
||||
|
||||
.local-index-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ---- 进度 ---- */
|
||||
|
||||
.local-index-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.local-index-progress-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.local-index-progress-count {
|
||||
color: var(--wxex-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 进度轨道复用 `ui/progress.tsx`(Radix),这里不写第二套。 */
|
||||
|
||||
/* ---- Card footer:明确的 [状态说明] ←→ [操作按钮] 两栏 ----
|
||||
按钮不再悬浮在 stats 区域右下角,而是固定在卡片底栏右侧,
|
||||
左侧放覆盖量 / 同步状态这类辅助说明。 */
|
||||
|
||||
.local-index-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.local-index-footer > .local-index-note {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 窄窗口放不下时按钮整体换到第二行,仍靠右,不与说明文字挤在一行。 */
|
||||
.local-index-footer > .local-index-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- 操作区:右对齐,按钮宽度按内容,不占满整行 ---- */
|
||||
|
||||
.local-index-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 窗口很窄时才让主按钮撑满一行,避免按钮被压成碎块。 */
|
||||
@media (max-width: 520px) {
|
||||
.local-index-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.local-index-actions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 确认区(就地展开,不用 alert) ---- */
|
||||
|
||||
.local-index-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md, 6px);
|
||||
background: var(--wxex-bg-app);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.local-index-confirm p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
@@ -206,7 +206,6 @@
|
||||
.ai-search-knowledge-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid hsl(var(--tm-border-subtle));
|
||||
border-radius: var(--wxex-radius-md);
|
||||
@@ -218,6 +217,17 @@
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「把知识库卡片推到底部」**只对问问微信侧栏成立**。
|
||||
*
|
||||
* 这条规则原先写在 `.ai-search-knowledge-card` 基类上,于是任何复用该卡片的页面
|
||||
* (例如「设置 · 本地索引」)都会让每张卡片各自被推开一大段,卡片之间出现几百像素空白。
|
||||
* 现在限定在 `.ai-search-workspace` 作用域内 —— 这里才是它语义成立的地方。
|
||||
*/
|
||||
.ai-search-workspace .ai-search-knowledge-card {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.ai-search-knowledge-card.building,
|
||||
.ai-search-knowledge-card.syncing {
|
||||
border-color: color-mix(in srgb, var(--wxex-brand) 55%, var(--wxex-border));
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { GroupExitMonitorEvent } from './group-exit-monitor'
|
||||
import type { Message } from './types'
|
||||
|
||||
/**
|
||||
* 退群事件在档案消息流里的标记类型。
|
||||
*
|
||||
* 复用它自己的 `type` 值而不是靠 `from === 'system'` 判断,是因为**真实微信也会发系统消息**
|
||||
* (入群通知、撤回提示等)。两者都走系统消息的分支渲染,但必须能被区分出来 ——
|
||||
* 退群事件是**本地推断的产物**,不是微信的原话,展示上不能让人误以为是。
|
||||
*/
|
||||
export const GROUP_EXIT_EVENT_MESSAGE_TYPE = 'group-exit-event'
|
||||
|
||||
/** 这条档案消息是不是退群推断事件。 */
|
||||
export function isGroupExitEventMessage(message: Message): boolean {
|
||||
return message.type === GROUP_EXIT_EVENT_MESSAGE_TYPE
|
||||
}
|
||||
|
||||
const pad2 = (value: number): string => String(value).padStart(2, '0')
|
||||
|
||||
/** 本地时区的 `YYYY-MM-DD HH:mm`。 */
|
||||
function formatLocalDateTime(epochMs: number): string {
|
||||
const date = new Date(epochMs)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return (
|
||||
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +
|
||||
`${pad2(date.getHours())}:${pad2(date.getMinutes())}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个退群事件映射成档案里的消息条目。
|
||||
*
|
||||
* ⚠️ **单位陷阱**:`Message.createTime` 全项目都是 **epoch 秒**
|
||||
* (见 `messageGrouping.getMessageTime`:`new Date(timestamp * 1000)`),
|
||||
* 而事件的 `detectedAt` 是 **epoch 毫秒**。这里必须 `/1000`,
|
||||
* 否则事件会被排到 55000 年之后,或者干脆从时间轴上消失。
|
||||
*/
|
||||
export function toGroupExitEventMessage(event: GroupExitMonitorEvent): Message {
|
||||
const detectedAtMs = Number(event.detectedAt) || 0
|
||||
return {
|
||||
id: `group-exit:${event.id}`,
|
||||
from: 'system',
|
||||
type: GROUP_EXIT_EVENT_MESSAGE_TYPE,
|
||||
datetime: formatLocalDateTime(detectedAtMs),
|
||||
// 文本用事件自带的渲染结果(含"退群时间"等模板字段),不再二次拼接。
|
||||
content: event.message,
|
||||
isSender: false,
|
||||
senderId: event.memberWxid,
|
||||
name: event.memberName,
|
||||
createTime: Math.floor(detectedAtMs / 1000),
|
||||
sessionId: event.roomId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把退群事件合并进档案消息流,按时间升序排列(与 `MessageList` 的预期一致)。
|
||||
*
|
||||
* 已存在的同 id 消息不重复插入,所以重复调用是幂等的 ——
|
||||
* `getState` 会随着广播反复刷新,没有这层去重就会越插越多。
|
||||
*/
|
||||
export function mergeGroupExitEvents(
|
||||
messages: Message[],
|
||||
events: GroupExitMonitorEvent[]
|
||||
): Message[] {
|
||||
const existingIds = new Set(messages.map((message) => message.id))
|
||||
const extra = events
|
||||
.map(toGroupExitEventMessage)
|
||||
.filter((message) => !existingIds.has(message.id))
|
||||
if (!extra.length) return messages
|
||||
|
||||
const combined = [...messages, ...extra]
|
||||
combined.sort((a, b) => {
|
||||
const left = Number(a.createTime) || 0
|
||||
const right = Number(b.createTime) || 0
|
||||
if (left !== right) return left - right
|
||||
// 同一秒内保持输入顺序稳定,避免每次渲染都抖动。
|
||||
return 0
|
||||
})
|
||||
return combined
|
||||
}
|
||||
@@ -173,7 +173,10 @@ export function buildMemberLeftNotification(
|
||||
}
|
||||
|
||||
export interface GroupExitMonitorState {
|
||||
/** 最近一批事件(一次回报最多带回这么多条,避免把整部历史推过 IPC)。 */
|
||||
events: GroupExitMonitorEvent[]
|
||||
/** 永久保留的事件总数;`events` 只是其中最近的若干条。 */
|
||||
totalEventCount?: number
|
||||
enabled: boolean
|
||||
running: boolean
|
||||
nativeMonitorActive: boolean
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 群员统计的跨层契约与文本渲染。
|
||||
*
|
||||
* 放在 `shared` 的理由:renderer 需要结果类型做展示、需要 formatter 生成「复制」内容,
|
||||
* 而 formatter 是**纯函数**(不依赖 Electron / Node),正好符合 shared 的约束 ——
|
||||
* 业务文本绝不能由 React 组件拼接,否则复制出来的内容和界面上看到的会各说各话。
|
||||
*/
|
||||
|
||||
/** 窗口内有发言的成员。 */
|
||||
export interface GroupMemberStatsActiveMember {
|
||||
senderId: string
|
||||
/** 主显示名(微信昵称优先,与项目既有优先级一致)。 */
|
||||
displayName: string
|
||||
/**
|
||||
* 群昵称(这个人在本群里显示的名字)。
|
||||
*
|
||||
* 与 `displayName` 相同或为空时不展示 —— 同一个人在不同群里叫不同名字,
|
||||
* 只给一个名字会让人对不上号,但两个名字一样时重复展示又是噪声。
|
||||
*/
|
||||
groupNickname: string
|
||||
messageCount: number
|
||||
/** 该成员在窗口内最后一条消息的时间,Unix epoch 毫秒。 */
|
||||
lastMessageTime: number
|
||||
}
|
||||
|
||||
/** 窗口内没有发言记录的**当前**群成员。 */
|
||||
export interface GroupMemberStatsSilentMember {
|
||||
senderId: string
|
||||
displayName: string
|
||||
groupNickname: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生索引相对源数据的新鲜度。
|
||||
*
|
||||
* - `fresh` 索引已追到源数据最新,结果可以当作完整结论
|
||||
* - `stale` 索引落后于源数据,结果**必须**标注为可能不完整
|
||||
* - `unknown` 口径缺失(例如源侧无缓存),无法判定,同样不得当作完整结论
|
||||
*/
|
||||
export type GroupStatsFreshness = 'fresh' | 'stale' | 'unknown'
|
||||
|
||||
/**
|
||||
* 群员统计查询请求(IPC 契约)。
|
||||
*
|
||||
* 时间用 epoch **毫秒**:这一路完全走 Knowledge,而 Knowledge 的内部口径就是毫秒。
|
||||
* 不沿用聊天消息的秒级口径,是为了让 service 内部不存在秒/毫秒跨边界 ——
|
||||
* 单位换错是**静默读到 0 条**,不会抛错。
|
||||
*/
|
||||
export interface GroupMemberStatsQuery {
|
||||
/** 群会话 md5(= `md5(username)`),与 `getGroupSnapshot(userMd5)` 同口径。 */
|
||||
userMd5: string
|
||||
/** 统计窗口起(含),Unix epoch 毫秒。 */
|
||||
startTime: number
|
||||
/** 统计窗口止(含),Unix epoch 毫秒。 */
|
||||
endTime: number
|
||||
}
|
||||
|
||||
export interface GroupMemberStatsResult {
|
||||
conversationId: string
|
||||
/** 统计窗口起(含),Unix epoch 毫秒。 */
|
||||
startTime: number
|
||||
/** 统计窗口止(含),Unix epoch 毫秒。 */
|
||||
endTime: number
|
||||
freshness: GroupStatsFreshness
|
||||
/**
|
||||
* 是否可作为完整结论使用。
|
||||
*
|
||||
* **只有 `freshness === 'fresh'` 时才为 true。** 界面不得把 stale 的「未发言」
|
||||
* 名单包装成完整结论 —— 那是会把实际发过言的人挂到群里的错误否定。
|
||||
*/
|
||||
complete: boolean
|
||||
/** 当前群成员总数。 */
|
||||
memberCount: number
|
||||
activeMemberCount: number
|
||||
silentMemberCount: number
|
||||
/** 按 `messageCount` 降序;同数按最后发言时间降序。 */
|
||||
activeMembers: GroupMemberStatsActiveMember[]
|
||||
/** 按显示名排序,便于人眼核对。 */
|
||||
silentMembers: GroupMemberStatsSilentMember[]
|
||||
/**
|
||||
* 窗口内 `sender_id` 缺失、无法归属任何成员的消息数。
|
||||
*
|
||||
* 只用于诊断,不计入任何成员的条数 —— 硬塞给某个人会制造假的「有发言」。
|
||||
*/
|
||||
unattributedMessages: number
|
||||
/** 窗口内被排除的系统消息数(微信侧 10000 / 10002 等)。 */
|
||||
excludedSystemMessages: number
|
||||
/**
|
||||
* 窗口内**最早一条消息**的时间(epoch ms);null 表示窗口内没有消息。
|
||||
*
|
||||
* 选「全部」时用它显示真实起点 ——「本机这个群第一条消息是 2020-02-02」,
|
||||
* 比「全部历史」这种含糊说法有用得多。
|
||||
*/
|
||||
firstMessageTime: number | null
|
||||
/** 必须展示给用户的边界说明;为空表示无需额外声明。 */
|
||||
limitations: string[]
|
||||
}
|
||||
|
||||
/** 未发言统计必须附带的边界说明(**不要删掉这个含义**)。 */
|
||||
export const GROUP_STATS_LIMITATION =
|
||||
'统计基于当前群成员名单与本地已归档消息。窗口期前入群、统计期间退群或加入的成员,以及未归档时段,可能影响结果,仅供参考。'
|
||||
|
||||
/** 索引未追平时必须展示的额外说明。 */
|
||||
export const GROUP_STATS_STALE_LIMITATION =
|
||||
'本地索引尚未完全同步,以下结果可能不完整。'
|
||||
|
||||
/** 统计时间范围的快捷选项。 */
|
||||
export type GroupStatsRangeKey = '7d' | '30d' | '90d' | 'all' | 'custom'
|
||||
|
||||
export const GROUP_STATS_RANGE_OPTIONS: Array<{ key: GroupStatsRangeKey; label: string }> = [
|
||||
{ key: '7d', label: '近 7 天' },
|
||||
{ key: '30d', label: '近 30 天' },
|
||||
{ key: '90d', label: '近 90 天' },
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'custom', label: '自定义' }
|
||||
]
|
||||
|
||||
const RANGE_DAYS: Record<'7d' | '30d' | '90d', number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90
|
||||
}
|
||||
|
||||
/**
|
||||
* 由「结束时刻 + 快捷范围」推出起点(epoch ms)。
|
||||
*
|
||||
* 用固定 24h 天数而不是日历日:用户要的是「最近 30 天」这种滑动窗口,
|
||||
* 日历日会随月份长度漂移,滑窗不会。
|
||||
*
|
||||
* `all` 返回 0(Unix epoch 起点)—— 语义是「不放下界」,覆盖全部已归档历史。
|
||||
*/
|
||||
export function resolveGroupStatsRangeStart(
|
||||
key: Exclude<GroupStatsRangeKey, 'custom'>,
|
||||
endTime: number
|
||||
): number {
|
||||
if (key === 'all') return 0
|
||||
return endTime - RANGE_DAYS[key] * 24 * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
const DIVIDER = '--------------------'
|
||||
|
||||
/**
|
||||
* 名单一行里的名字:`主名(群昵称)`。
|
||||
*
|
||||
* 同一个人在微信里和在这个群里可能叫两个名字,只给一个会让人对不上号;
|
||||
* 但两者相同时重复展示又是纯噪声,所以只在**真的不同**时才加括号。
|
||||
*/
|
||||
export function formatMemberLineName(member: {
|
||||
displayName: string
|
||||
groupNickname: string
|
||||
}): string {
|
||||
if (member.groupNickname && member.groupNickname !== member.displayName) {
|
||||
return `${member.displayName}(${member.groupNickname})`
|
||||
}
|
||||
return member.displayName
|
||||
}
|
||||
|
||||
const pad2 = (value: number): string => String(value).padStart(2, '0')
|
||||
|
||||
/** 本地时区的 `YYYY-MM-DD`。 */
|
||||
export function formatStatsDate(epochMs: number): string {
|
||||
const date = new Date(epochMs)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
|
||||
}
|
||||
|
||||
/** 本地时区的 `YYYY-MM-DD HH:mm`。 */
|
||||
export function formatStatsDateTime(epochMs: number): string {
|
||||
const date = new Date(epochMs)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${formatStatsDate(epochMs)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 区间的人类可读文案。
|
||||
*
|
||||
* 选「全部」时 `startTime === 0`(不放下界),此时用 `firstMessageTime`
|
||||
* 显示**本机这个群第一条消息**的真实日期 ——「2020-02-02 至 2026-09-20」比
|
||||
* 「全部历史」有用得多。两者都拿不到时才退化成「全部历史」,
|
||||
* 且绝不格式化成 1970-01-01 假装统计真的从那儿开始。
|
||||
*/
|
||||
const rangeLabel = (
|
||||
result: Pick<GroupMemberStatsResult, 'startTime' | 'endTime' | 'firstMessageTime'>
|
||||
): string => {
|
||||
const start = result.startTime > 0 ? result.startTime : result.firstMessageTime
|
||||
if (start && start > 0) {
|
||||
return `${formatStatsDate(start)} 至 ${formatStatsDate(result.endTime)}`
|
||||
}
|
||||
return `全部历史(至 ${formatStatsDate(result.endTime)})`
|
||||
}
|
||||
|
||||
/**
|
||||
* 未发言名单的纯文本模板。
|
||||
*
|
||||
* 刻意使用纯文本而非 Markdown:这段文本的归宿是聊天窗口,任何富文本标记
|
||||
* 在微信里都会变成裸露的符号。
|
||||
*/
|
||||
export function formatSilentMemberStats(
|
||||
result: GroupMemberStatsResult,
|
||||
options: { limit?: number } = {}
|
||||
): string {
|
||||
const limit = options.limit && options.limit > 0 ? options.limit : result.silentMembers.length
|
||||
const listed = result.silentMembers.slice(0, limit)
|
||||
|
||||
const lines = [
|
||||
'未发言统计',
|
||||
`以下当前群成员在 ${rangeLabel(result)} 的本地已归档消息中无发言记录`,
|
||||
DIVIDER
|
||||
]
|
||||
|
||||
if (listed.length === 0) {
|
||||
lines.push('(无)')
|
||||
} else {
|
||||
listed.forEach((member, index) => {
|
||||
lines.push(`${index + 1}. ${formatMemberLineName(member)}`)
|
||||
})
|
||||
if (listed.length < result.silentMembers.length) {
|
||||
lines.push(`… 其余 ${result.silentMembers.length - listed.length} 人未列出`)
|
||||
}
|
||||
}
|
||||
|
||||
const notes = [...result.limitations]
|
||||
if (notes.length) {
|
||||
lines.push('', `注:${notes.join(' ')}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** 活跃榜的纯文本模板。 */
|
||||
export function formatActiveMemberStats(
|
||||
result: GroupMemberStatsResult,
|
||||
options: { limit?: number } = {}
|
||||
): string {
|
||||
const limit = options.limit && options.limit > 0 ? options.limit : result.activeMembers.length
|
||||
const listed = result.activeMembers.slice(0, limit)
|
||||
|
||||
const lines = [
|
||||
'群聊活跃统计',
|
||||
`统计时间:${rangeLabel(result)}`,
|
||||
DIVIDER
|
||||
]
|
||||
|
||||
if (listed.length === 0) {
|
||||
lines.push('(无)')
|
||||
} else {
|
||||
listed.forEach((member, index) => {
|
||||
lines.push(`${index + 1}. ${formatMemberLineName(member)} ${member.messageCount} 条`)
|
||||
})
|
||||
if (listed.length < result.activeMembers.length) {
|
||||
lines.push(`… 其余 ${result.activeMembers.length - listed.length} 人未列出`)
|
||||
}
|
||||
}
|
||||
|
||||
const notes = [...result.limitations]
|
||||
if (notes.length) {
|
||||
lines.push('', `注:${notes.join(' ')}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -878,3 +878,48 @@ export interface ImageTextIndexStatus {
|
||||
/** 各阶段耗时画像(可选的附加诊断字段,UI 不渲染)。 */
|
||||
stageTimings?: ImageTextIndexStageTimings
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片索引的状态文案。
|
||||
*
|
||||
* **两处 UI 共用**(问问微信侧栏卡片 / 设置页宽版卡片)—— 各写一份必然会分叉,
|
||||
* 而这段文案里有几条不能丢的语义:
|
||||
* - 「已建立」不能等于「全失败」(`coverageState === 'failed'` 必须叫异常);
|
||||
* - 「取消」不等于「部分完成」(进度保留,但被打断过这件事要说出来);
|
||||
* - 未完成时进度封顶 99.9%(`imageTextProcessedPercent` 保证),不允许显示成 100%。
|
||||
*
|
||||
* 参数用宽松类型而不是具体 interface:调用方两边的局部状态形状不同(侧栏卡片多几个
|
||||
* 派生布尔值),传具体类型会逼着两边先做一次无用适配。
|
||||
*/
|
||||
export function imageTextStateLabel(input: {
|
||||
progressState?: string
|
||||
running: boolean
|
||||
paused: boolean
|
||||
established: boolean
|
||||
coverageState: string
|
||||
percent: number
|
||||
}): string {
|
||||
if (input.progressState === 'error') return '建立失败'
|
||||
if (input.running) return `建立中 · ${input.percent}%`
|
||||
if (input.paused) return `已暂停 · ${input.percent}%`
|
||||
if (input.progressState === 'cancelled') return `已取消 · ${input.percent}%`
|
||||
if (!input.established) return '未建立'
|
||||
if (input.coverageState === 'failed') return '图片文字索引异常'
|
||||
if (input.coverageState === 'complete') return '已完成'
|
||||
return `部分完成 · ${input.percent}%`
|
||||
}
|
||||
|
||||
/** 状态徽章色调(两处 UI 共用同一套映射)。 */
|
||||
export function imageTextStateTone(input: {
|
||||
progressState?: string
|
||||
running: boolean
|
||||
paused: boolean
|
||||
established: boolean
|
||||
coverageState: string
|
||||
}): 'ok' | 'warn' | 'error' | 'idle' {
|
||||
if (input.progressState === 'error' || input.coverageState === 'failed') return 'error'
|
||||
if (!input.established) return 'idle'
|
||||
if (input.running || input.paused || input.progressState === 'cancelled') return 'warn'
|
||||
if (input.coverageState === 'complete') return 'ok'
|
||||
return 'warn'
|
||||
}
|
||||
|
||||
+68
-1
@@ -491,6 +491,62 @@ export interface KnowledgeStatusRequest {
|
||||
fts: KnowledgeFtsConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个会话内的「按发送者聚合」统计请求。
|
||||
*
|
||||
* 存在的理由:群员统计(谁说话了、说了多少条、谁一个字没说)只需要
|
||||
* `GROUP BY sender_id` 的聚合结果,**不需要**把消息逐条搬到主进程再算。
|
||||
*
|
||||
* 刻意不做成 `KnowledgeQuery` 的一个分支:检索是「找内容」,这里是「数人头」,
|
||||
* 两者的谓词与返回形状没有交集,塞在一起会让两端都长出用不到的条件分支。
|
||||
*/
|
||||
export interface KnowledgeMemberStatsRequest {
|
||||
accountId: string
|
||||
databaseRoot: string
|
||||
fts: KnowledgeFtsConfig
|
||||
/** 会话 md5(= `md5(username)`),不是原始 `@chatroom`。 */
|
||||
conversationId: string
|
||||
/** Unix epoch **毫秒**,闭区间。与 knowledge 内部口径一致,不经过 WCDB 的秒级边界。 */
|
||||
startTime: number
|
||||
/** Unix epoch **毫秒**,闭区间。 */
|
||||
endTime: number
|
||||
}
|
||||
|
||||
/** 单个发送者在指定会话 / 时间窗内的发言聚合。 */
|
||||
export interface KnowledgeMemberStatRow {
|
||||
senderId: string
|
||||
messageCount: number
|
||||
/** 该发送者在窗口内最后一条消息的时间(epoch ms)。 */
|
||||
lastMessageTime: number
|
||||
}
|
||||
|
||||
export interface KnowledgeMemberStatsResult {
|
||||
conversationId: string
|
||||
/** 窗口内、排除系统消息之后的总消息数(**含**未归属消息)。 */
|
||||
totalMessages: number
|
||||
/** 按 `messageCount` 降序。 */
|
||||
senders: KnowledgeMemberStatRow[]
|
||||
/**
|
||||
* 窗口内 `sender_id` 缺失的消息数。
|
||||
*
|
||||
* 这些消息**不得归属任何成员**:把它们硬塞给某个人,会让「未发言」名单出现
|
||||
* 错误否定,而这份名单是会被发到群里的。
|
||||
*/
|
||||
unattributedMessages: number
|
||||
/** 窗口内被 `kind = 'system'` 排除的消息数。 */
|
||||
excludedSystemMessages: number
|
||||
/**
|
||||
* 窗口内**最早一条消息**的时间(epoch ms),**不过滤 kind**。
|
||||
*
|
||||
* 用途:选「全部」时向用户显示真实的数据起点(「本机这个群第一条消息是什么时候」),
|
||||
* 而不是含糊的「全部历史」。刻意不过滤系统消息 —— 群的第一条往往就是建群通知,
|
||||
* 那才是用户认知里的「第一条」。
|
||||
*/
|
||||
earliestMessageTime: number | null
|
||||
/** 派生索引里最新一条消息的时间(epoch ms);null 表示无法判定。 */
|
||||
indexLatestAt: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引「已覆盖到的源数据时间」与源数据「当前最新活跃时间」之间允许的固定落差。
|
||||
*
|
||||
@@ -515,7 +571,16 @@ export function isKnowledgeFresh(
|
||||
|
||||
export interface KnowledgeWorkerRequest {
|
||||
version: 1
|
||||
type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close' | 'highWater'
|
||||
type:
|
||||
| 'index'
|
||||
| 'preflight'
|
||||
| 'search'
|
||||
| 'status'
|
||||
| 'remove'
|
||||
| 'cancel'
|
||||
| 'close'
|
||||
| 'highWater'
|
||||
| 'memberStats'
|
||||
requestId: string
|
||||
/** Parent monotonic wall-clock used only for transport timing. */
|
||||
sentAt?: number
|
||||
@@ -524,6 +589,7 @@ export interface KnowledgeWorkerRequest {
|
||||
| KnowledgeCapacityPreflightRequest
|
||||
| KnowledgeSearchRequest
|
||||
| KnowledgeStatusRequest
|
||||
| KnowledgeMemberStatsRequest
|
||||
| { accountId: string; databaseRoot: string }
|
||||
| { targetRequestId: string }
|
||||
| Record<string, never>
|
||||
@@ -539,6 +605,7 @@ export interface KnowledgeWorkerResponse {
|
||||
| KnowledgeCapacityPreflight
|
||||
| KnowledgeSearchResult
|
||||
| KnowledgeRuntimeStatus
|
||||
| KnowledgeMemberStatsResult
|
||||
| { removed: true }
|
||||
error?: string
|
||||
transport?: {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChatHeader } from '../../src/renderer/src/components/chat/ChatHeader'
|
||||
import { TooltipProvider } from '../../src/renderer/src/components/ui'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
|
||||
const groupContact: Contact = {
|
||||
m_nsUsrName: '研发群@chatroom',
|
||||
m_nsNickName: '研发群',
|
||||
md5: 'g'.repeat(32),
|
||||
type: 'group'
|
||||
}
|
||||
|
||||
const userContact: Contact = {
|
||||
m_nsUsrName: 'wxid_friend',
|
||||
m_nsNickName: '好友',
|
||||
md5: 'u'.repeat(32),
|
||||
type: 'user'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
;(window as unknown as { api: Record<string, unknown> }).api = {
|
||||
getGroupMemberStats: vi.fn(),
|
||||
getPersonalWechatSenderStatus: vi.fn().mockResolvedValue({ canSendText: false })
|
||||
}
|
||||
})
|
||||
|
||||
function renderHeader(contact: Contact, isGroupChat: boolean): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ChatHeader
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
loadedCount={10}
|
||||
filteredCount={10}
|
||||
contentFilter=""
|
||||
isAiLoading={false}
|
||||
onContentFilterChange={vi.fn()}
|
||||
onTestSend={vi.fn()}
|
||||
onOpenAiSettings={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
async function openMoreMenu(): Promise<void> {
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: '更多功能' }))
|
||||
await waitFor(() => expect(screen.getByText('刷新数据')).toBeTruthy())
|
||||
}
|
||||
|
||||
describe('ChatHeader · 群发言统计入口', () => {
|
||||
it('群聊显示「群发言统计」入口', async () => {
|
||||
renderHeader(groupContact, true)
|
||||
await openMoreMenu()
|
||||
expect(screen.getByText('群发言统计')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('非群聊不显示「群发言统计」入口', async () => {
|
||||
renderHeader(userContact, false)
|
||||
await openMoreMenu()
|
||||
expect(screen.queryByText('群发言统计')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -53,7 +53,7 @@ describe('chat menus', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '更多' })
|
||||
const trigger = screen.getByRole('button', { name: '更多功能' })
|
||||
await user.click(trigger)
|
||||
expect(screen.getByRole('menuitem', { name: '刷新数据' })).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { GroupMemberStatsDialog } from '../../src/renderer/src/components/group-stats/GroupMemberStatsDialog'
|
||||
import {
|
||||
GROUP_STATS_LIMITATION,
|
||||
GROUP_STATS_STALE_LIMITATION,
|
||||
type GroupMemberStatsResult
|
||||
} from '../../src/shared/group-stats'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
|
||||
const contact: Contact = {
|
||||
m_nsUsrName: '研发群@chatroom',
|
||||
m_nsNickName: '研发群',
|
||||
remark: '公司研发大群',
|
||||
md5: 'g'.repeat(32),
|
||||
type: 'group'
|
||||
}
|
||||
|
||||
const at = (year: number, month: number, day: number): number =>
|
||||
new Date(year, month - 1, day).getTime()
|
||||
|
||||
const baseResult = (
|
||||
overrides: Partial<GroupMemberStatsResult> = {}
|
||||
): GroupMemberStatsResult => ({
|
||||
conversationId: contact.md5,
|
||||
startTime: at(2026, 6, 15),
|
||||
endTime: at(2026, 9, 4),
|
||||
freshness: 'fresh',
|
||||
complete: true,
|
||||
memberCount: 5,
|
||||
activeMemberCount: 3,
|
||||
silentMemberCount: 2,
|
||||
activeMembers: [
|
||||
{
|
||||
senderId: 'wxid_a',
|
||||
displayName: '张三',
|
||||
groupNickname: '老张',
|
||||
messageCount: 328,
|
||||
lastMessageTime: at(2026, 9, 1)
|
||||
},
|
||||
{
|
||||
senderId: 'wxid_b',
|
||||
displayName: '李四',
|
||||
groupNickname: '',
|
||||
messageCount: 217,
|
||||
lastMessageTime: at(2026, 9, 2)
|
||||
},
|
||||
{
|
||||
senderId: 'wxid_c',
|
||||
displayName: '王五',
|
||||
groupNickname: '',
|
||||
messageCount: 196,
|
||||
lastMessageTime: at(2026, 9, 3)
|
||||
}
|
||||
],
|
||||
silentMembers: [
|
||||
{ senderId: 'wxid_d', displayName: '轩轩', groupNickname: '轩轩子' },
|
||||
{ senderId: 'wxid_e', displayName: '十二', groupNickname: '' }
|
||||
],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
firstMessageTime: at(2026, 6, 15),
|
||||
limitations: [GROUP_STATS_LIMITATION],
|
||||
...overrides
|
||||
})
|
||||
|
||||
const getGroupMemberStats = vi.fn()
|
||||
const getPersonalWechatSenderStatus = vi.fn()
|
||||
const sendPersonalWechatMessage = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
getGroupMemberStats.mockReset().mockResolvedValue(baseResult())
|
||||
getPersonalWechatSenderStatus.mockReset().mockResolvedValue({ canSendText: false })
|
||||
sendPersonalWechatMessage.mockReset().mockResolvedValue({ ok: true })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) }
|
||||
})
|
||||
;(window as unknown as { api: Record<string, unknown> }).api = {
|
||||
getGroupMemberStats,
|
||||
getPersonalWechatSenderStatus,
|
||||
sendPersonalWechatMessage
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const renderDialog = () =>
|
||||
render(<GroupMemberStatsDialog open onOpenChange={vi.fn()} contact={contact} />)
|
||||
|
||||
/**
|
||||
* Radix Tabs 只挂载 active 的内容,未发言名单要先切过去才存在。
|
||||
*
|
||||
* ⚠️ 必须先等 tab 出现:数据没加载完时整个结果区(含 Tabs)都还没渲染,
|
||||
* 直接 `getByRole('tab')` 会因为「DOM 里还没有 tab」而失败。
|
||||
*/
|
||||
const switchToSilentTab = async (): Promise<void> => {
|
||||
const user = userEvent.setup()
|
||||
const tab = await screen.findByRole('tab', { name: /未发言统计/ })
|
||||
await user.click(tab)
|
||||
await waitFor(() => expect(screen.getByText(/轩轩/)).toBeTruthy())
|
||||
}
|
||||
|
||||
describe('GroupMemberStatsDialog', () => {
|
||||
it('展示群备注与三个概览数字', async () => {
|
||||
renderDialog()
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/张三/)).toBeTruthy())
|
||||
expect(screen.getByText('群备注:公司研发大群')).toBeTruthy()
|
||||
expect(screen.getByText('当前群成员')).toBeTruthy()
|
||||
expect(screen.getByText('发过言')).toBeTruthy()
|
||||
expect(screen.getByText('没发言')).toBeTruthy()
|
||||
expect(screen.getByText(/共 3 人/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('默认展示「发言排行」tab,且不渲染未发言名单', async () => {
|
||||
renderDialog()
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/张三/)).toBeTruthy())
|
||||
expect(screen.getByText(/李四/)).toBeTruthy()
|
||||
// 未发言的人此时不应出现在 DOM 里,避免两个名单互相干扰
|
||||
expect(screen.queryByText(/轩轩/)).toBeNull()
|
||||
})
|
||||
|
||||
it('切到「未发言统计」tab 后才展示未发言名单', async () => {
|
||||
renderDialog()
|
||||
await waitFor(() => expect(screen.getByText(/张三/)).toBeTruthy())
|
||||
|
||||
await switchToSilentTab()
|
||||
|
||||
expect(screen.getByText(/十二/)).toBeTruthy()
|
||||
expect(screen.getByText(/共 2 人/)).toBeTruthy()
|
||||
// 切过去之后也不应再看到发言榜的人
|
||||
expect(screen.queryByText(/张三/)).toBeNull()
|
||||
})
|
||||
|
||||
it('canSendText = false 时只提供复制,不出现发送按钮', async () => {
|
||||
renderDialog()
|
||||
await switchToSilentTab()
|
||||
|
||||
expect(screen.queryByRole('button', { name: '发送到当前群' })).toBeNull()
|
||||
expect(screen.getAllByRole('button', { name: '复制' }).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('canSendText = true 时出现发送按钮', async () => {
|
||||
getPersonalWechatSenderStatus.mockResolvedValue({ canSendText: true })
|
||||
renderDialog()
|
||||
await switchToSilentTab()
|
||||
|
||||
expect(screen.getByRole('button', { name: '发送到当前群' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('复制走 formatter 生成的完整文本', async () => {
|
||||
renderDialog()
|
||||
await switchToSilentTab()
|
||||
|
||||
const user = userEvent.setup()
|
||||
// userEvent.setup() 会安装它自己的 clipboard stub,覆盖 beforeEach 里的定义,
|
||||
// 所以必须在 setup 之后再 spy,否则断言的是一个永远不会被调用的替身。
|
||||
const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText')
|
||||
await user.click(screen.getByRole('button', { name: '复制' }))
|
||||
|
||||
await waitFor(() => expect(clipboardSpy).toHaveBeenCalled())
|
||||
const text = clipboardSpy.mock.calls[0][0] as string
|
||||
expect(text).toContain('未发言统计')
|
||||
expect(text).toContain('1. 轩轩')
|
||||
expect(text).toContain('2. 十二')
|
||||
})
|
||||
|
||||
it('索引未追平时显示 warning 且不宣称完整', async () => {
|
||||
getGroupMemberStats.mockResolvedValue(
|
||||
baseResult({
|
||||
freshness: 'stale',
|
||||
complete: false,
|
||||
limitations: [GROUP_STATS_LIMITATION, GROUP_STATS_STALE_LIMITATION]
|
||||
})
|
||||
)
|
||||
renderDialog()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert.textContent).toContain('本地索引尚未完全同步')
|
||||
})
|
||||
|
||||
it('索引未追平时提供「去建立索引」的去处', async () => {
|
||||
getGroupMemberStats.mockResolvedValue(
|
||||
baseResult({
|
||||
freshness: 'stale',
|
||||
complete: false,
|
||||
limitations: [GROUP_STATS_LIMITATION, GROUP_STATS_STALE_LIMITATION]
|
||||
})
|
||||
)
|
||||
const onOpenLocalIndexSettings = vi.fn()
|
||||
const onOpenChange = vi.fn()
|
||||
render(
|
||||
<GroupMemberStatsDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
contact={contact}
|
||||
onOpenLocalIndexSettings={onOpenLocalIndexSettings}
|
||||
/>
|
||||
)
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(await screen.findByRole('button', { name: '去建立索引' }))
|
||||
|
||||
// 必须先关面板再跳,否则设置页会被这个 Dialog 挡在后面。
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
expect(onOpenLocalIndexSettings).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('没有提供跳转回调时不显示「去建立索引」', async () => {
|
||||
getGroupMemberStats.mockResolvedValue(
|
||||
baseResult({ freshness: 'stale', complete: false })
|
||||
)
|
||||
renderDialog()
|
||||
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.queryByRole('button', { name: '去建立索引' })).toBeNull()
|
||||
})
|
||||
|
||||
it('发送失败时展示错误而不是静默失败', async () => {
|
||||
getPersonalWechatSenderStatus.mockResolvedValue({ canSendText: true })
|
||||
sendPersonalWechatMessage.mockRejectedValue(new Error('hook 未就绪'))
|
||||
renderDialog()
|
||||
await switchToSilentTab()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: '发送到当前群' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('hook 未就绪')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LocalIndexPage } from '../../src/renderer/src/features/settings/pages/LocalIndexPage'
|
||||
|
||||
const NOW = Date.now()
|
||||
const DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
const knowledgeStatus = (overrides: Record<string, unknown> = {}) => ({
|
||||
accountId: 'acct',
|
||||
state: 'ready',
|
||||
indexedMessageCount: 2_517_181,
|
||||
indexedChunkCount: 9000,
|
||||
sourceMessageCount: 2_517_181,
|
||||
processedMessages: 2_517_181,
|
||||
totalMessages: 2_517_181,
|
||||
estimatedRemainingMs: null,
|
||||
databaseBytes: 6.4 * 1024 ** 3,
|
||||
walBytes: 0,
|
||||
shmBytes: 0,
|
||||
indexLatestAt: NOW,
|
||||
sourceLatestAt: NOW,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const imageStatus = (overrides: Record<string, unknown> = {}) => ({
|
||||
progress: {
|
||||
state: 'idle',
|
||||
processed: 349_263,
|
||||
totalImageMessages: 349_263,
|
||||
percent: 100,
|
||||
indexed: 57_078,
|
||||
empty: 74_816,
|
||||
missing: 184_805,
|
||||
failed: 8
|
||||
},
|
||||
coverage: {
|
||||
// `established` / `complete` 是状态判定的两个开关
|
||||
// (`imageTextCoverageState`:!established→not_built,complete 才→complete)。
|
||||
// 漏任一个,卡片都会停在别的分支上。
|
||||
established: true,
|
||||
complete: true,
|
||||
indexed: 57_078,
|
||||
empty: 74_816,
|
||||
missing: 184_805,
|
||||
failed: 8,
|
||||
processed: 349_263,
|
||||
totalImageMessages: 349_263,
|
||||
systemicFailure: false,
|
||||
tiers: []
|
||||
},
|
||||
storage: {},
|
||||
counting: false,
|
||||
...overrides
|
||||
})
|
||||
|
||||
let api: Record<string, ReturnType<typeof vi.fn>>
|
||||
|
||||
beforeEach(() => {
|
||||
api = {
|
||||
getKnowledgeStatus: vi.fn().mockResolvedValue(knowledgeStatus()),
|
||||
onKnowledgeStatus: vi.fn(() => () => undefined),
|
||||
startKnowledgeIndex: vi.fn().mockResolvedValue(knowledgeStatus({ state: 'syncing' })),
|
||||
cancelKnowledgeIndex: vi.fn().mockResolvedValue({ cancellable: true, cancelled: true }),
|
||||
getImageTextIndexStatus: vi.fn().mockResolvedValue(imageStatus()),
|
||||
onImageTextIndexStatus: vi.fn(() => () => undefined),
|
||||
countImageMessages: vi.fn().mockResolvedValue({
|
||||
totalImageMessages: 349_263,
|
||||
scannedConversations: 1375,
|
||||
failedConversations: 0
|
||||
}),
|
||||
startImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
|
||||
pauseImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
|
||||
resumeImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
|
||||
cancelImageTextIndex: vi.fn().mockResolvedValue(imageStatus()),
|
||||
resetImageTextIndexFailures: vi.fn().mockResolvedValue(imageStatus()),
|
||||
repairImageTextIndex: vi.fn().mockResolvedValue(imageStatus())
|
||||
}
|
||||
;(window as unknown as { api: Record<string, unknown> }).api = api
|
||||
})
|
||||
|
||||
const renderPage = (dbReady = true) =>
|
||||
render(<LocalIndexPage dbReady={dbReady} onNotice={vi.fn()} />)
|
||||
|
||||
describe('LocalIndexPage · 信息层级', () => {
|
||||
it('页面标题与两处索引卡片都在', async () => {
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { name: '本地索引' })).toBeTruthy()
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: '聊天记录索引' })).toBeTruthy())
|
||||
expect(screen.getByRole('heading', { name: '图片文字索引' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('聊天记录索引显示三个核心数字', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('2,517,181')).toBeTruthy())
|
||||
expect(screen.getByText('已收录消息')).toBeTruthy()
|
||||
expect(screen.getByText('6.4 GB')).toBeTruthy()
|
||||
expect(screen.getByText('占用空间')).toBeTruthy()
|
||||
expect(screen.getByText('最后更新')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('图片文字索引显示四个 metric', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('57,078')).toBeTruthy())
|
||||
expect(screen.getByText('已识别文字')).toBeTruthy()
|
||||
expect(screen.getByText('74,816')).toBeTruthy()
|
||||
expect(screen.getByText('无文字图片')).toBeTruthy()
|
||||
expect(screen.getByText('184,805')).toBeTruthy()
|
||||
expect(screen.getByText('图片已清理')).toBeTruthy()
|
||||
expect(screen.getByText('8')).toBeTruthy()
|
||||
expect(screen.getByText('识别失败')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalIndexPage · 状态徽章', () => {
|
||||
it('索引追平时显示「可用 · 已追至最新」', async () => {
|
||||
renderPage()
|
||||
await waitFor(() => expect(screen.getByText('可用 · 已追至最新')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('索引落后时显示「可用 · 待追新」', async () => {
|
||||
api.getKnowledgeStatus.mockResolvedValue(
|
||||
knowledgeStatus({ indexLatestAt: NOW - 3 * DAY, sourceLatestAt: NOW })
|
||||
)
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('可用 · 待追新')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('图片索引完成后显示「已完成」', async () => {
|
||||
renderPage()
|
||||
await waitFor(() => expect(screen.getByText('已完成')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalIndexPage · 运行态与进度', () => {
|
||||
it('图片索引运行中:显示进度条与暂停/取消,不再显示主操作按钮', async () => {
|
||||
api.getImageTextIndexStatus.mockResolvedValue(
|
||||
imageStatus({
|
||||
progress: {
|
||||
state: 'running',
|
||||
processed: 184_235,
|
||||
totalImageMessages: 349_263,
|
||||
percent: 52,
|
||||
indexed: 1_000,
|
||||
empty: 500,
|
||||
missing: 0,
|
||||
failed: 0
|
||||
}
|
||||
})
|
||||
)
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByRole('progressbar')).toBeTruthy()
|
||||
expect(screen.getByText('184,235 / 349,263')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '暂停' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '取消' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '更新索引' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalIndexPage · 操作绑定与禁用', () => {
|
||||
it('「同步最新记录」仍然走原来的 startKnowledgeIndex', async () => {
|
||||
renderPage()
|
||||
const button = await screen.findByRole('button', { name: '同步最新记录' })
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(button)
|
||||
|
||||
await waitFor(() => expect(api.startKnowledgeIndex).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('数据库未连接时主按钮禁用并给出提示', async () => {
|
||||
renderPage(false)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('请先连接微信数据,然后再建立聊天记录索引。')).toBeTruthy())
|
||||
expect((screen.getByRole('button', { name: '同步最新记录' }) as HTMLButtonElement).disabled).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('「···」菜单里的修复动作仍绑定原逻辑', async () => {
|
||||
renderPage()
|
||||
const more = await screen.findByRole('button', { name: '更多操作' })
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(more)
|
||||
await user.click(await screen.findByText('图片内容搜不到?修复搜索索引'))
|
||||
|
||||
await waitFor(() => expect(api.repairImageTextIndex).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
GROUP_EXIT_EVENT_MESSAGE_TYPE,
|
||||
isGroupExitEventMessage,
|
||||
mergeGroupExitEvents,
|
||||
toGroupExitEventMessage
|
||||
} from '../../src/shared/group-exit-event-message'
|
||||
import type { GroupExitMonitorEvent } from '../../src/shared/group-exit-monitor'
|
||||
import type { Message } from '../../src/shared/types'
|
||||
|
||||
const at = (year: number, month: number, day: number, hour = 0, minute = 0): number =>
|
||||
new Date(year, month - 1, day, hour, minute).getTime()
|
||||
|
||||
const makeEvent = (overrides: Partial<GroupExitMonitorEvent> = {}): GroupExitMonitorEvent => ({
|
||||
id: 'evt-1',
|
||||
contactId: 'g'.repeat(32),
|
||||
roomId: 'room@chatroom',
|
||||
groupName: '研发群',
|
||||
memberWxid: 'wxid_a',
|
||||
memberName: '老张',
|
||||
previousCount: 10,
|
||||
currentCount: 9,
|
||||
delta: -1,
|
||||
message: '老张退出了研发群',
|
||||
detectedAt: at(2026, 9, 20, 14, 30),
|
||||
...overrides
|
||||
})
|
||||
|
||||
const makeMessage = (id: string, createTimeSec: number): Message => ({
|
||||
id,
|
||||
from: 'user',
|
||||
type: '文本',
|
||||
datetime: '2026-09-20 14:00',
|
||||
content: 'hello',
|
||||
isSender: false,
|
||||
createTime: createTimeSec
|
||||
})
|
||||
|
||||
describe('toGroupExitEventMessage', () => {
|
||||
it('把事件映射成系统分支的消息,并带专用 type 标记', () => {
|
||||
const message = toGroupExitEventMessage(makeEvent())
|
||||
|
||||
expect(message.id).toBe('group-exit:evt-1')
|
||||
expect(message.from).toBe('system')
|
||||
expect(message.type).toBe(GROUP_EXIT_EVENT_MESSAGE_TYPE)
|
||||
expect(message.content).toBe('老张退出了研发群')
|
||||
expect(message.isSender).toBe(false)
|
||||
expect(message.senderId).toBe('wxid_a')
|
||||
expect(message.sessionId).toBe('room@chatroom')
|
||||
expect(isGroupExitEventMessage(message)).toBe(true)
|
||||
})
|
||||
|
||||
it('⚠️ createTime 必须从毫秒换算成秒(全项目 Message.createTime 都是秒)', () => {
|
||||
const detectedAt = at(2026, 9, 20, 14, 30)
|
||||
const message = toGroupExitEventMessage(makeEvent({ detectedAt }))
|
||||
|
||||
// 直接塞毫秒会让事件被排到几万年之后,或者从时间轴上消失。
|
||||
expect(message.createTime).toBe(Math.floor(detectedAt / 1000))
|
||||
expect(message.createTime).toBeLessThan(1e11)
|
||||
})
|
||||
|
||||
it('真实微信系统消息不会被误判为退群事件', () => {
|
||||
const systemMessage: Message = {
|
||||
id: 'm1',
|
||||
from: 'system',
|
||||
type: '系统消息',
|
||||
datetime: '2026-09-20 14:00',
|
||||
content: '某某加入了群聊',
|
||||
isSender: false
|
||||
}
|
||||
|
||||
expect(isGroupExitEventMessage(systemMessage)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeGroupExitEvents', () => {
|
||||
it('按时间升序并入,事件落在正确位置', () => {
|
||||
const messages = [
|
||||
makeMessage('m1', Math.floor(at(2026, 9, 20, 10) / 1000)),
|
||||
makeMessage('m2', Math.floor(at(2026, 9, 20, 16) / 1000))
|
||||
]
|
||||
const events = [makeEvent({ detectedAt: at(2026, 9, 20, 14, 30) })]
|
||||
|
||||
const merged = mergeGroupExitEvents(messages, events)
|
||||
|
||||
expect(merged.map((message) => message.id)).toEqual(['m1', 'group-exit:evt-1', 'm2'])
|
||||
})
|
||||
|
||||
it('重复合并是幂等的(广播会反复刷新,不能越插越多)', () => {
|
||||
const messages = [makeMessage('m1', Math.floor(at(2026, 9, 20, 10) / 1000))]
|
||||
const events = [makeEvent()]
|
||||
|
||||
const once = mergeGroupExitEvents(messages, events)
|
||||
const twice = mergeGroupExitEvents(once, events)
|
||||
|
||||
expect(twice).toHaveLength(2)
|
||||
expect(twice.map((message) => message.id)).toEqual(once.map((message) => message.id))
|
||||
})
|
||||
|
||||
it('没有事件时原样返回同一个引用(避免无谓的重渲染)', () => {
|
||||
const messages = [makeMessage('m1', 1_700_000_000)]
|
||||
expect(mergeGroupExitEvents(messages, [])).toBe(messages)
|
||||
})
|
||||
|
||||
it('多个事件各自成组、保持时间顺序', () => {
|
||||
const messages = [makeMessage('m1', Math.floor(at(2026, 9, 20, 8) / 1000))]
|
||||
const events = [
|
||||
makeEvent({ id: 'e1', detectedAt: at(2026, 9, 20, 12) }),
|
||||
makeEvent({ id: 'e2', detectedAt: at(2026, 9, 20, 15) })
|
||||
]
|
||||
|
||||
const merged = mergeGroupExitEvents(messages, events)
|
||||
|
||||
expect(merged.map((message) => message.id)).toEqual([
|
||||
'm1',
|
||||
'group-exit:e1',
|
||||
'group-exit:e2'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, readJsonSync, rmSync, writeJsonSync } from 'fs-extra'
|
||||
import { existsSync, mkdtempSync, readFileSync, readJsonSync, rmSync, writeJsonSync } from 'fs-extra'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -394,6 +394,7 @@ describe('GroupExitMonitorService', () => {
|
||||
await service.start(true)
|
||||
|
||||
const statePath = join(mocks.userData, 'group-exit-monitor.json')
|
||||
const eventsPath = join(mocks.userData, 'group-exit-monitor-events.jsonl')
|
||||
await vi.waitFor(() => {
|
||||
expect(readJsonSync(statePath).snapshots[0].members[1]).toMatchObject({
|
||||
wxid: departed.wxid,
|
||||
@@ -401,7 +402,10 @@ describe('GroupExitMonitorService', () => {
|
||||
})
|
||||
})
|
||||
const baseline = readJsonSync(statePath)
|
||||
expect(baseline.events).toEqual([])
|
||||
// 事件**不再写在状态文件里**(它们走 append-only 的 JSONL),
|
||||
// 所以这里断言的是「两条存储都没有事件」,而不是只看状态文件。
|
||||
expect(baseline.events).toBeUndefined()
|
||||
expect(existsSync(eventsPath) ? readFileSync(eventsPath, 'utf8').trim() : '').toBe('')
|
||||
expect(baseline.snapshots).toEqual([
|
||||
expect.objectContaining({
|
||||
roomId: 'room@chatroom',
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatActiveMemberStats,
|
||||
formatSilentMemberStats,
|
||||
formatStatsDate,
|
||||
formatStatsDateTime,
|
||||
resolveGroupStatsRangeStart,
|
||||
type GroupMemberStatsResult
|
||||
} from '../../src/shared/group-stats'
|
||||
|
||||
/** 用本地时区构造时间,避免测试受运行机器时区影响。 */
|
||||
const at = (year: number, month: number, day: number, hour = 0): number =>
|
||||
new Date(year, month - 1, day, hour).getTime()
|
||||
|
||||
const base = (overrides: Partial<GroupMemberStatsResult> = {}): GroupMemberStatsResult => ({
|
||||
conversationId: 'c'.repeat(32),
|
||||
startTime: at(2026, 6, 15),
|
||||
endTime: at(2026, 9, 4),
|
||||
freshness: 'fresh',
|
||||
complete: true,
|
||||
memberCount: 48,
|
||||
activeMemberCount: 45,
|
||||
silentMemberCount: 3,
|
||||
activeMembers: [],
|
||||
silentMembers: [],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
firstMessageTime: at(2026, 6, 15),
|
||||
limitations: [],
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('GroupStats formatter', () => {
|
||||
it('未发言模板按编号列出,并带区间与人数前缀', () => {
|
||||
const text = formatSilentMemberStats(
|
||||
base({
|
||||
silentMembers: [
|
||||
{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' },
|
||||
{ senderId: 'wxid_2', displayName: '十二', groupNickname: '' },
|
||||
{ senderId: 'wxid_3', displayName: '湘江中路', groupNickname: '' }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(text).toContain('未发言统计')
|
||||
expect(text).toContain('2026-06-15 至 2026-09-04')
|
||||
expect(text).toContain('1. 轩轩')
|
||||
expect(text).toContain('2. 十二')
|
||||
expect(text).toContain('3. 湘江中路')
|
||||
// 纯文本:不能出现 Markdown 标记
|
||||
expect(text).not.toContain('**')
|
||||
expect(text).not.toContain('##')
|
||||
})
|
||||
|
||||
it('未发言模板保留 limitation 文案', () => {
|
||||
const text = formatSilentMemberStats(
|
||||
base({
|
||||
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }],
|
||||
limitations: ['统计基于当前群成员名单与本地已归档消息。']
|
||||
})
|
||||
)
|
||||
expect(text).toContain('注:统计基于当前群成员名单与本地已归档消息。')
|
||||
})
|
||||
|
||||
it('未发言为空时给出明确文案而不是空白', () => {
|
||||
const text = formatSilentMemberStats(base())
|
||||
expect(text).toContain('(无)')
|
||||
})
|
||||
|
||||
it('未发言模板支持 limit 并说明省略人数', () => {
|
||||
const silentMembers = Array.from({ length: 5 }, (_, index) => ({
|
||||
senderId: `wxid_${index}`,
|
||||
displayName: `成员${index}`,
|
||||
groupNickname: ''
|
||||
}))
|
||||
const text = formatSilentMemberStats(base({ silentMembers }), { limit: 2 })
|
||||
expect(text).toContain('1. 成员0')
|
||||
expect(text).toContain('2. 成员1')
|
||||
expect(text).not.toContain('3. 成员2')
|
||||
expect(text).toContain('其余 3 人未列出')
|
||||
})
|
||||
|
||||
it('活跃模板带条数', () => {
|
||||
const text = formatActiveMemberStats(
|
||||
base({
|
||||
activeMembers: [
|
||||
{
|
||||
senderId: 'wxid_1',
|
||||
displayName: '张三',
|
||||
groupNickname: '',
|
||||
messageCount: 328,
|
||||
lastMessageTime: at(2026, 9, 20, 12)
|
||||
},
|
||||
{
|
||||
senderId: 'wxid_2',
|
||||
displayName: '李四',
|
||||
groupNickname: '',
|
||||
messageCount: 217,
|
||||
lastMessageTime: at(2026, 9, 19, 12)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(text).toContain('群聊活跃统计')
|
||||
expect(text).toContain('统计时间:2026-06-15 至 2026-09-04')
|
||||
expect(text).toContain('1. 张三 328 条')
|
||||
expect(text).toContain('2. 李四 217 条')
|
||||
})
|
||||
|
||||
it('群昵称与主名不同时显示成「微信名(群昵称)」', () => {
|
||||
const text = formatSilentMemberStats(
|
||||
base({
|
||||
silentMembers: [
|
||||
{ senderId: 'wxid_1', displayName: '张三', groupNickname: '老张' },
|
||||
// 两者相同时不重复展示,避免噪声
|
||||
{ senderId: 'wxid_2', displayName: '李四', groupNickname: '李四' },
|
||||
// 没有群昵称时只显示主名
|
||||
{ senderId: 'wxid_3', displayName: '王五', groupNickname: '' }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(text).toContain('1. 张三(老张)')
|
||||
expect(text).toContain('2. 李四')
|
||||
expect(text).not.toContain('李四(李四)')
|
||||
expect(text).toContain('3. 王五')
|
||||
})
|
||||
|
||||
it('日期格式化使用本地时区且补零', () => {
|
||||
expect(formatStatsDate(at(2026, 1, 5))).toBe('2026-01-05')
|
||||
expect(formatStatsDateTime(at(2026, 1, 5, 9))).toBe('2026-01-05 09:00')
|
||||
})
|
||||
|
||||
it('快捷范围按滑动窗口折算起点', () => {
|
||||
const end = at(2026, 9, 20)
|
||||
expect(resolveGroupStatsRangeStart('7d', end)).toBe(end - 7 * 24 * 60 * 60 * 1000)
|
||||
expect(resolveGroupStatsRangeStart('30d', end)).toBe(end - 30 * 24 * 60 * 60 * 1000)
|
||||
expect(resolveGroupStatsRangeStart('90d', end)).toBe(end - 90 * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('「全部」不放下界(startTime = 0)', () => {
|
||||
expect(resolveGroupStatsRangeStart('all', at(2026, 9, 20))).toBe(0)
|
||||
})
|
||||
|
||||
it('选「全部」时区间文案用本机第一条消息的真实日期,而不是 1970-01-01', () => {
|
||||
const text = formatSilentMemberStats(
|
||||
base({
|
||||
startTime: 0,
|
||||
firstMessageTime: at(2020, 2, 2),
|
||||
endTime: at(2026, 9, 20),
|
||||
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }]
|
||||
})
|
||||
)
|
||||
expect(text).toContain('2020-02-02 至 2026-09-20')
|
||||
expect(text).not.toContain('1970')
|
||||
expect(text).not.toContain('全部历史')
|
||||
})
|
||||
|
||||
it('选「全部」但取不到第一条消息时,退化成「全部历史」且不假装从 1970 开始', () => {
|
||||
const text = formatSilentMemberStats(
|
||||
base({
|
||||
startTime: 0,
|
||||
firstMessageTime: null,
|
||||
endTime: at(2026, 9, 20),
|
||||
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }]
|
||||
})
|
||||
)
|
||||
expect(text).toContain('全部历史')
|
||||
expect(text).not.toContain('1970')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,297 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* 群员统计服务的差集 / freshness 行为。
|
||||
*
|
||||
* 依赖全部 mock:这一层要证明的是**编排规则**(谁进活跃榜、谁算未发言、
|
||||
* 什么情况下结论不完整),不是 SQL 正确性 —— 后者由
|
||||
* `knowledge-store-member-stats.test.ts` 用真实 SQLite 覆盖。
|
||||
*/
|
||||
|
||||
const chatMock = vi.hoisted(() => ({
|
||||
resolveGroupRoomId: vi.fn(),
|
||||
getGroupMemberIdsAsync: vi.fn(),
|
||||
getGroupMemberNamesAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/chat-service', () => chatMock)
|
||||
|
||||
import { GroupStatsService } from '../../src/main/services/group-stats-service'
|
||||
import {
|
||||
GROUP_STATS_LIMITATION,
|
||||
GROUP_STATS_STALE_LIMITATION
|
||||
} from '../../src/shared/group-stats'
|
||||
import type { KnowledgeSearchService } from '../../src/main/knowledge/knowledge-search-service'
|
||||
import type { KnowledgeMemberStatsResult } from '../../src/shared/knowledge'
|
||||
|
||||
const USER_MD5 = 'a'.repeat(32)
|
||||
const ROOM_ID = 'room@chatroom'
|
||||
const NOW = 1_700_000_000_000
|
||||
const DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
type Member = {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
const member = (wxid: string, nickname: string): Member => ({
|
||||
wxid,
|
||||
nickname,
|
||||
groupNickname: '',
|
||||
wechatNickname: '',
|
||||
remark: '',
|
||||
avatar: ''
|
||||
})
|
||||
|
||||
const statsResult = (
|
||||
overrides: Partial<KnowledgeMemberStatsResult> = {}
|
||||
): KnowledgeMemberStatsResult => ({
|
||||
conversationId: USER_MD5,
|
||||
totalMessages: 0,
|
||||
senders: [],
|
||||
unattributedMessages: 0,
|
||||
excludedSystemMessages: 0,
|
||||
earliestMessageTime: NOW - 30 * DAY,
|
||||
indexLatestAt: NOW,
|
||||
...overrides
|
||||
})
|
||||
|
||||
function makeKnowledge(options: {
|
||||
result?: KnowledgeMemberStatsResult | null
|
||||
sourceLatestAt?: number | null
|
||||
retryResult?: KnowledgeMemberStatsResult | null
|
||||
} = {}): KnowledgeSearchService {
|
||||
const first = options.result === undefined ? statsResult() : options.result
|
||||
const retry = options.retryResult === undefined ? first : options.retryResult
|
||||
let call = 0
|
||||
return {
|
||||
memberStats: vi.fn(async () => {
|
||||
call += 1
|
||||
return {
|
||||
result: call === 1 ? first : retry,
|
||||
sourceLatestAt: options.sourceLatestAt === undefined ? NOW : options.sourceLatestAt
|
||||
}
|
||||
}),
|
||||
requestCatchUp: vi.fn(() => ({ triggered: true, inProgress: true })),
|
||||
waitForIndexingComplete: vi.fn(async () => true)
|
||||
} as unknown as KnowledgeSearchService
|
||||
}
|
||||
|
||||
const run = (
|
||||
knowledge: KnowledgeSearchService,
|
||||
startTime = NOW - 30 * DAY,
|
||||
endTime = NOW
|
||||
) => new GroupStatsService(knowledge).getMemberStats({ userMd5: USER_MD5, startTime, endTime })
|
||||
|
||||
beforeEach(() => {
|
||||
chatMock.resolveGroupRoomId.mockReset().mockReturnValue(ROOM_ID)
|
||||
chatMock.getGroupMemberIdsAsync.mockReset().mockResolvedValue({
|
||||
roomId: ROOM_ID,
|
||||
memberIds: []
|
||||
})
|
||||
chatMock.getGroupMemberNamesAsync.mockReset().mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('GroupStatsService · 差集', () => {
|
||||
it('5 名成员里 3 人有发言、2 人未发言', async () => {
|
||||
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
|
||||
member('wxid_a', '阿一'),
|
||||
member('wxid_b', '阿二'),
|
||||
member('wxid_c', '阿三'),
|
||||
member('wxid_d', '阿四'),
|
||||
member('wxid_e', '阿五')
|
||||
])
|
||||
const knowledge = makeKnowledge({
|
||||
result: statsResult({
|
||||
totalMessages: 16,
|
||||
senders: [
|
||||
{ senderId: 'wxid_a', messageCount: 10, lastMessageTime: NOW - 1000 },
|
||||
{ senderId: 'wxid_b', messageCount: 5, lastMessageTime: NOW - 2000 },
|
||||
{ senderId: 'wxid_c', messageCount: 1, lastMessageTime: NOW - 3000 }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const result = await run(knowledge)
|
||||
|
||||
expect(result.memberCount).toBe(5)
|
||||
expect(result.activeMemberCount).toBe(3)
|
||||
expect(result.silentMemberCount).toBe(2)
|
||||
expect(result.activeMembers.map((item) => item.senderId)).toEqual([
|
||||
'wxid_a',
|
||||
'wxid_b',
|
||||
'wxid_c'
|
||||
])
|
||||
expect(result.silentMembers.map((item) => item.senderId).sort()).toEqual([
|
||||
'wxid_d',
|
||||
'wxid_e'
|
||||
])
|
||||
})
|
||||
|
||||
it('历史消息的 sender 已退群时,不进当前成员活跃榜,并留下 limitation', async () => {
|
||||
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '阿一')])
|
||||
const knowledge = makeKnowledge({
|
||||
result: statsResult({
|
||||
senders: [
|
||||
{ senderId: 'wxid_a', messageCount: 5, lastMessageTime: NOW - 1000 },
|
||||
{ senderId: 'wxid_left', messageCount: 3, lastMessageTime: NOW - 2000 }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const result = await run(knowledge)
|
||||
|
||||
expect(result.activeMembers.map((item) => item.senderId)).toEqual(['wxid_a'])
|
||||
expect(result.activeMemberCount).toBe(1)
|
||||
expect(result.limitations.some((text) => text.includes('已不在当前群成员名单中'))).toBe(true)
|
||||
})
|
||||
|
||||
it('成员改昵称不影响统计:join 用 wxid,展示用当前昵称', async () => {
|
||||
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '改过的名字')])
|
||||
const knowledge = makeKnowledge({
|
||||
result: statsResult({
|
||||
senders: [{ senderId: 'wxid_a', messageCount: 7, lastMessageTime: NOW - 500 }]
|
||||
})
|
||||
})
|
||||
|
||||
const result = await run(knowledge)
|
||||
|
||||
expect(result.activeMembers).toHaveLength(1)
|
||||
expect(result.activeMembers[0].displayName).toBe('改过的名字')
|
||||
expect(result.activeMembers[0].senderId).toBe('wxid_a')
|
||||
expect(result.silentMemberCount).toBe(0)
|
||||
})
|
||||
|
||||
it('sender 缺失的消息只计入诊断,不硬映射给任何成员', async () => {
|
||||
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
|
||||
member('wxid_a', '阿一'),
|
||||
member('wxid_b', '阿二')
|
||||
])
|
||||
const knowledge = makeKnowledge({
|
||||
result: statsResult({
|
||||
senders: [{ senderId: 'wxid_a', messageCount: 4, lastMessageTime: NOW - 1000 }],
|
||||
unattributedMessages: 9
|
||||
})
|
||||
})
|
||||
|
||||
const result = await run(knowledge)
|
||||
|
||||
expect(result.unattributedMessages).toBe(9)
|
||||
// 未归属消息既不能把人抬进活跃榜,也不能把人踢进未发言名单之外。
|
||||
expect(result.activeMemberCount).toBe(1)
|
||||
expect(result.silentMemberCount).toBe(1)
|
||||
expect(result.activeMembers[0].senderId).toBe('wxid_a')
|
||||
expect(result.silentMembers[0].senderId).toBe('wxid_b')
|
||||
})
|
||||
|
||||
it('活跃榜按条数降序,同数按最后发言时间降序', async () => {
|
||||
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
|
||||
member('wxid_a', 'A'),
|
||||
member('wxid_b', 'B'),
|
||||
member('wxid_c', 'C')
|
||||
])
|
||||
const knowledge = makeKnowledge({
|
||||
result: statsResult({
|
||||
senders: [
|
||||
{ senderId: 'wxid_b', messageCount: 5, lastMessageTime: NOW - 9000 },
|
||||
{ senderId: 'wxid_c', messageCount: 5, lastMessageTime: NOW - 1000 },
|
||||
{ senderId: 'wxid_a', messageCount: 9, lastMessageTime: NOW - 5000 }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const result = await run(knowledge)
|
||||
|
||||
expect(result.activeMembers.map((item) => item.senderId)).toEqual([
|
||||
'wxid_a',
|
||||
'wxid_c',
|
||||
'wxid_b'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupStatsService · freshness', () => {
|
||||
it('索引追平时 complete = true', async () => {
|
||||
const result = await run(makeKnowledge({ sourceLatestAt: NOW }))
|
||||
expect(result.freshness).toBe('fresh')
|
||||
expect(result.complete).toBe(true)
|
||||
expect(result.limitations).not.toContain(GROUP_STATS_STALE_LIMITATION)
|
||||
})
|
||||
|
||||
it('索引落后时 complete = false,绝不输出完整结论', async () => {
|
||||
const knowledge = makeKnowledge({
|
||||
result: statsResult({ indexLatestAt: NOW - 3 * DAY }),
|
||||
sourceLatestAt: NOW,
|
||||
// 追赶后仍然落后 —— 必须保持不完整。
|
||||
retryResult: statsResult({ indexLatestAt: NOW - 3 * DAY })
|
||||
})
|
||||
|
||||
const result = await run(knowledge)
|
||||
|
||||
expect(result.freshness).toBe('stale')
|
||||
expect(result.complete).toBe(false)
|
||||
expect(result.limitations).toContain(GROUP_STATS_STALE_LIMITATION)
|
||||
})
|
||||
|
||||
it('落后时先触发有界追赶,追平后回到完整结论', async () => {
|
||||
const knowledge = makeKnowledge({
|
||||
result: statsResult({ indexLatestAt: NOW - 3 * DAY }),
|
||||
sourceLatestAt: NOW,
|
||||
retryResult: statsResult({ indexLatestAt: NOW })
|
||||
})
|
||||
|
||||
const result = await run(knowledge)
|
||||
|
||||
expect(knowledge.requestCatchUp).toHaveBeenCalled()
|
||||
expect(knowledge.waitForIndexingComplete).toHaveBeenCalled()
|
||||
expect(result.freshness).toBe('fresh')
|
||||
expect(result.complete).toBe(true)
|
||||
})
|
||||
|
||||
it('口径缺失(indexLatestAt 为 null)按 unknown 处理,不得当作完整', async () => {
|
||||
const knowledge = makeKnowledge({
|
||||
result: statsResult({ indexLatestAt: null }),
|
||||
sourceLatestAt: null
|
||||
})
|
||||
|
||||
const result = await run(knowledge)
|
||||
|
||||
expect(result.freshness).toBe('unknown')
|
||||
expect(result.complete).toBe(false)
|
||||
expect(result.limitations).toContain(GROUP_STATS_STALE_LIMITATION)
|
||||
})
|
||||
|
||||
it('结果里始终带边界说明', async () => {
|
||||
const result = await run(makeKnowledge())
|
||||
expect(result.limitations).toContain(GROUP_STATS_LIMITATION)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupStatsService · 异常输入', () => {
|
||||
it('不是群会话时返回空结果,不编造统计', async () => {
|
||||
chatMock.resolveGroupRoomId.mockReturnValue(null)
|
||||
|
||||
const result = await run(makeKnowledge())
|
||||
|
||||
expect(result.memberCount).toBe(0)
|
||||
expect(result.activeMemberCount).toBe(0)
|
||||
expect(result.silentMemberCount).toBe(0)
|
||||
expect(result.complete).toBe(false)
|
||||
expect(result.freshness).toBe('unknown')
|
||||
expect(chatMock.getGroupMemberIdsAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Knowledge 返回 null(派生库不可用)时不崩溃,且不完整', async () => {
|
||||
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '阿一')])
|
||||
|
||||
const result = await run(makeKnowledge({ result: null, sourceLatestAt: null }))
|
||||
|
||||
expect(result.memberCount).toBe(1)
|
||||
expect(result.silentMemberCount).toBe(1)
|
||||
expect(result.complete).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
|
||||
import {
|
||||
DEFAULT_KNOWLEDGE_CHUNKER,
|
||||
DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||
type KnowledgeIndexRequest,
|
||||
type KnowledgeMessageKind,
|
||||
type KnowledgeSourceMessage
|
||||
} from '../../src/shared/knowledge'
|
||||
|
||||
/**
|
||||
* 「按发送者聚合」SQL 的正确性。
|
||||
*
|
||||
* 这一层必须用**真实 SQLite**:系统消息排除、`sender_id IS NULL` 的归属、
|
||||
* 时间窗闭区间,三条规则都写在 SQL 里 —— mock 掉数据库就等于什么都没验证。
|
||||
*/
|
||||
|
||||
const ACCOUNT = 'acct-stats-test'
|
||||
const CONV = 'c'.repeat(32)
|
||||
const OTHER_CONV = 'd'.repeat(32)
|
||||
const DAY = 24 * 60 * 60 * 1000
|
||||
const BASE = new Date(2026, 5, 15, 12).getTime()
|
||||
|
||||
let root = ''
|
||||
let store: KnowledgeStore | null = null
|
||||
|
||||
const message = (
|
||||
id: string,
|
||||
senderId: string | undefined,
|
||||
kind: KnowledgeMessageKind,
|
||||
createTime: number,
|
||||
conversationId = CONV
|
||||
): KnowledgeSourceMessage => ({
|
||||
accountId: ACCOUNT,
|
||||
conversationId,
|
||||
messageId: id,
|
||||
createTime,
|
||||
senderId,
|
||||
kind,
|
||||
// 必须带可索引文本,否则会被 normalizer 的可索引性判定挡在索引之外,
|
||||
// 测试就会因为「消息压根没入库」而假通过。
|
||||
text: `payload-${id}`
|
||||
})
|
||||
|
||||
async function seed(messages: KnowledgeSourceMessage[]): Promise<void> {
|
||||
store = new KnowledgeStore(root, ACCOUNT, DEFAULT_KNOWLEDGE_FTS_CONFIG)
|
||||
const conversations = Array.from(new Set(messages.map((item) => item.conversationId))).map(
|
||||
(conversationId) => ({
|
||||
conversationId,
|
||||
completeSnapshot: true,
|
||||
messages: messages.filter((item) => item.conversationId === conversationId),
|
||||
sourceHighWaterTime: BASE + 10 * DAY
|
||||
})
|
||||
)
|
||||
const request: KnowledgeIndexRequest = {
|
||||
accountId: ACCOUNT,
|
||||
databaseRoot: root,
|
||||
conversations,
|
||||
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
|
||||
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG
|
||||
}
|
||||
await store.index(request, undefined, () => undefined)
|
||||
}
|
||||
|
||||
const query = (startTime: number, endTime: number, conversationId = CONV) =>
|
||||
store!.memberStats({ conversationId, startTime, endTime })
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'tm-knowledge-stats-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
store?.close()
|
||||
} finally {
|
||||
store = null
|
||||
if (root) rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('KnowledgeStore.memberStats', () => {
|
||||
it('按 sender 聚合出条数与最后发言时间,并按条数降序', async () => {
|
||||
await seed([
|
||||
message('m1', 'wxid_a', 'text', BASE + 1000),
|
||||
message('m2', 'wxid_a', 'text', BASE + 5000),
|
||||
message('m3', 'wxid_a', 'text', BASE + 3000),
|
||||
message('m4', 'wxid_b', 'text', BASE + 2000)
|
||||
])
|
||||
|
||||
const result = query(BASE, BASE + DAY)
|
||||
|
||||
expect(result.totalMessages).toBe(4)
|
||||
expect(result.senders).toEqual([
|
||||
{ senderId: 'wxid_a', messageCount: 3, lastMessageTime: BASE + 5000 },
|
||||
{ senderId: 'wxid_b', messageCount: 1, lastMessageTime: BASE + 2000 }
|
||||
])
|
||||
})
|
||||
|
||||
it('系统消息不参与任何成员的发言统计', async () => {
|
||||
await seed([
|
||||
message('m1', 'wxid_a', 'text', BASE + 1000),
|
||||
message('m2', 'wxid_a', 'system', BASE + 2000),
|
||||
message('m3', undefined, 'system', BASE + 3000)
|
||||
])
|
||||
|
||||
const result = query(BASE, BASE + DAY)
|
||||
|
||||
// 只有那条 text 算发言;系统消息既不计入总数,也不进 senders。
|
||||
expect(result.totalMessages).toBe(1)
|
||||
expect(result.senders).toEqual([
|
||||
{ senderId: 'wxid_a', messageCount: 1, lastMessageTime: BASE + 1000 }
|
||||
])
|
||||
expect(result.excludedSystemMessages).toBe(2)
|
||||
expect(result.unattributedMessages).toBe(0)
|
||||
})
|
||||
|
||||
it('sender_id 缺失的消息只计入诊断,不归属任何成员', async () => {
|
||||
await seed([
|
||||
message('m1', 'wxid_a', 'text', BASE + 1000),
|
||||
message('m2', undefined, 'text', BASE + 2000),
|
||||
message('m3', undefined, 'other', BASE + 3000)
|
||||
])
|
||||
|
||||
const result = query(BASE, BASE + DAY)
|
||||
|
||||
expect(result.unattributedMessages).toBe(2)
|
||||
expect(result.totalMessages).toBe(3)
|
||||
// 未归属消息**不得**变成一个空 sender 的分组。
|
||||
expect(result.senders).toEqual([
|
||||
{ senderId: 'wxid_a', messageCount: 1, lastMessageTime: BASE + 1000 }
|
||||
])
|
||||
})
|
||||
|
||||
it('时间窗口是闭区间,窗口外的消息不参与', async () => {
|
||||
await seed([
|
||||
message('before', 'wxid_a', 'text', BASE - 1),
|
||||
message('start', 'wxid_a', 'text', BASE),
|
||||
message('inside', 'wxid_a', 'text', BASE + 5000),
|
||||
message('end', 'wxid_a', 'text', BASE + DAY),
|
||||
message('after', 'wxid_a', 'text', BASE + DAY + 1)
|
||||
])
|
||||
|
||||
const result = query(BASE, BASE + DAY)
|
||||
|
||||
// 闭区间:两头都算在内。
|
||||
expect(result.totalMessages).toBe(3)
|
||||
expect(result.senders[0].messageCount).toBe(3)
|
||||
expect(result.senders[0].lastMessageTime).toBe(BASE + DAY)
|
||||
})
|
||||
|
||||
it('只统计指定会话,不串到其它群', async () => {
|
||||
await seed([
|
||||
message('mine', 'wxid_a', 'text', BASE + 1000),
|
||||
message('theirs', 'wxid_z', 'text', BASE + 1000, OTHER_CONV)
|
||||
])
|
||||
|
||||
const result = query(BASE, BASE + DAY, CONV)
|
||||
|
||||
expect(result.totalMessages).toBe(1)
|
||||
expect(result.senders.map((item) => item.senderId)).toEqual(['wxid_a'])
|
||||
})
|
||||
|
||||
it('窗口内没有消息时返回空聚合而不是报错', async () => {
|
||||
await seed([message('m1', 'wxid_a', 'text', BASE + 1000)])
|
||||
|
||||
const result = query(BASE + 100 * DAY, BASE + 101 * DAY)
|
||||
|
||||
expect(result.totalMessages).toBe(0)
|
||||
expect(result.senders).toEqual([])
|
||||
expect(result.unattributedMessages).toBe(0)
|
||||
expect(result.indexLatestAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('conversationId 为空时安全返回空结果', async () => {
|
||||
await seed([message('m1', 'wxid_a', 'text', BASE + 1000)])
|
||||
|
||||
const result = store!.memberStats({ conversationId: '', startTime: BASE, endTime: BASE + DAY })
|
||||
|
||||
expect(result.totalMessages).toBe(0)
|
||||
expect(result.senders).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user