export const EXPORT_PAGE_SIZE = 240 export const exportStyles = ` :root { color-scheme: light; --page: #edf2f0; --panel: #fff; --text: #1d2a25; --muted: #68766f; --border: #d8e2dc; --mine: #d9f0e2; --accent: #176b57; --accent-soft: #e4f2ec; } * { box-sizing: border-box; } html, body { height: 100%; } body { margin: 0; background: var(--page); color: var(--text); font: 14px system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; } .page { max-width: 1380px; height: 100vh; margin: auto; padding: 20px 24px; display: flex; flex-direction: column; } .toolbar { display: grid; grid-template-columns: minmax(220px, 1fr) auto; gap: 14px 24px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 18px; padding: 16px 20px; box-shadow: 0 8px 24px #29483b12; } .archive-heading { display: flex; align-items: center; gap: 8px 12px; min-width: 0; flex-wrap: wrap; } .title { font-size: 18px; font-weight: 750; } .meta { color: var(--muted); margin-left: 12px; font-size: 13px; } .archive-heading .meta { margin-left: 0; } .controls { display: flex; gap: 8px; align-items: center; justify-content: flex-end; } .controls input, .filter-button { border: 1px solid var(--border); border-radius: 10px; padding: 8px 11px; background: #fff; color: var(--text); font: inherit; } .controls input[type=search] { width: min(320px, 34vw); } .filters { grid-column: 1 / -1; display: flex; gap: 7px; align-items: center; flex-wrap: wrap; } .filter-button { cursor: pointer; } .filter-button.active { background: var(--accent); border-color: var(--accent); color: #fff; } .count { margin-left: auto; color: var(--muted); font-size: 13px; } .archive-layout { display: grid; grid-template-columns: 168px minmax(0, 1fr); gap: 18px; min-height: 0; flex: 1; margin-top: 16px; } .archive-layout.single-conversation { grid-template-columns: 168px minmax(0, 1fr); } .archive-navigation { display: flex; flex-direction: column; gap: 10px; min-width: 0; min-height: 0; } .conversation-filter { position: relative; display: inline-flex; flex: 0 1 320px; min-width: 210px; max-width: 100%; } .conversation-filter[hidden] { display: none; } .conversation-filter::after { content: ''; position: absolute; right: 15px; top: 50%; width: 8px; height: 8px; border-right: 2px solid var(--accent); border-bottom: 2px solid var(--accent); transform: translateY(-65%) rotate(45deg); pointer-events: none; } .conversation-select { appearance: none; width: 100%; min-width: 0; height: 42px; padding: 0 42px 0 14px; border: 1px solid #b8cec5; border-radius: 10px; outline: 0; background: #f7fbf9; color: var(--text); font: inherit; font-size: 17px; font-weight: 700; cursor: pointer; box-shadow: 0 2px 8px #29483b12; transition: border-color .15s ease, background .15s ease, box-shadow .15s ease; } .conversation-select:hover { border-color: #70a392; background: #fff; } .conversation-select:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 3px #176b571c; } .timeline { flex: 1; overflow: auto; background: #f7faf8; border: 1px solid var(--border); border-radius: 16px; padding: 12px 9px; } .timeline-empty { padding: 10px; color: var(--muted); font-size: 12px; } .timeline-year { margin: 6px 7px 5px; color: var(--text); font-size: 13px; font-weight: 700; } .timeline-month { width: 100%; display: flex; justify-content: space-between; gap: 8px; border: 0; border-left: 3px solid transparent; border-radius: 8px; background: transparent; color: var(--muted); padding: 7px 8px; cursor: pointer; text-align: left; } .timeline-month:hover, .timeline-month.active { border-left-color: var(--accent); background: var(--accent-soft); color: var(--accent); } .timeline-month small { color: inherit; } .scroll { overflow: auto; min-width: 0; padding: 10px 8px 36px; } .lazy-hint { width: min(100%, 820px); margin: 0 auto 12px; padding: 7px 12px; border-radius: 999px; background: #e4ece8; color: var(--muted); font-size: 12px; text-align: center; } .message { display: flex; flex-direction: column; gap: 6px; width: min(100%, 820px); margin: 0 auto 22px; } .message.sent { align-items: flex-end; } .message.system { align-items: center; } .message.system .row { justify-content: center; } .message.system .avatar { display: none; } .message.system .bubble { max-width: 92%; padding: 5px 10px; border: 0; border-radius: 5px; background: #e9eeeb; color: var(--muted); font-size: 11px; text-align: center; box-shadow: none; } .message.system .sender { display: none; } .time { color: var(--muted); font-size: 11px; margin: 0 12px; } .conversation-source { display: inline-block; margin-left: 8px; padding: 2px 6px; border-radius: 5px; background: var(--accent-soft); color: var(--accent); } .row { display: flex; gap: 12px; align-items: flex-end; max-width: 100%; } .sent .row { flex-direction: row-reverse; } .avatar { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 50%; overflow: hidden; background: #dcebe4; display: grid; place-items: center; } .avatar img { width: 100%; height: 100%; object-fit: cover; } .bubble { min-width: 0; max-width: min(78%, 760px); padding: 13px 15px; border: 1px solid var(--border); border-radius: 10px 18px 18px 18px; background: #fff; box-shadow: 0 4px 12px #29483b0d; } .sent .bubble { background: var(--mine); border-color: #c7e6d4; border-radius: 18px 10px 18px 18px; } .sender { color: var(--muted); font-size: 12px; margin-bottom: 5px; } .content { line-height: 1.7; word-break: break-word; white-space: pre-wrap; } .audio-wrap { width: 260px; max-width: 100%; min-width: 0; } .audio { display: block; width: 100%; max-width: 100%; height: 38px; } .media-status { margin-top: 8px; padding: 6px 8px; border-left: 3px solid #b27a18; background: #fff8e8; color: #79530f; font-size: 12px; line-height: 1.5; } .file-attachment { display: flex; align-items: center; gap: 9px; max-width: 320px; margin: 2px 0 8px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: #f7faf8; color: var(--accent); font-weight: 650; text-decoration: none; word-break: break-all; } .file-attachment:hover { text-decoration: underline; } .quote-reference { margin-top: 10px; padding: 8px 11px; border-left: 3px solid #8eb4a3; background: #f1f6f3; color: var(--muted); display: grid; gap: 3px; } .quote-reference strong { font-weight: 650; color: var(--text); } .quote-reference span { white-space: pre-wrap; } .media-image { display: block; max-width: 100%; max-height: 420px; margin-bottom: 8px; border-radius: 12px; object-fit: contain; background: #eef2f5; } .media-image[data-preview] { cursor: zoom-in; } .empty { display: grid; place-items: center; min-height: 260px; color: var(--muted); text-align: center; } .lightbox { position: fixed; inset: 0; display: none; place-items: center; background: #14231ddd; z-index: 10; padding: 24px; overflow: auto; } .lightbox.open { display: grid; } .lightbox img { width: min(86vw, 980px); max-height: 88vh; object-fit: contain; cursor: zoom-in; transform: scale(var(--zoom, 1)); transform-origin: center; transition: transform .12s ease; } .lightbox-close { position: fixed; top: 20px; right: 20px; z-index: 11; width: 42px; height: 42px; border: 1px solid #ffffff66; border-radius: 50%; background: #14231dcc; color: #fff; font-size: 30px; line-height: 1; cursor: pointer; } @media (max-width: 760px) { .page { padding: 10px; } .toolbar { grid-template-columns: 1fr; padding: 13px; } .controls { justify-content: flex-start; } .controls input[type=search] { width: 100%; } .filters { grid-column: 1; } .count { width: 100%; margin-left: 0; } .archive-layout { grid-template-columns: 1fr; align-content: start; margin-top: 10px; } .archive-layout.single-conversation { grid-template-columns: 1fr; } .archive-navigation { align-self: start; gap: 8px; } .timeline { align-self: start; display: flex; gap: 6px; overflow: auto; padding: 8px; } .archive-heading { align-items: flex-start; } .conversation-filter { flex-basis: 100%; width: 100%; } .timeline-year { flex: 0 0 auto; align-self: center; margin: 0 2px 0 0; padding: 7px 5px; white-space: nowrap; } .timeline-month { flex: 0 0 auto; width: auto; border-left: 0; border-bottom: 3px solid transparent; } .timeline-month:hover, .timeline-month.active { border-left-color: transparent; border-bottom-color: var(--accent); } .bubble { max-width: calc(100vw - 92px); } .audio-wrap { width: min(260px, calc(100vw - 130px)); } } ` const safe = (value: unknown): string => String(value ?? '').replace( /[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] || character ) const renderExportScript = (name: string): string => ` (() => { 'use strict' const PAGE_SIZE = ${EXPORT_PAGE_SIZE} const WINDOW_STEP = Math.floor(PAGE_SIZE / 2) const archive = window.__WECHAT_EXPORT__ || { name: ${JSON.stringify(name)}, messages: [] } const legacyConversation = archive.sourceId ? [{ id: archive.sourceId, name: archive.name || ${JSON.stringify(name)}, type: 'user', messageCount: 0 }] : [] const conversations = Array.isArray(archive.conversations) && archive.conversations.length ? archive.conversations : legacyConversation const allMessages = (Array.isArray(archive.messages) ? archive.messages : []).map((message) => message.exportConversationId || conversations.length !== 1 ? message : Object.assign({}, message, { exportConversationId: conversations[0].id, exportConversationName: conversations[0].name }) ) const list = document.querySelector('#messages') const layout = document.querySelector('.archive-layout') const conversationFilter = document.querySelector('#conversation-filter') const conversationSelect = document.querySelector('#conversation-select') const timeline = document.querySelector('#timeline') const query = document.querySelector('#query') const count = document.querySelector('#count') const meta = document.querySelector('#archive-meta') const title = document.querySelector('#archive-title') const filters = document.querySelector('#filters') const box = document.querySelector('#lightbox') const preview = document.querySelector('#lightbox-image') const closeButton = document.querySelector('#lightbox-close') let activeKind = 'all' let activeConversation = 'all' let filtered = [] let windowStart = 0 let windowEnd = 0 let scrollLoadPending = false let scrollLoadSuppressed = false let lastScrollTop = 0 let zoom = 1 const nextFrame = (callback) => { if (typeof window.requestAnimationFrame === 'function') { window.requestAnimationFrame(callback) } else { window.setTimeout(callback, 0) } } const esc = (value) => String(value ?? '').replace( /[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] || character ) const pad = (value) => String(value).padStart(2, '0') const fullTime = (message) => { const timestamp = Number(message.createTime || 0) if (!timestamp) return String(message.datetime || '') const date = new Date(timestamp * 1000) if (Number.isNaN(date.getTime())) return String(message.datetime || '') return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()) } const monthKey = (message) => { const timestamp = Number(message.createTime || 0) if (!timestamp) return 'unknown' const date = new Date(timestamp * 1000) return date.getFullYear() + '-' + pad(date.getMonth() + 1) } const kindOf = (message) => { const data = message.contentData || {} if (message.exportMediaType === 'file' || (data.type === 'share' && String(data.typeVal) === '6')) return 'file' if ( message.exportMediaType === 'image' || message.exportMediaType === 'video' || message.exportMediaType === 'sticker' || data.type === 'image' || data.type === 'video' || data.type === 'sticker' ) return 'media' if (message.voiceDataUrl || data.type === 'voice' || message.type === '语音') return 'voice' if ( data.type === 'share' || data.type === 'location' || data.type === 'miniProgram' || data.type === 'forwardBundle' ) return 'share' if (data.type === 'system' || data.type === 'unknown' || message.from === 'system') return 'system' return 'text' } const searchText = (message) => [ message.exportConversationName, message.name, message.senderId, message.content, message.type, message.contentData && message.contentData.title, message.contentData && message.contentData.quotedSender, message.contentData && message.contentData.quotedContent, message.exportMediaName ].filter(Boolean).join(' ').toLowerCase() const renderMessage = (message, archiveIndex) => { const data = message.contentData || {} const mediaUrl = message.exportMediaUrl ? esc(message.exportMediaUrl) : '' const mediaType = message.exportMediaType || data.type let media = '' if (mediaUrl && mediaType === 'image') { media = '图片' } else if (mediaUrl && mediaType === 'video') { media = '' } else if (mediaUrl && mediaType === 'sticker') { media = '表情包' } else if (mediaUrl && mediaType === 'file') { const fileName = esc(message.exportMediaName || data.title || '下载文件') media = '📎' + fileName + '' } const audio = message.voiceDataUrl ? '
' : '' const mediaStatus = message.exportMediaError ? '
' + esc(message.exportMediaError) + '
' : '' const quote = data.type === 'quote' ? '
' + esc(data.quotedSender || '引用消息') + '' + esc(data.quotedContent || '[引用消息]') + '
' : '' const isSystem = data.type === 'system' && data.pat const sender = message.name || (message.isSender ? '我' : '联系人') const avatarFallback = esc(String(sender || '友').slice(0, 1)) const avatar = message.exportShowAvatar === false ? '' : '
' + (message.exportAvatarUrl ? '' : avatarFallback) + '
' const text = message.content || (data.type === 'quote' ? data.title : '') const content = esc(text || (!media && !audio && !quote ? '[' + (message.type || '消息') + ']' : '')) const source = conversations.length > 1 && activeConversation === 'all' ? '' + esc(message.exportConversationName || '聊天') + '' : '' return '
' + '
' + esc(fullTime(message)) + source + '
' + (isSystem ? '' : avatar) + '
' + (isSystem ? '' : esc(sender)) + '
' + media + audio + quote + '
' + content + '
' + mediaStatus + '
' } const renderConversations = () => { if (conversations.length <= 1) { conversationFilter.hidden = true title.hidden = false layout.classList.add('single-conversation') return } const counts = new Map() for (const message of allMessages) { const id = message.exportConversationId || '' counts.set(id, (counts.get(id) || 0) + 1) } const option = (id, label, total) => '' title.hidden = true conversationFilter.hidden = false conversationSelect.innerHTML = option('all', '全部聊天', allMessages.length) + conversations.map((conversation) => option( conversation.id, conversation.name, counts.get(conversation.id) || 0 )).join('') conversationSelect.value = activeConversation } const renderTimeline = () => { if (filtered.length === 0) { timeline.innerHTML = '
没有可跳转的月份
' return } const groups = new Map() for (const message of filtered) { const key = monthKey(message) if (key === 'unknown') continue groups.set(key, (groups.get(key) || 0) + 1) } let currentYear = '' let html = '' for (const [key, total] of groups) { const parts = key.split('-') if (parts[0] !== currentYear) { currentYear = parts[0] html += '
' + esc(currentYear) + ' 年
' } html += '' } timeline.innerHTML = html || '
时间信息不可用
' } const updateActiveMonth = () => { const first = list.querySelector('.message') const key = first && first.dataset.month timeline.querySelectorAll('.timeline-month').forEach((button) => { button.classList.toggle('active', button.dataset.month === key) }) } const updateCount = () => { const shown = Math.max(0, windowEnd - windowStart) const scopeTotal = activeConversation === 'all' ? allMessages.length : allMessages.filter((message) => message.exportConversationId === activeConversation).length const scopeLabel = activeConversation === 'all' ? '全部' : '当前聊天' count.textContent = '已显示 ' + shown + ' / 筛选 ' + filtered.length + ' / ' + scopeLabel + ' ' + scopeTotal } const setScrollTop = (value) => { scrollLoadSuppressed = true const previousBehavior = list.style.scrollBehavior list.style.scrollBehavior = 'auto' list.scrollTop = value lastScrollTop = list.scrollTop nextFrame(() => { list.style.scrollBehavior = previousBehavior lastScrollTop = list.scrollTop scrollLoadSuppressed = false }) } const renderWindow = (anchorIndex, anchorOffset) => { const visible = filtered.slice(windowStart, windowEnd) const before = windowStart > 0 ? '
向上滚动加载更早消息
' : '' const after = windowEnd < filtered.length ? '
向下滚动加载更多消息
' : '' list.innerHTML = visible.length ? before + visible.map((message, index) => renderMessage(message, windowStart + index)).join('') + after : '
没有符合条件的消息
可以更换筛选条件或关键词
' if (Number.isInteger(anchorIndex)) { const anchor = list.querySelector('.message[data-index="' + anchorIndex + '"]') if (anchor) setScrollTop(anchor.offsetTop - anchorOffset) } updateCount() updateActiveMonth() } const resetWindow = (preferLatest) => { windowEnd = filtered.length windowStart = Math.max(0, windowEnd - PAGE_SIZE) if (!preferLatest) { windowStart = 0 windowEnd = Math.min(filtered.length, PAGE_SIZE) } renderWindow() setScrollTop(preferLatest ? list.scrollHeight : 0) } const applyFilters = () => { const term = query.value.trim().toLowerCase() filtered = allMessages.filter((message) => (activeConversation === 'all' || message.exportConversationId === activeConversation) && (activeKind === 'all' || kindOf(message) === activeKind) && (!term || searchText(message).includes(term)) ) renderTimeline() resetWindow(true) } const jumpToMonth = (key) => { const index = filtered.findIndex((message) => monthKey(message) === key) if (index < 0) return windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4)) windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) windowStart = Math.max(0, windowEnd - PAGE_SIZE) renderWindow() const target = list.querySelector('.message[data-index="' + index + '"]') setScrollTop(target ? Math.max(0, target.offsetTop - 24) : 0) timeline.querySelectorAll('.timeline-month').forEach((button) => { button.classList.toggle('active', button.dataset.month === key) }) } const slideWindow = (direction) => { const renderedMessages = list.querySelectorAll('.message') const anchor = direction < 0 ? renderedMessages[0] : renderedMessages[renderedMessages.length - 1] const anchorIndex = anchor ? Number(anchor.dataset.index) : undefined const anchorOffset = anchor ? anchor.offsetTop - list.scrollTop : 0 if (direction < 0) { windowStart = Math.max(0, windowStart - WINDOW_STEP) windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) } else { windowEnd = Math.min(filtered.length, windowEnd + WINDOW_STEP) windowStart = Math.max(0, windowEnd - PAGE_SIZE) } renderWindow(anchorIndex, anchorOffset) } const scheduleWindowSlide = (direction) => { if (scrollLoadPending) return scrollLoadPending = true nextFrame(() => { if (direction < 0 ? windowStart > 0 : windowEnd < filtered.length) { slideWindow(direction) } scrollLoadPending = false }) } list.addEventListener('scroll', () => { const currentTop = list.scrollTop const movingUp = currentTop < lastScrollTop const nearTop = currentTop < 180 const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240 lastScrollTop = currentTop if (scrollLoadSuppressed) return if (movingUp && nearTop) scheduleWindowSlide(-1) else if (!movingUp && nearBottom) scheduleWindowSlide(1) }) list.addEventListener('wheel', (event) => { if (!scrollLoadSuppressed && event.deltaY < 0 && list.scrollTop <= 1) { scheduleWindowSlide(-1) } }, { passive: true }) query.addEventListener('input', applyFilters) filters.addEventListener('click', (event) => { const button = event.target.closest('[data-kind]') if (!button) return activeKind = button.dataset.kind filters.querySelectorAll('[data-kind]').forEach((item) => item.classList.toggle('active', item === button)) applyFilters() }) conversationSelect.addEventListener('change', () => { activeConversation = conversationSelect.value applyFilters() }) timeline.addEventListener('click', (event) => { const button = event.target.closest('[data-month]') if (button) jumpToMonth(button.dataset.month) }) const updateZoom = () => preview.style.setProperty('--zoom', zoom) const closeLightbox = () => { box.classList.remove('open') zoom = 1 updateZoom() } list.addEventListener('click', (event) => { const image = event.target.closest('img[data-preview]') if (!image) return preview.src = image.src zoom = 1 updateZoom() box.classList.add('open') }) preview.addEventListener('wheel', (event) => { event.preventDefault() zoom = Math.min(5, Math.max(.5, zoom + (event.deltaY < 0 ? .2 : -.2))) updateZoom() }, { passive: false }) preview.addEventListener('dblclick', () => { zoom = 1 updateZoom() }) box.addEventListener('click', (event) => { if (event.target === box) closeLightbox() }) closeButton.addEventListener('click', closeLightbox) document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeLightbox() }) title.textContent = archive.name || ${JSON.stringify(name)} const updatedAt = archive.exportedAt ? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false }) : '未知时间' meta.textContent = (conversations.length > 1 ? '' : allMessages.length.toLocaleString() + ' 条消息 · ') + '更新于 ' + updatedAt renderConversations() applyFilters() })() ` export function renderExportPage(name: string): string { return ` ${safe(name)} - 聊天记录
${safe(name)} 正在读取消息…
正在加载聊天档案…
` }