From 816770d4070ed8e7f2e9d1eb790ff0fa04f9cb3a Mon Sep 17 00:00:00 2001 From: hicccc77 <98377878+hicccc77@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:45:15 +0800 Subject: [PATCH 01/13] fix: remove pacman target from Linux build (bsdtar not available on Ubuntu runner) --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 4a784bf..b6ec942 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,6 @@ "linux": { "icon": "public/icon.png", "target": [ - "pacman", "deb", "tar.gz" ], From 3fabf961e500a06a0bb95d15e94e1b05dc78a8da Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 20 Mar 2026 14:57:45 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dhtml=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/config.ts | 10 +++++++++- electron/services/exportService.ts | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/electron/services/config.ts b/electron/services/config.ts index 4b8324d..bb6b9f5 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -688,8 +688,16 @@ export class ConfigService { } } + private getUserDataPath(): string { + const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() + if (workerUserDataPath) { + return workerUserDataPath + } + return app?.getPath?.('userData') || process.cwd() + } + getCacheBasePath(): string { - return join(app.getPath('userData'), 'cache') + return join(this.getUserDataPath(), 'cache') } getAll(): Partial { diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 6929f59..6649e0c 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -6945,6 +6945,7 @@ class ExportService { if (collected.rows.length === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } + const totalMessages = collected.rows.length const senderUsernames = new Set() let senderScanIndex = 0 @@ -6987,6 +6988,7 @@ class ExportService { : [] const mediaCache = new Map() + const mediaDirCache = new Set() if (mediaMessages.length > 0) { await this.preloadMediaLookupCaches(sessionId, mediaMessages, { From a163ea377cc3c74b669341cacf2832c9b6791a10 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 20 Mar 2026 15:12:13 +0800 Subject: [PATCH 03/13] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=97=B6=20=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E5=8F=AA=E6=9C=89=E4=B8=80=E4=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Export/ExportDateRangeDialog.scss | 128 +++++-- .../Export/ExportDateRangeDialog.tsx | 348 ++++++++++-------- 2 files changed, 292 insertions(+), 184 deletions(-) diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss index 9bb27c4..31565e9 100644 --- a/src/components/Export/ExportDateRangeDialog.scss +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -13,13 +13,14 @@ width: min(480px, calc(100vw - 32px)); max-height: calc(100vh - 64px); overflow-y: auto; - border-radius: 12px; + border-radius: 16px; border: 1px solid var(--border-color); background: var(--bg-secondary-solid, var(--bg-primary)); - padding: 12px; + padding: 14px; display: flex; flex-direction: column; gap: 10px; + box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16); } .export-date-range-dialog-header { @@ -83,8 +84,8 @@ } .export-date-range-mode-banner { - border-radius: 8px; - padding: 6px 8px; + border-radius: 10px; + padding: 7px 10px; font-size: 11px; line-height: 1.4; border: 1px solid var(--border-color); @@ -98,47 +99,92 @@ } } -.export-date-range-calendar-grid { +.export-date-range-boundary-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.export-date-range-boundary-card { + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + padding: 8px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); + } + + .boundary-label { + font-size: 11px; + color: var(--text-secondary); + } +} + +.export-date-range-selection-hint { + font-size: 11px; + color: var(--text-secondary); + padding: 0 2px; +} + .export-date-range-calendar-panel { border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--bg-secondary); - padding: 7px; + border-radius: 12px; + background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.04), transparent 28%), var(--bg-secondary); + padding: 10px; + + &.single { + width: 100%; + } } .export-date-range-calendar-panel-header { display: flex; justify-content: space-between; - align-items: flex-start; + align-items: center; gap: 8px; } .export-date-range-calendar-date-label { display: flex; flex-direction: column; - gap: 2px; + gap: 3px; span { font-size: 11px; color: var(--text-secondary); } + + strong { + font-size: 13px; + color: var(--text-primary); + } } .export-date-range-date-input { width: 100%; min-width: 0; - border-radius: 6px; + border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); - height: 24px; - padding: 0 7px; - font-size: 11px; + height: 30px; + padding: 0 9px; + font-size: 12px; + + &:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); + } &.invalid { border-color: #e84d4d; @@ -149,28 +195,31 @@ .export-date-range-calendar-nav { display: inline-flex; align-items: center; - gap: 4px; + gap: 6px; font-size: 11px; color: var(--text-primary); button { - width: 20px; - height: 20px; - border-radius: 5px; + width: 28px; + height: 28px; + border-radius: 8px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); cursor: pointer; padding: 0; line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; } } .export-date-range-calendar-weekdays { - margin-top: 6px; + margin-top: 10px; display: grid; grid-template-columns: repeat(7, 1fr); - gap: 2px; + gap: 4px; span { text-align: center; @@ -180,32 +229,49 @@ } .export-date-range-calendar-days { - margin-top: 4px; + margin-top: 6px; display: grid; grid-template-columns: repeat(7, 1fr); - gap: 2px; + gap: 4px; } .export-date-range-calendar-day { border: 1px solid transparent; - border-radius: 6px; - min-height: 20px; + border-radius: 10px; + min-height: 34px; background: var(--bg-primary); color: var(--text-primary); - font-size: 10px; + font-size: 12px; cursor: pointer; padding: 0; + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease; + + &:hover { + border-color: rgba(var(--primary-rgb), 0.28); + transform: translateY(-1px); + } &.outside { color: var(--text-quaternary); - opacity: 0.75; + opacity: 0.72; } - &.selected { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.14); + &.in-range { + background: rgba(var(--primary-rgb), 0.1); color: var(--primary); + } + + &.range-start, + &.range-end { + border-color: var(--primary); + background: var(--primary); + color: #fff; font-weight: 600; + opacity: 1; + } + + &.active-boundary { + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.22); } } @@ -247,8 +313,8 @@ } } -@media (max-width: 860px) { - .export-date-range-calendar-grid { +@media (max-width: 640px) { + .export-date-range-boundary-row { grid-template-columns: 1fr; } } diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index e6695f1..f450803 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' -import { Check, X } from 'lucide-react' +import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react' import { EXPORT_DATE_RANGE_PRESETS, WEEKDAY_SHORT_LABELS, @@ -29,15 +29,15 @@ interface ExportDateRangeDialogProps { onConfirm: (value: ExportDateRangeSelection) => void } +type ActiveBoundary = 'start' | 'end' + interface ExportDateRangeDialogDraft extends ExportDateRangeSelection { - startPanelMonth: Date - endPanelMonth: Date + panelMonth: Date } const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({ ...cloneExportDateRangeSelection(value), - startPanelMonth: toMonthStart(value.dateRange.start), - endPanelMonth: toMonthStart(value.dateRange.end) + panelMonth: toMonthStart(value.dateRange.start) }) export function ExportDateRangeDialog({ @@ -48,6 +48,7 @@ export function ExportDateRangeDialog({ onConfirm }: ExportDateRangeDialogProps) { const [draft, setDraft] = useState(() => buildDialogDraft(value)) + const [activeBoundary, setActiveBoundary] = useState('start') const [dateInput, setDateInput] = useState({ start: formatDateInputValue(value.dateRange.start), end: formatDateInputValue(value.dateRange.end) @@ -58,6 +59,7 @@ export function ExportDateRangeDialog({ if (!open) return const nextDraft = buildDialogDraft(value) setDraft(nextDraft) + setActiveBoundary('start') setDateInput({ start: formatDateInputValue(nextDraft.dateRange.start), end: formatDateInputValue(nextDraft.dateRange.end) @@ -74,32 +76,7 @@ export function ExportDateRangeDialog({ setDateInputError({ start: false, end: false }) }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) - const applyPreset = useCallback((preset: Exclude) => { - if (preset === 'all') { - const previewRange = createDefaultDateRange() - setDraft(prev => ({ - ...prev, - preset, - useAllTime: true, - dateRange: previewRange, - startPanelMonth: toMonthStart(previewRange.start), - endPanelMonth: toMonthStart(previewRange.end) - })) - return - } - - const range = createDateRangeByPreset(preset) - setDraft(prev => ({ - ...prev, - preset, - useAllTime: false, - dateRange: range, - startPanelMonth: toMonthStart(range.start), - endPanelMonth: toMonthStart(range.end) - })) - }, []) - - const updateDraftStart = useCallback((targetDate: Date) => { + const setRangeStart = useCallback((targetDate: Date) => { const start = startOfDay(targetDate) setDraft(prev => { const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end @@ -111,13 +88,12 @@ export function ExportDateRangeDialog({ start, end: nextEnd }, - startPanelMonth: toMonthStart(start), - endPanelMonth: toMonthStart(nextEnd) + panelMonth: toMonthStart(start) } }) }, []) - const updateDraftEnd = useCallback((targetDate: Date) => { + const setRangeEnd = useCallback((targetDate: Date) => { const end = endOfDay(targetDate) setDraft(prev => { const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start @@ -130,12 +106,36 @@ export function ExportDateRangeDialog({ start: nextStart, end: nextEnd }, - startPanelMonth: toMonthStart(nextStart), - endPanelMonth: toMonthStart(nextEnd) + panelMonth: toMonthStart(targetDate) } }) }, []) + const applyPreset = useCallback((preset: Exclude) => { + if (preset === 'all') { + const previewRange = createDefaultDateRange() + setDraft(prev => ({ + ...prev, + preset, + useAllTime: true, + dateRange: previewRange, + panelMonth: toMonthStart(previewRange.start) + })) + setActiveBoundary('start') + return + } + + const range = createDateRangeByPreset(preset) + setDraft(prev => ({ + ...prev, + preset, + useAllTime: false, + dateRange: range, + panelMonth: toMonthStart(range.start) + })) + setActiveBoundary('start') + }, []) + const commitStartFromInput = useCallback(() => { const parsed = parseDateInputValue(dateInput.start) if (!parsed) { @@ -143,8 +143,8 @@ export function ExportDateRangeDialog({ return } setDateInputError(prev => ({ ...prev, start: false })) - updateDraftStart(parsed) - }, [dateInput.start, updateDraftStart]) + setRangeStart(parsed) + }, [dateInput.start, setRangeStart]) const commitEndFromInput = useCallback(() => { const parsed = parseDateInputValue(dateInput.end) @@ -153,29 +153,71 @@ export function ExportDateRangeDialog({ return } setDateInputError(prev => ({ ...prev, end: false })) - updateDraftEnd(parsed) - }, [dateInput.end, updateDraftEnd]) + setRangeEnd(parsed) + }, [dateInput.end, setRangeEnd]) - const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => { - setDraft(prev => ( - panel === 'start' - ? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) } - : { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) } - )) + const shiftPanelMonth = useCallback((delta: number) => { + setDraft(prev => ({ + ...prev, + panelMonth: addMonths(prev.panelMonth, delta) + })) }, []) + const handleCalendarSelect = useCallback((targetDate: Date) => { + if (activeBoundary === 'start') { + setRangeStart(targetDate) + setActiveBoundary('end') + return + } + + setDraft(prev => { + const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start + const pickedStart = startOfDay(targetDate) + const nextStart = pickedStart <= start ? pickedStart : start + const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate) + return { + ...prev, + preset: 'custom', + useAllTime: false, + dateRange: { + start: nextStart, + end: nextEnd + }, + panelMonth: toMonthStart(targetDate) + } + }) + setActiveBoundary('start') + }, [activeBoundary, setRangeEnd, setRangeStart]) + const isRangeModeActive = !draft.useAllTime const modeText = isRangeModeActive ? '当前导出模式:按时间范围导出' - : '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)' + : '当前导出模式:全部时间导出,选择下方日期会切换为自定义时间范围' const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => { if (preset === 'all') return draft.useAllTime return !draft.useAllTime && draft.preset === preset }, [draft]) - const startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth]) - const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth]) + const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth]) + + const isStartSelected = useCallback((date: Date) => ( + !draft.useAllTime && isSameDay(date, draft.dateRange.start) + ), [draft]) + + const isEndSelected = useCallback((date: Date) => ( + !draft.useAllTime && isSameDay(date, draft.dateRange.end) + ), [draft]) + + const isDateInRange = useCallback((date: Date) => ( + !draft.useAllTime && + startOfDay(date).getTime() >= startOfDay(draft.dateRange.start).getTime() && + startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime() + ), [draft]) + + const hintText = draft.useAllTime + ? '选择开始或结束日期后,会自动切换为自定义时间范围' + : (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期') if (!open) return null @@ -215,112 +257,112 @@ export function ExportDateRangeDialog({ {modeText} -
-
-
-
- 起始日期 - { - const nextValue = event.target.value - setDateInput(prev => ({ ...prev, start: nextValue })) - if (dateInputError.start) { - setDateInputError(prev => ({ ...prev, start: false })) - } - }} - onKeyDown={(event) => { - if (event.key !== 'Enter') return - event.preventDefault() - commitStartFromInput() - }} - onBlur={commitStartFromInput} - /> -
-
- - {formatCalendarMonthTitle(draft.startPanelMonth)} - -
-
-
- {WEEKDAY_SHORT_LABELS.map(label => ( - {label} - ))} -
-
- {startPanelCells.map((cell) => { - const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start) - return ( - - ) - })} -
-
- -
-
-
- 截止日期 - { - const nextValue = event.target.value - setDateInput(prev => ({ ...prev, end: nextValue })) - if (dateInputError.end) { - setDateInputError(prev => ({ ...prev, end: false })) - } - }} - onKeyDown={(event) => { - if (event.key !== 'Enter') return - event.preventDefault() - commitEndFromInput() - }} - onBlur={commitEndFromInput} - /> -
-
- - {formatCalendarMonthTitle(draft.endPanelMonth)} - -
-
-
- {WEEKDAY_SHORT_LABELS.map(label => ( - {label} - ))} -
-
- {endPanelCells.map((cell) => { - const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end) - return ( - - ) - })} -
-
+
+
setActiveBoundary('start')} + > + 开始 + { + const nextValue = event.target.value + setDateInput(prev => ({ ...prev, start: nextValue })) + if (dateInputError.start) { + setDateInputError(prev => ({ ...prev, start: false })) + } + }} + onFocus={() => setActiveBoundary('start')} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key !== 'Enter') return + event.preventDefault() + commitStartFromInput() + }} + onBlur={commitStartFromInput} + /> +
+
setActiveBoundary('end')} + > + 结束 + { + const nextValue = event.target.value + setDateInput(prev => ({ ...prev, end: nextValue })) + if (dateInputError.end) { + setDateInputError(prev => ({ ...prev, end: false })) + } + }} + onFocus={() => setActiveBoundary('end')} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key !== 'Enter') return + event.preventDefault() + commitEndFromInput() + }} + onBlur={commitEndFromInput} + /> +
+
{hintText}
+ +
+
+
+ 选择日期范围 + {formatCalendarMonthTitle(draft.panelMonth)} +
+
+ + +
+
+
+ {WEEKDAY_SHORT_LABELS.map(label => ( + {label} + ))} +
+
+ {calendarCells.map((cell) => { + const startSelected = isStartSelected(cell.date) + const endSelected = isEndSelected(cell.date) + const inRange = isDateInRange(cell.date) + return ( + + ) + })} +
+
+
- -
@@ -341,13 +421,16 @@ export function ExportDateRangeDialog({ const startSelected = isStartSelected(cell.date) const endSelected = isEndSelected(cell.date) const inRange = isDateInRange(cell.date) + const selectable = isDateSelectable(cell.date) return (
@@ -7840,6 +8004,8 @@ function ExportPage() { { setTimeRangeSelection(nextSelection) diff --git a/src/services/config.ts b/src/services/config.ts index ee85acd..7d57bff 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -580,6 +580,8 @@ export interface ExportSessionContentMetricCacheEntry { imageMessages?: number videoMessages?: number emojiMessages?: number + firstTimestamp?: number + lastTimestamp?: number } export interface ExportSessionContentMetricCacheItem { @@ -742,6 +744,12 @@ export async function getExportSessionContentMetricCache(scopeKey: string): Prom if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) { metric.emojiMessages = Math.floor(source.emojiMessages) } + if (typeof source.firstTimestamp === 'number' && Number.isFinite(source.firstTimestamp) && source.firstTimestamp > 0) { + metric.firstTimestamp = Math.floor(source.firstTimestamp) + } + if (typeof source.lastTimestamp === 'number' && Number.isFinite(source.lastTimestamp) && source.lastTimestamp > 0) { + metric.lastTimestamp = Math.floor(source.lastTimestamp) + } if (Object.keys(metric).length === 0) continue metrics[sessionId] = metric } @@ -781,6 +789,12 @@ export async function setExportSessionContentMetricCache( if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) { metric.emojiMessages = Math.floor(rawMetric.emojiMessages) } + if (typeof rawMetric.firstTimestamp === 'number' && Number.isFinite(rawMetric.firstTimestamp) && rawMetric.firstTimestamp > 0) { + metric.firstTimestamp = Math.floor(rawMetric.firstTimestamp) + } + if (typeof rawMetric.lastTimestamp === 'number' && Number.isFinite(rawMetric.lastTimestamp) && rawMetric.lastTimestamp > 0) { + metric.lastTimestamp = Math.floor(rawMetric.lastTimestamp) + } if (Object.keys(metric).length === 0) continue normalized[sessionId] = metric } From 4c70ebcaf9885cf2e2257dda9a739d975e7fbfda Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 20 Mar 2026 15:29:47 +0800 Subject: [PATCH 05/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=8B=E5=8F=8B?= =?UTF-8?q?=E5=9C=88=E8=81=94=E7=B3=BB=E4=BA=BA=E9=87=8D=E5=A4=8D=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/SnsPage.tsx | 145 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 135 insertions(+), 10 deletions(-) diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index fb8f009..62c83aa 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -176,6 +176,8 @@ export default function SnsPage() { const selectedContactUsernamesRef = useRef(selectedContactUsernames) const cacheScopeKeyRef = useRef('') const snsUserPostCountsCacheScopeKeyRef = useRef('') + const activeContactsLoadTaskIdRef = useRef(null) + const activeContactsCountTaskIdRef = useRef(null) const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const pendingResetFeedRef = useRef(false) const contactsLoadTokenRef = useRef(0) @@ -750,6 +752,12 @@ export default function SnsPage() { window.clearTimeout(contactsCountBatchTimerRef.current) contactsCountBatchTimerRef.current = null } + if (activeContactsCountTaskIdRef.current) { + finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', { + detail: '已停止后续联系人朋友圈条数补算' + }) + activeContactsCountTaskIdRef.current = null + } if (resetProgress) { setContactsCountProgress({ resolved: 0, @@ -814,31 +822,56 @@ export default function SnsPage() { cancelable: true }) + activeContactsCountTaskIdRef.current = taskId let normalizedCounts: Record = {} try { const result = await window.electronAPI.sns.getUserPostCounts() if (isBackgroundTaskCancelRequested(taskId)) { + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,当前计数查询结束后不再继续分批写入' }) return } - if (runToken !== contactsCountHydrationTokenRef.current) return + if (runToken !== contactsCountHydrationTokenRef.current) { + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期' + }) + return + } if (result.success && result.counts) { - normalizedCounts = Object.fromEntries( - Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)]) - ) + normalizedCounts = pendingTargets.reduce>((acc, username) => { + acc[username] = normalizePostCount(result.counts?.[username]) + return acc + }, {}) void (async () => { try { const scopeKey = await ensureSnsUserPostCountsCacheScopeKey() - await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts) + const currentCache = await configService.getExportSnsUserPostCountsCache(scopeKey) + await configService.setExportSnsUserPostCountsCache(scopeKey, { + ...(currentCache?.counts || {}), + ...normalizedCounts + }) } catch (cacheError) { console.error('Failed to persist SNS user post counts cache:', cacheError) } })() + } else { + normalizedCounts = pendingTargets.reduce>((acc, username) => { + acc[username] = 0 + return acc + }, {}) } } catch (error) { console.error('Failed to load contact post counts:', error) + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } finishBackgroundTask(taskId, 'failed', { detail: String(error) }) @@ -848,8 +881,19 @@ export default function SnsPage() { let resolved = preResolved let cursor = 0 const applyBatch = () => { - if (runToken !== contactsCountHydrationTokenRef.current) return + if (runToken !== contactsCountHydrationTokenRef.current) { + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人朋友圈条数补算已过期' + }) + return + } if (isBackgroundTaskCancelRequested(taskId)) { + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } finishBackgroundTask(taskId, 'canceled', { detail: `已停止后续加载,已完成 ${resolved}/${totalTargets}` }) @@ -870,6 +914,9 @@ export default function SnsPage() { running: false }) contactsCountBatchTimerRef.current = null + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } finishBackgroundTask(taskId, 'completed', { detail: '联系人朋友圈条数补算完成', progressText: `${totalTargets}/${totalTargets}` @@ -910,6 +957,18 @@ export default function SnsPage() { contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS) } else { contactsCountBatchTimerRef.current = null + setContactsCountProgress({ + resolved: totalTargets, + total: totalTargets, + running: false + }) + if (activeContactsCountTaskIdRef.current === taskId) { + activeContactsCountTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'completed', { + detail: '鑱旂郴浜烘湅鍙嬪湀鏉℃暟琛ョ畻瀹屾垚', + progressText: `${totalTargets}/${totalTargets}` + }) } } @@ -918,6 +977,12 @@ export default function SnsPage() { // Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序) const loadContacts = useCallback(async () => { + if (activeContactsLoadTaskIdRef.current) { + finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', { + detail: '新一轮联系人列表加载已开始,旧任务已取消' + }) + activeContactsLoadTaskIdRef.current = null + } const requestToken = ++contactsLoadTokenRef.current const taskId = registerBackgroundTask({ sourcePage: 'sns', @@ -926,6 +991,7 @@ export default function SnsPage() { progressText: '初始化', cancelable: true }) + activeContactsLoadTaskIdRef.current = taskId stopContactsCountHydration(true) setContactsLoading(true) try { @@ -955,7 +1021,15 @@ export default function SnsPage() { } }) - if (requestToken !== contactsLoadTokenRef.current) return + if (requestToken !== contactsLoadTokenRef.current) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人列表加载已过期' + }) + return + } if (cachedContacts.length > 0) { const cachedContactsSorted = sortContactsForRanking(cachedContacts) setContacts(cachedContactsSorted) @@ -977,6 +1051,9 @@ export default function SnsPage() { window.electronAPI.chat.getSessions() ]) if (isBackgroundTaskCancelRequested(taskId)) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,当前联系人查询结束后未继续补齐' }) @@ -1021,7 +1098,15 @@ export default function SnsPage() { } let contactsList = sortContactsForRanking(Array.from(contactMap.values())) - if (requestToken !== contactsLoadTokenRef.current) return + if (requestToken !== contactsLoadTokenRef.current) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人列表加载已过期' + }) + return + } setContacts(contactsList) const readyUsernames = new Set( contactsList @@ -1043,6 +1128,9 @@ export default function SnsPage() { }) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames) if (isBackgroundTaskCancelRequested(taskId)) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,联系人补齐未继续写入' }) @@ -1058,7 +1146,15 @@ export default function SnsPage() { avatarUrl: extra.avatarUrl || contact.avatarUrl } }) - if (requestToken !== contactsLoadTokenRef.current) return + if (requestToken !== contactsLoadTokenRef.current) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人列表加载已过期' + }) + return + } setContacts((prev) => { const prevMap = new Map(prev.map((contact) => [contact.username, contact])) const merged = contactsList.map((contact) => { @@ -1074,18 +1170,35 @@ export default function SnsPage() { }) } } + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } finishBackgroundTask(taskId, 'completed', { detail: `朋友圈联系人列表加载完成,共 ${contactsList.length} 人`, progressText: `${contactsList.length} 人` }) } catch (error) { - if (requestToken !== contactsLoadTokenRef.current) return + if (requestToken !== contactsLoadTokenRef.current) { + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } + finishBackgroundTask(taskId, 'canceled', { + detail: '页面状态已刷新,本次联系人列表加载已过期' + }) + return + } console.error('Failed to load contacts:', error) stopContactsCountHydration(true) + if (activeContactsLoadTaskIdRef.current === taskId) { + activeContactsLoadTaskIdRef.current = null + } finishBackgroundTask(taskId, 'failed', { detail: String(error) }) } finally { + if (activeContactsLoadTaskIdRef.current === taskId && requestToken !== contactsLoadTokenRef.current) { + activeContactsLoadTaskIdRef.current = null + } if (requestToken === contactsLoadTokenRef.current) { setContactsLoading(false) } @@ -1185,6 +1298,18 @@ export default function SnsPage() { window.clearTimeout(contactsCountBatchTimerRef.current) contactsCountBatchTimerRef.current = null } + if (activeContactsCountTaskIdRef.current) { + finishBackgroundTask(activeContactsCountTaskIdRef.current, 'canceled', { + detail: '已离开朋友圈页,联系人朋友圈条数补算已取消' + }) + activeContactsCountTaskIdRef.current = null + } + if (activeContactsLoadTaskIdRef.current) { + finishBackgroundTask(activeContactsLoadTaskIdRef.current, 'canceled', { + detail: '已离开朋友圈页,联系人列表加载已取消' + }) + activeContactsLoadTaskIdRef.current = null + } } }, []) From a331f45f8700d6bcf45f8c8e8e86d2adefe25d1f Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 20 Mar 2026 16:01:31 +0800 Subject: [PATCH 06/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E6=97=A5=E6=9C=9F=E9=80=89=E6=8B=A9=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ExportPage.tsx | 69 ++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 04efb15..3c21ce3 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -2796,7 +2796,9 @@ function ExportPage() { emojiMessages: typeof metric.emojiMessages === 'number' ? metric.emojiMessages : previous.emojiMessages, transferMessages: typeof metric.transferMessages === 'number' ? metric.transferMessages : previous.transferMessages, redPacketMessages: typeof metric.redPacketMessages === 'number' ? metric.redPacketMessages : previous.redPacketMessages, - callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages + callMessages: typeof metric.callMessages === 'number' ? metric.callMessages : previous.callMessages, + firstTimestamp: typeof metric.firstTimestamp === 'number' ? metric.firstTimestamp : previous.firstTimestamp, + lastTimestamp: typeof metric.lastTimestamp === 'number' ? metric.lastTimestamp : previous.lastTimestamp } if ( previous.totalMessages === nextMetric.totalMessages && @@ -4033,21 +4035,42 @@ function ExportPage() { const normalizedSessionIds = Array.from(new Set((sessionIds || []).map(id => String(id || '').trim()).filter(Boolean))) if (normalizedSessionIds.length === 0) return null + const sessionRowMap = new Map() + for (const session of sessions) { + sessionRowMap.set(session.username, session) + } + let minTimestamp: number | undefined let maxTimestamp: number | undefined - const resolvedSessionIds = new Set() + const resolvedSessionBounds = new Map() const absorbMetric = (sessionId: string, metric?: { firstTimestamp?: number; lastTimestamp?: number } | null) => { if (!metric) return const firstTimestamp = normalizeTimestampSeconds(metric.firstTimestamp) const lastTimestamp = normalizeTimestampSeconds(metric.lastTimestamp) - if (typeof firstTimestamp !== 'number' || typeof lastTimestamp !== 'number') return - resolvedSessionIds.add(sessionId) - if (minTimestamp === undefined || firstTimestamp < minTimestamp) minTimestamp = firstTimestamp - if (maxTimestamp === undefined || lastTimestamp > maxTimestamp) maxTimestamp = lastTimestamp + if (typeof firstTimestamp !== 'number' && typeof lastTimestamp !== 'number') return + + const previous = resolvedSessionBounds.get(sessionId) || { hasMin: false, hasMax: false } + const nextState = { + hasMin: previous.hasMin || typeof firstTimestamp === 'number', + hasMax: previous.hasMax || typeof lastTimestamp === 'number' + } + resolvedSessionBounds.set(sessionId, nextState) + + if (typeof firstTimestamp === 'number' && (minTimestamp === undefined || firstTimestamp < minTimestamp)) { + minTimestamp = firstTimestamp + } + if (typeof lastTimestamp === 'number' && (maxTimestamp === undefined || lastTimestamp > maxTimestamp)) { + maxTimestamp = lastTimestamp + } } for (const sessionId of normalizedSessionIds) { + const sessionRow = sessionRowMap.get(sessionId) + absorbMetric(sessionId, { + firstTimestamp: undefined, + lastTimestamp: sessionRow?.sortTimestamp || sessionRow?.lastTimestamp + }) absorbMetric(sessionId, sessionContentMetrics[sessionId]) if (sessionDetail?.wxid === sessionId) { absorbMetric(sessionId, { @@ -4068,23 +4091,37 @@ function ExportPage() { } } - const missingSessionIds = () => normalizedSessionIds.filter(sessionId => !resolvedSessionIds.has(sessionId)) + const missingSessionIds = () => normalizedSessionIds.filter(sessionId => { + const resolved = resolvedSessionBounds.get(sessionId) + return !resolved?.hasMin || !resolved?.hasMax + }) + + const staleSessionIds = new Set() if (missingSessionIds().length > 0) { - applyStatsResult(await window.electronAPI.chat.getExportSessionStats( + const cacheResult = await window.electronAPI.chat.getExportSessionStats( missingSessionIds(), { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ) + applyStatsResult(cacheResult) + for (const sessionId of cacheResult?.needsRefresh || []) { + staleSessionIds.add(String(sessionId || '').trim()) + } + } + + const sessionsNeedingFreshStats = Array.from(new Set([ + ...missingSessionIds(), + ...Array.from(staleSessionIds).filter(Boolean) + ])) + + if (sessionsNeedingFreshStats.length > 0) { + applyStatsResult(await window.electronAPI.chat.getExportSessionStats( + sessionsNeedingFreshStats, + { includeRelations: false } )) } if (missingSessionIds().length > 0) { - applyStatsResult(await window.electronAPI.chat.getExportSessionStats( - missingSessionIds(), - { includeRelations: false, allowStaleCache: true } - )) - } - - if (resolvedSessionIds.size !== normalizedSessionIds.length) { return null } if (typeof minTimestamp !== 'number' || typeof maxTimestamp !== 'number') { @@ -4095,7 +4132,7 @@ function ExportPage() { minDate: new Date(minTimestamp * 1000), maxDate: new Date(maxTimestamp * 1000) } - }, [applySessionMediaMetricsFromStats, sessionContentMetrics, sessionDetail]) + }, [applySessionMediaMetricsFromStats, sessionContentMetrics, sessionDetail, sessions]) const openTimeRangeDialog = useCallback(() => { void (async () => { From 80786c572adc0c2e0dd7b2168821edb7a9c2cc1b Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 20 Mar 2026 16:15:58 +0800 Subject: [PATCH 07/13] =?UTF-8?q?=E5=BC=95=E7=94=A8=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.tsx | 330 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 321 insertions(+), 9 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 9a49bdc..d6faca4 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -585,6 +585,263 @@ interface GroupPanelMember { messageCountStatus: GroupMessageCountStatus } +const QUOTED_SENDER_CACHE_TTL_MS = 10 * 60 * 1000 +const quotedSenderDisplayCache = new Map() +const quotedSenderDisplayLoading = new Map>() +const quotedGroupMembersCache = new Map() +const quotedGroupMembersLoading = new Map>() + +function buildQuotedSenderCacheKey( + sessionId: string, + senderUsername: string, + isGroupChat: boolean +): string { + const normalizedSessionId = normalizeSearchIdentityText(sessionId) || String(sessionId || '').trim() + const normalizedSender = normalizeSearchIdentityText(senderUsername) || String(senderUsername || '').trim() + return `${isGroupChat ? 'group' : 'direct'}::${normalizedSessionId}::${normalizedSender}` +} + +function isSameQuotedSenderIdentity(left?: string | null, right?: string | null): boolean { + const leftCandidates = buildSearchIdentityCandidates(left) + const rightCandidates = buildSearchIdentityCandidates(right) + if (leftCandidates.length === 0 || rightCandidates.length === 0) { + return false + } + + for (const leftCandidate of leftCandidates) { + for (const rightCandidate of rightCandidates) { + if (leftCandidate === rightCandidate) return true + if (leftCandidate.startsWith(rightCandidate + '_')) return true + if (rightCandidate.startsWith(leftCandidate + '_')) return true + } + } + + return false +} + +function normalizeQuotedGroupMember(member: Partial | null | undefined): GroupPanelMember | null { + const username = String(member?.username || '').trim() + if (!username) return null + + const displayName = String(member?.displayName || '').trim() + const nickname = String(member?.nickname || '').trim() + const remark = String(member?.remark || '').trim() + const alias = String(member?.alias || '').trim() + const groupNickname = String(member?.groupNickname || '').trim() + + return { + username, + displayName: displayName || groupNickname || remark || nickname || alias || username, + avatarUrl: member?.avatarUrl, + nickname, + alias, + remark, + groupNickname, + isOwner: Boolean(member?.isOwner), + isFriend: Boolean(member?.isFriend), + messageCount: Number.isFinite(member?.messageCount) ? Math.max(0, Math.floor(member?.messageCount as number)) : 0, + messageCountStatus: 'ready' + } +} + +function resolveQuotedSenderFallbackDisplayName( + sessionId: string, + senderUsername?: string | null, + fallbackDisplayName?: string | null +): string | undefined { + const resolved = resolveSearchSenderDisplayName(fallbackDisplayName, senderUsername, sessionId) + if (resolved) return resolved + return resolveSearchSenderUsernameFallback(senderUsername) +} + +function resolveQuotedSenderUsername( + fromusr?: string | null, + chatusr?: string | null +): string { + const normalizedChatUsr = String(chatusr || '').trim() + const normalizedFromUsr = String(fromusr || '').trim() + + if (normalizedChatUsr) { + return normalizedChatUsr + } + + if (normalizedFromUsr.endsWith('@chatroom')) { + return '' + } + + return normalizedFromUsr +} + +function resolveQuotedGroupMemberDisplayName(member: GroupPanelMember): string | undefined { + const remark = normalizeSearchIdentityText(member.remark) + if (remark) return remark + + const groupNickname = normalizeSearchIdentityText(member.groupNickname) + if (groupNickname) return groupNickname + + const nickname = normalizeSearchIdentityText(member.nickname) + if (nickname) return nickname + + const displayName = resolveSearchSenderDisplayName(member.displayName, member.username) + if (displayName) return displayName + + const alias = normalizeSearchIdentityText(member.alias) + if (alias) return alias + + return resolveSearchSenderUsernameFallback(member.username) +} + +function resolveQuotedPrivateDisplayName(contact: any): string | undefined { + const remark = normalizeSearchIdentityText(contact?.remark) + if (remark) return remark + + const nickname = normalizeSearchIdentityText( + contact?.nickName || contact?.nick_name || contact?.nickname + ) + if (nickname) return nickname + + const alias = normalizeSearchIdentityText(contact?.alias) + if (alias) return alias + + return undefined +} + +async function getQuotedGroupMembers(chatroomId: string): Promise { + const normalizedChatroomId = String(chatroomId || '').trim() + if (!normalizedChatroomId || !normalizedChatroomId.includes('@chatroom')) { + return [] + } + + const cached = quotedGroupMembersCache.get(normalizedChatroomId) + if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) { + return cached.members + } + + const pending = quotedGroupMembersLoading.get(normalizedChatroomId) + if (pending) return pending + + const request = window.electronAPI.groupAnalytics.getGroupMembersPanelData( + normalizedChatroomId, + { forceRefresh: false, includeMessageCounts: false } + ).then((result) => { + const members = Array.isArray(result.data) + ? result.data + .map((member) => normalizeQuotedGroupMember(member as Partial)) + .filter((member): member is GroupPanelMember => Boolean(member)) + : [] + + if (members.length > 0) { + quotedGroupMembersCache.set(normalizedChatroomId, { + members, + updatedAt: Date.now() + }) + return members + } + + return cached?.members || [] + }).catch(() => cached?.members || []).finally(() => { + quotedGroupMembersLoading.delete(normalizedChatroomId) + }) + + quotedGroupMembersLoading.set(normalizedChatroomId, request) + return request +} + +async function resolveQuotedSenderDisplayName(options: { + sessionId: string + senderUsername?: string | null + fallbackDisplayName?: string | null + isGroupChat?: boolean + myWxid?: string | null +}): Promise { + const normalizedSessionId = String(options.sessionId || '').trim() + const normalizedSender = String(options.senderUsername || '').trim() + const fallbackDisplayName = resolveQuotedSenderFallbackDisplayName( + normalizedSessionId, + normalizedSender, + options.fallbackDisplayName + ) + + if (!normalizedSender) { + return fallbackDisplayName + } + + const cacheKey = buildQuotedSenderCacheKey(normalizedSessionId, normalizedSender, Boolean(options.isGroupChat)) + const cached = quotedSenderDisplayCache.get(cacheKey) + if (cached && Date.now() - cached.updatedAt < QUOTED_SENDER_CACHE_TTL_MS) { + return cached.displayName + } + + const pending = quotedSenderDisplayLoading.get(cacheKey) + if (pending) return pending + + const request = (async (): Promise => { + if (options.isGroupChat) { + const members = await getQuotedGroupMembers(normalizedSessionId) + const matchedMember = members.find((member) => isSameQuotedSenderIdentity(member.username, normalizedSender)) + const groupDisplayName = matchedMember ? resolveQuotedGroupMemberDisplayName(matchedMember) : undefined + if (groupDisplayName) { + quotedSenderDisplayCache.set(cacheKey, { + displayName: groupDisplayName, + updatedAt: Date.now() + }) + return groupDisplayName + } + } + + if (isCurrentUserSearchIdentity(normalizedSender, options.myWxid)) { + const selfDisplayName = fallbackDisplayName || '我' + quotedSenderDisplayCache.set(cacheKey, { + displayName: selfDisplayName, + updatedAt: Date.now() + }) + return selfDisplayName + } + + try { + const contact = await window.electronAPI.chat.getContact(normalizedSender) + const contactDisplayName = resolveQuotedPrivateDisplayName(contact) + if (contactDisplayName) { + quotedSenderDisplayCache.set(cacheKey, { + displayName: contactDisplayName, + updatedAt: Date.now() + }) + return contactDisplayName + } + } catch { + // ignore contact lookup failures and fall back below + } + + try { + const profile = await window.electronAPI.chat.getContactAvatar(normalizedSender) + const profileDisplayName = normalizeSearchIdentityText(profile?.displayName) + if (profileDisplayName && !isWxidLikeSearchIdentity(profileDisplayName)) { + quotedSenderDisplayCache.set(cacheKey, { + displayName: profileDisplayName, + updatedAt: Date.now() + }) + return profileDisplayName + } + } catch { + // ignore avatar lookup failures and keep fallback usable + } + + if (fallbackDisplayName) { + quotedSenderDisplayCache.set(cacheKey, { + displayName: fallbackDisplayName, + updatedAt: Date.now() + }) + } + + return fallbackDisplayName + })().finally(() => { + quotedSenderDisplayLoading.delete(cacheKey) + }) + + quotedSenderDisplayLoading.set(cacheKey, request) + return request +} + interface SessionListCachePayload { updatedAt: number sessions: ChatSession[] @@ -2394,6 +2651,10 @@ function ChatPage(props: ChatPageProps) { const handleAccountChanged = useCallback(async () => { senderAvatarCache.clear() senderAvatarLoading.clear() + quotedSenderDisplayCache.clear() + quotedSenderDisplayLoading.clear() + quotedGroupMembersCache.clear() + quotedGroupMembersLoading.clear() sessionContactProfileCacheRef.current.clear() pendingSessionContactEnrichRef.current.clear() sessionContactEnrichAttemptAtRef.current.clear() @@ -5660,6 +5921,7 @@ function ChatPage(props: ChatPageProps) { session={currentSession!} showTime={!showDateDivider && showTime} myAvatarUrl={myAvatarUrl} + myWxid={myWxid} isGroupChat={isCurrentSessionGroup} autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled} onRequireModelDownload={handleRequireModelDownload} @@ -5678,6 +5940,7 @@ function ChatPage(props: ChatPageProps) { formatDateDivider, currentSession, myAvatarUrl, + myWxid, isCurrentSessionGroup, autoTranscribeVoiceEnabled, handleRequireModelDownload, @@ -7258,6 +7521,7 @@ function MessageBubble({ session, showTime, myAvatarUrl, + myWxid, isGroupChat, autoTranscribeVoiceEnabled, onRequireModelDownload, @@ -7271,6 +7535,7 @@ function MessageBubble({ session: ChatSession; showTime?: boolean; myAvatarUrl?: string; + myWxid?: string; isGroupChat?: boolean; autoTranscribeVoiceEnabled?: boolean; onRequireModelDownload?: (sessionId: string, messageId: string) => void; @@ -7290,6 +7555,7 @@ function MessageBubble({ const isSent = message.isSend === 1 const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) const [senderName, setSenderName] = useState(undefined) + const [quotedSenderName, setQuotedSenderName] = useState(undefined) const senderProfileRequestSeqRef = useRef(0) const [emojiError, setEmojiError] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false) @@ -8214,6 +8480,53 @@ function MessageBubble({ appMsgTextCache.set(selector, value) return value }, [appMsgDoc, appMsgTextCache]) + const quotedSenderUsername = resolveQuotedSenderUsername( + queryAppMsgText('refermsg > fromusr'), + queryAppMsgText('refermsg > chatusr') + ) + const quotedContent = message.quotedContent || queryAppMsgText('refermsg > content') || '' + const quotedSenderFallbackName = useMemo( + () => resolveQuotedSenderFallbackDisplayName( + session.username, + quotedSenderUsername, + message.quotedSender || queryAppMsgText('refermsg > displayname') || '' + ), + [message.quotedSender, queryAppMsgText, quotedSenderUsername, session.username] + ) + + useEffect(() => { + let cancelled = false + const nextFallbackName = quotedSenderFallbackName || undefined + setQuotedSenderName(nextFallbackName) + + if (!quotedContent || !quotedSenderUsername) { + return () => { + cancelled = true + } + } + + void resolveQuotedSenderDisplayName({ + sessionId: session.username, + senderUsername: quotedSenderUsername, + fallbackDisplayName: nextFallbackName, + isGroupChat, + myWxid + }).then((resolvedName) => { + if (cancelled) return + setQuotedSenderName(resolvedName || nextFallbackName) + }) + + return () => { + cancelled = true + } + }, [ + quotedContent, + quotedSenderFallbackName, + quotedSenderUsername, + session.username, + isGroupChat, + myWxid + ]) const locationMessageMeta = useMemo(() => { if (message.localType !== 48) return null @@ -8248,7 +8561,8 @@ function MessageBubble({ : (isGroupChat ? resolvedSenderAvatarUrl : session.avatarUrl) // 是否有引用消息 - const hasQuote = message.quotedContent && message.quotedContent.length > 0 + const hasQuote = quotedContent.length > 0 + const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName const handlePlayVideo = useCallback(async () => { if (!videoInfo?.videoUrl) return @@ -8659,7 +8973,6 @@ function MessageBubble({ if (xmlType === '57') { const replyText = q('title') || cleanedParsedContent || '' const referContent = q('refermsg > content') || '' - const referSender = q('refermsg > displayname') || '' const referType = q('refermsg > type') || '' // 根据被引用消息类型渲染对应内容 @@ -8691,7 +9004,7 @@ function MessageBubble({ return (
- {referSender && {referSender}} + {displayQuotedSenderName && {displayQuotedSenderName}} {renderReferContent()}
{renderTextWithEmoji(cleanMessageContent(replyText))}
@@ -8787,11 +9100,10 @@ function MessageBubble({ // 引用回复消息(appMsgKind='quote',xmlType=57) const replyText = message.linkTitle || q('title') || cleanedParsedContent || '' const referContent = message.quotedContent || q('refermsg > content') || '' - const referSender = message.quotedSender || q('refermsg > displayname') || '' return (
- {referSender && {referSender}} + {displayQuotedSenderName && {displayQuotedSenderName}} {renderTextWithEmoji(cleanMessageContent(referContent))}
{renderTextWithEmoji(cleanMessageContent(replyText))}
@@ -8982,7 +9294,6 @@ function MessageBubble({ if (appMsgType === '57') { const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || '' const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || '' - const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || '' const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || '' const renderReferContent2 = () => { @@ -9008,7 +9319,7 @@ function MessageBubble({ return (
- {referSender && {referSender}} + {displayQuotedSenderName && {displayQuotedSenderName}} {renderReferContent2()}
{renderTextWithEmoji(cleanMessageContent(replyText))}
@@ -9294,8 +9605,8 @@ function MessageBubble({ return (
- {message.quotedSender && {message.quotedSender}} - {renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))} + {displayQuotedSenderName && {displayQuotedSenderName}} + {renderTextWithEmoji(cleanMessageContent(quotedContent))}
{renderTextWithEmoji(cleanedParsedContent)}
@@ -9398,6 +9709,7 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => { if (prevProps.messageKey !== nextProps.messageKey) return false if (prevProps.showTime !== nextProps.showTime) return false if (prevProps.myAvatarUrl !== nextProps.myAvatarUrl) return false + if (prevProps.myWxid !== nextProps.myWxid) return false if (prevProps.isGroupChat !== nextProps.isGroupChat) return false if (prevProps.autoTranscribeVoiceEnabled !== nextProps.autoTranscribeVoiceEnabled) return false if (prevProps.isSelectionMode !== nextProps.isSelectionMode) return false From 8e8c14a51f063e6b5660f64a5808d86743ce3381 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 20 Mar 2026 16:42:01 +0800 Subject: [PATCH 08/13] =?UTF-8?q?=E5=AF=BC=E5=87=BAchatlab=E7=9A=84?= =?UTF-8?q?=E6=97=B6=E5=80=99=E6=9C=89=E5=BC=95=E7=94=A8=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 55 ++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 6649e0c..0e27cc7 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -46,6 +46,8 @@ interface ChatLabMessage { timestamp: number type: number content: string | null + platformMessageId?: string + replyToMessageId?: string chatRecords?: any[] // 嵌套的聊天记录 } @@ -952,6 +954,18 @@ class ExportService { return fallback } + private getRowField(row: Record, keys: string[]): any { + for (const key of keys) { + if (row && Object.prototype.hasOwnProperty.call(row, key)) { + const value = row[key] + if (value !== undefined && value !== null && value !== '') { + return value + } + } + } + return undefined + } + private normalizeUnsignedIntToken(value: unknown): string { const raw = String(value ?? '').trim() if (!raw) return '0' @@ -963,14 +977,14 @@ class ExportService { return String(Math.floor(num)) } - private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown }): string { + private getStableMessageKey(msg: { localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string { const localId = this.normalizeUnsignedIntToken(msg?.localId) const createTime = this.normalizeUnsignedIntToken(msg?.createTime) - const serverId = this.normalizeUnsignedIntToken(msg?.serverId) + const serverId = this.normalizeUnsignedIntToken(msg?.serverIdRaw ?? msg?.serverId) return `${localId}:${createTime}:${serverId}` } - private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown }): string { + private getMediaCacheKey(msg: { localType?: unknown; localId?: unknown; createTime?: unknown; serverId?: unknown; serverIdRaw?: unknown }): string { const localType = this.normalizeUnsignedIntToken(msg?.localType) return `${localType}_${this.getStableMessageKey(msg)}` } @@ -2462,6 +2476,23 @@ class ExportService { } } + private extractChatLabReplyToMessageId(content: string): string | undefined { + try { + const normalized = this.normalizeAppMessageContent(content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return undefined + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + const replyToMessageIdRaw = this.normalizeUnsignedIntToken(this.extractXmlValue(referMsgXml, 'svrid')) + return replyToMessageIdRaw !== '0' ? replyToMessageIdRaw : undefined + } catch { + return undefined + } + } + private extractArkmeAppMessageMeta(content: string, localType: number): Record | null { if (!content) return null @@ -3507,6 +3538,13 @@ class ExportService { 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id' ], 0) + const rawServerIdValue = this.getRowField(row, [ + 'server_id', 'serverId', 'ServerId', + 'msg_server_id', 'msgServerId', 'MsgServerId', + 'svr_id', 'svrId', 'msg_svr_id', 'msgSvrId', 'MsgSvrId', + 'WCDB_CT_server_id' + ]) + const serverIdRaw = this.normalizeUnsignedIntToken(rawServerIdValue) const serverId = this.getIntFromRow(row, [ 'server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', @@ -3598,6 +3636,7 @@ class ExportService { rows.push({ localId, serverId, + serverIdRaw: serverIdRaw !== '0' ? serverIdRaw : undefined, createTime, localType, content, @@ -4440,6 +4479,16 @@ class ExportService { content: content } + const platformMessageId = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId) + if (platformMessageId !== '0') { + message.platformMessageId = platformMessageId + } + + const replyToMessageId = this.extractChatLabReplyToMessageId(msg.content) + if (replyToMessageId) { + message.replyToMessageId = replyToMessageId + } + // 如果有聊天记录,添加为嵌套字段 if (msg.chatRecordList && msg.chatRecordList.length > 0) { const chatRecords: any[] = [] From b52bdcf4b3aa18aa67c94c154a52420bee80ddce Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 20 Mar 2026 17:03:48 +0800 Subject: [PATCH 09/13] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=88=AB=E7=9A=84?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportHtml.css | 27 +++ electron/services/exportHtmlStyles.ts | 27 +++ electron/services/exportService.ts | 313 ++++++++++++++++++++++++-- 3 files changed, 353 insertions(+), 14 deletions(-) diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css index 993e478..c6751fc 100644 --- a/electron/services/exportHtml.css +++ b/electron/services/exportHtml.css @@ -186,6 +186,33 @@ body { word-break: break-word; } +.quoted-message { + border-left: 3px solid rgba(79, 70, 229, 0.35); + background: rgba(79, 70, 229, 0.06); + border-radius: 12px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.message.sent .quoted-message { + background: rgba(37, 99, 235, 0.08); + border-left-color: rgba(37, 99, 235, 0.35); +} + +.quoted-sender { + font-size: 12px; + color: #374151; + font-weight: 600; +} + +.quoted-text { + font-size: 13px; + color: #4b5563; + word-break: break-word; +} + .message-link-card { color: #2563eb; text-decoration: underline; diff --git a/electron/services/exportHtmlStyles.ts b/electron/services/exportHtmlStyles.ts index 935eb49..96f4288 100644 --- a/electron/services/exportHtmlStyles.ts +++ b/electron/services/exportHtmlStyles.ts @@ -186,6 +186,33 @@ body { word-break: break-word; } +.quoted-message { + border-left: 3px solid rgba(79, 70, 229, 0.35); + background: rgba(79, 70, 229, 0.06); + border-radius: 12px; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.message.sent .quoted-message { + background: rgba(37, 99, 235, 0.08); + border-left-color: rgba(37, 99, 235, 0.35); +} + +.quoted-sender { + font-size: 12px; + color: #374151; + font-weight: 600; +} + +.quoted-text { + font-size: 13px; + color: #4b5563; + word-break: break-word; +} + .message-link-card { color: #2563eb; text-decoration: underline; diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 0e27cc7..89e77ca 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1634,7 +1634,13 @@ class ExportService { if (type === '6') return title ? `[文件] ${title}` : '[文件]' if (type === '19') return this.formatForwardChatRecordContent(normalizedContent) if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]' - if (type === '57') return title || '[引用消息]' + if (type === '57') { + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } + return title || '[引用消息]' + } if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]' return title ? `[链接] ${title}` : '[链接]' } @@ -1643,6 +1649,10 @@ class ExportService { case 266287972401: return this.cleanSystemMessage(content) // 拍一拍 case 244813135921: { // 引用消息 + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } const title = this.extractXmlValue(content, 'title') return title || '[引用消息]' } @@ -1676,7 +1686,13 @@ class ExportService { if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]' if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent) if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' - if (xmlType === '57') return title || '[引用消息]' + if (xmlType === '57') { + const quoteDisplay = this.extractQuotedReplyDisplay(content) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } + return title || '[引用消息]' + } if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' // 有 title 就返回 title @@ -1801,6 +1817,10 @@ class ExportService { return `[小程序]${appName}` } if (subType === 57) { + const quoteDisplay = this.extractQuotedReplyDisplay(safeContent) + if (quoteDisplay) { + return this.buildQuotedReplyText(quoteDisplay) + } return title || '[引用消息]' } if (title) { @@ -1812,6 +1832,151 @@ class ExportService { return '[其他消息]' } + private formatQuotedReferencePreview(content: string, type?: string): string { + const safeContent = content || '' + const referType = Number.parseInt(String(type || ''), 10) + if (!Number.isFinite(referType)) { + const sanitized = this.sanitizeQuotedContent(safeContent) + return sanitized || '[消息]' + } + + if (referType === 49) { + const normalized = this.normalizeAppMessageContent(safeContent) + const title = + this.extractXmlValue(normalized, 'title') || + this.extractXmlValue(normalized, 'filename') || + this.extractXmlValue(normalized, 'appname') + if (title) return this.stripSenderPrefix(title) + + const subTypeRaw = this.extractAppMessageType(normalized) + const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0 + if (subType === 6) return '[文件]' + if (subType === 19) return '[聊天记录]' + if (subType === 33 || subType === 36) return '[小程序]' + return '[链接]' + } + + return this.formatPlainExportContent(safeContent, referType, { exportVoiceAsText: false }) || '[消息]' + } + + private resolveQuotedSenderUsername(fromusr?: string, chatusr?: string): string { + const normalizedChatUsr = String(chatusr || '').trim() + const normalizedFromUsr = String(fromusr || '').trim() + + if (normalizedChatUsr) { + return normalizedChatUsr + } + + if (normalizedFromUsr.endsWith('@chatroom')) { + return '' + } + + return normalizedFromUsr + } + + private buildQuotedReplyText(display: { + replyText: string + quotedSender?: string + quotedPreview: string + }): string { + const quoteLabel = display.quotedSender + ? `${display.quotedSender}:${display.quotedPreview}` + : display.quotedPreview + if (display.replyText) { + return `${display.replyText}[引用 ${quoteLabel}]` + } + return `[引用 ${quoteLabel}]` + } + + private extractQuotedReplyDisplay(content: string): { + replyText: string + quotedSender?: string + quotedPreview: string + } | null { + try { + const normalized = this.normalizeAppMessageContent(content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return null + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + const quoteInfo = this.parseQuoteMessage(normalized) + const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '') + const quotedPreview = this.formatQuotedReferencePreview( + this.extractXmlValue(referMsgXml, 'content'), + this.extractXmlValue(referMsgXml, 'type') + ) + + if (!replyText && !quotedPreview) { + return null + } + + return { + replyText, + quotedSender: quoteInfo.sender || undefined, + quotedPreview: quotedPreview || '[消息]' + } + } catch { + return null + } + } + + private async resolveQuotedReplyDisplayWithNames(args: { + content: string + isGroup: boolean + displayNamePreference: ExportOptions['displayNamePreference'] + getContact: (username: string) => Promise<{ success: boolean; contact?: any; error?: string }> + groupNicknamesMap: Map + cleanedMyWxid: string + rawMyWxid?: string + myDisplayName?: string + }): Promise<{ + replyText: string + quotedSender?: string + quotedPreview: string + } | null> { + const base = this.extractQuotedReplyDisplay(args.content) + if (!base) return null + if (base.quotedSender) return base + + const normalized = this.normalizeAppMessageContent(args.content || '') + const referMsgStart = normalized.indexOf('') + const referMsgEnd = normalized.indexOf('') + if (referMsgStart === -1 || referMsgEnd === -1) { + return base + } + + const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) + const quotedSenderUsername = this.resolveQuotedSenderUsername( + this.extractXmlValue(referMsgXml, 'fromusr'), + this.extractXmlValue(referMsgXml, 'chatusr') + ) + if (!quotedSenderUsername) { + return base + } + + const isQuotedSelf = this.isSameWxid(quotedSenderUsername, args.cleanedMyWxid) + const fallbackDisplayName = isQuotedSelf + ? (args.myDisplayName || quotedSenderUsername) + : quotedSenderUsername + + const profile = await this.resolveExportDisplayProfile( + quotedSenderUsername, + args.displayNamePreference, + args.getContact, + args.groupNicknamesMap, + fallbackDisplayName, + isQuotedSelf ? [args.rawMyWxid, args.cleanedMyWxid] : [] + ) + + return { + ...base, + quotedSender: profile.displayName || fallbackDisplayName || base.quotedSender + } + } + private parseDurationSeconds(value: string): number | null { const numeric = Number(value) if (!Number.isFinite(numeric) || numeric <= 0) return null @@ -2493,6 +2658,15 @@ class ExportService { } } + private getExportPlatformMessageId(msg: { serverIdRaw?: unknown; serverId?: unknown }): string | undefined { + const value = this.normalizeUnsignedIntToken(msg.serverIdRaw ?? msg.serverId) + return value !== '0' ? value : undefined + } + + private getExportReplyToMessageId(content: string): string | undefined { + return this.extractChatLabReplyToMessageId(content) + } + private extractArkmeAppMessageMeta(content: string, localType: number): Record | null { if (!content) return null @@ -4944,6 +5118,20 @@ class ExportService { ) } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + content = this.buildQuotedReplyText(quotedReplyDisplay) + } + // 获取发送者信息用于名称显示 const senderWxid = msg.senderUsername const contact = senderWxid @@ -4987,6 +5175,12 @@ class ExportService { senderAvatarKey: msg.senderUsername } + const platformMessageId = this.getExportPlatformMessageId(msg) + if (platformMessageId) msgObj.platformMessageId = platformMessageId + + const replyToMessageId = this.getExportReplyToMessageId(msg.content) + if (replyToMessageId) msgObj.replyToMessageId = replyToMessageId + const appMsgMeta = this.extractArkmeAppMessageMeta(msg.content, msg.localType) if (appMsgMeta) { if ( @@ -4996,6 +5190,10 @@ class ExportService { Object.assign(msgObj, appMsgMeta) } } + if (quotedReplyDisplay) { + if (quotedReplyDisplay.quotedSender) msgObj.quotedSender = quotedReplyDisplay.quotedSender + if (quotedReplyDisplay.quotedPreview) msgObj.quotedContent = quotedReplyDisplay.quotedPreview + } if (options.format === 'arkme-json') { const contactCardMeta = this.extractArkmeContactCardMeta(msg.content, msg.localType) @@ -5193,6 +5391,8 @@ class ExportService { senderID, source: message.source } + if (message.platformMessageId) compactMessage.platformMessageId = message.platformMessageId + if (message.replyToMessageId) compactMessage.replyToMessageId = message.replyToMessageId if (message.locationLat != null) compactMessage.locationLat = message.locationLat if (message.locationLng != null) compactMessage.locationLng = message.locationLng if (message.locationPoiname) compactMessage.locationPoiname = message.locationPoiname @@ -5830,6 +6030,20 @@ class ExportService { } } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + // 调试日志 if (msg.localType === 3 || msg.localType === 47) { } @@ -6077,6 +6291,20 @@ class ExportService { } } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + appendRow(useCompactColumns ? [ i + 1, @@ -6430,6 +6658,20 @@ class ExportService { } } + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + if (quotedReplyDisplay) { + enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) + } + let senderRole: string let senderWxid: string let senderNickname: string @@ -6724,7 +6966,7 @@ class ExportService { }) const lines: string[] = [] - lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') + lines.push('id,MsgSvrID,ReplyToMsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') const senderProfileCache = new Map() for (let i = 0; i < totalMessages; i++) { @@ -6790,15 +7032,31 @@ class ExportService { msg.senderUsername, msg.isSend ) || '') + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) + const finalMsgText = quotedReplyDisplay + ? this.buildQuotedReplyText(quotedReplyDisplay) + : msgText const src = this.getWeCloneSource(msg, typeName, mediaItem) + const platformMessageId = this.getExportPlatformMessageId(msg) || '' + const replyToMessageId = this.getExportReplyToMessageId(msg.content) || '' const row = [ i + 1, - i + 1, + platformMessageId, + replyToMessageId, typeName, msg.isSend ? 1 : 0, talker, - msgText, + finalMsgText, src, this.formatIsoTimestamp(msg.createTime) ] @@ -7270,8 +7528,18 @@ class ExportService { const timeText = this.formatTimestamp(msg.createTime) const typeName = this.getMessageTypeName(msg.localType) + const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ + content: msg.content, + isGroup, + displayNamePreference: options.displayNamePreference, + getContact: getContactCached, + groupNicknamesMap, + cleanedMyWxid, + rawMyWxid, + myDisplayName: myInfo.displayName || cleanedMyWxid + }) - let textContent = this.formatHtmlMessageText( + let textContent = quotedReplyDisplay?.replyText || this.formatHtmlMessageText( msg.content, msg.localType, cleanedMyWxid, @@ -7302,7 +7570,7 @@ class ExportService { } } - const linkCard = this.extractHtmlLinkCard(msg.content, msg.localType) + const linkCard = quotedReplyDisplay ? null : this.extractHtmlLinkCard(msg.content, msg.localType) let mediaHtml = '' if (mediaItem?.kind === 'image') { @@ -7318,25 +7586,40 @@ class ExportService { mediaHtml = `` } - const textHtml = linkCard - ? `` - : (textContent - ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` - : '') + const textHtml = quotedReplyDisplay + ? (() => { + const quotedSenderHtml = quotedReplyDisplay.quotedSender + ? `
${this.escapeHtml(quotedReplyDisplay.quotedSender)}
` + : '' + const quotedPreviewHtml = `
${this.renderTextWithEmoji(quotedReplyDisplay.quotedPreview).replace(/\r?\n/g, '
')}
` + const replyTextHtml = textContent + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` + : '' + return `
${quotedSenderHtml}${quotedPreviewHtml}
${replyTextHtml}` + })() + : (linkCard + ? `` + : (textContent + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` + : '')) const senderNameHtml = isGroup ? `
${this.escapeHtml(resolvedSenderName)}
` : '' const timeHtml = `
${this.escapeHtml(timeText)}
` const messageBody = `${timeHtml}${senderNameHtml}
${mediaHtml}${textHtml}
` + const platformMessageId = this.getExportPlatformMessageId(msg) + const replyToMessageId = this.getExportReplyToMessageId(msg.content) // Compact JSON object - const itemObj = { + const itemObj: Record = { i: i + 1, // index t: msg.createTime, // timestamp s: isSenderMe ? 1 : 0, // isSend a: avatarHtml, // avatar HTML b: messageBody // body HTML } + if (platformMessageId) itemObj.p = platformMessageId + if (replyToMessageId) itemObj.r = replyToMessageId writeBuf.push(JSON.stringify(itemObj)) @@ -7384,8 +7667,10 @@ class ExportService { // Render Item Function const renderItem = (item, index) => { const isSenderMe = item.s === 1; + const platformIdAttr = item.p ? \` data-platform-message-id="\${item.p}"\` : ''; + const replyToAttr = item.r ? \` data-reply-to-message-id="\${item.r}"\` : ''; return \` -
+
\${item.a}
From 4b17d203256855dcad605b6ed34260af43c6d35b Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 20 Mar 2026 17:11:28 +0800 Subject: [PATCH 10/13] =?UTF-8?q?weclone=E5=AF=BC=E5=87=BA=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=9C=89=E5=BC=95=E7=94=A8=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 39 +++++++++++++++--------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 89e77ca..8aa5d9d 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1923,6 +1923,16 @@ class ExportService { } } + private isQuotedReplyMessage(localType: number, content: string): boolean { + if (localType === 244813135921) return true + const normalized = this.normalizeAppMessageContent(content || '') + if (!(localType === 49 || normalized.includes(''))) { + return false + } + const subType = this.extractAppMessageType(normalized) + return subType === '57' || normalized.includes('') + } + private async resolveQuotedReplyDisplayWithNames(args: { content: string isGroup: boolean @@ -6813,7 +6823,7 @@ class ExportService { control, collectProgressReporter ) - const totalMessages = collected.rows.length + let totalMessages = collected.rows.length if (totalMessages === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -6841,7 +6851,13 @@ class ExportService { ? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates) : new Map() - const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) + const sortedMessages = collected.rows + .sort((a, b) => a.createTime - b.createTime) + .filter((msg) => !this.isQuotedReplyMessage(msg.localType, msg.content || '')) + totalMessages = sortedMessages.length + if (totalMessages === 0) { + return { success: false, error: '该会话在指定时间范围内没有可导出的消息' } + } const voiceMessages = options.exportVoiceAsText ? sortedMessages.filter(msg => msg.localType === 34) @@ -6966,7 +6982,7 @@ class ExportService { }) const lines: string[] = [] - lines.push('id,MsgSvrID,ReplyToMsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') + lines.push('id,MsgSvrID,type_name,is_sender,talker,msg,src,CreateTime') const senderProfileCache = new Map() for (let i = 0; i < totalMessages; i++) { @@ -7032,31 +7048,16 @@ class ExportService { msg.senderUsername, msg.isSend ) || '') - const quotedReplyDisplay = await this.resolveQuotedReplyDisplayWithNames({ - content: msg.content, - isGroup, - displayNamePreference: options.displayNamePreference, - getContact: getContactCached, - groupNicknamesMap, - cleanedMyWxid, - rawMyWxid, - myDisplayName: myInfo.displayName || cleanedMyWxid - }) - const finalMsgText = quotedReplyDisplay - ? this.buildQuotedReplyText(quotedReplyDisplay) - : msgText const src = this.getWeCloneSource(msg, typeName, mediaItem) const platformMessageId = this.getExportPlatformMessageId(msg) || '' - const replyToMessageId = this.getExportReplyToMessageId(msg.content) || '' const row = [ i + 1, platformMessageId, - replyToMessageId, typeName, msg.isSend ? 1 : 0, talker, - finalMsgText, + msgText, src, this.formatIsoTimestamp(msg.createTime) ] From ba5a791b2dd26ffdff6d3d2f7461330a478dd888 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:38:30 +0800 Subject: [PATCH 11/13] =?UTF-8?q?Mac=E5=AF=86=E9=92=A5=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/keyServiceMac.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index af33b75..79f9cdc 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -262,6 +262,7 @@ export class KeyServiceMac { ): Promise { const helperPath = this.getHelperPath() const waitMs = Math.max(timeoutMs, 30_000) + const timeoutSec = Math.ceil(waitMs / 1000) + 30 const pid = await this.getWeChatPid() onStatus?.(`已找到微信进程 PID=${pid},正在定位目标函数...`, 0) // 最佳努力清理同路径残留 helper(普通权限) @@ -378,12 +379,22 @@ export class KeyServiceMac { ): Promise { const helperPath = this.getHelperPath() const waitMs = Math.max(timeoutMs, 30_000) + const timeoutSec = Math.ceil(waitMs / 1000) + 30 const pid = await this.getWeChatPid() // 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败 + // 通过 try/on error 回传详细错误,避免只看到 "Command failed" const scriptLines = [ `set helperPath to ${JSON.stringify(helperPath)}`, - `set cmd to quoted form of helperPath & " ${pid} ${waitMs} 2>&1"`, - 'do shell script cmd with administrator privileges' + `set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`, + `set timeoutSec to ${timeoutSec}`, + 'try', + 'with timeout of timeoutSec seconds', + 'set outText to do shell script cmd with administrator privileges', + 'end timeout', + 'return "WF_OK::" & outText', + 'on error errMsg number errNum partial result pr', + 'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)', + 'end try' ] onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0) @@ -400,6 +411,16 @@ export class KeyServiceMac { const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean) if (!lines.length) throw new Error('elevated helper returned empty output') + const joined = lines.join('\n') + + if (joined.startsWith('WF_ERR::')) { + const parts = joined.split('::') + const errNum = parts[1] || 'unknown' + const errMsg = parts[2] || 'unknown' + const partial = parts.slice(3).join('::') + throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`) + } + const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined // 从所有行里提取所有 JSON 对象(同一行可能有多个拼接),找含 key/result 的那个 const extractJsonObjects = (s: string): any[] => { @@ -411,7 +432,7 @@ export class KeyServiceMac { } return results } - const fullOutput = lines.join('\n') + const fullOutput = normalizedOutput const allJson = extractJsonObjects(fullOutput) // 优先找 success=true && key 字段 const successPayload = allJson.find(p => p?.success === true && typeof p?.key === 'string') From 2ea7c72fc63fa882edcca2882175029a38230ef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9C=E5=8C=97=E5=B0=98?= <2678115663@qq.com> Date: Fri, 20 Mar 2026 21:13:25 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20HTTP=20API=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20Type=2049=20=E9=93=BE=E6=8E=A5=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 HTTP API 导出重新解析 appmsg 子类型,修复公众号链接被误判为 OTHER 的问题,并补齐导出内容中的 `[链接]` 前缀。 Fixes #300 --- electron/services/httpService.ts | 91 +++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 7b95996..163106c 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -1226,7 +1226,7 @@ class HttpService { * 映射 Type 49 子类型 */ private mapType49(msg: Message): number { - const xmlType = msg.xmlType + const xmlType = this.resolveType49Subtype(msg) switch (xmlType) { case '5': // 链接 @@ -1250,10 +1250,97 @@ class HttpService { } } + private extractType49Subtype(rawContent: string): string { + const content = String(rawContent || '') + if (!content) return '' + + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) + if (appmsgMatch) { + const appmsgInner = appmsgMatch[1] + .replace(//gi, '') + .replace(//gi, '') + const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) + if (typeMatch) { + return typeMatch[1].replace(//g, '').trim() + } + } + + const fallbackMatch = /([\s\S]*?)<\/type>/i.exec(content) + if (fallbackMatch) { + return fallbackMatch[1].replace(//g, '').trim() + } + + return '' + } + + private resolveType49Subtype(msg: Message): string { + const xmlType = String(msg.xmlType || '').trim() + if (xmlType) return xmlType + + const extractedType = this.extractType49Subtype(msg.rawContent) + if (extractedType) return extractedType + + switch (msg.appMsgKind) { + case 'official-link': + case 'link': + return '5' + case 'file': + return '6' + case 'chat-record': + return '19' + case 'miniapp': + return '33' + case 'quote': + return '57' + case 'transfer': + return '2000' + case 'red-packet': + return '2001' + case 'music': + return '3' + default: + if (msg.linkUrl) return '5' + if (msg.fileName) return '6' + return '' + } + } + + private getType49Content(msg: Message): string { + const subtype = this.resolveType49Subtype(msg) + const title = msg.linkTitle || msg.fileName || '' + + switch (subtype) { + case '5': + case '49': + return title ? `[链接] ${title}` : '[链接]' + case '6': + return title ? `[文件] ${title}` : '[文件]' + case '19': + return title ? `[聊天记录] ${title}` : '[聊天记录]' + case '33': + case '36': + return title ? `[小程序] ${title}` : '[小程序]' + case '57': + return msg.parsedContent || title || '[引用消息]' + case '2000': + return title ? `[转账] ${title}` : '[转账]' + case '2001': + return title ? `[红包] ${title}` : '[红包]' + case '3': + return title ? `[音乐] ${title}` : '[音乐]' + default: + return msg.parsedContent || title || '[消息]' + } + } + /** * 获取消息内容 */ private getMessageContent(msg: Message): string | null { + if (msg.localType === 49) { + return this.getType49Content(msg) + } + // 优先使用已解析的内容 if (msg.parsedContent) { return msg.parsedContent @@ -1276,7 +1363,7 @@ class HttpService { case 48: return '[位置]' case 49: - return msg.linkTitle || msg.fileName || '[消息]' + return this.getType49Content(msg) default: return msg.rawContent || null } From 262b3622dd110e8e6829a9ac69c002b8312b7a07 Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:39:39 +0800 Subject: [PATCH 13/13] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 586b166..7600b8f 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,19 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 - HTTP API 接口(供开发者集成) - 查看完整能力清单:[详细功能](#详细功能清单) +## 支持平台与设备 + + +| 平台 | 设备/架构 | 安装包 | +|------|----------|--------| +| Windows | Windows10+、x64(amd64) | `.exe` | +| macOS | Apple Silicon(M 系列,arm64) | `.dmg` | +| Linux | x64 设备(amd64) | `.deb`、`.tar.gz` | + + ## 快速开始 -若你只想使用成品版本,可前往 Release 下载并安装。 +若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。 ## 详细功能清单