mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-12 07:25:50 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e79d18da03 | ||
|
|
69a598f196 | ||
|
|
ac84606f20 | ||
|
|
b086507569 | ||
|
|
360f4917b1 | ||
|
|
89d0f22dac | ||
|
|
f4d63d01bd | ||
|
|
48ca54a856 | ||
|
|
bf3dfbba0f | ||
|
|
bd1bd8a8aa | ||
|
|
7e1ca95bef | ||
|
|
b7cb2cd42d | ||
|
|
6359123323 | ||
|
|
f2f78bb4e2 | ||
|
|
716b21b0dd | ||
|
|
cde3590986 | ||
|
|
f89ad6ec15 | ||
|
|
4efa169313 | ||
|
|
933912f15d | ||
|
|
4e216ce036 | ||
|
|
567fcd3683 | ||
|
|
49ab0de7b3 | ||
|
|
0f34222954 | ||
|
|
caf5b0c9db | ||
|
|
f2d6188c53 | ||
|
|
5bec4f3cd6 | ||
|
|
56b767ff46 |
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
@@ -104,6 +104,11 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure linux key helper is executable
|
||||
shell: bash
|
||||
run: |
|
||||
[ -f "resources/key/linux/x64/xkey_helper" ] && chmod +x "resources/key/linux/x64/xkey_helper" || echo "File not found"
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -311,3 +316,22 @@ jobs:
|
||||
EOF
|
||||
|
||||
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||
|
||||
deploy-aur:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-linux]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Publish AUR package
|
||||
uses: KSXGitHub/github-actions-deploy-aur@master
|
||||
with:
|
||||
pkgname: weflow
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_username: H3CoF6
|
||||
commit_email: h3cof6@gmail.com
|
||||
ssh_keyscan_types: ed25519
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface BizAccount {
|
||||
type: number
|
||||
last_time: number
|
||||
formatted_last_time: string
|
||||
unread_count?: number
|
||||
}
|
||||
|
||||
export interface BizMessage {
|
||||
@@ -104,19 +105,24 @@ export class BizService {
|
||||
if (!root || !accountWxid) return []
|
||||
|
||||
const bizLatestTime: Record<string, number> = {}
|
||||
const bizUnreadCount: Record<string, number> = {}
|
||||
|
||||
try {
|
||||
const sessionsRes = await wcdbService.getSessions()
|
||||
const sessionsRes = await chatService.getSessions()
|
||||
if (sessionsRes.success && sessionsRes.sessions) {
|
||||
for (const session of sessionsRes.sessions) {
|
||||
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)
|
||||
|
||||
if (usernames.includes(uname) && time > 0) {
|
||||
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) {
|
||||
@@ -152,7 +158,8 @@ export class BizService {
|
||||
avatar: info?.avatarUrl || '',
|
||||
type: 0,
|
||||
last_time: lastTime,
|
||||
formatted_last_time: formatBizTime(lastTime)
|
||||
formatted_last_time: formatBizTime(lastTime),
|
||||
unread_count: bizUnreadCount[uname] || 0
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -232,6 +232,16 @@ interface SessionDetailExtra {
|
||||
|
||||
type SessionDetail = SessionDetailFast & SessionDetailExtra
|
||||
|
||||
interface SyntheticUnreadState {
|
||||
readTimestamp: number
|
||||
scannedTimestamp: number
|
||||
latestTimestamp: number
|
||||
unreadCount: number
|
||||
summaryTimestamp?: number
|
||||
summary?: string
|
||||
lastMsgType?: number
|
||||
}
|
||||
|
||||
interface MyFootprintSummary {
|
||||
private_inbound_people: number
|
||||
private_replied_people: number
|
||||
@@ -378,6 +388,7 @@ class ChatService {
|
||||
private readonly messageDbCountSnapshotCacheTtlMs = 8000
|
||||
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
||||
private sessionMessageCountHintCache = new Map<string, number>()
|
||||
private syntheticUnreadState = new Map<string, SyntheticUnreadState>()
|
||||
private sessionMessageCountBatchCache: {
|
||||
dbSignature: 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 来补充信息
|
||||
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<{
|
||||
success: boolean
|
||||
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
|
||||
@@ -1814,6 +2065,9 @@ class ChatService {
|
||||
releaseMessageCursorMutex?.()
|
||||
|
||||
this.messageCacheService.set(sessionId, filtered)
|
||||
if (offset === 0 && startTime === 0 && endTime === 0) {
|
||||
this.markSyntheticUnreadRead(sessionId, filtered)
|
||||
}
|
||||
console.log(
|
||||
`[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':
|
||||
// 引用消息,title 就是回复的内容
|
||||
return title
|
||||
case '53':
|
||||
return `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}`
|
||||
case '2000':
|
||||
return `[转账] ${title}`
|
||||
case '2001':
|
||||
@@ -4445,6 +4701,8 @@ class ChatService {
|
||||
return '[链接]'
|
||||
case '87':
|
||||
return '[群公告]'
|
||||
case '53':
|
||||
return '[接龙]'
|
||||
default:
|
||||
return '[消息]'
|
||||
}
|
||||
@@ -5044,6 +5302,8 @@ class ChatService {
|
||||
const quoteInfo = this.parseQuoteMessage(content)
|
||||
result.quotedContent = quoteInfo.content
|
||||
result.quotedSender = quoteInfo.sender
|
||||
} else if (xmlType === '53') {
|
||||
result.appMsgKind = 'solitaire'
|
||||
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
|
||||
result.appMsgKind = 'official-link'
|
||||
} else if (url) {
|
||||
|
||||
@@ -61,6 +61,8 @@ interface ConfigSchema {
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
messagePushEnabled: boolean
|
||||
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
messagePushFilterList: string[]
|
||||
httpApiEnabled: boolean
|
||||
httpApiPort: number
|
||||
httpApiHost: string
|
||||
@@ -177,6 +179,8 @@ export class ConfigService {
|
||||
httpApiPort: 5031,
|
||||
httpApiHost: '127.0.0.1',
|
||||
messagePushEnabled: false,
|
||||
messagePushFilterMode: 'all',
|
||||
messagePushFilterList: [],
|
||||
windowCloseBehavior: 'ask',
|
||||
quoteLayout: 'quote-top',
|
||||
wordCloudExcludeWords: [],
|
||||
|
||||
@@ -2119,6 +2119,7 @@ class ExportService {
|
||||
}
|
||||
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}` : '[链接]'
|
||||
|
||||
// 有 title 就返回 title
|
||||
@@ -3220,6 +3221,8 @@ class ExportService {
|
||||
appMsgKind = 'announcement'
|
||||
} else if (xmlType === '57' || hasReferMsg || localType === 244813135921) {
|
||||
appMsgKind = 'quote'
|
||||
} else if (xmlType === '53') {
|
||||
appMsgKind = 'solitaire'
|
||||
} else if (xmlType === '5' || xmlType === '49') {
|
||||
appMsgKind = 'link'
|
||||
} else if (looksLikeAppMsg) {
|
||||
|
||||
@@ -98,7 +98,12 @@ export class KeyServiceLinux {
|
||||
'xwechat',
|
||||
'/opt/wechat/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) {
|
||||
@@ -152,7 +157,7 @@ export class KeyServiceLinux {
|
||||
}
|
||||
|
||||
if (!pid) {
|
||||
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动并登录。'
|
||||
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动微信,看到登录窗口后点击确认。'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
@@ -555,7 +555,19 @@ export class KeyServiceMac {
|
||||
if (code === 'HOOK_TARGET_ONLY') {
|
||||
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 '未知错误'
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ interface SessionBaseline {
|
||||
interface MessagePushPayload {
|
||||
event: 'message.new'
|
||||
sessionId: string
|
||||
sessionType: 'private' | 'group' | 'official' | 'other'
|
||||
messageKey: string
|
||||
avatarUrl?: string
|
||||
sourceName: string
|
||||
@@ -20,6 +21,8 @@ interface MessagePushPayload {
|
||||
|
||||
const PUSH_CONFIG_KEYS = new Set([
|
||||
'messagePushEnabled',
|
||||
'messagePushFilterMode',
|
||||
'messagePushFilterList',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
@@ -38,6 +41,7 @@ class MessagePushService {
|
||||
private rerunRequested = false
|
||||
private started = false
|
||||
private baselineReady = false
|
||||
private messageTableScanRequested = false
|
||||
|
||||
constructor() {
|
||||
this.configService = ConfigService.getInstance()
|
||||
@@ -60,12 +64,15 @@ class MessagePushService {
|
||||
payload = null
|
||||
}
|
||||
|
||||
const tableName = String(payload?.table || '').trim().toLowerCase()
|
||||
if (tableName && tableName !== 'session') {
|
||||
const tableName = String(payload?.table || '').trim()
|
||||
if (this.isSessionTableChange(tableName)) {
|
||||
this.scheduleSync()
|
||||
return
|
||||
}
|
||||
|
||||
this.scheduleSync()
|
||||
if (!tableName || this.isMessageTableChange(tableName)) {
|
||||
this.scheduleSync({ scanMessageBackedSessions: true })
|
||||
}
|
||||
}
|
||||
|
||||
async handleConfigChanged(key: string): Promise<void> {
|
||||
@@ -91,6 +98,7 @@ class MessagePushService {
|
||||
this.recentMessageKeys.clear()
|
||||
this.groupNicknameCache.clear()
|
||||
this.baselineReady = false
|
||||
this.messageTableScanRequested = false
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
@@ -121,7 +129,11 @@ class MessagePushService {
|
||||
this.baselineReady = true
|
||||
}
|
||||
|
||||
private scheduleSync(): void {
|
||||
private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void {
|
||||
if (options.scanMessageBackedSessions) {
|
||||
this.messageTableScanRequested = true
|
||||
}
|
||||
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
}
|
||||
@@ -141,6 +153,8 @@ class MessagePushService {
|
||||
this.processing = true
|
||||
try {
|
||||
if (!this.isPushEnabled()) return
|
||||
const scanMessageBackedSessions = this.messageTableScanRequested
|
||||
this.messageTableScanRequested = false
|
||||
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
@@ -163,27 +177,47 @@ class MessagePushService {
|
||||
const previousBaseline = new Map(this.sessionBaseline)
|
||||
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) {
|
||||
await this.pushSessionMessages(session, previousBaseline.get(session.username))
|
||||
await this.pushSessionMessages(
|
||||
session,
|
||||
previousBaseline.get(session.username) || this.sessionBaseline.get(session.username)
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
this.processing = false
|
||||
if (this.rerunRequested) {
|
||||
this.rerunRequested = false
|
||||
this.scheduleSync()
|
||||
this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
for (const session of sessions) {
|
||||
this.sessionBaseline.set(session.username, {
|
||||
lastTimestamp: Number(session.lastTimestamp || 0),
|
||||
const username = String(session.username || '').trim()
|
||||
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)
|
||||
})
|
||||
}
|
||||
for (const [username, baseline] of nextBaseline.entries()) {
|
||||
this.sessionBaseline.set(username, baseline)
|
||||
}
|
||||
}
|
||||
|
||||
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||
@@ -204,16 +238,30 @@ class MessagePushService {
|
||||
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
|
||||
}
|
||||
|
||||
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
|
||||
return unreadCount > previous.unreadCount
|
||||
const summary = String(session.summary || '').trim()
|
||||
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> {
|
||||
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)
|
||||
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
||||
return
|
||||
@@ -224,7 +272,7 @@ class MessagePushService {
|
||||
if (!messageKey) 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
|
||||
}
|
||||
|
||||
@@ -234,9 +282,11 @@ class MessagePushService {
|
||||
|
||||
const payload = await this.buildPayload(session, message)
|
||||
if (!payload) continue
|
||||
if (!this.shouldPushPayload(payload)) continue
|
||||
|
||||
httpService.broadcastMessagePush(payload)
|
||||
this.rememberMessageKey(messageKey)
|
||||
this.bumpSessionBaseline(session.username, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +296,7 @@ class MessagePushService {
|
||||
if (!sessionId || !messageKey) return null
|
||||
|
||||
const isGroup = sessionId.endsWith('@chatroom')
|
||||
const sessionType = this.getSessionType(sessionId, session)
|
||||
const content = this.getMessageDisplayContent(message)
|
||||
|
||||
if (isGroup) {
|
||||
@@ -255,6 +306,7 @@ class MessagePushService {
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
sessionType,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
||||
groupName,
|
||||
@@ -267,6 +319,7 @@ class MessagePushService {
|
||||
return {
|
||||
event: 'message.new',
|
||||
sessionId,
|
||||
sessionType,
|
||||
messageKey,
|
||||
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
||||
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 {
|
||||
const cleanOfficialPrefix = (value: string | null): string | null => {
|
||||
if (!value) return value
|
||||
return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value
|
||||
}
|
||||
switch (Number(message.localType || 0)) {
|
||||
case 1:
|
||||
return message.rawContent || null
|
||||
return cleanOfficialPrefix(message.rawContent || null)
|
||||
case 3:
|
||||
return '[图片]'
|
||||
case 34:
|
||||
@@ -287,13 +414,13 @@ class MessagePushService {
|
||||
case 47:
|
||||
return '[表情]'
|
||||
case 42:
|
||||
return message.cardNickname || '[名片]'
|
||||
return cleanOfficialPrefix(message.cardNickname || '[名片]')
|
||||
case 48:
|
||||
return '[位置]'
|
||||
case 49:
|
||||
return message.linkTitle || message.fileName || '[消息]'
|
||||
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
|
||||
default:
|
||||
return message.parsedContent || message.rawContent || null
|
||||
return cleanOfficialPrefix(message.parsedContent || message.rawContent || null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
0
resources/key/linux/x64/xkey_helper_linux
Normal file → Executable file
0
resources/key/linux/x64/xkey_helper_linux
Normal file → Executable 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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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 { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import {
|
||||
EXPORT_DATE_RANGE_PRESETS,
|
||||
WEEKDAY_SHORT_LABELS,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
createDateRangeByPreset,
|
||||
createDefaultDateRange,
|
||||
formatCalendarMonthTitle,
|
||||
formatDateInputValue,
|
||||
isSameDay,
|
||||
parseDateInputValue,
|
||||
startOfDay,
|
||||
@@ -37,6 +36,10 @@ interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
|
||||
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 => {
|
||||
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
|
||||
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
|
||||
@@ -57,16 +60,42 @@ const clampSelectionToBounds = (
|
||||
const bounds = resolveBounds(minDate, maxDate)
|
||||
if (!bounds) return cloneExportDateRangeSelection(value)
|
||||
|
||||
const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start)
|
||||
const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end)
|
||||
const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
|
||||
const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime()
|
||||
// For custom selections, only ensure end >= start, preserve time precision
|
||||
if (value.preset === 'custom' && !value.useAllTime) {
|
||||
const { start, end } = value.dateRange
|
||||
if (end.getTime() < start.getTime()) {
|
||||
return {
|
||||
...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 {
|
||||
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
|
||||
useAllTime: value.useAllTime,
|
||||
preset: value.preset,
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
@@ -95,62 +124,129 @@ export function ExportDateRangeDialog({
|
||||
onClose,
|
||||
onConfirm
|
||||
}: 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 [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
|
||||
const [dateInput, setDateInput] = useState({
|
||||
start: formatDateInputValue(value.dateRange.start),
|
||||
end: formatDateInputValue(value.dateRange.end)
|
||||
start: formatDateOnly(value.dateRange.start),
|
||||
end: formatDateOnly(value.dateRange.end)
|
||||
})
|
||||
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(() => {
|
||||
if (!open) return
|
||||
const nextDraft = buildDialogDraft(value, minDate, maxDate)
|
||||
setDraft(nextDraft)
|
||||
setActiveBoundary('start')
|
||||
setDateInput({
|
||||
start: formatDateInputValue(nextDraft.dateRange.start),
|
||||
end: formatDateInputValue(nextDraft.dateRange.end)
|
||||
start: formatDateOnly(nextDraft.dateRange.start),
|
||||
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 })
|
||||
}, [maxDate, minDate, open, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setDateInput({
|
||||
start: formatDateInputValue(draft.dateRange.start),
|
||||
end: formatDateInputValue(draft.dateRange.end)
|
||||
start: formatDateOnly(draft.dateRange.start),
|
||||
end: formatDateOnly(draft.dateRange.end)
|
||||
})
|
||||
// Don't sync timeInput here - it's controlled by the time picker
|
||||
setDateInputError({ start: false, end: false })
|
||||
}, [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 clampStartDate = useCallback((targetDate: Date) => {
|
||||
const start = startOfDay(targetDate)
|
||||
if (!bounds) return start
|
||||
if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate
|
||||
if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate)
|
||||
return start
|
||||
if (!bounds) return targetDate
|
||||
const min = bounds.minDate
|
||||
const max = bounds.maxDate
|
||||
if (targetDate.getTime() < min.getTime()) return min
|
||||
if (targetDate.getTime() > max.getTime()) return max
|
||||
return targetDate
|
||||
}, [bounds])
|
||||
const clampEndDate = useCallback((targetDate: Date) => {
|
||||
const end = endOfDay(targetDate)
|
||||
if (!bounds) return end
|
||||
if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate)
|
||||
if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate
|
||||
return end
|
||||
if (!bounds) return targetDate
|
||||
const min = bounds.minDate
|
||||
const max = bounds.maxDate
|
||||
if (targetDate.getTime() < min.getTime()) return min
|
||||
if (targetDate.getTime() > max.getTime()) return max
|
||||
return targetDate
|
||||
}, [bounds])
|
||||
|
||||
const setRangeStart = useCallback((targetDate: Date) => {
|
||||
const start = clampStartDate(targetDate)
|
||||
setDraft(prev => {
|
||||
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start,
|
||||
end: nextEnd
|
||||
end: prev.dateRange.end
|
||||
},
|
||||
panelMonth: toMonthStart(start)
|
||||
}
|
||||
@@ -161,14 +257,13 @@ export function ExportDateRangeDialog({
|
||||
const end = clampEndDate(targetDate)
|
||||
setDraft(prev => {
|
||||
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
|
||||
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
end: end
|
||||
},
|
||||
panelMonth: toMonthStart(targetDate)
|
||||
}
|
||||
@@ -180,6 +275,11 @@ export function ExportDateRangeDialog({
|
||||
const previewRange = bounds
|
||||
? { start: bounds.minDate, end: bounds.maxDate }
|
||||
: createDefaultDateRange()
|
||||
setTimeInput({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
setOpenTimeDropdown(null)
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset,
|
||||
@@ -196,6 +296,11 @@ export function ExportDateRangeDialog({
|
||||
useAllTime: false,
|
||||
dateRange: createDateRangeByPreset(preset)
|
||||
}, minDate, maxDate).dateRange
|
||||
setTimeInput({
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
})
|
||||
setOpenTimeDropdown(null)
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset,
|
||||
@@ -206,25 +311,149 @@ export function ExportDateRangeDialog({
|
||||
setActiveBoundary('start')
|
||||
}, [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 parsed = parseDateInputValue(dateInput.start)
|
||||
if (!parsed) {
|
||||
const parsedDate = parseDateInputValue(dateInput.start)
|
||||
if (!parsedDate) {
|
||||
setDateInputError(prev => ({ ...prev, start: true }))
|
||||
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 }))
|
||||
setRangeStart(parsed)
|
||||
}, [dateInput.start, setRangeStart])
|
||||
setRangeStart(parsedDate)
|
||||
}, [dateInput.start, timeInput.start, setRangeStart])
|
||||
|
||||
const commitEndFromInput = useCallback(() => {
|
||||
const parsed = parseDateInputValue(dateInput.end)
|
||||
if (!parsed) {
|
||||
const parsedDate = parseDateInputValue(dateInput.end)
|
||||
if (!parsedDate) {
|
||||
setDateInputError(prev => ({ ...prev, end: true }))
|
||||
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 }))
|
||||
setRangeEnd(parsed)
|
||||
}, [dateInput.end, setRangeEnd])
|
||||
setRangeEnd(parsedDate)
|
||||
}, [dateInput.end, timeInput.end, setRangeEnd])
|
||||
|
||||
const shiftPanelMonth = useCallback((delta: number) => {
|
||||
setDraft(prev => ({
|
||||
@@ -234,30 +463,50 @@ export function ExportDateRangeDialog({
|
||||
}, [])
|
||||
|
||||
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') {
|
||||
setRangeStart(targetDate)
|
||||
const newStart = new Date(targetDate)
|
||||
const time = parseTime(timeInput.start)
|
||||
newStart.setHours(time.hours, time.minutes, 0, 0)
|
||||
setRangeStart(newStart)
|
||||
setActiveBoundary('end')
|
||||
setOpenTimeDropdown(null)
|
||||
return
|
||||
}
|
||||
|
||||
setDraft(prev => {
|
||||
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
||||
const pickedStart = startOfDay(targetDate)
|
||||
const nextStart = pickedStart <= start ? pickedStart : start
|
||||
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
|
||||
return {
|
||||
...prev,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
},
|
||||
panelMonth: toMonthStart(targetDate)
|
||||
}
|
||||
})
|
||||
const pickedStart = startOfDay(targetDate)
|
||||
const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start
|
||||
const nextStart = pickedStart <= start ? pickedStart : start
|
||||
|
||||
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,
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: newEnd
|
||||
},
|
||||
panelMonth: toMonthStart(targetDate)
|
||||
}))
|
||||
setActiveBoundary('start')
|
||||
}, [activeBoundary, setRangeEnd, setRangeStart])
|
||||
setOpenTimeDropdown(null)
|
||||
}, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart])
|
||||
|
||||
const isRangeModeActive = !draft.useAllTime
|
||||
const modeText = isRangeModeActive
|
||||
@@ -364,6 +613,23 @@ export function ExportDateRangeDialog({
|
||||
}}
|
||||
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
|
||||
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
||||
@@ -391,6 +657,23 @@ export function ExportDateRangeDialog({
|
||||
}}
|
||||
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>
|
||||
|
||||
@@ -453,7 +736,14 @@ export function ExportDateRangeDialog({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
}
|
||||
|
||||
.biz-account-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -46,6 +47,24 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useThemeStore } from '../stores/themeStore';
|
||||
import { Newspaper, MessageSquareOff } from 'lucide-react';
|
||||
import './BizPage.scss';
|
||||
@@ -10,6 +10,7 @@ export interface BizAccount {
|
||||
type: string;
|
||||
last_time: number;
|
||||
formatted_last_time: string;
|
||||
unread_count?: number;
|
||||
}
|
||||
|
||||
export const BizAccountList: React.FC<{
|
||||
@@ -36,25 +37,42 @@ export const BizAccountList: React.FC<{
|
||||
initWxid().then(_r => { });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
if (!myWxid) {
|
||||
return;
|
||||
}
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
if (!myWxid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await window.electronAPI.biz.listAccounts(myWxid)
|
||||
setAccounts(res || []);
|
||||
} catch (err) {
|
||||
console.error('获取服务号列表失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetch().then(_r => { } );
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await window.electronAPI.biz.listAccounts(myWxid)
|
||||
setAccounts(res || []);
|
||||
} catch (err) {
|
||||
console.error('获取服务号列表失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [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(() => {
|
||||
let result = accounts;
|
||||
@@ -80,7 +98,12 @@ export const BizAccountList: React.FC<{
|
||||
{filtered.map(item => (
|
||||
<div
|
||||
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' : ''}`}
|
||||
>
|
||||
<img
|
||||
@@ -88,6 +111,9 @@ export const BizAccountList: React.FC<{
|
||||
className="biz-avatar"
|
||||
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-top">
|
||||
<span className="biz-name">{item.name || item.username}</span>
|
||||
|
||||
@@ -2064,6 +2064,7 @@
|
||||
.message-bubble .bubble-content:has(> .link-message),
|
||||
.message-bubble .bubble-content:has(> .card-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(> .channel-video-card),
|
||||
.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 {
|
||||
display: flex;
|
||||
|
||||
@@ -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(
|
||||
seedMap: Map<string, GlobalMsgSearchResult[]>,
|
||||
authoritativeMap: Map<string, GlobalMsgSearchResult[]>
|
||||
@@ -1058,6 +1103,13 @@ const SessionItem = React.memo(function SessionItem({
|
||||
</div>
|
||||
<div className="session-bottom">
|
||||
<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>
|
||||
@@ -5049,24 +5101,37 @@ function ChatPage(props: ChatPageProps) {
|
||||
return []
|
||||
}
|
||||
|
||||
const officialSessions = sessions.filter(s => s.username.startsWith('gh_'))
|
||||
|
||||
// 检查是否有折叠的群聊
|
||||
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
||||
const hasFoldedGroups = foldedGroups.length > 0
|
||||
|
||||
let visible = sessions.filter(s => {
|
||||
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
||||
if (s.username.startsWith('gh_')) return false
|
||||
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 = {
|
||||
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
||||
displayName: '公众号',
|
||||
summary: '查看公众号历史消息',
|
||||
summary: latestOfficial
|
||||
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
|
||||
: '查看公众号历史消息',
|
||||
type: 0,
|
||||
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
|
||||
lastTimestamp: 0,
|
||||
lastMsgType: 0,
|
||||
unreadCount: 0,
|
||||
lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0,
|
||||
lastMsgType: latestOfficial?.lastMsgType || 0,
|
||||
unreadCount: officialUnreadCount,
|
||||
isMuted: false,
|
||||
isFolded: false
|
||||
}
|
||||
@@ -7805,6 +7870,7 @@ function MessageBubble({
|
||||
const [senderName, setSenderName] = useState<string | undefined>(undefined)
|
||||
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
|
||||
const [quoteLayout, setQuoteLayout] = useState<QuoteLayout>('quote-top')
|
||||
const [solitaireExpanded, setSolitaireExpanded] = useState(false)
|
||||
const senderProfileRequestSeqRef = useRef(0)
|
||||
const [emojiError, setEmojiError] = 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 desc = message.appMsgDesc || q('des')
|
||||
const url = message.linkUrl || q('url')
|
||||
|
||||
@@ -1105,21 +1105,42 @@ const clampExportSelectionToBounds = (
|
||||
): ExportDateRangeSelection => {
|
||||
if (!bounds) return cloneExportDateRangeSelection(selection)
|
||||
|
||||
const boundedStart = startOfDay(bounds.minDate)
|
||||
const boundedEnd = endOfDay(bounds.maxDate)
|
||||
const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start)
|
||||
const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end)
|
||||
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()
|
||||
// For custom selections, only ensure end >= start, preserve time precision
|
||||
if (selection.preset === 'custom' && !selection.useAllTime) {
|
||||
const { start, end } = selection.dateRange
|
||||
if (end.getTime() < start.getTime()) {
|
||||
return {
|
||||
...selection,
|
||||
dateRange: { start, end: start }
|
||||
}
|
||||
}
|
||||
return cloneExportDateRangeSelection(selection)
|
||||
}
|
||||
|
||||
// For useAllTime, use bounds directly
|
||||
if (selection.useAllTime) {
|
||||
return {
|
||||
preset: selection.preset,
|
||||
useAllTime: true,
|
||||
dateRange: {
|
||||
start: bounds.minDate,
|
||||
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.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset),
|
||||
useAllTime: selection.useAllTime,
|
||||
preset: selection.preset,
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
start: boundedStart,
|
||||
end: boundedEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6866,6 +6887,7 @@ function ExportPage() {
|
||||
const nextCanExport = Boolean(nextContact && sessionRowByUsername.get(nextContact.username)?.hasSession)
|
||||
const previousSelected = Boolean(previousContact && previousCanExport && selectedSessions.has(previousContact.username))
|
||||
const nextSelected = Boolean(nextContact && nextCanExport && selectedSessions.has(nextContact.username))
|
||||
const resolvedAvatarUrl = normalizeExportAvatarUrl(matchedSession?.avatarUrl || contact.avatarUrl)
|
||||
const rowClassName = [
|
||||
'contact-row',
|
||||
checked ? 'selected' : '',
|
||||
@@ -6889,7 +6911,7 @@ function ExportPage() {
|
||||
</div>
|
||||
<div className="contact-avatar">
|
||||
<Avatar
|
||||
src={normalizeExportAvatarUrl(contact.avatarUrl)}
|
||||
src={resolvedAvatarUrl}
|
||||
name={contact.displayName}
|
||||
size="100%"
|
||||
shape="rounded"
|
||||
|
||||
@@ -2349,6 +2349,24 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
@@ -2412,6 +2430,16 @@
|
||||
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 {
|
||||
font-size: 18px;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useThemeStore, themes } from '../stores/themeStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
import type { ContactInfo } from '../types/models'
|
||||
import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
|
||||
@@ -71,6 +72,25 @@ interface WxidOption {
|
||||
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 {
|
||||
onClose?: () => void
|
||||
}
|
||||
@@ -170,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
|
||||
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
|
||||
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
||||
const [notificationTypeFilter, setNotificationTypeFilter] = useState<SessionFilterTypeValue>('all')
|
||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||
const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false)
|
||||
@@ -225,6 +246,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||
const [showApiWarning, setShowApiWarning] = 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 [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
|
||||
@@ -356,15 +383,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setFilterModeDropdownOpen(false)
|
||||
setPositionDropdownOpen(false)
|
||||
setCloseBehaviorDropdownOpen(false)
|
||||
setMessagePushFilterDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) {
|
||||
if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen])
|
||||
}, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen])
|
||||
|
||||
|
||||
const loadConfig = async () => {
|
||||
@@ -387,6 +415,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||
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 savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||
const savedQuoteLayout = await configService.getQuoteLayout()
|
||||
@@ -437,6 +468,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setNotificationFilterMode(savedNotificationFilterMode)
|
||||
setNotificationFilterList(savedNotificationFilterList)
|
||||
setMessagePushEnabled(savedMessagePushEnabled)
|
||||
setMessagePushFilterMode(savedMessagePushFilterMode)
|
||||
setMessagePushFilterList(savedMessagePushFilterList)
|
||||
if (contactsResult.success && Array.isArray(contactsResult.contacts)) {
|
||||
setMessagePushContactOptions(contactsResult.contacts as ContactInfo[])
|
||||
}
|
||||
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
|
||||
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
|
||||
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
|
||||
@@ -1186,7 +1222,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
|
||||
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride })
|
||||
} 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)
|
||||
setDbKeyStatus('需要手动启动微信')
|
||||
} else {
|
||||
@@ -1642,15 +1684,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
)
|
||||
|
||||
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) => {
|
||||
if (notificationFilterList.includes(username)) return
|
||||
@@ -1668,18 +1701,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
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 (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
@@ -1771,17 +1792,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-select-option ${notificationFilterMode === option.value ? 'selected' : ''}`}
|
||||
onClick={async () => {
|
||||
const val = option.value as 'all' | 'whitelist' | 'blacklist'
|
||||
setNotificationFilterMode(val)
|
||||
setFilterModeDropdownOpen(false)
|
||||
await configService.setNotificationFilterMode(val)
|
||||
showMessage(
|
||||
val === 'all' ? '已设为接收所有通知' :
|
||||
val === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知',
|
||||
true
|
||||
)
|
||||
}}
|
||||
onClick={() => { void handleSetNotificationFilterMode(option.value as SessionFilterMode) }}
|
||||
>
|
||||
{option.label}
|
||||
{notificationFilterMode === option.value && <Check size={14} />}
|
||||
@@ -1800,11 +1811,33 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
: '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'}
|
||||
</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="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<span>可选会话</span>
|
||||
{notificationAvailableSessions.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="filter-panel-action"
|
||||
onClick={() => { void handleAddAllNotificationFilterSessions() }}
|
||||
>
|
||||
全选当前
|
||||
</button>
|
||||
)}
|
||||
<div className="filter-search-box">
|
||||
<Search size={14} />
|
||||
<input
|
||||
@@ -1816,8 +1849,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{availableSessions.length > 0 ? (
|
||||
availableSessions.map(session => (
|
||||
{notificationAvailableSessions.length > 0 ? (
|
||||
notificationAvailableSessions.map(session => (
|
||||
<div
|
||||
key={session.username}
|
||||
className="filter-panel-item"
|
||||
@@ -1829,12 +1862,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
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">
|
||||
{filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'}
|
||||
{filterSearchKeyword || notificationTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1847,11 +1881,20 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
{notificationFilterList.length > 0 && (
|
||||
<span className="filter-panel-count">{notificationFilterList.length}</span>
|
||||
)}
|
||||
{notificationFilterList.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="filter-panel-action"
|
||||
onClick={() => { void handleRemoveAllNotificationFilterSessions() }}
|
||||
>
|
||||
全不选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-panel-list">
|
||||
{notificationFilterList.length > 0 ? (
|
||||
notificationFilterList.map(username => {
|
||||
const info = getSessionInfo(username)
|
||||
const info = getSessionFilterOptionInfo(username)
|
||||
return (
|
||||
<div
|
||||
key={username}
|
||||
@@ -1864,6 +1907,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
size={28}
|
||||
/>
|
||||
<span className="filter-item-name">{info.displayName}</span>
|
||||
<span className="filter-item-type">{getSessionFilterTypeLabel(info.type)}</span>
|
||||
<span className="filter-item-action">×</span>
|
||||
</div>
|
||||
)
|
||||
@@ -2108,9 +2152,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
{isManualStartPrompt ? (
|
||||
<div className="manual-prompt">
|
||||
<p className="prompt-text">未能自动启动微信,请手动启动并登录后点击下方确认</p>
|
||||
<p className="prompt-text">未能自动启动微信,请手动启动微信,看到登录窗口后点击下方确认</p>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleManualConfirm}>
|
||||
我已启动微信,继续检测
|
||||
我已看到登录窗口,继续检测
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -2517,6 +2561,163 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
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 () => {
|
||||
setIsTestingInsight(true)
|
||||
setInsightTestResult(null)
|
||||
@@ -3350,6 +3551,154 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</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">
|
||||
<label>推送地址</label>
|
||||
<span className="form-hint">外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务`</span>
|
||||
@@ -3384,7 +3733,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
</div>
|
||||
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
||||
<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">
|
||||
<code>{param}</code>
|
||||
</span>
|
||||
|
||||
@@ -368,7 +368,13 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
setError('')
|
||||
await handleScanWxid(true)
|
||||
} 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)
|
||||
setDbKeyStatus('需要手动启动微信')
|
||||
} else {
|
||||
@@ -844,9 +850,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<div className="key-actions">
|
||||
{isManualStartPrompt ? (
|
||||
<div className="manual-prompt">
|
||||
<p>未能自动启动微信,请手动启动并登录</p>
|
||||
<p>未能自动启动微信,请手动启动微信,看到登录窗口后点击下方确认</p>
|
||||
<button className="btn btn-primary" onClick={handleManualConfirm}>
|
||||
我已登录,继续
|
||||
我已看到登录窗口,继续
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -72,6 +72,8 @@ export const CONFIG_KEYS = {
|
||||
HTTP_API_PORT: 'httpApiPort',
|
||||
HTTP_API_HOST: 'httpApiHost',
|
||||
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
||||
MESSAGE_PUSH_FILTER_MODE: 'messagePushFilterMode',
|
||||
MESSAGE_PUSH_FILTER_LIST: 'messagePushFilterList',
|
||||
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||
QUOTE_LAYOUT: 'quoteLayout',
|
||||
|
||||
@@ -1505,6 +1507,29 @@ export async function setMessagePushEnabled(enabled: boolean): Promise<void> {
|
||||
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> {
|
||||
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
|
||||
if (value === 'tray' || value === 'quit') return value
|
||||
|
||||
@@ -138,19 +138,24 @@ export const formatDateInputValue = (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}`
|
||||
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 => {
|
||||
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
|
||||
const year = Number(matched[1])
|
||||
const month = Number(matched[2])
|
||||
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 (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 (
|
||||
parsed.getFullYear() !== year ||
|
||||
parsed.getMonth() !== month - 1 ||
|
||||
@@ -291,14 +296,14 @@ export const resolveExportDateRangeConfig = (
|
||||
const parsedStart = parseStoredDate(raw.start)
|
||||
const parsedEnd = parseStoredDate(raw.end)
|
||||
if (parsedStart && parsedEnd) {
|
||||
const start = startOfDay(parsedStart)
|
||||
const end = endOfDay(parsedEnd)
|
||||
const start = parsedStart
|
||||
const end = parsedEnd
|
||||
return {
|
||||
preset: 'custom',
|
||||
useAllTime: false,
|
||||
dateRange: {
|
||||
start,
|
||||
end: end < start ? endOfDay(start) : end
|
||||
end: end < start ? start : end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user