mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 12:07:01 +08:00
feat: 导出功能
This commit is contained in:
@@ -20,6 +20,7 @@ import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportG
|
||||
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
|
||||
import { Contact, Message } from '../../shared/types'
|
||||
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
|
||||
import { ExportWorkspace } from './components/export/ExportWorkspace'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
@@ -1201,8 +1202,19 @@ function App(): React.ReactElement {
|
||||
/>
|
||||
)
|
||||
case 'search':
|
||||
case 'export':
|
||||
return renderPlaceholderPage(activePage)
|
||||
case 'export':
|
||||
return (
|
||||
<ExportWorkspace
|
||||
contacts={contacts}
|
||||
selectedContact={selectedContact}
|
||||
previewMessages={messages}
|
||||
selfInfo={selfInfo}
|
||||
dbReady={isDatabaseConnected}
|
||||
onSelectContact={handleSelectContact}
|
||||
onOpenSettings={openSettings}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7808,3 +7808,743 @@ body {
|
||||
color: var(--wxex-text-muted);
|
||||
font: 11px/17px var(--wxex-font);
|
||||
}
|
||||
|
||||
/* Export workspace */
|
||||
.export-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 292px minmax(520px, 1fr) 360px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--wxex-bg-main);
|
||||
}
|
||||
|
||||
.export-contact-panel,
|
||||
.export-config-panel,
|
||||
.export-preview-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--wxex-bg-elevated);
|
||||
}
|
||||
|
||||
.export-contact-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--wxex-border);
|
||||
background: var(--wxex-bg-sidebar);
|
||||
}
|
||||
|
||||
.export-panel-header {
|
||||
padding: 20px 16px 12px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
}
|
||||
|
||||
.export-panel-title-row,
|
||||
.export-section-heading,
|
||||
.export-preview-heading,
|
||||
.export-action-bar,
|
||||
.export-target-path,
|
||||
.export-media-master,
|
||||
.export-account-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.export-panel-title-row {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.export-panel-title-row h2,
|
||||
.export-config-header h1,
|
||||
.export-job-state h2 {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.export-panel-title-row h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.export-count-badge {
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.export-search-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 7px;
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-muted);
|
||||
}
|
||||
|
||||
.export-search-field input,
|
||||
.export-save-section input,
|
||||
.export-date-fields input {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--wxex-text-primary);
|
||||
font: 13px/20px var(--wxex-font);
|
||||
}
|
||||
|
||||
.export-search-field input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.export-filter-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 3px;
|
||||
margin-top: 12px;
|
||||
padding: 3px;
|
||||
border-radius: 7px;
|
||||
background: #e3e9e5;
|
||||
}
|
||||
|
||||
.export-filter-tabs button,
|
||||
.export-range-toggle button {
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--wxex-text-secondary);
|
||||
cursor: pointer;
|
||||
font: 600 12px/30px var(--wxex-font);
|
||||
}
|
||||
|
||||
.export-filter-tabs button.active,
|
||||
.export-range-toggle button.active {
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-brand);
|
||||
box-shadow: 0 1px 2px rgba(32, 39, 36, 0.06);
|
||||
}
|
||||
|
||||
.export-contact-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.export-contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
padding: 11px 16px;
|
||||
border: 0;
|
||||
border-left: 3px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--wxex-text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.export-contact-item:hover {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
.export-contact-item.active {
|
||||
border-left-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand-soft);
|
||||
}
|
||||
|
||||
.export-contact-avatar,
|
||||
.export-account-avatar,
|
||||
.export-chat-avatar,
|
||||
.export-preview-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.export-contact-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.export-contact-avatar img,
|
||||
.export-account-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.export-contact-copy,
|
||||
.export-account-summary > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.export-contact-copy strong,
|
||||
.export-account-summary strong {
|
||||
overflow: hidden;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.export-contact-copy small,
|
||||
.export-account-summary small {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.export-account-summary small.ready {
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
|
||||
.export-account-summary {
|
||||
gap: 9px;
|
||||
padding: 14px 16px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.export-account-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.export-config-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--wxex-bg-main);
|
||||
}
|
||||
|
||||
.export-config-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 24px 28px 28px;
|
||||
}
|
||||
|
||||
.export-config-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.export-chat-avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.export-chat-avatar img,
|
||||
.export-preview-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.export-config-header h1 {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
.export-config-header p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.export-section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
.export-section h3 {
|
||||
margin: 0 0 12px;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.export-section-heading {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.export-section-heading span {
|
||||
color: var(--wxex-brand);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.export-range-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.export-range-toggle button {
|
||||
border: 1px solid var(--wxex-border);
|
||||
background: var(--wxex-bg-elevated);
|
||||
line-height: 36px;
|
||||
}
|
||||
.export-range-toggle button.active {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.export-date-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #f0f3f0;
|
||||
}
|
||||
.export-date-fields label,
|
||||
.export-save-section > label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
.export-date-fields input,
|
||||
.export-save-section input {
|
||||
width: 100%;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.export-kind-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px 20px;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #f0f3f0;
|
||||
}
|
||||
.export-check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.export-check-row input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--wxex-brand);
|
||||
}
|
||||
.export-name-mode-grid {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #f0f3f0;
|
||||
}
|
||||
.export-name-mode-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: 1;
|
||||
min-height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.export-name-mode-option input {
|
||||
accent-color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
.export-media-master {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.export-media-master input {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
accent-color: var(--wxex-brand);
|
||||
}
|
||||
.export-media-options {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-top: 8px;
|
||||
padding: 9px 13px;
|
||||
border-radius: 8px;
|
||||
background: #f0f3f0;
|
||||
}
|
||||
.export-media-options.disabled {
|
||||
opacity: 0.52;
|
||||
}
|
||||
.export-resource-statuses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.export-resource-statuses span {
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-success);
|
||||
font-size: 10px;
|
||||
}
|
||||
.export-helper-text {
|
||||
margin: 9px 0 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.export-format-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.export-format-grid button {
|
||||
min-height: 78px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--wxex-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.export-format-grid button.active {
|
||||
border: 2px solid var(--wxex-brand);
|
||||
background: #f2f8f5;
|
||||
}
|
||||
.export-format-grid strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
}
|
||||
.export-format-grid small {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--wxex-success);
|
||||
font-size: 10px;
|
||||
}
|
||||
.export-html-options {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 8px;
|
||||
background: #f0f3f0;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.export-html-options input {
|
||||
accent-color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
.export-save-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.export-target-path {
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
.export-target-path strong {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--wxex-text-primary);
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.export-target-path button {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: var(--wxex-text-primary);
|
||||
cursor: pointer;
|
||||
font: 11px var(--wxex-font);
|
||||
}
|
||||
|
||||
.export-action-bar {
|
||||
flex: 0 0 58px;
|
||||
gap: 8px;
|
||||
padding: 0 22px;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
background: #fff;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.export-ready-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--wxex-success);
|
||||
}
|
||||
.export-ready-dot.completed {
|
||||
background: var(--wxex-brand);
|
||||
}
|
||||
.export-target-summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 14px;
|
||||
overflow: hidden;
|
||||
color: var(--wxex-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.export-reset-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--wxex-text-primary);
|
||||
cursor: pointer;
|
||||
font: 600 12px var(--wxex-font);
|
||||
}
|
||||
.export-primary-button {
|
||||
min-width: 132px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: var(--wxex-brand);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: 700 12px var(--wxex-font);
|
||||
}
|
||||
.export-primary-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.export-preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--wxex-border);
|
||||
background: #fbfcfb;
|
||||
}
|
||||
.export-preview-heading {
|
||||
justify-content: space-between;
|
||||
flex: 0 0 52px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.export-preview-heading span {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.export-message-preview {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px 14px;
|
||||
}
|
||||
.export-preview-date {
|
||||
width: fit-content;
|
||||
margin: 0 auto 18px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 10px;
|
||||
background: #e9eeeb;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
.export-preview-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.export-preview-message.mine {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.export-preview-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.export-preview-bubble {
|
||||
max-width: 78%;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
box-shadow: 0 1px 2px rgba(32, 39, 36, 0.05);
|
||||
}
|
||||
.export-preview-message.mine .export-preview-bubble {
|
||||
background: #95ec69;
|
||||
}
|
||||
.export-preview-bubble small {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.export-preview-stats {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
}
|
||||
.export-preview-stats span,
|
||||
.export-complete-summary span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.export-preview-stats strong,
|
||||
.export-complete-summary strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.export-job-state {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
.export-job-state h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
.export-job-state p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.export-job-state ol {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 8px 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
}
|
||||
.export-job-state li {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.export-job-state li::before {
|
||||
display: inline-grid;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
vertical-align: -3px;
|
||||
}
|
||||
.export-job-state li.done {
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
.export-job-state li.done::before {
|
||||
border-color: var(--wxex-success);
|
||||
background: var(--wxex-success);
|
||||
content: '✓';
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
}
|
||||
.export-job-state li.current {
|
||||
color: var(--wxex-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.export-job-state li.current::before {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand);
|
||||
box-shadow: inset 0 0 0 4px #fff;
|
||||
}
|
||||
.export-progress-bar {
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #e2e9e4;
|
||||
}
|
||||
.export-progress-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--wxex-brand);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.export-job-state > strong {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.export-cancel-button,
|
||||
.export-open-folder-button {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: var(--wxex-text-primary);
|
||||
cursor: pointer;
|
||||
font: 600 12px var(--wxex-font);
|
||||
}
|
||||
.export-success-icon {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin: 0 auto 4px;
|
||||
border: 5px solid var(--wxex-brand-soft);
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: var(--wxex-brand);
|
||||
font-size: 30px;
|
||||
line-height: 48px;
|
||||
}
|
||||
.export-complete-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #eef3ef;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.export-workspace {
|
||||
grid-template-columns: 248px minmax(460px, 1fr);
|
||||
}
|
||||
.export-preview-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ChatHeader } from './chat/ChatHeader'
|
||||
import { ChatStatusBar } from './chat/ChatStatusBar'
|
||||
import { DataTrustBar } from './chat/DataTrustBar'
|
||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||
import { ExportRange } from './chat/ExportMenu'
|
||||
import { MessageList } from './chat/MessageList'
|
||||
|
||||
interface ChatWindowProps {
|
||||
@@ -171,65 +170,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
}
|
||||
}, [previewImage])
|
||||
|
||||
const handleExport = (days: ExportRange): void => {
|
||||
if (!messages.length) return
|
||||
|
||||
let filtered = messages
|
||||
if (days !== 'all') {
|
||||
const now = new Date()
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||
|
||||
filtered = messages.filter((m) => {
|
||||
const parsed = new Date(m.datetime).getTime()
|
||||
if (isNaN(parsed)) return true
|
||||
|
||||
if (days === 0) {
|
||||
// 今天
|
||||
return parsed >= startOfDay
|
||||
} else if (days === 1) {
|
||||
// 昨天
|
||||
const startOfYesterday = startOfDay - 86400000
|
||||
return parsed >= startOfYesterday && parsed < startOfDay
|
||||
} else if (days === 7) {
|
||||
// 过去 7 天
|
||||
const startOf7DaysAgo = startOfDay - 7 * 86400000
|
||||
return parsed >= startOf7DaysAgo
|
||||
} else if (days === 30) {
|
||||
// 过去 30 天
|
||||
const startOf30DaysAgo = startOfDay - 30 * 86400000
|
||||
return parsed >= startOf30DaysAgo
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const headers = ['发送者', '类型', '时间', '内容']
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...filtered.map((m) => {
|
||||
let prefix = ''
|
||||
if (isGroupChat) {
|
||||
prefix = m.name ? `${m.name}: ` : ''
|
||||
} else {
|
||||
const name = m.from === 'user' ? contact?.m_nsNickName || '未知' : '我'
|
||||
prefix = `${name}: `
|
||||
}
|
||||
const fullContent = `${prefix}${m.content}`
|
||||
const content = fullContent.replace(/"/g, '""').replace(/\n/g, ' ')
|
||||
return `"${m.from}","${m.type}","${m.datetime}","${content}"`
|
||||
})
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', `${contact?.m_nsNickName || 'chat'}_export.csv`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const filteredMessages = React.useMemo(() => {
|
||||
return messages.filter((msg) => {
|
||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||
@@ -255,11 +195,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
filteredCount={filteredMessages.length}
|
||||
contentFilter={contentFilter || ''}
|
||||
isAiLoading={isAiLoading}
|
||||
canExport={messages.length > 0}
|
||||
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||
onRefresh={onRefresh}
|
||||
onRefreshData={onRefreshData}
|
||||
onExport={handleExport}
|
||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||
/>
|
||||
<DataTrustBar messageCount={messages.length} />
|
||||
|
||||
@@ -2,14 +2,21 @@ import { ParsedContent } from '../../../shared/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
|
||||
import { ImageBubble } from './ImageBubble'
|
||||
|
||||
const stickerDataUrlCache = new Map<string, string>()
|
||||
|
||||
interface RichMessageBubbleProps {
|
||||
contentData: ParsedContent
|
||||
sessionId?: string
|
||||
onImageClick?: (imageUrl: string) => void
|
||||
}
|
||||
|
||||
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
|
||||
export function RichMessageBubble({
|
||||
contentData,
|
||||
sessionId,
|
||||
onImageClick
|
||||
}: RichMessageBubbleProps): JSX.Element {
|
||||
switch (contentData.type) {
|
||||
case 'location':
|
||||
return <LocationBubble data={contentData} />
|
||||
@@ -22,7 +29,7 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
|
||||
case 'sticker':
|
||||
return <StickerBubble data={contentData} />
|
||||
case 'quote':
|
||||
return <QuoteBubble data={contentData} />
|
||||
return <QuoteBubble data={contentData} sessionId={sessionId} onImageClick={onImageClick} />
|
||||
case 'system':
|
||||
return <SystemBubble data={contentData} />
|
||||
case 'unknown':
|
||||
@@ -201,7 +208,15 @@ function StickerBubble({
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
|
||||
function QuoteBubble({
|
||||
data,
|
||||
sessionId,
|
||||
onImageClick
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'quote' }>
|
||||
sessionId?: string
|
||||
onImageClick?: (imageUrl: string) => void
|
||||
}): JSX.Element {
|
||||
const quotedText = data.quotedContent || data.content || '[引用消息]'
|
||||
const replyText = data.content || data.title || ''
|
||||
const quotedSender = data.quotedSender || data.sender || ''
|
||||
@@ -210,7 +225,17 @@ function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }>
|
||||
<div className="quote-message">
|
||||
<div className="quoted-message">
|
||||
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
|
||||
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
|
||||
{data.quotedImageMd5 || data.quotedImageDatName ? (
|
||||
<ImageBubble
|
||||
imageMd5={data.quotedImageMd5}
|
||||
imageDatName={data.quotedImageDatName}
|
||||
sessionId={sessionId}
|
||||
isThumb
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
) : (
|
||||
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
|
||||
)}
|
||||
</div>
|
||||
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
import { ConversationContentSearch } from './ConversationContentSearch'
|
||||
import { ExportMenu, ExportRange } from './ExportMenu'
|
||||
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
||||
|
||||
interface ChatHeaderProps {
|
||||
@@ -12,11 +11,9 @@ interface ChatHeaderProps {
|
||||
filteredCount: number
|
||||
contentFilter: string
|
||||
isAiLoading: boolean
|
||||
canExport: boolean
|
||||
onContentFilterChange: (value: string) => void
|
||||
onRefresh?: () => void
|
||||
onRefreshData?: () => void
|
||||
onExport: (range: ExportRange) => void
|
||||
onOpenAiSettings: () => void
|
||||
}
|
||||
|
||||
@@ -28,11 +25,9 @@ export function ChatHeader({
|
||||
filteredCount,
|
||||
contentFilter,
|
||||
isAiLoading,
|
||||
canExport,
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
onRefreshData,
|
||||
onExport,
|
||||
onOpenAiSettings
|
||||
}: ChatHeaderProps): React.ReactElement {
|
||||
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
||||
@@ -96,7 +91,6 @@ export function ChatHeader({
|
||||
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<ExportMenu disabled={!canExport} onExport={onExport} />
|
||||
<div className="chat-menu" ref={moreRef}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -16,7 +16,7 @@ interface MessageBubbleProps {
|
||||
onImageClick: (imageUrl: string) => void
|
||||
}
|
||||
|
||||
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息']
|
||||
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '引用消息', '通话', '表情包', '系统消息']
|
||||
|
||||
export function MessageBubble({
|
||||
message,
|
||||
@@ -60,7 +60,11 @@ export function MessageBubble({
|
||||
duration={message.contentData.duration}
|
||||
/>
|
||||
) : isRichMedia && message.contentData ? (
|
||||
<RichMessageBubble contentData={message.contentData} />
|
||||
<RichMessageBubble
|
||||
contentData={message.contentData}
|
||||
sessionId={message.sessionId}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import type { Contact, Message } from '../../../../shared/types'
|
||||
import type {
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
ExportNameMode
|
||||
} from '../../../../shared/export'
|
||||
|
||||
type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
|
||||
type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
|
||||
type ExportStatus = 'idle' | 'running' | 'completed'
|
||||
|
||||
interface GroupMemberName {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
interface SelfInfo {
|
||||
wxid: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
interface ExportWorkspaceProps {
|
||||
contacts: Contact[]
|
||||
selectedContact: Contact | null
|
||||
previewMessages: Message[]
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
onSelectContact: (contact: Contact) => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
const messageKinds = [
|
||||
['text', '文字'],
|
||||
['image', '图片'],
|
||||
['video', '视频'],
|
||||
['voice', '语音'],
|
||||
['sticker', '表情包'],
|
||||
['share', '链接与分享'],
|
||||
['location', '位置'],
|
||||
['system', '系统消息']
|
||||
] as const
|
||||
|
||||
const formatLabels: Record<ExportFormat, { label: string; hint?: string }> = {
|
||||
html: { label: 'HTML', hint: '推荐' },
|
||||
csv: { label: 'CSV' },
|
||||
json: { label: 'JSON' },
|
||||
markdown: { label: 'Markdown' }
|
||||
}
|
||||
|
||||
function displayName(contact: Contact | null): string {
|
||||
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
|
||||
}
|
||||
|
||||
function formatPreviewTime(message: Message): string {
|
||||
if (!message.createTime) return message.datetime || ''
|
||||
return new Date(message.createTime * 1000).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function ExportWorkspace({
|
||||
contacts,
|
||||
selectedContact,
|
||||
previewMessages,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
onSelectContact,
|
||||
onOpenSettings
|
||||
}: ExportWorkspaceProps): React.ReactElement {
|
||||
const [contactFilter, setContactFilter] = useState('')
|
||||
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
||||
const [range, setRange] = useState<ExportRange>('today')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
|
||||
const [nameMode, setNameMode] = useState<ExportNameMode>('remark')
|
||||
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([])
|
||||
const [includeMedia, setIncludeMedia] = useState(true)
|
||||
const [includeAvatars, setIncludeAvatars] = useState(true)
|
||||
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
||||
const [keepMissing, setKeepMissing] = useState(false)
|
||||
const [format, setFormat] = useState<ExportFormat>('html')
|
||||
const [zip, setZip] = useState(false)
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [status, setStatus] = useState<ExportStatus>('idle')
|
||||
const [jobId, setJobId] = useState('')
|
||||
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
const keyword = contactFilter.trim().toLowerCase()
|
||||
return contacts.filter((contact) => {
|
||||
if (contactType !== 'all' && contact.type !== contactType) return false
|
||||
if (!keyword) return true
|
||||
return [contact.m_nsNickName, contact.m_nsUsrName].some((value) =>
|
||||
value.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
}, [contactFilter, contactType, contacts])
|
||||
|
||||
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
|
||||
const activeName = displayName(activeContact)
|
||||
const preview = previewMessages.slice(-20)
|
||||
const outputName = fileName.trim() || `${activeName}_聊天档案`
|
||||
const nameOptions: { value: ExportNameMode; label: string }[] =
|
||||
activeContact?.type === 'group'
|
||||
? [
|
||||
{ value: 'groupNickname', label: '群昵称' },
|
||||
{ value: 'remark', label: '备注' },
|
||||
{ value: 'wechatNickname', label: '微信名' }
|
||||
]
|
||||
: [
|
||||
{ value: 'remark', label: '备注' },
|
||||
{ value: 'wechatNickname', label: '微信名' }
|
||||
]
|
||||
|
||||
const nameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
if (activeContact?.type === 'group') {
|
||||
for (const member of groupMembers) {
|
||||
const value =
|
||||
nameMode === 'groupNickname'
|
||||
? member.groupNickname || member.nickname || member.wxid
|
||||
: nameMode === 'remark'
|
||||
? member.remark || member.wechatNickname || member.wxid
|
||||
: member.wechatNickname || member.wxid
|
||||
map[member.wxid] = value
|
||||
}
|
||||
} else if (activeContact) {
|
||||
map[activeContact.m_nsUsrName] =
|
||||
nameMode === 'remark'
|
||||
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
|
||||
: activeContact.wechatNickname || activeContact.m_nsUsrName
|
||||
}
|
||||
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
|
||||
return map
|
||||
}, [activeContact, groupMembers, nameMode, selfInfo])
|
||||
|
||||
const avatarUrls = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
if (activeContact?.m_nsUsrName && activeContact.avatar) {
|
||||
map[activeContact.m_nsUsrName] = activeContact.avatar
|
||||
}
|
||||
for (const member of groupMembers) {
|
||||
if (member.avatar) map[member.wxid] = member.avatar
|
||||
}
|
||||
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
|
||||
return map
|
||||
}, [activeContact, groupMembers, selfInfo])
|
||||
|
||||
React.useEffect(() => {
|
||||
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
|
||||
let cancelled = false
|
||||
if (!activeContact || activeContact.type !== 'group') {
|
||||
setGroupMembers([])
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
|
||||
if (!cancelled) setGroupMembers((snapshot?.members || []) as GroupMemberName[])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeContact])
|
||||
|
||||
const toggleKind = (value: string): void => {
|
||||
setSelectedKinds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(value)) next.delete(value)
|
||||
else next.add(value)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleStart = async (): Promise<void> => {
|
||||
if (!activeContact || status === 'running') return
|
||||
const nextJobId = `export-${Date.now()}`
|
||||
setJobId(nextJobId)
|
||||
setProgress(null)
|
||||
setStatus('running')
|
||||
const now = new Date()
|
||||
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
|
||||
const startOfRange = days
|
||||
? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1)
|
||||
: null
|
||||
const request = {
|
||||
jobId: nextJobId,
|
||||
userMd5: activeContact.md5,
|
||||
name: activeName,
|
||||
format,
|
||||
outputName,
|
||||
startTime: startOfRange
|
||||
? Math.floor(startOfRange.getTime() / 1000)
|
||||
: range === 'custom' && startDate
|
||||
? Math.floor(new Date(startDate).getTime() / 1000)
|
||||
: undefined,
|
||||
endTime: startOfRange
|
||||
? Math.floor(endOfToday.getTime() / 1000)
|
||||
: range === 'custom' && endDate
|
||||
? Math.floor(new Date(endDate).getTime() / 1000)
|
||||
: undefined,
|
||||
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
||||
includeMedia,
|
||||
includeAvatars,
|
||||
avatarUrls,
|
||||
nameMode,
|
||||
nameMap,
|
||||
zip
|
||||
}
|
||||
const result = await window.api.startExport(request)
|
||||
if (!result.success && result.error !== '已取消') setStatus('idle')
|
||||
}
|
||||
|
||||
React.useEffect(
|
||||
() =>
|
||||
window.api.onExportProgress((next) => {
|
||||
if (next.jobId !== jobId) return
|
||||
setProgress(next)
|
||||
if (next.phase === 'completed') setStatus('completed')
|
||||
if (next.phase === 'cancelled' || next.phase === 'failed') setStatus('idle')
|
||||
}),
|
||||
[jobId]
|
||||
)
|
||||
|
||||
const targetPath =
|
||||
format === 'html'
|
||||
? zip
|
||||
? `文稿/WechatExplorer/导出/${outputName}.zip`
|
||||
: `文稿/WechatExplorer/导出/${outputName}/`
|
||||
: `文稿/WechatExplorer/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
|
||||
|
||||
return (
|
||||
<div className="export-workspace">
|
||||
<aside className="export-contact-panel">
|
||||
<div className="export-panel-header">
|
||||
<div className="export-panel-title-row">
|
||||
<h2>选择聊天</h2>
|
||||
<span className="export-count-badge">共 {contacts.length.toLocaleString()} 个</span>
|
||||
</div>
|
||||
<label className="export-search-field">
|
||||
<span aria-hidden>⌕</span>
|
||||
<input
|
||||
value={contactFilter}
|
||||
onChange={(event) => setContactFilter(event.target.value)}
|
||||
placeholder="搜索群聊、联系人或 wxid"
|
||||
aria-label="搜索聊天"
|
||||
/>
|
||||
</label>
|
||||
<div className="export-filter-tabs" role="tablist" aria-label="聊天类型">
|
||||
{(
|
||||
[
|
||||
['all', '全部'],
|
||||
['group', '群聊'],
|
||||
['user', '联系人']
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={contactType === value ? 'active' : ''}
|
||||
onClick={() => setContactType(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="export-contact-list">
|
||||
{filteredContacts.map((contact) => {
|
||||
const name = displayName(contact)
|
||||
return (
|
||||
<button
|
||||
key={contact.md5}
|
||||
type="button"
|
||||
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
>
|
||||
<span className="export-contact-avatar">
|
||||
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="export-contact-copy">
|
||||
<strong>{name}</strong>
|
||||
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button type="button" className="export-account-summary" onClick={onOpenSettings}>
|
||||
<span className="export-account-avatar">
|
||||
{selfInfo?.avatar ? (
|
||||
<img src={selfInfo.avatar} alt="" />
|
||||
) : (
|
||||
(selfInfo?.nickname || '我').slice(0, 1)
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{selfInfo?.nickname || '当前账号'}</strong>
|
||||
<small className={dbReady ? 'ready' : ''}>
|
||||
{dbReady ? '数据库已连接' : '数据库未连接'}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="export-config-panel">
|
||||
<div className="export-config-scroll">
|
||||
<header className="export-config-header">
|
||||
<span className="export-chat-avatar">
|
||||
{activeContact?.avatar ? (
|
||||
<img src={activeContact.avatar} alt="" />
|
||||
) : (
|
||||
activeName.slice(0, 1)
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<h1>导出设置</h1>
|
||||
<p>
|
||||
{activeName}
|
||||
{activeContact?.type === 'group' ? ' · 群聊' : ''}
|
||||
</p>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section className="export-section">
|
||||
<div className="export-section-heading">
|
||||
<h3>时间范围</h3>
|
||||
<span>{status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'}</span>
|
||||
</div>
|
||||
<div className="export-range-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={range === 'today' ? 'active' : ''}
|
||||
onClick={() => setRange('today')}
|
||||
>
|
||||
今天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={range === 'threeDays' ? 'active' : ''}
|
||||
onClick={() => setRange('threeDays')}
|
||||
>
|
||||
最近 3 天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={range === 'sevenDays' ? 'active' : ''}
|
||||
onClick={() => setRange('sevenDays')}
|
||||
>
|
||||
最近 7 天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={range === 'custom' ? 'active' : ''}
|
||||
onClick={() => setRange('custom')}
|
||||
>
|
||||
自定义时间
|
||||
</button>
|
||||
</div>
|
||||
{range === 'custom' && (
|
||||
<div className="export-date-fields">
|
||||
<label>
|
||||
开始时间
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
结束时间
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="export-section">
|
||||
<h3>消息内容</h3>
|
||||
<div className="export-kind-grid">
|
||||
{messageKinds.map(([value, label]) => (
|
||||
<label key={value} className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKinds.has(value)}
|
||||
onChange={() => toggleKind(value)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="export-section">
|
||||
<h3>消息显示名称</h3>
|
||||
<div className="export-name-mode-grid" role="radiogroup" aria-label="消息显示名称">
|
||||
{nameOptions.map((option) => (
|
||||
<label key={option.value} className="export-name-mode-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="export-name-mode"
|
||||
checked={nameMode === option.value}
|
||||
onChange={() => setNameMode(option.value)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="export-section">
|
||||
<h3>资源处理</h3>
|
||||
<label className="export-media-master">
|
||||
<span>包含图片、视频、语音及动态表情</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeMedia}
|
||||
onChange={(event) => setIncludeMedia(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<div className={`export-media-options ${includeMedia ? '' : 'disabled'}`}>
|
||||
<label className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferOriginal}
|
||||
disabled={!includeMedia}
|
||||
onChange={(event) => setPreferOriginal(event.target.checked)}
|
||||
/>
|
||||
<span>优先导出原图</span>
|
||||
</label>
|
||||
<label className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fallbackThumbnail}
|
||||
disabled={!includeMedia}
|
||||
onChange={(event) => setFallbackThumbnail(event.target.checked)}
|
||||
/>
|
||||
<span>原图缺失时使用缩略图</span>
|
||||
</label>
|
||||
<label className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keepMissing}
|
||||
disabled={!includeMedia}
|
||||
onChange={(event) => setKeepMissing(event.target.checked)}
|
||||
/>
|
||||
<span>媒体缺失时保留占位说明</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="export-resource-statuses">
|
||||
<span>图片解密:已就绪</span>
|
||||
<span>视频资源:可用</span>
|
||||
<span>语音资源:可用</span>
|
||||
<span>表情资源:按需解析</span>
|
||||
</div>
|
||||
<p className="export-helper-text">媒体资源会延长导出时间,缺失资源不会中断任务。</p>
|
||||
<label className="export-media-master">
|
||||
<span>在聊天气泡旁显示头像</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeAvatars}
|
||||
onChange={(event) => setIncludeAvatars(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="export-section">
|
||||
<h3>导出格式</h3>
|
||||
<div className="export-format-grid">
|
||||
{(Object.keys(formatLabels) as ExportFormat[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={format === value ? 'active' : ''}
|
||||
onClick={() => setFormat(value)}
|
||||
>
|
||||
<strong>{formatLabels[value].label}</strong>
|
||||
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{format === 'html' && (
|
||||
<div className="export-html-options">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="html-package"
|
||||
checked={!zip}
|
||||
onChange={() => setZip(false)}
|
||||
/>{' '}
|
||||
HTML 资源包(推荐)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="html-package"
|
||||
checked={zip}
|
||||
onChange={() => setZip(true)}
|
||||
/>{' '}
|
||||
HTML 资源包并压缩为 ZIP
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="export-section export-save-section">
|
||||
<h3>保存设置</h3>
|
||||
<label>
|
||||
文件名称
|
||||
<input
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
placeholder={`${activeName}_聊天档案`}
|
||||
/>
|
||||
</label>
|
||||
<div className="export-target-path">
|
||||
<span>保存位置</span>
|
||||
<strong>{targetPath}</strong>
|
||||
<button type="button">选择位置</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer className="export-action-bar">
|
||||
<span className={`export-ready-dot ${status === 'completed' ? 'completed' : ''}`} />
|
||||
<span>
|
||||
{status === 'running'
|
||||
? '正在后台导出'
|
||||
: status === 'completed'
|
||||
? '导出完成'
|
||||
: '准备就绪'}
|
||||
</span>
|
||||
<span className="export-target-summary">路径:{targetPath}</span>
|
||||
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}>
|
||||
恢复默认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="export-primary-button"
|
||||
disabled={!activeContact || status === 'running'}
|
||||
onClick={handleStart}
|
||||
>
|
||||
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
|
||||
</button>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
|
||||
{status === 'idle' && (
|
||||
<>
|
||||
<div className="export-preview-heading">
|
||||
<strong>导出预览</strong>
|
||||
<span>仅预览最近 20 条</span>
|
||||
</div>
|
||||
<div className="export-message-preview">
|
||||
<div className="export-preview-date">最近消息</div>
|
||||
{(preview.length
|
||||
? preview
|
||||
: [
|
||||
{
|
||||
id: 'empty',
|
||||
from: 'user',
|
||||
content: '导出预览将在这里显示',
|
||||
type: '文字',
|
||||
datetime: '',
|
||||
isSender: false
|
||||
}
|
||||
]
|
||||
).map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`export-preview-message ${message.isSender ? 'mine' : ''}`}
|
||||
>
|
||||
<span className="export-preview-avatar">
|
||||
{message.img || (message.isSender && selfInfo?.avatar) ? (
|
||||
<img src={message.isSender ? selfInfo?.avatar : message.img} alt="" />
|
||||
) : (
|
||||
(message.isSender ? '我' : message.name || '友').slice(0, 1)
|
||||
)}
|
||||
</span>
|
||||
<span className="export-preview-bubble">
|
||||
<small>
|
||||
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
|
||||
{formatPreviewTime(message)}
|
||||
</small>
|
||||
{message.content || `[${message.type}]`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="export-preview-stats">
|
||||
<span>
|
||||
消息总数<strong>待统计</strong>
|
||||
</span>
|
||||
<span>
|
||||
媒体文件<strong>待统计</strong>
|
||||
</span>
|
||||
<span>
|
||||
预计大小<strong>待统计</strong>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === 'running' && (
|
||||
<div className="export-job-state">
|
||||
<h2>正在导出</h2>
|
||||
<p>导出任务在后台运行,不影响档案浏览。</p>
|
||||
<ol>
|
||||
<li className="done">准备导出</li>
|
||||
<li className="current">
|
||||
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
|
||||
</li>
|
||||
<li>解析消息内容</li>
|
||||
<li>处理媒体资源</li>
|
||||
<li>生成档案</li>
|
||||
</ol>
|
||||
<div className="export-progress-bar" aria-label="导出进度">
|
||||
<span style={{ width: `${progress?.percent ?? 0}%` }} />
|
||||
</div>
|
||||
<strong>
|
||||
{progress?.phase === 'writing'
|
||||
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
|
||||
: `正在读取消息... ${progress?.percent ?? 0}%`}
|
||||
</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="export-cancel-button"
|
||||
onClick={() => {
|
||||
void window.api.cancelExport(jobId)
|
||||
setStatus('idle')
|
||||
}}
|
||||
>
|
||||
取消导出
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'completed' && (
|
||||
<div className="export-job-state completed">
|
||||
<div className="export-success-icon">✓</div>
|
||||
<h2>导出完成</h2>
|
||||
<p>聊天档案已成功保存。</p>
|
||||
<div className="export-complete-summary">
|
||||
<span>
|
||||
导出消息<strong>{progress?.processed.toLocaleString() || '已完成'}</strong>
|
||||
</span>
|
||||
<span>
|
||||
媒体资源<strong>按设置处理</strong>
|
||||
</span>
|
||||
<span>
|
||||
输出位置<strong>已保存</strong>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="export-primary-button"
|
||||
onClick={() =>
|
||||
progress?.outputPath && void window.api.revealExport(progress.outputPath)
|
||||
}
|
||||
>
|
||||
打开档案
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="export-open-folder-button"
|
||||
onClick={() =>
|
||||
progress?.outputPath && void window.api.revealExport(progress.outputPath)
|
||||
}
|
||||
>
|
||||
在文件夹中显示
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user