Compare commits

...

23 Commits

Author SHA1 Message Date
cc
e79d18da03 Merge pull request #733 from hicccc77/dev
Update release.yml
2026-04-12 14:46:41 +08:00
cc
69a598f196 Update release.yml 2026-04-12 14:46:17 +08:00
cc
ac84606f20 Merge pull request #732 from hicccc77/dev
Dev
2026-04-12 14:22:42 +08:00
cc
b086507569 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-12 14:19:51 +08:00
cc
360f4917b1 更新提示文案 2026-04-12 14:19:46 +08:00
H3CoF6
89d0f22dac Merge pull request #731 from H3CoF6/fix/linux-key
适配更多wechat路径,优化拉起失败提示
2026-04-12 13:37:01 +08:00
H3CoF6
f4d63d01bd 适配更多wechat路径,优化拉起失败提示 2026-04-12 13:27:18 +08:00
xuncha
48ca54a856 Merge pull request #721 from xunchahaha/dev
Dev
2026-04-12 08:39:41 +08:00
xuncha
bf3dfbba0f Merge branch 'dev' into dev 2026-04-12 08:37:03 +08:00
xuncha
bd1bd8a8aa Merge pull request #716 from zgshe/feature/export-date-range-time-picker-v2
feat(export): 导出日期范围添加时间选择功能
2026-04-12 08:36:32 +08:00
xuncha
7e1ca95bef 修复导出页头像丢失 2026-04-12 08:36:13 +08:00
xuncha
b7cb2cd42d 优化了选择会话逻辑 2026-04-12 08:20:54 +08:00
xuncha
6359123323 优化了接龙的消息样式 2026-04-12 08:11:20 +08:00
xuncha
f2f78bb4e2 实现了服务号的推送以及未读 2026-04-12 08:03:12 +08:00
xuncha
716b21b0dd Merge branch 'dev' into feature/export-date-range-time-picker-v2 2026-04-12 07:26:00 +08:00
xuncha
cde3590986 优化一下ui 2026-04-12 07:10:59 +08:00
H3CoF6
f89ad6ec15 修release action
fix: 修复yml空格错误
2026-04-12 00:55:57 +08:00
H3CoF6
4efa169313 Merge remote-tracking branch 'upstream/dev' 2026-04-12 00:52:29 +08:00
H3CoF6
933912f15d fix: 修复yml空格的bug 2026-04-12 00:50:52 +08:00
佘志高
caf5b0c9db fix(export): 统一时间输入框字体与日期输入框一致 2026-04-11 22:18:03 +08:00
佘志高
f2d6188c53 feat(export): 导出日期范围添加时间选择功能
为导出窗口的日期范围选择器添加了时间(HH:mm)选择功能:

- 在日期输入框下方添加了时间选择控件(type="time")
- 默认时间范围:开始 00:00,结束 23:59
- 支持精确到分钟的时间范围设置
- 预设类型(今天、昨天、最近7天等)默认使用 00:00-23:59
- 自定义时间范围保留用户设置的具体时间
- 添加了结束时间不能早于开始时间的验证

修改文件:
- src/utils/exportDateRange.ts - 支持 YYYY-MM-DD HH:mm 格式的解析和格式化
- src/components/Export/ExportDateRangeDialog.tsx - 添加时间选择 UI 和逻辑
- src/components/Export/ExportDateRangeDialog.scss - 时间输入框样式
- src/pages/ExportPage.tsx - 修复 preset 类型的默认时间不正确的 bug
2026-04-11 22:00:32 +08:00
cc
5bec4f3cd6 Merge pull request #713 from hicccc77/dev
Dev
2026-04-11 17:15:22 +08:00
cc
56b767ff46 Merge pull request #705 from hicccc77/dev
Dev
2026-04-10 21:08:43 +08:00
20 changed files with 1810 additions and 183 deletions

View File

@@ -318,20 +318,20 @@ jobs:
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
deploy-aur: deploy-aur:
    runs-on: ubuntu-latest runs-on: ubuntu-latest
    needs: [release-linux] # 确保 Linux 包已经构建发布 needs: [release-linux]
    if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
    steps: steps:
      - name: Checkout code - name: Checkout code
        uses: actions/checkout@v5 uses: actions/checkout@v5
        with: with:
          fetch-depth: 0 fetch-depth: 0
      - name: Publish AUR package - name: Publish AUR package
        uses: KSX_Zeus/github-action-aur@master uses: KSXGitHub/github-actions-deploy-aur@master
        with: with:
pkgname: weflow pkgname: weflow
          ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
          commit_username: H3CoF6 commit_username: H3CoF6
          commit_email: h3cof6@gmail.com commit_email: h3cof6@gmail.com
          ssh_keyscan_types: ed25519 ssh_keyscan_types: ed25519

View File

@@ -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
} }
}) })

View File

@@ -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}`
) )
@@ -4416,6 +4670,8 @@ class ChatService {
case '57': case '57':
// 引用消息title 就是回复的内容 // 引用消息title 就是回复的内容
return title return title
case '53':
return `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}`
case '2000': case '2000':
return `[转账] ${title}` return `[转账] ${title}`
case '2001': case '2001':
@@ -4445,6 +4701,8 @@ class ChatService {
return '[链接]' return '[链接]'
case '87': case '87':
return '[群公告]' return '[群公告]'
case '53':
return '[接龙]'
default: default:
return '[消息]' return '[消息]'
} }
@@ -5044,6 +5302,8 @@ class ChatService {
const quoteInfo = this.parseQuoteMessage(content) const quoteInfo = this.parseQuoteMessage(content)
result.quotedContent = quoteInfo.content result.quotedContent = quoteInfo.content
result.quotedSender = quoteInfo.sender result.quotedSender = quoteInfo.sender
} else if (xmlType === '53') {
result.appMsgKind = 'solitaire'
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) { } else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
result.appMsgKind = 'official-link' result.appMsgKind = 'official-link'
} else if (url) { } else if (url) {

View File

@@ -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: [],

View File

@@ -2119,6 +2119,7 @@ class ExportService {
} }
return title || '[引用消息]' return title || '[引用消息]'
} }
if (xmlType === '53') return title ? `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` : '[接龙]'
if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]'
// 有 title 就返回 title // 有 title 就返回 title
@@ -3220,6 +3221,8 @@ class ExportService {
appMsgKind = 'announcement' appMsgKind = 'announcement'
} else if (xmlType === '57' || hasReferMsg || localType === 244813135921) { } else if (xmlType === '57' || hasReferMsg || localType === 244813135921) {
appMsgKind = 'quote' appMsgKind = 'quote'
} else if (xmlType === '53') {
appMsgKind = 'solitaire'
} else if (xmlType === '5' || xmlType === '49') { } else if (xmlType === '5' || xmlType === '49') {
appMsgKind = 'link' appMsgKind = 'link'
} else if (looksLikeAppMsg) { } else if (looksLikeAppMsg) {

View File

@@ -98,7 +98,12 @@ export class KeyServiceLinux {
'xwechat', 'xwechat',
'/opt/wechat/wechat', '/opt/wechat/wechat',
'/usr/bin/wechat', '/usr/bin/wechat',
'/opt/apps/com.tencent.wechat/files/wechat' '/usr/local/bin/wechat',
'/usr/bin/wechat',
'/opt/apps/com.tencent.wechat/files/wechat',
'/usr/bin/wechat-bin',
'/usr/local/bin/wechat-bin',
'com.tencent.wechat'
] ]
for (const binName of wechatBins) { for (const binName of wechatBins) {
@@ -152,7 +157,7 @@ export class KeyServiceLinux {
} }
if (!pid) { if (!pid) {
const err = '未能自动启动微信或获取PID失败请查看控制台日志或手动启动并登录。' const err = '未能自动启动微信或获取PID失败请查看控制台日志或手动启动微信,看到登录窗口后点击确认。'
onStatus?.(err, 2) onStatus?.(err, 2)
return { success: false, error: err } return { success: false, error: err }
} }

View File

@@ -555,7 +555,19 @@ export class KeyServiceMac {
if (code === 'HOOK_TARGET_ONLY') { if (code === 'HOOK_TARGET_ONLY') {
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。` return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
} }
if (code === 'SCAN_FAILED') return '内存扫描失败' if (code === 'SCAN_FAILED') {
const normalizedDetail = (detail || '').trim()
if (!normalizedDetail) {
return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。'
}
if (normalizedDetail.includes('Sink pattern not found')) {
return '内存扫描失败:未匹配到目标函数特征,可使用微信 4.1.8.100 版本尝试。'
}
if (normalizedDetail.includes('No suitable module found')) {
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台,再重试。'
}
return `内存扫描失败:${normalizedDetail}`
}
return '未知错误' return '未知错误'
} }

View File

@@ -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)
} }
} }

View File

@@ -192,6 +192,149 @@
} }
} }
.export-date-range-time-select {
position: relative;
width: 100%;
&.open .export-date-range-time-trigger {
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
color: var(--primary);
}
}
.export-date-range-time-trigger {
width: 100%;
min-width: 0;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
height: 30px;
padding: 0 9px;
font-size: 12px;
font-family: inherit;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
&:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
}
.export-date-range-time-trigger-value {
flex: 1;
min-width: 0;
text-align: left;
}
.export-date-range-time-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 24;
border: 1px solid var(--border-color);
border-radius: 12px;
background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary));
box-shadow: var(--shadow-md);
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.export-date-range-time-dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
span {
font-size: 11px;
color: var(--text-secondary);
}
strong {
font-size: 13px;
color: var(--text-primary);
}
}
.export-date-range-time-quick-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.export-date-range-time-quick-item,
.export-date-range-time-option {
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--bg-tertiary);
}
&.active {
border-color: rgba(var(--primary-rgb), 0.28);
background: rgba(var(--primary-rgb), 0.12);
color: var(--primary);
}
}
.export-date-range-time-quick-item {
min-width: 52px;
height: 28px;
padding: 0 10px;
font-size: 11px;
}
.export-date-range-time-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.export-date-range-time-column {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.export-date-range-time-column-label {
font-size: 11px;
color: var(--text-secondary);
}
.export-date-range-time-column-list {
max-height: 168px;
overflow-y: auto;
padding-right: 2px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
}
.export-date-range-time-option {
min-height: 28px;
padding: 0 8px;
font-size: 11px;
}
.export-date-range-calendar-nav { .export-date-range-calendar-nav {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react' import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'
import { import {
EXPORT_DATE_RANGE_PRESETS, EXPORT_DATE_RANGE_PRESETS,
WEEKDAY_SHORT_LABELS, WEEKDAY_SHORT_LABELS,
@@ -10,7 +10,6 @@ import {
createDateRangeByPreset, createDateRangeByPreset,
createDefaultDateRange, createDefaultDateRange,
formatCalendarMonthTitle, formatCalendarMonthTitle,
formatDateInputValue,
isSameDay, isSameDay,
parseDateInputValue, parseDateInputValue,
startOfDay, startOfDay,
@@ -37,6 +36,10 @@ interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
panelMonth: Date panelMonth: Date
} }
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, index) => `${index}`.padStart(2, '0'))
const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => `${index}`.padStart(2, '0'))
const QUICK_TIME_OPTIONS = ['00:00', '08:00', '12:00', '18:00', '23:59']
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => { const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
@@ -57,16 +60,42 @@ const clampSelectionToBounds = (
const bounds = resolveBounds(minDate, maxDate) const bounds = resolveBounds(minDate, maxDate)
if (!bounds) return cloneExportDateRangeSelection(value) if (!bounds) return cloneExportDateRangeSelection(value)
const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start) // For custom selections, only ensure end >= start, preserve time precision
const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end) if (value.preset === 'custom' && !value.useAllTime) {
const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) const { start, end } = value.dateRange
const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) if (end.getTime() < start.getTime()) {
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate return {
const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime() ...value,
dateRange: { start, end: start }
}
}
return cloneExportDateRangeSelection(value)
}
// For useAllTime, use bounds directly
if (value.useAllTime) {
return {
preset: value.preset,
useAllTime: true,
dateRange: {
start: bounds.minDate,
end: bounds.maxDate
}
}
}
// For preset selections (not custom), clamp dates to bounds and use default times
const nextStart = new Date(Math.min(Math.max(value.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEndCandidate = new Date(Math.min(Math.max(value.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? nextStart : nextEndCandidate
// Set default times: start at 00:00:00, end at 23:59:59
nextStart.setHours(0, 0, 0, 0)
nextEnd.setHours(23, 59, 59, 999)
return { return {
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset), preset: value.preset,
useAllTime: value.useAllTime, useAllTime: false,
dateRange: { dateRange: {
start: nextStart, start: nextStart,
end: nextEnd end: nextEnd
@@ -95,62 +124,129 @@ export function ExportDateRangeDialog({
onClose, onClose,
onConfirm onConfirm
}: ExportDateRangeDialogProps) { }: ExportDateRangeDialogProps) {
// Helper: Format date only (YYYY-MM-DD) for the date input field
const formatDateOnly = (date: Date): string => {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}`
}
// Helper: Format time only (HH:mm) for the time input field
const formatTimeOnly = (date: Date): string => {
const h = `${date.getHours()}`.padStart(2, '0')
const m = `${date.getMinutes()}`.padStart(2, '0')
return `${h}:${m}`
}
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate)) const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start') const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
const [dateInput, setDateInput] = useState({ const [dateInput, setDateInput] = useState({
start: formatDateInputValue(value.dateRange.start), start: formatDateOnly(value.dateRange.start),
end: formatDateInputValue(value.dateRange.end) end: formatDateOnly(value.dateRange.end)
}) })
const [dateInputError, setDateInputError] = useState({ start: false, end: false }) const [dateInputError, setDateInputError] = useState({ start: false, end: false })
// Default times: start at 00:00, end at 23:59
const [timeInput, setTimeInput] = useState({
start: '00:00',
end: '23:59'
})
const [openTimeDropdown, setOpenTimeDropdown] = useState<ActiveBoundary | null>(null)
const startTimeSelectRef = useRef<HTMLDivElement>(null)
const endTimeSelectRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const nextDraft = buildDialogDraft(value, minDate, maxDate) const nextDraft = buildDialogDraft(value, minDate, maxDate)
setDraft(nextDraft) setDraft(nextDraft)
setActiveBoundary('start') setActiveBoundary('start')
setDateInput({ setDateInput({
start: formatDateInputValue(nextDraft.dateRange.start), start: formatDateOnly(nextDraft.dateRange.start),
end: formatDateInputValue(nextDraft.dateRange.end) end: formatDateOnly(nextDraft.dateRange.end)
}) })
// For preset-based selections (not custom), use default times 00:00 and 23:59
// For custom selections, preserve the time from value.dateRange
if (nextDraft.useAllTime || nextDraft.preset !== 'custom') {
setTimeInput({
start: '00:00',
end: '23:59'
})
} else {
setTimeInput({
start: formatTimeOnly(nextDraft.dateRange.start),
end: formatTimeOnly(nextDraft.dateRange.end)
})
}
setOpenTimeDropdown(null)
setDateInputError({ start: false, end: false }) setDateInputError({ start: false, end: false })
}, [maxDate, minDate, open, value]) }, [maxDate, minDate, open, value])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setDateInput({ setDateInput({
start: formatDateInputValue(draft.dateRange.start), start: formatDateOnly(draft.dateRange.start),
end: formatDateInputValue(draft.dateRange.end) end: formatDateOnly(draft.dateRange.end)
}) })
// Don't sync timeInput here - it's controlled by the time picker
setDateInputError({ start: false, end: false }) setDateInputError({ start: false, end: false })
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
useEffect(() => {
if (!openTimeDropdown) return
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node
const activeContainer = openTimeDropdown === 'start'
? startTimeSelectRef.current
: endTimeSelectRef.current
if (!activeContainer?.contains(target)) {
setOpenTimeDropdown(null)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpenTimeDropdown(null)
}
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleEscape)
}
}, [openTimeDropdown])
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate]) const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
const clampStartDate = useCallback((targetDate: Date) => { const clampStartDate = useCallback((targetDate: Date) => {
const start = startOfDay(targetDate) if (!bounds) return targetDate
if (!bounds) return start const min = bounds.minDate
if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate const max = bounds.maxDate
if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate) if (targetDate.getTime() < min.getTime()) return min
return start if (targetDate.getTime() > max.getTime()) return max
return targetDate
}, [bounds]) }, [bounds])
const clampEndDate = useCallback((targetDate: Date) => { const clampEndDate = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate) if (!bounds) return targetDate
if (!bounds) return end const min = bounds.minDate
if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate) const max = bounds.maxDate
if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate if (targetDate.getTime() < min.getTime()) return min
return end if (targetDate.getTime() > max.getTime()) return max
return targetDate
}, [bounds]) }, [bounds])
const setRangeStart = useCallback((targetDate: Date) => { const setRangeStart = useCallback((targetDate: Date) => {
const start = clampStartDate(targetDate) const start = clampStartDate(targetDate)
setDraft(prev => { setDraft(prev => {
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
return { return {
...prev, ...prev,
preset: 'custom', preset: 'custom',
useAllTime: false, useAllTime: false,
dateRange: { dateRange: {
start, start,
end: nextEnd end: prev.dateRange.end
}, },
panelMonth: toMonthStart(start) panelMonth: toMonthStart(start)
} }
@@ -161,14 +257,13 @@ export function ExportDateRangeDialog({
const end = clampEndDate(targetDate) const end = clampEndDate(targetDate)
setDraft(prev => { setDraft(prev => {
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
return { return {
...prev, ...prev,
preset: 'custom', preset: 'custom',
useAllTime: false, useAllTime: false,
dateRange: { dateRange: {
start: nextStart, start: nextStart,
end: nextEnd end: end
}, },
panelMonth: toMonthStart(targetDate) panelMonth: toMonthStart(targetDate)
} }
@@ -180,6 +275,11 @@ export function ExportDateRangeDialog({
const previewRange = bounds const previewRange = bounds
? { start: bounds.minDate, end: bounds.maxDate } ? { start: bounds.minDate, end: bounds.maxDate }
: createDefaultDateRange() : createDefaultDateRange()
setTimeInput({
start: '00:00',
end: '23:59'
})
setOpenTimeDropdown(null)
setDraft(prev => ({ setDraft(prev => ({
...prev, ...prev,
preset, preset,
@@ -196,6 +296,11 @@ export function ExportDateRangeDialog({
useAllTime: false, useAllTime: false,
dateRange: createDateRangeByPreset(preset) dateRange: createDateRangeByPreset(preset)
}, minDate, maxDate).dateRange }, minDate, maxDate).dateRange
setTimeInput({
start: '00:00',
end: '23:59'
})
setOpenTimeDropdown(null)
setDraft(prev => ({ setDraft(prev => ({
...prev, ...prev,
preset, preset,
@@ -206,25 +311,149 @@ export function ExportDateRangeDialog({
setActiveBoundary('start') setActiveBoundary('start')
}, [bounds, maxDate, minDate]) }, [bounds, maxDate, minDate])
const parseTimeValue = (timeStr: string): { hours: number; minutes: number } | null => {
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
if (!matched) return null
const hours = Number(matched[1])
const minutes = Number(matched[2])
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
return { hours, minutes }
}
const updateBoundaryTime = useCallback((boundary: ActiveBoundary, timeStr: string) => {
setTimeInput(prev => ({ ...prev, [boundary]: timeStr }))
const parsedTime = parseTimeValue(timeStr)
if (!parsedTime) return
setDraft(prev => {
const dateObj = boundary === 'start' ? prev.dateRange.start : prev.dateRange.end
const newDate = new Date(dateObj)
newDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
...prev.dateRange,
[boundary]: newDate
}
}
})
}, [])
const toggleTimeDropdown = useCallback((boundary: ActiveBoundary) => {
setActiveBoundary(boundary)
setOpenTimeDropdown(prev => (prev === boundary ? null : boundary))
}, [])
const handleTimeColumnSelect = useCallback((boundary: ActiveBoundary, field: 'hour' | 'minute', value: string) => {
const parsedCurrent = parseTimeValue(timeInput[boundary]) ?? {
hours: boundary === 'start' ? 0 : 23,
minutes: boundary === 'start' ? 0 : 59
}
const nextHours = field === 'hour' ? Number(value) : parsedCurrent.hours
const nextMinutes = field === 'minute' ? Number(value) : parsedCurrent.minutes
updateBoundaryTime(boundary, `${`${nextHours}`.padStart(2, '0')}:${`${nextMinutes}`.padStart(2, '0')}`)
}, [timeInput, updateBoundaryTime])
const renderTimeDropdown = (boundary: ActiveBoundary) => {
const currentTime = timeInput[boundary]
const parsedCurrent = parseTimeValue(currentTime) ?? {
hours: boundary === 'start' ? 0 : 23,
minutes: boundary === 'start' ? 0 : 59
}
return (
<div className="export-date-range-time-dropdown" onClick={(event) => event.stopPropagation()}>
<div className="export-date-range-time-dropdown-header">
<span>{boundary === 'start' ? '开始时间' : '结束时间'}</span>
<strong>{currentTime}</strong>
</div>
<div className="export-date-range-time-quick-list">
{QUICK_TIME_OPTIONS.map(option => (
<button
key={`${boundary}-${option}`}
type="button"
className={`export-date-range-time-quick-item ${currentTime === option ? 'active' : ''}`}
onClick={() => updateBoundaryTime(boundary, option)}
>
{option}
</button>
))}
</div>
<div className="export-date-range-time-columns">
<div className="export-date-range-time-column">
<span className="export-date-range-time-column-label"></span>
<div className="export-date-range-time-column-list">
{HOUR_OPTIONS.map(option => (
<button
key={`${boundary}-hour-${option}`}
type="button"
className={`export-date-range-time-option ${parsedCurrent.hours === Number(option) ? 'active' : ''}`}
onClick={() => handleTimeColumnSelect(boundary, 'hour', option)}
>
{option}
</button>
))}
</div>
</div>
<div className="export-date-range-time-column">
<span className="export-date-range-time-column-label"></span>
<div className="export-date-range-time-column-list">
{MINUTE_OPTIONS.map(option => (
<button
key={`${boundary}-minute-${option}`}
type="button"
className={`export-date-range-time-option ${parsedCurrent.minutes === Number(option) ? 'active' : ''}`}
onClick={() => handleTimeColumnSelect(boundary, 'minute', option)}
>
{option}
</button>
))}
</div>
</div>
</div>
</div>
)
}
// Check if date input string contains time (YYYY-MM-DD HH:mm format)
const dateInputHasTime = (dateStr: string): boolean => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(dateStr.trim())
const commitStartFromInput = useCallback(() => { const commitStartFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.start) const parsedDate = parseDateInputValue(dateInput.start)
if (!parsed) { if (!parsedDate) {
setDateInputError(prev => ({ ...prev, start: true })) setDateInputError(prev => ({ ...prev, start: true }))
return return
} }
// Only apply time picker value if date input doesn't contain time
if (!dateInputHasTime(dateInput.start)) {
const parsedTime = parseTimeValue(timeInput.start)
if (parsedTime) {
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
}
}
setDateInputError(prev => ({ ...prev, start: false })) setDateInputError(prev => ({ ...prev, start: false }))
setRangeStart(parsed) setRangeStart(parsedDate)
}, [dateInput.start, setRangeStart]) }, [dateInput.start, timeInput.start, setRangeStart])
const commitEndFromInput = useCallback(() => { const commitEndFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.end) const parsedDate = parseDateInputValue(dateInput.end)
if (!parsed) { if (!parsedDate) {
setDateInputError(prev => ({ ...prev, end: true })) setDateInputError(prev => ({ ...prev, end: true }))
return return
} }
// Only apply time picker value if date input doesn't contain time
if (!dateInputHasTime(dateInput.end)) {
const parsedTime = parseTimeValue(timeInput.end)
if (parsedTime) {
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
}
}
setDateInputError(prev => ({ ...prev, end: false })) setDateInputError(prev => ({ ...prev, end: false }))
setRangeEnd(parsed) setRangeEnd(parsedDate)
}, [dateInput.end, setRangeEnd]) }, [dateInput.end, timeInput.end, setRangeEnd])
const shiftPanelMonth = useCallback((delta: number) => { const shiftPanelMonth = useCallback((delta: number) => {
setDraft(prev => ({ setDraft(prev => ({
@@ -234,30 +463,50 @@ export function ExportDateRangeDialog({
}, []) }, [])
const handleCalendarSelect = useCallback((targetDate: Date) => { const handleCalendarSelect = useCallback((targetDate: Date) => {
// Use time from timeInput state (which is updated by the time picker)
const parseTime = (timeStr: string): { hours: number; minutes: number } => {
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
if (!matched) return { hours: 0, minutes: 0 }
return { hours: Number(matched[1]), minutes: Number(matched[2]) }
}
if (activeBoundary === 'start') { if (activeBoundary === 'start') {
setRangeStart(targetDate) const newStart = new Date(targetDate)
const time = parseTime(timeInput.start)
newStart.setHours(time.hours, time.minutes, 0, 0)
setRangeStart(newStart)
setActiveBoundary('end') setActiveBoundary('end')
setOpenTimeDropdown(null)
return return
} }
setDraft(prev => {
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
const pickedStart = startOfDay(targetDate) const pickedStart = startOfDay(targetDate)
const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start
const nextStart = pickedStart <= start ? pickedStart : start const nextStart = pickedStart <= start ? pickedStart : start
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
return { const newEnd = new Date(targetDate)
const time = parseTime(timeInput.end)
// If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput
if (pickedStart <= start) {
newEnd.setHours(23, 59, 59, 999)
setTimeInput(prev => ({ ...prev, end: '23:59' }))
} else {
newEnd.setHours(time.hours, time.minutes, 59, 999)
}
setDraft(prev => ({
...prev, ...prev,
preset: 'custom', preset: 'custom',
useAllTime: false, useAllTime: false,
dateRange: { dateRange: {
start: nextStart, start: nextStart,
end: nextEnd end: newEnd
}, },
panelMonth: toMonthStart(targetDate) panelMonth: toMonthStart(targetDate)
} }))
})
setActiveBoundary('start') setActiveBoundary('start')
}, [activeBoundary, setRangeEnd, setRangeStart]) setOpenTimeDropdown(null)
}, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart])
const isRangeModeActive = !draft.useAllTime const isRangeModeActive = !draft.useAllTime
const modeText = isRangeModeActive const modeText = isRangeModeActive
@@ -364,6 +613,23 @@ export function ExportDateRangeDialog({
}} }}
onBlur={commitStartFromInput} onBlur={commitStartFromInput}
/> />
<div
className={`export-date-range-time-select ${openTimeDropdown === 'start' ? 'open' : ''}`}
ref={startTimeSelectRef}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="export-date-range-time-trigger"
onClick={() => toggleTimeDropdown('start')}
aria-haspopup="dialog"
aria-expanded={openTimeDropdown === 'start'}
>
<span className="export-date-range-time-trigger-value">{timeInput.start}</span>
<ChevronDown size={14} />
</button>
{openTimeDropdown === 'start' && renderTimeDropdown('start')}
</div>
</div> </div>
<div <div
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`} className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
@@ -391,6 +657,23 @@ export function ExportDateRangeDialog({
}} }}
onBlur={commitEndFromInput} onBlur={commitEndFromInput}
/> />
<div
className={`export-date-range-time-select ${openTimeDropdown === 'end' ? 'open' : ''}`}
ref={endTimeSelectRef}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="export-date-range-time-trigger"
onClick={() => toggleTimeDropdown('end')}
aria-haspopup="dialog"
aria-expanded={openTimeDropdown === 'end'}
>
<span className="export-date-range-time-trigger-value">{timeInput.end}</span>
<ChevronDown size={14} />
</button>
{openTimeDropdown === 'end' && renderTimeDropdown('end')}
</div>
</div> </div>
</div> </div>
@@ -453,7 +736,14 @@ export function ExportDateRangeDialog({
<button <button
type="button" type="button"
className="export-date-range-dialog-btn primary" className="export-date-range-dialog-btn primary"
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))} onClick={() => {
// Validate: end time should not be earlier than start time
if (draft.dateRange.end.getTime() < draft.dateRange.start.getTime()) {
setDateInputError({ start: true, end: true })
return
}
onConfirm(cloneExportDateRangeSelection(draft))
}}
> >
</button> </button>

View File

@@ -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;

View File

@@ -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>

View File

@@ -2064,6 +2064,7 @@
.message-bubble .bubble-content:has(> .link-message), .message-bubble .bubble-content:has(> .link-message),
.message-bubble .bubble-content:has(> .card-message), .message-bubble .bubble-content:has(> .card-message),
.message-bubble .bubble-content:has(> .chat-record-message), .message-bubble .bubble-content:has(> .chat-record-message),
.message-bubble .bubble-content:has(> .solitaire-message),
.message-bubble .bubble-content:has(> .official-message), .message-bubble .bubble-content:has(> .official-message),
.message-bubble .bubble-content:has(> .channel-video-card), .message-bubble .bubble-content:has(> .channel-video-card),
.message-bubble .bubble-content:has(> .location-message) { .message-bubble .bubble-content:has(> .location-message) {
@@ -3604,6 +3605,140 @@
} }
} }
// 接龙消息
.solitaire-message {
width: min(360px, 72vw);
max-width: 360px;
background: var(--card-inner-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
}
.solitaire-header {
display: flex;
gap: 10px;
padding: 12px 14px 10px;
border-bottom: 1px solid var(--border-color);
}
.solitaire-icon {
width: 30px;
height: 30px;
border-radius: 8px;
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.solitaire-heading {
min-width: 0;
flex: 1;
}
.solitaire-title {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.solitaire-meta {
margin-top: 2px;
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.4;
}
.solitaire-intro,
.solitaire-entry-list {
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
}
.solitaire-intro {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.55;
}
.solitaire-intro-line {
white-space: pre-wrap;
word-break: break-word;
}
.solitaire-entry-list {
display: flex;
flex-direction: column;
gap: 7px;
}
.solitaire-entry {
display: flex;
gap: 8px;
align-items: flex-start;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.45;
}
.solitaire-entry-index {
width: 22px;
height: 22px;
border-radius: 8px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 11px;
}
.solitaire-entry-text {
min-width: 0;
flex: 1;
word-break: break-word;
}
.solitaire-muted-line {
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.45;
}
.solitaire-footer {
padding: 8px 14px 10px;
color: var(--text-tertiary);
font-size: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.solitaire-chevron {
transition: transform 0.2s ease;
}
&.expanded .solitaire-chevron {
transform: rotate(180deg);
}
}
// 通话消息 // 通话消息
.call-message { .call-message {
display: flex; display: flex;

View File

@@ -181,6 +181,51 @@ function buildChatRecordPreviewItems(recordList: ChatRecordItem[], maxVisible =
] ]
} }
interface SolitaireEntry {
index: string
text: string
}
interface SolitaireContent {
title: string
introLines: string[]
entries: SolitaireEntry[]
}
function parseSolitaireContent(rawTitle: string): SolitaireContent {
const lines = String(rawTitle || '')
.replace(/\r\n/g, '\n')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
const title = lines[0] || '接龙'
const introLines: string[] = []
const entries: SolitaireEntry[] = []
let hasStartedEntries = false
for (const line of lines.slice(1)) {
const entryMatch = /^(\d+)[..、]\s*(.+)$/.exec(line)
if (entryMatch) {
hasStartedEntries = true
entries.push({
index: entryMatch[1],
text: entryMatch[2].trim()
})
continue
}
if (hasStartedEntries && entries.length > 0) {
const previous = entries[entries.length - 1]
previous.text = `${previous.text} ${line}`.trim()
} else {
introLines.push(line)
}
}
return { title, introLines, entries }
}
function composeGlobalMsgSearchResults( function composeGlobalMsgSearchResults(
seedMap: Map<string, GlobalMsgSearchResult[]>, seedMap: Map<string, GlobalMsgSearchResult[]>,
authoritativeMap: Map<string, GlobalMsgSearchResult[]> authoritativeMap: Map<string, GlobalMsgSearchResult[]>
@@ -1058,6 +1103,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 +5101,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
} }
@@ -7805,6 +7870,7 @@ function MessageBubble({
const [senderName, setSenderName] = useState<string | undefined>(undefined) const [senderName, setSenderName] = useState<string | undefined>(undefined)
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined) const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
const [quoteLayout, setQuoteLayout] = useState<QuoteLayout>('quote-top') const [quoteLayout, setQuoteLayout] = useState<QuoteLayout>('quote-top')
const [solitaireExpanded, setSolitaireExpanded] = useState(false)
const senderProfileRequestSeqRef = useRef(0) const senderProfileRequestSeqRef = useRef(0)
const [emojiError, setEmojiError] = useState(false) const [emojiError, setEmojiError] = useState(false)
const [emojiLoading, setEmojiLoading] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false)
@@ -9413,6 +9479,71 @@ function MessageBubble({
) )
} }
if (xmlType === '53' || message.appMsgKind === 'solitaire') {
const solitaireText = message.linkTitle || q('appmsg > title') || q('title') || cleanedParsedContent || '接龙'
const solitaire = parseSolitaireContent(solitaireText)
const previewEntries = solitaireExpanded ? solitaire.entries : solitaire.entries.slice(0, 3)
const hiddenEntryCount = Math.max(0, solitaire.entries.length - previewEntries.length)
const introLines = solitaireExpanded ? solitaire.introLines : solitaire.introLines.slice(0, 4)
const hasMoreIntro = !solitaireExpanded && solitaire.introLines.length > introLines.length
const countText = solitaire.entries.length > 0 ? `${solitaire.entries.length} 人参与` : '接龙消息'
return (
<div
className={`solitaire-message${solitaireExpanded ? ' expanded' : ''}`}
role="button"
tabIndex={0}
aria-expanded={solitaireExpanded}
onClick={isSelectionMode ? undefined : (e) => {
e.stopPropagation()
setSolitaireExpanded(value => !value)
}}
onKeyDown={isSelectionMode ? undefined : (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
e.stopPropagation()
setSolitaireExpanded(value => !value)
}}
title={solitaireExpanded ? '点击收起接龙' : '点击展开接龙'}
>
<div className="solitaire-header">
<div className="solitaire-icon" aria-hidden="true">
<Hash size={18} />
</div>
<div className="solitaire-heading">
<div className="solitaire-title">{solitaire.title}</div>
<div className="solitaire-meta">{countText}</div>
</div>
</div>
{introLines.length > 0 && (
<div className="solitaire-intro">
{introLines.map((line, index) => (
<div key={`${line}-${index}`} className="solitaire-intro-line">{line}</div>
))}
{hasMoreIntro && <div className="solitaire-muted-line">...</div>}
</div>
)}
{previewEntries.length > 0 ? (
<div className="solitaire-entry-list">
{previewEntries.map(entry => (
<div key={`${entry.index}-${entry.text}`} className="solitaire-entry">
<span className="solitaire-entry-index">{entry.index}</span>
<span className="solitaire-entry-text">{entry.text}</span>
</div>
))}
{hiddenEntryCount > 0 && (
<div className="solitaire-muted-line"> {hiddenEntryCount} ...</div>
)}
</div>
) : null}
<div className="solitaire-footer">
<span>{solitaireExpanded ? '收起接龙' : '展开接龙'}</span>
<ChevronDown size={14} className="solitaire-chevron" />
</div>
</div>
)
}
const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card' const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card'
const desc = message.appMsgDesc || q('des') const desc = message.appMsgDesc || q('des')
const url = message.linkUrl || q('url') const url = message.linkUrl || q('url')

View File

@@ -1105,21 +1105,42 @@ const clampExportSelectionToBounds = (
): ExportDateRangeSelection => { ): ExportDateRangeSelection => {
if (!bounds) return cloneExportDateRangeSelection(selection) if (!bounds) return cloneExportDateRangeSelection(selection)
const boundedStart = startOfDay(bounds.minDate) // For custom selections, only ensure end >= start, preserve time precision
const boundedEnd = endOfDay(bounds.maxDate) if (selection.preset === 'custom' && !selection.useAllTime) {
const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start) const { start, end } = selection.dateRange
const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end) if (end.getTime() < start.getTime()) {
const nextStart = new Date(Math.min(Math.max(originalStart.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
const nextEndCandidate = new Date(Math.min(Math.max(originalEnd.getTime(), boundedStart.getTime()), boundedEnd.getTime()))
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
const rangeChanged = nextStart.getTime() !== originalStart.getTime() || nextEnd.getTime() !== originalEnd.getTime()
return { return {
preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset), ...selection,
useAllTime: selection.useAllTime, dateRange: { start, end: start }
}
}
return cloneExportDateRangeSelection(selection)
}
// For useAllTime, use bounds directly
if (selection.useAllTime) {
return {
preset: selection.preset,
useAllTime: true,
dateRange: { dateRange: {
start: nextStart, start: bounds.minDate,
end: nextEnd end: bounds.maxDate
}
}
}
// For preset selections (not custom), clamp dates to bounds and use default times
const boundedStart = new Date(Math.min(Math.max(selection.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const boundedEnd = new Date(Math.min(Math.max(selection.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
// Use default times: start at 00:00, end at 23:59:59
boundedStart.setHours(0, 0, 0, 0)
boundedEnd.setHours(23, 59, 59, 999)
return {
preset: selection.preset,
useAllTime: false,
dateRange: {
start: boundedStart,
end: boundedEnd
} }
} }
} }
@@ -6866,6 +6887,7 @@ function ExportPage() {
const nextCanExport = Boolean(nextContact && sessionRowByUsername.get(nextContact.username)?.hasSession) const nextCanExport = Boolean(nextContact && sessionRowByUsername.get(nextContact.username)?.hasSession)
const previousSelected = Boolean(previousContact && previousCanExport && selectedSessions.has(previousContact.username)) const previousSelected = Boolean(previousContact && previousCanExport && selectedSessions.has(previousContact.username))
const nextSelected = Boolean(nextContact && nextCanExport && selectedSessions.has(nextContact.username)) const nextSelected = Boolean(nextContact && nextCanExport && selectedSessions.has(nextContact.username))
const resolvedAvatarUrl = normalizeExportAvatarUrl(matchedSession?.avatarUrl || contact.avatarUrl)
const rowClassName = [ const rowClassName = [
'contact-row', 'contact-row',
checked ? 'selected' : '', checked ? 'selected' : '',
@@ -6889,7 +6911,7 @@ function ExportPage() {
</div> </div>
<div className="contact-avatar"> <div className="contact-avatar">
<Avatar <Avatar
src={normalizeExportAvatarUrl(contact.avatarUrl)} src={resolvedAvatarUrl}
name={contact.displayName} name={contact.displayName}
size="100%" size="100%"
shape="rounded" shape="rounded"

View File

@@ -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;

View File

@@ -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,
@@ -71,6 +72,25 @@ interface WxidOption {
avatarUrl?: string avatarUrl?: string
} }
type SessionFilterType = configService.MessagePushSessionType
type SessionFilterTypeValue = 'all' | SessionFilterType
type SessionFilterMode = 'all' | 'whitelist' | 'blacklist'
interface SessionFilterOption {
username: string
displayName: string
avatarUrl?: string
type: SessionFilterType
}
const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: string }> = [
{ value: 'all', label: '全部' },
{ value: 'private', label: '私聊' },
{ value: 'group', label: '群聊' },
{ value: 'official', label: '订阅号/服务号' },
{ value: 'other', label: '其他/非好友' }
]
interface SettingsPageProps { interface SettingsPageProps {
onClose?: () => void onClose?: () => void
} }
@@ -170,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top') const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable') const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
const [filterSearchKeyword, setFilterSearchKeyword] = useState('') const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
const [notificationTypeFilter, setNotificationTypeFilter] = useState<SessionFilterTypeValue>('all')
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false) const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
@@ -225,6 +246,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<SessionFilterTypeValue>('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 +383,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 +415,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 +468,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 || '')
@@ -1186,7 +1222,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const keysOverride = buildKeysFromInputs({ decryptKey: result.key }) const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride }) await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride })
} else { } else {
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { if (
result.error?.includes('未找到微信安装路径') ||
result.error?.includes('启动微信失败') ||
result.error?.includes('未能自动启动微信') ||
result.error?.includes('未找到微信进程') ||
result.error?.includes('微信进程未运行')
) {
setIsManualStartPrompt(true) setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信') setDbKeyStatus('需要手动启动微信')
} else { } else {
@@ -1642,15 +1684,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
) )
const renderNotificationTab = () => { const renderNotificationTab = () => {
// 获取已过滤会话的信息
const getSessionInfo = (username: string) => {
const session = chatSessions.find(s => s.username === username)
return {
displayName: session?.displayName || username,
avatarUrl: session?.avatarUrl || ''
}
}
// 添加会话到过滤列表 // 添加会话到过滤列表
const handleAddToFilterList = async (username: string) => { const handleAddToFilterList = async (username: string) => {
if (notificationFilterList.includes(username)) return if (notificationFilterList.includes(username)) return
@@ -1668,18 +1701,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage('已从过滤列表移除', true) showMessage('已从过滤列表移除', true)
} }
// 过滤掉已在列表中的会话,并根据搜索关键字过滤
const availableSessions = chatSessions.filter(s => {
if (notificationFilterList.includes(s.username)) return false
if (filterSearchKeyword) {
const keyword = filterSearchKeyword.toLowerCase()
const displayName = (s.displayName || '').toLowerCase()
const username = s.username.toLowerCase()
return displayName.includes(keyword) || username.includes(keyword)
}
return true
})
return ( return (
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
@@ -1771,17 +1792,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div <div
key={option.value} key={option.value}
className={`custom-select-option ${notificationFilterMode === option.value ? 'selected' : ''}`} className={`custom-select-option ${notificationFilterMode === option.value ? 'selected' : ''}`}
onClick={async () => { onClick={() => { void handleSetNotificationFilterMode(option.value as SessionFilterMode) }}
const val = option.value as 'all' | 'whitelist' | 'blacklist'
setNotificationFilterMode(val)
setFilterModeDropdownOpen(false)
await configService.setNotificationFilterMode(val)
showMessage(
val === 'all' ? '已设为接收所有通知' :
val === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知',
true
)
}}
> >
{option.label} {option.label}
{notificationFilterMode === option.value && <Check size={14} />} {notificationFilterMode === option.value && <Check size={14} />}
@@ -1800,11 +1811,33 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
: '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'} : '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'}
</span> </span>
<div className="push-filter-type-tabs">
{sessionFilterTypeOptions.map(option => (
<button
key={option.value}
type="button"
className={`push-filter-type-tab ${notificationTypeFilter === option.value ? 'active' : ''}`}
onClick={() => setNotificationTypeFilter(option.value)}
>
{option.label}
</button>
))}
</div>
<div className="notification-filter-container"> <div className="notification-filter-container">
{/* 可选会话列表 */} {/* 可选会话列表 */}
<div className="filter-panel"> <div className="filter-panel">
<div className="filter-panel-header"> <div className="filter-panel-header">
<span></span> <span></span>
{notificationAvailableSessions.length > 0 && (
<button
type="button"
className="filter-panel-action"
onClick={() => { void handleAddAllNotificationFilterSessions() }}
>
</button>
)}
<div className="filter-search-box"> <div className="filter-search-box">
<Search size={14} /> <Search size={14} />
<input <input
@@ -1816,8 +1849,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
</div> </div>
<div className="filter-panel-list"> <div className="filter-panel-list">
{availableSessions.length > 0 ? ( {notificationAvailableSessions.length > 0 ? (
availableSessions.map(session => ( notificationAvailableSessions.map(session => (
<div <div
key={session.username} key={session.username}
className="filter-panel-item" className="filter-panel-item"
@@ -1829,12 +1862,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
size={28} size={28}
/> />
<span className="filter-item-name">{session.displayName || session.username}</span> <span className="filter-item-name">{session.displayName || session.username}</span>
<span className="filter-item-type">{getSessionFilterTypeLabel(session.type)}</span>
<span className="filter-item-action">+</span> <span className="filter-item-action">+</span>
</div> </div>
)) ))
) : ( ) : (
<div className="filter-panel-empty"> <div className="filter-panel-empty">
{filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'} {filterSearchKeyword || notificationTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
</div> </div>
)} )}
</div> </div>
@@ -1847,11 +1881,20 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{notificationFilterList.length > 0 && ( {notificationFilterList.length > 0 && (
<span className="filter-panel-count">{notificationFilterList.length}</span> <span className="filter-panel-count">{notificationFilterList.length}</span>
)} )}
{notificationFilterList.length > 0 && (
<button
type="button"
className="filter-panel-action"
onClick={() => { void handleRemoveAllNotificationFilterSessions() }}
>
</button>
)}
</div> </div>
<div className="filter-panel-list"> <div className="filter-panel-list">
{notificationFilterList.length > 0 ? ( {notificationFilterList.length > 0 ? (
notificationFilterList.map(username => { notificationFilterList.map(username => {
const info = getSessionInfo(username) const info = getSessionFilterOptionInfo(username)
return ( return (
<div <div
key={username} key={username}
@@ -1864,6 +1907,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
size={28} size={28}
/> />
<span className="filter-item-name">{info.displayName}</span> <span className="filter-item-name">{info.displayName}</span>
<span className="filter-item-type">{getSessionFilterTypeLabel(info.type)}</span>
<span className="filter-item-action">×</span> <span className="filter-item-action">×</span>
</div> </div>
) )
@@ -2108,9 +2152,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div> </div>
{isManualStartPrompt ? ( {isManualStartPrompt ? (
<div className="manual-prompt"> <div className="manual-prompt">
<p className="prompt-text"></p> <p className="prompt-text"></p>
<button className="btn btn-primary btn-sm" onClick={handleManualConfirm}> <button className="btn btn-primary btn-sm" onClick={handleManualConfirm}>
</button> </button>
</div> </div>
) : ( ) : (
@@ -2517,6 +2561,163 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true) showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true)
} }
const getSessionFilterType = (session: { username: string; type?: ContactInfo['type'] | number }): SessionFilterType => {
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 getSessionFilterTypeLabel = (type: SessionFilterType) => {
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 handleRemoveAllMessagePushFilterSessions = async () => {
if (messagePushFilterList.length === 0) return
setMessagePushFilterList([])
await configService.setMessagePushFilterList([])
showMessage('已清空主动推送过滤列表', true)
}
const sessionFilterOptionMap = new Map<string, SessionFilterOption>()
for (const session of chatSessions) {
if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue
sessionFilterOptionMap.set(session.username, {
username: session.username,
displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl,
type: getSessionFilterType(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 = sessionFilterOptionMap.get(contact.username)
sessionFilterOptionMap.set(contact.username, {
username: contact.username,
displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username,
avatarUrl: existing?.avatarUrl || contact.avatarUrl,
type: getSessionFilterType(contact)
})
}
const sessionFilterOptions = Array.from(sessionFilterOptionMap.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 getSessionFilterOptionInfo = (username: string) => {
return sessionFilterOptionMap.get(username) || {
username,
displayName: username,
avatarUrl: undefined,
type: 'other' as SessionFilterType
}
}
const getAvailableSessionFilterOptions = (
selectedList: string[],
typeFilter: SessionFilterTypeValue,
searchKeyword: string
) => {
const keyword = searchKeyword.trim().toLowerCase()
return sessionFilterOptions.filter(session => {
if (selectedList.includes(session.username)) return false
if (typeFilter !== 'all' && session.type !== typeFilter) return false
if (keyword) {
return String(session.displayName || '').toLowerCase().includes(keyword) ||
session.username.toLowerCase().includes(keyword)
}
return true
})
}
const notificationAvailableSessions = getAvailableSessionFilterOptions(
notificationFilterList,
notificationTypeFilter,
filterSearchKeyword
)
const messagePushAvailableSessions = getAvailableSessionFilterOptions(
messagePushFilterList,
messagePushTypeFilter,
messagePushFilterSearchKeyword
)
const handleAddAllNotificationFilterSessions = async () => {
const usernames = notificationAvailableSessions.map(session => session.username)
if (usernames.length === 0) return
const next = Array.from(new Set([...notificationFilterList, ...usernames]))
setNotificationFilterList(next)
await configService.setNotificationFilterList(next)
showMessage(`已添加 ${usernames.length} 个会话`, true)
}
const handleRemoveAllNotificationFilterSessions = async () => {
if (notificationFilterList.length === 0) return
setNotificationFilterList([])
await configService.setNotificationFilterList([])
showMessage('已清空通知过滤列表', true)
}
const handleSetNotificationFilterMode = async (mode: SessionFilterMode) => {
setNotificationFilterMode(mode)
setFilterModeDropdownOpen(false)
await configService.setNotificationFilterMode(mode)
showMessage(
mode === 'all' ? '已设为接收所有通知' :
mode === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知',
true
)
}
const handleTestInsightConnection = async () => { const handleTestInsightConnection = async () => {
setIsTestingInsight(true) setIsTestingInsight(true)
setInsightTestResult(null) setInsightTestResult(null)
@@ -3350,6 +3551,154 @@ 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">
{sessionFilterTypeOptions.map(option => (
<button
key={option.value}
type="button"
className={`push-filter-type-tab ${messagePushTypeFilter === option.value ? 'active' : ''}`}
onClick={() => setMessagePushTypeFilter(option.value)}
>
{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">{getSessionFilterTypeLabel(session.type)}</span>
<span className="filter-item-action">+</span>
</div>
))
) : (
<div className="filter-panel-empty">
{messagePushFilterSearchKeyword || messagePushTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
</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>
)}
{messagePushFilterList.length > 0 && (
<button
type="button"
className="filter-panel-action"
onClick={() => { void handleRemoveAllMessagePushFilterSessions() }}
>
</button>
)}
</div>
<div className="filter-panel-list">
{messagePushFilterList.length > 0 ? (
messagePushFilterList.map(username => {
const session = getSessionFilterOptionInfo(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">{getSessionFilterTypeLabel(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 +3733,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>

View File

@@ -368,7 +368,13 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setError('') setError('')
await handleScanWxid(true) await handleScanWxid(true)
} else { } else {
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { if (
result.error?.includes('未找到微信安装路径') ||
result.error?.includes('启动微信失败') ||
result.error?.includes('未能自动启动微信') ||
result.error?.includes('未找到微信进程') ||
result.error?.includes('微信进程未运行')
) {
setIsManualStartPrompt(true) setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信') setDbKeyStatus('需要手动启动微信')
} else { } else {
@@ -844,9 +850,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<div className="key-actions"> <div className="key-actions">
{isManualStartPrompt ? ( {isManualStartPrompt ? (
<div className="manual-prompt"> <div className="manual-prompt">
<p></p> <p></p>
<button className="btn btn-primary" onClick={handleManualConfirm}> <button className="btn btn-primary" onClick={handleManualConfirm}>
</button> </button>
</div> </div>
) : ( ) : (

View File

@@ -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

View File

@@ -138,19 +138,24 @@ export const formatDateInputValue = (date: Date): string => {
const y = date.getFullYear() const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0') const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0') const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}` const h = `${date.getHours()}`.padStart(2, '0')
const min = `${date.getMinutes()}`.padStart(2, '0')
return `${y}-${m}-${d} ${h}:${min}`
} }
export const parseDateInputValue = (raw: string): Date | null => { export const parseDateInputValue = (raw: string): Date | null => {
const text = String(raw || '').trim() const text = String(raw || '').trim()
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) const matched = /^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}):(\d{2}))?$/.exec(text)
if (!matched) return null if (!matched) return null
const year = Number(matched[1]) const year = Number(matched[1])
const month = Number(matched[2]) const month = Number(matched[2])
const day = Number(matched[3]) const day = Number(matched[3])
const hour = matched[4] !== undefined ? Number(matched[4]) : 0
const minute = matched[5] !== undefined ? Number(matched[5]) : 0
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
if (month < 1 || month > 12 || day < 1 || day > 31) return null if (month < 1 || month > 12 || day < 1 || day > 31) return null
const parsed = new Date(year, month - 1, day) if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null
const parsed = new Date(year, month - 1, day, hour, minute, 0, 0)
if ( if (
parsed.getFullYear() !== year || parsed.getFullYear() !== year ||
parsed.getMonth() !== month - 1 || parsed.getMonth() !== month - 1 ||
@@ -291,14 +296,14 @@ export const resolveExportDateRangeConfig = (
const parsedStart = parseStoredDate(raw.start) const parsedStart = parseStoredDate(raw.start)
const parsedEnd = parseStoredDate(raw.end) const parsedEnd = parseStoredDate(raw.end)
if (parsedStart && parsedEnd) { if (parsedStart && parsedEnd) {
const start = startOfDay(parsedStart) const start = parsedStart
const end = endOfDay(parsedEnd) const end = parsedEnd
return { return {
preset: 'custom', preset: 'custom',
useAllTime: false, useAllTime: false,
dateRange: { dateRange: {
start, start,
end: end < start ? endOfDay(start) : end end: end < start ? start : end
} }
} }
} }