mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-12 07:25:50 +00:00
实现了服务号的推送以及未读
This commit is contained in:
@@ -13,6 +13,7 @@ export interface BizAccount {
|
|||||||
type: number
|
type: number
|
||||||
last_time: number
|
last_time: number
|
||||||
formatted_last_time: string
|
formatted_last_time: string
|
||||||
|
unread_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BizMessage {
|
export interface BizMessage {
|
||||||
@@ -104,19 +105,24 @@ export class BizService {
|
|||||||
if (!root || !accountWxid) return []
|
if (!root || !accountWxid) return []
|
||||||
|
|
||||||
const bizLatestTime: Record<string, number> = {}
|
const bizLatestTime: Record<string, number> = {}
|
||||||
|
const bizUnreadCount: Record<string, number> = {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionsRes = await wcdbService.getSessions()
|
const sessionsRes = await chatService.getSessions()
|
||||||
if (sessionsRes.success && sessionsRes.sessions) {
|
if (sessionsRes.success && sessionsRes.sessions) {
|
||||||
for (const session of sessionsRes.sessions) {
|
for (const session of sessionsRes.sessions) {
|
||||||
const uname = session.username || session.strUsrName || session.userName || session.id
|
const uname = session.username || session.strUsrName || session.userName || session.id
|
||||||
// 适配日志中发现的字段,注意转为整型数字
|
// 适配日志中发现的字段,注意转为整型数字
|
||||||
const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
|
const timeStr = session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
|
||||||
const time = parseInt(timeStr.toString(), 10)
|
const time = parseInt(timeStr.toString(), 10)
|
||||||
|
|
||||||
if (usernames.includes(uname) && time > 0) {
|
if (usernames.includes(uname) && time > 0) {
|
||||||
bizLatestTime[uname] = time
|
bizLatestTime[uname] = time
|
||||||
}
|
}
|
||||||
|
if (usernames.includes(uname)) {
|
||||||
|
const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
|
||||||
|
bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -152,7 +158,8 @@ export class BizService {
|
|||||||
avatar: info?.avatarUrl || '',
|
avatar: info?.avatarUrl || '',
|
||||||
type: 0,
|
type: 0,
|
||||||
last_time: lastTime,
|
last_time: lastTime,
|
||||||
formatted_last_time: formatBizTime(lastTime)
|
formatted_last_time: formatBizTime(lastTime),
|
||||||
|
unread_count: bizUnreadCount[uname] || 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,16 @@ interface SessionDetailExtra {
|
|||||||
|
|
||||||
type SessionDetail = SessionDetailFast & SessionDetailExtra
|
type SessionDetail = SessionDetailFast & SessionDetailExtra
|
||||||
|
|
||||||
|
interface SyntheticUnreadState {
|
||||||
|
readTimestamp: number
|
||||||
|
scannedTimestamp: number
|
||||||
|
latestTimestamp: number
|
||||||
|
unreadCount: number
|
||||||
|
summaryTimestamp?: number
|
||||||
|
summary?: string
|
||||||
|
lastMsgType?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface MyFootprintSummary {
|
interface MyFootprintSummary {
|
||||||
private_inbound_people: number
|
private_inbound_people: number
|
||||||
private_replied_people: number
|
private_replied_people: number
|
||||||
@@ -378,6 +388,7 @@ class ChatService {
|
|||||||
private readonly messageDbCountSnapshotCacheTtlMs = 8000
|
private readonly messageDbCountSnapshotCacheTtlMs = 8000
|
||||||
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
||||||
private sessionMessageCountHintCache = new Map<string, number>()
|
private sessionMessageCountHintCache = new Map<string, number>()
|
||||||
|
private syntheticUnreadState = new Map<string, SyntheticUnreadState>()
|
||||||
private sessionMessageCountBatchCache: {
|
private sessionMessageCountBatchCache: {
|
||||||
dbSignature: string
|
dbSignature: string
|
||||||
sessionIdsKey: string
|
sessionIdsKey: string
|
||||||
@@ -865,6 +876,10 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.addMissingOfficialSessions(sessions, myWxid)
|
||||||
|
await this.applySyntheticUnreadCounts(sessions)
|
||||||
|
sessions.sort((a, b) => Number(b.sortTimestamp || b.lastTimestamp || 0) - Number(a.sortTimestamp || a.lastTimestamp || 0))
|
||||||
|
|
||||||
// 不等待联系人信息加载,直接返回基础会话列表
|
// 不等待联系人信息加载,直接返回基础会话列表
|
||||||
// 前端可以异步调用 enrichSessionsWithContacts 来补充信息
|
// 前端可以异步调用 enrichSessionsWithContacts 来补充信息
|
||||||
return { success: true, sessions }
|
return { success: true, sessions }
|
||||||
@@ -874,6 +889,242 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise<void> {
|
||||||
|
const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean))
|
||||||
|
try {
|
||||||
|
const contactResult = await wcdbService.getContactsCompact()
|
||||||
|
if (!contactResult.success || !Array.isArray(contactResult.contacts)) return
|
||||||
|
|
||||||
|
for (const row of contactResult.contacts as Record<string, any>[]) {
|
||||||
|
const username = String(row.username || '').trim()
|
||||||
|
if (!username.startsWith('gh_') || existing.has(username)) continue
|
||||||
|
|
||||||
|
sessions.push({
|
||||||
|
username,
|
||||||
|
type: 0,
|
||||||
|
unreadCount: 0,
|
||||||
|
summary: '查看公众号历史消息',
|
||||||
|
sortTimestamp: 0,
|
||||||
|
lastTimestamp: 0,
|
||||||
|
lastMsgType: 0,
|
||||||
|
displayName: row.remark || row.nick_name || row.alias || username,
|
||||||
|
avatarUrl: undefined,
|
||||||
|
selfWxid: myWxid
|
||||||
|
})
|
||||||
|
existing.add(username)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ChatService] 补充公众号会话失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldUseSyntheticUnread(sessionId: string): boolean {
|
||||||
|
const normalized = String(sessionId || '').trim()
|
||||||
|
return normalized.startsWith('gh_')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSessionMessageStatsSnapshot(sessionId: string): Promise<{ total: number; latestTimestamp: number }> {
|
||||||
|
const tableStatsResult = await wcdbService.getMessageTableStats(sessionId)
|
||||||
|
if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) {
|
||||||
|
return { total: 0, latestTimestamp: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = 0
|
||||||
|
let latestTimestamp = 0
|
||||||
|
for (const row of tableStatsResult.tables as Record<string, any>[]) {
|
||||||
|
const count = Number(row.count ?? row.message_count ?? row.messageCount ?? 0)
|
||||||
|
if (Number.isFinite(count) && count > 0) {
|
||||||
|
total += Math.floor(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = Number(
|
||||||
|
row.last_timestamp ??
|
||||||
|
row.lastTimestamp ??
|
||||||
|
row.last_time ??
|
||||||
|
row.lastTime ??
|
||||||
|
row.max_create_time ??
|
||||||
|
row.maxCreateTime ??
|
||||||
|
0
|
||||||
|
)
|
||||||
|
if (Number.isFinite(latest) && latest > latestTimestamp) {
|
||||||
|
latestTimestamp = Math.floor(latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, latestTimestamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applySyntheticUnreadCounts(sessions: ChatSession[]): Promise<void> {
|
||||||
|
const candidates = sessions.filter((session) => this.shouldUseSyntheticUnread(session.username))
|
||||||
|
if (candidates.length === 0) return
|
||||||
|
|
||||||
|
for (const session of candidates) {
|
||||||
|
try {
|
||||||
|
const snapshot = await this.getSessionMessageStatsSnapshot(session.username)
|
||||||
|
const latestTimestamp = Math.max(
|
||||||
|
Number(session.lastTimestamp || 0),
|
||||||
|
Number(session.sortTimestamp || 0),
|
||||||
|
snapshot.latestTimestamp
|
||||||
|
)
|
||||||
|
if (latestTimestamp > 0) {
|
||||||
|
session.lastTimestamp = latestTimestamp
|
||||||
|
session.sortTimestamp = Math.max(Number(session.sortTimestamp || 0), latestTimestamp)
|
||||||
|
}
|
||||||
|
if (snapshot.total > 0) {
|
||||||
|
session.messageCountHint = Math.max(Number(session.messageCountHint || 0), snapshot.total)
|
||||||
|
this.sessionMessageCountHintCache.set(session.username, session.messageCountHint)
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = this.syntheticUnreadState.get(session.username)
|
||||||
|
if (!state) {
|
||||||
|
const initialUnread = await this.getInitialSyntheticUnreadState(session.username, latestTimestamp)
|
||||||
|
state = {
|
||||||
|
readTimestamp: latestTimestamp,
|
||||||
|
scannedTimestamp: latestTimestamp,
|
||||||
|
latestTimestamp,
|
||||||
|
unreadCount: initialUnread.count
|
||||||
|
}
|
||||||
|
if (initialUnread.latestMessage) {
|
||||||
|
state.summary = this.getSessionSummaryFromMessage(initialUnread.latestMessage)
|
||||||
|
state.summaryTimestamp = Number(initialUnread.latestMessage.createTime || latestTimestamp)
|
||||||
|
state.lastMsgType = Number(initialUnread.latestMessage.localType || 0)
|
||||||
|
}
|
||||||
|
this.syntheticUnreadState.set(session.username, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
let latestMessageForSummary: Message | undefined
|
||||||
|
if (latestTimestamp > state.scannedTimestamp) {
|
||||||
|
const newMessagesResult = await this.getNewMessages(
|
||||||
|
session.username,
|
||||||
|
Math.max(0, state.scannedTimestamp),
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
if (newMessagesResult.success && Array.isArray(newMessagesResult.messages)) {
|
||||||
|
let nextUnread = state.unreadCount
|
||||||
|
let nextScannedTimestamp = state.scannedTimestamp
|
||||||
|
for (const message of newMessagesResult.messages) {
|
||||||
|
const createTime = Number(message.createTime || 0)
|
||||||
|
if (!Number.isFinite(createTime) || createTime <= state.scannedTimestamp) continue
|
||||||
|
if (message.isSend === 1) continue
|
||||||
|
nextUnread += 1
|
||||||
|
latestMessageForSummary = message
|
||||||
|
if (createTime > nextScannedTimestamp) {
|
||||||
|
nextScannedTimestamp = Math.floor(createTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.unreadCount = nextUnread
|
||||||
|
state.scannedTimestamp = Math.max(nextScannedTimestamp, latestTimestamp)
|
||||||
|
} else {
|
||||||
|
state.scannedTimestamp = latestTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.latestTimestamp = Math.max(state.latestTimestamp, latestTimestamp)
|
||||||
|
if (latestMessageForSummary) {
|
||||||
|
const summary = this.getSessionSummaryFromMessage(latestMessageForSummary)
|
||||||
|
if (summary) {
|
||||||
|
state.summary = summary
|
||||||
|
state.summaryTimestamp = Number(latestMessageForSummary.createTime || latestTimestamp)
|
||||||
|
state.lastMsgType = Number(latestMessageForSummary.localType || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.summary) {
|
||||||
|
session.summary = state.summary
|
||||||
|
session.lastMsgType = Number(state.lastMsgType || session.lastMsgType || 0)
|
||||||
|
}
|
||||||
|
session.unreadCount = Math.max(Number(session.unreadCount || 0), state.unreadCount)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[ChatService] 合成公众号未读失败: ${session.username}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSessionSummaryFromMessage(message: Message): string {
|
||||||
|
const cleanOfficialPrefix = (value: string): string => value.replace(/^\s*\[视频号\]\s*/u, '').trim()
|
||||||
|
let summary = ''
|
||||||
|
switch (Number(message.localType || 0)) {
|
||||||
|
case 1:
|
||||||
|
summary = message.parsedContent || message.rawContent || ''
|
||||||
|
break
|
||||||
|
case 3:
|
||||||
|
summary = '[图片]'
|
||||||
|
break
|
||||||
|
case 34:
|
||||||
|
summary = '[语音]'
|
||||||
|
break
|
||||||
|
case 43:
|
||||||
|
summary = '[视频]'
|
||||||
|
break
|
||||||
|
case 47:
|
||||||
|
summary = '[表情]'
|
||||||
|
break
|
||||||
|
case 42:
|
||||||
|
summary = message.cardNickname || '[名片]'
|
||||||
|
break
|
||||||
|
case 48:
|
||||||
|
summary = '[位置]'
|
||||||
|
break
|
||||||
|
case 49:
|
||||||
|
summary = message.linkTitle || message.fileName || message.parsedContent || '[消息]'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
summary = message.parsedContent || message.rawContent || this.getMessageTypeLabel(Number(message.localType || 0))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return cleanOfficialPrefix(this.cleanString(summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInitialSyntheticUnreadState(sessionId: string, latestTimestamp: number): Promise<{
|
||||||
|
count: number
|
||||||
|
latestMessage?: Message
|
||||||
|
}> {
|
||||||
|
const normalizedLatest = Number(latestTimestamp || 0)
|
||||||
|
if (!Number.isFinite(normalizedLatest) || normalizedLatest <= 0) return { count: 0 }
|
||||||
|
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||||
|
if (Math.abs(nowSeconds - normalizedLatest) > 10 * 60) {
|
||||||
|
return { count: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.getNewMessages(sessionId, Math.max(0, Math.floor(normalizedLatest) - 1), 20)
|
||||||
|
if (!result.success || !Array.isArray(result.messages)) return { count: 0 }
|
||||||
|
const unreadMessages = result.messages.filter((message) => {
|
||||||
|
const createTime = Number(message.createTime || 0)
|
||||||
|
return Number.isFinite(createTime) &&
|
||||||
|
createTime >= normalizedLatest &&
|
||||||
|
message.isSend !== 1
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
count: unreadMessages.length,
|
||||||
|
latestMessage: unreadMessages[unreadMessages.length - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markSyntheticUnreadRead(sessionId: string, messages: Message[] = []): void {
|
||||||
|
const normalized = String(sessionId || '').trim()
|
||||||
|
if (!this.shouldUseSyntheticUnread(normalized)) return
|
||||||
|
|
||||||
|
let latestTimestamp = 0
|
||||||
|
const state = this.syntheticUnreadState.get(normalized)
|
||||||
|
if (state) latestTimestamp = Math.max(latestTimestamp, state.latestTimestamp, state.scannedTimestamp)
|
||||||
|
for (const message of messages) {
|
||||||
|
const createTime = Number(message.createTime || 0)
|
||||||
|
if (Number.isFinite(createTime) && createTime > latestTimestamp) {
|
||||||
|
latestTimestamp = Math.floor(createTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syntheticUnreadState.set(normalized, {
|
||||||
|
readTimestamp: latestTimestamp,
|
||||||
|
scannedTimestamp: latestTimestamp,
|
||||||
|
latestTimestamp,
|
||||||
|
unreadCount: 0,
|
||||||
|
summary: state?.summary,
|
||||||
|
summaryTimestamp: state?.summaryTimestamp,
|
||||||
|
lastMsgType: state?.lastMsgType
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async getSessionStatuses(usernames: string[]): Promise<{
|
async getSessionStatuses(usernames: string[]): Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
||||||
@@ -1814,6 +2065,9 @@ class ChatService {
|
|||||||
releaseMessageCursorMutex?.()
|
releaseMessageCursorMutex?.()
|
||||||
|
|
||||||
this.messageCacheService.set(sessionId, filtered)
|
this.messageCacheService.set(sessionId, filtered)
|
||||||
|
if (offset === 0 && startTime === 0 && endTime === 0) {
|
||||||
|
this.markSyntheticUnreadRead(sessionId, filtered)
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}`
|
`[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ interface ConfigSchema {
|
|||||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
notificationFilterList: string[]
|
notificationFilterList: string[]
|
||||||
messagePushEnabled: boolean
|
messagePushEnabled: boolean
|
||||||
|
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
|
messagePushFilterList: string[]
|
||||||
httpApiEnabled: boolean
|
httpApiEnabled: boolean
|
||||||
httpApiPort: number
|
httpApiPort: number
|
||||||
httpApiHost: string
|
httpApiHost: string
|
||||||
@@ -177,6 +179,8 @@ export class ConfigService {
|
|||||||
httpApiPort: 5031,
|
httpApiPort: 5031,
|
||||||
httpApiHost: '127.0.0.1',
|
httpApiHost: '127.0.0.1',
|
||||||
messagePushEnabled: false,
|
messagePushEnabled: false,
|
||||||
|
messagePushFilterMode: 'all',
|
||||||
|
messagePushFilterList: [],
|
||||||
windowCloseBehavior: 'ask',
|
windowCloseBehavior: 'ask',
|
||||||
quoteLayout: 'quote-top',
|
quoteLayout: 'quote-top',
|
||||||
wordCloudExcludeWords: [],
|
wordCloudExcludeWords: [],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface SessionBaseline {
|
|||||||
interface MessagePushPayload {
|
interface MessagePushPayload {
|
||||||
event: 'message.new'
|
event: 'message.new'
|
||||||
sessionId: string
|
sessionId: string
|
||||||
|
sessionType: 'private' | 'group' | 'official' | 'other'
|
||||||
messageKey: string
|
messageKey: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
sourceName: string
|
sourceName: string
|
||||||
@@ -20,6 +21,8 @@ interface MessagePushPayload {
|
|||||||
|
|
||||||
const PUSH_CONFIG_KEYS = new Set([
|
const PUSH_CONFIG_KEYS = new Set([
|
||||||
'messagePushEnabled',
|
'messagePushEnabled',
|
||||||
|
'messagePushFilterMode',
|
||||||
|
'messagePushFilterList',
|
||||||
'dbPath',
|
'dbPath',
|
||||||
'decryptKey',
|
'decryptKey',
|
||||||
'myWxid'
|
'myWxid'
|
||||||
@@ -38,6 +41,7 @@ class MessagePushService {
|
|||||||
private rerunRequested = false
|
private rerunRequested = false
|
||||||
private started = false
|
private started = false
|
||||||
private baselineReady = false
|
private baselineReady = false
|
||||||
|
private messageTableScanRequested = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = ConfigService.getInstance()
|
this.configService = ConfigService.getInstance()
|
||||||
@@ -60,12 +64,15 @@ class MessagePushService {
|
|||||||
payload = null
|
payload = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableName = String(payload?.table || '').trim().toLowerCase()
|
const tableName = String(payload?.table || '').trim()
|
||||||
if (tableName && tableName !== 'session') {
|
if (this.isSessionTableChange(tableName)) {
|
||||||
|
this.scheduleSync()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scheduleSync()
|
if (!tableName || this.isMessageTableChange(tableName)) {
|
||||||
|
this.scheduleSync({ scanMessageBackedSessions: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleConfigChanged(key: string): Promise<void> {
|
async handleConfigChanged(key: string): Promise<void> {
|
||||||
@@ -91,6 +98,7 @@ class MessagePushService {
|
|||||||
this.recentMessageKeys.clear()
|
this.recentMessageKeys.clear()
|
||||||
this.groupNicknameCache.clear()
|
this.groupNicknameCache.clear()
|
||||||
this.baselineReady = false
|
this.baselineReady = false
|
||||||
|
this.messageTableScanRequested = false
|
||||||
if (this.debounceTimer) {
|
if (this.debounceTimer) {
|
||||||
clearTimeout(this.debounceTimer)
|
clearTimeout(this.debounceTimer)
|
||||||
this.debounceTimer = null
|
this.debounceTimer = null
|
||||||
@@ -121,7 +129,11 @@ class MessagePushService {
|
|||||||
this.baselineReady = true
|
this.baselineReady = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleSync(): void {
|
private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void {
|
||||||
|
if (options.scanMessageBackedSessions) {
|
||||||
|
this.messageTableScanRequested = true
|
||||||
|
}
|
||||||
|
|
||||||
if (this.debounceTimer) {
|
if (this.debounceTimer) {
|
||||||
clearTimeout(this.debounceTimer)
|
clearTimeout(this.debounceTimer)
|
||||||
}
|
}
|
||||||
@@ -141,6 +153,8 @@ class MessagePushService {
|
|||||||
this.processing = true
|
this.processing = true
|
||||||
try {
|
try {
|
||||||
if (!this.isPushEnabled()) return
|
if (!this.isPushEnabled()) return
|
||||||
|
const scanMessageBackedSessions = this.messageTableScanRequested
|
||||||
|
this.messageTableScanRequested = false
|
||||||
|
|
||||||
const connectResult = await chatService.connect()
|
const connectResult = await chatService.connect()
|
||||||
if (!connectResult.success) {
|
if (!connectResult.success) {
|
||||||
@@ -163,27 +177,47 @@ class MessagePushService {
|
|||||||
const previousBaseline = new Map(this.sessionBaseline)
|
const previousBaseline = new Map(this.sessionBaseline)
|
||||||
this.setBaseline(sessions)
|
this.setBaseline(sessions)
|
||||||
|
|
||||||
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
|
const candidates = sessions.filter((session) => {
|
||||||
|
const previous = previousBaseline.get(session.username)
|
||||||
|
if (this.shouldInspectSession(previous, session)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session)
|
||||||
|
})
|
||||||
for (const session of candidates) {
|
for (const session of candidates) {
|
||||||
await this.pushSessionMessages(session, previousBaseline.get(session.username))
|
await this.pushSessionMessages(
|
||||||
|
session,
|
||||||
|
previousBaseline.get(session.username) || this.sessionBaseline.get(session.username)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (this.rerunRequested) {
|
if (this.rerunRequested) {
|
||||||
this.rerunRequested = false
|
this.rerunRequested = false
|
||||||
this.scheduleSync()
|
this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setBaseline(sessions: ChatSession[]): void {
|
private setBaseline(sessions: ChatSession[]): void {
|
||||||
|
const previousBaseline = new Map(this.sessionBaseline)
|
||||||
|
const nextBaseline = new Map<string, SessionBaseline>()
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||||
this.sessionBaseline.clear()
|
this.sessionBaseline.clear()
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
this.sessionBaseline.set(session.username, {
|
const username = String(session.username || '').trim()
|
||||||
lastTimestamp: Number(session.lastTimestamp || 0),
|
if (!username) continue
|
||||||
|
const previous = previousBaseline.get(username)
|
||||||
|
const sessionTimestamp = Number(session.lastTimestamp || 0)
|
||||||
|
const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds
|
||||||
|
nextBaseline.set(username, {
|
||||||
|
lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp),
|
||||||
unreadCount: Number(session.unreadCount || 0)
|
unreadCount: Number(session.unreadCount || 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for (const [username, baseline] of nextBaseline.entries()) {
|
||||||
|
this.sessionBaseline.set(username, baseline)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||||
@@ -204,16 +238,30 @@ class MessagePushService {
|
|||||||
return unreadCount > 0 && lastTimestamp > 0
|
return unreadCount > 0 && lastTimestamp > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastTimestamp <= previous.lastTimestamp) {
|
return lastTimestamp > previous.lastTimestamp || unreadCount > previous.unreadCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldScanMessageBackedSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||||
|
const sessionId = String(session.username || '').trim()
|
||||||
|
if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
|
const summary = String(session.summary || '').trim()
|
||||||
return unreadCount > previous.unreadCount
|
if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionType = this.getSessionType(sessionId, session)
|
||||||
|
if (sessionType === 'private') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(previous) || Number(session.lastTimestamp || 0) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
|
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
|
||||||
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
|
const since = Math.max(0, Number(previous?.lastTimestamp || 0))
|
||||||
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
|
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
|
||||||
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
||||||
return
|
return
|
||||||
@@ -224,7 +272,7 @@ class MessagePushService {
|
|||||||
if (!messageKey) continue
|
if (!messageKey) continue
|
||||||
if (message.isSend === 1) continue
|
if (message.isSend === 1) continue
|
||||||
|
|
||||||
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
|
if (previous && Number(message.createTime || 0) <= Number(previous.lastTimestamp || 0)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,9 +282,11 @@ class MessagePushService {
|
|||||||
|
|
||||||
const payload = await this.buildPayload(session, message)
|
const payload = await this.buildPayload(session, message)
|
||||||
if (!payload) continue
|
if (!payload) continue
|
||||||
|
if (!this.shouldPushPayload(payload)) continue
|
||||||
|
|
||||||
httpService.broadcastMessagePush(payload)
|
httpService.broadcastMessagePush(payload)
|
||||||
this.rememberMessageKey(messageKey)
|
this.rememberMessageKey(messageKey)
|
||||||
|
this.bumpSessionBaseline(session.username, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +296,7 @@ class MessagePushService {
|
|||||||
if (!sessionId || !messageKey) return null
|
if (!sessionId || !messageKey) return null
|
||||||
|
|
||||||
const isGroup = sessionId.endsWith('@chatroom')
|
const isGroup = sessionId.endsWith('@chatroom')
|
||||||
|
const sessionType = this.getSessionType(sessionId, session)
|
||||||
const content = this.getMessageDisplayContent(message)
|
const content = this.getMessageDisplayContent(message)
|
||||||
|
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
@@ -255,6 +306,7 @@ class MessagePushService {
|
|||||||
return {
|
return {
|
||||||
event: 'message.new',
|
event: 'message.new',
|
||||||
sessionId,
|
sessionId,
|
||||||
|
sessionType,
|
||||||
messageKey,
|
messageKey,
|
||||||
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
||||||
groupName,
|
groupName,
|
||||||
@@ -267,6 +319,7 @@ class MessagePushService {
|
|||||||
return {
|
return {
|
||||||
event: 'message.new',
|
event: 'message.new',
|
||||||
sessionId,
|
sessionId,
|
||||||
|
sessionType,
|
||||||
messageKey,
|
messageKey,
|
||||||
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
||||||
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||||
@@ -274,10 +327,84 @@ class MessagePushService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
|
||||||
|
if (sessionId.endsWith('@chatroom')) {
|
||||||
|
return 'group'
|
||||||
|
}
|
||||||
|
if (sessionId.startsWith('gh_') || session.type === 'official') {
|
||||||
|
return 'official'
|
||||||
|
}
|
||||||
|
if (session.type === 'friend') {
|
||||||
|
return 'private'
|
||||||
|
}
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldPushPayload(payload: MessagePushPayload): boolean {
|
||||||
|
const sessionId = String(payload.sessionId || '').trim()
|
||||||
|
const filterMode = this.getMessagePushFilterMode()
|
||||||
|
if (filterMode === 'all') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterList = this.getMessagePushFilterList()
|
||||||
|
const listed = filterList.has(sessionId)
|
||||||
|
if (filterMode === 'whitelist') {
|
||||||
|
return listed
|
||||||
|
}
|
||||||
|
return !listed
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' {
|
||||||
|
const value = this.configService.get('messagePushFilterMode')
|
||||||
|
if (value === 'whitelist' || value === 'blacklist') return value
|
||||||
|
return 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessagePushFilterList(): Set<string> {
|
||||||
|
const value = this.configService.get('messagePushFilterList')
|
||||||
|
if (!Array.isArray(value)) return new Set()
|
||||||
|
return new Set(value.map((item) => String(item || '').trim()).filter(Boolean))
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSessionTableChange(tableName: string): boolean {
|
||||||
|
return String(tableName || '').trim().toLowerCase() === 'session'
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMessageTableChange(tableName: string): boolean {
|
||||||
|
const normalized = String(tableName || '').trim().toLowerCase()
|
||||||
|
if (!normalized) return false
|
||||||
|
return normalized === 'message' ||
|
||||||
|
normalized === 'msg' ||
|
||||||
|
normalized.startsWith('message_') ||
|
||||||
|
normalized.startsWith('msg_') ||
|
||||||
|
normalized.includes('message')
|
||||||
|
}
|
||||||
|
|
||||||
|
private bumpSessionBaseline(sessionId: string, message: Message): void {
|
||||||
|
const key = String(sessionId || '').trim()
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
const createTime = Number(message.createTime || 0)
|
||||||
|
if (!Number.isFinite(createTime) || createTime <= 0) return
|
||||||
|
|
||||||
|
const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 }
|
||||||
|
if (createTime > current.lastTimestamp) {
|
||||||
|
this.sessionBaseline.set(key, {
|
||||||
|
...current,
|
||||||
|
lastTimestamp: createTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getMessageDisplayContent(message: Message): string | null {
|
private getMessageDisplayContent(message: Message): string | null {
|
||||||
|
const cleanOfficialPrefix = (value: string | null): string | null => {
|
||||||
|
if (!value) return value
|
||||||
|
return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value
|
||||||
|
}
|
||||||
switch (Number(message.localType || 0)) {
|
switch (Number(message.localType || 0)) {
|
||||||
case 1:
|
case 1:
|
||||||
return message.rawContent || null
|
return cleanOfficialPrefix(message.rawContent || null)
|
||||||
case 3:
|
case 3:
|
||||||
return '[图片]'
|
return '[图片]'
|
||||||
case 34:
|
case 34:
|
||||||
@@ -287,13 +414,13 @@ class MessagePushService {
|
|||||||
case 47:
|
case 47:
|
||||||
return '[表情]'
|
return '[表情]'
|
||||||
case 42:
|
case 42:
|
||||||
return message.cardNickname || '[名片]'
|
return cleanOfficialPrefix(message.cardNickname || '[名片]')
|
||||||
case 48:
|
case 48:
|
||||||
return '[位置]'
|
return '[位置]'
|
||||||
case 49:
|
case 49:
|
||||||
return message.linkTitle || message.fileName || '[消息]'
|
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
|
||||||
default:
|
default:
|
||||||
return message.parsedContent || message.rawContent || null
|
return cleanOfficialPrefix(message.parsedContent || message.rawContent || null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.biz-account-item {
|
.biz-account-item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -46,6 +47,24 @@
|
|||||||
background-color: var(--bg-tertiary);
|
background-color: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.biz-unread-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 52px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
border: 2px solid var(--bg-secondary);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
.biz-info {
|
.biz-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { useThemeStore } from '../stores/themeStore';
|
import { useThemeStore } from '../stores/themeStore';
|
||||||
import { Newspaper, MessageSquareOff } from 'lucide-react';
|
import { Newspaper, MessageSquareOff } from 'lucide-react';
|
||||||
import './BizPage.scss';
|
import './BizPage.scss';
|
||||||
@@ -10,6 +10,7 @@ export interface BizAccount {
|
|||||||
type: string;
|
type: string;
|
||||||
last_time: number;
|
last_time: number;
|
||||||
formatted_last_time: string;
|
formatted_last_time: string;
|
||||||
|
unread_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BizAccountList: React.FC<{
|
export const BizAccountList: React.FC<{
|
||||||
@@ -36,8 +37,7 @@ export const BizAccountList: React.FC<{
|
|||||||
initWxid().then(_r => { });
|
initWxid().then(_r => { });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchAccounts = useCallback(async () => {
|
||||||
const fetch = async () => {
|
|
||||||
if (!myWxid) {
|
if (!myWxid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,10 +51,28 @@ export const BizAccountList: React.FC<{
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
fetch().then(_r => { } );
|
|
||||||
}, [myWxid]);
|
}, [myWxid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccounts().then(_r => { });
|
||||||
|
}, [fetchAccounts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.electronAPI.chat.onWcdbChange) return;
|
||||||
|
const removeListener = window.electronAPI.chat.onWcdbChange((_event: any, data: { json?: string }) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data.json || '{}');
|
||||||
|
const tableName = String(payload.table || '').toLowerCase();
|
||||||
|
if (!tableName || tableName === 'session' || tableName.includes('message') || tableName.startsWith('msg_')) {
|
||||||
|
fetchAccounts().then(_r => { });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fetchAccounts().then(_r => { });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => removeListener();
|
||||||
|
}, [fetchAccounts]);
|
||||||
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let result = accounts;
|
let result = accounts;
|
||||||
@@ -80,7 +98,12 @@ export const BizAccountList: React.FC<{
|
|||||||
{filtered.map(item => (
|
{filtered.map(item => (
|
||||||
<div
|
<div
|
||||||
key={item.username}
|
key={item.username}
|
||||||
onClick={() => onSelect(item)}
|
onClick={() => {
|
||||||
|
setAccounts(prev => prev.map(account =>
|
||||||
|
account.username === item.username ? { ...account, unread_count: 0 } : account
|
||||||
|
));
|
||||||
|
onSelect({ ...item, unread_count: 0 });
|
||||||
|
}}
|
||||||
className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`}
|
className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -88,6 +111,9 @@ export const BizAccountList: React.FC<{
|
|||||||
className="biz-avatar"
|
className="biz-avatar"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
|
{(item.unread_count || 0) > 0 && (
|
||||||
|
<span className="biz-unread-badge">{(item.unread_count || 0) > 99 ? '99+' : item.unread_count}</span>
|
||||||
|
)}
|
||||||
<div className="biz-info">
|
<div className="biz-info">
|
||||||
<div className="biz-info-top">
|
<div className="biz-info-top">
|
||||||
<span className="biz-name">{item.name || item.username}</span>
|
<span className="biz-name">{item.name || item.username}</span>
|
||||||
|
|||||||
@@ -1058,6 +1058,13 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
</div>
|
</div>
|
||||||
<div className="session-bottom">
|
<div className="session-bottom">
|
||||||
<span className="session-summary">{session.summary || '查看公众号历史消息'}</span>
|
<span className="session-summary">{session.summary || '查看公众号历史消息'}</span>
|
||||||
|
<div className="session-badges">
|
||||||
|
{session.unreadCount > 0 && (
|
||||||
|
<span className="unread-badge">
|
||||||
|
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -5049,24 +5056,37 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const officialSessions = sessions.filter(s => s.username.startsWith('gh_'))
|
||||||
|
|
||||||
// 检查是否有折叠的群聊
|
// 检查是否有折叠的群聊
|
||||||
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
||||||
const hasFoldedGroups = foldedGroups.length > 0
|
const hasFoldedGroups = foldedGroups.length > 0
|
||||||
|
|
||||||
let visible = sessions.filter(s => {
|
let visible = sessions.filter(s => {
|
||||||
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
||||||
|
if (s.username.startsWith('gh_')) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const latestOfficial = officialSessions.reduce<ChatSession | null>((latest, current) => {
|
||||||
|
if (!latest) return current
|
||||||
|
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
||||||
|
const currentTime = current.sortTimestamp || current.lastTimestamp
|
||||||
|
return currentTime > latestTime ? current : latest
|
||||||
|
}, null)
|
||||||
|
const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0)
|
||||||
|
|
||||||
const bizEntry: ChatSession = {
|
const bizEntry: ChatSession = {
|
||||||
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
||||||
displayName: '公众号',
|
displayName: '公众号',
|
||||||
summary: '查看公众号历史消息',
|
summary: latestOfficial
|
||||||
|
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
|
||||||
|
: '查看公众号历史消息',
|
||||||
type: 0,
|
type: 0,
|
||||||
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
|
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
|
||||||
lastTimestamp: 0,
|
lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0,
|
||||||
lastMsgType: 0,
|
lastMsgType: latestOfficial?.lastMsgType || 0,
|
||||||
unreadCount: 0,
|
unreadCount: officialUnreadCount,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
isFolded: false
|
isFolded: false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2349,6 +2349,24 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-panel-action {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.16s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--primary) 8%, var(--bg-tertiary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.filter-panel-list {
|
.filter-panel-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
@@ -2412,6 +2430,16 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-item-type {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
.filter-item-action {
|
.filter-item-action {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -2421,6 +2449,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.push-filter-type-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-filter-type-tab {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.16s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 54%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.filter-panel-empty {
|
.filter-panel-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useThemeStore, themes } from '../stores/themeStore'
|
|||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import { dialog } from '../services/ipc'
|
import { dialog } from '../services/ipc'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import type { ContactInfo } from '../types/models'
|
||||||
import {
|
import {
|
||||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||||
@@ -225,6 +226,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||||
const [showApiWarning, setShowApiWarning] = useState(false)
|
const [showApiWarning, setShowApiWarning] = useState(false)
|
||||||
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
|
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
|
||||||
|
const [messagePushFilterMode, setMessagePushFilterMode] = useState<configService.MessagePushFilterMode>('all')
|
||||||
|
const [messagePushFilterList, setMessagePushFilterList] = useState<string[]>([])
|
||||||
|
const [messagePushFilterDropdownOpen, setMessagePushFilterDropdownOpen] = useState(false)
|
||||||
|
const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('')
|
||||||
|
const [messagePushTypeFilter, setMessagePushTypeFilter] = useState<'all' | configService.MessagePushSessionType>('all')
|
||||||
|
const [messagePushContactOptions, setMessagePushContactOptions] = useState<ContactInfo[]>([])
|
||||||
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
|
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
|
||||||
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
|
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
|
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
|
||||||
@@ -356,15 +363,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setFilterModeDropdownOpen(false)
|
setFilterModeDropdownOpen(false)
|
||||||
setPositionDropdownOpen(false)
|
setPositionDropdownOpen(false)
|
||||||
setCloseBehaviorDropdownOpen(false)
|
setCloseBehaviorDropdownOpen(false)
|
||||||
|
setMessagePushFilterDropdownOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) {
|
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
}
|
}
|
||||||
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen])
|
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
|
||||||
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -387,6 +395,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||||
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
||||||
|
const savedMessagePushFilterMode = await configService.getMessagePushFilterMode()
|
||||||
|
const savedMessagePushFilterList = await configService.getMessagePushFilterList()
|
||||||
|
const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
|
||||||
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
|
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
|
||||||
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||||
const savedQuoteLayout = await configService.getQuoteLayout()
|
const savedQuoteLayout = await configService.getQuoteLayout()
|
||||||
@@ -437,6 +448,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setNotificationFilterMode(savedNotificationFilterMode)
|
setNotificationFilterMode(savedNotificationFilterMode)
|
||||||
setNotificationFilterList(savedNotificationFilterList)
|
setNotificationFilterList(savedNotificationFilterList)
|
||||||
setMessagePushEnabled(savedMessagePushEnabled)
|
setMessagePushEnabled(savedMessagePushEnabled)
|
||||||
|
setMessagePushFilterMode(savedMessagePushFilterMode)
|
||||||
|
setMessagePushFilterList(savedMessagePushFilterList)
|
||||||
|
if (contactsResult.success && Array.isArray(contactsResult.contacts)) {
|
||||||
|
setMessagePushContactOptions(contactsResult.contacts as ContactInfo[])
|
||||||
|
}
|
||||||
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
|
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
|
||||||
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
|
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
|
||||||
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
|
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
|
||||||
@@ -2517,6 +2533,116 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true)
|
showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getMessagePushSessionType = (session: { username: string; type?: ContactInfo['type'] | number }): configService.MessagePushSessionType => {
|
||||||
|
const username = String(session.username || '').trim()
|
||||||
|
if (username.endsWith('@chatroom')) return 'group'
|
||||||
|
if (username.startsWith('gh_') || session.type === 'official') return 'official'
|
||||||
|
if (username.toLowerCase().includes('placeholder_foldgroup')) return 'other'
|
||||||
|
if (session.type === 'former_friend' || session.type === 'other') return 'other'
|
||||||
|
return 'private'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMessagePushTypeLabel = (type: configService.MessagePushSessionType) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'private': return '私聊'
|
||||||
|
case 'group': return '群聊'
|
||||||
|
case 'official': return '订阅号/服务号'
|
||||||
|
default: return '其他/非好友'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetMessagePushFilterMode = async (mode: configService.MessagePushFilterMode) => {
|
||||||
|
setMessagePushFilterMode(mode)
|
||||||
|
setMessagePushFilterDropdownOpen(false)
|
||||||
|
await configService.setMessagePushFilterMode(mode)
|
||||||
|
showMessage(
|
||||||
|
mode === 'all' ? '主动推送已设为接收所有会话' :
|
||||||
|
mode === 'whitelist' ? '主动推送已设为仅推送白名单' : '主动推送已设为屏蔽黑名单',
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddMessagePushFilterSession = async (username: string) => {
|
||||||
|
if (messagePushFilterList.includes(username)) return
|
||||||
|
const next = [...messagePushFilterList, username]
|
||||||
|
setMessagePushFilterList(next)
|
||||||
|
await configService.setMessagePushFilterList(next)
|
||||||
|
showMessage('已添加到主动推送过滤列表', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveMessagePushFilterSession = async (username: string) => {
|
||||||
|
const next = messagePushFilterList.filter(item => item !== username)
|
||||||
|
setMessagePushFilterList(next)
|
||||||
|
await configService.setMessagePushFilterList(next)
|
||||||
|
showMessage('已从主动推送过滤列表移除', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddAllMessagePushFilterSessions = async () => {
|
||||||
|
const usernames = messagePushAvailableSessions.map(session => session.username)
|
||||||
|
if (usernames.length === 0) return
|
||||||
|
const next = Array.from(new Set([...messagePushFilterList, ...usernames]))
|
||||||
|
setMessagePushFilterList(next)
|
||||||
|
await configService.setMessagePushFilterList(next)
|
||||||
|
showMessage(`已添加 ${usernames.length} 个会话`, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagePushOptionMap = new Map<string, {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
type: configService.MessagePushSessionType
|
||||||
|
}>()
|
||||||
|
|
||||||
|
for (const session of chatSessions) {
|
||||||
|
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||||
|
messagePushOptionMap.set(session.username, {
|
||||||
|
username: session.username,
|
||||||
|
displayName: session.displayName || session.username,
|
||||||
|
avatarUrl: session.avatarUrl,
|
||||||
|
type: getMessagePushSessionType(session)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const contact of messagePushContactOptions) {
|
||||||
|
if (!contact.username) continue
|
||||||
|
if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue
|
||||||
|
const existing = messagePushOptionMap.get(contact.username)
|
||||||
|
messagePushOptionMap.set(contact.username, {
|
||||||
|
username: contact.username,
|
||||||
|
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
|
||||||
|
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
|
||||||
|
type: getMessagePushSessionType(contact)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagePushOptions = Array.from(messagePushOptionMap.values())
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aSession = chatSessions.find(session => session.username === a.username)
|
||||||
|
const bSession = chatSessions.find(session => session.username === b.username)
|
||||||
|
return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) -
|
||||||
|
Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const messagePushAvailableSessions = messagePushOptions.filter(session => {
|
||||||
|
if (messagePushFilterList.includes(session.username)) return false
|
||||||
|
if (messagePushTypeFilter !== 'all' && session.type !== messagePushTypeFilter) return false
|
||||||
|
if (messagePushFilterSearchKeyword.trim()) {
|
||||||
|
const keyword = messagePushFilterSearchKeyword.trim().toLowerCase()
|
||||||
|
return String(session.displayName || '').toLowerCase().includes(keyword) ||
|
||||||
|
session.username.toLowerCase().includes(keyword)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const getMessagePushOptionInfo = (username: string) => {
|
||||||
|
return messagePushOptionMap.get(username) || {
|
||||||
|
username,
|
||||||
|
displayName: username,
|
||||||
|
avatarUrl: undefined,
|
||||||
|
type: 'other' as configService.MessagePushSessionType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleTestInsightConnection = async () => {
|
const handleTestInsightConnection = async () => {
|
||||||
setIsTestingInsight(true)
|
setIsTestingInsight(true)
|
||||||
setInsightTestResult(null)
|
setInsightTestResult(null)
|
||||||
@@ -3350,6 +3476,151 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>推送会话过滤</label>
|
||||||
|
<span className="form-hint">选择只推送特定会话,或屏蔽特定会话</span>
|
||||||
|
<div className="custom-select">
|
||||||
|
<div
|
||||||
|
className={`custom-select-trigger ${messagePushFilterDropdownOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => setMessagePushFilterDropdownOpen(!messagePushFilterDropdownOpen)}
|
||||||
|
>
|
||||||
|
<span className="custom-select-value">
|
||||||
|
{messagePushFilterMode === 'all' ? '推送所有会话' :
|
||||||
|
messagePushFilterMode === 'whitelist' ? '仅推送白名单' : '屏蔽黑名单'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={14} className={`custom-select-arrow ${messagePushFilterDropdownOpen ? 'rotate' : ''}`} />
|
||||||
|
</div>
|
||||||
|
<div className={`custom-select-dropdown ${messagePushFilterDropdownOpen ? 'open' : ''}`}>
|
||||||
|
{[
|
||||||
|
{ value: 'all', label: '推送所有会话' },
|
||||||
|
{ value: 'whitelist', label: '仅推送白名单' },
|
||||||
|
{ value: 'blacklist', label: '屏蔽黑名单' }
|
||||||
|
].map(option => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={`custom-select-option ${messagePushFilterMode === option.value ? 'selected' : ''}`}
|
||||||
|
onClick={() => { void handleSetMessagePushFilterMode(option.value as configService.MessagePushFilterMode) }}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
{messagePushFilterMode === option.value && <Check size={14} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{messagePushFilterMode !== 'all' && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{messagePushFilterMode === 'whitelist' ? '主动推送白名单' : '主动推送黑名单'}</label>
|
||||||
|
<span className="form-hint">
|
||||||
|
{messagePushFilterMode === 'whitelist'
|
||||||
|
? '点击左侧会话添加到白名单,只有白名单会话会推送'
|
||||||
|
: '点击左侧会话添加到黑名单,黑名单会话不会推送'}
|
||||||
|
</span>
|
||||||
|
<div className="push-filter-type-tabs">
|
||||||
|
{[
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'private', label: '私聊' },
|
||||||
|
{ value: 'group', label: '群聊' },
|
||||||
|
{ value: 'official', label: '订阅号/服务号' },
|
||||||
|
{ value: 'other', label: '其他/非好友' }
|
||||||
|
].map(option => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`push-filter-type-tab ${messagePushTypeFilter === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => setMessagePushTypeFilter(option.value as 'all' | configService.MessagePushSessionType)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="notification-filter-container">
|
||||||
|
<div className="filter-panel">
|
||||||
|
<div className="filter-panel-header">
|
||||||
|
<span>可选会话</span>
|
||||||
|
{messagePushAvailableSessions.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="filter-panel-action"
|
||||||
|
onClick={() => { void handleAddAllMessagePushFilterSessions() }}
|
||||||
|
>
|
||||||
|
全选当前
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="filter-search-box">
|
||||||
|
<Search size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索会话..."
|
||||||
|
value={messagePushFilterSearchKeyword}
|
||||||
|
onChange={(e) => setMessagePushFilterSearchKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filter-panel-list">
|
||||||
|
{messagePushAvailableSessions.length > 0 ? (
|
||||||
|
messagePushAvailableSessions.map(session => (
|
||||||
|
<div
|
||||||
|
key={session.username}
|
||||||
|
className="filter-panel-item"
|
||||||
|
onClick={() => { void handleAddMessagePushFilterSession(session.username) }}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={session.avatarUrl}
|
||||||
|
name={session.displayName || session.username}
|
||||||
|
size={28}
|
||||||
|
/>
|
||||||
|
<span className="filter-item-name">{session.displayName || session.username}</span>
|
||||||
|
<span className="filter-item-type">{getMessagePushTypeLabel(session.type)}</span>
|
||||||
|
<span className="filter-item-action">+</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="filter-panel-empty">
|
||||||
|
{messagePushFilterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-panel">
|
||||||
|
<div className="filter-panel-header">
|
||||||
|
<span>{messagePushFilterMode === 'whitelist' ? '白名单' : '黑名单'}</span>
|
||||||
|
{messagePushFilterList.length > 0 && (
|
||||||
|
<span className="filter-panel-count">{messagePushFilterList.length}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="filter-panel-list">
|
||||||
|
{messagePushFilterList.length > 0 ? (
|
||||||
|
messagePushFilterList.map(username => {
|
||||||
|
const session = getMessagePushOptionInfo(username)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={username}
|
||||||
|
className="filter-panel-item selected"
|
||||||
|
onClick={() => { void handleRemoveMessagePushFilterSession(username) }}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={session.avatarUrl}
|
||||||
|
name={session.displayName || username}
|
||||||
|
size={28}
|
||||||
|
/>
|
||||||
|
<span className="filter-item-name">{session.displayName || username}</span>
|
||||||
|
<span className="filter-item-type">{getMessagePushTypeLabel(session.type)}</span>
|
||||||
|
<span className="filter-item-action">×</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="filter-panel-empty">尚未添加任何会话</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>推送地址</label>
|
<label>推送地址</label>
|
||||||
<span className="form-hint">外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`</span>
|
<span className="form-hint">外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`</span>
|
||||||
@@ -3384,7 +3655,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
||||||
<div className="api-params">
|
<div className="api-params">
|
||||||
{['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
|
{['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
|
||||||
<span key={param} className="param">
|
<span key={param} className="param">
|
||||||
<code>{param}</code>
|
<code>{param}</code>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export const CONFIG_KEYS = {
|
|||||||
HTTP_API_PORT: 'httpApiPort',
|
HTTP_API_PORT: 'httpApiPort',
|
||||||
HTTP_API_HOST: 'httpApiHost',
|
HTTP_API_HOST: 'httpApiHost',
|
||||||
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
||||||
|
MESSAGE_PUSH_FILTER_MODE: 'messagePushFilterMode',
|
||||||
|
MESSAGE_PUSH_FILTER_LIST: 'messagePushFilterList',
|
||||||
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||||
QUOTE_LAYOUT: 'quoteLayout',
|
QUOTE_LAYOUT: 'quoteLayout',
|
||||||
|
|
||||||
@@ -1505,6 +1507,29 @@ export async function setMessagePushEnabled(enabled: boolean): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.MESSAGE_PUSH_ENABLED, enabled)
|
await config.set(CONFIG_KEYS.MESSAGE_PUSH_ENABLED, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MessagePushFilterMode = 'all' | 'whitelist' | 'blacklist'
|
||||||
|
export type MessagePushSessionType = 'private' | 'group' | 'official' | 'other'
|
||||||
|
|
||||||
|
export async function getMessagePushFilterMode(): Promise<MessagePushFilterMode> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_FILTER_MODE)
|
||||||
|
if (value === 'whitelist' || value === 'blacklist') return value
|
||||||
|
return 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMessagePushFilterMode(mode: MessagePushFilterMode): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.MESSAGE_PUSH_FILTER_MODE, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMessagePushFilterList(): Promise<string[]> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_FILTER_LIST)
|
||||||
|
return Array.isArray(value) ? value.map(item => String(item || '').trim()).filter(Boolean) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMessagePushFilterList(list: string[]): Promise<void> {
|
||||||
|
const normalized = Array.from(new Set((list || []).map(item => String(item || '').trim()).filter(Boolean)))
|
||||||
|
await config.set(CONFIG_KEYS.MESSAGE_PUSH_FILTER_LIST, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWindowCloseBehavior(): Promise<WindowCloseBehavior> {
|
export async function getWindowCloseBehavior(): Promise<WindowCloseBehavior> {
|
||||||
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
|
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
|
||||||
if (value === 'tray' || value === 'quit') return value
|
if (value === 'tray' || value === 'quit') return value
|
||||||
|
|||||||
Reference in New Issue
Block a user