mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
完成 UI-02 和 UI-03 聊天档案界面
This commit is contained in:
@@ -6,6 +6,9 @@ import { AppShell } from './components/layout/AppShell'
|
|||||||
import { AppPage } from './components/layout/navigation'
|
import { AppPage } from './components/layout/navigation'
|
||||||
import { Contact, Message } from '../../shared/types'
|
import { Contact, Message } from '../../shared/types'
|
||||||
|
|
||||||
|
const SIDEBAR_MIN_WIDTH = 260
|
||||||
|
const SIDEBAR_MAX_WIDTH = 380
|
||||||
|
|
||||||
function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement {
|
function EyeIcon({ hidden }: { hidden: boolean }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
@@ -711,12 +714,20 @@ function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(250)
|
const [sidebarWidth, setSidebarWidth] = useState(300)
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
|
const sidebarResizeStartRef = React.useRef({ x: 0, width: 300 })
|
||||||
|
|
||||||
const startResizing = React.useCallback(() => {
|
const startResizing = React.useCallback(
|
||||||
setIsResizing(true)
|
(mouseDownEvent: React.MouseEvent<HTMLDivElement>) => {
|
||||||
}, [])
|
sidebarResizeStartRef.current = {
|
||||||
|
x: mouseDownEvent.clientX,
|
||||||
|
width: sidebarWidth
|
||||||
|
}
|
||||||
|
setIsResizing(true)
|
||||||
|
},
|
||||||
|
[sidebarWidth]
|
||||||
|
)
|
||||||
|
|
||||||
const stopResizing = React.useCallback(() => {
|
const stopResizing = React.useCallback(() => {
|
||||||
setIsResizing(false)
|
setIsResizing(false)
|
||||||
@@ -725,7 +736,9 @@ function App(): React.ReactElement {
|
|||||||
const resize = React.useCallback(
|
const resize = React.useCallback(
|
||||||
(mouseMoveEvent: MouseEvent) => {
|
(mouseMoveEvent: MouseEvent) => {
|
||||||
if (isResizing) {
|
if (isResizing) {
|
||||||
setSidebarWidth(mouseMoveEvent.clientX)
|
const { x, width } = sidebarResizeStartRef.current
|
||||||
|
const nextWidth = width + mouseMoveEvent.clientX - x
|
||||||
|
setSidebarWidth(Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, nextWidth)))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isResizing]
|
[isResizing]
|
||||||
@@ -853,11 +866,13 @@ function App(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
<div className="resizer" onMouseDown={startResizing} />
|
<div className="resizer" onMouseDown={startResizing} />
|
||||||
<ChatWindow
|
<ChatWindow
|
||||||
key={`${selectedContact?.md5}-${contentFilter}`}
|
key={selectedContact?.md5}
|
||||||
contact={selectedContact}
|
contact={selectedContact}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isLoadingMessages={isMessagesLoading}
|
isLoadingMessages={isMessagesLoading}
|
||||||
contentFilter={contentFilter}
|
contentFilter={contentFilter}
|
||||||
|
dateRange={dateRange}
|
||||||
|
onContentFilterChange={setContentFilter}
|
||||||
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
|
||||||
onRefreshData={loadContacts}
|
onRefreshData={loadContacts}
|
||||||
/>
|
/>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,24 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Message, Contact } from '../../../shared/types'
|
import { Message, Contact } from '../../../shared/types'
|
||||||
import { VoicePlayer } from './VoicePlayer'
|
|
||||||
import { RichMessageBubble } from './RichMessageBubble'
|
|
||||||
import { ImageBubble } from './ImageBubble'
|
|
||||||
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
|
|
||||||
import {
|
import {
|
||||||
buildGroupReportInput,
|
buildGroupReportInput,
|
||||||
GROUP_REPORT_SYSTEM_PROMPT,
|
GROUP_REPORT_SYSTEM_PROMPT,
|
||||||
parseGroupDailyReport
|
parseGroupDailyReport
|
||||||
} from '../utils/group-report'
|
} from '../utils/group-report'
|
||||||
|
import { ChatHeader } from './chat/ChatHeader'
|
||||||
|
import { ChatStatusBar } from './chat/ChatStatusBar'
|
||||||
|
import { DataTrustBar } from './chat/DataTrustBar'
|
||||||
|
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||||
|
import { ExportRange } from './chat/ExportMenu'
|
||||||
|
import { MessageList } from './chat/MessageList'
|
||||||
|
|
||||||
interface ChatWindowProps {
|
interface ChatWindowProps {
|
||||||
contact: Contact | null
|
contact: Contact | null
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
isLoadingMessages?: boolean
|
isLoadingMessages?: boolean
|
||||||
contentFilter?: string
|
contentFilter?: string
|
||||||
|
dateRange?: string
|
||||||
|
onContentFilterChange?: (keyword: string) => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
onRefreshData?: () => void
|
onRefreshData?: () => void
|
||||||
}
|
}
|
||||||
@@ -29,6 +33,43 @@ const SUMMARY_DATE_OPTIONS: { value: SummaryDateRange; label: string }[] = [
|
|||||||
]
|
]
|
||||||
const MAX_RENDERED_MESSAGES = 600
|
const MAX_RENDERED_MESSAGES = 600
|
||||||
const REPORT_STEP_TIMEOUT_MS = 90_000
|
const REPORT_STEP_TIMEOUT_MS = 90_000
|
||||||
|
const DATE_RANGE_LABELS: Record<string, string> = {
|
||||||
|
today: '今天',
|
||||||
|
yesterday: '昨日',
|
||||||
|
'7': '7 天',
|
||||||
|
'30': '30 天',
|
||||||
|
all: '全部'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatClock = (date: Date): string =>
|
||||||
|
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
const formatRangeDate = (date: Date, now: Date): string => {
|
||||||
|
const clock = formatClock(date)
|
||||||
|
if (date.getFullYear() === now.getFullYear()) {
|
||||||
|
return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||||
|
}
|
||||||
|
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChatHeaderRangeLabel = (range: string): string => {
|
||||||
|
const now = new Date()
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
|
||||||
|
|
||||||
|
if (range === 'today') return `今天 00:00—现在`
|
||||||
|
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
|
||||||
|
if (range === '7') {
|
||||||
|
const start = new Date(Date.now() - 7 * 86400000)
|
||||||
|
return `${formatRangeDate(start, now)}—现在`
|
||||||
|
}
|
||||||
|
if (range === '30') {
|
||||||
|
const start = new Date(Date.now() - 30 * 86400000)
|
||||||
|
return `${formatRangeDate(start, now)}—现在`
|
||||||
|
}
|
||||||
|
if (range === 'all') return '全部记录'
|
||||||
|
return DATE_RANGE_LABELS[range] || '当前范围'
|
||||||
|
}
|
||||||
|
|
||||||
const withTimeout = async <T,>(promise: Promise<T>, label: string): Promise<T> => {
|
const withTimeout = async <T,>(promise: Promise<T>, label: string): Promise<T> => {
|
||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
@@ -44,7 +85,9 @@ const withTimeout = async <T,>(promise: Promise<T>, label: string): Promise<T> =
|
|||||||
|
|
||||||
const isInternalName = (value?: string): boolean => {
|
const isInternalName = (value?: string): boolean => {
|
||||||
const text = String(value || '').trim()
|
const text = String(value || '').trim()
|
||||||
return !text || /^wxid_/i.test(text) || /@chatroom$/i.test(text) || /^[a-z0-9_-]{18,}$/i.test(text)
|
return (
|
||||||
|
!text || /^wxid_/i.test(text) || /@chatroom$/i.test(text) || /^[a-z0-9_-]{18,}$/i.test(text)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUMMARY_TYPE_OPTIONS: {
|
const SUMMARY_TYPE_OPTIONS: {
|
||||||
@@ -79,12 +122,15 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
messages,
|
messages,
|
||||||
isLoadingMessages,
|
isLoadingMessages,
|
||||||
contentFilter,
|
contentFilter,
|
||||||
|
dateRange = 'today',
|
||||||
|
onContentFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRefreshData
|
onRefreshData
|
||||||
}) => {
|
}) => {
|
||||||
const isGroupChat = Boolean(
|
const isGroupChat = Boolean(
|
||||||
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
||||||
)
|
)
|
||||||
|
const messageListRef = useRef<HTMLDivElement>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||||
const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null)
|
const [reportPaths, setReportPaths] = useState<{ htmlPath: string; pngPath: string } | null>(null)
|
||||||
@@ -107,6 +153,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat')
|
const [model, setModel] = useState(() => localStorage.getItem('ai_model') || 'deepseek-chat')
|
||||||
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
const [summaryDateRange, setSummaryDateRange] = useState<SummaryDateRange>('today')
|
||||||
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
const [summaryMessageTypes, setSummaryMessageTypes] = useState<SummaryMessageType[]>(['text'])
|
||||||
|
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||||
|
|
||||||
const handleSaveSettings = (): void => {
|
const handleSaveSettings = (): void => {
|
||||||
if (!summaryMessageTypes.length) {
|
if (!summaryMessageTypes.length) {
|
||||||
@@ -126,13 +173,20 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToBottom = (): void => {
|
const scrollToBottom = useCallback((): void => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||||
}
|
setIsAtLatest(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
||||||
|
const target = event.currentTarget
|
||||||
|
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||||
|
setIsAtLatest(distanceToBottom <= 24)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}, [messages])
|
}, [messages, scrollToBottom])
|
||||||
|
|
||||||
const openImagePreview = (imageUrl: string): void => {
|
const openImagePreview = (imageUrl: string): void => {
|
||||||
setPreviewImage(imageUrl)
|
setPreviewImage(imageUrl)
|
||||||
@@ -203,7 +257,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
}
|
}
|
||||||
}, [previewImage])
|
}, [previewImage])
|
||||||
|
|
||||||
const handleExport = (days: number | 'all'): void => {
|
const handleExport = (days: ExportRange): void => {
|
||||||
if (!messages.length) return
|
if (!messages.length) return
|
||||||
|
|
||||||
let filtered = messages
|
let filtered = messages
|
||||||
@@ -360,150 +414,52 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
[filteredMessages]
|
[filteredMessages]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!contact) {
|
if (!contact) return <EmptyConversationState />
|
||||||
return (
|
|
||||||
<div className="chat-window">
|
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
||||||
<div className="empty-state">选择一条消息</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-window">
|
<div className="chat-window">
|
||||||
<div className="chat-header">
|
<ChatHeader
|
||||||
<h2>{contact.m_nsNickName}</h2>
|
contact={contact}
|
||||||
<div className="window-controls"></div>
|
isGroupChat={isGroupChat}
|
||||||
</div>
|
dateRangeLabel={dateRangeLabel}
|
||||||
|
loadedCount={messages.length}
|
||||||
<div className="message-list wechat-message-list">
|
filteredCount={filteredMessages.length}
|
||||||
{isLoadingMessages && (
|
contentFilter={contentFilter || ''}
|
||||||
<div className="message-loading-pill">正在加载聊天记录...</div>
|
isAiLoading={isLoading}
|
||||||
)}
|
canExport={messages.length > 0}
|
||||||
{hiddenMessageCount > 0 && (
|
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||||
<div className="wechat-system-message-row">
|
onRefresh={onRefresh}
|
||||||
<div className="wechat-system-message">
|
onRefreshData={onRefreshData}
|
||||||
已隐藏较早的 {hiddenMessageCount} 条消息,当前显示最新 {MAX_RENDERED_MESSAGES} 条
|
onExport={handleExport}
|
||||||
</div>
|
onOpenAiSettings={() => setShowSettingsModal(true)}
|
||||||
</div>
|
/>
|
||||||
)}
|
<DataTrustBar messageCount={messages.length} />
|
||||||
{renderedMessages.map((msg) => {
|
<MessageList
|
||||||
const isMine = msg.from === 'assistant'
|
contact={contact}
|
||||||
const isSystem = msg.from === 'system' || msg.type === '系统消息'
|
messages={renderedMessages}
|
||||||
const displayName = isMine
|
hiddenMessageCount={hiddenMessageCount}
|
||||||
? '我'
|
isLoadingMessages={isLoadingMessages}
|
||||||
: isGroupChat
|
isGroupChat={isGroupChat}
|
||||||
? msg.name || msg.from
|
showAvatar={showAvatar}
|
||||||
: contact.m_nsNickName
|
listRef={messageListRef}
|
||||||
const avatarSrc = isMine ? msg.img : msg.img || contact.avatar
|
bottomRef={messagesEndRef}
|
||||||
const isVoice = msg.type === '语音'
|
onScroll={handleMessageListScroll}
|
||||||
const isImage = msg.type === '图片'
|
onImageClick={openImagePreview}
|
||||||
const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息'].includes(
|
/>
|
||||||
msg.type
|
<ChatStatusBar
|
||||||
)
|
count={renderedMessages.length}
|
||||||
|
showAvatar={showAvatar}
|
||||||
if (isSystem) {
|
isAtLatest={isAtLatest}
|
||||||
return (
|
onShowAvatarChange={setShowAvatar}
|
||||||
<div key={msg.id} className="wechat-system-message-row">
|
onJumpToLatest={scrollToBottom}
|
||||||
<div className="wechat-system-message">{msg.content}</div>
|
/>
|
||||||
<div className="wechat-system-message-meta">{msg.datetime}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={msg.id} className={`wechat-message-row ${isMine ? 'mine' : 'other'}`}>
|
|
||||||
{!isMine && showAvatar && (
|
|
||||||
<div className="message-avatar">
|
|
||||||
{avatarSrc ? (
|
|
||||||
<img src={avatarSrc} alt={displayName} referrerPolicy="no-referrer" />
|
|
||||||
) : (
|
|
||||||
(displayName || '?').charAt(0)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="message-stack">
|
|
||||||
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
|
|
||||||
<div
|
|
||||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${isImage ? 'image-message-bubble' : ''}`}
|
|
||||||
>
|
|
||||||
{isVoice && msg.sessionId ? (
|
|
||||||
<VoicePlayer
|
|
||||||
sessionId={msg.sessionId}
|
|
||||||
localId={msg.localId || 0}
|
|
||||||
createTime={msg.createTime || 0}
|
|
||||||
/>
|
|
||||||
) : isImage && msg.contentData && msg.contentData.type === 'image' ? (
|
|
||||||
<ImageBubble
|
|
||||||
imageMd5={msg.contentData.md5}
|
|
||||||
imageDatName={msg.contentData.datName}
|
|
||||||
sessionId={msg.sessionId}
|
|
||||||
onImageClick={openImagePreview}
|
|
||||||
/>
|
|
||||||
) : isRichMedia && msg.contentData ? (
|
|
||||||
<RichMessageBubble contentData={msg.contentData} />
|
|
||||||
) : (
|
|
||||||
<div className="message-text">{renderWechatEmojiText(msg.content)}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="message-meta">
|
|
||||||
<span>{msg.datetime}</span>
|
|
||||||
<span>{msg.type}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isMine && showAvatar && (
|
|
||||||
<div className="message-avatar mine-avatar">
|
|
||||||
{avatarSrc ? <img src={avatarSrc} alt="我" referrerPolicy="no-referrer" /> : '我'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="chat-toolbar">
|
|
||||||
<label
|
|
||||||
style={{ marginRight: '10px', display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showAvatar}
|
|
||||||
onChange={(e) => setShowAvatar(e.target.checked)}
|
|
||||||
style={{ marginRight: '5px' }}
|
|
||||||
/>
|
|
||||||
显示头像
|
|
||||||
</label>
|
|
||||||
<button className="toolbar-btn" onClick={onRefresh}>
|
|
||||||
🔄 刷新聊天记录
|
|
||||||
</button>
|
|
||||||
<button className="toolbar-btn" onClick={onRefreshData}>
|
|
||||||
🔄 刷新数据
|
|
||||||
</button>
|
|
||||||
<button className="toolbar-btn" onClick={() => handleExport('all')}>
|
|
||||||
📤 导出全部
|
|
||||||
</button>
|
|
||||||
<button className="toolbar-btn" onClick={() => handleExport(0)}>
|
|
||||||
🕒 导出今日
|
|
||||||
</button>
|
|
||||||
<button className="toolbar-btn" onClick={() => handleExport(1)}>
|
|
||||||
📅 导出昨日
|
|
||||||
</button>
|
|
||||||
<button className="toolbar-btn" onClick={() => handleExport(7)}>
|
|
||||||
📅 导出近7天
|
|
||||||
</button>
|
|
||||||
<button className="toolbar-btn" onClick={() => handleExport(30)}>
|
|
||||||
📅 导出近30天
|
|
||||||
</button>
|
|
||||||
<button className="toolbar-btn" onClick={() => setShowSettingsModal(true)}>
|
|
||||||
🤖 AI总结群聊
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 加载模态框 */}
|
{/* 加载模态框 */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
|
<div className="modal-content" style={{ textAlign: 'center', minWidth: '200px' }}>
|
||||||
<div style={{ fontSize: '40px', marginBottom: '20px' }}>🤖</div>
|
|
||||||
<div style={{ fontSize: '16px', color: '#333' }}>正在生成群聊日报...</div>
|
<div style={{ fontSize: '16px', color: '#333' }}>正在生成群聊日报...</div>
|
||||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
|
<div style={{ fontSize: '12px', color: '#999', marginTop: '10px' }}>
|
||||||
正在分析记录、处理头像并生成 HTML 和长图
|
正在分析记录、处理头像并生成 HTML 和长图
|
||||||
@@ -537,7 +493,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
|||||||
borderRadius: '4px'
|
borderRadius: '4px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📋 复制图片
|
复制图片
|
||||||
</button>
|
</button>
|
||||||
{reportPaths && (
|
{reportPaths && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ export function ImageBubble({
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="image-bubble image-loading" onClick={handleClick}>
|
<div className="image-bubble image-loading" onClick={handleClick}>
|
||||||
<div className="image-loading-spinner">加载中...</div>
|
<div className="image-loading-skeleton" aria-hidden />
|
||||||
|
<div className="image-quality-badge">加载中</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -118,7 +119,8 @@ export function ImageBubble({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="image-bubble image-error" onClick={loadImage}>
|
<div className="image-bubble image-error" onClick={loadImage}>
|
||||||
<div className="image-error-text">{error || '图片未加载'}</div>
|
<div className="image-error-text">{error || '图片未缓存'}</div>
|
||||||
|
<div className="image-quality-badge">加载失败</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -126,8 +128,8 @@ export function ImageBubble({
|
|||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="image-bubble image-placeholder">
|
<div ref={containerRef} className="image-bubble image-placeholder">
|
||||||
<div className="image-placeholder-icon">图</div>
|
<div className="image-loading-skeleton" aria-hidden />
|
||||||
<div className="image-placeholder-text">加载图片中...</div>
|
<div className="image-quality-badge">加载中</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -136,7 +138,7 @@ export function ImageBubble({
|
|||||||
<div className="image-bubble image-loaded" onClick={handleClick}>
|
<div className="image-bubble image-loaded" onClick={handleClick}>
|
||||||
<img src={imageUrl} alt="图片" className="image-content" />
|
<img src={imageUrl} alt="图片" className="image-content" />
|
||||||
{(upgrading || isThumbnail) && (
|
{(upgrading || isThumbnail) && (
|
||||||
<div className="image-quality-badge">{upgrading ? '查找原图...' : '缩略图'}</div>
|
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
||||||
)}
|
)}
|
||||||
<div className="image-actions">
|
<div className="image-actions">
|
||||||
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
|
<button className="image-action-btn" onClick={handleCopy} title="复制图片">
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function LocationBubble({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="location-message" onClick={hasCoords ? handleClick : undefined}>
|
<div className="location-message" onClick={hasCoords ? handleClick : undefined}>
|
||||||
<div className="location-icon">📍</div>
|
<div className="location-icon">位置</div>
|
||||||
<div className="location-info">
|
<div className="location-info">
|
||||||
<div className="location-name">{locationText}</div>
|
<div className="location-name">{locationText}</div>
|
||||||
{label && poiname && label !== poiname && <div className="location-label">{label}</div>}
|
{label && poiname && label !== poiname && <div className="location-label">{label}</div>}
|
||||||
@@ -126,7 +126,7 @@ function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="voip-message">
|
<div className="voip-message">
|
||||||
<span className="voip-icon">{isVideo ? '📹' : '📞'}</span>
|
<span className="voip-icon">{isVideo ? '视频' : '通话'}</span>
|
||||||
<span className="voip-status">
|
<span className="voip-status">
|
||||||
{status}
|
{status}
|
||||||
{duration ? ` ${formatDuration(duration)}` : ''}
|
{duration ? ` ${formatDuration(duration)}` : ''}
|
||||||
|
|||||||
@@ -1,163 +1,6 @@
|
|||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import { Contact } from '../../../shared/types'
|
import { ConversationSidebar, ConversationSidebarProps } from './conversation/ConversationSidebar'
|
||||||
|
|
||||||
interface SelfInfo {
|
export const Sidebar: React.FC<ConversationSidebarProps> = (props) => (
|
||||||
wxid: string
|
<ConversationSidebar {...props} />
|
||||||
nickname: string
|
)
|
||||||
avatar?: string
|
|
||||||
accountRoot: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
contacts: Contact[]
|
|
||||||
selectedContact: Contact | null
|
|
||||||
onSelectContact: (contact: Contact) => void
|
|
||||||
onSearch: (keyword: string) => void
|
|
||||||
onContentFilter: (keyword: string) => void
|
|
||||||
width: number
|
|
||||||
dateRange: string
|
|
||||||
onDateRangeChange: (range: string) => void
|
|
||||||
selfInfo: SelfInfo | null
|
|
||||||
dbReady: boolean
|
|
||||||
onOpenSettings: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({
|
|
||||||
contacts,
|
|
||||||
selectedContact,
|
|
||||||
onSelectContact,
|
|
||||||
onSearch,
|
|
||||||
onContentFilter,
|
|
||||||
width,
|
|
||||||
dateRange,
|
|
||||||
onDateRangeChange,
|
|
||||||
selfInfo,
|
|
||||||
dbReady,
|
|
||||||
onOpenSettings
|
|
||||||
}) => {
|
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
|
||||||
const [contentFilter, setContentFilter] = useState('')
|
|
||||||
const [isGroupsExpanded, setIsGroupsExpanded] = useState(true)
|
|
||||||
const [isContactsExpanded, setIsContactsExpanded] = useState(true)
|
|
||||||
|
|
||||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
|
||||||
const term = e.target.value
|
|
||||||
setSearchTerm(term)
|
|
||||||
onSearch(term)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContentFilterChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
|
||||||
const term = e.target.value
|
|
||||||
setContentFilter(term)
|
|
||||||
onContentFilter(term)
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = contacts.filter((c) => c.type === 'group')
|
|
||||||
const users = contacts.filter((c) => c.type === 'user')
|
|
||||||
|
|
||||||
const renderContactItem = (contact: Contact): React.ReactElement => (
|
|
||||||
<div
|
|
||||||
key={contact.md5}
|
|
||||||
className={`contact-item ${selectedContact?.md5 === contact.md5 ? 'active' : ''}`}
|
|
||||||
onClick={() => onSelectContact(contact)}
|
|
||||||
>
|
|
||||||
<div className="contact-avatar">
|
|
||||||
{contact.avatar ? (
|
|
||||||
<img
|
|
||||||
src={contact.avatar}
|
|
||||||
alt={contact.m_nsNickName}
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
(contact.m_nsNickName || contact.m_nsUsrName || '?').charAt(0)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="contact-info">
|
|
||||||
<div className="contact-name">{contact.m_nsNickName}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sidebar" style={{ width: width }}>
|
|
||||||
<div className="sidebar-header">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="search-input"
|
|
||||||
placeholder="搜索联系人"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
style={{ marginBottom: '8px' }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="search-input"
|
|
||||||
placeholder="过滤消息内容"
|
|
||||||
value={contentFilter}
|
|
||||||
onChange={handleContentFilterChange}
|
|
||||||
/>
|
|
||||||
<div className="date-range-selector">
|
|
||||||
{[
|
|
||||||
{ key: 'today', label: '当天' },
|
|
||||||
{ key: 'yesterday', label: '昨日' },
|
|
||||||
{ key: '7', label: '近7天' },
|
|
||||||
{ key: '30', label: '近30天' },
|
|
||||||
{ key: 'all', label: '所有' }
|
|
||||||
].map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
className={`range-btn ${dateRange === item.key ? 'active' : ''}`}
|
|
||||||
onClick={() => onDateRangeChange(item.key)}
|
|
||||||
title={item.label}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="contact-list">
|
|
||||||
{/* 群聊部分 */}
|
|
||||||
<div className="section-header" onClick={() => setIsGroupsExpanded(!isGroupsExpanded)}>
|
|
||||||
<span className="arrow">{isGroupsExpanded ? '▼' : '▶'}</span> 群聊 ({groups.length})
|
|
||||||
</div>
|
|
||||||
{isGroupsExpanded && groups.map(renderContactItem)}
|
|
||||||
{isGroupsExpanded && groups.length === 0 && <div className="section-empty">暂无群聊</div>}
|
|
||||||
|
|
||||||
{/* 联系人部分 */}
|
|
||||||
<div className="section-header" onClick={() => setIsContactsExpanded(!isContactsExpanded)}>
|
|
||||||
<span className="arrow">{isContactsExpanded ? '▼' : '▶'}</span> 联系人 ({users.length})
|
|
||||||
</div>
|
|
||||||
{isContactsExpanded && users.map(renderContactItem)}
|
|
||||||
{isContactsExpanded && users.length === 0 && (
|
|
||||||
<div className="section-empty">暂无联系人</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="sidebar-footer" onClick={onOpenSettings} title="设置">
|
|
||||||
<div className="sidebar-self-avatar">
|
|
||||||
{selfInfo?.avatar ? (
|
|
||||||
<img
|
|
||||||
src={selfInfo.avatar}
|
|
||||||
alt={selfInfo.nickname}
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
((selfInfo?.nickname || selfInfo?.wxid || '我').charAt(0))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="sidebar-self-info">
|
|
||||||
<div className="sidebar-self-nickname">
|
|
||||||
{dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '我' : '未连接'}
|
|
||||||
</div>
|
|
||||||
<div className="sidebar-self-wxid">
|
|
||||||
{dbReady && selfInfo ? selfInfo.wxid : '点击设置 →'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sidebar-self-arrow" aria-hidden>⚙</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export function VoicePlayer({
|
|||||||
return (
|
return (
|
||||||
<div className="voice-message voice-error" onClick={handlePlayPause}>
|
<div className="voice-message voice-error" onClick={handlePlayPause}>
|
||||||
<span className="voice-icon">▶</span>
|
<span className="voice-icon">▶</span>
|
||||||
<span className="voice-error-text">[语音]</span>
|
<span className="voice-error-text">当前版本暂不支持播放</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,31 +20,48 @@ export function AccountSummary({
|
|||||||
compact = false,
|
compact = false,
|
||||||
onClick
|
onClick
|
||||||
}: AccountSummaryProps): React.ReactElement {
|
}: AccountSummaryProps): React.ReactElement {
|
||||||
const displayName = dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
|
const displayName =
|
||||||
|
dbReady && selfInfo ? selfInfo.nickname || selfInfo.wxid || '当前账号' : '未连接'
|
||||||
const subtitle = dbReady && selfInfo ? selfInfo.wxid : '打开设置'
|
const subtitle = dbReady && selfInfo ? selfInfo.wxid : '打开设置'
|
||||||
|
const statusText = dbReady ? '数据库已连接' : '数据库未连接'
|
||||||
const initial = (displayName || '?').charAt(0)
|
const initial = (displayName || '?').charAt(0)
|
||||||
|
const title = `${displayName}\n${subtitle}`
|
||||||
|
const avatar = (
|
||||||
|
<span className="account-summary-avatar">
|
||||||
|
{selfInfo?.avatar ? (
|
||||||
|
<img src={selfInfo.avatar} alt={displayName} referrerPolicy="no-referrer" />
|
||||||
|
) : (
|
||||||
|
initial
|
||||||
|
)}
|
||||||
|
<span className={`account-summary-status ${dbReady ? 'ready' : ''}`} aria-hidden />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<button type="button" className="account-summary compact" onClick={onClick} title={title}>
|
||||||
|
{avatar}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="account-summary" title={title}>
|
||||||
type="button"
|
{avatar}
|
||||||
className={`account-summary ${compact ? 'compact' : ''}`}
|
<span className="account-summary-text">
|
||||||
onClick={onClick}
|
<span className="account-summary-name">{displayName}</span>
|
||||||
title={compact ? `${displayName}\n${subtitle}` : undefined}
|
<span className="account-summary-meta">{subtitle}</span>
|
||||||
>
|
<span className="account-summary-state">
|
||||||
<span className="account-summary-avatar">
|
<span className={`account-summary-state-dot ${dbReady ? 'ready' : ''}`} aria-hidden />
|
||||||
{selfInfo?.avatar ? (
|
{statusText}
|
||||||
<img src={selfInfo.avatar} alt={displayName} referrerPolicy="no-referrer" />
|
|
||||||
) : (
|
|
||||||
initial
|
|
||||||
)}
|
|
||||||
<span className={`account-summary-status ${dbReady ? 'ready' : ''}`} aria-hidden />
|
|
||||||
</span>
|
|
||||||
{!compact && (
|
|
||||||
<span className="account-summary-text">
|
|
||||||
<span className="account-summary-name">{displayName}</span>
|
|
||||||
<span className="account-summary-meta">{subtitle}</span>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
</span>
|
||||||
</button>
|
<button type="button" className="account-summary-settings" onClick={onClick} title="设置">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" />
|
||||||
|
<path d="M19.4 15a8.2 8.2 0 0 0 .1-1l2-1.5-2-3.5-2.4 1a7.5 7.5 0 0 0-1.7-1l-.3-2.5h-4l-.4 2.5a7.5 7.5 0 0 0-1.7 1l-2.3-1-2 3.5 2 1.5a8.2 8.2 0 0 0 0 2l-2 1.5 2 3.5 2.3-1a7.5 7.5 0 0 0 1.7 1l.4 2.5h4l.3-2.5a7.5 7.5 0 0 0 1.7-1l2.4 1 2-3.5-2.1-1.5Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Contact } from '../../../../shared/types'
|
||||||
|
import { ConversationContentSearch } from './ConversationContentSearch'
|
||||||
|
import { ExportMenu, ExportRange } from './ExportMenu'
|
||||||
|
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
||||||
|
|
||||||
|
interface ChatHeaderProps {
|
||||||
|
contact: Contact
|
||||||
|
isGroupChat: boolean
|
||||||
|
dateRangeLabel: string
|
||||||
|
loadedCount: number
|
||||||
|
filteredCount: number
|
||||||
|
contentFilter: string
|
||||||
|
isAiLoading: boolean
|
||||||
|
canExport: boolean
|
||||||
|
onContentFilterChange: (value: string) => void
|
||||||
|
onRefresh?: () => void
|
||||||
|
onRefreshData?: () => void
|
||||||
|
onExport: (range: ExportRange) => void
|
||||||
|
onOpenAiSettings: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatHeader({
|
||||||
|
contact,
|
||||||
|
isGroupChat,
|
||||||
|
dateRangeLabel,
|
||||||
|
loadedCount,
|
||||||
|
filteredCount,
|
||||||
|
contentFilter,
|
||||||
|
isAiLoading,
|
||||||
|
canExport,
|
||||||
|
onContentFilterChange,
|
||||||
|
onRefresh,
|
||||||
|
onRefreshData,
|
||||||
|
onExport,
|
||||||
|
onOpenAiSettings
|
||||||
|
}: ChatHeaderProps): React.ReactElement {
|
||||||
|
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false)
|
||||||
|
const moreRef = useRef<HTMLDivElement>(null)
|
||||||
|
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||||
|
const typeLabel = isGroupChat ? '群聊' : '联系人'
|
||||||
|
const visibleCount = contentFilter ? filteredCount : loadedCount
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!moreOpen) return
|
||||||
|
const handlePointerDown = (event: PointerEvent): void => {
|
||||||
|
if (!moreRef.current?.contains(event.target as Node)) setMoreOpen(false)
|
||||||
|
}
|
||||||
|
window.addEventListener('pointerdown', handlePointerDown)
|
||||||
|
return () => window.removeEventListener('pointerdown', handlePointerDown)
|
||||||
|
}, [moreOpen])
|
||||||
|
|
||||||
|
const handleCloseSearch = (): void => {
|
||||||
|
onContentFilterChange('')
|
||||||
|
setSearchOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-archive-header">
|
||||||
|
<div className="chat-title-block">
|
||||||
|
<div className="chat-title-avatar">
|
||||||
|
{contact.avatar ? (
|
||||||
|
<img src={contact.avatar} alt={displayName} referrerPolicy="no-referrer" />
|
||||||
|
) : (
|
||||||
|
displayName.charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="chat-title-text">
|
||||||
|
<h2>{displayName}</h2>
|
||||||
|
<div className="chat-title-meta">
|
||||||
|
<span>{typeLabel}</span>
|
||||||
|
<span>{dateRangeLabel}</span>
|
||||||
|
<span>{visibleCount} 条消息</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-header-actions">
|
||||||
|
{searchOpen ? (
|
||||||
|
<ConversationContentSearch
|
||||||
|
value={contentFilter}
|
||||||
|
resultCount={filteredCount}
|
||||||
|
onChange={onContentFilterChange}
|
||||||
|
onClose={handleCloseSearch}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-icon-button"
|
||||||
|
onClick={() => setSearchOpen(true)}
|
||||||
|
title="搜索当前聊天"
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
|
||||||
|
<RefreshIcon />
|
||||||
|
</button>
|
||||||
|
<ExportMenu disabled={!canExport} onExport={onExport} />
|
||||||
|
<div className="chat-menu" ref={moreRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-icon-button"
|
||||||
|
onClick={() => setMoreOpen((current) => !current)}
|
||||||
|
title="更多"
|
||||||
|
>
|
||||||
|
<MoreIcon />
|
||||||
|
</button>
|
||||||
|
{moreOpen && (
|
||||||
|
<div className="chat-dropdown-menu right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onRefreshData?.()
|
||||||
|
setMoreOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
刷新数据
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-ai-button"
|
||||||
|
onClick={onOpenAiSettings}
|
||||||
|
disabled={!isGroupChat || isAiLoading}
|
||||||
|
title={isGroupChat ? '生成 AI 日报' : 'AI 日报当前仅支持群聊'}
|
||||||
|
>
|
||||||
|
<AiIcon />
|
||||||
|
<span>{isAiLoading ? '生成中' : '生成 AI 日报'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface ChatStatusBarProps {
|
||||||
|
count: number
|
||||||
|
showAvatar: boolean
|
||||||
|
isAtLatest: boolean
|
||||||
|
onShowAvatarChange: (show: boolean) => void
|
||||||
|
onJumpToLatest: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatStatusBar({
|
||||||
|
count,
|
||||||
|
showAvatar,
|
||||||
|
isAtLatest,
|
||||||
|
onShowAvatarChange,
|
||||||
|
onJumpToLatest
|
||||||
|
}: ChatStatusBarProps): React.ReactElement {
|
||||||
|
const jumpDisabled = isAtLatest || count === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-status-bar">
|
||||||
|
<div>已显示当前范围 {count} 条消息</div>
|
||||||
|
<div className="chat-status-actions">
|
||||||
|
<label className="chat-avatar-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showAvatar}
|
||||||
|
onChange={(event) => onShowAvatarChange(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>显示头像</span>
|
||||||
|
</label>
|
||||||
|
<span className="chat-status-separator" aria-hidden />
|
||||||
|
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
|
||||||
|
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { CloseIcon, SearchIcon } from './icons'
|
||||||
|
|
||||||
|
interface ConversationContentSearchProps {
|
||||||
|
value: string
|
||||||
|
resultCount: number
|
||||||
|
onChange: (value: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationContentSearch({
|
||||||
|
value,
|
||||||
|
resultCount,
|
||||||
|
onChange,
|
||||||
|
onClose
|
||||||
|
}: ConversationContentSearchProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="chat-content-search">
|
||||||
|
<span className="chat-content-search-icon">
|
||||||
|
<SearchIcon />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
placeholder="搜索当前聊天内容"
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<span className="chat-content-search-count">
|
||||||
|
{value ? `${resultCount} 条结果` : '输入关键词'}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="chat-icon-button" onClick={onClose} title="关闭搜索">
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { CheckCircleIcon, HomeIcon } from './icons'
|
||||||
|
|
||||||
|
interface DataTrustBarProps {
|
||||||
|
messageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTrustBar({ messageCount }: DataTrustBarProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="data-trust-bar">
|
||||||
|
<div className="data-trust-left">
|
||||||
|
<HomeIcon />
|
||||||
|
<span>聊天记录仅在本机读取</span>
|
||||||
|
</div>
|
||||||
|
<div className="data-trust-right">
|
||||||
|
<span className="data-trust-item">
|
||||||
|
<CheckCircleIcon />
|
||||||
|
数据库已连接
|
||||||
|
</span>
|
||||||
|
<span className="data-trust-dot" aria-hidden />
|
||||||
|
<span>已加载 {messageCount} 条消息</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export function EmptyConversationState(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="chat-window empty">
|
||||||
|
<div className="empty-conversation-state">
|
||||||
|
<h2>选择一条消息</h2>
|
||||||
|
<p>从左侧选择群聊或联系人以浏览历史记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import { ArrowDownIcon, ExportIcon } from './icons'
|
||||||
|
|
||||||
|
export type ExportRange = number | 'all'
|
||||||
|
|
||||||
|
interface ExportMenuProps {
|
||||||
|
disabled: boolean
|
||||||
|
onExport: (range: ExportRange) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPORT_OPTIONS: { label: string; value: ExportRange }[] = [
|
||||||
|
{ label: '导出当前范围', value: 'all' },
|
||||||
|
{ label: '导出今天', value: 0 },
|
||||||
|
{ label: '导出昨日', value: 1 },
|
||||||
|
{ label: '导出近 7 天', value: 7 },
|
||||||
|
{ label: '导出近 30 天', value: 30 },
|
||||||
|
{ label: '导出全部', value: 'all' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ExportMenu({ disabled, onExport }: ExportMenuProps): React.ReactElement {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handlePointerDown = (event: PointerEvent): void => {
|
||||||
|
if (!menuRef.current?.contains(event.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
window.addEventListener('pointerdown', handlePointerDown)
|
||||||
|
return () => window.removeEventListener('pointerdown', handlePointerDown)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-menu" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-tool-button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
title={disabled ? '没有可导出的消息' : '导出聊天记录'}
|
||||||
|
>
|
||||||
|
<ExportIcon />
|
||||||
|
<span>导出</span>
|
||||||
|
<ArrowDownIcon />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="chat-dropdown-menu">
|
||||||
|
{EXPORT_OPTIONS.map((option, index) => (
|
||||||
|
<button
|
||||||
|
key={`${option.label}-${index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onExport(option.value)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Contact, Message } from '../../../../shared/types'
|
||||||
|
import { ImageBubble } from '../ImageBubble'
|
||||||
|
import { RichMessageBubble } from '../RichMessageBubble'
|
||||||
|
import { VoicePlayer } from '../VoicePlayer'
|
||||||
|
import { renderWechatEmojiText } from '../../utils/wechatEmojiText'
|
||||||
|
import { formatMessageTime } from './messageGrouping'
|
||||||
|
|
||||||
|
interface MessageBubbleProps {
|
||||||
|
message: Message
|
||||||
|
contact: Contact
|
||||||
|
isGroupChat: boolean
|
||||||
|
isMine: boolean
|
||||||
|
showAvatarSpace: boolean
|
||||||
|
onImageClick: (imageUrl: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息']
|
||||||
|
|
||||||
|
export function MessageBubble({
|
||||||
|
message,
|
||||||
|
contact,
|
||||||
|
isGroupChat,
|
||||||
|
isMine,
|
||||||
|
showAvatarSpace,
|
||||||
|
onImageClick
|
||||||
|
}: MessageBubbleProps): React.ReactElement {
|
||||||
|
const isVoice = message.type === '语音'
|
||||||
|
const isImage = message.type === '图片'
|
||||||
|
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
||||||
|
const hoverTime = formatMessageTime(message)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'}`}>
|
||||||
|
<div
|
||||||
|
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
||||||
|
isImage ? 'image-message-bubble' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isVoice && message.sessionId ? (
|
||||||
|
<VoicePlayer
|
||||||
|
sessionId={message.sessionId}
|
||||||
|
localId={message.localId || 0}
|
||||||
|
createTime={message.createTime || 0}
|
||||||
|
/>
|
||||||
|
) : isImage && message.contentData && message.contentData.type === 'image' ? (
|
||||||
|
<ImageBubble
|
||||||
|
imageMd5={message.contentData.md5}
|
||||||
|
imageDatName={message.contentData.datName}
|
||||||
|
sessionId={message.sessionId}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
) : isRichMedia && message.contentData ? (
|
||||||
|
<RichMessageBubble contentData={message.contentData} />
|
||||||
|
) : (
|
||||||
|
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="message-hover-time">{hoverTime}</span>
|
||||||
|
{!isGroupChat && !isMine && contact.m_nsNickName && (
|
||||||
|
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Contact } from '../../../../shared/types'
|
||||||
|
import { MessageBubble } from './MessageBubble'
|
||||||
|
import { MessageGroupModel } from './messageGrouping'
|
||||||
|
|
||||||
|
interface MessageGroupProps {
|
||||||
|
group: MessageGroupModel
|
||||||
|
contact: Contact
|
||||||
|
isGroupChat: boolean
|
||||||
|
showAvatar: boolean
|
||||||
|
onImageClick: (imageUrl: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageGroup({
|
||||||
|
group,
|
||||||
|
contact,
|
||||||
|
isGroupChat,
|
||||||
|
showAvatar,
|
||||||
|
onImageClick
|
||||||
|
}: MessageGroupProps): React.ReactElement {
|
||||||
|
const firstMessage = group.messages[0]
|
||||||
|
|
||||||
|
if (group.isSystem) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{group.timeLabel && <div className="chat-time-separator">{group.timeLabel}</div>}
|
||||||
|
{group.messages.map((message) => (
|
||||||
|
<div key={message.id} className="wechat-system-message-row">
|
||||||
|
<div className="wechat-system-message">{message.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMine = group.isMine
|
||||||
|
const displayName = isMine
|
||||||
|
? '我'
|
||||||
|
: isGroupChat
|
||||||
|
? firstMessage.name || firstMessage.from
|
||||||
|
: contact.m_nsNickName
|
||||||
|
const avatarSrc = isMine ? firstMessage.img : firstMessage.img || contact.avatar
|
||||||
|
const shouldShowAvatar = showAvatar
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{group.timeLabel && <div className="chat-time-separator">{group.timeLabel}</div>}
|
||||||
|
<div className={`wechat-message-row ${isMine ? 'mine' : 'other'} grouped`}>
|
||||||
|
{!isMine && shouldShowAvatar && (
|
||||||
|
<div className="message-avatar">
|
||||||
|
{avatarSrc ? (
|
||||||
|
<img src={avatarSrc} alt={displayName} referrerPolicy="no-referrer" />
|
||||||
|
) : (
|
||||||
|
(displayName || '?').charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
||||||
|
<div className="message-stack">
|
||||||
|
{!isMine && isGroupChat && shouldShowAvatar && (
|
||||||
|
<div className="message-sender-name">{displayName}</div>
|
||||||
|
)}
|
||||||
|
{group.messages.map((message, index) => (
|
||||||
|
<MessageBubble
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
contact={contact}
|
||||||
|
isGroupChat={isGroupChat}
|
||||||
|
isMine={isMine}
|
||||||
|
showAvatarSpace={index === 0}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isMine && shouldShowAvatar && (
|
||||||
|
<div className="message-avatar mine-avatar">
|
||||||
|
{avatarSrc ? <img src={avatarSrc} alt="我" referrerPolicy="no-referrer" /> : '我'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Contact, Message } from '../../../../shared/types'
|
||||||
|
import { MessageGroup } from './MessageGroup'
|
||||||
|
import { buildMessageGroups } from './messageGrouping'
|
||||||
|
|
||||||
|
interface MessageListProps {
|
||||||
|
contact: Contact
|
||||||
|
messages: Message[]
|
||||||
|
hiddenMessageCount: number
|
||||||
|
isLoadingMessages?: boolean
|
||||||
|
isGroupChat: boolean
|
||||||
|
showAvatar: boolean
|
||||||
|
listRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
bottomRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
||||||
|
onImageClick: (imageUrl: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({
|
||||||
|
contact,
|
||||||
|
messages,
|
||||||
|
hiddenMessageCount,
|
||||||
|
isLoadingMessages,
|
||||||
|
isGroupChat,
|
||||||
|
showAvatar,
|
||||||
|
listRef,
|
||||||
|
bottomRef,
|
||||||
|
onScroll,
|
||||||
|
onImageClick
|
||||||
|
}: MessageListProps): React.ReactElement {
|
||||||
|
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}>
|
||||||
|
{isLoadingMessages && <div className="message-loading-pill">正在加载聊天记录...</div>}
|
||||||
|
{hiddenMessageCount > 0 && (
|
||||||
|
<div className="wechat-system-message-row">
|
||||||
|
<div className="wechat-system-message">
|
||||||
|
已隐藏较早的 {hiddenMessageCount} 条消息,当前显示最新 {messages.length} 条
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{groups.map((group) => (
|
||||||
|
<MessageGroup
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
contact={contact}
|
||||||
|
isGroupChat={isGroupChat}
|
||||||
|
showAvatar={showAvatar}
|
||||||
|
onImageClick={onImageClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchIcon({ className }: IconProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<circle cx="11" cy="11" r="6.5" />
|
||||||
|
<path d="m16 16 4 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshIcon({ className }: IconProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M20 12a8 8 0 0 1-13.7 5.7" />
|
||||||
|
<path d="M4 12A8 8 0 0 1 17.7 6.3" />
|
||||||
|
<path d="M17.5 3.5v3h-3" />
|
||||||
|
<path d="M6.5 20.5v-3h3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportIcon({ className }: IconProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M12 3v11" />
|
||||||
|
<path d="m8 10 4 4 4-4" />
|
||||||
|
<path d="M5 17v2a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoreIcon({ className }: IconProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<circle cx="5" cy="12" r="1.4" />
|
||||||
|
<circle cx="12" cy="12" r="1.4" />
|
||||||
|
<circle cx="19" cy="12" r="1.4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiIcon({ className }: IconProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M12 3l1.5 5 5 1.5-5 1.5-1.5 5-1.5-5-5-1.5 5-1.5L12 3Z" />
|
||||||
|
<path d="M18 15l.8 2.4 2.2.6-2.2.7L18 22l-.8-2.3-2.2-.7 2.2-.6L18 15Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloseIcon({ className }: IconProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M6 6l12 12" />
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArrowDownIcon({ className }: IconProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckCircleIcon({ className }: IconProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
<path d="m8.5 12.2 2.2 2.2 4.8-5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeIcon({ className }: IconProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M4 11.5 12 5l8 6.5" />
|
||||||
|
<path d="M6.5 10.5V19h11v-8.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Message } from '../../../../shared/types'
|
||||||
|
|
||||||
|
const GROUP_INTERVAL_SECONDS = 5 * 60
|
||||||
|
const TIME_SEPARATOR_INTERVAL_SECONDS = 10 * 60
|
||||||
|
|
||||||
|
export interface MessageGroupModel {
|
||||||
|
id: string
|
||||||
|
messages: Message[]
|
||||||
|
isSystem: boolean
|
||||||
|
isMine: boolean
|
||||||
|
timeLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMessageTime = (message: Message): number | null => {
|
||||||
|
if (typeof message.createTime === 'number' && message.createTime > 0) return message.createTime
|
||||||
|
const parsed = Date.parse(message.datetime)
|
||||||
|
if (Number.isNaN(parsed)) return null
|
||||||
|
return Math.floor(parsed / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSenderKey = (message: Message): string =>
|
||||||
|
String(message.senderId || message.name || message.from || '').trim()
|
||||||
|
|
||||||
|
const formatClock = (date: Date): string =>
|
||||||
|
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
export const isSystemMessage = (message: Message): boolean =>
|
||||||
|
message.from === 'system' || message.type === '系统消息'
|
||||||
|
|
||||||
|
export const isMineMessage = (message: Message): boolean => message.from === 'assistant'
|
||||||
|
|
||||||
|
export const formatMessageTime = (message: Message): string => {
|
||||||
|
const timestamp = getMessageTime(message)
|
||||||
|
if (!timestamp) return message.datetime
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
const now = new Date()
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||||
|
const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
|
||||||
|
const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86400000)
|
||||||
|
const clock = formatClock(date)
|
||||||
|
|
||||||
|
if (dayDiff === 0) return `今天 ${clock}`
|
||||||
|
if (dayDiff === 1) return `昨天 ${clock}`
|
||||||
|
if (date.getFullYear() === now.getFullYear()) {
|
||||||
|
return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||||
|
}
|
||||||
|
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildMessageGroups = (messages: Message[]): MessageGroupModel[] => {
|
||||||
|
const groups: MessageGroupModel[] = []
|
||||||
|
let previousGroupStartTime: number | null = null
|
||||||
|
|
||||||
|
messages.forEach((message) => {
|
||||||
|
const isSystem = isSystemMessage(message)
|
||||||
|
const isMine = isMineMessage(message)
|
||||||
|
const timestamp = getMessageTime(message)
|
||||||
|
const previousGroup = groups[groups.length - 1]
|
||||||
|
const previousMessage = previousGroup?.messages[previousGroup.messages.length - 1]
|
||||||
|
const previousTimestamp = previousMessage ? getMessageTime(previousMessage) : null
|
||||||
|
const canJoinPrevious =
|
||||||
|
previousGroup &&
|
||||||
|
!isSystem &&
|
||||||
|
!previousGroup.isSystem &&
|
||||||
|
previousGroup.isMine === isMine &&
|
||||||
|
getSenderKey(previousMessage) === getSenderKey(message) &&
|
||||||
|
timestamp !== null &&
|
||||||
|
previousTimestamp !== null &&
|
||||||
|
timestamp - previousTimestamp >= 0 &&
|
||||||
|
timestamp - previousTimestamp <= GROUP_INTERVAL_SECONDS
|
||||||
|
|
||||||
|
if (canJoinPrevious) {
|
||||||
|
previousGroup.messages.push(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsTimeLabel =
|
||||||
|
timestamp !== null &&
|
||||||
|
(previousGroupStartTime === null ||
|
||||||
|
Math.abs(timestamp - previousGroupStartTime) >= TIME_SEPARATOR_INTERVAL_SECONDS)
|
||||||
|
const group: MessageGroupModel = {
|
||||||
|
id: message.id || `${groups.length}-${message.datetime}`,
|
||||||
|
messages: [message],
|
||||||
|
isSystem,
|
||||||
|
isMine,
|
||||||
|
timeLabel: needsTimeLabel ? formatMessageTime(message) : undefined
|
||||||
|
}
|
||||||
|
groups.push(group)
|
||||||
|
if (timestamp !== null && needsTimeLabel) previousGroupStartTime = timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Contact } from '../../../../shared/types'
|
||||||
|
|
||||||
|
interface ConversationItemProps {
|
||||||
|
contact: Contact
|
||||||
|
active: boolean
|
||||||
|
onSelect: (contact: Contact) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationItem({
|
||||||
|
contact,
|
||||||
|
active,
|
||||||
|
onSelect
|
||||||
|
}: ConversationItemProps): React.ReactElement {
|
||||||
|
const nickname = contact.m_nsNickName?.trim()
|
||||||
|
const wxid = contact.m_nsUsrName
|
||||||
|
const displayName = nickname || wxid || '未命名会话'
|
||||||
|
const initial = (displayName || wxid || '?').charAt(0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`conversation-item ${active ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelect(contact)}
|
||||||
|
title={wxid && wxid !== displayName ? wxid : displayName}
|
||||||
|
>
|
||||||
|
<span className="conversation-item-active-mark" aria-hidden />
|
||||||
|
<span className="conversation-item-avatar">
|
||||||
|
{contact.avatar ? (
|
||||||
|
<img
|
||||||
|
src={contact.avatar}
|
||||||
|
alt={displayName}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initial
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="conversation-item-body">
|
||||||
|
<span className="conversation-item-name">{displayName}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface ConversationSearchProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchIcon(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<circle cx="11" cy="11" r="6.5" />
|
||||||
|
<path d="m16 16 4 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationSearch({
|
||||||
|
value,
|
||||||
|
onChange
|
||||||
|
}: ConversationSearchProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<label className="conversation-search">
|
||||||
|
<span className="conversation-search-icon">
|
||||||
|
<SearchIcon />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
placeholder="搜索群聊、联系人或 wxid"
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Contact } from '../../../../shared/types'
|
||||||
|
import { ConversationItem } from './ConversationItem'
|
||||||
|
|
||||||
|
interface ConversationSectionProps {
|
||||||
|
title: string
|
||||||
|
contacts: Contact[]
|
||||||
|
expanded: boolean
|
||||||
|
selectedContact: Contact | null
|
||||||
|
emptyText: string
|
||||||
|
onToggle: () => void
|
||||||
|
onSelectContact: (contact: Contact) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronIcon({ expanded }: { expanded: boolean }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
|
<path d={expanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationSection({
|
||||||
|
title,
|
||||||
|
contacts,
|
||||||
|
expanded,
|
||||||
|
selectedContact,
|
||||||
|
emptyText,
|
||||||
|
onToggle,
|
||||||
|
onSelectContact
|
||||||
|
}: ConversationSectionProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<section className="conversation-section">
|
||||||
|
<button type="button" className="conversation-section-header" onClick={onToggle}>
|
||||||
|
<span className="conversation-section-chevron">
|
||||||
|
<ChevronIcon expanded={expanded} />
|
||||||
|
</span>
|
||||||
|
<span className="conversation-section-title">
|
||||||
|
{title}({contacts.length})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="conversation-section-list">
|
||||||
|
{contacts.length > 0 ? (
|
||||||
|
contacts.map((contact) => (
|
||||||
|
<ConversationItem
|
||||||
|
key={contact.md5}
|
||||||
|
contact={contact}
|
||||||
|
active={selectedContact?.md5 === contact.md5}
|
||||||
|
onSelect={onSelectContact}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="conversation-section-empty">{emptyText}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Contact } from '../../../../shared/types'
|
||||||
|
import { AccountSummary } from '../account/AccountSummary'
|
||||||
|
import { ConversationSection } from './ConversationSection'
|
||||||
|
import { ConversationSidebarHeader } from './ConversationSidebarHeader'
|
||||||
|
|
||||||
|
interface SelfInfo {
|
||||||
|
wxid: string
|
||||||
|
nickname: string
|
||||||
|
avatar?: string
|
||||||
|
accountRoot: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationSidebarProps {
|
||||||
|
contacts: Contact[]
|
||||||
|
selectedContact: Contact | null
|
||||||
|
onSelectContact: (contact: Contact) => void
|
||||||
|
onSearch: (keyword: string) => void
|
||||||
|
onContentFilter: (keyword: string) => void
|
||||||
|
width: number
|
||||||
|
dateRange: string
|
||||||
|
onDateRangeChange: (range: string) => void
|
||||||
|
selfInfo: SelfInfo | null
|
||||||
|
dbReady: boolean
|
||||||
|
onOpenSettings: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationSidebar({
|
||||||
|
contacts,
|
||||||
|
selectedContact,
|
||||||
|
onSelectContact,
|
||||||
|
onSearch,
|
||||||
|
width,
|
||||||
|
dateRange,
|
||||||
|
onDateRangeChange,
|
||||||
|
selfInfo,
|
||||||
|
dbReady,
|
||||||
|
onOpenSettings
|
||||||
|
}: ConversationSidebarProps): React.ReactElement {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [expandedSection, setExpandedSection] = useState<'groups' | 'contacts'>('groups')
|
||||||
|
|
||||||
|
const groups = contacts.filter((contact) => contact.type === 'group')
|
||||||
|
const users = contacts.filter((contact) => contact.type === 'user')
|
||||||
|
|
||||||
|
const handleSearchChange = (term: string): void => {
|
||||||
|
setSearchTerm(term)
|
||||||
|
onSearch(term)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="conversation-sidebar" style={{ width }}>
|
||||||
|
<ConversationSidebarHeader
|
||||||
|
totalCount={contacts.length}
|
||||||
|
searchValue={searchTerm}
|
||||||
|
dateRange={dateRange}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
onDateRangeChange={onDateRangeChange}
|
||||||
|
/>
|
||||||
|
<div className="conversation-list" aria-label="会话列表">
|
||||||
|
<ConversationSection
|
||||||
|
title="群聊"
|
||||||
|
contacts={groups}
|
||||||
|
expanded={expandedSection === 'groups'}
|
||||||
|
selectedContact={selectedContact}
|
||||||
|
emptyText="暂无群聊"
|
||||||
|
onToggle={() => setExpandedSection('groups')}
|
||||||
|
onSelectContact={onSelectContact}
|
||||||
|
/>
|
||||||
|
<ConversationSection
|
||||||
|
title="联系人"
|
||||||
|
contacts={users}
|
||||||
|
expanded={expandedSection === 'contacts'}
|
||||||
|
selectedContact={selectedContact}
|
||||||
|
emptyText="暂无联系人"
|
||||||
|
onToggle={() => setExpandedSection('contacts')}
|
||||||
|
onSelectContact={onSelectContact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="conversation-sidebar-account">
|
||||||
|
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ConversationSearch } from './ConversationSearch'
|
||||||
|
import { DateRangeSelector } from './DateRangeSelector'
|
||||||
|
|
||||||
|
interface ConversationSidebarHeaderProps {
|
||||||
|
totalCount: number
|
||||||
|
searchValue: string
|
||||||
|
dateRange: string
|
||||||
|
onSearchChange: (value: string) => void
|
||||||
|
onDateRangeChange: (range: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversationSidebarHeader({
|
||||||
|
totalCount,
|
||||||
|
searchValue,
|
||||||
|
dateRange,
|
||||||
|
onSearchChange,
|
||||||
|
onDateRangeChange
|
||||||
|
}: ConversationSidebarHeaderProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="conversation-sidebar-header">
|
||||||
|
<div className="conversation-sidebar-title-row">
|
||||||
|
<h2>聊天档案</h2>
|
||||||
|
<span>{totalCount} 个会话</span>
|
||||||
|
</div>
|
||||||
|
<ConversationSearch value={searchValue} onChange={onSearchChange} />
|
||||||
|
<DateRangeSelector value={dateRange} onChange={onDateRangeChange} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface DateRangeSelectorProps {
|
||||||
|
value: string
|
||||||
|
onChange: (range: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_RANGE_OPTIONS = [
|
||||||
|
{ key: 'today', label: '今天' },
|
||||||
|
{ key: 'yesterday', label: '昨日' },
|
||||||
|
{ key: '7', label: '7 天' },
|
||||||
|
{ key: '30', label: '30 天' },
|
||||||
|
{ key: 'all', label: '全部' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="conversation-date-range" aria-label="时间范围">
|
||||||
|
{DATE_RANGE_OPTIONS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
className={`conversation-date-range-button ${value === item.key ? 'active' : ''}`}
|
||||||
|
onClick={() => onChange(item.key)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user