refactor: modernize chat page

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

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)

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)

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)

View File

@@ -5301,3 +5301,581 @@
.in-session-search-btn.active { .in-session-search-btn.active {
color: var(--accent-color, #07c160); color: var(--accent-color, #07c160);
} }
// Modern chat surface overrides for the refactored Chat components.
.chat-page {
gap: 0;
background: var(--bg-sidebar);
&:not(.standalone) {
.message-area {
margin-left: 0;
}
}
}
.session-sidebar {
background: var(--bg-sidebar);
border-right: 0;
border-radius: 0;
}
.resize-handle {
background: transparent;
&:hover {
background: color-mix(in srgb, var(--text-tertiary) 18%, transparent);
}
}
.message-area {
background: var(--bg-primary);
border-radius: 0;
box-shadow: none;
}
.chat-page.standalone {
background: var(--bg-primary);
.session-sidebar {
background: var(--bg-sidebar);
border-right: 0;
backdrop-filter: none;
}
.message-area {
background: var(--bg-primary);
.message-header {
min-height: 64px;
padding: 12px 22px;
border-bottom: 0;
background: color-mix(in srgb, var(--bg-primary) 88%, transparent);
backdrop-filter: blur(14px);
box-shadow: 0 1px 0 color-mix(in srgb, var(--border-color) 70%, transparent);
.session-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
}
.header-actions .icon-btn {
width: 34px;
height: 34px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
box-shadow: none;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
box-shadow: none;
}
&.active {
background: var(--primary-light);
color: var(--primary);
}
}
}
.message-list {
background: var(--bg-primary);
padding: 18px clamp(16px, 3vw, 48px) 104px;
padding-bottom: calc(104px + env(safe-area-inset-bottom));
}
}
}
.message-header {
min-height: 64px;
padding: 12px 22px;
border-bottom: 0;
background: color-mix(in srgb, var(--bg-primary) 88%, transparent);
backdrop-filter: blur(14px);
box-shadow: 0 1px 0 color-mix(in srgb, var(--border-color) 70%, transparent);
.session-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
}
.header-info {
min-width: 0;
h3 {
font-size: 15px;
font-weight: 600;
letter-spacing: 0;
line-height: 1.25;
}
}
.header-actions {
gap: 2px;
}
.icon-btn {
width: 34px;
height: 34px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
box-shadow: none;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
box-shadow: none;
}
&.active {
background: var(--primary-light);
color: var(--primary);
}
}
}
.message-content-wrapper {
background: var(--bg-primary);
}
.message-list {
background: var(--bg-primary);
padding: 18px clamp(16px, 3vw, 48px) 104px;
padding-bottom: calc(104px + env(safe-area-inset-bottom));
gap: 0;
}
.message-wrapper {
padding-bottom: 14px;
&.sent {
align-items: flex-end;
}
&.received {
align-items: flex-start;
}
}
.message-wrapper-with-selection {
display: flex;
align-items: flex-start;
width: 100%;
cursor: default;
&[data-sent="true"] {
justify-content: flex-end;
}
&[data-sent="false"] {
justify-content: flex-start;
}
&.selectable {
cursor: pointer;
}
}
.chat-selection-checkbox {
width: 20px;
height: 20px;
border-radius: 6px;
border: 1.5px solid color-mix(in srgb, var(--text-tertiary) 55%, transparent);
background: transparent;
color: var(--on-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 9px;
&.left {
margin-right: 12px;
}
&.right {
margin-left: 12px;
}
&.checked {
background: var(--primary);
border-color: var(--primary);
}
}
.message-bubble {
gap: 10px;
max-width: min(76%, 760px);
.bubble-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-tertiary);
}
.bubble-body {
min-width: 0;
width: fit-content;
}
.bubble-content {
border-radius: 18px;
padding: 10px 14px;
line-height: 1.56;
box-shadow: none;
border: 0;
}
&.sent {
.bubble-content {
background: #3b82f6;
color: #fff;
border-radius: 18px;
}
.bubble-body {
align-items: flex-end;
}
}
&.received {
.bubble-content {
background: var(--bg-tertiary);
color: var(--text-primary);
border-radius: 18px;
backdrop-filter: none;
}
.bubble-body {
align-items: flex-start;
}
}
&.system {
max-width: min(86%, 680px);
.bubble-avatar {
display: none;
}
.bubble-content {
background: color-mix(in srgb, var(--bg-tertiary) 70%, transparent);
color: var(--text-tertiary);
border-radius: 999px;
padding: 6px 12px;
text-align: center;
}
}
&.emoji,
&.image {
max-width: min(82%, 760px);
.bubble-content {
background: transparent;
padding: 0;
}
}
&.voice.sent .bubble-content {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
.message-bubble .bubble-content:has(> .link-message),
.message-bubble .bubble-content:has(> .card-message),
.message-bubble .bubble-content:has(> .chat-record-message),
.message-bubble .bubble-content:has(> .solitaire-message),
.message-bubble .bubble-content:has(> .official-message),
.message-bubble .bubble-content:has(> .channel-video-card),
.message-bubble .bubble-content:has(> .location-message),
.message-bubble .bubble-content:has(> .hongbao-message),
.message-bubble .bubble-content:has(> .transfer-message),
.message-bubble .bubble-content:has(> .gift-message),
.message-bubble .bubble-content:has(> .miniapp-message),
.message-bubble .bubble-content:has(> .file-message) {
background: transparent !important;
padding: 0 !important;
border: 0 !important;
box-shadow: none !important;
}
.sender-name {
color: var(--text-tertiary);
font-size: 12px;
margin-bottom: 5px;
}
.quoted-message {
background: transparent;
border-left: 2px solid color-mix(in srgb, var(--primary) 62%, var(--text-tertiary));
border-radius: 0;
padding: 2px 0 2px 10px;
color: var(--text-secondary);
}
.message-bubble.sent .quoted-message {
background: color-mix(in srgb, #fff 12%, transparent);
border-left-color: color-mix(in srgb, #fff 62%, #3b82f6);
.quoted-sender,
.quoted-text {
color: color-mix(in srgb, #fff 82%, #3b82f6);
}
}
.link-message,
.card-message,
.chat-record-message,
.solitaire-message,
.official-message,
.channel-video-card,
.location-message,
.miniapp-message,
.file-message {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--shadow-sm);
}
.link-message,
.appmsg-rich-card {
width: min(320px, calc(100vw - 112px));
overflow: hidden;
&:hover {
background: var(--bg-hover);
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
}
.link-thumb,
.link-thumb-placeholder {
border-radius: 8px;
}
}
.message-bubble.sent .link-message,
.message-bubble.sent .card-message,
.message-bubble.sent .miniapp-message,
.message-bubble.sent .appmsg-rich-card {
background: var(--card-bg);
border-color: var(--border-color);
.card-name,
.miniapp-title,
.link-title {
color: var(--text-primary);
}
.card-label,
.miniapp-label,
.link-desc,
.appmsg-url-line {
color: var(--text-secondary);
}
}
.hongbao-message,
.transfer-message,
.gift-message {
width: min(280px, calc(100vw - 112px));
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: var(--shadow-sm);
color: var(--text-primary);
}
.hongbao-message,
.transfer-message {
padding: 14px;
.hongbao-icon,
.transfer-icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.hongbao-icon {
background: #ef4444;
}
.transfer-icon {
background: #f59e0b;
}
.hongbao-info,
.transfer-info {
color: var(--text-primary);
}
.hongbao-greeting,
.transfer-amount {
color: var(--text-primary);
text-shadow: none;
}
.hongbao-label,
.transfer-desc,
.transfer-memo,
.transfer-label {
color: var(--text-secondary);
opacity: 1;
text-shadow: none;
}
}
.transfer-message.received .transfer-icon {
background: #64748b;
}
.gift-message {
padding: 12px;
.gift-info {
color: var(--text-primary);
}
.gift-wish,
.gift-price {
color: var(--text-primary);
}
.gift-label {
color: var(--text-secondary);
opacity: 1;
}
}
[data-mode="dark"] {
.hongbao-message,
.transfer-message,
.gift-message {
background: #2b2b2b;
border-color: rgba(255, 255, 255, 0.08);
}
.message-bubble.received .bubble-content,
.message-bubble.voice.sent .bubble-content {
background: #2f2f2f;
}
}
.chat-input-area {
position: absolute;
left: max(18px, env(safe-area-inset-left));
right: max(18px, env(safe-area-inset-right));
bottom: calc(14px + env(safe-area-inset-bottom));
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
pointer-events: none;
&::before {
content: '';
position: absolute;
left: -18px;
right: -18px;
bottom: -14px;
height: 86px;
background: linear-gradient(to top, var(--bg-primary) 62%, transparent);
pointer-events: none;
z-index: -1;
}
}
.chat-input-search-btn,
.chat-input-shell {
height: 44px;
border: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
color: var(--text-secondary);
box-shadow: var(--shadow-sm);
backdrop-filter: blur(14px);
}
.chat-input-search-btn {
width: 44px;
border-radius: 12px;
display: grid;
place-items: center;
cursor: pointer;
pointer-events: auto;
&:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
&:disabled {
cursor: default;
opacity: 0.72;
}
}
.chat-input-shell {
width: min(680px, 100%);
border-radius: 14px;
padding: 0 14px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.scroll-to-bottom {
bottom: 74px;
background: color-mix(in srgb, var(--bg-primary) 92%, transparent);
color: var(--text-primary);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-sm);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
@media (max-width: 720px) {
.message-header {
padding-inline: 14px;
.header-actions {
gap: 0;
}
.icon-btn {
width: 32px;
height: 32px;
}
}
.message-list {
padding-inline: 14px;
}
.message-bubble {
max-width: 88%;
}
.chat-input-area {
left: 12px;
right: 12px;
}
}

View File

@@ -29,6 +29,9 @@ import {
onSingleExportDialogStatus, onSingleExportDialogStatus,
requestExportSessionStatus requestExportSessionStatus
} from '../services/exportBridge' } from '../services/exportBridge'
import ChatHeader from './Chat/ChatHeader'
import ChatInputArea from './Chat/ChatInputArea'
import ChatMessageBubble from './Chat/ChatMessageBubble'
import '../styles/batchTranscribe.scss' import '../styles/batchTranscribe.scss'
import './ChatPage.scss' import './ChatPage.scss'
@@ -6988,155 +6991,62 @@ function ChatPage(props: ChatPageProps) {
<BizMessageArea account={selectedBizAccount} /> <BizMessageArea account={selectedBizAccount} />
) : currentSession ? ( ) : currentSession ? (
<> <>
<div className="message-header"> <ChatHeader
<Avatar session={currentSession}
src={currentSession.avatarUrl} isGroupChat={isCurrentSessionGroup}
name={currentSession.displayName || currentSession.username} standaloneSessionWindow={standaloneSessionWindow}
size={40} showGroupMembersPanel={showGroupMembersPanel}
className={isCurrentSessionGroup ? 'group session-avatar' : 'session-avatar'} showJumpPopover={showJumpPopover}
showInSessionSearch={showInSessionSearch}
showDetailPanel={showDetailPanel}
shouldHideStandaloneDetailButton={shouldHideStandaloneDetailButton}
isPrivateSnsSupported={isCurrentSessionPrivateSnsSupported}
isExportActionBusy={isExportActionBusy}
isCurrentSessionExporting={isCurrentSessionExporting}
isPreparingExportDialog={isPreparingExportDialog}
isBatchTranscribing={isBatchTranscribing}
runningBatchVoiceTaskType={runningBatchVoiceTaskType}
isBatchDecrypting={isBatchDecrypting}
isRefreshingMessages={isRefreshingMessages}
isLoadingMessages={isLoadingMessages}
currentSessionId={currentSessionId}
jumpCalendarWrapRef={jumpCalendarWrapRef}
onGroupAnalytics={handleGroupAnalytics}
onToggleGroupMembersPanel={toggleGroupMembersPanel}
onExportCurrentSession={handleExportCurrentSession}
onOpenSnsTimeline={openCurrentSessionSnsTimeline}
onBatchTranscribe={handleBatchTranscribe}
onBatchDecrypt={handleBatchDecrypt}
onToggleJumpPopover={handleToggleJumpPopover}
onToggleInSessionSearch={handleToggleInSessionSearch}
onRefreshMessages={handleRefreshMessages}
onToggleDetailPanel={toggleDetailPanel}
/> />
<div className="header-info"> {showJumpPopover && createPortal(
<h3>{currentSession.displayName || currentSession.username}</h3> <div
{isCurrentSessionGroup && ( ref={jumpPopoverPortalRef}
<div className="header-subtitle"></div> style={{
)} position: 'fixed',
</div> top: jumpPopoverPosition.top,
<div className="header-actions"> left: jumpPopoverPosition.left,
{!standaloneSessionWindow && isCurrentSessionGroup && ( zIndex: 3600
<button }}
className="icon-btn group-analytics-btn"
onClick={handleGroupAnalytics}
title="群聊分析"
>
<BarChart3 size={18} />
</button>
)}
{isCurrentSessionGroup && (
<button
className={`icon-btn group-members-btn ${showGroupMembersPanel ? 'active' : ''}`}
onClick={toggleGroupMembersPanel}
title="群成员"
>
<Users size={18} />
</button>
)}
{!standaloneSessionWindow && (
<button
className={`icon-btn export-session-btn${isExportActionBusy ? ' exporting' : ''}`}
onClick={handleExportCurrentSession}
disabled={!currentSessionId || isExportActionBusy}
title={isCurrentSessionExporting ? '导出中' : isPreparingExportDialog ? '正在准备导出模块' : '导出当前会话'}
>
{isExportActionBusy ? (
<Loader2 size={18} className="spin" />
) : (
<Download size={18} />
)}
</button>
)}
{!standaloneSessionWindow && isCurrentSessionPrivateSnsSupported && (
<button
className="icon-btn chat-sns-timeline-btn"
onClick={openCurrentSessionSnsTimeline}
disabled={!currentSessionId}
title="查看对方朋友圈"
>
<Aperture size={18} />
</button>
)}
{!standaloneSessionWindow && (
<button
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
onClick={handleBatchTranscribe}
disabled={!currentSessionId}
title={isBatchTranscribing
? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中,可在导出页任务中心查看进度`
: '批量语音处理(解密/转文字)'}
>
{isBatchTranscribing ? (
<Loader2 size={18} className="spin" />
) : (
<Mic size={18} />
)}
</button>
)}
{!standaloneSessionWindow && (
<button
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
onClick={handleBatchDecrypt}
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={handleToggleJumpPopover}
title="跳转到指定时间"
>
<Calendar size={18} />
</button>
</div>
{showJumpPopover && createPortal(
<div
ref={jumpPopoverPortalRef}
style={{
position: 'fixed',
top: jumpPopoverPosition.top,
left: jumpPopoverPosition.left,
zIndex: 3600
}}
>
<JumpToDatePopover
isOpen={showJumpPopover}
currentDate={jumpPopoverDate}
onClose={() => setShowJumpPopover(false)}
onSelect={handleJumpDateSelect}
messageDates={messageDates}
hasLoadedMessageDates={hasLoadedMessageDates}
messageDateCounts={messageDateCounts}
loadingDates={loadingDates}
loadingDateCounts={loadingDateCounts}
style={{ position: 'static', top: 'auto', right: 'auto' }}
/>
</div>,
document.body
)}
<button
className={`icon-btn in-session-search-btn ${showInSessionSearch ? 'active' : ''}`}
onClick={handleToggleInSessionSearch}
disabled={!currentSessionId}
title="搜索会话消息"
> >
<Search size={18} /> <JumpToDatePopover
</button> isOpen={showJumpPopover}
<button currentDate={jumpPopoverDate}
className="icon-btn refresh-messages-btn" onClose={() => setShowJumpPopover(false)}
onClick={handleRefreshMessages} onSelect={handleJumpDateSelect}
disabled={isRefreshingMessages || isLoadingMessages} messageDates={messageDates}
title="刷新消息" hasLoadedMessageDates={hasLoadedMessageDates}
> messageDateCounts={messageDateCounts}
<RefreshCw size={18} className={isRefreshingMessages ? 'spin' : ''} /> loadingDates={loadingDates}
</button> loadingDateCounts={loadingDateCounts}
{!shouldHideStandaloneDetailButton && ( style={{ position: 'static', top: 'auto', right: 'auto' }}
<button />
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`} </div>,
onClick={toggleDetailPanel} document.body
title="会话详情" )}
>
<Info size={18} />
</button>
)}
</div>
</div>
{isPreparingExportDialog && exportPrepareHint && ( {isPreparingExportDialog && exportPrepareHint && (
<div className="export-prepare-hint" role="status" aria-live="polite"> <div className="export-prepare-hint" role="status" aria-live="polite">
@@ -7292,6 +7202,7 @@ function ChatPage(props: ChatPageProps) {
<span></span> <span></span>
</div> </div>
</div> </div>
<ChatInputArea placeholder="聊天记录" onFocusSearch={handleToggleInSessionSearch} />
{/* 群成员面板 */} {/* 群成员面板 */}
{showGroupMembersPanel && isCurrentSessionGroup && ( {showGroupMembersPanel && isCurrentSessionGroup && (
@@ -10696,115 +10607,57 @@ function MessageBubble({
return <div className="bubble-content">{renderTextWithEmoji(cleanedParsedContent)}</div> return <div className="bubble-content">{renderTextWithEmoji(cleanedParsedContent)}</div>
} }
return ( const systemAlertPortal = systemAlert ? createPortal(
<> <div className="modal-overlay" onClick={() => setSystemAlert(null)} style={{ zIndex: 99999 }}>
{showTime && ( <div className="delete-confirm-card" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<div className="time-divider"> <div className="confirm-icon">
<span>{formatTime(message.createTime)}</span> <AlertCircle size={32} color="var(--danger)" />
</div> </div>
)} <div className="confirm-content">
<div <h3>{systemAlert.title}</h3>
className={`message-wrapper-with-selection ${isSelectionMode ? 'selectable' : ''}`} <p style={{ marginTop: '12px', lineHeight: '1.6', fontSize: '14px', color: 'var(--text-secondary)' }}>
style={{ {systemAlert.message}
display: 'flex', </p>
alignItems: 'flex-start', </div>
width: '100%', <div className="confirm-actions" style={{ justifyContent: 'center', marginTop: '24px' }}>
justifyContent: isSent ? 'flex-end' : 'flex-start', <button
cursor: isSelectionMode ? 'pointer' : 'default' className="btn-primary"
}} onClick={() => setSystemAlert(null)}
onClick={(e) => { style={{ padding: '8px 32px' }}
if (isSelectionMode) { >
e.stopPropagation()
onToggleSelection?.(messageKey, e.shiftKey) </button>
}
}}
>
{isSelectionMode && !isSent && (
<div className={`checkbox ${isSelected ? 'checked' : ''}`} style={{
width: '20px',
height: '20px',
borderRadius: '4px',
border: isSelected ? 'none' : '2px solid rgba(128,128,128,0.5)',
backgroundColor: isSelected ? 'var(--primary)' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
marginRight: '12px',
marginTop: '10px', // Align with avatar top
flexShrink: 0
}}>
{isSelected && <Check size={14} strokeWidth={3} />}
</div>
)}
<div className={`message-bubble ${bubbleClass} ${isEmoji && message.emojiCdnUrl && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVoice ? 'voice' : ''}`}
onContextMenu={(e) => onContextMenu?.(e, message)}
>
<div className="bubble-avatar">
<Avatar
src={avatarUrl}
name={!isSent ? (isGroupChat ? (resolvedSenderName || '?') : (session.displayName || session.username)) : '我'}
size={36}
className="bubble-avatar"
/>
</div>
<div className="bubble-body">
{/* 群聊中显示发送者名称 */}
{isGroupChat && !isSent && (
<div className="sender-name">
{resolvedSenderName || '群成员'}
</div>
)}
{renderContent()}
</div>
</div> </div>
{isSelectionMode && isSent && (
<div className={`checkbox ${isSelected ? 'checked' : ''}`} style={{
width: '20px',
height: '20px',
borderRadius: '4px',
border: isSelected ? 'none' : '2px solid rgba(128,128,128,0.5)',
backgroundColor: isSelected ? 'var(--primary)' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
marginLeft: '12px',
marginTop: '10px',
flexShrink: 0
}}>
{isSelected && <Check size={14} strokeWidth={3} />}
</div>
)}
{systemAlert && createPortal(
<div className="modal-overlay" onClick={() => setSystemAlert(null)} style={{ zIndex: 99999 }}>
<div className="delete-confirm-card" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<div className="confirm-icon">
<AlertCircle size={32} color="var(--danger)" />
</div>
<div className="confirm-content">
<h3>{systemAlert.title}</h3>
<p style={{ marginTop: '12px', lineHeight: '1.6', fontSize: '14px', color: 'var(--text-secondary)' }}>
{systemAlert.message}
</p>
</div>
<div className="confirm-actions" style={{ justifyContent: 'center', marginTop: '24px' }}>
<button
className="btn-primary"
onClick={() => setSystemAlert(null)}
style={{ padding: '8px 32px' }}
>
</button>
</div>
</div>
</div>,
document.body
)}
</div> </div>
</> </div>,
document.body
) : null
return (
<ChatMessageBubble
message={message}
messageKey={messageKey}
session={session}
showTime={showTime}
timeText={formatTime(message.createTime)}
isSent={isSent}
isSystem={isSystem}
isEmoji={isEmoji}
isImage={isImage}
isVoice={isVoice}
emojiHasAsset={Boolean(message.emojiCdnUrl || message.emojiLocalPath)}
emojiError={emojiError}
avatarUrl={avatarUrl}
isGroupChat={isGroupChat}
resolvedSenderName={resolvedSenderName}
isSelectionMode={isSelectionMode}
isSelected={isSelected}
onContextMenu={onContextMenu}
onToggleSelection={onToggleSelection}
portal={systemAlertPortal}
>
{renderContent()}
</ChatMessageBubble>
) )
} }