Merge pull request #732 from hicccc77/dev

Dev
This commit is contained in:
cc
2026-04-12 14:22:42 +08:00
committed by GitHub
28 changed files with 2540 additions and 340 deletions

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ export interface BizAccount {
type: number
last_time: number
formatted_last_time: string
unread_count?: number
}
export interface BizMessage {
@@ -104,19 +105,24 @@ export class BizService {
if (!root || !accountWxid) return []
const bizLatestTime: Record<string, number> = {}
const bizUnreadCount: Record<string, number> = {}
try {
const sessionsRes = await wcdbService.getSessions()
const sessionsRes = await chatService.getSessions()
if (sessionsRes.success && sessionsRes.sessions) {
for (const session of sessionsRes.sessions) {
const uname = session.username || session.strUsrName || session.userName || session.id
// 适配日志中发现的字段,注意转为整型数字
const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
const timeStr = session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
const time = parseInt(timeStr.toString(), 10)
if (usernames.includes(uname) && time > 0) {
bizLatestTime[uname] = time
}
if (usernames.includes(uname)) {
const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
}
}
}
} catch (e) {
@@ -152,7 +158,8 @@ export class BizService {
avatar: info?.avatarUrl || '',
type: 0,
last_time: lastTime,
formatted_last_time: formatBizTime(lastTime)
formatted_last_time: formatBizTime(lastTime),
unread_count: bizUnreadCount[uname] || 0
}
})

View File

@@ -232,6 +232,16 @@ interface SessionDetailExtra {
type SessionDetail = SessionDetailFast & SessionDetailExtra
interface SyntheticUnreadState {
readTimestamp: number
scannedTimestamp: number
latestTimestamp: number
unreadCount: number
summaryTimestamp?: number
summary?: string
lastMsgType?: number
}
interface MyFootprintSummary {
private_inbound_people: number
private_replied_people: number
@@ -378,6 +388,7 @@ class ChatService {
private readonly messageDbCountSnapshotCacheTtlMs = 8000
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
private sessionMessageCountHintCache = new Map<string, number>()
private syntheticUnreadState = new Map<string, SyntheticUnreadState>()
private sessionMessageCountBatchCache: {
dbSignature: string
sessionIdsKey: string
@@ -865,6 +876,10 @@ class ChatService {
}
}
await this.addMissingOfficialSessions(sessions, myWxid)
await this.applySyntheticUnreadCounts(sessions)
sessions.sort((a, b) => Number(b.sortTimestamp || b.lastTimestamp || 0) - Number(a.sortTimestamp || a.lastTimestamp || 0))
// 不等待联系人信息加载,直接返回基础会话列表
// 前端可以异步调用 enrichSessionsWithContacts 来补充信息
return { success: true, sessions }
@@ -874,6 +889,242 @@ class ChatService {
}
}
private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise<void> {
const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean))
try {
const contactResult = await wcdbService.getContactsCompact()
if (!contactResult.success || !Array.isArray(contactResult.contacts)) return
for (const row of contactResult.contacts as Record<string, any>[]) {
const username = String(row.username || '').trim()
if (!username.startsWith('gh_') || existing.has(username)) continue
sessions.push({
username,
type: 0,
unreadCount: 0,
summary: '查看公众号历史消息',
sortTimestamp: 0,
lastTimestamp: 0,
lastMsgType: 0,
displayName: row.remark || row.nick_name || row.alias || username,
avatarUrl: undefined,
selfWxid: myWxid
})
existing.add(username)
}
} catch (error) {
console.warn('[ChatService] 补充公众号会话失败:', error)
}
}
private shouldUseSyntheticUnread(sessionId: string): boolean {
const normalized = String(sessionId || '').trim()
return normalized.startsWith('gh_')
}
private async getSessionMessageStatsSnapshot(sessionId: string): Promise<{ total: number; latestTimestamp: number }> {
const tableStatsResult = await wcdbService.getMessageTableStats(sessionId)
if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) {
return { total: 0, latestTimestamp: 0 }
}
let total = 0
let latestTimestamp = 0
for (const row of tableStatsResult.tables as Record<string, any>[]) {
const count = Number(row.count ?? row.message_count ?? row.messageCount ?? 0)
if (Number.isFinite(count) && count > 0) {
total += Math.floor(count)
}
const latest = Number(
row.last_timestamp ??
row.lastTimestamp ??
row.last_time ??
row.lastTime ??
row.max_create_time ??
row.maxCreateTime ??
0
)
if (Number.isFinite(latest) && latest > latestTimestamp) {
latestTimestamp = Math.floor(latest)
}
}
return { total, latestTimestamp }
}
private async applySyntheticUnreadCounts(sessions: ChatSession[]): Promise<void> {
const candidates = sessions.filter((session) => this.shouldUseSyntheticUnread(session.username))
if (candidates.length === 0) return
for (const session of candidates) {
try {
const snapshot = await this.getSessionMessageStatsSnapshot(session.username)
const latestTimestamp = Math.max(
Number(session.lastTimestamp || 0),
Number(session.sortTimestamp || 0),
snapshot.latestTimestamp
)
if (latestTimestamp > 0) {
session.lastTimestamp = latestTimestamp
session.sortTimestamp = Math.max(Number(session.sortTimestamp || 0), latestTimestamp)
}
if (snapshot.total > 0) {
session.messageCountHint = Math.max(Number(session.messageCountHint || 0), snapshot.total)
this.sessionMessageCountHintCache.set(session.username, session.messageCountHint)
}
let state = this.syntheticUnreadState.get(session.username)
if (!state) {
const initialUnread = await this.getInitialSyntheticUnreadState(session.username, latestTimestamp)
state = {
readTimestamp: latestTimestamp,
scannedTimestamp: latestTimestamp,
latestTimestamp,
unreadCount: initialUnread.count
}
if (initialUnread.latestMessage) {
state.summary = this.getSessionSummaryFromMessage(initialUnread.latestMessage)
state.summaryTimestamp = Number(initialUnread.latestMessage.createTime || latestTimestamp)
state.lastMsgType = Number(initialUnread.latestMessage.localType || 0)
}
this.syntheticUnreadState.set(session.username, state)
}
let latestMessageForSummary: Message | undefined
if (latestTimestamp > state.scannedTimestamp) {
const newMessagesResult = await this.getNewMessages(
session.username,
Math.max(0, state.scannedTimestamp),
1000
)
if (newMessagesResult.success && Array.isArray(newMessagesResult.messages)) {
let nextUnread = state.unreadCount
let nextScannedTimestamp = state.scannedTimestamp
for (const message of newMessagesResult.messages) {
const createTime = Number(message.createTime || 0)
if (!Number.isFinite(createTime) || createTime <= state.scannedTimestamp) continue
if (message.isSend === 1) continue
nextUnread += 1
latestMessageForSummary = message
if (createTime > nextScannedTimestamp) {
nextScannedTimestamp = Math.floor(createTime)
}
}
state.unreadCount = nextUnread
state.scannedTimestamp = Math.max(nextScannedTimestamp, latestTimestamp)
} else {
state.scannedTimestamp = latestTimestamp
}
}
state.latestTimestamp = Math.max(state.latestTimestamp, latestTimestamp)
if (latestMessageForSummary) {
const summary = this.getSessionSummaryFromMessage(latestMessageForSummary)
if (summary) {
state.summary = summary
state.summaryTimestamp = Number(latestMessageForSummary.createTime || latestTimestamp)
state.lastMsgType = Number(latestMessageForSummary.localType || 0)
}
}
if (state.summary) {
session.summary = state.summary
session.lastMsgType = Number(state.lastMsgType || session.lastMsgType || 0)
}
session.unreadCount = Math.max(Number(session.unreadCount || 0), state.unreadCount)
} catch (error) {
console.warn(`[ChatService] 合成公众号未读失败: ${session.username}`, error)
}
}
}
private getSessionSummaryFromMessage(message: Message): string {
const cleanOfficialPrefix = (value: string): string => value.replace(/^\s*\[\]\s*/u, '').trim()
let summary = ''
switch (Number(message.localType || 0)) {
case 1:
summary = message.parsedContent || message.rawContent || ''
break
case 3:
summary = '[图片]'
break
case 34:
summary = '[语音]'
break
case 43:
summary = '[视频]'
break
case 47:
summary = '[表情]'
break
case 42:
summary = message.cardNickname || '[名片]'
break
case 48:
summary = '[位置]'
break
case 49:
summary = message.linkTitle || message.fileName || message.parsedContent || '[消息]'
break
default:
summary = message.parsedContent || message.rawContent || this.getMessageTypeLabel(Number(message.localType || 0))
break
}
return cleanOfficialPrefix(this.cleanString(summary))
}
private async getInitialSyntheticUnreadState(sessionId: string, latestTimestamp: number): Promise<{
count: number
latestMessage?: Message
}> {
const normalizedLatest = Number(latestTimestamp || 0)
if (!Number.isFinite(normalizedLatest) || normalizedLatest <= 0) return { count: 0 }
const nowSeconds = Math.floor(Date.now() / 1000)
if (Math.abs(nowSeconds - normalizedLatest) > 10 * 60) {
return { count: 0 }
}
const result = await this.getNewMessages(sessionId, Math.max(0, Math.floor(normalizedLatest) - 1), 20)
if (!result.success || !Array.isArray(result.messages)) return { count: 0 }
const unreadMessages = result.messages.filter((message) => {
const createTime = Number(message.createTime || 0)
return Number.isFinite(createTime) &&
createTime >= normalizedLatest &&
message.isSend !== 1
})
return {
count: unreadMessages.length,
latestMessage: unreadMessages[unreadMessages.length - 1]
}
}
private markSyntheticUnreadRead(sessionId: string, messages: Message[] = []): void {
const normalized = String(sessionId || '').trim()
if (!this.shouldUseSyntheticUnread(normalized)) return
let latestTimestamp = 0
const state = this.syntheticUnreadState.get(normalized)
if (state) latestTimestamp = Math.max(latestTimestamp, state.latestTimestamp, state.scannedTimestamp)
for (const message of messages) {
const createTime = Number(message.createTime || 0)
if (Number.isFinite(createTime) && createTime > latestTimestamp) {
latestTimestamp = Math.floor(createTime)
}
}
this.syntheticUnreadState.set(normalized, {
readTimestamp: latestTimestamp,
scannedTimestamp: latestTimestamp,
latestTimestamp,
unreadCount: 0,
summary: state?.summary,
summaryTimestamp: state?.summaryTimestamp,
lastMsgType: state?.lastMsgType
})
}
async getSessionStatuses(usernames: string[]): Promise<{
success: boolean
map?: Record<string, { isFolded?: boolean; isMuted?: boolean }>
@@ -1814,6 +2065,9 @@ class ChatService {
releaseMessageCursorMutex?.()
this.messageCacheService.set(sessionId, filtered)
if (offset === 0 && startTime === 0 && endTime === 0) {
this.markSyntheticUnreadRead(sessionId, filtered)
}
console.log(
`[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}`
)
@@ -4416,6 +4670,8 @@ class ChatService {
case '57':
// 引用消息title 就是回复的内容
return title
case '53':
return `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}`
case '2000':
return `[转账] ${title}`
case '2001':
@@ -4445,6 +4701,8 @@ class ChatService {
return '[链接]'
case '87':
return '[群公告]'
case '53':
return '[接龙]'
default:
return '[消息]'
}
@@ -5044,6 +5302,8 @@ class ChatService {
const quoteInfo = this.parseQuoteMessage(content)
result.quotedContent = quoteInfo.content
result.quotedSender = quoteInfo.sender
} else if (xmlType === '53') {
result.appMsgKind = 'solitaire'
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
result.appMsgKind = 'official-link'
} else if (url) {

View File

@@ -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<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken', 'aiInsightApiKey'])
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
'decryptKey',
'imageAesKey',
'authPassword',
'httpApiToken',
'aiModelApiKey',
'aiInsightApiKey'
])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = 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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 '未知错误'
}

View File

@@ -11,6 +11,7 @@ interface SessionBaseline {
interface MessagePushPayload {
event: 'message.new'
sessionId: string
sessionType: 'private' | 'group' | 'official' | 'other'
messageKey: string
avatarUrl?: string
sourceName: string
@@ -20,6 +21,8 @@ interface MessagePushPayload {
const PUSH_CONFIG_KEYS = new Set([
'messagePushEnabled',
'messagePushFilterMode',
'messagePushFilterList',
'dbPath',
'decryptKey',
'myWxid'
@@ -38,6 +41,7 @@ class MessagePushService {
private rerunRequested = false
private started = false
private baselineReady = false
private messageTableScanRequested = false
constructor() {
this.configService = ConfigService.getInstance()
@@ -60,12 +64,15 @@ class MessagePushService {
payload = null
}
const tableName = String(payload?.table || '').trim().toLowerCase()
if (tableName && tableName !== 'session') {
const tableName = String(payload?.table || '').trim()
if (this.isSessionTableChange(tableName)) {
this.scheduleSync()
return
}
this.scheduleSync()
if (!tableName || this.isMessageTableChange(tableName)) {
this.scheduleSync({ scanMessageBackedSessions: true })
}
}
async handleConfigChanged(key: string): Promise<void> {
@@ -91,6 +98,7 @@ class MessagePushService {
this.recentMessageKeys.clear()
this.groupNicknameCache.clear()
this.baselineReady = false
this.messageTableScanRequested = false
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
@@ -121,7 +129,11 @@ class MessagePushService {
this.baselineReady = true
}
private scheduleSync(): void {
private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void {
if (options.scanMessageBackedSessions) {
this.messageTableScanRequested = true
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
@@ -141,6 +153,8 @@ class MessagePushService {
this.processing = true
try {
if (!this.isPushEnabled()) return
const scanMessageBackedSessions = this.messageTableScanRequested
this.messageTableScanRequested = false
const connectResult = await chatService.connect()
if (!connectResult.success) {
@@ -163,27 +177,47 @@ class MessagePushService {
const previousBaseline = new Map(this.sessionBaseline)
this.setBaseline(sessions)
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
const candidates = sessions.filter((session) => {
const previous = previousBaseline.get(session.username)
if (this.shouldInspectSession(previous, session)) {
return true
}
return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session)
})
for (const session of candidates) {
await this.pushSessionMessages(session, previousBaseline.get(session.username))
await this.pushSessionMessages(
session,
previousBaseline.get(session.username) || this.sessionBaseline.get(session.username)
)
}
} finally {
this.processing = false
if (this.rerunRequested) {
this.rerunRequested = false
this.scheduleSync()
this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested })
}
}
}
private setBaseline(sessions: ChatSession[]): void {
const previousBaseline = new Map(this.sessionBaseline)
const nextBaseline = new Map<string, SessionBaseline>()
const nowSeconds = Math.floor(Date.now() / 1000)
this.sessionBaseline.clear()
for (const session of sessions) {
this.sessionBaseline.set(session.username, {
lastTimestamp: Number(session.lastTimestamp || 0),
const username = String(session.username || '').trim()
if (!username) continue
const previous = previousBaseline.get(username)
const sessionTimestamp = Number(session.lastTimestamp || 0)
const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds
nextBaseline.set(username, {
lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp),
unreadCount: Number(session.unreadCount || 0)
})
}
for (const [username, baseline] of nextBaseline.entries()) {
this.sessionBaseline.set(username, baseline)
}
}
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
@@ -204,16 +238,30 @@ class MessagePushService {
return unreadCount > 0 && lastTimestamp > 0
}
if (lastTimestamp <= previous.lastTimestamp) {
return lastTimestamp > previous.lastTimestamp || unreadCount > previous.unreadCount
}
private shouldScanMessageBackedSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
const sessionId = String(session.username || '').trim()
if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) {
return false
}
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
return unreadCount > previous.unreadCount
const summary = String(session.summary || '').trim()
if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) {
return false
}
const sessionType = this.getSessionType(sessionId, session)
if (sessionType === 'private') {
return false
}
return Boolean(previous) || Number(session.lastTimestamp || 0) > 0
}
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
const since = Math.max(0, Number(previous?.lastTimestamp || 0))
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
return
@@ -224,7 +272,7 @@ class MessagePushService {
if (!messageKey) continue
if (message.isSend === 1) continue
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
if (previous && Number(message.createTime || 0) <= Number(previous.lastTimestamp || 0)) {
continue
}
@@ -234,9 +282,11 @@ class MessagePushService {
const payload = await this.buildPayload(session, message)
if (!payload) continue
if (!this.shouldPushPayload(payload)) continue
httpService.broadcastMessagePush(payload)
this.rememberMessageKey(messageKey)
this.bumpSessionBaseline(session.username, message)
}
}
@@ -246,6 +296,7 @@ class MessagePushService {
if (!sessionId || !messageKey) return null
const isGroup = sessionId.endsWith('@chatroom')
const sessionType = this.getSessionType(sessionId, session)
const content = this.getMessageDisplayContent(message)
if (isGroup) {
@@ -255,6 +306,7 @@ class MessagePushService {
return {
event: 'message.new',
sessionId,
sessionType,
messageKey,
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
groupName,
@@ -267,6 +319,7 @@ class MessagePushService {
return {
event: 'message.new',
sessionId,
sessionType,
messageKey,
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
sourceName: session.displayName || contactInfo?.displayName || sessionId,
@@ -274,10 +327,84 @@ class MessagePushService {
}
}
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
if (sessionId.endsWith('@chatroom')) {
return 'group'
}
if (sessionId.startsWith('gh_') || session.type === 'official') {
return 'official'
}
if (session.type === 'friend') {
return 'private'
}
return 'other'
}
private shouldPushPayload(payload: MessagePushPayload): boolean {
const sessionId = String(payload.sessionId || '').trim()
const filterMode = this.getMessagePushFilterMode()
if (filterMode === 'all') {
return true
}
const filterList = this.getMessagePushFilterList()
const listed = filterList.has(sessionId)
if (filterMode === 'whitelist') {
return listed
}
return !listed
}
private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' {
const value = this.configService.get('messagePushFilterMode')
if (value === 'whitelist' || value === 'blacklist') return value
return 'all'
}
private getMessagePushFilterList(): Set<string> {
const value = this.configService.get('messagePushFilterList')
if (!Array.isArray(value)) return new Set()
return new Set(value.map((item) => String(item || '').trim()).filter(Boolean))
}
private isSessionTableChange(tableName: string): boolean {
return String(tableName || '').trim().toLowerCase() === 'session'
}
private isMessageTableChange(tableName: string): boolean {
const normalized = String(tableName || '').trim().toLowerCase()
if (!normalized) return false
return normalized === 'message' ||
normalized === 'msg' ||
normalized.startsWith('message_') ||
normalized.startsWith('msg_') ||
normalized.includes('message')
}
private bumpSessionBaseline(sessionId: string, message: Message): void {
const key = String(sessionId || '').trim()
if (!key) return
const createTime = Number(message.createTime || 0)
if (!Number.isFinite(createTime) || createTime <= 0) return
const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 }
if (createTime > current.lastTimestamp) {
this.sessionBaseline.set(key, {
...current,
lastTimestamp: createTime
})
}
}
private getMessageDisplayContent(message: Message): string | null {
const cleanOfficialPrefix = (value: string | null): string | null => {
if (!value) return value
return value.replace(/^\s*\[\]\s*/u, '').trim() || value
}
switch (Number(message.localType || 0)) {
case 1:
return message.rawContent || null
return cleanOfficialPrefix(message.rawContent || null)
case 3:
return '[图片]'
case 34:
@@ -287,13 +414,13 @@ class MessagePushService {
case 47:
return '[表情]'
case 42:
return message.cardNickname || '[名片]'
return cleanOfficialPrefix(message.cardNickname || '[名片]')
case 48:
return '[位置]'
case 49:
return message.linkTitle || message.fileName || '[消息]'
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
default:
return message.parsedContent || message.rawContent || null
return cleanOfficialPrefix(message.parsedContent || message.rawContent || null)
}
}

0
resources/key/linux/x64/xkey_helper_linux Normal file → Executable file
View File

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useThemeStore } from '../stores/themeStore';
import { 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,8 +37,7 @@ export const BizAccountList: React.FC<{
initWxid().then(_r => { });
}, []);
useEffect(() => {
const fetch = async () => {
const fetchAccounts = useCallback(async () => {
if (!myWxid) {
return;
}
@@ -51,10 +51,28 @@ export const BizAccountList: React.FC<{
} finally {
setLoading(false);
}
};
fetch().then(_r => { } );
}, [myWxid]);
useEffect(() => {
fetchAccounts().then(_r => { });
}, [fetchAccounts]);
useEffect(() => {
if (!window.electronAPI.chat.onWcdbChange) return;
const removeListener = window.electronAPI.chat.onWcdbChange((_event: any, data: { json?: string }) => {
try {
const payload = JSON.parse(data.json || '{}');
const tableName = String(payload.table || '').toLowerCase();
if (!tableName || tableName === 'session' || tableName.includes('message') || tableName.startsWith('msg_')) {
fetchAccounts().then(_r => { });
}
} catch {
fetchAccounts().then(_r => { });
}
});
return () => removeListener();
}, [fetchAccounts]);
const filtered = useMemo(() => {
let result = accounts;
@@ -80,7 +98,12 @@ export const BizAccountList: React.FC<{
{filtered.map(item => (
<div
key={item.username}
onClick={() => onSelect(item)}
onClick={() => {
setAccounts(prev => prev.map(account =>
account.username === item.username ? { ...account, unread_count: 0 } : account
));
onSelect({ ...item, unread_count: 0 });
}}
className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`}
>
<img
@@ -88,6 +111,9 @@ export const BizAccountList: React.FC<{
className="biz-avatar"
alt=""
/>
{(item.unread_count || 0) > 0 && (
<span className="biz-unread-badge">{(item.unread_count || 0) > 99 ? '99+' : item.unread_count}</span>
)}
<div className="biz-info">
<div className="biz-info-top">
<span className="biz-name">{item.name || item.username}</span>

View File

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

View File

@@ -181,6 +181,51 @@ function buildChatRecordPreviewItems(recordList: ChatRecordItem[], maxVisible =
]
}
interface SolitaireEntry {
index: string
text: string
}
interface SolitaireContent {
title: string
introLines: string[]
entries: SolitaireEntry[]
}
function parseSolitaireContent(rawTitle: string): SolitaireContent {
const lines = String(rawTitle || '')
.replace(/\r\n/g, '\n')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
const title = lines[0] || '接龙'
const introLines: string[] = []
const entries: SolitaireEntry[] = []
let hasStartedEntries = false
for (const line of lines.slice(1)) {
const entryMatch = /^(\d+)[..、]\s*(.+)$/.exec(line)
if (entryMatch) {
hasStartedEntries = true
entries.push({
index: entryMatch[1],
text: entryMatch[2].trim()
})
continue
}
if (hasStartedEntries && entries.length > 0) {
const previous = entries[entries.length - 1]
previous.text = `${previous.text} ${line}`.trim()
} else {
introLines.push(line)
}
}
return { title, introLines, entries }
}
function composeGlobalMsgSearchResults(
seedMap: Map<string, GlobalMsgSearchResult[]>,
authoritativeMap: Map<string, GlobalMsgSearchResult[]>
@@ -1058,6 +1103,13 @@ const SessionItem = React.memo(function SessionItem({
</div>
<div className="session-bottom">
<span className="session-summary">{session.summary || '查看公众号历史消息'}</span>
<div className="session-badges">
{session.unreadCount > 0 && (
<span className="unread-badge">
{session.unreadCount > 99 ? '99+' : session.unreadCount}
</span>
)}
</div>
</div>
</div>
</div>
@@ -5049,24 +5101,37 @@ function ChatPage(props: ChatPageProps) {
return []
}
const officialSessions = sessions.filter(s => s.username.startsWith('gh_'))
// 检查是否有折叠的群聊
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
const hasFoldedGroups = foldedGroups.length > 0
let visible = sessions.filter(s => {
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
if (s.username.startsWith('gh_')) return false
return true
})
const latestOfficial = officialSessions.reduce<ChatSession | null>((latest, current) => {
if (!latest) return current
const latestTime = latest.sortTimestamp || latest.lastTimestamp
const currentTime = current.sortTimestamp || current.lastTimestamp
return currentTime > latestTime ? current : latest
}, null)
const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0)
const bizEntry: ChatSession = {
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
displayName: '公众号',
summary: '查看公众号历史消息',
summary: latestOfficial
? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}`
: '查看公众号历史消息',
type: 0,
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
lastTimestamp: 0,
lastMsgType: 0,
unreadCount: 0,
lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0,
lastMsgType: latestOfficial?.lastMsgType || 0,
unreadCount: officialUnreadCount,
isMuted: false,
isFolded: false
}
@@ -7805,6 +7870,7 @@ function MessageBubble({
const [senderName, setSenderName] = useState<string | undefined>(undefined)
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
const [quoteLayout, setQuoteLayout] = useState<QuoteLayout>('quote-top')
const [solitaireExpanded, setSolitaireExpanded] = useState(false)
const senderProfileRequestSeqRef = useRef(0)
const [emojiError, setEmojiError] = useState(false)
const [emojiLoading, setEmojiLoading] = useState(false)
@@ -9413,6 +9479,71 @@ function MessageBubble({
)
}
if (xmlType === '53' || message.appMsgKind === 'solitaire') {
const solitaireText = message.linkTitle || q('appmsg > title') || q('title') || cleanedParsedContent || '接龙'
const solitaire = parseSolitaireContent(solitaireText)
const previewEntries = solitaireExpanded ? solitaire.entries : solitaire.entries.slice(0, 3)
const hiddenEntryCount = Math.max(0, solitaire.entries.length - previewEntries.length)
const introLines = solitaireExpanded ? solitaire.introLines : solitaire.introLines.slice(0, 4)
const hasMoreIntro = !solitaireExpanded && solitaire.introLines.length > introLines.length
const countText = solitaire.entries.length > 0 ? `${solitaire.entries.length} 人参与` : '接龙消息'
return (
<div
className={`solitaire-message${solitaireExpanded ? ' expanded' : ''}`}
role="button"
tabIndex={0}
aria-expanded={solitaireExpanded}
onClick={isSelectionMode ? undefined : (e) => {
e.stopPropagation()
setSolitaireExpanded(value => !value)
}}
onKeyDown={isSelectionMode ? undefined : (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
e.stopPropagation()
setSolitaireExpanded(value => !value)
}}
title={solitaireExpanded ? '点击收起接龙' : '点击展开接龙'}
>
<div className="solitaire-header">
<div className="solitaire-icon" aria-hidden="true">
<Hash size={18} />
</div>
<div className="solitaire-heading">
<div className="solitaire-title">{solitaire.title}</div>
<div className="solitaire-meta">{countText}</div>
</div>
</div>
{introLines.length > 0 && (
<div className="solitaire-intro">
{introLines.map((line, index) => (
<div key={`${line}-${index}`} className="solitaire-intro-line">{line}</div>
))}
{hasMoreIntro && <div className="solitaire-muted-line">...</div>}
</div>
)}
{previewEntries.length > 0 ? (
<div className="solitaire-entry-list">
{previewEntries.map(entry => (
<div key={`${entry.index}-${entry.text}`} className="solitaire-entry">
<span className="solitaire-entry-index">{entry.index}</span>
<span className="solitaire-entry-text">{entry.text}</span>
</div>
))}
{hiddenEntryCount > 0 && (
<div className="solitaire-muted-line"> {hiddenEntryCount} ...</div>
)}
</div>
) : null}
<div className="solitaire-footer">
<span>{solitaireExpanded ? '收起接龙' : '展开接龙'}</span>
<ChevronDown size={14} className="solitaire-chevron" />
</div>
</div>
)
}
const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card'
const desc = message.appMsgDesc || q('des')
const url = message.linkUrl || q('url')

View File

@@ -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 {
preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset),
useAllTime: selection.useAllTime,
...selection,
dateRange: { start, end: start }
}
}
return cloneExportDateRangeSelection(selection)
}
// For useAllTime, use bounds directly
if (selection.useAllTime) {
return {
preset: selection.preset,
useAllTime: true,
dateRange: {
start: nextStart,
end: nextEnd
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.preset,
useAllTime: false,
dateRange: {
start: boundedStart,
end: boundedEnd
}
}
}
@@ -6866,6 +6887,7 @@ function ExportPage() {
const nextCanExport = Boolean(nextContact && sessionRowByUsername.get(nextContact.username)?.hasSession)
const previousSelected = Boolean(previousContact && previousCanExport && selectedSessions.has(previousContact.username))
const nextSelected = Boolean(nextContact && nextCanExport && selectedSessions.has(nextContact.username))
const resolvedAvatarUrl = normalizeExportAvatarUrl(matchedSession?.avatarUrl || contact.avatarUrl)
const rowClassName = [
'contact-row',
checked ? 'selected' : '',
@@ -6889,7 +6911,7 @@ function ExportPage() {
</div>
<div className="contact-avatar">
<Avatar
src={normalizeExportAvatarUrl(contact.avatarUrl)}
src={resolvedAvatarUrl}
name={contact.displayName}
size="100%"
shape="rounded"

View File

@@ -258,6 +258,42 @@
display: none; /* Minimalistic, hide icon in KPI */
}
.footprint-ai-result {
border-radius: 10px;
padding: 14px 16px;
background: color-mix(in srgb, var(--text-tertiary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
.footprint-ai-head {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
strong {
font-size: 14px;
color: var(--text-primary);
}
span {
font-size: 12px;
color: var(--text-tertiary);
}
}
p {
margin: 0;
white-space: pre-wrap;
line-height: 1.6;
color: var(--text-secondary);
font-size: 13px;
}
&.footprint-ai-result-error {
border-color: color-mix(in srgb, #ef4444 50%, transparent);
}
}
.kpi-label {
font-size: 13px;
font-weight: 500;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { AlertCircle, AtSign, CheckCircle2, Download, MessageCircle, RefreshCw, Search, Users } from 'lucide-react'
import { AlertCircle, AtSign, CheckCircle2, Download, Loader2, MessageCircle, RefreshCw, Search, Sparkles, Users } from 'lucide-react'
import DateRangePicker from '../components/DateRangePicker'
import './MyFootprintPage.scss'
@@ -9,6 +9,7 @@ type TimelineMode = 'all' | 'mention' | 'private'
type TimelineTimeMode = 'clock' | 'month_day_clock' | 'full_date_clock'
type PrivateDotVariant = 'both' | 'inbound_only' | 'outbound_only'
type ExportModalStatus = 'idle' | 'progress' | 'success' | 'error'
type FootprintAiStatus = 'idle' | 'loading' | 'success' | 'error'
interface MyFootprintSummary {
private_inbound_people: number
@@ -336,6 +337,8 @@ function MyFootprintPage() {
const [exportModalDescription, setExportModalDescription] = useState('')
const [exportModalPath, setExportModalPath] = useState('')
const [error, setError] = useState<string | null>(null)
const [footprintAiStatus, setFootprintAiStatus] = useState<FootprintAiStatus>('idle')
const [footprintAiText, setFootprintAiText] = useState('')
const inflightRangeKeyRef = useRef<string | null>(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 (
<div className="my-footprint-page">
<section className="footprint-header">
@@ -690,6 +728,10 @@ function MyFootprintPage() {
<RefreshCw size={15} className={loading ? 'spin' : ''} />
<span></span>
</button>
<button type="button" className="action-btn" onClick={() => void handleGenerateAiSummary()} disabled={loading || footprintAiStatus === 'loading'}>
{footprintAiStatus === 'loading' ? <Loader2 size={15} className="spin" /> : <Sparkles size={15} />}
<span>{footprintAiStatus === 'loading' ? '生成中...' : 'AI 总结'}</span>
</button>
<button type="button" className="action-btn" onClick={() => void handleExport('csv')} disabled={exporting || loading}>
<Download size={15} />
<span> CSV</span>
@@ -749,6 +791,16 @@ function MyFootprintPage() {
</button>
</section>
{footprintAiStatus !== 'idle' && (
<section className={`footprint-ai-result footprint-ai-result-${footprintAiStatus}`}>
<div className="footprint-ai-head">
<strong>AI </strong>
<span>{currentRange.label}</span>
</div>
<p>{footprintAiText}</p>
</section>
)}
<section
className={`footprint-timeline timeline-time-${timelineTimeMode}`}
key={`${timelineMode}:${currentRange.begin}:${currentRange.end}`}

View File

@@ -177,6 +177,66 @@
box-shadow: var(--shadow-sm);
}
}
.tab-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.tab-group-trigger {
position: relative;
}
.tab-group-arrow {
margin-left: auto;
color: var(--text-tertiary);
transition: transform 0.2s ease;
&.expanded {
transform: rotate(180deg);
}
}
.tab-sublist {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 8px;
}
.tab-sublist-wrap {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition: grid-template-rows 0.22s ease, opacity 0.18s ease;
&.expanded {
grid-template-rows: 1fr;
opacity: 1;
}
&.collapsed {
pointer-events: none;
}
}
.tab-sublist {
min-height: 0;
overflow: hidden;
}
.tab-sub-btn {
padding-left: 24px;
font-size: 13px;
}
.tab-sub-dot {
width: 5px;
height: 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-tertiary) 70%, transparent);
}
}
.settings-body {
@@ -199,6 +259,12 @@
}
}
.ai-prompt-textarea {
font-family: inherit !important;
font-size: 14px !important;
line-height: 1.6;
}
.tab-content {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
@@ -2283,6 +2349,24 @@
border-radius: 10px;
}
.filter-panel-action {
flex-shrink: 0;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
transition: all 0.16s ease;
&:hover {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
background: color-mix(in srgb, var(--primary) 8%, var(--bg-tertiary));
}
}
.filter-panel-list {
flex: 1;
min-height: 200px;
@@ -2346,6 +2430,16 @@
white-space: nowrap;
}
.filter-item-type {
flex-shrink: 0;
padding: 2px 6px;
border-radius: 6px;
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
}
.filter-item-action {
font-size: 18px;
font-weight: 500;
@@ -2355,6 +2449,36 @@
}
}
.push-filter-type-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
margin-bottom: 10px;
}
.push-filter-type-tab {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
padding: 6px 10px;
font-size: 13px;
cursor: pointer;
transition: all 0.16s ease;
&:hover {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color));
}
&.active {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 54%, var(--border-color));
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
}
}
.filter-panel-empty {
display: flex;
align-items: center;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -72,6 +72,8 @@ export const CONFIG_KEYS = {
HTTP_API_PORT: 'httpApiPort',
HTTP_API_HOST: 'httpApiHost',
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
MESSAGE_PUSH_FILTER_MODE: 'messagePushFilterMode',
MESSAGE_PUSH_FILTER_LIST: 'messagePushFilterList',
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
QUOTE_LAYOUT: 'quoteLayout',
@@ -83,6 +85,9 @@ export const CONFIG_KEYS = {
ANALYTICS_DENY_COUNT: 'analyticsDenyCount',
// AI 见解
AI_MODEL_API_BASE_URL: 'aiModelApiBaseUrl',
AI_MODEL_API_KEY: 'aiModelApiKey',
AI_MODEL_API_MODEL: 'aiModelApiModel',
AI_INSIGHT_ENABLED: 'aiInsightEnabled',
AI_INSIGHT_API_BASE_URL: 'aiInsightApiBaseUrl',
AI_INSIGHT_API_KEY: 'aiInsightApiKey',
@@ -97,7 +102,11 @@ export const CONFIG_KEYS = {
AI_INSIGHT_SYSTEM_PROMPT: 'aiInsightSystemPrompt',
AI_INSIGHT_TELEGRAM_ENABLED: 'aiInsightTelegramEnabled',
AI_INSIGHT_TELEGRAM_TOKEN: 'aiInsightTelegramToken',
AI_INSIGHT_TELEGRAM_CHAT_IDS: 'aiInsightTelegramChatIds'
AI_INSIGHT_TELEGRAM_CHAT_IDS: 'aiInsightTelegramChatIds',
// AI 足迹
AI_FOOTPRINT_ENABLED: 'aiFootprintEnabled',
AI_FOOTPRINT_SYSTEM_PROMPT: 'aiFootprintSystemPrompt'
} as const
export interface WxidConfig {
@@ -1498,6 +1507,29 @@ export async function setMessagePushEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.MESSAGE_PUSH_ENABLED, enabled)
}
export type MessagePushFilterMode = 'all' | 'whitelist' | 'blacklist'
export type MessagePushSessionType = 'private' | 'group' | 'official' | 'other'
export async function getMessagePushFilterMode(): Promise<MessagePushFilterMode> {
const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_FILTER_MODE)
if (value === 'whitelist' || value === 'blacklist') return value
return 'all'
}
export async function setMessagePushFilterMode(mode: MessagePushFilterMode): Promise<void> {
await config.set(CONFIG_KEYS.MESSAGE_PUSH_FILTER_MODE, mode)
}
export async function getMessagePushFilterList(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_FILTER_LIST)
return Array.isArray(value) ? value.map(item => String(item || '').trim()).filter(Boolean) : []
}
export async function setMessagePushFilterList(list: string[]): Promise<void> {
const normalized = Array.from(new Set((list || []).map(item => String(item || '').trim()).filter(Boolean)))
await config.set(CONFIG_KEYS.MESSAGE_PUSH_FILTER_LIST, normalized)
}
export async function getWindowCloseBehavior(): Promise<WindowCloseBehavior> {
const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR)
if (value === 'tray' || value === 'quit') return value
@@ -1586,6 +1618,39 @@ export async function setHttpApiHost(host: string): Promise<void> {
// ─── AI 见解 ──────────────────────────────────────────────────────────────────
export async function getAiModelApiBaseUrl(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_MODEL_API_BASE_URL)
if (typeof value === 'string' && value.trim()) return value
const legacy = await config.get(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL)
return typeof legacy === 'string' ? legacy : ''
}
export async function setAiModelApiBaseUrl(url: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_MODEL_API_BASE_URL, url)
}
export async function getAiModelApiKey(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_MODEL_API_KEY)
if (typeof value === 'string' && value.trim()) return value
const legacy = await config.get(CONFIG_KEYS.AI_INSIGHT_API_KEY)
return typeof legacy === 'string' ? legacy : ''
}
export async function setAiModelApiKey(key: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_MODEL_API_KEY, key)
}
export async function getAiModelApiModel(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_MODEL_API_MODEL)
if (typeof value === 'string' && value.trim()) return value.trim()
const legacy = await config.get(CONFIG_KEYS.AI_INSIGHT_API_MODEL)
return typeof legacy === 'string' && legacy.trim() ? legacy.trim() : 'gpt-4o-mini'
}
export async function setAiModelApiModel(model: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_MODEL_API_MODEL, model)
}
export async function getAiInsightEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_ENABLED)
return value === true
@@ -1596,30 +1661,30 @@ export async function setAiInsightEnabled(enabled: boolean): Promise<void> {
}
export async function getAiInsightApiBaseUrl(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL)
return typeof value === 'string' ? value : ''
return getAiModelApiBaseUrl()
}
export async function setAiInsightApiBaseUrl(url: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_API_BASE_URL, url)
await setAiModelApiBaseUrl(url)
}
export async function getAiInsightApiKey(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_KEY)
return typeof value === 'string' ? value : ''
return getAiModelApiKey()
}
export async function setAiInsightApiKey(key: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_API_KEY, key)
await setAiModelApiKey(key)
}
export async function getAiInsightApiModel(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_INSIGHT_API_MODEL)
return typeof value === 'string' && value.trim() ? value.trim() : 'gpt-4o-mini'
return getAiModelApiModel()
}
export async function setAiInsightApiModel(model: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_API_MODEL, model)
await setAiModelApiModel(model)
}
export async function getAiInsightSilenceDays(): Promise<number> {
@@ -1720,3 +1785,21 @@ export async function getAiInsightTelegramChatIds(): Promise<string> {
export async function setAiInsightTelegramChatIds(chatIds: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_INSIGHT_TELEGRAM_CHAT_IDS, chatIds)
}
export async function getAiFootprintEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AI_FOOTPRINT_ENABLED)
return value === true
}
export async function setAiFootprintEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AI_FOOTPRINT_ENABLED, enabled)
}
export async function getAiFootprintSystemPrompt(): Promise<string> {
const value = await config.get(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT)
return typeof value === 'string' ? value : ''
}
export async function setAiFootprintSystemPrompt(prompt: string): Promise<void> {
await config.set(CONFIG_KEYS.AI_FOOTPRINT_SYSTEM_PROMPT, prompt)
}

View File

@@ -1075,6 +1075,24 @@ export interface ElectronAPI {
stop: () => Promise<{ success: boolean }>
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
}
insight: {
testConnection: () => Promise<{ success: boolean; message: string }>
getTodayStats: () => Promise<Array<{ sessionId: string; count: number; times: string[] }>>
triggerTest: () => Promise<{ success: boolean; message: string }>
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 }>
}) => Promise<{ success: boolean; message: string; insight?: string }>
}
}
export interface ExportOptions {

View File

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