mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-22 15:09:04 +00:00
图片与视频索引优化 #786;修复 #786;修复导出页面打开目录缺失路径的问题;完善朋友圈卡片封面解析
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
|
||||
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
|
||||
@@ -8,6 +8,7 @@ import { getEmojiPath } from 'wechat-emojis'
|
||||
|
||||
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
||||
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
||||
const LINK_XML_DIRECT_URL_TAGS = ['contentUrl', ...LINK_XML_URL_TAGS]
|
||||
const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle']
|
||||
const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload']
|
||||
|
||||
@@ -29,6 +30,13 @@ const decodeHtmlEntities = (text: string): string => {
|
||||
.trim()
|
||||
}
|
||||
|
||||
const normalizeRawXmlForParsing = (xml: string): string => {
|
||||
if (!xml) return ''
|
||||
return decodeHtmlEntities(xml)
|
||||
.replace(/\\+"/g, '"')
|
||||
.replace(/\\+'/g, "'")
|
||||
}
|
||||
|
||||
const normalizeUrlCandidate = (raw: string): string | null => {
|
||||
const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim()
|
||||
if (!value) return null
|
||||
@@ -43,12 +51,13 @@ const simplifyUrlForCompare = (value: string): string => {
|
||||
}
|
||||
|
||||
const getXmlTagValues = (xml: string, tags: string[]): string[] => {
|
||||
if (!xml) return []
|
||||
const normalizedXml = normalizeRawXmlForParsing(xml)
|
||||
if (!normalizedXml) return []
|
||||
const results: string[] = []
|
||||
for (const tag of tags) {
|
||||
const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig')
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = reg.exec(xml)) !== null) {
|
||||
while ((match = reg.exec(normalizedXml)) !== null) {
|
||||
if (match[1]) results.push(match[1])
|
||||
}
|
||||
}
|
||||
@@ -65,20 +74,87 @@ const isLikelyMediaAssetUrl = (url: string): boolean => {
|
||||
return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint))
|
||||
}
|
||||
|
||||
const normalizeSnsAssetUrl = (url: string, token?: string, encIdx?: string): string => {
|
||||
const base = decodeHtmlEntities(url).trim()
|
||||
if (!base) return ''
|
||||
|
||||
let fixed = base.replace(/^http:\/\//i, 'https://')
|
||||
|
||||
const normalizedToken = decodeHtmlEntities(String(token || '')).trim()
|
||||
const normalizedEncIdx = decodeHtmlEntities(String(encIdx || '')).trim()
|
||||
const effectiveIdx = normalizedEncIdx || (normalizedToken ? '1' : '')
|
||||
const appendParams: string[] = []
|
||||
if (normalizedToken && !/[?&]token=/i.test(fixed)) {
|
||||
appendParams.push(`token=${normalizedToken}`)
|
||||
}
|
||||
if (effectiveIdx && !/[?&]idx=/i.test(fixed)) {
|
||||
appendParams.push(`idx=${effectiveIdx}`)
|
||||
}
|
||||
if (appendParams.length > 0) {
|
||||
const connector = fixed.includes('?') ? '&' : '?'
|
||||
fixed = `${fixed}${connector}${appendParams.join('&')}`
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
|
||||
const extractCardThumbMetaFromXml = (xml: string): { thumb?: string; thumbKey?: string } => {
|
||||
const normalizedXml = normalizeRawXmlForParsing(xml)
|
||||
if (!normalizedXml) return {}
|
||||
const mediaMatch = normalizedXml.match(/<media>([\s\S]*?)<\/media>/i)
|
||||
if (!mediaMatch?.[1]) return {}
|
||||
|
||||
const mediaXml = mediaMatch[1]
|
||||
const thumbMatch = mediaXml.match(/<thumb([^>]*)>([^<]+)<\/thumb>/i)
|
||||
if (!thumbMatch) return {}
|
||||
|
||||
const attrs = thumbMatch[1] || ''
|
||||
const getAttr = (name: string): string | undefined => {
|
||||
const reg = new RegExp(`${name}\\s*=\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s>]+))`, 'i')
|
||||
const m = attrs.match(reg)
|
||||
return decodeHtmlEntities((m?.[1] || m?.[2] || m?.[3] || '').trim()) || undefined
|
||||
}
|
||||
const thumbRawUrl = thumbMatch[2] || ''
|
||||
const thumbToken = getAttr('token')
|
||||
const thumbKey = getAttr('key')
|
||||
const thumbEncIdx = getAttr('enc_idx')
|
||||
const thumb = normalizeSnsAssetUrl(thumbRawUrl, thumbToken, thumbEncIdx)
|
||||
|
||||
return {
|
||||
thumb: thumb || undefined,
|
||||
thumbKey: thumbKey ? decodeHtmlEntities(thumbKey).trim() : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const pickCardTitle = (post: SnsPost): string => {
|
||||
const titleCandidates = [
|
||||
post.linkTitle || '',
|
||||
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||
post.contentDesc || ''
|
||||
]
|
||||
return titleCandidates
|
||||
.map((value) => decodeHtmlEntities(value))
|
||||
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) || '网页链接'
|
||||
}
|
||||
|
||||
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
||||
// type 3 是链接类型,直接用 media[0] 的 url 和 thumb
|
||||
if (post.type === 3) {
|
||||
const url = post.media[0]?.url || post.linkUrl
|
||||
if (!url) return null
|
||||
const titleCandidates = [
|
||||
post.linkTitle || '',
|
||||
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||
post.contentDesc || ''
|
||||
// type 3 / 5 是链接卡片类型,优先按卡片链接解析
|
||||
if (post.type === 3 || post.type === 5) {
|
||||
const thumbMeta = extractCardThumbMetaFromXml(post.rawXml || '')
|
||||
const directUrlCandidates = [
|
||||
post.linkUrl || '',
|
||||
...getXmlTagValues(post.rawXml || '', LINK_XML_DIRECT_URL_TAGS),
|
||||
...post.media.map((item) => item.url || '')
|
||||
]
|
||||
const title = titleCandidates
|
||||
.map((v) => decodeHtmlEntities(v))
|
||||
.find((v) => Boolean(v) && !/^https?:\/\//i.test(v))
|
||||
return { url, title: title || '网页链接', thumb: post.media[0]?.thumb }
|
||||
const url = directUrlCandidates
|
||||
.map(normalizeUrlCandidate)
|
||||
.find((value): value is string => Boolean(value))
|
||||
if (!url) return null
|
||||
return {
|
||||
url,
|
||||
title: pickCardTitle(post),
|
||||
thumb: thumbMeta.thumb || post.media[0]?.thumb || post.media[0]?.url,
|
||||
thumbKey: thumbMeta.thumbKey || post.media[0]?.key
|
||||
}
|
||||
}
|
||||
|
||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||
@@ -117,19 +193,9 @@ const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
||||
|
||||
if (!linkUrl) return null
|
||||
|
||||
const titleCandidates = [
|
||||
post.linkTitle || '',
|
||||
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||
post.contentDesc || ''
|
||||
]
|
||||
|
||||
const title = titleCandidates
|
||||
.map((value) => decodeHtmlEntities(value))
|
||||
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value))
|
||||
|
||||
return {
|
||||
url: linkUrl,
|
||||
title: title || '网页链接',
|
||||
title: pickCardTitle(post),
|
||||
thumb: post.media[0]?.thumb || post.media[0]?.url
|
||||
}
|
||||
}
|
||||
@@ -158,8 +224,11 @@ const buildLocationText = (location?: SnsLocation): string => {
|
||||
return primary || region
|
||||
}
|
||||
|
||||
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
||||
const SnsLinkCard = ({ card, thumbKey }: { card: SnsLinkCardData; thumbKey?: string }) => {
|
||||
const [thumbFailed, setThumbFailed] = useState(false)
|
||||
const [thumbSrc, setThumbSrc] = useState(card.thumb || '')
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const retryCountRef = useRef(0)
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(card.url).hostname.replace(/^www\./i, '')
|
||||
@@ -168,6 +237,58 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
||||
}
|
||||
}, [card.url])
|
||||
|
||||
useEffect(() => {
|
||||
retryCountRef.current = 0
|
||||
}, [card.thumb, thumbKey])
|
||||
|
||||
const scheduleRetry = () => {
|
||||
if (retryCountRef.current >= 2) return
|
||||
retryCountRef.current += 1
|
||||
window.setTimeout(() => {
|
||||
setReloadNonce((v) => v + 1)
|
||||
}, 900)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const rawThumb = card.thumb || ''
|
||||
setThumbFailed(false)
|
||||
setThumbSrc(rawThumb)
|
||||
if (!rawThumb) return
|
||||
|
||||
let cancelled = false
|
||||
const loadThumb = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.sns.proxyImage({
|
||||
url: rawThumb,
|
||||
key: thumbKey
|
||||
})
|
||||
if (cancelled) return
|
||||
if (!result.success) {
|
||||
console.warn('[SnsLinkCard] thumb decrypt failed', {
|
||||
url: rawThumb,
|
||||
key: thumbKey,
|
||||
error: result.error
|
||||
})
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
if (result.dataUrl) {
|
||||
setThumbSrc(result.dataUrl)
|
||||
return
|
||||
}
|
||||
if (result.videoPath) {
|
||||
setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
|
||||
}
|
||||
} catch {
|
||||
// noop: keep raw thumb fallback
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
|
||||
loadThumb()
|
||||
return () => { cancelled = true }
|
||||
}, [card.thumb, thumbKey, reloadNonce])
|
||||
|
||||
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
@@ -180,13 +301,31 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
||||
return (
|
||||
<button type="button" className="post-link-card" onClick={handleClick}>
|
||||
<div className="link-thumb">
|
||||
{card.thumb && !thumbFailed ? (
|
||||
{thumbSrc && !thumbFailed ? (
|
||||
<img
|
||||
src={card.thumb}
|
||||
src={thumbSrc}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
onError={() => setThumbFailed(true)}
|
||||
onError={() => {
|
||||
const rawThumb = card.thumb || ''
|
||||
if (thumbSrc !== rawThumb && rawThumb) {
|
||||
console.warn('[SnsLinkCard] thumb render failed, fallback raw thumb', {
|
||||
failedSrc: thumbSrc,
|
||||
rawThumb,
|
||||
key: thumbKey
|
||||
})
|
||||
setThumbSrc(rawThumb)
|
||||
return
|
||||
}
|
||||
console.warn('[SnsLinkCard] thumb render failed, fallback exhausted', {
|
||||
failedSrc: thumbSrc,
|
||||
rawThumb,
|
||||
key: thumbKey
|
||||
})
|
||||
setThumbFailed(true)
|
||||
scheduleRetry()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="link-thumb-fallback">
|
||||
@@ -278,9 +417,11 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const linkCard = buildLinkCardData(post)
|
||||
const linkCardThumbKey = linkCard?.thumbKey || post.media[0]?.key
|
||||
const locationText = useMemo(() => buildLocationText(post.location), [post.location])
|
||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
||||
const isLinkCardType = post.type === 3 || post.type === 5
|
||||
const showLinkCard = Boolean(linkCard) && !hasVideoMedia && (isLinkCardType || post.media.length <= 1)
|
||||
const showMediaGrid = post.media.length > 0 && !showLinkCard
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
@@ -412,7 +553,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
)}
|
||||
|
||||
{showLinkCard && linkCard && (
|
||||
<SnsLinkCard card={linkCard} />
|
||||
<SnsLinkCard card={linkCard} thumbKey={linkCardThumbKey} />
|
||||
)}
|
||||
|
||||
{showMediaGrid && (
|
||||
|
||||
@@ -3043,6 +3043,30 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-name-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
|
||||
font-size: 12px;
|
||||
|
||||
.table-name-label {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-name-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-table-placeholder {
|
||||
padding: 11px 12px;
|
||||
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
|
||||
@@ -3056,6 +3080,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||
@@ -3071,15 +3096,17 @@
|
||||
.db-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
max-width: 62%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-count {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7424,14 +7424,29 @@ function ChatPage(props: ChatPageProps) {
|
||||
<span>数据库分布</span>
|
||||
</div>
|
||||
{Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? (
|
||||
<div className="table-list">
|
||||
{sessionDetail.messageTables.map((t, i) => (
|
||||
<div key={i} className="table-item">
|
||||
<span className="db-name">{t.dbName}</span>
|
||||
<span className="table-count">{t.count.toLocaleString()} 条</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="table-name-summary">
|
||||
<span className="table-name-label">表名</span>
|
||||
<span className="table-name-value">
|
||||
{(() => {
|
||||
const tableNames = Array.from(new Set(
|
||||
sessionDetail.messageTables
|
||||
.map(item => String(item.tableName || '').trim())
|
||||
.filter(Boolean)
|
||||
))
|
||||
return tableNames[0] || '—'
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="table-list">
|
||||
{sessionDetail.messageTables.map((t, i) => (
|
||||
<div key={`${t.dbName}-${t.tableName}-${i}`} className="table-item">
|
||||
<span className="db-name">{t.dbName || '—'}</span>
|
||||
<span className="table-count">{t.count.toLocaleString()} 条</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="detail-table-placeholder">
|
||||
{isLoadingDetailExtra ? '统计中...' : '暂无统计数据'}
|
||||
|
||||
@@ -178,6 +178,7 @@ interface ExportTask {
|
||||
title: string
|
||||
status: TaskStatus
|
||||
settledSessionIds?: string[]
|
||||
sessionOutputPaths?: Record<string, string>
|
||||
createdAt: number
|
||||
startedAt?: number
|
||||
finishedAt?: number
|
||||
@@ -653,6 +654,32 @@ const formatPathBrief = (value: string, maxLength = 52): string => {
|
||||
return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}`
|
||||
}
|
||||
|
||||
const resolveParentDir = (value: string): string => {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return ''
|
||||
const noTrailing = normalized.replace(/[\\/]+$/, '')
|
||||
if (!noTrailing) return normalized
|
||||
const lastSlash = Math.max(noTrailing.lastIndexOf('/'), noTrailing.lastIndexOf('\\'))
|
||||
if (lastSlash < 0) return normalized
|
||||
if (lastSlash === 0) return noTrailing.slice(0, 1)
|
||||
if (/^[A-Za-z]:$/.test(noTrailing.slice(0, lastSlash))) {
|
||||
return `${noTrailing.slice(0, lastSlash)}\\`
|
||||
}
|
||||
return noTrailing.slice(0, lastSlash)
|
||||
}
|
||||
|
||||
const resolveTaskOpenDir = (task: ExportTask): string => {
|
||||
const sessionIds = Array.isArray(task.payload.sessionIds) ? task.payload.sessionIds : []
|
||||
if (sessionIds.length === 1) {
|
||||
const onlySessionId = String(sessionIds[0] || '').trim()
|
||||
const outputPath = onlySessionId ? String(task.sessionOutputPaths?.[onlySessionId] || '').trim() : ''
|
||||
if (outputPath) {
|
||||
return resolveParentDir(outputPath) || task.payload.outputDir
|
||||
}
|
||||
}
|
||||
return task.payload.outputDir
|
||||
}
|
||||
|
||||
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
|
||||
if (!timestamp) return ''
|
||||
const diff = Math.max(0, now - timestamp)
|
||||
@@ -2005,7 +2032,14 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
{isPerfExpanded ? '收起详情' : '性能详情'}
|
||||
</button>
|
||||
)}
|
||||
<button className="task-action-btn" onClick={() => task.payload.outputDir && void window.electronAPI.shell.openPath(task.payload.outputDir)}>
|
||||
<button
|
||||
className="task-action-btn"
|
||||
onClick={() => {
|
||||
const openDir = resolveTaskOpenDir(task)
|
||||
if (!openDir) return
|
||||
void window.electronAPI.shell.openPath(openDir)
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} /> 目录
|
||||
</button>
|
||||
</div>
|
||||
@@ -5715,6 +5749,12 @@ function ExportPage() {
|
||||
...task,
|
||||
status: 'success',
|
||||
finishedAt: doneAt,
|
||||
sessionOutputPaths: {
|
||||
...(task.sessionOutputPaths || {}),
|
||||
...((result.sessionOutputPaths && typeof result.sessionOutputPaths === 'object')
|
||||
? result.sessionOutputPaths
|
||||
: {})
|
||||
},
|
||||
progress: {
|
||||
...task.progress,
|
||||
current: task.progress.total || next.payload.sessionIds.length,
|
||||
|
||||
@@ -281,10 +281,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.floating-info,
|
||||
.floating-delete {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 4;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -302,6 +302,18 @@
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.floating-info {
|
||||
right: 10px;
|
||||
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.floating-delete {
|
||||
right: 44px;
|
||||
}
|
||||
|
||||
.media-card:hover .floating-info,
|
||||
.media-card:focus-within .floating-info,
|
||||
.media-card:hover .floating-delete,
|
||||
.media-card:focus-within .floating-delete {
|
||||
opacity: 1;
|
||||
@@ -490,7 +502,9 @@
|
||||
.resource-dialog-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(8, 11, 18, 0.24);
|
||||
background: rgba(8, 11, 18, 0.46);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -498,11 +512,13 @@
|
||||
}
|
||||
|
||||
.resource-dialog {
|
||||
--dialog-surface: color-mix(in srgb, var(--bg-primary, #ffffff) 82%, var(--card-inner-bg, #ffffff) 18%);
|
||||
--dialog-surface-header: color-mix(in srgb, var(--dialog-surface) 90%, var(--bg-secondary, #ffffff) 10%);
|
||||
width: min(420px, calc(100% - 32px));
|
||||
background: var(--card-bg, #ffffff);
|
||||
background: var(--dialog-surface);
|
||||
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);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -512,7 +528,7 @@
|
||||
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);
|
||||
background: var(--dialog-surface-header);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
@@ -521,6 +537,34 @@
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
background: var(--dialog-surface);
|
||||
}
|
||||
|
||||
.dialog-info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-info-row {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
@@ -528,6 +572,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
background: var(--dialog-surface);
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Calendar, Image as ImageIcon, Info, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
|
||||
import { VirtuosoGrid } from 'react-virtuoso'
|
||||
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
|
||||
import './ResourcesPage.scss'
|
||||
@@ -28,9 +28,10 @@ interface ContactOption {
|
||||
}
|
||||
|
||||
type DialogState = {
|
||||
mode: 'alert' | 'confirm'
|
||||
mode: 'alert' | 'confirm' | 'info'
|
||||
title: string
|
||||
message: string
|
||||
message?: string
|
||||
infoRows?: Array<{ label: string; value: string }>
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm?: (() => void) | null
|
||||
@@ -115,6 +116,12 @@ function formatTimeLabel(timestampSec: number): string {
|
||||
})
|
||||
}
|
||||
|
||||
function formatInfoValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '-'
|
||||
const text = String(value).trim()
|
||||
return text || '-'
|
||||
}
|
||||
|
||||
function extractVideoTitle(content?: string): string {
|
||||
const xml = String(content || '')
|
||||
if (!xml) return '视频'
|
||||
@@ -152,6 +159,7 @@ const MediaCard = memo(function MediaCard({
|
||||
decrypting,
|
||||
onToggleSelect,
|
||||
onDelete,
|
||||
onShowInfo,
|
||||
onImagePreviewAction,
|
||||
onUpdateImageQuality,
|
||||
onOpenVideo,
|
||||
@@ -167,6 +175,7 @@ const MediaCard = memo(function MediaCard({
|
||||
decrypting: boolean
|
||||
onToggleSelect: (item: MediaStreamItem) => void
|
||||
onDelete: (item: MediaStreamItem) => void
|
||||
onShowInfo: (item: MediaStreamItem) => void
|
||||
onImagePreviewAction: (item: MediaStreamItem) => void
|
||||
onUpdateImageQuality: (item: MediaStreamItem) => void
|
||||
onOpenVideo: (item: MediaStreamItem) => void
|
||||
@@ -178,6 +187,9 @@ const MediaCard = memo(function MediaCard({
|
||||
|
||||
return (
|
||||
<article className={`media-card ${selected ? 'selected' : ''} ${isDecryptingVisual ? 'decrypting' : ''}`}>
|
||||
<button type="button" className="floating-info" onClick={() => onShowInfo(item)} aria-label="查看资源信息">
|
||||
<Info size={14} />
|
||||
</button>
|
||||
<button type="button" className="floating-delete" onClick={() => onDelete(item)} aria-label="删除资源">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -796,6 +808,93 @@ function ResourcesPage() {
|
||||
return md5
|
||||
}, [])
|
||||
|
||||
const showMediaInfo = useCallback(async (item: MediaStreamItem) => {
|
||||
const itemKey = getItemKey(item)
|
||||
const mediaLabel = item.mediaType === 'image' ? '图片' : '视频'
|
||||
const baseRows: Array<{ label: string; value: string }> = [
|
||||
{ label: '资源类型', value: mediaLabel },
|
||||
{ label: '会话 ID', value: formatInfoValue(item.sessionId) },
|
||||
{ label: '消息 LocalId', value: formatInfoValue(item.localId) },
|
||||
{ label: '消息时间', value: formatTimeLabel(item.createTime) },
|
||||
{ label: '发送方', value: formatInfoValue(item.senderUsername) },
|
||||
{ label: '是否我发送', value: item.isSend === 1 ? '是' : (item.isSend === 0 ? '否' : '-') }
|
||||
]
|
||||
|
||||
setDialog({
|
||||
mode: 'info',
|
||||
title: `${mediaLabel}信息`,
|
||||
infoRows: [...baseRows, { label: '状态', value: '正在读取缓存信息...' }],
|
||||
confirmText: '关闭',
|
||||
onConfirm: null
|
||||
})
|
||||
|
||||
try {
|
||||
if (item.mediaType === 'image') {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
const previewPath = previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey] || ''
|
||||
const cachePath = String(resolved?.localPath || previewPath || '').trim()
|
||||
const rows: Array<{ label: string; value: string }> = [
|
||||
...baseRows,
|
||||
{ label: 'imageMd5', value: formatInfoValue(normalizeMediaToken(item.imageMd5)) },
|
||||
{ label: 'imageDatName', value: formatInfoValue(getSafeImageDatName(item)) },
|
||||
{ label: '列表预览路径', value: formatInfoValue(previewPath) },
|
||||
{ label: '缓存命中', value: resolved?.success && cachePath ? '是' : '否' },
|
||||
{ label: '缓存路径', value: formatInfoValue(cachePath) },
|
||||
{ label: '缓存可更新', value: resolved?.hasUpdate ? '是' : '否' },
|
||||
{ label: '缓存状态', value: resolved?.success ? '可用' : formatInfoValue(resolved?.error || resolved?.failureKind || '未命中') }
|
||||
]
|
||||
setDialog({
|
||||
mode: 'info',
|
||||
title: '图片信息',
|
||||
infoRows: rows,
|
||||
confirmText: '关闭',
|
||||
onConfirm: null
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedMd5 = await resolveItemVideoMd5(item)
|
||||
const videoInfo = resolvedMd5
|
||||
? await window.electronAPI.video.getVideoInfo(resolvedMd5, { includePoster: true, posterFormat: 'fileUrl' })
|
||||
: null
|
||||
const posterPath = videoPosterMapRef.current[itemKey] || posterPatchRef.current[itemKey] || ''
|
||||
const rows: Array<{ label: string; value: string }> = [
|
||||
...baseRows,
|
||||
{ label: 'videoMd5(消息)', value: formatInfoValue(normalizeMediaToken(item.videoMd5)) },
|
||||
{ label: 'videoMd5(解析)', value: formatInfoValue(resolvedMd5) },
|
||||
{ label: '视频文件存在', value: videoInfo?.success && videoInfo.exists ? '是' : '否' },
|
||||
{ label: '视频路径', value: formatInfoValue(videoInfo?.videoUrl) },
|
||||
{ label: '同名封面路径', value: formatInfoValue(videoInfo?.coverUrl) },
|
||||
{ label: '列表封面路径', value: formatInfoValue(posterPath) },
|
||||
{ label: '视频状态', value: videoInfo?.success ? '可用' : formatInfoValue(videoInfo?.error || '未找到') }
|
||||
]
|
||||
setDialog({
|
||||
mode: 'info',
|
||||
title: '视频信息',
|
||||
infoRows: rows,
|
||||
confirmText: '关闭',
|
||||
onConfirm: null
|
||||
})
|
||||
} catch (e) {
|
||||
setDialog({
|
||||
mode: 'info',
|
||||
title: `${mediaLabel}信息`,
|
||||
infoRows: [...baseRows, { label: '读取失败', value: formatInfoValue(String(e)) }],
|
||||
confirmText: '关闭',
|
||||
onConfirm: null
|
||||
})
|
||||
}
|
||||
}, [resolveItemVideoMd5])
|
||||
|
||||
const resolveVideoPoster = useCallback(async (item: MediaStreamItem) => {
|
||||
if (item.mediaType !== 'video') return
|
||||
const itemKey = getItemKey(item)
|
||||
@@ -815,7 +914,7 @@ function ResourcesPage() {
|
||||
attemptedVideoPosterKeysRef.current.add(itemKey)
|
||||
return
|
||||
}
|
||||
const poster = String(info.coverUrl || info.thumbUrl || '')
|
||||
const poster = String(info.coverUrl || '')
|
||||
if (!poster) {
|
||||
attemptedVideoPosterKeysRef.current.add(itemKey)
|
||||
return
|
||||
@@ -1371,6 +1470,7 @@ function ResourcesPage() {
|
||||
decrypting={decryptingKeys.has(itemKey)}
|
||||
onToggleSelect={toggleSelect}
|
||||
onDelete={deleteOne}
|
||||
onShowInfo={showMediaInfo}
|
||||
onImagePreviewAction={onImagePreviewAction}
|
||||
onUpdateImageQuality={updateImageQuality}
|
||||
onOpenVideo={openVideo}
|
||||
@@ -1388,7 +1488,20 @@ function ResourcesPage() {
|
||||
<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>
|
||||
<div className="dialog-body">
|
||||
{dialog.mode === 'info' ? (
|
||||
<div className="dialog-info-list">
|
||||
{(dialog.infoRows || []).map((row, idx) => (
|
||||
<div className="dialog-info-row" key={`${row.label}-${idx}`}>
|
||||
<span className="info-label">{row.label}</span>
|
||||
<span className="info-value" title={row.value}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
dialog.message
|
||||
)}
|
||||
</div>
|
||||
<footer className="dialog-actions">
|
||||
{dialog.mode === 'confirm' && (
|
||||
<button type="button" className="dialog-btn ghost" onClick={closeDialog}>
|
||||
|
||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -985,6 +985,7 @@ export interface ElectronAPI {
|
||||
pendingSessionIds?: string[]
|
||||
successSessionIds?: string[]
|
||||
failedSessionIds?: string[]
|
||||
sessionOutputPaths?: Record<string, string>
|
||||
error?: string
|
||||
}>
|
||||
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
|
||||
|
||||
@@ -68,4 +68,5 @@ export interface SnsLinkCardData {
|
||||
title: string
|
||||
url: string
|
||||
thumb?: string
|
||||
thumbKey?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user