mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 支持点击图片优先打开原图
This commit is contained in:
@@ -72,24 +72,33 @@ export class ImageDecryptService {
|
|||||||
/**
|
/**
|
||||||
* 根据 md5 查找图片文件 (WechatExplorer 风格)
|
* 根据 md5 查找图片文件 (WechatExplorer 风格)
|
||||||
*/
|
*/
|
||||||
findImageFile(md5?: string, imageDatName?: string): string | null {
|
findImageFile(
|
||||||
|
md5?: string,
|
||||||
|
imageDatName?: string,
|
||||||
|
options?: { allowThumbnail?: boolean }
|
||||||
|
): string | null {
|
||||||
const accountDir = this.getAccountDir()
|
const accountDir = this.getAccountDir()
|
||||||
if (!accountDir) return null
|
if (!accountDir) return null
|
||||||
|
const allowThumbnail = options?.allowThumbnail !== false
|
||||||
|
|
||||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||||
console.log('[ImageDecrypt] findImageFile:', {
|
console.log('[ImageDecrypt] findImageFile:', {
|
||||||
md5: normalizedMd5,
|
md5: normalizedMd5,
|
||||||
imageDatName: normalizedDatName,
|
imageDatName: normalizedDatName,
|
||||||
accountDir
|
accountDir,
|
||||||
|
allowThumbnail
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const key of this.uniq([normalizedMd5, normalizedDatName])) {
|
for (const key of this.uniq([normalizedMd5, normalizedDatName])) {
|
||||||
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
||||||
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
||||||
if (fullPath && existsSync(fullPath)) {
|
if (fullPath && existsSync(fullPath)) {
|
||||||
console.log('[ImageDecrypt] hardlink hit:', fullPath)
|
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
|
||||||
return this.getPreferredDatVariantPath(fullPath, true)
|
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
||||||
|
console.log('[ImageDecrypt] hardlink hit:', selected)
|
||||||
|
return selected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,18 +113,22 @@ export class ImageDecryptService {
|
|||||||
if (searchKeys.length === 0) return null
|
if (searchKeys.length === 0) return null
|
||||||
|
|
||||||
for (const key of searchKeys) {
|
for (const key of searchKeys) {
|
||||||
const directHit = this.fastProbabilisticSearch(attachDir, key)
|
const directHit = this.fastProbabilisticSearch(attachDir, key, allowThumbnail)
|
||||||
if (directHit) return directHit
|
if (directHit) return directHit
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0])
|
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
|
||||||
if (legacyHit) return legacyHit
|
if (legacyHit) return legacyHit
|
||||||
|
|
||||||
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fastProbabilisticSearch(attachDir: string, datName: string): string | null {
|
private fastProbabilisticSearch(
|
||||||
|
attachDir: string,
|
||||||
|
datName: string,
|
||||||
|
allowThumbnail = true
|
||||||
|
): string | null {
|
||||||
const normalized = this.normalizeDatBase(datName)
|
const normalized = this.normalizeDatBase(datName)
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
|
|
||||||
@@ -131,7 +144,7 @@ export class ImageDecryptService {
|
|||||||
join(attachDir, dir1, dir2, 'Image', variant),
|
join(attachDir, dir1, dir2, 'Image', variant),
|
||||||
join(attachDir, dir1, dir2, 'image', variant)
|
join(attachDir, dir1, dir2, 'image', variant)
|
||||||
]
|
]
|
||||||
const found = this.getLargestExistingPath(candidates, true)
|
const found = this.getLargestExistingPath(candidates, allowThumbnail)
|
||||||
if (found) {
|
if (found) {
|
||||||
console.log('[ImageDecrypt] prefix path hit:', found)
|
console.log('[ImageDecrypt] prefix path hit:', found)
|
||||||
return found
|
return found
|
||||||
@@ -159,7 +172,7 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
const found = this.getLargestExistingPath(
|
const found = this.getLargestExistingPath(
|
||||||
variants.map((variant) => join(imgDir, variant)),
|
variants.map((variant) => join(imgDir, variant)),
|
||||||
true
|
allowThumbnail
|
||||||
)
|
)
|
||||||
if (found) {
|
if (found) {
|
||||||
console.log('[ImageDecrypt] found at:', found)
|
console.log('[ImageDecrypt] found at:', found)
|
||||||
@@ -175,7 +188,11 @@ export class ImageDecryptService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private findImageFileInLegacyDirs(accountDir: string, datName: string): string | null {
|
private findImageFileInLegacyDirs(
|
||||||
|
accountDir: string,
|
||||||
|
datName: string,
|
||||||
|
allowThumbnail = true
|
||||||
|
): string | null {
|
||||||
const normalized = this.normalizeDatBase(datName)
|
const normalized = this.normalizeDatBase(datName)
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
|
|
||||||
@@ -186,18 +203,27 @@ export class ImageDecryptService {
|
|||||||
].filter((root) => existsSync(root))
|
].filter((root) => existsSync(root))
|
||||||
|
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
const found = this.recursiveFindDat(root, normalized, 5)
|
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail)
|
||||||
if (found) return found
|
if (found) return found
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private recursiveFindDat(dir: string, datName: string, depth: number): string | null {
|
private recursiveFindDat(
|
||||||
|
dir: string,
|
||||||
|
datName: string,
|
||||||
|
depth: number,
|
||||||
|
allowThumbnail = true
|
||||||
|
): string | null {
|
||||||
if (depth < 0) return null
|
if (depth < 0) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const variants = new Set(this.buildPreferredDatNames(datName))
|
const variants = new Set(
|
||||||
|
this.buildPreferredDatNames(datName).filter(
|
||||||
|
(name) => allowThumbnail || !this.isThumbnailName(name)
|
||||||
|
)
|
||||||
|
)
|
||||||
const entries = readdirSync(dir)
|
const entries = readdirSync(dir)
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry)
|
const fullPath = join(dir, entry)
|
||||||
@@ -211,7 +237,7 @@ export class ImageDecryptService {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(dir, entry)
|
const fullPath = join(dir, entry)
|
||||||
if (!statSync(fullPath).isDirectory()) continue
|
if (!statSync(fullPath).isDirectory()) continue
|
||||||
const found = this.recursiveFindDat(fullPath, datName, depth - 1)
|
const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail)
|
||||||
if (found) return found
|
if (found) return found
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -422,6 +448,8 @@ export class ImageDecryptService {
|
|||||||
`${base}.dat`,
|
`${base}.dat`,
|
||||||
`${base}_hd.dat`,
|
`${base}_hd.dat`,
|
||||||
`${base}_h.dat`,
|
`${base}_h.dat`,
|
||||||
|
`${base}_b.dat`,
|
||||||
|
`${base}_w.dat`,
|
||||||
`${base}_c.dat`,
|
`${base}_c.dat`,
|
||||||
`${base}_t.dat`,
|
`${base}_t.dat`,
|
||||||
`${base}.thumb.dat`,
|
`${base}.thumb.dat`,
|
||||||
@@ -470,6 +498,10 @@ export class ImageDecryptService {
|
|||||||
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
|
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isThumbnailFile(filePath: string): boolean {
|
||||||
|
return this.isThumbnailName(basename(filePath))
|
||||||
|
}
|
||||||
|
|
||||||
private unwrapWxgf(buffer: Buffer): Buffer {
|
private unwrapWxgf(buffer: Buffer): Buffer {
|
||||||
if (
|
if (
|
||||||
buffer.length < 20 ||
|
buffer.length < 20 ||
|
||||||
|
|||||||
+45
-22
@@ -1,4 +1,4 @@
|
|||||||
import './preload-env'
|
import './preload-env'
|
||||||
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard, Menu, Tray } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain, nativeImage, clipboard, Menu, Tray } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
@@ -76,7 +76,7 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
|
|||||||
|
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// 创建浏览器窗口
|
// 鍒涘缓娴忚鍣ㄧ獥鍙?
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
height: 800,
|
height: 800,
|
||||||
@@ -98,8 +98,8 @@ function createWindow(): void {
|
|||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 基于 electron-vite cli 的渲染器 HMR
|
// 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR
|
||||||
// 加载开发环境的远程 URL 或生产环境的本地 html 文件
|
// 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢
|
||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
} else {
|
} else {
|
||||||
@@ -107,8 +107,8 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
|
// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶
|
||||||
// 某些 API 只能在此事件发生后使用
|
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||||
|
|
||||||
@@ -122,12 +122,12 @@ app.whenReady().then(async () => {
|
|||||||
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为窗口设置应用程序用户模型 ID
|
// 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID
|
||||||
electronApp.setAppUserModelId('com.electron')
|
electronApp.setAppUserModelId('com.electron')
|
||||||
|
|
||||||
// 在开发环境中默认按 F12 打开或关闭 DevTools
|
// 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools
|
||||||
// 在生产环境中忽略 CommandOrControl + R
|
// 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R
|
||||||
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
// 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
@@ -297,7 +297,7 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: '未配置 API Key' }
|
return { success: false, error: '未配置 API Key' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动态导入以避免如果未安装或初始类型缺失的问题
|
// 鍔ㄦ€佸鍏ヤ互閬垮厤濡傛灉鏈畨瑁呮垨鍒濆绫诲瀷缂哄け鐨勯棶棰?
|
||||||
const { OpenAI } = await import('openai')
|
const { OpenAI } = await import('openai')
|
||||||
|
|
||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
@@ -358,20 +358,38 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'db:getImage',
|
'db:getImage',
|
||||||
async (_, imageMd5?: string, imageDatNameOrThumb?: string | boolean, _sessionId?: string) => {
|
async (
|
||||||
|
_,
|
||||||
|
imageMd5?: string,
|
||||||
|
imageDatNameOrThumb?: string | boolean,
|
||||||
|
_sessionId?: string,
|
||||||
|
options?: { force?: boolean }
|
||||||
|
) => {
|
||||||
void _sessionId
|
void _sessionId
|
||||||
if (!imageDecryptService) {
|
if (!imageDecryptService) {
|
||||||
const { xorKey, aesKey } = getConfiguredImageKeys()
|
const { xorKey, aesKey } = getConfiguredImageKeys()
|
||||||
if (!aesKey) {
|
if (!aesKey) {
|
||||||
return { success: false, error: '未配置图片解密密钥' }
|
return { success: false, error: '未配置图片解密密钥' }
|
||||||
}
|
}
|
||||||
imageDecryptService = new ImageDecryptService(xorKey, aesKey, chat.getChatDb()?.getWcdb4Client())
|
imageDecryptService = new ImageDecryptService(
|
||||||
|
xorKey,
|
||||||
|
aesKey,
|
||||||
|
chat.getChatDb()?.getWcdb4Client()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||||
const filePath = imageDecryptService.findImageFile(imageMd5, imageDatName)
|
const force = options?.force === true
|
||||||
|
let filePath = force
|
||||||
|
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
|
||||||
|
: null
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return { success: false, error: '未找到图片文件' }
|
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
|
||||||
|
allowThumbnail: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!filePath) {
|
||||||
|
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64 = imageDecryptService.decryptImageToBase64(filePath)
|
const base64 = imageDecryptService.decryptImageToBase64(filePath)
|
||||||
@@ -379,7 +397,12 @@ app.whenReady().then(async () => {
|
|||||||
return { success: false, error: '图片解密失败' }
|
return { success: false, error: '图片解密失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: base64 }
|
return {
|
||||||
|
success: true,
|
||||||
|
data: base64,
|
||||||
|
isThumb: imageDecryptService.isThumbnailFile(filePath),
|
||||||
|
filePath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -451,7 +474,7 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
// 启动本地 HTTP API(根据 settings.apiEnabled 控制)
|
// 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗)
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
if (settings.apiEnabled) {
|
if (settings.apiEnabled) {
|
||||||
await apiServer.start(settings.apiHost, settings.apiPort)
|
await apiServer.start(settings.apiHost, settings.apiPort)
|
||||||
@@ -463,15 +486,15 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.on('activate', function () {
|
app.on('activate', function () {
|
||||||
// 在 macOS 上,当点击 dock 图标且没有其他窗口打开时,
|
// 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝
|
||||||
// 通常会在应用程序中重新创建一个窗口。
|
// 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€?
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当所有窗口关闭时退出,除了 macOS。在那里,
|
// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛?
|
||||||
// 应用程序及其菜单栏通常会保持活动状态,直到用户
|
// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛
|
||||||
// 显式使用 Cmd + Q 退出。
|
// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€?
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (TRAY_MODE) return
|
if (TRAY_MODE) return
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
|
|||||||
Vendored
+3
-2
@@ -76,8 +76,9 @@ declare global {
|
|||||||
getImage: (
|
getImage: (
|
||||||
imageMd5?: string,
|
imageMd5?: string,
|
||||||
imageDatNameOrThumb?: string | boolean,
|
imageDatNameOrThumb?: string | boolean,
|
||||||
sessionId?: string
|
sessionId?: string,
|
||||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
options?: { force?: boolean }
|
||||||
|
) => Promise<{ success: boolean; data?: string; error?: string; isThumb?: boolean; filePath?: string }>
|
||||||
getSticker: (
|
getSticker: (
|
||||||
cdnUrl?: string,
|
cdnUrl?: string,
|
||||||
md5?: string
|
md5?: string
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ const api = {
|
|||||||
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
|
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
|
||||||
parseMessage: (content: string, messageType: number) =>
|
parseMessage: (content: string, messageType: number) =>
|
||||||
ipcRenderer.invoke('db:parseMessage', content, messageType),
|
ipcRenderer.invoke('db:parseMessage', content, messageType),
|
||||||
getImage: (imageMd5?: string, imageDatNameOrThumb?: string | boolean, sessionId?: string) =>
|
getImage: (
|
||||||
ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId),
|
imageMd5?: string,
|
||||||
|
imageDatNameOrThumb?: string | boolean,
|
||||||
|
sessionId?: string,
|
||||||
|
options?: { force?: boolean }
|
||||||
|
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||||
ipcRenderer.invoke('report:export', request),
|
ipcRenderer.invoke('report:export', request),
|
||||||
|
|||||||
@@ -456,6 +456,20 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-quality-badge {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
top: 6px;
|
||||||
|
max-width: calc(100% - 12px);
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.image-action-btn {
|
.image-action-btn {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export function ImageBubble({
|
|||||||
}: ImageBubbleProps): JSX.Element {
|
}: ImageBubbleProps): JSX.Element {
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [upgrading, setUpgrading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isThumbnail, setIsThumbnail] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const loadImage = useCallback(async () => {
|
const loadImage = useCallback(async () => {
|
||||||
@@ -31,22 +33,18 @@ export function ImageBubble({
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data?.startsWith('data:image/')) {
|
||||||
// 验证返回的是否是有效的图片 data URL
|
setImageUrl(result.data)
|
||||||
if (result.data.startsWith('data:image/')) {
|
setIsThumbnail(Boolean(result.isThumb))
|
||||||
setImageUrl(result.data)
|
setError(null)
|
||||||
setError(null)
|
|
||||||
} else {
|
|
||||||
// 解密后不是有效图片格式,显示未解密
|
|
||||||
setError('未解密')
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || '加载图片失败')
|
setError(result.error || '加载图片失败')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('加载图片失败')
|
setError('加载图片失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
setLoading(false)
|
|
||||||
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,16 +69,42 @@ export function ImageBubble({
|
|||||||
|
|
||||||
const handleCopy = async (event: MouseEvent): Promise<void> => {
|
const handleCopy = async (event: MouseEvent): Promise<void> => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (imageUrl) {
|
if (!imageUrl) return
|
||||||
await window.api.copyImage(imageUrl)
|
await window.api.copyImage(imageUrl)
|
||||||
alert('图片已复制')
|
alert('图片已复制')
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClick = (): void => {
|
const handleClick = async (): Promise<void> => {
|
||||||
if (imageUrl) {
|
if (!imageUrl && !error) return
|
||||||
onImageClick?.(imageUrl)
|
if (!imageMd5 && !imageDatName) {
|
||||||
|
if (imageUrl) onImageClick?.(imageUrl)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (upgrading) {
|
||||||
|
if (imageUrl) onImageClick?.(imageUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpgrading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, {
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
if (result.success && result.data?.startsWith('data:image/')) {
|
||||||
|
setImageUrl(result.data)
|
||||||
|
setIsThumbnail(Boolean(result.isThumb))
|
||||||
|
setError(null)
|
||||||
|
onImageClick?.(result.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the already visible image.
|
||||||
|
} finally {
|
||||||
|
setUpgrading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageUrl) onImageClick?.(imageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -94,7 +118,7 @@ export function ImageBubble({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="image-bubble image-error" onClick={loadImage}>
|
<div className="image-bubble image-error" onClick={loadImage}>
|
||||||
<div className="image-error-text">图片未加载</div>
|
<div className="image-error-text">{error || '图片未加载'}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -102,8 +126,8 @@ export function ImageBubble({
|
|||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="image-bubble image-placeholder">
|
<div ref={containerRef} className="image-bubble image-placeholder">
|
||||||
<div className="image-placeholder-icon">🖼</div>
|
<div className="image-placeholder-icon">图</div>
|
||||||
<div className="image-placeholder-text">加载图片中</div>
|
<div className="image-placeholder-text">加载图片中...</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -111,9 +135,12 @@ export function ImageBubble({
|
|||||||
return (
|
return (
|
||||||
<div className="image-bubble image-loaded" onClick={handleClick}>
|
<div className="image-bubble image-loaded" onClick={handleClick}>
|
||||||
<img src={imageUrl} alt="图片" className="image-content" />
|
<img src={imageUrl} alt="图片" className="image-content" />
|
||||||
|
{(upgrading || isThumbnail) && (
|
||||||
|
<div className="image-quality-badge">{upgrading ? '查找原图...' : '缩略图'}</div>
|
||||||
|
)}
|
||||||
<div className="image-actions">
|
<div className="image-actions">
|
||||||
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
|
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
|
||||||
📋
|
复制
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user