feat(chat): smooth standalone session window loading

This commit is contained in:
aits2026
2026-03-05 16:32:25 +08:00
parent e050402787
commit f18fb83a92
6 changed files with 214 additions and 23 deletions

View File

@@ -126,21 +126,36 @@ interface AnnualReportYearsTaskState {
interface OpenSessionChatWindowOptions { interface OpenSessionChatWindowOptions {
source?: 'chat' | 'export' source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => { const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => {
return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat' return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat'
} }
const normalizeSessionChatWindowOptionString = (value: unknown): string => {
return String(value || '').trim()
}
const loadSessionChatWindowContent = ( const loadSessionChatWindowContent = (
win: BrowserWindow, win: BrowserWindow,
sessionId: string, sessionId: string,
source: 'chat' | 'export' source: 'chat' | 'export',
options?: OpenSessionChatWindowOptions
) => { ) => {
const query = new URLSearchParams({ const queryParams = new URLSearchParams({
sessionId, sessionId,
source source
}).toString() })
const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName)
const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl)
const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType)
if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName)
if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl)
if (initialContactType) queryParams.set('initialContactType', initialContactType)
const query = queryParams.toString()
if (process.env.VITE_DEV_SERVER_URL) { if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`) win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`)
return return
@@ -724,7 +739,7 @@ function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWin
if (existing && !existing.isDestroyed()) { if (existing && !existing.isDestroyed()) {
const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat' const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat'
if (trackedSource !== normalizedSource) { if (trackedSource !== normalizedSource) {
loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource) loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource, options)
sessionChatWindowSources.set(normalizedSessionId, normalizedSource) sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
} }
if (existing.isMinimized()) { if (existing.isMinimized()) {
@@ -763,7 +778,7 @@ function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWin
autoHideMenuBar: true autoHideMenuBar: true
}) })
loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource) loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource, options)
if (process.env.VITE_DEV_SERVER_URL) { if (process.env.VITE_DEV_SERVER_URL) {
win.webContents.on('before-input-event', (event, input) => { win.webContents.on('before-input-event', (event, input) => {

View File

@@ -99,7 +99,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) => openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
openSessionChatWindow: (sessionId: string, options?: { source?: 'chat' | 'export' }) => openSessionChatWindow: (
sessionId: string,
options?: {
source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options) ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
}, },

View File

@@ -405,7 +405,19 @@ function App() {
const params = new URLSearchParams(location.search) const params = new URLSearchParams(location.search)
const sessionId = params.get('sessionId') || '' const sessionId = params.get('sessionId') || ''
const standaloneSource = params.get('source') const standaloneSource = params.get('source')
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} standaloneSource={standaloneSource} /> const standaloneInitialDisplayName = params.get('initialDisplayName')
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
const standaloneInitialContactType = params.get('initialContactType')
return (
<ChatPage
standaloneSessionWindow
initialSessionId={sessionId}
standaloneSource={standaloneSource}
standaloneInitialDisplayName={standaloneInitialDisplayName}
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
standaloneInitialContactType={standaloneInitialContactType}
/>
)
} }
// 独立通知窗口 // 独立通知窗口

View File

@@ -1783,6 +1783,30 @@
z-index: 2; z-index: 2;
} }
.standalone-phase-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent);
color: var(--text-secondary);
font-size: 14px;
pointer-events: none;
.spin {
animation: spin 1s linear infinite;
}
small {
color: var(--text-tertiary);
font-size: 12px;
}
}
.empty-chat-inline { .empty-chat-inline {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -205,8 +205,12 @@ interface ChatPageProps {
standaloneSessionWindow?: boolean standaloneSessionWindow?: boolean
initialSessionId?: string | null initialSessionId?: string | null
standaloneSource?: string | null standaloneSource?: string | null
standaloneInitialDisplayName?: string | null
standaloneInitialAvatarUrl?: string | null
standaloneInitialContactType?: string | null
} }
type StandaloneLoadStage = 'idle' | 'connecting' | 'loading' | 'ready'
interface SessionDetail { interface SessionDetail {
wxid: string wxid: string
@@ -409,9 +413,19 @@ const SessionItem = React.memo(function SessionItem({
function ChatPage(props: ChatPageProps) { function ChatPage(props: ChatPageProps) {
const { standaloneSessionWindow = false, initialSessionId = null, standaloneSource = null } = props const {
standaloneSessionWindow = false,
initialSessionId = null,
standaloneSource = null,
standaloneInitialDisplayName = null,
standaloneInitialAvatarUrl = null,
standaloneInitialContactType = null
} = props
const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId]) const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId])
const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource]) const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource])
const normalizedStandaloneInitialDisplayName = useMemo(() => String(standaloneInitialDisplayName || '').trim(), [standaloneInitialDisplayName])
const normalizedStandaloneInitialAvatarUrl = useMemo(() => String(standaloneInitialAvatarUrl || '').trim(), [standaloneInitialAvatarUrl])
const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType])
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export' const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
const navigate = useNavigate() const navigate = useNavigate()
@@ -496,7 +510,12 @@ function ChatPage(props: ChatPageProps) {
const [hasInitialMessages, setHasInitialMessages] = useState(false) const [hasInitialMessages, setHasInitialMessages] = useState(false)
const [isSessionSwitching, setIsSessionSwitching] = useState(false) const [isSessionSwitching, setIsSessionSwitching] = useState(false)
const [noMessageTable, setNoMessageTable] = useState(false) const [noMessageTable, setNoMessageTable] = useState(false)
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null) const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(normalizedStandaloneInitialDisplayName || null)
const [fallbackAvatarUrl, setFallbackAvatarUrl] = useState<string | null>(normalizedStandaloneInitialAvatarUrl || null)
const [standaloneLoadStage, setStandaloneLoadStage] = useState<StandaloneLoadStage>(
standaloneSessionWindow && normalizedInitialSessionId ? 'connecting' : 'idle'
)
const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false)
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set()) const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState<Set<string>>(new Set())
@@ -2411,9 +2430,9 @@ function ChatPage(props: ChatPageProps) {
}, [appendMessages, getMessageKey]) }, [appendMessages, getMessageKey])
// 选择会话 // 选择会话
const selectSessionById = useCallback((sessionId: string) => { const selectSessionById = useCallback((sessionId: string, options: { force?: boolean } = {}) => {
const normalizedSessionId = String(sessionId || '').trim() const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId || normalizedSessionId === currentSessionId) return if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return
const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1 const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1
sessionSwitchRequestSeqRef.current = switchRequestSeq sessionSwitchRequestSeqRef.current = switchRequestSeq
@@ -2737,7 +2756,7 @@ function ChatPage(props: ChatPageProps) {
}, [currentSessionId, messages.length, isLoadingMessages]) }, [currentSessionId, messages.length, isLoadingMessages])
useEffect(() => { useEffect(() => {
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) { if (currentSessionId && isConnected && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
if (pendingSessionLoadRef.current === currentSessionId) return if (pendingSessionLoadRef.current === currentSessionId) return
if (initialLoadRequestedSessionRef.current === currentSessionId) return if (initialLoadRequestedSessionRef.current === currentSessionId) return
initialLoadRequestedSessionRef.current = currentSessionId initialLoadRequestedSessionRef.current = currentSessionId
@@ -2748,7 +2767,7 @@ function ChatPage(props: ChatPageProps) {
forceInitialLimit: 30 forceInitialLimit: 30
}) })
} }
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable]) }, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -2909,7 +2928,21 @@ function ChatPage(props: ChatPageProps) {
// 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback // 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback
const currentSession = (() => { const currentSession = (() => {
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
if (found || !currentSessionId) return found if (found) {
if (
standaloneSessionWindow &&
normalizedInitialSessionId &&
found.username === normalizedInitialSessionId
) {
return {
...found,
displayName: found.displayName || fallbackDisplayName || found.username,
avatarUrl: found.avatarUrl || fallbackAvatarUrl || undefined
}
}
return found
}
if (!currentSessionId) return found
return { return {
username: currentSessionId, username: currentSessionId,
type: 0, type: 0,
@@ -2919,6 +2952,7 @@ function ChatPage(props: ChatPageProps) {
lastTimestamp: 0, lastTimestamp: 0,
lastMsgType: 0, lastMsgType: 0,
displayName: fallbackDisplayName || currentSessionId, displayName: fallbackDisplayName || currentSessionId,
avatarUrl: fallbackAvatarUrl || undefined,
} as ChatSession } as ChatSession
})() })()
const filteredGroupPanelMembers = useMemo(() => { const filteredGroupPanelMembers = useMemo(() => {
@@ -2938,33 +2972,121 @@ function ChatPage(props: ChatPageProps) {
}, [groupMemberSearchKeyword, groupPanelMembers]) }, [groupMemberSearchKeyword, groupPanelMembers])
const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId)) const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId))
const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog
const isCurrentSessionGroup = Boolean(
currentSession && (
isGroupChatSession(currentSession.username) ||
(
standaloneSessionWindow &&
currentSession.username === normalizedInitialSessionId &&
normalizedStandaloneInitialContactType === 'group'
)
)
)
useEffect(() => {
if (!standaloneSessionWindow) return
setStandaloneInitialLoadRequested(false)
setStandaloneLoadStage(normalizedInitialSessionId ? 'connecting' : 'idle')
setFallbackDisplayName(normalizedStandaloneInitialDisplayName || null)
setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl || null)
}, [
standaloneSessionWindow,
normalizedInitialSessionId,
normalizedStandaloneInitialDisplayName,
normalizedStandaloneInitialAvatarUrl
])
useEffect(() => {
if (!standaloneSessionWindow) return
if (!normalizedInitialSessionId) return
if (normalizedStandaloneInitialDisplayName) {
setFallbackDisplayName(normalizedStandaloneInitialDisplayName)
}
if (normalizedStandaloneInitialAvatarUrl) {
setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl)
}
if (!currentSessionId) {
setCurrentSession(normalizedInitialSessionId, { preserveMessages: false })
}
if (!isConnected || isConnecting) {
setStandaloneLoadStage('connecting')
}
}, [
standaloneSessionWindow,
normalizedInitialSessionId,
normalizedStandaloneInitialDisplayName,
normalizedStandaloneInitialAvatarUrl,
currentSessionId,
isConnected,
isConnecting,
setCurrentSession
])
useEffect(() => { useEffect(() => {
if (!standaloneSessionWindow) return if (!standaloneSessionWindow) return
if (!normalizedInitialSessionId) return if (!normalizedInitialSessionId) return
if (!isConnected || isConnecting) return if (!isConnected || isConnecting) return
if (currentSessionId === normalizedInitialSessionId) return if (currentSessionId === normalizedInitialSessionId && standaloneInitialLoadRequested) return
selectSessionById(normalizedInitialSessionId) setStandaloneInitialLoadRequested(true)
setStandaloneLoadStage('loading')
selectSessionById(normalizedInitialSessionId, {
force: currentSessionId === normalizedInitialSessionId
})
}, [ }, [
standaloneSessionWindow, standaloneSessionWindow,
normalizedInitialSessionId, normalizedInitialSessionId,
isConnected, isConnected,
isConnecting, isConnecting,
currentSessionId, currentSessionId,
standaloneInitialLoadRequested,
selectSessionById selectSessionById
]) ])
useEffect(() => {
if (!standaloneSessionWindow || !normalizedInitialSessionId) return
if (!isConnected || isConnecting) {
setStandaloneLoadStage('connecting')
return
}
if (!standaloneInitialLoadRequested) {
setStandaloneLoadStage('loading')
return
}
if (currentSessionId !== normalizedInitialSessionId) {
setStandaloneLoadStage('loading')
return
}
if (isLoadingMessages || isSessionSwitching) {
setStandaloneLoadStage('loading')
return
}
setStandaloneLoadStage('ready')
}, [
standaloneSessionWindow,
normalizedInitialSessionId,
isConnected,
isConnecting,
standaloneInitialLoadRequested,
currentSessionId,
isLoadingMessages,
isSessionSwitching
])
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称 // 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
useEffect(() => { useEffect(() => {
if (!currentSessionId) return if (!currentSessionId) return
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
if (found) { if (found) {
setFallbackDisplayName(null) if (found.displayName) setFallbackDisplayName(found.displayName)
if (found.avatarUrl) setFallbackAvatarUrl(found.avatarUrl)
return return
} }
loadContactInfoBatch([currentSessionId]).then(() => { loadContactInfoBatch([currentSessionId]).then(() => {
const cached = senderAvatarCache.get(currentSessionId) const cached = senderAvatarCache.get(currentSessionId)
if (cached?.displayName) setFallbackDisplayName(cached.displayName) if (cached?.displayName) setFallbackDisplayName(cached.displayName)
if (cached?.avatarUrl) setFallbackAvatarUrl(cached.avatarUrl)
}) })
}, [currentSessionId, sessions]) }, [currentSessionId, sessions])
@@ -3741,16 +3863,16 @@ function ChatPage(props: ChatPageProps) {
src={currentSession.avatarUrl} src={currentSession.avatarUrl}
name={currentSession.displayName || currentSession.username} name={currentSession.displayName || currentSession.username}
size={40} size={40}
className={isGroupChatSession(currentSession.username) ? 'group session-avatar' : 'session-avatar'} className={isCurrentSessionGroup ? 'group session-avatar' : 'session-avatar'}
/> />
<div className="header-info"> <div className="header-info">
<h3>{currentSession.displayName || currentSession.username}</h3> <h3>{currentSession.displayName || currentSession.username}</h3>
{isGroupChatSession(currentSession.username) && ( {isCurrentSessionGroup && (
<div className="header-subtitle"></div> <div className="header-subtitle"></div>
)} )}
</div> </div>
<div className="header-actions"> <div className="header-actions">
{!standaloneSessionWindow && isGroupChatSession(currentSession.username) && ( {!standaloneSessionWindow && isCurrentSessionGroup && (
<button <button
className="icon-btn group-analytics-btn" className="icon-btn group-analytics-btn"
onClick={handleGroupAnalytics} onClick={handleGroupAnalytics}
@@ -3759,7 +3881,7 @@ function ChatPage(props: ChatPageProps) {
<BarChart3 size={18} /> <BarChart3 size={18} />
</button> </button>
)} )}
{isGroupChatSession(currentSession.username) && ( {isCurrentSessionGroup && (
<button <button
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`} className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
onClick={toggleGroupMembersPanel} onClick={toggleGroupMembersPanel}
@@ -3886,6 +4008,13 @@ function ChatPage(props: ChatPageProps) {
)} )}
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}> <div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}>
{standaloneSessionWindow && standaloneLoadStage !== 'ready' && (
<div className="standalone-phase-overlay" role="status" aria-live="polite">
<Loader2 size={22} className="spin" />
<span>{standaloneLoadStage === 'connecting' ? '正在建立连接...' : '正在加载最近消息...'}</span>
{connectionError && <small>{connectionError}</small>}
</div>
)}
{isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && ( {isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
<div className="loading-messages loading-overlay"> <div className="loading-messages loading-overlay">
<Loader2 size={24} /> <Loader2 size={24} />
@@ -3942,7 +4071,7 @@ function ChatPage(props: ChatPageProps) {
session={currentSession} session={currentSession}
showTime={!showDateDivider && showTime} showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl} myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChatSession(currentSession.username)} isGroupChat={isCurrentSessionGroup}
onRequireModelDownload={handleRequireModelDownload} onRequireModelDownload={handleRequireModelDownload}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
@@ -3974,7 +4103,7 @@ function ChatPage(props: ChatPageProps) {
</div> </div>
{/* 群成员面板 */} {/* 群成员面板 */}
{showGroupMembersPanel && isGroupChatSession(currentSession.username) && ( {showGroupMembersPanel && isCurrentSessionGroup && (
<div className="detail-panel group-members-panel"> <div className="detail-panel group-members-panel">
<div className="detail-header"> <div className="detail-header">
<h4></h4> <h4></h4>

View File

@@ -2,6 +2,9 @@ import type { ChatSession, Message, Contact, ContactInfo } from './models'
export interface SessionChatWindowOpenOptions { export interface SessionChatWindowOpenOptions {
source?: 'chat' | 'export' source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: ContactInfo['type']
} }
export interface ElectronAPI { export interface ElectronAPI {