Merge pull request #287 from hicccc77/dev

Dev
This commit is contained in:
cc
2026-02-21 23:07:10 +08:00
committed by GitHub
8 changed files with 203 additions and 82 deletions

View File

@@ -173,6 +173,20 @@ function createWindow(options: { autoShow?: boolean } = {}) {
}
)
// 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确)
win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
try {
const host = new URL(url).hostname
if (trusted.some(d => host.endsWith(d))) {
event.preventDefault()
callback(true)
return
}
} catch {}
callback(false)
})
return win
}

View File

@@ -103,7 +103,7 @@ export interface ContactInfo {
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other'
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
// 表情包缓存
@@ -603,7 +603,7 @@ class ChatService {
// 使用execQuery直接查询加密的contact.db
// kind='contact', path=null表示使用已打开的contact.db
const contactQuery = `
SELECT username, remark, nick_name, alias, local_type, flag
SELECT username, remark, nick_name, alias, local_type, flag, quan_pin
FROM contact
`
@@ -651,48 +651,23 @@ class ChatService {
for (const row of rows) {
const username = row.username || ''
// 过滤系统账号和特殊账号 - 完全复制cipher的逻辑
if (!username) continue
if (username === 'filehelper' || username === 'fmessage' || username === 'floatbottle' ||
username === 'medianote' || username === 'newsapp' || username.startsWith('fake_') ||
username === 'weixin' || username === 'qmessage' || username === 'qqmail' ||
username === 'tmessage' || username.startsWith('wxid_') === false &&
username.includes('@') === false && username.startsWith('gh_') === false &&
/^[a-zA-Z0-9_-]+$/.test(username) === false) {
continue
}
// 判断类型 - 正确规则wxid开头且有alias的是好友
let type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other' = 'other'
const localType = row.local_type || 0
const excludeNames = ['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']
let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other'
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
const flag = Number(row.flag ?? 0)
const quanPin = this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || ''
if (username.includes('@chatroom')) {
type = 'group'
} else if (username.startsWith('gh_')) {
if (flag === 0) continue
type = 'official'
} else if (localType === 3 || localType === 4) {
if (flag === 0) continue
if (flag === 4) continue
type = 'official'
} else if (username.startsWith('wxid_') && row.alias) {
type = flag === 0 ? 'deleted_friend' : 'friend'
} else if (localType === 1) {
type = flag === 0 ? 'deleted_friend' : 'friend'
} else if (localType === 2) {
// local_type=2 是群成员但非好友,跳过
continue
} else if (localType === 0) {
// local_type=0 可能是好友或其他,检查是否有备注或昵称
if (row.remark || row.nick_name) {
type = flag === 0 ? 'deleted_friend' : 'friend'
} else {
continue
}
} else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 1 && !excludeNames.includes(username)) {
type = 'friend'
} else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 0 && quanPin) {
type = 'former_friend'
} else {
// 其他未知类型,跳过
continue
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { Play, Lock, Download } from 'lucide-react'
import React, { useState, useRef } from 'react'
import { Play, Lock, Download, ImageOff } from 'lucide-react'
import { LivePhotoIcon } from '../../components/LivePhotoIcon'
import { RefreshCw } from 'lucide-react'
@@ -22,6 +22,7 @@ interface SnsMedia {
interface SnsMediaGridProps {
mediaList: SnsMedia[]
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onMediaDeleted?: () => void
}
const isSnsVideoUrl = (url?: string): boolean => {
@@ -79,9 +80,13 @@ const extractVideoFrame = async (videoPath: string): Promise<string> => {
})
}
const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => {
const MediaItem = ({ media, onPreview, onMediaDeleted }: { media: SnsMedia; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
const [error, setError] = useState(false)
const [deleted, setDeleted] = useState(false)
const [loading, setLoading] = useState(true)
const markDeleted = () => { setDeleted(true); onMediaDeleted?.() }
const retryCount = useRef(0)
const [retryKey, setRetryKey] = useState(0)
const [thumbSrc, setThumbSrc] = useState<string>('')
const [videoPath, setVideoPath] = useState<string>('')
const [liveVideoPath, setLiveVideoPath] = useState<string>('')
@@ -92,6 +97,16 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
const isLive = !!media.livePhoto
const targetUrl = media.thumb || media.url
// 视频重试失败时重试最多2次耗尽才标记删除
const videoRetryOrDelete = () => {
if (retryCount.current < 2) {
retryCount.current++
setRetryKey(k => k + 1)
} else {
markDeleted()
}
}
// Simple effect to load image/decrypt
// Simple effect to load image/decrypt
React.useEffect(() => {
@@ -112,7 +127,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
if (result.dataUrl) setThumbSrc(result.dataUrl)
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
} else {
setThumbSrc(targetUrl)
markDeleted()
}
// Pre-load live photo video if needed
@@ -149,11 +164,11 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
if (!cancelled) setThumbSrc(coverDataUrl)
} catch (err) {
console.error('Frame extraction failed', err)
// Fallback to video path if extraction fails, though it might be black
// Only set thumbSrc if extraction fails, so we don't override the generated one
// 封面提取失败,用视频路径作为 fallback,让 <video> 标签显示
if (!cancelled) setThumbSrc(localPath)
}
} else {
console.error('Video decryption for cover failed')
videoRetryOrDelete()
}
setIsGeneratingCover(false)
@@ -162,7 +177,11 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
} catch (e) {
console.error(e)
if (!cancelled) {
setThumbSrc(targetUrl)
if (isVideo) {
videoRetryOrDelete()
} else {
markDeleted()
}
setLoading(false)
setIsGeneratingCover(false)
}
@@ -171,7 +190,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
load()
return () => { cancelled = true }
}, [media, isVideo, isLive, targetUrl])
}, [media, isVideo, isLive, targetUrl, retryKey])
const handlePreview = async (e: React.MouseEvent) => {
e.stopPropagation()
@@ -248,6 +267,17 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
}
}
if (deleted) {
return (
<div className="sns-media-item deleted-media">
<div className="deleted-placeholder">
<ImageOff size={24} />
<span></span>
</div>
</div>
)
}
return (
<div
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
@@ -267,15 +297,15 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
e.currentTarget.currentTime = 0.1
}}
/>
) : (
) : thumbSrc ? (
<img
src={thumbSrc || targetUrl}
src={thumbSrc}
className="media-image"
loading="lazy"
onError={() => setError(true)}
onError={() => { if (!loading && !isVideo) markDeleted() }}
alt=""
/>
)}
) : null}
{isGeneratingCover && (
<div className="media-decrypting-mask">
@@ -304,7 +334,7 @@ const MediaItem = ({ media, onPreview }: { media: SnsMedia; onPreview: (src: str
)
}
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview }) => {
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview, onMediaDeleted }) => {
if (!mediaList || mediaList.length === 0) return null
const count = mediaList.length
@@ -320,7 +350,7 @@ export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, onPreview
return (
<div className={`sns-media-grid ${gridClass}`}>
{mediaList.map((media, idx) => (
<MediaItem key={idx} media={media} onPreview={onPreview} />
<MediaItem key={idx} media={media} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
))}
</div>
)

View File

@@ -1,8 +1,9 @@
import React, { useState, useMemo } from 'react'
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal } from 'lucide-react'
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal, Trash2 } from 'lucide-react'
import { SnsPost, SnsLinkCardData } from '../../types/sns'
import { Avatar } from '../Avatar'
import { SnsMediaGrid } from './SnsMediaGrid'
import { getEmojiPath } from 'wechat-emojis'
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
@@ -64,6 +65,21 @@ const isLikelyMediaAssetUrl = (url: string): boolean => {
}
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 || ''
]
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 hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
if (hasVideoMedia) return null
@@ -169,6 +185,7 @@ interface SnsPostItemProps {
}
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
const [mediaDeleted, setMediaDeleted] = useState(false)
const linkCard = buildLinkCardData(post)
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
@@ -187,11 +204,25 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
})
}
// Add extra class for media-only posts (no text) to adjust spacing?
// Not strictly needed but good to know
// 解析微信表情
const renderTextWithEmoji = (text: string) => {
if (!text) return text
const parts = text.split(/\[(.*?)\]/g)
return parts.map((part, index) => {
if (index % 2 === 1) {
// @ts-ignore
const path = getEmojiPath(part as any)
if (path) {
return <img key={index} src={`${import.meta.env.BASE_URL}${path}`} alt={`[${part}]`} className="inline-emoji" style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }} />
}
return `[${part}]`
}
return part
})
}
return (
<div className="sns-post-item">
<div className={`sns-post-item ${mediaDeleted ? 'post-deleted' : ''}`}>
<div className="post-avatar-col">
<Avatar
src={post.avatarUrl}
@@ -207,16 +238,24 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
<span className="post-time">{formatTime(post.createTime)}</span>
</div>
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
e.stopPropagation();
onDebug(post);
}} title="查看原始数据">
<Code size={14} />
</button>
<div className="post-header-actions">
{mediaDeleted && (
<span className="post-deleted-badge">
<Trash2 size={12} />
<span></span>
</span>
)}
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
e.stopPropagation();
onDebug(post);
}} title="查看原始数据">
<Code size={14} />
</button>
</div>
</div>
{post.contentDesc && (
<div className="post-text">{decodeHtmlEntities(post.contentDesc)}</div>
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
)}
{showLinkCard && linkCard && (
@@ -225,7 +264,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
{showMediaGrid && (
<div className="post-media-container">
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} />
<SnsMediaGrid mediaList={post.media} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
</div>
)}
@@ -250,7 +289,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
</>
)}
<span className="comment-colon"></span>
<span className="comment-content">{c.content}</span>
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
</div>
))}
</div>

View File

@@ -281,6 +281,8 @@ function ChatPage(_props: ChatPageProps) {
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
const [hasInitialMessages, setHasInitialMessages] = useState(false)
const [noMessageTable, setNoMessageTable] = useState(false)
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
@@ -857,6 +859,10 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.messages) {
if (offset === 0) {
setMessages(result.messages)
if (result.messages.length === 0) {
setNoMessageTable(true)
setHasMoreMessages(false)
}
// 预取发送者信息:在关闭加载遮罩前处理
const unreadCount = session?.unreadCount ?? 0
@@ -929,7 +935,7 @@ function ChatPage(_props: ChatPageProps) {
}
setCurrentOffset(offset + result.messages.length)
} else if (!result.success) {
setConnectionError(result.error || '加载消息失败')
setNoMessageTable(true)
setHasMoreMessages(false)
}
} catch (e) {
@@ -1247,6 +1253,7 @@ function ChatPage(_props: ChatPageProps) {
useEffect(() => {
if (currentSessionId !== prevSessionRef.current) {
prevSessionRef.current = currentSessionId
setNoMessageTable(false)
if (initialRevealTimerRef.current !== null) {
window.clearTimeout(initialRevealTimerRef.current)
initialRevealTimerRef.current = null
@@ -1260,11 +1267,11 @@ function ChatPage(_props: ChatPageProps) {
}, [currentSessionId, messages.length, isLoadingMessages])
useEffect(() => {
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore) {
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
setHasInitialMessages(false)
loadMessages(currentSessionId, 0)
}
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore])
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
useEffect(() => {
return () => {
@@ -1340,10 +1347,24 @@ function ChatPage(_props: ChatPageProps) {
sortTimestamp: 0,
lastTimestamp: 0,
lastMsgType: 0,
displayName: currentSessionId,
displayName: fallbackDisplayName || currentSessionId,
} as ChatSession
})()
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
useEffect(() => {
if (!currentSessionId) return
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
if (found) {
setFallbackDisplayName(null)
return
}
loadContactInfoBatch([currentSessionId]).then(() => {
const cached = senderAvatarCache.get(currentSessionId)
if (cached?.displayName) setFallbackDisplayName(cached.displayName)
})
}, [currentSessionId, sessions])
// 判断是否为群聊
const isGroupChat = (username: string) => username.includes('@chatroom')

View File

@@ -7,8 +7,8 @@
// 左侧联系人面板
.contacts-panel {
width: 400px;
min-width: 400px;
width: 350px;
min-width: 350px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
@@ -115,11 +115,11 @@
}
.type-filters {
display: flex;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 0 20px 16px;
flex-wrap: nowrap;
overflow-x: auto;
max-width: 300px;
&::-webkit-scrollbar {
display: none;
@@ -397,6 +397,7 @@
.detail-value {
color: var(--text-primary);
word-break: break-all;
user-select: text;
}
}

View File

@@ -10,7 +10,7 @@ interface ContactInfo {
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other'
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
function ContactsPage() {
@@ -21,8 +21,8 @@ function ContactsPage() {
const [searchKeyword, setSearchKeyword] = useState('')
const [contactTypes, setContactTypes] = useState({
friends: true,
groups: true,
officials: true,
groups: false,
officials: false,
deletedFriends: false
})
@@ -94,7 +94,7 @@ function ContactsPage() {
if (c.type === 'friend' && !contactTypes.friends) return false
if (c.type === 'group' && !contactTypes.groups) return false
if (c.type === 'official' && !contactTypes.officials) return false
if (c.type === 'deleted_friend' && !contactTypes.deletedFriends) return false
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false
return true
})
@@ -164,7 +164,7 @@ function ContactsPage() {
case 'friend': return <User size={14} />
case 'group': return <Users size={14} />
case 'official': return <MessageSquare size={14} />
case 'deleted_friend': return <UserX size={14} />
case 'former_friend': return <UserX size={14} />
default: return <User size={14} />
}
}
@@ -174,7 +174,7 @@ function ContactsPage() {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
case 'deleted_friend': return '已删除'
case 'former_friend': return '曾经的好友'
default: return '其他'
}
}
@@ -292,7 +292,7 @@ function ContactsPage() {
</label>
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
<UserX size={16} /><span></span>
<UserX size={16} /><span></span>
</label>
</div>

View File

@@ -105,11 +105,28 @@
gap: 16px;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
position: relative;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06);
}
&.post-deleted {
opacity: 0.7;
}
.post-deleted-badge {
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 77, 79, 0.1);
color: #ff4d4f;
font-size: 12px;
font-weight: 500;
padding: 3px 8px;
border-radius: 6px;
}
}
.post-avatar-col {
@@ -147,6 +164,12 @@
}
}
.post-header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.debug-btn {
opacity: 0;
transition: opacity 0.2s;
@@ -313,12 +336,15 @@
width: fit-content;
height: auto;
max-width: 300px;
/* Max width constraint */
max-height: 480px;
/* Increased max height a bit */
aspect-ratio: auto;
border-radius: var(--sns-border-radius-md);
&.deleted-media {
width: 200px;
aspect-ratio: 1;
}
img,
video {
max-width: 100%;
@@ -327,7 +353,6 @@
height: auto;
object-fit: contain;
display: block;
/* Remove baseline space */
background: rgba(0, 0, 0, 0.05);
}
}
@@ -444,6 +469,22 @@
&:hover .media-download-btn {
opacity: 1;
}
&.deleted-media {
cursor: default;
opacity: 0.6;
.deleted-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 6px;
color: var(--text-tertiary);
font-size: 12px;
}
}
}
}