feat(export): redesign export board workflow

This commit is contained in:
tisonhuang committed 2026-03-04 21:16:39 +08:00
1 parent d3adae42fe
commit 06d6f15e38
3 files changed
+2118 -2151

No files matched your search

+833 -1123
View File
@@ -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 中定义
+1236 -1028
View File
@@ -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<ContentType, string> = {
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<ChatSession[]>([])
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true)
const [sessions, setSessions] = useState<SessionRow[]>([])
const [contactMap, setContactMap] = useState<Record<string, ContactInfo>>({})
const [groupMemberCountMap, setGroupMemberCountMap] = useState<Record<string, number>>({})
const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({})
const [searchKeyword, setSearchKeyword] = useState('')
const [exportFolder, setExportFolder] = useState<string>('')
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 })
const [exportResult, setExportResult] = useState<ExportResult | null>(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<SessionLayout>('shared')
const exportStartTime = useRef<number>(0)
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
const preselectAppliedRef = useRef(false)
const statsRequestIdRef = useRef(0)
const [activeTab, setActiveTab] = useState<ConversationTab>('private')
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(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<CurrentUserProfile>({ 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<configService.ExportWriteLayout>('A')
const [showWriteLayoutSelect, setShowWriteLayoutSelect] = useState(false)
const [options, setOptions] = useState<ExportOptions>({
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<ExportDialogState>({
open: false,
scope: 'single',
sessionIds: [],
sessionNames: [],
title: ''
})
const loadSessions = useCallback(async () => {
setIsLoading(true)
const [tasks, setTasks] = useState<ExportTask[]>([])
const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({})
const [lastExportByContent, setLastExportByContent] = useState<Record<string, number>>({})
const [nowTick, setNowTick] = useState(Date.now())
const progressUnsubscribeRef = useRef<(() => void) | null>(null)
const runningTaskIdRef = useRef<string | null>(null)
const tasksRef = useRef<ExportTask[]>([])
const loadingMetricsRef = useRef<Set<string>>(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<Record<string, ContactInfo>>((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<string, number> = {}
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<string, SessionMetrics> = {}
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<ExportDialogState, 'open'>) => {
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<string>()
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<string>()
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 (
<div className="session-cell">
<div className="session-avatar">
{session.avatarUrl ? <img src={session.avatarUrl} alt="" /> : <span>{getAvatarLetter(session.displayName || session.username)}</span>}
</div>
<div className="session-meta">
<div className="session-name">{session.displayName || session.username}</div>
<div className="session-id">{session.wechatId || session.username}</div>
</div>
</div>
)
}
const renderActionCell = (session: SessionRow) => {
const isRunning = runningSessionIds.has(session.username)
const isQueued = queuedSessionIds.has(session.username)
const recent = formatRecentExportTime(lastExportBySession[session.username], nowTick)
return (
<div className="row-action-cell">
<button
className={`row-export-btn ${isRunning ? 'running' : ''}`}
disabled={isRunning}
onClick={() => openSingleExport(session)}
>
{isRunning ? (
<>
<Loader2 size={14} className="spin" />
导出中
</>
) : isQueued ? '排队中' : '导出'}
</button>
<span className="row-export-time">{recent}</span>
</div>
)
}
const renderTableHeader = () => {
if (activeTab === 'private') {
return (
<tr>
<th className="sticky-col">选择</th>
<th>会话名(头像/昵称/微信号)</th>
<th>总消息</th>
<th>语音</th>
<th>图片</th>
<th>视频</th>
<th>表情包</th>
<th>共同群聊数</th>
<th>最早时间</th>
<th>最新时间</th>
<th className="sticky-right">操作</th>
</tr>
)
}
if (activeTab === 'group') {
return (
<tr>
<th className="sticky-col">选择</th>
<th>会话名(群头像/群名称/群ID)</th>
<th>总消息</th>
<th>语音</th>
<th>图片</th>
<th>视频</th>
<th>表情包</th>
<th>我发的消息数</th>
<th>群人数</th>
<th>群发言人数</th>
<th>群共同好友数</th>
<th>最早时间</th>
<th>最新时间</th>
<th className="sticky-right">操作</th>
</tr>
)
}
return (
<tr>
<th className="sticky-col">选择</th>
<th>会话名(头像/名称/微信号)</th>
<th>总消息</th>
<th>语音</th>
<th>图片</th>
<th>视频</th>
<th>表情包</th>
<th>最早时间</th>
<th>最新时间</th>
<th className="sticky-right">操作</th>
</tr>
)
}
const renderRow = (session: SessionRow) => {
const metrics = sessionMetrics[session.username] || {}
const checked = selectedSessions.has(session.username)
return (
<tr key={session.username} className={checked ? 'selected-row' : ''}>
<td className="sticky-col">
<button
className={`select-icon-btn ${checked ? 'checked' : ''}`}
onClick={() => toggleSelectSession(session.username)}
title={checked ? '取消选择' : '选择会话'}
>
{checked ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</td>
<td>{renderSessionName(session)}</td>
<td>{valueOrDash(metrics.totalMessages)}</td>
<td>{valueOrDash(metrics.voiceMessages)}</td>
<td>{valueOrDash(metrics.imageMessages)}</td>
<td>{valueOrDash(metrics.videoMessages)}</td>
<td>{valueOrDash(metrics.emojiMessages)}</td>
{activeTab === 'private' && (
<>
<td>{valueOrDash(metrics.privateMutualGroups)}</td>
<td>{timestampOrDash(metrics.firstTimestamp)}</td>
<td>{timestampOrDash(metrics.lastTimestamp)}</td>
</>
)}
{activeTab === 'group' && (
<>
<td>{valueOrDash(metrics.groupMyMessages)}</td>
<td>{valueOrDash(metrics.groupMemberCount)}</td>
<td>{valueOrDash(metrics.groupActiveSpeakers)}</td>
<td>{valueOrDash(metrics.groupMutualFriends)}</td>
<td>{timestampOrDash(metrics.firstTimestamp)}</td>
<td>{timestampOrDash(metrics.lastTimestamp)}</td>
</>
)}
{activeTab === 'official' && (
<>
<td>{timestampOrDash(metrics.firstTimestamp)}</td>
<td>{timestampOrDash(metrics.lastTimestamp)}</td>
</>
)}
<td className="sticky-right">{renderActionCell(session)}</td>
</tr>
)
}
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 (
<div className="export-page">
<div className="session-panel">
<div className="panel-header">
<h2>选择会话</h2>
<button className="icon-btn" onClick={loadSessions} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
</div>
<div className="search-bar">
<Search size={16} />
<input
type="text"
placeholder="搜索联系人或群组..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
{searchKeyword && (
<button className="clear-btn" onClick={() => setSearchKeyword('')}>
<X size={14} />
</button>
)}
</div>
<div className="select-actions">
<button className="select-all-btn" onClick={toggleSelectAll}>
{selectedSessions.size === filteredSessions.length && filteredSessions.length > 0 ? '取消全选' : '全选'}
</button>
<span className="selected-count">已选 {selectedSessions.size} 个</span>
</div>
{isLoading ? (
<div className="loading-state">
<Loader2 size={24} className="spin" />
<span>加载中...</span>
<div className="export-board-page">
<div className="export-top-panel">
<div className="current-user-box">
<div className="avatar-wrap">
{currentUser.avatarUrl ? <img src={currentUser.avatarUrl} alt="" /> : <span>{getAvatarLetter(currentUser.displayName)}</span>}
</div>
) : filteredSessions.length === 0 ? (
<div className="empty-state">
<span>暂无会话</span>
<div className="user-meta">
<div className="user-name">{currentUser.displayName}</div>
<div className="user-wxid">{currentUser.wxid || 'wxid 未识别'}</div>
</div>
) : (
<div className="export-session-list">
{filteredSessions.map(session => (
<div
key={session.username}
className={`export-session-item ${selectedSessions.has(session.username) ? 'selected' : ''}`}
onClick={() => toggleSession(session.username)}
</div>
<div className="global-export-controls">
<div className="path-control">
<span className="control-label">导出位置</span>
<div className="path-value" title={exportFolder}>{exportFolder || '未设置'}</div>
<div className="path-actions">
<button className="secondary-btn" onClick={() => exportFolder && void window.electronAPI.shell.openPath(exportFolder)}>
<ExternalLink size={14} /> 打开目录
</button>
<button
className="secondary-btn"
onClick={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)
}
}}
>
<div className="check-box">
{selectedSessions.has(session.username) && <Check size={14} />}
<FolderOpen size={14} /> 更换目录
</button>
</div>
</div>
<div className="write-layout-control">
<span className="control-label">写入格式</span>
<button className="layout-trigger" onClick={() => setShowWriteLayoutSelect(prev => !prev)}>
{writeLayoutLabel}
</button>
{showWriteLayoutSelect && (
<div className="layout-dropdown">
{writeLayoutOptions.map(option => (
<button
key={option.value}
className={`layout-option ${writeLayout === option.value ? 'active' : ''}`}
onClick={async () => {
setWriteLayout(option.value)
setShowWriteLayoutSelect(false)
await configService.setExportWriteLayout(option.value)
}}
>
<span className="layout-option-label">{option.label}</span>
<span className="layout-option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="content-card-grid">
{contentCards.map(card => {
const Icon = card.icon
return (
<div key={card.type} className="content-card">
<div className="card-header">
<div className="card-title"><Icon size={16} /> {card.label}</div>
</div>
<div className="card-stats">
<div className="stat-item">
<span>总会话数</span>
<strong>{card.total}</strong>
</div>
<div className="export-avatar">
{session.avatarUrl ? (
<img src={session.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(session.displayName || session.username)}</span>
<div className="stat-item">
<span>已导出会话数</span>
<strong>{card.exported}</strong>
</div>
</div>
<button className="card-export-btn" onClick={() => openContentExport(card.type)}>导出</button>
</div>
)
})}
</div>
<div className="task-center">
<div className="section-title">任务中心</div>
{tasks.length === 0 ? (
<div className="task-empty">暂无任务。点击会话导出或卡片导出后会在这里创建任务。</div>
) : (
<div className="task-list">
{tasks.map(task => (
<div key={task.id} className={`task-card ${task.status}`}>
<div className="task-main">
<div className="task-title">{task.title}</div>
<div className="task-meta">
<span className={`task-status ${task.status}`}>{task.status === 'queued' ? '排队中' : task.status === 'running' ? '进行中' : task.status === 'success' ? '已完成' : '失败'}</span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
{task.status === 'running' && (
<>
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress.total > 0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }}
/>
</div>
<div className="task-progress-text">
{task.progress.current} / {task.progress.total || task.payload.sessionIds.length}
{task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
</div>
</>
)}
{task.status === 'error' && <div className="task-error">{task.error || '任务失败'}</div>}
</div>
<div className="export-session-info">
<div className="export-session-name">{session.displayName || session.username}</div>
<div className="export-session-summary">{session.summary || '暂无消息'}</div>
<div className="task-actions">
<button className="secondary-btn" onClick={() => exportFolder && void window.electronAPI.shell.openPath(exportFolder)}>
<FolderOpen size={14} /> 目录
</button>
</div>
</div>
))}
@@ -614,591 +1207,206 @@ function ExportPage() {
)}
</div>
<div className="settings-panel">
<div className="panel-header">
<h2>导出设置</h2>
</div>
<div className="settings-content">
<div className="setting-section">
<h3>导出格式</h3>
<div className="format-options">
{formatOptions.map(fmt => (
<div
key={fmt.value}
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])}
>
<fmt.icon size={24} />
<span className="format-label">{fmt.label}</span>
<span className="format-desc">{fmt.desc}</span>
</div>
))}
</div>
<div className="session-table-section">
<div className="table-toolbar">
<div className="table-tabs" role="tablist" aria-label="会话类型">
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}>私聊</button>
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>群聊</button>
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}>公众号</button>
</div>
<div className="setting-section">
<h3>时间范围</h3>
<p className="setting-subtitle">选择要导出的消息时间区间</p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title">导出全部时间</span>
<span className="media-switch-desc">关闭此项以选择特定的起止日期</span>
</div>
<div className="toolbar-actions">
<div className="search-input-wrap">
<Search size={14} />
<input
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
placeholder={`搜索${activeTabLabel}会话...`}
/>
{searchKeyword && (
<button className="clear-search" onClick={() => setSearchKeyword('')}>
<X size={12} />
</button>
)}
</div>
<button className="secondary-btn" onClick={toggleSelectAllVisible}>
{visibleSelectedCount > 0 && visibleSelectedCount === visibleSessions.length ? '取消全选' : '全选当前'}
</button>
{selectedCount > 0 && (
<div className="selected-batch-actions">
<span>已选中 {selectedCount} 个会话</span>
<button className="primary-btn" onClick={openBatchExport}>
<Download size={14} /> 导出
</button>
<button className="secondary-btn" onClick={clearSelection}>清空</button>
</div>
)}
</div>
</div>
<div className="table-wrap">
<table className="session-table">
<thead>{renderTableHeader()}</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={activeTab === 'group' ? 14 : activeTab === 'private' ? 11 : 10}>
<div className="table-state"><Loader2 size={16} className="spin" />加载中...</div>
</td>
</tr>
) : visibleSessions.length === 0 ? (
<tr>
<td colSpan={activeTab === 'group' ? 14 : activeTab === 'private' ? 11 : 10}>
<div className="table-state">暂无会话</div>
</td>
</tr>
) : (
visibleSessions.map(renderRow)
)}
</tbody>
</table>
</div>
</div>
{exportDialog.open && (
<div className="export-dialog-overlay" onClick={closeExportDialog}>
<div className="export-dialog" onClick={(event) => event.stopPropagation()}>
<div className="dialog-header">
<h3>{exportDialog.title}</h3>
<button className="close-icon-btn" onClick={closeExportDialog}><X size={16} /></button>
</div>
<div className="dialog-section">
<h4>导出范围</h4>
<div className="scope-tag-row">
<span className="scope-tag">{exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' ? '多会话' : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']})`}</span>
<span className="scope-count">共 {exportDialog.sessionIds.length} 个会话</span>
</div>
<div className="scope-list">
{exportDialog.sessionNames.slice(0, 20).map(name => (
<span key={name} className="scope-item">{name}</span>
))}
{exportDialog.sessionNames.length > 20 && <span className="scope-item">... 还有 {exportDialog.sessionNames.length - 20} 个</span>}
</div>
</div>
<div className="dialog-section">
<h4>对话文本导出格式选择</h4>
<div className="format-grid">
{formatOptions.map(option => (
<button
key={option.value}
className={`format-card ${options.format === option.value ? 'active' : ''}`}
onClick={() => setOptions(prev => ({ ...prev, format: option.value }))}
>
<div className="format-label">{option.label}</div>
<div className="format-desc">{option.desc}</div>
</button>
))}
</div>
</div>
<div className="dialog-section">
<h4>时间范围</h4>
<div className="switch-row">
<span>导出全部时间</span>
<label className="switch">
<input
type="checkbox"
checked={options.useAllTime}
onChange={e => setOptions({ ...options, useAllTime: e.target.checked })}
onChange={(event) => setOptions(prev => ({ ...prev, useAllTime: event.target.checked }))}
/>
<span className="switch-slider"></span>
</label>
</div>
{!options.useAllTime && options.dateRange && (
<>
<div className="media-option-divider"></div>
<div className="time-range-picker-item" onClick={() => setShowDatePicker(true)}>
<div className="time-picker-info">
<Calendar size={16} />
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
</div>
<ChevronDown size={14} />
</div>
</>
<div className="date-range-row">
<label>
开始
<input
type="date"
value={formatDateInputValue(options.dateRange.start)}
onChange={(event) => {
const start = parseDateInput(event.target.value, false)
setOptions(prev => ({
...prev,
dateRange: prev.dateRange ? {
start,
end: prev.dateRange.end < start ? parseDateInput(event.target.value, true) : prev.dateRange.end
} : { start, end: new Date() }
}))
}}
/>
</label>
<label>
结束
<input
type="date"
value={formatDateInputValue(options.dateRange.end)}
onChange={(event) => {
const end = parseDateInput(event.target.value, true)
setOptions(prev => ({
...prev,
dateRange: prev.dateRange ? {
start: prev.dateRange.start > end ? parseDateInput(event.target.value, false) : prev.dateRange.start,
end
} : { start: new Date(), end }
}))
}}
/>
</label>
</div>
)}
</div>
</div>
{/* 发送者名称显示偏好 */}
{(options.format === 'html' || options.format === 'json' || options.format === 'txt') && (
<div className="setting-section">
<h3>发送者名称显示</h3>
<p className="setting-subtitle">选择导出时优先显示的名称</p>
<div className="select-field" ref={displayNameDropdownRef}>
<button
type="button"
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
onClick={() => setShowDisplayNameSelect(!showDisplayNameSelect)}
>
<span className="select-value">{displayNameLabel}</span>
<ChevronDown size={16} />
</button>
{showDisplayNameSelect && (
<div className="select-dropdown">
{displayNameOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${options.displayNamePreference === option.value ? 'active' : ''}`}
onClick={() => {
setOptions({
...options,
displayNamePreference: option.value as ExportOptions['displayNamePreference']
})
setShowDisplayNameSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
<div className="setting-section">
<h3>媒体文件</h3>
<p className="setting-subtitle">导出图片/语音/视频/表情并在记录内写入相对路径</p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title">导出媒体文件</span>
<span className="media-switch-desc">会创建子文件夹并保存媒体资源</span>
</div>
<div className="dialog-section">
<h4>媒体与头像</h4>
<div className="switch-row">
<span>导出媒体文件</span>
<label className="switch">
<input
type="checkbox"
checked={options.exportMedia}
onChange={e => setOptions({ ...options, exportMedia: e.target.checked })}
onChange={(event) => setOptions(prev => ({ ...prev, exportMedia: event.target.checked }))}
/>
<span className="switch-slider"></span>
</label>
</div>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title">图片</span>
<span className="media-checkbox-desc">已有文件直接复制,缺失时尝试解密</span>
</div>
<input
type="checkbox"
checked={options.exportImages}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportImages: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title">语音</span>
<span className="media-checkbox-desc">缺失时会解码生成 MP3</span>
</div>
<input
type="checkbox"
checked={options.exportVoices}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportVoices: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className="media-checkbox-row">
<div className="media-checkbox-info">
<span className="media-checkbox-title">语音转文字</span>
<span className="media-checkbox-desc">将语音消息转换为文字导出</span>
</div>
<input
type="checkbox"
checked={options.exportVoiceAsText}
onChange={e => setOptions({ ...options, exportVoiceAsText: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title">视频</span>
<span className="media-checkbox-desc">直接复制视频文件到导出目录</span>
</div>
<input
type="checkbox"
checked={options.exportVideos}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportVideos: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title">表情</span>
<span className="media-checkbox-desc">本地无缓存时尝试下载</span>
</div>
<input
type="checkbox"
checked={options.exportEmojis}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportEmojis: e.target.checked })}
/>
</label>
</div>
</div>
<div className="setting-section">
<h3>头像</h3>
<p className="setting-subtitle">可选导出头像索引,关闭则不下载头像</p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title">导出头像</span>
<span className="media-switch-desc">用于展示发送者头像,可能会读取或下载头像文件</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={options.exportAvatars}
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
/>
<span className="switch-slider"></span>
</label>
<div className="media-check-grid">
<label><input type="checkbox" checked={options.exportImages} disabled={!options.exportMedia} onChange={event => setOptions(prev => ({ ...prev, exportImages: event.target.checked }))} /> 图片</label>
<label><input type="checkbox" checked={options.exportVoices} disabled={!options.exportMedia} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> 语音</label>
<label><input type="checkbox" checked={options.exportVideos} disabled={!options.exportMedia} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> 视频</label>
<label><input type="checkbox" checked={options.exportEmojis} disabled={!options.exportMedia} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> 表情包</label>
<label><input type="checkbox" checked={options.exportVoiceAsText} onChange={event => setOptions(prev => ({ ...prev, exportVoiceAsText: event.target.checked }))} /> 语音转文字</label>
<label><input type="checkbox" checked={options.exportAvatars} onChange={event => setOptions(prev => ({ ...prev, exportAvatars: event.target.checked }))} /> 导出头像</label>
</div>
</div>
</div>
<div className="setting-section">
<h3>导出位置</h3>
<div className="export-path-display">
<FolderOpen size={16} />
<span>{exportFolder || '未设置'}</span>
</div>
<button
className="select-folder-btn"
onClick={async () => {
try {
const result = await window.electronAPI.dialog.openFile({
title: '选择导出目录',
properties: ['openDirectory']
})
if (!result.canceled && result.filePaths.length > 0) {
setExportFolder(result.filePaths[0])
await configService.setExportPath(result.filePaths[0])
}
} catch (e) {
console.error('选择目录失败:', e)
}
}}
>
<FolderOpen size={16} />
<span>选择导出目录</span>
</button>
</div>
</div>
<div className="export-action">
<button
className="export-btn"
onClick={startExport}
disabled={selectedSessions.size === 0 || !exportFolder || isExporting}
>
{isExporting ? (
<>
<Loader2 size={18} className="spin" />
<span>导出中 ({exportProgress.current}/{exportProgress.total})</span>
</>
) : (
<>
<Download size={18} />
<span>开始导出</span>
</>
)}
</button>
</div>
</div>
{/* 媒体导出布局选择弹窗 */}
{showMediaLayoutPrompt && (
<div className="export-overlay" onClick={() => setShowMediaLayoutPrompt(false)}>
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3>导出文件夹布局</h3>
<p className="layout-subtitle">检测到同时导出多个会话并包含媒体文件,请选择存放方式:</p>
<div className="layout-options">
<button
className="layout-option-btn primary"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('shared')
}}
>
<span className="layout-title">所有会话在同一文件夹</span>
<span className="layout-desc">媒体会按会话名归档到 media 子目录</span>
</button>
<button
className="layout-option-btn"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('per-session')
}}
>
<span className="layout-title">每个会话一个文件夹</span>
<span className="layout-desc">每个会话单独包含导出文件和媒体</span>
</button>
</div>
<div className="layout-actions">
<button className="layout-cancel-btn" onClick={() => setShowMediaLayoutPrompt(false)}>
取消
</button>
</div>
</div>
</div>
)}
{/* 导出前预估弹窗 */}
{showPreExportDialog && (
<div className="export-overlay">
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3>导出预估</h3>
{isLoadingStats ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '24px 0', justifyContent: 'center' }}>
<Loader2 size={20} className="spin" />
<span style={{ fontSize: 14, color: 'var(--text-secondary)' }}>正在统计消息,可直接点击“直接导出”跳过等待</span>
</div>
) : preExportStats ? (
<div style={{ padding: '12px 0' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px', fontSize: 14 }}>
<div>
<span style={{ color: 'var(--text-secondary)' }}>会话数</span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{selectedSessions.size}</div>
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}>总消息</span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.totalMessages.toLocaleString()}</div>
</div>
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && (
<>
<div>
<span style={{ color: 'var(--text-secondary)' }}>语音消息</span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.voiceMessages}</div>
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}>已有缓存</span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2, color: 'var(--primary)' }}>{preExportStats.cachedVoiceCount}</div>
</div>
</>
)}
</div>
{options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && (
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-warning, #e6a23c)' }}>⚠</span>
{' '}需要转写 <b>{preExportStats.needTranscribeCount}</b> 条语音,预计耗时约 <b>{preExportStats.estimatedSeconds > 60
? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟`
: `${preExportStats.estimatedSeconds} 秒`
}</b>
</div>
)}
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && (
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-success, #67c23a)' }}>✓</span>
{' '}所有 {preExportStats.voiceMessages} 条语音已有转写缓存,无需重新转写
</div>
)}
</div>
) : (
<p style={{ fontSize: 14, color: 'var(--text-secondary)', padding: '16px 0' }}>统计信息获取失败,仍可继续导出</p>
)}
<div className="layout-actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button className="layout-cancel-btn" onClick={() => { statsRequestIdRef.current++; setIsLoadingStats(false); setShowPreExportDialog(false); setPreExportStats(null) }}>
取消
</button>
<button className="layout-option-btn primary" onClick={confirmExport}>
<span className="layout-title">{isLoadingStats ? '直接导出' : '开始导出'}</span>
</button>
</div>
</div>
</div>
)}
{/* 导出进度弹窗 */}
{isExporting && (
<div className="export-overlay">
<div className="export-progress-modal">
<div className="progress-spinner">
<Loader2 size={32} className="spin" />
</div>
<h3>正在导出</h3>
<p className="progress-text">{exportProgress.currentName}</p>
{exportProgress.phaseLabel && (
<p className="progress-phase-label" style={{ fontSize: 13, color: 'var(--text-secondary)', margin: '4px 0 8px' }}>
{exportProgress.phaseLabel}
</p>
)}
{exportProgress.phaseTotal > 0 && (
<div className="progress-bar" style={{ marginBottom: 8 }}>
<div
className="progress-fill"
style={{ width: `${(exportProgress.phaseProgress / exportProgress.phaseTotal) * 100}%`, background: 'var(--primary-light, #79bbff)' }}
/>
</div>
)}
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${exportProgress.total > 0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }}
/>
</div>
<p className="progress-count">
{exportProgress.current} / {exportProgress.total} 个会话
<span style={{ marginLeft: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
{elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒` : `${elapsedSeconds}秒`}`}
</span>
</p>
</div>
</div>
)}
{/* 导出结果弹窗 */}
{exportResult && (
<div className="export-overlay">
<div className="export-result-modal">
<div className={`result-icon ${exportResult.success ? 'success' : 'error'}`}>
{exportResult.success ? <CheckCircle size={48} /> : <XCircle size={48} />}
</div>
<h3>{exportResult.success ? '导出完成' : '导出失败'}</h3>
{exportResult.success ? (
<p className="result-text">
成功导出 {exportResult.successCount} 个会话
{exportResult.failCount ? `,${exportResult.failCount} 个失败` : ''}
</p>
) : (
<p className="result-text error">{exportResult.error}</p>
)}
<div className="result-actions">
{exportResult.success && (
<button className="open-folder-btn" onClick={openExportFolder}>
<ExternalLink size={16} />
<span>打开文件夹</span>
</button>
)}
<button className="close-btn" onClick={() => setExportResult(null)}>
关闭
</button>
</div>
</div>
</div>
)}
{/* 日期选择弹窗 */}
{showDatePicker && (
<div className="export-overlay" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
<h3>选择时间范围</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
点击选择开始和结束日期,系统会自动调整确保时间顺序正确
</p>
<div className="quick-select">
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
最近7天
</button>
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
最近30天
</button>
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
最近90天
</button>
</div>
<div className="date-display">
<div
className={`date-display-item ${selectingStart ? 'active' : ''}`}
onClick={() => setSelectingStart(true)}
>
<span className="date-label">开始日期</span>
<span className="date-value">
{options.dateRange?.start?.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
</span>
</div>
<span className="date-separator">至</span>
<div
className={`date-display-item ${!selectingStart ? 'active' : ''}`}
onClick={() => setSelectingStart(false)}
>
<span className="date-label">结束日期</span>
<span className="date-value">
{options.dateRange?.end?.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
</span>
</div>
</div>
<div className="calendar-container">
<div className="calendar-header">
<button
className="calendar-nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
>
<ChevronLeft size={18} />
</button>
<span className="calendar-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
</span>
<button
className="calendar-nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
>
<ChevronRight size={18} />
</button>
</div>
{showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
<ChevronLeft size={16} />
</button>
<span className="year-label">{calendarDate.getFullYear()}年</span>
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
<ChevronRight size={16} />
</button>
</div>
<div className="month-grid">
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : (
<>
<div className="calendar-weekdays">
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
<div key={day} className="calendar-weekday">{day}</div>
<div className="dialog-section">
<h4>发送者名称显示</h4>
<div className="display-name-options">
{displayNameOptions.map(option => (
<label key={option.value} className={`display-name-item ${options.displayNamePreference === option.value ? 'active' : ''}`}>
<input
type="radio"
checked={options.displayNamePreference === option.value}
onChange={() => setOptions(prev => ({ ...prev, displayNamePreference: option.value }))}
/>
<span>{option.label}</span>
<small>{option.desc}</small>
</label>
))}
</div>
<div className="calendar-days">
{generateCalendar().map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="calendar-day empty" />
}
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 (
<div
key={day}
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''} ${isFuture ? 'disabled' : ''}`}
onClick={() => !isFuture && handleDateSelect(day)}
style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }}
>
{day}
</div>
)
})}
</div>
</>
)}
</div>
<div className="date-picker-actions">
<button className="cancel-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
取消
</button>
<button className="confirm-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
确定
<div className="dialog-actions">
<button className="secondary-btn" onClick={closeExportDialog}>取消</button>
<button className="primary-btn" onClick={() => void createTask()} disabled={!exportFolder || exportDialog.sessionIds.length === 0}>
<Download size={14} /> 创建导出任务
</button>
</div>
</div>
+49
View File
@@ -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<ExportWriteLayout> {
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<void> {
await config.set(CONFIG_KEYS.EXPORT_WRITE_LAYOUT, layout)
}
export async function getExportLastSessionRunMap(): Promise<Record<string, number>> {
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<string, unknown>)
const map: Record<string, number> = {}
for (const [sessionId, raw] of entries) {
if (typeof raw === 'number' && Number.isFinite(raw)) {
map[sessionId] = raw
}
}
return map
}
export async function setExportLastSessionRunMap(map: Record<string, number>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP, map)
}
export async function getExportLastContentRunMap(): Promise<Record<string, number>> {
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<string, unknown>)
const map: Record<string, number> = {}
for (const [key, raw] of entries) {
if (typeof raw === 'number' && Number.isFinite(raw)) {
map[key] = raw
}
}
return map
}
export async function setExportLastContentRunMap(map: Record<string, number>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map)
}
// === 安全相关 ===
export async function getAuthEnabled(): Promise<boolean> {