fix: 支持 wxgf 原图解密并修复缩略图缓存刷新

- 增加 wxgf/HEVC 图片转换支持
- 增加 FFmpeg 跨平台安装、目录填写与能力检测
- 避免缩略图占用原图缓存,下载原图后可即时刷新
- 移除聊天图片的缩略图角标

(cherry picked from commit 4cee159a65496bd30dd690b568c47a120f3fff30)
This commit is contained in:
电摇小子
2026-08-03 10:03:55 +08:00
committed by Wxw-Gu
parent a3955d691d
commit 90bf1aed90
12 changed files with 639 additions and 18 deletions
+1 -6
View File
@@ -25,7 +25,6 @@ export function ImageBubble({
const [loading, setLoading] = useState(false)
const [upgrading, setUpgrading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
const [usingFallback, setUsingFallback] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const mountedRef = useRef(true)
@@ -75,7 +74,6 @@ export function ImageBubble({
if (!mountedRef.current) return
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
setError(null)
} catch (error) {
if (!mountedRef.current) return
@@ -145,7 +143,6 @@ export function ImageBubble({
if (result.data.startsWith('data:image/') || result.data.startsWith('wxe-media://')) {
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
setError(null)
onImageClick?.(result.data)
return
@@ -193,9 +190,7 @@ export function ImageBubble({
alt="图片"
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
/>
{(upgrading || isThumbnail) && (
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
)}
{upgrading && <div className="image-quality-badge"></div>}
<div className="image-actions">
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
@@ -59,6 +59,12 @@ function getCachedImage(
for (const key of keys) {
const cached = imageCache.get(key)
if (!cached) continue
if (options.force && cached.isThumbnail) {
imageCache.delete(key)
imageCacheBytes -= imageCacheSizes.get(key) || 0
imageCacheSizes.delete(key)
continue
}
imageCache.delete(key)
imageCache.set(key, cached)
return cached
@@ -81,6 +87,10 @@ function cacheImage(
options: ImageLoadOptions,
image: LoadedImage
): void {
// A forced original request may temporarily fall back to a thumbnail. Do not
// let that fallback prevent a later retry after WeChat downloads the original.
if (options.force && image.isThumbnail) return
const keys = cacheKeys(imageMd5, imageDatName, options)
const size = image.data.length * 2
for (const key of keys) {
@@ -0,0 +1,182 @@
import { useEffect, useState } from 'react'
import type { ImageDecoderStatus } from '../../../../../shared/image-decryption'
interface ImageDecoderRequirementNoticeProps {
status?: ImageDecoderStatus
onNotice: (message: string) => void
}
export function ImageDecoderRequirementNotice({
status,
onNotice
}: ImageDecoderRequirementNoticeProps): React.ReactElement {
const platform = window.electron.process.platform
const [currentStatus, setCurrentStatus] = useState(status)
const [selecting, setSelecting] = useState(false)
const [checking, setChecking] = useState(false)
const [error, setError] = useState<string>()
useEffect(() => setCurrentStatus(status), [status])
const selectDirectory = async (): Promise<void> => {
setSelecting(true)
setError(undefined)
try {
const result = await window.api.selectImageDecoder()
if (result.canceled) return
if (!result.success || !result.status) {
setError(result.error || '没有在所选文件夹中找到 FFmpeg,请重新选择。')
return
}
setCurrentStatus(result.status)
onNotice(
result.status.available
? 'FFmpeg 安装目录已保存,原图支持可以使用'
: 'FFmpeg 安装目录已保存,但原图支持未通过检测'
)
} catch {
setError('无法打开目录选择窗口,请稍后重试。')
} finally {
setSelecting(false)
}
}
const checkOriginalSupport = async (): Promise<void> => {
setChecking(true)
setError(undefined)
try {
const nextStatus = await window.api.getImageDecoderStatus()
setCurrentStatus(nextStatus)
onNotice(nextStatus.available ? '原图支持检测通过' : '原图支持检测未通过')
} catch {
setError('原图支持检测失败,请稍后重试。')
} finally {
setChecking(false)
}
}
const openDownload = async (): Promise<void> => {
setError(undefined)
try {
const result = await window.api.openImageDecoderDownload()
if (!result.success) setError(result.error || '无法打开 FFmpeg 下载页面')
} catch {
setError('无法打开 FFmpeg 下载页面,请检查系统默认浏览器设置。')
}
}
const installed = currentStatus?.installed === true
const supported = currentStatus?.available === true
const downloadLabel = platform === 'darwin' ? '打开 Homebrew 官网' : '下载 FFmpeg'
return (
<section
className={`image-decoder-requirement ${supported ? 'ready' : ''}`}
aria-label="FFmpeg 原图支持"
>
<span className="image-decoder-requirement-icon" aria-hidden>
{supported ? '✓' : '!'}
</span>
<div className="image-decoder-requirement-body">
<div className="image-decoder-requirement-heading">
<strong>FFmpeg </strong>
<span>
{supported ? '原图支持可用' : installed ? 'FFmpeg 已安装' : '需要安装 FFmpeg'}
</span>
</div>
<p> wxgf/HEVC FFmpeg</p>
<div className="image-decoder-checks">
<div>
<span className={`image-decoder-check-index ${installed ? 'complete' : ''}`}>1</span>
<div>
<strong>FFmpeg </strong>
<small title={currentStatus?.directory}>
{currentStatus?.directory || '尚未检测到 FFmpeg 安装目录'}
</small>
</div>
<b className={installed ? 'success' : ''}>{installed ? '已检测' : '未检测'}</b>
</div>
<div>
<span className={`image-decoder-check-index ${supported ? 'complete' : ''}`}>2</span>
<div>
<strong></strong>
<small>
{supported
? '已支持 wxgf/HEVC 特殊原图转换'
: installed
? '尚未检测到 HEVC 原图转换能力'
: '请先安装并检测 FFmpeg 目录'}
</small>
</div>
<b className={supported ? 'success' : ''}>{supported ? '已通过' : '未通过'}</b>
</div>
</div>
<div className="image-decoder-requirement-actions">
<button
type="button"
className="settings-header-action"
onClick={() => void openDownload()}
>
{downloadLabel}
</button>
<button
type="button"
className="settings-primary-button"
disabled={selecting}
onClick={() => void selectDirectory()}
>
{selecting ? '正在保存…' : '填写 FFmpeg 安装目录'}
</button>
<button
type="button"
className="settings-header-action"
disabled={!installed || checking}
onClick={() => void checkOriginalSupport()}
>
{checking ? '正在检测…' : '检测原图支持'}
</button>
</div>
<div className="image-decoder-platform-help">
{platform === 'win32' ? (
<>
<strong>Windows </strong>
<p>
PowerShell <code>(Get-Command ffmpeg).Source</code>CMD{' '}
<code>where ffmpeg</code> bin
</p>
</>
) : platform === 'darwin' ? (
<>
<strong>macOS </strong>
<p>
<code>brew install ffmpeg</code>
brew Homebrew {' '}
<code>which ffmpeg</code> {' '}
<code>/opt/homebrew/bin</code> <code>/usr/local/bin</code>
</p>
</>
) : (
<>
<strong>Linux </strong>
<p>
FFmpeg <code>which ffmpeg</code>{' '}
</p>
</>
)}
</div>
{error && (
<p className="image-decoder-requirement-error" role="alert">
{error}
</p>
)}
</div>
</section>
)
}
@@ -1,6 +1,7 @@
import type { SettingsSelfInfo } from '../model/types'
import { AutoDetectImageKeySection } from '../image-decryption/AutoDetectImageKeySection'
import { DangerZone } from '../image-decryption/DangerZone'
import { ImageDecoderRequirementNotice } from '../image-decryption/ImageDecoderRequirementNotice'
import { ImageDecryptStatus } from '../image-decryption/ImageDecryptStatus'
import { ImageKeyConfiguration } from '../image-decryption/ImageKeyConfiguration'
import { ImageTestSection } from '../image-decryption/ImageTestSection'
@@ -55,6 +56,11 @@ export function ImageDecryptionPage({
</div>
</section>
<ImageDecoderRequirementNotice
status={controller.state.status?.decoder}
onNotice={onNotice}
/>
<h2 className="settings-section-heading"></h2>
<ImageDecryptStatus
state={controller.state}
+164
View File
@@ -956,6 +956,170 @@
.image-decryption-content {
padding-bottom: 48px;
}
.image-decoder-requirement {
display: flex;
gap: 14px;
align-items: flex-start;
margin-top: 14px;
padding: 18px;
border: 1px solid #ead2a8;
border-radius: 8px;
background: #fff9ee;
color: #4b4439;
}
.image-decoder-requirement.ready {
border-color: #bfddd2;
background: #f2f8f5;
}
.image-decoder-requirement-icon {
display: grid;
width: 22px;
height: 22px;
flex: 0 0 22px;
place-items: center;
border-radius: 50%;
background: #b87524;
color: #fff;
font-size: 13px;
font-weight: 700;
}
.image-decoder-requirement.ready .image-decoder-requirement-icon {
background: #247a63;
}
.image-decoder-requirement-body {
min-width: 0;
flex: 1;
}
.image-decoder-requirement-heading {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.image-decoder-requirement-heading strong {
color: #61451f;
font-size: 14px;
}
.image-decoder-requirement.ready .image-decoder-requirement-heading strong {
color: #294b41;
}
.image-decoder-requirement-heading span {
flex: 0 0 auto;
border-radius: 999px;
padding: 3px 8px;
background: rgba(184, 117, 36, 0.1);
color: #8b5d23;
font-size: 11px;
}
.image-decoder-requirement.ready .image-decoder-requirement-heading span {
background: #e1f0ea;
color: #247a63;
}
.image-decoder-requirement p {
margin: 7px 0 12px;
color: #6d665c;
font-size: 13px;
line-height: 1.6;
}
.image-decoder-requirement.ready p {
color: #5d6d66;
}
.image-decoder-checks {
margin: 2px 0 14px;
border-top: 1px solid rgba(139, 103, 47, 0.16);
}
.image-decoder-checks > div {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-height: 48px;
border-bottom: 1px solid rgba(139, 103, 47, 0.16);
}
.image-decoder-check-index {
display: grid;
width: 20px;
height: 20px;
place-items: center;
border-radius: 50%;
background: rgba(184, 117, 36, 0.12);
color: #8b5d23;
font-size: 11px;
font-weight: 700;
}
.image-decoder-check-index.complete {
background: #e1f0ea;
color: #247a63;
}
.image-decoder-checks strong,
.image-decoder-checks small {
display: block;
}
.image-decoder-checks strong {
color: #4f4a42;
font-size: 12px;
}
.image-decoder-checks small {
overflow: hidden;
margin-top: 2px;
color: #81796d;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.image-decoder-checks b {
color: #9a7750;
font-size: 11px;
font-weight: 600;
}
.image-decoder-checks b.success {
color: #247a63;
}
.image-decoder-requirement-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 9px;
}
.image-decoder-requirement-actions button {
min-height: 34px;
}
.image-decoder-requirement small {
display: block;
color: #81796d;
font-size: 11px;
line-height: 1.6;
}
.image-decoder-platform-help {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(139, 103, 47, 0.16);
}
.image-decoder-platform-help strong {
color: #5b5145;
font-size: 12px;
}
.image-decoder-platform-help p {
margin: 5px 0 0;
color: #71695f;
font-size: 12px;
line-height: 1.75;
}
.image-decoder-platform-help code {
border-radius: 4px;
padding: 1px 5px;
background: rgba(117, 82, 31, 0.1);
color: #68491f;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
overflow-wrap: anywhere;
}
.image-decoder-requirement .image-decoder-requirement-error {
margin: 10px 0 0;
color: #b34848;
font-size: 12px;
}
.image-decrypt-badge.unconfigured {
background: #f1f3f2;
color: #66706b;