新增资源管理并修复了朋友圈的资源缓存路径
This commit is contained in:
1 parent
20c5381211
commit
d128bedffa
23 files changed
+3860
-86
No files matched your search
@@ -0,0 +1,620 @@
|
||||
.resources-page.stream-rebuild {
|
||||
--stream-columns: 4;
|
||||
--stream-grid-gap: 12px;
|
||||
--stream-card-width: 272px;
|
||||
--stream-card-height: 356px;
|
||||
--stream-visual-height: 236px;
|
||||
--stream-slot-width: calc(var(--stream-card-width) + var(--stream-grid-gap));
|
||||
--stream-slot-height: calc(var(--stream-card-height) + var(--stream-grid-gap));
|
||||
--stream-grid-width: calc(var(--stream-slot-width) * var(--stream-columns));
|
||||
height: calc(100% + 48px);
|
||||
margin: -24px;
|
||||
padding: 16px 18px;
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.stream-toolbar {
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
|
||||
background: var(--card-bg, #f8f9fb);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.media-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 85%, transparent);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #5f6674);
|
||||
border-radius: 9px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--primary) 18%, var(--card-bg));
|
||||
color: var(--text-primary, #1c2230);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 45%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.filter-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color, #d2d7df) 95%, transparent);
|
||||
background: var(--bg-secondary, #f3f5f8);
|
||||
color: var(--text-secondary, #566074);
|
||||
border-radius: 10px;
|
||||
padding: 0 10px;
|
||||
min-height: 36px;
|
||||
box-sizing: border-box;
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary, #8a92a3);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.filter-date {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #1c2230);
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.contact-select {
|
||||
width: 100%;
|
||||
min-width: 220px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
width: 128px;
|
||||
min-width: 128px;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #5f6674);
|
||||
border-radius: 10px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
border-color: color-mix(in srgb, var(--border-color, #d2d7df) 95%, transparent);
|
||||
background: var(--bg-secondary, #f3f5f8);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary, #f3f5f8);
|
||||
color: var(--text-secondary, #5f6674);
|
||||
border-radius: 10px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.58;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, var(--border-color));
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 10%, var(--bg-secondary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stream-summary {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stream-state {
|
||||
height: 120px;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&.error {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 45%, var(--border-color));
|
||||
}
|
||||
}
|
||||
|
||||
.stream-grid-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--card-bg) 94%, transparent);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stream-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.stream-grid-list,
|
||||
.virtuoso-grid-list {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
align-content: flex-start;
|
||||
padding: 10px 0 2px;
|
||||
width: var(--stream-grid-width);
|
||||
min-width: var(--stream-grid-width);
|
||||
max-width: var(--stream-grid-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stream-grid-item,
|
||||
.virtuoso-grid-item {
|
||||
box-sizing: border-box;
|
||||
width: var(--stream-slot-width);
|
||||
min-width: var(--stream-slot-width);
|
||||
max-width: var(--stream-slot-width);
|
||||
flex: 0 0 var(--stream-slot-width);
|
||||
height: var(--stream-slot-height);
|
||||
padding-right: var(--stream-grid-gap);
|
||||
padding-bottom: var(--stream-grid-gap);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.stream-grid-item > *,
|
||||
.virtuoso-grid-item > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.16s ease;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: color-mix(in srgb, var(--primary) 56%, var(--border-color));
|
||||
outline: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
&.decrypting {
|
||||
.card-visual {
|
||||
opacity: 0.68;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.floating-delete {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 4;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border-color));
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: translateY(-2px) scale(0.96);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.media-card:hover .floating-delete,
|
||||
.media-card:focus-within .floating-delete {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.floating-update {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 4;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 45%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, transparent);
|
||||
color: var(--text-primary);
|
||||
border-radius: 9px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.card-visual {
|
||||
width: 100%;
|
||||
height: var(--stream-visual-height);
|
||||
min-height: var(--stream-visual-height);
|
||||
max-height: var(--stream-visual-height);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 70%, transparent);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.image img,
|
||||
&.video img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.image img.long-image {
|
||||
object-fit: cover;
|
||||
object-position: top center;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
max-width: 90%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.decrypting-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(140deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.04));
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -40%;
|
||||
background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.35) 50%, transparent 65%);
|
||||
animation: decrypt-sheen 1.6s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.decrypting-spinner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(15, 23, 42, 0.2);
|
||||
border-top-color: color-mix(in srgb, var(--primary) 78%, #ffffff);
|
||||
animation: decrypt-spin 0.85s linear infinite, decrypt-pulse 1.2s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 0 0 8px rgba(255, 255, 255, 0.26),
|
||||
0 10px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
padding: 9px 10px 8px;
|
||||
min-height: 66px;
|
||||
margin-top: auto;
|
||||
cursor: pointer;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-secondary) 68%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.title-row,
|
||||
.sub-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
.session {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 62%;
|
||||
}
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sub-row {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.grid-loading-more,
|
||||
.grid-end {
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: resources-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.action-message {
|
||||
color: color-mix(in srgb, var(--primary) 75%, var(--text-secondary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.resource-dialog-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(8, 11, 18, 0.24);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.resource-dialog {
|
||||
width: min(420px, calc(100% - 32px));
|
||||
background: var(--card-bg, #ffffff);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 85%, transparent);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 16px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
padding: 0 14px 14px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
min-width: 72px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
&.ghost {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.solid {
|
||||
background: color-mix(in srgb, var(--primary) 16%, var(--bg-secondary));
|
||||
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.resources-page.stream-rebuild {
|
||||
.stream-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.filters {
|
||||
.filter-select {
|
||||
min-width: 220px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.resources-page.stream-rebuild {
|
||||
--stream-grid-width: calc(var(--stream-slot-width) * var(--stream-columns));
|
||||
|
||||
.stream-grid-list,
|
||||
.virtuoso-grid-list {
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes resources-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes decrypt-sheen {
|
||||
from {
|
||||
transform: translateX(-45%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(45%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes decrypt-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes decrypt-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.92;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.68;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1265 @@
|
||||
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
|
||||
import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
|
||||
import { VirtuosoGrid } from 'react-virtuoso'
|
||||
import './ResourcesPage.scss'
|
||||
|
||||
type MediaTab = 'image' | 'video'
|
||||
|
||||
interface MediaStreamItem {
|
||||
sessionId: string
|
||||
sessionDisplayName?: string
|
||||
mediaType: 'image' | 'video'
|
||||
localId: number
|
||||
serverId?: string
|
||||
createTime: number
|
||||
localType: number
|
||||
senderUsername?: string
|
||||
isSend?: number | null
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
videoMd5?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface ContactOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type DialogState = {
|
||||
mode: 'alert' | 'confirm'
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm?: (() => void) | null
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 120
|
||||
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 18
|
||||
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 36
|
||||
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 4
|
||||
|
||||
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
|
||||
const { className = '', ...rest } = props
|
||||
const mergedClassName = ['stream-grid-list', className].filter(Boolean).join(' ')
|
||||
return <div ref={ref} className={mergedClassName} {...rest} />
|
||||
})
|
||||
|
||||
function GridItem(props: HTMLAttributes<HTMLDivElement>) {
|
||||
const { className = '', ...rest } = props
|
||||
const mergedClassName = ['stream-grid-item', className].filter(Boolean).join(' ')
|
||||
return <div className={mergedClassName} {...rest} />
|
||||
}
|
||||
|
||||
function getRangeTimestampStart(date: string): number | undefined {
|
||||
if (!date) return undefined
|
||||
const parsed = new Date(`${date}T00:00:00`)
|
||||
const n = Math.floor(parsed.getTime() / 1000)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
function getRangeTimestampEnd(date: string): number | undefined {
|
||||
if (!date) return undefined
|
||||
const parsed = new Date(`${date}T23:59:59`)
|
||||
const n = Math.floor(parsed.getTime() / 1000)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
function getItemKey(item: MediaStreamItem): string {
|
||||
const sessionId = String(item.sessionId || '').trim().toLowerCase()
|
||||
const localId = Number(item.localId || 0)
|
||||
if (sessionId && Number.isFinite(localId) && localId > 0) {
|
||||
return `${sessionId}|${localId}`
|
||||
}
|
||||
|
||||
const serverId = String(item.serverId || '').trim().toLowerCase()
|
||||
const createTime = Number(item.createTime || 0)
|
||||
const localType = Number(item.localType || 0)
|
||||
const mediaId = String(
|
||||
item.mediaType === 'video'
|
||||
? (item.videoMd5 || '')
|
||||
: (item.imageMd5 || item.imageDatName || '')
|
||||
).trim().toLowerCase()
|
||||
return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}`
|
||||
}
|
||||
|
||||
function formatTimeLabel(timestampSec: number): string {
|
||||
if (!timestampSec) return '--:--'
|
||||
return new Date(timestampSec * 1000).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function extractVideoTitle(content?: string): string {
|
||||
const xml = String(content || '')
|
||||
if (!xml) return '视频'
|
||||
const match = /<title>([\s\S]*?)<\/title>/i.exec(xml)
|
||||
const text = String(match?.[1] || '').replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
|
||||
return text || '视频'
|
||||
}
|
||||
|
||||
function toRenderableMediaSrc(rawPath?: string): string {
|
||||
const src = String(rawPath || '').trim()
|
||||
if (!src) return ''
|
||||
if (/^(data:image\/|blob:|https?:\/\/)/i.test(src)) {
|
||||
return src
|
||||
}
|
||||
if (/^file:\/\//i.test(src)) {
|
||||
return src.replace(/#/g, '%23')
|
||||
}
|
||||
if (src.startsWith('/')) {
|
||||
return encodeURI(`file://${src}`).replace(/#/g, '%23')
|
||||
}
|
||||
if (/^[a-zA-Z]:[\\/]/.test(src)) {
|
||||
return encodeURI(`file:///${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
|
||||
}
|
||||
return encodeURI(`file://${src.startsWith('/') ? '' : '/'}${src.replace(/\\/g, '/')}`).replace(/#/g, '%23')
|
||||
}
|
||||
|
||||
const MediaCard = memo(function MediaCard({
|
||||
item,
|
||||
sessionName,
|
||||
previewPath,
|
||||
videoPosterPath,
|
||||
imageIsLong,
|
||||
hasPreviewUpdate,
|
||||
selected,
|
||||
decrypting,
|
||||
onToggleSelect,
|
||||
onDelete,
|
||||
onImagePreviewAction,
|
||||
onUpdateImageQuality,
|
||||
onOpenVideo,
|
||||
onImageLoaded
|
||||
}: {
|
||||
item: MediaStreamItem
|
||||
sessionName: string
|
||||
previewPath: string
|
||||
videoPosterPath: string
|
||||
imageIsLong: boolean
|
||||
hasPreviewUpdate: boolean
|
||||
selected: boolean
|
||||
decrypting: boolean
|
||||
onToggleSelect: (item: MediaStreamItem) => void
|
||||
onDelete: (item: MediaStreamItem) => void
|
||||
onImagePreviewAction: (item: MediaStreamItem) => void
|
||||
onUpdateImageQuality: (item: MediaStreamItem) => void
|
||||
onOpenVideo: (item: MediaStreamItem) => void
|
||||
onImageLoaded: (item: MediaStreamItem, width: number, height: number) => void
|
||||
}) {
|
||||
const isImage = item.mediaType === 'image'
|
||||
const isDecryptingVisual = decrypting
|
||||
const showDecryptOverlay = isImage && isDecryptingVisual
|
||||
|
||||
return (
|
||||
<article className={`media-card ${selected ? 'selected' : ''} ${isDecryptingVisual ? 'decrypting' : ''}`}>
|
||||
<button type="button" className="floating-delete" onClick={() => onDelete(item)} aria-label="删除资源">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
||||
{isImage && hasPreviewUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="floating-update"
|
||||
disabled={decrypting}
|
||||
onClick={() => onUpdateImageQuality(item)}
|
||||
title="已扫描到高清图,点击更新画质"
|
||||
aria-label="更新画质"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
更新
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`card-visual ${isImage ? 'image' : 'video'}`}
|
||||
disabled={isImage && isDecryptingVisual}
|
||||
onClick={() => {
|
||||
if (isImage) {
|
||||
onImagePreviewAction(item)
|
||||
return
|
||||
}
|
||||
onOpenVideo(item)
|
||||
}}
|
||||
>
|
||||
{isImage ? (
|
||||
previewPath
|
||||
? <img
|
||||
src={toRenderableMediaSrc(previewPath)}
|
||||
alt="图片资源"
|
||||
className={imageIsLong ? 'long-image' : ''}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={(event) => {
|
||||
const width = event.currentTarget.naturalWidth || 0
|
||||
const height = event.currentTarget.naturalHeight || 0
|
||||
onImageLoaded(item, width, height)
|
||||
}}
|
||||
/>
|
||||
: <div className="placeholder"><ImageIcon size={30} /></div>
|
||||
) : (
|
||||
videoPosterPath
|
||||
? <img src={toRenderableMediaSrc(videoPosterPath)} alt="视频封面" loading="lazy" decoding="async" />
|
||||
: <div className="placeholder">
|
||||
<PlayCircle size={34} />
|
||||
<span>{extractVideoTitle(item.content)}</span>
|
||||
</div>
|
||||
)}
|
||||
{showDecryptOverlay && (
|
||||
<div className="decrypting-overlay" aria-hidden="true">
|
||||
<div className="decrypting-spinner" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="card-meta" onClick={() => onToggleSelect(item)}>
|
||||
<div className="title-row">
|
||||
<span className="session" title={sessionName}>{sessionName}</span>
|
||||
<span className="time">{formatTimeLabel(item.createTime)}</span>
|
||||
</div>
|
||||
<div className="sub-row">
|
||||
<span>{item.mediaType === 'image' ? '图片' : '视频'}</span>
|
||||
{item.senderUsername && <span>{item.senderUsername}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})
|
||||
|
||||
function ResourcesPage() {
|
||||
const [tab, setTab] = useState<MediaTab>('image')
|
||||
const [contacts, setContacts] = useState<ContactOption[]>([{ id: 'all', name: '全部联系人' }])
|
||||
const [selectedContact, setSelectedContact] = useState('all')
|
||||
const [dateStart, setDateStart] = useState('')
|
||||
const [dateEnd, setDateEnd] = useState('')
|
||||
|
||||
const [items, setItems] = useState<MediaStreamItem[]>([])
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
|
||||
const [nextOffset, setNextOffset] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [batchBusy, setBatchBusy] = useState(false)
|
||||
const [decryptingKeys, setDecryptingKeys] = useState<Set<string>>(new Set())
|
||||
const [actionMessage, setActionMessage] = useState('')
|
||||
const [sessionNameMap, setSessionNameMap] = useState<Record<string, string>>({})
|
||||
const [previewPathMap, setPreviewPathMap] = useState<Record<string, string>>({})
|
||||
const [previewUpdateMap, setPreviewUpdateMap] = useState<Record<string, boolean>>({})
|
||||
const [videoPosterMap, setVideoPosterMap] = useState<Record<string, string>>({})
|
||||
const [imageAspectMap, setImageAspectMap] = useState<Record<string, number>>({})
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null)
|
||||
|
||||
const previewPathMapRef = useRef<Record<string, string>>({})
|
||||
const previewUpdateMapRef = useRef<Record<string, boolean>>({})
|
||||
const videoPosterMapRef = useRef<Record<string, string>>({})
|
||||
const imageAspectMapRef = useRef<Record<string, number>>({})
|
||||
const resolvingImageCacheBatchRef = useRef(false)
|
||||
const pendingImageResolveRangeRef = useRef<{ start: number; end: number } | null>(null)
|
||||
const imagePreloadUntilRef = useRef<Record<string, number>>({})
|
||||
const imageCacheMissUntilRef = useRef<Record<string, number>>({})
|
||||
const resolvingVideoPosterKeysRef = useRef<Set<string>>(new Set())
|
||||
const attemptedVideoPosterKeysRef = useRef<Set<string>>(new Set())
|
||||
const resolvedVideoMd5Ref = useRef<Record<string, string>>({})
|
||||
const previewPatchRef = useRef<Record<string, string>>({})
|
||||
const updatePatchRef = useRef<Record<string, boolean>>({})
|
||||
const previewPatchTimerRef = useRef<number | null>(null)
|
||||
const posterPatchRef = useRef<Record<string, string>>({})
|
||||
const posterPatchTimerRef = useRef<number | null>(null)
|
||||
const aspectPatchRef = useRef<Record<string, number>>({})
|
||||
const aspectPatchTimerRef = useRef<number | null>(null)
|
||||
const pendingRangeRef = useRef<{ start: number; end: number } | null>(null)
|
||||
const rangeTimerRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
previewPathMapRef.current = previewPathMap
|
||||
}, [previewPathMap])
|
||||
|
||||
useEffect(() => {
|
||||
previewUpdateMapRef.current = previewUpdateMap
|
||||
}, [previewUpdateMap])
|
||||
|
||||
useEffect(() => {
|
||||
videoPosterMapRef.current = videoPosterMap
|
||||
}, [videoPosterMap])
|
||||
|
||||
useEffect(() => {
|
||||
imageAspectMapRef.current = imageAspectMap
|
||||
}, [imageAspectMap])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (previewPatchTimerRef.current !== null) {
|
||||
window.clearTimeout(previewPatchTimerRef.current)
|
||||
previewPatchTimerRef.current = null
|
||||
}
|
||||
if (posterPatchTimerRef.current !== null) {
|
||||
window.clearTimeout(posterPatchTimerRef.current)
|
||||
posterPatchTimerRef.current = null
|
||||
}
|
||||
if (aspectPatchTimerRef.current !== null) {
|
||||
window.clearTimeout(aspectPatchTimerRef.current)
|
||||
aspectPatchTimerRef.current = null
|
||||
}
|
||||
if (rangeTimerRef.current !== null) {
|
||||
window.clearTimeout(rangeTimerRef.current)
|
||||
rangeTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showAlert = useCallback((message: string, title: string = '提示') => {
|
||||
setDialog({
|
||||
mode: 'alert',
|
||||
title,
|
||||
message,
|
||||
confirmText: '确定',
|
||||
onConfirm: null
|
||||
})
|
||||
}, [])
|
||||
|
||||
const showConfirm = useCallback((message: string, onConfirm: () => void, title: string = '确认操作') => {
|
||||
setDialog({
|
||||
mode: 'confirm',
|
||||
title,
|
||||
message,
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
onConfirm
|
||||
})
|
||||
}, [])
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialog(null)
|
||||
}, [])
|
||||
|
||||
const isLikelyThumbnailPreview = useCallback((path: string): boolean => {
|
||||
const lower = String(path || '').toLowerCase()
|
||||
if (!lower) return false
|
||||
return lower.includes('_thumb') || lower.includes('_t.') || lower.includes('.t.')
|
||||
}, [])
|
||||
|
||||
const flushPreviewPatch = useCallback(() => {
|
||||
const pathPatch = previewPatchRef.current
|
||||
const updatePatch = updatePatchRef.current
|
||||
previewPatchRef.current = {}
|
||||
updatePatchRef.current = {}
|
||||
previewPatchTimerRef.current = null
|
||||
const hasPathPatch = Object.keys(pathPatch).length > 0
|
||||
const hasUpdatePatch = Object.keys(updatePatch).length > 0
|
||||
if (hasPathPatch) {
|
||||
setPreviewPathMap((prev) => ({ ...prev, ...pathPatch }))
|
||||
}
|
||||
if (hasUpdatePatch) {
|
||||
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const queuePreviewPatch = useCallback((itemKey: string, localPath: string, hasUpdate: boolean) => {
|
||||
if (!itemKey || !localPath) return
|
||||
if (previewPathMapRef.current[itemKey] === localPath && previewUpdateMapRef.current[itemKey] === hasUpdate) return
|
||||
previewPatchRef.current[itemKey] = localPath
|
||||
updatePatchRef.current[itemKey] = hasUpdate
|
||||
if (previewPatchTimerRef.current !== null) return
|
||||
previewPatchTimerRef.current = window.setTimeout(flushPreviewPatch, 16)
|
||||
}, [flushPreviewPatch])
|
||||
|
||||
const flushPosterPatch = useCallback(() => {
|
||||
const patch = posterPatchRef.current
|
||||
posterPatchRef.current = {}
|
||||
posterPatchTimerRef.current = null
|
||||
if (Object.keys(patch).length === 0) return
|
||||
setVideoPosterMap((prev) => ({ ...prev, ...patch }))
|
||||
}, [])
|
||||
|
||||
const queuePosterPatch = useCallback((itemKey: string, posterPath: string) => {
|
||||
if (!itemKey || !posterPath) return
|
||||
if (videoPosterMapRef.current[itemKey] === posterPath) return
|
||||
posterPatchRef.current[itemKey] = posterPath
|
||||
if (posterPatchTimerRef.current !== null) return
|
||||
posterPatchTimerRef.current = window.setTimeout(flushPosterPatch, 16)
|
||||
}, [flushPosterPatch])
|
||||
|
||||
const flushAspectPatch = useCallback(() => {
|
||||
const patch = aspectPatchRef.current
|
||||
aspectPatchRef.current = {}
|
||||
aspectPatchTimerRef.current = null
|
||||
if (Object.keys(patch).length === 0) return
|
||||
setImageAspectMap((prev) => ({ ...prev, ...patch }))
|
||||
}, [])
|
||||
|
||||
const queueAspectPatch = useCallback((itemKey: string, ratio: number) => {
|
||||
const old = imageAspectMapRef.current[itemKey]
|
||||
if (typeof old === 'number' && Math.abs(old - ratio) < 0.01) return
|
||||
aspectPatchRef.current[itemKey] = ratio
|
||||
if (aspectPatchTimerRef.current !== null) return
|
||||
aspectPatchTimerRef.current = window.setTimeout(flushAspectPatch, 24)
|
||||
}, [flushAspectPatch])
|
||||
|
||||
const loadStream = useCallback(async (reset: boolean) => {
|
||||
if (reset) setLoading(true)
|
||||
else setLoadingMore(true)
|
||||
if (reset) {
|
||||
setError('')
|
||||
setActionMessage('')
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI.chat.connect()
|
||||
const requestOffset = reset ? 0 : nextOffset
|
||||
const streamResult = await window.electronAPI.chat.getMediaStream({
|
||||
sessionId: selectedContact === 'all' ? undefined : selectedContact,
|
||||
mediaType: tab,
|
||||
beginTimestamp: getRangeTimestampStart(dateStart),
|
||||
endTimestamp: getRangeTimestampEnd(dateEnd),
|
||||
offset: requestOffset,
|
||||
limit: PAGE_SIZE
|
||||
})
|
||||
|
||||
if (!streamResult.success) {
|
||||
setError(streamResult.error || '加载失败')
|
||||
if (reset) {
|
||||
previewPatchRef.current = {}
|
||||
updatePatchRef.current = {}
|
||||
posterPatchRef.current = {}
|
||||
aspectPatchRef.current = {}
|
||||
if (previewPatchTimerRef.current !== null) {
|
||||
window.clearTimeout(previewPatchTimerRef.current)
|
||||
previewPatchTimerRef.current = null
|
||||
}
|
||||
if (posterPatchTimerRef.current !== null) {
|
||||
window.clearTimeout(posterPatchTimerRef.current)
|
||||
posterPatchTimerRef.current = null
|
||||
}
|
||||
if (aspectPatchTimerRef.current !== null) {
|
||||
window.clearTimeout(aspectPatchTimerRef.current)
|
||||
aspectPatchTimerRef.current = null
|
||||
}
|
||||
if (rangeTimerRef.current !== null) {
|
||||
window.clearTimeout(rangeTimerRef.current)
|
||||
rangeTimerRef.current = null
|
||||
}
|
||||
pendingRangeRef.current = null
|
||||
resolvingImageCacheBatchRef.current = false
|
||||
pendingImageResolveRangeRef.current = null
|
||||
imagePreloadUntilRef.current = {}
|
||||
imageCacheMissUntilRef.current = {}
|
||||
resolvingVideoPosterKeysRef.current.clear()
|
||||
attemptedVideoPosterKeysRef.current.clear()
|
||||
resolvedVideoMd5Ref.current = {}
|
||||
setItems([])
|
||||
setNextOffset(0)
|
||||
setHasMore(false)
|
||||
setSelectedKeys(new Set())
|
||||
setPreviewPathMap({})
|
||||
setPreviewUpdateMap({})
|
||||
setVideoPosterMap({})
|
||||
setImageAspectMap({})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const incoming = (streamResult.items || []) as MediaStreamItem[]
|
||||
if (reset) {
|
||||
previewPatchRef.current = {}
|
||||
updatePatchRef.current = {}
|
||||
posterPatchRef.current = {}
|
||||
aspectPatchRef.current = {}
|
||||
if (previewPatchTimerRef.current !== null) {
|
||||
window.clearTimeout(previewPatchTimerRef.current)
|
||||
previewPatchTimerRef.current = null
|
||||
}
|
||||
if (posterPatchTimerRef.current !== null) {
|
||||
window.clearTimeout(posterPatchTimerRef.current)
|
||||
posterPatchTimerRef.current = null
|
||||
}
|
||||
if (aspectPatchTimerRef.current !== null) {
|
||||
window.clearTimeout(aspectPatchTimerRef.current)
|
||||
aspectPatchTimerRef.current = null
|
||||
}
|
||||
if (rangeTimerRef.current !== null) {
|
||||
window.clearTimeout(rangeTimerRef.current)
|
||||
rangeTimerRef.current = null
|
||||
}
|
||||
pendingRangeRef.current = null
|
||||
resolvingImageCacheBatchRef.current = false
|
||||
pendingImageResolveRangeRef.current = null
|
||||
imagePreloadUntilRef.current = {}
|
||||
imageCacheMissUntilRef.current = {}
|
||||
resolvingVideoPosterKeysRef.current.clear()
|
||||
attemptedVideoPosterKeysRef.current.clear()
|
||||
resolvedVideoMd5Ref.current = {}
|
||||
setItems(incoming)
|
||||
setSelectedKeys(new Set())
|
||||
setPreviewPathMap({})
|
||||
setPreviewUpdateMap({})
|
||||
setVideoPosterMap({})
|
||||
setImageAspectMap({})
|
||||
} else {
|
||||
setItems((prev) => {
|
||||
const map = new Map(prev.map((row) => [getItemKey(row), row]))
|
||||
incoming.forEach((row) => map.set(getItemKey(row), row))
|
||||
return Array.from(map.values())
|
||||
})
|
||||
}
|
||||
setNextOffset(Number(streamResult.nextOffset || requestOffset + incoming.length))
|
||||
setHasMore(Boolean(streamResult.hasMore))
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [dateEnd, dateStart, nextOffset, selectedContact, tab])
|
||||
|
||||
useEffect(() => {
|
||||
void loadStream(true)
|
||||
}, [tab, selectedContact, dateStart, dateEnd])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
try {
|
||||
await window.electronAPI.chat.connect()
|
||||
const sessionResult = await window.electronAPI.chat.getSessions()
|
||||
if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) {
|
||||
const initialNameMap: Record<string, string> = {}
|
||||
sessionResult.sessions.forEach((session) => {
|
||||
initialNameMap[session.username] = session.displayName || session.username
|
||||
})
|
||||
setSessionNameMap(initialNameMap)
|
||||
setContacts([
|
||||
{ id: 'all', name: '全部联系人' },
|
||||
...sessionResult.sessions.map((session) => ({
|
||||
id: session.username,
|
||||
name: session.displayName || session.username
|
||||
}))
|
||||
])
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const displayItems = useMemo(() => (
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
sessionDisplayName: item.sessionDisplayName || sessionNameMap[item.sessionId] || item.sessionId
|
||||
}))
|
||||
), [items, sessionNameMap])
|
||||
|
||||
useEffect(() => {
|
||||
const imageKeySet = new Set(
|
||||
displayItems
|
||||
.filter((item) => item.mediaType === 'image')
|
||||
.map((item) => getItemKey(item))
|
||||
)
|
||||
const videoKeySet = new Set(
|
||||
displayItems
|
||||
.filter((item) => item.mediaType === 'video')
|
||||
.map((item) => getItemKey(item))
|
||||
)
|
||||
|
||||
setPreviewPathMap((prev) => {
|
||||
let changed = false
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(prev)) {
|
||||
if (!imageKeySet.has(key)) continue
|
||||
next[key] = value
|
||||
}
|
||||
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
|
||||
return changed ? next : prev
|
||||
})
|
||||
|
||||
setPreviewUpdateMap((prev) => {
|
||||
let changed = false
|
||||
const next: Record<string, boolean> = {}
|
||||
for (const [key, value] of Object.entries(prev)) {
|
||||
if (!imageKeySet.has(key)) continue
|
||||
next[key] = value
|
||||
}
|
||||
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
|
||||
return changed ? next : prev
|
||||
})
|
||||
|
||||
setImageAspectMap((prev) => {
|
||||
let changed = false
|
||||
const next: Record<string, number> = {}
|
||||
for (const [key, value] of Object.entries(prev)) {
|
||||
if (!imageKeySet.has(key)) continue
|
||||
next[key] = value
|
||||
}
|
||||
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
|
||||
return changed ? next : prev
|
||||
})
|
||||
|
||||
setVideoPosterMap((prev) => {
|
||||
let changed = false
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(prev)) {
|
||||
if (!videoKeySet.has(key)) continue
|
||||
next[key] = value
|
||||
}
|
||||
if (Object.keys(next).length !== Object.keys(prev).length) changed = true
|
||||
return changed ? next : prev
|
||||
})
|
||||
|
||||
const validKeys = new Set<string>(displayItems.map((item) => getItemKey(item)))
|
||||
const nextResolvedVideoMd5: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(resolvedVideoMd5Ref.current)) {
|
||||
if (!validKeys.has(key)) continue
|
||||
nextResolvedVideoMd5[key] = value
|
||||
}
|
||||
resolvedVideoMd5Ref.current = nextResolvedVideoMd5
|
||||
|
||||
const nextAttempted = new Set<string>()
|
||||
attemptedVideoPosterKeysRef.current.forEach((key) => {
|
||||
if (validKeys.has(key)) nextAttempted.add(key)
|
||||
})
|
||||
attemptedVideoPosterKeysRef.current = nextAttempted
|
||||
|
||||
const nextImageMissUntil: Record<string, number> = {}
|
||||
for (const [key, value] of Object.entries(imageCacheMissUntilRef.current)) {
|
||||
if (!validKeys.has(key)) continue
|
||||
nextImageMissUntil[key] = value
|
||||
}
|
||||
imageCacheMissUntilRef.current = nextImageMissUntil
|
||||
|
||||
const nextImagePreloadUntil: Record<string, number> = {}
|
||||
for (const [key, value] of Object.entries(imagePreloadUntilRef.current)) {
|
||||
if (!validKeys.has(key)) continue
|
||||
nextImagePreloadUntil[key] = value
|
||||
}
|
||||
imagePreloadUntilRef.current = nextImagePreloadUntil
|
||||
|
||||
}, [displayItems])
|
||||
|
||||
const resolveImageCacheRange = useCallback((start: number, end: number) => {
|
||||
const from = Math.max(0, start)
|
||||
const to = Math.min(displayItems.length - 1, end)
|
||||
if (to < from) return
|
||||
const now = Date.now()
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
|
||||
const itemKeys: string[] = []
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const item = displayItems[i]
|
||||
if (!item || item.mediaType !== 'image') continue
|
||||
const itemKey = getItemKey(item)
|
||||
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
|
||||
if (!item.imageMd5 && !item.imageDatName) continue
|
||||
if ((imageCacheMissUntilRef.current[itemKey] || 0) > now) continue
|
||||
payloads.push({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
})
|
||||
itemKeys.push(itemKey)
|
||||
if (payloads.length >= MAX_IMAGE_CACHE_RESOLVE_PER_TICK) break
|
||||
}
|
||||
if (payloads.length === 0) return
|
||||
if (resolvingImageCacheBatchRef.current) {
|
||||
pendingImageResolveRangeRef.current = { start: from, end: to }
|
||||
return
|
||||
}
|
||||
|
||||
resolvingImageCacheBatchRef.current = true
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.image.resolveCacheBatch(payloads, { disableUpdateCheck: true })
|
||||
const rows = Array.isArray(result?.rows) ? result.rows : []
|
||||
const pathPatch: Record<string, string> = {}
|
||||
const updatePatch: Record<string, boolean> = {}
|
||||
const missUntil = Date.now() + 4500
|
||||
|
||||
for (let i = 0; i < itemKeys.length; i += 1) {
|
||||
const itemKey = itemKeys[i]
|
||||
const row = rows[i]
|
||||
if (row?.success && row.localPath) {
|
||||
delete imageCacheMissUntilRef.current[itemKey]
|
||||
pathPatch[itemKey] = row.localPath
|
||||
updatePatch[itemKey] = Boolean(row.hasUpdate)
|
||||
} else {
|
||||
imageCacheMissUntilRef.current[itemKey] = missUntil
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(pathPatch).length > 0) {
|
||||
setPreviewPathMap((prev) => ({ ...prev, ...pathPatch }))
|
||||
}
|
||||
if (Object.keys(updatePatch).length > 0) {
|
||||
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
|
||||
}
|
||||
} catch {
|
||||
const missUntil = Date.now() + 4500
|
||||
itemKeys.forEach((itemKey) => {
|
||||
imageCacheMissUntilRef.current[itemKey] = missUntil
|
||||
})
|
||||
} finally {
|
||||
resolvingImageCacheBatchRef.current = false
|
||||
const pending = pendingImageResolveRangeRef.current
|
||||
pendingImageResolveRangeRef.current = null
|
||||
if (pending) {
|
||||
resolveImageCacheRange(pending.start, pending.end)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, [displayItems])
|
||||
|
||||
const preloadImageCacheRange = useCallback((start: number, end: number) => {
|
||||
const from = Math.max(0, start)
|
||||
const to = Math.min(displayItems.length - 1, end)
|
||||
if (to < from) return
|
||||
|
||||
const now = Date.now()
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
|
||||
const dedup = new Set<string>()
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const item = displayItems[i]
|
||||
if (!item || item.mediaType !== 'image') continue
|
||||
const itemKey = getItemKey(item)
|
||||
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
|
||||
if (!item.imageMd5 && !item.imageDatName) continue
|
||||
if ((imagePreloadUntilRef.current[itemKey] || 0) > now) continue
|
||||
const dedupKey = `${item.sessionId || ''}|${item.imageMd5 || ''}|${item.imageDatName || ''}`
|
||||
if (dedup.has(dedupKey)) continue
|
||||
dedup.add(dedupKey)
|
||||
imagePreloadUntilRef.current[itemKey] = now + 12000
|
||||
payloads.push({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
})
|
||||
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
|
||||
}
|
||||
if (payloads.length === 0) return
|
||||
void window.electronAPI.image.preload(payloads, { allowDecrypt: false })
|
||||
}, [displayItems])
|
||||
|
||||
const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => {
|
||||
const itemKey = getItemKey(item)
|
||||
const cached = resolvedVideoMd5Ref.current[itemKey]
|
||||
if (cached) return cached
|
||||
|
||||
let md5 = String(item.videoMd5 || '').trim().toLowerCase()
|
||||
if (md5) {
|
||||
resolvedVideoMd5Ref.current[itemKey] = md5
|
||||
return md5
|
||||
}
|
||||
const parsed = await window.electronAPI.video.parseVideoMd5(String(item.content || ''))
|
||||
if (parsed.success && parsed.md5) md5 = String(parsed.md5).trim().toLowerCase()
|
||||
if (md5) resolvedVideoMd5Ref.current[itemKey] = md5
|
||||
return md5
|
||||
}, [])
|
||||
|
||||
const resolveVideoPoster = useCallback(async (item: MediaStreamItem) => {
|
||||
if (item.mediaType !== 'video') return
|
||||
const itemKey = getItemKey(item)
|
||||
if (videoPosterMapRef.current[itemKey]) return
|
||||
if (attemptedVideoPosterKeysRef.current.has(itemKey)) return
|
||||
if (resolvingVideoPosterKeysRef.current.has(itemKey)) return
|
||||
|
||||
resolvingVideoPosterKeysRef.current.add(itemKey)
|
||||
try {
|
||||
const md5 = await resolveItemVideoMd5(item)
|
||||
if (!md5) {
|
||||
attemptedVideoPosterKeysRef.current.add(itemKey)
|
||||
return
|
||||
}
|
||||
const info = await window.electronAPI.video.getVideoInfo(md5, { includePoster: true, posterFormat: 'fileUrl' })
|
||||
if (!info.success || !info.exists) {
|
||||
attemptedVideoPosterKeysRef.current.add(itemKey)
|
||||
return
|
||||
}
|
||||
const poster = String(info.coverUrl || info.thumbUrl || '')
|
||||
if (!poster) {
|
||||
attemptedVideoPosterKeysRef.current.add(itemKey)
|
||||
return
|
||||
}
|
||||
queuePosterPatch(itemKey, poster)
|
||||
attemptedVideoPosterKeysRef.current.add(itemKey)
|
||||
} catch {
|
||||
attemptedVideoPosterKeysRef.current.add(itemKey)
|
||||
} finally {
|
||||
resolvingVideoPosterKeysRef.current.delete(itemKey)
|
||||
}
|
||||
}, [queuePosterPatch, resolveItemVideoMd5])
|
||||
|
||||
const resolvePosterRange = useCallback((start: number, end: number) => {
|
||||
const from = Math.max(0, start)
|
||||
const to = Math.min(displayItems.length - 1, end)
|
||||
if (to < from) return
|
||||
let resolvedCount = 0
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const item = displayItems[i]
|
||||
if (!item || item.mediaType !== 'video') continue
|
||||
void resolveVideoPoster(item)
|
||||
resolvedCount += 1
|
||||
if (resolvedCount >= MAX_VIDEO_POSTER_RESOLVE_PER_TICK) break
|
||||
}
|
||||
}, [displayItems, resolveVideoPoster])
|
||||
|
||||
const flushRangeResolve = useCallback(() => {
|
||||
rangeTimerRef.current = null
|
||||
const pending = pendingRangeRef.current
|
||||
if (!pending) return
|
||||
pendingRangeRef.current = null
|
||||
if (tab === 'image') {
|
||||
preloadImageCacheRange(pending.start - 8, pending.end + 32)
|
||||
resolveImageCacheRange(pending.start - 2, pending.end + 8)
|
||||
return
|
||||
}
|
||||
resolvePosterRange(pending.start, pending.end)
|
||||
}, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
|
||||
|
||||
const scheduleRangeResolve = useCallback((start: number, end: number) => {
|
||||
pendingRangeRef.current = { start, end }
|
||||
if (rangeTimerRef.current !== null) {
|
||||
window.clearTimeout(rangeTimerRef.current)
|
||||
rangeTimerRef.current = null
|
||||
}
|
||||
rangeTimerRef.current = window.setTimeout(flushRangeResolve, 120)
|
||||
}, [flushRangeResolve])
|
||||
|
||||
useEffect(() => {
|
||||
if (displayItems.length === 0) return
|
||||
if (tab === 'image') {
|
||||
preloadImageCacheRange(0, Math.min(displayItems.length - 1, 80))
|
||||
resolveImageCacheRange(0, Math.min(displayItems.length - 1, 20))
|
||||
return
|
||||
}
|
||||
resolvePosterRange(0, Math.min(displayItems.length - 1, 12))
|
||||
}, [displayItems, preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
if (selectedKeys.size === 0) return []
|
||||
return displayItems.filter((item) => selectedKeys.has(getItemKey(item)))
|
||||
}, [displayItems, selectedKeys])
|
||||
|
||||
const toggleSelect = useCallback((item: MediaStreamItem) => {
|
||||
const key = getItemKey(item)
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onImageLoaded = useCallback((item: MediaStreamItem, width: number, height: number) => {
|
||||
if (item.mediaType !== 'image') return
|
||||
if (!width || !height) return
|
||||
const ratio = height / width
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) return
|
||||
const itemKey = getItemKey(item)
|
||||
queueAspectPatch(itemKey, ratio)
|
||||
}, [queueAspectPatch])
|
||||
|
||||
const deleteOne = useCallback((item: MediaStreamItem) => {
|
||||
showConfirm('确认删除该原始记录?此操作不可恢复。', () => {
|
||||
void (async () => {
|
||||
const result = await window.electronAPI.chat.deleteMessage(item.sessionId, item.localId, item.createTime)
|
||||
if (!result.success) {
|
||||
showAlert(`删除失败:${result.error || '未知错误'}`, '删除失败')
|
||||
return
|
||||
}
|
||||
|
||||
const key = getItemKey(item)
|
||||
setItems((prev) => prev.filter((row) => getItemKey(row) !== key))
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(key)
|
||||
return next
|
||||
})
|
||||
setPreviewPathMap((prev) => {
|
||||
if (!prev[key]) return prev
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setPreviewUpdateMap((prev) => {
|
||||
if (prev[key] === undefined) return prev
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setActionMessage('删除成功')
|
||||
})()
|
||||
}, '删除确认')
|
||||
}, [showAlert, showConfirm])
|
||||
|
||||
const batchDelete = useCallback(() => {
|
||||
if (selectedItems.length === 0 || batchBusy) return
|
||||
|
||||
showConfirm(`确认删除选中 ${selectedItems.length} 条记录?此操作不可恢复。`, () => {
|
||||
void (async () => {
|
||||
setBatchBusy(true)
|
||||
let success = 0
|
||||
const deletedKeys = new Set<string>()
|
||||
try {
|
||||
for (const item of selectedItems) {
|
||||
const result = await window.electronAPI.chat.deleteMessage(item.sessionId, item.localId, item.createTime)
|
||||
if (result.success) {
|
||||
success += 1
|
||||
deletedKeys.add(getItemKey(item))
|
||||
}
|
||||
}
|
||||
|
||||
setItems((prev) => prev.filter((item) => !deletedKeys.has(getItemKey(item))))
|
||||
setSelectedKeys(new Set())
|
||||
setPreviewPathMap((prev) => {
|
||||
const next = { ...prev }
|
||||
deletedKeys.forEach((key) => { delete next[key] })
|
||||
return next
|
||||
})
|
||||
setPreviewUpdateMap((prev) => {
|
||||
const next = { ...prev }
|
||||
deletedKeys.forEach((key) => { delete next[key] })
|
||||
return next
|
||||
})
|
||||
setActionMessage(`批量删除完成:成功 ${success},失败 ${selectedItems.length - success}`)
|
||||
showAlert(`批量删除完成:成功 ${success},失败 ${selectedItems.length - success}`, '批量删除完成')
|
||||
} finally {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
})()
|
||||
}, '批量删除确认')
|
||||
}, [batchBusy, selectedItems, showAlert, showConfirm])
|
||||
|
||||
const decryptImage = useCallback(async (item: MediaStreamItem): Promise<string | undefined> => {
|
||||
if (item.mediaType !== 'image') return
|
||||
|
||||
const key = getItemKey(item)
|
||||
if (!item.imageMd5 && !item.imageDatName) {
|
||||
showAlert('当前图片缺少解密所需字段(imageMd5/imageDatName)', '无法解密')
|
||||
return
|
||||
}
|
||||
|
||||
setDecryptingKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(key)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined,
|
||||
force: true
|
||||
})
|
||||
if (!result?.success) {
|
||||
showAlert(`解密失败:${result?.error || '未知错误'}`, '解密失败')
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (result.localPath) {
|
||||
const localPath = result.localPath as string
|
||||
setPreviewPathMap((prev) => ({ ...prev, [key]: localPath }))
|
||||
setPreviewUpdateMap((prev) => ({ ...prev, [key]: isLikelyThumbnailPreview(localPath) }))
|
||||
setActionMessage('图片解密完成')
|
||||
return localPath
|
||||
}
|
||||
try {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
const localPath = resolved.localPath
|
||||
setPreviewPathMap((prev) => ({ ...prev, [key]: localPath }))
|
||||
setPreviewUpdateMap((prev) => ({ ...prev, [key]: Boolean(resolved.hasUpdate) }))
|
||||
setActionMessage('图片解密完成')
|
||||
return localPath
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setActionMessage('图片解密完成')
|
||||
return undefined
|
||||
} catch (e) {
|
||||
showAlert(`解密失败:${String(e)}`, '解密失败')
|
||||
return undefined
|
||||
} finally {
|
||||
setDecryptingKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [isLikelyThumbnailPreview, showAlert])
|
||||
|
||||
const onImagePreviewAction = useCallback(async (item: MediaStreamItem) => {
|
||||
if (item.mediaType !== 'image') return
|
||||
const key = getItemKey(item)
|
||||
let localPath = previewPathMapRef.current[key] || previewPatchRef.current[key] || ''
|
||||
|
||||
if (localPath) {
|
||||
try {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
localPath = resolved.localPath
|
||||
queuePreviewPatch(key, localPath, Boolean(resolved.hasUpdate))
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (localPath) {
|
||||
await window.electronAPI.window.openImageViewerWindow(localPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
localPath = resolved.localPath
|
||||
queuePreviewPatch(key, localPath, Boolean(resolved.hasUpdate))
|
||||
await window.electronAPI.window.openImageViewerWindow(localPath)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await decryptImage(item)
|
||||
}, [decryptImage, queuePreviewPatch])
|
||||
|
||||
const updateImageQuality = useCallback(async (item: MediaStreamItem) => {
|
||||
await decryptImage(item)
|
||||
}, [decryptImage])
|
||||
|
||||
const batchDecryptImage = useCallback(async () => {
|
||||
if (batchBusy) return
|
||||
|
||||
const imageItems = selectedItems.filter((item) => item.mediaType === 'image')
|
||||
if (imageItems.length === 0) {
|
||||
showAlert('当前选中中没有图片资源', '无法批量解密')
|
||||
return
|
||||
}
|
||||
|
||||
setBatchBusy(true)
|
||||
let success = 0
|
||||
const previewPatch: Record<string, string> = {}
|
||||
const updatePatch: Record<string, boolean> = {}
|
||||
try {
|
||||
for (const item of imageItems) {
|
||||
if (!item.imageMd5 && !item.imageDatName) continue
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined,
|
||||
force: true
|
||||
})
|
||||
if (!result?.success) continue
|
||||
success += 1
|
||||
if (result.localPath) {
|
||||
const key = getItemKey(item)
|
||||
previewPatch[key] = result.localPath
|
||||
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(previewPatch).length > 0) {
|
||||
setPreviewPathMap((prev) => ({ ...prev, ...previewPatch }))
|
||||
}
|
||||
if (Object.keys(updatePatch).length > 0) {
|
||||
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
|
||||
}
|
||||
setActionMessage(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`)
|
||||
showAlert(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`, '批量解密完成')
|
||||
} finally {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
}, [batchBusy, isLikelyThumbnailPreview, selectedItems, showAlert])
|
||||
|
||||
const openVideo = useCallback(async (item: MediaStreamItem) => {
|
||||
if (item.mediaType !== 'video') return
|
||||
|
||||
const md5 = await resolveItemVideoMd5(item)
|
||||
if (!md5) {
|
||||
showAlert('未解析到视频资源标识', '无法播放')
|
||||
return
|
||||
}
|
||||
|
||||
const info = await window.electronAPI.video.getVideoInfo(md5, { includePoster: false })
|
||||
if (!info.success || !info.exists || !info.videoUrl) {
|
||||
showAlert(info.error || '未找到视频文件', '无法播放')
|
||||
return
|
||||
}
|
||||
|
||||
await window.electronAPI.window.openVideoPlayerWindow(info.videoUrl)
|
||||
}, [resolveItemVideoMd5, showAlert])
|
||||
|
||||
return (
|
||||
<div className="resources-page stream-rebuild">
|
||||
<header className="stream-toolbar">
|
||||
<div className="toolbar-left">
|
||||
<div className="media-tabs">
|
||||
<button type="button" className={tab === 'image' ? 'active' : ''} onClick={() => setTab('image')}>图片</button>
|
||||
<button type="button" className={tab === 'video' ? 'active' : ''} onClick={() => setTab('video')}>视频</button>
|
||||
</div>
|
||||
<div className="filters">
|
||||
<label className="filter-field filter-select">
|
||||
<UserRound size={14} />
|
||||
<select
|
||||
className="contact-select"
|
||||
value={selectedContact}
|
||||
onChange={(event) => setSelectedContact(event.target.value)}
|
||||
>
|
||||
{contacts.map((contact) => (
|
||||
<option key={contact.id} value={contact.id}>{contact.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="filter-field filter-date">
|
||||
<Calendar size={14} />
|
||||
<input
|
||||
className="date-input"
|
||||
type="date"
|
||||
value={dateStart}
|
||||
onChange={(event) => setDateStart(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<span className="sep">至</span>
|
||||
<label className="filter-field filter-date">
|
||||
<Calendar size={14} />
|
||||
<input
|
||||
className="date-input"
|
||||
type="date"
|
||||
value={dateEnd}
|
||||
onChange={(event) => setDateEnd(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="ghost reset-btn" onClick={() => { setDateStart(''); setDateEnd('') }}>重置时间</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="toolbar-right">
|
||||
<button type="button" onClick={() => void loadStream(true)} disabled={loading || loadingMore}>
|
||||
{loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
刷新
|
||||
</button>
|
||||
{tab === 'image' && (
|
||||
<button type="button" onClick={() => void batchDecryptImage()} disabled={selectedKeys.size === 0 || batchBusy}>
|
||||
批量解密
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="danger" onClick={() => void batchDelete()} disabled={selectedKeys.size === 0 || batchBusy}>
|
||||
批量删除
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="stream-summary">
|
||||
<span>已加载 {items.length} 条</span>
|
||||
<span>已选 {selectedKeys.size} 条</span>
|
||||
<span>{tab === 'image' ? '图片按时间倒序流式展示' : '视频按时间倒序流式展示'}</span>
|
||||
{actionMessage && <span className="action-message">{actionMessage}</span>}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="stream-state error">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && items.length === 0 && (loading || loadingMore) && (
|
||||
<div className="stream-state"><Loader2 size={18} className="spin" /> 正在加载...</div>
|
||||
)}
|
||||
|
||||
{!error && items.length === 0 && !loading && !loadingMore && (
|
||||
<div className="stream-state">当前筛选条件下没有内容</div>
|
||||
)}
|
||||
|
||||
{!error && items.length > 0 && (
|
||||
<div className="stream-grid-wrap">
|
||||
<VirtuosoGrid
|
||||
className="stream-grid"
|
||||
overscan={48}
|
||||
components={{
|
||||
List: GridList,
|
||||
Item: GridItem
|
||||
}}
|
||||
data={displayItems}
|
||||
computeItemKey={(_, item) => getItemKey(item)}
|
||||
rangeChanged={(range) => {
|
||||
scheduleRangeResolve(range.startIndex - 3, range.endIndex + 6)
|
||||
}}
|
||||
endReached={() => {
|
||||
if (!hasMore || loading || loadingMore) return
|
||||
void loadStream(false)
|
||||
}}
|
||||
itemContent={(_, item) => {
|
||||
const itemKey = getItemKey(item)
|
||||
const aspect = imageAspectMap[itemKey] || 0
|
||||
return (
|
||||
<MediaCard
|
||||
item={item}
|
||||
sessionName={item.sessionDisplayName || item.sessionId}
|
||||
previewPath={previewPathMap[itemKey] || ''}
|
||||
videoPosterPath={videoPosterMap[itemKey] || ''}
|
||||
imageIsLong={aspect >= 2.8}
|
||||
hasPreviewUpdate={Boolean(previewUpdateMap[itemKey])}
|
||||
selected={selectedKeys.has(itemKey)}
|
||||
decrypting={decryptingKeys.has(itemKey)}
|
||||
onToggleSelect={toggleSelect}
|
||||
onDelete={deleteOne}
|
||||
onImagePreviewAction={onImagePreviewAction}
|
||||
onUpdateImageQuality={updateImageQuality}
|
||||
onOpenVideo={openVideo}
|
||||
onImageLoaded={onImageLoaded}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{loadingMore && <div className="grid-loading-more"><Loader2 size={16} className="spin" /> 加载更多中...</div>}
|
||||
{!hasMore && <div className="grid-end">已加载到底</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<div className="resource-dialog-mask">
|
||||
<div className="resource-dialog" role="dialog" aria-modal="true" aria-label={dialog.title}>
|
||||
<header className="dialog-header">{dialog.title}</header>
|
||||
<div className="dialog-body">{dialog.message}</div>
|
||||
<footer className="dialog-actions">
|
||||
{dialog.mode === 'confirm' && (
|
||||
<button type="button" className="dialog-btn ghost" onClick={closeDialog}>
|
||||
{dialog.cancelText || '取消'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-btn solid"
|
||||
onClick={() => {
|
||||
const callback = dialog.onConfirm
|
||||
closeDialog()
|
||||
callback?.()
|
||||
}}
|
||||
>
|
||||
{dialog.confirmText || '确定'}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResourcesPage
|
||||
@@ -1642,6 +1642,202 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sns-cache-migration-dialog {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--sns-border-radius-lg);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||
width: 540px;
|
||||
max-width: 92vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.sns-cache-migration-close {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.sns-cache-migration-header {
|
||||
padding: 18px 20px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.sns-cache-migration-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sns-cache-migration-subtitle {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sns-cache-migration-body {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sns-cache-migration-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
|
||||
strong {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.sns-cache-migration-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sns-cache-migration-progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sns-cache-migration-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #34d399, #10b981);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.sns-cache-migration-progress-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sns-cache-migration-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.sns-cache-migration-item {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.sns-cache-migration-item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sns-cache-migration-item-detail {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-all;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sns-cache-migration-error,
|
||||
.sns-cache-migration-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sns-cache-migration-error {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: var(--color-error, #f44336);
|
||||
}
|
||||
|
||||
.sns-cache-migration-success {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: var(--color-success, #4caf50);
|
||||
}
|
||||
|
||||
.sns-cache-migration-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
padding: 0 20px 18px;
|
||||
}
|
||||
|
||||
.sns-cache-migration-btn {
|
||||
min-width: 110px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&.primary {
|
||||
background: var(--primary, #576b95);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.author-timeline-dialog {
|
||||
background: var(--sns-card-bg);
|
||||
border-radius: var(--sns-border-radius-lg);
|
||||
|
||||
+274
-1
@@ -66,6 +66,33 @@ type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
||||
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
const SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY = 'sns_cache_migration_prompted_v1'
|
||||
|
||||
interface SnsCacheMigrationItem {
|
||||
label: string
|
||||
sourceDir: string
|
||||
targetDir: string
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
interface SnsCacheMigrationStatus {
|
||||
totalFiles: number
|
||||
legacyBaseDir?: string
|
||||
currentBaseDir?: string
|
||||
items: SnsCacheMigrationItem[]
|
||||
}
|
||||
|
||||
interface SnsCacheMigrationProgress {
|
||||
status: 'running' | 'done' | 'error'
|
||||
phase: 'copying' | 'cleanup' | 'done' | 'error'
|
||||
current: number
|
||||
total: number
|
||||
copied: number
|
||||
skipped: number
|
||||
remaining: number
|
||||
message?: string
|
||||
currentItemLabel?: string
|
||||
}
|
||||
|
||||
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||
try {
|
||||
@@ -162,6 +189,12 @@ export default function SnsPage() {
|
||||
const [triggerInstalled, setTriggerInstalled] = useState<boolean | null>(null)
|
||||
const [triggerLoading, setTriggerLoading] = useState(false)
|
||||
const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [showCacheMigrationDialog, setShowCacheMigrationDialog] = useState(false)
|
||||
const [cacheMigrationStatus, setCacheMigrationStatus] = useState<SnsCacheMigrationStatus | null>(null)
|
||||
const [cacheMigrationProgress, setCacheMigrationProgress] = useState<SnsCacheMigrationProgress | null>(null)
|
||||
const [cacheMigrationRunning, setCacheMigrationRunning] = useState(false)
|
||||
const [cacheMigrationDone, setCacheMigrationDone] = useState(false)
|
||||
const [cacheMigrationError, setCacheMigrationError] = useState<string | null>(null)
|
||||
|
||||
const postsContainerRef = useRef<HTMLDivElement>(null)
|
||||
const jumpCalendarWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -185,6 +218,7 @@ export default function SnsPage() {
|
||||
const contactsCountBatchTimerRef = useRef<number | null>(null)
|
||||
const jumpDateCountsCacheRef = useRef<Map<string, Record<string, number>>>(new Map())
|
||||
const jumpDateRequestSeqRef = useRef(0)
|
||||
const checkedCacheMigrationRef = useRef(false)
|
||||
|
||||
// Sync posts ref
|
||||
useEffect(() => {
|
||||
@@ -595,6 +629,133 @@ export default function SnsPage() {
|
||||
}
|
||||
}, [persistSnsPageCache])
|
||||
|
||||
const markCacheMigrationPrompted = useCallback(() => {
|
||||
try {
|
||||
window.sessionStorage.setItem(SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY, '1')
|
||||
} catch {
|
||||
// ignore session storage failures
|
||||
}
|
||||
}, [])
|
||||
|
||||
const hasCacheMigrationPrompted = useCallback(() => {
|
||||
try {
|
||||
return window.sessionStorage.getItem(SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const checkCacheMigrationStatus = useCallback(async () => {
|
||||
if (checkedCacheMigrationRef.current) return
|
||||
checkedCacheMigrationRef.current = true
|
||||
if (hasCacheMigrationPrompted()) return
|
||||
try {
|
||||
const result = await window.electronAPI.sns.getCacheMigrationStatus()
|
||||
if (!result?.success || !result.needed) return
|
||||
const totalFiles = Math.max(0, Number(result.totalFiles || 0))
|
||||
const items = Array.isArray(result.items)
|
||||
? result.items.map((item) => ({
|
||||
label: String(item.label || '').trim(),
|
||||
sourceDir: String(item.sourceDir || '').trim(),
|
||||
targetDir: String(item.targetDir || '').trim(),
|
||||
fileCount: Math.max(0, Number(item.fileCount || 0))
|
||||
})).filter((item) => item.label && item.sourceDir && item.targetDir && item.fileCount > 0)
|
||||
: []
|
||||
if (totalFiles <= 0 || items.length === 0) return
|
||||
setCacheMigrationStatus({
|
||||
totalFiles,
|
||||
legacyBaseDir: result.legacyBaseDir,
|
||||
currentBaseDir: result.currentBaseDir,
|
||||
items
|
||||
})
|
||||
setCacheMigrationProgress(null)
|
||||
setCacheMigrationDone(false)
|
||||
setCacheMigrationError(null)
|
||||
setShowCacheMigrationDialog(true)
|
||||
markCacheMigrationPrompted()
|
||||
} catch (error) {
|
||||
console.error('Failed to check SNS cache migration status:', error)
|
||||
}
|
||||
}, [hasCacheMigrationPrompted, markCacheMigrationPrompted])
|
||||
|
||||
const startCacheMigration = useCallback(async () => {
|
||||
const total = Math.max(0, cacheMigrationStatus?.totalFiles || 0)
|
||||
setCacheMigrationError(null)
|
||||
setCacheMigrationDone(false)
|
||||
setCacheMigrationRunning(true)
|
||||
setCacheMigrationProgress({
|
||||
status: 'running',
|
||||
phase: 'copying',
|
||||
current: 0,
|
||||
total,
|
||||
copied: 0,
|
||||
skipped: 0,
|
||||
remaining: total,
|
||||
message: '准备迁移...'
|
||||
})
|
||||
|
||||
const removeProgress = window.electronAPI.sns.onCacheMigrationProgress((payload) => {
|
||||
if (!payload) return
|
||||
setCacheMigrationProgress({
|
||||
status: payload.status,
|
||||
phase: payload.phase,
|
||||
current: Math.max(0, Number(payload.current || 0)),
|
||||
total: Math.max(0, Number(payload.total || 0)),
|
||||
copied: Math.max(0, Number(payload.copied || 0)),
|
||||
skipped: Math.max(0, Number(payload.skipped || 0)),
|
||||
remaining: Math.max(0, Number(payload.remaining || 0)),
|
||||
message: payload.message,
|
||||
currentItemLabel: payload.currentItemLabel
|
||||
})
|
||||
if (payload.status === 'done') {
|
||||
setCacheMigrationDone(true)
|
||||
setCacheMigrationError(null)
|
||||
} else if (payload.status === 'error') {
|
||||
setCacheMigrationError(payload.message || '迁移失败')
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.sns.startCacheMigration()
|
||||
if (!result?.success) {
|
||||
setCacheMigrationError(result?.error || '迁移失败')
|
||||
} else {
|
||||
const totalFiles = Math.max(0, Number(result.totalFiles || 0))
|
||||
if (totalFiles === 0) {
|
||||
setCacheMigrationDone(true)
|
||||
setCacheMigrationProgress({
|
||||
status: 'done',
|
||||
phase: 'done',
|
||||
current: 0,
|
||||
total: 0,
|
||||
copied: 0,
|
||||
skipped: 0,
|
||||
remaining: 0,
|
||||
message: result.message || '无需迁移'
|
||||
})
|
||||
} else {
|
||||
// 兜底:若 done 事件因时序原因未到达,仍以返回结果收敛到完成态。
|
||||
setCacheMigrationDone(true)
|
||||
setCacheMigrationProgress((prev) => prev || {
|
||||
status: 'done',
|
||||
phase: 'done',
|
||||
current: totalFiles,
|
||||
total: totalFiles,
|
||||
copied: Math.max(0, Number(result.copied || 0)),
|
||||
skipped: Math.max(0, Number(result.skipped || 0)),
|
||||
remaining: 0,
|
||||
message: '迁移完成'
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setCacheMigrationError(String((error as Error)?.message || error || '迁移失败'))
|
||||
} finally {
|
||||
removeProgress()
|
||||
setCacheMigrationRunning(false)
|
||||
}
|
||||
}, [cacheMigrationStatus?.totalFiles])
|
||||
|
||||
const renderOverviewRangeText = () => {
|
||||
if (overviewStatsStatus === 'error') {
|
||||
return (
|
||||
@@ -1256,7 +1417,8 @@ export default function SnsPage() {
|
||||
void hydrateSnsPageCache()
|
||||
loadContacts()
|
||||
loadOverviewStats()
|
||||
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
|
||||
void checkCacheMigrationStatus()
|
||||
}, [checkCacheMigrationStatus, hydrateSnsPageCache, loadContacts, loadOverviewStats])
|
||||
|
||||
useEffect(() => {
|
||||
const syncCurrentUserProfile = async () => {
|
||||
@@ -1659,6 +1821,117 @@ export default function SnsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCacheMigrationDialog && cacheMigrationStatus && (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={() => {
|
||||
if (cacheMigrationRunning) return
|
||||
setShowCacheMigrationDialog(false)
|
||||
}}
|
||||
>
|
||||
<div className="sns-cache-migration-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="close-btn sns-cache-migration-close"
|
||||
onClick={() => !cacheMigrationRunning && setShowCacheMigrationDialog(false)}
|
||||
disabled={cacheMigrationRunning}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="sns-cache-migration-header">
|
||||
<div className="sns-cache-migration-title">发现旧版朋友圈缓存</div>
|
||||
<div className="sns-cache-migration-subtitle">
|
||||
建议迁移到当前缓存目录,避免目录分散和重复占用空间
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sns-cache-migration-body">
|
||||
<div className="sns-cache-migration-meta">
|
||||
<span>待处理文件</span>
|
||||
<strong>{cacheMigrationStatus.totalFiles}</strong>
|
||||
</div>
|
||||
|
||||
{cacheMigrationProgress && (
|
||||
<div className="sns-cache-migration-progress">
|
||||
<div className="sns-cache-migration-progress-bar">
|
||||
<div
|
||||
className="sns-cache-migration-progress-fill"
|
||||
style={{
|
||||
width: cacheMigrationProgress.total > 0
|
||||
? `${Math.min(100, Math.round((cacheMigrationProgress.current / cacheMigrationProgress.total) * 100))}%`
|
||||
: '100%'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sns-cache-migration-progress-text">
|
||||
<span>{cacheMigrationProgress.message || '迁移中...'}</span>
|
||||
<span>
|
||||
已迁移 {cacheMigrationProgress.copied},剩余 {cacheMigrationProgress.remaining},跳过重复 {cacheMigrationProgress.skipped}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!cacheMigrationProgress && (
|
||||
<div className="sns-cache-migration-items">
|
||||
{cacheMigrationStatus.items.map((item, idx) => (
|
||||
<div className="sns-cache-migration-item" key={`${item.label}-${idx}`}>
|
||||
<div className="sns-cache-migration-item-title">{item.label}</div>
|
||||
<div className="sns-cache-migration-item-detail">
|
||||
{item.fileCount} 个文件 · {item.sourceDir} → {item.targetDir}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cacheMigrationError && (
|
||||
<div className="sns-cache-migration-error">
|
||||
<AlertCircle size={14} />
|
||||
<span>{cacheMigrationError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cacheMigrationDone && !cacheMigrationError && (
|
||||
<div className="sns-cache-migration-success">
|
||||
<CheckCircle size={14} />
|
||||
<span>迁移完成,旧目录已清理。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sns-cache-migration-actions">
|
||||
{!cacheMigrationDone ? (
|
||||
<>
|
||||
<button
|
||||
className="sns-cache-migration-btn secondary"
|
||||
onClick={() => setShowCacheMigrationDialog(false)}
|
||||
disabled={cacheMigrationRunning}
|
||||
>
|
||||
稍后再说
|
||||
</button>
|
||||
<button
|
||||
className="sns-cache-migration-btn primary"
|
||||
onClick={() => { void startCacheMigration() }}
|
||||
disabled={cacheMigrationRunning}
|
||||
>
|
||||
{cacheMigrationRunning ? '迁移中...' : '开始迁移'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="sns-cache-migration-btn primary"
|
||||
onClick={() => setShowCacheMigrationDialog(false)}
|
||||
disabled={cacheMigrationRunning}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 朋友圈防删除插件对话框 */}
|
||||
{showTriggerDialog && (
|
||||
<div className="modal-overlay" onClick={() => { setShowTriggerDialog(false); setTriggerMessage(null) }}>
|
||||
|
||||
@@ -16,6 +16,7 @@ const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||
const isWindows = !isMac && !isLinux
|
||||
|
||||
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||
const DB_PATH_CHINESE_ERROR = '路径包含中文字符,迁移至全英文目录后再试'
|
||||
const dbPathPlaceholder = isMac
|
||||
? '例如: ~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9'
|
||||
: isLinux
|
||||
@@ -221,10 +222,23 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
if (!path) return null
|
||||
// 检测中文字符和其他可能有问题的特殊字符
|
||||
if (/[\u4e00-\u9fa5]/.test(path)) {
|
||||
return '路径包含中文字符,请迁移至全英文目录'
|
||||
return DB_PATH_CHINESE_ERROR
|
||||
}
|
||||
return null
|
||||
}
|
||||
const dbPathValidationError = validatePath(dbPath)
|
||||
|
||||
const handleDbPathChange = (value: string) => {
|
||||
setDbPath(value)
|
||||
const validationError = validatePath(value)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
if (error === DB_PATH_CHINESE_ERROR) {
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectPath = async () => {
|
||||
try {
|
||||
@@ -236,10 +250,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const selectedPath = result.filePaths[0]
|
||||
const validationError = validatePath(selectedPath)
|
||||
setDbPath(selectedPath)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
} else {
|
||||
setDbPath(selectedPath)
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
@@ -256,10 +270,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const result = await window.electronAPI.dbPath.autoDetect()
|
||||
if (result.success && result.path) {
|
||||
const validationError = validatePath(result.path)
|
||||
setDbPath(result.path)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
} else {
|
||||
setDbPath(result.path)
|
||||
setError('')
|
||||
}
|
||||
} else {
|
||||
@@ -426,7 +440,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
const canGoNext = () => {
|
||||
if (currentStep.id === 'intro') return true
|
||||
if (currentStep.id === 'db') return Boolean(dbPath)
|
||||
if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
|
||||
if (currentStep.id === 'cache') return true
|
||||
if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid)
|
||||
if (currentStep.id === 'image') return true
|
||||
@@ -442,6 +456,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const handleNext = () => {
|
||||
if (!canGoNext()) {
|
||||
if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录')
|
||||
else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError)
|
||||
if (currentStep.id === 'key') {
|
||||
if (decryptKey.length !== 64) setError('密钥长度必须为 64 个字符')
|
||||
else if (!wxid) setError('未能自动识别 wxid,请尝试重新获取或检查目录')
|
||||
@@ -664,7 +679,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
className="field-input"
|
||||
placeholder={dbPathPlaceholder}
|
||||
value={dbPath}
|
||||
onChange={(e) => setDbPath(e.target.value)}
|
||||
onChange={(e) => handleDbPathChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
|
||||
Reference in new issue
Block a user