From 06d6f15e38adc892e0c76c0ec99fc0749438e487 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 14:40:08 +0800 Subject: [PATCH 001/155] feat(export): redesign export board workflow --- src/pages/ExportPage.scss | 1956 ++++++++++++++------------------ src/pages/ExportPage.tsx | 2264 ++++++++++++++++++++----------------- src/services/config.ts | 49 + 3 files changed, 2118 insertions(+), 2151 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 1d7e414..d17679e 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1,1134 +1,909 @@ -.export-page { - display: flex; +.export-board-page { height: calc(100% + 48px); margin: -24px; + padding: 20px; background: var(--bg-primary); + display: flex; + flex-direction: column; + gap: 16px; overflow: hidden; - // 左侧会话选择面板 - .session-panel { - width: 380px; - min-width: 380px; - display: flex; - flex-direction: column; - border-right: 1px solid var(--border-color); - background: var(--card-bg); + .spin { + animation: exportSpin 1s linear infinite; } +} - .panel-header { +.export-top-panel { + display: grid; + grid-template-columns: minmax(260px, 380px) 1fr; + gap: 14px; + flex-shrink: 0; +} + +.current-user-box { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 14px; + display: flex; + align-items: center; + gap: 12px; + + .avatar-wrap { + width: 48px; + height: 48px; + border-radius: 12px; + overflow: hidden; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); display: flex; align-items: center; - justify-content: space-between; - padding: 20px 24px; - border-bottom: 1px solid var(--border-color); - - h2 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0; - } - - .icon-btn { - width: 32px; - height: 32px; - border: none; - background: var(--bg-tertiary); - border-radius: 8px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-secondary); - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .spin { - animation: exportSpin 1s linear infinite; - } - } - } - - .search-bar { - display: flex; - align-items: center; - gap: 10px; - margin: 16px 20px; - padding: 10px 14px; - background: var(--bg-secondary); - border-radius: 10px; - border: 1px solid var(--border-color); - transition: border-color 0.2s; - - &:focus-within { - border-color: var(--primary); - } - - svg { - color: var(--text-tertiary); - flex-shrink: 0; - } - - input { - flex: 1; - border: none; - background: none; - outline: none; - font-size: 14px; - color: var(--text-primary); - - &::placeholder { - color: var(--text-tertiary); - } - } - - .clear-btn { - background: none; - border: none; - padding: 4px; - cursor: pointer; - color: var(--text-tertiary); - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - } - } - - .select-actions { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 20px 12px; - - .select-all-btn { - background: none; - border: none; - padding: 6px 12px; - font-size: 13px; - color: var(--primary); - cursor: pointer; - border-radius: 6px; - - &:hover { - background: rgba(var(--primary-rgb), 0.1); - } - } - - .selected-count { - font-size: 13px; - color: var(--text-secondary); - padding: 4px 12px; - background: var(--bg-secondary); - border-radius: 12px; - } - } - - .loading-state, - .empty-state { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; justify-content: center; - gap: 12px; - color: var(--text-tertiary); - font-size: 14px; - .spin { - animation: exportSpin 1s linear infinite; - } - } - - .export-session-list { - flex: 1; - overflow-y: auto; - padding: 0 12px 12px; - - &::-webkit-scrollbar { - width: 6px; + img { + width: 100%; + height: 100%; + object-fit: cover; } - &::-webkit-scrollbar-thumb { - background: var(--text-tertiary); - border-radius: 3px; - opacity: 0.3; - } - } - - .export-session-item { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - border-radius: 10px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - } - - &.selected { - background: rgba(var(--primary-rgb), 0.08); - - .check-box { - background: var(--primary); - border-color: var(--primary); - color: #fff; - } - } - - .check-box { - width: 20px; - height: 20px; - border: 2px solid var(--border-color); - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: all 0.2s; - } - - .export-avatar { - width: 44px; - height: 44px; - border-radius: 10px; - background: linear-gradient(135deg, var(--primary), var(--primary-hover)); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - flex-shrink: 0; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - - span { - color: #fff; - font-size: 16px; - font-weight: 600; - } - } - - .export-session-info { - flex: 1; - min-width: 0; - } - - .export-session-name { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .export-session-summary { - font-size: 12px; - color: var(--text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-top: 2px; - } - } - - // 右侧设置面板 - .settings-panel { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - } - - .settings-content { - flex: 1; - overflow-y: auto; - padding: 20px 24px; - - &::-webkit-scrollbar { - width: 6px; - } - - &::-webkit-scrollbar-thumb { - background: var(--text-tertiary); - border-radius: 3px; - } - } - - .setting-section { - margin-bottom: 28px; - - h3 { - font-size: 13px; + span { + color: #fff; + font-size: 18px; font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - margin: 0 0 14px; } } - .format-options { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 12px; - } - - .format-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - padding: 20px 16px; - background: var(--bg-secondary); - border: 2px solid transparent; - border-radius: 12px; - cursor: pointer; - transition: all 0.2s; - text-align: center; - - &:hover { - background: var(--bg-hover); - } - - &.active { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.05); - - svg { - color: var(--primary); - } - } - - svg { - color: var(--text-secondary); - } - - .format-label { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - } - - .format-desc { - font-size: 11px; - color: var(--text-tertiary); - line-height: 1.4; - } - } - - .time-range-picker-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - cursor: pointer; - transition: background 0.2s; - background: transparent; - - &:hover { - background: var(--bg-hover); - } - - .time-picker-info { - display: flex; - align-items: center; - gap: 10px; - font-size: 14px; - color: var(--text-primary); - - svg { - color: var(--primary); - } - } - - svg { - color: var(--text-tertiary); - } - } - - .select-field { - position: relative; - } - - .select-trigger { - width: 100%; - padding: 10px 16px; - border: 1px solid var(--border-color); - border-radius: 9999px; - font-size: 14px; - background: var(--bg-primary); - color: var(--text-primary); - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - border-color: var(--text-tertiary); - } - - &.open { - border-color: var(--primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); - } - } - - .select-value { - flex: 1; + .user-meta { min-width: 0; - text-align: left; + } + + .user-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .select-dropdown { + .user-wxid { + margin-top: 2px; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.global-export-controls { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 14px; + display: grid; + grid-template-columns: minmax(300px, 1fr) 320px; + gap: 16px; + + .control-label { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; + letter-spacing: 0.3px; + } + + .path-control { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + .path-value { + border: 1px dashed var(--border-color); + border-radius: 10px; + padding: 10px 12px; + font-size: 13px; + color: var(--text-primary); + background: var(--bg-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .path-actions { + display: flex; + gap: 8px; + } + + .write-layout-control { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + } + + .layout-trigger { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 13px; + text-align: left; + cursor: pointer; + + &:hover { + border-color: var(--primary); + } + } + + .layout-dropdown { position: absolute; top: calc(100% + 6px); left: 0; right: 0; - background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); + background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; - padding: 6px; box-shadow: var(--shadow-md); + padding: 6px; z-index: 20; max-height: 260px; overflow-y: auto; - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); } - .select-option { + .layout-option { width: 100%; + border: none; + background: transparent; text-align: left; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; display: flex; flex-direction: column; - gap: 4px; - padding: 10px 12px; - border: none; - border-radius: 10px; - background: transparent; - cursor: pointer; - transition: all 0.15s; - color: var(--text-primary); - font-size: 14px; - - &:hover { - background: var(--bg-tertiary); - } - - &.active { - background: color-mix(in srgb, var(--primary) 12%, transparent); - color: var(--primary); - } - } - - .option-label { - font-weight: 500; - } - - .option-desc { - font-size: 12px; - color: var(--text-tertiary); - } - - .select-option.active .option-desc { - color: var(--primary); - } - - .media-options { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 12px; - padding-left: 28px; - } - - .folder-select { - display: flex; - align-items: center; - gap: 12px; - padding: 14px 16px; - background: var(--bg-secondary); - border: 1px dashed var(--border-color); - border-radius: 10px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.02); - } - - svg { - color: var(--primary); - } - - .folder-path { - flex: 1; - font-size: 13px; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .export-path-display { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - background: var(--bg-secondary); - border-radius: 10px; - font-size: 13px; - color: var(--text-primary); - - svg { - color: var(--primary); - flex-shrink: 0; - } - - span { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .path-hint { - font-size: 12px; - color: var(--text-tertiary); - margin: 8px 0 0; - } - - .select-folder-btn { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 10px 16px; - margin-top: 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - font-size: 13px; - font-weight: 500; - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; + gap: 2px; &:hover { background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); + } - svg { - color: var(--primary); + &.active { + background: rgba(var(--primary-rgb), 0.12); + color: var(--primary); + } + } + + .layout-option-label { + font-size: 13px; + font-weight: 600; + } + + .layout-option-desc { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.45; + } +} + +.content-card-grid { + display: grid; + grid-template-columns: repeat(5, minmax(150px, 1fr)); + gap: 10px; + flex-shrink: 0; +} + +.content-card { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + + .card-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: var(--text-primary); + font-weight: 600; + } + + .card-stats { + display: grid; + grid-template-columns: 1fr; + gap: 4px; + + .stat-item { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + color: var(--text-secondary); + + strong { + color: var(--text-primary); + font-size: 14px; } } + } - &:active { - transform: scale(0.98); + .card-export-btn { + margin-top: auto; + border: none; + border-radius: 8px; + padding: 8px 10px; + background: var(--primary); + color: #fff; + cursor: pointer; + font-size: 13px; + font-weight: 600; + + &:hover { + background: var(--primary-hover); + } + } +} + +.task-center { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + padding: 12px; + flex-shrink: 0; + + .section-title { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; + } + + .task-empty { + padding: 12px; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 13px; + color: var(--text-secondary); + } + + .task-list { + display: grid; + gap: 8px; + max-height: 190px; + overflow-y: auto; + } + + .task-card { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px; + display: flex; + gap: 10px; + align-items: flex-start; + background: var(--bg-secondary); + + &.running { + border-color: var(--primary); } - svg { - color: var(--text-secondary); - transition: color 0.2s; + &.error { + border-color: rgba(255, 77, 79, 0.45); + } + + &.success { + border-color: rgba(82, 196, 26, 0.4); } } - .export-action { - padding: 20px 24px; - border-top: 1px solid var(--border-color); + .task-main { + flex: 1; + min-width: 0; } - .export-btn { - width: 100%; + .task-title { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .task-meta { + margin-top: 2px; + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: var(--text-secondary); + } + + .task-status { + border-radius: 999px; + padding: 2px 8px; + font-weight: 600; + + &.queued { + background: rgba(var(--primary-rgb), 0.14); + color: var(--primary); + } + + &.running { + background: rgba(var(--primary-rgb), 0.2); + color: var(--primary); + } + + &.success { + background: rgba(82, 196, 26, 0.18); + color: #52c41a; + } + + &.error { + background: rgba(255, 77, 79, 0.15); + color: #ff4d4f; + } + } + + .task-progress-bar { + margin-top: 8px; + height: 6px; + background: rgba(0, 0, 0, 0.08); + border-radius: 3px; + overflow: hidden; + } + + .task-progress-fill { + height: 100%; + background: var(--primary); + transition: width 0.2s ease; + } + + .task-progress-text { + margin-top: 4px; + font-size: 11px; + color: var(--text-secondary); + } + + .task-error { + margin-top: 6px; + font-size: 12px; + color: #ff4d4f; + } +} + +.session-table-section { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + padding: 12px; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} + +.table-toolbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.table-tabs { + display: flex; + gap: 8px; + + .tab-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + padding: 7px 12px; + border-radius: 999px; + cursor: pointer; + font-size: 13px; + + &.active { + border-color: var(--primary); + color: var(--primary); + background: rgba(var(--primary-rgb), 0.12); + } + } +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.search-input-wrap { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + min-width: 220px; + + input { + border: none; + background: transparent; + color: var(--text-primary); + font-size: 13px; + outline: none; + width: 180px; + } + + .clear-search { + border: none; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + display: flex; + } +} + +.selected-batch-actions { + display: flex; + align-items: center; + gap: 8px; + border: 1px dashed rgba(var(--primary-rgb), 0.45); + background: rgba(var(--primary-rgb), 0.06); + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + color: var(--text-secondary); +} + +.table-wrap { + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 10px; + min-height: 0; + flex: 1; +} + +.session-table { + width: 100%; + min-width: 1300px; + border-collapse: separate; + border-spacing: 0; + + thead th { + position: sticky; + top: 0; + background: color-mix(in srgb, var(--bg-primary) 75%, var(--bg-secondary)); + z-index: 4; + font-size: 12px; + text-align: left; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + padding: 10px 10px; + white-space: nowrap; + } + + tbody td { + padding: 10px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + font-size: 13px; + color: var(--text-primary); + vertical-align: middle; + white-space: nowrap; + } + + tbody tr:hover { + background: rgba(var(--primary-rgb), 0.03); + } + + .selected-row { + background: rgba(var(--primary-rgb), 0.08); + } + + .sticky-col { + position: sticky; + left: 0; + z-index: 5; + background: inherit; + } + + .sticky-right { + position: sticky; + right: 0; + z-index: 5; + background: inherit; + } +} + +.select-icon-btn { + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + + &:hover { + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + } + + &.checked { + color: var(--primary); + } +} + +.session-cell { + display: flex; + align-items: center; + gap: 10px; + min-width: 230px; + + .session-avatar { + width: 36px; + height: 36px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); display: flex; align-items: center; justify-content: center; - gap: 10px; - padding: 14px 24px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .session-name { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + } + + .session-id { + margin-top: 2px; + font-size: 11px; + color: var(--text-tertiary); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.row-action-cell { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + + .row-export-btn { + border: none; + border-radius: 8px; + padding: 7px 10px; background: var(--primary); color: #fff; - border: none; - border-radius: 12px; - font-size: 15px; - font-weight: 600; + font-size: 12px; cursor: pointer; - transition: all 0.2s; + display: flex; + align-items: center; + gap: 5px; &:hover:not(:disabled) { background: var(--primary-hover); } &:disabled { - opacity: 0.5; + opacity: 0.75; cursor: not-allowed; } - .spin { - animation: exportSpin 1s linear infinite; + &.running { + background: color-mix(in srgb, var(--primary) 80%, #000); } } - // 导出进度弹窗 - .export-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); + .row-export-time { + font-size: 11px; + color: var(--text-tertiary); + } +} + +.table-state { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 120px; + color: var(--text-secondary); +} + +.export-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.export-dialog { + width: min(980px, calc(100vw - 40px)); + max-height: calc(100vh - 60px); + overflow: auto; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 14px; + padding: 16px; +} + +.dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + + h3 { + margin: 0; + color: var(--text-primary); + font-size: 18px; + } +} + +.close-icon-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-secondary); +} + +.dialog-section { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px; + margin-bottom: 10px; + background: var(--bg-secondary); + + h4 { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.4px; + } +} + +.scope-tag-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.scope-tag { + border-radius: 999px; + background: rgba(var(--primary-rgb), 0.15); + color: var(--primary); + padding: 4px 10px; + font-size: 12px; + font-weight: 600; +} + +.scope-count { + font-size: 12px; + color: var(--text-secondary); +} + +.scope-list { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 120px; + overflow: auto; +} + +.scope-item { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 4px 9px; + font-size: 12px; + color: var(--text-primary); +} + +.format-grid { + display: grid; + grid-template-columns: repeat(4, minmax(130px, 1fr)); + gap: 8px; +} + +.format-card { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px; + text-align: left; + background: var(--bg-primary); + cursor: pointer; + + .format-label { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + } + + .format-desc { + margin-top: 3px; + font-size: 11px; + color: var(--text-tertiary); + line-height: 1.4; + } + + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } +} + +.switch-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + color: var(--text-primary); +} + +.date-range-row { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + + label { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 12px; + color: var(--text-secondary); + } + + input { + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + padding: 8px; + } +} + +.media-check-grid { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(3, minmax(100px, 1fr)); + gap: 8px; + + label { display: flex; align-items: center; - justify-content: center; - z-index: 1000; + gap: 6px; + font-size: 12px; + color: var(--text-primary); } - .export-progress-modal { - background: var(--card-bg); - padding: 32px 40px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - text-align: center; - min-width: 320px; + input[type='checkbox'] { + accent-color: var(--primary); + } +} - .progress-spinner { - margin-bottom: 20px; - color: var(--primary); +.display-name-options { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} - .spin { - animation: exportSpin 1s linear infinite; - } - } +.display-name-item { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--bg-primary); - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 8px; - } - - .progress-text { - font-size: 14px; - color: var(--text-secondary); - margin: 0 0 20px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .progress-bar { - height: 6px; - background: var(--bg-secondary); - border-radius: 3px; - overflow: hidden; - margin-bottom: 12px; - - .progress-fill { - height: 100%; - background: var(--primary); - border-radius: 3px; - transition: width 0.3s ease; - } - } - - .progress-count { - font-size: 13px; - color: var(--text-tertiary); - margin: 0; - } + span { + font-size: 12px; + color: var(--text-primary); + font-weight: 600; } - .export-layout-modal { - background: var(--card-bg); - padding: 28px 32px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - text-align: center; - width: min(520px, 90vw); - - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 8px; - } - - .layout-subtitle { - font-size: 14px; - color: var(--text-secondary); - margin: 0 0 20px; - } - - .layout-options { - display: grid; - gap: 12px; - } - - .layout-option-btn { - display: flex; - flex-direction: column; - gap: 6px; - padding: 14px 18px; - border-radius: 12px; - border: 1px solid var(--border-color); - background: var(--bg-secondary); - text-align: left; - cursor: pointer; - transition: all 0.2s; - - &:hover { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.08); - } - - &.primary { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.12); - } - - .layout-title { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - } - - .layout-desc { - font-size: 12px; - color: var(--text-tertiary); - } - } - - .layout-actions { - margin-top: 18px; - display: flex; - justify-content: center; - } - - .layout-cancel-btn { - padding: 8px 20px; - border-radius: 8px; - border: 1px solid var(--border-color); - background: var(--bg-secondary); - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - } - } + small { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.4; } - .export-result-modal { - background: var(--card-bg); - padding: 32px 40px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - text-align: center; - min-width: 320px; - - .result-icon { - margin-bottom: 16px; - - &.success { - color: #52c41a; - } - - &.error { - color: #ff4d4f; - } - } - - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 8px; - } - - .result-text { - font-size: 14px; - color: var(--text-secondary); - margin: 0 0 24px; - - &.error { - color: #ff4d4f; - } - } - - .result-actions { - display: flex; - gap: 12px; - justify-content: center; - - button { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 10px 20px; - border-radius: 8px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - } - - .open-folder-btn { - background: var(--primary); - color: #fff; - border: none; - - &:hover { - background: var(--primary-hover); - } - } - - .close-btn { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - - &:hover { - background: var(--bg-hover); - } - } - } + input { + accent-color: var(--primary); + margin: 0 0 4px; } - .date-picker-modal { - background: var(--card-bg); - padding: 28px 32px; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); - width: 420px; + &.active { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } +} - h3 { - font-size: 18px; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 20px; - } +.dialog-actions { + margin-top: 10px; + display: flex; + justify-content: flex-end; + gap: 8px; +} - .quick-select { - display: flex; - gap: 8px; - margin-bottom: 20px; +.primary-btn, +.secondary-btn { + border-radius: 8px; + padding: 7px 12px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--border-color); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; +} - .quick-btn { - flex: 1; - padding: 10px 12px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - font-size: 13px; - font-weight: 500; - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s; +.primary-btn { + border-color: var(--primary); + background: var(--primary); + color: #fff; - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - } + &:hover { + background: var(--primary-hover); + } - &:active { - transform: scale(0.98); - } - } - } + &:disabled { + opacity: 0.65; + cursor: not-allowed; + } +} - .date-display { - display: flex; - align-items: center; - gap: 16px; - padding: 20px; - background: var(--bg-secondary); - border-radius: 12px; - margin-bottom: 24px; +.secondary-btn { + background: var(--bg-secondary); + color: var(--text-primary); - .date-display-item { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; - padding: 8px 12px; - border-radius: 8px; - cursor: pointer; - transition: all 0.2s; - - &:hover { - background: rgba(var(--primary-rgb), 0.05); - } - - &.active { - background: rgba(var(--primary-rgb), 0.1); - border: 1px solid var(--primary); - } - - .date-label { - font-size: 12px; - color: var(--text-tertiary); - font-weight: 500; - } - - .date-value { - font-size: 15px; - color: var(--text-primary); - font-weight: 600; - } - } - - .date-separator { - font-size: 14px; - color: var(--text-tertiary); - padding: 0 4px; - } - } - - .calendar-container { - margin-bottom: 20px; - } - - .calendar-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; - padding: 0 4px; - - .calendar-nav-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - cursor: pointer; - color: var(--text-secondary); - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - } - - &:active { - transform: scale(0.95); - } - } - - .calendar-month { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - - &.clickable { - cursor: pointer; - border-radius: 6px; - padding: 2px 8px; - transition: all 0.15s; - - &:hover { - background: var(--bg-hover); - color: var(--primary); - } - } - } - } - - .calendar-weekdays { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 4px; - margin-bottom: 8px; - - .calendar-weekday { - text-align: center; - font-size: 12px; - font-weight: 500; - color: var(--text-tertiary); - padding: 8px 0; - } - } - - .calendar-days { - display: grid; - grid-template-columns: repeat(7, 1fr); - grid-template-rows: repeat(6, 40px); - gap: 4px; - - .calendar-day { - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - color: var(--text-primary); - border-radius: 8px; - cursor: pointer; - transition: all 0.2s; - position: relative; - - &.empty { - cursor: default; - } - - &:not(.empty):hover { - background: var(--bg-hover); - } - - &.in-range { - background: rgba(var(--primary-rgb), 0.08); - } - - &.start, - &.end { - background: var(--primary); - color: #fff; - font-weight: 600; - - &:hover { - background: var(--primary-hover); - } - } - } - } - - .year-month-picker { - padding: 4px 0; - - .year-selector { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; - - .year-label { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - } - - .calendar-nav-btn { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - cursor: pointer; - color: var(--text-secondary); - transition: all 0.2s; - - &:hover { - background: var(--bg-hover); - border-color: var(--primary); - color: var(--primary); - } - } - } - - .month-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - - .month-btn { - padding: 10px 0; - border: none; - background: transparent; - border-radius: 8px; - cursor: pointer; - font-size: 13px; - color: var(--text-secondary); - transition: all 0.15s; - - &:hover { - background: var(--bg-hover); - color: var(--text-primary); - } - - &.active { - background: var(--primary); - color: #fff; - } - } - } - } - - .date-picker-actions { - display: flex; - gap: 12px; - justify-content: flex-end; - - button { - padding: 10px 20px; - border-radius: 8px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - - &:active { - transform: scale(0.98); - } - } - - .cancel-btn { - background: var(--bg-secondary); - color: var(--text-primary); - border: 1px solid var(--border-color); - - &:hover { - background: var(--bg-hover); - } - } - - .confirm-btn { - background: var(--primary); - color: #fff; - border: none; - - &:hover { - background: var(--primary-hover); - } - } - } + &:hover { + border-color: var(--primary); + color: var(--primary); } } @@ -1142,93 +917,28 @@ } } -// 媒体导出选项卡片样式 -.setting-subtitle { - font-size: 12px; - color: var(--text-tertiary); - margin: 4px 0 12px 0; -} - -.media-options-card { - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 12px; - overflow: hidden; -} - -.media-switch-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; -} - -.media-switch-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.media-switch-title { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); -} - -.media-switch-desc { - font-size: 11px; - color: var(--text-tertiary); -} - -.media-option-divider { - height: 1px; - background: var(--border-color); - margin-left: 16px; -} - -.media-checkbox-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - cursor: pointer; - transition: background 0.2s; - - &:hover:not(.disabled) { - background: var(--bg-hover); +@media (max-width: 1360px) { + .export-top-panel { + grid-template-columns: 1fr; } - &.disabled { - opacity: 0.5; - cursor: not-allowed; + .global-export-controls { + grid-template-columns: 1fr; } - input[type="checkbox"] { - width: 18px; - height: 18px; - accent-color: var(--primary); - cursor: pointer; + .content-card-grid { + grid-template-columns: repeat(2, minmax(160px, 1fr)); + } - &:disabled { - cursor: not-allowed; - } + .format-grid { + grid-template-columns: repeat(2, minmax(120px, 1fr)); + } + + .display-name-options { + grid-template-columns: 1fr; + } + + .media-check-grid { + grid-template-columns: repeat(2, minmax(120px, 1fr)); } } - -.media-checkbox-info { - display: flex; - flex-direction: column; - gap: 2px; -} - -.media-checkbox-title { - font-size: 14px; - color: var(--text-primary); -} - -.media-checkbox-desc { - font-size: 11px; - color: var(--text-tertiary); -} - -// 全局样式已在 main.scss 中定义 \ No newline at end of file diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index fce60b4..6855b60 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,19 +1,38 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' -import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' +import { + CheckSquare, + Download, + ExternalLink, + FolderOpen, + Image as ImageIcon, + Loader2, + MessageSquareText, + Mic, + Search, + Square, + Video, + WandSparkles, + X +} from 'lucide-react' +import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' +import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' import * as configService from '../services/config' import './ExportPage.scss' -interface ChatSession { - username: string - displayName?: string - avatarUrl?: string - summary: string - lastTimestamp: number -} +type ConversationTab = 'private' | 'group' | 'official' +type TaskStatus = 'queued' | 'running' | 'success' | 'error' +type TaskScope = 'single' | 'multi' | 'content' +type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' + +type SessionLayout = 'shared' | 'per-session' + +type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname' + +type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' interface ExportOptions { - format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' + format: TextExportFormat dateRange: { start: Date; end: Date } | null useAllTime: boolean exportAvatars: boolean @@ -25,61 +44,211 @@ interface ExportOptions { exportVoiceAsText: boolean excelCompactColumns: boolean txtColumns: string[] - displayNamePreference: 'group-nickname' | 'remark' | 'nickname' + displayNamePreference: DisplayNamePreference exportConcurrency: number } -interface ExportResult { - success: boolean - successCount?: number - failCount?: number - error?: string +interface SessionRow extends AppChatSession { + kind: ConversationTab + wechatId?: string } -type SessionLayout = 'shared' | 'per-session' +interface SessionMetrics { + totalMessages?: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number + firstTimestamp?: number + lastTimestamp?: number +} + +interface TaskProgress { + current: number + total: number + currentName: string + phaseLabel: string + phaseProgress: number + phaseTotal: number +} + +interface ExportTaskPayload { + sessionIds: string[] + outputDir: string + options: ElectronExportOptions + scope: TaskScope + contentType?: ContentType + sessionNames: string[] +} + +interface ExportTask { + id: string + title: string + status: TaskStatus + createdAt: number + startedAt?: number + finishedAt?: number + error?: string + payload: ExportTaskPayload + progress: TaskProgress +} + +interface ExportDialogState { + open: boolean + scope: TaskScope + contentType?: ContentType + sessionIds: string[] + sessionNames: string[] + title: string +} + +interface CurrentUserProfile { + wxid: string + displayName: string + avatarUrl?: string +} + +const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] +const contentTypeLabels: Record = { + text: '聊天文本', + voice: '语音', + image: '图片', + video: '视频', + emoji: '表情包' +} + +const formatOptions: Array<{ value: TextExportFormat; label: string; desc: string }> = [ + { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, + { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, + { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, + { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, + { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, + { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, + { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } +] + +const displayNameOptions: Array<{ value: DisplayNamePreference; label: string; desc: string }> = [ + { value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' }, + { value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' }, + { value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' } +] + +const writeLayoutOptions: Array<{ value: configService.ExportWriteLayout; label: string; desc: string }> = [ + { + value: 'A', + label: 'A(类型分目录)', + desc: '聊天文本、语音、视频、表情包、图片分别创建文件夹' + }, + { + value: 'B', + label: 'B(文本根目录+媒体按会话)', + desc: '聊天文本在根目录;媒体按类型目录后再按会话分目录' + }, + { + value: 'C', + label: 'C(按会话分目录)', + desc: '每个会话一个目录,目录内包含文本与媒体文件' + } +] + +const createEmptyProgress = (): TaskProgress => ({ + current: 0, + total: 0, + currentName: '', + phaseLabel: '', + phaseProgress: 0, + phaseTotal: 0 +}) + +const formatAbsoluteDate = (timestamp: number): string => { + const d = new Date(timestamp) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + return `${y}-${m}-${day}` +} + +const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { + if (!timestamp) return '未导出' + const diff = Math.max(0, now - timestamp) + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + if (diff < hour) { + const minutes = Math.max(1, Math.floor(diff / minute)) + return `${minutes} 分钟前` + } + if (diff < day) { + const hours = Math.max(1, Math.floor(diff / hour)) + return `${hours} 小时前` + } + return formatAbsoluteDate(timestamp) +} + +const formatDateInputValue = (date: Date): string => { + const y = date.getFullYear() + const m = `${date.getMonth() + 1}`.padStart(2, '0') + const d = `${date.getDate()}`.padStart(2, '0') + return `${y}-${m}-${d}` +} + +const parseDateInput = (value: string, endOfDay: boolean): Date => { + const [year, month, day] = value.split('-').map(v => Number(v)) + const date = new Date(year, month - 1, day) + if (endOfDay) { + date.setHours(23, 59, 59, 999) + } else { + date.setHours(0, 0, 0, 0) + } + return date +} + +const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => { + if (session.username.endsWith('@chatroom')) return 'group' + if (contact?.type === 'official') return 'official' + return 'private' +} + +const getAvatarLetter = (name: string): string => { + if (!name) return '?' + return [...name][0] || '?' +} + +const valueOrDash = (value?: number): string => { + if (value === undefined || value === null) return '--' + return value.toLocaleString() +} + +const timestampOrDash = (timestamp?: number): string => { + if (!timestamp) return '--' + return formatAbsoluteDate(timestamp * 1000) +} + +const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` function ExportPage() { const location = useLocation() - const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] - const [sessions, setSessions] = useState([]) - const [filteredSessions, setFilteredSessions] = useState([]) - const [selectedSessions, setSelectedSessions] = useState>(new Set()) + const [isLoading, setIsLoading] = useState(true) + const [sessions, setSessions] = useState([]) + const [contactMap, setContactMap] = useState>({}) + const [groupMemberCountMap, setGroupMemberCountMap] = useState>({}) + const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') - const [exportFolder, setExportFolder] = useState('') - const [isExporting, setIsExporting] = useState(false) - const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) - const [exportResult, setExportResult] = useState(null) - const [showDatePicker, setShowDatePicker] = useState(false) - const [calendarDate, setCalendarDate] = useState(new Date()) - const [selectingStart, setSelectingStart] = useState(true) - const [showYearMonthPicker, setShowYearMonthPicker] = useState(false) - const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) - const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) - const [showPreExportDialog, setShowPreExportDialog] = useState(false) - const [preExportStats, setPreExportStats] = useState<{ - totalMessages: number; voiceMessages: number; cachedVoiceCount: number; - needTranscribeCount: number; mediaMessages: number; estimatedSeconds: number - } | null>(null) - const [isLoadingStats, setIsLoadingStats] = useState(false) - const [pendingLayout, setPendingLayout] = useState('shared') - const exportStartTime = useRef(0) - const [elapsedSeconds, setElapsedSeconds] = useState(0) - const displayNameDropdownRef = useRef(null) - const preselectAppliedRef = useRef(false) - const statsRequestIdRef = useRef(0) + const [activeTab, setActiveTab] = useState('private') + const [selectedSessions, setSelectedSessions] = useState>(new Set()) - const preselectSessionIds = useMemo(() => { - const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null - const rawList = Array.isArray(state?.preselectSessionIds) - ? state?.preselectSessionIds - : (typeof state?.preselectSessionId === 'string' ? [state.preselectSessionId] : []) + const [currentUser, setCurrentUser] = useState({ wxid: '', displayName: '未识别用户' }) - return rawList - .filter((item): item is string => typeof item === 'string') - .map(item => item.trim()) - .filter(Boolean) - }, [location.state]) + const [exportFolder, setExportFolder] = useState('') + const [writeLayout, setWriteLayout] = useState('A') + const [showWriteLayoutSelect, setShowWriteLayoutSelect] = useState(false) const [options, setOptions] = useState({ format: 'excel', @@ -101,105 +270,170 @@ function ExportPage() { exportConcurrency: 2 }) - const buildDateRangeFromPreset = (preset: string) => { - const now = new Date() - if (preset === 'all') { - return { useAllTime: true, dateRange: { start: now, end: now } } - } - let rangeMs = 0 - if (preset === '7d') rangeMs = 7 * 24 * 60 * 60 * 1000 - if (preset === '30d') rangeMs = 30 * 24 * 60 * 60 * 1000 - if (preset === '90d') rangeMs = 90 * 24 * 60 * 60 * 1000 - if (preset === 'today' || rangeMs === 0) { - const start = new Date(now) - start.setHours(0, 0, 0, 0) - return { useAllTime: false, dateRange: { start, end: now } } - } - const start = new Date(now.getTime() - rangeMs) - start.setHours(0, 0, 0, 0) - return { useAllTime: false, dateRange: { start, end: now } } - } + const [exportDialog, setExportDialog] = useState({ + open: false, + scope: 'single', + sessionIds: [], + sessionNames: [], + title: '' + }) - const loadSessions = useCallback(async () => { - setIsLoading(true) + const [tasks, setTasks] = useState([]) + const [lastExportBySession, setLastExportBySession] = useState>({}) + const [lastExportByContent, setLastExportByContent] = useState>({}) + const [nowTick, setNowTick] = useState(Date.now()) + + const progressUnsubscribeRef = useRef<(() => void) | null>(null) + const runningTaskIdRef = useRef(null) + const tasksRef = useRef([]) + const loadingMetricsRef = useRef>(new Set()) + const preselectAppliedRef = useRef(false) + + useEffect(() => { + tasksRef.current = tasks + }, [tasks]) + + const preselectSessionIds = useMemo(() => { + const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null + const rawList = Array.isArray(state?.preselectSessionIds) + ? state?.preselectSessionIds + : (typeof state?.preselectSessionId === 'string' ? [state.preselectSessionId] : []) + + return rawList + .filter((item): item is string => typeof item === 'string') + .map(item => item.trim()) + .filter(Boolean) + }, [location.state]) + + useEffect(() => { + const timer = setInterval(() => setNowTick(Date.now()), 60 * 1000) + return () => clearInterval(timer) + }, []) + + const loadCurrentUser = useCallback(async () => { try { - const result = await window.electronAPI.chat.connect() - if (!result.success) { - console.error('连接失败:', result.error) - setIsLoading(false) - return + const wxid = await configService.getMyWxid() + let displayName = wxid || '未识别用户' + let avatarUrl: string | undefined + + if (wxid) { + const myContact = await window.electronAPI.chat.getContact(wxid) + const bestName = [myContact?.remark, myContact?.nickName, myContact?.alias, wxid].find(Boolean) + if (bestName) displayName = bestName } - const sessionsResult = await window.electronAPI.chat.getSessions() - if (sessionsResult.success && sessionsResult.sessions) { - setSessions(sessionsResult.sessions) - setFilteredSessions(sessionsResult.sessions) + + const avatarResult = await window.electronAPI.chat.getMyAvatarUrl() + if (avatarResult.success && avatarResult.avatarUrl) { + avatarUrl = avatarResult.avatarUrl } - } catch (e) { - console.error('加载会话失败:', e) - } finally { - setIsLoading(false) + + setCurrentUser({ wxid: wxid || '', displayName, avatarUrl }) + } catch (error) { + console.error('加载当前用户信息失败:', error) } }, []) - const loadExportPath = useCallback(async () => { + const loadBaseConfig = useCallback(async () => { try { - const savedPath = await configService.getExportPath() + const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap] = await Promise.all([ + configService.getExportPath(), + configService.getExportDefaultFormat(), + configService.getExportDefaultMedia(), + configService.getExportDefaultVoiceAsText(), + configService.getExportDefaultExcelCompactColumns(), + configService.getExportDefaultTxtColumns(), + configService.getExportDefaultConcurrency(), + configService.getExportWriteLayout(), + configService.getExportLastSessionRunMap(), + configService.getExportLastContentRunMap() + ]) + if (savedPath) { setExportFolder(savedPath) } else { const downloadsPath = await window.electronAPI.app.getDownloadsPath() setExportFolder(downloadsPath) } - } catch (e) { - console.error('加载导出路径失败:', e) + + setWriteLayout(savedWriteLayout) + setLastExportBySession(savedSessionMap) + setLastExportByContent(savedContentMap) + + const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns + setOptions(prev => ({ + ...prev, + format: (savedFormat as TextExportFormat) || prev.format, + exportMedia: savedMedia ?? prev.exportMedia, + exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, + excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, + txtColumns, + exportConcurrency: savedConcurrency ?? prev.exportConcurrency + })) + } catch (error) { + console.error('加载导出配置失败:', error) } }, []) - const loadExportDefaults = useCallback(async () => { + const loadSessions = useCallback(async () => { + setIsLoading(true) try { - const [ - savedFormat, - savedRange, - savedMedia, - savedVoiceAsText, - savedExcelCompactColumns, - savedTxtColumns, - savedConcurrency - ] = await Promise.all([ - configService.getExportDefaultFormat(), - configService.getExportDefaultDateRange(), - configService.getExportDefaultMedia(), - configService.getExportDefaultVoiceAsText(), - configService.getExportDefaultExcelCompactColumns(), - configService.getExportDefaultTxtColumns(), - configService.getExportDefaultConcurrency() + const connectResult = await window.electronAPI.chat.connect() + if (!connectResult.success) { + console.error('连接失败:', connectResult.error) + setIsLoading(false) + return + } + + const [sessionsResult, contactsResult, groupChatsResult] = await Promise.all([ + window.electronAPI.chat.getSessions(), + window.electronAPI.chat.getContacts(), + window.electronAPI.groupAnalytics.getGroupChats() ]) - const preset = savedRange || 'today' - const rangeDefaults = buildDateRangeFromPreset(preset) - const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns + const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] + const nextContactMap = contacts.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, {}) + setContactMap(nextContactMap) - setOptions((prev) => ({ - ...prev, - format: (savedFormat as ExportOptions['format']) || 'excel', - useAllTime: rangeDefaults.useAllTime, - dateRange: rangeDefaults.dateRange, - exportMedia: savedMedia ?? false, - exportVoiceAsText: savedVoiceAsText ?? false, - excelCompactColumns: savedExcelCompactColumns ?? true, - txtColumns, - exportConcurrency: savedConcurrency ?? 2 - })) - } catch (e) { - console.error('加载导出默认设置失败:', e) + const nextGroupMemberCountMap: Record = {} + if (groupChatsResult.success && groupChatsResult.data) { + for (const group of groupChatsResult.data) { + nextGroupMemberCountMap[group.username] = group.memberCount + } + } + setGroupMemberCountMap(nextGroupMemberCountMap) + + if (sessionsResult.success && sessionsResult.sessions) { + const nextSessions = sessionsResult.sessions + .map((session) => { + const contact = nextContactMap[session.username] + const kind = toKindByContactType(session, contact) + return { + ...session, + kind, + wechatId: contact?.username || session.username, + displayName: session.displayName || contact?.displayName || session.username, + avatarUrl: session.avatarUrl || contact?.avatarUrl + } as SessionRow + }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) + + setSessions(nextSessions) + } + } catch (error) { + console.error('加载会话失败:', error) + } finally { + setIsLoading(false) } }, []) useEffect(() => { + loadCurrentUser() + loadBaseConfig() loadSessions() - loadExportPath() - loadExportDefaults() - }, [loadSessions, loadExportPath, loadExportDefaults]) + }, [loadCurrentUser, loadBaseConfig, loadSessions]) useEffect(() => { preselectAppliedRef.current = false @@ -215,398 +449,757 @@ function ExportPage() { if (matched.length > 0) { setSelectedSessions(new Set(matched)) - setSearchKeyword('') } }, [sessions, preselectSessionIds]) - useEffect(() => { - const handleChange = () => { - setSelectedSessions(new Set()) - setSearchKeyword('') - setExportResult(null) - setSessions([]) - setFilteredSessions([]) - loadSessions() - } - window.addEventListener('wxid-changed', handleChange as EventListener) - return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadSessions]) - - useEffect(() => { - const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string; phaseProgress?: number; phaseTotal?: number; phaseLabel?: string }) => { - setExportProgress({ - current: payload.current, - total: payload.total, - currentName: payload.currentSession, - phaseLabel: payload.phaseLabel || '', - phaseProgress: payload.phaseProgress || 0, - phaseTotal: payload.phaseTotal || 0 - }) + const visibleSessions = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase() + return sessions.filter((session) => { + if (session.kind !== activeTab) return false + if (!keyword) return true + return ( + (session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + ) }) - return () => { - removeListener?.() + }, [sessions, activeTab, searchKeyword]) + + const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { + const pending = targetSessions.filter(session => !sessionMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) + if (pending.length === 0) return + + for (const session of pending) { + loadingMetricsRef.current.add(session.username) } - }, []) - // 导出计时器 - useEffect(() => { - if (!isExporting) return - const timer = setInterval(() => { - setElapsedSeconds(Math.floor((Date.now() - exportStartTime.current) / 1000)) - }, 1000) - return () => clearInterval(timer) - }, [isExporting]) + const updates: Record = {} - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node - if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) { - setShowDisplayNameSelect(false) - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showDisplayNameSelect]) + for (const session of pending) { + const metrics: SessionMetrics = {} + try { + const detailResult = await window.electronAPI.chat.getSessionDetail(session.username) + if (detailResult.success && detailResult.detail) { + metrics.totalMessages = detailResult.detail.messageCount + metrics.firstTimestamp = detailResult.detail.firstMessageTime + metrics.lastTimestamp = detailResult.detail.latestMessageTime + } - useEffect(() => { - if (!searchKeyword.trim()) { - setFilteredSessions(sessions) - return - } - const lower = searchKeyword.toLowerCase() - setFilteredSessions(sessions.filter(s => - s.displayName?.toLowerCase().includes(lower) || - s.username.toLowerCase().includes(lower) - )) - }, [searchKeyword, sessions]) - - const toggleSession = (username: string) => { - const newSet = new Set(selectedSessions) - if (newSet.has(username)) { - newSet.delete(username) - } else { - newSet.add(username) - } - setSelectedSessions(newSet) - } - - const toggleSelectAll = () => { - if (selectedSessions.size === filteredSessions.length) { - setSelectedSessions(new Set()) - } else { - setSelectedSessions(new Set(filteredSessions.map(s => s.username))) - } - } - - const getAvatarLetter = (name: string) => { - if (!name) return '?' - return [...name][0] || '?' - } - - const formatDate = (date: Date) => { - return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) - } - - const handleFormatChange = (format: ExportOptions['format']) => { - setOptions((prev) => { - const next = { ...prev, format } - if (format === 'html') { - return { - ...next, + const exportStats = await window.electronAPI.export.getExportStats([session.username], { + exportVoiceAsText: false, exportMedia: true, exportImages: true, exportVoices: true, exportVideos: true, - exportEmojis: true + exportEmojis: true, + dateRange: null + }) + metrics.voiceMessages = exportStats.voiceMessages + if (metrics.totalMessages === undefined) { + metrics.totalMessages = exportStats.totalMessages + } + + if (session.kind === 'group') { + metrics.groupMemberCount = groupMemberCountMap[session.username] + + const [mediaStatsResult, rankingResult] = await Promise.all([ + window.electronAPI.groupAnalytics.getGroupMediaStats(session.username), + window.electronAPI.groupAnalytics.getGroupMessageRanking(session.username) + ]) + + if (mediaStatsResult.success && mediaStatsResult.data?.typeCounts) { + for (const item of mediaStatsResult.data.typeCounts) { + const n = item.name.toLowerCase() + if (n.includes('图片')) metrics.imageMessages = item.count + if (n.includes('视频')) metrics.videoMessages = item.count + if (n.includes('语音')) metrics.voiceMessages = item.count + if (n.includes('表情')) metrics.emojiMessages = item.count + } + } + + if (rankingResult.success && rankingResult.data) { + metrics.groupActiveSpeakers = rankingResult.data.length + const selfWxid = session.selfWxid || currentUser.wxid + const me = rankingResult.data.find(item => item.member.username === selfWxid) + if (me) { + metrics.groupMyMessages = me.messageCount + } + } + } + } catch (error) { + console.error('加载会话统计失败:', session.username, error) + } finally { + loadingMetricsRef.current.delete(session.username) + } + + updates[session.username] = metrics + } + + if (Object.keys(updates).length > 0) { + setSessionMetrics(prev => ({ ...prev, ...updates })) + } + }, [sessionMetrics, groupMemberCountMap, currentUser.wxid]) + + useEffect(() => { + const targets = visibleSessions.slice(0, 40) + void ensureSessionMetrics(targets) + }, [visibleSessions, ensureSessionMetrics]) + + const selectedCount = selectedSessions.size + + const toggleSelectSession = (sessionId: string) => { + setSelectedSessions(prev => { + const next = new Set(prev) + if (next.has(sessionId)) { + next.delete(sessionId) + } else { + next.add(sessionId) + } + return next + }) + } + + const toggleSelectAllVisible = () => { + const visibleIds = visibleSessions.map(session => session.username) + if (visibleIds.length === 0) return + + setSelectedSessions(prev => { + const next = new Set(prev) + const allSelected = visibleIds.every(id => next.has(id)) + if (allSelected) { + for (const id of visibleIds) { + next.delete(id) + } + } else { + for (const id of visibleIds) { + next.add(id) } } return next }) } - const openExportFolder = async () => { - if (exportFolder) { - await window.electronAPI.shell.openPath(exportFolder) - } - } + const clearSelection = () => setSelectedSessions(new Set()) - const runExport = async (sessionLayout: SessionLayout) => { - if (selectedSessions.size === 0 || !exportFolder) return + const openExportDialog = (payload: Omit) => { + setExportDialog({ open: true, ...payload }) - setIsExporting(true) - setExportProgress({ current: 0, total: selectedSessions.size, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) - setExportResult(null) - exportStartTime.current = Date.now() - setElapsedSeconds(0) - - try { - const sessionList = Array.from(selectedSessions) - const exportOptions = { - format: options.format, - exportAvatars: options.exportAvatars, - exportMedia: options.exportMedia, - exportImages: options.exportMedia && options.exportImages, - exportVoices: options.exportMedia && options.exportVoices, - exportVideos: options.exportMedia && options.exportVideos, - exportEmojis: options.exportMedia && options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 - excelCompactColumns: options.excelCompactColumns, - txtColumns: options.txtColumns, - displayNamePreference: options.displayNamePreference, - exportConcurrency: options.exportConcurrency, - sessionLayout, - dateRange: options.useAllTime ? null : options.dateRange ? { - start: Math.floor(options.dateRange.start.getTime() / 1000), - // 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录 - end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) - } : null - } - - if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html' || options.format === 'weclone') { - const result = await window.electronAPI.export.exportSessions( - sessionList, - exportFolder, - exportOptions - ) - setExportResult(result) + if (payload.scope === 'content' && payload.contentType) { + if (payload.contentType === 'text') { + setOptions(prev => ({ ...prev, exportMedia: false })) } else { - setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` }) + setOptions(prev => ({ + ...prev, + exportMedia: true, + exportImages: payload.contentType === 'image', + exportVoices: payload.contentType === 'voice', + exportVideos: payload.contentType === 'video', + exportEmojis: payload.contentType === 'emoji' + })) } - } catch (e) { - console.error('导出过程中发生异常:', e) - setExportResult({ success: false, error: String(e) }) - } finally { - setIsExporting(false) } } - const startExport = async () => { - if (selectedSessions.size === 0 || !exportFolder) return - - // 先获取预估统计 - const requestId = ++statsRequestIdRef.current - setIsLoadingStats(true) - setPreExportStats(null) - setShowPreExportDialog(true) - try { - const sessionList = Array.from(selectedSessions) - const exportOptions = { - format: options.format, - exportVoiceAsText: options.exportVoiceAsText, - exportMedia: options.exportMedia, - exportImages: options.exportMedia && options.exportImages, - exportVoices: options.exportMedia && options.exportVoices, - exportVideos: options.exportMedia && options.exportVideos, - exportEmojis: options.exportMedia && options.exportEmojis, - dateRange: options.useAllTime ? null : options.dateRange ? { - start: Math.floor(options.dateRange.start.getTime() / 1000), - end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) - } : null - } - const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions) - if (statsRequestIdRef.current !== requestId) return - setPreExportStats(stats) - } catch (e) { - console.error('获取导出统计失败:', e) - if (statsRequestIdRef.current !== requestId) return - setPreExportStats(null) - } finally { - if (statsRequestIdRef.current !== requestId) return - setIsLoadingStats(false) - } + const closeExportDialog = () => { + setExportDialog(prev => ({ ...prev, open: false })) } - const confirmExport = () => { - statsRequestIdRef.current++ - setIsLoadingStats(false) - setShowPreExportDialog(false) - setPreExportStats(null) + const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => { + const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared' - if (options.exportMedia && selectedSessions.size > 1) { - setShowMediaLayoutPrompt(true) - return + const base: ElectronExportOptions = { + format: options.format, + exportAvatars: options.exportAvatars, + exportMedia: options.exportMedia, + exportImages: options.exportMedia && options.exportImages, + exportVoices: options.exportMedia && options.exportVoices, + exportVideos: options.exportMedia && options.exportVideos, + exportEmojis: options.exportMedia && options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText, + excelCompactColumns: options.excelCompactColumns, + txtColumns: options.txtColumns, + displayNamePreference: options.displayNamePreference, + exportConcurrency: options.exportConcurrency, + sessionLayout, + dateRange: options.useAllTime + ? null + : options.dateRange + ? { + start: Math.floor(options.dateRange.start.getTime() / 1000), + end: Math.floor(options.dateRange.end.getTime() / 1000) + } + : null } - const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared' - runExport(layout) - } - - const getDaysInMonth = (date: Date) => { - const year = date.getFullYear() - const month = date.getMonth() - return new Date(year, month + 1, 0).getDate() - } - - const getFirstDayOfMonth = (date: Date) => { - const year = date.getFullYear() - const month = date.getMonth() - return new Date(year, month, 1).getDay() - } - - const generateCalendar = () => { - const daysInMonth = getDaysInMonth(calendarDate) - const firstDay = getFirstDayOfMonth(calendarDate) - const days: (number | null)[] = [] - - for (let i = 0; i < firstDay; i++) { - days.push(null) - } - - for (let i = 1; i <= daysInMonth; i++) { - days.push(i) - } - - return days - } - - const handleDateSelect = (day: number) => { - const year = calendarDate.getFullYear() - const month = calendarDate.getMonth() - const selectedDate = new Date(year, month, day) - // 设置时间为当天的开始或结束 - selectedDate.setHours(selectingStart ? 0 : 23, selectingStart ? 0 : 59, selectingStart ? 0 : 59, selectingStart ? 0 : 999) - - const now = new Date() - // 如果选择的日期晚于当前时间,限制为当前时间 - if (selectedDate > now) { - selectedDate.setTime(now.getTime()) - } - - if (selectingStart) { - // 选择开始日期 - const currentEnd = options.dateRange?.end || new Date() - // 如果选择的开始日期晚于结束日期,则同时更新结束日期 - if (selectedDate > currentEnd) { - const newEnd = new Date(selectedDate) - newEnd.setHours(23, 59, 59, 999) - // 确保结束日期也不晚于当前时间 - if (newEnd > now) { - newEnd.setTime(now.getTime()) + if (scope === 'content' && contentType) { + if (contentType === 'text') { + return { + ...base, + exportMedia: false, + exportImages: false, + exportVoices: false, + exportVideos: false, + exportEmojis: false } - setOptions({ - ...options, - dateRange: { start: selectedDate, end: newEnd } - }) - } else { - setOptions({ - ...options, - dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() } - }) } - setSelectingStart(false) - } else { - // 选择结束日期 - const currentStart = options.dateRange?.start || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - // 如果选择的结束日期早于开始日期,则同时更新开始日期 - if (selectedDate < currentStart) { - const newStart = new Date(selectedDate) - newStart.setHours(0, 0, 0, 0) - setOptions({ - ...options, - dateRange: { start: newStart, end: selectedDate } - }) - } else { - setOptions({ - ...options, - dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate } - }) + + return { + ...base, + exportMedia: true, + exportImages: contentType === 'image', + exportVoices: contentType === 'voice', + exportVideos: contentType === 'video', + exportEmojis: contentType === 'emoji' } - setSelectingStart(true) } + + return base } - const formatOptions = [ - { value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' }, - { value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' }, - { value: 'json', label: 'JSON', icon: FileJson, desc: '详细格式,包含完整消息信息' }, - { value: 'html', label: 'HTML', icon: FileText, desc: '网页格式,可直接浏览' }, - { value: 'txt', label: 'TXT', icon: Table, desc: '纯文本,通用格式' }, - { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, - { value: 'weclone', label: 'WeClone CSV', icon: Table, desc: 'WeClone 兼容字段格式(CSV)' }, - { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } - ] - const displayNameOptions = [ - { - value: 'group-nickname', - label: '群昵称优先', - desc: '仅群聊有效,私聊显示备注/昵称' - }, - { - value: 'remark', - label: '备注优先', - desc: '有备注显示备注,否则显示昵称' - }, - { - value: 'nickname', - label: '微信昵称', - desc: '始终显示微信昵称' + const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => { + setLastExportBySession(prev => { + const next = { ...prev } + for (const id of sessionIds) { + next[id] = timestamp + } + void configService.setExportLastSessionRunMap(next) + return next + }) + }, []) + + const markContentExported = useCallback((sessionIds: string[], contentTypes: ContentType[], timestamp: number) => { + setLastExportByContent(prev => { + const next = { ...prev } + for (const id of sessionIds) { + for (const type of contentTypes) { + next[`${id}::${type}`] = timestamp + } + } + void configService.setExportLastContentRunMap(next) + return next + }) + }, []) + + const inferContentTypesFromOptions = (opts: ElectronExportOptions): ContentType[] => { + const types: ContentType[] = ['text'] + if (opts.exportMedia) { + if (opts.exportVoices) types.push('voice') + if (opts.exportImages) types.push('image') + if (opts.exportVideos) types.push('video') + if (opts.exportEmojis) types.push('emoji') } - ] - const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference) - const displayNameLabel = displayNameOption?.label || '备注优先' + return types + } + + const updateTask = useCallback((taskId: string, updater: (task: ExportTask) => ExportTask) => { + setTasks(prev => prev.map(task => (task.id === taskId ? updater(task) : task))) + }, []) + + const runNextTask = useCallback(async () => { + if (runningTaskIdRef.current) return + + const queue = [...tasksRef.current].reverse() + const next = queue.find(task => task.status === 'queued') + if (!next) return + + runningTaskIdRef.current = next.id + updateTask(next.id, task => ({ ...task, status: 'running', startedAt: Date.now() })) + + progressUnsubscribeRef.current?.() + progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => { + updateTask(next.id, task => ({ + ...task, + progress: { + current: payload.current, + total: payload.total, + currentName: payload.currentSession, + phaseLabel: payload.phaseLabel || '', + phaseProgress: payload.phaseProgress || 0, + phaseTotal: payload.phaseTotal || 0 + } + })) + }) + + try { + const result = await window.electronAPI.export.exportSessions( + next.payload.sessionIds, + next.payload.outputDir, + next.payload.options + ) + + if (!result.success) { + updateTask(next.id, task => ({ + ...task, + status: 'error', + finishedAt: Date.now(), + error: result.error || '导出失败' + })) + } else { + const doneAt = Date.now() + const contentTypes = next.payload.contentType + ? [next.payload.contentType] + : inferContentTypesFromOptions(next.payload.options) + + markSessionExported(next.payload.sessionIds, doneAt) + markContentExported(next.payload.sessionIds, contentTypes, doneAt) + + updateTask(next.id, task => ({ + ...task, + status: 'success', + finishedAt: doneAt, + progress: { + ...task.progress, + current: task.progress.total || next.payload.sessionIds.length, + total: task.progress.total || next.payload.sessionIds.length, + phaseLabel: '完成', + phaseProgress: 1, + phaseTotal: 1 + } + })) + } + } catch (error) { + updateTask(next.id, task => ({ + ...task, + status: 'error', + finishedAt: Date.now(), + error: String(error) + })) + } finally { + progressUnsubscribeRef.current?.() + progressUnsubscribeRef.current = null + runningTaskIdRef.current = null + void runNextTask() + } + }, [updateTask, markSessionExported, markContentExported]) + + useEffect(() => { + void runNextTask() + }, [tasks, runNextTask]) + + useEffect(() => { + return () => { + progressUnsubscribeRef.current?.() + progressUnsubscribeRef.current = null + } + }, []) + + const createTask = async () => { + if (!exportDialog.open || exportDialog.sessionIds.length === 0 || !exportFolder) return + + const exportOptions = buildExportOptions(exportDialog.scope, exportDialog.contentType) + const title = + exportDialog.scope === 'single' + ? `${exportDialog.sessionNames[0] || '会话'} 导出` + : exportDialog.scope === 'multi' + ? `批量导出(${exportDialog.sessionIds.length} 个会话)` + : `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出` + + const task: ExportTask = { + id: createTaskId(), + title, + status: 'queued', + createdAt: Date.now(), + payload: { + sessionIds: exportDialog.sessionIds, + sessionNames: exportDialog.sessionNames, + outputDir: exportFolder, + options: exportOptions, + scope: exportDialog.scope, + contentType: exportDialog.contentType + }, + progress: createEmptyProgress() + } + + setTasks(prev => [task, ...prev]) + closeExportDialog() + + await configService.setExportDefaultFormat(options.format) + await configService.setExportDefaultMedia(options.exportMedia) + await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText) + await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) + await configService.setExportDefaultTxtColumns(options.txtColumns) + await configService.setExportDefaultConcurrency(options.exportConcurrency) + } + + const openSingleExport = (session: SessionRow) => { + openExportDialog({ + scope: 'single', + sessionIds: [session.username], + sessionNames: [session.displayName || session.username], + title: `导出会话:${session.displayName || session.username}` + }) + } + + const openBatchExport = () => { + const ids = Array.from(selectedSessions) + if (ids.length === 0) return + const nameMap = new Map(sessions.map(session => [session.username, session.displayName || session.username])) + const names = ids.map(id => nameMap.get(id) || id) + + openExportDialog({ + scope: 'multi', + sessionIds: ids, + sessionNames: names, + title: `批量导出(${ids.length} 个会话)` + }) + } + + const openContentExport = (contentType: ContentType) => { + const ids = sessions + .filter(session => session.kind === 'private' || session.kind === 'group') + .map(session => session.username) + + const names = sessions + .filter(session => session.kind === 'private' || session.kind === 'group') + .map(session => session.displayName || session.username) + + openExportDialog({ + scope: 'content', + contentType, + sessionIds: ids, + sessionNames: names, + title: `${contentTypeLabels[contentType]}批量导出` + }) + } + + const runningSessionIds = useMemo(() => { + const set = new Set() + for (const task of tasks) { + if (task.status !== 'running') continue + for (const id of task.payload.sessionIds) { + set.add(id) + } + } + return set + }, [tasks]) + + const queuedSessionIds = useMemo(() => { + const set = new Set() + for (const task of tasks) { + if (task.status !== 'queued') continue + for (const id of task.payload.sessionIds) { + set.add(id) + } + } + return set + }, [tasks]) + + const contentCards = useMemo(() => { + const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') + const total = scopeSessions.length + + return [ + { type: 'text' as ContentType, icon: MessageSquareText }, + { type: 'voice' as ContentType, icon: Mic }, + { type: 'image' as ContentType, icon: ImageIcon }, + { type: 'video' as ContentType, icon: Video }, + { type: 'emoji' as ContentType, icon: WandSparkles } + ].map(item => { + let exported = 0 + for (const session of scopeSessions) { + if (lastExportByContent[`${session.username}::${item.type}`]) { + exported += 1 + } + } + + return { + ...item, + label: contentTypeLabels[item.type], + total, + exported + } + }) + }, [sessions, lastExportByContent]) + + const activeTabLabel = useMemo(() => { + if (activeTab === 'private') return '私聊' + if (activeTab === 'group') return '群聊' + return '公众号' + }, [activeTab]) + + const renderSessionName = (session: SessionRow) => { + return ( +
+
+ {session.avatarUrl ? : {getAvatarLetter(session.displayName || session.username)}} +
+
+
{session.displayName || session.username}
+
{session.wechatId || session.username}
+
+
+ ) + } + + const renderActionCell = (session: SessionRow) => { + const isRunning = runningSessionIds.has(session.username) + const isQueued = queuedSessionIds.has(session.username) + const recent = formatRecentExportTime(lastExportBySession[session.username], nowTick) + + return ( +
+ + {recent} +
+ ) + } + + const renderTableHeader = () => { + if (activeTab === 'private') { + return ( + + 选择 + 会话名(头像/昵称/微信号) + 总消息 + 语音 + 图片 + 视频 + 表情包 + 共同群聊数 + 最早时间 + 最新时间 + 操作 + + ) + } + + if (activeTab === 'group') { + return ( + + 选择 + 会话名(群头像/群名称/群ID) + 总消息 + 语音 + 图片 + 视频 + 表情包 + 我发的消息数 + 群人数 + 群发言人数 + 群共同好友数 + 最早时间 + 最新时间 + 操作 + + ) + } + + return ( + + 选择 + 会话名(头像/名称/微信号) + 总消息 + 语音 + 图片 + 视频 + 表情包 + 最早时间 + 最新时间 + 操作 + + ) + } + + const renderRow = (session: SessionRow) => { + const metrics = sessionMetrics[session.username] || {} + const checked = selectedSessions.has(session.username) + + return ( + + + + + + {renderSessionName(session)} + {valueOrDash(metrics.totalMessages)} + {valueOrDash(metrics.voiceMessages)} + {valueOrDash(metrics.imageMessages)} + {valueOrDash(metrics.videoMessages)} + {valueOrDash(metrics.emojiMessages)} + + {activeTab === 'private' && ( + <> + {valueOrDash(metrics.privateMutualGroups)} + {timestampOrDash(metrics.firstTimestamp)} + {timestampOrDash(metrics.lastTimestamp)} + + )} + + {activeTab === 'group' && ( + <> + {valueOrDash(metrics.groupMyMessages)} + {valueOrDash(metrics.groupMemberCount)} + {valueOrDash(metrics.groupActiveSpeakers)} + {valueOrDash(metrics.groupMutualFriends)} + {timestampOrDash(metrics.firstTimestamp)} + {timestampOrDash(metrics.lastTimestamp)} + + )} + + {activeTab === 'official' && ( + <> + {timestampOrDash(metrics.firstTimestamp)} + {timestampOrDash(metrics.lastTimestamp)} + + )} + + {renderActionCell(session)} + + ) + } + + const visibleSelectedCount = useMemo(() => { + const visibleSet = new Set(visibleSessions.map(session => session.username)) + let count = 0 + for (const id of selectedSessions) { + if (visibleSet.has(id)) count += 1 + } + return count + }, [visibleSessions, selectedSessions]) + + const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)' return ( -
-
-
-

选择会话

- -
- -
- - setSearchKeyword(e.target.value)} - /> - {searchKeyword && ( - - )} -
- -
- - 已选 {selectedSessions.size} 个 -
- - {isLoading ? ( -
- - 加载中... +
+
+
+
+ {currentUser.avatarUrl ? : {getAvatarLetter(currentUser.displayName)}}
- ) : filteredSessions.length === 0 ? ( -
- 暂无会话 +
+
{currentUser.displayName}
+
{currentUser.wxid || 'wxid 未识别'}
- ) : ( -
- {filteredSessions.map(session => ( -
toggleSession(session.username)} +
+ +
+
+ 导出位置 +
{exportFolder || '未设置'}
+
+ + +
+
+ +
+ 写入格式 + + {showWriteLayoutSelect && ( +
+ {writeLayoutOptions.map(option => ( + + ))} +
+ )} +
+
+
+ +
+ {contentCards.map(card => { + const Icon = card.icon + return ( +
+
+
{card.label}
+
+
+
+ 总会话数 + {card.total}
-
- {session.avatarUrl ? ( - - ) : ( - {getAvatarLetter(session.displayName || session.username)} +
+ 已导出会话数 + {card.exported} +
+
+ +
+ ) + })} +
+ +
+
任务中心
+ {tasks.length === 0 ? ( +
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
+ ) : ( +
+ {tasks.map(task => ( +
+
+
{task.title}
+
+ {task.status === 'queued' ? '排队中' : task.status === 'running' ? '进行中' : task.status === 'success' ? '已完成' : '失败'} + {new Date(task.createdAt).toLocaleString('zh-CN')} +
+ {task.status === 'running' && ( + <> +
+
0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} + /> +
+
+ {task.progress.current} / {task.progress.total || task.payload.sessionIds.length} + {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} +
+ )} + {task.status === 'error' &&
{task.error || '任务失败'}
}
-
-
{session.displayName || session.username}
-
{session.summary || '暂无消息'}
+
+
))} @@ -614,591 +1207,206 @@ function ExportPage() { )}
-
-
-

导出设置

-
- -
-
-

导出格式

-
- {formatOptions.map(fmt => ( -
handleFormatChange(fmt.value as ExportOptions['format'])} - > - - {fmt.label} - {fmt.desc} -
- ))} -
+
+
+
+ + +
-
-

时间范围

-

选择要导出的消息时间区间

-
-
-
- 导出全部时间 - 关闭此项以选择特定的起止日期 -
+
+
+ + setSearchKeyword(event.target.value)} + placeholder={`搜索${activeTabLabel}会话...`} + /> + {searchKeyword && ( + + )} +
+ + + + {selectedCount > 0 && ( +
+ 已选中 {selectedCount} 个会话 + + +
+ )} +
+
+ +
+ + {renderTableHeader()} + + {isLoading ? ( + + + + ) : visibleSessions.length === 0 ? ( + + + + ) : ( + visibleSessions.map(renderRow) + )} + +
+
加载中...
+
+
暂无会话
+
+
+
+ + {exportDialog.open && ( +
+
event.stopPropagation()}> +
+

{exportDialog.title}

+ +
+ +
+

导出范围

+
+ {exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' ? '多会话' : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']})`} + 共 {exportDialog.sessionIds.length} 个会话 +
+
+ {exportDialog.sessionNames.slice(0, 20).map(name => ( + {name} + ))} + {exportDialog.sessionNames.length > 20 && ... 还有 {exportDialog.sessionNames.length - 20} 个} +
+
+ +
+

对话文本导出格式选择

+
+ {formatOptions.map(option => ( + + ))} +
+
+ +
+

时间范围

+
+ 导出全部时间
{!options.useAllTime && options.dateRange && ( - <> -
-
setShowDatePicker(true)}> -
- - {formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)} -
- -
- +
+ + +
)}
-
- {/* 发送者名称显示偏好 */} - {(options.format === 'html' || options.format === 'json' || options.format === 'txt') && ( -
-

发送者名称显示

-

选择导出时优先显示的名称

-
- - {showDisplayNameSelect && ( -
- {displayNameOptions.map(option => ( - - ))} -
- )} -
-
- )} -
-

媒体文件

-

导出图片/语音/视频/表情并在记录内写入相对路径

-
-
-
- 导出媒体文件 - 会创建子文件夹并保存媒体资源 -
+
+

媒体与头像

+
+ 导出媒体文件
-
- - - -
- - - -
- - - -
- - - -
- - -
-
- -
-

头像

-

可选导出头像索引,关闭则不下载头像

-
-
-
- 导出头像 - 用于展示发送者头像,可能会读取或下载头像文件 -
- +
+ + + + + +
-
-
-

导出位置

-
- - {exportFolder || '未设置'} -
- -
-
- -
- -
-
- - {/* 媒体导出布局选择弹窗 */} - {showMediaLayoutPrompt && ( -
setShowMediaLayoutPrompt(false)}> -
e.stopPropagation()}> -

导出文件夹布局

-

检测到同时导出多个会话并包含媒体文件,请选择存放方式:

-
- - -
-
- -
-
-
- )} - - {/* 导出前预估弹窗 */} - {showPreExportDialog && ( -
-
e.stopPropagation()}> -

导出预估

- {isLoadingStats ? ( -
- - 正在统计消息,可直接点击“直接导出”跳过等待 -
- ) : preExportStats ? ( -
-
-
- 会话数 -
{selectedSessions.size}
-
-
- 总消息 -
{preExportStats.totalMessages.toLocaleString()}
-
- {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && ( - <> -
- 语音消息 -
{preExportStats.voiceMessages}
-
-
- 已有缓存 -
{preExportStats.cachedVoiceCount}
-
- - )} -
- {options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && ( -
- - {' '}需要转写 {preExportStats.needTranscribeCount} 条语音,预计耗时约 {preExportStats.estimatedSeconds > 60 - ? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟` - : `${preExportStats.estimatedSeconds} 秒` - } -
- )} - {options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && ( -
- - {' '}所有 {preExportStats.voiceMessages} 条语音已有转写缓存,无需重新转写 -
- )} -
- ) : ( -

统计信息获取失败,仍可继续导出

- )} -
- - -
-
-
- )} - - {/* 导出进度弹窗 */} - {isExporting && ( -
-
-
- -
-

正在导出

-

{exportProgress.currentName}

- {exportProgress.phaseLabel && ( -

- {exportProgress.phaseLabel} -

- )} - {exportProgress.phaseTotal > 0 && ( -
-
-
- )} -
-
0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }} - /> -
-

- {exportProgress.current} / {exportProgress.total} 个会话 - - {elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒` : `${elapsedSeconds}秒`}`} - -

-
-
- )} - - {/* 导出结果弹窗 */} - {exportResult && ( -
-
-
- {exportResult.success ? : } -
-

{exportResult.success ? '导出完成' : '导出失败'}

- {exportResult.success ? ( -

- 成功导出 {exportResult.successCount} 个会话 - {exportResult.failCount ? `,${exportResult.failCount} 个失败` : ''} -

- ) : ( -

{exportResult.error}

- )} -
- {exportResult.success && ( - - )} - -
-
-
- )} - - {/* 日期选择弹窗 */} - {showDatePicker && ( -
{ setShowDatePicker(false); setShowYearMonthPicker(false) }}> -
e.stopPropagation()}> -

选择时间范围

-

- 点击选择开始和结束日期,系统会自动调整确保时间顺序正确 -

-
- - - -
-
-
setSelectingStart(true)} - > - 开始日期 - - {options.dateRange?.start?.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit' - })} - -
- -
setSelectingStart(false)} - > - 结束日期 - - {options.dateRange?.end?.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit' - })} - -
-
-
-
- - setShowYearMonthPicker(!showYearMonthPicker)}> - {calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月 - - -
- {showYearMonthPicker ? ( -
-
- - {calendarDate.getFullYear()}年 - -
-
- {['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => ( - - ))} -
-
- ) : ( - <> -
- {['日', '一', '二', '三', '四', '五', '六'].map(day => ( -
{day}
+
+

发送者名称显示

+
+ {displayNameOptions.map(option => ( + ))}
-
- {generateCalendar().map((day, index) => { - if (day === null) { - return
- } - - const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day) - const isStart = options.dateRange?.start?.toDateString() === currentDate.toDateString() - const isEnd = options.dateRange?.end?.toDateString() === currentDate.toDateString() - const isInRange = options.dateRange?.start && options.dateRange?.end && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end - const today = new Date() - today.setHours(0, 0, 0, 0) - const isFuture = currentDate > today - - return ( -
!isFuture && handleDateSelect(day)} - style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }} - > - {day} -
- ) - })} -
- - )}
-
- - +
diff --git a/src/services/config.ts b/src/services/config.ts index 6b5ddc7..bb96231 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -32,6 +32,9 @@ export const CONFIG_KEYS = { EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns', EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency', + EXPORT_WRITE_LAYOUT: 'exportWriteLayout', + EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', + EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', // 安全 AUTH_ENABLED: 'authEnabled', @@ -386,6 +389,52 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise< await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency) } +export type ExportWriteLayout = 'A' | 'B' | 'C' + +export async function getExportWriteLayout(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_WRITE_LAYOUT) + if (value === 'A' || value === 'B' || value === 'C') return value + return 'A' +} + +export async function setExportWriteLayout(layout: ExportWriteLayout): Promise { + await config.set(CONFIG_KEYS.EXPORT_WRITE_LAYOUT, layout) +} + +export async function getExportLastSessionRunMap(): Promise> { + const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP) + if (!value || typeof value !== 'object') return {} + const entries = Object.entries(value as Record) + const map: Record = {} + for (const [sessionId, raw] of entries) { + if (typeof raw === 'number' && Number.isFinite(raw)) { + map[sessionId] = raw + } + } + return map +} + +export async function setExportLastSessionRunMap(map: Record): Promise { + await config.set(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP, map) +} + +export async function getExportLastContentRunMap(): Promise> { + const value = await config.get(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP) + if (!value || typeof value !== 'object') return {} + const entries = Object.entries(value as Record) + const map: Record = {} + for (const [key, raw] of entries) { + if (typeof raw === 'number' && Number.isFinite(raw)) { + map[key] = raw + } + } + return map +} + +export async function setExportLastContentRunMap(map: Record): Promise { + await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map) +} + // === 安全相关 === export async function getAuthEnabled(): Promise { From e686bb624786c3ac70894030bdcf23ef7869df8a Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 14:55:19 +0800 Subject: [PATCH 002/155] feat(export): add batch session stats api for export board --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/chatService.ts | 340 +++++++++++++++++++++++++++++++ src/pages/ExportPage.tsx | 100 +++------ src/types/electron.d.ts | 18 ++ 5 files changed, 392 insertions(+), 71 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index f686c4b..af89f08 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -974,6 +974,10 @@ function registerIpcHandlers() { return chatService.getSessionDetail(sessionId) }) + ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[]) => { + return chatService.getExportSessionStats(sessionIds) + }) + ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => { return chatService.getImageData(sessionId, msgId) }) diff --git a/electron/preload.ts b/electron/preload.ts index dd087bb..99aceff 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -151,6 +151,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), close: () => ipcRenderer.invoke('chat:close'), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), + getExportSessionStats: (sessionIds: string[]) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e188de8..171ac0b 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -136,9 +136,25 @@ export interface ContactInfo { type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' } +interface ExportSessionStats { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() +const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) class ChatService { private configService: ConfigService @@ -1210,6 +1226,228 @@ class ChatService { return Number.isFinite(parsed) ? parsed : NaN } + private buildIdentityKeys(raw: string): string[] { + const value = String(raw || '').trim() + if (!value) return [] + const lowerRaw = value.toLowerCase() + const cleaned = this.cleanAccountDirName(value).toLowerCase() + if (cleaned && cleaned !== lowerRaw) { + return [cleaned, lowerRaw] + } + return [lowerRaw] + } + + private extractGroupMemberUsername(member: any): string { + if (!member) return '' + if (typeof member === 'string') return member.trim() + return String( + member.username || + member.userName || + member.user_name || + member.encryptUsername || + member.encryptUserName || + member.encrypt_username || + member.originalName || + '' + ).trim() + } + + private async getFriendIdentitySet(): Promise> { + const identities = new Set() + const contactResult = await wcdbService.execQuery( + 'contact', + null, + 'SELECT username, local_type, quan_pin FROM contact' + ) + if (!contactResult.success || !contactResult.rows) { + return identities + } + + for (const rowAny of contactResult.rows) { + const row = rowAny as Record + const username = String(row.username || '').trim() + if (!username || username.includes('@chatroom') || username.startsWith('gh_')) continue + if (FRIEND_EXCLUDE_USERNAMES.has(username)) continue + + const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) + if (localType !== 1) continue + + for (const key of this.buildIdentityKeys(username)) { + identities.add(key) + } + } + return identities + } + + private async forEachWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise + ): Promise { + if (items.length === 0) return + const concurrency = Math.max(1, Math.min(limit, items.length)) + let index = 0 + + const runners = Array.from({ length: concurrency }, async () => { + while (true) { + const current = index + index += 1 + if (current >= items.length) return + await worker(items[current]) + } + }) + + await Promise.all(runners) + } + + private async collectSessionExportStats( + sessionId: string, + selfIdentitySet: Set + ): Promise { + const stats: ExportSessionStats = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0 + } + if (sessionId.endsWith('@chatroom')) { + stats.groupMyMessages = 0 + stats.groupActiveSpeakers = 0 + } + + const senderIdentities = new Set() + const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return stats + } + + const cursor = cursorResult.cursor + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + break + } + + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + for (const row of rows) { + stats.totalMessages += 1 + + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + if (localType === 34) stats.voiceMessages += 1 + if (localType === 3) stats.imageMessages += 1 + if (localType === 43) stats.videoMessages += 1 + if (localType === 47) stats.emojiMessages += 1 + + const createTime = this.getRowInt( + row, + ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], + 0 + ) + if (createTime > 0) { + if (stats.firstTimestamp === undefined || createTime < stats.firstTimestamp) { + stats.firstTimestamp = createTime + } + if (stats.lastTimestamp === undefined || createTime > stats.lastTimestamp) { + stats.lastTimestamp = createTime + } + } + + if (sessionId.endsWith('@chatroom')) { + const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim() + const senderKeys = this.buildIdentityKeys(sender) + if (senderKeys.length > 0) { + senderIdentities.add(senderKeys[0]) + if (senderKeys.some((key) => selfIdentitySet.has(key))) { + stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 + } + } else { + const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])) + if (Number.isFinite(isSend) && isSend === 1) { + stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 + } + } + } + } + + if (!batch.hasMore || rows.length === 0) { + break + } + } + } finally { + await wcdbService.closeMessageCursor(cursor) + } + + if (sessionId.endsWith('@chatroom')) { + stats.groupActiveSpeakers = senderIdentities.size + } + return stats + } + + private async buildGroupRelationStats( + groupSessionIds: string[], + privateSessionIds: string[], + selfIdentitySet: Set + ): Promise<{ + privateMutualGroupMap: Record + groupMutualFriendMap: Record + }> { + const privateMutualGroupMap: Record = {} + const groupMutualFriendMap: Record = {} + if (groupSessionIds.length === 0) { + return { privateMutualGroupMap, groupMutualFriendMap } + } + + const privateIndex = new Map>() + for (const sessionId of privateSessionIds) { + for (const key of this.buildIdentityKeys(sessionId)) { + const set = privateIndex.get(key) || new Set() + set.add(sessionId) + privateIndex.set(key, set) + } + privateMutualGroupMap[sessionId] = 0 + } + + const friendIdentitySet = await this.getFriendIdentitySet() + await this.forEachWithConcurrency(groupSessionIds, 4, async (groupId) => { + const membersResult = await wcdbService.getGroupMembers(groupId) + if (!membersResult.success || !membersResult.members) { + groupMutualFriendMap[groupId] = 0 + return + } + + const touchedPrivateSessions = new Set() + const friendMembers = new Set() + + for (const member of membersResult.members) { + const username = this.extractGroupMemberUsername(member) + const identityKeys = this.buildIdentityKeys(username) + if (identityKeys.length === 0) continue + const canonical = identityKeys[0] + + if (!selfIdentitySet.has(canonical) && friendIdentitySet.has(canonical)) { + friendMembers.add(canonical) + } + + for (const key of identityKeys) { + const linked = privateIndex.get(key) + if (!linked) continue + for (const sessionId of linked) { + touchedPrivateSessions.add(sessionId) + } + } + } + + groupMutualFriendMap[groupId] = friendMembers.size + for (const sessionId of touchedPrivateSessions) { + privateMutualGroupMap[sessionId] = (privateMutualGroupMap[sessionId] || 0) + 1 + } + }) + + return { privateMutualGroupMap, groupMutualFriendMap } + } + /** * HTTP API 复用消息解析逻辑,确保和应用内展示一致。 */ @@ -3407,6 +3645,108 @@ class ChatService { return { success: false, error: String(e) } } } + + async getExportSessionStats(sessionIds: string[]): Promise<{ + success: boolean + data?: Record + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedSessionIds.length === 0) { + return { success: true, data: {} } + } + + const myWxid = this.configService.get('myWxid') || '' + const selfIdentitySet = new Set(this.buildIdentityKeys(myWxid)) + + const resultMap: Record = {} + await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => { + try { + resultMap[sessionId] = await this.collectSessionExportStats(sessionId, selfIdentitySet) + } catch { + resultMap[sessionId] = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0 + } + } + }) + + const groupSessionIds = normalizedSessionIds.filter((id) => id.endsWith('@chatroom')) + const privateSessionIds = normalizedSessionIds.filter((id) => !id.endsWith('@chatroom')) + + for (const privateId of privateSessionIds) { + resultMap[privateId] = { + ...resultMap[privateId], + privateMutualGroups: resultMap[privateId]?.privateMutualGroups ?? 0 + } + } + for (const groupId of groupSessionIds) { + resultMap[groupId] = { + ...resultMap[groupId], + groupMyMessages: resultMap[groupId]?.groupMyMessages ?? 0, + groupActiveSpeakers: resultMap[groupId]?.groupActiveSpeakers ?? 0, + groupMemberCount: resultMap[groupId]?.groupMemberCount ?? 0, + groupMutualFriends: resultMap[groupId]?.groupMutualFriends ?? 0 + } + } + + if (groupSessionIds.length > 0) { + const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds) + const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} + for (const groupId of groupSessionIds) { + resultMap[groupId] = { + ...resultMap[groupId], + groupMemberCount: typeof memberCountMap[groupId] === 'number' ? memberCountMap[groupId] : 0 + } + } + } + + if (groupSessionIds.length > 0) { + try { + const { privateMutualGroupMap, groupMutualFriendMap } = await this.buildGroupRelationStats( + groupSessionIds, + privateSessionIds, + selfIdentitySet + ) + + for (const privateId of privateSessionIds) { + resultMap[privateId] = { + ...resultMap[privateId], + privateMutualGroups: privateMutualGroupMap[privateId] || 0 + } + } + for (const groupId of groupSessionIds) { + resultMap[groupId] = { + ...resultMap[groupId], + groupMutualFriends: groupMutualFriendMap[groupId] || 0 + } + } + } catch { + // 群成员关系统计失败时保留默认值,避免影响主列表展示 + } + } + + return { success: true, data: resultMap } + } catch (e) { + console.error('ChatService: 获取导出会话统计失败:', e) + return { success: false, error: String(e) } + } + } /** * 获取图片数据(解密后的) */ diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 6855b60..52dcc6f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -237,8 +237,6 @@ function ExportPage() { const [isLoading, setIsLoading] = useState(true) const [sessions, setSessions] = useState([]) - const [contactMap, setContactMap] = useState>({}) - const [groupMemberCountMap, setGroupMemberCountMap] = useState>({}) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') @@ -384,10 +382,9 @@ function ExportPage() { return } - const [sessionsResult, contactsResult, groupChatsResult] = await Promise.all([ + const [sessionsResult, contactsResult] = await Promise.all([ window.electronAPI.chat.getSessions(), - window.electronAPI.chat.getContacts(), - window.electronAPI.groupAnalytics.getGroupChats() + window.electronAPI.chat.getContacts() ]) const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] @@ -395,15 +392,6 @@ function ExportPage() { map[contact.username] = contact return map }, {}) - setContactMap(nextContactMap) - - const nextGroupMemberCountMap: Record = {} - if (groupChatsResult.success && groupChatsResult.data) { - for (const group of groupChatsResult.data) { - nextGroupMemberCountMap[group.username] = group.memberCount - } - } - setGroupMemberCountMap(nextGroupMemberCountMap) if (sessionsResult.success && sessionsResult.sessions) { const nextSessions = sessionsResult.sessions @@ -468,76 +456,46 @@ function ExportPage() { const pending = targetSessions.filter(session => !sessionMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) if (pending.length === 0) return + const updates: Record = {} for (const session of pending) { loadingMetricsRef.current.add(session.username) + updates[session.username] = {} } - const updates: Record = {} - - for (const session of pending) { - const metrics: SessionMetrics = {} - try { - const detailResult = await window.electronAPI.chat.getSessionDetail(session.username) - if (detailResult.success && detailResult.detail) { - metrics.totalMessages = detailResult.detail.messageCount - metrics.firstTimestamp = detailResult.detail.firstMessageTime - metrics.lastTimestamp = detailResult.detail.latestMessageTime - } - - const exportStats = await window.electronAPI.export.getExportStats([session.username], { - exportVoiceAsText: false, - exportMedia: true, - exportImages: true, - exportVoices: true, - exportVideos: true, - exportEmojis: true, - dateRange: null - }) - metrics.voiceMessages = exportStats.voiceMessages - if (metrics.totalMessages === undefined) { - metrics.totalMessages = exportStats.totalMessages - } - - if (session.kind === 'group') { - metrics.groupMemberCount = groupMemberCountMap[session.username] - - const [mediaStatsResult, rankingResult] = await Promise.all([ - window.electronAPI.groupAnalytics.getGroupMediaStats(session.username), - window.electronAPI.groupAnalytics.getGroupMessageRanking(session.username) - ]) - - if (mediaStatsResult.success && mediaStatsResult.data?.typeCounts) { - for (const item of mediaStatsResult.data.typeCounts) { - const n = item.name.toLowerCase() - if (n.includes('图片')) metrics.imageMessages = item.count - if (n.includes('视频')) metrics.videoMessages = item.count - if (n.includes('语音')) metrics.voiceMessages = item.count - if (n.includes('表情')) metrics.emojiMessages = item.count - } - } - - if (rankingResult.success && rankingResult.data) { - metrics.groupActiveSpeakers = rankingResult.data.length - const selfWxid = session.selfWxid || currentUser.wxid - const me = rankingResult.data.find(item => item.member.username === selfWxid) - if (me) { - metrics.groupMyMessages = me.messageCount - } + try { + const statsResult = await window.electronAPI.chat.getExportSessionStats(pending.map(session => session.username)) + if (statsResult.success && statsResult.data) { + for (const session of pending) { + const raw = statsResult.data[session.username] + if (!raw) continue + updates[session.username] = { + totalMessages: raw.totalMessages, + voiceMessages: raw.voiceMessages, + imageMessages: raw.imageMessages, + videoMessages: raw.videoMessages, + emojiMessages: raw.emojiMessages, + privateMutualGroups: raw.privateMutualGroups, + groupMemberCount: raw.groupMemberCount, + groupMyMessages: raw.groupMyMessages, + groupActiveSpeakers: raw.groupActiveSpeakers, + groupMutualFriends: raw.groupMutualFriends, + firstTimestamp: raw.firstTimestamp, + lastTimestamp: raw.lastTimestamp } } - } catch (error) { - console.error('加载会话统计失败:', session.username, error) - } finally { + } + } catch (error) { + console.error('加载会话统计失败:', error) + } finally { + for (const session of pending) { loadingMetricsRef.current.delete(session.username) } - - updates[session.username] = metrics } if (Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } - }, [sessionMetrics, groupMemberCountMap, currentUser.wxid]) + }, [sessionMetrics]) useEffect(() => { const targets = visibleSessions.slice(0, 40) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 45116aa..ff6a293 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -124,6 +124,24 @@ export interface ElectronAPI { } error?: string }> + getExportSessionStats: (sessionIds: string[]) => Promise<{ + success: boolean + data?: Record + error?: string + }> getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }> getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }> From 596baad2969d00f185ce18308b53393acf3f8888 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 15:20:08 +0800 Subject: [PATCH 003/155] feat(export): add sns stats card and conversation tab updates --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/snsService.ts | 40 ++++ src/components/Sidebar.scss | 70 +++++- src/components/Sidebar.tsx | 63 +++++ src/pages/ExportPage.scss | 74 +----- src/pages/ExportPage.tsx | 412 ++++++++++++++++++++++---------- src/services/config.ts | 14 ++ src/types/electron.d.ts | 1 + 9 files changed, 491 insertions(+), 188 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index af89f08..0a47a22 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1020,6 +1020,10 @@ function registerIpcHandlers() { return snsService.getSnsUsernames() }) + ipcMain.handle('sns:getExportStats', async () => { + return snsService.getExportStats() + }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { return snsService.debugResource(url) }) diff --git a/electron/preload.ts b/electron/preload.ts index 99aceff..49c3126 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -288,6 +288,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), + getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 835850f..b9f43c2 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -235,6 +235,13 @@ class SnsService { this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string) } + private parseCountValue(row: any): number { + if (!row || typeof row !== 'object') return 0 + const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0] + const num = Number(raw) + return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 + } + private parseLikesFromXml(xml: string): string[] { if (!xml) return [] const likes: string[] = [] @@ -359,6 +366,39 @@ class SnsService { return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } } + async getExportStats(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { + try { + let totalPosts = 0 + const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') + if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { + totalPosts = this.parseCountValue(postCountResult.rows[0]) + } + + let totalFriends = 0 + const friendCountPrimary = await wcdbService.execQuery( + 'sns', + null, + "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" + ) + if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) + } else { + const friendCountFallback = await wcdbService.execQuery( + 'sns', + null, + "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" + ) + if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + } + } + + return { success: true, data: { totalPosts, totalFriends } } + } catch (e) { + return { success: false, error: String(e) } + } + } + // 安装朋友圈删除拦截 async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { return wcdbService.installSnsBlockDeleteTrigger() diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index d2a1b7f..70781e7 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -10,6 +10,16 @@ &.collapsed { width: 64px; + .sidebar-user-card { + margin: 0 8px 8px; + padding: 8px 0; + justify-content: center; + + .user-meta { + display: none; + } + } + .nav-menu, .sidebar-footer { padding: 0 8px; @@ -27,6 +37,64 @@ } } +.sidebar-user-card { + margin: 0 12px 10px; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-secondary); + display: flex; + align-items: center; + gap: 10px; + min-height: 56px; + + .user-avatar { + width: 36px; + height: 36px; + border-radius: 10px; + overflow: hidden; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .user-meta { + min-width: 0; + } + + .user-name { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .user-wxid { + margin-top: 2px; + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + .nav-menu { flex: 1; display: flex; @@ -130,4 +198,4 @@ background: rgba(209, 158, 187, 0.15); color: #D19EBB; border: 1px solid rgba(209, 158, 187, 0.2); -} \ No newline at end of file +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0085b6d..2effba3 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,19 +2,69 @@ import { useState, useEffect } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react' import { useAppStore } from '../stores/appStore' +import * as configService from '../services/config' import './Sidebar.scss' +interface SidebarUserProfile { + wxid: string + displayName: string + avatarUrl?: string +} + function Sidebar() { const location = useLocation() const [collapsed, setCollapsed] = useState(false) const [authEnabled, setAuthEnabled] = useState(false) + const [userProfile, setUserProfile] = useState({ + wxid: '', + displayName: '未识别用户' + }) const setLocked = useAppStore(state => state.setLocked) useEffect(() => { window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) }, []) + useEffect(() => { + const loadCurrentUser = async () => { + try { + const wxid = await configService.getMyWxid() + let displayName = wxid || '未识别用户' + + if (wxid) { + const myContact = await window.electronAPI.chat.getContact(wxid) + const bestName = [myContact?.remark, myContact?.nickName, myContact?.alias, wxid].find(Boolean) + if (bestName) displayName = bestName + } + + let avatarUrl: string | undefined + const avatarResult = await window.electronAPI.chat.getMyAvatarUrl() + if (avatarResult.success && avatarResult.avatarUrl) { + avatarUrl = avatarResult.avatarUrl + } + + setUserProfile({ + wxid: wxid || '', + displayName, + avatarUrl + }) + } catch (error) { + console.error('加载侧边栏用户信息失败:', error) + } + } + + void loadCurrentUser() + const onWxidChanged = () => { void loadCurrentUser() } + window.addEventListener('wxid-changed', onWxidChanged as EventListener) + return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener) + }, []) + + const getAvatarLetter = (name: string): string => { + if (!name) return '?' + return [...name][0] || '?' + } + const isActive = (path: string) => { return location.pathname === path || location.pathname.startsWith(`${path}/`) } @@ -106,6 +156,19 @@ function Sidebar() {
+
+
+ {userProfile.avatarUrl ? : {getAvatarLetter(userProfile.displayName)}} +
+
+
{userProfile.displayName}
+
{userProfile.wxid || 'wxid 未识别'}
+
+
+ {authEnabled && ( @@ -1109,16 +1251,25 @@ function ExportPage() {
{card.label}
-
- 总会话数 - {card.total} -
-
- 已导出会话数 - {card.exported} -
+ {card.stats.map((stat) => ( +
+ {stat.label} + {stat.value.toLocaleString()} +
+ ))}
- +
) })} @@ -1147,7 +1298,9 @@ function ExportPage() { />
- {task.progress.current} / {task.progress.total || task.payload.sessionIds.length} + {task.progress.total > 0 + ? `${task.progress.current} / ${task.progress.total}` + : '处理中'} {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
@@ -1168,9 +1321,18 @@ function ExportPage() {
- - - + + + +
@@ -1210,13 +1372,13 @@ function ExportPage() { {isLoading ? ( - +
加载中...
) : visibleSessions.length === 0 ? ( - +
暂无会话
@@ -1239,8 +1401,8 @@ function ExportPage() {

导出范围

- {exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' ? '多会话' : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']})`} - 共 {exportDialog.sessionIds.length} 个会话 + {scopeLabel} + {scopeCountLabel}
{exportDialog.sessionNames.slice(0, 20).map(name => ( @@ -1253,7 +1415,7 @@ function ExportPage() {

对话文本导出格式选择

- {formatOptions.map(option => ( + {formatCandidateOptions.map(option => ( -
diff --git a/src/services/config.ts b/src/services/config.ts index bb96231..7927939 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -35,6 +35,7 @@ export const CONFIG_KEYS = { EXPORT_WRITE_LAYOUT: 'exportWriteLayout', EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', + EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount', // 安全 AUTH_ENABLED: 'authEnabled', @@ -435,6 +436,19 @@ export async function setExportLastContentRunMap(map: Record): P await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map) } +export async function getExportLastSnsPostCount(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT) + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return Math.floor(value) + } + return 0 +} + +export async function setExportLastSnsPostCount(count: number): Promise { + const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0 + await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized) +} + // === 安全相关 === export async function getAuthEnabled(): Promise { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index ff6a293..dfa82e3 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -539,6 +539,7 @@ export interface ElectronAPI { onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> + getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> From 0444ca143e4b3e5df0de3662c424f780b5c83107 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 15:53:01 +0800 Subject: [PATCH 004/155] fix(export): correct profile name, sns stats, avatars and sorting --- electron/services/snsService.ts | 77 +++++++++++++++++++++++++++------ src/components/Sidebar.tsx | 33 ++++++++++++-- src/pages/ExportPage.tsx | 63 +++++++++++++++++++++++---- 3 files changed, 148 insertions(+), 25 deletions(-) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index b9f43c2..9484cdb 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -242,6 +242,43 @@ class SnsService { return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 } + private pickTimelineUsername(post: any): string { + const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' + if (typeof raw !== 'string') return '' + return raw.trim() + } + + private async getExportStatsFromTimeline(): Promise<{ totalPosts: number; totalFriends: number }> { + const pageSize = 500 + const uniqueUsers = new Set() + let totalPosts = 0 + let offset = 0 + + for (let round = 0; round < 2000; round++) { + const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) + if (!result.success || !Array.isArray(result.timeline)) { + throw new Error(result.error || '获取朋友圈统计失败') + } + + const rows = result.timeline + if (rows.length === 0) break + + totalPosts += rows.length + for (const row of rows) { + const username = this.pickTimelineUsername(row) + if (username) uniqueUsers.add(username) + } + + if (rows.length < pageSize) break + offset += rows.length + } + + return { + totalPosts, + totalFriends: uniqueUsers.size + } + } + private parseLikesFromXml(xml: string): string[] { if (!xml) return [] const likes: string[] = [] @@ -369,27 +406,41 @@ class SnsService { async getExportStats(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { try { let totalPosts = 0 + let totalFriends = 0 + const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { totalPosts = this.parseCountValue(postCountResult.rows[0]) } - let totalFriends = 0 - const friendCountPrimary = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" - ) - if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) - } else { - const friendCountFallback = await wcdbService.execQuery( + if (totalPosts > 0) { + const friendCountPrimary = await wcdbService.execQuery( 'sns', null, - "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" + "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" ) - if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) + } else { + const friendCountFallback = await wcdbService.execQuery( + 'sns', + null, + "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" + ) + if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + } + } + } + + // 某些环境下 SnsTimeLine 统计查询会返回 0,这里回退到与导出同源的 timeline 接口统计。 + if (totalPosts <= 0 || totalFriends <= 0) { + const timelineStats = await this.getExportStatsFromTimeline() + if (timelineStats.totalPosts > 0) { + totalPosts = timelineStats.totalPosts + } + if (timelineStats.totalFriends > 0) { + totalFriends = timelineStats.totalFriends } } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2effba3..b1478e1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -32,10 +32,37 @@ function Sidebar() { const wxid = await configService.getMyWxid() let displayName = wxid || '未识别用户' + const normalizeName = (value?: string | null): string | undefined => { + if (!value) return undefined + const trimmed = value.trim() + if (!trimmed || trimmed.toLowerCase() === 'self') return undefined + return trimmed + } + + let enrichedDisplayName: string | undefined + let fallbackSelfName: string | undefined + if (wxid) { - const myContact = await window.electronAPI.chat.getContact(wxid) - const bestName = [myContact?.remark, myContact?.nickName, myContact?.alias, wxid].find(Boolean) - if (bestName) displayName = bestName + const [myContact, enrichedResult] = await Promise.all([ + window.electronAPI.chat.getContact(wxid), + window.electronAPI.chat.enrichSessionsContactInfo([wxid, 'self']) + ]) + + enrichedDisplayName = normalizeName(enrichedResult.contacts?.[wxid]?.displayName) + fallbackSelfName = normalizeName(enrichedResult.contacts?.self?.displayName) + + const bestName = + normalizeName(myContact?.remark) || + normalizeName(myContact?.nickName) || + normalizeName(myContact?.alias) || + enrichedDisplayName || + fallbackSelfName + + if (bestName) { + displayName = bestName + } else if (fallbackSelfName && fallbackSelfName !== wxid) { + displayName = fallbackSelfName + } } let avatarUrl: string | undefined diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 7f34983..76cc77d 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -177,7 +177,7 @@ const formatAbsoluteDate = (timestamp: number): string => { } const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { - if (!timestamp) return '未导出' + if (!timestamp) return '' const diff = Math.max(0, now - timestamp) const minute = 60 * 1000 const hour = 60 * minute @@ -290,6 +290,7 @@ function ExportPage() { const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) + const sessionMetricsRef = useRef>({}) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) @@ -297,6 +298,10 @@ function ExportPage() { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + sessionMetricsRef.current = sessionMetrics + }, [sessionMetrics]) + const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const rawList = Array.isArray(state?.preselectSessionIds) @@ -393,7 +398,7 @@ function ExportPage() { }, {}) if (sessionsResult.success && sessionsResult.sessions) { - const nextSessions = sessionsResult.sessions + const baseSessions = sessionsResult.sessions .map((session) => { const contact = nextContactMap[session.username] const kind = toKindByContactType(session, contact) @@ -405,7 +410,29 @@ function ExportPage() { avatarUrl: session.avatarUrl || contact?.avatarUrl } as SessionRow }) - .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) + + const needsEnrichment = baseSessions + .filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) + .map(session => session.username) + + let nextSessions = baseSessions + if (needsEnrichment.length > 0) { + try { + const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) + if (enrichResult.success && enrichResult.contacts) { + nextSessions = baseSessions.map((session) => { + const extra = enrichResult.contacts?.[session.username] + return { + ...session, + displayName: extra?.displayName || session.displayName || session.username, + avatarUrl: extra?.avatarUrl || session.avatarUrl + } + }) + } + } catch (enrichError) { + console.error('导出页补充会话联系人信息失败:', enrichError) + } + } setSessions(nextSessions) } @@ -441,18 +468,31 @@ function ExportPage() { const visibleSessions = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase() - return sessions.filter((session) => { + return sessions + .filter((session) => { if (session.kind !== activeTab) return false if (!keyword) return true return ( (session.displayName || '').toLowerCase().includes(keyword) || session.username.toLowerCase().includes(keyword) ) - }) - }, [sessions, activeTab, searchKeyword]) + }) + .sort((a, b) => { + const totalA = sessionMetrics[a.username]?.totalMessages ?? 0 + const totalB = sessionMetrics[b.username]?.totalMessages ?? 0 + if (totalB !== totalA) { + return totalB - totalA + } + + const latestA = sessionMetrics[a.username]?.lastTimestamp ?? a.lastTimestamp ?? 0 + const latestB = sessionMetrics[b.username]?.lastTimestamp ?? b.lastTimestamp ?? 0 + return latestB - latestA + }) + }, [sessions, activeTab, searchKeyword, sessionMetrics]) const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { - const pending = targetSessions.filter(session => !sessionMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) + const currentMetrics = sessionMetricsRef.current + const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) if (pending.length === 0) return const updates: Record = {} @@ -494,13 +534,18 @@ function ExportPage() { if (Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } - }, [sessionMetrics]) + }, []) useEffect(() => { const targets = visibleSessions.slice(0, 40) void ensureSessionMetrics(targets) }, [visibleSessions, ensureSessionMetrics]) + useEffect(() => { + if (sessions.length === 0) return + void ensureSessionMetrics(sessions) + }, [sessions, ensureSessionMetrics]) + const selectedCount = selectedSessions.size const toggleSelectSession = (sessionId: string) => { @@ -1042,7 +1087,7 @@ function ExportPage() { ) : isQueued ? '排队中' : '导出'} - {recent} + {recent && {recent}}
) } From de7cbdf4943b80660f534b1b374556093be84c14 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:03:49 +0800 Subject: [PATCH 005/155] perf(sidebar): show cached user profile before async refresh --- src/components/Sidebar.tsx | 158 ++++++++++++++++++++++++++----------- 1 file changed, 114 insertions(+), 44 deletions(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b1478e1..a362ac7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -12,6 +12,42 @@ interface SidebarUserProfile { avatarUrl?: string } +const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' + +interface SidebarUserProfileCache extends SidebarUserProfile { + updatedAt: number +} + +const readSidebarUserProfileCache = (): SidebarUserProfile | null => { + try { + const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as SidebarUserProfileCache + if (!parsed || typeof parsed !== 'object') return null + if (!parsed.wxid || !parsed.displayName) return null + return { + wxid: parsed.wxid, + displayName: parsed.displayName, + avatarUrl: parsed.avatarUrl + } + } catch { + return null + } +} + +const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => { + if (!profile.wxid || !profile.displayName) return + try { + const payload: SidebarUserProfileCache = { + ...profile, + updatedAt: Date.now() + } + window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload)) + } catch { + // 忽略本地缓存失败,不影响主流程 + } +} + function Sidebar() { const location = useLocation() const [collapsed, setCollapsed] = useState(false) @@ -28,59 +64,93 @@ function Sidebar() { useEffect(() => { const loadCurrentUser = async () => { + const normalizeName = (value?: string | null): string | undefined => { + if (!value) return undefined + const trimmed = value.trim() + if (!trimmed || trimmed.toLowerCase() === 'self') return undefined + return trimmed + } + + const patchUserProfile = (patch: Partial, expectedWxid?: string) => { + setUserProfile(prev => { + if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) { + return prev + } + const next: SidebarUserProfile = { + ...prev, + ...patch + } + if (!next.displayName) { + next.displayName = next.wxid || '未识别用户' + } + writeSidebarUserProfileCache(next) + return next + }) + } + try { const wxid = await configService.getMyWxid() - let displayName = wxid || '未识别用户' + const resolvedWxid = wxid || '' + const fallbackDisplayName = resolvedWxid || '未识别用户' - const normalizeName = (value?: string | null): string | undefined => { - if (!value) return undefined - const trimmed = value.trim() - if (!trimmed || trimmed.toLowerCase() === 'self') return undefined - return trimmed - } - - let enrichedDisplayName: string | undefined - let fallbackSelfName: string | undefined - - if (wxid) { - const [myContact, enrichedResult] = await Promise.all([ - window.electronAPI.chat.getContact(wxid), - window.electronAPI.chat.enrichSessionsContactInfo([wxid, 'self']) - ]) - - enrichedDisplayName = normalizeName(enrichedResult.contacts?.[wxid]?.displayName) - fallbackSelfName = normalizeName(enrichedResult.contacts?.self?.displayName) - - const bestName = - normalizeName(myContact?.remark) || - normalizeName(myContact?.nickName) || - normalizeName(myContact?.alias) || - enrichedDisplayName || - fallbackSelfName - - if (bestName) { - displayName = bestName - } else if (fallbackSelfName && fallbackSelfName !== wxid) { - displayName = fallbackSelfName - } - } - - let avatarUrl: string | undefined - const avatarResult = await window.electronAPI.chat.getMyAvatarUrl() - if (avatarResult.success && avatarResult.avatarUrl) { - avatarUrl = avatarResult.avatarUrl - } - - setUserProfile({ - wxid: wxid || '', - displayName, - avatarUrl + // 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。 + patchUserProfile({ + wxid: resolvedWxid, + displayName: fallbackDisplayName }) + + if (!resolvedWxid) return + + // 第二阶段:后台补齐名称(不会阻塞首屏)。 + void (async () => { + try { + const myContact = await window.electronAPI.chat.getContact(resolvedWxid) + const fromContact = + normalizeName(myContact?.remark) || + normalizeName(myContact?.nickName) || + normalizeName(myContact?.alias) + + if (fromContact) { + patchUserProfile({ displayName: fromContact }, resolvedWxid) + return + } + + const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo([resolvedWxid, 'self']) + const enrichedDisplayName = normalizeName(enrichedResult.contacts?.[resolvedWxid]?.displayName) + const fallbackSelfName = normalizeName(enrichedResult.contacts?.self?.displayName) + const bestName = enrichedDisplayName || fallbackSelfName + if (bestName) { + patchUserProfile({ displayName: bestName }, resolvedWxid) + } + } catch (nameError) { + console.error('加载侧边栏用户昵称失败:', nameError) + } + })() + + // 第二阶段:后台补齐头像(不会阻塞首屏)。 + void (async () => { + try { + const avatarResult = await window.electronAPI.chat.getMyAvatarUrl() + if (avatarResult.success && avatarResult.avatarUrl) { + patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid) + } + } catch (avatarError) { + console.error('加载侧边栏用户头像失败:', avatarError) + } + })() } catch (error) { console.error('加载侧边栏用户信息失败:', error) } } + const cachedProfile = readSidebarUserProfileCache() + if (cachedProfile) { + setUserProfile(prev => ({ + ...prev, + ...cachedProfile + })) + } + void loadCurrentUser() const onWxidChanged = () => { void loadCurrentUser() } window.addEventListener('wxid-changed', onWxidChanged as EventListener) From b62c18fd84ab524fa5711f5ba10853c44079cac2 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:11:04 +0800 Subject: [PATCH 006/155] perf(export): phase-load sessions and add strong skeleton states --- src/pages/ExportPage.scss | 85 +++++++++++++++++++ src/pages/ExportPage.tsx | 172 ++++++++++++++++++++++++++++---------- 2 files changed, 211 insertions(+), 46 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index e6bfbaf..5f31d01 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -191,6 +191,14 @@ background: var(--primary-hover); } } + + &.skeleton-card { + pointer-events: none; + + .card-stats { + gap: 10px; + } + } } .task-center { @@ -332,6 +340,19 @@ overflow: hidden; } +.table-stage-hint { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(var(--primary-rgb), 0.1); + border: 1px solid rgba(var(--primary-rgb), 0.2); + color: var(--primary); + font-size: 12px; + width: fit-content; +} + .table-toolbar { display: flex; justify-content: space-between; @@ -589,6 +610,61 @@ color: var(--text-secondary); } +.table-skeleton-list { + display: grid; + gap: 8px; + padding: 4px 0; +} + +.table-skeleton-item { + display: grid; + grid-template-columns: 20px 36px minmax(160px, 2fr) repeat(3, minmax(80px, 1fr)); + align-items: center; + gap: 12px; + padding: 10px 8px; + border-radius: 8px; + background: color-mix(in srgb, var(--bg-secondary) 80%, transparent); +} + +.skeleton-shimmer { + position: relative; + overflow: hidden; + border-radius: 8px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.35) 50%, + rgba(255, 255, 255, 0.08) 100% + ); + background-size: 220% 100%; + animation: exportSkeletonShimmer 1.2s linear infinite; +} + +.skeleton-dot { + width: 16px; + height: 16px; + border-radius: 6px; +} + +.skeleton-avatar { + width: 36px; + height: 36px; + border-radius: 8px; +} + +.skeleton-line { + display: inline-block; + height: 12px; +} + +.skeleton-line.w-12 { width: 48%; min-width: 42px; } +.skeleton-line.w-20 { width: 22%; min-width: 36px; } +.skeleton-line.w-30 { width: 32%; min-width: 120px; } +.skeleton-line.w-40 { width: 45%; min-width: 80px; } +.skeleton-line.w-60 { width: 62%; min-width: 110px; } +.skeleton-line.w-100 { width: 100%; } +.skeleton-line.h-32 { height: 32px; border-radius: 10px; } + .export-dialog-overlay { position: fixed; inset: 0; @@ -867,6 +943,15 @@ } } +@keyframes exportSkeletonShimmer { + 0% { + background-position: 220% 0; + } + 100% { + background-position: -20% 0; + } +} + @media (max-width: 1360px) { .export-top-panel { grid-template-columns: 1fr; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 76cc77d..c7c2daf 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -239,6 +239,8 @@ function ExportPage() { const location = useLocation() const [isLoading, setIsLoading] = useState(true) + const [isSessionEnriching, setIsSessionEnriching] = useState(false) + const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [sessions, setSessions] = useState([]) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') @@ -291,6 +293,7 @@ function ExportPage() { const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) const sessionMetricsRef = useRef>({}) + const sessionLoadTokenRef = useRef(0) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) @@ -363,6 +366,7 @@ function ExportPage() { }, []) const loadSnsStats = useCallback(async () => { + setIsSnsStatsLoading(true) try { const result = await window.electronAPI.sns.getExportStats() if (result.success && result.data) { @@ -373,80 +377,122 @@ function ExportPage() { } } catch (error) { console.error('加载朋友圈导出统计失败:', error) + } finally { + setIsSnsStatsLoading(false) } }, []) const loadSessions = useCallback(async () => { + const loadToken = Date.now() + sessionLoadTokenRef.current = loadToken setIsLoading(true) + setIsSessionEnriching(false) + + const isStale = () => sessionLoadTokenRef.current !== loadToken + try { const connectResult = await window.electronAPI.chat.connect() if (!connectResult.success) { console.error('连接失败:', connectResult.error) - setIsLoading(false) + if (!isStale()) setIsLoading(false) return } - const [sessionsResult, contactsResult] = await Promise.all([ - window.electronAPI.chat.getSessions(), - window.electronAPI.chat.getContacts() - ]) - - const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] - const nextContactMap = contacts.reduce>((map, contact) => { - map[contact.username] = contact - return map - }, {}) + const sessionsResult = await window.electronAPI.chat.getSessions() + if (isStale()) return if (sessionsResult.success && sessionsResult.sessions) { const baseSessions = sessionsResult.sessions .map((session) => { - const contact = nextContactMap[session.username] - const kind = toKindByContactType(session, contact) return { ...session, - kind, - wechatId: contact?.username || session.username, - displayName: session.displayName || contact?.displayName || session.username, - avatarUrl: session.avatarUrl || contact?.avatarUrl + kind: toKindByContactType(session), + wechatId: session.username, + displayName: session.displayName || session.username, + avatarUrl: session.avatarUrl } as SessionRow }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) - const needsEnrichment = baseSessions - .filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) - .map(session => session.username) + if (isStale()) return + setSessions(baseSessions) + setIsLoading(false) - let nextSessions = baseSessions - if (needsEnrichment.length > 0) { + // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 + setIsSessionEnriching(true) + void (async () => { try { - const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) - if (enrichResult.success && enrichResult.contacts) { - nextSessions = baseSessions.map((session) => { - const extra = enrichResult.contacts?.[session.username] + const contactsResult = await window.electronAPI.chat.getContacts() + if (isStale()) return + + const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] + const nextContactMap = contacts.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, {}) + + const needsEnrichment = baseSessions + .filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) + .map(session => session.username) + + let extraContactMap: Record = {} + if (needsEnrichment.length > 0) { + const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) + if (enrichResult.success && enrichResult.contacts) { + extraContactMap = enrichResult.contacts + } + } + + if (isStale()) return + const nextSessions = baseSessions + .map((session) => { + const contact = nextContactMap[session.username] + const extra = extraContactMap[session.username] + const displayName = extra?.displayName || contact?.displayName || session.displayName || session.username + const avatarUrl = extra?.avatarUrl || session.avatarUrl || contact?.avatarUrl return { ...session, - displayName: extra?.displayName || session.displayName || session.username, - avatarUrl: extra?.avatarUrl || session.avatarUrl + kind: toKindByContactType(session, contact), + wechatId: contact?.username || session.wechatId || session.username, + displayName, + avatarUrl } }) - } + .sort((a, b) => { + const aMetric = sessionMetricsRef.current[a.username]?.totalMessages ?? 0 + const bMetric = sessionMetricsRef.current[b.username]?.totalMessages ?? 0 + if (bMetric !== aMetric) return bMetric - aMetric + return (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0) + }) + + setSessions(nextSessions) } catch (enrichError) { console.error('导出页补充会话联系人信息失败:', enrichError) + } finally { + if (!isStale()) setIsSessionEnriching(false) } - } - - setSessions(nextSessions) + })() + } else { + setIsLoading(false) } } catch (error) { console.error('加载会话失败:', error) + if (!isStale()) setIsLoading(false) } finally { - setIsLoading(false) + if (!isStale()) setIsLoading(false) } }, []) useEffect(() => { - loadBaseConfig() - loadSessions() - loadSnsStats() + void loadBaseConfig() + void loadSessions() + + // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 + const timer = window.setTimeout(() => { + void loadSnsStats() + }, 180) + + return () => window.clearTimeout(timer) }, [loadBaseConfig, loadSessions, loadSnsStats]) useEffect(() => { @@ -470,12 +516,12 @@ function ExportPage() { const keyword = searchKeyword.trim().toLowerCase() return sessions .filter((session) => { - if (session.kind !== activeTab) return false - if (!keyword) return true - return ( - (session.displayName || '').toLowerCase().includes(keyword) || - session.username.toLowerCase().includes(keyword) - ) + if (session.kind !== activeTab) return false + if (!keyword) return true + return ( + (session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + ) }) .sort((a, b) => { const totalA = sessionMetrics[a.username]?.totalMessages ?? 0 @@ -1229,6 +1275,7 @@ function ExportPage() { const formatCandidateOptions = exportDialog.scope === 'sns' ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions + const showInitialSkeleton = isLoading && sessions.length === 0 return (
@@ -1288,7 +1335,22 @@ function ExportPage() {
- {contentCards.map(card => { + {showInitialSkeleton ? Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+ + +
+
+ + +
+
+
+
+ )) : contentCards.map(card => { const Icon = card.icon return (
@@ -1299,7 +1361,7 @@ function ExportPage() { {card.stats.map((stat) => (
{stat.label} - {stat.value.toLocaleString()} + {isSnsStatsLoading && card.type === 'sns' ? '--' : stat.value.toLocaleString()}
))}
@@ -1411,14 +1473,32 @@ function ExportPage() {
+ {(isLoading || isSessionEnriching) && ( +
+ + {isLoading ? '正在加载会话列表…' : '正在补充头像和统计…'} +
+ )} +
{renderTableHeader()} - {isLoading ? ( + {showInitialSkeleton ? ( ) : visibleSessions.length === 0 ? ( From adff7b9e1e5d546c80f95be4acbcfb37be65eb97 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:24:12 +0800 Subject: [PATCH 007/155] feat(export): refine task center and loading interactions --- src/pages/ExportPage.scss | 121 +++++++++++++++++++++++++- src/pages/ExportPage.tsx | 173 ++++++++++++++++++++++++-------------- 2 files changed, 229 insertions(+), 65 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 5f31d01..e8a1380 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -41,6 +41,13 @@ gap: 6px; } + .path-inline-row { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + } + .path-value { border: 1px dashed var(--border-color); border-radius: 10px; @@ -51,11 +58,29 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + min-width: 0; + flex: 1; + } + + .path-link { + cursor: pointer; + text-align: left; + + &:hover:not(:disabled) { + border-color: var(--primary); + color: var(--primary); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.65; + } } .path-actions { display: flex; gap: 8px; + flex-shrink: 0; } .write-layout-control { @@ -75,10 +100,15 @@ font-size: 13px; text-align: left; cursor: pointer; + transition: border-color 0.12s ease; &:hover { border-color: var(--primary); } + + &.active { + border-color: var(--primary); + } } .layout-dropdown { @@ -94,8 +124,21 @@ z-index: 3000; max-height: 260px; overflow-y: auto; - opacity: 1; + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + visibility: hidden; + transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-end; backdrop-filter: none; + will-change: opacity, transform; + + &.open { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + visibility: visible; + transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-start; + } } .layout-option { @@ -201,6 +244,15 @@ } } +.count-loading { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 500; + display: inline-flex; + align-items: baseline; + gap: 1px; +} + .task-center { border: 1px solid var(--border-color); border-radius: 12px; @@ -208,14 +260,51 @@ padding: 12px; flex-shrink: 0; + .task-center-header { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + .section-title { font-size: 14px; font-weight: 700; color: var(--text-primary); - margin-bottom: 8px; + margin: 0; + flex-shrink: 0; + } + + .task-summary { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + } + + .task-collapse-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + padding: 4px 8px; + font-size: 12px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + + &:hover { + border-color: var(--primary); + color: var(--primary); + } } .task-empty { + margin-top: 10px; padding: 12px; background: var(--bg-secondary); border-radius: 8px; @@ -224,6 +313,7 @@ } .task-list { + margin-top: 10px; display: grid; gap: 8px; max-height: 190px; @@ -377,6 +467,7 @@ white-space: nowrap; display: inline-flex; align-items: center; + gap: 4px; &.active { border-color: var(--primary); @@ -386,6 +477,14 @@ } } +.animated-ellipsis { + display: inline-block; + width: 0; + overflow: hidden; + vertical-align: bottom; + animation: exportDots 1s steps(4, end) infinite; +} + .toolbar-actions { display: flex; align-items: center; @@ -952,6 +1051,15 @@ } } +@keyframes exportDots { + 0% { + width: 0; + } + 100% { + width: 1.8em; + } +} + @media (max-width: 1360px) { .export-top-panel { grid-template-columns: 1fr; @@ -959,6 +1067,15 @@ .global-export-controls { grid-template-columns: 1fr; + + .path-inline-row { + flex-wrap: wrap; + } + + .path-actions { + width: 100%; + justify-content: flex-end; + } } .content-card-grid { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c7c2daf..486d31e 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' import { Aperture, + ChevronDown, + ChevronRight, CheckSquare, Download, ExternalLink, @@ -241,6 +243,8 @@ function ExportPage() { const [isLoading, setIsLoading] = useState(true) const [isSessionEnriching, setIsSessionEnriching] = useState(false) const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) + const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) + const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') @@ -296,6 +300,7 @@ function ExportPage() { const sessionLoadTokenRef = useRef(0) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) + const writeLayoutControlRef = useRef(null) useEffect(() => { tasksRef.current = tasks @@ -323,6 +328,7 @@ function ExportPage() { }, []) const loadBaseConfig = useCallback(async () => { + setIsBaseConfigLoading(true) try { const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount] = await Promise.all([ configService.getExportPath(), @@ -362,6 +368,8 @@ function ExportPage() { })) } catch (error) { console.error('加载导出配置失败:', error) + } finally { + setIsBaseConfigLoading(false) } }, []) @@ -499,6 +507,18 @@ function ExportPage() { preselectAppliedRef.current = false }, [location.key, preselectSessionIds]) + useEffect(() => { + if (!showWriteLayoutSelect) return + + const handleOutsideClick = (event: MouseEvent) => { + if (writeLayoutControlRef.current?.contains(event.target as Node)) return + setShowWriteLayoutSelect(false) + } + + document.addEventListener('mousedown', handleOutsideClick) + return () => document.removeEventListener('mousedown', handleOutsideClick) + }, [showWriteLayoutSelect]) + useEffect(() => { if (preselectAppliedRef.current) return if (sessions.length === 0 || preselectSessionIds.length === 0) return @@ -1275,6 +1295,10 @@ function ExportPage() { const formatCandidateOptions = exportDialog.scope === 'sns' ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions + const isTabCountComputing = isLoading || isSessionEnriching + const isSessionCardStatsLoading = isLoading || isBaseConfigLoading + const taskRunningCount = tasks.filter(task => task.status === 'running').length + const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const showInitialSkeleton = isLoading && sessions.length === 0 return ( @@ -1283,75 +1307,76 @@ function ExportPage() {
导出位置 -
{exportFolder || '未设置'}
-
- +
+
+ + +
-
+
写入目录方式 - - {showWriteLayoutSelect && ( -
- {writeLayoutOptions.map(option => ( - - ))} -
- )} +
+ {writeLayoutOptions.map(option => ( + + ))} +
- {showInitialSkeleton ? Array.from({ length: 6 }).map((_, index) => ( -
-
-
-
- - -
-
- - -
-
-
-
- )) : contentCards.map(card => { + {contentCards.map(card => { const Icon = card.icon + const isCardStatsLoading = card.type === 'sns' + ? (isSnsStatsLoading || isBaseConfigLoading) + : isSessionCardStatsLoading return (
@@ -1361,7 +1386,13 @@ function ExportPage() { {card.stats.map((stat) => (
{stat.label} - {isSnsStatsLoading && card.type === 'sns' ? '--' : stat.value.toLocaleString()} + + {isCardStatsLoading ? ( + + 统计中 + + ) : stat.value.toLocaleString()} +
))}
@@ -1382,9 +1413,25 @@ function ExportPage() { })}
-
-
任务中心
- {tasks.length === 0 ? ( +
+
+
任务中心
+
+ 进行中 {taskRunningCount} + 排队 {taskQueuedCount} + 总计 {tasks.length} +
+ +
+ + {isTaskCenterExpanded && (tasks.length === 0 ? (
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
) : (
@@ -1422,23 +1469,23 @@ function ExportPage() {
))}
- )} + ))}
From c6e8bde0781a6ee07838fc0b5c690d2aa759eadd Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:32:48 +0800 Subject: [PATCH 008/155] feat(export): prioritize tab counts via lightweight api --- electron/main.ts | 4 ++ electron/preload.ts | 1 + electron/services/chatService.ts | 113 +++++++++++++++++++++++++++++++ src/pages/ExportPage.tsx | 38 +++++++++-- src/types/electron.d.ts | 10 +++ 5 files changed, 162 insertions(+), 4 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 0a47a22..c13c9dc 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -912,6 +912,10 @@ function registerIpcHandlers() { return chatService.getSessions() }) + ipcMain.handle('chat:getExportTabCounts', async () => { + return chatService.getExportTabCounts() + }) + ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { return chatService.enrichSessionsContactInfo(usernames) }) diff --git a/electron/preload.ts b/electron/preload.ts index 49c3126..c0f76d1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -130,6 +130,7 @@ contextBridge.exposeInMainWorld('electronAPI', { chat: { connect: () => ipcRenderer.invoke('chat:connect'), getSessions: () => ipcRenderer.invoke('chat:getSessions'), + getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), enrichSessionsContactInfo: (usernames: string[]) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 171ac0b..6cc1e4a 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -151,6 +151,13 @@ interface ExportSessionStats { groupMutualFriends?: number } +interface ExportTabCounts { + private: number + group: number + official: number + former_friend: number +} + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() @@ -657,6 +664,112 @@ class ChatService { } } + /** + * 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示) + */ + async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error } + } + + const sessionResult = await wcdbService.getSessions() + if (!sessionResult.success || !sessionResult.sessions) { + return { success: false, error: sessionResult.error || '获取会话失败' } + } + + const counts: ExportTabCounts = { + private: 0, + group: 0, + official: 0, + former_friend: 0 + } + + const nonGroupUsernames: string[] = [] + const usernameSet = new Set() + + for (const row of sessionResult.sessions as Record[]) { + const username = + row.username || + row.user_name || + row.userName || + row.usrName || + row.UsrName || + row.talker || + row.talker_id || + row.talkerId || + '' + + if (!this.shouldKeepSession(username)) continue + if (usernameSet.has(username)) continue + usernameSet.add(username) + + if (username.endsWith('@chatroom')) { + counts.group += 1 + } else { + nonGroupUsernames.push(username) + } + } + + if (nonGroupUsernames.length === 0) { + return { success: true, counts } + } + + const contactTypeMap = new Map() + const chunkSize = 400 + + for (let i = 0; i < nonGroupUsernames.length; i += chunkSize) { + const chunk = nonGroupUsernames.slice(i, i + chunkSize) + if (chunk.length === 0) continue + + const usernamesExpr = chunk.map((name) => `'${this.escapeSqlString(name)}'`).join(',') + const contactSql = ` + SELECT username, local_type, quan_pin + FROM contact + WHERE username IN (${usernamesExpr}) + ` + + const contactResult = await wcdbService.execQuery('contact', null, contactSql) + if (!contactResult.success || !contactResult.rows) { + continue + } + + for (const row of contactResult.rows as Record[]) { + const username = String(row.username || '').trim() + if (!username) continue + + if (username.startsWith('gh_')) { + contactTypeMap.set(username, 'official') + continue + } + + const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) + const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() + if (localType === 0 && quanPin) { + contactTypeMap.set(username, 'former_friend') + } + } + } + + for (const username of nonGroupUsernames) { + const type = contactTypeMap.get(username) + if (type === 'official') { + counts.official += 1 + } else if (type === 'former_friend') { + counts.former_friend += 1 + } else { + counts.private += 1 + } + } + + return { success: true, counts } + } catch (e) { + console.error('ChatService: 获取导出页会话分类数量失败:', e) + return { success: false, error: String(e) } + } + } + /** * 获取通讯录列表 */ diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 486d31e..e8dd8ca 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -242,10 +242,12 @@ function ExportPage() { const [isLoading, setIsLoading] = useState(true) const [isSessionEnriching, setIsSessionEnriching] = useState(false) + const [isTabCountsLoading, setIsTabCountsLoading] = useState(true) const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) + const [prefetchedTabCounts, setPrefetchedTabCounts] = useState | null>(null) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') @@ -373,6 +375,20 @@ function ExportPage() { } }, []) + const loadTabCounts = useCallback(async () => { + setIsTabCountsLoading(true) + try { + const result = await window.electronAPI.chat.getExportTabCounts() + if (result.success && result.counts) { + setPrefetchedTabCounts(result.counts) + } + } catch (error) { + console.error('加载导出页会话分类数量失败:', error) + } finally { + setIsTabCountsLoading(false) + } + }, []) + const loadSnsStats = useCallback(async () => { setIsSnsStatsLoading(true) try { @@ -493,7 +509,10 @@ function ExportPage() { useEffect(() => { void loadBaseConfig() - void loadSessions() + void (async () => { + await loadTabCounts() + await loadSessions() + })() // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { @@ -501,7 +520,7 @@ function ExportPage() { }, 180) return () => window.clearTimeout(timer) - }, [loadBaseConfig, loadSessions, loadSnsStats]) + }, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats]) useEffect(() => { preselectAppliedRef.current = false @@ -1057,7 +1076,7 @@ function ExportPage() { return set }, [tasks]) - const tabCounts = useMemo(() => { + const sessionTabCounts = useMemo(() => { const counts: Record = { private: 0, group: 0, @@ -1070,6 +1089,16 @@ function ExportPage() { return counts }, [sessions]) + const tabCounts = useMemo(() => { + if (sessions.length > 0) { + return sessionTabCounts + } + if (prefetchedTabCounts) { + return prefetchedTabCounts + } + return sessionTabCounts + }, [sessions.length, sessionTabCounts, prefetchedTabCounts]) + const contentCards = useMemo(() => { const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') const totalSessions = scopeSessions.length @@ -1295,7 +1324,8 @@ function ExportPage() { const formatCandidateOptions = exportDialog.scope === 'sns' ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions - const isTabCountComputing = isLoading || isSessionEnriching + const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0 + const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource const isSessionCardStatsLoading = isLoading || isBaseConfigLoading const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index dfa82e3..4d96cb9 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -74,6 +74,16 @@ export interface ElectronAPI { chat: { connect: () => Promise<{ success: boolean; error?: string }> getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> + getExportTabCounts: () => Promise<{ + success: boolean + counts?: { + private: number + group: number + official: number + former_friend: number + } + error?: string + }> enrichSessionsContactInfo: (usernames: string[]) => Promise<{ success: boolean contacts?: Record From d99c0ff8b208c81f90b4682ae54902cfe1c86a1b Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:44:10 +0800 Subject: [PATCH 009/155] perf(export): make write-layout dropdown instant --- src/pages/ExportPage.tsx | 111 +++++++++++++++++++++++---------------- 1 file changed, 67 insertions(+), 44 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index e8dd8ca..f5c2b39 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' import { Aperture, @@ -237,6 +237,60 @@ const timestampOrDash = (timestamp?: number): string => { const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +const WriteLayoutSelector = memo(function WriteLayoutSelector({ + writeLayout, + onChange +}: { + writeLayout: configService.ExportWriteLayout + onChange: (value: configService.ExportWriteLayout) => Promise +}) { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + const handleOutsideClick = (event: MouseEvent) => { + if (containerRef.current?.contains(event.target as Node)) return + setIsOpen(false) + } + + document.addEventListener('mousedown', handleOutsideClick) + return () => document.removeEventListener('mousedown', handleOutsideClick) + }, [isOpen]) + + const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)' + + return ( +
+ 写入目录方式 + +
+ {writeLayoutOptions.map(option => ( + + ))} +
+
+ ) +}) + function ExportPage() { const location = useLocation() @@ -255,7 +309,6 @@ function ExportPage() { const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') - const [showWriteLayoutSelect, setShowWriteLayoutSelect] = useState(false) const [options, setOptions] = useState({ format: 'excel', @@ -302,7 +355,6 @@ function ExportPage() { const sessionLoadTokenRef = useRef(0) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) - const writeLayoutControlRef = useRef(null) useEffect(() => { tasksRef.current = tasks @@ -526,18 +578,6 @@ function ExportPage() { preselectAppliedRef.current = false }, [location.key, preselectSessionIds]) - useEffect(() => { - if (!showWriteLayoutSelect) return - - const handleOutsideClick = (event: MouseEvent) => { - if (writeLayoutControlRef.current?.contains(event.target as Node)) return - setShowWriteLayoutSelect(false) - } - - document.addEventListener('mousedown', handleOutsideClick) - return () => document.removeEventListener('mousedown', handleOutsideClick) - }, [showWriteLayoutSelect]) - useEffect(() => { if (preselectAppliedRef.current) return if (sessions.length === 0 || preselectSessionIds.length === 0) return @@ -1306,7 +1346,6 @@ function ExportPage() { return count }, [visibleSessions, selectedSessions]) - const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)' const tableColSpan = activeTab === 'group' ? 14 : (activeTab === 'private' || activeTab === 'former_friend' ? 11 : 10) const canCreateTask = exportDialog.scope === 'sns' ? Boolean(exportFolder) @@ -1330,6 +1369,10 @@ function ExportPage() { const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const showInitialSkeleton = isLoading && sessions.length === 0 + const tableBodyRows = useMemo( + () => visibleSessions.map(renderRow), + [visibleSessions, selectedSessions, sessionMetrics, activeTab, runningSessionIds, queuedSessionIds, nowTick, lastExportBySession] + ) return (
@@ -1371,33 +1414,13 @@ function ExportPage() {
-
- 写入目录方式 - -
- {writeLayoutOptions.map(option => ( - - ))} -
-
+ { + setWriteLayout(value) + await configService.setExportWriteLayout(value) + }} + />
@@ -1585,7 +1608,7 @@ function ExportPage() { ) : ( - visibleSessions.map(renderRow) + tableBodyRows )}
-
加载中...
+
+ {Array.from({ length: 8 }).map((_, rowIndex) => ( +
+ + + + + + +
+ ))} +
From 96aa9d08136f535097acce773eec561989231656 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:49:23 +0800 Subject: [PATCH 010/155] feat(export): adjust path actions and compact sns card --- src/pages/ExportPage.scss | 49 +++++++++++++++++++++++++------------- src/pages/ExportPage.tsx | 50 ++++++++++++++++++--------------------- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index e8a1380..00a5f1e 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -51,36 +51,52 @@ .path-value { border: 1px dashed var(--border-color); border-radius: 10px; - padding: 10px 12px; + background: var(--bg-secondary); + display: flex; + align-items: stretch; + min-width: 0; + flex: 1; + overflow: hidden; + } + + .path-link { + border: none; + background: transparent; font-size: 13px; color: var(--text-primary); - background: var(--bg-secondary); + text-align: left; + padding: 10px 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; flex: 1; - } - - .path-link { cursor: pointer; - text-align: left; - &:hover:not(:disabled) { - border-color: var(--primary); + &:hover { color: var(--primary); } - &:disabled { - cursor: not-allowed; - opacity: 0.65; + &:focus-visible { + outline: none; } } - .path-actions { - display: flex; - gap: 8px; + .path-change-btn { + border: none; + border-left: 1px dashed var(--border-color); + background: transparent; + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + padding: 0 12px; + cursor: pointer; flex-shrink: 0; + + &:hover { + border-color: var(--primary); + color: var(--primary); + } } .write-layout-control { @@ -1072,9 +1088,8 @@ flex-wrap: wrap; } - .path-actions { - width: 100%; - justify-content: flex-end; + .path-inline-row > .secondary-btn { + margin-left: auto; } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index f5c2b39..2d18979 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1174,7 +1174,6 @@ function ExportPage() { label: '朋友圈', stats: [ { label: '朋友圈条数', value: snsStats.totalPosts }, - { label: '好友数', value: snsStats.totalFriends }, { label: '已导出朋友圈条数', value: snsExportedCount } ] } @@ -1373,6 +1372,17 @@ function ExportPage() { () => visibleSessions.map(renderRow), [visibleSessions, selectedSessions, sessionMetrics, activeTab, runningSessionIds, queuedSessionIds, nowTick, lastExportBySession] ) + const chooseExportFolder = useCallback(async () => { + const result = await window.electronAPI.dialog.openFile({ + title: '选择导出目录', + properties: ['openDirectory'] + }) + if (!result.canceled && result.filePaths.length > 0) { + const nextPath = result.filePaths[0] + setExportFolder(nextPath) + await configService.setExportPath(nextPath) + } + }, []) return (
@@ -1381,36 +1391,22 @@ function ExportPage() {
导出位置
- -
- +
+
+
From 22c7048ef69cc7ccb9d60150da5845b7b0121535 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:54:02 +0800 Subject: [PATCH 011/155] fix(sidebar): prefer valid nickname over wxid --- electron/services/chatService.ts | 10 +++--- src/components/Sidebar.tsx | 55 +++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 6cc1e4a..d55ef0f 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -3250,11 +3250,13 @@ class ChatService { if (!connectResult.success) return null const result = await wcdbService.getContact(username) if (!result.success || !result.contact) return null + const contact = result.contact as Record return { - username: result.contact.username || username, - alias: result.contact.alias || '', - remark: result.contact.remark || '', - nickName: result.contact.nickName || '' + username: String(contact.username || contact.user_name || contact.userName || username || ''), + alias: String(contact.alias || contact.Alias || ''), + remark: String(contact.remark || contact.Remark || ''), + // 兼容不同表结构字段,避免 nick_name 丢失导致侧边栏退化到 wxid。 + nickName: String(contact.nickName || contact.nick_name || contact.nickname || contact.NickName || '') } } catch { return null diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a362ac7..8d81da8 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -64,13 +64,6 @@ function Sidebar() { useEffect(() => { const loadCurrentUser = async () => { - const normalizeName = (value?: string | null): string | undefined => { - if (!value) return undefined - const trimmed = value.trim() - if (!trimmed || trimmed.toLowerCase() === 'self') return undefined - return trimmed - } - const patchUserProfile = (patch: Partial, expectedWxid?: string) => { setUserProfile(prev => { if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) { @@ -91,6 +84,32 @@ function Sidebar() { try { const wxid = await configService.getMyWxid() const resolvedWxid = wxid || '' + const cleanedWxidMatch = resolvedWxid.match(/^(wxid_[^_]+)/i) + const cleanedWxid = cleanedWxidMatch?.[1] || resolvedWxid + const wxidCandidates = new Set([ + resolvedWxid.trim().toLowerCase(), + cleanedWxid.trim().toLowerCase() + ].filter(Boolean)) + + const normalizeName = (value?: string | null): string | undefined => { + if (!value) return undefined + const trimmed = value.trim() + if (!trimmed) return undefined + const lowered = trimmed.toLowerCase() + if (lowered === 'self') return undefined + if (lowered.startsWith('wxid_')) return undefined + if (wxidCandidates.has(lowered)) return undefined + return trimmed + } + + const pickFirstValidName = (...candidates: Array): string | undefined => { + for (const candidate of candidates) { + const normalized = normalizeName(candidate) + if (normalized) return normalized + } + return undefined + } + const fallbackDisplayName = resolvedWxid || '未识别用户' // 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。 @@ -105,20 +124,26 @@ function Sidebar() { void (async () => { try { const myContact = await window.electronAPI.chat.getContact(resolvedWxid) - const fromContact = - normalizeName(myContact?.remark) || - normalizeName(myContact?.nickName) || - normalizeName(myContact?.alias) + const fromContact = pickFirstValidName( + myContact?.remark, + myContact?.nickName, + myContact?.alias + ) if (fromContact) { patchUserProfile({ displayName: fromContact }, resolvedWxid) return } - const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo([resolvedWxid, 'self']) - const enrichedDisplayName = normalizeName(enrichedResult.contacts?.[resolvedWxid]?.displayName) - const fallbackSelfName = normalizeName(enrichedResult.contacts?.self?.displayName) - const bestName = enrichedDisplayName || fallbackSelfName + const enrichTargets = Array.from(new Set([resolvedWxid, cleanedWxid, 'self'].filter(Boolean))) + const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets) + const enrichedDisplayName = pickFirstValidName( + enrichedResult.contacts?.[resolvedWxid]?.displayName, + enrichedResult.contacts?.[cleanedWxid]?.displayName, + enrichedResult.contacts?.self?.displayName, + myContact?.alias + ) + const bestName = enrichedDisplayName if (bestName) { patchUserProfile({ displayName: bestName }, resolvedWxid) } From c34f7af6ded66c875c0c45c76fa2832fe7fe9aa8 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 16:56:03 +0800 Subject: [PATCH 012/155] chore(export): shorten card exported labels --- src/pages/ExportPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 2d18979..f2190b0 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1163,7 +1163,7 @@ function ExportPage() { label: contentTypeLabels[item.type], stats: [ { label: '总会话数', value: totalSessions }, - { label: '已导出会话数', value: exported } + { label: '已导出', value: exported } ] } }) @@ -1174,7 +1174,7 @@ function ExportPage() { label: '朋友圈', stats: [ { label: '朋友圈条数', value: snsStats.totalPosts }, - { label: '已导出朋友圈条数', value: snsExportedCount } + { label: '已导出', value: snsExportedCount } ] } From dffd3c91385c0db18a4a700b4af651702998da53 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 17:00:37 +0800 Subject: [PATCH 013/155] fix(export): batch session stats and avoid stale empty cache --- src/pages/ExportPage.tsx | 50 +++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index f2190b0..0fd53ce 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -623,29 +623,41 @@ function ExportPage() { const updates: Record = {} for (const session of pending) { loadingMetricsRef.current.add(session.username) - updates[session.username] = {} } try { - const statsResult = await window.electronAPI.chat.getExportSessionStats(pending.map(session => session.username)) - if (statsResult.success && statsResult.data) { - for (const session of pending) { - const raw = statsResult.data[session.username] - if (!raw) continue - updates[session.username] = { - totalMessages: raw.totalMessages, - voiceMessages: raw.voiceMessages, - imageMessages: raw.imageMessages, - videoMessages: raw.videoMessages, - emojiMessages: raw.emojiMessages, - privateMutualGroups: raw.privateMutualGroups, - groupMemberCount: raw.groupMemberCount, - groupMyMessages: raw.groupMyMessages, - groupActiveSpeakers: raw.groupActiveSpeakers, - groupMutualFriends: raw.groupMutualFriends, - firstTimestamp: raw.firstTimestamp, - lastTimestamp: raw.lastTimestamp + const batchSize = 80 + for (let i = 0; i < pending.length; i += batchSize) { + const chunk = pending.slice(i, i + batchSize) + const ids = chunk.map(session => session.username) + + try { + const statsResult = await window.electronAPI.chat.getExportSessionStats(ids) + if (!statsResult.success || !statsResult.data) { + console.error('加载会话统计失败:', statsResult.error || '未知错误') + continue } + + for (const session of chunk) { + const raw = statsResult.data[session.username] + // 成功响应但无明细时按 0 回填,避免该行反复重试导致滚动抖动。 + updates[session.username] = { + totalMessages: raw?.totalMessages ?? 0, + voiceMessages: raw?.voiceMessages ?? 0, + imageMessages: raw?.imageMessages ?? 0, + videoMessages: raw?.videoMessages ?? 0, + emojiMessages: raw?.emojiMessages ?? 0, + privateMutualGroups: raw?.privateMutualGroups, + groupMemberCount: raw?.groupMemberCount, + groupMyMessages: raw?.groupMyMessages, + groupActiveSpeakers: raw?.groupActiveSpeakers, + groupMutualFriends: raw?.groupMutualFriends, + firstTimestamp: raw?.firstTimestamp, + lastTimestamp: raw?.lastTimestamp + } + } + } catch (error) { + console.error('加载会话统计分批失败:', error) } } } catch (error) { From d12c111684a123980985c79c0cc241abe80c5bc3 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 17:07:32 +0800 Subject: [PATCH 014/155] perf(export): virtualize session table and prioritize metrics loading --- src/pages/ExportPage.scss | 12 +++- src/pages/ExportPage.tsx | 114 ++++++++++++++++++++++++-------------- 2 files changed, 81 insertions(+), 45 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 00a5f1e..c576959 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -549,14 +549,19 @@ } .table-wrap { - overflow: auto; + overflow: hidden; border: 1px solid var(--border-color); border-radius: 10px; min-height: 0; flex: 1; } -.session-table { +.table-virtuoso { + height: 100%; +} + +.session-table, +.table-wrap table { width: 100%; min-width: 1300px; border-collapse: separate; @@ -588,7 +593,8 @@ background: rgba(var(--primary-rgb), 0.03); } - .selected-row { + .selected-row, + tbody tr:has(.select-icon-btn.checked) { background: rgba(var(--primary-rgb), 0.08); } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 0fd53ce..c5338bf 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' +import { TableVirtuoso } from 'react-virtuoso' import { Aperture, ChevronDown, @@ -236,6 +237,9 @@ const timestampOrDash = (timestamp?: number): string => { } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +const METRICS_VIEWPORT_PREFETCH = 140 +const METRICS_BACKGROUND_BATCH = 60 +const METRICS_BACKGROUND_INTERVAL_MS = 180 const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, @@ -355,6 +359,7 @@ function ExportPage() { const sessionLoadTokenRef = useRef(0) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) + const visibleSessionsRef = useRef([]) useEffect(() => { tasksRef.current = tasks @@ -615,6 +620,10 @@ function ExportPage() { }) }, [sessions, activeTab, searchKeyword, sessionMetrics]) + useEffect(() => { + visibleSessionsRef.current = visibleSessions + }, [visibleSessions]) + const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { const currentMetrics = sessionMetricsRef.current const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) @@ -674,13 +683,44 @@ function ExportPage() { }, []) useEffect(() => { - const targets = visibleSessions.slice(0, 40) + const keyword = searchKeyword.trim().toLowerCase() + const targets = sessions + .filter((session) => { + if (session.kind !== activeTab) return false + if (!keyword) return true + return ( + (session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + ) + }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) + .slice(0, METRICS_VIEWPORT_PREFETCH) void ensureSessionMetrics(targets) - }, [visibleSessions, ensureSessionMetrics]) + }, [sessions, activeTab, searchKeyword, ensureSessionMetrics]) + + const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { + const current = visibleSessionsRef.current + if (current.length === 0) return + const start = Math.max(0, range.startIndex - METRICS_VIEWPORT_PREFETCH) + const end = Math.min(current.length - 1, range.endIndex + METRICS_VIEWPORT_PREFETCH) + if (end < start) return + void ensureSessionMetrics(current.slice(start, end + 1)) + }, [ensureSessionMetrics]) useEffect(() => { if (sessions.length === 0) return - void ensureSessionMetrics(sessions) + let cursor = 0 + const timer = window.setInterval(() => { + if (cursor >= sessions.length) { + window.clearInterval(timer) + return + } + const chunk = sessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH) + cursor += METRICS_BACKGROUND_BATCH + void ensureSessionMetrics(chunk) + }, METRICS_BACKGROUND_INTERVAL_MS) + + return () => window.clearInterval(timer) }, [sessions, ensureSessionMetrics]) const selectedCount = selectedSessions.size @@ -1294,12 +1334,12 @@ function ExportPage() { ) } - const renderRow = (session: SessionRow) => { + const renderRowCells = (session: SessionRow) => { const metrics = sessionMetrics[session.username] || {} const checked = selectedSessions.has(session.username) return ( - + <>
From bf9b5ba5935e5325ac84fb4d1f03bcc9ca5e6a0c Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 17:27:10 +0800 Subject: [PATCH 015/155] perf(export): prioritize totals and keep table visible --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/chatService.ts | 42 ++++++ src/pages/ExportPage.scss | 4 +- src/pages/ExportPage.tsx | 219 +++++++++++++++++++++++-------- src/types/electron.d.ts | 5 + 6 files changed, 216 insertions(+), 59 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index c13c9dc..14bf1e6 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -916,6 +916,10 @@ function registerIpcHandlers() { return chatService.getExportTabCounts() }) + ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { + return chatService.getSessionMessageCounts(sessionIds) + }) + ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { return chatService.enrichSessionsContactInfo(usernames) }) diff --git a/electron/preload.ts b/electron/preload.ts index c0f76d1..43a478f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -131,6 +131,7 @@ contextBridge.exposeInMainWorld('electronAPI', { connect: () => ipcRenderer.invoke('chat:connect'), getSessions: () => ipcRenderer.invoke('chat:getSessions'), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), + getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), enrichSessionsContactInfo: (usernames: string[]) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index d55ef0f..67984e9 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -770,6 +770,48 @@ class ChatService { } } + /** + * 批量获取会话消息总数(轻量接口,用于列表优先排序) + */ + async getSessionMessageCounts(sessionIds: string[]): Promise<{ + success: boolean + counts?: Record + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedSessionIds.length === 0) { + return { success: true, counts: {} } + } + + const counts: Record = {} + await this.forEachWithConcurrency(normalizedSessionIds, 8, async (sessionId) => { + try { + const result = await wcdbService.getMessageCount(sessionId) + counts[sessionId] = result.success && typeof result.count === 'number' ? result.count : 0 + } catch { + counts[sessionId] = 0 + } + }) + + return { success: true, counts } + } catch (e) { + console.error('ChatService: 批量获取会话消息总数失败:', e) + return { success: false, error: String(e) } + } + } + /** * 获取通讯录列表 */ diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index c576959..ae7129b 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -439,6 +439,7 @@ border-radius: 12px; background: var(--card-bg); padding: 12px; + flex: 1; min-height: 0; display: flex; flex-direction: column; @@ -552,7 +553,8 @@ overflow: hidden; border: 1px solid var(--border-color); border-radius: 10px; - min-height: 0; + min-height: 320px; + height: 100%; flex: 1; } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c5338bf..671c8e2 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -237,9 +237,29 @@ const timestampOrDash = (timestamp?: number): string => { } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const METRICS_VIEWPORT_PREFETCH = 140 -const METRICS_BACKGROUND_BATCH = 60 -const METRICS_BACKGROUND_INTERVAL_MS = 180 +const MESSAGE_COUNT_VIEWPORT_PREFETCH = 220 +const MESSAGE_COUNT_BACKGROUND_BATCH = 180 +const MESSAGE_COUNT_BACKGROUND_INTERVAL_MS = 100 +const METRICS_VIEWPORT_PREFETCH = 90 +const METRICS_BACKGROUND_BATCH = 40 +const METRICS_BACKGROUND_INTERVAL_MS = 220 +const CONTACT_ENRICH_TIMEOUT_MS = 7000 + +const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { + let timer: ReturnType | null = null + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs) + }) + ]) + } finally { + if (timer) { + clearTimeout(timer) + } + } +} const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, @@ -306,6 +326,7 @@ function ExportPage() { const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) const [prefetchedTabCounts, setPrefetchedTabCounts] = useState | null>(null) + const [sessionMessageCounts, setSessionMessageCounts] = useState>({}) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') @@ -355,8 +376,10 @@ function ExportPage() { const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) + const sessionMessageCountsRef = useRef>({}) const sessionMetricsRef = useRef>({}) const sessionLoadTokenRef = useRef(0) + const loadingMessageCountsRef = useRef>(new Set()) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) const visibleSessionsRef = useRef([]) @@ -365,6 +388,10 @@ function ExportPage() { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + sessionMessageCountsRef.current = sessionMessageCounts + }, [sessionMessageCounts]) + useEffect(() => { sessionMetricsRef.current = sessionMetrics }, [sessionMetrics]) @@ -468,6 +495,12 @@ function ExportPage() { sessionLoadTokenRef.current = loadToken setIsLoading(true) setIsSessionEnriching(false) + loadingMessageCountsRef.current.clear() + loadingMetricsRef.current.clear() + sessionMessageCountsRef.current = {} + sessionMetricsRef.current = {} + setSessionMessageCounts({}) + setSessionMetrics({}) const isStale = () => sessionLoadTokenRef.current !== loadToken @@ -503,10 +536,10 @@ function ExportPage() { setIsSessionEnriching(true) void (async () => { try { - const contactsResult = await window.electronAPI.chat.getContacts() + const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) if (isStale()) return - const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] + const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] const nextContactMap = contacts.reduce>((map, contact) => { map[contact.username] = contact return map @@ -518,8 +551,11 @@ function ExportPage() { let extraContactMap: Record = {} if (needsEnrichment.length > 0) { - const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) - if (enrichResult.success && enrichResult.contacts) { + const enrichResult = await withTimeout( + window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment), + CONTACT_ENRICH_TIMEOUT_MS + ) + if (enrichResult?.success && enrichResult.contacts) { extraContactMap = enrichResult.contacts } } @@ -539,12 +575,7 @@ function ExportPage() { avatarUrl } }) - .sort((a, b) => { - const aMetric = sessionMetricsRef.current[a.username]?.totalMessages ?? 0 - const bMetric = sessionMetricsRef.current[b.username]?.totalMessages ?? 0 - if (bMetric !== aMetric) return bMetric - aMetric - return (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0) - }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) setSessions(nextSessions) } catch (enrichError) { @@ -566,10 +597,8 @@ function ExportPage() { useEffect(() => { void loadBaseConfig() - void (async () => { - await loadTabCounts() - await loadSessions() - })() + void loadTabCounts() + void loadSessions() // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { @@ -608,23 +637,74 @@ function ExportPage() { ) }) .sort((a, b) => { - const totalA = sessionMetrics[a.username]?.totalMessages ?? 0 - const totalB = sessionMetrics[b.username]?.totalMessages ?? 0 - if (totalB !== totalA) { + const totalA = sessionMessageCounts[a.username] + const totalB = sessionMessageCounts[b.username] + const hasTotalA = typeof totalA === 'number' + const hasTotalB = typeof totalB === 'number' + + if (hasTotalA && hasTotalB && totalB !== totalA) { return totalB - totalA } + if (hasTotalA !== hasTotalB) { + return hasTotalA ? -1 : 1 + } const latestA = sessionMetrics[a.username]?.lastTimestamp ?? a.lastTimestamp ?? 0 const latestB = sessionMetrics[b.username]?.lastTimestamp ?? b.lastTimestamp ?? 0 return latestB - latestA }) - }, [sessions, activeTab, searchKeyword, sessionMetrics]) + }, [sessions, activeTab, searchKeyword, sessionMessageCounts, sessionMetrics]) useEffect(() => { visibleSessionsRef.current = visibleSessions }, [visibleSessions]) + const ensureSessionMessageCounts = useCallback(async (targetSessions: SessionRow[]) => { + const loadTokenAtStart = sessionLoadTokenRef.current + const currentCounts = sessionMessageCountsRef.current + const pending = targetSessions.filter( + session => currentCounts[session.username] === undefined && !loadingMessageCountsRef.current.has(session.username) + ) + if (pending.length === 0) return + + const updates: Record = {} + for (const session of pending) { + loadingMessageCountsRef.current.add(session.username) + } + + try { + const batchSize = 220 + for (let i = 0; i < pending.length; i += batchSize) { + if (loadTokenAtStart !== sessionLoadTokenRef.current) return + const chunk = pending.slice(i, i + batchSize) + const ids = chunk.map(session => session.username) + + try { + const result = await window.electronAPI.chat.getSessionMessageCounts(ids) + for (const session of chunk) { + const value = result.success && result.counts ? result.counts[session.username] : undefined + updates[session.username] = typeof value === 'number' ? value : 0 + } + } catch (error) { + console.error('加载会话总消息数失败:', error) + for (const session of chunk) { + updates[session.username] = 0 + } + } + } + } finally { + for (const session of pending) { + loadingMessageCountsRef.current.delete(session.username) + } + } + + if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { + setSessionMessageCounts(prev => ({ ...prev, ...updates })) + } + }, []) + const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { + const loadTokenAtStart = sessionLoadTokenRef.current const currentMetrics = sessionMetricsRef.current const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) if (pending.length === 0) return @@ -637,6 +717,7 @@ function ExportPage() { try { const batchSize = 80 for (let i = 0; i < pending.length; i += batchSize) { + if (loadTokenAtStart !== sessionLoadTokenRef.current) return const chunk = pending.slice(i, i + batchSize) const ids = chunk.map(session => session.username) @@ -677,35 +758,48 @@ function ExportPage() { } } - if (Object.keys(updates).length > 0) { + if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } }, []) useEffect(() => { - const keyword = searchKeyword.trim().toLowerCase() - const targets = sessions - .filter((session) => { - if (session.kind !== activeTab) return false - if (!keyword) return true - return ( - (session.displayName || '').toLowerCase().includes(keyword) || - session.username.toLowerCase().includes(keyword) - ) - }) - .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) - .slice(0, METRICS_VIEWPORT_PREFETCH) + const targets = visibleSessions.slice(0, MESSAGE_COUNT_VIEWPORT_PREFETCH) + void ensureSessionMessageCounts(targets) + }, [visibleSessions, ensureSessionMessageCounts]) + + useEffect(() => { + const targets = visibleSessions.slice(0, METRICS_VIEWPORT_PREFETCH) void ensureSessionMetrics(targets) - }, [sessions, activeTab, searchKeyword, ensureSessionMetrics]) + }, [visibleSessions, ensureSessionMetrics]) const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { const current = visibleSessionsRef.current if (current.length === 0) return - const start = Math.max(0, range.startIndex - METRICS_VIEWPORT_PREFETCH) - const end = Math.min(current.length - 1, range.endIndex + METRICS_VIEWPORT_PREFETCH) + const prefetch = Math.max(MESSAGE_COUNT_VIEWPORT_PREFETCH, METRICS_VIEWPORT_PREFETCH) + const start = Math.max(0, range.startIndex - prefetch) + const end = Math.min(current.length - 1, range.endIndex + prefetch) if (end < start) return - void ensureSessionMetrics(current.slice(start, end + 1)) - }, [ensureSessionMetrics]) + const rangeSessions = current.slice(start, end + 1) + void ensureSessionMessageCounts(rangeSessions) + void ensureSessionMetrics(rangeSessions) + }, [ensureSessionMessageCounts, ensureSessionMetrics]) + + useEffect(() => { + if (sessions.length === 0) return + let cursor = 0 + const timer = window.setInterval(() => { + if (cursor >= sessions.length) { + window.clearInterval(timer) + return + } + const chunk = sessions.slice(cursor, cursor + MESSAGE_COUNT_BACKGROUND_BATCH) + cursor += MESSAGE_COUNT_BACKGROUND_BATCH + void ensureSessionMessageCounts(chunk) + }, MESSAGE_COUNT_BACKGROUND_INTERVAL_MS) + + return () => window.clearInterval(timer) + }, [sessions, ensureSessionMessageCounts]) useEffect(() => { if (sessions.length === 0) return @@ -1335,7 +1429,8 @@ function ExportPage() { } const renderRowCells = (session: SessionRow) => { - const metrics = sessionMetrics[session.username] || {} + const metrics = sessionMetrics[session.username] + const totalMessages = sessionMessageCounts[session.username] const checked = selectedSessions.has(session.username) return ( @@ -1351,35 +1446,43 @@ function ExportPage() { {renderSessionName(session)} - {valueOrDash(metrics.totalMessages)} - {valueOrDash(metrics.voiceMessages)} - {valueOrDash(metrics.imageMessages)} - {valueOrDash(metrics.videoMessages)} - {valueOrDash(metrics.emojiMessages)} + + {typeof totalMessages === 'number' + ? totalMessages.toLocaleString() + : ( + + 统计中 + + )} + + {valueOrDash(metrics?.voiceMessages)} + {valueOrDash(metrics?.imageMessages)} + {valueOrDash(metrics?.videoMessages)} + {valueOrDash(metrics?.emojiMessages)} {(activeTab === 'private' || activeTab === 'former_friend') && ( <> - {valueOrDash(metrics.privateMutualGroups)} - {timestampOrDash(metrics.firstTimestamp)} - {timestampOrDash(metrics.lastTimestamp)} + {valueOrDash(metrics?.privateMutualGroups)} + {timestampOrDash(metrics?.firstTimestamp)} + {timestampOrDash(metrics?.lastTimestamp)} )} {activeTab === 'group' && ( <> - {valueOrDash(metrics.groupMyMessages)} - {valueOrDash(metrics.groupMemberCount)} - {valueOrDash(metrics.groupActiveSpeakers)} - {valueOrDash(metrics.groupMutualFriends)} - {timestampOrDash(metrics.firstTimestamp)} - {timestampOrDash(metrics.lastTimestamp)} + {valueOrDash(metrics?.groupMyMessages)} + {valueOrDash(metrics?.groupMemberCount)} + {valueOrDash(metrics?.groupActiveSpeakers)} + {valueOrDash(metrics?.groupMutualFriends)} + {timestampOrDash(metrics?.firstTimestamp)} + {timestampOrDash(metrics?.lastTimestamp)} )} {activeTab === 'official' && ( <> - {timestampOrDash(metrics.firstTimestamp)} - {timestampOrDash(metrics.lastTimestamp)} + {timestampOrDash(metrics?.firstTimestamp)} + {timestampOrDash(metrics?.lastTimestamp)} )} @@ -1616,10 +1719,10 @@ function ExportPage() {
- {(isLoading || isSessionEnriching) && ( + {!showInitialSkeleton && (isLoading || isSessionEnriching) && (
- {isLoading ? '正在加载会话列表…' : '正在补充头像和统计…'} + {isLoading ? '正在刷新会话列表…' : '正在补充头像和统计…'}
)} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 4d96cb9..471ac70 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -84,6 +84,11 @@ export interface ElectronAPI { } error?: string }> + getSessionMessageCounts: (sessionIds: string[]) => Promise<{ + success: boolean + counts?: Record + error?: string + }> enrichSessionsContactInfo: (usernames: string[]) => Promise<{ success: boolean contacts?: Record From 7604ff2ae414cba286922718311ef0b4c765262b Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 17:51:28 +0800 Subject: [PATCH 016/155] perf(export): cache counts and speed sns/session stats --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/chatService.ts | 38 ++++++- electron/services/snsService.ts | 100 +++++++++++++---- src/pages/ExportPage.tsx | 183 ++++++++++++++++++++++++------- src/services/config.ts | 100 +++++++++++++++++ src/types/electron.d.ts | 1 + 7 files changed, 365 insertions(+), 62 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 14bf1e6..4638662 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1032,6 +1032,10 @@ function registerIpcHandlers() { return snsService.getExportStats() }) + ipcMain.handle('sns:getExportStatsFast', async () => { + return snsService.getExportStatsFast() + }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { return snsService.debugResource(url) }) diff --git a/electron/preload.ts b/electron/preload.ts index 43a478f..a26c46b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -290,6 +290,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), + getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 67984e9..67de6e0 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -196,6 +196,9 @@ class ChatService { // 缓存会话表信息,避免每次查询 private sessionTablesCache = new Map>() private readonly sessionTablesCacheTtl = 300000 // 5分钟 + private sessionMessageCountCache = new Map() + private sessionMessageCountCacheScope = '' + private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000 constructor() { this.configService = new ConfigService() @@ -795,13 +798,35 @@ class ChatService { return { success: true, counts: {} } } + this.refreshSessionMessageCountCacheScope() const counts: Record = {} - await this.forEachWithConcurrency(normalizedSessionIds, 8, async (sessionId) => { + const now = Date.now() + const pendingSessionIds: string[] = [] + + for (const sessionId of normalizedSessionIds) { + const cached = this.sessionMessageCountCache.get(sessionId) + if (cached && now - cached.updatedAt <= this.sessionMessageCountCacheTtlMs) { + counts[sessionId] = cached.count + } else { + pendingSessionIds.push(sessionId) + } + } + + await this.forEachWithConcurrency(pendingSessionIds, 16, async (sessionId) => { try { const result = await wcdbService.getMessageCount(sessionId) - counts[sessionId] = result.success && typeof result.count === 'number' ? result.count : 0 + const nextCount = result.success && typeof result.count === 'number' ? result.count : 0 + counts[sessionId] = nextCount + this.sessionMessageCountCache.set(sessionId, { + count: nextCount, + updatedAt: Date.now() + }) } catch { counts[sessionId] = 0 + this.sessionMessageCountCache.set(sessionId, { + count: 0, + updatedAt: Date.now() + }) } }) @@ -1455,6 +1480,15 @@ class ChatService { await Promise.all(runners) } + private refreshSessionMessageCountCacheScope(): void { + const dbPath = String(this.configService.get('dbPath') || '') + const myWxid = String(this.configService.get('myWxid') || '') + const scope = `${dbPath}::${myWxid}` + if (scope === this.sessionMessageCountCacheScope) return + this.sessionMessageCountCacheScope = scope + this.sessionMessageCountCache.clear() + } + private async collectSessionExportStats( sessionId: string, selfIdentitySet: Set diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 9484cdb..369a003 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -229,6 +229,10 @@ class SnsService { private configService: ConfigService private contactCache: ContactCacheService private imageCache = new Map() + private exportStatsCache: { totalPosts: number; totalFriends: number; updatedAt: number } | null = null + private readonly exportStatsCacheTtlMs = 5 * 60 * 1000 + private lastTimelineFallbackAt = 0 + private readonly timelineFallbackCooldownMs = 3 * 60 * 1000 constructor() { this.configService = new ConfigService() @@ -403,38 +407,66 @@ class SnsService { return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } } - async getExportStats(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { - try { - let totalPosts = 0 - let totalFriends = 0 + private async getExportStatsFromTableCount(): Promise<{ totalPosts: number; totalFriends: number }> { + let totalPosts = 0 + let totalFriends = 0 - const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') - if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { - totalPosts = this.parseCountValue(postCountResult.rows[0]) - } + const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') + if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { + totalPosts = this.parseCountValue(postCountResult.rows[0]) + } - if (totalPosts > 0) { - const friendCountPrimary = await wcdbService.execQuery( + if (totalPosts > 0) { + const friendCountPrimary = await wcdbService.execQuery( + 'sns', + null, + "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" + ) + if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) + } else { + const friendCountFallback = await wcdbService.execQuery( 'sns', null, - "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" + "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" ) - if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) - } else { - const friendCountFallback = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" - ) - if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + } + } + } + + return { totalPosts, totalFriends } + } + + async getExportStats(options?: { + allowTimelineFallback?: boolean + preferCache?: boolean + }): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { + const allowTimelineFallback = options?.allowTimelineFallback ?? true + const preferCache = options?.preferCache ?? false + const now = Date.now() + + try { + if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends } } } - // 某些环境下 SnsTimeLine 统计查询会返回 0,这里回退到与导出同源的 timeline 接口统计。 - if (totalPosts <= 0 || totalFriends <= 0) { + let { totalPosts, totalFriends } = await this.getExportStatsFromTableCount() + + // 某些环境下 SnsTimeLine 统计查询会返回 0,这里在允许时回退到与导出同源的 timeline 接口统计。 + if ( + allowTimelineFallback && + (totalPosts <= 0 || totalFriends <= 0) && + now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs + ) { + this.lastTimelineFallbackAt = now const timelineStats = await this.getExportStatsFromTimeline() if (timelineStats.totalPosts > 0) { totalPosts = timelineStats.totalPosts @@ -444,12 +476,34 @@ class SnsService { } } + this.exportStatsCache = { + totalPosts, + totalFriends, + updatedAt: Date.now() + } + return { success: true, data: { totalPosts, totalFriends } } } catch (e) { + if (this.exportStatsCache) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends + } + } + } return { success: false, error: String(e) } } } + async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { + return this.getExportStats({ + allowTimelineFallback: false, + preferCache: true + }) + } + // 安装朋友圈删除拦截 async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { return wcdbService.installSnsBlockDeleteTrigger() diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 671c8e2..415a93a 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -237,13 +237,15 @@ const timestampOrDash = (timestamp?: number): string => { } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const MESSAGE_COUNT_VIEWPORT_PREFETCH = 220 -const MESSAGE_COUNT_BACKGROUND_BATCH = 180 -const MESSAGE_COUNT_BACKGROUND_INTERVAL_MS = 100 +const MESSAGE_COUNT_VIEWPORT_PREFETCH = 120 +const MESSAGE_COUNT_BACKGROUND_BATCH = 90 +const MESSAGE_COUNT_BACKGROUND_INTERVAL_MS = 90 const METRICS_VIEWPORT_PREFETCH = 90 const METRICS_BACKGROUND_BATCH = 40 const METRICS_BACKGROUND_INTERVAL_MS = 220 const CONTACT_ENRICH_TIMEOUT_MS = 7000 +const EXPORT_SESSION_COUNT_CACHE_STALE_MS = 48 * 60 * 60 * 1000 +const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null @@ -371,11 +373,13 @@ function ExportPage() { totalPosts: 0, totalFriends: 0 }) + const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [nowTick, setNowTick] = useState(Date.now()) const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) + const hasSeededSnsStatsRef = useRef(false) const sessionMessageCountsRef = useRef>({}) const sessionMetricsRef = useRef>({}) const sessionLoadTokenRef = useRef(0) @@ -383,11 +387,18 @@ function ExportPage() { const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) const visibleSessionsRef = useRef([]) + const exportCacheScopeRef = useRef('default') + const exportCacheScopeReadyRef = useRef(false) + const persistSessionCountTimerRef = useRef(null) useEffect(() => { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + hasSeededSnsStatsRef.current = hasSeededSnsStats + }, [hasSeededSnsStats]) + useEffect(() => { sessionMessageCountsRef.current = sessionMessageCounts }, [sessionMessageCounts]) @@ -396,6 +407,30 @@ function ExportPage() { sessionMetricsRef.current = sessionMetrics }, [sessionMetrics]) + useEffect(() => { + if (persistSessionCountTimerRef.current) { + window.clearTimeout(persistSessionCountTimerRef.current) + persistSessionCountTimerRef.current = null + } + + if (isBaseConfigLoading || !exportCacheScopeReadyRef.current) return + + const countSize = Object.keys(sessionMessageCounts).length + if (countSize === 0) return + + persistSessionCountTimerRef.current = window.setTimeout(() => { + void configService.setExportSessionMessageCountCache(exportCacheScopeRef.current, sessionMessageCounts) + persistSessionCountTimerRef.current = null + }, 900) + + return () => { + if (persistSessionCountTimerRef.current) { + window.clearTimeout(persistSessionCountTimerRef.current) + persistSessionCountTimerRef.current = null + } + } + }, [sessionMessageCounts, isBaseConfigLoading]) + const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const rawList = Array.isArray(state?.preselectSessionIds) @@ -416,7 +451,7 @@ function ExportPage() { const loadBaseConfig = useCallback(async () => { setIsBaseConfigLoading(true) try { - const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount] = await Promise.all([ + const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, myWxid, dbPath] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultMedia(), @@ -427,7 +462,17 @@ function ExportPage() { configService.getExportWriteLayout(), configService.getExportLastSessionRunMap(), configService.getExportLastContentRunMap(), - configService.getExportLastSnsPostCount() + configService.getExportLastSnsPostCount(), + configService.getMyWxid(), + configService.getDbPath() + ]) + const exportCacheScope = `${dbPath || ''}::${myWxid || ''}` || 'default' + exportCacheScopeRef.current = exportCacheScope + exportCacheScopeReadyRef.current = true + + const [cachedSessionCountMap, cachedSnsStats] = await Promise.all([ + configService.getExportSessionMessageCountCache(exportCacheScope), + configService.getExportSnsStatsCache(exportCacheScope) ]) if (savedPath) { @@ -442,6 +487,19 @@ function ExportPage() { setLastExportByContent(savedContentMap) setLastSnsExportPostCount(savedSnsPostCount) + if (cachedSessionCountMap && Date.now() - cachedSessionCountMap.updatedAt <= EXPORT_SESSION_COUNT_CACHE_STALE_MS) { + setSessionMessageCounts(cachedSessionCountMap.counts || {}) + } + + if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { + setSnsStats({ + totalPosts: cachedSnsStats.totalPosts || 0, + totalFriends: cachedSnsStats.totalFriends || 0 + }) + hasSeededSnsStatsRef.current = true + setHasSeededSnsStats(true) + } + const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns setOptions(prev => ({ ...prev, @@ -473,20 +531,52 @@ function ExportPage() { } }, []) - const loadSnsStats = useCallback(async () => { - setIsSnsStatsLoading(true) + const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => { + if (!options?.silent) { + setIsSnsStatsLoading(true) + } + + const applyStats = async (next: { totalPosts: number; totalFriends: number } | null) => { + if (!next) return + const normalized = { + totalPosts: Number.isFinite(next.totalPosts) ? Math.max(0, Math.floor(next.totalPosts)) : 0, + totalFriends: Number.isFinite(next.totalFriends) ? Math.max(0, Math.floor(next.totalFriends)) : 0 + } + setSnsStats(normalized) + hasSeededSnsStatsRef.current = true + setHasSeededSnsStats(true) + if (exportCacheScopeReadyRef.current) { + await configService.setExportSnsStatsCache(exportCacheScopeRef.current, normalized) + } + } + try { - const result = await window.electronAPI.sns.getExportStats() - if (result.success && result.data) { - setSnsStats({ - totalPosts: result.data.totalPosts || 0, - totalFriends: result.data.totalFriends || 0 - }) + const fastResult = await withTimeout(window.electronAPI.sns.getExportStatsFast(), 2200) + if (fastResult?.success && fastResult.data) { + const fastStats = { + totalPosts: fastResult.data.totalPosts || 0, + totalFriends: fastResult.data.totalFriends || 0 + } + if (fastStats.totalPosts > 0 || hasSeededSnsStatsRef.current) { + await applyStats(fastStats) + } + } + + if (options?.full) { + const result = await withTimeout(window.electronAPI.sns.getExportStats(), 9000) + if (result?.success && result.data) { + await applyStats({ + totalPosts: result.data.totalPosts || 0, + totalFriends: result.data.totalFriends || 0 + }) + } } } catch (error) { console.error('加载朋友圈导出统计失败:', error) } finally { - setIsSnsStatsLoading(false) + if (!options?.silent) { + setIsSnsStatsLoading(false) + } } }, []) @@ -497,9 +587,7 @@ function ExportPage() { setIsSessionEnriching(false) loadingMessageCountsRef.current.clear() loadingMetricsRef.current.clear() - sessionMessageCountsRef.current = {} sessionMetricsRef.current = {} - setSessionMessageCounts({}) setSessionMetrics({}) const isStale = () => sessionLoadTokenRef.current !== loadToken @@ -530,6 +618,16 @@ function ExportPage() { if (isStale()) return setSessions(baseSessions) + setSessionMessageCounts(prev => { + const next: Record = {} + for (const session of baseSessions) { + const count = prev[session.username] + if (typeof count === 'number') { + next[session.username] = count + } + } + return next + }) setIsLoading(false) // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 @@ -602,8 +700,8 @@ function ExportPage() { // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { - void loadSnsStats() - }, 180) + void loadSnsStats({ full: true }) + }, 120) return () => window.clearTimeout(timer) }, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats]) @@ -666,41 +764,43 @@ function ExportPage() { session => currentCounts[session.username] === undefined && !loadingMessageCountsRef.current.has(session.username) ) if (pending.length === 0) return - - const updates: Record = {} for (const session of pending) { loadingMessageCountsRef.current.add(session.username) } try { - const batchSize = 220 + const batchSize = pending.length > 100 ? 48 : 28 for (let i = 0; i < pending.length; i += batchSize) { if (loadTokenAtStart !== sessionLoadTokenRef.current) return const chunk = pending.slice(i, i + batchSize) const ids = chunk.map(session => session.username) + const chunkUpdates: Record = {} try { - const result = await window.electronAPI.chat.getSessionMessageCounts(ids) + const result = await withTimeout(window.electronAPI.chat.getSessionMessageCounts(ids), 10000) + if (!result) { + continue + } for (const session of chunk) { - const value = result.success && result.counts ? result.counts[session.username] : undefined - updates[session.username] = typeof value === 'number' ? value : 0 + const value = result?.success && result.counts ? result.counts[session.username] : undefined + chunkUpdates[session.username] = typeof value === 'number' ? value : 0 } } catch (error) { console.error('加载会话总消息数失败:', error) for (const session of chunk) { - updates[session.username] = 0 + chunkUpdates[session.username] = 0 } } + + if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(chunkUpdates).length > 0) { + setSessionMessageCounts(prev => ({ ...prev, ...chunkUpdates })) + } } } finally { for (const session of pending) { loadingMessageCountsRef.current.delete(session.username) } } - - if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { - setSessionMessageCounts(prev => ({ ...prev, ...updates })) - } }, []) const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { @@ -787,35 +887,43 @@ function ExportPage() { useEffect(() => { if (sessions.length === 0) return + const prioritySessions = [ + ...sessions.filter(session => session.kind === activeTab), + ...sessions.filter(session => session.kind !== activeTab) + ] let cursor = 0 const timer = window.setInterval(() => { - if (cursor >= sessions.length) { + if (cursor >= prioritySessions.length) { window.clearInterval(timer) return } - const chunk = sessions.slice(cursor, cursor + MESSAGE_COUNT_BACKGROUND_BATCH) + const chunk = prioritySessions.slice(cursor, cursor + MESSAGE_COUNT_BACKGROUND_BATCH) cursor += MESSAGE_COUNT_BACKGROUND_BATCH void ensureSessionMessageCounts(chunk) }, MESSAGE_COUNT_BACKGROUND_INTERVAL_MS) return () => window.clearInterval(timer) - }, [sessions, ensureSessionMessageCounts]) + }, [sessions, activeTab, ensureSessionMessageCounts]) useEffect(() => { if (sessions.length === 0) return + const prioritySessions = [ + ...sessions.filter(session => session.kind === activeTab), + ...sessions.filter(session => session.kind !== activeTab) + ] let cursor = 0 const timer = window.setInterval(() => { - if (cursor >= sessions.length) { + if (cursor >= prioritySessions.length) { window.clearInterval(timer) return } - const chunk = sessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH) + const chunk = prioritySessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH) cursor += METRICS_BACKGROUND_BATCH void ensureSessionMetrics(chunk) }, METRICS_BACKGROUND_INTERVAL_MS) return () => window.clearInterval(timer) - }, [sessions, ensureSessionMetrics]) + }, [sessions, activeTab, ensureSessionMetrics]) const selectedCount = selectedSessions.size @@ -1059,7 +1167,7 @@ function ExportPage() { const mergedExportedCount = Math.max(lastSnsExportPostCount, exportedPosts) setLastSnsExportPostCount(mergedExportedCount) await configService.setExportLastSnsPostCount(mergedExportedCount) - await loadSnsStats() + await loadSnsStats({ full: true }) updateTask(next.id, task => ({ ...task, @@ -1519,6 +1627,7 @@ function ExportPage() { const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0 const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource const isSessionCardStatsLoading = isLoading || isBaseConfigLoading + const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const showInitialSkeleton = isLoading && sessions.length === 0 @@ -1574,7 +1683,7 @@ function ExportPage() { {contentCards.map(card => { const Icon = card.icon const isCardStatsLoading = card.type === 'sns' - ? (isSnsStatsLoading || isBaseConfigLoading) + ? isSnsCardStatsLoading : isSessionCardStatsLoading return (
diff --git a/src/services/config.ts b/src/services/config.ts index 7927939..53969ef 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -36,6 +36,8 @@ export const CONFIG_KEYS = { EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount', + EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', + EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', // 安全 AUTH_ENABLED: 'authEnabled', @@ -449,6 +451,104 @@ export async function setExportLastSnsPostCount(count: number): Promise { await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized) } +export interface ExportSessionMessageCountCacheItem { + updatedAt: number + counts: Record +} + +export interface ExportSnsStatsCacheItem { + updatedAt: number + totalPosts: number + totalFriends: number +} + +export async function getExportSessionMessageCountCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const rawUpdatedAt = (rawItem as Record).updatedAt + const rawCounts = (rawItem as Record).counts + if (!rawCounts || typeof rawCounts !== 'object') return null + + const counts: Record = {} + for (const [sessionId, countRaw] of Object.entries(rawCounts as Record)) { + if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) { + counts[sessionId] = Math.floor(countRaw) + } + } + + return { + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + counts + } +} + +export async function setExportSessionMessageCountCache(scopeKey: string, counts: Record): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: Record = {} + for (const [sessionId, countRaw] of Object.entries(counts || {})) { + if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) { + normalized[sessionId] = Math.floor(countRaw) + } + } + + map[scopeKey] = { + updatedAt: Date.now(), + counts: normalized + } + await config.set(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP, map) +} + +export async function getExportSnsStatsCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const raw = rawItem as Record + const totalPosts = typeof raw.totalPosts === 'number' && Number.isFinite(raw.totalPosts) && raw.totalPosts >= 0 + ? Math.floor(raw.totalPosts) + : 0 + const totalFriends = typeof raw.totalFriends === 'number' && Number.isFinite(raw.totalFriends) && raw.totalFriends >= 0 + ? Math.floor(raw.totalFriends) + : 0 + const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) + ? raw.updatedAt + : 0 + + return { updatedAt, totalPosts, totalFriends } +} + +export async function setExportSnsStatsCache( + scopeKey: string, + stats: { totalPosts: number; totalFriends: number } +): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + map[scopeKey] = { + updatedAt: Date.now(), + totalPosts: Number.isFinite(stats.totalPosts) ? Math.max(0, Math.floor(stats.totalPosts)) : 0, + totalFriends: Number.isFinite(stats.totalFriends) ? Math.max(0, Math.floor(stats.totalFriends)) : 0 + } + + await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map) +} + // === 安全相关 === export async function getAuthEnabled(): Promise { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 471ac70..e638331 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -554,6 +554,7 @@ export interface ElectronAPI { onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> + getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> From a8eb0057e392cc8201ac88fbf3a759c51608fc53 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 17:55:49 +0800 Subject: [PATCH 017/155] perf(export): keep page alive across route switches --- src/App.scss | 13 +++++++++++++ src/App.tsx | 16 +++++++++++++++- src/pages/ExportPage.tsx | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/App.scss b/src/App.scss index 3c137bd..5cffbbd 100644 --- a/src/App.scss +++ b/src/App.scss @@ -69,6 +69,19 @@ flex: 1; overflow: auto; padding: 24px; + position: relative; +} + +.export-keepalive-page { + height: 100%; + + &.hidden { + display: none; + } +} + +.export-route-anchor { + display: none; } @keyframes appFadeIn { diff --git a/src/App.tsx b/src/App.tsx index c999a80..adc32cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,7 +61,9 @@ function App() { const isVideoPlayerWindow = location.pathname === '/video-player-window' const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') const isNotificationWindow = location.pathname === '/notification-window' + const isExportRoute = location.pathname === '/export' const [themeHydrated, setThemeHydrated] = useState(false) + const [hasVisitedExport, setHasVisitedExport] = useState(isExportRoute) // 锁定状态 // const [isLocked, setIsLocked] = useState(false) // Moved to store @@ -99,6 +101,12 @@ function App() { } }, [isOnboardingWindow]) + useEffect(() => { + if (isExportRoute) { + setHasVisitedExport(true) + } + }, [isExportRoute]) + // 应用主题 useEffect(() => { const mq = window.matchMedia('(prefers-color-scheme: dark)') @@ -454,6 +462,12 @@ function App() {
+ {hasVisitedExport && ( +
+ +
+ )} + } /> } /> @@ -468,7 +482,7 @@ function App() { } /> } /> - } /> +
{/* 折叠群 header */} @@ -2093,7 +2160,7 @@ function ChatPage(_props: ChatPageProps) { )} {/* ... (previous content) ... */} - {isLoadingSessions ? ( + {shouldShowSessionsSkeleton ? (
{[1, 2, 3, 4, 5].map(i => (
@@ -2311,11 +2378,11 @@ function ChatPage(_props: ChatPageProps) {
-
- {isLoadingMessages && !hasInitialMessages && ( +
+ {isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
- 加载消息中... + {isSessionSwitching ? '切换会话中...' : '加载消息中...'}
)}
void setSessions: (sessions: ChatSession[]) => void setFilteredSessions: (sessions: ChatSession[]) => void - setCurrentSession: (sessionId: string | null) => void + setCurrentSession: (sessionId: string | null, options?: { preserveMessages?: boolean }) => void setLoadingSessions: (loading: boolean) => void setMessages: (messages: Message[]) => void appendMessages: (messages: Message[], prepend?: boolean) => void @@ -69,12 +69,12 @@ export const useChatStore = create((set, get) => ({ setSessions: (sessions) => set({ sessions, filteredSessions: sessions }), setFilteredSessions: (sessions) => set({ filteredSessions: sessions }), - setCurrentSession: (sessionId) => set({ + setCurrentSession: (sessionId, options) => set((state) => ({ currentSessionId: sessionId, - messages: [], + messages: options?.preserveMessages ? state.messages : [], hasMoreMessages: true, hasMoreLater: false - }), + })), setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e638331..8f8f6f1 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -74,6 +74,11 @@ export interface ElectronAPI { chat: { connect: () => Promise<{ success: boolean; error?: string }> getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> + getSessionStatuses: (usernames: string[]) => Promise<{ + success: boolean + map?: Record + error?: string + }> getExportTabCounts: () => Promise<{ success: boolean counts?: { From a5ae22d2a5cd11fb3496f166453c6bc7351f02ff Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 18:41:06 +0800 Subject: [PATCH 021/155] perf(chat): split session detail into fast and extra loading --- electron/main.ts | 8 + electron/preload.ts | 2 + electron/services/chatService.ts | 254 ++++++++++++++++++++++++------- src/pages/ChatPage.scss | 8 + src/pages/ChatPage.tsx | 146 +++++++++++++----- src/types/electron.d.ts | 22 +++ 6 files changed, 344 insertions(+), 96 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index a1ac68f..e73a715 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -986,6 +986,14 @@ function registerIpcHandlers() { return chatService.getSessionDetail(sessionId) }) + ipcMain.handle('chat:getSessionDetailFast', async (_, sessionId: string) => { + return chatService.getSessionDetailFast(sessionId) + }) + + ipcMain.handle('chat:getSessionDetailExtra', async (_, sessionId: string) => { + return chatService.getSessionDetailExtra(sessionId) + }) + ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[]) => { return chatService.getExportSessionStats(sessionIds) }) diff --git a/electron/preload.ts b/electron/preload.ts index 7a3c0af..999486f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -154,6 +154,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), close: () => ipcRenderer.invoke('chat:close'), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), + getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId), + getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), getExportSessionStats: (sessionIds: string[]) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index d5db221..0e655d3 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -159,6 +159,24 @@ interface ExportTabCounts { former_friend: number } +interface SessionDetailFast { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number +} + +interface SessionDetailExtra { + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] +} + +type SessionDetail = SessionDetailFast & SessionDetailExtra + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() @@ -201,6 +219,10 @@ class ChatService { private sessionMessageCountHintCache = new Map() private sessionMessageCountCacheScope = '' private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000 + private sessionDetailFastCache = new Map() + private sessionDetailExtraCache = new Map() + private readonly sessionDetailFastCacheTtlMs = 60 * 1000 + private readonly sessionDetailExtraCacheTtlMs = 5 * 60 * 1000 private sessionStatusCache = new Map() private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000 @@ -1565,6 +1587,8 @@ class ChatService { this.sessionMessageCountCacheScope = scope this.sessionMessageCountCache.clear() this.sessionMessageCountHintCache.clear() + this.sessionDetailFastCache.clear() + this.sessionDetailExtraCache.clear() this.sessionStatusCache.clear() } @@ -3819,20 +3843,9 @@ class ChatService { /** * 获取会话详情信息 */ - async getSessionDetail(sessionId: string): Promise<{ + async getSessionDetailFast(sessionId: string): Promise<{ success: boolean - detail?: { - wxid: string - displayName: string - remark?: string - nickName?: string - alias?: string - avatarUrl?: string - messageCount: number - firstMessageTime?: number - latestMessageTime?: number - messageTables: { dbName: string; tableName: string; count: number }[] - } + detail?: SessionDetailFast error?: string }> { try { @@ -3840,53 +3853,152 @@ class ChatService { if (!connectResult.success) { return { success: false, error: connectResult.error || '数据库未连接' } } + this.refreshSessionMessageCountCacheScope() - let displayName = sessionId + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) { + return { success: false, error: '会话ID不能为空' } + } + + const now = Date.now() + const cachedDetail = this.sessionDetailFastCache.get(normalizedSessionId) + if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailFastCacheTtlMs) { + return { success: true, detail: cachedDetail.detail } + } + + let displayName = normalizedSessionId let remark: string | undefined let nickName: string | undefined let alias: string | undefined let avatarUrl: string | undefined - - const contactResult = await wcdbService.getContact(sessionId) - if (contactResult.success && contactResult.contact) { - remark = contactResult.contact.remark || undefined - nickName = contactResult.contact.nickName || undefined - alias = contactResult.contact.alias || undefined - displayName = remark || nickName || alias || sessionId - } - const avatarResult = await wcdbService.getAvatarUrls([sessionId]) - if (avatarResult.success && avatarResult.map) { - avatarUrl = avatarResult.map[sessionId] + const cachedContact = this.avatarCache.get(normalizedSessionId) + if (cachedContact) { + displayName = cachedContact.displayName || normalizedSessionId + avatarUrl = cachedContact.avatarUrl } - const countResult = await wcdbService.getMessageCount(sessionId) - const totalMessageCount = countResult.success && countResult.count ? countResult.count : 0 + const [contactResult, avatarResult] = await Promise.allSettled([ + wcdbService.getContact(normalizedSessionId), + avatarUrl ? Promise.resolve({ success: true, map: { [normalizedSessionId]: avatarUrl } }) : wcdbService.getAvatarUrls([normalizedSessionId]) + ]) - let firstMessageTime: number | undefined - let latestMessageTime: number | undefined + if (contactResult.status === 'fulfilled' && contactResult.value.success && contactResult.value.contact) { + remark = contactResult.value.contact.remark || undefined + nickName = contactResult.value.contact.nickName || undefined + alias = contactResult.value.contact.alias || undefined + displayName = remark || nickName || alias || displayName + } - const earliestCursor = await wcdbService.openMessageCursor(sessionId, 1, true, 0, 0) - if (earliestCursor.success && earliestCursor.cursor) { - const batch = await wcdbService.fetchMessageBatch(earliestCursor.cursor) - if (batch.success && batch.rows && batch.rows.length > 0) { - firstMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined + if (avatarResult.status === 'fulfilled' && avatarResult.value.success && avatarResult.value.map) { + avatarUrl = avatarResult.value.map[normalizedSessionId] + } + + let messageCount: number | undefined + const cachedCount = this.sessionMessageCountCache.get(normalizedSessionId) + if (cachedCount && now - cachedCount.updatedAt <= this.sessionMessageCountCacheTtlMs) { + messageCount = cachedCount.count + } else { + const hintCount = this.sessionMessageCountHintCache.get(normalizedSessionId) + if (typeof hintCount === 'number' && Number.isFinite(hintCount) && hintCount >= 0) { + messageCount = Math.floor(hintCount) + this.sessionMessageCountCache.set(normalizedSessionId, { + count: messageCount, + updatedAt: now + }) } - await wcdbService.closeMessageCursor(earliestCursor.cursor) } - const latestCursor = await wcdbService.openMessageCursor(sessionId, 1, false, 0, 0) - if (latestCursor.success && latestCursor.cursor) { - const batch = await wcdbService.fetchMessageBatch(latestCursor.cursor) - if (batch.success && batch.rows && batch.rows.length > 0) { - latestMessageTime = parseInt(batch.rows[0].create_time || '0', 10) || undefined - } - await wcdbService.closeMessageCursor(latestCursor.cursor) + if (!Number.isFinite(messageCount)) { + const countResult = await wcdbService.getMessageCount(normalizedSessionId) + messageCount = countResult.success && Number.isFinite(countResult.count) + ? Math.max(0, Math.floor(countResult.count || 0)) + : 0 + this.sessionMessageCountCache.set(normalizedSessionId, { + count: messageCount, + updatedAt: Date.now() + }) } + const detail: SessionDetailFast = { + wxid: normalizedSessionId, + displayName, + remark, + nickName, + alias, + avatarUrl, + messageCount: Math.max(0, Math.floor(messageCount || 0)) + } + + this.sessionDetailFastCache.set(normalizedSessionId, { + detail, + updatedAt: Date.now() + }) + + return { success: true, detail } + } catch (e) { + console.error('ChatService: 获取会话详情快速信息失败:', e) + return { success: false, error: String(e) } + } + } + + private async getBoundaryMessageTime(sessionId: string, ascending: boolean): Promise { + const cursorResult = await wcdbService.openMessageCursor(sessionId, 1, ascending, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return undefined + } + + const cursor = cursorResult.cursor + try { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success || !batch.rows || batch.rows.length === 0) { + return undefined + } + const ts = parseInt(batch.rows[0].create_time || '0', 10) + return Number.isFinite(ts) && ts > 0 ? ts : undefined + } finally { + await wcdbService.closeMessageCursor(cursor) + } + } + + async getSessionDetailExtra(sessionId: string): Promise<{ + success: boolean + detail?: SessionDetailExtra + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + this.refreshSessionMessageCountCacheScope() + + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) { + return { success: false, error: '会话ID不能为空' } + } + + const now = Date.now() + const cachedDetail = this.sessionDetailExtraCache.get(normalizedSessionId) + if (cachedDetail && now - cachedDetail.updatedAt <= this.sessionDetailExtraCacheTtlMs) { + return { success: true, detail: cachedDetail.detail } + } + + const [firstMessageTimeResult, latestMessageTimeResult, tableStatsResult] = await Promise.allSettled([ + this.getBoundaryMessageTime(normalizedSessionId, true), + this.getBoundaryMessageTime(normalizedSessionId, false), + wcdbService.getMessageTableStats(normalizedSessionId) + ]) + + const firstMessageTime = firstMessageTimeResult.status === 'fulfilled' + ? firstMessageTimeResult.value + : undefined + const latestMessageTime = latestMessageTimeResult.status === 'fulfilled' + ? latestMessageTimeResult.value + : undefined + const messageTables: { dbName: string; tableName: string; count: number }[] = [] - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (tableStats.success && tableStats.tables) { - for (const row of tableStats.tables) { + if (tableStatsResult.status === 'fulfilled' && tableStatsResult.value.success && tableStatsResult.value.tables) { + for (const row of tableStatsResult.value.tables) { messageTables.push({ dbName: basename(row.db_path || ''), tableName: row.table_name || '', @@ -3895,21 +4007,49 @@ class ChatService { } } + const detail: SessionDetailExtra = { + firstMessageTime, + latestMessageTime, + messageTables + } + + this.sessionDetailExtraCache.set(normalizedSessionId, { + detail, + updatedAt: Date.now() + }) + return { success: true, - detail: { - wxid: sessionId, - displayName, - remark, - nickName, - alias, - avatarUrl, - messageCount: totalMessageCount, - firstMessageTime, - latestMessageTime, - messageTables - } + detail } + } catch (e) { + console.error('ChatService: 获取会话详情补充统计失败:', e) + return { success: false, error: String(e) } + } + } + + async getSessionDetail(sessionId: string): Promise<{ + success: boolean + detail?: SessionDetail + error?: string + }> { + try { + const fastResult = await this.getSessionDetailFast(sessionId) + if (!fastResult.success || !fastResult.detail) { + return { success: false, error: fastResult.error || '获取会话详情失败' } + } + + const extraResult = await this.getSessionDetailExtra(sessionId) + const detail: SessionDetail = { + ...fastResult.detail, + firstMessageTime: extraResult.success ? extraResult.detail?.firstMessageTime : undefined, + latestMessageTime: extraResult.success ? extraResult.detail?.latestMessageTime : undefined, + messageTables: extraResult.success && extraResult.detail?.messageTables + ? extraResult.detail.messageTables + : [] + } + + return { success: true, detail } } catch (e) { console.error('ChatService: 获取会话详情失败:', e) return { success: false, error: String(e) } diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 01bb85e..d54b2c4 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2766,6 +2766,14 @@ gap: 8px; } + .detail-table-placeholder { + padding: 10px 12px; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 12px; + color: var(--text-secondary); + } + .table-item { display: flex; align-items: center; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index cc351f6..6e4f282 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -312,6 +312,7 @@ function ChatPage(_props: ChatPageProps) { const [showDetailPanel, setShowDetailPanel] = useState(false) const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingDetail, setIsLoadingDetail] = useState(false) + const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false) const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) @@ -386,6 +387,7 @@ function ChatPage(_props: ChatPageProps) { const searchKeywordRef = useRef('') const preloadImageKeysRef = useRef>(new Set()) const lastPreloadSessionRef = useRef(null) + const detailRequestSeqRef = useRef(0) // 加载当前用户头像 const loadMyAvatar = useCallback(async () => { @@ -401,25 +403,91 @@ function ChatPage(_props: ChatPageProps) { // 加载会话详情 const loadSessionDetail = useCallback(async (sessionId: string) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + + const requestSeq = ++detailRequestSeqRef.current + const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId) + const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0 + ? Math.floor(mappedSession.messageCountHint) + : undefined + + setSessionDetail((prev) => { + const sameSession = prev?.wxid === normalizedSessionId + return { + wxid: normalizedSessionId, + displayName: mappedSession?.displayName || prev?.displayName || normalizedSessionId, + remark: sameSession ? prev?.remark : undefined, + nickName: sameSession ? prev?.nickName : undefined, + alias: sameSession ? prev?.alias : undefined, + avatarUrl: mappedSession?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), + messageCount: hintedCount ?? (sameSession ? prev.messageCount : Number.NaN), + firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, + latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, + messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] + } + }) setIsLoadingDetail(true) + setIsLoadingDetailExtra(true) + try { - const result = await window.electronAPI.chat.getSessionDetail(sessionId) + const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) + if (requestSeq !== detailRequestSeqRef.current) return if (result.success && result.detail) { - setSessionDetail(result.detail) + setSessionDetail((prev) => ({ + wxid: normalizedSessionId, + displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId, + remark: result.detail!.remark, + nickName: result.detail!.nickName, + alias: result.detail!.alias, + avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, + messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, + firstMessageTime: prev?.firstMessageTime, + latestMessageTime: prev?.latestMessageTime, + messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] + })) } } catch (e) { console.error('加载会话详情失败:', e) } finally { - setIsLoadingDetail(false) + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingDetail(false) + } + } + + try { + const result = await window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId) + if (requestSeq !== detailRequestSeqRef.current) return + if (result.success && result.detail) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + firstMessageTime: result.detail!.firstMessageTime, + latestMessageTime: result.detail!.latestMessageTime, + messageTables: Array.isArray(result.detail!.messageTables) ? result.detail!.messageTables : [] + } + }) + } + } catch (e) { + console.error('加载会话详情补充统计失败:', e) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingDetailExtra(false) + } } }, []) // 切换详情面板 const toggleDetailPanel = useCallback(() => { - if (!showDetailPanel && currentSessionId) { - loadSessionDetail(currentSessionId) + if (showDetailPanel) { + setShowDetailPanel(false) + return + } + setShowDetailPanel(true) + if (currentSessionId) { + void loadSessionDetail(currentSessionId) } - setShowDetailPanel(!showDetailPanel) }, [showDetailPanel, currentSessionId, loadSessionDetail]) // 复制字段值到剪贴板 @@ -1107,7 +1175,7 @@ function ChatPage(_props: ChatPageProps) { // 重置详情面板 setSessionDetail(null) if (showDetailPanel) { - loadSessionDetail(session.username) + void loadSessionDetail(session.username) } } @@ -2475,7 +2543,7 @@ function ChatPage(_props: ChatPageProps) {
- {isLoadingDetail ? ( + {isLoadingDetail && !sessionDetail ? (
加载中... @@ -2530,39 +2598,35 @@ function ChatPage(_props: ChatPageProps) { {Number.isFinite(sessionDetail.messageCount) ? sessionDetail.messageCount.toLocaleString() - : '—'} + : (isLoadingDetail ? '统计中...' : '—')} + +
+
+ + 首条消息 + + {Number.isFinite(sessionDetail.firstMessageTime) + ? new Date((sessionDetail.firstMessageTime as number) * 1000).toLocaleDateString('zh-CN') + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ + 最新消息 + + {Number.isFinite(sessionDetail.latestMessageTime) + ? new Date((sessionDetail.latestMessageTime as number) * 1000).toLocaleDateString('zh-CN') + : (isLoadingDetailExtra ? '统计中...' : '—')}
- {sessionDetail.firstMessageTime && ( -
- - 首条消息 - - {Number.isFinite(sessionDetail.firstMessageTime) - ? new Date(sessionDetail.firstMessageTime * 1000).toLocaleDateString('zh-CN') - : '—'} - -
- )} - {sessionDetail.latestMessageTime && ( -
- - 最新消息 - - {Number.isFinite(sessionDetail.latestMessageTime) - ? new Date(sessionDetail.latestMessageTime * 1000).toLocaleDateString('zh-CN') - : '—'} - -
- )}
- {Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 && ( -
-
- - 数据库分布 -
+
+
+ + 数据库分布 +
+ {Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? (
{sessionDetail.messageTables.map((t, i) => (
@@ -2571,8 +2635,12 @@ function ChatPage(_props: ChatPageProps) {
))}
-
- )} + ) : ( +
+ {isLoadingDetailExtra ? '统计中...' : '暂无统计数据'} +
+ )} +
) : (
暂无详情
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 8f8f6f1..88bc819 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -144,6 +144,28 @@ export interface ElectronAPI { } error?: string }> + getSessionDetailFast: (sessionId: string) => Promise<{ + success: boolean + detail?: { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number + } + error?: string + }> + getSessionDetailExtra: (sessionId: string) => Promise<{ + success: boolean + detail?: { + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] + } + error?: string + }> getExportSessionStats: (sessionIds: string[]) => Promise<{ success: boolean data?: Record Date: Sun, 1 Mar 2026 18:45:04 +0800 Subject: [PATCH 022/155] feat(chat): show export-table metrics in session detail sidebar --- src/pages/ChatPage.tsx | 177 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 160 insertions(+), 17 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 6e4f282..1536157 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -142,6 +142,15 @@ function cleanMessageContent(content: string): string { return content.trim() } +function formatYmdDateFromSeconds(timestamp?: number): string { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const d = new Date(timestamp * 1000) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + return `${y}-${m}-${day}` +} + interface ChatPageProps { // 保留接口以备将来扩展 } @@ -155,6 +164,15 @@ interface SessionDetail { alias?: string avatarUrl?: string messageCount: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number firstMessageTime?: number latestMessageTime?: number messageTables: { dbName: string; tableName: string; count: number }[] @@ -422,6 +440,15 @@ function ChatPage(_props: ChatPageProps) { alias: sameSession ? prev?.alias : undefined, avatarUrl: mappedSession?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), messageCount: hintedCount ?? (sameSession ? prev.messageCount : Number.NaN), + voiceMessages: sameSession ? prev?.voiceMessages : undefined, + imageMessages: sameSession ? prev?.imageMessages : undefined, + videoMessages: sameSession ? prev?.videoMessages : undefined, + emojiMessages: sameSession ? prev?.emojiMessages : undefined, + privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined, + groupMemberCount: sameSession ? prev?.groupMemberCount : undefined, + groupMyMessages: sameSession ? prev?.groupMyMessages : undefined, + groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined, + groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined, firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] @@ -442,6 +469,15 @@ function ChatPage(_props: ChatPageProps) { alias: result.detail!.alias, avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, + voiceMessages: prev?.voiceMessages, + imageMessages: prev?.imageMessages, + videoMessages: prev?.videoMessages, + emojiMessages: prev?.emojiMessages, + privateMutualGroups: prev?.privateMutualGroups, + groupMemberCount: prev?.groupMemberCount, + groupMyMessages: prev?.groupMyMessages, + groupActiveSpeakers: prev?.groupActiveSpeakers, + groupMutualFriends: prev?.groupMutualFriends, firstMessageTime: prev?.firstMessageTime, latestMessageTime: prev?.latestMessageTime, messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] @@ -456,19 +492,49 @@ function ChatPage(_props: ChatPageProps) { } try { - const result = await window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId) + const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ + window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), + window.electronAPI.chat.getExportSessionStats([normalizedSessionId]) + ]) + if (requestSeq !== detailRequestSeqRef.current) return - if (result.success && result.detail) { - setSessionDetail((prev) => { - if (!prev || prev.wxid !== normalizedSessionId) return prev - return { - ...prev, - firstMessageTime: result.detail!.firstMessageTime, - latestMessageTime: result.detail!.latestMessageTime, - messageTables: Array.isArray(result.detail!.messageTables) ? result.detail!.messageTables : [] + + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + + let next = { ...prev } + if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) { + next = { + ...next, + firstMessageTime: extraResultSettled.value.detail.firstMessageTime, + latestMessageTime: extraResultSettled.value.detail.latestMessageTime, + messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : [] } - }) - } + } + + if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) { + const metric = statsResultSettled.value.data[normalizedSessionId] + if (metric) { + next = { + ...next, + messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount, + voiceMessages: metric.voiceMessages, + imageMessages: metric.imageMessages, + videoMessages: metric.videoMessages, + emojiMessages: metric.emojiMessages, + privateMutualGroups: metric.privateMutualGroups, + groupMemberCount: metric.groupMemberCount, + groupMyMessages: metric.groupMyMessages, + groupActiveSpeakers: metric.groupActiveSpeakers, + groupMutualFriends: metric.groupMutualFriends, + firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime, + latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime + } + } + } + + return next + }) } catch (e) { console.error('加载会话详情补充统计失败:', e) } finally { @@ -2591,22 +2657,99 @@ function ChatPage(_props: ChatPageProps) {
- 消息统计 + 消息统计(导出口径)
消息总数 {Number.isFinite(sessionDetail.messageCount) ? sessionDetail.messageCount.toLocaleString() - : (isLoadingDetail ? '统计中...' : '—')} + : ((isLoadingDetail || isLoadingDetailExtra) ? '统计中...' : '—')}
+
+ 语音 + + {Number.isFinite(sessionDetail.voiceMessages) + ? (sessionDetail.voiceMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 图片 + + {Number.isFinite(sessionDetail.imageMessages) + ? (sessionDetail.imageMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 视频 + + {Number.isFinite(sessionDetail.videoMessages) + ? (sessionDetail.videoMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 表情包 + + {Number.isFinite(sessionDetail.emojiMessages) + ? (sessionDetail.emojiMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+ {sessionDetail.wxid.includes('@chatroom') ? ( + <> +
+ 我发的消息数 + + {Number.isFinite(sessionDetail.groupMyMessages) + ? (sessionDetail.groupMyMessages as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 群人数 + + {Number.isFinite(sessionDetail.groupMemberCount) + ? (sessionDetail.groupMemberCount as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 群发言人数 + + {Number.isFinite(sessionDetail.groupActiveSpeakers) + ? (sessionDetail.groupActiveSpeakers as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+
+ 群共同好友数 + + {Number.isFinite(sessionDetail.groupMutualFriends) + ? (sessionDetail.groupMutualFriends as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+ + ) : ( +
+ 共同群聊数 + + {Number.isFinite(sessionDetail.privateMutualGroups) + ? (sessionDetail.privateMutualGroups as number).toLocaleString() + : (isLoadingDetailExtra ? '统计中...' : '—')} + +
+ )}
首条消息 - {Number.isFinite(sessionDetail.firstMessageTime) - ? new Date((sessionDetail.firstMessageTime as number) * 1000).toLocaleDateString('zh-CN') + {sessionDetail.firstMessageTime + ? formatYmdDateFromSeconds(sessionDetail.firstMessageTime) : (isLoadingDetailExtra ? '统计中...' : '—')}
@@ -2614,8 +2757,8 @@ function ChatPage(_props: ChatPageProps) { 最新消息 - {Number.isFinite(sessionDetail.latestMessageTime) - ? new Date((sessionDetail.latestMessageTime as number) * 1000).toLocaleDateString('zh-CN') + {sessionDetail.latestMessageTime + ? formatYmdDateFromSeconds(sessionDetail.latestMessageTime) : (isLoadingDetailExtra ? '统计中...' : '—')}
From a87d4198687d45a0c3839bd6eb6dc13521f9d81e Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 18:48:03 +0800 Subject: [PATCH 023/155] fix(chat): collapse detail panel when switching sessions --- src/pages/ChatPage.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 1536157..b9b4def 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1238,11 +1238,9 @@ function ChatPage(_props: ChatPageProps) { setJumpStartTime(0) setJumpEndTime(0) void loadMessages(session.username, 0, 0, 0) - // 重置详情面板 + // 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开 + setShowDetailPanel(false) setSessionDetail(null) - if (showDetailPanel) { - void loadSessionDetail(session.username) - } } // 搜索过滤 From ac61ee183306448bf73b20c77f309625cbf35043 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 18:55:39 +0800 Subject: [PATCH 024/155] perf(chat): add local session list and preview cache hydration --- src/pages/ChatPage.tsx | 247 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 244 insertions(+), 3 deletions(-) diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b9b4def..a8c2f36 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -142,6 +142,35 @@ function cleanMessageContent(content: string): string { return content.trim() } +const CHAT_SESSION_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30 +const CHAT_SESSION_PREVIEW_MAX_SESSIONS = 18 + +function buildChatSessionListCacheKey(scope: string): string { + return `weflow.chat.sessions.v1::${scope || 'default'}` +} + +function buildChatSessionPreviewCacheKey(scope: string): string { + return `weflow.chat.preview.v1::${scope || 'default'}` +} + +function normalizeChatCacheScope(dbPath: unknown, wxid: unknown): string { + const db = String(dbPath || '').trim() + const id = String(wxid || '').trim() + if (!db && !id) return 'default' + return `${db}::${id}` +} + +function safeParseJson(raw: string | null): T | null { + if (!raw) return null + try { + return JSON.parse(raw) as T + } catch { + return null + } +} + function formatYmdDateFromSeconds(timestamp?: number): string { if (!timestamp || !Number.isFinite(timestamp)) return '—' const d = new Date(timestamp * 1000) @@ -178,6 +207,21 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: number }[] } +interface SessionListCachePayload { + updatedAt: number + sessions: ChatSession[] +} + +interface SessionPreviewCacheEntry { + updatedAt: number + messages: Message[] +} + +interface SessionPreviewCachePayload { + updatedAt: number + entries: Record +} + // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts import { avatarLoadQueue } from '../utils/AvatarLoadQueue' @@ -406,6 +450,10 @@ function ChatPage(_props: ChatPageProps) { const preloadImageKeysRef = useRef>(new Set()) const lastPreloadSessionRef = useRef(null) const detailRequestSeqRef = useRef(0) + const chatCacheScopeRef = useRef('default') + const previewCacheRef = useRef>({}) + const previewPersistTimerRef = useRef(null) + const sessionListPersistTimerRef = useRef(null) // 加载当前用户头像 const loadMyAvatar = useCallback(async () => { @@ -419,6 +467,150 @@ function ChatPage(_props: ChatPageProps) { } }, []) + const resolveChatCacheScope = useCallback(async (): Promise => { + try { + const [dbPath, myWxid] = await Promise.all([ + window.electronAPI.config.get('dbPath'), + window.electronAPI.config.get('myWxid') + ]) + const scope = normalizeChatCacheScope(dbPath, myWxid) + chatCacheScopeRef.current = scope + return scope + } catch { + chatCacheScopeRef.current = 'default' + return 'default' + } + }, []) + + const loadPreviewCacheFromStorage = useCallback((scope: string): Record => { + try { + const cacheKey = buildChatSessionPreviewCacheKey(scope) + const payload = safeParseJson(window.localStorage.getItem(cacheKey)) + if (!payload || typeof payload.updatedAt !== 'number' || !payload.entries) { + return {} + } + if (Date.now() - payload.updatedAt > CHAT_SESSION_PREVIEW_CACHE_TTL_MS) { + return {} + } + return payload.entries + } catch { + return {} + } + }, []) + + const persistPreviewCacheToStorage = useCallback((scope: string, entries: Record) => { + try { + const cacheKey = buildChatSessionPreviewCacheKey(scope) + const payload: SessionPreviewCachePayload = { + updatedAt: Date.now(), + entries + } + window.localStorage.setItem(cacheKey, JSON.stringify(payload)) + } catch { + // ignore cache write failures + } + }, []) + + const persistSessionPreviewCache = useCallback((sessionId: string, previewMessages: Message[]) => { + const id = String(sessionId || '').trim() + if (!id || !Array.isArray(previewMessages) || previewMessages.length === 0) return + + const trimmed = previewMessages.slice(-CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION) + const currentEntries = { ...previewCacheRef.current } + currentEntries[id] = { + updatedAt: Date.now(), + messages: trimmed + } + + const sortedIds = Object.entries(currentEntries) + .sort((a, b) => (b[1]?.updatedAt || 0) - (a[1]?.updatedAt || 0)) + .map(([entryId]) => entryId) + + const keptIds = new Set(sortedIds.slice(0, CHAT_SESSION_PREVIEW_MAX_SESSIONS)) + const compactEntries: Record = {} + for (const [entryId, entry] of Object.entries(currentEntries)) { + if (keptIds.has(entryId)) { + compactEntries[entryId] = entry + } + } + + previewCacheRef.current = compactEntries + if (previewPersistTimerRef.current !== null) { + window.clearTimeout(previewPersistTimerRef.current) + } + previewPersistTimerRef.current = window.setTimeout(() => { + persistPreviewCacheToStorage(chatCacheScopeRef.current, previewCacheRef.current) + previewPersistTimerRef.current = null + }, 220) + }, [persistPreviewCacheToStorage]) + + const hydrateSessionPreview = useCallback(async (sessionId: string) => { + const id = String(sessionId || '').trim() + if (!id) return + + const localEntry = previewCacheRef.current[id] + if ( + localEntry && + Array.isArray(localEntry.messages) && + localEntry.messages.length > 0 && + Date.now() - localEntry.updatedAt <= CHAT_SESSION_PREVIEW_CACHE_TTL_MS + ) { + setMessages(localEntry.messages.slice()) + setHasInitialMessages(true) + return + } + + try { + const result = await window.electronAPI.chat.getCachedMessages(id) + if (!result.success || !Array.isArray(result.messages) || result.messages.length === 0) { + return + } + if (currentSessionRef.current !== id && pendingSessionLoadRef.current !== id) return + setMessages(result.messages) + setHasInitialMessages(true) + persistSessionPreviewCache(id, result.messages) + } catch { + // ignore preview cache errors + } + }, [persistSessionPreviewCache, setMessages]) + + const hydrateSessionListCache = useCallback((scope: string): boolean => { + try { + const cacheKey = buildChatSessionListCacheKey(scope) + const payload = safeParseJson(window.localStorage.getItem(cacheKey)) + if (!payload || typeof payload.updatedAt !== 'number' || !Array.isArray(payload.sessions)) { + previewCacheRef.current = loadPreviewCacheFromStorage(scope) + return false + } + previewCacheRef.current = loadPreviewCacheFromStorage(scope) + if (Date.now() - payload.updatedAt > CHAT_SESSION_LIST_CACHE_TTL_MS) { + return false + } + if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) { + setSessions(payload.sessions) + sessionsRef.current = payload.sessions + return payload.sessions.length > 0 + } + return false + } catch { + previewCacheRef.current = loadPreviewCacheFromStorage(scope) + return false + } + }, [loadPreviewCacheFromStorage, setSessions]) + + const persistSessionListCache = useCallback((scope: string, nextSessions: ChatSession[]) => { + try { + const cacheKey = buildChatSessionListCacheKey(scope) + const payload: SessionListCachePayload = { + updatedAt: Date.now(), + sessions: nextSessions + } + window.localStorage.setItem(cacheKey, JSON.stringify(payload)) + } catch { + // ignore cache write failures + } + }, []) + // 加载会话详情 const loadSessionDetail = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() @@ -580,11 +772,12 @@ function ChatPage(_props: ChatPageProps) { setConnecting(true) setConnectionError(null) try { + const scopePromise = resolveChatCacheScope() const result = await window.electronAPI.chat.connect() if (result.success) { setConnected(true) const wxidPromise = window.electronAPI.config.get('myWxid') - await Promise.all([loadSessions(), loadMyAvatar()]) + await Promise.all([scopePromise, loadSessions(), loadMyAvatar()]) // 获取 myWxid 用于匹配个人头像 const wxid = await wxidPromise if (wxid) setMyWxid(wxid as string) @@ -596,7 +789,7 @@ function ChatPage(_props: ChatPageProps) { } finally { setConnecting(false) } - }, [loadMyAvatar]) + }, [loadMyAvatar, resolveChatCacheScope]) const handleAccountChanged = useCallback(async () => { senderAvatarCache.clear() @@ -616,9 +809,13 @@ function ChatPage(_props: ChatPageProps) { setConnecting(false) setHasMoreMessages(true) setHasMoreLater(false) + const scope = await resolveChatCacheScope() + hydrateSessionListCache(scope) await connect() }, [ connect, + resolveChatCacheScope, + hydrateSessionListCache, setConnected, setConnecting, setConnectionError, @@ -632,6 +829,19 @@ function ChatPage(_props: ChatPageProps) { setSessions ]) + useEffect(() => { + let cancelled = false + void (async () => { + const scope = await resolveChatCacheScope() + if (cancelled) return + hydrateSessionListCache(scope) + })() + + return () => { + cancelled = true + } + }, [resolveChatCacheScope, hydrateSessionListCache]) + // 同步 currentSessionId 到 ref useEffect(() => { currentSessionRef.current = currentSessionId @@ -684,6 +894,7 @@ function ChatPage(_props: ChatPageProps) { setLoadingSessions(true) } try { + const scope = await resolveChatCacheScope() const result = await window.electronAPI.chat.getSessions() if (result.success && result.sessions) { // 确保 sessions 是数组 @@ -695,12 +906,15 @@ function ChatPage(_props: ChatPageProps) { setSessions(nextSessions) sessionsRef.current = nextSessions + persistSessionListCache(scope, nextSessions) void hydrateSessionStatuses(nextSessions) // 立即启动联系人信息加载,不再延迟 500ms void enrichSessionsContactInfo(nextSessions) } else { console.error('mergeSessions returned non-array:', nextSessions) setSessions(sessionsArray) + sessionsRef.current = sessionsArray + persistSessionListCache(scope, sessionsArray) void hydrateSessionStatuses(sessionsArray) void enrichSessionsContactInfo(sessionsArray) } @@ -1085,6 +1299,7 @@ function ChatPage(_props: ChatPageProps) { if (result.success && result.messages) { if (offset === 0) { setMessages(result.messages) + persistSessionPreviewCache(sessionId, result.messages) if (result.messages.length === 0) { setNoMessageTable(true) setHasMoreMessages(false) @@ -1233,10 +1448,12 @@ function ChatPage(_props: ChatPageProps) { if (session.username === currentSessionId) return pendingSessionLoadRef.current = session.username setIsSessionSwitching(true) - setCurrentSession(session.username, { preserveMessages: true }) + setCurrentSession(session.username, { preserveMessages: false }) + void hydrateSessionPreview(session.username) setCurrentOffset(0) setJumpStartTime(0) setJumpEndTime(0) + setNoMessageTable(false) void loadMessages(session.username, 0, 0, 0) // 切换会话后回到正常聊天窗口:收起详情侧栏,详情需手动再次展开 setShowDetailPanel(false) @@ -1374,6 +1591,14 @@ function ChatPage(_props: ChatPageProps) { // 组件卸载时清理 return () => { avatarLoadQueue.clear() + if (previewPersistTimerRef.current !== null) { + window.clearTimeout(previewPersistTimerRef.current) + previewPersistTimerRef.current = null + } + if (sessionListPersistTimerRef.current !== null) { + window.clearTimeout(sessionListPersistTimerRef.current) + sessionListPersistTimerRef.current = null + } if (contactUpdateTimerRef.current) { clearTimeout(contactUpdateTimerRef.current) } @@ -1522,6 +1747,22 @@ function ChatPage(_props: ChatPageProps) { searchKeywordRef.current = searchKeyword }, [searchKeyword]) + useEffect(() => { + if (!currentSessionId || !Array.isArray(messages) || messages.length === 0) return + persistSessionPreviewCache(currentSessionId, messages) + }, [currentSessionId, messages, persistSessionPreviewCache]) + + useEffect(() => { + if (!Array.isArray(sessions) || sessions.length === 0) return + if (sessionListPersistTimerRef.current !== null) { + window.clearTimeout(sessionListPersistTimerRef.current) + } + sessionListPersistTimerRef.current = window.setTimeout(() => { + persistSessionListCache(chatCacheScopeRef.current, sessions) + sessionListPersistTimerRef.current = null + }, 260) + }, [sessions, persistSessionListCache]) + // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 useEffect(() => { if (!Array.isArray(sessions)) { From 794a306f897f1ef9b9fc053a5d3a2ced5cff1569 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 19:03:15 +0800 Subject: [PATCH 025/155] perf(contacts): speed up directory loading and smooth list rendering --- electron/services/chatService.ts | 114 ++++++----- src/pages/ContactsPage.scss | 25 ++- src/pages/ContactsPage.tsx | 341 +++++++++++++++++++++++-------- 3 files changed, 335 insertions(+), 145 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 0e655d3..00958cf 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -684,40 +684,52 @@ class ChatService { if (!headImageDbPath) return result - // 使用 wcdbService.execQuery 查询加密的 head_image.db - for (const username of usernames) { - try { - const escapedUsername = username.replace(/'/g, "''") - const queryResult = await wcdbService.execQuery( - 'media', - headImageDbPath, - `SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1` - ) + const normalizedUsernames = Array.from( + new Set( + usernames + .map((username) => String(username || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedUsernames.length === 0) return result - if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) { - const row = queryResult.rows[0] as any - if (row?.image_buffer) { - let base64Data: string - if (typeof row.image_buffer === 'string') { - // WCDB 返回的 BLOB 是十六进制字符串,需要转换为 base64 - if (row.image_buffer.toLowerCase().startsWith('ffd8')) { - const buffer = Buffer.from(row.image_buffer, 'hex') - base64Data = buffer.toString('base64') - } else { - base64Data = row.image_buffer - } - } else if (Buffer.isBuffer(row.image_buffer)) { - base64Data = row.image_buffer.toString('base64') - } else if (Array.isArray(row.image_buffer)) { - base64Data = Buffer.from(row.image_buffer).toString('base64') - } else { - continue - } - result[username] = `data:image/jpeg;base64,${base64Data}` + const batchSize = 320 + for (let i = 0; i < normalizedUsernames.length; i += batchSize) { + const batch = normalizedUsernames.slice(i, i + batchSize) + if (batch.length === 0) continue + const usernamesExpr = batch.map((name) => `'${this.escapeSqlString(name)}'`).join(',') + const queryResult = await wcdbService.execQuery( + 'media', + headImageDbPath, + `SELECT username, image_buffer FROM head_image WHERE username IN (${usernamesExpr})` + ) + + if (!queryResult.success || !queryResult.rows || queryResult.rows.length === 0) { + continue + } + + for (const row of queryResult.rows as any[]) { + const username = String(row?.username || '').trim() + if (!username || !row?.image_buffer) continue + + let base64Data: string | null = null + if (typeof row.image_buffer === 'string') { + // WCDB 返回的 BLOB 可能是十六进制字符串,需要转换为 base64 + if (row.image_buffer.toLowerCase().startsWith('ffd8')) { + const buffer = Buffer.from(row.image_buffer, 'hex') + base64Data = buffer.toString('base64') + } else { + base64Data = row.image_buffer } + } else if (Buffer.isBuffer(row.image_buffer)) { + base64Data = row.image_buffer.toString('base64') + } else if (Array.isArray(row.image_buffer)) { + base64Data = Buffer.from(row.image_buffer).toString('base64') + } + + if (base64Data) { + result[username] = `data:image/jpeg;base64,${base64Data}` } - } catch { - // 静默处理单个用户的错误 } } } catch (e) { @@ -949,11 +961,17 @@ class ChatService { // 使用execQuery直接查询加密的contact.db // kind='contact', path=null表示使用已打开的contact.db const contactQuery = ` - SELECT username, remark, nick_name, alias, local_type, flag, quan_pin + SELECT username, remark, nick_name, alias, local_type, quan_pin FROM contact + WHERE username IS NOT NULL + AND username != '' + AND ( + username LIKE '%@chatroom' + OR username LIKE 'gh_%' + OR local_type = 1 + OR (local_type = 0 AND COALESCE(quan_pin, '') != '') + ) ` - - const contactResult = await wcdbService.execQuery('contact', null, contactQuery) if (!contactResult.success || !contactResult.rows) { @@ -963,21 +981,6 @@ class ChatService { const rows = contactResult.rows as Record[] - - // 调试:显示前5条数据样本 - - rows.slice(0, 5).forEach((row, idx) => { - - }) - - // 调试:统计local_type分布 - const localTypeStats = new Map() - rows.forEach(row => { - const lt = row.local_type || 0 - localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1) - }) - - // 获取会话表的最后联系时间用于排序 const lastContactTimeMap = new Map() const sessionResult = await wcdbService.getSessions() @@ -993,25 +996,24 @@ class ChatService { // 转换为ContactInfo const contacts: (ContactInfo & { lastContactTime: number })[] = [] + const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) for (const row of rows) { - const username = row.username || '' + const username = String(row.username || '').trim() if (!username) continue - const excludeNames = ['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'] let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other' const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) - const flag = Number(row.flag ?? 0) - const quanPin = this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '' + const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() - if (username.includes('@chatroom')) { + if (username.endsWith('@chatroom')) { type = 'group' } else if (username.startsWith('gh_')) { type = 'official' - } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 1 && !excludeNames.includes(username)) { + } else if (localType === 1 && !excludeNames.has(username)) { type = 'friend' - } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 0 && quanPin) { + } else if (localType === 0 && quanPin) { type = 'former_friend' } else { continue diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index f7986ff..6bb4844 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -177,6 +177,12 @@ padding: 0 20px 12px; font-size: 13px; color: var(--text-secondary); + + .avatar-enrich-progress { + margin-left: 10px; + color: var(--text-tertiary); + font-size: 12px; + } } .selection-toolbar { @@ -217,6 +223,7 @@ flex: 1; overflow-y: auto; padding: 0 12px 12px; + position: relative; &::-webkit-scrollbar { width: 6px; @@ -229,15 +236,31 @@ } } + .contacts-list-virtual { + position: relative; + min-height: 100%; + } + + .contact-row { + position: absolute; + left: 0; + right: 0; + height: 76px; + padding-bottom: 4px; + will-change: transform; + } + .contact-item { display: flex; align-items: center; gap: 12px; padding: 12px; + height: 72px; + box-sizing: border-box; border-radius: 10px; transition: all 0.2s; cursor: pointer; - margin-bottom: 4px; + margin-bottom: 0; &:hover { background: var(--bg-hover); diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 1f74576..570b40c 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react' import { useNavigate } from 'react-router-dom' import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react' import { useChatStore } from '../stores/chatStore' @@ -13,12 +13,22 @@ interface ContactInfo { type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' } +interface ContactEnrichInfo { + displayName?: string + avatarUrl?: string +} + +const AVATAR_ENRICH_BATCH_SIZE = 80 +const SEARCH_DEBOUNCE_MS = 120 +const VIRTUAL_ROW_HEIGHT = 76 +const VIRTUAL_OVERSCAN = 10 + function ContactsPage() { const [contacts, setContacts] = useState([]) - const [filteredContacts, setFilteredContacts] = useState([]) const [selectedUsernames, setSelectedUsernames] = useState>(new Set()) const [isLoading, setIsLoading] = useState(true) const [searchKeyword, setSearchKeyword] = useState('') + const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('') const [contactTypes, setContactTypes] = useState({ friends: true, groups: false, @@ -39,79 +49,193 @@ function ContactsPage() { const [isExporting, setIsExporting] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const formatDropdownRef = useRef(null) + const listRef = useRef(null) + const loadVersionRef = useRef(0) + const [avatarEnrichProgress, setAvatarEnrichProgress] = useState({ + loaded: 0, + total: 0, + running: false + }) + const [scrollTop, setScrollTop] = useState(0) + const [listViewportHeight, setListViewportHeight] = useState(480) + + const applyEnrichedContacts = useCallback((enrichedMap: Record) => { + if (!enrichedMap || Object.keys(enrichedMap).length === 0) return + + setContacts(prev => { + let changed = false + const next = prev.map(contact => { + const enriched = enrichedMap[contact.username] + if (!enriched) return contact + const displayName = enriched.displayName || contact.displayName + const avatarUrl = enriched.avatarUrl || contact.avatarUrl + if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + displayName, + avatarUrl + } + }) + return changed ? next : prev + }) + + setSelectedContact(prev => { + if (!prev) return prev + const enriched = enrichedMap[prev.username] + if (!enriched) return prev + const displayName = enriched.displayName || prev.displayName + const avatarUrl = enriched.avatarUrl || prev.avatarUrl + if (displayName === prev.displayName && avatarUrl === prev.avatarUrl) { + return prev + } + return { + ...prev, + displayName, + avatarUrl + } + }) + }, []) + + const enrichContactsInBackground = useCallback(async (sourceContacts: ContactInfo[], loadVersion: number) => { + const usernames = sourceContacts.map(contact => contact.username).filter(Boolean) + const total = usernames.length + setAvatarEnrichProgress({ + loaded: 0, + total, + running: total > 0 + }) + if (total === 0) return + + for (let i = 0; i < total; i += AVATAR_ENRICH_BATCH_SIZE) { + if (loadVersionRef.current !== loadVersion) return + const batch = usernames.slice(i, i + AVATAR_ENRICH_BATCH_SIZE) + if (batch.length === 0) continue + + try { + const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch) + if (loadVersionRef.current !== loadVersion) return + if (avatarResult.success && avatarResult.contacts) { + applyEnrichedContacts(avatarResult.contacts) + } + } catch (e) { + console.error('分批补全头像失败:', e) + } + + const loaded = Math.min(i + batch.length, total) + setAvatarEnrichProgress({ + loaded, + total, + running: loaded < total + }) + + await new Promise(resolve => setTimeout(resolve, 0)) + } + }, [applyEnrichedContacts]) // 加载通讯录 const loadContacts = useCallback(async () => { + const loadVersion = loadVersionRef.current + 1 + loadVersionRef.current = loadVersion setIsLoading(true) + setAvatarEnrichProgress({ + loaded: 0, + total: 0, + running: false + }) try { - const result = await window.electronAPI.chat.connect() - if (!result.success) { - console.error('连接失败:', result.error) - setIsLoading(false) - return - } const contactsResult = await window.electronAPI.chat.getContacts() - + + if (loadVersionRef.current !== loadVersion) return if (contactsResult.success && contactsResult.contacts) { - - - - // 获取头像URL - const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username) - if (usernames.length > 0) { - const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) - if (avatarResult.success && avatarResult.contacts) { - contactsResult.contacts.forEach((contact: ContactInfo) => { - const enriched = avatarResult.contacts?.[contact.username] - if (enriched?.avatarUrl) { - contact.avatarUrl = enriched.avatarUrl - } - }) - } - } - setContacts(contactsResult.contacts) - setFilteredContacts(contactsResult.contacts) setSelectedUsernames(new Set()) + setSelectedContact(prev => { + if (!prev) return prev + return contactsResult.contacts!.find(contact => contact.username === prev.username) || null + }) + setIsLoading(false) + void enrichContactsInBackground(contactsResult.contacts, loadVersion) + return } } catch (e) { console.error('加载通讯录失败:', e) } finally { - setIsLoading(false) + if (loadVersionRef.current === loadVersion) { + setIsLoading(false) + } } - }, []) + }, [enrichContactsInBackground]) useEffect(() => { loadContacts() }, [loadContacts]) - // 搜索和类型过滤 useEffect(() => { - let filtered = contacts + return () => { + loadVersionRef.current += 1 + } + }, []) - // 类型过滤 - filtered = filtered.filter(c => { - if (c.type === 'friend' && !contactTypes.friends) return false - if (c.type === 'group' && !contactTypes.groups) return false - if (c.type === 'official' && !contactTypes.officials) return false - if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase()) + }, SEARCH_DEBOUNCE_MS) + return () => window.clearTimeout(timer) + }, [searchKeyword]) + + const filteredContacts = useMemo(() => { + let filtered = contacts.filter(contact => { + if (contact.type === 'friend' && !contactTypes.friends) return false + if (contact.type === 'group' && !contactTypes.groups) return false + if (contact.type === 'official' && !contactTypes.officials) return false + if (contact.type === 'former_friend' && !contactTypes.deletedFriends) return false return true }) - // 关键词过滤 - if (searchKeyword.trim()) { - const lower = searchKeyword.toLowerCase() - filtered = filtered.filter(c => - c.displayName?.toLowerCase().includes(lower) || - c.remark?.toLowerCase().includes(lower) || - c.username.toLowerCase().includes(lower) + if (debouncedSearchKeyword) { + filtered = filtered.filter(contact => + contact.displayName?.toLowerCase().includes(debouncedSearchKeyword) || + contact.remark?.toLowerCase().includes(debouncedSearchKeyword) || + contact.username.toLowerCase().includes(debouncedSearchKeyword) ) } - setFilteredContacts(filtered) - }, [searchKeyword, contacts, contactTypes]) + return filtered + }, [contacts, contactTypes, debouncedSearchKeyword]) - // 点击外部关闭下拉菜单 + useEffect(() => { + if (!listRef.current) return + listRef.current.scrollTop = 0 + setScrollTop(0) + }, [debouncedSearchKeyword, contactTypes]) + + useEffect(() => { + const node = listRef.current + if (!node) return + + const updateViewportHeight = () => { + setListViewportHeight(Math.max(node.clientHeight, VIRTUAL_ROW_HEIGHT)) + } + updateViewportHeight() + + const observer = new ResizeObserver(() => updateViewportHeight()) + observer.observe(node) + return () => observer.disconnect() + }, [filteredContacts.length, isLoading]) + + useEffect(() => { + const maxScroll = Math.max(0, filteredContacts.length * VIRTUAL_ROW_HEIGHT - listViewportHeight) + if (scrollTop <= maxScroll) return + setScrollTop(maxScroll) + if (listRef.current) { + listRef.current.scrollTop = maxScroll + } + }, [filteredContacts.length, listViewportHeight, scrollTop]) + + // 搜索和类型过滤 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node @@ -123,11 +247,35 @@ function ContactsPage() { return () => document.removeEventListener('mousedown', handleClickOutside) }, [showFormatSelect]) - const selectedInFilteredCount = filteredContacts.reduce((count, contact) => { - return selectedUsernames.has(contact.username) ? count + 1 : count - }, 0) + const selectedInFilteredCount = useMemo(() => { + return filteredContacts.reduce((count, contact) => { + return selectedUsernames.has(contact.username) ? count + 1 : count + }, 0) + }, [filteredContacts, selectedUsernames]) const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length + const { startIndex, endIndex } = useMemo(() => { + if (filteredContacts.length === 0) { + return { startIndex: 0, endIndex: 0 } + } + const baseStart = Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) + const visibleCount = Math.ceil(listViewportHeight / VIRTUAL_ROW_HEIGHT) + const nextStart = Math.max(0, baseStart - VIRTUAL_OVERSCAN) + const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + VIRTUAL_OVERSCAN * 2) + return { + startIndex: nextStart, + endIndex: nextEnd + } + }, [filteredContacts.length, listViewportHeight, scrollTop]) + + const visibleContacts = useMemo(() => { + return filteredContacts.slice(startIndex, endIndex) + }, [filteredContacts, startIndex, endIndex]) + + const onContactsListScroll = useCallback((event: UIEvent) => { + setScrollTop(event.currentTarget.scrollTop) + }, []) + const toggleContactSelected = (username: string, checked: boolean) => { setSelectedUsernames(prev => { const next = new Set(prev) @@ -297,7 +445,12 @@ function ContactsPage() {
- 共 {filteredContacts.length} 个联系人 + 共 {filteredContacts.length} / {contacts.length} 个联系人 + {avatarEnrichProgress.running && ( + + 头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total} + + )}
{exportMode && ( @@ -315,61 +468,73 @@ function ContactsPage() {
)} - {isLoading ? ( + {isLoading && contacts.length === 0 ? (
- 加载中... + 联系人加载中...
) : filteredContacts.length === 0 ? (
暂无联系人
) : ( -
- {filteredContacts.map(contact => { +
+
+ {visibleContacts.map((contact, idx) => { + const absoluteIndex = startIndex + idx + const top = absoluteIndex * VIRTUAL_ROW_HEIGHT const isChecked = selectedUsernames.has(contact.username) const isActive = !exportMode && selectedContact?.username === contact.username return (
{ - if (exportMode) { - toggleContactSelected(contact.username, !isChecked) - } else { - setSelectedContact(isActive ? null : contact) - } - }} + className="contact-row" + style={{ transform: `translateY(${top}px)` }} > - {exportMode && ( - - )} -
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} +
{ + if (exportMode) { + toggleContactSelected(contact.username, !isChecked) + } else { + setSelectedContact(isActive ? null : contact) + } + }} + > + {exportMode && ( + )} -
-
-
{contact.displayName}
- {contact.remark && contact.remark !== contact.displayName && ( -
备注: {contact.remark}
- )} -
-
- {getContactTypeIcon(contact.type)} - {getContactTypeName(contact.type)} +
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+ {contact.remark && contact.remark !== contact.displayName && ( +
备注: {contact.remark}
+ )} +
+
+ {getContactTypeIcon(contact.type)} + {getContactTypeName(contact.type)} +
) - })} + })} +
)}
From da7d3544363e921b5fb833db5cef8e95a9f34b53 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 10:23:36 +0800 Subject: [PATCH 026/155] feat(counts): unify contacts and export tab counters --- electron/main.ts | 4 + electron/preload.ts | 1 + electron/services/chatService.ts | 136 ++++++++++----------------- src/pages/ContactsPage.scss | 11 +++ src/pages/ContactsPage.tsx | 24 ++++- src/pages/ExportPage.tsx | 57 +++-------- src/stores/contactTypeCountsStore.ts | 115 ++++++++++++++++++++++ src/types/electron.d.ts | 10 ++ 8 files changed, 222 insertions(+), 136 deletions(-) create mode 100644 src/stores/contactTypeCountsStore.ts diff --git a/electron/main.ts b/electron/main.ts index e73a715..5985639 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -920,6 +920,10 @@ function registerIpcHandlers() { return chatService.getExportTabCounts() }) + ipcMain.handle('chat:getContactTypeCounts', async () => { + return chatService.getContactTypeCounts() + }) + ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[]) => { return chatService.getSessionMessageCounts(sessionIds) }) diff --git a/electron/preload.ts b/electron/preload.ts index 999486f..8723db5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -132,6 +132,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessions: () => ipcRenderer.invoke('chat:getSessions'), getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames), getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), + getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), enrichSessionsContactInfo: (usernames: string[]) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 00958cf..788234e 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -762,111 +762,73 @@ class ChatService { } /** - * 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示) + * 获取联系人类型数量(好友、群聊、公众号、曾经的好友) */ - async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + async getContactTypeCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { try { const connectResult = await this.ensureConnected() if (!connectResult.success) { return { success: false, error: connectResult.error } } - const sessionResult = await wcdbService.getSessions() - if (!sessionResult.success || !sessionResult.sessions) { - return { success: false, error: sessionResult.error || '获取会话失败' } + const excludeExpr = Array.from(FRIEND_EXCLUDE_USERNAMES) + .map((username) => `'${this.escapeSqlString(username)}'`) + .join(',') + + const countsSql = ` + SELECT + SUM(CASE WHEN username LIKE '%@chatroom' THEN 1 ELSE 0 END) AS group_count, + SUM(CASE WHEN username LIKE 'gh_%' THEN 1 ELSE 0 END) AS official_count, + SUM( + CASE + WHEN username NOT LIKE '%@chatroom' + AND username NOT LIKE 'gh_%' + AND local_type = 1 + AND username NOT IN (${excludeExpr}) + THEN 1 ELSE 0 + END + ) AS private_count, + SUM( + CASE + WHEN username NOT LIKE '%@chatroom' + AND username NOT LIKE 'gh_%' + AND local_type = 0 + AND COALESCE(quan_pin, '') != '' + THEN 1 ELSE 0 + END + ) AS former_friend_count + FROM contact + WHERE username IS NOT NULL + AND username != '' + ` + + const result = await wcdbService.execQuery('contact', null, countsSql) + if (!result.success || !result.rows || result.rows.length === 0) { + return { success: false, error: result.error || '获取联系人类型数量失败' } } + const row = result.rows[0] as Record const counts: ExportTabCounts = { - private: 0, - group: 0, - official: 0, - former_friend: 0 - } - - const nonGroupUsernames: string[] = [] - const usernameSet = new Set() - - for (const row of sessionResult.sessions as Record[]) { - const username = - row.username || - row.user_name || - row.userName || - row.usrName || - row.UsrName || - row.talker || - row.talker_id || - row.talkerId || - '' - - if (!this.shouldKeepSession(username)) continue - if (usernameSet.has(username)) continue - usernameSet.add(username) - - if (username.endsWith('@chatroom')) { - counts.group += 1 - } else { - nonGroupUsernames.push(username) - } - } - - if (nonGroupUsernames.length === 0) { - return { success: true, counts } - } - - const contactTypeMap = new Map() - const chunkSize = 400 - - for (let i = 0; i < nonGroupUsernames.length; i += chunkSize) { - const chunk = nonGroupUsernames.slice(i, i + chunkSize) - if (chunk.length === 0) continue - - const usernamesExpr = chunk.map((name) => `'${this.escapeSqlString(name)}'`).join(',') - const contactSql = ` - SELECT username, local_type, quan_pin - FROM contact - WHERE username IN (${usernamesExpr}) - ` - - const contactResult = await wcdbService.execQuery('contact', null, contactSql) - if (!contactResult.success || !contactResult.rows) { - continue - } - - for (const row of contactResult.rows as Record[]) { - const username = String(row.username || '').trim() - if (!username) continue - - if (username.startsWith('gh_')) { - contactTypeMap.set(username, 'official') - continue - } - - const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) - const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() - if (localType === 0 && quanPin) { - contactTypeMap.set(username, 'former_friend') - } - } - } - - for (const username of nonGroupUsernames) { - const type = contactTypeMap.get(username) - if (type === 'official') { - counts.official += 1 - } else if (type === 'former_friend') { - counts.former_friend += 1 - } else { - counts.private += 1 - } + private: this.getRowInt(row, ['private_count', 'privateCount'], 0), + group: this.getRowInt(row, ['group_count', 'groupCount'], 0), + official: this.getRowInt(row, ['official_count', 'officialCount'], 0), + former_friend: this.getRowInt(row, ['former_friend_count', 'formerFriendCount'], 0) } return { success: true, counts } } catch (e) { - console.error('ChatService: 获取导出页会话分类数量失败:', e) + console.error('ChatService: 获取联系人类型数量失败:', e) return { success: false, error: String(e) } } } + /** + * 获取导出页会话分类数量(轻量接口,优先用于顶部 Tab 数量展示) + */ + async getExportTabCounts(): Promise<{ success: boolean; counts?: ExportTabCounts; error?: string }> { + return this.getContactTypeCounts() + } + /** * 批量获取会话消息总数(轻量接口,用于列表优先排序) */ diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index 6bb4844..541f428 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -148,6 +148,17 @@ svg { opacity: 0.7; transition: transform 0.2s; + flex-shrink: 0; + } + + .chip-label { + min-width: 0; + } + + .chip-count { + margin-left: auto; + text-align: right; + font-variant-numeric: tabular-nums; } &:hover { diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 570b40c..77271c0 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from import { useNavigate } from 'react-router-dom' import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react' import { useChatStore } from '../stores/chatStore' +import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import './ContactsPage.scss' interface ContactInfo { @@ -58,6 +59,8 @@ function ContactsPage() { }) const [scrollTop, setScrollTop] = useState(0) const [listViewportHeight, setListViewportHeight] = useState(480) + const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts) + const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts) const applyEnrichedContacts = useCallback((enrichedMap: Record) => { if (!enrichedMap || Object.keys(enrichedMap).length === 0) return @@ -151,6 +154,7 @@ function ContactsPage() { if (loadVersionRef.current !== loadVersion) return if (contactsResult.success && contactsResult.contacts) { setContacts(contactsResult.contacts) + syncContactTypeCounts(contactsResult.contacts) setSelectedUsernames(new Set()) setSelectedContact(prev => { if (!prev) return prev @@ -167,7 +171,7 @@ function ContactsPage() { setIsLoading(false) } } - }, [enrichContactsInBackground]) + }, [enrichContactsInBackground, syncContactTypeCounts]) useEffect(() => { loadContacts() @@ -206,6 +210,8 @@ function ContactsPage() { return filtered }, [contacts, contactTypes, debouncedSearchKeyword]) + const contactTypeCounts = useMemo(() => toContactTypeCardCounts(sharedTabCounts), [sharedTabCounts]) + useEffect(() => { if (!listRef.current) return listRef.current.scrollTop = 0 @@ -428,19 +434,27 @@ function ContactsPage() {
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 9b704c8..1812efd 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -22,6 +22,7 @@ import { import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' import * as configService from '../services/config' +import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' @@ -321,12 +322,10 @@ function ExportPage() { const [isLoading, setIsLoading] = useState(true) const [isSessionEnriching, setIsSessionEnriching] = useState(false) - const [isTabCountsLoading, setIsTabCountsLoading] = useState(true) const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) - const [prefetchedTabCounts, setPrefetchedTabCounts] = useState | null>(null) const [sessionMessageCounts, setSessionMessageCounts] = useState>({}) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') @@ -374,6 +373,11 @@ function ExportPage() { }) const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [nowTick, setNowTick] = useState(Date.now()) + const tabCounts = useContactTypeCountsStore(state => state.tabCounts) + const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading) + const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady) + const ensureSharedTabCountsLoaded = useContactTypeCountsStore(state => state.ensureLoaded) + const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts) const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) @@ -516,20 +520,6 @@ function ExportPage() { } }, []) - const loadTabCounts = useCallback(async () => { - setIsTabCountsLoading(true) - try { - const result = await window.electronAPI.chat.getExportTabCounts() - if (result.success && result.counts) { - setPrefetchedTabCounts(result.counts) - } - } catch (error) { - console.error('加载导出页会话分类数量失败:', error) - } finally { - setIsTabCountsLoading(false) - } - }, []) - const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => { if (!options?.silent) { setIsSnsStatsLoading(true) @@ -641,6 +631,9 @@ function ExportPage() { if (isStale()) return const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] + if (contacts.length > 0) { + syncContactTypeCounts(contacts) + } const nextContactMap = contacts.reduce>((map, contact) => { map[contact.username] = contact return map @@ -694,11 +687,11 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, []) + }, [syncContactTypeCounts]) useEffect(() => { void loadBaseConfig() - void loadTabCounts() + void ensureSharedTabCountsLoaded() void loadSessions() // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 @@ -707,7 +700,7 @@ function ExportPage() { }, 120) return () => window.clearTimeout(timer) - }, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats]) + }, [ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats]) useEffect(() => { preselectAppliedRef.current = false @@ -1363,29 +1356,6 @@ function ExportPage() { return set }, [tasks]) - const sessionTabCounts = useMemo(() => { - const counts: Record = { - private: 0, - group: 0, - official: 0, - former_friend: 0 - } - for (const session of sessions) { - counts[session.kind] += 1 - } - return counts - }, [sessions]) - - const tabCounts = useMemo(() => { - if (sessions.length > 0) { - return sessionTabCounts - } - if (prefetchedTabCounts) { - return prefetchedTabCounts - } - return sessionTabCounts - }, [sessions.length, sessionTabCounts, prefetchedTabCounts]) - const contentCards = useMemo(() => { const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') const totalSessions = scopeSessions.length @@ -1617,8 +1587,7 @@ function ExportPage() { const formatCandidateOptions = exportDialog.scope === 'sns' ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions - const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0 - const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource + const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady const isSessionCardStatsLoading = isLoading || isBaseConfigLoading const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length diff --git a/src/stores/contactTypeCountsStore.ts b/src/stores/contactTypeCountsStore.ts new file mode 100644 index 0000000..d3252fc --- /dev/null +++ b/src/stores/contactTypeCountsStore.ts @@ -0,0 +1,115 @@ +import { create } from 'zustand' +import type { ContactInfo } from '../types/models' + +export interface ContactTypeTabCounts { + private: number + group: number + official: number + former_friend: number +} + +export interface ContactTypeCardCounts { + friends: number + groups: number + officials: number + deletedFriends: number +} + +const emptyTabCounts: ContactTypeTabCounts = { + private: 0, + group: 0, + official: 0, + former_friend: 0 +} + +let inflightPromise: Promise | null = null + +const normalizeCounts = (counts?: Partial | null): ContactTypeTabCounts => { + return { + private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0, + group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0, + official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0, + former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0 + } +} + +export const toContactTypeTabCountsFromContacts = (contacts: ContactInfo[]): ContactTypeTabCounts => { + const next = { ...emptyTabCounts } + for (const contact of contacts || []) { + if (contact.type === 'friend') next.private += 1 + if (contact.type === 'group') next.group += 1 + if (contact.type === 'official') next.official += 1 + if (contact.type === 'former_friend') next.former_friend += 1 + } + return next +} + +export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTypeCardCounts => { + return { + friends: counts.private, + groups: counts.group, + officials: counts.official, + deletedFriends: counts.former_friend + } +} + +interface ContactTypeCountsState { + tabCounts: ContactTypeTabCounts + isLoading: boolean + isReady: boolean + updatedAt: number + setTabCounts: (counts: ContactTypeTabCounts) => void + syncFromContacts: (contacts: ContactInfo[]) => void + ensureLoaded: (options?: { force?: boolean }) => Promise +} + +export const useContactTypeCountsStore = create((set, get) => ({ + tabCounts: { ...emptyTabCounts }, + isLoading: false, + isReady: false, + updatedAt: 0, + setTabCounts: (counts) => { + const normalized = normalizeCounts(counts) + set({ + tabCounts: normalized, + isReady: true, + updatedAt: Date.now() + }) + }, + syncFromContacts: (contacts) => { + const fromContacts = toContactTypeTabCountsFromContacts(contacts || []) + get().setTabCounts(fromContacts) + }, + ensureLoaded: async (options) => { + if (!options?.force && get().isReady) { + return get().tabCounts + } + if (inflightPromise) { + return inflightPromise + } + + set({ isLoading: true }) + inflightPromise = (async () => { + try { + const result = await window.electronAPI.chat.getContactTypeCounts() + if (result?.success && result.counts) { + const normalized = normalizeCounts(result.counts) + set({ + tabCounts: normalized, + isReady: true, + updatedAt: Date.now() + }) + return normalized + } + } catch (error) { + console.error('加载联系人类型计数失败:', error) + } + return get().tabCounts + })().finally(() => { + inflightPromise = null + set({ isLoading: false }) + }) + + return inflightPromise + } +})) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 88bc819..f7a1e57 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -89,6 +89,16 @@ export interface ElectronAPI { } error?: string }> + getContactTypeCounts: () => Promise<{ + success: boolean + counts?: { + private: number + group: number + official: number + former_friend: number + } + error?: string + }> getSessionMessageCounts: (sessionIds: string[]) => Promise<{ success: boolean counts?: Record From abdb4f62def10c407e783f72bcb7c9d2e001c67b Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 10:37:56 +0800 Subject: [PATCH 027/155] fix(export): pause hidden export background loading to unblock contacts --- src/pages/ExportPage.tsx | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 1812efd..02774f1 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -319,6 +319,7 @@ const WriteLayoutSelector = memo(function WriteLayoutSelector({ function ExportPage() { const location = useLocation() + const isExportRoute = location.pathname === '/export' const [isLoading, setIsLoading] = useState(true) const [isSessionEnriching, setIsSessionEnriching] = useState(false) @@ -690,6 +691,7 @@ function ExportPage() { }, [syncContactTypeCounts]) useEffect(() => { + if (!isExportRoute) return void loadBaseConfig() void ensureSharedTabCountsLoaded() void loadSessions() @@ -700,7 +702,16 @@ function ExportPage() { }, 120) return () => window.clearTimeout(timer) - }, [ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats]) + }, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats]) + + useEffect(() => { + if (isExportRoute) return + // 导出页隐藏时停止后台统计请求,避免与通讯录页面查询抢占。 + sessionLoadTokenRef.current = Date.now() + loadingMessageCountsRef.current.clear() + loadingMetricsRef.current.clear() + setIsSessionEnriching(false) + }, [isExportRoute]) useEffect(() => { preselectAppliedRef.current = false @@ -754,6 +765,7 @@ function ExportPage() { }, [visibleSessions]) const ensureSessionMessageCounts = useCallback(async (targetSessions: SessionRow[]) => { + if (!isExportRoute) return const loadTokenAtStart = sessionLoadTokenRef.current const currentCounts = sessionMessageCountsRef.current const pending = targetSessions.filter( @@ -797,9 +809,10 @@ function ExportPage() { loadingMessageCountsRef.current.delete(session.username) } } - }, []) + }, [isExportRoute]) const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { + if (!isExportRoute) return const loadTokenAtStart = sessionLoadTokenRef.current const currentMetrics = sessionMetricsRef.current const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) @@ -857,14 +870,16 @@ function ExportPage() { if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } - }, []) + }, [isExportRoute]) useEffect(() => { + if (!isExportRoute) return const targets = visibleSessions.slice(0, MESSAGE_COUNT_VIEWPORT_PREFETCH) void ensureSessionMessageCounts(targets) - }, [visibleSessions, ensureSessionMessageCounts]) + }, [isExportRoute, visibleSessions, ensureSessionMessageCounts]) useEffect(() => { + if (!isExportRoute) return if (sessions.length === 0) return const activeTabTargets = sessions .filter(session => session.kind === activeTab) @@ -872,14 +887,16 @@ function ExportPage() { .slice(0, MESSAGE_COUNT_ACTIVE_TAB_WARMUP_LIMIT) if (activeTabTargets.length === 0) return void ensureSessionMessageCounts(activeTabTargets) - }, [sessions, activeTab, ensureSessionMessageCounts]) + }, [isExportRoute, sessions, activeTab, ensureSessionMessageCounts]) useEffect(() => { + if (!isExportRoute) return const targets = visibleSessions.slice(0, METRICS_VIEWPORT_PREFETCH) void ensureSessionMetrics(targets) - }, [visibleSessions, ensureSessionMetrics]) + }, [isExportRoute, visibleSessions, ensureSessionMetrics]) const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { + if (!isExportRoute) return const current = visibleSessionsRef.current if (current.length === 0) return const prefetch = Math.max(MESSAGE_COUNT_VIEWPORT_PREFETCH, METRICS_VIEWPORT_PREFETCH) @@ -889,9 +906,10 @@ function ExportPage() { const rangeSessions = current.slice(start, end + 1) void ensureSessionMessageCounts(rangeSessions) void ensureSessionMetrics(rangeSessions) - }, [ensureSessionMessageCounts, ensureSessionMetrics]) + }, [isExportRoute, ensureSessionMessageCounts, ensureSessionMetrics]) useEffect(() => { + if (!isExportRoute) return if (sessions.length === 0) return const prioritySessions = [ ...sessions.filter(session => session.kind === activeTab), @@ -909,7 +927,7 @@ function ExportPage() { }, METRICS_BACKGROUND_INTERVAL_MS) return () => window.clearInterval(timer) - }, [sessions, activeTab, ensureSessionMetrics]) + }, [isExportRoute, sessions, activeTab, ensureSessionMetrics]) const selectedCount = selectedSessions.size From 9cb41e01e241d93ebf77e26493b0eea89c6c8b44 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 10:51:28 +0800 Subject: [PATCH 028/155] fix(contacts): persist list cache and add load timeout diagnostics --- src/pages/ContactsPage.scss | 102 +++++++++++++ src/pages/ContactsPage.tsx | 275 +++++++++++++++++++++++++++++++++++- src/pages/ExportPage.tsx | 16 ++- src/services/config.ts | 101 +++++++++++++ 4 files changed, 481 insertions(+), 13 deletions(-) diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index 541f428..bd6fc98 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -189,6 +189,16 @@ font-size: 13px; color: var(--text-secondary); + .contacts-cache-meta { + margin-left: 10px; + color: var(--text-tertiary); + font-size: 12px; + + &.syncing { + color: var(--primary); + } + } + .avatar-enrich-progress { margin-left: 10px; color: var(--text-tertiary); @@ -230,6 +240,98 @@ } } + .load-issue-state { + flex: 1; + padding: 14px 14px 18px; + overflow-y: auto; + } + + .issue-card { + border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); + background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg)); + border-radius: 12px; + padding: 14px; + color: var(--text-primary); + + .issue-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary)); + margin-bottom: 8px; + } + + .issue-message { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-reason { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-hints { + margin: 10px 0 0; + padding-left: 18px; + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.6; + } + + .issue-actions { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .issue-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + padding: 7px 10px; + font-size: 12px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + background: var(--bg-hover); + } + + &.primary { + background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--primary); + } + } + + .issue-diagnostics { + margin-top: 12px; + border-radius: 8px; + background: var(--bg-primary); + border: 1px dashed var(--border-color); + padding: 10px; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + } + } + .contacts-list { flex: 1; overflow-y: auto; diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 77271c0..6a87372 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react' import { useNavigate } from 'react-router-dom' -import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react' +import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react' import { useChatStore } from '../stores/chatStore' import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import * as configService from '../services/config' import './ContactsPage.scss' interface ContactInfo { @@ -23,6 +24,26 @@ const AVATAR_ENRICH_BATCH_SIZE = 80 const SEARCH_DEBOUNCE_MS = 120 const VIRTUAL_ROW_HEIGHT = 76 const VIRTUAL_OVERSCAN = 10 +const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 + +interface ContactsLoadSession { + requestId: string + startedAt: number + attempt: number + timeoutMs: number +} + +interface ContactsLoadIssue { + kind: 'timeout' | 'error' + title: string + message: string + reason: string + errorDetail?: string + occurredAt: number + elapsedMs: number +} + +type ContactsDataSource = 'cache' | 'network' | null function ContactsPage() { const [contacts, setContacts] = useState([]) @@ -61,6 +82,53 @@ function ContactsPage() { const [listViewportHeight, setListViewportHeight] = useState(480) const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts) const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts) + const loadAttemptRef = useRef(0) + const loadTimeoutTimerRef = useRef(null) + const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const [loadSession, setLoadSession] = useState(null) + const [loadIssue, setLoadIssue] = useState(null) + const [showDiagnostics, setShowDiagnostics] = useState(false) + const [diagnosticTick, setDiagnosticTick] = useState(Date.now()) + const [contactsDataSource, setContactsDataSource] = useState(null) + const [contactsUpdatedAt, setContactsUpdatedAt] = useState(null) + const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const contactsCacheScopeRef = useRef('default') + + const ensureContactsCacheScope = useCallback(async () => { + if (contactsCacheScopeRef.current !== 'default') { + return contactsCacheScopeRef.current + } + const [dbPath, myWxid] = await Promise.all([ + configService.getDbPath(), + configService.getMyWxid() + ]) + const scopeKey = dbPath || myWxid + ? `${dbPath || ''}::${myWxid || ''}` + : 'default' + contactsCacheScopeRef.current = scopeKey + return scopeKey + }, []) + + useEffect(() => { + let cancelled = false + void (async () => { + try { + const value = await configService.getContactsLoadTimeoutMs() + if (!cancelled) { + setContactsLoadTimeoutMs(value) + } + } catch (error) { + console.error('读取通讯录超时配置失败:', error) + } + })() + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs + }, [contactsLoadTimeoutMs]) const applyEnrichedContacts = useCallback((enrichedMap: Record) => { if (!enrichedMap || Object.keys(enrichedMap).length === 0) return @@ -139,9 +207,40 @@ function ContactsPage() { }, [applyEnrichedContacts]) // 加载通讯录 - const loadContacts = useCallback(async () => { + const loadContacts = useCallback(async (options?: { scopeKey?: string }) => { + const scopeKey = options?.scopeKey || await ensureContactsCacheScope() const loadVersion = loadVersionRef.current + 1 loadVersionRef.current = loadVersion + loadAttemptRef.current += 1 + const startedAt = Date.now() + const timeoutMs = contactsLoadTimeoutMsRef.current + const requestId = `contacts-${startedAt}-${loadAttemptRef.current}` + setLoadSession({ + requestId, + startedAt, + attempt: loadAttemptRef.current, + timeoutMs + }) + setLoadIssue(null) + setShowDiagnostics(false) + if (loadTimeoutTimerRef.current) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } + const timeoutTimerId = window.setTimeout(() => { + if (loadVersionRef.current !== loadVersion) return + const elapsedMs = Date.now() - startedAt + setLoadIssue({ + kind: 'timeout', + title: '通讯录加载超时', + message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`, + reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。', + occurredAt: Date.now(), + elapsedMs + }) + }, timeoutMs) + loadTimeoutTimerRef.current = timeoutTimerId + setIsLoading(true) setAvatarEnrichProgress({ loaded: 0, @@ -153,6 +252,10 @@ function ContactsPage() { if (loadVersionRef.current !== loadVersion) return if (contactsResult.success && contactsResult.contacts) { + if (loadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } setContacts(contactsResult.contacts) syncContactTypeCounts(contactsResult.contacts) setSelectedUsernames(new Set()) @@ -160,29 +263,108 @@ function ContactsPage() { if (!prev) return prev return contactsResult.contacts!.find(contact => contact.username === prev.username) || null }) + const now = Date.now() + setContactsDataSource('network') + setContactsUpdatedAt(now) + setLoadIssue(null) setIsLoading(false) + void configService.setContactsListCache( + scopeKey, + contactsResult.contacts.map(contact => ({ + username: contact.username, + displayName: contact.displayName, + remark: contact.remark, + nickname: contact.nickname, + type: contact.type + })) + ).catch((error) => { + console.error('写入通讯录缓存失败:', error) + }) void enrichContactsInBackground(contactsResult.contacts, loadVersion) return } + const elapsedMs = Date.now() - startedAt + setLoadIssue({ + kind: 'error', + title: '通讯录加载失败', + message: '联系人接口返回失败,未拿到联系人列表。', + reason: 'chat.getContacts 返回 success=false。', + errorDetail: contactsResult.error || '未知错误', + occurredAt: Date.now(), + elapsedMs + }) } catch (e) { console.error('加载通讯录失败:', e) + const elapsedMs = Date.now() - startedAt + setLoadIssue({ + kind: 'error', + title: '通讯录加载失败', + message: '联系人请求执行异常。', + reason: '调用 chat.getContacts 发生异常。', + errorDetail: String(e), + occurredAt: Date.now(), + elapsedMs + }) } finally { + if (loadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } if (loadVersionRef.current === loadVersion) { setIsLoading(false) } } - }, [enrichContactsInBackground, syncContactTypeCounts]) + }, [ensureContactsCacheScope, enrichContactsInBackground, syncContactTypeCounts]) useEffect(() => { - loadContacts() - }, [loadContacts]) + let cancelled = false + void (async () => { + const scopeKey = await ensureContactsCacheScope() + if (cancelled) return + try { + const cacheItem = await configService.getContactsListCache(scopeKey) + if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) { + const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({ + ...contact, + avatarUrl: undefined + })) + setContacts(cachedContacts) + syncContactTypeCounts(cachedContacts) + setContactsDataSource('cache') + setContactsUpdatedAt(cacheItem.updatedAt || null) + setIsLoading(false) + } + } catch (error) { + console.error('读取通讯录缓存失败:', error) + } + if (!cancelled) { + void loadContacts({ scopeKey }) + } + })() + return () => { + cancelled = true + } + }, [ensureContactsCacheScope, loadContacts, syncContactTypeCounts]) useEffect(() => { return () => { + if (loadTimeoutTimerRef.current) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } loadVersionRef.current += 1 } }, []) + useEffect(() => { + if (!loadIssue || contacts.length > 0) return + if (!(isLoading && loadIssue.kind === 'timeout')) return + const timer = window.setInterval(() => { + setDiagnosticTick(Date.now()) + }, 500) + return () => window.clearInterval(timer) + }, [contacts.length, isLoading, loadIssue]) + useEffect(() => { const timer = window.setTimeout(() => { setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase()) @@ -282,6 +464,45 @@ function ContactsPage() { setScrollTop(event.currentTarget.scrollTop) }, []) + const issueElapsedMs = useMemo(() => { + if (!loadIssue) return 0 + if (isLoading && loadSession) { + return Math.max(loadIssue.elapsedMs, diagnosticTick - loadSession.startedAt) + } + return loadIssue.elapsedMs + }, [diagnosticTick, isLoading, loadIssue, loadSession]) + + const diagnosticsText = useMemo(() => { + if (!loadIssue || !loadSession) return '' + return [ + `请求ID: ${loadSession.requestId}`, + `请求序号: 第 ${loadSession.attempt} 次`, + `阈值配置: ${loadSession.timeoutMs}ms`, + `当前状态: ${loadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`, + `累计耗时: ${(issueElapsedMs / 1000).toFixed(1)}s`, + `发生时间: ${new Date(loadIssue.occurredAt).toLocaleString()}`, + `阶段: chat.getContacts`, + `原因: ${loadIssue.reason}`, + `错误详情: ${loadIssue.errorDetail || '无'}` + ].join('\n') + }, [issueElapsedMs, loadIssue, loadSession]) + + const copyDiagnostics = useCallback(async () => { + if (!diagnosticsText) return + try { + await navigator.clipboard.writeText(diagnosticsText) + alert('诊断信息已复制') + } catch (error) { + console.error('复制诊断信息失败:', error) + alert('复制失败,请手动复制诊断信息') + } + }, [diagnosticsText]) + + const contactsUpdatedAtLabel = useMemo(() => { + if (!contactsUpdatedAt) return '' + return new Date(contactsUpdatedAt).toLocaleString() + }, [contactsUpdatedAt]) + const toggleContactSelected = (username: string, checked: boolean) => { setSelectedUsernames(prev => { const next = new Set(prev) @@ -410,7 +631,7 @@ function ContactsPage() { > -
@@ -460,6 +681,14 @@ function ContactsPage() {
共 {filteredContacts.length} / {contacts.length} 个联系人 + {contactsUpdatedAt && ( + + {contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel} + + )} + {isLoading && contacts.length > 0 && ( + 后台同步中... + )} {avatarEnrichProgress.running && ( 头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total} @@ -482,7 +711,39 @@ function ContactsPage() {
)} - {isLoading && contacts.length === 0 ? ( + {contacts.length === 0 && loadIssue ? ( +
+
+
+ + {loadIssue.title} +
+

{loadIssue.message}

+

{loadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showDiagnostics && ( +
{diagnosticsText}
+ )} +
+
+ ) : isLoading && contacts.length === 0 ? (
联系人加载中... diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 02774f1..7f4861d 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -222,6 +222,10 @@ const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): Co return 'private' } +const isContentScopeSession = (session: SessionRow): boolean => ( + session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend' +) + const getAvatarLetter = (name: string): string => { if (!name) return '?' return [...name][0] || '?' @@ -1327,11 +1331,11 @@ function ExportPage() { const openContentExport = (contentType: ContentType) => { const ids = sessions - .filter(session => session.kind === 'private' || session.kind === 'group') + .filter(isContentScopeSession) .map(session => session.username) const names = sessions - .filter(session => session.kind === 'private' || session.kind === 'group') + .filter(isContentScopeSession) .map(session => session.displayName || session.username) openExportDialog({ @@ -1375,8 +1379,8 @@ function ExportPage() { }, [tasks]) const contentCards = useMemo(() => { - const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') - const totalSessions = scopeSessions.length + const scopeSessions = sessions.filter(isContentScopeSession) + const totalSessions = tabCounts.private + tabCounts.group + tabCounts.former_friend const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts) const sessionCards = [ @@ -1414,7 +1418,7 @@ function ExportPage() { } return [...sessionCards, snsCard] - }, [sessions, lastExportByContent, snsStats, lastSnsExportPostCount]) + }, [sessions, tabCounts, lastExportByContent, snsStats, lastSnsExportPostCount]) const activeTabLabel = useMemo(() => { if (activeTab === 'private') return '私聊' @@ -1606,7 +1610,7 @@ function ExportPage() { ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady - const isSessionCardStatsLoading = isLoading || isBaseConfigLoading + const isSessionCardStatsLoading = isBaseConfigLoading || (isSharedTabCountsLoading && !isSharedTabCountsReady) const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length diff --git a/src/services/config.ts b/src/services/config.ts index 53969ef..3ea4652 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -38,6 +38,8 @@ export const CONFIG_KEYS = { EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount', EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', + CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs', + CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap', // 安全 AUTH_ENABLED: 'authEnabled', @@ -462,6 +464,19 @@ export interface ExportSnsStatsCacheItem { totalFriends: number } +export interface ContactsListCacheContact { + username: string + displayName: string + remark?: string + nickname?: string + type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' +} + +export interface ContactsListCacheItem { + updatedAt: number + contacts: ContactsListCacheContact[] +} + export async function getExportSessionMessageCountCache(scopeKey: string): Promise { if (!scopeKey) return null const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP) @@ -549,6 +564,92 @@ export async function setExportSnsStatsCache( await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map) } +// 获取通讯录加载超时阈值(毫秒) +export async function getContactsLoadTimeoutMs(): Promise { + const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS) + if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) { + return Math.floor(value) + } + return 3000 +} + +// 设置通讯录加载超时阈值(毫秒) +export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise { + const normalized = Number.isFinite(timeoutMs) + ? Math.min(60000, Math.max(1000, Math.floor(timeoutMs))) + : 3000 + await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized) +} + +export async function getContactsListCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const rawUpdatedAt = (rawItem as Record).updatedAt + const rawContacts = (rawItem as Record).contacts + if (!Array.isArray(rawContacts)) return null + + const contacts: ContactsListCacheContact[] = [] + for (const raw of rawContacts) { + if (!raw || typeof raw !== 'object') continue + const item = raw as Record + const username = typeof item.username === 'string' ? item.username.trim() : '' + if (!username) continue + const displayName = typeof item.displayName === 'string' ? item.displayName : username + const type = typeof item.type === 'string' ? item.type : 'other' + contacts.push({ + username, + displayName, + remark: typeof item.remark === 'string' ? item.remark : undefined, + nickname: typeof item.nickname === 'string' ? item.nickname : undefined, + type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other') + ? type + : 'other' + }) + } + + return { + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + contacts + } +} + +export async function setContactsListCache(scopeKey: string, contacts: ContactsListCacheContact[]): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: ContactsListCacheContact[] = [] + for (const contact of contacts || []) { + const username = String(contact?.username || '').trim() + if (!username) continue + const displayName = String(contact?.displayName || username) + const type = contact?.type || 'other' + if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'other') { + continue + } + normalized.push({ + username, + displayName, + remark: contact?.remark ? String(contact.remark) : undefined, + nickname: contact?.nickname ? String(contact.nickname) : undefined, + type + }) + } + + map[scopeKey] = { + updatedAt: Date.now(), + contacts: normalized + } + await config.set(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP, map) +} + // === 安全相关 === export async function getAuthEnabled(): Promise { From 01a221831f5364964b023e97f0f28a54345bf400 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:06:42 +0800 Subject: [PATCH 029/155] feat(export): move task center into top control row --- src/pages/ExportPage.scss | 102 ++++++------ src/pages/ExportPage.tsx | 315 ++++++++++++++++++++++---------------- 2 files changed, 242 insertions(+), 175 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index ae7129b..8a69ee6 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -24,8 +24,9 @@ border-radius: 14px; padding: 14px; display: grid; - grid-template-columns: minmax(300px, 1fr) 320px; + grid-template-columns: minmax(320px, 1.45fr) minmax(220px, 0.8fr) minmax(260px, 1fr); gap: 16px; + align-items: end; .control-label { font-size: 12px; @@ -189,6 +190,54 @@ color: var(--text-secondary); line-height: 1.45; } + + .task-center-control { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + } + + .task-center-inline { + min-height: 40px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + padding: 0 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .task-summary { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + min-width: 0; + } + + .task-collapse-btn { + border: 1px solid var(--border-color); + background: var(--bg-primary); + border-radius: 8px; + padding: 4px 8px; + font-size: 12px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + flex-shrink: 0; + + &:hover { + border-color: var(--primary); + color: var(--primary); + } + } } .content-card-grid { @@ -276,51 +325,7 @@ padding: 12px; flex-shrink: 0; - .task-center-header { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - } - - .section-title { - font-size: 14px; - font-weight: 700; - color: var(--text-primary); - margin: 0; - flex-shrink: 0; - } - - .task-summary { - margin-left: auto; - display: inline-flex; - align-items: center; - gap: 10px; - font-size: 12px; - color: var(--text-secondary); - white-space: nowrap; - } - - .task-collapse-btn { - border: 1px solid var(--border-color); - background: var(--bg-secondary); - border-radius: 8px; - padding: 4px 8px; - font-size: 12px; - color: var(--text-secondary); - display: inline-flex; - align-items: center; - gap: 4px; - cursor: pointer; - - &:hover { - border-color: var(--primary); - color: var(--primary); - } - } - .task-empty { - margin-top: 10px; padding: 12px; background: var(--bg-secondary); border-radius: 8px; @@ -329,7 +334,6 @@ } .task-list { - margin-top: 10px; display: grid; gap: 8px; max-height: 190px; @@ -1099,6 +1103,12 @@ .path-inline-row > .secondary-btn { margin-left: auto; } + + .task-center-inline { + flex-wrap: wrap; + min-height: auto; + padding: 8px 10px; + } } .content-card-grid { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 7f4861d..f6177ed 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -242,11 +242,13 @@ const timestampOrDash = (timestamp?: number): string => { } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const MESSAGE_COUNT_VIEWPORT_PREFETCH = 180 -const MESSAGE_COUNT_ACTIVE_TAB_WARMUP_LIMIT = 960 -const METRICS_VIEWPORT_PREFETCH = 90 -const METRICS_BACKGROUND_BATCH = 40 -const METRICS_BACKGROUND_INTERVAL_MS = 220 +const MESSAGE_COUNT_VIEWPORT_PREFETCH = 90 +const MESSAGE_COUNT_ACTIVE_TAB_WARMUP_LIMIT = 240 +const MESSAGE_COUNT_REQUEST_BATCH = 120 +const METRICS_VIEWPORT_PREFETCH = 60 +const METRICS_REQUEST_BATCH = 24 +const METRICS_BACKGROUND_BATCH = 20 +const METRICS_BACKGROUND_INTERVAL_MS = 500 const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SESSION_COUNT_CACHE_STALE_MS = 48 * 60 * 60 * 1000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 @@ -393,6 +395,11 @@ function ExportPage() { const sessionLoadTokenRef = useRef(0) const loadingMessageCountsRef = useRef>(new Set()) const loadingMetricsRef = useRef>(new Set()) + const pendingMessageCountsRef = useRef>(new Set()) + const pendingMetricsRef = useRef>(new Set()) + const messageCountPumpRunningRef = useRef(false) + const metricsPumpRunningRef = useRef(false) + const isExportRouteRef = useRef(isExportRoute) const preselectAppliedRef = useRef(false) const visibleSessionsRef = useRef([]) const exportCacheScopeRef = useRef('default') @@ -415,6 +422,10 @@ function ExportPage() { sessionMetricsRef.current = sessionMetrics }, [sessionMetrics]) + useEffect(() => { + isExportRouteRef.current = isExportRoute + }, [isExportRoute]) + useEffect(() => { if (persistSessionCountTimerRef.current) { window.clearTimeout(persistSessionCountTimerRef.current) @@ -452,9 +463,10 @@ function ExportPage() { }, [location.state]) useEffect(() => { + if (!isExportRoute) return const timer = setInterval(() => setNowTick(Date.now()), 60 * 1000) return () => clearInterval(timer) - }, []) + }, [isExportRoute]) const loadBaseConfig = useCallback(async () => { setIsBaseConfigLoading(true) @@ -581,6 +593,8 @@ function ExportPage() { setIsSessionEnriching(false) loadingMessageCountsRef.current.clear() loadingMetricsRef.current.clear() + pendingMessageCountsRef.current.clear() + pendingMetricsRef.current.clear() sessionMetricsRef.current = {} setSessionMetrics({}) @@ -632,6 +646,7 @@ function ExportPage() { setIsSessionEnriching(true) void (async () => { try { + if (isStale()) return const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) if (isStale()) return @@ -650,6 +665,7 @@ function ExportPage() { let extraContactMap: Record = {} if (needsEnrichment.length > 0) { + if (isStale()) return const enrichResult = await withTimeout( window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment), CONTACT_ENRICH_TIMEOUT_MS @@ -714,6 +730,8 @@ function ExportPage() { sessionLoadTokenRef.current = Date.now() loadingMessageCountsRef.current.clear() loadingMetricsRef.current.clear() + pendingMessageCountsRef.current.clear() + pendingMetricsRef.current.clear() setIsSessionEnriching(false) }, [isExportRoute]) @@ -769,38 +787,50 @@ function ExportPage() { }, [visibleSessions]) const ensureSessionMessageCounts = useCallback(async (targetSessions: SessionRow[]) => { - if (!isExportRoute) return - const loadTokenAtStart = sessionLoadTokenRef.current + if (!isExportRouteRef.current) return const currentCounts = sessionMessageCountsRef.current - const pending = targetSessions.filter( - session => currentCounts[session.username] === undefined && !loadingMessageCountsRef.current.has(session.username) - ) - if (pending.length === 0) return - for (const session of pending) { - loadingMessageCountsRef.current.add(session.username) + for (const session of targetSessions) { + if (currentCounts[session.username] !== undefined) continue + if (loadingMessageCountsRef.current.has(session.username)) continue + pendingMessageCountsRef.current.add(session.username) } + if (pendingMessageCountsRef.current.size === 0 || messageCountPumpRunningRef.current) return + + messageCountPumpRunningRef.current = true + const loadTokenAtStart = sessionLoadTokenRef.current try { - const batchSize = pending.length > 260 ? 260 : pending.length - for (let i = 0; i < pending.length; i += batchSize) { - if (loadTokenAtStart !== sessionLoadTokenRef.current) return - const chunk = pending.slice(i, i + batchSize) - const ids = chunk.map(session => session.username) + while (isExportRouteRef.current && loadTokenAtStart === sessionLoadTokenRef.current) { + const ids = Array.from(pendingMessageCountsRef.current).slice(0, MESSAGE_COUNT_REQUEST_BATCH) + if (ids.length === 0) break + + for (const id of ids) { + pendingMessageCountsRef.current.delete(id) + loadingMessageCountsRef.current.add(id) + } + const chunkUpdates: Record = {} try { const result = await withTimeout(window.electronAPI.chat.getSessionMessageCounts(ids), 10000) if (!result) { - continue - } - for (const session of chunk) { - const value = result?.success && result.counts ? result.counts[session.username] : undefined - chunkUpdates[session.username] = typeof value === 'number' ? value : 0 + for (const id of ids) { + chunkUpdates[id] = 0 + } + } else { + for (const id of ids) { + const value = result?.success && result.counts ? result.counts[id] : undefined + chunkUpdates[id] = typeof value === 'number' ? value : 0 + } } } catch (error) { console.error('加载会话总消息数失败:', error) - for (const session of chunk) { - chunkUpdates[session.username] = 0 + for (const id of ids) { + chunkUpdates[id] = 0 + } + } finally { + for (const id of ids) { + loadingMessageCountsRef.current.delete(id) } } @@ -809,72 +839,95 @@ function ExportPage() { } } } finally { - for (const session of pending) { - loadingMessageCountsRef.current.delete(session.username) - } + messageCountPumpRunningRef.current = false } - }, [isExportRoute]) + }, []) const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { - if (!isExportRoute) return - const loadTokenAtStart = sessionLoadTokenRef.current + if (!isExportRouteRef.current) return const currentMetrics = sessionMetricsRef.current - const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) - if (pending.length === 0) return - - const updates: Record = {} - for (const session of pending) { - loadingMetricsRef.current.add(session.username) + for (const session of targetSessions) { + if (currentMetrics[session.username]) continue + if (loadingMetricsRef.current.has(session.username)) continue + pendingMetricsRef.current.add(session.username) } + if (pendingMetricsRef.current.size === 0 || metricsPumpRunningRef.current) return + + metricsPumpRunningRef.current = true + const loadTokenAtStart = sessionLoadTokenRef.current try { - const batchSize = 80 - for (let i = 0; i < pending.length; i += batchSize) { - if (loadTokenAtStart !== sessionLoadTokenRef.current) return - const chunk = pending.slice(i, i + batchSize) - const ids = chunk.map(session => session.username) + while (isExportRouteRef.current && loadTokenAtStart === sessionLoadTokenRef.current) { + const ids = Array.from(pendingMetricsRef.current).slice(0, METRICS_REQUEST_BATCH) + if (ids.length === 0) break + + for (const id of ids) { + pendingMetricsRef.current.delete(id) + loadingMetricsRef.current.add(id) + } + + const updates: Record = {} try { const statsResult = await window.electronAPI.chat.getExportSessionStats(ids) if (!statsResult.success || !statsResult.data) { console.error('加载会话统计失败:', statsResult.error || '未知错误') - continue - } - - for (const session of chunk) { - const raw = statsResult.data[session.username] - // 成功响应但无明细时按 0 回填,避免该行反复重试导致滚动抖动。 - updates[session.username] = { - totalMessages: raw?.totalMessages ?? 0, - voiceMessages: raw?.voiceMessages ?? 0, - imageMessages: raw?.imageMessages ?? 0, - videoMessages: raw?.videoMessages ?? 0, - emojiMessages: raw?.emojiMessages ?? 0, - privateMutualGroups: raw?.privateMutualGroups, - groupMemberCount: raw?.groupMemberCount, - groupMyMessages: raw?.groupMyMessages, - groupActiveSpeakers: raw?.groupActiveSpeakers, - groupMutualFriends: raw?.groupMutualFriends, - firstTimestamp: raw?.firstTimestamp, - lastTimestamp: raw?.lastTimestamp + for (const id of ids) { + updates[id] = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0 + } + } + } else { + for (const id of ids) { + const raw = statsResult.data[id] + // 成功响应但无明细时按 0 回填,避免该行反复重试导致滚动抖动。 + updates[id] = { + totalMessages: raw?.totalMessages ?? 0, + voiceMessages: raw?.voiceMessages ?? 0, + imageMessages: raw?.imageMessages ?? 0, + videoMessages: raw?.videoMessages ?? 0, + emojiMessages: raw?.emojiMessages ?? 0, + privateMutualGroups: raw?.privateMutualGroups, + groupMemberCount: raw?.groupMemberCount, + groupMyMessages: raw?.groupMyMessages, + groupActiveSpeakers: raw?.groupActiveSpeakers, + groupMutualFriends: raw?.groupMutualFriends, + firstTimestamp: raw?.firstTimestamp, + lastTimestamp: raw?.lastTimestamp + } } } } catch (error) { console.error('加载会话统计分批失败:', error) + for (const id of ids) { + updates[id] = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0 + } + } + } finally { + for (const id of ids) { + loadingMetricsRef.current.delete(id) + } + } + + if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { + setSessionMetrics(prev => ({ ...prev, ...updates })) } } } catch (error) { console.error('加载会话统计失败:', error) } finally { - for (const session of pending) { - loadingMetricsRef.current.delete(session.username) - } + metricsPumpRunningRef.current = false } - - if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { - setSessionMetrics(prev => ({ ...prev, ...updates })) - } - }, [isExportRoute]) + }, []) useEffect(() => { if (!isExportRoute) return @@ -1660,9 +1713,72 @@ function ExportPage() { await configService.setExportWriteLayout(value) }} /> + +
+ 任务中心 +
+
+ 进行中 {taskRunningCount} + 排队 {taskQueuedCount} + 总计 {tasks.length} +
+ +
+
+ {isTaskCenterExpanded && ( +
+ {tasks.length === 0 ? ( +
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
+ ) : ( +
+ {tasks.map(task => ( +
+
+
{task.title}
+
+ {task.status === 'queued' ? '排队中' : task.status === 'running' ? '进行中' : task.status === 'success' ? '已完成' : '失败'} + {new Date(task.createdAt).toLocaleString('zh-CN')} +
+ {task.status === 'running' && ( + <> +
+
0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} + /> +
+
+ {task.progress.total > 0 + ? `${task.progress.current} / ${task.progress.total}` + : '处理中'} + {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} +
+ + )} + {task.status === 'error' &&
{task.error || '任务失败'}
} +
+
+ +
+
+ ))} +
+ )} +
+ )} +
{contentCards.map(card => { const Icon = card.icon @@ -1705,65 +1821,6 @@ function ExportPage() { })}
-
-
-
任务中心
-
- 进行中 {taskRunningCount} - 排队 {taskQueuedCount} - 总计 {tasks.length} -
- -
- - {isTaskCenterExpanded && (tasks.length === 0 ? ( -
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
- ) : ( -
- {tasks.map(task => ( -
-
-
{task.title}
-
- {task.status === 'queued' ? '排队中' : task.status === 'running' ? '进行中' : task.status === 'success' ? '已完成' : '失败'} - {new Date(task.createdAt).toLocaleString('zh-CN')} -
- {task.status === 'running' && ( - <> -
-
0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} - /> -
-
- {task.progress.total > 0 - ? `${task.progress.current} / ${task.progress.total}` - : '处理中'} - {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} -
- - )} - {task.status === 'error' &&
{task.error || '任务失败'}
} -
-
- -
-
- ))} -
- ))} -
-
From b3700c3a4c1703515be72da8fac60fdae2ff5189 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:12:09 +0800 Subject: [PATCH 030/155] refactor(export): remove session stats columns and background counting --- src/pages/ExportPage.tsx | 430 +-------------------------------------- 1 file changed, 6 insertions(+), 424 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index f6177ed..6991981 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -59,21 +59,6 @@ interface SessionRow extends AppChatSession { wechatId?: string } -interface SessionMetrics { - totalMessages?: number - voiceMessages?: number - imageMessages?: number - videoMessages?: number - emojiMessages?: number - privateMutualGroups?: number - groupMemberCount?: number - groupMyMessages?: number - groupActiveSpeakers?: number - groupMutualFriends?: number - firstTimestamp?: number - lastTimestamp?: number -} - interface TaskProgress { current: number total: number @@ -231,26 +216,8 @@ const getAvatarLetter = (name: string): string => { return [...name][0] || '?' } -const valueOrDash = (value?: number): string => { - if (value === undefined || value === null) return '--' - return value.toLocaleString() -} - -const timestampOrDash = (timestamp?: number): string => { - if (!timestamp) return '--' - return formatAbsoluteDate(timestamp * 1000) -} - const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const MESSAGE_COUNT_VIEWPORT_PREFETCH = 90 -const MESSAGE_COUNT_ACTIVE_TAB_WARMUP_LIMIT = 240 -const MESSAGE_COUNT_REQUEST_BATCH = 120 -const METRICS_VIEWPORT_PREFETCH = 60 -const METRICS_REQUEST_BATCH = 24 -const METRICS_BACKGROUND_BATCH = 20 -const METRICS_BACKGROUND_INTERVAL_MS = 500 const CONTACT_ENRICH_TIMEOUT_MS = 7000 -const EXPORT_SESSION_COUNT_CACHE_STALE_MS = 48 * 60 * 60 * 1000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { @@ -333,8 +300,6 @@ function ExportPage() { const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) - const [sessionMessageCounts, setSessionMessageCounts] = useState>({}) - const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') const [selectedSessions, setSelectedSessions] = useState>(new Set()) @@ -390,21 +355,10 @@ function ExportPage() { const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) const hasSeededSnsStatsRef = useRef(false) - const sessionMessageCountsRef = useRef>({}) - const sessionMetricsRef = useRef>({}) const sessionLoadTokenRef = useRef(0) - const loadingMessageCountsRef = useRef>(new Set()) - const loadingMetricsRef = useRef>(new Set()) - const pendingMessageCountsRef = useRef>(new Set()) - const pendingMetricsRef = useRef>(new Set()) - const messageCountPumpRunningRef = useRef(false) - const metricsPumpRunningRef = useRef(false) - const isExportRouteRef = useRef(isExportRoute) const preselectAppliedRef = useRef(false) - const visibleSessionsRef = useRef([]) const exportCacheScopeRef = useRef('default') const exportCacheScopeReadyRef = useRef(false) - const persistSessionCountTimerRef = useRef(null) useEffect(() => { tasksRef.current = tasks @@ -414,42 +368,6 @@ function ExportPage() { hasSeededSnsStatsRef.current = hasSeededSnsStats }, [hasSeededSnsStats]) - useEffect(() => { - sessionMessageCountsRef.current = sessionMessageCounts - }, [sessionMessageCounts]) - - useEffect(() => { - sessionMetricsRef.current = sessionMetrics - }, [sessionMetrics]) - - useEffect(() => { - isExportRouteRef.current = isExportRoute - }, [isExportRoute]) - - useEffect(() => { - if (persistSessionCountTimerRef.current) { - window.clearTimeout(persistSessionCountTimerRef.current) - persistSessionCountTimerRef.current = null - } - - if (isBaseConfigLoading || !exportCacheScopeReadyRef.current) return - - const countSize = Object.keys(sessionMessageCounts).length - if (countSize === 0) return - - persistSessionCountTimerRef.current = window.setTimeout(() => { - void configService.setExportSessionMessageCountCache(exportCacheScopeRef.current, sessionMessageCounts) - persistSessionCountTimerRef.current = null - }, 900) - - return () => { - if (persistSessionCountTimerRef.current) { - window.clearTimeout(persistSessionCountTimerRef.current) - persistSessionCountTimerRef.current = null - } - } - }, [sessionMessageCounts, isBaseConfigLoading]) - const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const rawList = Array.isArray(state?.preselectSessionIds) @@ -490,10 +408,7 @@ function ExportPage() { exportCacheScopeRef.current = exportCacheScope exportCacheScopeReadyRef.current = true - const [cachedSessionCountMap, cachedSnsStats] = await Promise.all([ - configService.getExportSessionMessageCountCache(exportCacheScope), - configService.getExportSnsStatsCache(exportCacheScope) - ]) + const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope) if (savedPath) { setExportFolder(savedPath) @@ -507,10 +422,6 @@ function ExportPage() { setLastExportByContent(savedContentMap) setLastSnsExportPostCount(savedSnsPostCount) - if (cachedSessionCountMap && Date.now() - cachedSessionCountMap.updatedAt <= EXPORT_SESSION_COUNT_CACHE_STALE_MS) { - setSessionMessageCounts(cachedSessionCountMap.counts || {}) - } - if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { setSnsStats({ totalPosts: cachedSnsStats.totalPosts || 0, @@ -591,12 +502,6 @@ function ExportPage() { sessionLoadTokenRef.current = loadToken setIsLoading(true) setIsSessionEnriching(false) - loadingMessageCountsRef.current.clear() - loadingMetricsRef.current.clear() - pendingMessageCountsRef.current.clear() - pendingMetricsRef.current.clear() - sessionMetricsRef.current = {} - setSessionMetrics({}) const isStale = () => sessionLoadTokenRef.current !== loadToken @@ -626,20 +531,6 @@ function ExportPage() { if (isStale()) return setSessions(baseSessions) - setSessionMessageCounts(prev => { - const next: Record = {} - for (const session of baseSessions) { - const count = prev[session.username] - if (typeof count === 'number') { - next[session.username] = count - continue - } - if (typeof session.messageCountHint === 'number' && Number.isFinite(session.messageCountHint) && session.messageCountHint >= 0) { - next[session.username] = Math.floor(session.messageCountHint) - } - } - return next - }) setIsLoading(false) // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 @@ -726,12 +617,8 @@ function ExportPage() { useEffect(() => { if (isExportRoute) return - // 导出页隐藏时停止后台统计请求,避免与通讯录页面查询抢占。 + // 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。 sessionLoadTokenRef.current = Date.now() - loadingMessageCountsRef.current.clear() - loadingMetricsRef.current.clear() - pendingMessageCountsRef.current.clear() - pendingMetricsRef.current.clear() setIsSessionEnriching(false) }, [isExportRoute]) @@ -764,227 +651,11 @@ function ExportPage() { ) }) .sort((a, b) => { - const totalA = sessionMessageCounts[a.username] - const totalB = sessionMessageCounts[b.username] - const hasTotalA = typeof totalA === 'number' - const hasTotalB = typeof totalB === 'number' - - if (hasTotalA && hasTotalB && totalB !== totalA) { - return totalB - totalA - } - if (hasTotalA !== hasTotalB) { - return hasTotalA ? -1 : 1 - } - - const latestA = sessionMetrics[a.username]?.lastTimestamp ?? a.lastTimestamp ?? 0 - const latestB = sessionMetrics[b.username]?.lastTimestamp ?? b.lastTimestamp ?? 0 + const latestA = a.sortTimestamp || a.lastTimestamp || 0 + const latestB = b.sortTimestamp || b.lastTimestamp || 0 return latestB - latestA }) - }, [sessions, activeTab, searchKeyword, sessionMessageCounts, sessionMetrics]) - - useEffect(() => { - visibleSessionsRef.current = visibleSessions - }, [visibleSessions]) - - const ensureSessionMessageCounts = useCallback(async (targetSessions: SessionRow[]) => { - if (!isExportRouteRef.current) return - const currentCounts = sessionMessageCountsRef.current - for (const session of targetSessions) { - if (currentCounts[session.username] !== undefined) continue - if (loadingMessageCountsRef.current.has(session.username)) continue - pendingMessageCountsRef.current.add(session.username) - } - if (pendingMessageCountsRef.current.size === 0 || messageCountPumpRunningRef.current) return - - messageCountPumpRunningRef.current = true - const loadTokenAtStart = sessionLoadTokenRef.current - - try { - while (isExportRouteRef.current && loadTokenAtStart === sessionLoadTokenRef.current) { - const ids = Array.from(pendingMessageCountsRef.current).slice(0, MESSAGE_COUNT_REQUEST_BATCH) - if (ids.length === 0) break - - for (const id of ids) { - pendingMessageCountsRef.current.delete(id) - loadingMessageCountsRef.current.add(id) - } - - const chunkUpdates: Record = {} - - try { - const result = await withTimeout(window.electronAPI.chat.getSessionMessageCounts(ids), 10000) - if (!result) { - for (const id of ids) { - chunkUpdates[id] = 0 - } - } else { - for (const id of ids) { - const value = result?.success && result.counts ? result.counts[id] : undefined - chunkUpdates[id] = typeof value === 'number' ? value : 0 - } - } - } catch (error) { - console.error('加载会话总消息数失败:', error) - for (const id of ids) { - chunkUpdates[id] = 0 - } - } finally { - for (const id of ids) { - loadingMessageCountsRef.current.delete(id) - } - } - - if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(chunkUpdates).length > 0) { - setSessionMessageCounts(prev => ({ ...prev, ...chunkUpdates })) - } - } - } finally { - messageCountPumpRunningRef.current = false - } - }, []) - - const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { - if (!isExportRouteRef.current) return - const currentMetrics = sessionMetricsRef.current - for (const session of targetSessions) { - if (currentMetrics[session.username]) continue - if (loadingMetricsRef.current.has(session.username)) continue - pendingMetricsRef.current.add(session.username) - } - if (pendingMetricsRef.current.size === 0 || metricsPumpRunningRef.current) return - - metricsPumpRunningRef.current = true - const loadTokenAtStart = sessionLoadTokenRef.current - - try { - while (isExportRouteRef.current && loadTokenAtStart === sessionLoadTokenRef.current) { - const ids = Array.from(pendingMetricsRef.current).slice(0, METRICS_REQUEST_BATCH) - if (ids.length === 0) break - - for (const id of ids) { - pendingMetricsRef.current.delete(id) - loadingMetricsRef.current.add(id) - } - - const updates: Record = {} - - try { - const statsResult = await window.electronAPI.chat.getExportSessionStats(ids) - if (!statsResult.success || !statsResult.data) { - console.error('加载会话统计失败:', statsResult.error || '未知错误') - for (const id of ids) { - updates[id] = { - totalMessages: 0, - voiceMessages: 0, - imageMessages: 0, - videoMessages: 0, - emojiMessages: 0 - } - } - } else { - for (const id of ids) { - const raw = statsResult.data[id] - // 成功响应但无明细时按 0 回填,避免该行反复重试导致滚动抖动。 - updates[id] = { - totalMessages: raw?.totalMessages ?? 0, - voiceMessages: raw?.voiceMessages ?? 0, - imageMessages: raw?.imageMessages ?? 0, - videoMessages: raw?.videoMessages ?? 0, - emojiMessages: raw?.emojiMessages ?? 0, - privateMutualGroups: raw?.privateMutualGroups, - groupMemberCount: raw?.groupMemberCount, - groupMyMessages: raw?.groupMyMessages, - groupActiveSpeakers: raw?.groupActiveSpeakers, - groupMutualFriends: raw?.groupMutualFriends, - firstTimestamp: raw?.firstTimestamp, - lastTimestamp: raw?.lastTimestamp - } - } - } - } catch (error) { - console.error('加载会话统计分批失败:', error) - for (const id of ids) { - updates[id] = { - totalMessages: 0, - voiceMessages: 0, - imageMessages: 0, - videoMessages: 0, - emojiMessages: 0 - } - } - } finally { - for (const id of ids) { - loadingMetricsRef.current.delete(id) - } - } - - if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { - setSessionMetrics(prev => ({ ...prev, ...updates })) - } - } - } catch (error) { - console.error('加载会话统计失败:', error) - } finally { - metricsPumpRunningRef.current = false - } - }, []) - - useEffect(() => { - if (!isExportRoute) return - const targets = visibleSessions.slice(0, MESSAGE_COUNT_VIEWPORT_PREFETCH) - void ensureSessionMessageCounts(targets) - }, [isExportRoute, visibleSessions, ensureSessionMessageCounts]) - - useEffect(() => { - if (!isExportRoute) return - if (sessions.length === 0) return - const activeTabTargets = sessions - .filter(session => session.kind === activeTab) - .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) - .slice(0, MESSAGE_COUNT_ACTIVE_TAB_WARMUP_LIMIT) - if (activeTabTargets.length === 0) return - void ensureSessionMessageCounts(activeTabTargets) - }, [isExportRoute, sessions, activeTab, ensureSessionMessageCounts]) - - useEffect(() => { - if (!isExportRoute) return - const targets = visibleSessions.slice(0, METRICS_VIEWPORT_PREFETCH) - void ensureSessionMetrics(targets) - }, [isExportRoute, visibleSessions, ensureSessionMetrics]) - - const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { - if (!isExportRoute) return - const current = visibleSessionsRef.current - if (current.length === 0) return - const prefetch = Math.max(MESSAGE_COUNT_VIEWPORT_PREFETCH, METRICS_VIEWPORT_PREFETCH) - const start = Math.max(0, range.startIndex - prefetch) - const end = Math.min(current.length - 1, range.endIndex + prefetch) - if (end < start) return - const rangeSessions = current.slice(start, end + 1) - void ensureSessionMessageCounts(rangeSessions) - void ensureSessionMetrics(rangeSessions) - }, [isExportRoute, ensureSessionMessageCounts, ensureSessionMetrics]) - - useEffect(() => { - if (!isExportRoute) return - if (sessions.length === 0) return - const prioritySessions = [ - ...sessions.filter(session => session.kind === activeTab), - ...sessions.filter(session => session.kind !== activeTab) - ] - let cursor = 0 - const timer = window.setInterval(() => { - if (cursor >= prioritySessions.length) { - window.clearInterval(timer) - return - } - const chunk = prioritySessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH) - cursor += METRICS_BACKGROUND_BATCH - void ensureSessionMetrics(chunk) - }, METRICS_BACKGROUND_INTERVAL_MS) - - return () => window.clearInterval(timer) - }, [isExportRoute, sessions, activeTab, ensureSessionMetrics]) + }, [sessions, activeTab, searchKeyword]) const selectedCount = selectedSessions.size @@ -1519,64 +1190,16 @@ function ExportPage() { } const renderTableHeader = () => { - if (activeTab === 'private' || activeTab === 'former_friend') { - return ( - - 选择 - 会话名(头像/昵称/微信号) - 总消息 - 语音 - 图片 - 视频 - 表情包 - 共同群聊数 - 最早时间 - 最新时间 - 操作 - - ) - } - - if (activeTab === 'group') { - return ( - - 选择 - 会话名(群头像/群名称/群ID) - 总消息 - 语音 - 图片 - 视频 - 表情包 - 我发的消息数 - 群人数 - 群发言人数 - 群共同好友数 - 最早时间 - 最新时间 - 操作 - - ) - } - return ( 选择 会话名(头像/名称/微信号) - 总消息 - 语音 - 图片 - 视频 - 表情包 - 最早时间 - 最新时间 操作 ) } const renderRowCells = (session: SessionRow) => { - const metrics = sessionMetrics[session.username] - const totalMessages = sessionMessageCounts[session.username] const checked = selectedSessions.has(session.username) return ( @@ -1592,46 +1215,6 @@ function ExportPage() { {renderSessionName(session)} - - {typeof totalMessages === 'number' - ? totalMessages.toLocaleString() - : ( - - 统计中 - - )} - - {valueOrDash(metrics?.voiceMessages)} - {valueOrDash(metrics?.imageMessages)} - {valueOrDash(metrics?.videoMessages)} - {valueOrDash(metrics?.emojiMessages)} - - {(activeTab === 'private' || activeTab === 'former_friend') && ( - <> - {valueOrDash(metrics?.privateMutualGroups)} - {timestampOrDash(metrics?.firstTimestamp)} - {timestampOrDash(metrics?.lastTimestamp)} - - )} - - {activeTab === 'group' && ( - <> - {valueOrDash(metrics?.groupMyMessages)} - {valueOrDash(metrics?.groupMemberCount)} - {valueOrDash(metrics?.groupActiveSpeakers)} - {valueOrDash(metrics?.groupMutualFriends)} - {timestampOrDash(metrics?.firstTimestamp)} - {timestampOrDash(metrics?.lastTimestamp)} - - )} - - {activeTab === 'official' && ( - <> - {timestampOrDash(metrics?.firstTimestamp)} - {timestampOrDash(metrics?.lastTimestamp)} - - )} - {renderActionCell(session)} ) @@ -1872,7 +1455,7 @@ function ExportPage() { {!showInitialSkeleton && (isLoading || isSessionEnriching) && (
- {isLoading ? '导出板块数据加载中…' : '正在补充头像和统计…'} + {isLoading ? '导出板块数据加载中…' : '正在补充头像…'}
)} @@ -1898,7 +1481,6 @@ function ExportPage() { data={visibleSessions} fixedHeaderContent={renderTableHeader} computeItemKey={(_, session) => session.username} - rangeChanged={handleTableRangeChanged} itemContent={(_, session) => renderRowCells(session)} overscan={420} /> From faeda030e9407be7b7b034bd1e5f205a5985fa26 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:15:24 +0800 Subject: [PATCH 031/155] feat(contacts): persist avatar cache with incremental refresh --- src/pages/ContactsPage.tsx | 176 ++++++++++++++++++++++++++++++++++--- src/services/config.ts | 100 +++++++++++++++++++++ 2 files changed, 265 insertions(+), 11 deletions(-) diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 6a87372..2d489f9 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -25,6 +25,7 @@ const SEARCH_DEBOUNCE_MS = 120 const VIRTUAL_ROW_HEIGHT = 76 const VIRTUAL_OVERSCAN = 10 const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 +const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 interface ContactsLoadSession { requestId: string @@ -91,8 +92,10 @@ function ContactsPage() { const [diagnosticTick, setDiagnosticTick] = useState(Date.now()) const [contactsDataSource, setContactsDataSource] = useState(null) const [contactsUpdatedAt, setContactsUpdatedAt] = useState(null) + const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState(null) const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const contactsCacheScopeRef = useRef('default') + const contactsAvatarCacheRef = useRef>({}) const ensureContactsCacheScope = useCallback(async () => { if (contactsCacheScopeRef.current !== 'default') { @@ -130,6 +133,85 @@ function ContactsPage() { contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs }, [contactsLoadTimeoutMs]) + const mergeAvatarCacheIntoContacts = useCallback((sourceContacts: ContactInfo[]): ContactInfo[] => { + const avatarCache = contactsAvatarCacheRef.current + if (!sourceContacts.length || Object.keys(avatarCache).length === 0) { + return sourceContacts + } + let changed = false + const merged = sourceContacts.map((contact) => { + const cachedAvatar = avatarCache[contact.username]?.avatarUrl + if (!cachedAvatar || contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + avatarUrl: cachedAvatar + } + }) + return changed ? merged : sourceContacts + }, []) + + const upsertAvatarCacheFromContacts = useCallback(( + scopeKey: string, + sourceContacts: ContactInfo[], + options?: { prune?: boolean; markCheckedUsernames?: string[] } + ) => { + if (!scopeKey) return + const nextCache = { ...contactsAvatarCacheRef.current } + const now = Date.now() + const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean)) + const usernamesInSource = new Set() + let changed = false + + for (const contact of sourceContacts) { + const username = String(contact.username || '').trim() + if (!username) continue + usernamesInSource.add(username) + const prev = nextCache[username] + const avatarUrl = String(contact.avatarUrl || '').trim() + if (!avatarUrl) continue + const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt + const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now) + if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) { + nextCache[username] = { + avatarUrl, + updatedAt, + checkedAt + } + changed = true + } + } + + for (const username of markCheckedSet) { + const prev = nextCache[username] + if (!prev) continue + if (prev.checkedAt !== now) { + nextCache[username] = { + ...prev, + checkedAt: now + } + changed = true + } + } + + if (options?.prune) { + for (const username of Object.keys(nextCache)) { + if (usernamesInSource.has(username)) continue + delete nextCache[username] + changed = true + } + } + + if (!changed) return + contactsAvatarCacheRef.current = nextCache + setAvatarCacheUpdatedAt(now) + void configService.setContactsAvatarCache(scopeKey, nextCache).catch((error) => { + console.error('写入通讯录头像缓存失败:', error) + }) + }, []) + const applyEnrichedContacts = useCallback((enrichedMap: Record) => { if (!enrichedMap || Object.keys(enrichedMap).length === 0) return @@ -170,8 +252,34 @@ function ContactsPage() { }) }, []) - const enrichContactsInBackground = useCallback(async (sourceContacts: ContactInfo[], loadVersion: number) => { - const usernames = sourceContacts.map(contact => contact.username).filter(Boolean) + const enrichContactsInBackground = useCallback(async ( + sourceContacts: ContactInfo[], + loadVersion: number, + scopeKey: string + ) => { + const sourceByUsername = new Map() + for (const contact of sourceContacts) { + if (!contact.username) continue + sourceByUsername.set(contact.username, contact) + } + const now = Date.now() + const usernames = sourceContacts + .map(contact => contact.username) + .filter(Boolean) + .filter((username) => { + const currentContact = sourceByUsername.get(username) + if (!currentContact) return false + const cacheEntry = contactsAvatarCacheRef.current[username] + if (!cacheEntry || !cacheEntry.avatarUrl) { + return !currentContact.avatarUrl + } + if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) { + return true + } + const checkedAt = cacheEntry.checkedAt || 0 + return now - checkedAt >= AVATAR_RECHECK_INTERVAL_MS + }) + const total = usernames.length setAvatarEnrichProgress({ loaded: 0, @@ -190,7 +298,22 @@ function ContactsPage() { if (loadVersionRef.current !== loadVersion) return if (avatarResult.success && avatarResult.contacts) { applyEnrichedContacts(avatarResult.contacts) + for (const [username, enriched] of Object.entries(avatarResult.contacts)) { + const prev = sourceByUsername.get(username) + if (!prev) continue + sourceByUsername.set(username, { + ...prev, + displayName: enriched.displayName || prev.displayName, + avatarUrl: enriched.avatarUrl || prev.avatarUrl + }) + } } + const batchContacts = batch + .map(username => sourceByUsername.get(username)) + .filter((contact): contact is ContactInfo => Boolean(contact)) + upsertAvatarCacheFromContacts(scopeKey, batchContacts, { + markCheckedUsernames: batch + }) } catch (e) { console.error('分批补全头像失败:', e) } @@ -204,7 +327,7 @@ function ContactsPage() { await new Promise(resolve => setTimeout(resolve, 0)) } - }, [applyEnrichedContacts]) + }, [applyEnrichedContacts, upsertAvatarCacheFromContacts]) // 加载通讯录 const loadContacts = useCallback(async (options?: { scopeKey?: string }) => { @@ -256,21 +379,23 @@ function ContactsPage() { window.clearTimeout(loadTimeoutTimerRef.current) loadTimeoutTimerRef.current = null } - setContacts(contactsResult.contacts) - syncContactTypeCounts(contactsResult.contacts) + const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(contactsResult.contacts) + setContacts(contactsWithAvatarCache) + syncContactTypeCounts(contactsWithAvatarCache) setSelectedUsernames(new Set()) setSelectedContact(prev => { if (!prev) return prev - return contactsResult.contacts!.find(contact => contact.username === prev.username) || null + return contactsWithAvatarCache.find(contact => contact.username === prev.username) || null }) const now = Date.now() setContactsDataSource('network') setContactsUpdatedAt(now) setLoadIssue(null) setIsLoading(false) + upsertAvatarCacheFromContacts(scopeKey, contactsWithAvatarCache, { prune: true }) void configService.setContactsListCache( scopeKey, - contactsResult.contacts.map(contact => ({ + contactsWithAvatarCache.map(contact => ({ username: contact.username, displayName: contact.displayName, remark: contact.remark, @@ -280,7 +405,7 @@ function ContactsPage() { ).catch((error) => { console.error('写入通讯录缓存失败:', error) }) - void enrichContactsInBackground(contactsResult.contacts, loadVersion) + void enrichContactsInBackground(contactsWithAvatarCache, loadVersion, scopeKey) return } const elapsedMs = Date.now() - startedAt @@ -314,7 +439,13 @@ function ContactsPage() { setIsLoading(false) } } - }, [ensureContactsCacheScope, enrichContactsInBackground, syncContactTypeCounts]) + }, [ + ensureContactsCacheScope, + enrichContactsInBackground, + mergeAvatarCacheIntoContacts, + syncContactTypeCounts, + upsertAvatarCacheFromContacts + ]) useEffect(() => { let cancelled = false @@ -322,11 +453,17 @@ function ContactsPage() { const scopeKey = await ensureContactsCacheScope() if (cancelled) return try { - const cacheItem = await configService.getContactsListCache(scopeKey) + const [cacheItem, avatarCacheItem] = await Promise.all([ + configService.getContactsListCache(scopeKey), + configService.getContactsAvatarCache(scopeKey) + ]) + const avatarCacheMap = avatarCacheItem?.avatars || {} + contactsAvatarCacheRef.current = avatarCacheMap + setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null) if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) { const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({ ...contact, - avatarUrl: undefined + avatarUrl: avatarCacheMap[contact.username]?.avatarUrl })) setContacts(cachedContacts) syncContactTypeCounts(cachedContacts) @@ -503,6 +640,17 @@ function ContactsPage() { return new Date(contactsUpdatedAt).toLocaleString() }, [contactsUpdatedAt]) + const avatarCachedCount = useMemo(() => { + return contacts.reduce((count, contact) => ( + contact.avatarUrl ? count + 1 : count + ), 0) + }, [contacts]) + + const avatarCacheUpdatedAtLabel = useMemo(() => { + if (!avatarCacheUpdatedAt) return '' + return new Date(avatarCacheUpdatedAt).toLocaleString() + }, [avatarCacheUpdatedAt]) + const toggleContactSelected = (username: string, checked: boolean) => { setSelectedUsernames(prev => { const next = new Set(prev) @@ -686,6 +834,12 @@ function ContactsPage() { {contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel} )} + {contacts.length > 0 && ( + + 头像缓存 {avatarCachedCount}/{contacts.length} + {avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''} + + )} {isLoading && contacts.length > 0 && ( 后台同步中... )} diff --git a/src/services/config.ts b/src/services/config.ts index 3ea4652..b34f71a 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -40,6 +40,7 @@ export const CONFIG_KEYS = { EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs', CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap', + CONTACTS_AVATAR_CACHE_MAP: 'contactsAvatarCacheMap', // 安全 AUTH_ENABLED: 'authEnabled', @@ -477,6 +478,17 @@ export interface ContactsListCacheItem { contacts: ContactsListCacheContact[] } +export interface ContactsAvatarCacheEntry { + avatarUrl: string + updatedAt: number + checkedAt: number +} + +export interface ContactsAvatarCacheItem { + updatedAt: number + avatars: Record +} + export async function getExportSessionMessageCountCache(scopeKey: string): Promise { if (!scopeKey) return null const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP) @@ -650,6 +662,94 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL await config.set(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP, map) } +export async function getContactsAvatarCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const rawUpdatedAt = (rawItem as Record).updatedAt + const rawAvatars = (rawItem as Record).avatars + if (!rawAvatars || typeof rawAvatars !== 'object') return null + + const avatars: Record = {} + for (const [rawUsername, rawEntry] of Object.entries(rawAvatars as Record)) { + const username = rawUsername.trim() + if (!username) continue + + if (typeof rawEntry === 'string') { + const avatarUrl = rawEntry.trim() + if (!avatarUrl) continue + avatars[username] = { + avatarUrl, + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + checkedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0 + } + continue + } + + if (!rawEntry || typeof rawEntry !== 'object') continue + const entry = rawEntry as Record + const avatarUrl = typeof entry.avatarUrl === 'string' ? entry.avatarUrl.trim() : '' + if (!avatarUrl) continue + const updatedAt = typeof entry.updatedAt === 'number' && Number.isFinite(entry.updatedAt) + ? entry.updatedAt + : 0 + const checkedAt = typeof entry.checkedAt === 'number' && Number.isFinite(entry.checkedAt) + ? entry.checkedAt + : updatedAt + + avatars[username] = { + avatarUrl, + updatedAt, + checkedAt + } + } + + return { + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + avatars + } +} + +export async function setContactsAvatarCache( + scopeKey: string, + avatars: Record +): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: Record = {} + for (const [rawUsername, rawEntry] of Object.entries(avatars || {})) { + const username = String(rawUsername || '').trim() + if (!username || !rawEntry || typeof rawEntry !== 'object') continue + const avatarUrl = String(rawEntry.avatarUrl || '').trim() + if (!avatarUrl) continue + const updatedAt = Number.isFinite(rawEntry.updatedAt) + ? Math.max(0, Math.floor(rawEntry.updatedAt)) + : Date.now() + const checkedAt = Number.isFinite(rawEntry.checkedAt) + ? Math.max(0, Math.floor(rawEntry.checkedAt)) + : updatedAt + normalized[username] = { + avatarUrl, + updatedAt, + checkedAt + } + } + + map[scopeKey] = { + updatedAt: Date.now(), + avatars: normalized + } + await config.set(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP, map) +} + // === 安全相关 === export async function getAuthEnabled(): Promise { From 0a1f55f6a6409791d54a551f9babc97559d2ec0e Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:21:53 +0800 Subject: [PATCH 032/155] feat(export): reuse contacts cache for session names and avatars --- src/pages/ExportPage.scss | 16 +++ src/pages/ExportPage.tsx | 245 ++++++++++++++++++++++++++++++++------ 2 files changed, 224 insertions(+), 37 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 8a69ee6..73a2fbc 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -472,6 +472,22 @@ flex-wrap: wrap; } +.table-cache-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 12px; + + .meta-item { + color: var(--text-tertiary); + } + + .meta-item.syncing { + color: var(--primary); + } +} + .table-tabs { display: flex; gap: 8px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 6991981..e32ecc1 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -219,6 +219,8 @@ const getAvatarLetter = (name: string): string => { const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 +const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 +type SessionDataSource = 'cache' | 'network' | null const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null @@ -236,6 +238,39 @@ const withTimeout = async (promise: Promise, timeoutMs: number): Promise< } } +const toContactMapFromCaches = ( + contacts: configService.ContactsListCacheContact[], + avatarEntries: Record +): Record => { + const map: Record = {} + for (const contact of contacts || []) { + if (!contact?.username) continue + map[contact.username] = { + ...contact, + avatarUrl: avatarEntries[contact.username]?.avatarUrl + } + } + return map +} + +const toSessionRowsWithContacts = ( + sessions: AppChatSession[], + contactMap: Record +): SessionRow[] => { + return sessions + .map((session) => { + const contact = contactMap[session.username] + return { + ...session, + kind: toKindByContactType(session, contact), + wechatId: contact?.username || session.username, + displayName: contact?.displayName || session.displayName || session.username, + avatarUrl: contact?.avatarUrl || session.avatarUrl + } as SessionRow + }) + .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) +} + const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, onChange @@ -300,6 +335,9 @@ function ExportPage() { const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) + const [sessionDataSource, setSessionDataSource] = useState(null) + const [sessionContactsUpdatedAt, setSessionContactsUpdatedAt] = useState(null) + const [sessionAvatarUpdatedAt, setSessionAvatarUpdatedAt] = useState(null) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') const [selectedSessions, setSelectedSessions] = useState>(new Set()) @@ -360,6 +398,22 @@ function ExportPage() { const exportCacheScopeRef = useRef('default') const exportCacheScopeReadyRef = useRef(false) + const ensureExportCacheScope = useCallback(async (): Promise => { + if (exportCacheScopeReadyRef.current) { + return exportCacheScopeRef.current + } + const [myWxid, dbPath] = await Promise.all([ + configService.getMyWxid(), + configService.getDbPath() + ]) + const scopeKey = dbPath || myWxid + ? `${dbPath || ''}::${myWxid || ''}` + : 'default' + exportCacheScopeRef.current = scopeKey + exportCacheScopeReadyRef.current = true + return scopeKey + }, []) + useEffect(() => { tasksRef.current = tasks }, [tasks]) @@ -389,7 +443,7 @@ function ExportPage() { const loadBaseConfig = useCallback(async () => { setIsBaseConfigLoading(true) try { - const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, myWxid, dbPath] = await Promise.all([ + const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, exportCacheScope] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultMedia(), @@ -401,12 +455,8 @@ function ExportPage() { configService.getExportLastSessionRunMap(), configService.getExportLastContentRunMap(), configService.getExportLastSnsPostCount(), - configService.getMyWxid(), - configService.getDbPath() + ensureExportCacheScope() ]) - const exportCacheScope = `${dbPath || ''}::${myWxid || ''}` || 'default' - exportCacheScopeRef.current = exportCacheScope - exportCacheScopeReadyRef.current = true const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope) @@ -446,7 +496,7 @@ function ExportPage() { } finally { setIsBaseConfigLoading(false) } - }, []) + }, [ensureExportCacheScope]) const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => { if (!options?.silent) { @@ -506,6 +556,24 @@ function ExportPage() { const isStale = () => sessionLoadTokenRef.current !== loadToken try { + const scopeKey = await ensureExportCacheScope() + if (isStale()) return + + const [cachedContactsItem, cachedAvatarItem] = await Promise.all([ + configService.getContactsListCache(scopeKey), + configService.getContactsAvatarCache(scopeKey) + ]) + if (isStale()) return + + const cachedContacts = cachedContactsItem?.contacts || [] + const cachedAvatarEntries = cachedAvatarItem?.avatars || {} + const cachedContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries) + if (cachedContacts.length > 0) { + syncContactTypeCounts(Object.values(cachedContactMap)) + } + setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null) + setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null) + const connectResult = await window.electronAPI.chat.connect() if (!connectResult.success) { console.error('连接失败:', connectResult.error) @@ -517,42 +585,54 @@ function ExportPage() { if (isStale()) return if (sessionsResult.success && sessionsResult.sessions) { - const baseSessions = sessionsResult.sessions - .map((session) => { - return { - ...session, - kind: toKindByContactType(session), - wechatId: session.username, - displayName: session.displayName || session.username, - avatarUrl: session.avatarUrl - } as SessionRow - }) - .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) + const rawSessions = sessionsResult.sessions + const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap) if (isStale()) return setSessions(baseSessions) + setSessionDataSource(cachedContacts.length > 0 ? 'cache' : 'network') + if (cachedContacts.length === 0) { + setSessionContactsUpdatedAt(Date.now()) + } setIsLoading(false) // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 setIsSessionEnriching(true) void (async () => { try { - if (isStale()) return - const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) - if (isStale()) return + let contactMap = { ...cachedContactMap } + let avatarEntries = { ...cachedAvatarEntries } + let hasFreshNetworkData = false - const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] - if (contacts.length > 0) { - syncContactTypeCounts(contacts) + if (isStale()) return + if (cachedContacts.length === 0) { + const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) + if (isStale()) return + + const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] + if (contacts.length > 0) { + hasFreshNetworkData = true + syncContactTypeCounts(contacts) + const nextContactMap = contacts.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, {}) + contactMap = nextContactMap + setSessionContactsUpdatedAt(Date.now()) + } } - const nextContactMap = contacts.reduce>((map, contact) => { - map[contact.username] = contact - return map - }, {}) + const now = Date.now() const needsEnrichment = baseSessions - .filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) - .map(session => session.username) + .filter((session) => { + const contact = contactMap[session.username] + const avatarEntry = avatarEntries[session.username] + const displayName = contact?.displayName || session.displayName || session.username + const avatarUrl = contact?.avatarUrl || session.avatarUrl || avatarEntry?.avatarUrl + const shouldRecheckAvatar = !avatarEntry || (now - (avatarEntry.checkedAt || 0) >= EXPORT_AVATAR_RECHECK_INTERVAL_MS) + return !avatarUrl || displayName === session.username || shouldRecheckAvatar + }) + .map((session) => session.username) let extraContactMap: Record = {} if (needsEnrichment.length > 0) { @@ -563,27 +643,87 @@ function ExportPage() { ) if (enrichResult?.success && enrichResult.contacts) { extraContactMap = enrichResult.contacts + hasFreshNetworkData = true + } + } + + const persistAt = Date.now() + for (const contact of Object.values(contactMap)) { + const avatarUrl = String(contact.avatarUrl || '').trim() + if (!avatarUrl) continue + const prev = avatarEntries[contact.username] + avatarEntries[contact.username] = { + avatarUrl, + updatedAt: prev?.avatarUrl === avatarUrl ? prev.updatedAt : persistAt, + checkedAt: prev?.checkedAt || persistAt + } + } + + for (const username of needsEnrichment) { + const extra = extraContactMap[username] + const prev = avatarEntries[username] + if (extra?.avatarUrl) { + avatarEntries[username] = { + avatarUrl: extra.avatarUrl, + updatedAt: !prev || prev.avatarUrl !== extra.avatarUrl ? persistAt : prev.updatedAt, + checkedAt: persistAt + } + } else if (prev) { + avatarEntries[username] = { + ...prev, + checkedAt: persistAt + } + } + + if (!extra) continue + const current = contactMap[username] + if (!current) continue + const nextDisplayName = extra.displayName || current.displayName + const nextAvatarUrl = extra.avatarUrl || current.avatarUrl + if (nextDisplayName !== current.displayName || nextAvatarUrl !== current.avatarUrl) { + contactMap[username] = { + ...current, + displayName: nextDisplayName, + avatarUrl: nextAvatarUrl + } } } if (isStale()) return - const nextSessions = baseSessions + const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap) .map((session) => { - const contact = nextContactMap[session.username] const extra = extraContactMap[session.username] - const displayName = extra?.displayName || contact?.displayName || session.displayName || session.username - const avatarUrl = extra?.avatarUrl || session.avatarUrl || contact?.avatarUrl + const displayName = extra?.displayName || session.displayName || session.username + const avatarUrl = extra?.avatarUrl || session.avatarUrl + if (displayName === session.displayName && avatarUrl === session.avatarUrl) { + return session + } return { ...session, - kind: toKindByContactType(session, contact), - wechatId: contact?.username || session.wechatId || session.username, displayName, avatarUrl } }) .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) + const contactsCachePayload = Object.values(contactMap).map((contact) => ({ + username: contact.username, + displayName: contact.displayName || contact.username, + remark: contact.remark, + nickname: contact.nickname, + type: contact.type + })) + setSessions(nextSessions) + if (contactsCachePayload.length > 0) { + await configService.setContactsListCache(scopeKey, contactsCachePayload) + setSessionContactsUpdatedAt(persistAt) + } + await configService.setContactsAvatarCache(scopeKey, avatarEntries) + setSessionAvatarUpdatedAt(persistAt) + if (hasFreshNetworkData) { + setSessionDataSource('network') + } } catch (enrichError) { console.error('导出页补充会话联系人信息失败:', enrichError) } finally { @@ -599,7 +739,7 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [syncContactTypeCounts]) + }, [ensureExportCacheScope, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return @@ -1151,6 +1291,20 @@ function ExportPage() { return '公众号' }, [activeTab]) + const sessionContactsUpdatedAtLabel = useMemo(() => { + if (!sessionContactsUpdatedAt) return '' + return new Date(sessionContactsUpdatedAt).toLocaleString() + }, [sessionContactsUpdatedAt]) + + const sessionAvatarUpdatedAtLabel = useMemo(() => { + if (!sessionAvatarUpdatedAt) return '' + return new Date(sessionAvatarUpdatedAt).toLocaleString() + }, [sessionAvatarUpdatedAt]) + + const sessionAvatarCachedCount = useMemo(() => { + return sessions.reduce((count, session) => (session.avatarUrl ? count + 1 : count), 0) + }, [sessions]) + const renderSessionName = (session: SessionRow) => { return (
@@ -1452,6 +1606,23 @@ function ExportPage() {
+
+ {sessionContactsUpdatedAt && ( + + {sessionDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {sessionContactsUpdatedAtLabel} + + )} + {sessions.length > 0 && ( + + 头像缓存 {sessionAvatarCachedCount}/{sessions.length} + {sessionAvatarUpdatedAtLabel ? ` · 更新于 ${sessionAvatarUpdatedAtLabel}` : ''} + + )} + {(isLoading || isSessionEnriching) && sessions.length > 0 && ( + 后台同步中... + )} +
+ {!showInitialSkeleton && (isLoading || isSessionEnriching) && (
From d18a8714292b25031265d5b76584c9d261fa09c3 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:31:07 +0800 Subject: [PATCH 033/155] fix(export): restore dialog scroll and adaptive format grid --- src/pages/ExportPage.scss | 66 +++++++- src/pages/ExportPage.tsx | 312 +++++++++++++++++++++++--------------- 2 files changed, 252 insertions(+), 126 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 73a2fbc..49efd28 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -655,6 +655,11 @@ &.checked { color: var(--primary); } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } } .session-cell { @@ -736,6 +741,12 @@ &.running { background: color-mix(in srgb, var(--primary) 80%, #000); } + + &.no-session { + background: var(--bg-secondary); + color: var(--text-tertiary); + border: 1px dashed var(--border-color); + } } .row-export-time { @@ -816,17 +827,29 @@ display: flex; align-items: center; justify-content: center; + padding: 16px; z-index: 1000; } .export-dialog { - width: min(980px, calc(100vw - 40px)); - max-height: calc(100vh - 60px); - overflow: auto; + width: min(1080px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 14px; - padding: 16px; + padding: 14px 14px 12px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dialog-body { + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; + padding-right: 2px; } .dialog-header { @@ -859,7 +882,6 @@ border: 1px solid var(--border-color); border-radius: 10px; padding: 12px; - margin-bottom: 10px; background: var(--bg-secondary); h4 { @@ -912,17 +934,23 @@ .format-grid { display: grid; - grid-template-columns: repeat(4, minmax(130px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; } .format-card { + width: 100%; + min-height: 82px; border: 1px solid var(--border-color); border-radius: 10px; padding: 10px; text-align: left; background: var(--bg-primary); cursor: pointer; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; .format-label { font-size: 13px; @@ -1033,9 +1061,13 @@ .dialog-actions { margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 8px; + flex-shrink: 0; + background: var(--card-bg); } .primary-btn, @@ -1132,7 +1164,7 @@ } .format-grid { - grid-template-columns: repeat(2, minmax(120px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } .display-name-options { @@ -1143,3 +1175,23 @@ grid-template-columns: repeat(2, minmax(120px, 1fr)); } } + +@media (max-width: 720px) { + .export-dialog-overlay { + padding: 10px; + } + + .export-dialog { + width: calc(100vw - 20px); + max-height: calc(100vh - 20px); + padding: 12px 10px 10px; + } + + .format-grid { + grid-template-columns: 1fr; + } + + .date-range-row { + grid-template-columns: 1fr; + } +} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index e32ecc1..d7cdf25 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -57,6 +57,7 @@ interface ExportOptions { interface SessionRow extends AppChatSession { kind: ConversationTab wechatId?: string + hasSession: boolean } interface TaskProgress { @@ -207,6 +208,13 @@ const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): Co return 'private' } +const toKindByContact = (contact: ContactInfo): ConversationTab => { + if (contact.type === 'group') return 'group' + if (contact.type === 'official') return 'official' + if (contact.type === 'former_friend') return 'former_friend' + return 'private' +} + const isContentScopeSession = (session: SessionRow): boolean => ( session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend' ) @@ -257,6 +265,50 @@ const toSessionRowsWithContacts = ( sessions: AppChatSession[], contactMap: Record ): SessionRow[] => { + const sessionMap = new Map() + for (const session of sessions || []) { + sessionMap.set(session.username, session) + } + + const contacts = Object.values(contactMap) + .filter((contact) => ( + contact.type === 'friend' || + contact.type === 'group' || + contact.type === 'official' || + contact.type === 'former_friend' + )) + + if (contacts.length > 0) { + return contacts + .map((contact) => { + const session = sessionMap.get(contact.username) + const latestTs = session?.sortTimestamp || session?.lastTimestamp || 0 + return { + ...(session || { + username: contact.username, + type: 0, + unreadCount: 0, + summary: '', + sortTimestamp: latestTs, + lastTimestamp: latestTs, + lastMsgType: 0 + }), + username: contact.username, + kind: toKindByContact(contact), + wechatId: contact.username, + displayName: contact.displayName || session?.displayName || contact.username, + avatarUrl: contact.avatarUrl || session?.avatarUrl, + hasSession: Boolean(session) + } as SessionRow + }) + .sort((a, b) => { + const latestA = a.sortTimestamp || a.lastTimestamp || 0 + const latestB = b.sortTimestamp || b.lastTimestamp || 0 + if (latestA !== latestB) return latestB - latestA + return (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN') + }) + } + return sessions .map((session) => { const contact = contactMap[session.username] @@ -265,7 +317,8 @@ const toSessionRowsWithContacts = ( kind: toKindByContactType(session, contact), wechatId: contact?.username || session.username, displayName: contact?.displayName || session.displayName || session.username, - avatarUrl: contact?.avatarUrl || session.avatarUrl + avatarUrl: contact?.avatarUrl || session.avatarUrl, + hasSession: true } as SessionRow }) .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) @@ -570,6 +623,9 @@ function ExportPage() { const cachedContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries) if (cachedContacts.length > 0) { syncContactTypeCounts(Object.values(cachedContactMap)) + setSessions(toSessionRowsWithContacts([], cachedContactMap)) + setSessionDataSource('cache') + setIsLoading(false) } setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null) setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null) @@ -800,6 +856,8 @@ function ExportPage() { const selectedCount = selectedSessions.size const toggleSelectSession = (sessionId: string) => { + const target = sessions.find(session => session.username === sessionId) + if (!target?.hasSession) return setSelectedSessions(prev => { const next = new Set(prev) if (next.has(sessionId)) { @@ -812,7 +870,7 @@ function ExportPage() { } const toggleSelectAllVisible = () => { - const visibleIds = visibleSessions.map(session => session.username) + const visibleIds = visibleSessions.filter(session => session.hasSession).map(session => session.username) if (visibleIds.length === 0) return setSelectedSessions(prev => { @@ -1171,6 +1229,7 @@ function ExportPage() { } const openSingleExport = (session: SessionRow) => { + if (!session.hasSession) return openExportDialog({ scope: 'single', sessionIds: [session.username], @@ -1180,7 +1239,8 @@ function ExportPage() { } const openBatchExport = () => { - const ids = Array.from(selectedSessions) + const selectable = new Set(sessions.filter(session => session.hasSession).map(session => session.username)) + const ids = Array.from(selectedSessions).filter(id => selectable.has(id)) if (ids.length === 0) return const nameMap = new Map(sessions.map(session => [session.username, session.displayName || session.username])) const names = ids.map(id => nameMap.get(id) || id) @@ -1195,11 +1255,11 @@ function ExportPage() { const openContentExport = (contentType: ContentType) => { const ids = sessions - .filter(isContentScopeSession) + .filter(session => session.hasSession && isContentScopeSession(session)) .map(session => session.username) const names = sessions - .filter(isContentScopeSession) + .filter(session => session.hasSession && isContentScopeSession(session)) .map(session => session.displayName || session.username) openExportDialog({ @@ -1320,6 +1380,16 @@ function ExportPage() { } const renderActionCell = (session: SessionRow) => { + if (!session.hasSession) { + return ( +
+ +
+ ) + } + const isRunning = runningSessionIds.has(session.username) const isQueued = queuedSessionIds.has(session.username) const recent = formatRecentExportTime(lastExportBySession[session.username], nowTick) @@ -1347,22 +1417,24 @@ function ExportPage() { return ( 选择 - 会话名(头像/名称/微信号) + 联系人(头像/名称/微信号) 操作 ) } const renderRowCells = (session: SessionRow) => { - const checked = selectedSessions.has(session.username) + const selectable = session.hasSession + const checked = selectable && selectedSessions.has(session.username) return ( <> @@ -1661,134 +1733,136 @@ function ExportPage() { {exportDialog.open && (
-
event.stopPropagation()}> +
event.stopPropagation()}>

{exportDialog.title}

-
-

导出范围

-
- {scopeLabel} - {scopeCountLabel} -
-
- {exportDialog.sessionNames.slice(0, 20).map(name => ( - {name} - ))} - {exportDialog.sessionNames.length > 20 && ... 还有 {exportDialog.sessionNames.length - 20} 个} -
-
- -
-

对话文本导出格式选择

-
- {formatCandidateOptions.map(option => ( - - ))} -
-
- -
-

时间范围

-
- 导出全部时间 - +
+
+

导出范围

+
+ {scopeLabel} + {scopeCountLabel} +
+
+ {exportDialog.sessionNames.slice(0, 20).map(name => ( + {name} + ))} + {exportDialog.sessionNames.length > 20 && ... 还有 {exportDialog.sessionNames.length - 20} 个} +
- {!options.useAllTime && options.dateRange && ( -
-
) From d1ef159e87d4552f18c579618f490104beb8ccce Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 11:57:04 +0800 Subject: [PATCH 035/155] fix(export): stabilize contact cache fallback and batched avatar enrich --- src/pages/ExportPage.tsx | 136 ++++++++++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 36 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 57209cf..b98ac48 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -228,6 +228,7 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString( const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 +const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 type SessionDataSource = 'cache' | 'network' | null const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { @@ -482,25 +483,74 @@ function ExportPage() { 'default' ].filter(Boolean))) + type CacheCandidate = { + scopeKey: string + contactsItem: configService.ContactsListCacheItem | null + avatarItem: configService.ContactsAvatarCacheItem | null + contactsCount: number + avatarCount: number + contactsUpdatedAt: number + avatarUpdatedAt: number + } + + const candidatesWithData: CacheCandidate[] = [] for (const candidate of candidates) { const [contactsItem, avatarItem] = await Promise.all([ configService.getContactsListCache(candidate), configService.getContactsAvatarCache(candidate) ]) - const hasContacts = Boolean(contactsItem?.contacts?.length) - const hasAvatars = Boolean(avatarItem && Object.keys(avatarItem.avatars || {}).length > 0) - if (!hasContacts && !hasAvatars) continue - return { - resolvedScopeKey: candidate, + const contactsCount = contactsItem?.contacts?.length || 0 + const avatarCount = avatarItem ? Object.keys(avatarItem.avatars || {}).length : 0 + if (contactsCount === 0 && avatarCount === 0) continue + candidatesWithData.push({ + scopeKey: candidate, contactsItem, - avatarItem + avatarItem, + contactsCount, + avatarCount, + contactsUpdatedAt: contactsItem?.updatedAt || 0, + avatarUpdatedAt: avatarItem?.updatedAt || 0 + }) + } + + if (candidatesWithData.length === 0) { + return { + resolvedContactsScopeKey: primaryScopeKey, + resolvedAvatarScopeKeys: [] as string[], + contactsItem: null as configService.ContactsListCacheItem | null, + avatarItem: null as configService.ContactsAvatarCacheItem | null } } + const bestContactsCandidate = candidatesWithData + .filter(item => item.contactsCount > 0) + .sort((a, b) => { + if (b.contactsCount !== a.contactsCount) return b.contactsCount - a.contactsCount + if (b.contactsUpdatedAt !== a.contactsUpdatedAt) return b.contactsUpdatedAt - a.contactsUpdatedAt + return b.avatarCount - a.avatarCount + })[0] + + const avatarCandidates = candidatesWithData + .filter(item => item.avatarCount > 0) + .sort((a, b) => a.avatarUpdatedAt - b.avatarUpdatedAt) + const mergedAvatarEntries: Record = {} + for (const candidate of avatarCandidates) { + Object.assign(mergedAvatarEntries, candidate.avatarItem?.avatars || {}) + } + const mergedAvatarUpdatedAt = avatarCandidates.reduce((max, candidate) => ( + candidate.avatarUpdatedAt > max ? candidate.avatarUpdatedAt : max + ), 0) + return { - resolvedScopeKey: primaryScopeKey, - contactsItem: null as configService.ContactsListCacheItem | null, - avatarItem: null as configService.ContactsAvatarCacheItem | null + resolvedContactsScopeKey: bestContactsCandidate?.scopeKey || primaryScopeKey, + resolvedAvatarScopeKeys: avatarCandidates.map(candidate => candidate.scopeKey), + contactsItem: bestContactsCandidate?.contactsItem || null, + avatarItem: Object.keys(mergedAvatarEntries).length > 0 + ? { + updatedAt: mergedAvatarUpdatedAt, + avatars: mergedAvatarEntries + } + : null } }, []) @@ -650,7 +700,8 @@ function ExportPage() { if (isStale()) return const { - resolvedScopeKey, + resolvedContactsScopeKey, + resolvedAvatarScopeKeys, contactsItem: cachedContactsItem, avatarItem: cachedAvatarItem } = await loadContactsCachesWithScopeFallback(scopeKey) @@ -668,12 +719,12 @@ function ExportPage() { setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null) setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null) - if (resolvedScopeKey !== scopeKey && cachedContacts.length > 0) { + if (resolvedContactsScopeKey !== scopeKey && cachedContacts.length > 0) { void configService.setContactsListCache(scopeKey, cachedContacts).catch((error) => { console.error('回填主 scope 通讯录缓存失败:', error) }) } - if (resolvedScopeKey !== scopeKey && Object.keys(cachedAvatarEntries).length > 0) { + if (!resolvedAvatarScopeKeys.includes(scopeKey) && Object.keys(cachedAvatarEntries).length > 0) { void configService.setContactsAvatarCache(scopeKey, cachedAvatarEntries).catch((error) => { console.error('回填主 scope 头像缓存失败:', error) }) @@ -710,21 +761,19 @@ function ExportPage() { let hasFreshNetworkData = false if (isStale()) return - if (cachedContacts.length === 0) { - const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) - if (isStale()) return + const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) + if (isStale()) return - const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] - if (contacts.length > 0) { - hasFreshNetworkData = true - syncContactTypeCounts(contacts) - const nextContactMap = contacts.reduce>((map, contact) => { - map[contact.username] = contact - return map - }, {}) - contactMap = nextContactMap - setSessionContactsUpdatedAt(Date.now()) - } + const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] + if (contacts.length > 0) { + hasFreshNetworkData = true + syncContactTypeCounts(contacts) + const nextContactMap = contacts.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, {}) + contactMap = nextContactMap + setSessionContactsUpdatedAt(Date.now()) } const now = Date.now() @@ -741,14 +790,27 @@ function ExportPage() { let extraContactMap: Record = {} if (needsEnrichment.length > 0) { - if (isStale()) return - const enrichResult = await withTimeout( - window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment), - CONTACT_ENRICH_TIMEOUT_MS - ) - if (enrichResult?.success && enrichResult.contacts) { - extraContactMap = enrichResult.contacts - hasFreshNetworkData = true + for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) { + if (isStale()) return + const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE) + if (batch.length === 0) continue + try { + const enrichResult = await withTimeout( + window.electronAPI.chat.enrichSessionsContactInfo(batch), + CONTACT_ENRICH_TIMEOUT_MS + ) + if (isStale()) return + if (enrichResult?.success && enrichResult.contacts) { + extraContactMap = { + ...extraContactMap, + ...enrichResult.contacts + } + hasFreshNetworkData = true + } + } catch (batchError) { + console.error('导出页分批补充会话联系人信息失败:', batchError) + } + await new Promise(resolve => setTimeout(resolve, 0)) } } @@ -824,8 +886,10 @@ function ExportPage() { await configService.setContactsListCache(scopeKey, contactsCachePayload) setSessionContactsUpdatedAt(persistAt) } - await configService.setContactsAvatarCache(scopeKey, avatarEntries) - setSessionAvatarUpdatedAt(persistAt) + if (Object.keys(avatarEntries).length > 0) { + await configService.setContactsAvatarCache(scopeKey, avatarEntries) + setSessionAvatarUpdatedAt(persistAt) + } if (hasFreshNetworkData) { setSessionDataSource('network') } From dabc6a2d0a7e71c2bedf18e9d6f0a505ab84f563 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 12:07:28 +0800 Subject: [PATCH 036/155] fix(export): align avatar loading pipeline with contacts --- src/pages/ExportPage.tsx | 330 +++++++++++++++++++++------------------ 1 file changed, 179 insertions(+), 151 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index b98ac48..1c4a27e 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -262,6 +262,91 @@ const toContactMapFromCaches = ( return map } +const mergeAvatarCacheIntoContacts = ( + sourceContacts: ContactInfo[], + avatarEntries: Record +): ContactInfo[] => { + if (!sourceContacts.length || Object.keys(avatarEntries).length === 0) { + return sourceContacts + } + + let changed = false + const merged = sourceContacts.map((contact) => { + const cachedAvatar = avatarEntries[contact.username]?.avatarUrl + if (!cachedAvatar || contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + avatarUrl: cachedAvatar + } + }) + + return changed ? merged : sourceContacts +} + +const upsertAvatarCacheFromContacts = ( + avatarEntries: Record, + sourceContacts: ContactInfo[], + options?: { prune?: boolean; markCheckedUsernames?: string[]; now?: number } +): { + avatarEntries: Record + changed: boolean + updatedAt: number | null +} => { + const nextCache = { ...avatarEntries } + const now = options?.now || Date.now() + const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean)) + const usernamesInSource = new Set() + let changed = false + + for (const contact of sourceContacts) { + const username = String(contact.username || '').trim() + if (!username) continue + usernamesInSource.add(username) + const prev = nextCache[username] + const avatarUrl = String(contact.avatarUrl || '').trim() + if (!avatarUrl) continue + const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt + const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now) + if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) { + nextCache[username] = { + avatarUrl, + updatedAt, + checkedAt + } + changed = true + } + } + + for (const username of markCheckedSet) { + const prev = nextCache[username] + if (!prev) continue + if (prev.checkedAt !== now) { + nextCache[username] = { + ...prev, + checkedAt: now + } + changed = true + } + } + + if (options?.prune) { + for (const username of Object.keys(nextCache)) { + if (usernamesInSource.has(username)) continue + delete nextCache[username] + changed = true + } + } + + return { + avatarEntries: nextCache, + changed, + updatedAt: changed ? now : null + } +} + const toSessionRowsWithContacts = ( sessions: AppChatSession[], contactMap: Record @@ -468,89 +553,14 @@ function ExportPage() { return scopeKey }, []) - const loadContactsCachesWithScopeFallback = useCallback(async (primaryScopeKey: string) => { - const [myWxid, dbPath] = await Promise.all([ - configService.getMyWxid(), - configService.getDbPath() + const loadContactsCaches = useCallback(async (scopeKey: string) => { + const [contactsItem, avatarItem] = await Promise.all([ + configService.getContactsListCache(scopeKey), + configService.getContactsAvatarCache(scopeKey) ]) - const candidates = Array.from(new Set([ - primaryScopeKey, - dbPath || '', - myWxid || '', - dbPath && myWxid ? `${dbPath}::${myWxid}` : '', - dbPath ? `${dbPath}::` : '', - myWxid ? `::${myWxid}` : '', - 'default' - ].filter(Boolean))) - - type CacheCandidate = { - scopeKey: string - contactsItem: configService.ContactsListCacheItem | null - avatarItem: configService.ContactsAvatarCacheItem | null - contactsCount: number - avatarCount: number - contactsUpdatedAt: number - avatarUpdatedAt: number - } - - const candidatesWithData: CacheCandidate[] = [] - for (const candidate of candidates) { - const [contactsItem, avatarItem] = await Promise.all([ - configService.getContactsListCache(candidate), - configService.getContactsAvatarCache(candidate) - ]) - const contactsCount = contactsItem?.contacts?.length || 0 - const avatarCount = avatarItem ? Object.keys(avatarItem.avatars || {}).length : 0 - if (contactsCount === 0 && avatarCount === 0) continue - candidatesWithData.push({ - scopeKey: candidate, - contactsItem, - avatarItem, - contactsCount, - avatarCount, - contactsUpdatedAt: contactsItem?.updatedAt || 0, - avatarUpdatedAt: avatarItem?.updatedAt || 0 - }) - } - - if (candidatesWithData.length === 0) { - return { - resolvedContactsScopeKey: primaryScopeKey, - resolvedAvatarScopeKeys: [] as string[], - contactsItem: null as configService.ContactsListCacheItem | null, - avatarItem: null as configService.ContactsAvatarCacheItem | null - } - } - - const bestContactsCandidate = candidatesWithData - .filter(item => item.contactsCount > 0) - .sort((a, b) => { - if (b.contactsCount !== a.contactsCount) return b.contactsCount - a.contactsCount - if (b.contactsUpdatedAt !== a.contactsUpdatedAt) return b.contactsUpdatedAt - a.contactsUpdatedAt - return b.avatarCount - a.avatarCount - })[0] - - const avatarCandidates = candidatesWithData - .filter(item => item.avatarCount > 0) - .sort((a, b) => a.avatarUpdatedAt - b.avatarUpdatedAt) - const mergedAvatarEntries: Record = {} - for (const candidate of avatarCandidates) { - Object.assign(mergedAvatarEntries, candidate.avatarItem?.avatars || {}) - } - const mergedAvatarUpdatedAt = avatarCandidates.reduce((max, candidate) => ( - candidate.avatarUpdatedAt > max ? candidate.avatarUpdatedAt : max - ), 0) - return { - resolvedContactsScopeKey: bestContactsCandidate?.scopeKey || primaryScopeKey, - resolvedAvatarScopeKeys: avatarCandidates.map(candidate => candidate.scopeKey), - contactsItem: bestContactsCandidate?.contactsItem || null, - avatarItem: Object.keys(mergedAvatarEntries).length > 0 - ? { - updatedAt: mergedAvatarUpdatedAt, - avatars: mergedAvatarEntries - } - : null + contactsItem, + avatarItem } }, []) @@ -700,11 +710,9 @@ function ExportPage() { if (isStale()) return const { - resolvedContactsScopeKey, - resolvedAvatarScopeKeys, contactsItem: cachedContactsItem, avatarItem: cachedAvatarItem - } = await loadContactsCachesWithScopeFallback(scopeKey) + } = await loadContactsCaches(scopeKey) if (isStale()) return const cachedContacts = cachedContactsItem?.contacts || [] @@ -719,17 +727,6 @@ function ExportPage() { setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null) setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null) - if (resolvedContactsScopeKey !== scopeKey && cachedContacts.length > 0) { - void configService.setContactsListCache(scopeKey, cachedContacts).catch((error) => { - console.error('回填主 scope 通讯录缓存失败:', error) - }) - } - if (!resolvedAvatarScopeKeys.includes(scopeKey) && Object.keys(cachedAvatarEntries).length > 0) { - void configService.setContactsAvatarCache(scopeKey, cachedAvatarEntries).catch((error) => { - console.error('回填主 scope 头像缓存失败:', error) - }) - } - const connectResult = await window.electronAPI.chat.connect() if (!connectResult.success) { console.error('连接失败:', connectResult.error) @@ -759,34 +756,71 @@ function ExportPage() { let contactMap = { ...cachedContactMap } let avatarEntries = { ...cachedAvatarEntries } let hasFreshNetworkData = false + let hasNetworkContactsSnapshot = false if (isStale()) return const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) if (isStale()) return - const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] - if (contacts.length > 0) { + const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] + if (contactsFromNetwork.length > 0) { hasFreshNetworkData = true - syncContactTypeCounts(contacts) - const nextContactMap = contacts.reduce>((map, contact) => { + hasNetworkContactsSnapshot = true + const contactsWithCachedAvatar = mergeAvatarCacheIntoContacts(contactsFromNetwork, avatarEntries) + const nextContactMap = contactsWithCachedAvatar.reduce>((map, contact) => { map[contact.username] = contact return map }, {}) + for (const [username, cachedContact] of Object.entries(cachedContactMap)) { + if (!nextContactMap[username]) { + nextContactMap[username] = cachedContact + } + } contactMap = nextContactMap - setSessionContactsUpdatedAt(Date.now()) + syncContactTypeCounts(Object.values(contactMap)) + const refreshAt = Date.now() + setSessionContactsUpdatedAt(refreshAt) + + const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, Object.values(contactMap), { + prune: true, + now: refreshAt + }) + avatarEntries = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setSessionAvatarUpdatedAt(upsertResult.updatedAt) + } } + const sourceContacts = Object.values(contactMap) + const sourceByUsername = new Map() + for (const contact of sourceContacts) { + if (!contact?.username) continue + sourceByUsername.set(contact.username, contact) + } const now = Date.now() - const needsEnrichment = baseSessions - .filter((session) => { - const contact = contactMap[session.username] - const avatarEntry = avatarEntries[session.username] - const displayName = contact?.displayName || session.displayName || session.username - const avatarUrl = contact?.avatarUrl || session.avatarUrl || avatarEntry?.avatarUrl - const shouldRecheckAvatar = !avatarEntry || (now - (avatarEntry.checkedAt || 0) >= EXPORT_AVATAR_RECHECK_INTERVAL_MS) - return !avatarUrl || displayName === session.username || shouldRecheckAvatar + const rawSessionMap = rawSessions.reduce>((map, session) => { + map[session.username] = session + return map + }, {}) + const candidateUsernames = sourceContacts.length > 0 + ? sourceContacts.map(contact => contact.username) + : baseSessions.map(session => session.username) + const needsEnrichment = candidateUsernames + .filter(Boolean) + .filter((username) => { + const currentContact = sourceByUsername.get(username) + const cacheEntry = avatarEntries[username] + const session = rawSessionMap[username] + const currentAvatarUrl = currentContact?.avatarUrl || session?.avatarUrl + if (!cacheEntry || !cacheEntry.avatarUrl) { + return !currentAvatarUrl + } + if (currentAvatarUrl && currentAvatarUrl !== cacheEntry.avatarUrl) { + return true + } + const checkedAt = cacheEntry.checkedAt || 0 + return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS }) - .map((session) => session.username) let extraContactMap: Record = {} if (needsEnrichment.length > 0) { @@ -806,62 +840,55 @@ function ExportPage() { ...enrichResult.contacts } hasFreshNetworkData = true + for (const [username, enriched] of Object.entries(enrichResult.contacts)) { + const current = sourceByUsername.get(username) + if (!current) continue + sourceByUsername.set(username, { + ...current, + displayName: enriched.displayName || current.displayName, + avatarUrl: enriched.avatarUrl || current.avatarUrl + }) + } } } catch (batchError) { console.error('导出页分批补充会话联系人信息失败:', batchError) } + + const batchContacts = batch + .map(username => sourceByUsername.get(username)) + .filter((contact): contact is ContactInfo => Boolean(contact)) + const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, batchContacts, { + markCheckedUsernames: batch + }) + avatarEntries = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setSessionAvatarUpdatedAt(upsertResult.updatedAt) + } await new Promise(resolve => setTimeout(resolve, 0)) } } - const persistAt = Date.now() - for (const contact of Object.values(contactMap)) { - const avatarUrl = String(contact.avatarUrl || '').trim() - if (!avatarUrl) continue - const prev = avatarEntries[contact.username] - avatarEntries[contact.username] = { - avatarUrl, - updatedAt: prev?.avatarUrl === avatarUrl ? prev.updatedAt : persistAt, - checkedAt: prev?.checkedAt || persistAt - } - } - - for (const username of needsEnrichment) { - const extra = extraContactMap[username] - const prev = avatarEntries[username] - if (extra?.avatarUrl) { - avatarEntries[username] = { - avatarUrl: extra.avatarUrl, - updatedAt: !prev || prev.avatarUrl !== extra.avatarUrl ? persistAt : prev.updatedAt, - checkedAt: persistAt - } - } else if (prev) { - avatarEntries[username] = { - ...prev, - checkedAt: persistAt - } - } - - if (!extra) continue - const current = contactMap[username] - if (!current) continue - const nextDisplayName = extra.displayName || current.displayName - const nextAvatarUrl = extra.avatarUrl || current.avatarUrl - if (nextDisplayName !== current.displayName || nextAvatarUrl !== current.avatarUrl) { - contactMap[username] = { - ...current, - displayName: nextDisplayName, - avatarUrl: nextAvatarUrl - } + const contactsForPersist = Array.from(sourceByUsername.values()) + if (hasNetworkContactsSnapshot && contactsForPersist.length > 0) { + const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, contactsForPersist, { + prune: true + }) + avatarEntries = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setSessionAvatarUpdatedAt(upsertResult.updatedAt) } } + contactMap = contactsForPersist.reduce>((map, contact) => { + map[contact.username] = contact + return map + }, contactMap) if (isStale()) return const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap) .map((session) => { const extra = extraContactMap[session.username] const displayName = extra?.displayName || session.displayName || session.username - const avatarUrl = extra?.avatarUrl || session.avatarUrl + const avatarUrl = extra?.avatarUrl || session.avatarUrl || avatarEntries[session.username]?.avatarUrl if (displayName === session.displayName && avatarUrl === session.avatarUrl) { return session } @@ -881,8 +908,9 @@ function ExportPage() { type: contact.type })) + const persistAt = Date.now() setSessions(nextSessions) - if (contactsCachePayload.length > 0) { + if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) { await configService.setContactsListCache(scopeKey, contactsCachePayload) setSessionContactsUpdatedAt(persistAt) } @@ -908,7 +936,7 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [ensureExportCacheScope, loadContactsCachesWithScopeFallback, syncContactTypeCounts]) + }, [ensureExportCacheScope, loadContactsCaches, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return From af7639aa731dd65b6dd599bb49b47d562ade1e08 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 12:28:40 +0800 Subject: [PATCH 037/155] feat(export): optimize dialog defaults and option cards --- src/pages/ExportPage.scss | 28 +- src/pages/ExportPage.tsx | 720 ++++++++++++++++++++++++++++++++++---- 2 files changed, 662 insertions(+), 86 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 49efd28..a33df95 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -935,15 +935,15 @@ .format-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 8px; + gap: 6px; } .format-card { width: 100%; - min-height: 82px; + min-height: 0; border: 1px solid var(--border-color); border-radius: 10px; - padding: 10px; + padding: 8px 10px; text-align: left; background: var(--bg-primary); cursor: pointer; @@ -956,13 +956,14 @@ font-size: 13px; font-weight: 600; color: var(--text-primary); + line-height: 1.35; } .format-desc { - margin-top: 3px; + margin-top: 1px; font-size: 11px; color: var(--text-tertiary); - line-height: 1.4; + line-height: 1.35; } &.active { @@ -1031,10 +1032,22 @@ border: 1px solid var(--border-color); border-radius: 8px; padding: 8px; + width: 100%; display: flex; flex-direction: column; gap: 2px; background: var(--bg-primary); + text-align: left; + cursor: pointer; + color: inherit; + font: inherit; + appearance: none; + -webkit-appearance: none; + + &:focus-visible { + outline: 2px solid rgba(var(--primary-rgb), 0.35); + outline-offset: 1px; + } span { font-size: 12px; @@ -1048,11 +1061,6 @@ line-height: 1.4; } - input { - accent-color: var(--primary); - margin: 0 0 4px; - } - &.active { border-color: var(--primary); background: rgba(var(--primary-rgb), 0.08); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 1c4a27e..b87c021 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from 'react' import { useLocation } from 'react-router-dom' import { TableVirtuoso } from 'react-virtuoso' import { @@ -11,8 +11,11 @@ import { FolderOpen, Image as ImageIcon, Loader2, + AlertTriangle, + ClipboardList, MessageSquareText, Mic, + RefreshCw, Search, Square, Video, @@ -224,12 +227,48 @@ const getAvatarLetter = (name: string): string => { return [...name][0] || '?' } +const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean => { + if (tab === 'private') return contact.type === 'friend' + if (tab === 'group') return contact.type === 'group' + if (tab === 'official') return contact.type === 'official' + return contact.type === 'former_friend' +} + +const getContactTypeName = (type: ContactInfo['type']): string => { + if (type === 'friend') return '好友' + if (type === 'group') return '群聊' + if (type === 'official') return '公众号' + if (type === 'former_friend') return '曾经的好友' + return '其他' +} + const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 +const CONTACTS_LIST_VIRTUAL_ROW_HEIGHT = 76 +const CONTACTS_LIST_VIRTUAL_OVERSCAN = 10 +const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 type SessionDataSource = 'cache' | 'network' | null +type ContactsDataSource = 'cache' | 'network' | null + +interface ContactsLoadSession { + requestId: string + startedAt: number + attempt: number + timeoutMs: number +} + +interface ContactsLoadIssue { + kind: 'timeout' | 'error' + title: string + message: string + reason: string + errorDetail?: string + occurredAt: number + elapsedMs: number +} const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null @@ -480,6 +519,23 @@ function ExportPage() { const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') const [selectedSessions, setSelectedSessions] = useState>(new Set()) + const [contactsList, setContactsList] = useState([]) + const [isContactsListLoading, setIsContactsListLoading] = useState(true) + const [contactsDataSource, setContactsDataSource] = useState(null) + const [contactsUpdatedAt, setContactsUpdatedAt] = useState(null) + const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState(null) + const [contactsListScrollTop, setContactsListScrollTop] = useState(0) + const [contactsListViewportHeight, setContactsListViewportHeight] = useState(480) + const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const [contactsLoadSession, setContactsLoadSession] = useState(null) + const [contactsLoadIssue, setContactsLoadIssue] = useState(null) + const [showContactsDiagnostics, setShowContactsDiagnostics] = useState(false) + const [contactsDiagnosticTick, setContactsDiagnosticTick] = useState(Date.now()) + const [contactsAvatarEnrichProgress, setContactsAvatarEnrichProgress] = useState({ + loaded: 0, + total: 0, + running: false + }) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') @@ -536,6 +592,12 @@ function ExportPage() { const preselectAppliedRef = useRef(false) const exportCacheScopeRef = useRef('default') const exportCacheScopeReadyRef = useRef(false) + const contactsLoadVersionRef = useRef(0) + const contactsLoadAttemptRef = useRef(0) + const contactsLoadTimeoutTimerRef = useRef(null) + const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const contactsAvatarCacheRef = useRef>({}) + const contactsListRef = useRef(null) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -564,6 +626,331 @@ function ExportPage() { } }, []) + useEffect(() => { + let cancelled = false + void (async () => { + try { + const value = await configService.getContactsLoadTimeoutMs() + if (!cancelled) { + setContactsLoadTimeoutMs(value) + } + } catch (error) { + console.error('读取通讯录超时配置失败:', error) + } + })() + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs + }, [contactsLoadTimeoutMs]) + + const applyEnrichedContactsToList = useCallback((enrichedMap: Record) => { + if (!enrichedMap || Object.keys(enrichedMap).length === 0) return + setContactsList(prev => { + let changed = false + const next = prev.map(contact => { + const enriched = enrichedMap[contact.username] + if (!enriched) return contact + const displayName = enriched.displayName || contact.displayName + const avatarUrl = enriched.avatarUrl || contact.avatarUrl + if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + displayName, + avatarUrl + } + }) + return changed ? next : prev + }) + }, []) + + const enrichContactsListInBackground = useCallback(async ( + sourceContacts: ContactInfo[], + loadVersion: number, + scopeKey: string + ) => { + const sourceByUsername = new Map() + for (const contact of sourceContacts) { + if (!contact.username) continue + sourceByUsername.set(contact.username, contact) + } + + const now = Date.now() + const usernames = sourceContacts + .map(contact => contact.username) + .filter(Boolean) + .filter((username) => { + const currentContact = sourceByUsername.get(username) + if (!currentContact) return false + const cacheEntry = contactsAvatarCacheRef.current[username] + if (!cacheEntry || !cacheEntry.avatarUrl) { + return !currentContact.avatarUrl + } + if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) { + return true + } + const checkedAt = cacheEntry.checkedAt || 0 + return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS + }) + + const total = usernames.length + setContactsAvatarEnrichProgress({ + loaded: 0, + total, + running: total > 0 + }) + if (total === 0) return + + for (let i = 0; i < total; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) { + if (contactsLoadVersionRef.current !== loadVersion) return + const batch = usernames.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE) + if (batch.length === 0) continue + + try { + const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch) + if (contactsLoadVersionRef.current !== loadVersion) return + if (avatarResult.success && avatarResult.contacts) { + applyEnrichedContactsToList(avatarResult.contacts) + for (const [username, enriched] of Object.entries(avatarResult.contacts)) { + const prev = sourceByUsername.get(username) + if (!prev) continue + sourceByUsername.set(username, { + ...prev, + displayName: enriched.displayName || prev.displayName, + avatarUrl: enriched.avatarUrl || prev.avatarUrl + }) + } + } + + const batchContacts = batch + .map(username => sourceByUsername.get(username)) + .filter((contact): contact is ContactInfo => Boolean(contact)) + const upsertResult = upsertAvatarCacheFromContacts( + contactsAvatarCacheRef.current, + batchContacts, + { markCheckedUsernames: batch } + ) + contactsAvatarCacheRef.current = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setAvatarCacheUpdatedAt(upsertResult.updatedAt) + } + } catch (error) { + console.error('导出页分批补全头像失败:', error) + } + + const loaded = Math.min(i + batch.length, total) + setContactsAvatarEnrichProgress({ + loaded, + total, + running: loaded < total + }) + await new Promise(resolve => setTimeout(resolve, 0)) + } + + void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => { + console.error('写入导出页头像缓存失败:', error) + }) + }, [applyEnrichedContactsToList]) + + const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => { + const scopeKey = options?.scopeKey || await ensureExportCacheScope() + const loadVersion = contactsLoadVersionRef.current + 1 + contactsLoadVersionRef.current = loadVersion + contactsLoadAttemptRef.current += 1 + const startedAt = Date.now() + const timeoutMs = contactsLoadTimeoutMsRef.current + const requestId = `export-contacts-${startedAt}-${contactsLoadAttemptRef.current}` + setContactsLoadSession({ + requestId, + startedAt, + attempt: contactsLoadAttemptRef.current, + timeoutMs + }) + setContactsLoadIssue(null) + setShowContactsDiagnostics(false) + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + const timeoutTimerId = window.setTimeout(() => { + if (contactsLoadVersionRef.current !== loadVersion) return + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'timeout', + title: '联系人列表加载超时', + message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`, + reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。', + occurredAt: Date.now(), + elapsedMs + }) + }, timeoutMs) + contactsLoadTimeoutTimerRef.current = timeoutTimerId + + setIsContactsListLoading(true) + setContactsAvatarEnrichProgress({ + loaded: 0, + total: 0, + running: false + }) + + try { + const contactsResult = await window.electronAPI.chat.getContacts() + if (contactsLoadVersionRef.current !== loadVersion) return + + if (contactsResult.success && contactsResult.contacts) { + if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + const contactsWithAvatarCache = mergeAvatarCacheIntoContacts( + contactsResult.contacts, + contactsAvatarCacheRef.current + ) + setContactsList(contactsWithAvatarCache) + syncContactTypeCounts(contactsWithAvatarCache) + setContactsDataSource('network') + setContactsUpdatedAt(Date.now()) + setContactsLoadIssue(null) + setIsContactsListLoading(false) + + const upsertResult = upsertAvatarCacheFromContacts( + contactsAvatarCacheRef.current, + contactsWithAvatarCache, + { prune: true } + ) + contactsAvatarCacheRef.current = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setAvatarCacheUpdatedAt(upsertResult.updatedAt) + } + + void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => { + console.error('写入导出页头像缓存失败:', error) + }) + void configService.setContactsListCache( + scopeKey, + contactsWithAvatarCache.map(contact => ({ + username: contact.username, + displayName: contact.displayName, + remark: contact.remark, + nickname: contact.nickname, + type: contact.type + })) + ).catch((error) => { + console.error('写入导出页通讯录缓存失败:', error) + }) + void enrichContactsListInBackground(contactsWithAvatarCache, loadVersion, scopeKey) + return + } + + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'error', + title: '联系人列表加载失败', + message: '联系人接口返回失败,未拿到联系人列表。', + reason: 'chat.getContacts 返回 success=false。', + errorDetail: contactsResult.error || '未知错误', + occurredAt: Date.now(), + elapsedMs + }) + } catch (error) { + console.error('加载导出页联系人失败:', error) + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'error', + title: '联系人列表加载失败', + message: '联系人请求执行异常。', + reason: '调用 chat.getContacts 发生异常。', + errorDetail: String(error), + occurredAt: Date.now(), + elapsedMs + }) + } finally { + if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + if (contactsLoadVersionRef.current === loadVersion) { + setIsContactsListLoading(false) + } + } + }, [ensureExportCacheScope, enrichContactsListInBackground, syncContactTypeCounts]) + + useEffect(() => { + if (!isExportRoute) return + let cancelled = false + void (async () => { + const scopeKey = await ensureExportCacheScope() + if (cancelled) return + try { + const [cacheItem, avatarCacheItem] = await Promise.all([ + configService.getContactsListCache(scopeKey), + configService.getContactsAvatarCache(scopeKey) + ]) + const avatarCacheMap = avatarCacheItem?.avatars || {} + contactsAvatarCacheRef.current = avatarCacheMap + setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null) + if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) { + const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({ + ...contact, + avatarUrl: avatarCacheMap[contact.username]?.avatarUrl + })) + setContactsList(cachedContacts) + syncContactTypeCounts(cachedContacts) + setContactsDataSource('cache') + setContactsUpdatedAt(cacheItem.updatedAt || null) + setIsContactsListLoading(false) + } + } catch (error) { + console.error('读取导出页联系人缓存失败:', error) + } + + if (!cancelled) { + void loadContactsList({ scopeKey }) + } + })() + return () => { + cancelled = true + } + }, [isExportRoute, ensureExportCacheScope, loadContactsList, syncContactTypeCounts]) + + useEffect(() => { + if (isExportRoute) return + contactsLoadVersionRef.current += 1 + setContactsAvatarEnrichProgress({ + loaded: 0, + total: 0, + running: false + }) + }, [isExportRoute]) + + useEffect(() => { + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + return () => { + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + } + }, []) + + useEffect(() => { + if (!contactsLoadIssue || contactsList.length > 0) return + if (!(isContactsListLoading && contactsLoadIssue.kind === 'timeout')) return + const timer = window.setInterval(() => { + setContactsDiagnosticTick(Date.now()) + }, 500) + return () => window.clearInterval(timer) + }, [contactsList.length, isContactsListLoading, contactsLoadIssue]) + useEffect(() => { tasksRef.current = tasks }, [tasks]) @@ -1035,28 +1422,39 @@ function ExportPage() { const openExportDialog = (payload: Omit) => { setExportDialog({ open: true, ...payload }) - if (payload.scope === 'sns') { - setOptions(prev => ({ - ...prev, - format: prev.format === 'json' || prev.format === 'html' ? prev.format : 'html' - })) - return - } + setOptions(prev => { + const nextDateRange = prev.dateRange ?? (() => { + const now = new Date() + const start = new Date(now) + start.setHours(0, 0, 0, 0) + return { start, end: now } + })() - if (payload.scope === 'content' && payload.contentType) { - if (payload.contentType === 'text') { - setOptions(prev => ({ ...prev, exportMedia: false })) - } else { - setOptions(prev => ({ - ...prev, - exportMedia: true, - exportImages: payload.contentType === 'image', - exportVoices: payload.contentType === 'voice', - exportVideos: payload.contentType === 'video', - exportEmojis: payload.contentType === 'emoji' - })) + const next: ExportOptions = { + ...prev, + useAllTime: true, + dateRange: nextDateRange } - } + + if (payload.scope === 'sns') { + next.format = prev.format === 'json' || prev.format === 'html' ? prev.format : 'html' + return next + } + + if (payload.scope === 'content' && payload.contentType) { + if (payload.contentType === 'text') { + next.exportMedia = false + } else { + next.exportMedia = true + next.exportImages = payload.contentType === 'image' + next.exportVoices = payload.contentType === 'voice' + next.exportVideos = payload.contentType === 'video' + next.exportEmojis = payload.contentType === 'emoji' + } + } + + return next + }) } const closeExportDialog = () => { @@ -1492,6 +1890,120 @@ function ExportPage() { return '公众号' }, [activeTab]) + const filteredContacts = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase() + return contactsList + .filter((contact) => { + if (!matchesContactTab(contact, activeTab)) return false + if (!keyword) return true + return ( + (contact.displayName || '').toLowerCase().includes(keyword) || + (contact.remark || '').toLowerCase().includes(keyword) || + contact.username.toLowerCase().includes(keyword) + ) + }) + .sort((a, b) => (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN')) + }, [contactsList, activeTab, searchKeyword]) + + const contactsUpdatedAtLabel = useMemo(() => { + if (!contactsUpdatedAt) return '' + return new Date(contactsUpdatedAt).toLocaleString() + }, [contactsUpdatedAt]) + + const avatarCacheUpdatedAtLabel = useMemo(() => { + if (!avatarCacheUpdatedAt) return '' + return new Date(avatarCacheUpdatedAt).toLocaleString() + }, [avatarCacheUpdatedAt]) + + const contactsAvatarCachedCount = useMemo(() => { + return contactsList.reduce((count, contact) => ( + contact.avatarUrl ? count + 1 : count + ), 0) + }, [contactsList]) + + useEffect(() => { + if (!contactsListRef.current) return + contactsListRef.current.scrollTop = 0 + setContactsListScrollTop(0) + }, [activeTab, searchKeyword]) + + useEffect(() => { + const node = contactsListRef.current + if (!node) return + const updateViewportHeight = () => { + setContactsListViewportHeight(Math.max(node.clientHeight, CONTACTS_LIST_VIRTUAL_ROW_HEIGHT)) + } + updateViewportHeight() + const observer = new ResizeObserver(() => updateViewportHeight()) + observer.observe(node) + return () => observer.disconnect() + }, [filteredContacts.length, isContactsListLoading]) + + useEffect(() => { + const maxScroll = Math.max(0, filteredContacts.length * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT - contactsListViewportHeight) + if (contactsListScrollTop <= maxScroll) return + setContactsListScrollTop(maxScroll) + if (contactsListRef.current) { + contactsListRef.current.scrollTop = maxScroll + } + }, [filteredContacts.length, contactsListViewportHeight, contactsListScrollTop]) + + const { startIndex: contactStartIndex, endIndex: contactEndIndex } = useMemo(() => { + if (filteredContacts.length === 0) { + return { startIndex: 0, endIndex: 0 } + } + const baseStart = Math.floor(contactsListScrollTop / CONTACTS_LIST_VIRTUAL_ROW_HEIGHT) + const visibleCount = Math.ceil(contactsListViewportHeight / CONTACTS_LIST_VIRTUAL_ROW_HEIGHT) + const nextStart = Math.max(0, baseStart - CONTACTS_LIST_VIRTUAL_OVERSCAN) + const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + CONTACTS_LIST_VIRTUAL_OVERSCAN * 2) + return { + startIndex: nextStart, + endIndex: nextEnd + } + }, [filteredContacts.length, contactsListViewportHeight, contactsListScrollTop]) + + const visibleContacts = useMemo(() => { + return filteredContacts.slice(contactStartIndex, contactEndIndex) + }, [filteredContacts, contactStartIndex, contactEndIndex]) + + const onContactsListScroll = useCallback((event: UIEvent) => { + setContactsListScrollTop(event.currentTarget.scrollTop) + }, []) + + const contactsIssueElapsedMs = useMemo(() => { + if (!contactsLoadIssue) return 0 + if (isContactsListLoading && contactsLoadSession) { + return Math.max(contactsLoadIssue.elapsedMs, contactsDiagnosticTick - contactsLoadSession.startedAt) + } + return contactsLoadIssue.elapsedMs + }, [contactsDiagnosticTick, isContactsListLoading, contactsLoadIssue, contactsLoadSession]) + + const contactsDiagnosticsText = useMemo(() => { + if (!contactsLoadIssue || !contactsLoadSession) return '' + return [ + `请求ID: ${contactsLoadSession.requestId}`, + `请求序号: 第 ${contactsLoadSession.attempt} 次`, + `阈值配置: ${contactsLoadSession.timeoutMs}ms`, + `当前状态: ${contactsLoadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`, + `累计耗时: ${(contactsIssueElapsedMs / 1000).toFixed(1)}s`, + `发生时间: ${new Date(contactsLoadIssue.occurredAt).toLocaleString()}`, + '阶段: chat.getContacts', + `原因: ${contactsLoadIssue.reason}`, + `错误详情: ${contactsLoadIssue.errorDetail || '无'}` + ].join('\n') + }, [contactsIssueElapsedMs, contactsLoadIssue, contactsLoadSession]) + + const copyContactsDiagnostics = useCallback(async () => { + if (!contactsDiagnosticsText) return + try { + await navigator.clipboard.writeText(contactsDiagnosticsText) + alert('诊断信息已复制') + } catch (error) { + console.error('复制诊断信息失败:', error) + alert('复制失败,请手动复制诊断信息') + } + }, [contactsDiagnosticsText]) + const sessionContactsUpdatedAtLabel = useMemo(() => { if (!sessionContactsUpdatedAt) return '' return new Date(sessionContactsUpdatedAt).toLocaleString() @@ -1797,7 +2309,7 @@ function ExportPage() { setSearchKeyword(event.target.value)} - placeholder={`搜索${activeTabLabel}会话...`} + placeholder={`搜索${activeTabLabel}联系人...`} /> {searchKeyword && ( )}
- - - - {selectedCount > 0 && ( -
- 已选中 {selectedCount} 个会话 - - -
- )}
- {sessionContactsUpdatedAt && ( + + 共 {filteredContacts.length} / {contactsList.length} 个联系人 + + {contactsUpdatedAt && ( - {sessionDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {sessionContactsUpdatedAtLabel} + {contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel} )} - {sessions.length > 0 && ( + {contactsList.length > 0 && ( - 头像缓存 {sessionAvatarCachedCount}/{sessions.length} - {sessionAvatarUpdatedAtLabel ? ` · 更新于 ${sessionAvatarUpdatedAtLabel}` : ''} + 头像缓存 {contactsAvatarCachedCount}/{contactsList.length} + {avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''} )} - {(isLoading || isSessionEnriching) && sessions.length > 0 && ( + {(isContactsListLoading || contactsAvatarEnrichProgress.running) && contactsList.length > 0 && ( 后台同步中... )} + {contactsAvatarEnrichProgress.running && ( + + 头像补全中 {contactsAvatarEnrichProgress.loaded}/{contactsAvatarEnrichProgress.total} + + )}
- {!showInitialSkeleton && (isLoading || isSessionEnriching) && ( + {contactsList.length > 0 && (isContactsListLoading || contactsAvatarEnrichProgress.running) && (
- {isLoading ? '导出板块数据加载中…' : '正在补充头像…'} + {isContactsListLoading ? '联系人列表同步中…' : '正在补充头像…'}
)}
- {showInitialSkeleton ? ( -
- {Array.from({ length: 8 }).map((_, rowIndex) => ( -
- - - - - - + {contactsList.length === 0 && contactsLoadIssue ? ( +
+
+
+ + {contactsLoadIssue.title}
- ))} +

{contactsLoadIssue.message}

+

{contactsLoadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showContactsDiagnostics && ( +
{contactsDiagnosticsText}
+ )} +
+
+ ) : isContactsListLoading && contactsList.length === 0 ? ( +
+ + 联系人加载中... +
+ ) : filteredContacts.length === 0 ? ( +
+ 暂无联系人
- ) : visibleSessions.length === 0 ? ( -
暂无会话
) : ( - session.username} - itemContent={(_, session) => renderRowCells(session)} - overscan={420} - /> +
+
+ {visibleContacts.map((contact, idx) => { + const absoluteIndex = contactStartIndex + idx + const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT + return ( +
+
+
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+
{contact.username}
+
+
+ {getContactTypeName(contact.type)} +
+
+
+ ) + })} +
+
)}
@@ -1994,18 +2557,23 @@ function ExportPage() {

发送者名称显示

-
- {displayNameOptions.map(option => ( - - ))} +
+ {displayNameOptions.map(option => { + const isActive = options.displayNamePreference === option.value + return ( + + ) + })}
From 1414a4a9cff98a9d9bbecde398ddfd66c82924cf Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 12:30:15 +0800 Subject: [PATCH 038/155] fix(export): style mirrored contacts list in export panel --- src/pages/ExportPage.scss | 219 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index a33df95..e13ecfc 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -576,6 +576,225 @@ min-height: 320px; height: 100%; flex: 1; + display: flex; + flex-direction: column; +} + +.table-wrap { + .loading-state, + .empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-tertiary); + font-size: 14px; + + .spin { + animation: exportSpin 1s linear infinite; + } + } + + .load-issue-state { + flex: 1; + padding: 14px; + overflow-y: auto; + } + + .issue-card { + border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); + background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg)); + border-radius: 12px; + padding: 14px; + color: var(--text-primary); + } + + .issue-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary)); + margin-bottom: 8px; + } + + .issue-message { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-reason { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-hints { + margin: 10px 0 0; + padding-left: 18px; + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.6; + } + + .issue-actions { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .issue-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + padding: 7px 10px; + font-size: 12px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + background: var(--bg-hover); + } + + &.primary { + background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--primary); + } + } + + .issue-diagnostics { + margin-top: 12px; + border-radius: 8px; + background: var(--bg-primary); + border: 1px dashed var(--border-color); + padding: 10px; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + } + + .contacts-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 12px 12px; + position: relative; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 3px; + opacity: 0.3; + } + } + + .contacts-list-virtual { + position: relative; + min-height: 100%; + } + + .contact-row { + position: absolute; + left: 0; + right: 0; + height: 76px; + padding-bottom: 4px; + will-change: transform; + } + + .contact-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + height: 72px; + box-sizing: border-box; + border-radius: 10px; + transition: all 0.2s; + cursor: default; + + &:hover { + background: var(--bg-hover); + } + } + + .contact-avatar { + width: 44px; + height: 44px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 16px; + font-weight: 600; + } + } + + .contact-info { + flex: 1; + min-width: 0; + } + + .contact-name { + font-size: 14px; + color: var(--text-primary); + margin-bottom: 2px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-remark { + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .contact-type { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + padding: 4px 8px; + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-secondary); + flex-shrink: 0; + } } .table-virtuoso { From 983783ea95a448368d5a8a4793324c6c46e44eb3 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 12:35:58 +0800 Subject: [PATCH 039/155] feat(export): add per-contact single export action button --- src/pages/ExportPage.tsx | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index b87c021..10a11c0 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1905,6 +1905,14 @@ function ExportPage() { .sort((a, b) => (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN')) }, [contactsList, activeTab, searchKeyword]) + const sessionRowByUsername = useMemo(() => { + const map = new Map() + for (const session of sessions) { + map.set(session.username, session) + } + return map + }, [sessions]) + const contactsUpdatedAtLabel = useMemo(() => { if (!contactsUpdatedAt) return '' return new Date(contactsUpdatedAt).toLocaleString() @@ -2407,6 +2415,11 @@ function ExportPage() { {visibleContacts.map((contact, idx) => { const absoluteIndex = contactStartIndex + idx const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT + const matchedSession = sessionRowByUsername.get(contact.username) + const canExport = Boolean(matchedSession?.hasSession) + const isRunning = canExport && runningSessionIds.has(contact.username) + const isQueued = canExport && queuedSessionIds.has(contact.username) + const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' return (
{getContactTypeName(contact.type)}
+
+ + {recent && {recent}} +
) From 64616b91365950342f1e16ef7645f218c005727a Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:31:42 +0800 Subject: [PATCH 040/155] feat(sns): add header overview stats line --- src/pages/SnsPage.scss | 29 +++++++++++--- src/pages/SnsPage.tsx | 85 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 10 deletions(-) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index e9620ae..b30ec19 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -45,11 +45,28 @@ margin-bottom: 8px; padding: 0 4px; - h2 { - font-size: 20px; - font-weight: 700; - margin: 0; - color: var(--text-primary); + .feed-header-main { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + + h2 { + font-size: 20px; + font-weight: 700; + margin: 0; + color: var(--text-primary); + } + } + + .feed-stats-line { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.4; + + &.loading { + opacity: 0.7; + } } .header-actions { @@ -2091,4 +2108,4 @@ cursor: not-allowed; } } -} \ No newline at end of file +} diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 452931c..60f79f1 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -13,11 +13,25 @@ interface Contact { type?: 'friend' | 'former_friend' | 'sns_only' } +interface SnsOverviewStats { + totalPosts: number + totalFriends: number + earliestTime: number | null + latestTime: number | null +} + export default function SnsPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(false) const [hasMore, setHasMore] = useState(true) const loadingRef = useRef(false) + const [overviewStats, setOverviewStats] = useState({ + totalPosts: 0, + totalFriends: 0, + earliestTime: null, + latestTime: null + }) + const [overviewStatsLoading, setOverviewStatsLoading] = useState(false) // Filter states const [searchKeyword, setSearchKeyword] = useState('') @@ -75,6 +89,58 @@ export default function SnsPage() { } }, [posts]) + const formatDateOnly = (timestamp: number | null): string => { + if (!timestamp || timestamp <= 0) return '--' + const date = new Date(timestamp * 1000) + if (Number.isNaN(date.getTime())) return '--' + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` + } + + const loadOverviewStats = useCallback(async () => { + setOverviewStatsLoading(true) + try { + const statsResult = await window.electronAPI.sns.getExportStats() + if (!statsResult.success || !statsResult.data) { + throw new Error(statsResult.error || '获取朋友圈统计失败') + } + + const totalPosts = Math.max(0, Number(statsResult.data.totalPosts || 0)) + const totalFriends = Math.max(0, Number(statsResult.data.totalFriends || 0)) + let earliestTime: number | null = null + let latestTime: number | null = null + + if (totalPosts > 0) { + const [latestResult, earliestResult] = await Promise.all([ + window.electronAPI.sns.getTimeline(1, 0), + window.electronAPI.sns.getTimeline(1, Math.max(totalPosts - 1, 0)) + ]) + const latestTs = Number(latestResult.timeline?.[0]?.createTime || 0) + const earliestTs = Number(earliestResult.timeline?.[0]?.createTime || 0) + + if (latestResult.success && Number.isFinite(latestTs) && latestTs > 0) { + latestTime = Math.floor(latestTs) + } + if (earliestResult.success && Number.isFinite(earliestTs) && earliestTs > 0) { + earliestTime = Math.floor(earliestTs) + } + } + + setOverviewStats({ + totalPosts, + totalFriends, + earliestTime, + latestTime + }) + } catch (error) { + console.error('Failed to load SNS overview stats:', error) + } finally { + setOverviewStatsLoading(false) + } + }, []) + const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { const { reset = false, direction = 'older' } = options if (loadingRef.current) return @@ -244,7 +310,8 @@ export default function SnsPage() { // Initial Load & Listeners useEffect(() => { loadContacts() - }, [loadContacts]) + loadOverviewStats() + }, [loadContacts, loadOverviewStats]) useEffect(() => { const handleChange = () => { @@ -252,11 +319,12 @@ export default function SnsPage() { setPosts([]); setHasMore(true); setHasNewer(false); setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined); loadContacts(); + loadOverviewStats(); loadPosts({ reset: true }); } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadContacts, loadPosts]) + }, [loadContacts, loadOverviewStats, loadPosts]) useEffect(() => { const timer = setTimeout(() => { @@ -288,7 +356,12 @@ export default function SnsPage() {
-

朋友圈

+
+

朋友圈

+
+ 共 {overviewStats.totalPosts} 条 | {formatDateOnly(overviewStats.earliestTime)} ~ {formatDateOnly(overviewStats.latestTime)} | {overviewStats.totalFriends} 位好友 +
+
From bc739dc4a0766057189c46bbc05406a12b8a0ba0 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:38:06 +0800 Subject: [PATCH 041/155] style(sns): keep header and actions sticky --- src/pages/SnsPage.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index b30ec19..896e18c 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -44,6 +44,13 @@ justify-content: space-between; margin-bottom: 8px; padding: 0 4px; + position: sticky; + top: 0; + z-index: 20; + background: var(--sns-bg-color); + border-bottom: 1px solid var(--border-color); + padding-top: 10px; + padding-bottom: 10px; .feed-header-main { display: flex; From 204baa52ab3980505d7f958f0e8e1a8f59f3d295 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:43:21 +0800 Subject: [PATCH 042/155] feat(sns): show per-contact post counts in filter panel --- electron/main.ts | 4 +++ electron/preload.ts | 1 + electron/services/snsService.ts | 34 ++++++++++++++++++++++++ src/components/Sns/SnsFilterPanel.tsx | 6 ++++- src/pages/SnsPage.scss | 38 ++++++++++++++++++++------- src/pages/SnsPage.tsx | 17 ++++++++---- src/types/electron.d.ts | 1 + 7 files changed, 86 insertions(+), 15 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 5985639..85a8ffb 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1044,6 +1044,10 @@ function registerIpcHandlers() { return snsService.getSnsUsernames() }) + ipcMain.handle('sns:getUserPostCounts', async () => { + return snsService.getUserPostCounts() + }) + ipcMain.handle('sns:getExportStats', async () => { return snsService.getExportStats() }) diff --git a/electron/preload.ts b/electron/preload.ts index 8723db5..1ac66df 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -294,6 +294,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), + getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'), getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 369a003..d22c853 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -407,6 +407,40 @@ class SnsService { return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } } + async getUserPostCounts(): Promise<{ success: boolean; data?: Record; error?: string }> { + try { + const counts: Record = {} + const primary = await wcdbService.execQuery( + 'sns', + null, + "SELECT user_name AS username, COUNT(1) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> '' GROUP BY user_name" + ) + + let rows = primary.rows + if (!primary.success || !rows) { + const fallback = await wcdbService.execQuery( + 'sns', + null, + "SELECT userName AS username, COUNT(1) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> '' GROUP BY userName" + ) + if (!fallback.success || !fallback.rows) { + return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人条数失败' } + } + rows = fallback.rows + } + + for (const row of rows) { + const usernameRaw = row?.username ?? row?.user_name ?? row?.userName ?? '' + const username = typeof usernameRaw === 'string' ? usernameRaw.trim() : String(usernameRaw || '').trim() + if (!username) continue + counts[username] = this.parseCountValue(row) + } + return { success: true, data: counts } + } catch (e) { + return { success: false, error: String(e) } + } + } + private async getExportStatsFromTableCount(): Promise<{ totalPosts: number; totalFriends: number }> { let totalPosts = 0 let totalFriends = 0 diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 9894689..6c914a0 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -7,6 +7,7 @@ interface Contact { username: string displayName: string avatarUrl?: string + postCount?: number } interface SnsFilterPanelProps { @@ -150,7 +151,10 @@ export const SnsFilterPanel: React.FC = ({ onClick={() => toggleUserSelection(contact.username)} > - {contact.displayName} +
+ {contact.displayName} + {Math.max(0, Number(contact.postCount || 0))} 条 +
))} {filteredContacts.length === 0 && ( diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 896e18c..98c2286 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1055,9 +1055,16 @@ margin-bottom: 0; /* Remove margin to merge */ - .contact-name { - color: var(--primary); - font-weight: 600; + .contact-meta { + .contact-name { + color: var(--primary); + font-weight: 600; + } + + .contact-post-count { + color: var(--primary); + opacity: 0.9; + } } /* If the NEXT item is also selected */ @@ -1080,13 +1087,26 @@ /* Compensate for missing border */ } - .contact-name { + .contact-meta { flex: 1; - font-size: 14px; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + + .contact-name { + font-size: 14px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .contact-post-count { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.2; + } } } } diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 60f79f1..6416700 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -11,6 +11,7 @@ interface Contact { displayName: string avatarUrl?: string type?: 'friend' | 'former_friend' | 'sns_only' + postCount: number } interface SnsOverviewStats { @@ -251,11 +252,16 @@ export default function SnsPage() { const loadContacts = useCallback(async () => { setContactsLoading(true) try { - // 并行获取联系人列表和朋友圈发布者列表 - const [contactsResult, snsResult] = await Promise.all([ + // 并行获取联系人列表、朋友圈发布者列表和每个发布者的动态条数 + const [contactsResult, snsResult, snsCountsResult] = await Promise.all([ window.electronAPI.chat.getContacts(), - window.electronAPI.sns.getSnsUsernames() + window.electronAPI.sns.getSnsUsernames(), + window.electronAPI.sns.getUserPostCounts() ]) + const snsPostCountMap = new Map( + Object.entries(snsCountsResult.success ? (snsCountsResult.data || {}) : {}) + .map(([username, count]) => [username, Math.max(0, Number(count || 0))]) + ) // 以联系人为基础,按 username 去重 const contactMap = new Map() @@ -268,7 +274,8 @@ export default function SnsPage() { username: c.username, displayName: c.displayName, avatarUrl: c.avatarUrl, - type: c.type === 'former_friend' ? 'former_friend' : 'friend' + type: c.type === 'former_friend' ? 'former_friend' : 'friend', + postCount: snsPostCountMap.get(c.username) || 0 }) } } @@ -278,7 +285,7 @@ export default function SnsPage() { if (snsResult.success && snsResult.usernames) { for (const u of snsResult.usernames) { if (!contactMap.has(u)) { - contactMap.set(u, { username: u, displayName: u, type: 'sns_only' }) + contactMap.set(u, { username: u, displayName: u, type: 'sns_only', postCount: snsPostCountMap.get(u) || 0 }) } } } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index f7a1e57..e24c3d5 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -591,6 +591,7 @@ export interface ElectronAPI { onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> + getUserPostCounts: () => Promise<{ success: boolean; data?: Record; error?: string }> getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> From b5507b9f5d98bfd68eac7c649502738071369eab Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:46:04 +0800 Subject: [PATCH 043/155] feat(export): add session detail sidebar entry --- src/pages/ExportPage.scss | 228 ++++++++++++++ src/pages/ExportPage.tsx | 645 +++++++++++++++++++++++++++++++------- 2 files changed, 759 insertions(+), 114 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index e13ecfc..eaad4bc 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -569,6 +569,18 @@ color: var(--text-secondary); } +.session-table-layout { + display: flex; + flex: 1; + min-height: 0; + gap: 10px; + + .table-wrap { + flex: 1; + min-width: 0; + } +} + .table-wrap { overflow: hidden; border: 1px solid var(--border-color); @@ -936,6 +948,35 @@ align-items: flex-end; gap: 4px; + .row-action-main { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .row-detail-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 7px 10px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + white-space: nowrap; + + &:hover { + border-color: var(--text-tertiary); + color: var(--text-primary); + background: var(--bg-hover); + } + + &.active { + border-color: var(--primary); + color: var(--primary); + background: rgba(var(--primary-rgb), 0.12); + } + } + .row-export-btn { border: none; border-radius: 8px; @@ -974,6 +1015,179 @@ } } +.export-session-detail-panel { + width: 300px; + min-width: 300px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--card-bg); + display: flex; + flex-direction: column; + overflow: hidden; + + .detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px; + border-bottom: 1px solid var(--border-color); + + h4 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .close-btn { + border: none; + background: transparent; + color: var(--text-secondary); + width: 26px; + height: 26px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + } + + .detail-loading, + .detail-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--text-secondary); + font-size: 13px; + padding: 14px; + } + + .detail-content { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 14px; + } + + .detail-section { + margin-bottom: 18px; + + &:last-child { + margin-bottom: 0; + } + + .section-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + } + } + + .detail-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + border-bottom: 1px solid var(--border-color); + font-size: 13px; + + &:last-child { + border-bottom: none; + } + + .label { + color: var(--text-secondary); + flex-shrink: 0; + } + + .value { + flex: 1; + text-align: right; + color: var(--text-primary); + word-break: break-all; + user-select: text; + + &.highlight { + color: var(--primary); + font-weight: 600; + } + } + + .copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + + &:hover { + background: var(--bg-secondary); + color: var(--text-primary); + } + } + + &:hover .copy-btn { + opacity: 1; + } + } + + .table-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .detail-table-placeholder { + padding: 10px 12px; + border-radius: 8px; + background: var(--bg-secondary); + font-size: 12px; + color: var(--text-secondary); + } + + .table-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: 8px; + background: var(--bg-secondary); + font-size: 12px; + + .db-name { + color: var(--text-primary); + font-weight: 500; + } + + .table-count { + color: var(--text-secondary); + } + } +} + .table-state { display: flex; align-items: center; @@ -1401,6 +1615,16 @@ .media-check-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); } + + .session-table-layout.with-detail { + flex-direction: column; + } + + .export-session-detail-panel { + width: 100%; + min-width: 0; + max-height: 360px; + } } @media (max-width: 720px) { @@ -1421,4 +1645,8 @@ .date-range-row { grid-template-columns: 1fr; } + + .export-session-detail-panel { + max-height: 320px; + } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 10a11c0..5051bea 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -3,16 +3,22 @@ import { useLocation } from 'react-router-dom' import { TableVirtuoso } from 'react-virtuoso' import { Aperture, + Calendar, + Check, ChevronDown, ChevronRight, CheckSquare, + Copy, + Database, Download, ExternalLink, FolderOpen, + Hash, Image as ImageIcon, Loader2, AlertTriangle, ClipboardList, + MessageSquare, MessageSquareText, Mic, RefreshCw, @@ -169,6 +175,15 @@ const formatAbsoluteDate = (timestamp: number): string => { return `${y}-${m}-${day}` } +const formatYmdDateFromSeconds = (timestamp?: number): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const d = new Date(timestamp * 1000) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + return `${y}-${m}-${day}` +} + const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { if (!timestamp) return '' const diff = Math.max(0, now - timestamp) @@ -270,6 +285,28 @@ interface ContactsLoadIssue { elapsedMs: number } +interface SessionDetail { + wxid: string + displayName: string + remark?: string + nickName?: string + alias?: string + avatarUrl?: string + messageCount: number + voiceMessages?: number + imageMessages?: number + videoMessages?: number + emojiMessages?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number + firstMessageTime?: number + latestMessageTime?: number + messageTables: { dbName: string; tableName: string; count: number }[] +} + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -536,6 +573,11 @@ function ExportPage() { total: 0, running: false }) + const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false) + const [sessionDetail, setSessionDetail] = useState(null) + const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false) + const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false) + const [copiedDetailField, setCopiedDetailField] = useState(null) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') @@ -598,6 +640,7 @@ function ExportPage() { const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const contactsAvatarCacheRef = useRef>({}) const contactsListRef = useRef(null) + const detailRequestSeqRef = useRef(0) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -1913,6 +1956,163 @@ function ExportPage() { return map }, [sessions]) + const contactByUsername = useMemo(() => { + const map = new Map() + for (const contact of contactsList) { + map.set(contact.username, contact) + } + return map + }, [contactsList]) + + const loadSessionDetail = useCallback(async (sessionId: string) => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return + + const requestSeq = ++detailRequestSeqRef.current + const mappedSession = sessionRowByUsername.get(normalizedSessionId) + const mappedContact = contactByUsername.get(normalizedSessionId) + const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0 + ? Math.floor(mappedSession.messageCountHint) + : undefined + + setCopiedDetailField(null) + setSessionDetail((prev) => { + const sameSession = prev?.wxid === normalizedSessionId + return { + wxid: normalizedSessionId, + displayName: mappedSession?.displayName || mappedContact?.displayName || prev?.displayName || normalizedSessionId, + remark: sameSession ? prev?.remark : mappedContact?.remark, + nickName: sameSession ? prev?.nickName : mappedContact?.nickname, + alias: sameSession ? prev?.alias : undefined, + avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined), + messageCount: hintedCount ?? (sameSession ? prev.messageCount : Number.NaN), + voiceMessages: sameSession ? prev?.voiceMessages : undefined, + imageMessages: sameSession ? prev?.imageMessages : undefined, + videoMessages: sameSession ? prev?.videoMessages : undefined, + emojiMessages: sameSession ? prev?.emojiMessages : undefined, + privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined, + groupMemberCount: sameSession ? prev?.groupMemberCount : undefined, + groupMyMessages: sameSession ? prev?.groupMyMessages : undefined, + groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined, + groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined, + firstMessageTime: sameSession ? prev?.firstMessageTime : undefined, + latestMessageTime: sameSession ? prev?.latestMessageTime : undefined, + messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : [] + } + }) + setIsLoadingSessionDetail(true) + setIsLoadingSessionDetailExtra(true) + + try { + const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) + if (requestSeq !== detailRequestSeqRef.current) return + if (result.success && result.detail) { + setSessionDetail((prev) => ({ + wxid: normalizedSessionId, + displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId, + remark: result.detail!.remark ?? prev?.remark, + nickName: result.detail!.nickName ?? prev?.nickName, + alias: result.detail!.alias ?? prev?.alias, + avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl, + messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN, + voiceMessages: prev?.voiceMessages, + imageMessages: prev?.imageMessages, + videoMessages: prev?.videoMessages, + emojiMessages: prev?.emojiMessages, + privateMutualGroups: prev?.privateMutualGroups, + groupMemberCount: prev?.groupMemberCount, + groupMyMessages: prev?.groupMyMessages, + groupActiveSpeakers: prev?.groupActiveSpeakers, + groupMutualFriends: prev?.groupMutualFriends, + firstMessageTime: prev?.firstMessageTime, + latestMessageTime: prev?.latestMessageTime, + messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : [] + })) + } + } catch (error) { + console.error('导出页加载会话详情失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingSessionDetail(false) + } + } + + try { + const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ + window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), + window.electronAPI.chat.getExportSessionStats([normalizedSessionId]) + ]) + + if (requestSeq !== detailRequestSeqRef.current) return + + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + + let next = { ...prev } + if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) { + next = { + ...next, + firstMessageTime: extraResultSettled.value.detail.firstMessageTime, + latestMessageTime: extraResultSettled.value.detail.latestMessageTime, + messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : [] + } + } + + if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) { + const metric = statsResultSettled.value.data[normalizedSessionId] + if (metric) { + next = { + ...next, + messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount, + voiceMessages: metric.voiceMessages, + imageMessages: metric.imageMessages, + videoMessages: metric.videoMessages, + emojiMessages: metric.emojiMessages, + privateMutualGroups: metric.privateMutualGroups, + groupMemberCount: metric.groupMemberCount, + groupMyMessages: metric.groupMyMessages, + groupActiveSpeakers: metric.groupActiveSpeakers, + groupMutualFriends: metric.groupMutualFriends, + firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime, + latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime + } + } + } + + return next + }) + } catch (error) { + console.error('导出页加载会话详情补充统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsLoadingSessionDetailExtra(false) + } + } + }, [contactByUsername, sessionRowByUsername]) + + const openSessionDetail = useCallback((sessionId: string) => { + if (!sessionId) return + setShowSessionDetailPanel(true) + void loadSessionDetail(sessionId) + }, [loadSessionDetail]) + + const handleCopyDetailField = useCallback(async (text: string, field: string) => { + try { + await navigator.clipboard.writeText(text) + setCopiedDetailField(field) + setTimeout(() => setCopiedDetailField(null), 1500) + } catch { + const textarea = document.createElement('textarea') + textarea.value = text + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + setCopiedDetailField(field) + setTimeout(() => setCopiedDetailField(null), 1500) + } + }, []) + const contactsUpdatedAtLabel = useMemo(() => { if (!contactsUpdatedAt) return '' return new Date(contactsUpdatedAt).toLocaleString() @@ -2044,12 +2244,21 @@ function ExportPage() { } const renderActionCell = (session: SessionRow) => { + const isDetailActive = showSessionDetailPanel && sessionDetail?.wxid === session.username if (!session.hasSession) { return (
- +
+ + +
) } @@ -2060,18 +2269,26 @@ function ExportPage() { return (
- +
+ + +
{recent && {recent}}
) @@ -2364,110 +2581,310 @@ function ExportPage() {
)} -
- {contactsList.length === 0 && contactsLoadIssue ? ( -
-
-
- - {contactsLoadIssue.title} +
+
+ {contactsList.length === 0 && contactsLoadIssue ? ( +
+
+
+ + {contactsLoadIssue.title} +
+

{contactsLoadIssue.message}

+

{contactsLoadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showContactsDiagnostics && ( +
{contactsDiagnosticsText}
+ )}
-

{contactsLoadIssue.message}

-

{contactsLoadIssue.reason}

-
    -
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • -
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • -
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • -
-
- - - -
- {showContactsDiagnostics && ( -
{contactsDiagnosticsText}
- )}
-
- ) : isContactsListLoading && contactsList.length === 0 ? ( -
- - 联系人加载中... -
- ) : filteredContacts.length === 0 ? ( -
- 暂无联系人 -
- ) : ( -
-
- {visibleContacts.map((contact, idx) => { - const absoluteIndex = contactStartIndex + idx - const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT - const matchedSession = sessionRowByUsername.get(contact.username) - const canExport = Boolean(matchedSession?.hasSession) - const isRunning = canExport && runningSessionIds.has(contact.username) - const isQueued = canExport && queuedSessionIds.has(contact.username) - const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' - return ( -
-
-
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} - )} -
-
-
{contact.displayName}
-
{contact.username}
-
-
- {getContactTypeName(contact.type)} -
-
- - {recent && {recent}} + ) : isContactsListLoading && contactsList.length === 0 ? ( +
+ + 联系人加载中... +
+ ) : filteredContacts.length === 0 ? ( +
+ 暂无联系人 +
+ ) : ( +
+
+ {visibleContacts.map((contact, idx) => { + const absoluteIndex = contactStartIndex + idx + const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT + const matchedSession = sessionRowByUsername.get(contact.username) + const canExport = Boolean(matchedSession?.hasSession) + const isRunning = canExport && runningSessionIds.has(contact.username) + const isQueued = canExport && queuedSessionIds.has(contact.username) + const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' + return ( +
+
+
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+
{contact.username}
+
+
+ {getContactTypeName(contact.type)} +
+
+
+ + +
+ {recent && {recent}} +
-
- ) - })} + ) + })} +
-
+ )} +
+ + {showSessionDetailPanel && ( + )}
From 89f0758fbb3c10bbb291636ff97c481d064d5b5b Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:49:16 +0800 Subject: [PATCH 044/155] fix(sns): keep header area always visible --- src/pages/SnsPage.scss | 17 +++-- src/pages/SnsPage.tsx | 140 +++++++++++++++++++++-------------------- 2 files changed, 83 insertions(+), 74 deletions(-) diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 98c2286..b2e77b4 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -23,7 +23,7 @@ ========================================= */ .sns-main-viewport { flex: 1; - overflow-y: scroll; + overflow: hidden; position: relative; display: flex; justify-content: center; @@ -35,7 +35,9 @@ padding: 20px 24px 60px 24px; display: flex; flex-direction: column; - gap: 24px; + gap: 0; + min-height: 0; + height: 100%; } .feed-header { @@ -44,9 +46,7 @@ justify-content: space-between; margin-bottom: 8px; padding: 0 4px; - position: sticky; - top: 0; - z-index: 20; + z-index: 2; background: var(--sns-bg-color); border-bottom: 1px solid var(--border-color); padding-top: 10px; @@ -109,6 +109,13 @@ } } +.sns-posts-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-top: 16px; +} + .posts-list { display: flex; flex-direction: column; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 6416700..dc1d86f 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -360,7 +360,7 @@ export default function SnsPage() { return (
-
+
@@ -417,78 +417,80 @@ export default function SnsPage() {
- {loadingNewer && ( -
- - 正在检查更新的动态... -
- )} - - {!loadingNewer && hasNewer && ( -
loadPosts({ direction: 'newer' })}> - 有新动态,点击查看 -
- )} - -
- {posts.map(post => ( - { - if (isVideo) { - void window.electronAPI.window.openVideoPlayerWindow(src) - } else { - void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) - } - }} - onDebug={(p) => setDebugPost(p)} - onDelete={(postId) => { - setPosts(prev => prev.filter(p => p.id !== postId)) - loadOverviewStats() - }} - /> - ))} -
- - {loading && posts.length === 0 && ( -
-
-
- 正在加载朋友圈... +
+ {loadingNewer && ( +
+ + 正在检查更新的动态...
-
- )} + )} - {loading && posts.length > 0 && ( -
- - 正在加载更多... -
- )} + {!loadingNewer && hasNewer && ( +
loadPosts({ direction: 'newer' })}> + 有新动态,点击查看 +
+ )} - {!hasMore && posts.length > 0 && ( -
{ - selectedUsernames.length === 1 && - contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend' - ? '在时间的长河里刻舟求剑' - : '或许过往已无可溯洄,但好在还有可以与你相遇的明天' - }
- )} - - {!loading && posts.length === 0 && ( -
-
-

未找到相关动态

- {(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && ( - - )} +
+ {posts.map(post => ( + { + if (isVideo) { + void window.electronAPI.window.openVideoPlayerWindow(src) + } else { + void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) + } + }} + onDebug={(p) => setDebugPost(p)} + onDelete={(postId) => { + setPosts(prev => prev.filter(p => p.id !== postId)) + loadOverviewStats() + }} + /> + ))}
- )} + + {loading && posts.length === 0 && ( +
+
+
+ 正在加载朋友圈... +
+
+ )} + + {loading && posts.length > 0 && ( +
+ + 正在加载更多... +
+ )} + + {!hasMore && posts.length > 0 && ( +
{ + selectedUsernames.length === 1 && + contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend' + ? '在时间的长河里刻舟求剑' + : '或许过往已无可溯洄,但好在还有可以与你相遇的明天' + }
+ )} + + {!loading && posts.length === 0 && ( +
+
+

未找到相关动态

+ {(selectedUsernames.length > 0 || searchKeyword || jumpTargetDate) && ( + + )} +
+ )} +
From 1347136b54ad733643b6d06df7cb9a053e3dd0f8 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:52:54 +0800 Subject: [PATCH 045/155] feat(export): use window-level detail drawer overlay --- electron/services/snsService.ts | 10 +++++++--- src/pages/ExportPage.scss | 31 +++++++++++++++---------------- src/pages/ExportPage.tsx | 28 +++++++++++++++++++++++++--- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index d22c853..e6e144c 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -417,13 +417,13 @@ class SnsService { ) let rows = primary.rows - if (!primary.success || !rows) { + if (!primary.success || !rows || rows.length === 0) { const fallback = await wcdbService.execQuery( 'sns', null, "SELECT userName AS username, COUNT(1) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> '' GROUP BY userName" ) - if (!fallback.success || !fallback.rows) { + if (!fallback.success || !fallback.rows || fallback.rows.length === 0) { return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人条数失败' } } rows = fallback.rows @@ -433,7 +433,11 @@ class SnsService { const usernameRaw = row?.username ?? row?.user_name ?? row?.userName ?? '' const username = typeof usernameRaw === 'string' ? usernameRaw.trim() : String(usernameRaw || '').trim() if (!username) continue - counts[username] = this.parseCountValue(row) + const countRaw = row?.total ?? row?.count ?? row?.cnt + const parsedCount = Number(countRaw) + counts[username] = Number.isFinite(parsedCount) && parsedCount > 0 + ? Math.floor(parsedCount) + : this.parseCountValue(row) } return { success: true, data: counts } } catch (e) { diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index eaad4bc..08a8183 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -573,7 +573,6 @@ display: flex; flex: 1; min-height: 0; - gap: 10px; .table-wrap { flex: 1; @@ -1015,15 +1014,25 @@ } } +.export-session-detail-overlay { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + justify-content: flex-end; + background: rgba(15, 23, 42, 0.24); +} + .export-session-detail-panel { - width: 300px; - min-width: 300px; - border: 1px solid var(--border-color); - border-radius: 10px; + width: min(360px, calc(100vw - 16px)); + height: 100vh; + border-left: 1px solid var(--border-color); + border-radius: 0; background: var(--card-bg); display: flex; flex-direction: column; overflow: hidden; + box-shadow: -12px 0 30px rgba(0, 0, 0, 0.18); .detail-header { display: flex; @@ -1615,16 +1624,6 @@ .media-check-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); } - - .session-table-layout.with-detail { - flex-direction: column; - } - - .export-session-detail-panel { - width: 100%; - min-width: 0; - max-height: 360px; - } } @media (max-width: 720px) { @@ -1647,6 +1646,6 @@ } .export-session-detail-panel { - max-height: 320px; + width: calc(100vw - 12px); } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 5051bea..e783995 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -2096,6 +2096,17 @@ function ExportPage() { void loadSessionDetail(sessionId) }, [loadSessionDetail]) + useEffect(() => { + if (!showSessionDetailPanel) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowSessionDetailPanel(false) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [showSessionDetailPanel]) + const handleCopyDetailField = useCallback(async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) @@ -2581,7 +2592,7 @@ function ExportPage() {
)} -
+
{contactsList.length === 0 && contactsLoadIssue ? (
@@ -2698,7 +2709,17 @@ function ExportPage() {
{showSessionDetailPanel && ( - +
)}
From f47eba5764c667ff0e6cb35a2273e12fdb061d1a Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:59:25 +0800 Subject: [PATCH 046/155] fix(export): avoid overlap with window close controls --- src/pages/ExportPage.scss | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 08a8183..66bcc4b 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1016,7 +1016,10 @@ .export-session-detail-overlay { position: fixed; - inset: 0; + top: 40px; + right: 0; + bottom: 0; + left: 0; z-index: 1100; display: flex; justify-content: flex-end; @@ -1025,7 +1028,7 @@ .export-session-detail-panel { width: min(360px, calc(100vw - 16px)); - height: 100vh; + height: calc(100vh - 40px); border-left: 1px solid var(--border-color); border-radius: 0; background: var(--card-bg); From b8ede4cfd0a426d6a0d46f1ba542fac33e941f0e Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 14:02:52 +0800 Subject: [PATCH 047/155] fix(export): use solid background for detail drawer --- src/pages/ExportPage.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 66bcc4b..f546c82 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1031,7 +1031,7 @@ height: calc(100vh - 40px); border-left: 1px solid var(--border-color); border-radius: 0; - background: var(--card-bg); + background: var(--bg-secondary-solid, #ffffff); display: flex; flex-direction: column; overflow: hidden; From 21a97b887118c085ae51117af47e4be01523e4c5 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 14:09:07 +0800 Subject: [PATCH 048/155] feat(sns): cache page data and show count loading state --- src/components/Sns/SnsFilterPanel.tsx | 20 ++- src/pages/SnsPage.scss | 8 ++ src/pages/SnsPage.tsx | 176 +++++++++++++++++++++++--- src/services/config.ts | 78 ++++++++++++ 4 files changed, 258 insertions(+), 24 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 6c914a0..f514f94 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -8,6 +8,7 @@ interface Contact { displayName: string avatarUrl?: string postCount?: number + postCountStatus?: 'loading' | 'ready' | 'error' } interface SnsFilterPanelProps { @@ -58,6 +59,16 @@ export const SnsFilterPanel: React.FC = ({ setJumpTargetDate(undefined) } + const getPostCountDisplay = (contact: Contact) => { + if (contact.postCountStatus === 'error') { + return { text: '统计失败', className: 'is-error' } + } + if (contact.postCountStatus !== 'ready') { + return { text: '统计中', className: 'is-loading' } + } + return { text: `${Math.max(0, Number(contact.postCount || 0))} 条`, className: '' } + } + return (