mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-06 23:15:58 +00:00
修复 #597;实现 #556;修复 #623与 #543;修复卡片图片问题
This commit is contained in:
@@ -2127,6 +2127,24 @@
|
||||
display: block;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
-webkit-app-region: no-drag;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.image-message.pending {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.image-message.ready {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-stage {
|
||||
display: inline-block;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.image-stage.locked {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-message-wrapper {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
@@ -64,6 +64,9 @@ const GLOBAL_MSG_LEGACY_CONCURRENCY = 6
|
||||
const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__'
|
||||
const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2
|
||||
const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare'
|
||||
const MESSAGE_LIST_SCROLL_IDLE_MS = 160
|
||||
const MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS = 160
|
||||
const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96
|
||||
|
||||
function isGlobalMsgSearchCanceled(error: unknown): boolean {
|
||||
return String(error || '') === GLOBAL_MSG_SEARCH_CANCELED_ERROR
|
||||
@@ -210,6 +213,12 @@ function sortMessagesByCreateTimeDesc<T extends Pick<Message, 'createTime' | 'lo
|
||||
})
|
||||
}
|
||||
|
||||
function isRenderableImageSrc(value?: string | null): boolean {
|
||||
const src = String(value || '').trim()
|
||||
if (!src) return false
|
||||
return /^(https?:\/\/|data:image\/|blob:|file:\/\/|\/)/i.test(src)
|
||||
}
|
||||
|
||||
function normalizeSearchIdentityText(value?: string | null): string | undefined {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return undefined
|
||||
@@ -1179,7 +1188,12 @@ function ChatPage(props: ChatPageProps) {
|
||||
const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 })
|
||||
const topRangeLoadLockRef = useRef(false)
|
||||
const bottomRangeLoadLockRef = useRef(false)
|
||||
const topRangeLoadLastTriggerAtRef = useRef(0)
|
||||
const suppressAutoLoadLaterRef = useRef(false)
|
||||
const suppressAutoScrollOnNextMessageGrowthRef = useRef(false)
|
||||
const prependingHistoryRef = useRef(false)
|
||||
const isMessageListScrollingRef = useRef(false)
|
||||
const messageListScrollTimeoutRef = useRef<number | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => {
|
||||
@@ -1400,6 +1414,18 @@ function ChatPage(props: ChatPageProps) {
|
||||
}, delayMs)
|
||||
}, [])
|
||||
|
||||
const markMessageListScrolling = useCallback(() => {
|
||||
isMessageListScrollingRef.current = true
|
||||
if (messageListScrollTimeoutRef.current !== null) {
|
||||
window.clearTimeout(messageListScrollTimeoutRef.current)
|
||||
messageListScrollTimeoutRef.current = null
|
||||
}
|
||||
messageListScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
isMessageListScrollingRef.current = false
|
||||
messageListScrollTimeoutRef.current = null
|
||||
}, MESSAGE_LIST_SCROLL_IDLE_MS)
|
||||
}, [])
|
||||
|
||||
const isGroupChatSession = useCallback((username: string) => {
|
||||
return username.includes('@chatroom')
|
||||
}, [])
|
||||
@@ -3246,6 +3272,29 @@ function ChatPage(props: ChatPageProps) {
|
||||
runWarmup()
|
||||
}, [loadContactInfoBatch])
|
||||
|
||||
const scheduleGroupSenderWarmup = useCallback((usernames: string[], defer = false) => {
|
||||
if (!Array.isArray(usernames) || usernames.length === 0) return
|
||||
const run = () => warmupGroupSenderProfiles(usernames, false)
|
||||
if (!defer && !isMessageListScrollingRef.current) {
|
||||
run()
|
||||
return
|
||||
}
|
||||
|
||||
const runWhenIdle = () => {
|
||||
if (isMessageListScrollingRef.current) {
|
||||
window.setTimeout(runWhenIdle, MESSAGE_LIST_SCROLL_IDLE_MS)
|
||||
return
|
||||
}
|
||||
run()
|
||||
}
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(runWhenIdle, { timeout: 1200 })
|
||||
} else {
|
||||
window.setTimeout(runWhenIdle, MESSAGE_LIST_SCROLL_IDLE_MS)
|
||||
}
|
||||
}, [warmupGroupSenderProfiles])
|
||||
|
||||
// 加载消息
|
||||
const loadMessages = async (
|
||||
sessionId: string,
|
||||
@@ -3255,6 +3304,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
ascending = false,
|
||||
options: LoadMessagesOptions = {}
|
||||
) => {
|
||||
const isPrependHistoryLoad = offset > 0 && !ascending
|
||||
if (isPrependHistoryLoad) {
|
||||
prependingHistoryRef.current = true
|
||||
}
|
||||
const listEl = messageListRef.current
|
||||
const session = sessionMapRef.current.get(sessionId)
|
||||
const unreadCount = session?.unreadCount ?? 0
|
||||
@@ -3288,10 +3341,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
Math.max(visibleRange.startIndex, 0),
|
||||
Math.max(messages.length - 1, 0)
|
||||
)
|
||||
const anchorMessageKeyBeforePrepend = offset > 0 && messages.length > 0
|
||||
? getMessageKey(messages[visibleStartIndex])
|
||||
: null
|
||||
|
||||
// 记录加载前的第一条消息元素(非虚拟列表回退路径)
|
||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||
|
||||
@@ -3340,12 +3389,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
.map(m => m.senderUsername as string)
|
||||
)]
|
||||
if (unknownSenders.length > 0) {
|
||||
warmupGroupSenderProfiles(unknownSenders, options.deferGroupSenderWarmup === true)
|
||||
scheduleGroupSenderWarmup(unknownSenders, options.deferGroupSenderWarmup === true)
|
||||
}
|
||||
}
|
||||
|
||||
// 日期跳转时滚动到顶部,否则滚动到底部
|
||||
const loadedMessages = result.messages
|
||||
requestAnimationFrame(() => {
|
||||
if (isDateJumpRef.current) {
|
||||
if (messageVirtuosoRef.current && resultMessages.length > 0) {
|
||||
@@ -3365,6 +3413,19 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const existingMessageKeys = messageKeySetRef.current
|
||||
const incomingSeen = new Set<string>()
|
||||
let prependedInsertedCount = 0
|
||||
for (const row of resultMessages) {
|
||||
const key = getMessageKey(row)
|
||||
if (incomingSeen.has(key)) continue
|
||||
incomingSeen.add(key)
|
||||
if (!existingMessageKeys.has(key)) {
|
||||
prependedInsertedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
suppressAutoScrollOnNextMessageGrowthRef.current = true
|
||||
appendMessages(resultMessages, true)
|
||||
|
||||
// 加载更多也同样处理发送者信息预取
|
||||
@@ -3375,24 +3436,20 @@ function ChatPage(props: ChatPageProps) {
|
||||
.map(m => m.senderUsername as string)
|
||||
)]
|
||||
if (unknownSenders.length > 0) {
|
||||
warmupGroupSenderProfiles(unknownSenders, false)
|
||||
scheduleGroupSenderWarmup(unknownSenders, false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更早消息后保持视口锚点,避免跳屏
|
||||
const appendedMessages = result.messages
|
||||
requestAnimationFrame(() => {
|
||||
if (messageVirtuosoRef.current) {
|
||||
if (anchorMessageKeyBeforePrepend) {
|
||||
const latestMessages = useChatStore.getState().messages || []
|
||||
const anchorIndex = latestMessages.findIndex((msg) => getMessageKey(msg) === anchorMessageKeyBeforePrepend)
|
||||
if (anchorIndex >= 0) {
|
||||
messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (resultMessages.length > 0) {
|
||||
messageVirtuosoRef.current.scrollToIndex({ index: resultMessages.length, align: 'start', behavior: 'auto' })
|
||||
const latestMessages = useChatStore.getState().messages || []
|
||||
const anchorIndex = Math.min(
|
||||
Math.max(visibleStartIndex + prependedInsertedCount, 0),
|
||||
Math.max(latestMessages.length - 1, 0)
|
||||
)
|
||||
if (latestMessages.length > 0) {
|
||||
messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' })
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -3432,6 +3489,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
setMessages([])
|
||||
}
|
||||
} finally {
|
||||
if (isPrependHistoryLoad) {
|
||||
requestAnimationFrame(() => {
|
||||
prependingHistoryRef.current = false
|
||||
})
|
||||
}
|
||||
setLoadingMessages(false)
|
||||
setLoadingMore(false)
|
||||
if (offset === 0 && pendingSessionLoadRef.current === sessionId) {
|
||||
@@ -3462,9 +3524,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
setCurrentOffset(0)
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(end)
|
||||
suppressAutoLoadLaterRef.current = true
|
||||
setShowJumpPopover(false)
|
||||
void loadMessages(targetSessionId, 0, 0, end, false, {
|
||||
switchRequestSeq: options.switchRequestSeq
|
||||
switchRequestSeq: options.switchRequestSeq,
|
||||
forceInitialLimit: 120
|
||||
})
|
||||
}, [currentSessionId, loadMessages])
|
||||
|
||||
@@ -4380,36 +4444,6 @@ function ChatPage(props: ChatPageProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (range.endIndex >= Math.max(total - 2, 0)) {
|
||||
isMessageListAtBottomRef.current = true
|
||||
setShowScrollToBottom(prev => (prev ? false : prev))
|
||||
}
|
||||
|
||||
if (
|
||||
range.startIndex <= 2 &&
|
||||
!topRangeLoadLockRef.current &&
|
||||
!isLoadingMore &&
|
||||
!isLoadingMessages &&
|
||||
hasMoreMessages &&
|
||||
currentSessionId
|
||||
) {
|
||||
topRangeLoadLockRef.current = true
|
||||
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
||||
}
|
||||
|
||||
if (
|
||||
range.endIndex >= total - 3 &&
|
||||
!bottomRangeLoadLockRef.current &&
|
||||
!suppressAutoLoadLaterRef.current &&
|
||||
!isLoadingMore &&
|
||||
!isLoadingMessages &&
|
||||
hasMoreLater &&
|
||||
currentSessionId
|
||||
) {
|
||||
bottomRangeLoadLockRef.current = true
|
||||
void loadLaterMessages()
|
||||
}
|
||||
|
||||
if (shouldWarmupVisibleGroupSenders) {
|
||||
const now = Date.now()
|
||||
if (now - lastVisibleSenderWarmupAtRef.current >= 180) {
|
||||
@@ -4428,27 +4462,18 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (pendingUsernames.size >= 24) break
|
||||
}
|
||||
if (pendingUsernames.size > 0) {
|
||||
warmupGroupSenderProfiles([...pendingUsernames], false)
|
||||
scheduleGroupSenderWarmup([...pendingUsernames], false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
messages.length,
|
||||
isLoadingMore,
|
||||
isLoadingMessages,
|
||||
hasMoreMessages,
|
||||
hasMoreLater,
|
||||
currentSessionId,
|
||||
currentOffset,
|
||||
jumpStartTime,
|
||||
jumpEndTime,
|
||||
isGroupChatSession,
|
||||
standaloneSessionWindow,
|
||||
normalizedInitialSessionId,
|
||||
normalizedStandaloneInitialContactType,
|
||||
warmupGroupSenderProfiles,
|
||||
loadMessages,
|
||||
loadLaterMessages
|
||||
scheduleGroupSenderWarmup
|
||||
])
|
||||
|
||||
const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => {
|
||||
@@ -4462,9 +4487,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
const distanceFromBottom = listEl
|
||||
? (listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight))
|
||||
: Number.POSITIVE_INFINITY
|
||||
const nearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(messages.length - 2, 0)
|
||||
const nearBottomByDistance = distanceFromBottom <= 140
|
||||
const effectiveAtBottom = atBottom || nearBottomByRange || nearBottomByDistance
|
||||
const effectiveAtBottom = atBottom || nearBottomByDistance
|
||||
isMessageListAtBottomRef.current = effectiveAtBottom
|
||||
|
||||
if (!effectiveAtBottom) {
|
||||
@@ -4492,19 +4516,48 @@ function ChatPage(props: ChatPageProps) {
|
||||
}, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching])
|
||||
|
||||
const handleMessageListWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (event.deltaY <= 18) return
|
||||
if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreLater) return
|
||||
markMessageListScrolling()
|
||||
if (!currentSessionId || isLoadingMore || isLoadingMessages) return
|
||||
const listEl = messageListRef.current
|
||||
if (!listEl) return
|
||||
const distanceFromTop = listEl.scrollTop
|
||||
const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)
|
||||
if (distanceFromBottom > 96) return
|
||||
|
||||
if (event.deltaY <= -18) {
|
||||
if (!hasMoreMessages) return
|
||||
if (distanceFromTop > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return
|
||||
if (topRangeLoadLockRef.current) return
|
||||
const now = Date.now()
|
||||
if (now - topRangeLoadLastTriggerAtRef.current < MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS) return
|
||||
topRangeLoadLastTriggerAtRef.current = now
|
||||
topRangeLoadLockRef.current = true
|
||||
isMessageListAtBottomRef.current = false
|
||||
void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.deltaY <= 18) return
|
||||
if (!hasMoreLater) return
|
||||
if (distanceFromBottom > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return
|
||||
if (bottomRangeLoadLockRef.current) return
|
||||
|
||||
// 用户明确向下滚动时允许加载后续消息
|
||||
suppressAutoLoadLaterRef.current = false
|
||||
bottomRangeLoadLockRef.current = true
|
||||
void loadLaterMessages()
|
||||
}, [currentSessionId, hasMoreLater, isLoadingMessages, isLoadingMore, loadLaterMessages])
|
||||
}, [
|
||||
currentSessionId,
|
||||
hasMoreLater,
|
||||
hasMoreMessages,
|
||||
isLoadingMessages,
|
||||
isLoadingMore,
|
||||
currentOffset,
|
||||
jumpStartTime,
|
||||
jumpEndTime,
|
||||
markMessageListScrolling,
|
||||
loadMessages,
|
||||
loadLaterMessages
|
||||
])
|
||||
|
||||
const handleMessageAtTopStateChange = useCallback((atTop: boolean) => {
|
||||
if (!atTop) {
|
||||
@@ -4659,6 +4712,11 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (sessionScrollTimeoutRef.current) {
|
||||
clearTimeout(sessionScrollTimeoutRef.current)
|
||||
}
|
||||
if (messageListScrollTimeoutRef.current !== null) {
|
||||
window.clearTimeout(messageListScrollTimeoutRef.current)
|
||||
messageListScrollTimeoutRef.current = null
|
||||
}
|
||||
isMessageListScrollingRef.current = false
|
||||
contactUpdateQueueRef.current.clear()
|
||||
pendingSessionContactEnrichRef.current.clear()
|
||||
sessionContactEnrichAttemptAtRef.current.clear()
|
||||
@@ -4699,8 +4757,12 @@ function ChatPage(props: ChatPageProps) {
|
||||
lastObservedMessageCountRef.current = currentCount
|
||||
if (currentCount <= previousCount) return
|
||||
if (!currentSessionId || isLoadingMessages || isSessionSwitching) return
|
||||
const wasNearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(previousCount - 2, 0)
|
||||
if (!isMessageListAtBottomRef.current && !wasNearBottomByRange) return
|
||||
if (suppressAutoScrollOnNextMessageGrowthRef.current || prependingHistoryRef.current) {
|
||||
suppressAutoScrollOnNextMessageGrowthRef.current = false
|
||||
return
|
||||
}
|
||||
if (!isMessageListAtBottomRef.current) return
|
||||
if (suppressAutoLoadLaterRef.current) return
|
||||
suppressScrollToBottomButton(220)
|
||||
isMessageListAtBottomRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
@@ -6603,6 +6665,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
<div
|
||||
className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`}
|
||||
ref={handleMessageListScrollParentRef}
|
||||
onScroll={markMessageListScrolling}
|
||||
onWheel={handleMessageListWheel}
|
||||
>
|
||||
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? (
|
||||
@@ -6616,8 +6679,12 @@ function ChatPage(props: ChatPageProps) {
|
||||
className="message-virtuoso"
|
||||
customScrollParent={messageListScrollParent ?? undefined}
|
||||
data={messages}
|
||||
overscan={360}
|
||||
followOutput={(atBottom) => (atBottom || isMessageListAtBottomRef.current ? 'auto' : false)}
|
||||
overscan={220}
|
||||
followOutput={(atBottom) => (
|
||||
prependingHistoryRef.current
|
||||
? false
|
||||
: (atBottom && isMessageListAtBottomRef.current ? 'auto' : false)
|
||||
)}
|
||||
atBottomThreshold={80}
|
||||
atBottomStateChange={handleMessageAtBottomStateChange}
|
||||
atTopStateChange={handleMessageAtTopStateChange}
|
||||
@@ -7659,6 +7726,8 @@ function MessageBubble({
|
||||
// State variables...
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [imageStageLockHeight, setImageStageLockHeight] = useState<number | null>(null)
|
||||
const [imageHasUpdate, setImageHasUpdate] = useState(false)
|
||||
const [imageClicked, setImageClicked] = useState(false)
|
||||
const imageUpdateCheckedRef = useRef<string | null>(null)
|
||||
@@ -7704,6 +7773,11 @@ function MessageBubble({
|
||||
const videoContainerRef = useRef<HTMLElement>(null)
|
||||
const [isVideoVisible, setIsVideoVisible] = useState(false)
|
||||
const [videoMd5, setVideoMd5] = useState<string | null>(null)
|
||||
const imageStageLockStyle = useMemo<React.CSSProperties | undefined>(() => (
|
||||
imageStageLockHeight && imageStageLockHeight > 0
|
||||
? { height: `${Math.round(imageStageLockHeight)}px` }
|
||||
: undefined
|
||||
), [imageStageLockHeight])
|
||||
|
||||
// 解析视频 MD5
|
||||
useEffect(() => {
|
||||
@@ -7847,6 +7921,14 @@ function MessageBubble({
|
||||
captureResizeBaseline(imageContainerRef.current, imageResizeBaselineRef)
|
||||
}, [captureResizeBaseline])
|
||||
|
||||
const lockImageStageHeight = useCallback(() => {
|
||||
const host = imageContainerRef.current
|
||||
if (!host) return
|
||||
const height = host.getBoundingClientRect().height
|
||||
if (!Number.isFinite(height) || height <= 0) return
|
||||
setImageStageLockHeight(Math.round(height))
|
||||
}, [])
|
||||
|
||||
const captureEmojiResizeBaseline = useCallback(() => {
|
||||
captureResizeBaseline(emojiContainerRef.current, emojiResizeBaselineRef)
|
||||
}, [captureResizeBaseline])
|
||||
@@ -7855,6 +7937,12 @@ function MessageBubble({
|
||||
stabilizeScrollAfterResize(imageContainerRef.current, imageResizeBaselineRef)
|
||||
}, [stabilizeScrollAfterResize])
|
||||
|
||||
const releaseImageStageLock = useCallback(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setImageStageLockHeight(null)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const stabilizeEmojiScrollAfterResize = useCallback(() => {
|
||||
stabilizeScrollAfterResize(emojiContainerRef.current, emojiResizeBaselineRef)
|
||||
}, [stabilizeScrollAfterResize])
|
||||
@@ -8008,6 +8096,7 @@ function MessageBubble({
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
if (imageLocalPath !== result.localPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageHasUpdate(false)
|
||||
@@ -8023,6 +8112,7 @@ function MessageBubble({
|
||||
imageDataUrlCache.set(imageCacheKey, dataUrl)
|
||||
if (imageLocalPath !== dataUrl) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath(dataUrl)
|
||||
setImageHasUpdate(false)
|
||||
@@ -8036,7 +8126,7 @@ function MessageBubble({
|
||||
imageDecryptPendingRef.current = false
|
||||
}
|
||||
return { success: false }
|
||||
}, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline])
|
||||
}, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
||||
|
||||
const triggerForceHd = useCallback(() => {
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
@@ -8099,6 +8189,7 @@ function MessageBubble({
|
||||
imageDataUrlCache.set(imageCacheKey, resolved.localPath)
|
||||
if (imageLocalPath !== resolved.localPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath(resolved.localPath)
|
||||
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
|
||||
@@ -8113,6 +8204,7 @@ function MessageBubble({
|
||||
imageLocalPath,
|
||||
imageCacheKey,
|
||||
captureImageResizeBaseline,
|
||||
lockImageStageHeight,
|
||||
message.imageDatName,
|
||||
message.imageMd5,
|
||||
requestImageDecrypt,
|
||||
@@ -8127,6 +8219,16 @@ function MessageBubble({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setImageLoaded(false)
|
||||
}, [imageLocalPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageLoading) return
|
||||
if (!imageError && imageLocalPath) return
|
||||
setImageStageLockHeight(null)
|
||||
}, [imageError, imageLoading, imageLocalPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage || imageLoading) return
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
@@ -8143,6 +8245,7 @@ function MessageBubble({
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
if (!imageLocalPath || imageLocalPath !== result.localPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageError(false)
|
||||
}
|
||||
@@ -8153,7 +8256,7 @@ function MessageBubble({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline])
|
||||
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
@@ -8187,6 +8290,7 @@ function MessageBubble({
|
||||
}
|
||||
if (imageLocalPath !== payload.localPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath))
|
||||
setImageError(false)
|
||||
@@ -8195,7 +8299,7 @@ function MessageBubble({
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline])
|
||||
}, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline, lockImageStageHeight])
|
||||
|
||||
// 图片进入视野前自动解密(懒加载)
|
||||
useEffect(() => {
|
||||
@@ -8578,6 +8682,19 @@ function MessageBubble({
|
||||
appMsgTextCache.set(selector, value)
|
||||
return value
|
||||
}, [appMsgDoc, appMsgTextCache])
|
||||
const appMsgThumbRawCandidate = useMemo(() => (
|
||||
message.linkThumb ||
|
||||
message.appMsgThumbUrl ||
|
||||
queryAppMsgText('appmsg > thumburl') ||
|
||||
queryAppMsgText('appmsg > cdnthumburl') ||
|
||||
queryAppMsgText('appmsg > cover') ||
|
||||
queryAppMsgText('appmsg > coverurl') ||
|
||||
queryAppMsgText('thumburl') ||
|
||||
queryAppMsgText('cdnthumburl') ||
|
||||
queryAppMsgText('cover') ||
|
||||
queryAppMsgText('coverurl') ||
|
||||
''
|
||||
).trim(), [message.linkThumb, message.appMsgThumbUrl, queryAppMsgText])
|
||||
const quotedSenderUsername = resolveQuotedSenderUsername(
|
||||
queryAppMsgText('refermsg > fromusr'),
|
||||
queryAppMsgText('refermsg > chatusr')
|
||||
@@ -8711,6 +8828,17 @@ function MessageBubble({
|
||||
// Selection mode handling removed from here to allow normal rendering
|
||||
// We will wrap the output instead
|
||||
if (isSystem) {
|
||||
const isPatSystemMessage = message.localType === 266287972401
|
||||
const patTitleRaw = isPatSystemMessage
|
||||
? (queryAppMsgText('appmsg > title') || queryAppMsgText('title') || message.parsedContent || '')
|
||||
: ''
|
||||
const patDisplayText = isPatSystemMessage
|
||||
? cleanMessageContent(String(patTitleRaw).replace(/^\s*\[拍一拍\]\s*/i, ''))
|
||||
: ''
|
||||
const systemContentNode = isPatSystemMessage
|
||||
? renderTextWithEmoji(patDisplayText || '拍一拍')
|
||||
: message.parsedContent
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`message-bubble system ${isSelectionMode ? 'selectable' : ''}`}
|
||||
@@ -8739,7 +8867,7 @@ function MessageBubble({
|
||||
{isSelected && <Check size={14} strokeWidth={3} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="bubble-content">{message.parsedContent}</div>
|
||||
<div className="bubble-content">{systemContentNode}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8748,7 +8876,11 @@ function MessageBubble({
|
||||
const renderContent = () => {
|
||||
if (isImage) {
|
||||
return (
|
||||
<div ref={imageContainerRef}>
|
||||
<div
|
||||
ref={imageContainerRef}
|
||||
className={`image-stage ${imageStageLockHeight ? 'locked' : ''}`}
|
||||
style={imageStageLockStyle}
|
||||
>
|
||||
{imageLoading ? (
|
||||
<div className="image-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
@@ -8770,15 +8902,19 @@ function MessageBubble({
|
||||
<img
|
||||
src={imageLocalPath}
|
||||
alt="图片"
|
||||
className="image-message"
|
||||
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
||||
onClick={() => { void handleOpenImageViewer() }}
|
||||
onLoad={() => {
|
||||
setImageLoaded(true)
|
||||
setImageError(false)
|
||||
stabilizeImageScrollAfterResize()
|
||||
releaseImageStageLock()
|
||||
}}
|
||||
onError={() => {
|
||||
imageResizeBaselineRef.current = null
|
||||
setImageLoaded(false)
|
||||
setImageError(true)
|
||||
releaseImageStageLock()
|
||||
}}
|
||||
/>
|
||||
{imageLiveVideoPath && (
|
||||
@@ -9104,6 +9240,12 @@ function MessageBubble({
|
||||
|
||||
const xmlType = message.xmlType || q('appmsg > type') || q('type')
|
||||
|
||||
// type 62: 拍一拍(按普通文本渲染,支持 [烟花] 这类 emoji 占位符)
|
||||
if (xmlType === '62') {
|
||||
const patText = cleanMessageContent((q('title') || cleanedParsedContent || '').replace(/^\s*\[拍一拍\]\s*/i, ''))
|
||||
return <div className="bubble-content">{renderTextWithEmoji(patText || '拍一拍')}</div>
|
||||
}
|
||||
|
||||
// type 57: 引用回复消息,解析 refermsg 渲染为引用样式
|
||||
if (xmlType === '57') {
|
||||
const replyText = q('title') || cleanedParsedContent || ''
|
||||
@@ -9147,7 +9289,8 @@ function MessageBubble({
|
||||
const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card'
|
||||
const desc = message.appMsgDesc || q('des')
|
||||
const url = message.linkUrl || q('url')
|
||||
const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl')
|
||||
const fallbackThumbUrl = appMsgThumbRawCandidate
|
||||
const thumbUrl = isRenderableImageSrc(fallbackThumbUrl) ? fallbackThumbUrl : ''
|
||||
const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl')
|
||||
const sourceName = message.appMsgSourceName || q('sourcename')
|
||||
const sourceDisplayName = q('sourcedisplayname') || ''
|
||||
@@ -9221,9 +9364,7 @@ function MessageBubble({
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className={`link-thumb-placeholder ${cardKind}`}>{cardKind.slice(0, 2).toUpperCase()}</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -9663,9 +9804,6 @@ function MessageBubble({
|
||||
</div>
|
||||
<div className="link-body">
|
||||
<div className="link-desc" title={desc}>{desc}</div>
|
||||
<div className="link-thumb-placeholder">
|
||||
<Link size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -181,6 +181,7 @@ interface ExportDialogState {
|
||||
|
||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
||||
const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900
|
||||
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
|
||||
const SESSION_MEDIA_METRIC_BATCH_SIZE = 8
|
||||
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
|
||||
@@ -311,9 +312,7 @@ const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance =>
|
||||
write: performance?.stages.write || 0,
|
||||
other: performance?.stages.other || 0
|
||||
},
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(performance?.sessions || {}).map(([sessionId, session]) => [sessionId, { ...session }])
|
||||
)
|
||||
sessions: { ...(performance?.sessions || {}) }
|
||||
})
|
||||
|
||||
const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => {
|
||||
@@ -333,6 +332,18 @@ const applyProgressToTaskPerformance = (
|
||||
const sessionId = String(payload.currentSessionId || '').trim()
|
||||
if (!sessionId) return task.performance || createEmptyTaskPerformance()
|
||||
|
||||
const currentPerformance = task.performance
|
||||
const currentSession = currentPerformance?.sessions?.[sessionId]
|
||||
if (
|
||||
payload.phase !== 'complete' &&
|
||||
currentSession &&
|
||||
currentSession.lastPhase === payload.phase &&
|
||||
typeof currentSession.lastPhaseStartedAt === 'number' &&
|
||||
now - currentSession.lastPhaseStartedAt < TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS
|
||||
) {
|
||||
return currentPerformance
|
||||
}
|
||||
|
||||
const performance = cloneTaskPerformance(task.performance)
|
||||
const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId)
|
||||
const existing = performance.sessions[sessionId]
|
||||
@@ -368,7 +379,9 @@ const applyProgressToTaskPerformance = (
|
||||
const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => {
|
||||
if (!isTextBatchTask(task) || !task.performance) return task.performance
|
||||
const performance = cloneTaskPerformance(task.performance)
|
||||
for (const session of Object.values(performance.sessions)) {
|
||||
const nextSessions: Record<string, TaskSessionPerformance> = {}
|
||||
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
|
||||
const session: TaskSessionPerformance = { ...sourceSession }
|
||||
if (session.finishedAt) continue
|
||||
if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
|
||||
const delta = Math.max(0, now - session.lastPhaseStartedAt)
|
||||
@@ -378,7 +391,13 @@ const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance
|
||||
session.finishedAt = now
|
||||
session.lastPhase = undefined
|
||||
session.lastPhaseStartedAt = undefined
|
||||
nextSessions[sessionId] = session
|
||||
}
|
||||
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
|
||||
if (nextSessions[sessionId]) continue
|
||||
nextSessions[sessionId] = { ...sourceSession }
|
||||
}
|
||||
performance.sessions = nextSessions
|
||||
return performance
|
||||
}
|
||||
|
||||
@@ -4697,7 +4716,7 @@ function ExportPage() {
|
||||
queuedProgressTimer = window.setTimeout(() => {
|
||||
queuedProgressTimer = null
|
||||
flushQueuedProgress()
|
||||
}, 100)
|
||||
}, 180)
|
||||
})
|
||||
}
|
||||
if (next.payload.scope === 'sns') {
|
||||
|
||||
@@ -2934,3 +2934,488 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
.anti-revoke-hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--bg-secondary) 94%, var(--primary) 6%) 0%,
|
||||
color-mix(in srgb, var(--bg-secondary) 96%, var(--bg-primary) 4%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.anti-revoke-hero-main {
|
||||
min-width: 240px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-metrics {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(112px, 1fr));
|
||||
gap: 10px;
|
||||
min-width: 460px;
|
||||
}
|
||||
|
||||
.anti-revoke-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 93%, var(--bg-secondary) 7%);
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&.is-total {
|
||||
border-color: color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%);
|
||||
background: color-mix(in srgb, var(--bg-primary) 88%, var(--primary) 12%);
|
||||
}
|
||||
|
||||
&.is-installed {
|
||||
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--bg-primary) 90%, var(--primary) 10%);
|
||||
|
||||
.value {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-pending {
|
||||
background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%);
|
||||
|
||||
.value {
|
||||
color: color-mix(in srgb, var(--text-primary) 82%, var(--text-secondary));
|
||||
}
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--danger) 6%, var(--bg-primary));
|
||||
|
||||
.value {
|
||||
color: color-mix(in srgb, var(--danger) 65%, var(--text-primary) 35%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-control-card {
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 95%, var(--bg-primary) 5%);
|
||||
}
|
||||
|
||||
.anti-revoke-toolbar {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.anti-revoke-search {
|
||||
min-width: 280px;
|
||||
flex: 1;
|
||||
max-width: 420px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary) 15%);
|
||||
|
||||
input {
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.anti-revoke-btn-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.anti-revoke-batch-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.anti-revoke-selected-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: auto;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%);
|
||||
|
||||
span {
|
||||
position: relative;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
|
||||
strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 70%, transparent);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-toolbar-actions .btn,
|
||||
.anti-revoke-batch-actions .btn {
|
||||
border-radius: 10px;
|
||||
padding-inline: 14px;
|
||||
border-width: 1px;
|
||||
min-height: 36px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.anti-revoke-summary {
|
||||
padding: 11px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%);
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
|
||||
&.success {
|
||||
color: color-mix(in srgb, var(--primary) 72%, var(--text-primary) 28%);
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--bg-primary));
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: color-mix(in srgb, var(--danger) 70%, var(--text-primary) 30%);
|
||||
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--danger) 7%, var(--bg-primary));
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-list {
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||
border-radius: 16px;
|
||||
background: var(--bg-primary);
|
||||
max-height: 460px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.anti-revoke-list-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 93%, var(--bg-primary) 7%);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.24px;
|
||||
}
|
||||
|
||||
.anti-revoke-empty {
|
||||
padding: 44px 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.anti-revoke-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
transition: background-color 0.18s ease, box-shadow 0.18s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-secondary) 32%, var(--bg-primary) 68%);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--primary) 70%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-row-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
|
||||
.anti-revoke-check {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
|
||||
input[type='checkbox'] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check-indicator {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%);
|
||||
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary) 14%);
|
||||
color: var(--on-primary, #fff);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
svg {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox']:checked + .check-indicator {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox']:focus-visible + .check-indicator {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
input[type='checkbox']:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input[type='checkbox']:disabled + .check-indicator {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
.name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.anti-revoke-row-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
color: var(--text-secondary);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.installed {
|
||||
color: var(--primary);
|
||||
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary));
|
||||
|
||||
.status-dot {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.not-installed {
|
||||
color: var(--text-secondary);
|
||||
border-color: color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
|
||||
|
||||
.status-dot {
|
||||
background: color-mix(in srgb, var(--text-tertiary) 86%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&.checking {
|
||||
color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%);
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary));
|
||||
|
||||
.status-dot {
|
||||
background: var(--primary);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%);
|
||||
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary));
|
||||
|
||||
.status-dot {
|
||||
background: var(--danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-error {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--danger) 66%, var(--text-primary) 34%);
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.anti-revoke-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.anti-revoke-metrics {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(2, minmax(130px, 1fr));
|
||||
}
|
||||
|
||||
.anti-revoke-batch-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.anti-revoke-selected-count {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.anti-revoke-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.anti-revoke-row-status {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
|
||||
type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'notification', label: '通知', icon: Bell },
|
||||
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
|
||||
{ id: 'database', label: '数据库连接', icon: Database },
|
||||
{ id: 'models', label: '模型管理', icon: Mic },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
@@ -70,6 +71,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
setShowUpdateDialog,
|
||||
} = useAppStore()
|
||||
|
||||
const chatSessions = useChatStore((state) => state.sessions)
|
||||
const setChatSessions = useChatStore((state) => state.setSessions)
|
||||
const resetChatStore = useChatStore((state) => state.reset)
|
||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||
const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
@@ -200,6 +203,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||
const [showApiWarning, setShowApiWarning] = useState(false)
|
||||
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
|
||||
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
|
||||
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
|
||||
const [isAntiRevokeRefreshing, setIsAntiRevokeRefreshing] = useState(false)
|
||||
const [isAntiRevokeInstalling, setIsAntiRevokeInstalling] = useState(false)
|
||||
const [isAntiRevokeUninstalling, setIsAntiRevokeUninstalling] = useState(false)
|
||||
const [antiRevokeSummary, setAntiRevokeSummary] = useState<{ action: 'refresh' | 'install' | 'uninstall'; success: number; failed: number } | null>(null)
|
||||
|
||||
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
||||
|
||||
@@ -586,6 +596,248 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const normalizeSessionIds = (sessionIds: string[]): string[] =>
|
||||
Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||
|
||||
const getCurrentAntiRevokeSessionIds = (): string[] =>
|
||||
normalizeSessionIds(chatSessions.map((session) => session.username))
|
||||
|
||||
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
|
||||
const current = getCurrentAntiRevokeSessionIds()
|
||||
if (current.length > 0) return current
|
||||
const sessionsResult = await window.electronAPI.chat.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||
throw new Error(sessionsResult.error || '加载会话失败')
|
||||
}
|
||||
setChatSessions(sessionsResult.sessions)
|
||||
return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username))
|
||||
}
|
||||
|
||||
const markAntiRevokeRowsLoading = (sessionIds: string[]) => {
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const sessionId of sessionIds) {
|
||||
next[sessionId] = {
|
||||
...(next[sessionId] || {}),
|
||||
loading: true,
|
||||
error: undefined
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleRefreshAntiRevokeStatus = async (sessionIds?: string[]) => {
|
||||
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
|
||||
setAntiRevokeSummary(null)
|
||||
setIsAntiRevokeRefreshing(true)
|
||||
try {
|
||||
const targetIds = normalizeSessionIds(
|
||||
sessionIds && sessionIds.length > 0
|
||||
? sessionIds
|
||||
: await ensureAntiRevokeSessionsLoaded()
|
||||
)
|
||||
if (targetIds.length === 0) {
|
||||
setAntiRevokeStatusMap({})
|
||||
showMessage('暂无可检查的会话', true)
|
||||
return
|
||||
}
|
||||
markAntiRevokeRowsLoading(targetIds)
|
||||
|
||||
const result = await window.electronAPI.chat.checkAntiRevokeTriggers(targetIds)
|
||||
if (!result.success || !result.rows) {
|
||||
const errorText = result.error || '防撤回状态检查失败'
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const sessionId of targetIds) {
|
||||
next[sessionId] = {
|
||||
...(next[sessionId] || {}),
|
||||
loading: false,
|
||||
error: errorText
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
showMessage(errorText, false)
|
||||
return
|
||||
}
|
||||
|
||||
const rowMap = new Map<string, { sessionId: string; success: boolean; installed?: boolean; error?: string }>()
|
||||
for (const row of result.rows || []) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
rowMap.set(sessionId, row)
|
||||
}
|
||||
const mergedRows = targetIds.map((sessionId) => (
|
||||
rowMap.get(sessionId) || { sessionId, success: false, error: '状态查询未返回结果' }
|
||||
))
|
||||
const successCount = mergedRows.filter((row) => row.success).length
|
||||
const failedCount = mergedRows.length - successCount
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of mergedRows) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
next[sessionId] = {
|
||||
installed: row.installed === true,
|
||||
loading: false,
|
||||
error: row.success ? undefined : (row.error || '状态查询失败')
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setAntiRevokeSummary({ action: 'refresh', success: successCount, failed: failedCount })
|
||||
showMessage(`状态刷新完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
|
||||
} catch (e: any) {
|
||||
showMessage(`防撤回状态刷新失败: ${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
setIsAntiRevokeRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInstallAntiRevokeTriggers = async () => {
|
||||
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
|
||||
const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds))
|
||||
if (sessionIds.length === 0) {
|
||||
showMessage('请先选择至少一个会话', false)
|
||||
return
|
||||
}
|
||||
setAntiRevokeSummary(null)
|
||||
setIsAntiRevokeInstalling(true)
|
||||
try {
|
||||
markAntiRevokeRowsLoading(sessionIds)
|
||||
const result = await window.electronAPI.chat.installAntiRevokeTriggers(sessionIds)
|
||||
if (!result.success || !result.rows) {
|
||||
const errorText = result.error || '批量安装失败'
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const sessionId of sessionIds) {
|
||||
next[sessionId] = {
|
||||
...(next[sessionId] || {}),
|
||||
loading: false,
|
||||
error: errorText
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
showMessage(errorText, false)
|
||||
return
|
||||
}
|
||||
|
||||
const rowMap = new Map<string, { sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>()
|
||||
for (const row of result.rows || []) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
rowMap.set(sessionId, row)
|
||||
}
|
||||
const mergedRows = sessionIds.map((sessionId) => (
|
||||
rowMap.get(sessionId) || { sessionId, success: false, error: '安装未返回结果' }
|
||||
))
|
||||
const successCount = mergedRows.filter((row) => row.success).length
|
||||
const failedCount = mergedRows.length - successCount
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of mergedRows) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
next[sessionId] = {
|
||||
installed: row.success ? true : next[sessionId]?.installed,
|
||||
loading: false,
|
||||
error: row.success ? undefined : (row.error || '安装失败')
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setAntiRevokeSummary({ action: 'install', success: successCount, failed: failedCount })
|
||||
showMessage(`批量安装完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
|
||||
} catch (e: any) {
|
||||
showMessage(`批量安装失败: ${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
setIsAntiRevokeInstalling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUninstallAntiRevokeTriggers = async () => {
|
||||
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
|
||||
const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds))
|
||||
if (sessionIds.length === 0) {
|
||||
showMessage('请先选择至少一个会话', false)
|
||||
return
|
||||
}
|
||||
setAntiRevokeSummary(null)
|
||||
setIsAntiRevokeUninstalling(true)
|
||||
try {
|
||||
markAntiRevokeRowsLoading(sessionIds)
|
||||
const result = await window.electronAPI.chat.uninstallAntiRevokeTriggers(sessionIds)
|
||||
if (!result.success || !result.rows) {
|
||||
const errorText = result.error || '批量卸载失败'
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const sessionId of sessionIds) {
|
||||
next[sessionId] = {
|
||||
...(next[sessionId] || {}),
|
||||
loading: false,
|
||||
error: errorText
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
showMessage(errorText, false)
|
||||
return
|
||||
}
|
||||
|
||||
const rowMap = new Map<string, { sessionId: string; success: boolean; error?: string }>()
|
||||
for (const row of result.rows || []) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
rowMap.set(sessionId, row)
|
||||
}
|
||||
const mergedRows = sessionIds.map((sessionId) => (
|
||||
rowMap.get(sessionId) || { sessionId, success: false, error: '卸载未返回结果' }
|
||||
))
|
||||
const successCount = mergedRows.filter((row) => row.success).length
|
||||
const failedCount = mergedRows.length - successCount
|
||||
setAntiRevokeStatusMap((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of mergedRows) {
|
||||
const sessionId = String(row.sessionId || '').trim()
|
||||
if (!sessionId) continue
|
||||
next[sessionId] = {
|
||||
installed: row.success ? false : next[sessionId]?.installed,
|
||||
loading: false,
|
||||
error: row.success ? undefined : (row.error || '卸载失败')
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
setAntiRevokeSummary({ action: 'uninstall', success: successCount, failed: failedCount })
|
||||
showMessage(`批量卸载完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
|
||||
} catch (e: any) {
|
||||
showMessage(`批量卸载失败: ${e?.message || String(e)}`, false)
|
||||
} finally {
|
||||
setIsAntiRevokeUninstalling(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'antiRevoke') return
|
||||
let canceled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const sessionIds = await ensureAntiRevokeSessionsLoaded()
|
||||
if (canceled) return
|
||||
await handleRefreshAntiRevokeStatus(sessionIds)
|
||||
} catch (e: any) {
|
||||
if (!canceled) {
|
||||
showMessage(`加载防撤回会话失败: ${e?.message || String(e)}`, false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
canceled = true
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
type WxidKeys = {
|
||||
decryptKey: string
|
||||
imageXorKey: number | null
|
||||
@@ -1319,11 +1571,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
)
|
||||
|
||||
const renderNotificationTab = () => {
|
||||
const { sessions } = useChatStore.getState()
|
||||
|
||||
// 获取已过滤会话的信息
|
||||
const getSessionInfo = (username: string) => {
|
||||
const session = sessions.find(s => s.username === username)
|
||||
const session = chatSessions.find(s => s.username === username)
|
||||
return {
|
||||
displayName: session?.displayName || username,
|
||||
avatarUrl: session?.avatarUrl || ''
|
||||
@@ -1348,7 +1598,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
}
|
||||
|
||||
// 过滤掉已在列表中的会话,并根据搜索关键字过滤
|
||||
const availableSessions = sessions.filter(s => {
|
||||
const availableSessions = chatSessions.filter(s => {
|
||||
if (notificationFilterList.includes(s.username)) return false
|
||||
if (filterSearchKeyword) {
|
||||
const keyword = filterSearchKeyword.toLowerCase()
|
||||
@@ -1564,6 +1814,199 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
const renderAntiRevokeTab = () => {
|
||||
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
||||
const keyword = antiRevokeSearchKeyword.trim().toLowerCase()
|
||||
const filteredSessions = sortedSessions.filter((session) => {
|
||||
if (!keyword) return true
|
||||
const displayName = String(session.displayName || '').toLowerCase()
|
||||
const username = String(session.username || '').toLowerCase()
|
||||
return displayName.includes(keyword) || username.includes(keyword)
|
||||
})
|
||||
const filteredSessionIds = filteredSessions.map((session) => session.username)
|
||||
const selectedCount = antiRevokeSelectedIds.size
|
||||
const selectedInFilteredCount = filteredSessionIds.filter((sessionId) => antiRevokeSelectedIds.has(sessionId)).length
|
||||
const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length
|
||||
const busy = isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling
|
||||
const statusStats = filteredSessions.reduce(
|
||||
(acc, session) => {
|
||||
const rowState = antiRevokeStatusMap[session.username]
|
||||
if (rowState?.error) acc.failed += 1
|
||||
else if (rowState?.installed === true) acc.installed += 1
|
||||
else if (rowState?.installed === false) acc.notInstalled += 1
|
||||
return acc
|
||||
},
|
||||
{ installed: 0, notInstalled: 0, failed: 0 }
|
||||
)
|
||||
|
||||
const toggleSelected = (sessionId: string) => {
|
||||
setAntiRevokeSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sessionId)) next.delete(sessionId)
|
||||
else next.add(sessionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectAllFiltered = () => {
|
||||
if (filteredSessionIds.length === 0) return
|
||||
setAntiRevokeSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const sessionId of filteredSessionIds) {
|
||||
next.add(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
setAntiRevokeSelectedIds(new Set())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tab-content anti-revoke-tab">
|
||||
<div className="anti-revoke-hero">
|
||||
<div className="anti-revoke-hero-main">
|
||||
<h3>会话级防撤回触发器</h3>
|
||||
<p>仅针对勾选会话执行批量安装或卸载,状态可随时刷新。</p>
|
||||
</div>
|
||||
<div className="anti-revoke-metrics">
|
||||
<div className="anti-revoke-metric is-total">
|
||||
<span className="label">筛选会话</span>
|
||||
<span className="value">{filteredSessionIds.length}</span>
|
||||
</div>
|
||||
<div className="anti-revoke-metric is-installed">
|
||||
<span className="label">已安装</span>
|
||||
<span className="value">{statusStats.installed}</span>
|
||||
</div>
|
||||
<div className="anti-revoke-metric is-pending">
|
||||
<span className="label">未安装</span>
|
||||
<span className="value">{statusStats.notInstalled}</span>
|
||||
</div>
|
||||
<div className="anti-revoke-metric is-error">
|
||||
<span className="label">异常</span>
|
||||
<span className="value">{statusStats.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="anti-revoke-control-card">
|
||||
<div className="anti-revoke-toolbar">
|
||||
<div className="filter-search-box anti-revoke-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索会话..."
|
||||
value={antiRevokeSearchKeyword}
|
||||
onChange={(e) => setAntiRevokeSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="anti-revoke-toolbar-actions">
|
||||
<div className="anti-revoke-btn-group">
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => void handleRefreshAntiRevokeStatus()} disabled={busy}>
|
||||
<RefreshCw size={14} /> {isAntiRevokeRefreshing ? '刷新中...' : '刷新状态'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="anti-revoke-btn-group">
|
||||
<button className="btn btn-secondary btn-sm" onClick={selectAllFiltered} disabled={busy || filteredSessionIds.length === 0 || allFilteredSelected}>
|
||||
全选
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={clearSelection} disabled={busy || selectedCount === 0}>
|
||||
清空选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="anti-revoke-batch-actions">
|
||||
<div className="anti-revoke-btn-group anti-revoke-batch-btns">
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void handleInstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}>
|
||||
{isAntiRevokeInstalling ? '安装中...' : '批量安装'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => void handleUninstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}>
|
||||
{isAntiRevokeUninstalling ? '卸载中...' : '批量卸载'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="anti-revoke-selected-count">
|
||||
<span>已选 <strong>{selectedCount}</strong> 个会话</span>
|
||||
<span>筛选命中 <strong>{selectedInFilteredCount}</strong> / {filteredSessionIds.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{antiRevokeSummary && (
|
||||
<div className={`anti-revoke-summary ${antiRevokeSummary.failed > 0 ? 'error' : 'success'}`}>
|
||||
{antiRevokeSummary.action === 'refresh' ? '刷新' : antiRevokeSummary.action === 'install' ? '安装' : '卸载'}
|
||||
完成:成功 {antiRevokeSummary.success},失败 {antiRevokeSummary.failed}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="anti-revoke-list">
|
||||
{filteredSessions.length === 0 ? (
|
||||
<div className="anti-revoke-empty">{antiRevokeSearchKeyword ? '没有匹配的会话' : '暂无会话可配置'}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="anti-revoke-list-header">
|
||||
<span>会话({filteredSessions.length})</span>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
{filteredSessions.map((session) => {
|
||||
const rowState = antiRevokeStatusMap[session.username]
|
||||
let statusClass = 'unknown'
|
||||
let statusLabel = '未检查'
|
||||
if (rowState?.loading) {
|
||||
statusClass = 'checking'
|
||||
statusLabel = '检查中'
|
||||
} else if (rowState?.error) {
|
||||
statusClass = 'error'
|
||||
statusLabel = '失败'
|
||||
} else if (rowState?.installed === true) {
|
||||
statusClass = 'installed'
|
||||
statusLabel = '已安装'
|
||||
} else if (rowState?.installed === false) {
|
||||
statusClass = 'not-installed'
|
||||
statusLabel = '未安装'
|
||||
}
|
||||
return (
|
||||
<div key={session.username} className={`anti-revoke-row ${antiRevokeSelectedIds.has(session.username) ? 'selected' : ''}`}>
|
||||
<label className="anti-revoke-row-main">
|
||||
<span className="anti-revoke-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={antiRevokeSelectedIds.has(session.username)}
|
||||
onChange={() => toggleSelected(session.username)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<span className="check-indicator" aria-hidden="true">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
</span>
|
||||
<Avatar
|
||||
src={session.avatarUrl}
|
||||
name={session.displayName || session.username}
|
||||
size={30}
|
||||
/>
|
||||
<div className="anti-revoke-row-text">
|
||||
<span className="name">{session.displayName || session.username}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div className="anti-revoke-row-status">
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
<i className="status-dot" aria-hidden="true" />
|
||||
{statusLabel}
|
||||
</span>
|
||||
{rowState?.error && <span className="status-error">{rowState.error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderDatabaseTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
@@ -2687,6 +3130,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="settings-body">
|
||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||
{activeTab === 'notification' && renderNotificationTab()}
|
||||
{activeTab === 'antiRevoke' && renderAntiRevokeTab()}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'models' && renderModelsTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
|
||||
Reference in New Issue
Block a user