refactor: modernize chat page

This commit is contained in:
Jason committed 2026-05-06 21:45:55 +08:00
1 parent 128055c4f4
commit 0f0f5abb2a
5 files changed
+1096 -253

No files matched your search

+234
View File
@@ -0,0 +1,234 @@
import React from 'react'
import {
Aperture,
BarChart3,
Calendar,
Download,
Image as ImageIcon,
Info,
Loader2,
Mic,
RefreshCw,
Search,
Users
} from 'lucide-react'
import { Avatar } from '../../components/Avatar'
import type { ChatSession } from '../../types/models'
import type { BatchVoiceTaskType } from '../../stores/batchTranscribeStore'
export interface ChatHeaderProps {
session: ChatSession
isGroupChat: boolean
standaloneSessionWindow: boolean
showGroupMembersPanel: boolean
showJumpPopover: boolean
showInSessionSearch: boolean
showDetailPanel: boolean
shouldHideStandaloneDetailButton: boolean
isPrivateSnsSupported: boolean
isExportActionBusy: boolean
isCurrentSessionExporting: boolean
isPreparingExportDialog: boolean
isBatchTranscribing: boolean
runningBatchVoiceTaskType?: BatchVoiceTaskType
isBatchDecrypting: boolean
isRefreshingMessages: boolean
isLoadingMessages: boolean
currentSessionId?: string | null
jumpCalendarWrapRef: React.RefObject<HTMLDivElement | null>
onGroupAnalytics: () => void
onToggleGroupMembersPanel: () => void
onExportCurrentSession: () => void
onOpenSnsTimeline: () => void
onBatchTranscribe: () => void
onBatchDecrypt: () => void
onToggleJumpPopover: () => void
onToggleInSessionSearch: () => void
onRefreshMessages: () => void
onToggleDetailPanel: () => void
}
function ChatHeader({
session,
isGroupChat,
standaloneSessionWindow,
showGroupMembersPanel,
showJumpPopover,
showInSessionSearch,
showDetailPanel,
shouldHideStandaloneDetailButton,
isPrivateSnsSupported,
isExportActionBusy,
isCurrentSessionExporting,
isPreparingExportDialog,
isBatchTranscribing,
runningBatchVoiceTaskType,
isBatchDecrypting,
isRefreshingMessages,
isLoadingMessages,
currentSessionId,
jumpCalendarWrapRef,
onGroupAnalytics,
onToggleGroupMembersPanel,
onExportCurrentSession,
onOpenSnsTimeline,
onBatchTranscribe,
onBatchDecrypt,
onToggleJumpPopover,
onToggleInSessionSearch,
onRefreshMessages,
onToggleDetailPanel
}: ChatHeaderProps) {
const sessionName = session.displayName || session.username
const exportTitle = isCurrentSessionExporting
? '导出中'
: isPreparingExportDialog
? '正在准备导出模块'
: '导出当前会话'
const batchVoiceTitle = isBatchTranscribing
? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中,可在导出页任务中心查看进度`
: '批量语音处理'
return (
<div className="message-header">
<Avatar
src={session.avatarUrl}
name={sessionName}
size={40}
className={isGroupChat ? 'group session-avatar' : 'session-avatar'}
/>
<div className="header-info">
<h3>{sessionName}</h3>
{isGroupChat && <div className="header-subtitle">群聊</div>}
</div>
<div className="header-actions">
{!standaloneSessionWindow && isGroupChat && (
<button className="icon-btn group-analytics-btn" onClick={onGroupAnalytics} title="群聊分析">
<BarChart3 size={18} />
</button>
)}
{isGroupChat && (
<button
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
onClick={onToggleGroupMembersPanel}
title="群成员"
>
<Users size={18} />
</button>
)}
{!standaloneSessionWindow && (
<button
className={`icon-btn export-session-btn${isExportActionBusy ? ' exporting' : ''}`}
onClick={onExportCurrentSession}
disabled={!currentSessionId || isExportActionBusy}
title={exportTitle}
>
{isExportActionBusy ? <Loader2 size={18} className="spin" /> : <Download size={18} />}
</button>
)}
{!standaloneSessionWindow && isPrivateSnsSupported && (
<button
className="icon-btn chat-sns-timeline-btn"
onClick={onOpenSnsTimeline}
disabled={!currentSessionId}
title="查看朋友圈"
>
<Aperture size={18} />
</button>
)}
{!standaloneSessionWindow && (
<button
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
onClick={onBatchTranscribe}
disabled={!currentSessionId}
title={batchVoiceTitle}
>
{isBatchTranscribing ? <Loader2 size={18} className="spin" /> : <Mic size={18} />}
</button>
)}
{!standaloneSessionWindow && (
<button
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
onClick={onBatchDecrypt}
disabled={!currentSessionId}
title={isBatchDecrypting ? '批量解密中' : '批量解密图片'}
>
{isBatchDecrypting ? <Loader2 size={18} className="spin" /> : <ImageIcon size={18} />}
</button>
)}
<div className="jump-calendar-anchor" ref={jumpCalendarWrapRef}>
<button
className={`icon-btn jump-to-time-btn ${showJumpPopover ? 'active' : ''}`}
onClick={onToggleJumpPopover}
title="跳转到指定时间"
>
<Calendar size={18} />
</button>
</div>
<button
className={`icon-btn in-session-search-btn ${showInSessionSearch ? 'active' : ''}`}
onClick={onToggleInSessionSearch}
disabled={!currentSessionId}
title="搜索会话消息"
>
<Search size={18} />
</button>
<button
className="icon-btn refresh-messages-btn"
onClick={onRefreshMessages}
disabled={isRefreshingMessages || isLoadingMessages}
title="刷新消息"
>
<RefreshCw size={18} className={isRefreshingMessages ? 'spin' : ''} />
</button>
{!shouldHideStandaloneDetailButton && (
<button
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`}
onClick={onToggleDetailPanel}
title="会话详情"
>
<Info size={18} />
</button>
)}
</div>
</div>
)
}
function areEqual(prev: ChatHeaderProps, next: ChatHeaderProps) {
return (
prev.session.username === next.session.username &&
prev.session.displayName === next.session.displayName &&
prev.session.avatarUrl === next.session.avatarUrl &&
prev.isGroupChat === next.isGroupChat &&
prev.standaloneSessionWindow === next.standaloneSessionWindow &&
prev.showGroupMembersPanel === next.showGroupMembersPanel &&
prev.showJumpPopover === next.showJumpPopover &&
prev.showInSessionSearch === next.showInSessionSearch &&
prev.showDetailPanel === next.showDetailPanel &&
prev.shouldHideStandaloneDetailButton === next.shouldHideStandaloneDetailButton &&
prev.isPrivateSnsSupported === next.isPrivateSnsSupported &&
prev.isExportActionBusy === next.isExportActionBusy &&
prev.isCurrentSessionExporting === next.isCurrentSessionExporting &&
prev.isPreparingExportDialog === next.isPreparingExportDialog &&
prev.isBatchTranscribing === next.isBatchTranscribing &&
prev.runningBatchVoiceTaskType === next.runningBatchVoiceTaskType &&
prev.isBatchDecrypting === next.isBatchDecrypting &&
prev.isRefreshingMessages === next.isRefreshingMessages &&
prev.isLoadingMessages === next.isLoadingMessages &&
prev.currentSessionId === next.currentSessionId &&
prev.jumpCalendarWrapRef === next.jumpCalendarWrapRef &&
prev.onGroupAnalytics === next.onGroupAnalytics &&
prev.onToggleGroupMembersPanel === next.onToggleGroupMembersPanel &&
prev.onExportCurrentSession === next.onExportCurrentSession &&
prev.onOpenSnsTimeline === next.onOpenSnsTimeline &&
prev.onBatchTranscribe === next.onBatchTranscribe &&
prev.onBatchDecrypt === next.onBatchDecrypt &&
prev.onToggleJumpPopover === next.onToggleJumpPopover &&
prev.onToggleInSessionSearch === next.onToggleInSessionSearch &&
prev.onRefreshMessages === next.onRefreshMessages &&
prev.onToggleDetailPanel === next.onToggleDetailPanel
)
}
export default React.memo(ChatHeader, areEqual)
+42
View File
@@ -0,0 +1,42 @@
import React from 'react'
import { Lock, Search } from 'lucide-react'
export interface ChatInputAreaProps {
disabled?: boolean
placeholder?: string
onFocusSearch?: () => void
}
function ChatInputArea({
disabled = true,
placeholder = '聊天记录',
onFocusSearch
}: ChatInputAreaProps) {
return (
<div className="chat-input-area" aria-hidden={disabled ? undefined : false}>
<button
type="button"
className="chat-input-search-btn"
onClick={onFocusSearch}
disabled={!onFocusSearch}
title="搜索"
>
<Search size={16} />
</button>
<div className="chat-input-shell">
<Lock size={15} />
<span>{placeholder}</span>
</div>
</div>
)
}
function areEqual(prev: ChatInputAreaProps, next: ChatInputAreaProps) {
return (
prev.disabled === next.disabled &&
prev.placeholder === next.placeholder &&
prev.onFocusSearch === next.onFocusSearch
)
}
export default React.memo(ChatInputArea, areEqual)
+136
View File
@@ -0,0 +1,136 @@
import React from 'react'
import { Check } from 'lucide-react'
import { Avatar } from '../../components/Avatar'
import type { ChatSession, Message } from '../../types/models'
export interface ChatMessageBubbleProps {
message: Message
messageKey: string
session: ChatSession
showTime?: boolean
timeText?: string
isSent: boolean
isSystem: boolean
isEmoji?: boolean
isImage?: boolean
isVoice?: boolean
emojiHasAsset?: boolean
emojiError?: boolean
avatarUrl?: string
isGroupChat?: boolean
resolvedSenderName?: string
isSelectionMode?: boolean
isSelected?: boolean
onContextMenu?: (event: React.MouseEvent, message: Message) => void
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void
children: React.ReactNode
portal?: React.ReactNode
}
function SelectionCheckbox({ checked, side }: { checked?: boolean; side: 'left' | 'right' }) {
return (
<div className={`chat-selection-checkbox ${side} ${checked ? 'checked' : ''}`}>
{checked && <Check size={14} strokeWidth={3} />}
</div>
)
}
function ChatMessageBubble({
message,
messageKey,
session,
showTime,
timeText,
isSent,
isSystem,
isEmoji,
isImage,
isVoice,
emojiHasAsset,
emojiError,
avatarUrl,
isGroupChat,
resolvedSenderName,
isSelectionMode,
isSelected,
onContextMenu,
onToggleSelection,
children,
portal
}: ChatMessageBubbleProps) {
const bubbleClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
const avatarName = !isSent
? (isGroupChat ? (resolvedSenderName || '?') : (session.displayName || session.username))
: '我'
return (
<>
{showTime && timeText && (
<div className="time-divider">
<span>{timeText}</span>
</div>
)}
<div
className={`message-wrapper-with-selection ${isSelectionMode ? 'selectable' : ''}`}
data-sent={isSent ? 'true' : 'false'}
onClick={(event) => {
if (!isSelectionMode) return
event.stopPropagation()
onToggleSelection?.(messageKey, event.shiftKey)
}}
>
{isSelectionMode && !isSent && <SelectionCheckbox checked={isSelected} side="left" />}
<div
className={`message-bubble ${bubbleClass} ${isEmoji && emojiHasAsset && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVoice ? 'voice' : ''}`}
onContextMenu={(event) => onContextMenu?.(event, message)}
>
<div className="bubble-avatar">
<Avatar src={avatarUrl} name={avatarName} size={36} className="bubble-avatar" />
</div>
<div className="bubble-body">
{isGroupChat && !isSent && (
<div className="sender-name">
{resolvedSenderName || '群成员'}
</div>
)}
{children}
</div>
</div>
{isSelectionMode && isSent && <SelectionCheckbox checked={isSelected} side="right" />}
{portal}
</div>
</>
)
}
function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps) {
return (
prev.message === next.message &&
prev.messageKey === next.messageKey &&
prev.session.username === next.session.username &&
prev.session.displayName === next.session.displayName &&
prev.session.avatarUrl === next.session.avatarUrl &&
prev.showTime === next.showTime &&
prev.timeText === next.timeText &&
prev.isSent === next.isSent &&
prev.isSystem === next.isSystem &&
prev.isEmoji === next.isEmoji &&
prev.isImage === next.isImage &&
prev.isVoice === next.isVoice &&
prev.emojiHasAsset === next.emojiHasAsset &&
prev.emojiError === next.emojiError &&
prev.avatarUrl === next.avatarUrl &&
prev.isGroupChat === next.isGroupChat &&
prev.resolvedSenderName === next.resolvedSenderName &&
prev.isSelectionMode === next.isSelectionMode &&
prev.isSelected === next.isSelected &&
prev.onContextMenu === next.onContextMenu &&
prev.onToggleSelection === next.onToggleSelection &&
prev.children === next.children &&
prev.portal === next.portal
)
}
export default React.memo(ChatMessageBubble, areEqual)