mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
@@ -688,8 +688,16 @@ export class ConfigService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getUserDataPath(): string {
|
||||||
|
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||||
|
if (workerUserDataPath) {
|
||||||
|
return workerUserDataPath
|
||||||
|
}
|
||||||
|
return app?.getPath?.('userData') || process.cwd()
|
||||||
|
}
|
||||||
|
|
||||||
getCacheBasePath(): string {
|
getCacheBasePath(): string {
|
||||||
return join(app.getPath('userData'), 'cache')
|
return join(this.getUserDataPath(), 'cache')
|
||||||
}
|
}
|
||||||
|
|
||||||
getAll(): Partial<ConfigSchema> {
|
getAll(): Partial<ConfigSchema> {
|
||||||
|
|||||||
@@ -186,6 +186,33 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quoted-message {
|
||||||
|
border-left: 3px solid rgba(79, 70, 229, 0.35);
|
||||||
|
background: rgba(79, 70, 229, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .quoted-message {
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
border-left-color: rgba(37, 99, 235, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-sender {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.message-link-card {
|
.message-link-card {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
@@ -186,6 +186,33 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quoted-message {
|
||||||
|
border-left: 3px solid rgba(79, 70, 229, 0.35);
|
||||||
|
background: rgba(79, 70, 229, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .quoted-message {
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
border-left-color: rgba(37, 99, 235, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-sender {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.message-link-card {
|
.message-link-card {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ interface ChatLabMessage {
|
|||||||
timestamp: number
|
timestamp: number
|
||||||
type: number
|
type: number
|
||||||
content: string | null
|
content: string | null
|
||||||
|
platformMessageId?: string
|
||||||
|
replyToMessageId?: string
|
||||||
chatRecords?: any[] // 嵌套的聊天记录
|
chatRecords?: any[] // 嵌套的聊天记录
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,6 +954,18 @@ class ExportService {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getRowField(row: Record<string, any>, keys: string[]): any {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (row && Object.prototype.hasOwnProperty.call(row, key)) {
|
||||||
|
const value = row[key]
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeUnsignedIntToken(value: unknown): string {
|
private normalizeUnsignedIntToken(value: unknown): string {
|
||||||
const raw = String(value ?? '').trim()
|
const raw = String(value ?? '').trim()
|
||||||
if (!raw) return '0'
|
if (!raw) return '0'
|
||||||
@@ -963,14 +977,14 @@ class ExportService {
|
|||||||
return String(Math.floor(num))
|
return String(Math.floor(num))
|
||||||
}
|
}
|
||||||
|
|
||||||
private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown }): string {
|
private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string {
|
||||||
const localId = this.normalizeUnsignedIntToken(msg?.localId)
|
const localId = this.normalizeUnsignedIntToken(msg?.localId)
|
||||||
const createTime = this.normalizeUnsignedIntToken(msg?.createTime)
|
const createTime = this.normalizeUnsignedIntToken(msg?.createTime)
|
||||||
const serverId = this.normalizeUnsignedIntToken(msg?.serverId)
|
const serverId = this.normalizeUnsignedIntToken(msg?.serverIdRaw ?? msg?.serverId)
|
||||||
return `${localId}:${createTime}:${serverId}`
|
return `${localId}:${createTime}:${serverId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown }): string {
|
private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string {
|
||||||
const localType = this.normalizeUnsignedIntToken(msg?.localType)
|
const localType = this.normalizeUnsignedIntToken(msg?.localType)
|
||||||
return `${localType}_${this.getStableMessageKey(msg)}`
|
return `${localType}_${this.getStableMessageKey(msg)}`
|
||||||
}
|
}
|
||||||
@@ -1620,7 +1634,13 @@ class ExportService {
|
|||||||
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
|
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
|
||||||
if (type === '19') return this.formatForwardChatRecordContent(normalizedContent)
|
if (type === '19') return this.formatForwardChatRecordContent(normalizedContent)
|
||||||
if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]'
|
if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]'
|
||||||
if (type === '57') return title || '[引用消息]'
|
if (type === '57') {
|
||||||
|
const quoteDisplay = this.extractQuotedReplyDisplay(content)
|
||||||
|
if (quoteDisplay) {
|
||||||
|
return this.buildQuotedReplyText(quoteDisplay)
|
||||||
|
}
|
||||||
|
return title || '[引用消息]'
|
||||||
|
}
|
||||||
if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]'
|
if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]'
|
||||||
return title ? `[链接] ${title}` : '[链接]'
|
return title ? `[链接] ${title}` : '[链接]'
|
||||||
}
|
}
|
||||||
@@ -1629,6 +1649,10 @@ class ExportService {
|
|||||||
case 266287972401: return this.cleanSystemMessage(content) // 拍一拍
|
case 266287972401: return this.cleanSystemMessage(content) // 拍一拍
|
||||||
case 244813135921: {
|
case 244813135921: {
|
||||||
// 引用消息
|
// 引用消息
|
||||||
|
const quoteDisplay = this.extractQuotedReplyDisplay(content)
|
||||||
|
if (quoteDisplay) {
|
||||||
|
return this.buildQuotedReplyText(quoteDisplay)
|
||||||
|
}
|
||||||
const title = this.extractXmlValue(content, 'title')
|
const title = this.extractXmlValue(content, 'title')
|
||||||
return title || '[引用消息]'
|
return title || '[引用消息]'
|
||||||
}
|
}
|
||||||
@@ -1662,7 +1686,13 @@ class ExportService {
|
|||||||
if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]'
|
if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]'
|
||||||
if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent)
|
if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent)
|
||||||
if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]'
|
if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]'
|
||||||
if (xmlType === '57') return title || '[引用消息]'
|
if (xmlType === '57') {
|
||||||
|
const quoteDisplay = this.extractQuotedReplyDisplay(content)
|
||||||
|
if (quoteDisplay) {
|
||||||
|
return this.buildQuotedReplyText(quoteDisplay)
|
||||||
|
}
|
||||||
|
return title || '[引用消息]'
|
||||||
|
}
|
||||||
if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]'
|
if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]'
|
||||||
|
|
||||||
// 有 title 就返回 title
|
// 有 title 就返回 title
|
||||||
@@ -1787,6 +1817,10 @@ class ExportService {
|
|||||||
return `[小程序]${appName}`
|
return `[小程序]${appName}`
|
||||||
}
|
}
|
||||||
if (subType === 57) {
|
if (subType === 57) {
|
||||||
|
const quoteDisplay = this.extractQuotedReplyDisplay(safeContent)
|
||||||
|
if (quoteDisplay) {
|
||||||
|
return this.buildQuotedReplyText(quoteDisplay)
|
||||||
|
}
|
||||||
return title || '[引用消息]'
|
return title || '[引用消息]'
|
||||||
}
|
}
|
||||||
if (title) {
|
if (title) {
|
||||||
@@ -1798,6 +1832,161 @@ class ExportService {
|
|||||||
return '[其他消息]'
|
return '[其他消息]'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatQuotedReferencePreview(content: string, type?: string): string {
|
||||||
|
const safeContent = content || ''
|
||||||
|
const referType = Number.parseInt(String(type || ''), 10)
|
||||||
|
if (!Number.isFinite(referType)) {
|
||||||
|
const sanitized = this.sanitizeQuotedContent(safeContent)
|
||||||
|
return sanitized || '[消息]'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (referType === 49) {
|
||||||
|
const normalized = this.normalizeAppMessageContent(safeContent)
|
||||||
|
const title =
|
||||||
|
this.extractXmlValue(normalized, 'title') ||
|
||||||
|
this.extractXmlValue(normalized, 'filename') ||
|
||||||
|
this.extractXmlValue(normalized, 'appname')
|
||||||
|
if (title) return this.stripSenderPrefix(title)
|
||||||
|
|
||||||
|
const subTypeRaw = this.extractAppMessageType(normalized)
|
||||||
|
const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0
|
||||||
|
if (subType === 6) return '[文件]'
|
||||||
|
if (subType === 19) return '[聊天记录]'
|
||||||
|
if (subType === 33 || subType === 36) return '[小程序]'
|
||||||
|
return '[链接]'
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.formatPlainExportContent(safeContent, referType, { exportVoiceAsText: false }) || '[消息]'
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveQuotedSenderUsername(fromusr?: string, chatusr?: string): string {
|
||||||
|
const normalizedChatUsr = String(chatusr || '').trim()
|
||||||
|
const normalizedFromUsr = String(fromusr || '').trim()
|
||||||
|
|
||||||
|
if (normalizedChatUsr) {
|
||||||
|
return normalizedChatUsr
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedFromUsr.endsWith('@chatroom')) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedFromUsr
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildQuotedReplyText(display: {
|
||||||
|
replyText: string
|
||||||
|
quotedSender?: string
|
||||||
|
quotedPreview: string
|
||||||
|
}): string {
|
||||||
|
const quoteLabel = display.quotedSender
|
||||||
|
? `${display.quotedSender}:${display.quotedPreview}`
|
||||||
|
: display.quotedPreview
|
||||||
|
if (display.replyText) {
|
||||||
|
return `${display.replyText}[引用 ${quoteLabel}]`
|
||||||
|
}
|
||||||
|
return `[引用 ${quoteLabel}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractQuotedReplyDisplay(content: string): {
|
||||||
|
replyText: string
|
||||||
|
quotedSender?: string
|
||||||
|
quotedPreview: string
|
||||||
|
} | null {
|
||||||
|
try {
|
||||||
|
const normalized = this.normalizeAppMessageContent(content || '')
|
||||||
|
const referMsgStart = normalized.indexOf('<refermsg>')
|
||||||
|
const referMsgEnd = normalized.indexOf('</refermsg>')
|
||||||
|
if (referMsgStart === -1 || referMsgEnd === -1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
|
||||||
|
const quoteInfo = this.parseQuoteMessage(normalized)
|
||||||
|
const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '')
|
||||||
|
const quotedPreview = this.formatQuotedReferencePreview(
|
||||||
|
this.extractXmlValue(referMsgXml, 'content'),
|
||||||
|
this.extractXmlValue(referMsgXml, 'type')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!replyText && !quotedPreview) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
replyText,
|
||||||
|
quotedSender: quoteInfo.sender || undefined,
|
||||||
|
quotedPreview: quotedPreview || '[消息]'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isQuotedReplyMessage(localType: number, content: string): boolean {
|
||||||
|
if (localType === 244813135921) return true
|
||||||
|
const normalized = this.normalizeAppMessageContent(content || '')
|
||||||
|
if (!(localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>'))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const subType = this.extractAppMessageType(normalized)
|
||||||
|
return subType === '57' || normalized.includes('<refermsg>')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveQuotedReplyDisplayWithNames(args: {
|
||||||
|
content: string
|
||||||
|
isGroup: boolean
|
||||||
|
displayNamePreference: ExportOptions['displayNamePreference']
|
||||||
|
getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }>
|
||||||
|
groupNicknamesMap: Map<string, string>
|
||||||
|
cleanedMyWxid: string
|
||||||
|
rawMyWxid?: string
|
||||||
|
myDisplayName?: string
|
||||||
|
}): Promise<{
|
||||||
|
replyText: string
|
||||||
|
quotedSender?: string
|
||||||
|
quotedPreview: string
|
||||||
|
} | null> {
|
||||||
|
const base = this.extractQuotedReplyDisplay(args.content)
|
||||||
|
if (!base) return null
|
||||||
|
if (base.quotedSender) return base
|
||||||
|
|
||||||
|
const normalized = this.normalizeAppMessageContent(args.content || '')
|
||||||
|
const referMsgStart = normalized.indexOf('<refermsg>')
|
||||||
|
const referMsgEnd = normalized.indexOf('</refermsg>')
|
||||||
|
if (referMsgStart === -1 || referMsgEnd === -1) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
|
||||||
|
const quotedSenderUsername = this.resolveQuotedSenderUsername(
|
||||||
|
this.extractXmlValue(referMsgXml, 'fromusr'),
|
||||||
|
this.extractXmlValue(referMsgXml, 'chatusr')
|
||||||
|
)
|
||||||
|
if (!quotedSenderUsername) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
const isQuotedSelf = this.isSameWxid(quotedSenderUsername, args.cleanedMyWxid)
|
||||||
|
const fallbackDisplayName = isQuotedSelf
|
||||||
|
? (args.myDisplayName || quotedSenderUsername)
|
||||||
|
: quotedSenderUsername
|
||||||
|
|
||||||
|
const profile = await this.resolveExportDisplayProfile(
|
||||||
|
quotedSenderUsername,
|
||||||
|
args.displayNamePreference,
|
||||||
|
args.getContact,
|
||||||
|
args.groupNicknamesMap,
|
||||||
|
fallbackDisplayName,
|
||||||
|
isQuotedSelf ? [args.rawMyWxid, args.cleanedMyWxid] : []
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
quotedSender: profile.displayName || fallbackDisplayName || base.quotedSender
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private parseDurationSeconds(value: string): number | null {
|
private parseDurationSeconds(value: string): number | null {
|
||||||
const numeric = Number(value)
|
const numeric = Number(value)
|
||||||
if (!Number.isFinite(numeric) || numeric <= 0) return null
|
if (!Number.isFinite(numeric) || numeric <= 0) return null
|
||||||
@@ -2462,6 +2651,32 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractChatLabReplyToMessageId(content: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const normalized = this.normalizeAppMessageContent(content || '')
|
||||||
|
const referMsgStart = normalized.indexOf('<refermsg>')
|
||||||
|
const referMsgEnd = normalized.indexOf('</refermsg>')
|
||||||
|
if (referMsgStart === -1 || referMsgEnd === -1) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
|
||||||
|
const replyToMessageIdRaw = this.normalizeUnsignedIntToken(this.extractXmlValue(referMsgXml, 'svrid'))
|
||||||
|
return replyToMessageIdRaw !== '0' ? replyToMessageIdRaw : undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExportPlatformMessageId(msg: { serverIdRaw?: unknown; serverId?: unknown }): string | undefined {
|
||||||
|
const value = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId)
|
||||||
|
return value !== '0' ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExportReplyToMessageId(content: string): string | undefined {
|
||||||
|
return this.extractChatLabReplyToMessageId(content)
|
||||||
|
}
|
||||||
|
|
||||||
private extractArkmeAppMessageMeta(content: string, localType: number): Record<string, any> | null {
|
private extractArkmeAppMessageMeta(content: string, localType: number): Record<string, any> | null {
|
||||||
if (!content) return null
|
if (!content) return null
|
||||||
|
|
||||||
@@ -3507,6 +3722,13 @@ class ExportService {
|
|||||||
'msg_id', 'msgId', 'MsgId', 'id',
|
'msg_id', 'msgId', 'MsgId', 'id',
|
||||||
'WCDB_CT_local_id'
|
'WCDB_CT_local_id'
|
||||||
], 0)
|
], 0)
|
||||||
|
const rawServerIdValue = this.getRowField(row, [
|
||||||
|
'server_id', 'serverId', 'ServerId',
|
||||||
|
'msg_server_id', 'msgServerId', 'MsgServerId',
|
||||||
|
'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId',
|
||||||
|
'WCDB_CT_server_id'
|
||||||
|
])
|
||||||
|
const serverIdRaw = this.normalizeUnsignedIntToken(rawServerIdValue)
|
||||||
const serverId = this.getIntFromRow(row, [
|
const serverId = this.getIntFromRow(row, [
|
||||||
'server_id', 'serverId', 'ServerId',
|
'server_id', 'serverId', 'ServerId',
|
||||||
'msg_server_id', 'msgServerId', 'MsgServerId',
|
'msg_server_id', 'msgServerId', 'MsgServerId',
|
||||||
@@ -3598,6 +3820,7 @@ class ExportService {
|
|||||||
rows.push({
|
rows.push({
|
||||||
localId,
|
localId,
|
||||||
serverId,
|
serverId,
|
||||||
|
serverIdRaw: serverIdRaw !== '0' ? serverIdRaw : undefined,
|
||||||
createTime,
|
createTime,
|
||||||
localType,
|
localType,
|
||||||
content,
|
content,
|
||||||
@@ -4440,6 +4663,16 @@ class ExportService {
|
|||||||
content: content
|
content: content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const platformMessageId = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId)
|
||||||
|
if (platformMessageId !== '0') {
|
||||||
|
message.platformMessageId = platformMessageId
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyToMessageId = this.extractChatLabReplyToMessageId(msg.content)
|
||||||
|
if (replyToMessageId) {
|
||||||
|
message.replyToMessageId = replyToMessageId
|
||||||
|
}
|
||||||
|
|
||||||
// 如果有聊天记录,添加为嵌套字段
|
// 如果有聊天记录,添加为嵌套字段
|
||||||
if (msg.chatRecordList && msg.chatRecordList.length > 0) {
|
if (msg.chatRecordList && msg.chatRecordList.length > 0) {
|
||||||
const chatRecords: any[] = []
|
const chatRecords: any[] = []
|
||||||
@@ -4895,6 +5128,20 @@ class ExportService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
|
||||||
|
content: msg.content,
|
||||||
|
isGroup,
|
||||||
|
displayNamePreference: options.displayNamePreference,
|
||||||
|
getContact: getContactCached,
|
||||||
|
groupNicknamesMap,
|
||||||
|
cleanedMyWxid,
|
||||||
|
rawMyWxid,
|
||||||
|
myDisplayName: myInfo.displayName || cleanedMyWxid
|
||||||
|
})
|
||||||
|
if (quotedReplyDisplay) {
|
||||||
|
content = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取发送者信息用于名称显示
|
// 获取发送者信息用于名称显示
|
||||||
const senderWxid = msg.senderUsername
|
const senderWxid = msg.senderUsername
|
||||||
const contact = senderWxid
|
const contact = senderWxid
|
||||||
@@ -4938,6 +5185,12 @@ class ExportService {
|
|||||||
senderAvatarKey: msg.senderUsername
|
senderAvatarKey: msg.senderUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const platformMessageId = this.getExportPlatformMessageId(msg)
|
||||||
|
if (platformMessageId) msgObj.platformMessageId = platformMessageId
|
||||||
|
|
||||||
|
const replyToMessageId = this.getExportReplyToMessageId(msg.content)
|
||||||
|
if (replyToMessageId) msgObj.replyToMessageId = replyToMessageId
|
||||||
|
|
||||||
const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType)
|
const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType)
|
||||||
if (appMsgMeta) {
|
if (appMsgMeta) {
|
||||||
if (
|
if (
|
||||||
@@ -4947,6 +5200,10 @@ class ExportService {
|
|||||||
Object.assign(msgObj, appMsgMeta)
|
Object.assign(msgObj, appMsgMeta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (quotedReplyDisplay) {
|
||||||
|
if (quotedReplyDisplay.quotedSender) msgObj.quotedSender = quotedReplyDisplay.quotedSender
|
||||||
|
if (quotedReplyDisplay.quotedPreview) msgObj.quotedContent = quotedReplyDisplay.quotedPreview
|
||||||
|
}
|
||||||
|
|
||||||
if (options.format === 'arkme-json') {
|
if (options.format === 'arkme-json') {
|
||||||
const contactCardMeta = this.extractArkmeContactCardMeta(msg.content, msg.localType)
|
const contactCardMeta = this.extractArkmeContactCardMeta(msg.content, msg.localType)
|
||||||
@@ -5144,6 +5401,8 @@ class ExportService {
|
|||||||
senderID,
|
senderID,
|
||||||
source: message.source
|
source: message.source
|
||||||
}
|
}
|
||||||
|
if (message.platformMessageId) compactMessage.platformMessageId = message.platformMessageId
|
||||||
|
if (message.replyToMessageId) compactMessage.replyToMessageId = message.replyToMessageId
|
||||||
if (message.locationLat != null) compactMessage.locationLat = message.locationLat
|
if (message.locationLat != null) compactMessage.locationLat = message.locationLat
|
||||||
if (message.locationLng != null) compactMessage.locationLng = message.locationLng
|
if (message.locationLng != null) compactMessage.locationLng = message.locationLng
|
||||||
if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname
|
if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname
|
||||||
@@ -5781,6 +6040,20 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
|
||||||
|
content: msg.content,
|
||||||
|
isGroup,
|
||||||
|
displayNamePreference: options.displayNamePreference,
|
||||||
|
getContact: getContactCached,
|
||||||
|
groupNicknamesMap,
|
||||||
|
cleanedMyWxid,
|
||||||
|
rawMyWxid,
|
||||||
|
myDisplayName: myInfo.displayName || cleanedMyWxid
|
||||||
|
})
|
||||||
|
if (quotedReplyDisplay) {
|
||||||
|
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||||
|
}
|
||||||
|
|
||||||
// 调试日志
|
// 调试日志
|
||||||
if (msg.localType === 3 || msg.localType === 47) {
|
if (msg.localType === 3 || msg.localType === 47) {
|
||||||
}
|
}
|
||||||
@@ -6028,6 +6301,20 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
|
||||||
|
content: msg.content,
|
||||||
|
isGroup,
|
||||||
|
displayNamePreference: options.displayNamePreference,
|
||||||
|
getContact: getContactCached,
|
||||||
|
groupNicknamesMap,
|
||||||
|
cleanedMyWxid,
|
||||||
|
rawMyWxid,
|
||||||
|
myDisplayName: myInfo.displayName || cleanedMyWxid
|
||||||
|
})
|
||||||
|
if (quotedReplyDisplay) {
|
||||||
|
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||||
|
}
|
||||||
|
|
||||||
appendRow(useCompactColumns
|
appendRow(useCompactColumns
|
||||||
? [
|
? [
|
||||||
i + 1,
|
i + 1,
|
||||||
@@ -6381,6 +6668,20 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
|
||||||
|
content: msg.content,
|
||||||
|
isGroup,
|
||||||
|
displayNamePreference: options.displayNamePreference,
|
||||||
|
getContact: getContactCached,
|
||||||
|
groupNicknamesMap,
|
||||||
|
cleanedMyWxid,
|
||||||
|
rawMyWxid,
|
||||||
|
myDisplayName: myInfo.displayName || cleanedMyWxid
|
||||||
|
})
|
||||||
|
if (quotedReplyDisplay) {
|
||||||
|
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||||
|
}
|
||||||
|
|
||||||
let senderRole: string
|
let senderRole: string
|
||||||
let senderWxid: string
|
let senderWxid: string
|
||||||
let senderNickname: string
|
let senderNickname: string
|
||||||
@@ -6522,7 +6823,7 @@ class ExportService {
|
|||||||
control,
|
control,
|
||||||
collectProgressReporter
|
collectProgressReporter
|
||||||
)
|
)
|
||||||
const totalMessages = collected.rows.length
|
let totalMessages = collected.rows.length
|
||||||
if (totalMessages === 0) {
|
if (totalMessages === 0) {
|
||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
@@ -6550,7 +6851,13 @@ class ExportService {
|
|||||||
? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
|
? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
|
||||||
: new Map<string, string>()
|
: new Map<string, string>()
|
||||||
|
|
||||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
const sortedMessages = collected.rows
|
||||||
|
.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
.filter((msg) => !this.isQuotedReplyMessage(msg.localType, msg.content || ''))
|
||||||
|
totalMessages = sortedMessages.length
|
||||||
|
if (totalMessages === 0) {
|
||||||
|
return { success: false, error: '该会话在指定时间范围内没有可导出的消息' }
|
||||||
|
}
|
||||||
|
|
||||||
const voiceMessages = options.exportVoiceAsText
|
const voiceMessages = options.exportVoiceAsText
|
||||||
? sortedMessages.filter(msg => msg.localType === 34)
|
? sortedMessages.filter(msg => msg.localType === 34)
|
||||||
@@ -6742,10 +7049,11 @@ class ExportService {
|
|||||||
msg.isSend
|
msg.isSend
|
||||||
) || '')
|
) || '')
|
||||||
const src = this.getWeCloneSource(msg, typeName, mediaItem)
|
const src = this.getWeCloneSource(msg, typeName, mediaItem)
|
||||||
|
const platformMessageId = this.getExportPlatformMessageId(msg) || ''
|
||||||
|
|
||||||
const row = [
|
const row = [
|
||||||
i + 1,
|
i + 1,
|
||||||
i + 1,
|
platformMessageId,
|
||||||
typeName,
|
typeName,
|
||||||
msg.isSend ? 1 : 0,
|
msg.isSend ? 1 : 0,
|
||||||
talker,
|
talker,
|
||||||
@@ -6945,6 +7253,7 @@ class ExportService {
|
|||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
|
const totalMessages = collected.rows.length
|
||||||
|
|
||||||
const senderUsernames = new Set<string>()
|
const senderUsernames = new Set<string>()
|
||||||
let senderScanIndex = 0
|
let senderScanIndex = 0
|
||||||
@@ -6987,6 +7296,7 @@ class ExportService {
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||||
|
const mediaDirCache = new Set<string>()
|
||||||
|
|
||||||
if (mediaMessages.length > 0) {
|
if (mediaMessages.length > 0) {
|
||||||
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
await this.preloadMediaLookupCaches(sessionId, mediaMessages, {
|
||||||
@@ -7219,8 +7529,18 @@ class ExportService {
|
|||||||
|
|
||||||
const timeText = this.formatTimestamp(msg.createTime)
|
const timeText = this.formatTimestamp(msg.createTime)
|
||||||
const typeName = this.getMessageTypeName(msg.localType)
|
const typeName = this.getMessageTypeName(msg.localType)
|
||||||
|
const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({
|
||||||
|
content: msg.content,
|
||||||
|
isGroup,
|
||||||
|
displayNamePreference: options.displayNamePreference,
|
||||||
|
getContact: getContactCached,
|
||||||
|
groupNicknamesMap,
|
||||||
|
cleanedMyWxid,
|
||||||
|
rawMyWxid,
|
||||||
|
myDisplayName: myInfo.displayName || cleanedMyWxid
|
||||||
|
})
|
||||||
|
|
||||||
let textContent = this.formatHtmlMessageText(
|
let textContent = quotedReplyDisplay?.replyText || this.formatHtmlMessageText(
|
||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -7251,7 +7571,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkCard = this.extractHtmlLinkCard(msg.content, msg.localType)
|
const linkCard = quotedReplyDisplay ? null : this.extractHtmlLinkCard(msg.content, msg.localType)
|
||||||
|
|
||||||
let mediaHtml = ''
|
let mediaHtml = ''
|
||||||
if (mediaItem?.kind === 'image') {
|
if (mediaItem?.kind === 'image') {
|
||||||
@@ -7267,25 +7587,40 @@ class ExportService {
|
|||||||
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>`
|
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>`
|
||||||
}
|
}
|
||||||
|
|
||||||
const textHtml = linkCard
|
const textHtml = quotedReplyDisplay
|
||||||
? `<div class="message-text"><a class="message-link-card" href="${this.escapeAttribute(linkCard.url)}" target="_blank" rel="noopener noreferrer">${this.renderTextWithEmoji(linkCard.title).replace(/\r?\n/g, '<br />')}</a></div>`
|
? (() => {
|
||||||
: (textContent
|
const quotedSenderHtml = quotedReplyDisplay.quotedSender
|
||||||
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
|
? `<div class="quoted-sender">${this.escapeHtml(quotedReplyDisplay.quotedSender)}</div>`
|
||||||
: '')
|
: ''
|
||||||
|
const quotedPreviewHtml = `<div class="quoted-text">${this.renderTextWithEmoji(quotedReplyDisplay.quotedPreview).replace(/\r?\n/g, '<br />')}</div>`
|
||||||
|
const replyTextHtml = textContent
|
||||||
|
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
|
||||||
|
: ''
|
||||||
|
return `<div class="quoted-message">${quotedSenderHtml}${quotedPreviewHtml}</div>${replyTextHtml}`
|
||||||
|
})()
|
||||||
|
: (linkCard
|
||||||
|
? `<div class="message-text"><a class="message-link-card" href="${this.escapeAttribute(linkCard.url)}" target="_blank" rel="noopener noreferrer">${this.renderTextWithEmoji(linkCard.title).replace(/\r?\n/g, '<br />')}</a></div>`
|
||||||
|
: (textContent
|
||||||
|
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
|
||||||
|
: ''))
|
||||||
const senderNameHtml = isGroup
|
const senderNameHtml = isGroup
|
||||||
? `<div class="sender-name">${this.escapeHtml(resolvedSenderName)}</div>`
|
? `<div class="sender-name">${this.escapeHtml(resolvedSenderName)}</div>`
|
||||||
: ''
|
: ''
|
||||||
const timeHtml = `<div class="message-time">${this.escapeHtml(timeText)}</div>`
|
const timeHtml = `<div class="message-time">${this.escapeHtml(timeText)}</div>`
|
||||||
const messageBody = `${timeHtml}${senderNameHtml}<div class="message-content">${mediaHtml}${textHtml}</div>`
|
const messageBody = `${timeHtml}${senderNameHtml}<div class="message-content">${mediaHtml}${textHtml}</div>`
|
||||||
|
const platformMessageId = this.getExportPlatformMessageId(msg)
|
||||||
|
const replyToMessageId = this.getExportReplyToMessageId(msg.content)
|
||||||
|
|
||||||
// Compact JSON object
|
// Compact JSON object
|
||||||
const itemObj = {
|
const itemObj: Record<string, any> = {
|
||||||
i: i + 1, // index
|
i: i + 1, // index
|
||||||
t: msg.createTime, // timestamp
|
t: msg.createTime, // timestamp
|
||||||
s: isSenderMe ? 1 : 0, // isSend
|
s: isSenderMe ? 1 : 0, // isSend
|
||||||
a: avatarHtml, // avatar HTML
|
a: avatarHtml, // avatar HTML
|
||||||
b: messageBody // body HTML
|
b: messageBody // body HTML
|
||||||
}
|
}
|
||||||
|
if (platformMessageId) itemObj.p = platformMessageId
|
||||||
|
if (replyToMessageId) itemObj.r = replyToMessageId
|
||||||
|
|
||||||
writeBuf.push(JSON.stringify(itemObj))
|
writeBuf.push(JSON.stringify(itemObj))
|
||||||
|
|
||||||
@@ -7333,8 +7668,10 @@ class ExportService {
|
|||||||
// Render Item Function
|
// Render Item Function
|
||||||
const renderItem = (item, index) => {
|
const renderItem = (item, index) => {
|
||||||
const isSenderMe = item.s === 1;
|
const isSenderMe = item.s === 1;
|
||||||
|
const platformIdAttr = item.p ? \` data-platform-message-id="\${item.p}"\` : '';
|
||||||
|
const replyToAttr = item.r ? \` data-reply-to-message-id="\${item.r}"\` : '';
|
||||||
return \`
|
return \`
|
||||||
<div class="message \${isSenderMe ? 'sent' : 'received'}" data-index="\${item.i}">
|
<div class="message \${isSenderMe ? 'sent' : 'received'}" data-index="\${item.i}"\${platformIdAttr}\${replyToAttr}>
|
||||||
<div class="message-row">
|
<div class="message-row">
|
||||||
<div class="avatar">\${item.a}</div>
|
<div class="avatar">\${item.a}</div>
|
||||||
<div class="bubble">
|
<div class="bubble">
|
||||||
|
|||||||
@@ -13,13 +13,14 @@
|
|||||||
width: min(480px, calc(100vw - 32px));
|
width: min(480px, calc(100vw - 32px));
|
||||||
max-height: calc(100vh - 64px);
|
max-height: calc(100vh - 64px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background: var(--bg-secondary-solid, var(--bg-primary));
|
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-date-range-dialog-header {
|
.export-date-range-dialog-header {
|
||||||
@@ -83,8 +84,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.export-date-range-mode-banner {
|
.export-date-range-mode-banner {
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 6px 8px;
|
padding: 7px 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -98,47 +99,92 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-date-range-calendar-grid {
|
.export-date-range-boundary-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-date-range-boundary-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boundary-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-selection-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.export-date-range-calendar-panel {
|
.export-date-range-calendar-panel {
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
background: var(--bg-secondary);
|
background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.04), transparent 28%), var(--bg-secondary);
|
||||||
padding: 7px;
|
padding: 10px;
|
||||||
|
|
||||||
|
&.single {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-date-range-calendar-panel-header {
|
.export-date-range-calendar-panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-date-range-calendar-date-label {
|
.export-date-range-calendar-date-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-date-range-date-input {
|
.export-date-range-date-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
height: 24px;
|
height: 30px;
|
||||||
padding: 0 7px;
|
padding: 0 9px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
&.invalid {
|
&.invalid {
|
||||||
border-color: #e84d4d;
|
border-color: #e84d4d;
|
||||||
@@ -149,28 +195,36 @@
|
|||||||
.export-date-range-calendar-nav {
|
.export-date-range-calendar-nav {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
||||||
button {
|
button {
|
||||||
width: 20px;
|
width: 28px;
|
||||||
height: 20px;
|
height: 28px;
|
||||||
border-radius: 5px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-date-range-calendar-weekdays {
|
.export-date-range-calendar-weekdays {
|
||||||
margin-top: 6px;
|
margin-top: 10px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -180,32 +234,61 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.export-date-range-calendar-days {
|
.export-date-range-calendar-days {
|
||||||
margin-top: 4px;
|
margin-top: 6px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-date-range-calendar-day {
|
.export-date-range-calendar-day {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 10px;
|
||||||
min-height: 20px;
|
min-height: 34px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.28);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
&.outside {
|
&.outside {
|
||||||
color: var(--text-quaternary);
|
color: var(--text-quaternary);
|
||||||
opacity: 0.75;
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
&.disabled {
|
||||||
border-color: var(--primary);
|
cursor: not-allowed;
|
||||||
background: rgba(var(--primary-rgb), 0.14);
|
opacity: 0.35;
|
||||||
|
transform: none;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.in-range {
|
||||||
|
background: rgba(var(--primary-rgb), 0.1);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.range-start,
|
||||||
|
&.range-end {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active-boundary {
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.22);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,8 +330,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 640px) {
|
||||||
.export-date-range-calendar-grid {
|
.export-date-range-boundary-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Check, X } from 'lucide-react'
|
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
EXPORT_DATE_RANGE_PRESETS,
|
EXPORT_DATE_RANGE_PRESETS,
|
||||||
WEEKDAY_SHORT_LABELS,
|
WEEKDAY_SHORT_LABELS,
|
||||||
@@ -25,29 +25,78 @@ interface ExportDateRangeDialogProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
value: ExportDateRangeSelection
|
value: ExportDateRangeSelection
|
||||||
title?: string
|
title?: string
|
||||||
|
minDate?: Date | null
|
||||||
|
maxDate?: Date | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onConfirm: (value: ExportDateRangeSelection) => void
|
onConfirm: (value: ExportDateRangeSelection) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ActiveBoundary = 'start' | 'end'
|
||||||
|
|
||||||
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
|
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
|
||||||
startPanelMonth: Date
|
panelMonth: Date
|
||||||
endPanelMonth: Date
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({
|
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
|
||||||
...cloneExportDateRangeSelection(value),
|
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
|
||||||
startPanelMonth: toMonthStart(value.dateRange.start),
|
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
|
||||||
endPanelMonth: toMonthStart(value.dateRange.end)
|
const normalizedMin = startOfDay(minDate)
|
||||||
})
|
const normalizedMax = endOfDay(maxDate)
|
||||||
|
if (normalizedMin.getTime() > normalizedMax.getTime()) return null
|
||||||
|
return {
|
||||||
|
minDate: normalizedMin,
|
||||||
|
maxDate: normalizedMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampSelectionToBounds = (
|
||||||
|
value: ExportDateRangeSelection,
|
||||||
|
minDate?: Date | null,
|
||||||
|
maxDate?: Date | null
|
||||||
|
): ExportDateRangeSelection => {
|
||||||
|
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()
|
||||||
|
|
||||||
|
return {
|
||||||
|
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
|
||||||
|
useAllTime: value.useAllTime,
|
||||||
|
dateRange: {
|
||||||
|
start: nextStart,
|
||||||
|
end: nextEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDialogDraft = (
|
||||||
|
value: ExportDateRangeSelection,
|
||||||
|
minDate?: Date | null,
|
||||||
|
maxDate?: Date | null
|
||||||
|
): ExportDateRangeDialogDraft => {
|
||||||
|
const nextValue = clampSelectionToBounds(value, minDate, maxDate)
|
||||||
|
return {
|
||||||
|
...nextValue,
|
||||||
|
panelMonth: toMonthStart(nextValue.dateRange.start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ExportDateRangeDialog({
|
export function ExportDateRangeDialog({
|
||||||
open,
|
open,
|
||||||
value,
|
value,
|
||||||
title = '时间范围设置',
|
title = '时间范围设置',
|
||||||
|
minDate,
|
||||||
|
maxDate,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm
|
onConfirm
|
||||||
}: ExportDateRangeDialogProps) {
|
}: ExportDateRangeDialogProps) {
|
||||||
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value))
|
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
|
||||||
|
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
|
||||||
const [dateInput, setDateInput] = useState({
|
const [dateInput, setDateInput] = useState({
|
||||||
start: formatDateInputValue(value.dateRange.start),
|
start: formatDateInputValue(value.dateRange.start),
|
||||||
end: formatDateInputValue(value.dateRange.end)
|
end: formatDateInputValue(value.dateRange.end)
|
||||||
@@ -56,14 +105,15 @@ export function ExportDateRangeDialog({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const nextDraft = buildDialogDraft(value)
|
const nextDraft = buildDialogDraft(value, minDate, maxDate)
|
||||||
setDraft(nextDraft)
|
setDraft(nextDraft)
|
||||||
|
setActiveBoundary('start')
|
||||||
setDateInput({
|
setDateInput({
|
||||||
start: formatDateInputValue(nextDraft.dateRange.start),
|
start: formatDateInputValue(nextDraft.dateRange.start),
|
||||||
end: formatDateInputValue(nextDraft.dateRange.end)
|
end: formatDateInputValue(nextDraft.dateRange.end)
|
||||||
})
|
})
|
||||||
setDateInputError({ start: false, end: false })
|
setDateInputError({ start: false, end: false })
|
||||||
}, [open, value])
|
}, [maxDate, minDate, open, value])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -74,33 +124,24 @@ export function ExportDateRangeDialog({
|
|||||||
setDateInputError({ start: false, end: false })
|
setDateInputError({ start: false, end: false })
|
||||||
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
||||||
|
|
||||||
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
|
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
|
||||||
if (preset === 'all') {
|
const clampStartDate = useCallback((targetDate: Date) => {
|
||||||
const previewRange = createDefaultDateRange()
|
|
||||||
setDraft(prev => ({
|
|
||||||
...prev,
|
|
||||||
preset,
|
|
||||||
useAllTime: true,
|
|
||||||
dateRange: previewRange,
|
|
||||||
startPanelMonth: toMonthStart(previewRange.start),
|
|
||||||
endPanelMonth: toMonthStart(previewRange.end)
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const range = createDateRangeByPreset(preset)
|
|
||||||
setDraft(prev => ({
|
|
||||||
...prev,
|
|
||||||
preset,
|
|
||||||
useAllTime: false,
|
|
||||||
dateRange: range,
|
|
||||||
startPanelMonth: toMonthStart(range.start),
|
|
||||||
endPanelMonth: toMonthStart(range.end)
|
|
||||||
}))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const updateDraftStart = useCallback((targetDate: Date) => {
|
|
||||||
const start = startOfDay(targetDate)
|
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
|
||||||
|
}, [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
|
||||||
|
}, [bounds])
|
||||||
|
|
||||||
|
const setRangeStart = useCallback((targetDate: Date) => {
|
||||||
|
const start = clampStartDate(targetDate)
|
||||||
setDraft(prev => {
|
setDraft(prev => {
|
||||||
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
||||||
return {
|
return {
|
||||||
@@ -111,16 +152,15 @@ export function ExportDateRangeDialog({
|
|||||||
start,
|
start,
|
||||||
end: nextEnd
|
end: nextEnd
|
||||||
},
|
},
|
||||||
startPanelMonth: toMonthStart(start),
|
panelMonth: toMonthStart(start)
|
||||||
endPanelMonth: toMonthStart(nextEnd)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [])
|
}, [clampStartDate])
|
||||||
|
|
||||||
const updateDraftEnd = useCallback((targetDate: Date) => {
|
const setRangeEnd = useCallback((targetDate: Date) => {
|
||||||
const end = endOfDay(targetDate)
|
const end = clampEndDate(targetDate)
|
||||||
setDraft(prev => {
|
setDraft(prev => {
|
||||||
const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
|
||||||
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
@@ -130,11 +170,41 @@ export function ExportDateRangeDialog({
|
|||||||
start: nextStart,
|
start: nextStart,
|
||||||
end: nextEnd
|
end: nextEnd
|
||||||
},
|
},
|
||||||
startPanelMonth: toMonthStart(nextStart),
|
panelMonth: toMonthStart(targetDate)
|
||||||
endPanelMonth: toMonthStart(nextEnd)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [])
|
}, [clampEndDate, clampStartDate])
|
||||||
|
|
||||||
|
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
|
||||||
|
if (preset === 'all') {
|
||||||
|
const previewRange = bounds
|
||||||
|
? { start: bounds.minDate, end: bounds.maxDate }
|
||||||
|
: createDefaultDateRange()
|
||||||
|
setDraft(prev => ({
|
||||||
|
...prev,
|
||||||
|
preset,
|
||||||
|
useAllTime: true,
|
||||||
|
dateRange: previewRange,
|
||||||
|
panelMonth: toMonthStart(previewRange.start)
|
||||||
|
}))
|
||||||
|
setActiveBoundary('start')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = clampSelectionToBounds({
|
||||||
|
preset,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: createDateRangeByPreset(preset)
|
||||||
|
}, minDate, maxDate).dateRange
|
||||||
|
setDraft(prev => ({
|
||||||
|
...prev,
|
||||||
|
preset,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: range,
|
||||||
|
panelMonth: toMonthStart(range.start)
|
||||||
|
}))
|
||||||
|
setActiveBoundary('start')
|
||||||
|
}, [bounds, maxDate, minDate])
|
||||||
|
|
||||||
const commitStartFromInput = useCallback(() => {
|
const commitStartFromInput = useCallback(() => {
|
||||||
const parsed = parseDateInputValue(dateInput.start)
|
const parsed = parseDateInputValue(dateInput.start)
|
||||||
@@ -143,8 +213,8 @@ export function ExportDateRangeDialog({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDateInputError(prev => ({ ...prev, start: false }))
|
setDateInputError(prev => ({ ...prev, start: false }))
|
||||||
updateDraftStart(parsed)
|
setRangeStart(parsed)
|
||||||
}, [dateInput.start, updateDraftStart])
|
}, [dateInput.start, setRangeStart])
|
||||||
|
|
||||||
const commitEndFromInput = useCallback(() => {
|
const commitEndFromInput = useCallback(() => {
|
||||||
const parsed = parseDateInputValue(dateInput.end)
|
const parsed = parseDateInputValue(dateInput.end)
|
||||||
@@ -153,29 +223,81 @@ export function ExportDateRangeDialog({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDateInputError(prev => ({ ...prev, end: false }))
|
setDateInputError(prev => ({ ...prev, end: false }))
|
||||||
updateDraftEnd(parsed)
|
setRangeEnd(parsed)
|
||||||
}, [dateInput.end, updateDraftEnd])
|
}, [dateInput.end, setRangeEnd])
|
||||||
|
|
||||||
const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
|
const shiftPanelMonth = useCallback((delta: number) => {
|
||||||
setDraft(prev => (
|
setDraft(prev => ({
|
||||||
panel === 'start'
|
...prev,
|
||||||
? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) }
|
panelMonth: addMonths(prev.panelMonth, delta)
|
||||||
: { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) }
|
}))
|
||||||
))
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleCalendarSelect = useCallback((targetDate: Date) => {
|
||||||
|
if (activeBoundary === 'start') {
|
||||||
|
setRangeStart(targetDate)
|
||||||
|
setActiveBoundary('end')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraft(prev => {
|
||||||
|
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
||||||
|
const pickedStart = startOfDay(targetDate)
|
||||||
|
const nextStart = pickedStart <= start ? pickedStart : start
|
||||||
|
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
start: nextStart,
|
||||||
|
end: nextEnd
|
||||||
|
},
|
||||||
|
panelMonth: toMonthStart(targetDate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setActiveBoundary('start')
|
||||||
|
}, [activeBoundary, setRangeEnd, setRangeStart])
|
||||||
|
|
||||||
const isRangeModeActive = !draft.useAllTime
|
const isRangeModeActive = !draft.useAllTime
|
||||||
const modeText = isRangeModeActive
|
const modeText = isRangeModeActive
|
||||||
? '当前导出模式:按时间范围导出'
|
? '当前导出模式:按时间范围导出'
|
||||||
: '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)'
|
: '当前导出模式:全部时间导出,选择下方日期会切换为自定义时间范围'
|
||||||
|
|
||||||
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
|
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
|
||||||
if (preset === 'all') return draft.useAllTime
|
if (preset === 'all') return draft.useAllTime
|
||||||
return !draft.useAllTime && draft.preset === preset
|
return !draft.useAllTime && draft.preset === preset
|
||||||
}, [draft])
|
}, [draft])
|
||||||
|
|
||||||
const startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth])
|
const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth])
|
||||||
const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth])
|
const minPanelMonth = bounds ? toMonthStart(bounds.minDate) : null
|
||||||
|
const maxPanelMonth = bounds ? toMonthStart(bounds.maxDate) : null
|
||||||
|
const canShiftPrev = !minPanelMonth || draft.panelMonth.getTime() > minPanelMonth.getTime()
|
||||||
|
const canShiftNext = !maxPanelMonth || draft.panelMonth.getTime() < maxPanelMonth.getTime()
|
||||||
|
|
||||||
|
const isStartSelected = useCallback((date: Date) => (
|
||||||
|
!draft.useAllTime && isSameDay(date, draft.dateRange.start)
|
||||||
|
), [draft])
|
||||||
|
|
||||||
|
const isEndSelected = useCallback((date: Date) => (
|
||||||
|
!draft.useAllTime && isSameDay(date, draft.dateRange.end)
|
||||||
|
), [draft])
|
||||||
|
|
||||||
|
const isDateInRange = useCallback((date: Date) => (
|
||||||
|
!draft.useAllTime &&
|
||||||
|
startOfDay(date).getTime() >= startOfDay(draft.dateRange.start).getTime() &&
|
||||||
|
startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime()
|
||||||
|
), [draft])
|
||||||
|
|
||||||
|
const isDateSelectable = useCallback((date: Date) => {
|
||||||
|
if (!bounds) return true
|
||||||
|
const target = startOfDay(date).getTime()
|
||||||
|
return target >= startOfDay(bounds.minDate).getTime() && target <= startOfDay(bounds.maxDate).getTime()
|
||||||
|
}, [bounds])
|
||||||
|
|
||||||
|
const hintText = draft.useAllTime
|
||||||
|
? '选择开始或结束日期后,会自动切换为自定义时间范围'
|
||||||
|
: (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期')
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
@@ -215,112 +337,115 @@ export function ExportDateRangeDialog({
|
|||||||
{modeText}
|
{modeText}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="export-date-range-calendar-grid">
|
<div className="export-date-range-boundary-row">
|
||||||
<section className="export-date-range-calendar-panel">
|
<div
|
||||||
<div className="export-date-range-calendar-panel-header">
|
className={`export-date-range-boundary-card ${activeBoundary === 'start' ? 'active' : ''}`}
|
||||||
<div className="export-date-range-calendar-date-label">
|
onClick={() => setActiveBoundary('start')}
|
||||||
<span>起始日期</span>
|
>
|
||||||
<input
|
<span className="boundary-label">开始</span>
|
||||||
type="text"
|
<input
|
||||||
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
|
type="text"
|
||||||
value={dateInput.start}
|
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
|
||||||
placeholder="YYYY-MM-DD"
|
value={dateInput.start}
|
||||||
onChange={(event) => {
|
placeholder="YYYY-MM-DD"
|
||||||
const nextValue = event.target.value
|
onChange={(event) => {
|
||||||
setDateInput(prev => ({ ...prev, start: nextValue }))
|
const nextValue = event.target.value
|
||||||
if (dateInputError.start) {
|
setDateInput(prev => ({ ...prev, start: nextValue }))
|
||||||
setDateInputError(prev => ({ ...prev, start: false }))
|
if (dateInputError.start) {
|
||||||
}
|
setDateInputError(prev => ({ ...prev, start: false }))
|
||||||
}}
|
}
|
||||||
onKeyDown={(event) => {
|
}}
|
||||||
if (event.key !== 'Enter') return
|
onFocus={() => setActiveBoundary('start')}
|
||||||
event.preventDefault()
|
onClick={(event) => event.stopPropagation()}
|
||||||
commitStartFromInput()
|
onKeyDown={(event) => {
|
||||||
}}
|
if (event.key !== 'Enter') return
|
||||||
onBlur={commitStartFromInput}
|
event.preventDefault()
|
||||||
/>
|
commitStartFromInput()
|
||||||
</div>
|
}}
|
||||||
<div className="export-date-range-calendar-nav">
|
onBlur={commitStartFromInput}
|
||||||
<button type="button" onClick={() => shiftPanelMonth('start', -1)} aria-label="上个月">‹</button>
|
/>
|
||||||
<span>{formatCalendarMonthTitle(draft.startPanelMonth)}</span>
|
</div>
|
||||||
<button type="button" onClick={() => shiftPanelMonth('start', 1)} aria-label="下个月">›</button>
|
<div
|
||||||
</div>
|
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
||||||
</div>
|
onClick={() => setActiveBoundary('end')}
|
||||||
<div className="export-date-range-calendar-weekdays">
|
>
|
||||||
{WEEKDAY_SHORT_LABELS.map(label => (
|
<span className="boundary-label">结束</span>
|
||||||
<span key={`start-weekday-${label}`}>{label}</span>
|
<input
|
||||||
))}
|
type="text"
|
||||||
</div>
|
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
|
||||||
<div className="export-date-range-calendar-days">
|
value={dateInput.end}
|
||||||
{startPanelCells.map((cell) => {
|
placeholder="YYYY-MM-DD"
|
||||||
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start)
|
onChange={(event) => {
|
||||||
return (
|
const nextValue = event.target.value
|
||||||
<button
|
setDateInput(prev => ({ ...prev, end: nextValue }))
|
||||||
key={`start-${cell.date.getTime()}`}
|
if (dateInputError.end) {
|
||||||
type="button"
|
setDateInputError(prev => ({ ...prev, end: false }))
|
||||||
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
|
}
|
||||||
onClick={() => updateDraftStart(cell.date)}
|
}}
|
||||||
>
|
onFocus={() => setActiveBoundary('end')}
|
||||||
{cell.date.getDate()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
</button>
|
onKeyDown={(event) => {
|
||||||
)
|
if (event.key !== 'Enter') return
|
||||||
})}
|
event.preventDefault()
|
||||||
</div>
|
commitEndFromInput()
|
||||||
</section>
|
}}
|
||||||
|
onBlur={commitEndFromInput}
|
||||||
<section className="export-date-range-calendar-panel">
|
/>
|
||||||
<div className="export-date-range-calendar-panel-header">
|
</div>
|
||||||
<div className="export-date-range-calendar-date-label">
|
|
||||||
<span>截止日期</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
|
|
||||||
value={dateInput.end}
|
|
||||||
placeholder="YYYY-MM-DD"
|
|
||||||
onChange={(event) => {
|
|
||||||
const nextValue = event.target.value
|
|
||||||
setDateInput(prev => ({ ...prev, end: nextValue }))
|
|
||||||
if (dateInputError.end) {
|
|
||||||
setDateInputError(prev => ({ ...prev, end: false }))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key !== 'Enter') return
|
|
||||||
event.preventDefault()
|
|
||||||
commitEndFromInput()
|
|
||||||
}}
|
|
||||||
onBlur={commitEndFromInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="export-date-range-calendar-nav">
|
|
||||||
<button type="button" onClick={() => shiftPanelMonth('end', -1)} aria-label="上个月">‹</button>
|
|
||||||
<span>{formatCalendarMonthTitle(draft.endPanelMonth)}</span>
|
|
||||||
<button type="button" onClick={() => shiftPanelMonth('end', 1)} aria-label="下个月">›</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="export-date-range-calendar-weekdays">
|
|
||||||
{WEEKDAY_SHORT_LABELS.map(label => (
|
|
||||||
<span key={`end-weekday-${label}`}>{label}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="export-date-range-calendar-days">
|
|
||||||
{endPanelCells.map((cell) => {
|
|
||||||
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`end-${cell.date.getTime()}`}
|
|
||||||
type="button"
|
|
||||||
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
|
|
||||||
onClick={() => updateDraftEnd(cell.date)}
|
|
||||||
>
|
|
||||||
{cell.date.getDate()}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="export-date-range-selection-hint">{hintText}</div>
|
||||||
|
|
||||||
|
<section className="export-date-range-calendar-panel single">
|
||||||
|
<div className="export-date-range-calendar-panel-header">
|
||||||
|
<div className="export-date-range-calendar-date-label">
|
||||||
|
<span>选择日期范围</span>
|
||||||
|
<strong>{formatCalendarMonthTitle(draft.panelMonth)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-nav">
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth(-1)} aria-label="上个月" disabled={!canShiftPrev}>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth(1)} aria-label="下个月" disabled={!canShiftNext}>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-weekdays">
|
||||||
|
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||||
|
<span key={`weekday-${label}`}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-days">
|
||||||
|
{calendarCells.map((cell) => {
|
||||||
|
const startSelected = isStartSelected(cell.date)
|
||||||
|
const endSelected = isEndSelected(cell.date)
|
||||||
|
const inRange = isDateInRange(cell.date)
|
||||||
|
const selectable = isDateSelectable(cell.date)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cell.date.getTime()}
|
||||||
|
type="button"
|
||||||
|
disabled={!selectable}
|
||||||
|
className={[
|
||||||
|
'export-date-range-calendar-day',
|
||||||
|
cell.inCurrentMonth ? '' : 'outside',
|
||||||
|
selectable ? '' : 'disabled',
|
||||||
|
inRange ? 'in-range' : '',
|
||||||
|
startSelected ? 'range-start' : '',
|
||||||
|
endSelected ? 'range-end' : '',
|
||||||
|
activeBoundary === 'start' && startSelected ? 'active-boundary' : '',
|
||||||
|
activeBoundary === 'end' && endSelected ? 'active-boundary' : ''
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => handleCalendarSelect(cell.date)}
|
||||||
|
>
|
||||||
|
{cell.date.getDate()}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="export-date-range-dialog-actions">
|
<div className="export-date-range-dialog-actions">
|
||||||
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
|
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
|
||||||
取消
|
取消
|
||||||
|
|||||||
@@ -585,6 +585,263 @@ interface GroupPanelMember {
|
|||||||
messageCountStatus: GroupMessageCountStatus
|
messageCountStatus: GroupMessageCountStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QUOTED_SENDER_CACHE_TTL_MS = 10 * 60 * 1000
|
||||||
|
const quotedSenderDisplayCache = new Map<string, { displayName: string; updatedAt: number }>()
|
||||||
|
const quotedSenderDisplayLoading = new Map<string, Promise<string | undefined>>()
|
||||||
|
const quotedGroupMembersCache = new Map<string, { members: GroupPanelMember[]; updatedAt: number }>()
|
||||||
|
const quotedGroupMembersLoading = new Map<string, Promise<GroupPanelMember[]>>()
|
||||||
|
|
||||||
|
function buildQuotedSenderCacheKey(
|
||||||
|
sessionId: string,
|
||||||
|
senderUsername: string,
|
||||||
|
isGroupChat: boolean
|
||||||
|
): string {
|
||||||
|
const normalizedSessionId = normalizeSearchIdentityText(sessionId) || String(sessionId || '').trim()
|
||||||
|
const normalizedSender = normalizeSearchIdentityText(senderUsername) || String(senderUsername || '').trim()
|
||||||
|
return `${isGroupChat ? 'group' : 'direct'}::${normalizedSessionId}::${normalizedSender}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameQuotedSenderIdentity(left?: string | null, right?: string | null): boolean {
|
||||||
|
const leftCandidates = buildSearchIdentityCandidates(left)
|
||||||
|
const rightCandidates = buildSearchIdentityCandidates(right)
|
||||||
|
if (leftCandidates.length === 0 || rightCandidates.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const leftCandidate of leftCandidates) {
|
||||||
|
for (const rightCandidate of rightCandidates) {
|
||||||
|
if (leftCandidate === rightCandidate) return true
|
||||||
|
if (leftCandidate.startsWith(rightCandidate + '_')) return true
|
||||||
|
if (rightCandidate.startsWith(leftCandidate + '_')) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuotedGroupMember(member: Partial<GroupPanelMember> | null | undefined): GroupPanelMember | null {
|
||||||
|
const username = String(member?.username || '').trim()
|
||||||
|
if (!username) return null
|
||||||
|
|
||||||
|
const displayName = String(member?.displayName || '').trim()
|
||||||
|
const nickname = String(member?.nickname || '').trim()
|
||||||
|
const remark = String(member?.remark || '').trim()
|
||||||
|
const alias = String(member?.alias || '').trim()
|
||||||
|
const groupNickname = String(member?.groupNickname || '').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
displayName: displayName || groupNickname || remark || nickname || alias || username,
|
||||||
|
avatarUrl: member?.avatarUrl,
|
||||||
|
nickname,
|
||||||
|
alias,
|
||||||
|
remark,
|
||||||
|
groupNickname,
|
||||||
|
isOwner: Boolean(member?.isOwner),
|
||||||
|
isFriend: Boolean(member?.isFriend),
|
||||||
|
messageCount: Number.isFinite(member?.messageCount) ? Math.max(0, Math.floor(member?.messageCount as number)) : 0,
|
||||||
|
messageCountStatus: 'ready'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuotedSenderFallbackDisplayName(
|
||||||
|
sessionId: string,
|
||||||
|
senderUsername?: string | null,
|
||||||
|
fallbackDisplayName?: string | null
|
||||||
|
): string | undefined {
|
||||||
|
const resolved = resolveSearchSenderDisplayName(fallbackDisplayName, senderUsername, sessionId)
|
||||||
|
if (resolved) return resolved
|
||||||
|
return resolveSearchSenderUsernameFallback(senderUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuotedSenderUsername(
|
||||||
|
fromusr?: string | null,
|
||||||
|
chatusr?: string | null
|
||||||
|
): string {
|
||||||
|
const normalizedChatUsr = String(chatusr || '').trim()
|
||||||
|
const normalizedFromUsr = String(fromusr || '').trim()
|
||||||
|
|
||||||
|
if (normalizedChatUsr) {
|
||||||
|
return normalizedChatUsr
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedFromUsr.endsWith('@chatroom')) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedFromUsr
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuotedGroupMemberDisplayName(member: GroupPanelMember): string | undefined {
|
||||||
|
const remark = normalizeSearchIdentityText(member.remark)
|
||||||
|
if (remark) return remark
|
||||||
|
|
||||||
|
const groupNickname = normalizeSearchIdentityText(member.groupNickname)
|
||||||
|
if (groupNickname) return groupNickname
|
||||||
|
|
||||||
|
const nickname = normalizeSearchIdentityText(member.nickname)
|
||||||
|
if (nickname) return nickname
|
||||||
|
|
||||||
|
const displayName = resolveSearchSenderDisplayName(member.displayName, member.username)
|
||||||
|
if (displayName) return displayName
|
||||||
|
|
||||||
|
const alias = normalizeSearchIdentityText(member.alias)
|
||||||
|
if (alias) return alias
|
||||||
|
|
||||||
|
return resolveSearchSenderUsernameFallback(member.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuotedPrivateDisplayName(contact: any): string | undefined {
|
||||||
|
const remark = normalizeSearchIdentityText(contact?.remark)
|
||||||
|
if (remark) return remark
|
||||||
|
|
||||||
|
const nickname = normalizeSearchIdentityText(
|
||||||
|
contact?.nickName || contact?.nick_name || contact?.nickname
|
||||||
|
)
|
||||||
|
if (nickname) return nickname
|
||||||
|
|
||||||
|
const alias = normalizeSearchIdentityText(contact?.alias)
|
||||||
|
if (alias) return alias
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getQuotedGroupMembers(chatroomId: string): Promise<GroupPanelMember[]> {
|
||||||
|
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||||
|
if (!normalizedChatroomId || !normalizedChatroomId.includes('@chatroom')) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = quotedGroupMembersCache.get(normalizedChatroomId)
|
||||||
|
if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) {
|
||||||
|
return cached.members
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = quotedGroupMembersLoading.get(normalizedChatroomId)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const request = window.electronAPI.groupAnalytics.getGroupMembersPanelData(
|
||||||
|
normalizedChatroomId,
|
||||||
|
{ forceRefresh: false, includeMessageCounts: false }
|
||||||
|
).then((result) => {
|
||||||
|
const members = Array.isArray(result.data)
|
||||||
|
? result.data
|
||||||
|
.map((member) => normalizeQuotedGroupMember(member as Partial<GroupPanelMember>))
|
||||||
|
.filter((member): member is GroupPanelMember => Boolean(member))
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (members.length > 0) {
|
||||||
|
quotedGroupMembersCache.set(normalizedChatroomId, {
|
||||||
|
members,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
return members
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached?.members || []
|
||||||
|
}).catch(() => cached?.members || []).finally(() => {
|
||||||
|
quotedGroupMembersLoading.delete(normalizedChatroomId)
|
||||||
|
})
|
||||||
|
|
||||||
|
quotedGroupMembersLoading.set(normalizedChatroomId, request)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveQuotedSenderDisplayName(options: {
|
||||||
|
sessionId: string
|
||||||
|
senderUsername?: string | null
|
||||||
|
fallbackDisplayName?: string | null
|
||||||
|
isGroupChat?: boolean
|
||||||
|
myWxid?: string | null
|
||||||
|
}): Promise<string | undefined> {
|
||||||
|
const normalizedSessionId = String(options.sessionId || '').trim()
|
||||||
|
const normalizedSender = String(options.senderUsername || '').trim()
|
||||||
|
const fallbackDisplayName = resolveQuotedSenderFallbackDisplayName(
|
||||||
|
normalizedSessionId,
|
||||||
|
normalizedSender,
|
||||||
|
options.fallbackDisplayName
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!normalizedSender) {
|
||||||
|
return fallbackDisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = buildQuotedSenderCacheKey(normalizedSessionId, normalizedSender, Boolean(options.isGroupChat))
|
||||||
|
const cached = quotedSenderDisplayCache.get(cacheKey)
|
||||||
|
if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) {
|
||||||
|
return cached.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = quotedSenderDisplayLoading.get(cacheKey)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const request = (async (): Promise<string | undefined> => {
|
||||||
|
if (options.isGroupChat) {
|
||||||
|
const members = await getQuotedGroupMembers(normalizedSessionId)
|
||||||
|
const matchedMember = members.find((member) => isSameQuotedSenderIdentity(member.username, normalizedSender))
|
||||||
|
const groupDisplayName = matchedMember ? resolveQuotedGroupMemberDisplayName(matchedMember) : undefined
|
||||||
|
if (groupDisplayName) {
|
||||||
|
quotedSenderDisplayCache.set(cacheKey, {
|
||||||
|
displayName: groupDisplayName,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
return groupDisplayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCurrentUserSearchIdentity(normalizedSender, options.myWxid)) {
|
||||||
|
const selfDisplayName = fallbackDisplayName || '我'
|
||||||
|
quotedSenderDisplayCache.set(cacheKey, {
|
||||||
|
displayName: selfDisplayName,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
return selfDisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contact = await window.electronAPI.chat.getContact(normalizedSender)
|
||||||
|
const contactDisplayName = resolveQuotedPrivateDisplayName(contact)
|
||||||
|
if (contactDisplayName) {
|
||||||
|
quotedSenderDisplayCache.set(cacheKey, {
|
||||||
|
displayName: contactDisplayName,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
return contactDisplayName
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore contact lookup failures and fall back below
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await window.electronAPI.chat.getContactAvatar(normalizedSender)
|
||||||
|
const profileDisplayName = normalizeSearchIdentityText(profile?.displayName)
|
||||||
|
if (profileDisplayName && !isWxidLikeSearchIdentity(profileDisplayName)) {
|
||||||
|
quotedSenderDisplayCache.set(cacheKey, {
|
||||||
|
displayName: profileDisplayName,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
return profileDisplayName
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore avatar lookup failures and keep fallback usable
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackDisplayName) {
|
||||||
|
quotedSenderDisplayCache.set(cacheKey, {
|
||||||
|
displayName: fallbackDisplayName,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackDisplayName
|
||||||
|
})().finally(() => {
|
||||||
|
quotedSenderDisplayLoading.delete(cacheKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
quotedSenderDisplayLoading.set(cacheKey, request)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionListCachePayload {
|
interface SessionListCachePayload {
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
sessions: ChatSession[]
|
sessions: ChatSession[]
|
||||||
@@ -2394,6 +2651,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const handleAccountChanged = useCallback(async () => {
|
const handleAccountChanged = useCallback(async () => {
|
||||||
senderAvatarCache.clear()
|
senderAvatarCache.clear()
|
||||||
senderAvatarLoading.clear()
|
senderAvatarLoading.clear()
|
||||||
|
quotedSenderDisplayCache.clear()
|
||||||
|
quotedSenderDisplayLoading.clear()
|
||||||
|
quotedGroupMembersCache.clear()
|
||||||
|
quotedGroupMembersLoading.clear()
|
||||||
sessionContactProfileCacheRef.current.clear()
|
sessionContactProfileCacheRef.current.clear()
|
||||||
pendingSessionContactEnrichRef.current.clear()
|
pendingSessionContactEnrichRef.current.clear()
|
||||||
sessionContactEnrichAttemptAtRef.current.clear()
|
sessionContactEnrichAttemptAtRef.current.clear()
|
||||||
@@ -5660,6 +5921,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
session={currentSession!}
|
session={currentSession!}
|
||||||
showTime={!showDateDivider && showTime}
|
showTime={!showDateDivider && showTime}
|
||||||
myAvatarUrl={myAvatarUrl}
|
myAvatarUrl={myAvatarUrl}
|
||||||
|
myWxid={myWxid}
|
||||||
isGroupChat={isCurrentSessionGroup}
|
isGroupChat={isCurrentSessionGroup}
|
||||||
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
|
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
|
||||||
onRequireModelDownload={handleRequireModelDownload}
|
onRequireModelDownload={handleRequireModelDownload}
|
||||||
@@ -5678,6 +5940,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
formatDateDivider,
|
formatDateDivider,
|
||||||
currentSession,
|
currentSession,
|
||||||
myAvatarUrl,
|
myAvatarUrl,
|
||||||
|
myWxid,
|
||||||
isCurrentSessionGroup,
|
isCurrentSessionGroup,
|
||||||
autoTranscribeVoiceEnabled,
|
autoTranscribeVoiceEnabled,
|
||||||
handleRequireModelDownload,
|
handleRequireModelDownload,
|
||||||
@@ -7258,6 +7521,7 @@ function MessageBubble({
|
|||||||
session,
|
session,
|
||||||
showTime,
|
showTime,
|
||||||
myAvatarUrl,
|
myAvatarUrl,
|
||||||
|
myWxid,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
autoTranscribeVoiceEnabled,
|
autoTranscribeVoiceEnabled,
|
||||||
onRequireModelDownload,
|
onRequireModelDownload,
|
||||||
@@ -7271,6 +7535,7 @@ function MessageBubble({
|
|||||||
session: ChatSession;
|
session: ChatSession;
|
||||||
showTime?: boolean;
|
showTime?: boolean;
|
||||||
myAvatarUrl?: string;
|
myAvatarUrl?: string;
|
||||||
|
myWxid?: string;
|
||||||
isGroupChat?: boolean;
|
isGroupChat?: boolean;
|
||||||
autoTranscribeVoiceEnabled?: boolean;
|
autoTranscribeVoiceEnabled?: boolean;
|
||||||
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
|
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
|
||||||
@@ -7290,6 +7555,7 @@ function MessageBubble({
|
|||||||
const isSent = message.isSend === 1
|
const isSent = message.isSend === 1
|
||||||
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
|
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
|
||||||
const [senderName, setSenderName] = useState<string | undefined>(undefined)
|
const [senderName, setSenderName] = useState<string | undefined>(undefined)
|
||||||
|
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
|
||||||
const senderProfileRequestSeqRef = useRef(0)
|
const senderProfileRequestSeqRef = useRef(0)
|
||||||
const [emojiError, setEmojiError] = useState(false)
|
const [emojiError, setEmojiError] = useState(false)
|
||||||
const [emojiLoading, setEmojiLoading] = useState(false)
|
const [emojiLoading, setEmojiLoading] = useState(false)
|
||||||
@@ -8214,6 +8480,53 @@ function MessageBubble({
|
|||||||
appMsgTextCache.set(selector, value)
|
appMsgTextCache.set(selector, value)
|
||||||
return value
|
return value
|
||||||
}, [appMsgDoc, appMsgTextCache])
|
}, [appMsgDoc, appMsgTextCache])
|
||||||
|
const quotedSenderUsername = resolveQuotedSenderUsername(
|
||||||
|
queryAppMsgText('refermsg > fromusr'),
|
||||||
|
queryAppMsgText('refermsg > chatusr')
|
||||||
|
)
|
||||||
|
const quotedContent = message.quotedContent || queryAppMsgText('refermsg > content') || ''
|
||||||
|
const quotedSenderFallbackName = useMemo(
|
||||||
|
() => resolveQuotedSenderFallbackDisplayName(
|
||||||
|
session.username,
|
||||||
|
quotedSenderUsername,
|
||||||
|
message.quotedSender || queryAppMsgText('refermsg > displayname') || ''
|
||||||
|
),
|
||||||
|
[message.quotedSender, queryAppMsgText, quotedSenderUsername, session.username]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const nextFallbackName = quotedSenderFallbackName || undefined
|
||||||
|
setQuotedSenderName(nextFallbackName)
|
||||||
|
|
||||||
|
if (!quotedContent || !quotedSenderUsername) {
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void resolveQuotedSenderDisplayName({
|
||||||
|
sessionId: session.username,
|
||||||
|
senderUsername: quotedSenderUsername,
|
||||||
|
fallbackDisplayName: nextFallbackName,
|
||||||
|
isGroupChat,
|
||||||
|
myWxid
|
||||||
|
}).then((resolvedName) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setQuotedSenderName(resolvedName || nextFallbackName)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
quotedContent,
|
||||||
|
quotedSenderFallbackName,
|
||||||
|
quotedSenderUsername,
|
||||||
|
session.username,
|
||||||
|
isGroupChat,
|
||||||
|
myWxid
|
||||||
|
])
|
||||||
|
|
||||||
const locationMessageMeta = useMemo(() => {
|
const locationMessageMeta = useMemo(() => {
|
||||||
if (message.localType !== 48) return null
|
if (message.localType !== 48) return null
|
||||||
@@ -8248,7 +8561,8 @@ function MessageBubble({
|
|||||||
: (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl)
|
: (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl)
|
||||||
|
|
||||||
// 是否有引用消息
|
// 是否有引用消息
|
||||||
const hasQuote = message.quotedContent && message.quotedContent.length > 0
|
const hasQuote = quotedContent.length > 0
|
||||||
|
const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName
|
||||||
|
|
||||||
const handlePlayVideo = useCallback(async () => {
|
const handlePlayVideo = useCallback(async () => {
|
||||||
if (!videoInfo?.videoUrl) return
|
if (!videoInfo?.videoUrl) return
|
||||||
@@ -8659,7 +8973,6 @@ function MessageBubble({
|
|||||||
if (xmlType === '57') {
|
if (xmlType === '57') {
|
||||||
const replyText = q('title') || cleanedParsedContent || ''
|
const replyText = q('title') || cleanedParsedContent || ''
|
||||||
const referContent = q('refermsg > content') || ''
|
const referContent = q('refermsg > content') || ''
|
||||||
const referSender = q('refermsg > displayname') || ''
|
|
||||||
const referType = q('refermsg > type') || ''
|
const referType = q('refermsg > type') || ''
|
||||||
|
|
||||||
// 根据被引用消息类型渲染对应内容
|
// 根据被引用消息类型渲染对应内容
|
||||||
@@ -8691,7 +9004,7 @@ function MessageBubble({
|
|||||||
return (
|
return (
|
||||||
<div className="bubble-content">
|
<div className="bubble-content">
|
||||||
<div className="quoted-message">
|
<div className="quoted-message">
|
||||||
{referSender && <span className="quoted-sender">{referSender}</span>}
|
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
|
||||||
<span className="quoted-text">{renderReferContent()}</span>
|
<span className="quoted-text">{renderReferContent()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||||
@@ -8787,11 +9100,10 @@ function MessageBubble({
|
|||||||
// 引用回复消息(appMsgKind='quote',xmlType=57)
|
// 引用回复消息(appMsgKind='quote',xmlType=57)
|
||||||
const replyText = message.linkTitle || q('title') || cleanedParsedContent || ''
|
const replyText = message.linkTitle || q('title') || cleanedParsedContent || ''
|
||||||
const referContent = message.quotedContent || q('refermsg > content') || ''
|
const referContent = message.quotedContent || q('refermsg > content') || ''
|
||||||
const referSender = message.quotedSender || q('refermsg > displayname') || ''
|
|
||||||
return (
|
return (
|
||||||
<div className="bubble-content">
|
<div className="bubble-content">
|
||||||
<div className="quoted-message">
|
<div className="quoted-message">
|
||||||
{referSender && <span className="quoted-sender">{referSender}</span>}
|
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
|
||||||
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
|
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||||
@@ -8982,7 +9294,6 @@ function MessageBubble({
|
|||||||
if (appMsgType === '57') {
|
if (appMsgType === '57') {
|
||||||
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || ''
|
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || ''
|
||||||
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
|
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
|
||||||
const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || ''
|
|
||||||
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
|
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
|
||||||
|
|
||||||
const renderReferContent2 = () => {
|
const renderReferContent2 = () => {
|
||||||
@@ -9008,7 +9319,7 @@ function MessageBubble({
|
|||||||
return (
|
return (
|
||||||
<div className="bubble-content">
|
<div className="bubble-content">
|
||||||
<div className="quoted-message">
|
<div className="quoted-message">
|
||||||
{referSender && <span className="quoted-sender">{referSender}</span>}
|
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
|
||||||
<span className="quoted-text">{renderReferContent2()}</span>
|
<span className="quoted-text">{renderReferContent2()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||||
@@ -9294,8 +9605,8 @@ function MessageBubble({
|
|||||||
return (
|
return (
|
||||||
<div className="bubble-content">
|
<div className="bubble-content">
|
||||||
<div className="quoted-message">
|
<div className="quoted-message">
|
||||||
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
|
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
|
||||||
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}</span>
|
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(quotedContent))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="message-text">{renderTextWithEmoji(cleanedParsedContent)}</div>
|
<div className="message-text">{renderTextWithEmoji(cleanedParsedContent)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -9398,6 +9709,7 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
|
|||||||
if (prevProps.messageKey !== nextProps.messageKey) return false
|
if (prevProps.messageKey !== nextProps.messageKey) return false
|
||||||
if (prevProps.showTime !== nextProps.showTime) return false
|
if (prevProps.showTime !== nextProps.showTime) return false
|
||||||
if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false
|
if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false
|
||||||
|
if (prevProps.myWxid !== nextProps.myWxid) return false
|
||||||
if (prevProps.isGroupChat !== nextProps.isGroupChat) return false
|
if (prevProps.isGroupChat !== nextProps.isGroupChat) return false
|
||||||
if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false
|
if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false
|
||||||
if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false
|
if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false
|
||||||
|
|||||||
@@ -52,10 +52,13 @@ import { ExportDefaultsSettingsForm, type ExportDefaultsSettingsPatch } from '..
|
|||||||
import type { SnsPost } from '../types/sns'
|
import type { SnsPost } from '../types/sns'
|
||||||
import {
|
import {
|
||||||
cloneExportDateRange,
|
cloneExportDateRange,
|
||||||
|
cloneExportDateRangeSelection,
|
||||||
createDefaultDateRange,
|
createDefaultDateRange,
|
||||||
createDefaultExportDateRangeSelection,
|
createDefaultExportDateRangeSelection,
|
||||||
getExportDateRangeLabel,
|
getExportDateRangeLabel,
|
||||||
resolveExportDateRangeConfig,
|
resolveExportDateRangeConfig,
|
||||||
|
startOfDay,
|
||||||
|
endOfDay,
|
||||||
type ExportDateRangeSelection
|
type ExportDateRangeSelection
|
||||||
} from '../utils/exportDateRange'
|
} from '../utils/exportDateRange'
|
||||||
import './ExportPage.scss'
|
import './ExportPage.scss'
|
||||||
@@ -830,6 +833,13 @@ interface SessionContentMetric {
|
|||||||
transferMessages?: number
|
transferMessages?: number
|
||||||
redPacketMessages?: number
|
redPacketMessages?: number
|
||||||
callMessages?: number
|
callMessages?: number
|
||||||
|
firstTimestamp?: number
|
||||||
|
lastTimestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeRangeBounds {
|
||||||
|
minDate: Date
|
||||||
|
maxDate: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionExportCacheMeta {
|
interface SessionExportCacheMeta {
|
||||||
@@ -1049,27 +1059,74 @@ const normalizeMessageCount = (value: unknown): number | undefined => {
|
|||||||
return Math.floor(parsed)
|
return Math.floor(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeTimestampSeconds = (value: unknown): number | undefined => {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
|
||||||
|
return Math.floor(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampExportSelectionToBounds = (
|
||||||
|
selection: ExportDateRangeSelection,
|
||||||
|
bounds: TimeRangeBounds | null
|
||||||
|
): 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()
|
||||||
|
|
||||||
|
return {
|
||||||
|
preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset),
|
||||||
|
useAllTime: selection.useAllTime,
|
||||||
|
dateRange: {
|
||||||
|
start: nextStart,
|
||||||
|
end: nextEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const areExportSelectionsEqual = (left: ExportDateRangeSelection, right: ExportDateRangeSelection): boolean => (
|
||||||
|
left.preset === right.preset &&
|
||||||
|
left.useAllTime === right.useAllTime &&
|
||||||
|
left.dateRange.start.getTime() === right.dateRange.start.getTime() &&
|
||||||
|
left.dateRange.end.getTime() === right.dateRange.end.getTime()
|
||||||
|
)
|
||||||
|
|
||||||
const pickSessionMediaMetric = (
|
const pickSessionMediaMetric = (
|
||||||
metricRaw: SessionExportMetric | SessionContentMetric | undefined
|
metricRaw: SessionExportMetric | SessionContentMetric | undefined
|
||||||
): SessionContentMetric | null => {
|
): SessionContentMetric | null => {
|
||||||
if (!metricRaw) return null
|
if (!metricRaw) return null
|
||||||
|
const totalMessages = normalizeMessageCount(metricRaw.totalMessages)
|
||||||
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
|
const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages)
|
||||||
const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
|
const imageMessages = normalizeMessageCount(metricRaw.imageMessages)
|
||||||
const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
|
const videoMessages = normalizeMessageCount(metricRaw.videoMessages)
|
||||||
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
|
const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages)
|
||||||
|
const firstTimestamp = normalizeTimestampSeconds(metricRaw.firstTimestamp)
|
||||||
|
const lastTimestamp = normalizeTimestampSeconds(metricRaw.lastTimestamp)
|
||||||
if (
|
if (
|
||||||
|
typeof totalMessages !== 'number' &&
|
||||||
typeof voiceMessages !== 'number' &&
|
typeof voiceMessages !== 'number' &&
|
||||||
typeof imageMessages !== 'number' &&
|
typeof imageMessages !== 'number' &&
|
||||||
typeof videoMessages !== 'number' &&
|
typeof videoMessages !== 'number' &&
|
||||||
typeof emojiMessages !== 'number'
|
typeof emojiMessages !== 'number' &&
|
||||||
|
typeof firstTimestamp !== 'number' &&
|
||||||
|
typeof lastTimestamp !== 'number'
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
totalMessages,
|
||||||
voiceMessages,
|
voiceMessages,
|
||||||
imageMessages,
|
imageMessages,
|
||||||
videoMessages,
|
videoMessages,
|
||||||
emojiMessages
|
emojiMessages,
|
||||||
|
firstTimestamp,
|
||||||
|
lastTimestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1520,6 +1577,8 @@ function ExportPage() {
|
|||||||
const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false)
|
const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false)
|
||||||
const [snsExportVideos, setSnsExportVideos] = useState(false)
|
const [snsExportVideos, setSnsExportVideos] = useState(false)
|
||||||
const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false)
|
const [isTimeRangeDialogOpen, setIsTimeRangeDialogOpen] = useState(false)
|
||||||
|
const [isResolvingTimeRangeBounds, setIsResolvingTimeRangeBounds] = useState(false)
|
||||||
|
const [timeRangeBounds, setTimeRangeBounds] = useState<TimeRangeBounds | null>(null)
|
||||||
const [isExportDefaultsModalOpen, setIsExportDefaultsModalOpen] = useState(false)
|
const [isExportDefaultsModalOpen, setIsExportDefaultsModalOpen] = useState(false)
|
||||||
const [timeRangeSelection, setTimeRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
const [timeRangeSelection, setTimeRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||||
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
|
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
|
||||||
@@ -2686,7 +2745,9 @@ function ExportPage() {
|
|||||||
typeof emojiMessages !== 'number' &&
|
typeof emojiMessages !== 'number' &&
|
||||||
typeof transferMessages !== 'number' &&
|
typeof transferMessages !== 'number' &&
|
||||||
typeof redPacketMessages !== 'number' &&
|
typeof redPacketMessages !== 'number' &&
|
||||||
typeof callMessages !== 'number'
|
typeof callMessages !== 'number' &&
|
||||||
|
typeof normalizeTimestampSeconds(metricRaw.firstTimestamp) !== 'number' &&
|
||||||
|
typeof normalizeTimestampSeconds(metricRaw.lastTimestamp) !== 'number'
|
||||||
) {
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -2699,7 +2760,9 @@ function ExportPage() {
|
|||||||
emojiMessages,
|
emojiMessages,
|
||||||
transferMessages,
|
transferMessages,
|
||||||
redPacketMessages,
|
redPacketMessages,
|
||||||
callMessages
|
callMessages,
|
||||||
|
firstTimestamp: normalizeTimestampSeconds(metricRaw.firstTimestamp),
|
||||||
|
lastTimestamp: normalizeTimestampSeconds(metricRaw.lastTimestamp)
|
||||||
}
|
}
|
||||||
if (typeof totalMessages === 'number') {
|
if (typeof totalMessages === 'number') {
|
||||||
nextMessageCounts[sessionId] = totalMessages
|
nextMessageCounts[sessionId] = totalMessages
|
||||||
@@ -2733,7 +2796,9 @@ function ExportPage() {
|
|||||||
emojiMessages: typeof metric.emojiMessages === 'number' ? metric.emojiMessages : previous.emojiMessages,
|
emojiMessages: typeof metric.emojiMessages === 'number' ? metric.emojiMessages : previous.emojiMessages,
|
||||||
transferMessages: typeof metric.transferMessages === 'number' ? metric.transferMessages : previous.transferMessages,
|
transferMessages: typeof metric.transferMessages === 'number' ? metric.transferMessages : previous.transferMessages,
|
||||||
redPacketMessages: typeof metric.redPacketMessages === 'number' ? metric.redPacketMessages : previous.redPacketMessages,
|
redPacketMessages: typeof metric.redPacketMessages === 'number' ? metric.redPacketMessages : previous.redPacketMessages,
|
||||||
callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages
|
callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages,
|
||||||
|
firstTimestamp: typeof metric.firstTimestamp === 'number' ? metric.firstTimestamp : previous.firstTimestamp,
|
||||||
|
lastTimestamp: typeof metric.lastTimestamp === 'number' ? metric.lastTimestamp : previous.lastTimestamp
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
previous.totalMessages === nextMetric.totalMessages &&
|
previous.totalMessages === nextMetric.totalMessages &&
|
||||||
@@ -2743,7 +2808,9 @@ function ExportPage() {
|
|||||||
previous.emojiMessages === nextMetric.emojiMessages &&
|
previous.emojiMessages === nextMetric.emojiMessages &&
|
||||||
previous.transferMessages === nextMetric.transferMessages &&
|
previous.transferMessages === nextMetric.transferMessages &&
|
||||||
previous.redPacketMessages === nextMetric.redPacketMessages &&
|
previous.redPacketMessages === nextMetric.redPacketMessages &&
|
||||||
previous.callMessages === nextMetric.callMessages
|
previous.callMessages === nextMetric.callMessages &&
|
||||||
|
previous.firstTimestamp === nextMetric.firstTimestamp &&
|
||||||
|
previous.lastTimestamp === nextMetric.lastTimestamp
|
||||||
) {
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -3898,6 +3965,7 @@ function ExportPage() {
|
|||||||
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
|
const openExportDialog = useCallback((payload: Omit<ExportDialogState, 'open'>) => {
|
||||||
setExportDialog({ open: true, ...payload })
|
setExportDialog({ open: true, ...payload })
|
||||||
setIsTimeRangeDialogOpen(false)
|
setIsTimeRangeDialogOpen(false)
|
||||||
|
setTimeRangeBounds(null)
|
||||||
setTimeRangeSelection(exportDefaultDateRangeSelection)
|
setTimeRangeSelection(exportDefaultDateRangeSelection)
|
||||||
|
|
||||||
setOptions(prev => {
|
setOptions(prev => {
|
||||||
@@ -3960,11 +4028,143 @@ function ExportPage() {
|
|||||||
const closeExportDialog = useCallback(() => {
|
const closeExportDialog = useCallback(() => {
|
||||||
setExportDialog(prev => ({ ...prev, open: false }))
|
setExportDialog(prev => ({ ...prev, open: false }))
|
||||||
setIsTimeRangeDialogOpen(false)
|
setIsTimeRangeDialogOpen(false)
|
||||||
|
setTimeRangeBounds(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const resolveChatExportTimeRangeBounds = useCallback(async (sessionIds: string[]): Promise<TimeRangeBounds | null> => {
|
||||||
|
const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean)))
|
||||||
|
if (normalizedSessionIds.length === 0) return null
|
||||||
|
|
||||||
|
const sessionRowMap = new Map<string, SessionRow>()
|
||||||
|
for (const session of sessions) {
|
||||||
|
sessionRowMap.set(session.username, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
let minTimestamp: number | undefined
|
||||||
|
let maxTimestamp: number | undefined
|
||||||
|
const resolvedSessionBounds = new Map<string, { hasMin: boolean; hasMax: boolean }>()
|
||||||
|
|
||||||
|
const absorbMetric = (sessionId: string, metric?: { firstTimestamp?: number; lastTimestamp?: number } | null) => {
|
||||||
|
if (!metric) return
|
||||||
|
const firstTimestamp = normalizeTimestampSeconds(metric.firstTimestamp)
|
||||||
|
const lastTimestamp = normalizeTimestampSeconds(metric.lastTimestamp)
|
||||||
|
if (typeof firstTimestamp !== 'number' && typeof lastTimestamp !== 'number') return
|
||||||
|
|
||||||
|
const previous = resolvedSessionBounds.get(sessionId) || { hasMin: false, hasMax: false }
|
||||||
|
const nextState = {
|
||||||
|
hasMin: previous.hasMin || typeof firstTimestamp === 'number',
|
||||||
|
hasMax: previous.hasMax || typeof lastTimestamp === 'number'
|
||||||
|
}
|
||||||
|
resolvedSessionBounds.set(sessionId, nextState)
|
||||||
|
|
||||||
|
if (typeof firstTimestamp === 'number' && (minTimestamp === undefined || firstTimestamp < minTimestamp)) {
|
||||||
|
minTimestamp = firstTimestamp
|
||||||
|
}
|
||||||
|
if (typeof lastTimestamp === 'number' && (maxTimestamp === undefined || lastTimestamp > maxTimestamp)) {
|
||||||
|
maxTimestamp = lastTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sessionId of normalizedSessionIds) {
|
||||||
|
const sessionRow = sessionRowMap.get(sessionId)
|
||||||
|
absorbMetric(sessionId, {
|
||||||
|
firstTimestamp: undefined,
|
||||||
|
lastTimestamp: sessionRow?.sortTimestamp || sessionRow?.lastTimestamp
|
||||||
|
})
|
||||||
|
absorbMetric(sessionId, sessionContentMetrics[sessionId])
|
||||||
|
if (sessionDetail?.wxid === sessionId) {
|
||||||
|
absorbMetric(sessionId, {
|
||||||
|
firstTimestamp: sessionDetail.firstMessageTime,
|
||||||
|
lastTimestamp: sessionDetail.latestMessageTime
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyStatsResult = (result?: {
|
||||||
|
success: boolean
|
||||||
|
data?: Record<string, SessionExportMetric>
|
||||||
|
} | null) => {
|
||||||
|
if (!result?.success || !result.data) return
|
||||||
|
applySessionMediaMetricsFromStats(result.data)
|
||||||
|
for (const sessionId of normalizedSessionIds) {
|
||||||
|
absorbMetric(sessionId, result.data[sessionId])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingSessionIds = () => normalizedSessionIds.filter(sessionId => {
|
||||||
|
const resolved = resolvedSessionBounds.get(sessionId)
|
||||||
|
return !resolved?.hasMin || !resolved?.hasMax
|
||||||
|
})
|
||||||
|
|
||||||
|
const staleSessionIds = new Set<string>()
|
||||||
|
|
||||||
|
if (missingSessionIds().length > 0) {
|
||||||
|
const cacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
missingSessionIds(),
|
||||||
|
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||||
|
)
|
||||||
|
applyStatsResult(cacheResult)
|
||||||
|
for (const sessionId of cacheResult?.needsRefresh || []) {
|
||||||
|
staleSessionIds.add(String(sessionId || '').trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionsNeedingFreshStats = Array.from(new Set([
|
||||||
|
...missingSessionIds(),
|
||||||
|
...Array.from(staleSessionIds).filter(Boolean)
|
||||||
|
]))
|
||||||
|
|
||||||
|
if (sessionsNeedingFreshStats.length > 0) {
|
||||||
|
applyStatsResult(await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
sessionsNeedingFreshStats,
|
||||||
|
{ includeRelations: false }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingSessionIds().length > 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof minTimestamp !== 'number' || typeof maxTimestamp !== 'number') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minDate: new Date(minTimestamp * 1000),
|
||||||
|
maxDate: new Date(maxTimestamp * 1000)
|
||||||
|
}
|
||||||
|
}, [applySessionMediaMetricsFromStats, sessionContentMetrics, sessionDetail, sessions])
|
||||||
|
|
||||||
const openTimeRangeDialog = useCallback(() => {
|
const openTimeRangeDialog = useCallback(() => {
|
||||||
setIsTimeRangeDialogOpen(true)
|
void (async () => {
|
||||||
}, [])
|
if (isResolvingTimeRangeBounds) return
|
||||||
|
setIsResolvingTimeRangeBounds(true)
|
||||||
|
try {
|
||||||
|
let nextBounds: TimeRangeBounds | null = null
|
||||||
|
if (exportDialog.scope !== 'sns') {
|
||||||
|
nextBounds = await resolveChatExportTimeRangeBounds(exportDialog.sessionIds)
|
||||||
|
}
|
||||||
|
setTimeRangeBounds(nextBounds)
|
||||||
|
if (nextBounds) {
|
||||||
|
const nextSelection = clampExportSelectionToBounds(timeRangeSelection, nextBounds)
|
||||||
|
if (!areExportSelectionsEqual(nextSelection, timeRangeSelection)) {
|
||||||
|
setTimeRangeSelection(nextSelection)
|
||||||
|
setOptions(prev => ({
|
||||||
|
...prev,
|
||||||
|
useAllTime: nextSelection.useAllTime,
|
||||||
|
dateRange: cloneExportDateRange(nextSelection.dateRange)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsTimeRangeDialogOpen(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出页解析时间范围边界失败', error)
|
||||||
|
setTimeRangeBounds(null)
|
||||||
|
setIsTimeRangeDialogOpen(true)
|
||||||
|
} finally {
|
||||||
|
setIsResolvingTimeRangeBounds(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [exportDialog.scope, exportDialog.sessionIds, isResolvingTimeRangeBounds, resolveChatExportTimeRangeBounds, timeRangeSelection])
|
||||||
|
|
||||||
const closeTimeRangeDialog = useCallback(() => {
|
const closeTimeRangeDialog = useCallback(() => {
|
||||||
setIsTimeRangeDialogOpen(false)
|
setIsTimeRangeDialogOpen(false)
|
||||||
@@ -7753,8 +7953,9 @@ function ExportPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="time-range-trigger"
|
className="time-range-trigger"
|
||||||
onClick={openTimeRangeDialog}
|
onClick={openTimeRangeDialog}
|
||||||
|
disabled={isResolvingTimeRangeBounds}
|
||||||
>
|
>
|
||||||
<span>{timeRangeSummaryLabel}</span>
|
<span>{isResolvingTimeRangeBounds ? '正在统计可选时间...' : timeRangeSummaryLabel}</span>
|
||||||
<span className="time-range-arrow">></span>
|
<span className="time-range-arrow">></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -7840,6 +8041,8 @@ function ExportPage() {
|
|||||||
<ExportDateRangeDialog
|
<ExportDateRangeDialog
|
||||||
open={isTimeRangeDialogOpen}
|
open={isTimeRangeDialogOpen}
|
||||||
value={timeRangeSelection}
|
value={timeRangeSelection}
|
||||||
|
minDate={timeRangeBounds?.minDate}
|
||||||
|
maxDate={timeRangeBounds?.maxDate}
|
||||||
onClose={closeTimeRangeDialog}
|
onClose={closeTimeRangeDialog}
|
||||||
onConfirm={(nextSelection) => {
|
onConfirm={(nextSelection) => {
|
||||||
setTimeRangeSelection(nextSelection)
|
setTimeRangeSelection(nextSelection)
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ export default function SnsPage() {
|
|||||||
const selectedContactUsernamesRef = useRef<string[]>(selectedContactUsernames)
|
const selectedContactUsernamesRef = useRef<string[]>(selectedContactUsernames)
|
||||||
const cacheScopeKeyRef = useRef('')
|
const cacheScopeKeyRef = useRef('')
|
||||||
const snsUserPostCountsCacheScopeKeyRef = useRef('')
|
const snsUserPostCountsCacheScopeKeyRef = useRef('')
|
||||||
|
const activeContactsLoadTaskIdRef = useRef<string | null>(null)
|
||||||
|
const activeContactsCountTaskIdRef = useRef<string | null>(null)
|
||||||
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||||
const pendingResetFeedRef = useRef(false)
|
const pendingResetFeedRef = useRef(false)
|
||||||
const contactsLoadTokenRef = useRef(0)
|
const contactsLoadTokenRef = useRef(0)
|
||||||
@@ -750,6 +752,12 @@ export default function SnsPage() {
|
|||||||
window.clearTimeout(contactsCountBatchTimerRef.current)
|
window.clearTimeout(contactsCountBatchTimerRef.current)
|
||||||
contactsCountBatchTimerRef.current = null
|
contactsCountBatchTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
if (activeContactsCountTaskIdRef.current) {
|
||||||
|
finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', {
|
||||||
|
detail: '已停止后续联系人朋友圈条数补算'
|
||||||
|
})
|
||||||
|
activeContactsCountTaskIdRef.current = null
|
||||||
|
}
|
||||||
if (resetProgress) {
|
if (resetProgress) {
|
||||||
setContactsCountProgress({
|
setContactsCountProgress({
|
||||||
resolved: 0,
|
resolved: 0,
|
||||||
@@ -814,31 +822,56 @@ export default function SnsPage() {
|
|||||||
cancelable: true
|
cancelable: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
activeContactsCountTaskIdRef.current = taskId
|
||||||
let normalizedCounts: Record<string, number> = {}
|
let normalizedCounts: Record<string, number> = {}
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.sns.getUserPostCounts()
|
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||||
|
activeContactsCountTaskIdRef.current = null
|
||||||
|
}
|
||||||
finishBackgroundTask(taskId, 'canceled', {
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
detail: '已停止后续加载,当前计数查询结束后不再继续分批写入'
|
detail: '已停止后续加载,当前计数查询结束后不再继续分批写入'
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (runToken !== contactsCountHydrationTokenRef.current) return
|
if (runToken !== contactsCountHydrationTokenRef.current) {
|
||||||
|
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||||
|
activeContactsCountTaskIdRef.current = null
|
||||||
|
}
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (result.success && result.counts) {
|
if (result.success && result.counts) {
|
||||||
normalizedCounts = Object.fromEntries(
|
normalizedCounts = pendingTargets.reduce<Record<string, number>>((acc, username) => {
|
||||||
Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)])
|
acc[username] = normalizePostCount(result.counts?.[username])
|
||||||
)
|
return acc
|
||||||
|
}, {})
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const scopeKey = await ensureSnsUserPostCountsCacheScopeKey()
|
const scopeKey = await ensureSnsUserPostCountsCacheScopeKey()
|
||||||
await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts)
|
const currentCache = await configService.getExportSnsUserPostCountsCache(scopeKey)
|
||||||
|
await configService.setExportSnsUserPostCountsCache(scopeKey, {
|
||||||
|
...(currentCache?.counts || {}),
|
||||||
|
...normalizedCounts
|
||||||
|
})
|
||||||
} catch (cacheError) {
|
} catch (cacheError) {
|
||||||
console.error('Failed to persist SNS user post counts cache:', cacheError)
|
console.error('Failed to persist SNS user post counts cache:', cacheError)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
} else {
|
||||||
|
normalizedCounts = pendingTargets.reduce<Record<string, number>>((acc, username) => {
|
||||||
|
acc[username] = 0
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load contact post counts:', error)
|
console.error('Failed to load contact post counts:', error)
|
||||||
|
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||||
|
activeContactsCountTaskIdRef.current = null
|
||||||
|
}
|
||||||
finishBackgroundTask(taskId, 'failed', {
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
detail: String(error)
|
detail: String(error)
|
||||||
})
|
})
|
||||||
@@ -848,8 +881,19 @@ export default function SnsPage() {
|
|||||||
let resolved = preResolved
|
let resolved = preResolved
|
||||||
let cursor = 0
|
let cursor = 0
|
||||||
const applyBatch = () => {
|
const applyBatch = () => {
|
||||||
if (runToken !== contactsCountHydrationTokenRef.current) return
|
if (runToken !== contactsCountHydrationTokenRef.current) {
|
||||||
|
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||||
|
activeContactsCountTaskIdRef.current = null
|
||||||
|
}
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||||
|
activeContactsCountTaskIdRef.current = null
|
||||||
|
}
|
||||||
finishBackgroundTask(taskId, 'canceled', {
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
detail: `已停止后续加载,已完成 ${resolved}/${totalTargets}`
|
detail: `已停止后续加载,已完成 ${resolved}/${totalTargets}`
|
||||||
})
|
})
|
||||||
@@ -870,6 +914,9 @@ export default function SnsPage() {
|
|||||||
running: false
|
running: false
|
||||||
})
|
})
|
||||||
contactsCountBatchTimerRef.current = null
|
contactsCountBatchTimerRef.current = null
|
||||||
|
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||||
|
activeContactsCountTaskIdRef.current = null
|
||||||
|
}
|
||||||
finishBackgroundTask(taskId, 'completed', {
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
detail: '联系人朋友圈条数补算完成',
|
detail: '联系人朋友圈条数补算完成',
|
||||||
progressText: `${totalTargets}/${totalTargets}`
|
progressText: `${totalTargets}/${totalTargets}`
|
||||||
@@ -910,6 +957,18 @@ export default function SnsPage() {
|
|||||||
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
|
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
|
||||||
} else {
|
} else {
|
||||||
contactsCountBatchTimerRef.current = null
|
contactsCountBatchTimerRef.current = null
|
||||||
|
setContactsCountProgress({
|
||||||
|
resolved: totalTargets,
|
||||||
|
total: totalTargets,
|
||||||
|
running: false
|
||||||
|
})
|
||||||
|
if (activeContactsCountTaskIdRef.current === taskId) {
|
||||||
|
activeContactsCountTaskIdRef.current = null
|
||||||
|
}
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '鑱旂郴浜烘湅鍙嬪湀鏉℃暟琛ョ畻瀹屾垚',
|
||||||
|
progressText: `${totalTargets}/${totalTargets}`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,6 +977,12 @@ export default function SnsPage() {
|
|||||||
|
|
||||||
// Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序)
|
// Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序)
|
||||||
const loadContacts = useCallback(async () => {
|
const loadContacts = useCallback(async () => {
|
||||||
|
if (activeContactsLoadTaskIdRef.current) {
|
||||||
|
finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', {
|
||||||
|
detail: '新一轮联系人列表加载已开始,旧任务已取消'
|
||||||
|
})
|
||||||
|
activeContactsLoadTaskIdRef.current = null
|
||||||
|
}
|
||||||
const requestToken = ++contactsLoadTokenRef.current
|
const requestToken = ++contactsLoadTokenRef.current
|
||||||
const taskId = registerBackgroundTask({
|
const taskId = registerBackgroundTask({
|
||||||
sourcePage: 'sns',
|
sourcePage: 'sns',
|
||||||
@@ -926,6 +991,7 @@ export default function SnsPage() {
|
|||||||
progressText: '初始化',
|
progressText: '初始化',
|
||||||
cancelable: true
|
cancelable: true
|
||||||
})
|
})
|
||||||
|
activeContactsLoadTaskIdRef.current = taskId
|
||||||
stopContactsCountHydration(true)
|
stopContactsCountHydration(true)
|
||||||
setContactsLoading(true)
|
setContactsLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -955,7 +1021,15 @@ export default function SnsPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (requestToken !== contactsLoadTokenRef.current) return
|
if (requestToken !== contactsLoadTokenRef.current) {
|
||||||
|
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||||
|
activeContactsLoadTaskIdRef.current = null
|
||||||
|
}
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '页面状态已刷新,本次联系人列表加载已过期'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (cachedContacts.length > 0) {
|
if (cachedContacts.length > 0) {
|
||||||
const cachedContactsSorted = sortContactsForRanking(cachedContacts)
|
const cachedContactsSorted = sortContactsForRanking(cachedContacts)
|
||||||
setContacts(cachedContactsSorted)
|
setContacts(cachedContactsSorted)
|
||||||
@@ -977,6 +1051,9 @@ export default function SnsPage() {
|
|||||||
window.electronAPI.chat.getSessions()
|
window.electronAPI.chat.getSessions()
|
||||||
])
|
])
|
||||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||||
|
activeContactsLoadTaskIdRef.current = null
|
||||||
|
}
|
||||||
finishBackgroundTask(taskId, 'canceled', {
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
detail: '已停止后续加载,当前联系人查询结束后未继续补齐'
|
detail: '已停止后续加载,当前联系人查询结束后未继续补齐'
|
||||||
})
|
})
|
||||||
@@ -1021,7 +1098,15 @@ export default function SnsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let contactsList = sortContactsForRanking(Array.from(contactMap.values()))
|
let contactsList = sortContactsForRanking(Array.from(contactMap.values()))
|
||||||
if (requestToken !== contactsLoadTokenRef.current) return
|
if (requestToken !== contactsLoadTokenRef.current) {
|
||||||
|
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||||
|
activeContactsLoadTaskIdRef.current = null
|
||||||
|
}
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '页面状态已刷新,本次联系人列表加载已过期'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
setContacts(contactsList)
|
setContacts(contactsList)
|
||||||
const readyUsernames = new Set(
|
const readyUsernames = new Set(
|
||||||
contactsList
|
contactsList
|
||||||
@@ -1043,6 +1128,9 @@ export default function SnsPage() {
|
|||||||
})
|
})
|
||||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
|
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
|
||||||
if (isBackgroundTaskCancelRequested(taskId)) {
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||||
|
activeContactsLoadTaskIdRef.current = null
|
||||||
|
}
|
||||||
finishBackgroundTask(taskId, 'canceled', {
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
detail: '已停止后续加载,联系人补齐未继续写入'
|
detail: '已停止后续加载,联系人补齐未继续写入'
|
||||||
})
|
})
|
||||||
@@ -1058,7 +1146,15 @@ export default function SnsPage() {
|
|||||||
avatarUrl: extra.avatarUrl || contact.avatarUrl
|
avatarUrl: extra.avatarUrl || contact.avatarUrl
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (requestToken !== contactsLoadTokenRef.current) return
|
if (requestToken !== contactsLoadTokenRef.current) {
|
||||||
|
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||||
|
activeContactsLoadTaskIdRef.current = null
|
||||||
|
}
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '页面状态已刷新,本次联系人列表加载已过期'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
setContacts((prev) => {
|
setContacts((prev) => {
|
||||||
const prevMap = new Map(prev.map((contact) => [contact.username, contact]))
|
const prevMap = new Map(prev.map((contact) => [contact.username, contact]))
|
||||||
const merged = contactsList.map((contact) => {
|
const merged = contactsList.map((contact) => {
|
||||||
@@ -1074,18 +1170,35 @@ export default function SnsPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||||
|
activeContactsLoadTaskIdRef.current = null
|
||||||
|
}
|
||||||
finishBackgroundTask(taskId, 'completed', {
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
detail: `朋友圈联系人列表加载完成,共 ${contactsList.length} 人`,
|
detail: `朋友圈联系人列表加载完成,共 ${contactsList.length} 人`,
|
||||||
progressText: `${contactsList.length} 人`
|
progressText: `${contactsList.length} 人`
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestToken !== contactsLoadTokenRef.current) return
|
if (requestToken !== contactsLoadTokenRef.current) {
|
||||||
|
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||||
|
activeContactsLoadTaskIdRef.current = null
|
||||||
|
}
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '页面状态已刷新,本次联系人列表加载已过期'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
console.error('Failed to load contacts:', error)
|
console.error('Failed to load contacts:', error)
|
||||||
stopContactsCountHydration(true)
|
stopContactsCountHydration(true)
|
||||||
|
if (activeContactsLoadTaskIdRef.current === taskId) {
|
||||||
|
activeContactsLoadTaskIdRef.current = null
|
||||||
|
}
|
||||||
finishBackgroundTask(taskId, 'failed', {
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
detail: String(error)
|
detail: String(error)
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
if (activeContactsLoadTaskIdRef.current === taskId && requestToken !== contactsLoadTokenRef.current) {
|
||||||
|
activeContactsLoadTaskIdRef.current = null
|
||||||
|
}
|
||||||
if (requestToken === contactsLoadTokenRef.current) {
|
if (requestToken === contactsLoadTokenRef.current) {
|
||||||
setContactsLoading(false)
|
setContactsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -1185,6 +1298,18 @@ export default function SnsPage() {
|
|||||||
window.clearTimeout(contactsCountBatchTimerRef.current)
|
window.clearTimeout(contactsCountBatchTimerRef.current)
|
||||||
contactsCountBatchTimerRef.current = null
|
contactsCountBatchTimerRef.current = null
|
||||||
}
|
}
|
||||||
|
if (activeContactsCountTaskIdRef.current) {
|
||||||
|
finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', {
|
||||||
|
detail: '已离开朋友圈页,联系人朋友圈条数补算已取消'
|
||||||
|
})
|
||||||
|
activeContactsCountTaskIdRef.current = null
|
||||||
|
}
|
||||||
|
if (activeContactsLoadTaskIdRef.current) {
|
||||||
|
finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', {
|
||||||
|
detail: '已离开朋友圈页,联系人列表加载已取消'
|
||||||
|
})
|
||||||
|
activeContactsLoadTaskIdRef.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -580,6 +580,8 @@ export interface ExportSessionContentMetricCacheEntry {
|
|||||||
imageMessages?: number
|
imageMessages?: number
|
||||||
videoMessages?: number
|
videoMessages?: number
|
||||||
emojiMessages?: number
|
emojiMessages?: number
|
||||||
|
firstTimestamp?: number
|
||||||
|
lastTimestamp?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportSessionContentMetricCacheItem {
|
export interface ExportSessionContentMetricCacheItem {
|
||||||
@@ -742,6 +744,12 @@ export async function getExportSessionContentMetricCache(scopeKey: string): Prom
|
|||||||
if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) {
|
if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) {
|
||||||
metric.emojiMessages = Math.floor(source.emojiMessages)
|
metric.emojiMessages = Math.floor(source.emojiMessages)
|
||||||
}
|
}
|
||||||
|
if (typeof source.firstTimestamp === 'number' && Number.isFinite(source.firstTimestamp) && source.firstTimestamp > 0) {
|
||||||
|
metric.firstTimestamp = Math.floor(source.firstTimestamp)
|
||||||
|
}
|
||||||
|
if (typeof source.lastTimestamp === 'number' && Number.isFinite(source.lastTimestamp) && source.lastTimestamp > 0) {
|
||||||
|
metric.lastTimestamp = Math.floor(source.lastTimestamp)
|
||||||
|
}
|
||||||
if (Object.keys(metric).length === 0) continue
|
if (Object.keys(metric).length === 0) continue
|
||||||
metrics[sessionId] = metric
|
metrics[sessionId] = metric
|
||||||
}
|
}
|
||||||
@@ -781,6 +789,12 @@ export async function setExportSessionContentMetricCache(
|
|||||||
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
|
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
|
||||||
metric.emojiMessages = Math.floor(rawMetric.emojiMessages)
|
metric.emojiMessages = Math.floor(rawMetric.emojiMessages)
|
||||||
}
|
}
|
||||||
|
if (typeof rawMetric.firstTimestamp === 'number' && Number.isFinite(rawMetric.firstTimestamp) && rawMetric.firstTimestamp > 0) {
|
||||||
|
metric.firstTimestamp = Math.floor(rawMetric.firstTimestamp)
|
||||||
|
}
|
||||||
|
if (typeof rawMetric.lastTimestamp === 'number' && Number.isFinite(rawMetric.lastTimestamp) && rawMetric.lastTimestamp > 0) {
|
||||||
|
metric.lastTimestamp = Math.floor(rawMetric.lastTimestamp)
|
||||||
|
}
|
||||||
if (Object.keys(metric).length === 0) continue
|
if (Object.keys(metric).length === 0) continue
|
||||||
normalized[sessionId] = metric
|
normalized[sessionId] = metric
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user