mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat(export): sync task badge globally and finalize export layout updates
This commit is contained in:
@@ -69,7 +69,7 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
dateRange?: { start: number; end: number } | null
|
||||
senderUsername?: string
|
||||
fileNameSuffix?: string
|
||||
@@ -2139,6 +2139,217 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
private extractGroupMemberUsername(member: any): string {
|
||||
if (!member) return ''
|
||||
if (typeof member === 'string') return member.trim()
|
||||
return String(
|
||||
member.username ||
|
||||
member.userName ||
|
||||
member.user_name ||
|
||||
member.encryptUsername ||
|
||||
member.encryptUserName ||
|
||||
member.encrypt_username ||
|
||||
member.originalName ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
|
||||
private extractGroupSenderCountMap(groupStats: any, sessionId: string): Map<string, number> {
|
||||
const senderCountMap = new Map<string, number>()
|
||||
if (!groupStats || typeof groupStats !== 'object') return senderCountMap
|
||||
|
||||
const sessions = (groupStats as any).sessions
|
||||
const sessionStats = sessions && typeof sessions === 'object'
|
||||
? (sessions[sessionId] || sessions[String(sessionId)] || null)
|
||||
: null
|
||||
const senderRaw = (sessionStats && typeof sessionStats === 'object' && (sessionStats as any).senders && typeof (sessionStats as any).senders === 'object')
|
||||
? (sessionStats as any).senders
|
||||
: ((groupStats as any).senders && typeof (groupStats as any).senders === 'object' ? (groupStats as any).senders : {})
|
||||
const idMap = (groupStats as any).idMap && typeof (groupStats as any).idMap === 'object'
|
||||
? (groupStats as any).idMap
|
||||
: ((sessionStats && typeof sessionStats === 'object' && (sessionStats as any).idMap && typeof (sessionStats as any).idMap === 'object')
|
||||
? (sessionStats as any).idMap
|
||||
: {})
|
||||
|
||||
for (const [senderKey, rawCount] of Object.entries(senderRaw)) {
|
||||
const countNumber = Number(rawCount)
|
||||
if (!Number.isFinite(countNumber) || countNumber <= 0) continue
|
||||
const count = Math.max(0, Math.floor(countNumber))
|
||||
const mapped = typeof (idMap as any)[senderKey] === 'string' ? String((idMap as any)[senderKey]).trim() : ''
|
||||
const wxid = (mapped || String(senderKey || '').trim())
|
||||
if (!wxid) continue
|
||||
senderCountMap.set(wxid, (senderCountMap.get(wxid) || 0) + count)
|
||||
}
|
||||
|
||||
return senderCountMap
|
||||
}
|
||||
|
||||
private sumSenderCountsByIdentity(senderCountMap: Map<string, number>, wxid: string): number {
|
||||
const target = String(wxid || '').trim()
|
||||
if (!target) return 0
|
||||
let total = 0
|
||||
for (const [senderWxid, count] of senderCountMap.entries()) {
|
||||
if (!Number.isFinite(count) || count <= 0) continue
|
||||
if (this.isSameWxid(senderWxid, target)) {
|
||||
total += count
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
private async queryFriendFlagMap(usernames: string[]): Promise<Map<string, boolean>> {
|
||||
const result = new Map<string, boolean>()
|
||||
const unique = Array.from(
|
||||
new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (unique.length === 0) return result
|
||||
|
||||
const BATCH = 200
|
||||
for (let i = 0; i < unique.length; i += BATCH) {
|
||||
const batch = unique.slice(i, i + BATCH)
|
||||
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
||||
const sql = `SELECT username, local_type FROM contact WHERE username IN (${inList})`
|
||||
const query = await wcdbService.execQuery('contact', null, sql)
|
||||
if (!query.success || !query.rows) continue
|
||||
for (const row of query.rows) {
|
||||
const username = String((row as any).username || '').trim()
|
||||
if (!username) continue
|
||||
const localType = Number.parseInt(String((row as any).local_type ?? (row as any).localType ?? (row as any).WCDB_CT_local_type ?? ''), 10)
|
||||
result.set(username, Number.isFinite(localType) && localType === 1)
|
||||
}
|
||||
}
|
||||
|
||||
for (const username of unique) {
|
||||
if (!result.has(username)) {
|
||||
result.set(username, false)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async collectPrivateMutualGroupStats(
|
||||
privateWxid: string,
|
||||
myWxid: string
|
||||
): Promise<{
|
||||
totalGroups: number
|
||||
totalMessagesByMe: number
|
||||
totalMessagesByPeer: number
|
||||
totalMessagesCombined: number
|
||||
groups: Array<{
|
||||
wxid: string
|
||||
displayName: string
|
||||
myMessageCount: number
|
||||
peerMessageCount: number
|
||||
totalMessageCount: number
|
||||
}>
|
||||
}> {
|
||||
const normalizedPrivateWxid = String(privateWxid || '').trim()
|
||||
const normalizedMyWxid = String(myWxid || '').trim()
|
||||
if (!normalizedPrivateWxid || !normalizedMyWxid) {
|
||||
return {
|
||||
totalGroups: 0,
|
||||
totalMessagesByMe: 0,
|
||||
totalMessagesByPeer: 0,
|
||||
totalMessagesCombined: 0,
|
||||
groups: []
|
||||
}
|
||||
}
|
||||
|
||||
const sessionsResult = await wcdbService.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||
return {
|
||||
totalGroups: 0,
|
||||
totalMessagesByMe: 0,
|
||||
totalMessagesByPeer: 0,
|
||||
totalMessagesCombined: 0,
|
||||
groups: []
|
||||
}
|
||||
}
|
||||
|
||||
const groupIds = Array.from(
|
||||
new Set(
|
||||
(sessionsResult.sessions as Array<Record<string, any>>)
|
||||
.map((row) => String(row.username || row.user_name || row.userName || '').trim())
|
||||
.filter((username) => username.endsWith('@chatroom'))
|
||||
)
|
||||
)
|
||||
if (groupIds.length === 0) {
|
||||
return {
|
||||
totalGroups: 0,
|
||||
totalMessagesByMe: 0,
|
||||
totalMessagesByPeer: 0,
|
||||
totalMessagesCombined: 0,
|
||||
groups: []
|
||||
}
|
||||
}
|
||||
|
||||
const mutualGroups = await parallelLimit(groupIds, 4, async (groupId) => {
|
||||
const membersResult = await wcdbService.getGroupMembers(groupId)
|
||||
if (!membersResult.success || !membersResult.members || membersResult.members.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let hasMe = false
|
||||
let hasPeer = false
|
||||
for (const member of membersResult.members) {
|
||||
const memberWxid = this.extractGroupMemberUsername(member)
|
||||
if (!memberWxid) continue
|
||||
if (!hasMe && this.isSameWxid(memberWxid, normalizedMyWxid)) {
|
||||
hasMe = true
|
||||
}
|
||||
if (!hasPeer && this.isSameWxid(memberWxid, normalizedPrivateWxid)) {
|
||||
hasPeer = true
|
||||
}
|
||||
if (hasMe && hasPeer) break
|
||||
}
|
||||
if (!hasMe || !hasPeer) return null
|
||||
|
||||
const [groupInfo, groupStatsResult] = await Promise.all([
|
||||
this.getContactInfo(groupId),
|
||||
wcdbService.getGroupStats(groupId, 0, 0)
|
||||
])
|
||||
const senderCountMap = groupStatsResult.success && groupStatsResult.data
|
||||
? this.extractGroupSenderCountMap(groupStatsResult.data, groupId)
|
||||
: new Map<string, number>()
|
||||
const myMessageCount = this.sumSenderCountsByIdentity(senderCountMap, normalizedMyWxid)
|
||||
const peerMessageCount = this.sumSenderCountsByIdentity(senderCountMap, normalizedPrivateWxid)
|
||||
const totalMessageCount = myMessageCount + peerMessageCount
|
||||
|
||||
return {
|
||||
wxid: groupId,
|
||||
displayName: groupInfo.displayName || groupId,
|
||||
myMessageCount,
|
||||
peerMessageCount,
|
||||
totalMessageCount
|
||||
}
|
||||
})
|
||||
|
||||
const groups = mutualGroups
|
||||
.filter((item): item is {
|
||||
wxid: string
|
||||
displayName: string
|
||||
myMessageCount: number
|
||||
peerMessageCount: number
|
||||
totalMessageCount: number
|
||||
} => Boolean(item))
|
||||
.sort((a, b) => {
|
||||
if (b.totalMessageCount !== a.totalMessageCount) return b.totalMessageCount - a.totalMessageCount
|
||||
return a.displayName.localeCompare(b.displayName, 'zh-CN')
|
||||
})
|
||||
|
||||
const totalMessagesByMe = groups.reduce((sum, item) => sum + item.myMessageCount, 0)
|
||||
const totalMessagesByPeer = groups.reduce((sum, item) => sum + item.peerMessageCount, 0)
|
||||
|
||||
return {
|
||||
totalGroups: groups.length,
|
||||
totalMessagesByMe,
|
||||
totalMessagesByPeer,
|
||||
totalMessagesCombined: totalMessagesByMe + totalMessagesByPeer,
|
||||
groups
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null {
|
||||
if (!avatarUrl) return null
|
||||
if (avatarUrl.startsWith('data:')) {
|
||||
@@ -2937,6 +3148,12 @@ class ExportService {
|
||||
})
|
||||
|
||||
const allMessages: any[] = []
|
||||
const senderProfileMap = new Map<string, {
|
||||
displayName: string
|
||||
nickname: string
|
||||
remark: string
|
||||
groupNickname: string
|
||||
}>()
|
||||
for (const msg of collected.rows) {
|
||||
const senderInfo = await this.getContactInfo(msg.senderUsername)
|
||||
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
|
||||
@@ -2998,6 +3215,29 @@ class ExportService {
|
||||
senderGroupNickname,
|
||||
options.displayNamePreference || 'remark'
|
||||
)
|
||||
const existingSenderProfile = senderProfileMap.get(senderWxid)
|
||||
if (!existingSenderProfile) {
|
||||
senderProfileMap.set(senderWxid, {
|
||||
displayName: senderDisplayName,
|
||||
nickname: senderNickname,
|
||||
remark: senderRemark,
|
||||
groupNickname: senderGroupNickname
|
||||
})
|
||||
} else {
|
||||
if (!existingSenderProfile.displayName && senderDisplayName) {
|
||||
existingSenderProfile.displayName = senderDisplayName
|
||||
}
|
||||
if (!existingSenderProfile.nickname && senderNickname) {
|
||||
existingSenderProfile.nickname = senderNickname
|
||||
}
|
||||
if (!existingSenderProfile.remark && senderRemark) {
|
||||
existingSenderProfile.remark = senderRemark
|
||||
}
|
||||
if (!existingSenderProfile.groupNickname && senderGroupNickname) {
|
||||
existingSenderProfile.groupNickname = senderGroupNickname
|
||||
}
|
||||
senderProfileMap.set(senderWxid, existingSenderProfile)
|
||||
}
|
||||
|
||||
const msgObj: any = {
|
||||
localId: allMessages.length + 1,
|
||||
@@ -3033,8 +3273,6 @@ class ExportService {
|
||||
phase: 'writing'
|
||||
})
|
||||
|
||||
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||
|
||||
// 获取会话的昵称和备注信息
|
||||
const sessionContact = await getContactCached(sessionId)
|
||||
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName
|
||||
@@ -3057,9 +3295,24 @@ class ExportService {
|
||||
)
|
||||
|
||||
const weflow = this.getWeflowHeader()
|
||||
const detailedExport: any = {
|
||||
weflow,
|
||||
session: {
|
||||
if (options.format === 'arkme-json' && isGroup) {
|
||||
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||
}
|
||||
|
||||
const avatarMap = options.exportAvatars
|
||||
? await this.exportAvatars(
|
||||
[
|
||||
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
|
||||
username,
|
||||
avatarUrl: info.avatarUrl
|
||||
})),
|
||||
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
||||
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
||||
]
|
||||
)
|
||||
: new Map<string, string>()
|
||||
|
||||
const sessionPayload: any = {
|
||||
wxid: sessionId,
|
||||
nickname: sessionNickname,
|
||||
remark: sessionRemark,
|
||||
@@ -3067,21 +3320,187 @@ class ExportService {
|
||||
type: isGroup ? '群聊' : '私聊',
|
||||
lastTimestamp: collected.lastTime,
|
||||
messageCount: allMessages.length,
|
||||
avatar: undefined as string | undefined
|
||||
avatar: avatarMap.get(sessionId)
|
||||
}
|
||||
|
||||
if (options.format === 'arkme-json') {
|
||||
const senderIdMap = new Map<string, number>()
|
||||
const senders: Array<{
|
||||
senderID: number
|
||||
wxid: string
|
||||
displayName: string
|
||||
nickname: string
|
||||
remark?: string
|
||||
groupNickname?: string
|
||||
avatar?: string
|
||||
}> = []
|
||||
const ensureSenderId = (senderWxidRaw: string): number => {
|
||||
const senderWxid = String(senderWxidRaw || '').trim() || 'unknown'
|
||||
const existed = senderIdMap.get(senderWxid)
|
||||
if (existed) return existed
|
||||
|
||||
const senderID = senders.length + 1
|
||||
senderIdMap.set(senderWxid, senderID)
|
||||
|
||||
const profile = senderProfileMap.get(senderWxid)
|
||||
const senderItem: {
|
||||
senderID: number
|
||||
wxid: string
|
||||
displayName: string
|
||||
nickname: string
|
||||
remark?: string
|
||||
groupNickname?: string
|
||||
avatar?: string
|
||||
} = {
|
||||
senderID,
|
||||
wxid: senderWxid,
|
||||
displayName: profile?.displayName || senderWxid,
|
||||
nickname: profile?.nickname || profile?.displayName || senderWxid
|
||||
}
|
||||
if (profile?.remark) senderItem.remark = profile.remark
|
||||
if (profile?.groupNickname) senderItem.groupNickname = profile.groupNickname
|
||||
const avatar = avatarMap.get(senderWxid)
|
||||
if (avatar) senderItem.avatar = avatar
|
||||
|
||||
senders.push(senderItem)
|
||||
return senderID
|
||||
}
|
||||
|
||||
const compactMessages = allMessages.map((message) => {
|
||||
const senderID = ensureSenderId(String(message.senderUsername || ''))
|
||||
const compactMessage: any = {
|
||||
localId: message.localId,
|
||||
createTime: message.createTime,
|
||||
formattedTime: message.formattedTime,
|
||||
type: message.type,
|
||||
localType: message.localType,
|
||||
content: message.content,
|
||||
isSend: message.isSend,
|
||||
senderID,
|
||||
source: message.source
|
||||
}
|
||||
if (message.locationLat != null) compactMessage.locationLat = message.locationLat
|
||||
if (message.locationLng != null) compactMessage.locationLng = message.locationLng
|
||||
if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname
|
||||
if (message.locationLabel) compactMessage.locationLabel = message.locationLabel
|
||||
return compactMessage
|
||||
})
|
||||
|
||||
const arkmeSession: any = {
|
||||
...sessionPayload
|
||||
}
|
||||
let privateMutualGroups: {
|
||||
totalGroups: number
|
||||
totalMessagesByMe: number
|
||||
totalMessagesByPeer: number
|
||||
totalMessagesCombined: number
|
||||
groups: Array<{
|
||||
wxid: string
|
||||
displayName: string
|
||||
myMessageCount: number
|
||||
peerMessageCount: number
|
||||
totalMessageCount: number
|
||||
}>
|
||||
} | undefined
|
||||
let groupMembers: Array<{
|
||||
wxid: string
|
||||
displayName: string
|
||||
nickname: string
|
||||
remark: string
|
||||
alias: string
|
||||
groupNickname?: string
|
||||
isFriend: boolean
|
||||
messageCount: number
|
||||
avatar?: string
|
||||
}> | undefined
|
||||
|
||||
if (isGroup) {
|
||||
const memberUsernames = Array.from(collected.memberSet.keys()).filter(Boolean)
|
||||
await this.preloadContacts(memberUsernames, contactCache)
|
||||
const friendLookupUsernames = this.buildGroupNicknameIdCandidates(memberUsernames)
|
||||
const friendFlagMap = await this.queryFriendFlagMap(friendLookupUsernames)
|
||||
const groupStatsResult = await wcdbService.getGroupStats(sessionId, 0, 0)
|
||||
const groupSenderCountMap = groupStatsResult.success && groupStatsResult.data
|
||||
? this.extractGroupSenderCountMap(groupStatsResult.data, sessionId)
|
||||
: new Map<string, number>()
|
||||
|
||||
groupMembers = []
|
||||
for (const memberWxid of memberUsernames) {
|
||||
const member = collected.memberSet.get(memberWxid)?.member
|
||||
const contactResult = await getContactCached(memberWxid)
|
||||
const contact = contactResult.success ? contactResult.contact : null
|
||||
const nickname = String(contact?.nickName || contact?.nick_name || member?.accountName || memberWxid)
|
||||
const remark = String(contact?.remark || '')
|
||||
const alias = String(contact?.alias || '')
|
||||
const groupNickname = member?.groupNickname || this.resolveGroupNicknameByCandidates(
|
||||
groupNicknamesMap,
|
||||
[memberWxid, contact?.username, contact?.userName, contact?.encryptUsername, contact?.encryptUserName, alias]
|
||||
) || ''
|
||||
const displayName = this.getPreferredDisplayName(
|
||||
memberWxid,
|
||||
nickname,
|
||||
remark,
|
||||
groupNickname,
|
||||
options.displayNamePreference || 'remark'
|
||||
)
|
||||
|
||||
const groupMember: {
|
||||
wxid: string
|
||||
displayName: string
|
||||
nickname: string
|
||||
remark: string
|
||||
alias: string
|
||||
groupNickname?: string
|
||||
isFriend: boolean
|
||||
messageCount: number
|
||||
avatar?: string
|
||||
} = {
|
||||
wxid: memberWxid,
|
||||
displayName,
|
||||
nickname,
|
||||
remark,
|
||||
alias,
|
||||
isFriend: this.buildGroupNicknameIdCandidates([memberWxid]).some((candidate) => friendFlagMap.get(candidate) === true),
|
||||
messageCount: this.sumSenderCountsByIdentity(groupSenderCountMap, memberWxid)
|
||||
}
|
||||
if (groupNickname) groupMember.groupNickname = groupNickname
|
||||
const avatar = avatarMap.get(memberWxid)
|
||||
if (avatar) groupMember.avatar = avatar
|
||||
groupMembers.push(groupMember)
|
||||
}
|
||||
groupMembers.sort((a, b) => {
|
||||
if (b.messageCount !== a.messageCount) return b.messageCount - a.messageCount
|
||||
return String(a.displayName || a.wxid).localeCompare(String(b.displayName || b.wxid), 'zh-CN')
|
||||
})
|
||||
} else if (!sessionId.startsWith('gh_')) {
|
||||
privateMutualGroups = await this.collectPrivateMutualGroupStats(sessionId, cleanedMyWxid)
|
||||
}
|
||||
|
||||
const arkmeExport: any = {
|
||||
weflow: {
|
||||
...weflow,
|
||||
format: 'arkme-json'
|
||||
},
|
||||
session: arkmeSession,
|
||||
senders,
|
||||
messages: compactMessages
|
||||
}
|
||||
if (privateMutualGroups) {
|
||||
arkmeExport.privateMutualGroups = privateMutualGroups
|
||||
}
|
||||
if (groupMembers) {
|
||||
arkmeExport.groupMembers = groupMembers
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
|
||||
} else {
|
||||
const detailedExport: any = {
|
||||
weflow,
|
||||
session: sessionPayload,
|
||||
messages: allMessages
|
||||
}
|
||||
|
||||
if (options.exportAvatars) {
|
||||
const avatarMap = await this.exportAvatars(
|
||||
[
|
||||
...Array.from(collected.memberSet.entries()).map(([username, info]) => ({
|
||||
username,
|
||||
avatarUrl: info.avatarUrl
|
||||
})),
|
||||
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl }
|
||||
]
|
||||
)
|
||||
const avatars: Record<string, string> = {}
|
||||
for (const [username, relPath] of avatarMap.entries()) {
|
||||
avatars[username] = relPath
|
||||
@@ -3096,6 +3515,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
current: 100,
|
||||
@@ -4882,7 +5302,7 @@ class ExportService {
|
||||
const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
|
||||
|
||||
let result: { success: boolean; error?: string }
|
||||
if (options.format === 'json') {
|
||||
if (options.format === 'json' || options.format === 'arkme-json') {
|
||||
result = await this.exportSessionToDetailedJson(sessionId, outputPath, options, sessionProgress)
|
||||
} else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') {
|
||||
result = await this.exportSessionToChatLab(sessionId, outputPath, options, sessionProgress)
|
||||
|
||||
@@ -138,11 +138,44 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-icon-with-badge {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
background: #ff3b30;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.18);
|
||||
}
|
||||
|
||||
.nav-badge.icon-badge {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -10px;
|
||||
margin-left: 0;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
box-shadow: 0 0 0 2px var(--bg-secondary);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import * as configService from '../services/config'
|
||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||
|
||||
import './Sidebar.scss'
|
||||
|
||||
@@ -52,6 +53,7 @@ function Sidebar() {
|
||||
const location = useLocation()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
||||
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
||||
wxid: '',
|
||||
displayName: '未识别用户'
|
||||
@@ -62,6 +64,26 @@ function Sidebar() {
|
||||
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onExportSessionStatus((payload) => {
|
||||
const countFromPayload = typeof payload?.activeTaskCount === 'number'
|
||||
? payload.activeTaskCount
|
||||
: Array.isArray(payload?.inProgressSessionIds)
|
||||
? payload.inProgressSessionIds.length
|
||||
: 0
|
||||
const normalized = Math.max(0, Math.floor(countFromPayload))
|
||||
setActiveExportTaskCount(normalized)
|
||||
})
|
||||
|
||||
requestExportSessionStatus()
|
||||
const timer = window.setTimeout(() => requestExportSessionStatus(), 120)
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadCurrentUser = async () => {
|
||||
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
|
||||
@@ -190,6 +212,7 @@ function Sidebar() {
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
}
|
||||
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
|
||||
|
||||
return (
|
||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
@@ -270,8 +293,16 @@ function Sidebar() {
|
||||
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
||||
title={collapsed ? '导出' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Download size={20} /></span>
|
||||
<span className="nav-icon nav-icon-with-badge">
|
||||
<Download size={20} />
|
||||
{collapsed && activeExportTaskCount > 0 && (
|
||||
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="nav-label">导出</span>
|
||||
{!collapsed && activeExportTaskCount > 0 && (
|
||||
<span className="nav-badge">{exportTaskBadge}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ type SessionLayout = 'shared' | 'per-session'
|
||||
|
||||
type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
|
||||
|
||||
type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
|
||||
interface ExportOptions {
|
||||
format: TextExportFormat
|
||||
@@ -134,6 +134,7 @@ const formatOptions: Array<{ value: TextExportFormat; label: string; desc: strin
|
||||
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
|
||||
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||
@@ -662,6 +663,7 @@ function ExportPage() {
|
||||
const contactsListRef = useRef<HTMLDivElement>(null)
|
||||
const detailRequestSeqRef = useRef(0)
|
||||
const inProgressSessionIdsRef = useRef<string[]>([])
|
||||
const activeTaskCountRef = useRef(0)
|
||||
const hasBaseConfigReadyRef = useRef(false)
|
||||
|
||||
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
|
||||
@@ -2180,26 +2182,40 @@ function ExportPage() {
|
||||
}
|
||||
return Array.from(set).sort()
|
||||
}, [tasks])
|
||||
const activeTaskCount = useMemo(
|
||||
() => tasks.filter(task => task.status === 'running' || task.status === 'queued').length,
|
||||
[tasks]
|
||||
)
|
||||
|
||||
const inProgressSessionIdsKey = useMemo(
|
||||
() => inProgressSessionIds.join('||'),
|
||||
[inProgressSessionIds]
|
||||
)
|
||||
const inProgressStatusKey = useMemo(
|
||||
() => `${activeTaskCount}::${inProgressSessionIdsKey}`,
|
||||
[activeTaskCount, inProgressSessionIdsKey]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
inProgressSessionIdsRef.current = inProgressSessionIds
|
||||
}, [inProgressSessionIds])
|
||||
|
||||
useEffect(() => {
|
||||
activeTaskCountRef.current = activeTaskCount
|
||||
}, [activeTaskCount])
|
||||
|
||||
useEffect(() => {
|
||||
emitExportSessionStatus({
|
||||
inProgressSessionIds: inProgressSessionIdsRef.current
|
||||
inProgressSessionIds: inProgressSessionIdsRef.current,
|
||||
activeTaskCount: activeTaskCountRef.current
|
||||
})
|
||||
}, [inProgressSessionIdsKey])
|
||||
}, [inProgressStatusKey])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onExportSessionStatusRequest(() => {
|
||||
emitExportSessionStatus({
|
||||
inProgressSessionIds: inProgressSessionIdsRef.current
|
||||
inProgressSessionIds: inProgressSessionIdsRef.current,
|
||||
activeTaskCount: activeTaskCountRef.current
|
||||
})
|
||||
})
|
||||
return unsubscribe
|
||||
|
||||
@@ -30,7 +30,7 @@ interface GroupMessageRank {
|
||||
}
|
||||
|
||||
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||
|
||||
interface MemberMessageExportOptions {
|
||||
format: MemberExportFormat
|
||||
@@ -119,6 +119,7 @@ function GroupAnalyticsPage() {
|
||||
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
|
||||
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||
|
||||
@@ -1542,6 +1542,7 @@ function SettingsPage() {
|
||||
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
|
||||
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface OpenSingleExportPayload {
|
||||
|
||||
export interface ExportSessionStatusPayload {
|
||||
inProgressSessionIds: string[]
|
||||
activeTaskCount: number
|
||||
}
|
||||
|
||||
export interface SingleExportDialogStatusPayload {
|
||||
|
||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -616,7 +616,7 @@ export interface ElectronAPI {
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||
dateRange?: { start: number; end: number } | null
|
||||
senderUsername?: string
|
||||
fileNameSuffix?: string
|
||||
|
||||
Reference in New Issue
Block a user