From 06d6f15e38adc892e0c76c0ec99fc0749438e487 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 14:40:08 +0800 Subject: [PATCH] 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 {