mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
支持朋友圈导出
This commit is contained in:
@@ -54,6 +54,12 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -69,8 +75,14 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,6 +758,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Initial Loading Animation */
|
||||
.initial-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120px 0;
|
||||
|
||||
.loading-pulse {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
.pulse-circle {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary, #576b95);
|
||||
opacity: 0.25;
|
||||
animation: pulse-ring 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -872,4 +931,748 @@
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
Export Dialog
|
||||
========================================= */
|
||||
.export-dialog {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border-radius: var(--sns-border-radius-lg);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||
width: 480px;
|
||||
max-width: 92vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
.export-dialog-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-dialog-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.export-filter-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
.filter-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--primary, #576b95);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-primary);
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
.export-format-options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
|
||||
.format-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 14px 10px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--primary, #576b95);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary, #576b95);
|
||||
background: rgba(87, 107, 149, 0.08);
|
||||
|
||||
svg {
|
||||
color: var(--primary, #576b95);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-path-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.export-path-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
cursor: default;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-browse-btn {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary, #576b95);
|
||||
color: #fff;
|
||||
border-color: var(--primary, #576b95);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.date-picker-trigger {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
min-height: 36px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary, #576b95);
|
||||
}
|
||||
|
||||
&>svg:first-child {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.clear-date {
|
||||
margin-left: auto;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
padding: 1px;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.date-separator {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2100;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.calendar-modal {
|
||||
background: var(--card-bg);
|
||||
width: 340px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
.calendar-header {
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.title-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-view {
|
||||
padding: 20px;
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.current-month {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
margin-bottom: 8px;
|
||||
|
||||
.weekday {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: repeat(6, 36px);
|
||||
gap: 4px;
|
||||
|
||||
.day-cell {
|
||||
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);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.today:not(.selected) {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
background: var(--primary-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 20px 16px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modalSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.export-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.export-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary, #555);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.25s;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.active {
|
||||
background: var(--primary, #576b95);
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.25s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.active .toggle-knob {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-media-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.export-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.export-progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
.export-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary, #576b95);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.export-progress-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.export-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
|
||||
.export-result-icon {
|
||||
&.success svg {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.error svg {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
|
||||
&.error-text {
|
||||
color: #ff4d4f;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.export-result-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.export-open-btn,
|
||||
.export-done-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.export-open-btn {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.export-done-btn {
|
||||
background: var(--primary, #576b95);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-sync-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
margin: 8px 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
border: 1px dashed var(--border-color);
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.export-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.export-cancel-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.export-start-btn {
|
||||
background: var(--primary, #576b95);
|
||||
border: none;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(87, 107, 149, 0.2);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px rgba(87, 107, 149, 0.3);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen } from 'lucide-react'
|
||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { ImagePreview } from '../components/ImagePreview'
|
||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||
import './SnsPage.scss'
|
||||
@@ -34,6 +34,18 @@ export default function SnsPage() {
|
||||
const [previewImage, setPreviewImage] = useState<{ src: string, isVideo?: boolean, liveVideoPath?: string } | null>(null)
|
||||
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
|
||||
|
||||
// 导出相关状态
|
||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
||||
const [exportFormat, setExportFormat] = useState<'json' | 'html'>('html')
|
||||
const [exportFolder, setExportFolder] = useState('')
|
||||
const [exportMedia, setExportMedia] = useState(false)
|
||||
const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' })
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null)
|
||||
const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null)
|
||||
const [refreshSpin, setRefreshSpin] = useState(false)
|
||||
const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null)
|
||||
|
||||
const postsContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [hasNewer, setHasNewer] = useState(false)
|
||||
const [loadingNewer, setLoadingNewer] = useState(false)
|
||||
@@ -257,12 +269,28 @@ export default function SnsPage() {
|
||||
<h2>朋友圈</h2>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
onClick={() => loadPosts({ reset: true })}
|
||||
onClick={() => {
|
||||
setExportResult(null)
|
||||
setExportProgress(null)
|
||||
setExportDateRange({ start: '', end: '' })
|
||||
setShowExportDialog(true)
|
||||
}}
|
||||
className="icon-btn export-btn"
|
||||
title="导出朋友圈"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRefreshSpin(true)
|
||||
loadPosts({ reset: true })
|
||||
setTimeout(() => setRefreshSpin(false), 800)
|
||||
}}
|
||||
disabled={loading || loadingNewer}
|
||||
className="icon-btn refresh-btn"
|
||||
title="从头刷新"
|
||||
>
|
||||
<RefreshCw size={20} className={(loading || loadingNewer) ? 'spinning' : ''} />
|
||||
<RefreshCw size={20} className={(loading || loadingNewer || refreshSpin) ? 'spinning' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,10 +319,21 @@ export default function SnsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <div className="status-indicator loading-more">
|
||||
<RefreshCw size={16} className="spinning" />
|
||||
<span>正在加载更多...</span>
|
||||
</div>}
|
||||
{loading && posts.length === 0 && (
|
||||
<div className="initial-loading">
|
||||
<div className="loading-pulse">
|
||||
<div className="pulse-circle"></div>
|
||||
<span>正在加载朋友圈...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && posts.length > 0 && (
|
||||
<div className="status-indicator loading-more">
|
||||
<RefreshCw size={16} className="spinning" />
|
||||
<span>正在加载更多...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMore && posts.length > 0 && (
|
||||
<div className="status-indicator no-more">已经到底啦</div>
|
||||
@@ -367,6 +406,338 @@ export default function SnsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导出对话框 */}
|
||||
{showExportDialog && (
|
||||
<div className="modal-overlay" onClick={() => !isExporting && setShowExportDialog(false)}>
|
||||
<div className="export-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="export-dialog-header">
|
||||
<h3>导出朋友圈</h3>
|
||||
<button className="close-btn" onClick={() => !isExporting && setShowExportDialog(false)} disabled={isExporting}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="export-dialog-body">
|
||||
{/* 筛选条件提示 */}
|
||||
{(selectedUsernames.length > 0 || searchKeyword) && (
|
||||
<div className="export-filter-info">
|
||||
<span className="filter-badge">筛选导出</span>
|
||||
{searchKeyword && <span className="filter-tag">关键词: "{searchKeyword}"</span>}
|
||||
{selectedUsernames.length > 0 && (
|
||||
<span className="filter-tag">
|
||||
<Users size={12} />
|
||||
{selectedUsernames.length} 个联系人
|
||||
<span className="sync-hint">(同步自侧栏筛选)</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!exportResult ? (
|
||||
<>
|
||||
{/* 格式选择 */}
|
||||
<div className="export-section">
|
||||
<label className="export-label">导出格式</label>
|
||||
<div className="export-format-options">
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'html' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('html')}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<FileText size={20} />
|
||||
<span>HTML</span>
|
||||
<small>浏览器可直接查看</small>
|
||||
</button>
|
||||
<button
|
||||
className={`format-option ${exportFormat === 'json' ? 'active' : ''}`}
|
||||
onClick={() => setExportFormat('json')}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<FileJson size={20} />
|
||||
<span>JSON</span>
|
||||
<small>结构化数据</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 输出路径 */}
|
||||
<div className="export-section">
|
||||
<label className="export-label">输出目录</label>
|
||||
<div className="export-path-row">
|
||||
<input
|
||||
type="text"
|
||||
value={exportFolder}
|
||||
readOnly
|
||||
placeholder="点击选择输出目录..."
|
||||
className="export-path-input"
|
||||
/>
|
||||
<button
|
||||
className="export-browse-btn"
|
||||
onClick={async () => {
|
||||
const result = await window.electronAPI.sns.selectExportDir()
|
||||
if (!result.canceled && result.filePath) {
|
||||
setExportFolder(result.filePath)
|
||||
}
|
||||
}}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时间范围 */}
|
||||
<div className="export-section">
|
||||
<label className="export-label"><Calendar size={14} /> 时间范围(可选)</label>
|
||||
<div className="export-date-row">
|
||||
<div className="date-picker-trigger" onClick={() => {
|
||||
if (!isExporting) setCalendarPicker(prev => prev?.field === 'start' ? null : { field: 'start', month: exportDateRange.start ? new Date(exportDateRange.start) : new Date() })
|
||||
}}>
|
||||
<Calendar size={14} />
|
||||
<span className={exportDateRange.start ? '' : 'placeholder'}>
|
||||
{exportDateRange.start || '开始日期'}
|
||||
</span>
|
||||
{exportDateRange.start && (
|
||||
<X size={12} className="clear-date" onClick={(e) => { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, start: '' })) }} />
|
||||
)}
|
||||
</div>
|
||||
<span className="date-separator">至</span>
|
||||
<div className="date-picker-trigger" onClick={() => {
|
||||
if (!isExporting) setCalendarPicker(prev => prev?.field === 'end' ? null : { field: 'end', month: exportDateRange.end ? new Date(exportDateRange.end) : new Date() })
|
||||
}}>
|
||||
<Calendar size={14} />
|
||||
<span className={exportDateRange.end ? '' : 'placeholder'}>
|
||||
{exportDateRange.end || '结束日期'}
|
||||
</span>
|
||||
{exportDateRange.end && (
|
||||
<X size={12} className="clear-date" onClick={(e) => { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, end: '' })) }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 媒体导出 */}
|
||||
<div className="export-section">
|
||||
<div className="export-toggle-row">
|
||||
<div className="toggle-label">
|
||||
<Image size={16} />
|
||||
<span>导出媒体文件(图片/视频)</span>
|
||||
</div>
|
||||
<button
|
||||
className={`toggle-switch${exportMedia ? ' active' : ''}`}
|
||||
onClick={() => !isExporting && setExportMedia(!exportMedia)}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<span className="toggle-knob" />
|
||||
</button>
|
||||
</div>
|
||||
{exportMedia && (
|
||||
<p className="export-media-hint">媒体文件将保存到输出目录的 media 子目录中,可能需要较长时间</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 同步提示 */}
|
||||
<div className="export-sync-hint">
|
||||
<Info size={14} />
|
||||
<span>将同步主页面的联系人范围筛选及关键词搜索</span>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{isExporting && exportProgress && (
|
||||
<div className="export-progress">
|
||||
<div className="export-progress-bar">
|
||||
<div
|
||||
className="export-progress-fill"
|
||||
style={{ width: exportProgress.total > 0 ? `${Math.round((exportProgress.current / exportProgress.total) * 100)}%` : '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="export-progress-text">{exportProgress.status}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="export-actions">
|
||||
<button
|
||||
className="export-cancel-btn"
|
||||
onClick={() => setShowExportDialog(false)}
|
||||
disabled={isExporting}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="export-start-btn"
|
||||
disabled={!exportFolder || isExporting}
|
||||
onClick={async () => {
|
||||
setIsExporting(true)
|
||||
setExportProgress({ current: 0, total: 0, status: '准备导出...' })
|
||||
setExportResult(null)
|
||||
|
||||
// 监听进度
|
||||
const removeProgress = window.electronAPI.sns.onExportProgress((progress: any) => {
|
||||
setExportProgress(progress)
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sns.exportTimeline({
|
||||
outputDir: exportFolder,
|
||||
format: exportFormat,
|
||||
usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined,
|
||||
keyword: searchKeyword || undefined,
|
||||
exportMedia,
|
||||
startTime: exportDateRange.start ? Math.floor(new Date(exportDateRange.start).getTime() / 1000) : undefined,
|
||||
endTime: exportDateRange.end ? Math.floor(new Date(exportDateRange.end + 'T23:59:59').getTime() / 1000) : undefined
|
||||
})
|
||||
setExportResult(result)
|
||||
} catch (e: any) {
|
||||
setExportResult({ success: false, error: e.message || String(e) })
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
removeProgress()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExporting ? '导出中...' : '开始导出'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* 导出结果 */
|
||||
<div className="export-result">
|
||||
{exportResult.success ? (
|
||||
<>
|
||||
<div className="export-result-icon success">
|
||||
<CheckCircle size={48} />
|
||||
</div>
|
||||
<h4>导出成功</h4>
|
||||
<p>共导出 {exportResult.postCount} 条动态{exportResult.mediaCount ? `,${exportResult.mediaCount} 个媒体文件` : ''}</p>
|
||||
<div className="export-result-actions">
|
||||
<button
|
||||
className="export-open-btn"
|
||||
onClick={() => {
|
||||
if (exportFolder) {
|
||||
window.electronAPI.shell.openExternal(`file://${exportFolder}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
打开目录
|
||||
</button>
|
||||
<button
|
||||
className="export-done-btn"
|
||||
onClick={() => setShowExportDialog(false)}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="export-result-icon error">
|
||||
<AlertCircle size={48} />
|
||||
</div>
|
||||
<h4>导出失败</h4>
|
||||
<p className="error-text">{exportResult.error}</p>
|
||||
<button
|
||||
className="export-done-btn"
|
||||
onClick={() => setExportResult(null)}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 日期选择弹窗 */}
|
||||
{calendarPicker && (
|
||||
<div className="calendar-overlay" onClick={() => setCalendarPicker(null)}>
|
||||
<div className="calendar-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="calendar-header">
|
||||
<div className="title-area">
|
||||
<Calendar size={18} />
|
||||
<h3>选择{calendarPicker.field === 'start' ? '开始' : '结束'}日期</h3>
|
||||
</div>
|
||||
<button className="close-btn" onClick={() => setCalendarPicker(null)}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="calendar-view">
|
||||
<div className="calendar-nav">
|
||||
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() - 1, 1) } : null)}>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="current-month">
|
||||
{calendarPicker.month.getFullYear()}年{calendarPicker.month.getMonth() + 1}月
|
||||
</span>
|
||||
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() + 1, 1) } : null)}>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="calendar-weekdays">
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map(d => <div key={d} className="weekday">{d}</div>)}
|
||||
</div>
|
||||
<div className="calendar-days">
|
||||
{(() => {
|
||||
const y = calendarPicker.month.getFullYear()
|
||||
const m = calendarPicker.month.getMonth()
|
||||
const firstDay = new Date(y, m, 1).getDay()
|
||||
const daysInMonth = new Date(y, m + 1, 0).getDate()
|
||||
const cells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDay; i++) cells.push(null)
|
||||
for (let i = 1; i <= daysInMonth; i++) cells.push(i)
|
||||
const today = new Date()
|
||||
return cells.map((day, i) => {
|
||||
if (day === null) return <div key={i} className="day-cell empty" />
|
||||
const dateStr = `${y}-${String(m + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
const isToday = day === today.getDate() && m === today.getMonth() && y === today.getFullYear()
|
||||
const currentVal = calendarPicker.field === 'start' ? exportDateRange.start : exportDateRange.end
|
||||
const isSelected = dateStr === currentVal
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`day-cell${isSelected ? ' selected' : ''}${isToday ? ' today' : ''}`}
|
||||
onClick={() => {
|
||||
setExportDateRange(prev => ({ ...prev, [calendarPicker.field]: dateStr }))
|
||||
setCalendarPicker(null)
|
||||
}}
|
||||
>{day}</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="quick-options">
|
||||
<button onClick={() => {
|
||||
if (calendarPicker.field === 'start') {
|
||||
const d = new Date(); d.setMonth(d.getMonth() - 1)
|
||||
setExportDateRange(prev => ({ ...prev, start: d.toISOString().split('T')[0] }))
|
||||
} else {
|
||||
setExportDateRange(prev => ({ ...prev, end: new Date().toISOString().split('T')[0] }))
|
||||
}
|
||||
setCalendarPicker(null)
|
||||
}}>{calendarPicker.field === 'start' ? '一个月前' : '今天'}</button>
|
||||
<button onClick={() => {
|
||||
if (calendarPicker.field === 'start') {
|
||||
const d = new Date(); d.setMonth(d.getMonth() - 3)
|
||||
setExportDateRange(prev => ({ ...prev, start: d.toISOString().split('T')[0] }))
|
||||
} else {
|
||||
const d = new Date(); d.setMonth(d.getMonth() - 1)
|
||||
setExportDateRange(prev => ({ ...prev, end: d.toISOString().split('T')[0] }))
|
||||
}
|
||||
setCalendarPicker(null)
|
||||
}}>{calendarPicker.field === 'start' ? '三个月前' : '一个月前'}</button>
|
||||
</div>
|
||||
<div className="dialog-footer">
|
||||
<button className="cancel-btn" onClick={() => setCalendarPicker(null)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user