mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-22 15:09:04 +00:00
Compare commits
4 Commits
dependabot
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
777f5b82db | ||
|
|
5802cf36c6 | ||
|
|
e3174370bb | ||
|
|
0f8a9602bd |
@@ -2390,6 +2390,8 @@ function registerIpcHandlers() {
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}) => {
|
||||
return chatService.getExportSessionStats(sessionIds, options)
|
||||
})
|
||||
@@ -3935,4 +3937,3 @@ app.on('window-all-closed', () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -219,6 +219,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}
|
||||
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||
@@ -565,4 +567,3 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -198,6 +198,8 @@ interface ExportSessionStatsOptions {
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}
|
||||
|
||||
interface ExportSessionStatsCacheMeta {
|
||||
@@ -2178,28 +2180,31 @@ class ChatService {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
const batchSize = Math.max(1, limit)
|
||||
const cursorResult = await wcdbService.openMessageCursor(sessionId, batchSize, false, 0, 0)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||
// 聊天页首屏优先走稳定路径:直接拉取固定窗口并做本地确定性排序,
|
||||
// 避免游标首批在极端数据分布下出现不稳定边界。
|
||||
const pageLimit = Math.max(1, Math.floor(limit || this.messageBatchDefault))
|
||||
const probeLimit = Math.min(500, pageLimit + 1)
|
||||
const result = await wcdbService.getMessages(sessionId, probeLimit, 0)
|
||||
if (!result.success || !Array.isArray(result.messages)) {
|
||||
return { success: false, error: result.error || '获取最新消息失败' }
|
||||
}
|
||||
|
||||
try {
|
||||
const collected = await this.collectVisibleMessagesFromCursor(sessionId, cursorResult.cursor, limit)
|
||||
if (!collected.success) {
|
||||
return { success: false, error: collected.error || '获取消息失败' }
|
||||
}
|
||||
console.log(
|
||||
`[ChatService] getLatestMessages session=${sessionId} rawRowsConsumed=${collected.rawRowsConsumed || 0} visibleMessagesReturned=${collected.messages?.length || 0} filteredOut=${collected.filteredOut || 0} nextOffset=${collected.rawRowsConsumed || 0} hasMore=${collected.hasMore === true}`
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
messages: collected.messages,
|
||||
hasMore: collected.hasMore,
|
||||
nextOffset: collected.rawRowsConsumed || 0
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
||||
const rawRows = result.messages as Record<string, any>[]
|
||||
const hasMore = rawRows.length > pageLimit
|
||||
const selectedRows = hasMore ? rawRows.slice(0, pageLimit) : rawRows
|
||||
const mapped = this.mapRowsToMessages(selectedRows)
|
||||
const visible = mapped.filter((msg) => this.isMessageVisibleForSession(sessionId, msg))
|
||||
const normalized = this.normalizeMessageOrder(visible)
|
||||
await this.repairEmojiMessages(normalized)
|
||||
|
||||
console.log(
|
||||
`[ChatService] getLatestMessages(stable) session=${sessionId} rawRows=${rawRows.length} visibleMessagesReturned=${normalized.length} nextOffset=${selectedRows.length} hasMore=${hasMore}`
|
||||
)
|
||||
return {
|
||||
success: true,
|
||||
messages: normalized,
|
||||
hasMore,
|
||||
nextOffset: selectedRows.length
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取最新消息失败:', e)
|
||||
@@ -2241,16 +2246,59 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
private compareMessagesByTimeline(a: Message, b: Message): number {
|
||||
const aSortSeq = Math.max(0, Number(a.sortSeq || 0))
|
||||
const bSortSeq = Math.max(0, Number(b.sortSeq || 0))
|
||||
const aCreateTime = Math.max(0, Number(a.createTime || 0))
|
||||
const bCreateTime = Math.max(0, Number(b.createTime || 0))
|
||||
const aLocalId = Math.max(0, Number(a.localId || 0))
|
||||
const bLocalId = Math.max(0, Number(b.localId || 0))
|
||||
const aServerId = Math.max(0, Number(a.serverId || 0))
|
||||
const bServerId = Math.max(0, Number(b.serverId || 0))
|
||||
|
||||
// 与 C++ 侧归并规则一致:当两侧都有 sortSeq 时优先 sortSeq,否则先看 createTime。
|
||||
if (aSortSeq > 0 && bSortSeq > 0 && aSortSeq !== bSortSeq) {
|
||||
return aSortSeq - bSortSeq
|
||||
}
|
||||
if (aCreateTime !== bCreateTime) {
|
||||
return aCreateTime - bCreateTime
|
||||
}
|
||||
if (aSortSeq !== bSortSeq) {
|
||||
return aSortSeq - bSortSeq
|
||||
}
|
||||
if (aLocalId !== bLocalId) {
|
||||
return aLocalId - bLocalId
|
||||
}
|
||||
if (aServerId !== bServerId) {
|
||||
return aServerId - bServerId
|
||||
}
|
||||
|
||||
const aKey = String(a.messageKey || '')
|
||||
const bKey = String(b.messageKey || '')
|
||||
if (aKey < bKey) return -1
|
||||
if (aKey > bKey) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
private normalizeMessageOrder(messages: Message[]): Message[] {
|
||||
if (messages.length < 2) return messages
|
||||
const first = messages[0]
|
||||
const last = messages[messages.length - 1]
|
||||
const firstKey = first.sortSeq || first.createTime || first.localId || 0
|
||||
const lastKey = last.sortSeq || last.createTime || last.localId || 0
|
||||
if (firstKey > lastKey) {
|
||||
return [...messages].reverse()
|
||||
|
||||
const withIndex = messages.map((msg, index) => ({ msg, index }))
|
||||
withIndex.sort((left, right) => {
|
||||
const diff = this.compareMessagesByTimeline(left.msg, right.msg)
|
||||
if (diff !== 0) return diff
|
||||
return left.index - right.index
|
||||
})
|
||||
|
||||
let changed = false
|
||||
for (let index = 0; index < withIndex.length; index += 1) {
|
||||
if (withIndex[index].msg !== messages[index]) {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return messages
|
||||
if (!changed) return messages
|
||||
return withIndex.map((entry) => entry.msg)
|
||||
}
|
||||
|
||||
private encodeMessageKeySegment(value: unknown): string {
|
||||
@@ -2436,6 +2484,95 @@ class ChatService {
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
private parseCompactDateTimeDigitsToSeconds(raw: string): number {
|
||||
const text = String(raw || '').trim()
|
||||
if (!/^\d{8}(?:\d{4}(?:\d{2})?)?$/.test(text)) return 0
|
||||
|
||||
const year = Number.parseInt(text.slice(0, 4), 10)
|
||||
const month = Number.parseInt(text.slice(4, 6), 10)
|
||||
const day = Number.parseInt(text.slice(6, 8), 10)
|
||||
const hour = text.length >= 12 ? Number.parseInt(text.slice(8, 10), 10) : 0
|
||||
const minute = text.length >= 12 ? Number.parseInt(text.slice(10, 12), 10) : 0
|
||||
const second = text.length >= 14 ? Number.parseInt(text.slice(12, 14), 10) : 0
|
||||
|
||||
if (!Number.isFinite(year) || year < 1990 || year > 2200) return 0
|
||||
if (!Number.isFinite(month) || month < 1 || month > 12) return 0
|
||||
if (!Number.isFinite(day) || day < 1 || day > 31) return 0
|
||||
if (!Number.isFinite(hour) || hour < 0 || hour > 23) return 0
|
||||
if (!Number.isFinite(minute) || minute < 0 || minute > 59) return 0
|
||||
if (!Number.isFinite(second) || second < 0 || second > 59) return 0
|
||||
|
||||
const dt = new Date(year, month - 1, day, hour, minute, second)
|
||||
if (
|
||||
dt.getFullYear() !== year ||
|
||||
dt.getMonth() !== month - 1 ||
|
||||
dt.getDate() !== day ||
|
||||
dt.getHours() !== hour ||
|
||||
dt.getMinutes() !== minute ||
|
||||
dt.getSeconds() !== second
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
const ts = Math.floor(dt.getTime() / 1000)
|
||||
return Number.isFinite(ts) && ts > 0 ? ts : 0
|
||||
}
|
||||
|
||||
private parseDateTimeTextToSeconds(raw: unknown): number {
|
||||
const text = String(raw ?? '').trim()
|
||||
if (!text) return 0
|
||||
|
||||
const compactDigits = this.parseCompactDateTimeDigitsToSeconds(text)
|
||||
if (compactDigits > 0) return compactDigits
|
||||
|
||||
if (/[zZ]|[+-]\d{2}:?\d{2}$/.test(text)) {
|
||||
const parsed = Date.parse(text)
|
||||
const seconds = Math.floor(parsed / 1000)
|
||||
if (Number.isFinite(seconds) && seconds > 0) return seconds
|
||||
}
|
||||
|
||||
const normalized = text.replace('T', ' ').replace(/\.\d+$/, '').replace(/\//g, '-')
|
||||
const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/)
|
||||
if (!match) return 0
|
||||
|
||||
const year = Number.parseInt(match[1], 10)
|
||||
const month = Number.parseInt(match[2], 10)
|
||||
const day = Number.parseInt(match[3], 10)
|
||||
const hour = Number.parseInt(match[4] || '0', 10)
|
||||
const minute = Number.parseInt(match[5] || '0', 10)
|
||||
const second = Number.parseInt(match[6] || '0', 10)
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return 0
|
||||
const dt = new Date(year, month - 1, day, hour, minute, second)
|
||||
const ts = Math.floor(dt.getTime() / 1000)
|
||||
return Number.isFinite(ts) && ts > 0 ? ts : 0
|
||||
}
|
||||
|
||||
private normalizeTimestampLikeToSeconds(raw: unknown): number {
|
||||
if (raw === undefined || raw === null || raw === '') return 0
|
||||
const text = String(raw ?? '').trim()
|
||||
if (!text) return 0
|
||||
|
||||
const compactDigits = this.parseCompactDateTimeDigitsToSeconds(text)
|
||||
if (compactDigits > 0) return compactDigits
|
||||
|
||||
const parsed = this.coerceRowNumber(raw)
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
let normalized = Math.floor(parsed)
|
||||
while (normalized > 10000000000) {
|
||||
normalized = Math.floor(normalized / 1000)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
return this.parseDateTimeTextToSeconds(text)
|
||||
}
|
||||
|
||||
private getRowTimestampSeconds(row: Record<string, any>, keys: string[], fallback = 0): number {
|
||||
const raw = this.getRowField(row, keys)
|
||||
if (raw === undefined || raw === null || raw === '') return fallback
|
||||
const parsed = this.normalizeTimestampLikeToSeconds(raw)
|
||||
return parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
private hasAnyContactExtendedFieldKey(row: Record<string, any>): boolean {
|
||||
for (const key of Object.keys(row || {})) {
|
||||
if (this.contactExtendedFieldCandidateSet.has(String(key || '').toLowerCase())) {
|
||||
@@ -3066,13 +3203,13 @@ class ChatService {
|
||||
if (typeof raw === 'number') return raw
|
||||
if (typeof raw === 'bigint') return Number(raw)
|
||||
if (Buffer.isBuffer(raw)) {
|
||||
return parseInt(raw.toString('utf-8'), 10)
|
||||
return this.coerceRowNumber(raw.toString('utf-8'))
|
||||
}
|
||||
if (raw instanceof Uint8Array) {
|
||||
return parseInt(Buffer.from(raw).toString('utf-8'), 10)
|
||||
return this.coerceRowNumber(Buffer.from(raw).toString('utf-8'))
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return parseInt(Buffer.from(raw).toString('utf-8'), 10)
|
||||
return this.coerceRowNumber(Buffer.from(raw).toString('utf-8'))
|
||||
}
|
||||
if (typeof raw === 'object') {
|
||||
if ('value' in raw) return this.coerceRowNumber(raw.value)
|
||||
@@ -3088,13 +3225,21 @@ class ChatService {
|
||||
}
|
||||
const text = raw.toString ? String(raw) : ''
|
||||
if (text && text !== '[object Object]') {
|
||||
const parsed = parseInt(text, 10)
|
||||
return Number.isFinite(parsed) ? parsed : NaN
|
||||
return this.coerceRowNumber(text)
|
||||
}
|
||||
return NaN
|
||||
}
|
||||
const parsed = parseInt(String(raw), 10)
|
||||
return Number.isFinite(parsed) ? parsed : NaN
|
||||
const text = String(raw).trim()
|
||||
if (!text) return NaN
|
||||
if (/^[+-]?\d+$/.test(text)) {
|
||||
const parsed = Number(text)
|
||||
return Number.isFinite(parsed) ? parsed : NaN
|
||||
}
|
||||
if (/^[+-]?\d+\.\d+$/.test(text)) {
|
||||
const parsed = Number(text)
|
||||
return Number.isFinite(parsed) ? parsed : NaN
|
||||
}
|
||||
return NaN
|
||||
}
|
||||
|
||||
private buildIdentityKeys(raw: string): string[] {
|
||||
@@ -3656,7 +3801,11 @@ class ChatService {
|
||||
return this.extractXmlValue(content, 'type')
|
||||
}
|
||||
|
||||
private async collectSpecialMessageCountsByCursorScan(sessionId: string): Promise<{
|
||||
private async collectSpecialMessageCountsByCursorScan(
|
||||
sessionId: string,
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0
|
||||
): Promise<{
|
||||
transferMessages: number
|
||||
redPacketMessages: number
|
||||
callMessages: number
|
||||
@@ -3667,7 +3816,7 @@ class ChatService {
|
||||
callMessages: 0
|
||||
}
|
||||
|
||||
const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, 0, 0)
|
||||
const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, beginTimestamp, endTimestamp)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return counters
|
||||
}
|
||||
@@ -3713,7 +3862,9 @@ class ChatService {
|
||||
|
||||
private async collectSessionExportStatsByCursorScan(
|
||||
sessionId: string,
|
||||
selfIdentitySet: Set<string>
|
||||
selfIdentitySet: Set<string>,
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0
|
||||
): Promise<ExportSessionStats> {
|
||||
const stats: ExportSessionStats = {
|
||||
totalMessages: 0,
|
||||
@@ -3731,7 +3882,7 @@ class ChatService {
|
||||
}
|
||||
|
||||
const senderIdentities = new Set<string>()
|
||||
const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, 0, 0)
|
||||
const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, beginTimestamp, endTimestamp)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return stats
|
||||
}
|
||||
@@ -3806,7 +3957,7 @@ class ChatService {
|
||||
|
||||
if (sessionId.endsWith('@chatroom')) {
|
||||
stats.groupActiveSpeakers = senderIdentities.size
|
||||
if (Number.isFinite(stats.groupMyMessages)) {
|
||||
if ((beginTimestamp <= 0 && endTimestamp <= 0) && Number.isFinite(stats.groupMyMessages)) {
|
||||
this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number)
|
||||
}
|
||||
}
|
||||
@@ -3816,7 +3967,9 @@ class ChatService {
|
||||
private async collectSessionExportStats(
|
||||
sessionId: string,
|
||||
selfIdentitySet: Set<string>,
|
||||
preferAccurateSpecialTypes: boolean = false
|
||||
preferAccurateSpecialTypes: boolean = false,
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0
|
||||
): Promise<ExportSessionStats> {
|
||||
const stats: ExportSessionStats = {
|
||||
totalMessages: 0,
|
||||
@@ -3834,9 +3987,9 @@ class ChatService {
|
||||
stats.groupActiveSpeakers = 0
|
||||
}
|
||||
|
||||
const nativeResult = await wcdbService.getSessionMessageTypeStats(sessionId, 0, 0)
|
||||
const nativeResult = await wcdbService.getSessionMessageTypeStats(sessionId, beginTimestamp, endTimestamp)
|
||||
if (!nativeResult.success || !nativeResult.data) {
|
||||
return this.collectSessionExportStatsByCursorScan(sessionId, selfIdentitySet)
|
||||
return this.collectSessionExportStatsByCursorScan(sessionId, selfIdentitySet, beginTimestamp, endTimestamp)
|
||||
}
|
||||
|
||||
const data = nativeResult.data as Record<string, any>
|
||||
@@ -3856,7 +4009,7 @@ class ChatService {
|
||||
|
||||
if (preferAccurateSpecialTypes) {
|
||||
try {
|
||||
const preciseCounters = await this.collectSpecialMessageCountsByCursorScan(sessionId)
|
||||
const preciseCounters = await this.collectSpecialMessageCountsByCursorScan(sessionId, beginTimestamp, endTimestamp)
|
||||
stats.transferMessages = preciseCounters.transferMessages
|
||||
stats.redPacketMessages = preciseCounters.redPacketMessages
|
||||
stats.callMessages = preciseCounters.callMessages
|
||||
@@ -3868,14 +4021,19 @@ class ChatService {
|
||||
if (isGroup) {
|
||||
stats.groupMyMessages = Math.max(0, Math.floor(Number(data.group_my_messages || 0)))
|
||||
stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(data.group_sender_count || 0)))
|
||||
if (Number.isFinite(stats.groupMyMessages)) {
|
||||
if ((beginTimestamp <= 0 && endTimestamp <= 0) && Number.isFinite(stats.groupMyMessages)) {
|
||||
this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
private toExportSessionStatsFromNativeTypeRow(sessionId: string, row: Record<string, any>): ExportSessionStats {
|
||||
private toExportSessionStatsFromNativeTypeRow(
|
||||
sessionId: string,
|
||||
row: Record<string, any>,
|
||||
options?: { updateGroupHint?: boolean }
|
||||
): ExportSessionStats {
|
||||
const updateGroupHint = options?.updateGroupHint !== false
|
||||
const stats: ExportSessionStats = {
|
||||
totalMessages: Math.max(0, Math.floor(Number(row?.total_messages || 0))),
|
||||
voiceMessages: Math.max(0, Math.floor(Number(row?.voice_messages || 0))),
|
||||
@@ -3895,7 +4053,7 @@ class ChatService {
|
||||
if (sessionId.endsWith('@chatroom')) {
|
||||
stats.groupMyMessages = Math.max(0, Math.floor(Number(row?.group_my_messages || 0)))
|
||||
stats.groupActiveSpeakers = Math.max(0, Math.floor(Number(row?.group_sender_count || 0)))
|
||||
if (Number.isFinite(stats.groupMyMessages)) {
|
||||
if (updateGroupHint && Number.isFinite(stats.groupMyMessages)) {
|
||||
this.setGroupMyMessageCountHintEntry(sessionId, stats.groupMyMessages as number)
|
||||
}
|
||||
}
|
||||
@@ -4025,9 +4183,17 @@ class ChatService {
|
||||
sessionId: string,
|
||||
selfIdentitySet: Set<string>,
|
||||
includeRelations: boolean,
|
||||
preferAccurateSpecialTypes: boolean = false
|
||||
preferAccurateSpecialTypes: boolean = false,
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0
|
||||
): Promise<ExportSessionStats> {
|
||||
const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes)
|
||||
const stats = await this.collectSessionExportStats(
|
||||
sessionId,
|
||||
selfIdentitySet,
|
||||
preferAccurateSpecialTypes,
|
||||
beginTimestamp,
|
||||
endTimestamp
|
||||
)
|
||||
const isGroup = sessionId.endsWith('@chatroom')
|
||||
|
||||
if (isGroup) {
|
||||
@@ -4066,7 +4232,9 @@ class ChatService {
|
||||
sessionIds: string[],
|
||||
includeRelations: boolean,
|
||||
selfIdentitySet: Set<string>,
|
||||
preferAccurateSpecialTypes: boolean = false
|
||||
preferAccurateSpecialTypes: boolean = false,
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0
|
||||
): Promise<Record<string, ExportSessionStats>> {
|
||||
const normalizedSessionIds = Array.from(
|
||||
new Set(
|
||||
@@ -4127,8 +4295,8 @@ class ChatService {
|
||||
try {
|
||||
const quickMode = !includeRelations && normalizedSessionIds.length > 1
|
||||
const nativeBatch = await wcdbService.getSessionMessageTypeStatsBatch(normalizedSessionIds, {
|
||||
beginTimestamp: 0,
|
||||
endTimestamp: 0,
|
||||
beginTimestamp,
|
||||
endTimestamp,
|
||||
quickMode,
|
||||
includeGroupSenderCount: true
|
||||
})
|
||||
@@ -4136,7 +4304,9 @@ class ChatService {
|
||||
for (const sessionId of normalizedSessionIds) {
|
||||
const row = nativeBatch.data?.[sessionId] as Record<string, any> | undefined
|
||||
if (!row || typeof row !== 'object') continue
|
||||
nativeBatchStats[sessionId] = this.toExportSessionStatsFromNativeTypeRow(sessionId, row)
|
||||
nativeBatchStats[sessionId] = this.toExportSessionStatsFromNativeTypeRow(sessionId, row, {
|
||||
updateGroupHint: beginTimestamp <= 0 && endTimestamp <= 0
|
||||
})
|
||||
}
|
||||
hasNativeBatchStats = Object.keys(nativeBatchStats).length > 0
|
||||
} else {
|
||||
@@ -4151,7 +4321,13 @@ class ChatService {
|
||||
try {
|
||||
const stats = hasNativeBatchStats && nativeBatchStats[sessionId]
|
||||
? { ...nativeBatchStats[sessionId] }
|
||||
: await this.collectSessionExportStats(sessionId, selfIdentitySet, preferAccurateSpecialTypes)
|
||||
: await this.collectSessionExportStats(
|
||||
sessionId,
|
||||
selfIdentitySet,
|
||||
preferAccurateSpecialTypes,
|
||||
beginTimestamp,
|
||||
endTimestamp
|
||||
)
|
||||
if (sessionId.endsWith('@chatroom')) {
|
||||
if (shouldLoadGroupMemberCount) {
|
||||
stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number'
|
||||
@@ -4181,10 +4357,12 @@ class ChatService {
|
||||
sessionId: string,
|
||||
includeRelations: boolean,
|
||||
selfIdentitySet: Set<string>,
|
||||
preferAccurateSpecialTypes: boolean = false
|
||||
preferAccurateSpecialTypes: boolean = false,
|
||||
beginTimestamp: number = 0,
|
||||
endTimestamp: number = 0
|
||||
): Promise<ExportSessionStats> {
|
||||
if (preferAccurateSpecialTypes) {
|
||||
return this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, true)
|
||||
return this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, true, beginTimestamp, endTimestamp)
|
||||
}
|
||||
|
||||
const scopedKey = this.buildScopedSessionStatsKey(sessionId)
|
||||
@@ -4199,8 +4377,13 @@ class ChatService {
|
||||
if (pendingFull) return pendingFull
|
||||
}
|
||||
|
||||
const shouldUsePendingPool = beginTimestamp <= 0 && endTimestamp <= 0
|
||||
if (!shouldUsePendingPool) {
|
||||
return this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, false, beginTimestamp, endTimestamp)
|
||||
}
|
||||
|
||||
const targetMap = includeRelations ? this.sessionStatsPendingFull : this.sessionStatsPendingBasic
|
||||
const pending = this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, false)
|
||||
const pending = this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations, false, beginTimestamp, endTimestamp)
|
||||
targetMap.set(scopedKey, pending)
|
||||
try {
|
||||
return await pending
|
||||
@@ -4216,6 +4399,55 @@ class ChatService {
|
||||
return this.mapRowsToMessages(rows)
|
||||
}
|
||||
|
||||
mapRowsToMessagesLiteForApi(rows: Record<string, any>[]): Message[] {
|
||||
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const messages: Message[] = []
|
||||
for (const row of rows) {
|
||||
const sourceInfo = this.getMessageSourceInfo(row)
|
||||
const localType = this.getRowInt(row, ['local_type'], 1)
|
||||
const createTime = this.getRowTimestampSeconds(row, ['create_time', 'createTime', 'msg_time', 'msgTime', 'time'], 0)
|
||||
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime > 0 ? createTime * 1000 : 0)
|
||||
const localId = this.getRowInt(row, ['local_id'], 0)
|
||||
const serverId = this.getRowInt(row, ['server_id'], 0)
|
||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send
|
||||
const parsedRawIsSend = isSendRaw === null || isSendRaw === undefined
|
||||
? null
|
||||
: parseInt(String(isSendRaw), 10)
|
||||
const normalizedIsSend = typeof parsedRawIsSend === 'number' && Number.isFinite(parsedRawIsSend)
|
||||
? parsedRawIsSend
|
||||
: null
|
||||
const senderFromRow = String(row.sender_username || '').trim() || this.extractSenderUsernameFromContent(content) || null
|
||||
const { isSend } = this.resolveMessageIsSend(normalizedIsSend, senderFromRow)
|
||||
const senderUsername = senderFromRow || (isSend === 1 && myWxid ? myWxid : null)
|
||||
|
||||
messages.push({
|
||||
messageKey: this.buildMessageKey({
|
||||
localId,
|
||||
serverId,
|
||||
createTime,
|
||||
sortSeq,
|
||||
senderUsername,
|
||||
localType,
|
||||
...sourceInfo
|
||||
}),
|
||||
localId,
|
||||
serverId,
|
||||
localType,
|
||||
createTime,
|
||||
sortSeq,
|
||||
isSend,
|
||||
senderUsername,
|
||||
parsedContent: '',
|
||||
rawContent: content,
|
||||
content,
|
||||
_db_path: sourceInfo.dbPath
|
||||
})
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
|
||||
@@ -4233,7 +4465,7 @@ class ChatService {
|
||||
|| this.extractSenderUsernameFromContent(content)
|
||||
|| null
|
||||
const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername)
|
||||
const createTime = this.getRowInt(row, ['create_time'], 0)
|
||||
const createTime = this.getRowTimestampSeconds(row, ['create_time', 'createTime', 'msg_time', 'msgTime', 'time'], 0)
|
||||
|
||||
if (senderUsername && !myWxid) {
|
||||
// [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送
|
||||
@@ -7042,6 +7274,9 @@ class ChatService {
|
||||
const allowStaleCache = options.allowStaleCache === true
|
||||
const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true
|
||||
const cacheOnly = options.cacheOnly === true
|
||||
const beginTimestamp = this.normalizeTimestampSeconds(Number(options.beginTimestamp || 0))
|
||||
const endTimestamp = this.normalizeTimestampSeconds(Number(options.endTimestamp || 0))
|
||||
const useRangeFilter = beginTimestamp > 0 || endTimestamp > 0
|
||||
|
||||
const normalizedSessionIds = Array.from(
|
||||
new Set(
|
||||
@@ -7065,7 +7300,7 @@ class ChatService {
|
||||
? this.getGroupMyMessageCountHintEntry(sessionId)
|
||||
: null
|
||||
const cachedResult = this.getSessionStatsCacheEntry(sessionId)
|
||||
const canUseCache = cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes)
|
||||
const canUseCache = !useRangeFilter && (cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes))
|
||||
if (canUseCache && cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
|
||||
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
|
||||
if (!stale || allowStaleCache || cacheOnly) {
|
||||
@@ -7103,31 +7338,16 @@ class ChatService {
|
||||
if (pendingSessionIds.length === 1) {
|
||||
const sessionId = pendingSessionIds[0]
|
||||
try {
|
||||
const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet, preferAccurateSpecialTypes)
|
||||
resultMap[sessionId] = stats
|
||||
const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations)
|
||||
cacheMeta[sessionId] = {
|
||||
updatedAt,
|
||||
stale: false,
|
||||
includeRelations,
|
||||
source: 'fresh'
|
||||
}
|
||||
usedBatchedCompute = true
|
||||
} catch {
|
||||
usedBatchedCompute = false
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const batchedStatsMap = await this.computeSessionExportStatsBatch(
|
||||
pendingSessionIds,
|
||||
const stats = await this.getOrComputeSessionExportStats(
|
||||
sessionId,
|
||||
includeRelations,
|
||||
selfIdentitySet,
|
||||
preferAccurateSpecialTypes
|
||||
preferAccurateSpecialTypes,
|
||||
beginTimestamp,
|
||||
endTimestamp
|
||||
)
|
||||
for (const sessionId of pendingSessionIds) {
|
||||
const stats = batchedStatsMap[sessionId]
|
||||
if (!stats) continue
|
||||
resultMap[sessionId] = stats
|
||||
resultMap[sessionId] = stats
|
||||
if (!useRangeFilter) {
|
||||
const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations)
|
||||
cacheMeta[sessionId] = {
|
||||
updatedAt,
|
||||
@@ -7140,19 +7360,56 @@ class ChatService {
|
||||
} catch {
|
||||
usedBatchedCompute = false
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const batchedStatsMap = await this.computeSessionExportStatsBatch(
|
||||
pendingSessionIds,
|
||||
includeRelations,
|
||||
selfIdentitySet,
|
||||
preferAccurateSpecialTypes,
|
||||
beginTimestamp,
|
||||
endTimestamp
|
||||
)
|
||||
for (const sessionId of pendingSessionIds) {
|
||||
const stats = batchedStatsMap[sessionId]
|
||||
if (!stats) continue
|
||||
resultMap[sessionId] = stats
|
||||
if (!useRangeFilter) {
|
||||
const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations)
|
||||
cacheMeta[sessionId] = {
|
||||
updatedAt,
|
||||
stale: false,
|
||||
includeRelations,
|
||||
source: 'fresh'
|
||||
}
|
||||
}
|
||||
}
|
||||
usedBatchedCompute = true
|
||||
} catch {
|
||||
usedBatchedCompute = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!usedBatchedCompute) {
|
||||
await this.forEachWithConcurrency(pendingSessionIds, 3, async (sessionId) => {
|
||||
try {
|
||||
const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet, preferAccurateSpecialTypes)
|
||||
resultMap[sessionId] = stats
|
||||
const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations)
|
||||
cacheMeta[sessionId] = {
|
||||
updatedAt,
|
||||
stale: false,
|
||||
const stats = await this.getOrComputeSessionExportStats(
|
||||
sessionId,
|
||||
includeRelations,
|
||||
source: 'fresh'
|
||||
selfIdentitySet,
|
||||
preferAccurateSpecialTypes,
|
||||
beginTimestamp,
|
||||
endTimestamp
|
||||
)
|
||||
resultMap[sessionId] = stats
|
||||
if (!useRangeFilter) {
|
||||
const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations)
|
||||
cacheMeta[sessionId] = {
|
||||
updatedAt,
|
||||
stale: false,
|
||||
includeRelations,
|
||||
source: 'fresh'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
resultMap[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations)
|
||||
@@ -8892,7 +9149,11 @@ class ChatService {
|
||||
private normalizeTimestampSeconds(value: number): number {
|
||||
const numeric = Number(value || 0)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return 0
|
||||
return numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric)
|
||||
let normalized = Math.floor(numeric)
|
||||
while (normalized > 10000000000) {
|
||||
normalized = Math.floor(normalized / 1000)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private toSafeInt(value: unknown, fallback = 0): number {
|
||||
@@ -10532,8 +10793,8 @@ class ChatService {
|
||||
const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id)
|
||||
const serverId = this.getRowInt(row, ['server_id'], 0)
|
||||
const localType = this.getRowInt(row, ['local_type'], 0)
|
||||
const createTime = this.getRowInt(row, ['create_time'], 0)
|
||||
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime)
|
||||
const createTime = this.getRowTimestampSeconds(row, ['create_time', 'createTime', 'msg_time', 'msgTime', 'time'], 0)
|
||||
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime > 0 ? createTime * 1000 : 0)
|
||||
const rawIsSend = row.computed_is_send ?? row.is_send
|
||||
const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent)
|
||||
const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -516,27 +516,29 @@ class HttpService {
|
||||
limit: number,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
ascending: boolean
|
||||
ascending: boolean,
|
||||
useLiteMapping: boolean = true
|
||||
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
|
||||
try {
|
||||
// 使用固定 batch 大小(与 limit 相同或最多 500)来减少循环次数
|
||||
const batchSize = Math.min(limit, 500)
|
||||
// 深分页时放大 batch,避免 offset 很大时出现大量小批次循环。
|
||||
const batchSize = Math.min(2000, Math.max(500, limit))
|
||||
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
|
||||
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
|
||||
|
||||
const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
const cursorResult = await wcdbService.openMessageCursorLite(talker, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '打开消息游标失败' }
|
||||
}
|
||||
|
||||
const cursor = cursorResult.cursor
|
||||
try {
|
||||
const allRows: Record<string, any>[] = []
|
||||
const collectedRows: Record<string, any>[] = []
|
||||
let hasMore = true
|
||||
let skipped = 0
|
||||
let reachedLimit = false
|
||||
|
||||
// 循环获取消息,处理 offset 跳过 + limit 累积
|
||||
while (allRows.length < limit && hasMore) {
|
||||
while (collectedRows.length < limit && hasMore) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batch.success || !batch.rows || batch.rows.length === 0) {
|
||||
hasMore = false
|
||||
@@ -557,12 +559,20 @@ class HttpService {
|
||||
skipped = offset
|
||||
}
|
||||
|
||||
allRows.push(...rows)
|
||||
const remainingCapacity = limit - collectedRows.length
|
||||
if (rows.length > remainingCapacity) {
|
||||
collectedRows.push(...rows.slice(0, remainingCapacity))
|
||||
reachedLimit = true
|
||||
break
|
||||
}
|
||||
|
||||
collectedRows.push(...rows)
|
||||
}
|
||||
|
||||
const trimmedRows = allRows.slice(0, limit)
|
||||
const finalHasMore = hasMore || allRows.length > limit
|
||||
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
||||
const finalHasMore = hasMore || reachedLimit
|
||||
const messages = useLiteMapping
|
||||
? chatService.mapRowsToMessagesLiteForApi(collectedRows)
|
||||
: chatService.mapRowsToMessagesForApi(collectedRows)
|
||||
await this.backfillMissingSenderUsernames(talker, messages)
|
||||
return { success: true, messages, hasMore: finalHasMore }
|
||||
} finally {
|
||||
@@ -590,32 +600,70 @@ class HttpService {
|
||||
if (targets.length === 0) return
|
||||
|
||||
const myWxid = (this.configService.get('myWxid') || '').trim()
|
||||
for (const msg of targets) {
|
||||
const localId = Number(msg.localId || 0)
|
||||
if (Number.isFinite(localId) && localId > 0) {
|
||||
try {
|
||||
const detail = await wcdbService.getMessageById(talker, localId)
|
||||
if (detail.success && detail.message) {
|
||||
const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0]
|
||||
if (hydrated?.senderUsername) {
|
||||
msg.senderUsername = hydrated.senderUsername
|
||||
}
|
||||
if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) {
|
||||
msg.isSend = hydrated.isSend
|
||||
}
|
||||
if (!msg.rawContent && hydrated?.rawContent) {
|
||||
msg.rawContent = hydrated.rawContent
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HttpService] backfill sender failed:', error)
|
||||
const MAX_DETAIL_BACKFILL = 120
|
||||
if (targets.length > MAX_DETAIL_BACKFILL) {
|
||||
for (const msg of targets) {
|
||||
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
|
||||
msg.senderUsername = myWxid
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
|
||||
msg.senderUsername = myWxid
|
||||
const queue = [...targets]
|
||||
const workerCount = Math.max(1, Math.min(6, queue.length))
|
||||
const state = {
|
||||
attempted: 0,
|
||||
hydrated: 0,
|
||||
consecutiveMiss: 0
|
||||
}
|
||||
const MAX_DETAIL_LOOKUPS = 80
|
||||
const MAX_CONSECUTIVE_MISS = 36
|
||||
const runWorker = async (): Promise<void> => {
|
||||
while (queue.length > 0) {
|
||||
if (state.attempted >= MAX_DETAIL_LOOKUPS) break
|
||||
if (state.consecutiveMiss >= MAX_CONSECUTIVE_MISS && state.hydrated <= 0) break
|
||||
const msg = queue.shift()
|
||||
if (!msg) break
|
||||
|
||||
const localId = Number(msg.localId || 0)
|
||||
if (Number.isFinite(localId) && localId > 0) {
|
||||
state.attempted += 1
|
||||
try {
|
||||
const detail = await wcdbService.getMessageById(talker, localId)
|
||||
if (detail.success && detail.message) {
|
||||
const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0]
|
||||
if (hydrated?.senderUsername) {
|
||||
msg.senderUsername = hydrated.senderUsername
|
||||
}
|
||||
if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) {
|
||||
msg.isSend = hydrated.isSend
|
||||
}
|
||||
if (!msg.rawContent && hydrated?.rawContent) {
|
||||
msg.rawContent = hydrated.rawContent
|
||||
}
|
||||
if (msg.senderUsername) {
|
||||
state.hydrated += 1
|
||||
state.consecutiveMiss = 0
|
||||
} else {
|
||||
state.consecutiveMiss += 1
|
||||
}
|
||||
} else {
|
||||
state.consecutiveMiss += 1
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[HttpService] backfill sender failed:', error)
|
||||
state.consecutiveMiss += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
|
||||
msg.senderUsername = myWxid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, () => runWorker()))
|
||||
}
|
||||
|
||||
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
||||
@@ -663,7 +711,7 @@ class HttpService {
|
||||
const talker = (url.searchParams.get('talker') || '').trim()
|
||||
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||
const keyword = (url.searchParams.get('keyword') || '').trim().toLowerCase()
|
||||
const keyword = (url.searchParams.get('keyword') || '').trim()
|
||||
const startParam = url.searchParams.get('start')
|
||||
const endParam = url.searchParams.get('end')
|
||||
const chatlab = this.parseBooleanParam(url, ['chatlab'], false)
|
||||
@@ -683,26 +731,41 @@ class HttpService {
|
||||
|
||||
const startTime = this.parseTimeParam(startParam)
|
||||
const endTime = this.parseTimeParam(endParam, true)
|
||||
const queryOffset = keyword ? 0 : offset
|
||||
const queryLimit = keyword ? 10000 : limit
|
||||
|
||||
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false)
|
||||
if (!result.success || !result.messages) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||
return
|
||||
}
|
||||
|
||||
let messages = result.messages
|
||||
let hasMore = result.hasMore === true
|
||||
let messages: Message[] = []
|
||||
let hasMore = false
|
||||
|
||||
if (keyword) {
|
||||
const filtered = messages.filter((msg) => {
|
||||
const content = (msg.parsedContent || msg.rawContent || '').toLowerCase()
|
||||
return content.includes(keyword)
|
||||
})
|
||||
const endIndex = offset + limit
|
||||
hasMore = filtered.length > endIndex
|
||||
messages = filtered.slice(offset, endIndex)
|
||||
const searchLimit = Math.max(1, limit) + 1
|
||||
const searchResult = await chatService.searchMessages(
|
||||
keyword,
|
||||
talker,
|
||||
searchLimit,
|
||||
offset,
|
||||
startTime,
|
||||
endTime
|
||||
)
|
||||
if (!searchResult.success || !searchResult.messages) {
|
||||
this.sendError(res, 500, searchResult.error || 'Failed to search messages')
|
||||
return
|
||||
}
|
||||
hasMore = searchResult.messages.length > limit
|
||||
messages = hasMore ? searchResult.messages.slice(0, limit) : searchResult.messages
|
||||
} else {
|
||||
const result = await this.fetchMessagesBatch(
|
||||
talker,
|
||||
offset,
|
||||
limit,
|
||||
startTime,
|
||||
endTime,
|
||||
false,
|
||||
!mediaOptions.enabled
|
||||
)
|
||||
if (!result.success || !result.messages) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||
return
|
||||
}
|
||||
messages = result.messages
|
||||
hasMore = result.hasMore === true
|
||||
}
|
||||
|
||||
const mediaMap = mediaOptions.enabled
|
||||
@@ -812,7 +875,7 @@ class HttpService {
|
||||
const endTime = endParam ? this.parseTimeParam(endParam, true) : 0
|
||||
|
||||
try {
|
||||
const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true)
|
||||
const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true, true)
|
||||
if (!result.success || !result.messages) {
|
||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||
return
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
12
src/App.tsx
12
src/App.tsx
@@ -81,6 +81,7 @@ function App() {
|
||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||
const isNotificationWindow = location.pathname === '/notification-window'
|
||||
const isAnnualReportWindow = location.pathname === '/annual-report/view'
|
||||
const isDualReportWindow = location.pathname === '/dual-report/view'
|
||||
const isSettingsRoute = location.pathname === '/settings'
|
||||
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||
const routeLocation = isSettingsRoute
|
||||
@@ -128,7 +129,7 @@ function App() {
|
||||
const body = document.body
|
||||
const appRoot = document.getElementById('app')
|
||||
|
||||
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) {
|
||||
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow || isDualReportWindow) {
|
||||
root.style.background = 'transparent'
|
||||
body.style.background = 'transparent'
|
||||
body.style.overflow = 'hidden'
|
||||
@@ -145,7 +146,7 @@ function App() {
|
||||
appRoot.style.overflow = ''
|
||||
}
|
||||
}
|
||||
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
|
||||
|
||||
// 应用主题
|
||||
useEffect(() => {
|
||||
@@ -166,7 +167,7 @@ function App() {
|
||||
}
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
|
||||
|
||||
// 读取已保存的主题设置
|
||||
useEffect(() => {
|
||||
@@ -517,6 +518,11 @@ function App() {
|
||||
return <AnnualReportWindow />
|
||||
}
|
||||
|
||||
// 独立双人报告全屏窗口
|
||||
if (isDualReportWindow) {
|
||||
return <DualReportWindow />
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
const handleCloseSettings = () => {
|
||||
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||
|
||||
@@ -299,6 +299,12 @@
|
||||
opacity: 0.05;
|
||||
box-shadow: none;
|
||||
filter: blur(80px);
|
||||
animation: coreBreathing 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes coreBreathing {
|
||||
0%, 100% { opacity: 0.03; transform: translate(-50%, -50%) scale(0.95); }
|
||||
50% { opacity: 0.06; transform: translate(-50%, -50%) scale(1.05); }
|
||||
}
|
||||
|
||||
/* S9: LEXICON (大气) */
|
||||
@@ -643,199 +649,160 @@
|
||||
}
|
||||
|
||||
#scene-8 {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0 6vw;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#scene-8 .s8-layout {
|
||||
/* V2 Background: Cinematic Aura */
|
||||
#scene-8 .s8-bg-layer {
|
||||
position: absolute;
|
||||
top: 18vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(1240px, 86vw);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
|
||||
column-gap: clamp(34px, 4.8vw, 84px);
|
||||
align-items: start;
|
||||
inset: -10%;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 2s 0.2s var(--ease-out);
|
||||
filter: blur(120px) contrast(1.1) brightness(0.6);
|
||||
pointer-events: none;
|
||||
|
||||
.bg-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
#scene-8 .s8-left {
|
||||
.scene.active #scene-8 .s8-bg-layer {
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
#scene-8 .s8-floating-layout {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
grid-template-rows: repeat(12, 1fr);
|
||||
padding: 10vh 8vw;
|
||||
}
|
||||
|
||||
/* The Central Pivot: Name & Meta */
|
||||
#scene-8 .s8-hero-unit {
|
||||
grid-column: 2 / 8;
|
||||
grid-row: 4 / 7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(2.5vh, 3.2vh, 4vh);
|
||||
padding-top: clamp(8vh, 9vh, 11vh);
|
||||
justify-content: center;
|
||||
|
||||
.s8-name {
|
||||
font-size: clamp(4.5rem, 10vw, 8.5rem);
|
||||
font-weight: 700;
|
||||
color: var(--c-text-bright);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
margin-bottom: 2vh;
|
||||
background: linear-gradient(135deg, var(--c-gold-strong), var(--c-text-bright), var(--c-gold-strong));
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shine 8s linear infinite;
|
||||
text-shadow: 0 0 40px rgba(var(--c-gold-rgb), 0.2);
|
||||
}
|
||||
|
||||
.s8-meta {
|
||||
font-family: 'SpaceMonoLocal';
|
||||
font-size: clamp(0.7rem, 0.85vw, 0.9rem);
|
||||
color: var(--c-gold-strong);
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5vw;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, rgba(var(--c-gold-rgb), 0.6), transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#scene-8 .s8-name-wrap,
|
||||
#scene-8 .s8-summary-wrap,
|
||||
#scene-8 .s8-quote-wrap,
|
||||
#scene-8 .s8-letter-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#scene-8 .s8-name {
|
||||
font-size: clamp(3.2rem, 7.4vw, 5.6rem);
|
||||
color: rgba(var(--c-gold-rgb), 0.88);
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
#scene-8 .s8-summary {
|
||||
max-width: 34ch;
|
||||
font-size: clamp(1.06rem, 1.35vw, 1.35rem);
|
||||
color: var(--c-text-soft);
|
||||
line-height: 1.95;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
#scene-8 .s8-summary-count {
|
||||
margin: 0 8px;
|
||||
font-size: clamp(1.35rem, 2vw, 1.75rem);
|
||||
color: var(--c-gold-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#scene-8 .s8-quote {
|
||||
max-width: 32ch;
|
||||
font-size: clamp(0.98rem, 1.12vw, 1.1rem);
|
||||
color: var(--c-text-muted);
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter-wrap {
|
||||
margin-top: clamp(3vh, 4vh, 5.5vh);
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter {
|
||||
position: relative;
|
||||
padding: clamp(24px, 3.2vh, 38px) clamp(20px, 2.6vw, 34px) clamp(24px, 3.2vh, 38px) clamp(30px, 3.2vw, 44px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(var(--c-gold-rgb), 0.34);
|
||||
background: linear-gradient(135deg, rgba(var(--c-gold-rgb), 0.16), rgba(var(--c-gold-rgb), 0.04));
|
||||
font-size: clamp(0.95rem, 1.05vw, 1.08rem);
|
||||
line-height: 2;
|
||||
color: var(--c-text-soft);
|
||||
text-align: left;
|
||||
text-shadow: 0 4px 16px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter::before {
|
||||
content: '';
|
||||
/* Fragmented Storytelling */
|
||||
#scene-8 .s8-fragments {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 14px;
|
||||
width: 2px;
|
||||
height: calc(100% - 40px);
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to bottom, rgba(var(--c-gold-rgb), 0.7), rgba(var(--c-gold-rgb), 0.08));
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#scene-8 .fragment {
|
||||
position: absolute;
|
||||
max-width: 24ch;
|
||||
font-size: clamp(0.95rem, 1.1vw, 1.15rem);
|
||||
line-height: 2.1;
|
||||
color: var(--c-text-muted);
|
||||
font-weight: 300;
|
||||
|
||||
&.f1 {
|
||||
top: 25vh;
|
||||
right: 12vw;
|
||||
text-align: right;
|
||||
color: var(--c-text-soft);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&.f2 {
|
||||
bottom: 20vh;
|
||||
left: 15vw;
|
||||
max-width: 38ch;
|
||||
}
|
||||
|
||||
&.f3 {
|
||||
bottom: 12vh;
|
||||
right: 10vw;
|
||||
text-align: right;
|
||||
opacity: 0.6;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shine {
|
||||
to { background-position: 200% center; }
|
||||
}
|
||||
|
||||
#scene-8 .s8-empty-wrap {
|
||||
display: block;
|
||||
width: min(760px, 78vw);
|
||||
margin-top: 24vh;
|
||||
grid-column: 4 / 10;
|
||||
grid-row: 5 / 8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#scene-8 .s8-empty-text {
|
||||
color: var(--c-text);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
#scene-8 .s8-layout {
|
||||
width: min(1120px, 88vw);
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
||||
column-gap: clamp(28px, 4vw, 56px);
|
||||
}
|
||||
|
||||
#scene-8 .s8-left {
|
||||
padding-top: clamp(6vh, 8vh, 9vh);
|
||||
.s8-empty-text {
|
||||
font-size: 1.6rem;
|
||||
line-height: 2.5;
|
||||
color: var(--c-text-soft);
|
||||
font-weight: 200;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
#scene-8 .s8-layout {
|
||||
top: 16vh;
|
||||
width: min(900px, 90vw);
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: clamp(3vh, 3.5vh, 4.5vh);
|
||||
#scene-8 .s8-hero-unit {
|
||||
grid-column: 2 / 12;
|
||||
grid-row: 2 / 5;
|
||||
}
|
||||
|
||||
#scene-8 .s8-left {
|
||||
padding-top: 0;
|
||||
gap: clamp(1.6vh, 2.2vh, 2.8vh);
|
||||
#scene-8 .fragment {
|
||||
position: relative;
|
||||
inset: auto !important;
|
||||
max-width: 100%;
|
||||
text-align: left !important;
|
||||
margin-top: 4vh;
|
||||
}
|
||||
|
||||
#scene-8 .s8-name {
|
||||
font-size: clamp(2.4rem, 8.4vw, 4.2rem);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
#scene-8 .s8-summary,
|
||||
#scene-8 .s8-quote {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter-wrap {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter {
|
||||
font-size: clamp(0.9rem, 1.9vw, 1rem);
|
||||
line-height: 1.95;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
#scene-8 .s8-layout {
|
||||
top: 14.5vh;
|
||||
width: 92vw;
|
||||
row-gap: clamp(2.2vh, 3vh, 3.8vh);
|
||||
}
|
||||
|
||||
#scene-8 .s8-name {
|
||||
font-size: clamp(2rem, 10vw, 3rem);
|
||||
}
|
||||
|
||||
#scene-8 .s8-summary {
|
||||
font-size: clamp(0.92rem, 3.9vw, 1rem);
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
#scene-8 .s8-summary-count {
|
||||
margin: 0 6px;
|
||||
font-size: clamp(1.1rem, 4.8vw, 1.35rem);
|
||||
}
|
||||
|
||||
#scene-8 .s8-quote {
|
||||
font-size: clamp(0.86rem, 3.5vw, 0.95rem);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter {
|
||||
border-radius: 14px;
|
||||
padding: 16px 16px 16px 24px;
|
||||
font-size: clamp(0.82rem, 3.4vw, 0.9rem);
|
||||
line-height: 1.82;
|
||||
}
|
||||
|
||||
#scene-8 .s8-letter::before {
|
||||
top: 16px;
|
||||
left: 11px;
|
||||
height: calc(100% - 32px);
|
||||
}
|
||||
|
||||
#scene-8 .s8-empty-wrap {
|
||||
width: 88vw;
|
||||
margin-top: 23vh;
|
||||
}
|
||||
|
||||
#scene-8 .s8-empty-text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.9;
|
||||
#scene-8 .s8-fragments {
|
||||
position: relative;
|
||||
grid-column: 2 / 12;
|
||||
grid-row: 6 / 12;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -590,7 +590,7 @@ function AnnualReportWindow() {
|
||||
<div className={`reveal-inner serif title-year ${yearTitleVariantClass} delay-1`}>{yearTitle}</div>
|
||||
</div>
|
||||
<div className="reveal-wrap desc-text p0-desc">
|
||||
<div className="reveal-inner serif delay-2 p0-desc-inner">那些被岁月悄悄掩埋的对话<br/>原来都在这里,等待一个春天。</div>
|
||||
<div className="reveal-inner serif delay-2 p0-desc-inner">那些被岁月悄悄掩埋的对话<br />原来都在这里,等待一个春天。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -606,7 +606,7 @@ function AnnualReportWindow() {
|
||||
</div>
|
||||
<div className="reveal-wrap desc-text">
|
||||
<div className="reveal-inner serif delay-2">
|
||||
这一年,你说出了 <strong className="num-display" style={{ color: COLOR.accentGold }}>{reportData.totalMessages.toLocaleString()}</strong> 句话。<br/>无数个日夜的碎碎念,都是为了在茫茫人海中,刻下彼此来过的痕迹。
|
||||
这一年,你说出了 <strong className="num-display" style={{ color: COLOR.accentGold }}>{reportData.totalMessages.toLocaleString()}</strong> 句话。<br />无数个日夜的碎碎念,都是为了在茫茫人海中,刻下彼此来过的痕迹。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -618,20 +618,20 @@ function AnnualReportWindow() {
|
||||
</div>
|
||||
<div className="reveal-wrap">
|
||||
<div className="reveal-inner serif title-time delay-1">
|
||||
{reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'}
|
||||
{reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap">
|
||||
<br/>
|
||||
<br />
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '1rem', color: 'var(--c-text-muted)', margin: '1vh 0' }}>
|
||||
在深夜陪你聊天最多的人
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap desc-text">
|
||||
<div className="reveal-inner serif delay-2">
|
||||
梦境之外,你与{reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'}共同醒着度过了许多个夜晚<br/>
|
||||
梦境之外,你与{reportData.midnightKing ? reportData.midnightKing.displayName : '00:00'}共同醒着度过了许多个夜晚<br />
|
||||
“曾有<strong className="num-display" style={{ color: COLOR.accentGold, margin: '0 10px', fontSize: '1.5rem' }}>
|
||||
<DecodeText value={(reportData.midnightKing?.count || 0).toLocaleString()} active={currentScene === 2} />
|
||||
<DecodeText value={(reportData.midnightKing?.count || 0).toLocaleString()} active={currentScene === 2} />
|
||||
</strong>条消息在那些无人知晓的夜里,代替星光照亮了彼此”
|
||||
</div>
|
||||
</div>
|
||||
@@ -689,56 +689,56 @@ function AnnualReportWindow() {
|
||||
{reportData.monthlyTopFriends.length > 0 ? (
|
||||
<div style={{ position: 'absolute', top: '55vh', left: '10vw', width: '80vw', height: '1px', background: 'transparent' }}>
|
||||
{reportData.monthlyTopFriends.map((m, i) => {
|
||||
const leftPos = (i / 11) * 100; // 0% to 100%
|
||||
const isTop = i % 2 === 0; // Alternate up and down to prevent crowding
|
||||
const isRightSide = i >= 6; // Center-focus alignment logic
|
||||
const leftPos = (i / 11) * 100; // 0% to 100%
|
||||
const isTop = i % 2 === 0; // Alternate up and down to prevent crowding
|
||||
const isRightSide = i >= 6; // Center-focus alignment logic
|
||||
|
||||
// Pseudo-random organic height variation for audio-wave feel (from 8vh to 18vh)
|
||||
const heightVariation = 12 + (Math.sin(i * 1.5) * 6);
|
||||
// Pseudo-random organic height variation for audio-wave feel (from 8vh to 18vh)
|
||||
const heightVariation = 12 + (Math.sin(i * 1.5) * 6);
|
||||
|
||||
const alignStyle = isRightSide ? { right: '10px', alignItems: 'flex-end', textAlign: 'right' as const } : { left: '10px', alignItems: 'flex-start', textAlign: 'left' as const };
|
||||
const alignStyle = isRightSide ? { right: '10px', alignItems: 'flex-end', textAlign: 'right' as const } : { left: '10px', alignItems: 'flex-start', textAlign: 'left' as const };
|
||||
|
||||
return (
|
||||
<div key={m.month} className="reveal-wrap float-el" style={{ position: 'absolute', left: `${leftPos}%`, top: 0, width: '1px', height: '1px', overflow: 'visible', animationDelay: `${-(i%4)*0.5}s` }}>
|
||||
return (
|
||||
<div key={m.month} className="reveal-wrap float-el" style={{ position: 'absolute', left: `${leftPos}%`, top: 0, width: '1px', height: '1px', overflow: 'visible', animationDelay: `${-(i % 4) * 0.5}s` }}>
|
||||
|
||||
{/* The connecting thread (gradient fades away from center line) */}
|
||||
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{
|
||||
position: 'absolute',
|
||||
left: '-0px',
|
||||
top: isTop ? `-${heightVariation}vh` : '0px',
|
||||
width: '1px',
|
||||
height: `${heightVariation}vh`,
|
||||
background: isTop
|
||||
? 'linear-gradient(to top, rgba(184,148,90,0.34), transparent)'
|
||||
: 'linear-gradient(to bottom, rgba(184,148,90,0.34), transparent)'
|
||||
}} />
|
||||
{/* The connecting thread (gradient fades away from center line) */}
|
||||
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{
|
||||
position: 'absolute',
|
||||
left: '-0px',
|
||||
top: isTop ? `-${heightVariation}vh` : '0px',
|
||||
width: '1px',
|
||||
height: `${heightVariation}vh`,
|
||||
background: isTop
|
||||
? 'linear-gradient(to top, rgba(184,148,90,0.34), transparent)'
|
||||
: 'linear-gradient(to bottom, rgba(184,148,90,0.34), transparent)'
|
||||
}} />
|
||||
|
||||
{/* Center Glowing Dot */}
|
||||
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{ position: 'absolute', left: '-2.5px', top: '-2.5px', width: '6px', height: '6px', borderRadius: '50%', background: 'rgba(184,148,90,0.72)', boxShadow: '0 0 10px rgba(184,148,90,0.34)' }} />
|
||||
{/* Center Glowing Dot */}
|
||||
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{ position: 'absolute', left: '-2.5px', top: '-2.5px', width: '6px', height: '6px', borderRadius: '50%', background: 'rgba(184,148,90,0.72)', boxShadow: '0 0 10px rgba(184,148,90,0.34)' }} />
|
||||
|
||||
{/* Text Payload */}
|
||||
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{
|
||||
position: 'absolute',
|
||||
...alignStyle,
|
||||
top: isTop ? `-${heightVariation + 2}vh` : `${heightVariation}vh`,
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '20vw' // ample space to avoid wrapping
|
||||
}}>
|
||||
<div className="mono num-display" style={{ fontSize: '0.9rem', color: COLOR.textFaint, marginBottom: '4px', letterSpacing: '0.1em' }}>
|
||||
{m.month.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="serif" style={{ fontSize: 'clamp(1rem, 2vw, 1.4rem)', color: COLOR.textStrong, letterSpacing: '0.05em' }}>
|
||||
{m.displayName}
|
||||
</div>
|
||||
<div className="mono num-display" style={{ fontSize: '0.65rem', color: COLOR.textMuted, marginTop: '4px', letterSpacing: '0.1em' }}>
|
||||
{m.messageCount.toLocaleString()} M
|
||||
</div>
|
||||
{/* Text Payload */}
|
||||
<div className={`reveal-inner delay-${(i % 5) + 1}`} style={{
|
||||
position: 'absolute',
|
||||
...alignStyle,
|
||||
top: isTop ? `-${heightVariation + 2}vh` : `${heightVariation}vh`,
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '20vw' // ample space to avoid wrapping
|
||||
}}>
|
||||
<div className="mono num-display" style={{ fontSize: '0.9rem', color: COLOR.textFaint, marginBottom: '4px', letterSpacing: '0.1em' }}>
|
||||
{m.month.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="serif" style={{ fontSize: 'clamp(1rem, 2vw, 1.4rem)', color: COLOR.textStrong, letterSpacing: '0.05em' }}>
|
||||
{m.displayName}
|
||||
</div>
|
||||
<div className="mono num-display" style={{ fontSize: '0.65rem', color: COLOR.textMuted, marginTop: '4px', letterSpacing: '0.1em' }}>
|
||||
{m.messageCount.toLocaleString()} M
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
@@ -757,29 +757,29 @@ function AnnualReportWindow() {
|
||||
<>
|
||||
<div className="reveal-wrap desc-text" style={{ position: 'absolute', top: '20vh' }}>
|
||||
<div className="reveal-inner serif delay-1" style={{ fontSize: 'clamp(3rem, 7vw, 4rem)', color: COLOR.accentGold, letterSpacing: '0.05em' }}>
|
||||
{reportData.mutualFriend.displayName}
|
||||
{reportData.mutualFriend.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', top: '42vh', left: '15vw' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-2" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}>发出</div>
|
||||
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', color: COLOR.accentGold, marginTop: '10px' }}><DecodeText value={reportData.mutualFriend.sentCount.toLocaleString()} active={currentScene === 5} /></div>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-2" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}>发出</div>
|
||||
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', color: COLOR.accentGold, marginTop: '10px' }}><DecodeText value={reportData.mutualFriend.sentCount.toLocaleString()} active={currentScene === 5} /></div>
|
||||
</div>
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', top: '42vh', right: '15vw', textAlign: 'right' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-2" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}>收到</div>
|
||||
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', color: COLOR.accentGold, marginTop: '10px' }}><DecodeText value={reportData.mutualFriend.receivedCount.toLocaleString()} active={currentScene === 5} /></div>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-2" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}>收到</div>
|
||||
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(2rem, 5vw, 3.5rem)', color: COLOR.accentGold, marginTop: '10px' }}><DecodeText value={reportData.mutualFriend.receivedCount.toLocaleString()} active={currentScene === 5} /></div>
|
||||
</div>
|
||||
|
||||
<div className="reveal-wrap desc-text" style={{ position: 'absolute', bottom: '20vh' }}>
|
||||
<div className="reveal-inner serif delay-3">
|
||||
你们之间收发的消息高达 <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.5rem' }}>{reportData.mutualFriend.ratio}</strong> 的平衡率
|
||||
<br/>
|
||||
<span style={{ fontSize: '1rem', color: COLOR.textMuted, marginTop: '15px', display: 'block' }}>“你抛出的每一句话,都落在了对方的心里。<br/>所谓重逢,就是我走向你的时候,你也在走向我。”</span>
|
||||
<br />
|
||||
<span style={{ fontSize: '1rem', color: COLOR.textMuted, marginTop: '15px', display: 'block' }}>“你抛出的每一句话,都落在了对方的心里。<br />所谓重逢,就是我走向你的时候,你也在走向我。”</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">今年似乎独自咽下了很多话。<br/>请相信,分别和孤独总会迎来终结,你终会遇到那个懂你的TA。</div></div>
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">今年似乎独自咽下了很多话。<br />请相信,分别和孤独总会迎来终结,你终会遇到那个懂你的TA。</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -790,45 +790,45 @@ function AnnualReportWindow() {
|
||||
</div>
|
||||
{reportData.socialInitiative || reportData.responseSpeed ? (
|
||||
<div style={{ position: 'absolute', top: '0', left: '0', width: '100%', height: '100%' }}>
|
||||
{reportData.socialInitiative && (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', top: '28vh', left: '15vw', width: '38vw', textAlign: 'left' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}>我的主动性</div>
|
||||
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(4.5rem, 8vw, 7rem)', color: COLOR.accentGold, lineHeight: '1', margin: '2vh 0' }}>
|
||||
{reportData.socialInitiative.initiativeRate}%
|
||||
{reportData.socialInitiative && (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', top: '28vh', left: '15vw', width: '38vw', textAlign: 'left' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.2em' }}>我的主动性</div>
|
||||
<div className="reveal-inner num-display delay-2" style={{ fontSize: 'clamp(4.5rem, 8vw, 7rem)', color: COLOR.accentGold, lineHeight: '1', margin: '2vh 0' }}>
|
||||
{reportData.socialInitiative.initiativeRate}%
|
||||
</div>
|
||||
<div className="reveal-inner serif delay-3" style={{ fontSize: '1.2rem', color: COLOR.textSoft, lineHeight: '1.8' }}>
|
||||
<div style={{ fontSize: '1.3rem', color: COLOR.textStrong, marginBottom: '0.6vh' }}>
|
||||
你的聊天开场大多由你发起。
|
||||
</div>
|
||||
<div className="reveal-inner serif delay-3" style={{ fontSize: '1.2rem', color: COLOR.textSoft, lineHeight: '1.8' }}>
|
||||
<div style={{ fontSize: '1.3rem', color: COLOR.textStrong, marginBottom: '0.6vh' }}>
|
||||
你的聊天开场大多由你发起。
|
||||
{reportData.socialInitiative.topInitiatedFriend && (reportData.socialInitiative.topInitiatedCount || 0) > 0 ? (
|
||||
<div style={{ marginBottom: '0.6vh' }}>
|
||||
其中<strong style={{ color: COLOR.accentGold }}>{reportData.socialInitiative.topInitiatedFriend}</strong>是你最常联系的人,
|
||||
有<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.2rem', margin: '0 4px' }}>{(reportData.socialInitiative.topInitiatedCount || 0).toLocaleString()}</strong>次,是你先忍不住敲响了对方的门
|
||||
</div>
|
||||
{reportData.socialInitiative.topInitiatedFriend && (reportData.socialInitiative.topInitiatedCount || 0) > 0 ? (
|
||||
<div style={{ marginBottom: '0.6vh' }}>
|
||||
其中<strong style={{ color: COLOR.accentGold }}>{reportData.socialInitiative.topInitiatedFriend}</strong>是你最常联系的人,
|
||||
有<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.2rem', margin: '0 4px' }}>{(reportData.socialInitiative.topInitiatedCount || 0).toLocaleString()}</strong>次,是你先忍不住敲响了对方的门
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: '0.6vh' }}>
|
||||
你主动发起了<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.2rem', margin: '0 4px' }}>{reportData.socialInitiative.initiatedChats.toLocaleString()}</strong>次联络。
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontSize: '0.9rem', color: COLOR.textMuted }}>想见一个人的心,总是走在时间的前面。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: '0.6vh' }}>
|
||||
你主动发起了<strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.2rem', margin: '0 4px' }}>{reportData.socialInitiative.initiatedChats.toLocaleString()}</strong>次联络。
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontSize: '0.9rem', color: COLOR.textMuted }}>想见一个人的心,总是走在时间的前面。</span>
|
||||
</div>
|
||||
)}
|
||||
{reportData.responseSpeed && (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', bottom: '22vh', right: '15vw', width: '38vw', textAlign: 'right' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em' }}>回应速度</div>
|
||||
<div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(3.5rem, 6vw, 5rem)', color: COLOR.accentSoft, lineHeight: '1', margin: '2vh 0' }}>
|
||||
<DecodeText value={reportData.responseSpeed.fastestTime} active={currentScene === 6} />S
|
||||
</div>
|
||||
<div className="reveal-inner serif delay-6" style={{ fontSize: '1.2rem', color: COLOR.textSoft, lineHeight: '1.8' }}>
|
||||
<strong style={{ color: COLOR.accentGold }}>{reportData.responseSpeed.fastestFriend}</strong> 回你的消息总是很快。<br/>
|
||||
<span style={{ fontSize: '0.9rem', color: COLOR.textMuted }}>这世上最让人安心的默契,莫过于一句 "我在"。</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{reportData.responseSpeed && (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', bottom: '22vh', right: '15vw', width: '38vw', textAlign: 'right' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em' }}>回应速度</div>
|
||||
<div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(3.5rem, 6vw, 5rem)', color: COLOR.accentSoft, lineHeight: '1', margin: '2vh 0' }}>
|
||||
<DecodeText value={reportData.responseSpeed.fastestTime} active={currentScene === 6} />S
|
||||
</div>
|
||||
)}
|
||||
<div className="reveal-inner serif delay-6" style={{ fontSize: '1.2rem', color: COLOR.textSoft, lineHeight: '1.8' }}>
|
||||
<strong style={{ color: COLOR.accentGold }}>{reportData.responseSpeed.fastestFriend}</strong> 回你的消息总是很快。<br />
|
||||
<span style={{ fontSize: '0.9rem', color: COLOR.textMuted }}>这世上最让人安心的默契,莫过于一句 "我在"。</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">暂无数据。</div></div>
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">暂无数据。</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -839,31 +839,31 @@ function AnnualReportWindow() {
|
||||
</div>
|
||||
|
||||
{reportData.longestStreak ? (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', top: '35vh', left: '15vw', textAlign: 'left' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em', marginBottom: '2vh' }}>最长连续聊天</div>
|
||||
<div className="reveal-inner serif delay-2" style={{ fontSize: 'clamp(3rem, 6vw, 5rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
|
||||
{reportData.longestStreak.friendName}
|
||||
</div>
|
||||
<div className="reveal-inner serif delay-3" style={{ fontSize: '1.2rem', color: COLOR.textSoft, marginTop: '2vh' }}>
|
||||
你们曾连续 <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.8rem' }}><DecodeText value={reportData.longestStreak.days} active={currentScene === 7} /></strong> 天,聊到忘记了时间,<br/>那些舍不得说再见的日夜,连成了最漫长的春天。
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', top: '35vh', left: '15vw', textAlign: 'left' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-1" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em', marginBottom: '2vh' }}>最长连续聊天</div>
|
||||
<div className="reveal-inner serif delay-2" style={{ fontSize: 'clamp(3rem, 6vw, 5rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
|
||||
{reportData.longestStreak.friendName}
|
||||
</div>
|
||||
<div className="reveal-inner serif delay-3" style={{ fontSize: '1.2rem', color: COLOR.textSoft, marginTop: '2vh' }}>
|
||||
你们曾连续 <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.8rem' }}><DecodeText value={reportData.longestStreak.days} active={currentScene === 7} /></strong> 天,聊到忘记了时间,<br />那些舍不得说再见的日夜,连成了最漫长的春天。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{reportData.peakDay ? (
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', bottom: '30vh', right: '15vw', textAlign: 'right' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em', marginBottom: '2vh' }}>最热烈的一天</div>
|
||||
<div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(2.5rem, 5vw, 4rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
|
||||
{reportData.peakDay.date}
|
||||
</div>
|
||||
<div className="reveal-inner serif delay-6" style={{ fontSize: '1.2rem', color: COLOR.textSoft, marginTop: '2vh' }}>
|
||||
“这一天,你们留下了 <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.8rem' }}>{reportData.peakDay.messageCount}</strong> 句话。<br/>好像要把积攒了很久的想念,一天全都说完。”
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap" style={{ position: 'absolute', bottom: '30vh', right: '15vw', textAlign: 'right' }}>
|
||||
<div className="reveal-inner serif scene0-cn-tag delay-4" style={{ fontSize: '0.8rem', color: COLOR.textMuted, letterSpacing: '0.3em', marginBottom: '2vh' }}>最热烈的一天</div>
|
||||
<div className="reveal-inner num-display delay-5" style={{ fontSize: 'clamp(2.5rem, 5vw, 4rem)', color: COLOR.accentGold, letterSpacing: '0.02em' }}>
|
||||
{reportData.peakDay.date}
|
||||
</div>
|
||||
<div className="reveal-inner serif delay-6" style={{ fontSize: '1.2rem', color: COLOR.textSoft, marginTop: '2vh' }}>
|
||||
“这一天,你们留下了 <strong className="num-display" style={{ color: COLOR.accentGold, fontSize: '1.8rem' }}>{reportData.peakDay.messageCount}</strong> 句话。<br />好像要把积攒了很久的想念,一天全都说完。”
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!reportData.longestStreak && !reportData.peakDay && (
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">没有激起过火花。</div></div>
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">没有激起过火花。</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -872,45 +872,69 @@ function AnnualReportWindow() {
|
||||
<div className="reveal-wrap en-tag">
|
||||
<div className="reveal-inner serif scene0-cn-tag">曾经的好友</div>
|
||||
</div>
|
||||
|
||||
{reportData.lostFriend && (
|
||||
<div className="s8-bg-layer">
|
||||
<img src={reportData.lostFriend.avatarUrl} alt="" className="bg-avatar" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{reportData.lostFriend ? (
|
||||
<div className="s8-layout">
|
||||
<div className="s8-left">
|
||||
<div className="reveal-wrap s8-name-wrap">
|
||||
<div className="reveal-inner serif delay-1 s8-name">
|
||||
<div className="s8-floating-layout">
|
||||
<div className="s8-hero-unit">
|
||||
<div className="reveal-wrap">
|
||||
<div className="reveal-inner s8-name delay-1">
|
||||
{reportData.lostFriend.displayName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap s8-summary-wrap">
|
||||
<div className="reveal-inner serif delay-2 s8-summary">
|
||||
后来,你们的交集停留在{reportData.lostFriend.periodDesc}这短短的
|
||||
<span className="num-display s8-summary-count">
|
||||
<div className="reveal-wrap">
|
||||
<div className="reveal-inner s8-meta delay-2">
|
||||
{reportData.lostFriend.periodDesc} /
|
||||
<span className="num-display" style={{ margin: '0 10px', fontSize: '1.4em' }}>
|
||||
<DecodeText value={reportData.lostFriend.lateCount.toLocaleString()} active={currentScene === 8} />
|
||||
</span>
|
||||
句话里。
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap s8-quote-wrap">
|
||||
<div className="reveal-inner serif delay-3 s8-quote">
|
||||
“我一直相信我们能够再次相见,相信分别的日子总会迎来终结。”
|
||||
MESSAGES
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap s8-letter-wrap">
|
||||
<div className="reveal-inner serif delay-4 s8-letter">
|
||||
所有的离散,或许都只是一场漫长的越冬。飞鸟要越过一万座雪山,才能带来春天的第一行回信;树木要褪去一万次枯叶,才能记住风的形状。如果时间注定要把我们推向不同的象限,那就在记忆的最深处建一座灯塔。哪怕要熬过几千个无法见面的黄昏,也要相信,总有一次日出的晨光,是为了照亮我们重逢的归途。
|
||||
|
||||
<div className="s8-fragments">
|
||||
<div className="reveal-wrap fragment f1">
|
||||
<div className="reveal-inner delay-3">
|
||||
“我一直相信我们能够再次相见,<br />相信分别的日子总会迎来终结。”
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="reveal-wrap fragment f2">
|
||||
<div className="reveal-inner delay-4">
|
||||
所有的离散,或许都只是一场漫长的越冬。<br />
|
||||
飞鸟要越过一万座雪山,才能带来春天的第一行回信;<br />
|
||||
树木要褪去一万次枯叶,才能记住风的形状。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="reveal-wrap fragment f3">
|
||||
<div className="reveal-inner delay-5">
|
||||
哪怕要熬过几千个无法见面的黄昏,也要相信,<br />
|
||||
总有一次日出的晨光,是为了照亮我们重逢的归途。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="reveal-wrap desc-text s8-empty-wrap">
|
||||
<div className="reveal-inner serif delay-1 s8-empty-text">
|
||||
缘分温柔地眷顾着你。<br/>
|
||||
这一年,所有重要的人都在,没有一次无疾而终的告别。<br/>
|
||||
<div className="s8-floating-layout">
|
||||
<div className="reveal-wrap s8-empty-wrap">
|
||||
<div className="reveal-inner serif s8-empty-text delay-1">
|
||||
缘分温柔地眷顾着你。<br />
|
||||
这一年,所有重要的人都在,没有一次无疾而终的告别。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* S9: LEXICON & ARCHIVE */}
|
||||
<div className={getSceneClass(9)} id="scene-9">
|
||||
<div className="reveal-wrap en-tag">
|
||||
@@ -953,7 +977,7 @@ function AnnualReportWindow() {
|
||||
)
|
||||
})}
|
||||
{(!reportData.topPhrases || reportData.topPhrases.length === 0) && (
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">词汇量太少,无法形成星云。</div></div>
|
||||
<div className="reveal-wrap desc-text" style={{ marginTop: '25vh' }}><div className="reveal-inner serif delay-1">词汇量太少,无法形成星云。</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -990,7 +1014,7 @@ function AnnualReportWindow() {
|
||||
</div>
|
||||
|
||||
<div className="serif" style={{ fontSize: '1.2rem', color: 'rgba(34, 28, 16, 0.82)', marginTop: '4vh', letterSpacing: '0.05em' }}>
|
||||
“故事的最后,我们把这一切悄悄还给岁月<br/>只要这些文字还在,所有的离别,就都只是一场短暂的缺席。”
|
||||
“故事的最后,我们把这一切悄悄还给岁月<br />只要这些文字还在,所有的离别,就都只是一场短暂的缺席。”
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1009,7 +1033,7 @@ function AnnualReportWindow() {
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
数据数得清一万句落笔的寒暄,却度量不出一个默契的眼神。<br/>在这片由数字构建的大海里,热烈的回应未必是感情的全部轮廓。<br/>真正的爱与羁绊,从来都不在跳动的屏幕里,而在无法被量化的现实。
|
||||
数据数得清一万句落笔的寒暄,却度量不出一个默契的眼神。<br />在这片由数字构建的大海里,热烈的回应未必是感情的全部轮廓。<br />真正的爱与羁绊,从来都不在跳动的屏幕里,而在无法被量化的现实。
|
||||
</div>
|
||||
</div>
|
||||
<div className="reveal-wrap">
|
||||
|
||||
@@ -3445,10 +3445,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (result.success && result.messages) {
|
||||
const resultMessages = result.messages
|
||||
if (offset === 0) {
|
||||
setNoMessageTable(false)
|
||||
setMessages(resultMessages)
|
||||
persistSessionPreviewCache(sessionId, resultMessages)
|
||||
if (resultMessages.length === 0) {
|
||||
setNoMessageTable(true)
|
||||
setHasMoreMessages(false)
|
||||
}
|
||||
|
||||
@@ -3549,7 +3549,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
: offset + resultMessages.length
|
||||
setCurrentOffset(nextOffset)
|
||||
} else if (!result.success) {
|
||||
setNoMessageTable(true)
|
||||
const errorText = String(result.error || '')
|
||||
const shouldMarkNoTable =
|
||||
/schema mismatch|no message db|no table|消息数据库未找到|消息表|message schema/i.test(errorText)
|
||||
setNoMessageTable(shouldMarkNoTable)
|
||||
setHasMoreMessages(false)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -3557,6 +3560,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
setConnectionError('加载消息失败')
|
||||
setHasMoreMessages(false)
|
||||
if (offset === 0 && currentSessionRef.current === sessionId) {
|
||||
setNoMessageTable(false)
|
||||
setMessages([])
|
||||
}
|
||||
} finally {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1899,7 +1899,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
? `缓存命中 ${mediaCacheHitFiles}/${mediaCacheTotal}`
|
||||
: ''
|
||||
const mediaMissMetricLabel = mediaCacheMissFiles > 0
|
||||
? `未导出 ${mediaCacheMissFiles} 个文件/媒体`
|
||||
? `缓存未命中 ${mediaCacheMissFiles}`
|
||||
: ''
|
||||
const mediaDedupMetricLabel = mediaDedupReuseFiles > 0
|
||||
? `复用 ${mediaDedupReuseFiles}`
|
||||
@@ -1914,7 +1914,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
)
|
||||
: ''
|
||||
const mediaLiveMetricLabel = task.progress.phase === 'exporting-media'
|
||||
? (mediaDoneFiles > 0 ? `已处理 ${mediaDoneFiles}` : '')
|
||||
? (mediaDoneFiles > 0 ? `已写入 ${mediaDoneFiles}` : '')
|
||||
: ''
|
||||
const sessionProgressLabel = completedSessionTotal > 0
|
||||
? `会话 ${completedSessionCount}/${completedSessionTotal}`
|
||||
@@ -2238,6 +2238,27 @@ function ExportPage() {
|
||||
exportConcurrency: 2
|
||||
})
|
||||
|
||||
const exportStatsRangeOptions = useMemo(() => {
|
||||
if (options.useAllTime || !options.dateRange) return null
|
||||
const beginTimestamp = Math.floor(options.dateRange.start.getTime() / 1000)
|
||||
const endTimestamp = Math.floor(options.dateRange.end.getTime() / 1000)
|
||||
if (!Number.isFinite(beginTimestamp) || !Number.isFinite(endTimestamp)) return null
|
||||
if (beginTimestamp <= 0 && endTimestamp <= 0) return null
|
||||
return {
|
||||
beginTimestamp: Math.max(0, beginTimestamp),
|
||||
endTimestamp: Math.max(0, endTimestamp)
|
||||
}
|
||||
}, [options.useAllTime, options.dateRange])
|
||||
|
||||
const withExportStatsRange = useCallback((statsOptions: Record<string, any>): Record<string, any> => {
|
||||
if (!exportStatsRangeOptions) return statsOptions
|
||||
return {
|
||||
...statsOptions,
|
||||
beginTimestamp: exportStatsRangeOptions.beginTimestamp,
|
||||
endTimestamp: exportStatsRangeOptions.endTimestamp
|
||||
}
|
||||
}, [exportStatsRangeOptions])
|
||||
|
||||
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
|
||||
open: false,
|
||||
intent: 'manual',
|
||||
@@ -4003,7 +4024,7 @@ function ExportPage() {
|
||||
const cacheResult = await withTimeout(
|
||||
window.electronAPI.chat.getExportSessionStats(
|
||||
batchSessionIds,
|
||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||
withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true })
|
||||
),
|
||||
12000,
|
||||
'cacheOnly'
|
||||
@@ -4018,7 +4039,7 @@ function ExportPage() {
|
||||
const freshResult = await withTimeout(
|
||||
window.electronAPI.chat.getExportSessionStats(
|
||||
missingSessionIds,
|
||||
{ includeRelations: false, allowStaleCache: true }
|
||||
withExportStatsRange({ includeRelations: false, allowStaleCache: true })
|
||||
),
|
||||
45000,
|
||||
'fresh'
|
||||
@@ -4062,7 +4083,7 @@ function ExportPage() {
|
||||
void runSessionMediaMetricWorker(runId)
|
||||
}
|
||||
}
|
||||
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage])
|
||||
}, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage, withExportStatsRange])
|
||||
|
||||
const scheduleSessionMediaMetricWorker = useCallback(() => {
|
||||
if (activeTaskCountRef.current > 0) return
|
||||
@@ -7243,7 +7264,7 @@ function ExportPage() {
|
||||
try {
|
||||
const quickStatsResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: false, allowStaleCache: true, cacheOnly: true }
|
||||
withExportStatsRange({ includeRelations: false, allowStaleCache: true, cacheOnly: true })
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (quickStatsResult.success) {
|
||||
@@ -7270,7 +7291,7 @@ function ExportPage() {
|
||||
try {
|
||||
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
|
||||
withExportStatsRange({ includeRelations: true, allowStaleCache: true, cacheOnly: true })
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (relationCacheResult.success && relationCacheResult.data) {
|
||||
@@ -7295,7 +7316,7 @@ function ExportPage() {
|
||||
// 后台补齐非关系统计,不走精确特型扫描,避免阻塞列表统计队列。
|
||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: false, forceRefresh: true }
|
||||
withExportStatsRange({ includeRelations: false, forceRefresh: true })
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
if (freshResult.success && freshResult.data) {
|
||||
@@ -7330,7 +7351,7 @@ function ExportPage() {
|
||||
setIsLoadingSessionDetailExtra(false)
|
||||
}
|
||||
}
|
||||
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername])
|
||||
}, [applySessionDetailStats, contactByUsername, mergeSessionContentMetrics, sessionContentMetrics, sessionMessageCounts, sessionRowByUsername, withExportStatsRange])
|
||||
|
||||
const loadSessionRelationStats = useCallback(async (options?: { forceRefresh?: boolean }) => {
|
||||
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
|
||||
@@ -7343,7 +7364,7 @@ function ExportPage() {
|
||||
if (!forceRefresh) {
|
||||
const relationCacheResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: true, allowStaleCache: true, cacheOnly: true }
|
||||
withExportStatsRange({ includeRelations: true, allowStaleCache: true, cacheOnly: true })
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
|
||||
@@ -7361,7 +7382,7 @@ function ExportPage() {
|
||||
|
||||
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
||||
[normalizedSessionId],
|
||||
{ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true }
|
||||
withExportStatsRange({ includeRelations: true, forceRefresh, preferAccurateSpecialTypes: true })
|
||||
)
|
||||
if (requestSeq !== detailRequestSeqRef.current) return
|
||||
|
||||
@@ -7381,7 +7402,7 @@ function ExportPage() {
|
||||
setIsLoadingSessionRelationStats(false)
|
||||
}
|
||||
}
|
||||
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
|
||||
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid, withExportStatsRange])
|
||||
|
||||
const handleRefreshTableData = useCallback(async () => {
|
||||
const scopeKey = await ensureExportCacheScope()
|
||||
|
||||
3
src/types/electron.d.ts
vendored
3
src/types/electron.d.ts
vendored
@@ -311,6 +311,8 @@ export interface ElectronAPI {
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
@@ -1220,4 +1222,3 @@ declare global {
|
||||
export { }
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user