完成 UI-02 和 UI-03 聊天档案界面

This commit is contained in:
电摇小子
2026-07-14 11:07:35 +08:00
committed by 电摇小子
parent 7e217f7034
commit 7880e874b2
25 changed files with 2048 additions and 422 deletions
+20 -5
View File
@@ -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(
(mouseDownEvent: React.MouseEvent<HTMLDivElement>) => {
sidebarResizeStartRef.current = {
x: mouseDownEvent.clientX,
width: sidebarWidth
}
setIsResizing(true) 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
+99 -143
View File
@@ -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>
)}
{renderedMessages.map((msg) => {
const isMine = msg.from === 'assistant'
const isSystem = msg.from === 'system' || msg.type === '系统消息'
const displayName = isMine
? '我'
: isGroupChat
? msg.name || msg.from
: contact.m_nsNickName
const avatarSrc = isMine ? msg.img : msg.img || contact.avatar
const isVoice = msg.type === '语音'
const isImage = msg.type === '图片'
const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息'].includes(
msg.type
)
if (isSystem) {
return (
<div key={msg.id} className="wechat-system-message-row">
<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' ? ( <DataTrustBar messageCount={messages.length} />
<ImageBubble <MessageList
imageMd5={msg.contentData.md5} contact={contact}
imageDatName={msg.contentData.datName} messages={renderedMessages}
sessionId={msg.sessionId} hiddenMessageCount={hiddenMessageCount}
isLoadingMessages={isLoadingMessages}
isGroupChat={isGroupChat}
showAvatar={showAvatar}
listRef={messageListRef}
bottomRef={messagesEndRef}
onScroll={handleMessageListScroll}
onImageClick={openImagePreview} onImageClick={openImagePreview}
/> />
) : isRichMedia && msg.contentData ? ( <ChatStatusBar
<RichMessageBubble contentData={msg.contentData} /> count={renderedMessages.length}
) : ( showAvatar={showAvatar}
<div className="message-text">{renderWechatEmojiText(msg.content)}</div> isAtLatest={isAtLatest}
)} onShowAvatarChange={setShowAvatar}
</div> onJumpToLatest={scrollToBottom}
<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
+7 -5
View File
@@ -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)}` : ''}
+5 -162
View File
@@ -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>
)
}
+1 -1
View File
@@ -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,17 +20,13 @@ 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}`
return ( const avatar = (
<button
type="button"
className={`account-summary ${compact ? 'compact' : ''}`}
onClick={onClick}
title={compact ? `${displayName}\n${subtitle}` : undefined}
>
<span className="account-summary-avatar"> <span className="account-summary-avatar">
{selfInfo?.avatar ? ( {selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt={displayName} referrerPolicy="no-referrer" /> <img src={selfInfo.avatar} alt={displayName} referrerPolicy="no-referrer" />
@@ -39,12 +35,33 @@ export function AccountSummary({
)} )}
<span className={`account-summary-status ${dbReady ? 'ready' : ''}`} aria-hidden /> <span className={`account-summary-status ${dbReady ? 'ready' : ''}`} aria-hidden />
</span> </span>
{!compact && ( )
if (compact) {
return (
<button type="button" className="account-summary compact" onClick={onClick} title={title}>
{avatar}
</button>
)
}
return (
<div className="account-summary" title={title}>
{avatar}
<span className="account-summary-text"> <span className="account-summary-text">
<span className="account-summary-name">{displayName}</span> <span className="account-summary-name">{displayName}</span>
<span className="account-summary-meta">{subtitle}</span> <span className="account-summary-meta">{subtitle}</span>
<span className="account-summary-state">
<span className={`account-summary-state-dot ${dbReady ? 'ready' : ''}`} aria-hidden />
{statusText}
</span> </span>
)} </span>
<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> </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>
)
}