mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
feat: 修复导出任务状态并优化聊天记录导出体验
This commit is contained in:
@@ -13,3 +13,6 @@ docs/design/
|
|||||||
docs/ui-redesign-plan.md
|
docs/ui-redesign-plan.md
|
||||||
docs/ui-redesign-spec.md
|
docs/ui-redesign-spec.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
findings.md
|
||||||
|
progress.md
|
||||||
|
task_plan.md
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Message } from '../shared/types'
|
import type { Message } from '../shared/types'
|
||||||
|
|
||||||
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px}.message{display:flex;flex-direction:column;gap:6px;max-width:820px;margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.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{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;min-width:260px}.audio{display:block;width:260px;height:38px}.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:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.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}`
|
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.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}.row{display:flex;gap:12px;align-items:flex-end}.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{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;min-width:260px}.audio{display:block;width:260px;height:38px}.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:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.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}`
|
||||||
const safe = (value: unknown): string =>
|
const safe = (value: unknown): string =>
|
||||||
String(value ?? '').replace(
|
String(value ?? '').replace(
|
||||||
/[&<>"']/g,
|
/[&<>"']/g,
|
||||||
@@ -32,8 +32,9 @@ export function renderExportPage(name: string, messages: Message[]): string {
|
|||||||
m.exportShowAvatar === false
|
m.exportShowAvatar === false
|
||||||
? ''
|
? ''
|
||||||
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
|
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
|
||||||
|
const isPat = m.contentData?.type === 'system' && m.contentData.pat
|
||||||
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
|
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
|
||||||
return `<article class="message${m.isSender ? ' sent' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${avatarMarkup}<div class="bubble"><div class="sender">${safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>`
|
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>`
|
||||||
})
|
})
|
||||||
.join('')
|
.join('')
|
||||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){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)box.classList.remove('open')});update()})()</script></body></html>`
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){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)box.classList.remove('open')});update()})()</script></body></html>`
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async function readAvatarAsset(
|
|||||||
}
|
}
|
||||||
const kindOf = (message: Message): ExportMessageKind => {
|
const kindOf = (message: Message): ExportMessageKind => {
|
||||||
const type = message.contentData?.type
|
const type = message.contentData?.type
|
||||||
|
if (type === 'system' && message.contentData?.pat) return 'text'
|
||||||
if (
|
if (
|
||||||
type === 'image' ||
|
type === 'image' ||
|
||||||
type === 'video' ||
|
type === 'video' ||
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type SystemContent = {
|
|||||||
type: 'system'
|
type: 'system'
|
||||||
content: string
|
content: string
|
||||||
raw?: string
|
raw?: string
|
||||||
|
pat?: boolean
|
||||||
recall?: {
|
recall?: {
|
||||||
targetId?: string
|
targetId?: string
|
||||||
targetIds?: string[]
|
targetIds?: string[]
|
||||||
|
|||||||
@@ -229,12 +229,23 @@ function listSourceMessages(
|
|||||||
|
|
||||||
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
||||||
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
||||||
|
const isPatMessage = /<patMsg\b|拍了拍|拍一拍/i.test(String(content || ''))
|
||||||
|
if (isPatMessage) {
|
||||||
|
const system = parseMessageContent(content, 10000)
|
||||||
|
const patContent =
|
||||||
|
system.type === 'system'
|
||||||
|
? { ...system, pat: true }
|
||||||
|
: { type: 'system' as const, content: String(content || '').replace(/<[^>]+>/g, '').trim(), pat: true }
|
||||||
|
contentData = patContent
|
||||||
|
content = patContent.content
|
||||||
|
displayType = '系统消息'
|
||||||
|
}
|
||||||
const inferredMsgType =
|
const inferredMsgType =
|
||||||
typeof content === 'string' &&
|
typeof content === 'string' &&
|
||||||
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
||||||
? 49
|
? 49
|
||||||
: msgType
|
: msgType
|
||||||
if ([3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||||
try {
|
try {
|
||||||
const isQuotePayload = /<refermsg\b/i.test(content)
|
const isQuotePayload = /<refermsg\b/i.test(content)
|
||||||
const hasStickerHints =
|
const hasStickerHints =
|
||||||
|
|||||||
@@ -408,6 +408,9 @@ export class RecallArchiveMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleDatabaseChange(payload: string): void {
|
handleDatabaseChange(payload: string): void {
|
||||||
|
// Ordinary message inserts are already visible in the main reader. The
|
||||||
|
// archive scan is only needed when the native event indicates a recall.
|
||||||
|
if (!/(recall|revoke|revokemsg|撤回)/i.test(String(payload || ''))) return
|
||||||
const groups = this.conversationContacts()
|
const groups = this.conversationContacts()
|
||||||
const knownMd5 = new Set(groups.map((contact) => contact.md5.toLowerCase()))
|
const knownMd5 = new Set(groups.map((contact) => contact.md5.toLowerCase()))
|
||||||
const payloadTargets = extractChangedConversationMd5(payload).filter((md5) => knownMd5.has(md5))
|
const payloadTargets = extractChangedConversationMd5(payload).filter((md5) => knownMd5.has(md5))
|
||||||
|
|||||||
+147
-8
@@ -21,6 +21,7 @@ import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
|
|||||||
import { Contact, Message } from '../../shared/types'
|
import { Contact, Message } from '../../shared/types'
|
||||||
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
|
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
|
||||||
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
||||||
|
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 260
|
const SIDEBAR_MIN_WIDTH = 260
|
||||||
const SIDEBAR_MAX_WIDTH = 380
|
const SIDEBAR_MAX_WIDTH = 380
|
||||||
@@ -39,7 +40,8 @@ interface SelfInfo {
|
|||||||
|
|
||||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||||
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
|
||||||
const VIEW_MESSAGE_LIMIT = 600
|
const MESSAGE_PAGE_SIZE = 100
|
||||||
|
const EXPORT_PREVIEW_LIMIT = 20
|
||||||
const getMessageIdentity = (message: Message): string => {
|
const getMessageIdentity = (message: Message): string => {
|
||||||
if (message.localId) return `local:${message.localId}`
|
if (message.localId) return `local:${message.localId}`
|
||||||
if (message.id) return `id:${message.id}`
|
if (message.id) return `id:${message.id}`
|
||||||
@@ -133,6 +135,12 @@ const sortMessagesChronologically = (items: Message[]): Message[] =>
|
|||||||
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
|
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mergeMessagePages = (older: Message[], current: Message[]): Message[] => {
|
||||||
|
const merged = new Map<string, Message>()
|
||||||
|
for (const message of [...older, ...current]) merged.set(getMessageIdentity(message), message)
|
||||||
|
return sortMessagesChronologically(Array.from(merged.values()))
|
||||||
|
}
|
||||||
|
|
||||||
function App(): React.ReactElement {
|
function App(): React.ReactElement {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
|
||||||
@@ -173,6 +181,14 @@ function App(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
|
||||||
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
||||||
|
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]')
|
||||||
|
return Array.isArray(stored) ? (stored as ExportTaskRecord[]).slice(0, 20) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
|
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
|
||||||
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
|
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
|
||||||
const [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null)
|
const [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null)
|
||||||
@@ -217,6 +233,80 @@ function App(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
const lastCapturedReportKeyRef = React.useRef('')
|
const lastCapturedReportKeyRef = React.useRef('')
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const unsubscribe = window.api.onExportProgress((progress: ExportJobProgress) => {
|
||||||
|
setExportTasks((current) =>
|
||||||
|
current.map((task) => {
|
||||||
|
if (task.jobId !== progress.jobId) return task
|
||||||
|
const status =
|
||||||
|
progress.phase === 'completed'
|
||||||
|
? 'completed'
|
||||||
|
: progress.phase === 'cancelled'
|
||||||
|
? 'cancelled'
|
||||||
|
: progress.phase === 'failed'
|
||||||
|
? 'failed'
|
||||||
|
: 'running'
|
||||||
|
return { ...task, status, progress }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return unsubscribe
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
localStorage.setItem('wxe_export_tasks', JSON.stringify(exportTasks.slice(0, 20)))
|
||||||
|
}, [exportTasks])
|
||||||
|
|
||||||
|
const handleStartExport = async (request: ExportRequest): Promise<import('../../shared/export').ExportResult> => {
|
||||||
|
const task: ExportTaskRecord = {
|
||||||
|
jobId: request.jobId,
|
||||||
|
contactId: request.userMd5,
|
||||||
|
contactName: request.name,
|
||||||
|
format: request.format,
|
||||||
|
status: 'running',
|
||||||
|
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
|
||||||
|
createdAt: Date.now()
|
||||||
|
}
|
||||||
|
setExportTasks((current) => [task, ...current.filter((item) => item.jobId !== task.jobId)].slice(0, 20))
|
||||||
|
const result = await window.api.startExport(request)
|
||||||
|
setExportTasks((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.jobId === request.jobId
|
||||||
|
? result.success
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
status: 'completed',
|
||||||
|
progress: {
|
||||||
|
...item.progress,
|
||||||
|
phase: 'completed',
|
||||||
|
processed: result.messageCount ?? item.progress.processed,
|
||||||
|
total: result.messageCount ?? item.progress.total,
|
||||||
|
percent: 100,
|
||||||
|
outputPath: result.outputPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...item,
|
||||||
|
status: 'failed',
|
||||||
|
progress: { ...item.progress, phase: 'failed', error: result.error }
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelExport = async (jobId: string): Promise<void> => {
|
||||||
|
await window.api.cancelExport(jobId)
|
||||||
|
setExportTasks((current) =>
|
||||||
|
current.map((task) =>
|
||||||
|
task.jobId === jobId
|
||||||
|
? { ...task, status: 'cancelled', progress: { ...task.progress, phase: 'cancelled' } }
|
||||||
|
: task
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const loadGeneratedReports = React.useCallback(async (): Promise<void> => {
|
const loadGeneratedReports = React.useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const result = await window.api.listGeneratedReports()
|
const result = await window.api.listGeneratedReports()
|
||||||
@@ -715,7 +805,7 @@ function App(): React.ReactElement {
|
|||||||
setMessages(
|
setMessages(
|
||||||
applyGroupMemberMeta(
|
applyGroupMemberMeta(
|
||||||
contact,
|
contact,
|
||||||
mergeSyntheticMessages(contact, cachedMsgs.slice(-VIEW_MESSAGE_LIMIT))
|
mergeSyntheticMessages(contact, cachedMsgs.slice(-MESSAGE_PAGE_SIZE))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -724,7 +814,7 @@ function App(): React.ReactElement {
|
|||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
try {
|
try {
|
||||||
const msgs = await window.api.getMessages(contact.md5, startTime, endTime, {
|
const msgs = await window.api.getMessages(contact.md5, startTime, endTime, {
|
||||||
limit: VIEW_MESSAGE_LIMIT
|
limit: MESSAGE_PAGE_SIZE
|
||||||
})
|
})
|
||||||
if (selectedContactMd5Ref.current !== contact.md5) return
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
|
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
|
||||||
@@ -748,6 +838,46 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLoadOlderMessages = async (): Promise<void> => {
|
||||||
|
const contact = selectedContact
|
||||||
|
if (!contact || isMessagesLoading || messages.length === 0) return
|
||||||
|
const oldestTime = messages[0]?.createTime
|
||||||
|
if (!oldestTime) return
|
||||||
|
const { startTime } = getDateRangeParams(dateRange)
|
||||||
|
if (startTime && oldestTime <= startTime) return
|
||||||
|
|
||||||
|
setIsMessagesLoading(true)
|
||||||
|
try {
|
||||||
|
const olderMessages = await window.api.getMessages(
|
||||||
|
contact.md5,
|
||||||
|
startTime,
|
||||||
|
oldestTime - 1,
|
||||||
|
{ limit: MESSAGE_PAGE_SIZE }
|
||||||
|
)
|
||||||
|
if (selectedContactMd5Ref.current !== contact.md5) return
|
||||||
|
setMessages((current) =>
|
||||||
|
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current)))
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Messages] older page load failed:', error)
|
||||||
|
} finally {
|
||||||
|
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadExportPreview = async (contact: Contact): Promise<void> => {
|
||||||
|
setSelectedContact(contact)
|
||||||
|
selectedContactMd5Ref.current = contact.md5
|
||||||
|
try {
|
||||||
|
const previewMessages = await window.api.getMessages(contact.md5, undefined, undefined, {
|
||||||
|
limit: EXPORT_PREVIEW_LIMIT
|
||||||
|
})
|
||||||
|
if (selectedContactMd5Ref.current === contact.md5) setMessages(previewMessages)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Export] preview load failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDateRangeChange = (range: string): void => {
|
const handleDateRangeChange = (range: string): void => {
|
||||||
setDateRange(range)
|
setDateRange(range)
|
||||||
if (selectedContact) {
|
if (selectedContact) {
|
||||||
@@ -759,13 +889,13 @@ function App(): React.ReactElement {
|
|||||||
setMessages(
|
setMessages(
|
||||||
applyGroupMemberMeta(
|
applyGroupMemberMeta(
|
||||||
selectedContact,
|
selectedContact,
|
||||||
mergeSyntheticMessages(selectedContact, cachedMessages.slice(-VIEW_MESSAGE_LIMIT))
|
mergeSyntheticMessages(selectedContact, cachedMessages.slice(-MESSAGE_PAGE_SIZE))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
setIsMessagesLoading(true)
|
setIsMessagesLoading(true)
|
||||||
window.api
|
window.api
|
||||||
.getMessages(selectedContact.md5, startTime, endTime, { limit: VIEW_MESSAGE_LIMIT })
|
.getMessages(selectedContact.md5, startTime, endTime, { limit: MESSAGE_PAGE_SIZE })
|
||||||
.then((nextMessages) => {
|
.then((nextMessages) => {
|
||||||
setMessages(
|
setMessages(
|
||||||
applyGroupMemberMeta(
|
applyGroupMemberMeta(
|
||||||
@@ -803,7 +933,7 @@ function App(): React.ReactElement {
|
|||||||
contactMd5,
|
contactMd5,
|
||||||
range.startTime,
|
range.startTime,
|
||||||
range.endTime,
|
range.endTime,
|
||||||
{ limit: VIEW_MESSAGE_LIMIT }
|
{ limit: MESSAGE_PAGE_SIZE }
|
||||||
)
|
)
|
||||||
const nextMessages = applyGroupMemberMeta(
|
const nextMessages = applyGroupMemberMeta(
|
||||||
selectedContact,
|
selectedContact,
|
||||||
@@ -828,7 +958,10 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = window.api.onWcdbChange(() => {
|
const unsubscribe = window.api.onWcdbChange(({ json }) => {
|
||||||
|
const eventText = String(json || '').toLowerCase()
|
||||||
|
const targetIds = [contactMd5, selectedContact.m_nsUsrName].filter(Boolean).map((value) => value.toLowerCase())
|
||||||
|
if (!targetIds.some((targetId) => eventText.includes(targetId))) return
|
||||||
if (refreshTimer) window.clearTimeout(refreshTimer)
|
if (refreshTimer) window.clearTimeout(refreshTimer)
|
||||||
refreshTimer = window.setTimeout(() => {
|
refreshTimer = window.setTimeout(() => {
|
||||||
refreshTimer = null
|
refreshTimer = null
|
||||||
@@ -870,6 +1003,8 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
const handlePageChange = (page: AppPage): void => {
|
const handlePageChange = (page: AppPage): void => {
|
||||||
setActivePage(page)
|
setActivePage(page)
|
||||||
|
if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact)
|
||||||
|
if (page === 'export' && selectedContact) void loadExportPreview(selectedContact)
|
||||||
if (page === 'settings') setSettingsCategory('account-database')
|
if (page === 'settings') setSettingsCategory('account-database')
|
||||||
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
|
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
|
||||||
setReportSourceContact(selectedContact)
|
setReportSourceContact(selectedContact)
|
||||||
@@ -1083,6 +1218,7 @@ function App(): React.ReactElement {
|
|||||||
onContentFilterChange={setContentFilter}
|
onContentFilterChange={setContentFilter}
|
||||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
||||||
onRefreshData={loadContacts}
|
onRefreshData={loadContacts}
|
||||||
|
onLoadOlderMessages={() => void handleLoadOlderMessages()}
|
||||||
onCreateGroupReport={handleOpenReportWorkspace}
|
onCreateGroupReport={handleOpenReportWorkspace}
|
||||||
isAiLoading={reportGeneration.isGenerating}
|
isAiLoading={reportGeneration.isGenerating}
|
||||||
/>
|
/>
|
||||||
@@ -1211,8 +1347,11 @@ function App(): React.ReactElement {
|
|||||||
previewMessages={messages}
|
previewMessages={messages}
|
||||||
selfInfo={selfInfo}
|
selfInfo={selfInfo}
|
||||||
dbReady={isDatabaseConnected}
|
dbReady={isDatabaseConnected}
|
||||||
onSelectContact={handleSelectContact}
|
onSelectContact={loadExportPreview}
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
|
exportTasks={exportTasks}
|
||||||
|
onStartExport={handleStartExport}
|
||||||
|
onCancelExport={handleCancelExport}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -482,10 +482,22 @@ body {
|
|||||||
.conversation-list {
|
.conversation-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow: auto;
|
||||||
padding: 8px 8px 12px;
|
padding: 8px 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conversation-virtual-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-virtual-row {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.conversation-section + .conversation-section {
|
.conversation-section + .conversation-section {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
@@ -541,9 +553,25 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conversation-section-list {
|
.conversation-section-list {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-direction: column;
|
overflow: visible;
|
||||||
gap: 2px;
|
}
|
||||||
|
|
||||||
|
.export-config-scroll > .export-section:not(.export-format-top):has(.export-format-grid) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-section-virtual-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-section-virtual-row {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 58px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-section-empty {
|
.conversation-section-empty {
|
||||||
@@ -4702,6 +4730,17 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fafbfa;
|
background: #fafbfa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-page-panel {
|
||||||
|
display: none;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page-panel.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
.settings-sidebar {
|
.settings-sidebar {
|
||||||
width: 294px;
|
width: 294px;
|
||||||
flex: 0 0 294px;
|
flex: 0 0 294px;
|
||||||
@@ -8209,6 +8248,25 @@ body {
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
accent-color: var(--wxex-brand);
|
accent-color: var(--wxex-brand);
|
||||||
}
|
}
|
||||||
|
.export-check-row.unsupported {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.export-check-row.unsupported input {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.export-unsupported-hint {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #c57923;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #a85e13;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
.export-name-mode-grid {
|
.export-name-mode-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -8440,11 +8498,16 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 15px;
|
width: min(100%, 620px);
|
||||||
|
margin: 0 auto 15px;
|
||||||
}
|
}
|
||||||
.export-preview-message.mine {
|
.export-preview-message.mine {
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
.export-preview-message.system {
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
.export-preview-avatar {
|
.export-preview-avatar {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
@@ -8464,6 +8527,23 @@ body {
|
|||||||
.export-preview-message.mine .export-preview-bubble {
|
.export-preview-message.mine .export-preview-bubble {
|
||||||
background: #95ec69;
|
background: #95ec69;
|
||||||
}
|
}
|
||||||
|
.export-preview-message.system .export-preview-avatar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.export-preview-message.system .export-preview-bubble {
|
||||||
|
max-width: 92%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #e9eeeb;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.export-preview-message.system .export-preview-bubble small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.export-preview-bubble small {
|
.export-preview-bubble small {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
@@ -8477,6 +8557,95 @@ body {
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
border-top: 1px solid var(--wxex-border);
|
border-top: 1px solid var(--wxex-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-task-center-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px 11px;
|
||||||
|
font: 600 12px/18px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-center {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-top: 1px solid var(--wxex-border);
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-row:first-of-type {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-row span:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-row strong,
|
||||||
|
.export-task-row small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-row strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-progress {
|
||||||
|
position: relative;
|
||||||
|
width: 110px;
|
||||||
|
height: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wxex-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-progress i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-progress b {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 0;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-task-row button {
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.export-preview-panel .export-preview-stats:not(.export-preview-real-stats) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.export-preview-stats span,
|
.export-preview-stats span,
|
||||||
.export-complete-summary span {
|
.export-complete-summary span {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface ChatWindowProps {
|
|||||||
onContentFilterChange?: (keyword: string) => void
|
onContentFilterChange?: (keyword: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onRefreshData?: () => void
|
onRefreshData?: () => void
|
||||||
|
onLoadOlderMessages?: () => void
|
||||||
onCreateGroupReport?: () => void
|
onCreateGroupReport?: () => void
|
||||||
isAiLoading?: boolean
|
isAiLoading?: boolean
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
onContentFilterChange,
|
onContentFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData,
|
onRefreshData,
|
||||||
|
onLoadOlderMessages,
|
||||||
onCreateGroupReport,
|
onCreateGroupReport,
|
||||||
isAiLoading = false
|
isAiLoading = false
|
||||||
}) => {
|
}) => {
|
||||||
@@ -97,9 +99,10 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isAtLatest) return
|
||||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||||
return () => window.cancelAnimationFrame(frame)
|
return () => window.cancelAnimationFrame(frame)
|
||||||
}, [messages, scrollToBottom])
|
}, [isAtLatest, messages, scrollToBottom])
|
||||||
|
|
||||||
const openImagePreview = (imageUrl: string): void => {
|
const openImagePreview = (imageUrl: string): void => {
|
||||||
setPreviewImage(imageUrl)
|
setPreviewImage(imageUrl)
|
||||||
@@ -211,6 +214,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
listRef={messageListRef}
|
listRef={messageListRef}
|
||||||
bottomRef={messagesEndRef}
|
bottomRef={messagesEndRef}
|
||||||
onScroll={handleMessageListScroll}
|
onScroll={handleMessageListScroll}
|
||||||
|
onReachTop={onLoadOlderMessages}
|
||||||
onImageClick={openImagePreview}
|
onImageClick={openImagePreview}
|
||||||
/>
|
/>
|
||||||
<ChatStatusBar
|
<ChatStatusBar
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface MessageListProps {
|
|||||||
listRef: React.RefObject<HTMLDivElement | null>
|
listRef: React.RefObject<HTMLDivElement | null>
|
||||||
bottomRef: React.RefObject<HTMLDivElement | null>
|
bottomRef: React.RefObject<HTMLDivElement | null>
|
||||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
||||||
|
onReachTop?: () => void
|
||||||
onImageClick: (imageUrl: string) => void
|
onImageClick: (imageUrl: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export function MessageList({
|
|||||||
listRef,
|
listRef,
|
||||||
bottomRef,
|
bottomRef,
|
||||||
onScroll,
|
onScroll,
|
||||||
|
onReachTop,
|
||||||
onImageClick
|
onImageClick
|
||||||
}: MessageListProps): React.ReactElement {
|
}: MessageListProps): React.ReactElement {
|
||||||
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||||
@@ -41,8 +43,13 @@ export function MessageList({
|
|||||||
})
|
})
|
||||||
const virtualItems = virtualizer.getVirtualItems()
|
const virtualItems = virtualizer.getVirtualItems()
|
||||||
|
|
||||||
|
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
|
||||||
|
onScroll(event)
|
||||||
|
if (event.currentTarget.scrollTop < 120) onReachTop?.()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}>
|
<div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}>
|
||||||
{isLoadingMessages && <div className="message-loading-pill">正在加载聊天记录...</div>}
|
{isLoadingMessages && <div className="message-loading-pill">正在加载聊天记录...</div>}
|
||||||
{hiddenMessageCount > 0 && (
|
{hiddenMessageCount > 0 && (
|
||||||
<div className="wechat-system-message-row">
|
<div className="wechat-system-message-row">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
import { ConversationItem } from './ConversationItem'
|
import { ConversationItem } from './ConversationItem'
|
||||||
|
|
||||||
@@ -29,6 +30,31 @@ export function ConversationSection({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onSelectContact
|
onSelectContact
|
||||||
}: ConversationSectionProps): React.ReactElement {
|
}: ConversationSectionProps): React.ReactElement {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [scrollMargin, setScrollMargin] = useState(0)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const updateMargin = (): void => {
|
||||||
|
const list = listRef.current
|
||||||
|
const scrollElement = document.querySelector<HTMLDivElement>('.conversation-list')
|
||||||
|
if (!list || !scrollElement) return
|
||||||
|
setScrollMargin(list.getBoundingClientRect().top - scrollElement.getBoundingClientRect().top)
|
||||||
|
}
|
||||||
|
updateMargin()
|
||||||
|
window.addEventListener('resize', updateMargin)
|
||||||
|
return () => window.removeEventListener('resize', updateMargin)
|
||||||
|
}, [contacts.length, expanded])
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: expanded ? contacts.length : 0,
|
||||||
|
getScrollElement: () => document.querySelector<HTMLDivElement>('.conversation-list'),
|
||||||
|
scrollMargin,
|
||||||
|
estimateSize: () => 58,
|
||||||
|
overscan: 8
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expanded) virtualizer.measure()
|
||||||
|
}, [contacts.length, expanded, virtualizer])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="conversation-section">
|
<section className="conversation-section">
|
||||||
<button type="button" className="conversation-section-header" onClick={onToggle}>
|
<button type="button" className="conversation-section-header" onClick={onToggle}>
|
||||||
@@ -40,16 +66,29 @@ export function ConversationSection({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="conversation-section-list">
|
<div ref={listRef} className="conversation-section-list">
|
||||||
{contacts.length > 0 ? (
|
{contacts.length > 0 ? (
|
||||||
contacts.map((contact) => (
|
<div
|
||||||
<ConversationItem
|
className="conversation-section-virtual-content"
|
||||||
key={contact.md5}
|
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||||
contact={contact}
|
>
|
||||||
active={selectedContact?.md5 === contact.md5}
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
onSelect={onSelectContact}
|
const contact = contacts[virtualItem.index]
|
||||||
/>
|
return (
|
||||||
))
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
className="conversation-section-virtual-row"
|
||||||
|
style={{ transform: `translateY(${virtualItem.start - scrollMargin}px)` }}
|
||||||
|
>
|
||||||
|
<ConversationItem
|
||||||
|
contact={contact}
|
||||||
|
active={selectedContact?.md5 === contact.md5}
|
||||||
|
onSelect={onSelectContact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="conversation-section-empty">{emptyText}</div>
|
<div className="conversation-section-empty">{emptyText}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useMemo, useRef, useState } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { Contact } from '../../../../shared/types'
|
import { Contact } from '../../../../shared/types'
|
||||||
import { AccountSummary } from '../account/AccountSummary'
|
import { AccountSummary } from '../account/AccountSummary'
|
||||||
import { ConversationSection } from './ConversationSection'
|
import { ConversationItem } from './ConversationItem'
|
||||||
import { ConversationSidebarHeader } from './ConversationSidebarHeader'
|
import { ConversationSidebarHeader } from './ConversationSidebarHeader'
|
||||||
|
|
||||||
interface SelfInfo {
|
interface SelfInfo {
|
||||||
@@ -25,6 +26,11 @@ export interface ConversationSidebarProps {
|
|||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SectionName = 'groups' | 'contacts'
|
||||||
|
type ConversationRow =
|
||||||
|
| { kind: 'header'; id: string; title: string; count: number; section: SectionName }
|
||||||
|
| { kind: 'contact'; id: string; contact: Contact }
|
||||||
|
|
||||||
export function ConversationSidebar({
|
export function ConversationSidebar({
|
||||||
contacts,
|
contacts,
|
||||||
selectedContact,
|
selectedContact,
|
||||||
@@ -38,10 +44,34 @@ export function ConversationSidebar({
|
|||||||
onOpenSettings
|
onOpenSettings
|
||||||
}: ConversationSidebarProps): React.ReactElement {
|
}: ConversationSidebarProps): React.ReactElement {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [expandedSection, setExpandedSection] = useState<'groups' | 'contacts'>('groups')
|
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
||||||
|
groups: true,
|
||||||
|
contacts: false
|
||||||
|
})
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const groups = contacts.filter((contact) => contact.type === 'group')
|
const groups = contacts.filter((contact) => contact.type === 'group')
|
||||||
const users = contacts.filter((contact) => contact.type === 'user')
|
const users = contacts.filter((contact) => contact.type === 'user')
|
||||||
|
const rows = useMemo<ConversationRow[]>(
|
||||||
|
() => [
|
||||||
|
{ kind: 'header', id: 'groups-header', title: '群聊', count: groups.length, section: 'groups' },
|
||||||
|
...(expandedSections.groups
|
||||||
|
? groups.map((contact) => ({ kind: 'contact' as const, id: `group-${contact.md5}`, contact }))
|
||||||
|
: []),
|
||||||
|
{ kind: 'header', id: 'contacts-header', title: '联系人', count: users.length, section: 'contacts' },
|
||||||
|
...(expandedSections.contacts
|
||||||
|
? users.map((contact) => ({ kind: 'contact' as const, id: `user-${contact.md5}`, contact }))
|
||||||
|
: [])
|
||||||
|
],
|
||||||
|
[expandedSections.contacts, expandedSections.groups, groups, users]
|
||||||
|
)
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => listRef.current,
|
||||||
|
estimateSize: (index) => (rows[index]?.kind === 'header' ? 38 : 58),
|
||||||
|
getItemKey: (index) => rows[index]?.id || index,
|
||||||
|
overscan: 10
|
||||||
|
})
|
||||||
|
|
||||||
const handleSearchChange = (term: string): void => {
|
const handleSearchChange = (term: string): void => {
|
||||||
setSearchTerm(term)
|
setSearchTerm(term)
|
||||||
@@ -57,25 +87,47 @@ export function ConversationSidebar({
|
|||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onDateRangeChange={onDateRangeChange}
|
onDateRangeChange={onDateRangeChange}
|
||||||
/>
|
/>
|
||||||
<div className="conversation-list" aria-label="会话列表">
|
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
||||||
<ConversationSection
|
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||||
title="群聊"
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
contacts={groups}
|
const row = rows[virtualItem.index]
|
||||||
expanded={expandedSection === 'groups'}
|
if (!row) return null
|
||||||
selectedContact={selectedContact}
|
if (row.kind === 'header') {
|
||||||
emptyText="暂无群聊"
|
const expanded = expandedSections[row.section]
|
||||||
onToggle={() => setExpandedSection('groups')}
|
return (
|
||||||
onSelectContact={onSelectContact}
|
<button
|
||||||
/>
|
key={virtualItem.key}
|
||||||
<ConversationSection
|
type="button"
|
||||||
title="联系人"
|
className="conversation-section-header conversation-virtual-row"
|
||||||
contacts={users}
|
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||||
expanded={expandedSection === 'contacts'}
|
onClick={() =>
|
||||||
selectedContact={selectedContact}
|
setExpandedSections((current) => ({ ...current, [row.section]: !current[row.section] }))
|
||||||
emptyText="暂无联系人"
|
}
|
||||||
onToggle={() => setExpandedSection('contacts')}
|
>
|
||||||
onSelectContact={onSelectContact}
|
<span className="conversation-section-chevron" aria-hidden="true">
|
||||||
/>
|
<svg viewBox="0 0 16 16" focusable="false">
|
||||||
|
<path d={expanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'} />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="conversation-section-title">{row.title} ({row.count})</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualItem.key}
|
||||||
|
className="conversation-virtual-row"
|
||||||
|
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||||
|
>
|
||||||
|
<ConversationItem
|
||||||
|
contact={row.contact}
|
||||||
|
active={selectedContact?.md5 === row.contact.md5}
|
||||||
|
onSelect={onSelectContact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="conversation-sidebar-account">
|
<div className="conversation-sidebar-account">
|
||||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ExportMessageKind,
|
ExportMessageKind,
|
||||||
ExportNameMode
|
ExportNameMode
|
||||||
} from '../../../../shared/export'
|
} from '../../../../shared/export'
|
||||||
|
import type { ExportRequest, ExportResult, ExportTaskRecord } from '../../../../shared/export'
|
||||||
|
|
||||||
type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
|
type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
|
||||||
type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
|
type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
|
||||||
@@ -34,6 +35,9 @@ interface ExportWorkspaceProps {
|
|||||||
dbReady: boolean
|
dbReady: boolean
|
||||||
onSelectContact: (contact: Contact) => void
|
onSelectContact: (contact: Contact) => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
|
exportTasks: ExportTaskRecord[]
|
||||||
|
onStartExport: (request: ExportRequest) => Promise<ExportResult>
|
||||||
|
onCancelExport: (jobId: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageKinds = [
|
const messageKinds = [
|
||||||
@@ -53,6 +57,7 @@ const formatLabels: Record<ExportFormat, { label: string; hint?: string }> = {
|
|||||||
json: { label: 'JSON' },
|
json: { label: 'JSON' },
|
||||||
markdown: { label: 'Markdown' }
|
markdown: { label: 'Markdown' }
|
||||||
}
|
}
|
||||||
|
const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
|
||||||
|
|
||||||
function displayName(contact: Contact | null): string {
|
function displayName(contact: Contact | null): string {
|
||||||
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
|
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
|
||||||
@@ -73,7 +78,10 @@ export function ExportWorkspace({
|
|||||||
selfInfo,
|
selfInfo,
|
||||||
dbReady,
|
dbReady,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
onOpenSettings
|
onOpenSettings,
|
||||||
|
exportTasks,
|
||||||
|
onStartExport,
|
||||||
|
onCancelExport
|
||||||
}: ExportWorkspaceProps): React.ReactElement {
|
}: ExportWorkspaceProps): React.ReactElement {
|
||||||
const [contactFilter, setContactFilter] = useState('')
|
const [contactFilter, setContactFilter] = useState('')
|
||||||
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
||||||
@@ -88,12 +96,13 @@ export function ExportWorkspace({
|
|||||||
const [preferOriginal, setPreferOriginal] = useState(true)
|
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||||
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
||||||
const [keepMissing, setKeepMissing] = useState(false)
|
const [keepMissing, setKeepMissing] = useState(false)
|
||||||
const [format, setFormat] = useState<ExportFormat>('html')
|
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||||
const [zip, setZip] = useState(false)
|
const [zip, setZip] = useState(false)
|
||||||
const [fileName, setFileName] = useState('')
|
const [fileName, setFileName] = useState('')
|
||||||
const [status, setStatus] = useState<ExportStatus>('idle')
|
const [status, setStatus] = useState<ExportStatus>('idle')
|
||||||
const [jobId, setJobId] = useState('')
|
const [jobId, setJobId] = useState('')
|
||||||
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
|
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
|
||||||
|
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
|
||||||
|
|
||||||
const filteredContacts = useMemo(() => {
|
const filteredContacts = useMemo(() => {
|
||||||
const keyword = contactFilter.trim().toLowerCase()
|
const keyword = contactFilter.trim().toLowerCase()
|
||||||
@@ -107,8 +116,17 @@ export function ExportWorkspace({
|
|||||||
}, [contactFilter, contactType, contacts])
|
}, [contactFilter, contactType, contacts])
|
||||||
|
|
||||||
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
|
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
|
||||||
|
const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5)
|
||||||
|
const taskCount = exportTasks.filter((task) => task.status === 'running').length
|
||||||
const activeName = displayName(activeContact)
|
const activeName = displayName(activeContact)
|
||||||
const preview = previewMessages.slice(-20)
|
const preview = previewMessages.slice(-20)
|
||||||
|
const previewMediaCount = preview.filter((message) =>
|
||||||
|
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '')
|
||||||
|
).length
|
||||||
|
const previewBytes = preview.reduce(
|
||||||
|
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
const outputName = fileName.trim() || `${activeName}_聊天档案`
|
const outputName = fileName.trim() || `${activeName}_聊天档案`
|
||||||
const nameOptions: { value: ExportNameMode; label: string }[] =
|
const nameOptions: { value: ExportNameMode; label: string }[] =
|
||||||
activeContact?.type === 'group'
|
activeContact?.type === 'group'
|
||||||
@@ -156,21 +174,31 @@ export function ExportWorkspace({
|
|||||||
return map
|
return map
|
||||||
}, [activeContact, groupMembers, selfInfo])
|
}, [activeContact, groupMembers, selfInfo])
|
||||||
|
|
||||||
|
const previewName = (message: Message): string =>
|
||||||
|
(message.senderId && nameMap[message.senderId]) ||
|
||||||
|
message.name ||
|
||||||
|
(message.isSender ? selfInfo?.nickname : undefined) ||
|
||||||
|
(message.isSender ? '我' : '联系人')
|
||||||
|
const previewAvatar = (message: Message): string | undefined =>
|
||||||
|
(message.senderId && avatarUrls[message.senderId]) ||
|
||||||
|
message.img ||
|
||||||
|
(message.isSender ? selfInfo?.avatar : undefined)
|
||||||
|
const previewItems = preview.map((message) => ({
|
||||||
|
...message,
|
||||||
|
name: previewName(message),
|
||||||
|
img: previewAvatar(message)
|
||||||
|
}))
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
|
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
|
||||||
let cancelled = false
|
setGroupMembers([])
|
||||||
if (!activeContact || activeContact.type !== 'group') {
|
if (!activeContact || activeContact.type !== 'group') return
|
||||||
setGroupMembers([])
|
const timer = window.setTimeout(() => {
|
||||||
return () => {
|
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
|
||||||
cancelled = true
|
setGroupMembers((snapshot?.members || []) as GroupMemberName[])
|
||||||
}
|
})
|
||||||
}
|
}, 300)
|
||||||
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
|
return () => window.clearTimeout(timer)
|
||||||
if (!cancelled) setGroupMembers((snapshot?.members || []) as GroupMemberName[])
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [activeContact])
|
}, [activeContact])
|
||||||
|
|
||||||
const toggleKind = (value: string): void => {
|
const toggleKind = (value: string): void => {
|
||||||
@@ -188,6 +216,24 @@ export function ExportWorkspace({
|
|||||||
setJobId(nextJobId)
|
setJobId(nextJobId)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
setStatus('running')
|
setStatus('running')
|
||||||
|
let exportNameMap = nameMap
|
||||||
|
let exportAvatarUrls = avatarUrls
|
||||||
|
if (activeContact.type === 'group') {
|
||||||
|
const snapshot = await window.api.getGroupSnapshot(activeContact.md5)
|
||||||
|
const members = (snapshot?.members || []) as GroupMemberName[]
|
||||||
|
setGroupMembers(members)
|
||||||
|
exportNameMap = { ...nameMap }
|
||||||
|
exportAvatarUrls = { ...avatarUrls }
|
||||||
|
for (const member of members) {
|
||||||
|
exportNameMap[member.wxid] =
|
||||||
|
nameMode === 'groupNickname'
|
||||||
|
? member.groupNickname || member.nickname || member.wxid
|
||||||
|
: nameMode === 'remark'
|
||||||
|
? member.remark || member.wechatNickname || member.wxid
|
||||||
|
: member.wechatNickname || member.wxid
|
||||||
|
if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||||
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
|
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
|
||||||
@@ -213,13 +259,25 @@ export function ExportWorkspace({
|
|||||||
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
||||||
includeMedia,
|
includeMedia,
|
||||||
includeAvatars,
|
includeAvatars,
|
||||||
avatarUrls,
|
avatarUrls: exportAvatarUrls,
|
||||||
nameMode,
|
nameMode,
|
||||||
nameMap,
|
nameMap: exportNameMap,
|
||||||
zip
|
zip
|
||||||
}
|
}
|
||||||
const result = await window.api.startExport(request)
|
const result = await onStartExport(request)
|
||||||
if (!result.success && result.error !== '已取消') setStatus('idle')
|
if (result.success) {
|
||||||
|
setProgress((current) => ({
|
||||||
|
...(current || { jobId: nextJobId, processed: result.messageCount || 0 }),
|
||||||
|
phase: 'completed',
|
||||||
|
processed: result.messageCount ?? current?.processed ?? 0,
|
||||||
|
total: result.messageCount ?? current?.total,
|
||||||
|
percent: 100,
|
||||||
|
outputPath: result.outputPath
|
||||||
|
}))
|
||||||
|
setStatus('completed')
|
||||||
|
} else if (result.error !== '已取消') {
|
||||||
|
setStatus('idle')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(
|
React.useEffect(
|
||||||
@@ -233,6 +291,19 @@ export function ExportWorkspace({
|
|||||||
[jobId]
|
[jobId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentTask) return
|
||||||
|
setJobId(currentTask.jobId)
|
||||||
|
setProgress(currentTask.progress)
|
||||||
|
setStatus(
|
||||||
|
currentTask.status === 'running'
|
||||||
|
? 'running'
|
||||||
|
: currentTask.status === 'completed'
|
||||||
|
? 'completed'
|
||||||
|
: 'idle'
|
||||||
|
)
|
||||||
|
}, [currentTask])
|
||||||
|
|
||||||
const targetPath =
|
const targetPath =
|
||||||
format === 'html'
|
format === 'html'
|
||||||
? zip
|
? zip
|
||||||
@@ -318,6 +389,21 @@ export function ExportWorkspace({
|
|||||||
|
|
||||||
<main className="export-config-panel">
|
<main className="export-config-panel">
|
||||||
<div className="export-config-scroll">
|
<div className="export-config-scroll">
|
||||||
|
<button type="button" className="export-task-center-button" onClick={() => setTaskCenterOpen((open) => !open)}>
|
||||||
|
任务中心{taskCount > 0 ? ` (${taskCount})` : ''}
|
||||||
|
</button>
|
||||||
|
{taskCenterOpen && (
|
||||||
|
<section className="export-task-center">
|
||||||
|
<div className="export-section-heading"><h3>导出任务</h3><span>{exportTasks.length} 条记录</span></div>
|
||||||
|
{exportTasks.length === 0 ? <p>暂无导出记录</p> : exportTasks.map((task) => (
|
||||||
|
<div className="export-task-row" key={task.jobId}>
|
||||||
|
<span><strong>{task.contactName}</strong><small>{task.format.toUpperCase()} · {task.progress.phase}</small></span>
|
||||||
|
<span className="export-task-progress"><i style={{ width: `${task.progress.percent ?? 0}%` }} /><b>{task.progress.percent ?? 0}%</b></span>
|
||||||
|
{task.status === 'running' && <button type="button" onClick={() => void onCancelExport(task.jobId)}>取消</button>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
<header className="export-config-header">
|
<header className="export-config-header">
|
||||||
<span className="export-chat-avatar">
|
<span className="export-chat-avatar">
|
||||||
{activeContact?.avatar ? (
|
{activeContact?.avatar ? (
|
||||||
@@ -335,6 +421,29 @@ export function ExportWorkspace({
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<section className="export-section export-format-top">
|
||||||
|
<h3>导出格式</h3>
|
||||||
|
<div className="export-format-grid">
|
||||||
|
{formatOrder.map((value) => (
|
||||||
|
<button key={value} type="button" className={format === value ? 'active' : ''} onClick={() => setFormat(value)}>
|
||||||
|
<strong>{formatLabels[value].label}</strong>
|
||||||
|
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="export-helper-text">CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。</p>
|
||||||
|
{format === 'html' && (
|
||||||
|
<div className="export-html-options">
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="html-package-top" checked={!zip} onChange={() => setZip(false)} /> HTML 资源包
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="html-package-top" checked={zip} onChange={() => setZip(true)} /> HTML 资源包并压缩为 ZIP
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="export-section">
|
<section className="export-section">
|
||||||
<div className="export-section-heading">
|
<div className="export-section-heading">
|
||||||
<h3>时间范围</h3>
|
<h3>时间范围</h3>
|
||||||
@@ -396,13 +505,19 @@ export function ExportWorkspace({
|
|||||||
<h3>消息内容</h3>
|
<h3>消息内容</h3>
|
||||||
<div className="export-kind-grid">
|
<div className="export-kind-grid">
|
||||||
{messageKinds.map(([value, label]) => (
|
{messageKinds.map(([value, label]) => (
|
||||||
<label key={value} className="export-check-row">
|
<label key={value} className={`export-check-row ${value === 'video' ? 'unsupported' : ''}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedKinds.has(value)}
|
checked={value !== 'video' && selectedKinds.has(value)}
|
||||||
|
disabled={value === 'video'}
|
||||||
onChange={() => toggleKind(value)}
|
onChange={() => toggleKind(value)}
|
||||||
/>
|
/>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
{value === 'video' && (
|
||||||
|
<span className="export-unsupported-hint" title="当前版本暂不支持视频导出" aria-label="当前版本暂不支持视频导出">
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -431,16 +546,17 @@ export function ExportWorkspace({
|
|||||||
<span>包含图片、视频、语音及动态表情</span>
|
<span>包含图片、视频、语音及动态表情</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={includeMedia}
|
checked={includeMedia}
|
||||||
|
disabled={format !== 'html'}
|
||||||
onChange={(event) => setIncludeMedia(event.target.checked)}
|
onChange={(event) => setIncludeMedia(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className={`export-media-options ${includeMedia ? '' : 'disabled'}`}>
|
<div className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}>
|
||||||
<label className="export-check-row">
|
<label className="export-check-row">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={preferOriginal}
|
checked={preferOriginal}
|
||||||
disabled={!includeMedia}
|
disabled={!includeMedia || format !== 'html'}
|
||||||
onChange={(event) => setPreferOriginal(event.target.checked)}
|
onChange={(event) => setPreferOriginal(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>优先导出原图</span>
|
<span>优先导出原图</span>
|
||||||
@@ -449,7 +565,7 @@ export function ExportWorkspace({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={fallbackThumbnail}
|
checked={fallbackThumbnail}
|
||||||
disabled={!includeMedia}
|
disabled={!includeMedia || format !== 'html'}
|
||||||
onChange={(event) => setFallbackThumbnail(event.target.checked)}
|
onChange={(event) => setFallbackThumbnail(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>原图缺失时使用缩略图</span>
|
<span>原图缺失时使用缩略图</span>
|
||||||
@@ -458,12 +574,13 @@ export function ExportWorkspace({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={keepMissing}
|
checked={keepMissing}
|
||||||
disabled={!includeMedia}
|
disabled={!includeMedia || format !== 'html'}
|
||||||
onChange={(event) => setKeepMissing(event.target.checked)}
|
onChange={(event) => setKeepMissing(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>媒体缺失时保留占位说明</span>
|
<span>媒体缺失时保留占位说明</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="export-helper-text">资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。</p>
|
||||||
<div className="export-resource-statuses">
|
<div className="export-resource-statuses">
|
||||||
<span>图片解密:已就绪</span>
|
<span>图片解密:已就绪</span>
|
||||||
<span>视频资源:可用</span>
|
<span>视频资源:可用</span>
|
||||||
@@ -484,7 +601,7 @@ export function ExportWorkspace({
|
|||||||
<section className="export-section">
|
<section className="export-section">
|
||||||
<h3>导出格式</h3>
|
<h3>导出格式</h3>
|
||||||
<div className="export-format-grid">
|
<div className="export-format-grid">
|
||||||
{(Object.keys(formatLabels) as ExportFormat[]).map((value) => (
|
{formatOrder.map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -570,8 +687,8 @@ export function ExportWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
<div className="export-message-preview">
|
<div className="export-message-preview">
|
||||||
<div className="export-preview-date">最近消息</div>
|
<div className="export-preview-date">最近消息</div>
|
||||||
{(preview.length
|
{(previewItems.length
|
||||||
? preview
|
? previewItems
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
id: 'empty',
|
id: 'empty',
|
||||||
@@ -585,11 +702,13 @@ export function ExportWorkspace({
|
|||||||
).map((message) => (
|
).map((message) => (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`export-preview-message ${message.isSender ? 'mine' : ''}`}
|
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
|
||||||
|
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="export-preview-avatar">
|
<span className="export-preview-avatar">
|
||||||
{message.img || (message.isSender && selfInfo?.avatar) ? (
|
{message.img || (message.isSender && selfInfo?.avatar) ? (
|
||||||
<img src={message.isSender ? selfInfo?.avatar : message.img} alt="" />
|
<img src={message.img || selfInfo?.avatar} alt="" />
|
||||||
) : (
|
) : (
|
||||||
(message.isSender ? '我' : message.name || '友').slice(0, 1)
|
(message.isSender ? '我' : message.name || '友').slice(0, 1)
|
||||||
)}
|
)}
|
||||||
@@ -604,6 +723,17 @@ export function ExportWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="export-preview-stats export-preview-real-stats">
|
||||||
|
<span>
|
||||||
|
预览消息<strong>{previewItems.length}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
媒体预览<strong>{previewMediaCount}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
预估文本大小<strong>{previewBytes < 1024 ? `${previewBytes} B` : `${(previewBytes / 1024).toFixed(1)} KB`}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="export-preview-stats">
|
<div className="export-preview-stats">
|
||||||
<span>
|
<span>
|
||||||
消息总数<strong>待统计</strong>
|
消息总数<strong>待统计</strong>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AccountDatabasePage } from './pages/AccountDatabasePage'
|
|||||||
import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
|
import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
|
||||||
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
||||||
import { AIModelPage } from './pages/AIModelPage'
|
import { AIModelPage } from './pages/AIModelPage'
|
||||||
|
import { RecallProtectionPage } from './pages/RecallProtectionPage'
|
||||||
import type { Contact } from '../../../../shared/types'
|
import type { Contact } from '../../../../shared/types'
|
||||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||||
|
|
||||||
@@ -49,14 +50,10 @@ export function SettingsWorkspace({
|
|||||||
dbReady={dbReady}
|
dbReady={dbReady}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
/>
|
/>
|
||||||
{selectedCategory === 'account-database' ? (
|
<div className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}>
|
||||||
<AccountDatabasePage
|
<AccountDatabasePage dbKey={dbKey} dbReady={dbReady} selfInfo={selfInfo} onNotice={onNotice} />
|
||||||
dbKey={dbKey}
|
</div>
|
||||||
dbReady={dbReady}
|
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
|
||||||
selfInfo={selfInfo}
|
|
||||||
onNotice={onNotice}
|
|
||||||
/>
|
|
||||||
) : selectedCategory === 'database-key' ? (
|
|
||||||
<DatabaseKeyPage
|
<DatabaseKeyPage
|
||||||
dbKey={dbKey}
|
dbKey={dbKey}
|
||||||
dbReady={dbReady}
|
dbReady={dbReady}
|
||||||
@@ -69,12 +66,20 @@ export function SettingsWorkspace({
|
|||||||
onReturnToLogin={onReturnToLogin}
|
onReturnToLogin={onReturnToLogin}
|
||||||
onNotice={onNotice}
|
onNotice={onNotice}
|
||||||
/>
|
/>
|
||||||
) : selectedCategory === 'image-key' ? (
|
</div>
|
||||||
|
<div className={`settings-page-panel ${selectedCategory === 'image-key' ? 'active' : ''}`}>
|
||||||
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
||||||
) : selectedCategory === 'ai-model' ? (
|
</div>
|
||||||
|
<div className={`settings-page-panel ${selectedCategory === 'ai-model' ? 'active' : ''}`}>
|
||||||
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||||
) : (
|
</div>
|
||||||
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
<div className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}>
|
||||||
|
<RecallProtectionPage onNotice={onNotice} />
|
||||||
|
</div>
|
||||||
|
{!['account-database', 'database-key', 'image-key', 'ai-model', 'recall-protection'].includes(selectedCategory) && (
|
||||||
|
<div className="settings-page-panel active">
|
||||||
|
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
|||||||
label: '智能能力',
|
label: '智能能力',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'ai-model', label: 'AI 模型' },
|
{ id: 'ai-model', label: 'AI 模型' },
|
||||||
{ id: 'local-api', label: '本地 API' }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,6 +30,7 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
|||||||
{
|
{
|
||||||
label: '应用',
|
label: '应用',
|
||||||
items: [
|
items: [
|
||||||
|
{ id: 'recall-protection', label: '防撤回' },
|
||||||
{ id: 'appearance', label: '外观与行为' },
|
{ id: 'appearance', label: '外观与行为' },
|
||||||
{ id: 'advanced', label: '高级' },
|
{ id: 'advanced', label: '高级' },
|
||||||
{ id: 'about', label: '关于' }
|
{ id: 'about', label: '关于' }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type SettingsCategoryId =
|
|||||||
| 'database-key'
|
| 'database-key'
|
||||||
| 'image-key'
|
| 'image-key'
|
||||||
| 'ai-model'
|
| 'ai-model'
|
||||||
|
| 'recall-protection'
|
||||||
| 'local-api'
|
| 'local-api'
|
||||||
| 'storage-export'
|
| 'storage-export'
|
||||||
| 'cache-cleanup'
|
| 'cache-cleanup'
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function RecallProtectionPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
void window.api.getSettings().then((result) => {
|
||||||
|
if (active) setEnabled(result.settings.recallProtectionEnabled)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const changeEnabled = async (checked: boolean): Promise<void> => {
|
||||||
|
const result = await window.api.setSettings({ recallProtectionEnabled: checked })
|
||||||
|
setEnabled(result.settings.recallProtectionEnabled)
|
||||||
|
onNotice(checked ? '已开启防撤回' : '已关闭防撤回')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page">
|
||||||
|
<header className="settings-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>防撤回</h1>
|
||||||
|
<p>尽量保留已撤回的聊天内容,并在聊天记录中明确标记。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="settings-page-scroll">
|
||||||
|
<div className="settings-page-content">
|
||||||
|
<h2 className="settings-section-heading">消息处理</h2>
|
||||||
|
<section className="settings-card settings-recall-card">
|
||||||
|
<div className="settings-recall-grid">
|
||||||
|
<label className="settings-recall-option">
|
||||||
|
<span>
|
||||||
|
<b>开启防撤回</b>
|
||||||
|
<small>尽量保留已撤回的聊天内容,方便后续查看。</small>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(event) => void changeEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<aside className="settings-recall-notice">
|
||||||
|
<strong>性能提示</strong>
|
||||||
|
<span>开启后会为消息表增加监听,数据量较大或磁盘较慢时可能让加载变慢。</span>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -40,6 +40,16 @@ export interface ExportJobProgress {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExportTaskRecord {
|
||||||
|
jobId: string
|
||||||
|
contactId: string
|
||||||
|
contactName: string
|
||||||
|
format: ExportFormat
|
||||||
|
status: 'running' | 'completed' | 'cancelled' | 'failed'
|
||||||
|
progress: ExportJobProgress
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExportResult {
|
export interface ExportResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
outputPath?: string
|
outputPath?: string
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ type SystemContent = {
|
|||||||
type: 'system'
|
type: 'system'
|
||||||
content: string
|
content: string
|
||||||
raw?: string
|
raw?: string
|
||||||
|
pat?: boolean
|
||||||
recall?: {
|
recall?: {
|
||||||
targetId?: string
|
targetId?: string
|
||||||
targetIds?: string[]
|
targetIds?: string[]
|
||||||
|
|||||||
Reference in New Issue
Block a user