mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 15:45:51 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f25c23b2b3 | ||
|
|
5ab0466a87 | ||
|
|
d49c44f3be | ||
|
|
4577b4e955 | ||
|
|
dafde2eaba | ||
|
|
6e8ae3a12b | ||
|
|
a4be7f9005 |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ export interface ContactInfo {
|
||||
remark?: string
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | '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
|
||||
SELECT username, remark, nick_name, alias, local_type, flag, quan_pin
|
||||
FROM contact
|
||||
`
|
||||
|
||||
@@ -651,45 +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' | '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_')) {
|
||||
type = 'official'
|
||||
} else if (localType === 3 || localType === 4) {
|
||||
type = 'official'
|
||||
} else if (username.startsWith('wxid_') && row.alias) {
|
||||
// wxid开头且有alias的是好友
|
||||
} else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 1 && !excludeNames.includes(username)) {
|
||||
type = 'friend'
|
||||
} else if (localType === 1) {
|
||||
// local_type=1 也是好友
|
||||
type = 'friend'
|
||||
} else if (localType === 2) {
|
||||
// local_type=2 是群成员但非好友,跳过
|
||||
continue
|
||||
} else if (localType === 0) {
|
||||
// local_type=0 可能是好友或其他,检查是否有备注或昵称
|
||||
if (row.remark || row.nick_name) {
|
||||
type = 'friend'
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
} else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 0 && quanPin) {
|
||||
type = 'former_friend'
|
||||
} else {
|
||||
// 其他未知类型,跳过
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-light);
|
||||
|
||||
// 浅色模式下使用不透明背景,避免透明窗口中通知过于透明
|
||||
[data-mode="light"] &,
|
||||
:not([data-mode]) & {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
padding: 12px;
|
||||
@@ -39,7 +46,7 @@
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
|
||||
// Ensure background is solid
|
||||
// 确保背景不透明
|
||||
background: var(--bg-secondary, #2c2c2c);
|
||||
color: var(--text-primary, #ffffff);
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1288,6 +1288,21 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.empty-chat-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 60px 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.message-list * {
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
@@ -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,10 +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 () => {
|
||||
@@ -1327,8 +1335,35 @@ function ChatPage(_props: ChatPageProps) {
|
||||
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
|
||||
}, [])
|
||||
|
||||
// 获取当前会话信息
|
||||
const currentSession = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
||||
// 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback)
|
||||
const currentSession = (() => {
|
||||
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
|
||||
if (found || !currentSessionId) return found
|
||||
return {
|
||||
username: currentSessionId,
|
||||
type: 0,
|
||||
unreadCount: 0,
|
||||
summary: '',
|
||||
sortTimestamp: 0,
|
||||
lastTimestamp: 0,
|
||||
lastMsgType: 0,
|
||||
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')
|
||||
@@ -2048,6 +2083,13 @@ function ChatPage(_props: ChatPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages && (
|
||||
<div className="empty-chat-inline">
|
||||
<MessageSquare size={32} />
|
||||
<span>该联系人没有聊天记录</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => {
|
||||
const prevMsg = index > 0 ? messages[index - 1] : undefined
|
||||
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
// 左侧联系人面板
|
||||
.contacts-panel {
|
||||
width: 380px;
|
||||
min-width: 380px;
|
||||
width: 350px;
|
||||
min-width: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color);
|
||||
@@ -55,6 +55,11 @@
|
||||
.spin {
|
||||
animation: contactsSpin 1s linear infinite;
|
||||
}
|
||||
|
||||
&.export-mode-btn.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,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;
|
||||
@@ -231,8 +236,8 @@
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
@@ -242,6 +247,10 @@
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.contact-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -334,6 +343,94 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧详情面板内的联系人资料
|
||||
.detail-profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
|
||||
.detail-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
img { width: 100%; height: 100%; object-fit: cover; }
|
||||
span { color: #fff; font-size: 28px; font-weight: 600; }
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-info-list {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--text-tertiary);
|
||||
min-width: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
.goto-chat-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover { background: var(--primary-hover); }
|
||||
}
|
||||
|
||||
.empty-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// 右侧设置面板
|
||||
.settings-panel {
|
||||
flex: 1;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import './ContactsPage.scss'
|
||||
|
||||
interface ContactInfo {
|
||||
@@ -8,7 +10,7 @@ interface ContactInfo {
|
||||
remark?: string
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'other'
|
||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||
}
|
||||
|
||||
function ContactsPage() {
|
||||
@@ -19,10 +21,17 @@ function ContactsPage() {
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [contactTypes, setContactTypes] = useState({
|
||||
friends: true,
|
||||
groups: true,
|
||||
officials: true
|
||||
groups: false,
|
||||
officials: false,
|
||||
deletedFriends: false
|
||||
})
|
||||
|
||||
// 导出模式与查看详情
|
||||
const [exportMode, setExportMode] = useState(false)
|
||||
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const { setCurrentSession } = useChatStore()
|
||||
|
||||
// 导出相关状态
|
||||
const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json')
|
||||
const [exportAvatars, setExportAvatars] = useState(true)
|
||||
@@ -85,6 +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 === 'former_friend' && !contactTypes.deletedFriends) return false
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -154,6 +164,7 @@ function ContactsPage() {
|
||||
case 'friend': return <User size={14} />
|
||||
case 'group': return <Users size={14} />
|
||||
case 'official': return <MessageSquare size={14} />
|
||||
case 'former_friend': return <UserX size={14} />
|
||||
default: return <User size={14} />
|
||||
}
|
||||
}
|
||||
@@ -163,6 +174,7 @@ function ContactsPage() {
|
||||
case 'friend': return '好友'
|
||||
case 'group': return '群聊'
|
||||
case 'official': return '公众号'
|
||||
case 'former_friend': return '曾经的好友'
|
||||
default: return '其他'
|
||||
}
|
||||
}
|
||||
@@ -236,9 +248,18 @@ function ContactsPage() {
|
||||
<div className="contacts-panel">
|
||||
<div className="panel-header">
|
||||
<h2>通讯录</h2>
|
||||
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
|
||||
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
className={`icon-btn export-mode-btn ${exportMode ? 'active' : ''}`}
|
||||
onClick={() => { setExportMode(!exportMode); setSelectedContact(null) }}
|
||||
title={exportMode ? '退出导出模式' : '进入导出模式'}
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
|
||||
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-bar">
|
||||
@@ -258,49 +279,41 @@ function ContactsPage() {
|
||||
|
||||
<div className="type-filters">
|
||||
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contactTypes.friends}
|
||||
onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })}
|
||||
/>
|
||||
<User size={16} />
|
||||
<span>好友</span>
|
||||
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
|
||||
<User size={16} /><span>好友</span>
|
||||
</label>
|
||||
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contactTypes.groups}
|
||||
onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })}
|
||||
/>
|
||||
<Users size={16} />
|
||||
<span>群聊</span>
|
||||
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
|
||||
<Users size={16} /><span>群聊</span>
|
||||
</label>
|
||||
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contactTypes.officials}
|
||||
onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })}
|
||||
/>
|
||||
<MessageSquare size={16} />
|
||||
<span>公众号</span>
|
||||
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
|
||||
<MessageSquare size={16} /><span>公众号</span>
|
||||
</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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="contacts-count">
|
||||
共 {filteredContacts.length} 个联系人
|
||||
</div>
|
||||
<div className="selection-toolbar">
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allFilteredSelected}
|
||||
onChange={e => toggleAllFilteredSelected(e.target.checked)}
|
||||
disabled={filteredContacts.length === 0}
|
||||
/>
|
||||
<span>全选当前筛选结果</span>
|
||||
</label>
|
||||
<span className="selection-count">已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length})</span>
|
||||
</div>
|
||||
|
||||
{exportMode && (
|
||||
<div className="selection-toolbar">
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allFilteredSelected}
|
||||
onChange={e => toggleAllFilteredSelected(e.target.checked)}
|
||||
disabled={filteredContacts.length === 0}
|
||||
/>
|
||||
<span>全选当前筛选结果</span>
|
||||
</label>
|
||||
<span className="selection-count">已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length})</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="loading-state">
|
||||
@@ -314,20 +327,29 @@ function ContactsPage() {
|
||||
) : (
|
||||
<div className="contacts-list">
|
||||
{filteredContacts.map(contact => {
|
||||
const isSelected = selectedUsernames.has(contact.username)
|
||||
const isChecked = selectedUsernames.has(contact.username)
|
||||
const isActive = !exportMode && selectedContact?.username === contact.username
|
||||
return (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => toggleContactSelected(contact.username, !isSelected)}
|
||||
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (exportMode) {
|
||||
toggleContactSelected(contact.username, !isChecked)
|
||||
} else {
|
||||
setSelectedContact(isActive ? null : contact)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="contact-select" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
{exportMode && (
|
||||
<label className="contact-select" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="contact-avatar">
|
||||
{contact.avatarUrl ? (
|
||||
<img src={contact.avatarUrl} alt="" />
|
||||
@@ -352,90 +374,129 @@ function ContactsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:导出设置 */}
|
||||
<div className="settings-panel">
|
||||
<div className="panel-header">
|
||||
<h2>导出设置</h2>
|
||||
</div>
|
||||
{/* 右侧面板 */}
|
||||
{exportMode ? (
|
||||
<div className="settings-panel">
|
||||
<div className="panel-header">
|
||||
<h2>导出设置</h2>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
<div className="setting-section">
|
||||
<h3>导出格式</h3>
|
||||
<div className="format-select" ref={formatDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
|
||||
onClick={() => setShowFormatSelect(!showFormatSelect)}
|
||||
>
|
||||
<span className="select-value">{getOptionLabel(exportFormat)}</span>
|
||||
<ChevronDown size={16} />
|
||||
<div className="settings-content">
|
||||
<div className="setting-section">
|
||||
<h3>导出格式</h3>
|
||||
<div className="format-select" ref={formatDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
|
||||
onClick={() => setShowFormatSelect(!showFormatSelect)}
|
||||
>
|
||||
<span className="select-value">{getOptionLabel(exportFormat)}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showFormatSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFormatOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportFormat === option.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setExportFormat(option.value as 'json' | 'csv' | 'vcf')
|
||||
setShowFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出选项</h3>
|
||||
<label className="checkbox-item">
|
||||
<input type="checkbox" checked={exportAvatars} onChange={e => setExportAvatars(e.target.checked)} />
|
||||
<span>导出头像</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出位置</h3>
|
||||
<div className="export-path-display">
|
||||
<FolderOpen size={16} />
|
||||
<span>{exportFolder || '未设置'}</span>
|
||||
</div>
|
||||
<button className="select-folder-btn" onClick={selectExportFolder}>
|
||||
<FolderOpen size={16} />
|
||||
<span>选择导出目录</span>
|
||||
</button>
|
||||
{showFormatSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFormatOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportFormat === option.value ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setExportFormat(option.value as 'json' | 'csv' | 'vcf')
|
||||
setShowFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="export-action">
|
||||
<button
|
||||
className="export-btn"
|
||||
onClick={startExport}
|
||||
disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
|
||||
>
|
||||
{isExporting ? (
|
||||
<><Loader2 size={18} className="spin" /><span>导出中...</span></>
|
||||
) : (
|
||||
<><Download size={18} /><span>开始导出</span></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出选项</h3>
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportAvatars}
|
||||
onChange={e => setExportAvatars(e.target.checked)}
|
||||
/>
|
||||
<span>导出头像</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出位置</h3>
|
||||
<div className="export-path-display">
|
||||
<FolderOpen size={16} />
|
||||
<span>{exportFolder || '未设置'}</span>
|
||||
</div>
|
||||
<button className="select-folder-btn" onClick={selectExportFolder}>
|
||||
<FolderOpen size={16} />
|
||||
<span>选择导出目录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedContact ? (
|
||||
<div className="settings-panel">
|
||||
<div className="panel-header">
|
||||
<h2>联系人详情</h2>
|
||||
</div>
|
||||
<div className="settings-content">
|
||||
<div className="detail-profile">
|
||||
<div className="detail-avatar">
|
||||
{selectedContact.avatarUrl ? (
|
||||
<img src={selectedContact.avatarUrl} alt="" />
|
||||
) : (
|
||||
<span>{getAvatarLetter(selectedContact.displayName)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-name">{selectedContact.displayName}</div>
|
||||
<div className={`contact-type ${selectedContact.type}`}>
|
||||
{getContactTypeIcon(selectedContact.type)}
|
||||
<span>{getContactTypeName(selectedContact.type)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="export-action">
|
||||
<button
|
||||
className="export-btn"
|
||||
onClick={startExport}
|
||||
disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 size={18} className="spin" />
|
||||
<span>导出中...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={18} />
|
||||
<span>开始导出</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<div className="detail-info-list">
|
||||
<div className="detail-row"><span className="detail-label">用户名</span><span className="detail-value">{selectedContact.username}</span></div>
|
||||
<div className="detail-row"><span className="detail-label">昵称</span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
|
||||
{selectedContact.remark && <div className="detail-row"><span className="detail-label">备注</span><span className="detail-value">{selectedContact.remark}</span></div>}
|
||||
<div className="detail-row"><span className="detail-label">类型</span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="goto-chat-btn"
|
||||
onClick={() => {
|
||||
setCurrentSession(selectedContact.username)
|
||||
navigate('/chat')
|
||||
}}
|
||||
>
|
||||
<MessageCircle size={18} />
|
||||
<span>查看聊天记录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="settings-panel">
|
||||
<div className="empty-detail">
|
||||
<User size={48} />
|
||||
<span>点击左侧联系人查看详情</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import '../components/NotificationToast.scss'
|
||||
import './NotificationWindow.scss'
|
||||
|
||||
export default function NotificationWindow() {
|
||||
const { currentTheme, themeMode } = useThemeStore()
|
||||
const [notification, setNotification] = useState<NotificationData | null>(null)
|
||||
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
|
||||
|
||||
@@ -17,6 +19,12 @@ export default function NotificationWindow() {
|
||||
|
||||
const notificationRef = useRef<NotificationData | null>(null)
|
||||
|
||||
// 应用主题到通知窗口
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||
document.documentElement.setAttribute('data-mode', themeMode)
|
||||
}, [currentTheme, themeMode])
|
||||
|
||||
useEffect(() => {
|
||||
notificationRef.current = notification
|
||||
}, [notification])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user