diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44cf1bb..db35077 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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] # 确保 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: KSX_Zeus/github-action-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 diff --git a/electron/main.ts b/electron/main.ts index dca37dc..f6a873a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1635,6 +1635,22 @@ function registerIpcHandlers() { return insightService.triggerTest() }) + ipcMain.handle('insight:generateFootprintInsight', async (_, payload: { + rangeLabel: string + summary: { + private_inbound_people?: number + private_replied_people?: number + private_outbound_people?: number + private_reply_rate?: number + mention_count?: number + mention_group_count?: number + } + privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> + mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> + }) => { + return insightService.generateFootprintInsight(payload) + }) + ipcMain.handle('config:clear', async () => { if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { const result = setSystemLaunchAtStartup(false) diff --git a/electron/preload.ts b/electron/preload.ts index 9e45516..838a305 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -526,6 +526,19 @@ contextBridge.exposeInMainWorld('electronAPI', { insight: { testConnection: () => ipcRenderer.invoke('insight:testConnection'), getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'), - triggerTest: () => ipcRenderer.invoke('insight:triggerTest') + triggerTest: () => ipcRenderer.invoke('insight:triggerTest'), + generateFootprintInsight: (payload: { + rangeLabel: string + summary: { + private_inbound_people?: number + private_replied_people?: number + private_outbound_people?: number + private_reply_rate?: number + mention_count?: number + mention_group_count?: number + } + privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> + mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> + }) => ipcRenderer.invoke('insight:generateFootprintInsight', payload) } }) diff --git a/electron/services/bizService.ts b/electron/services/bizService.ts index f7c0eed..a5bb984 100644 --- a/electron/services/bizService.ts +++ b/electron/services/bizService.ts @@ -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 = {} + const bizUnreadCount: Record = {} 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 } }) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 90f2555..e6da68f 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -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() private sessionMessageCountHintCache = new Map() + private syntheticUnreadState = new Map() 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 { + 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[]) { + 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[]) { + 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 { + 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 @@ -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) { diff --git a/electron/services/config.ts b/electron/services/config.ts index c096d06..250c93d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -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 @@ -71,6 +73,9 @@ interface ConfigSchema { exportWriteLayout: 'A' | 'B' | 'C' // AI 见解 + aiModelApiBaseUrl: string + aiModelApiKey: string + aiModelApiModel: string aiInsightEnabled: boolean aiInsightApiBaseUrl: string aiInsightApiKey: string @@ -93,10 +98,21 @@ interface ConfigSchema { aiInsightTelegramToken: string /** Telegram 接收 Chat ID,逗号分隔,支持多个 */ aiInsightTelegramChatIds: string + + // AI 足迹 + aiFootprintEnabled: boolean + aiFootprintSystemPrompt: string } // 需要 safeStorage 加密的字段(普通模式) -const ENCRYPTED_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken', 'aiInsightApiKey']) +const ENCRYPTED_STRING_KEYS: Set = new Set([ + 'decryptKey', + 'imageAesKey', + 'authPassword', + 'httpApiToken', + 'aiModelApiKey', + 'aiInsightApiKey' +]) const ENCRYPTED_BOOL_KEYS: Set = new Set(['authEnabled', 'authUseHello']) const ENCRYPTED_NUMBER_KEYS: Set = new Set(['imageXorKey']) @@ -163,10 +179,15 @@ export class ConfigService { httpApiPort: 5031, httpApiHost: '127.0.0.1', messagePushEnabled: false, + messagePushFilterMode: 'all', + messagePushFilterList: [], windowCloseBehavior: 'ask', quoteLayout: 'quote-top', wordCloudExcludeWords: [], exportWriteLayout: 'A', + aiModelApiBaseUrl: '', + aiModelApiKey: '', + aiModelApiModel: 'gpt-4o-mini', aiInsightEnabled: false, aiInsightApiBaseUrl: '', aiInsightApiKey: '', @@ -181,7 +202,9 @@ export class ConfigService { aiInsightSystemPrompt: '', aiInsightTelegramEnabled: false, aiInsightTelegramToken: '', - aiInsightTelegramChatIds: '' + aiInsightTelegramChatIds: '', + aiFootprintEnabled: false, + aiFootprintSystemPrompt: '' } const storeOptions: any = { @@ -213,6 +236,7 @@ export class ConfigService { } } this.migrateAuthFields() + this.migrateAiConfig() } // === 状态查询 === @@ -717,6 +741,26 @@ export class ConfigService { } } + private migrateAiConfig(): void { + const sharedBaseUrl = String(this.get('aiModelApiBaseUrl') || '').trim() + const sharedApiKey = String(this.get('aiModelApiKey') || '').trim() + const sharedModel = String(this.get('aiModelApiModel') || '').trim() + + const legacyBaseUrl = String(this.get('aiInsightApiBaseUrl') || '').trim() + const legacyApiKey = String(this.get('aiInsightApiKey') || '').trim() + const legacyModel = String(this.get('aiInsightApiModel') || '').trim() + + if (!sharedBaseUrl && legacyBaseUrl) { + this.set('aiModelApiBaseUrl', legacyBaseUrl) + } + if (!sharedApiKey && legacyApiKey) { + this.set('aiModelApiKey', legacyApiKey) + } + if (!sharedModel && legacyModel) { + this.set('aiModelApiModel', legacyModel) + } + } + // === 验证 === verifyAuthEnabled(): boolean { diff --git a/electron/services/exportRecordService.ts b/electron/services/exportRecordService.ts index 23c82a9..5ff1049 100644 --- a/electron/services/exportRecordService.ts +++ b/electron/services/exportRecordService.ts @@ -19,7 +19,8 @@ class ExportRecordService { private resolveFilePath(): string { if (this.filePath) return this.filePath - const userDataPath = app.getPath('userData') + const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() + const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd() fs.mkdirSync(userDataPath, { recursive: true }) this.filePath = path.join(userDataPath, 'weflow-export-records.json') return this.filePath diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index d13458c..2717718 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -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) { diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index 47295ad..6890f7a 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -39,6 +39,9 @@ const DEFAULT_SILENCE_DAYS = 3 const INSIGHT_CONFIG_KEYS = new Set([ 'aiInsightEnabled', 'aiInsightScanIntervalHours', + 'aiModelApiBaseUrl', + 'aiModelApiKey', + 'aiModelApiModel', 'dbPath', 'decryptKey', 'myWxid' @@ -51,6 +54,12 @@ interface TodayTriggerRecord { timestamps: number[] } +interface SharedAiModelConfig { + apiBaseUrl: string + apiKey: string + model: string +} + // ─── 日志 ───────────────────────────────────────────────────────────────────── /** @@ -320,9 +329,7 @@ class InsightService { * 供设置页"测试连接"按钮调用。 */ async testConnection(): Promise<{ success: boolean; message: string }> { - const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string - const apiKey = this.config.get('aiInsightApiKey') as string - const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini' + const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() if (!apiBaseUrl || !apiKey) { return { success: false, message: '请先填写 API 地址和 API Key' } @@ -348,8 +355,7 @@ class InsightService { */ async triggerTest(): Promise<{ success: boolean; message: string }> { insightLog('INFO', '手动触发测试见解...') - const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string - const apiKey = this.config.get('aiInsightApiKey') as string + const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig() if (!apiBaseUrl || !apiKey) { return { success: false, message: '请先填写 API 地址和 Key' } } @@ -398,12 +404,124 @@ class InsightService { return result } + async generateFootprintInsight(params: { + rangeLabel: string + summary: { + private_inbound_people?: number + private_replied_people?: number + private_outbound_people?: number + private_reply_rate?: number + mention_count?: number + mention_group_count?: number + } + privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> + mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> + }): Promise<{ success: boolean; message: string; insight?: string }> { + const enabled = this.config.get('aiFootprintEnabled') === true + if (!enabled) { + return { success: false, message: '请先在设置中开启「AI 足迹总结」' } + } + + const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() + if (!apiBaseUrl || !apiKey) { + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } + } + + const summary = params?.summary || {} + const rangeLabel = String(params?.rangeLabel || '').trim() || '当前范围' + const privateSegments = Array.isArray(params?.privateSegments) ? params.privateSegments.slice(0, 6) : [] + const mentionGroups = Array.isArray(params?.mentionGroups) ? params.mentionGroups.slice(0, 6) : [] + + const topPrivateText = privateSegments.length > 0 + ? privateSegments + .map((item, idx) => { + const name = String(item.displayName || item.session_id || `联系人${idx + 1}`).trim() + const inbound = Number(item.incoming_count) || 0 + const outbound = Number(item.outgoing_count) || 0 + const total = Math.max(Number(item.message_count) || 0, inbound + outbound) + return `${idx + 1}. ${name}(收${inbound}/发${outbound}/总${total}${item.replied ? '/已回复' : ''})` + }) + .join('\n') + : '无' + + const topMentionText = mentionGroups.length > 0 + ? mentionGroups + .map((item, idx) => { + const name = String(item.displayName || item.session_id || `群聊${idx + 1}`).trim() + const count = Number(item.count) || 0 + return `${idx + 1}. ${name}(@我 ${count} 次)` + }) + .join('\n') + : '无' + + const defaultSystemPrompt = `你是用户的聊天足迹教练,负责基于统计数据给出一段简明复盘。 +要求: +1. 输出 2-3 句,总长度不超过 180 字。 +2. 必须包含:总体观察 + 一个可执行建议。 +3. 语气务实,不夸张,不使用 Markdown。` + const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim() + const systemPrompt = customPrompt || defaultSystemPrompt + + const userPrompt = `统计范围:${rangeLabel} +有聊天的人数:${Number(summary.private_inbound_people) || 0} +我有回复的人数:${Number(summary.private_outbound_people) || 0} +回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}% +@我次数:${Number(summary.mention_count) || 0} +涉及群聊:${Number(summary.mention_group_count) || 0} + +私聊重点: +${topPrivateText} + +群聊@我重点: +${topMentionText} + +请给出足迹复盘(2-3句,含建议):` + + try { + const result = await callApi( + apiBaseUrl, + apiKey, + model, + [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + 25_000 + ) + const insight = result.trim().slice(0, 400) + if (!insight) return { success: false, message: '模型返回为空' } + return { success: true, message: '生成成功', insight } + } catch (error) { + return { success: false, message: `生成失败:${(error as Error).message}` } + } + } + // ── 私有方法 ──────────────────────────────────────────────────────────────── private isEnabled(): boolean { return this.config.get('aiInsightEnabled') === true } + private getSharedAiModelConfig(): SharedAiModelConfig { + const apiBaseUrl = String( + this.config.get('aiModelApiBaseUrl') + || this.config.get('aiInsightApiBaseUrl') + || '' + ).trim() + const apiKey = String( + this.config.get('aiModelApiKey') + || this.config.get('aiInsightApiKey') + || '' + ).trim() + const model = String( + this.config.get('aiModelApiModel') + || this.config.get('aiInsightApiModel') + || 'gpt-4o-mini' + ).trim() || 'gpt-4o-mini' + + return { apiBaseUrl, apiKey, model } + } + /** * 判断某个会话是否允许触发见解。 * 若白名单未启用,则所有私聊会话均允许; @@ -696,9 +814,7 @@ class InsightService { if (!sessionId) return if (!this.isEnabled()) return - const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string - const apiKey = this.config.get('aiInsightApiKey') as string - const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini' + const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() const allowContext = this.config.get('aiInsightAllowContext') as boolean const contextCount = (this.config.get('aiInsightContextCount') as number) || 40 diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index 85d5a36..0e94d6c 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -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 } } diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index 40cb2f2..fd95372 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -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 '未知错误' } diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts index 95c180c..ca7e057 100644 --- a/electron/services/messagePushService.ts +++ b/electron/services/messagePushService.ts @@ -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 { @@ -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() + 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 { - 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 { + 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) } } diff --git a/resources/key/linux/x64/xkey_helper_linux b/resources/key/linux/x64/xkey_helper_linux old mode 100644 new mode 100755 diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss index 3907662..215520e 100644 --- a/src/components/Export/ExportDateRangeDialog.scss +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -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; diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index 8a49fdd..d2cbabf 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -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(() => buildDialogDraft(value, minDate, maxDate)) const [activeBoundary, setActiveBoundary] = useState('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(null) + const startTimeSelectRef = useRef(null) + const endTimeSelectRef = useRef(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 ( +
event.stopPropagation()}> +
+ {boundary === 'start' ? '开始时间' : '结束时间'} + {currentTime} +
+
+ {QUICK_TIME_OPTIONS.map(option => ( + + ))} +
+
+
+ 小时 +
+ {HOUR_OPTIONS.map(option => ( + + ))} +
+
+
+ 分钟 +
+ {MINUTE_OPTIONS.map(option => ( + + ))} +
+
+
+
+ ) + } + + // 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} /> +
event.stopPropagation()} + > + + {openTimeDropdown === 'start' && renderTimeDropdown('start')} +
+
event.stopPropagation()} + > + + {openTimeDropdown === 'end' && renderTimeDropdown('end')} +
@@ -453,7 +736,14 @@ export function ExportDateRangeDialog({ diff --git a/src/pages/BizPage.scss b/src/pages/BizPage.scss index a2faddb..5ff28c6 100644 --- a/src/pages/BizPage.scss +++ b/src/pages/BizPage.scss @@ -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; diff --git a/src/pages/BizPage.tsx b/src/pages/BizPage.tsx index 6831d54..be7b547 100644 --- a/src/pages/BizPage.tsx +++ b/src/pages/BizPage.tsx @@ -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 => (
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' : ''}`} > + {(item.unread_count || 0) > 0 && ( + {(item.unread_count || 0) > 99 ? '99+' : item.unread_count} + )}
{item.name || item.username} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 22d2e56..60b99ee 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -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; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index ec05e62..7af1bc4 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -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, authoritativeMap: Map @@ -1058,6 +1103,13 @@ const SessionItem = React.memo(function SessionItem({
{session.summary || '查看公众号历史消息'} +
+ {session.unreadCount > 0 && ( + + {session.unreadCount > 99 ? '99+' : session.unreadCount} + + )} +
@@ -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((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(undefined) const [quotedSenderName, setQuotedSenderName] = useState(undefined) const [quoteLayout, setQuoteLayout] = useState('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 ( +
{ + 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 ? '点击收起接龙' : '点击展开接龙'} + > +
+ +
+
{solitaire.title}
+
{countText}
+
+
+ {introLines.length > 0 && ( +
+ {introLines.map((line, index) => ( +
{line}
+ ))} + {hasMoreIntro &&
...
} +
+ )} + {previewEntries.length > 0 ? ( +
+ {previewEntries.map(entry => ( +
+ {entry.index} + {entry.text} +
+ ))} + {hiddenEntryCount > 0 && ( +
还有 {hiddenEntryCount} 条...
+ )} +
+ ) : null} +
+ {solitaireExpanded ? '收起接龙' : '展开接龙'} + +
+
+ ) + } + const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card' const desc = message.appMsgDesc || q('des') const url = message.linkUrl || q('url') diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 1f95d36..1c70471 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -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() {
(null) + const [footprintAiStatus, setFootprintAiStatus] = useState('idle') + const [footprintAiText, setFootprintAiText] = useState('') const inflightRangeKeyRef = useRef(null) const currentRange = useMemo(() => buildRange(preset, customStartDate, customEndDate), [preset, customStartDate, customEndDate]) @@ -638,6 +641,41 @@ function MyFootprintPage() { } }, [currentRange.begin, currentRange.end, currentRange.label]) + const handleGenerateAiSummary = useCallback(async () => { + setFootprintAiStatus('loading') + setFootprintAiText('') + try { + const privateSegments = (data.private_segments.length > 0 ? data.private_segments : data.private_sessions).slice(0, 12) + const result = await window.electronAPI.insight.generateFootprintInsight({ + rangeLabel: currentRange.label, + summary: data.summary, + privateSegments: privateSegments.map((item: MyFootprintPrivateSegment | MyFootprintPrivateSession) => ({ + session_id: item.session_id, + displayName: item.displayName, + incoming_count: item.incoming_count, + outgoing_count: item.outgoing_count, + message_count: 'message_count' in item ? item.message_count : item.incoming_count + item.outgoing_count, + replied: item.replied + })), + mentionGroups: data.mention_groups.slice(0, 12).map((item) => ({ + session_id: item.session_id, + displayName: item.displayName, + count: item.count + })) + }) + if (!result.success || !result.insight) { + setFootprintAiStatus('error') + setFootprintAiText(result.message || '生成失败') + return + } + setFootprintAiStatus('success') + setFootprintAiText(result.insight) + } catch (generateError) { + setFootprintAiStatus('error') + setFootprintAiText(String(generateError)) + } + }, [currentRange.label, data]) + return (
@@ -690,6 +728,10 @@ function MyFootprintPage() { 刷新 +
+ {footprintAiStatus !== 'idle' && ( +
+
+ AI 足迹总结 + {currentRange.label} +
+

{footprintAiText}

+
+ )} +
; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, { id: 'notification', label: '通知', icon: Bell }, { id: 'antiRevoke', label: '防撤回', icon: RotateCcw }, @@ -27,12 +42,17 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'cache', label: '缓存', icon: HardDrive }, { id: 'api', label: 'API 服务', icon: Globe }, { id: 'analytics', label: '分析', icon: BarChart2 }, - { id: 'insight', label: 'AI 见解', icon: Sparkles }, { id: 'security', label: '安全', icon: ShieldCheck }, { id: 'updates', label: '版本更新', icon: RefreshCw }, { id: 'about', label: '关于', icon: Info } ] +const aiTabs: Array<{ id: Extract; label: string }> = [ + { id: 'aiCommon', label: 'AI 通用' }, + { id: 'insight', label: 'AI 见解' }, + { id: 'aiFootprint', label: 'AI 足迹' } +] + const isMac = navigator.userAgent.toLowerCase().includes('mac') const isLinux = navigator.userAgent.toLowerCase().includes('linux') const isWindows = !isMac && !isLinux @@ -52,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 } @@ -88,6 +127,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache) const [activeTab, setActiveTab] = useState('appearance') + const [aiGroupExpanded, setAiGroupExpanded] = useState(false) const [decryptKey, setDecryptKey] = useState('') const [imageXorKey, setImageXorKey] = useState('') const [imageAesKey, setImageAesKey] = useState('') @@ -150,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [quoteLayout, setQuoteLayout] = useState('quote-top') const [updateChannel, setUpdateChannel] = useState('stable') const [filterSearchKeyword, setFilterSearchKeyword] = useState('') + const [notificationTypeFilter, setNotificationTypeFilter] = useState('all') const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false) @@ -205,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('all') + const [messagePushFilterList, setMessagePushFilterList] = useState([]) + const [messagePushFilterDropdownOpen, setMessagePushFilterDropdownOpen] = useState(false) + const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('') + const [messagePushTypeFilter, setMessagePushTypeFilter] = useState('all') + const [messagePushContactOptions, setMessagePushContactOptions] = useState([]) const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('') const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState>(new Set()) const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState>({}) @@ -217,9 +264,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { // AI 见解 state const [aiInsightEnabled, setAiInsightEnabled] = useState(false) - const [aiInsightApiBaseUrl, setAiInsightApiBaseUrl] = useState('') - const [aiInsightApiKey, setAiInsightApiKey] = useState('') - const [aiInsightApiModel, setAiInsightApiModel] = useState('gpt-4o-mini') + const [aiModelApiBaseUrl, setAiModelApiBaseUrl] = useState('') + const [aiModelApiKey, setAiModelApiKey] = useState('') + const [aiModelApiModel, setAiModelApiModel] = useState('gpt-4o-mini') const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3) const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false) const [isTestingInsight, setIsTestingInsight] = useState(false) @@ -237,6 +284,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [aiInsightTelegramEnabled, setAiInsightTelegramEnabled] = useState(false) const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('') const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('') + const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false) + const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('') // 检查 Hello 可用性 useEffect(() => { @@ -276,6 +325,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setActiveTab(initialTab) }, [location.state]) + useEffect(() => { + if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') { + setAiGroupExpanded(true) + } + }, [activeTab]) + useEffect(() => { if (!onClose) return const handleKeyDown = (event: KeyboardEvent) => { @@ -328,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 () => { @@ -359,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() @@ -409,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 || '') @@ -448,9 +512,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { // 加载 AI 见解配置 const savedAiInsightEnabled = await configService.getAiInsightEnabled() - const savedAiInsightApiBaseUrl = await configService.getAiInsightApiBaseUrl() - const savedAiInsightApiKey = await configService.getAiInsightApiKey() - const savedAiInsightApiModel = await configService.getAiInsightApiModel() + const savedAiModelApiBaseUrl = await configService.getAiModelApiBaseUrl() + const savedAiModelApiKey = await configService.getAiModelApiKey() + const savedAiModelApiModel = await configService.getAiModelApiModel() const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays() const savedAiInsightAllowContext = await configService.getAiInsightAllowContext() const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled() @@ -462,10 +526,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled() const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken() const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds() + const savedAiFootprintEnabled = await configService.getAiFootprintEnabled() + const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt() setAiInsightEnabled(savedAiInsightEnabled) - setAiInsightApiBaseUrl(savedAiInsightApiBaseUrl) - setAiInsightApiKey(savedAiInsightApiKey) - setAiInsightApiModel(savedAiInsightApiModel) + setAiModelApiBaseUrl(savedAiModelApiBaseUrl) + setAiModelApiKey(savedAiModelApiKey) + setAiModelApiModel(savedAiModelApiModel) setAiInsightSilenceDays(savedAiInsightSilenceDays) setAiInsightAllowContext(savedAiInsightAllowContext) setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled) @@ -477,6 +543,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled) setAiInsightTelegramToken(savedAiInsightTelegramToken) setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds) + setAiFootprintEnabled(savedAiFootprintEnabled) + setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt) } catch (e: any) { console.error('加载配置失败:', e) @@ -1154,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 { @@ -1610,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 @@ -1636,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 (
@@ -1739,17 +1792,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{ - 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 && } @@ -1768,11 +1811,33 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { : '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'} +
+ {sessionFilterTypeOptions.map(option => ( + + ))} +
+
{/* 可选会话列表 */}
可选会话 + {notificationAvailableSessions.length > 0 && ( + + )}
- {availableSessions.length > 0 ? ( - availableSessions.map(session => ( + {notificationAvailableSessions.length > 0 ? ( + notificationAvailableSessions.map(session => (
{session.displayName || session.username} + {getSessionFilterTypeLabel(session.type)} +
)) ) : (
- {filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'} + {filterSearchKeyword || notificationTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
)}
@@ -1815,11 +1881,20 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { {notificationFilterList.length > 0 && ( {notificationFilterList.length} )} + {notificationFilterList.length > 0 && ( + + )}
{notificationFilterList.length > 0 ? ( notificationFilterList.map(username => { - const info = getSessionInfo(username) + const info = getSessionFilterOptionInfo(username) return (
{info.displayName} + {getSessionFilterTypeLabel(info.type)} ×
) @@ -2076,9 +2152,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{isManualStartPrompt ? (
-

未能自动启动微信,请手动启动并登录后点击下方确认

+

未能自动启动微信,请手动启动微信,看到登录窗口后点击下方确认

) : ( @@ -2485,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() + + 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) @@ -2498,6 +2731,118 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { } } + const renderAiCommonTab = () => ( +
+
+ + + 这是「AI 见解」与「AI 足迹总结」共享的模型接入配置。填写 OpenAI 兼容接口的 Base URL,末尾不要加斜杠。 + 程序会自动拼接 /chat/completions。 +
+ 示例:https://api.ohmygpt.com/v1https://api.openai.com/v1 +
+ { + const val = e.target.value + setAiModelApiBaseUrl(val) + scheduleConfigSave('aiModelApiBaseUrl', () => configService.setAiModelApiBaseUrl(val)) + }} + /> +
+ +
+ + + 你的 API Key,保存后经过系统加密存储,不会明文写入磁盘。 + +
+ { + const val = e.target.value + setAiModelApiKey(val) + scheduleConfigSave('aiModelApiKey', () => configService.setAiModelApiKey(val)) + }} + style={{ flex: 1 }} + /> + + {aiModelApiKey && ( + + )} +
+
+ +
+ + + 填写你的 API 提供商支持的模型名,将同时用于见解和足迹模块。 +
+ 常用示例:gpt-4o-minigpt-4odeepseek-chatclaude-3-5-haiku-20241022 +
+ { + const val = e.target.value.trim() || 'gpt-4o-mini' + setAiModelApiModel(val) + scheduleConfigSave('aiModelApiModel', () => configService.setAiModelApiModel(val)) + }} + style={{ width: 260 }} + /> +
+ +
+ + + 测试通用模型连接,见解与足迹都会使用这套配置。 + +
+ + {insightTestResult && ( + + {insightTestResult.success ? : } + {insightTestResult.message} + + )} +
+
+
+ ) + const renderInsightTab = () => (
{/* 总开关 */} @@ -2526,149 +2871,41 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- {/* API 配置 */} -
- - - 填写 OpenAI 兼容接口的 Base URL,末尾不要加斜杠。 - 程序会自动拼接 /chat/completions。 -
- 示例:https://api.ohmygpt.com/v1https://api.openai.com/v1 -
- { - const val = e.target.value - setAiInsightApiBaseUrl(val) - scheduleConfigSave('aiInsightApiBaseUrl', () => configService.setAiInsightApiBaseUrl(val)) - }} - style={{ fontFamily: 'monospace' }} - /> -
- -
- - - 你的 API Key,保存后经过系统加密存储,不会明文写入磁盘。 - -
- { - const val = e.target.value - setAiInsightApiKey(val) - scheduleConfigSave('aiInsightApiKey', () => configService.setAiInsightApiKey(val)) - }} - style={{ flex: 1, fontFamily: 'monospace' }} - /> - - {aiInsightApiKey && ( - - )} -
-
- -
- - - 填写你的 API 提供商支持的模型名,建议使用综合能力较强的模型以获得有洞察力的见解。 -
- 常用示例:gpt-4o-minigpt-4odeepseek-chatclaude-3-5-haiku-20241022 -
- { - const val = e.target.value.trim() || 'gpt-4o-mini' - setAiInsightApiModel(val) - scheduleConfigSave('aiInsightApiModel', () => configService.setAiInsightApiModel(val)) - }} - style={{ width: 260, fontFamily: 'monospace' }} - /> -
- - {/* 测试连接 + 触发测试 */}
- 先用"测试 API 连接"确认 Key 和 URL 填写正确,再用"立即触发测试见解"验证完整链路(数据库→API→弹窗)。触发后请留意右下角通知弹窗。 + 该功能依赖「AI 通用」里的模型配置。用于验证完整链路(数据库→API→弹窗)。 -
- {/* 测试 API 连接 */} -
- - {insightTestResult && ( - - {insightTestResult.success ? : } - {insightTestResult.message} - +
+
- {/* 触发测试见解 */} -
- - {insightTriggerResult && ( - - {insightTriggerResult.success ? : } - {insightTriggerResult.message} - - )} -
+ + {insightTriggerResult && ( + + {insightTriggerResult.success ? : } + {insightTriggerResult.message} + + )}
@@ -2824,9 +3061,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { 当前显示内置默认提示词,可直接编辑修改。修改后立即生效,无需重启。可变的统计信息(触发次数、对话内容)会自动附加在用户消息里,无需在此填写。