Compare commits

..
Author SHA1 Message Date
Wxw-Gu d0ae9e6019 perf: 优化图片消息后台加载与解密缓存
- 缩略图优先展示并在后台准备原图
- 增加图片请求去重与受控并发队列
- 缓存图片路径、账号目录和解密结果
2026-07-28 16:11:59 +08:00
Wxw-Gu 49684f3365 feat: 完善 AI 智能检索与定位
新增 AI 查询规划和主题变体多轮检索
修复无结果时回退全量消息导致的错误结论
优化目标成员优先级和大数据量消息匹配性能
新增检索诊断日志、任务中心和持久化缓存
支持证据按时间定位档案并闪烁提示
2026-07-28 15:52:31 +08:00
20 changed files with 3194 additions and 216 deletions
+152 -30
View File
@@ -9,10 +9,23 @@ const imageDecryptLog = (...args: unknown[]): void => {
if (imageDecryptDebugEnabled) console.log(...args)
}
type DecodedImage = {
data: string
filePath: string
isThumbnail: boolean
}
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
export class ImageDecryptService {
private xorKey: number = 0
private aesKey: string = ''
private wcdb4Client: Wcdb4Client | null = null
private accountDirResolved = false
private cachedAccountDir: string | null = null
private imagePathCache = new Map<string, string>()
private decodedImageCache = new Map<string, DecodedImage>()
private decodedImageCacheBytes = 0
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
// 解析 XOR Key (支持 0x40 或 64 格式)
@@ -32,9 +45,13 @@ export class ImageDecryptService {
* 获取账号目录
*/
private getAccountDir(): string | null {
if (this.accountDirResolved) return this.cachedAccountDir
this.accountDirResolved = true
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
return wcdbAccountRoot
this.cachedAccountDir = wcdbAccountRoot
return this.cachedAccountDir
}
const homeDir = os.homedir()
@@ -69,7 +86,8 @@ export class ImageDecryptService {
}
// 返回最新的账号目录
return join(accountRoot, accounts[0].name)
this.cachedAccountDir = join(accountRoot, accounts[0].name)
return this.cachedAccountDir
}
/**
@@ -78,16 +96,32 @@ export class ImageDecryptService {
findImageFile(
md5?: string,
imageDatName?: string,
options?: { allowThumbnail?: boolean; accountDir?: string }
options?: { allowThumbnail?: boolean; accountDir?: string; preferThumbnail?: boolean }
): string | null {
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
const accountDir =
options?.accountDir && existsSync(options.accountDir) ? options.accountDir : this.getAccountDir()
if (!accountDir) return null
const allowThumbnail = options?.allowThumbnail !== false
const normalizedMd5 = this.normalizeDatBase(md5 || '')
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
const pathCacheKey = [
normalizedMd5,
normalizedDatName,
allowThumbnail ? 'thumb' : 'original',
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
options?.accountDir || ''
].join('|')
const cachedPath = this.imagePathCache.get(pathCacheKey)
if (cachedPath && existsSync(cachedPath)) return cachedPath
const rememberPath = (path: string | null): string | null => {
if (path) this.imagePathCache.set(pathCacheKey, path)
return path
}
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
const accountDir =
options?.accountDir && existsSync(options.accountDir)
? options.accountDir
: this.getAccountDir()
if (!accountDir) return null
imageDecryptLog('[ImageDecrypt] findImageFile:', {
md5: normalizedMd5,
imageDatName: normalizedDatName,
@@ -99,10 +133,14 @@ export class ImageDecryptService {
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
if (fullPath && existsSync(fullPath)) {
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
const selected = this.getPreferredDatVariantPath(
fullPath,
allowThumbnail,
options?.preferThumbnail
)
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
return selected
return rememberPath(selected)
}
}
}
@@ -111,28 +149,75 @@ export class ImageDecryptService {
const attachDir = join(accountDir, 'msg', 'attach')
if (!existsSync(attachDir)) {
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
return rememberPath(
this.findImageFileInLegacyDirs(
accountDir,
normalizedMd5 || normalizedDatName,
allowThumbnail,
options?.preferThumbnail
)
)
}
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
if (searchKeys.length === 0) return null
for (const key of searchKeys) {
const directHit = this.fastProbabilisticSearch(attachDir, key, allowThumbnail)
if (directHit) return directHit
const directHit = this.fastProbabilisticSearch(
attachDir,
key,
allowThumbnail,
options?.preferThumbnail
)
if (directHit) return rememberPath(directHit)
}
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
if (legacyHit) return legacyHit
const legacyHit = this.findImageFileInLegacyDirs(
accountDir,
searchKeys[0],
allowThumbnail,
options?.preferThumbnail
)
if (legacyHit) return rememberPath(legacyHit)
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
return null
}
getCachedDecodedImage(key: string): DecodedImage | null {
const cached = this.decodedImageCache.get(key)
if (!cached) return null
this.decodedImageCache.delete(key)
this.decodedImageCache.set(key, cached)
return cached
}
cacheDecodedImage(key: string, image: DecodedImage): void {
const size = image.data.length * 2
const previous = this.decodedImageCache.get(key)
if (previous) {
this.decodedImageCacheBytes -= previous.data.length * 2
this.decodedImageCache.delete(key)
}
this.decodedImageCache.set(key, image)
this.decodedImageCacheBytes += size
while (
this.decodedImageCacheBytes > MAX_DECODED_IMAGE_CACHE_BYTES &&
this.decodedImageCache.size > 1
) {
const oldestKey = this.decodedImageCache.keys().next().value
if (!oldestKey) break
const oldest = this.decodedImageCache.get(oldestKey)
this.decodedImageCache.delete(oldestKey)
this.decodedImageCacheBytes -= oldest?.data.length ? oldest.data.length * 2 : 0
}
}
private fastProbabilisticSearch(
attachDir: string,
datName: string,
allowThumbnail = true
allowThumbnail = true,
preferThumbnail = false
): string | null {
const normalized = this.normalizeDatBase(datName)
if (!normalized) return null
@@ -149,7 +234,7 @@ export class ImageDecryptService {
join(attachDir, dir1, dir2, 'Image', variant),
join(attachDir, dir1, dir2, 'image', variant)
]
const found = this.getLargestExistingPath(candidates, allowThumbnail)
const found = this.getLargestExistingPath(candidates, allowThumbnail, preferThumbnail)
if (found) {
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
return found
@@ -177,7 +262,8 @@ export class ImageDecryptService {
const found = this.getLargestExistingPath(
variants.map((variant) => join(imgDir, variant)),
allowThumbnail
allowThumbnail,
preferThumbnail
)
if (found) {
imageDecryptLog('[ImageDecrypt] found at:', found)
@@ -196,7 +282,8 @@ export class ImageDecryptService {
private findImageFileInLegacyDirs(
accountDir: string,
datName: string,
allowThumbnail = true
allowThumbnail = true,
preferThumbnail = false
): string | null {
const normalized = this.normalizeDatBase(datName)
if (!normalized) return null
@@ -208,7 +295,7 @@ export class ImageDecryptService {
].filter((root) => existsSync(root))
for (const root of roots) {
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail)
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail, preferThumbnail)
if (found) return found
}
@@ -219,30 +306,52 @@ export class ImageDecryptService {
dir: string,
datName: string,
depth: number,
allowThumbnail = true
allowThumbnail = true,
preferThumbnail = false
): string | null {
if (depth < 0) return null
try {
const variantNames = this.buildPreferredDatNames(datName).filter(
(name) => allowThumbnail || !this.isThumbnailName(name)
)
const variants = new Set(
this.buildPreferredDatNames(datName).filter(
(name) => allowThumbnail || !this.isThumbnailName(name)
)
preferThumbnail
? [
...variantNames.filter((name) => this.isThumbnailName(name)),
...variantNames.filter((name) => !this.isThumbnailName(name))
]
: variantNames
)
const entries = readdirSync(dir)
const matchingFiles: string[] = []
for (const entry of entries) {
const fullPath = join(dir, entry)
const stat = statSync(fullPath)
if (stat.isFile() && variants.has(entry.toLowerCase())) {
imageDecryptLog('[ImageDecrypt] legacy path hit:', fullPath)
return fullPath
matchingFiles.push(fullPath)
}
}
const preferredFile = this.getLargestExistingPath(
matchingFiles,
allowThumbnail,
preferThumbnail
)
if (preferredFile) {
imageDecryptLog('[ImageDecrypt] legacy path hit:', preferredFile)
return preferredFile
}
for (const entry of entries) {
const fullPath = join(dir, entry)
if (!statSync(fullPath).isDirectory()) continue
const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail)
const found = this.recursiveFindDat(
fullPath,
datName,
depth - 1,
allowThumbnail,
preferThumbnail
)
if (found) return found
}
} catch {
@@ -491,7 +600,11 @@ export class ImageDecryptService {
]
}
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
private getPreferredDatVariantPath(
inputPath: string,
allowThumbnail: boolean,
preferThumbnail = false
): string {
const actualDir = dirname(inputPath)
const base = this.normalizeDatBase(basename(inputPath))
const variants = this.buildPreferredDatNames(base)
@@ -500,13 +613,18 @@ export class ImageDecryptService {
: variants.filter((name) => !this.isThumbnailName(name))
const largest = this.getLargestExistingPath(
ordered.map((variant) => join(actualDir, variant)),
allowThumbnail
allowThumbnail,
preferThumbnail
)
if (largest) return largest
return inputPath
}
private getLargestExistingPath(paths: string[], allowThumbnail: boolean): string | null {
private getLargestExistingPath(
paths: string[],
allowThumbnail: boolean,
preferThumbnail = false
): string | null {
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
candidates
.filter((candidate) => existsSync(candidate))
@@ -519,6 +637,10 @@ export class ImageDecryptService {
})
.sort((left, right) => right.size - left.size)
const thumbnail = toSized(
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
)
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
const nonThumb = toSized(
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
)
+32 -5
View File
@@ -579,7 +579,13 @@ app.whenReady().then(async () => {
ipcMain.handle(
'db:getMessages',
async (_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
async (
_,
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
) => {
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
if (chat.isReady()) {
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
@@ -654,7 +660,6 @@ app.whenReady().then(async () => {
return { success: false, error: String(error) }
}
})
ipcMain.handle('report:listGenerated', async () => {
return listGeneratedReports()
})
@@ -697,7 +702,7 @@ app.whenReady().then(async () => {
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
_sessionId?: string,
options?: { force?: boolean }
options?: { force?: boolean; preferThumbnail?: boolean }
) => {
void _sessionId
if (!imageDecryptService) {
@@ -714,12 +719,28 @@ app.whenReady().then(async () => {
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
const force = options?.force === true
const preferThumbnail = options?.preferThumbnail === true
const imageCacheKey = [
imageMd5 || '',
imageDatName || '',
force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto'
].join('|')
const cachedImage = imageDecryptService.getCachedDecodedImage(imageCacheKey)
if (cachedImage) {
return {
success: true,
data: cachedImage.data,
isThumb: cachedImage.isThumbnail,
filePath: cachedImage.filePath
}
}
let filePath = force
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
: null
if (!filePath) {
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
allowThumbnail: true
allowThumbnail: true,
preferThumbnail
})
}
if (!filePath) {
@@ -731,12 +752,18 @@ app.whenReady().then(async () => {
return { success: false, error: '图片解密失败' }
}
return {
const result = {
success: true,
data: decrypted.data,
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
filePath: decrypted.filePath
}
imageDecryptService.cacheDecodedImage(imageCacheKey, {
data: result.data,
filePath: result.filePath,
isThumbnail: result.isThumb
})
return result
}
)
+2
View File
@@ -29,6 +29,7 @@ export interface AppSettings {
imageAesKey: string
imageKeyFallbackDisabled: boolean
recallProtectionEnabled: boolean
debugEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
}
@@ -105,6 +106,7 @@ const DEFAULT_SETTINGS: AppSettings = {
imageAesKey: '',
imageKeyFallbackDisabled: false,
recallProtectionEnabled: false,
debugEnabled: false,
autoLogin: ['1', 'true', 'yes', 'on'].includes(
String(import.meta.env.VITE_AUTO_LOGIN || '')
.trim()
+5 -1
View File
@@ -196,7 +196,7 @@ declare global {
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string,
options?: { force?: boolean }
options?: { force?: boolean; preferThumbnail?: boolean }
) => Promise<{
success: boolean
data?: string
@@ -252,6 +252,7 @@ declare global {
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
debugEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
@@ -279,6 +280,7 @@ declare global {
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
debugEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
@@ -294,6 +296,7 @@ declare global {
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
debugEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
@@ -307,6 +310,7 @@ declare global {
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
debugEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
+1 -1
View File
@@ -61,7 +61,7 @@ const api = {
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string,
options?: { force?: boolean }
options?: { force?: boolean; preferThumbnail?: boolean }
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
+79 -59
View File
@@ -21,6 +21,7 @@ import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
import { Contact, Message } from '../../shared/types'
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
import { ExportWorkspace } from './components/export/ExportWorkspace'
import { AISearchWorkspace } from './components/search/AISearchWorkspace'
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
const SIDEBAR_MIN_WIDTH = 260
@@ -101,10 +102,7 @@ const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[])
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
}
if (
quotedSender === quote.quotedSender &&
quotedImageDatName === quote.quotedImageDatName
) {
if (quotedSender === quote.quotedSender && quotedImageDatName === quote.quotedImageDatName) {
return message
}
return {
@@ -227,6 +225,7 @@ function App(): React.ReactElement {
getDevelopmentDatabaseKey() ? 'manual' : 'automatic'
)
const [activePage, setActivePage] = useState<AppPage>('archive')
const [archiveJumpTime, setArchiveJumpTime] = useState<number | null>(null)
const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('account-database')
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
@@ -326,7 +325,9 @@ function App(): React.ReactElement {
localStorage.setItem('wxe_export_tasks', JSON.stringify(exportTasks.slice(0, 20)))
}, [exportTasks])
const handleStartExport = async (request: ExportRequest): Promise<import('../../shared/export').ExportResult> => {
const handleStartExport = async (
request: ExportRequest
): Promise<import('../../shared/export').ExportResult> => {
const task: ExportTaskRecord = {
jobId: request.jobId,
contactId: request.userMd5,
@@ -336,7 +337,9 @@ function App(): React.ReactElement {
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
createdAt: Date.now()
}
setExportTasks((current) => [task, ...current.filter((item) => item.jobId !== task.jobId)].slice(0, 20))
setExportTasks((current) =>
[task, ...current.filter((item) => item.jobId !== task.jobId)].slice(0, 20)
)
const result = await window.api.startExport(request)
setExportTasks((current) =>
current.map((item) =>
@@ -561,23 +564,25 @@ function App(): React.ReactElement {
setIsAuthenticated(true)
setIsDatabaseConnected(false)
setBootState('login')
void initPromise.then(async (result) => {
const success = typeof result === 'boolean' ? result : result.success
if (!success) {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`)
void initPromise
.then(async (result) => {
const success = typeof result === 'boolean' ? result : result.success
if (!success) {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`)
setDbKeyStatusKind('error')
return
}
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsDatabaseConnected(true)
setDbKeyStatus('已连接数据库')
// Cached contacts/self info are enough for startup. Native refresh is
// intentionally user-triggered so it cannot freeze the first session.
})
.catch((error) => {
console.warn('[Startup] background database init failed:', error)
setDbKeyStatusKind('error')
return
}
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsDatabaseConnected(true)
setDbKeyStatus('已连接数据库')
// Cached contacts/self info are enough for startup. Native refresh is
// intentionally user-triggered so it cannot freeze the first session.
}).catch((error) => {
console.warn('[Startup] background database init failed:', error)
setDbKeyStatusKind('error')
})
})
return
}
const result = await initPromise
@@ -686,17 +691,13 @@ function App(): React.ReactElement {
if (hasBootstrap) {
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
setIsAuthenticated(true)
void Promise.all([
loadContacts({ waitForAvatars: false }),
refreshSelfInfo(3)
]).catch((error) => {
console.warn('[Startup] background refresh failed:', error)
})
void Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)]).catch(
(error) => {
console.warn('[Startup] background refresh failed:', error)
}
)
} else {
await Promise.all([
loadContacts({ waitForAvatars: false }),
refreshSelfInfo(3)
])
await Promise.all([loadContacts({ waitForAvatars: false }), refreshSelfInfo(3)])
setIsAuthenticated(true)
}
setStartupProgress({
@@ -911,6 +912,7 @@ function App(): React.ReactElement {
}
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
setArchiveJumpTime(null)
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
currentGroupSnapshotRef.current = null
@@ -984,6 +986,26 @@ function App(): React.ReactElement {
}
}
const handleOpenSearchEvidence = async (contact: Contact, createTime?: number): Promise<void> => {
setActivePage('archive')
await handleSelectContact(contact)
if (!createTime || selectedContactMd5Ref.current !== contact.md5) return
try {
const windowStart = Math.max(0, createTime - 12 * 3600)
const windowEnd = createTime + 12 * 3600
const nearbyMessages = await window.api.getMessages(contact.md5, windowStart, windowEnd)
if (selectedContactMd5Ref.current !== contact.md5) return
const focusedMessages = sortMessagesChronologically(nearbyMessages)
messageHistoryRef.current = focusedMessages
setMessages(applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, focusedMessages)))
setArchiveJumpTime(createTime)
} catch (error) {
console.warn('[Search] evidence context load failed:', error)
setReportNotice('证据所在时间段加载失败,请在档案中手动查看')
}
}
React.useEffect(() => {
if (!isDatabaseConnected || !selectedContact) return
void handleSelectContact(selectedContact)
@@ -1045,7 +1067,10 @@ function App(): React.ReactElement {
if (selectedContactMd5Ref.current !== contact.md5) return
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
setMessages((current) =>
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current)))
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current))
)
)
} catch (error) {
console.warn('[Messages] older page load failed:', error)
@@ -1083,12 +1108,9 @@ function App(): React.ReactElement {
}
refreshInFlight = true
try {
const latestMessages = await window.api.getMessages(
contactMd5,
undefined,
undefined,
{ limit: INITIAL_MESSAGE_COUNT }
)
const latestMessages = await window.api.getMessages(contactMd5, undefined, undefined, {
limit: INITIAL_MESSAGE_COUNT
})
const nextMessages = applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, latestMessages)
@@ -1114,7 +1136,9 @@ function App(): React.ReactElement {
const unsubscribe = window.api.onWcdbChange(({ json }) => {
const eventText = String(json || '').toLowerCase()
const targetIds = [contactMd5, selectedContact.m_nsUsrName].filter(Boolean).map((value) => value.toLowerCase())
const targetIds = [contactMd5, selectedContact.m_nsUsrName]
.filter(Boolean)
.map((value) => value.toLowerCase())
if (!targetIds.some((targetId) => eventText.includes(targetId))) return
if (refreshTimer) window.clearTimeout(refreshTimer)
refreshTimer = window.setTimeout(() => {
@@ -1327,24 +1351,6 @@ function App(): React.ReactElement {
return { success: true }
}
const renderPlaceholderPage = (
page: Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>
): React.ReactElement => {
const labels: Record<Exclude<AppPage, 'archive' | 'report' | 'agent-hub'>, string> = {
search: '检索',
export: '导出',
api: 'API',
settings: '设置'
}
return (
<div className="app-page-placeholder">
<div className="app-page-placeholder-eyebrow">WechatExplorer</div>
<h2>{labels[page]}</h2>
<p> UI </p>
</div>
)
}
const renderArchiveWorkspace = (): React.ReactElement => (
<div className="app-container">
<Sidebar
@@ -1372,6 +1378,7 @@ function App(): React.ReactElement {
onLoadOlderMessages={handleLoadOlderMessages}
onCreateGroupReport={handleOpenReportWorkspace}
isAiLoading={reportGeneration.isGenerating}
jumpToTime={archiveJumpTime}
/>
</div>
)
@@ -1489,7 +1496,20 @@ function App(): React.ReactElement {
/>
)
case 'search':
return renderPlaceholderPage(activePage)
return (
<AISearchWorkspace
contacts={contacts}
selectedContact={selectedContact}
dbReady={isDatabaseConnected}
aiModelConfig={aiModelConfig}
onSelectContact={(contact) => void handleSelectContact(contact)}
onOpenEvidence={(contact, createTime) =>
void handleOpenSearchEvidence(contact, createTime)
}
onOpenAISettings={openModelSettings}
onNotice={setReportNotice}
/>
)
case 'export':
return (
<ExportWorkspace
+14 -7
View File
@@ -18,6 +18,7 @@ interface ChatWindowProps {
onLoadOlderMessages?: () => Promise<void>
onCreateGroupReport?: () => void
isAiLoading?: boolean
jumpToTime?: number | null
}
const ChatWindow: React.FC<ChatWindowProps> = ({
@@ -31,7 +32,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onReloadAvatars,
onLoadOlderMessages,
onCreateGroupReport,
isAiLoading = false
isAiLoading = false,
jumpToTime
}) => {
const isGroupChat = Boolean(
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
@@ -73,19 +75,23 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}, [contact?.md5])
useEffect(() => {
if (!isAtLatest) return
const frame = window.requestAnimationFrame(() => scrollToBottom())
return () => window.cancelAnimationFrame(frame)
}, [isAtLatest, messages, scrollToBottom])
if (jumpToTime !== undefined && jumpToTime !== null) setIsAtLatest(false)
}, [jumpToTime])
useEffect(() => {
if (!isAtLatest) return
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
const frame = window.requestAnimationFrame(() => scrollToBottom())
return () => window.cancelAnimationFrame(frame)
}, [isAtLatest, jumpToTime, messages, scrollToBottom])
useEffect(() => {
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
const content = messageListRef.current?.querySelector('.virtual-message-list')
if (!content) return
const observer = new ResizeObserver(() => scrollToBottom())
observer.observe(content)
return () => observer.disconnect()
}, [contact?.md5, isAtLatest, scrollToBottom])
}, [contact?.md5, isAtLatest, jumpToTime, scrollToBottom])
const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl)
@@ -206,6 +212,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
onScroll={handleMessageListScroll}
onReachTop={onLoadOlderMessages}
onImageClick={openImagePreview}
jumpToTime={jumpToTime}
/>
<ChatStatusBar
count={filteredMessages.length}
+54 -72
View File
@@ -1,44 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import type { JSX, MouseEvent } from 'react'
type CachedImage = { data: string; isThumbnail: boolean }
const MAX_IMAGE_CACHE_ENTRIES = 80
const imageDataUrlCache = new Map<string, CachedImage>()
function imageCacheKeys(imageMd5?: string, imageDatName?: string): string[] {
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
Boolean
)
}
function getCachedImage(imageMd5?: string, imageDatName?: string): CachedImage | undefined {
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
const cached = imageDataUrlCache.get(key)
if (cached) {
imageDataUrlCache.delete(key)
imageDataUrlCache.set(key, cached)
return cached
}
}
return undefined
}
function cacheImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
cached: CachedImage
): void {
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
imageDataUrlCache.delete(key)
imageDataUrlCache.set(key, cached)
}
while (imageDataUrlCache.size > MAX_IMAGE_CACHE_ENTRIES) {
const oldestKey = imageDataUrlCache.keys().next().value
if (!oldestKey) break
imageDataUrlCache.delete(oldestKey)
}
}
import { getCachedLoadedImage, requestImage } from './image-loader'
interface ImageBubbleProps {
imageMd5?: string
@@ -53,11 +15,12 @@ export function ImageBubble({
imageMd5,
imageDatName,
sessionId,
isThumb = false,
fallbackUrl,
onImageClick
}: ImageBubbleProps): JSX.Element {
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
const initialCachedImage = getCachedLoadedImage(imageMd5, imageDatName, {
preferThumbnail: true
})
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
const [loading, setLoading] = useState(false)
const [upgrading, setUpgrading] = useState(false)
@@ -65,6 +28,26 @@ export function ImageBubble({
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
const [usingFallback, setUsingFallback] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const mountedRef = useRef(true)
const backgroundUpgradeRef = useRef(false)
useEffect(() => {
return () => {
mountedRef.current = false
}
}, [])
const upgradeOriginalInBackground = useCallback(() => {
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return
backgroundUpgradeRef.current = true
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1)
.then((original) => {
if (!mountedRef.current) return
setImageUrl(original.data)
setIsThumbnail(false)
})
.catch(() => undefined)
}, [imageDatName, imageMd5, isThumbnail, sessionId])
const loadImage = useCallback(async () => {
if (imageUrl || loading) return
@@ -81,37 +64,42 @@ export function ImageBubble({
setLoading(true)
try {
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
if (result.success && result.data?.startsWith('data:image/')) {
cacheImage(imageMd5, imageDatName, {
data: result.data,
isThumbnail: Boolean(result.isThumb)
})
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(Boolean(result.isThumb))
setError(null)
} else {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError(result.error || '加载图片失败')
}
}
} catch {
const result = await requestImage(
imageMd5,
imageDatName,
sessionId,
{ preferThumbnail: true },
0
)
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(result.isThumbnail)
setError(null)
if (result.isThumbnail) upgradeOriginalInBackground()
} catch (error) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError('加载图片失败')
setError(error instanceof Error ? error.message : '加载图片失败')
}
} finally {
setLoading(false)
}
}, [fallbackUrl, imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
}, [
fallbackUrl,
imageDatName,
imageMd5,
imageUrl,
loading,
sessionId,
upgradeOriginalInBackground
])
useEffect(() => {
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
useEffect(() => {
if (imageUrl || loading || error) return
@@ -154,17 +142,11 @@ export function ImageBubble({
setUpgrading(true)
try {
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, {
force: true
})
if (result.success && result.data?.startsWith('data:image/')) {
cacheImage(imageMd5, imageDatName, {
data: result.data,
isThumbnail: Boolean(result.isThumb)
})
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
if (result.data.startsWith('data:image/')) {
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(Boolean(result.isThumb))
setIsThumbnail(result.isThumbnail)
setError(null)
onImageClick?.(result.data)
return
@@ -14,6 +14,7 @@ interface MessageBubbleProps {
isMine: boolean
showAvatarSpace: boolean
onImageClick: (imageUrl: string) => void
isJumpTarget?: boolean
}
const RICH_MESSAGE_TYPES = [
@@ -39,7 +40,8 @@ export function MessageBubble({
isGroupChat,
isMine,
showAvatarSpace,
onImageClick
onImageClick,
isJumpTarget
}: MessageBubbleProps): React.ReactElement {
const isVoice = message.type === '语音'
const isImage = message.type === '图片'
@@ -48,7 +50,9 @@ export function MessageBubble({
const hoverTime = formatMessageTime(message)
return (
<div className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'}`}>
<div
className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'} ${isJumpTarget ? 'archive-jump-message' : ''}`}
>
<div
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
isImage ? 'image-message-bubble' : ''
@@ -9,6 +9,7 @@ interface MessageGroupProps {
isGroupChat: boolean
showAvatar: boolean
onImageClick: (imageUrl: string) => void
jumpTargetMessageId?: string
}
export function MessageGroup({
@@ -16,7 +17,8 @@ export function MessageGroup({
contact,
isGroupChat,
showAvatar,
onImageClick
onImageClick,
jumpTargetMessageId
}: MessageGroupProps): React.ReactElement {
const firstMessage = group.messages[0]
@@ -57,9 +59,7 @@ export function MessageGroup({
)}
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
<div className="message-stack">
{!isMine && isGroupChat && (
<div className="message-sender-name">{displayName}</div>
)}
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
{group.messages.map((message, index) => (
<MessageBubble
key={message.id}
@@ -69,6 +69,7 @@ export function MessageGroup({
isMine={isMine}
showAvatarSpace={index === 0}
onImageClick={onImageClick}
isJumpTarget={message.id === jumpTargetMessageId}
/>
))}
</div>
@@ -16,6 +16,7 @@ interface MessageListProps {
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
onReachTop?: () => Promise<void>
onImageClick: (imageUrl: string) => void
jumpToTime?: number | null
}
export function MessageList({
@@ -29,14 +30,13 @@ export function MessageList({
bottomRef,
onScroll,
onReachTop,
onImageClick
onImageClick,
jumpToTime
}: MessageListProps): React.ReactElement {
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
const groupsRef = React.useRef(groups)
const loadingOlderRef = React.useRef(false)
groupsRef.current = groups
// TanStack Virtual intentionally exposes mutable measurement methods.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: groups.length,
getScrollElement: () => listRef.current,
@@ -45,11 +45,29 @@ export function MessageList({
overscan: 8
})
const virtualItems = virtualizer.getVirtualItems()
const jumpTarget = React.useMemo(() => {
if (jumpToTime === undefined || jumpToTime === null) return null
const groupIndex = groups.findIndex((group) =>
group.messages.some((message) => (message.createTime || 0) >= jumpToTime)
)
if (groupIndex < 0) return null
const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime)
return { groupIndex, messageId: message?.id }
}, [groups, jumpToTime])
React.useEffect(() => {
if (!jumpTarget) return
const frame = window.requestAnimationFrame(() => {
virtualizer.scrollToIndex(jumpTarget.groupIndex, { align: 'center' })
})
return () => window.cancelAnimationFrame(frame)
}, [jumpTarget, virtualizer])
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
onScroll(event)
const scrollElement = event.currentTarget
if (
(jumpToTime !== undefined && jumpToTime !== null) ||
scrollElement.scrollTop >= 48 ||
loadingOlderRef.current ||
isLoadingMessages ||
@@ -117,7 +135,7 @@ export function MessageList({
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="virtual-message-group"
className={`virtual-message-group ${jumpTarget?.groupIndex === virtualItem.index ? 'archive-jump-target-group' : ''}`}
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
<MessageGroup
@@ -126,6 +144,7 @@ export function MessageList({
isGroupChat={isGroupChat}
showAvatar={showAvatar}
onImageClick={onImageClick}
jumpTargetMessageId={jumpTarget?.messageId}
/>
</div>
)
+167
View File
@@ -0,0 +1,167 @@
export type LoadedImage = {
data: string
isThumbnail: boolean
}
export type ImageLoadOptions = {
force?: boolean
preferThumbnail?: boolean
}
type QueueItem = {
priority: number
run: () => Promise<LoadedImage>
resolve: (value: LoadedImage) => void
reject: (error: Error) => void
}
const MAX_CONCURRENT_IMAGE_LOADS = 3
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
const imageCache = new Map<string, LoadedImage>()
const imageCacheSizes = new Map<string, number>()
const imageRequests = new Map<string, Promise<LoadedImage>>()
const imageQueue: QueueItem[] = []
let activeImageLoads = 0
let imageCacheBytes = 0
function imageIdentityKeys(imageMd5?: string, imageDatName?: string): string[] {
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
Boolean
)
}
function cacheMode(options: ImageLoadOptions): string {
if (options.force) return 'original'
if (options.preferThumbnail) return 'thumbnail'
return 'auto'
}
function cacheKeys(
imageMd5: string | undefined,
imageDatName: string | undefined,
options: ImageLoadOptions
): string[] {
return imageIdentityKeys(imageMd5, imageDatName).map(
(identity) => `${identity}:${cacheMode(options)}`
)
}
function getCachedImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
options: ImageLoadOptions
): LoadedImage | undefined {
const keys = cacheKeys(imageMd5, imageDatName, options)
for (const key of keys) {
const cached = imageCache.get(key)
if (!cached) continue
imageCache.delete(key)
imageCache.set(key, cached)
return cached
}
if (!options.force && options.preferThumbnail) {
for (const identity of imageIdentityKeys(imageMd5, imageDatName)) {
const fallbackKey = `${identity}:auto`
const cached = imageCache.get(fallbackKey)
if (cached) return cached
}
}
return undefined
}
function cacheImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
options: ImageLoadOptions,
image: LoadedImage
): void {
const keys = cacheKeys(imageMd5, imageDatName, options)
const size = image.data.length * 2
for (const key of keys) {
const previousSize = imageCacheSizes.get(key) || 0
imageCacheBytes -= previousSize
imageCache.delete(key)
imageCacheSizes.delete(key)
imageCache.set(key, image)
imageCacheSizes.set(key, size)
imageCacheBytes += size
}
while (imageCacheBytes > MAX_IMAGE_CACHE_BYTES && imageCache.size > 1) {
const oldestKey = imageCache.keys().next().value
if (!oldestKey) break
imageCache.delete(oldestKey)
imageCacheBytes -= imageCacheSizes.get(oldestKey) || 0
imageCacheSizes.delete(oldestKey)
}
}
function pumpImageQueue(): void {
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
imageQueue.sort((left, right) => left.priority - right.priority)
const item = imageQueue.shift()
if (!item) return
activeImageLoads += 1
void item
.run()
.then(item.resolve, item.reject)
.finally(() => {
activeImageLoads -= 1
pumpImageQueue()
})
}
}
export function getCachedLoadedImage(
imageMd5?: string,
imageDatName?: string,
options: ImageLoadOptions = {}
): LoadedImage | undefined {
return getCachedImage(imageMd5, imageDatName, options)
}
export function requestImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
sessionId: string | undefined,
options: ImageLoadOptions = {},
priority = 0
): Promise<LoadedImage> {
const cached = getCachedImage(imageMd5, imageDatName, options)
if (cached) return Promise.resolve(cached)
const identity = imageIdentityKeys(imageMd5, imageDatName)[0]
if (!identity) return Promise.reject(new Error('缺少图片标识'))
const requestKey = `${identity}:${cacheMode(options)}`
const existingRequest = imageRequests.get(requestKey)
if (existingRequest) return existingRequest
const request = new Promise<LoadedImage>((resolve, reject) => {
imageQueue.push({
priority,
resolve,
reject,
run: async () => {
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options)
if (!result.success || !result.data?.startsWith('data:image/')) {
throw new Error(result.error || '加载图片失败')
}
const loadedImage = {
data: result.data,
isThumbnail: Boolean(result.isThumb)
}
cacheImage(imageMd5, imageDatName, options, loadedImage)
return loadedImage
}
})
pumpImageQueue()
})
imageRequests.set(requestKey, request)
void request.then(
() => imageRequests.delete(requestKey),
() => imageRequests.delete(requestKey)
)
return request
}
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
import { AIModelPage } from './pages/AIModelPage'
import { RecallProtectionPage } from './pages/RecallProtectionPage'
import { AdvancedPage } from './pages/AdvancedPage'
import type { Contact } from '../../../../shared/types'
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
@@ -50,8 +51,15 @@ export function SettingsWorkspace({
dbReady={dbReady}
onOpenSettings={onOpenSettings}
/>
<div className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}>
<AccountDatabasePage dbKey={dbKey} dbReady={dbReady} selfInfo={selfInfo} onNotice={onNotice} />
<div
className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}
>
<AccountDatabasePage
dbKey={dbKey}
dbReady={dbReady}
selfInfo={selfInfo}
onNotice={onNotice}
/>
</div>
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
<DatabaseKeyPage
@@ -73,10 +81,22 @@ export function SettingsWorkspace({
<div className={`settings-page-panel ${selectedCategory === 'ai-model' ? 'active' : ''}`}>
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}>
<div
className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}
>
<RecallProtectionPage onNotice={onNotice} />
</div>
{!['account-database', 'database-key', 'image-key', 'ai-model', 'recall-protection'].includes(selectedCategory) && (
<div className={`settings-page-panel ${selectedCategory === 'advanced' ? 'active' : ''}`}>
<AdvancedPage onNotice={onNotice} />
</div>
{![
'account-database',
'database-key',
'image-key',
'ai-model',
'recall-protection',
'advanced'
].includes(selectedCategory) && (
<div className="settings-page-panel active">
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
</div>
@@ -27,14 +27,12 @@ export function AccountDatabasePage({
}): React.ReactElement {
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
const [autoLogin, setAutoLogin] = useState(false)
const [recallProtectionEnabled, setRecallProtectionEnabled] = useState(false)
useEffect(() => {
let active = true
void window.api.getSettings().then((result) => {
if (!active) return
setAutoLogin(result.settings.autoLogin)
setRecallProtectionEnabled(result.settings.recallProtectionEnabled)
})
return () => {
active = false
@@ -50,11 +48,6 @@ export function AccountDatabasePage({
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
}
const changeRecallProtection = async (checked: boolean): Promise<void> => {
const result = await window.api.setSettings({ recallProtectionEnabled: checked })
setRecallProtectionEnabled(result.settings.recallProtectionEnabled)
onNotice(checked ? '已开启防撤回' : '已关闭防撤回')
}
return (
<div className="settings-page">
<header className="settings-page-header">
@@ -102,26 +95,6 @@ export function AccountDatabasePage({
/>
</label>
</section>
<h2 className="settings-section-heading"></h2>
<section className="settings-card settings-recall-card">
<div className="settings-recall-grid">
<label className="settings-recall-option">
<span>
<b></b>
<small>便</small>
</span>
<input
type="checkbox"
checked={recallProtectionEnabled}
onChange={(event) => void changeRecallProtection(event.target.checked)}
/>
</label>
<aside className="settings-recall-notice">
<strong></strong>
<span></span>
</aside>
</div>
</section>
</div>
</div>
</div>
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
export function AdvancedPage({
onNotice
}: {
onNotice: (message: string) => void
}): React.ReactElement {
const [debugEnabled, setDebugEnabled] = useState(false)
useEffect(() => {
let active = true
void window.api.getSettings().then((result) => {
if (active) setDebugEnabled(result.settings.debugEnabled)
})
return () => {
active = false
}
}, [])
const changeDebugEnabled = async (checked: boolean): Promise<void> => {
const result = await window.api.setSettings({ debugEnabled: checked })
setDebugEnabled(result.settings.debugEnabled)
onNotice(checked ? '已开启调试日志' : '已关闭调试日志')
}
return (
<div className="settings-page">
<header className="settings-page-header">
<div>
<h1></h1>
<p></p>
</div>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
<h2 className="settings-section-heading"></h2>
<section className="settings-card settings-debug-card">
<label>
<span>
<b></b>
<small></small>
</span>
<input
type="checkbox"
checked={debugEnabled}
onChange={(event) => void changeDebugEnabled(event.target.checked)}
/>
</label>
<div className="settings-debug-actions">
<button type="button" onClick={() => void window.api.revealAppLog()}>
</button>
<small></small>
</div>
</section>
</div>
</div>
</div>
)
}
+3
View File
@@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/tokens.css'
import './assets/main.css'
import './styles/search.css'
import './styles/archive.css'
import './styles/settings-advanced.css'
window.addEventListener('error', (event) => {
void window.api
+28
View File
@@ -0,0 +1,28 @@
.archive-jump-target-group {
z-index: 2;
}
.archive-jump-message {
z-index: 1;
animation: archive-jump-flash 0.72s ease-in-out 2;
}
@keyframes archive-jump-flash {
0%,
100% {
filter: none;
}
22%,
58% {
filter: drop-shadow(0 0 0.45rem rgba(38, 128, 103, 0.68));
}
}
@media (prefers-reduced-motion: reduce) {
.archive-jump-message {
outline: 3px solid rgba(38, 128, 103, 0.58);
outline-offset: 5px;
animation: none;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
.settings-debug-card {
display: grid;
gap: 14px;
}
.settings-debug-card > label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.settings-debug-card > label > span {
display: grid;
gap: 5px;
}
.settings-debug-card b {
color: var(--wxex-text-primary);
font-size: 13px;
}
.settings-debug-card small {
color: var(--wxex-text-secondary);
font-size: 11px;
line-height: 18px;
}
.settings-debug-actions {
display: flex;
align-items: center;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--wxex-border);
}
.settings-debug-actions button {
padding: 7px 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
font: inherit;
font-size: 11px;
}