Compare commits

...

4 Commits

Author SHA1 Message Date
cc
777f5b82db 优化底层游标索引性能;优化HTTPAPI索引逻辑;优化导出图片的索引写入逻辑 2026-04-22 23:02:17 +08:00
cc
5802cf36c6 年报S8临时修改 2026-04-21 23:44:20 +08:00
xuncha
e3174370bb Merge pull request #817 from xunchahaha:dev
修复双人年度报告[Bug]: 双人年度报告坏了
2026-04-21 20:21:01 +08:00
xuncha
0f8a9602bd 修复双人年度报告[Bug]: 双人年度报告坏了
Fixes #816
2026-04-21 20:20:11 +08:00
17 changed files with 2228 additions and 2502 deletions

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -93,7 +93,7 @@ const DecodeText = ({
if (i < iter) return strVal[i]
return chars[Math.floor(Math.random() * chars.length)]
}).join(''))
if (iter >= strVal.length) {
clearInterval(inv)
setDisplay(strVal)
@@ -123,7 +123,7 @@ function AnnualReportWindow() {
const s3LayoutRef = useRef<HTMLDivElement | null>(null)
const s3ListRef = useRef<HTMLDivElement | null>(null)
const [s3LineVars, setS3LineVars] = useState<React.CSSProperties>({})
// 提取长图逻辑变量
const [buttonText, setButtonText] = useState('EXTRACT RECORD')
const [isExtracting, setIsExtracting] = useState(false)
@@ -202,7 +202,7 @@ function AnnualReportWindow() {
setIsAnimating(true)
setCurrentScene(index)
setTimeout(() => {
setIsAnimating(false)
}, 1500)
@@ -217,7 +217,7 @@ function AnnualReportWindow() {
const handleWheel = (e: WheelEvent) => {
const now = Date.now()
if (now - lastWheelTime < 1000) return // Throttle wheel events
if (Math.abs(e.deltaY) > 30) {
lastWheelTime = now
goToScene(e.deltaY > 0 ? currentScene + 1 : currentScene - 1)
@@ -564,21 +564,21 @@ function AnnualReportWindow() {
<canvas ref={p0CanvasRef} className="p0-particle-canvas" />
<div className="p0-center-glow" />
</div>
<div className="film-grain"></div>
<div id="memory-core"></div>
<div className="pagination">
{Array.from({ length: TOTAL_SCENES }).map((_, i) => (
<div
key={i}
<div
key={i}
className={`dot-nav ${currentScene === i ? 'active' : ''}`}
onClick={() => goToScene(i)}
/>
))}
</div>
<div className="swipe-hint"></div>
{/* S0: THE ARCHIVE */}
@@ -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
// 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 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
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)'
}} />
// Pseudo-random organic height variation for audio-wave feel (from 8vh to 18vh)
const heightVariation = 12 + (Math.sin(i * 1.5) * 6);
{/* 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)' }} />
const alignStyle = isRightSide ? { right: '10px', alignItems: 'flex-end', textAlign: 'right' as const } : { left: '10px', alignItems: 'flex-start', textAlign: 'left' as const };
{/* 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>
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)'
}} />
{/* 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>
</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>
@@ -837,33 +837,33 @@ function AnnualReportWindow() {
<div className="reveal-wrap en-tag">
<div className="reveal-inner serif scene0-cn-tag"></div>
</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">
@@ -936,16 +960,16 @@ function AnnualReportWindow() {
const st = demoStyles[i];
return (
<div
key={phrase.phrase + i}
<div
key={phrase.phrase + i}
className="word-burst"
style={{
left: st.left,
top: st.top,
fontSize: st.fontSize,
color: st.color,
transitionDelay: st.delay,
'--target-op': st.targetOp
style={{
left: st.left,
top: st.top,
fontSize: st.fontSize,
color: st.color,
transitionDelay: st.delay,
'--target-op': st.targetOp
} as React.CSSProperties}
>
<span className="float-el" style={{ animationDelay: st.floatDelay }}>{phrase.phrase}</span>
@@ -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>
@@ -962,7 +986,7 @@ function AnnualReportWindow() {
<div className="reveal-wrap en-tag" style={{ zIndex: 20 }}>
<div className="reveal-inner serif scene0-cn-tag" style={{ color: COLOR.paperMuted }}></div>
</div>
{/* The Final Summary Receipt / Dashboard */}
<div className="reveal-wrap" style={{ position: 'absolute', top: '45vh', left: '50vw', transform: 'translate(-50%, -50%)', width: '60vw', textAlign: 'center', zIndex: 20 }}>
<div className="reveal-inner delay-1" style={{ display: 'flex', flexDirection: 'column', gap: '3vh' }}>
@@ -972,7 +996,7 @@ function AnnualReportWindow() {
<div className="mono" style={{ fontSize: '0.8rem', color: COLOR.paperMuted, letterSpacing: '0.4em' }}>
TRANSMISSION COMPLETE
</div>
{/* Core Stats Row */}
<div style={{ display: 'flex', justifyContent: 'space-around', marginTop: '6vh', borderTop: '1px solid rgba(110, 89, 46, 0.35)', borderBottom: '1px solid rgba(110, 89, 46, 0.35)', padding: '4vh 0' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
@@ -988,9 +1012,9 @@ function AnnualReportWindow() {
<div className="num-display" style={{ fontSize: '2.5rem', color: COLOR.accentMuted, fontWeight: 600 }}>{endingTopPhrase}</div>
</div>
</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,15 +1033,15 @@ function AnnualReportWindow() {
fontWeight: 500
}}
>
<br/><br/>
<br /><br />
</div>
</div>
<div className="reveal-wrap">
<button
className="btn num-display reveal-inner delay-3"
<button
className="btn num-display reveal-inner delay-3"
onClick={handleExtract}
disabled={isExtracting}
style={{
style={{
background: isExtracting ? '#CDC4B0' : (buttonText === 'SAVED TO DEVICE' ? '#1A140A' : '#101010'),
color: 'var(--c-gold-strong)',
fontSize: '0.85rem',

View File

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

View File

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

View File

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