mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat: 实现朋友圈获取; 实现聊天页面跳转到指定日期
This commit is contained in:
12
src/App.tsx
12
src/App.tsx
@@ -16,6 +16,7 @@ import DataManagementPage from './pages/DataManagementPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ExportPage from './pages/ExportPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
import SnsPage from './pages/SnsPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||
@@ -205,11 +206,11 @@ function App() {
|
||||
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
||||
// 其他错误可能需要重新配置
|
||||
const errorMsg = result.error || ''
|
||||
if (errorMsg.includes('Visual C++') ||
|
||||
errorMsg.includes('DLL') ||
|
||||
errorMsg.includes('Worker') ||
|
||||
errorMsg.includes('126') ||
|
||||
errorMsg.includes('模块')) {
|
||||
if (errorMsg.includes('Visual C++') ||
|
||||
errorMsg.includes('DLL') ||
|
||||
errorMsg.includes('Worker') ||
|
||||
errorMsg.includes('126') ||
|
||||
errorMsg.includes('模块')) {
|
||||
console.warn('检测到可能的运行时依赖问题:', errorMsg)
|
||||
// 不清除配置,让用户安装 VC++ 后重试
|
||||
}
|
||||
@@ -336,6 +337,7 @@ function App() {
|
||||
<Route path="/data-management" element={<DataManagementPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
</Routes>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
|
||||
238
src/components/JumpToDateDialog.scss
Normal file
238
src/components/JumpToDateDialog.scss
Normal file
@@ -0,0 +1,238 @@
|
||||
.jump-date-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.jump-date-modal {
|
||||
background: var(--card-bg);
|
||||
width: 340px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.jump-date-header {
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.title-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-view {
|
||||
padding: 20px;
|
||||
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.current-month {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
.weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
margin-bottom: 8px;
|
||||
|
||||
.weekday {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
|
||||
.day-cell {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.empty {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:not(.empty):hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.today:not(.selected) {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
background: var(--primary-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 20px 16px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modalSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
156
src/components/JumpToDateDialog.tsx
Normal file
156
src/components/JumpToDateDialog.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react'
|
||||
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'
|
||||
import './JumpToDateDialog.scss'
|
||||
|
||||
interface JumpToDateDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSelect: (date: Date) => void
|
||||
currentDate?: Date
|
||||
}
|
||||
|
||||
const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
currentDate = new Date()
|
||||
}) => {
|
||||
const [calendarDate, setCalendarDate] = useState(new Date(currentDate))
|
||||
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
const getFirstDayOfMonth = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
return new Date(year, month, 1).getDay()
|
||||
}
|
||||
|
||||
const generateCalendar = () => {
|
||||
const daysInMonth = getDaysInMonth(calendarDate)
|
||||
const firstDay = getFirstDayOfMonth(calendarDate)
|
||||
const days: (number | null)[] = []
|
||||
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(null)
|
||||
}
|
||||
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i)
|
||||
}
|
||||
|
||||
return days
|
||||
}
|
||||
|
||||
const handleDateClick = (day: number) => {
|
||||
const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||
setSelectedDate(newDate)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSelect(selectedDate)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const isToday = (day: number) => {
|
||||
const today = new Date()
|
||||
return day === today.getDate() &&
|
||||
calendarDate.getMonth() === today.getMonth() &&
|
||||
calendarDate.getFullYear() === today.getFullYear()
|
||||
}
|
||||
|
||||
const isSelected = (day: number) => {
|
||||
return day === selectedDate.getDate() &&
|
||||
calendarDate.getMonth() === selectedDate.getMonth() &&
|
||||
calendarDate.getFullYear() === selectedDate.getFullYear()
|
||||
}
|
||||
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
|
||||
return (
|
||||
<div className="jump-date-overlay" onClick={onClose}>
|
||||
<div className="jump-date-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="jump-date-header">
|
||||
<div className="title-area">
|
||||
<CalendarIcon size={18} />
|
||||
<h3>跳转到日期</h3>
|
||||
</div>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="calendar-view">
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="current-month">
|
||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||
</span>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="calendar-grid">
|
||||
<div className="weekdays">
|
||||
{weekdays.map(d => <div key={d} className="weekday">{d}</div>)}
|
||||
</div>
|
||||
<div className="days">
|
||||
{days.map((day, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`day-cell ${day === null ? 'empty' : ''} ${day !== null && isSelected(day) ? 'selected' : ''} ${day !== null && isToday(day) ? 'today' : ''}`}
|
||||
onClick={() => day !== null && handleDateClick(day)}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-options">
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
}}>今天</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 7)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
}}>一周前</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
}}>一月前</button>
|
||||
</div>
|
||||
|
||||
<div className="dialog-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>取消</button>
|
||||
<button className="confirm-btn" onClick={handleConfirm}>跳转</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default JumpToDateDialog
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture } from 'lucide-react'
|
||||
import './Sidebar.scss'
|
||||
|
||||
function Sidebar() {
|
||||
@@ -34,6 +34,16 @@ function Sidebar() {
|
||||
<span className="nav-label">聊天</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 朋友圈 */}
|
||||
<NavLink
|
||||
to="/sns"
|
||||
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
||||
title={collapsed ? '朋友圈' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Aperture size={20} /></span>
|
||||
<span className="nav-label">朋友圈</span>
|
||||
</NavLink>
|
||||
|
||||
|
||||
|
||||
{/* 私聊分析 */}
|
||||
|
||||
@@ -489,8 +489,21 @@
|
||||
}
|
||||
|
||||
.load-more-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
|
||||
&.later {
|
||||
padding: 24px 0 12px;
|
||||
}
|
||||
|
||||
svg {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-chat {
|
||||
@@ -1660,7 +1673,7 @@
|
||||
max-width: 100%;
|
||||
min-width: 0; // 允许收缩
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
|
||||
// 让气泡宽度由内容决定,而不是被父容器撑开
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getEmojiPath } from 'wechat-emojis'
|
||||
import { ImagePreview } from '../components/ImagePreview'
|
||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
|
||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||
import * as configService from '../services/config'
|
||||
import './ChatPage.scss'
|
||||
|
||||
@@ -132,15 +133,25 @@ function ChatPage(_props: ChatPageProps) {
|
||||
setLoadingMessages,
|
||||
setLoadingMore,
|
||||
setHasMoreMessages,
|
||||
hasMoreLater,
|
||||
setHasMoreLater,
|
||||
setSearchKeyword
|
||||
} = useChatStore()
|
||||
|
||||
const messageListRef = useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const getMessageKey = useCallback((msg: Message): string => {
|
||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||
}, [])
|
||||
const initialRevealTimerRef = useRef<number | null>(null)
|
||||
const sessionListRef = useRef<HTMLDivElement>(null)
|
||||
const [currentOffset, setCurrentOffset] = useState(0)
|
||||
const [jumpStartTime, setJumpStartTime] = useState(0)
|
||||
const [jumpEndTime, setJumpEndTime] = useState(0)
|
||||
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
||||
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
|
||||
const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
@@ -477,6 +488,9 @@ function ChatPage(_props: ChatPageProps) {
|
||||
|
||||
// 刷新会话列表
|
||||
const handleRefresh = async () => {
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(0)
|
||||
setHasMoreLater(false)
|
||||
await loadSessions({ silent: true })
|
||||
}
|
||||
|
||||
@@ -484,6 +498,9 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const [isRefreshingMessages, setIsRefreshingMessages] = useState(false)
|
||||
const handleRefreshMessages = async () => {
|
||||
if (!currentSessionId || isRefreshingMessages) return
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(0)
|
||||
setHasMoreLater(false)
|
||||
setIsRefreshingMessages(true)
|
||||
try {
|
||||
// 获取最新消息并增量添加
|
||||
@@ -518,7 +535,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
|
||||
// 加载消息
|
||||
const loadMessages = async (sessionId: string, offset = 0) => {
|
||||
const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => {
|
||||
const listEl = messageListRef.current
|
||||
const session = sessionMapRef.current.get(sessionId)
|
||||
const unreadCount = session?.unreadCount ?? 0
|
||||
@@ -535,7 +552,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit)
|
||||
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime)
|
||||
if (result.success && result.messages) {
|
||||
if (offset === 0) {
|
||||
setMessages(result.messages)
|
||||
@@ -601,6 +618,14 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
}
|
||||
setHasMoreMessages(result.hasMore ?? false)
|
||||
// 如果是按 endTime 跳转加载,且结果刚好满批,可能后面(更晚)还有消息
|
||||
if (offset === 0) {
|
||||
if (endTime > 0) {
|
||||
setHasMoreLater(true)
|
||||
} else {
|
||||
setHasMoreLater(false)
|
||||
}
|
||||
}
|
||||
setCurrentOffset(offset + result.messages.length)
|
||||
} else if (!result.success) {
|
||||
setConnectionError(result.error || '加载消息失败')
|
||||
@@ -616,12 +641,41 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更晚的消息
|
||||
const loadLaterMessages = useCallback(async () => {
|
||||
if (!currentSessionId || isLoadingMore || isLoadingMessages || messages.length === 0) return
|
||||
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const lastMsg = messages[messages.length - 1]
|
||||
// 从最后一条消息的时间开始往后找
|
||||
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true)
|
||||
|
||||
if (result.success && result.messages) {
|
||||
// 过滤掉已经在列表中的重复消息
|
||||
const existingKeys = messageKeySetRef.current
|
||||
const newMsgs = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
|
||||
|
||||
if (newMsgs.length > 0) {
|
||||
appendMessages(newMsgs, false)
|
||||
}
|
||||
setHasMoreLater(result.hasMore ?? false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载后续消息失败:', e)
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [currentSessionId, isLoadingMore, isLoadingMessages, messages, getMessageKey, appendMessages, setHasMoreLater, setLoadingMore])
|
||||
|
||||
// 选择会话
|
||||
const handleSelectSession = (session: ChatSession) => {
|
||||
if (session.username === currentSessionId) return
|
||||
setCurrentSession(session.username)
|
||||
setCurrentOffset(0)
|
||||
loadMessages(session.username, 0)
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(0)
|
||||
loadMessages(session.username, 0, 0, 0)
|
||||
// 重置详情面板
|
||||
setSessionDetail(null)
|
||||
if (showDetailPanel) {
|
||||
@@ -678,16 +732,21 @@ function ChatPage(_props: ChatPageProps) {
|
||||
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
|
||||
const threshold = clientHeight * 0.3
|
||||
if (scrollTop < threshold) {
|
||||
loadMessages(currentSessionId, currentOffset)
|
||||
loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime)
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载更晚的消息
|
||||
if (!isLoadingMore && !isLoadingMessages && hasMoreLater && currentSessionId) {
|
||||
const threshold = clientHeight * 0.3
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
if (distanceFromBottom < threshold) {
|
||||
loadLaterMessages()
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset, loadMessages])
|
||||
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, hasMoreLater, currentSessionId, currentOffset, jumpStartTime, jumpEndTime, loadMessages, loadLaterMessages])
|
||||
|
||||
const getMessageKey = useCallback((msg: Message): string => {
|
||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||
}, [])
|
||||
|
||||
const isSameSession = useCallback((prev: ChatSession, next: ChatSession): boolean => {
|
||||
return (
|
||||
@@ -1102,6 +1161,25 @@ function ChatPage(_props: ChatPageProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="icon-btn jump-to-time-btn"
|
||||
onClick={() => setShowJumpDialog(true)}
|
||||
title="跳转到指定时间"
|
||||
>
|
||||
<Calendar size={18} />
|
||||
</button>
|
||||
<JumpToDateDialog
|
||||
isOpen={showJumpDialog}
|
||||
onClose={() => setShowJumpDialog(false)}
|
||||
onSelect={(date) => {
|
||||
if (!currentSessionId) return
|
||||
const end = Math.floor(date.setHours(23, 59, 59, 999) / 1000)
|
||||
setCurrentOffset(0)
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(end)
|
||||
loadMessages(currentSessionId, 0, 0, end)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="icon-btn refresh-messages-btn"
|
||||
onClick={handleRefreshMessages}
|
||||
@@ -1177,6 +1255,19 @@ function ChatPage(_props: ChatPageProps) {
|
||||
)
|
||||
})}
|
||||
|
||||
{hasMoreLater && (
|
||||
<div className={`load-more-trigger later ${isLoadingMore ? 'loading' : ''}`}>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 size={14} />
|
||||
<span>正在加载后续消息...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>向下滚动查看更新消息</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 回到底部按钮 */}
|
||||
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
|
||||
<ChevronDown size={16} />
|
||||
|
||||
@@ -231,12 +231,12 @@ function ExportPage() {
|
||||
exportImages: options.exportMedia && options.exportImages,
|
||||
exportVoices: options.exportMedia && options.exportVoices,
|
||||
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia
|
||||
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
|
||||
excelCompactColumns: options.excelCompactColumns,
|
||||
sessionLayout,
|
||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||
// ?????????????????????????????????23:59:59,??????????????????????????????
|
||||
// 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录
|
||||
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
|
||||
} : null
|
||||
}
|
||||
@@ -249,10 +249,10 @@ function ExportPage() {
|
||||
)
|
||||
setExportResult(result)
|
||||
} else {
|
||||
setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` })
|
||||
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('????????????:', e)
|
||||
console.error('导出过程中发生异常:', e)
|
||||
setExportResult({ success: false, error: String(e) })
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
|
||||
579
src/pages/SnsPage.scss
Normal file
579
src/pages/SnsPage.scss
Normal file
@@ -0,0 +1,579 @@
|
||||
.sns-page {
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
|
||||
.sns-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sns-sidebar {
|
||||
width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.closed {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* 自定义滚动条 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
padding: 10px 20px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.date-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
input[type="date"] {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contact-filter-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
padding: 12px 0 0 0;
|
||||
|
||||
.section-header {
|
||||
padding: 0 20px 8px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: 11px;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-search {
|
||||
margin: 0 20px 10px 20px;
|
||||
position: relative;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px 6px 28px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contact-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px;
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
gap: 10px;
|
||||
margin-bottom: 2px;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--accent-color-rgb), 0.1);
|
||||
|
||||
.contact-name {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
color: var(--accent-color);
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-contacts {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
.clear-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sns-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
|
||||
.sns-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
height: 60px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.sns-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
scroll-behavior: smooth;
|
||||
|
||||
.active-filters {
|
||||
max-width: 680px;
|
||||
margin: 0 auto 16px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(var(--accent-color-rgb), 0.05);
|
||||
border: 1px solid rgba(var(--accent-color-rgb), 0.2);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--accent-color);
|
||||
|
||||
.clear-chip-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sns-post {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
max-width: 680px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.post-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
|
||||
.post-info {
|
||||
margin-left: 12px;
|
||||
|
||||
.nickname {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-body {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.post-text {
|
||||
margin-bottom: 12px;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.post-media-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
|
||||
&.media-count-1 {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
.media-item {
|
||||
max-width: 400px;
|
||||
aspect-ratio: unset;
|
||||
}
|
||||
|
||||
img {
|
||||
height: auto;
|
||||
max-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
&.media-count-2,
|
||||
&.media-count-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&.media-count-3,
|
||||
&.media-count-5,
|
||||
&.media-count-6,
|
||||
&.media-count-7,
|
||||
&.media-count-8,
|
||||
&.media-count-9 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.media-item {
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.error {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border-color);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
min-width: 100px;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-video-placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: rgba(var(--accent-color-rgb), 0.1);
|
||||
color: var(--accent-color);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13.5px;
|
||||
|
||||
.likes-section {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
color: var(--accent-color);
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-top: 3.5px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.likes-list {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
.comment-item {
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.5;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-user {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-text {
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.comment-separator {
|
||||
color: var(--text-secondary);
|
||||
margin-left: -2px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more,
|
||||
.no-more,
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
|
||||
.reset-inline {
|
||||
margin-top: 12px;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 2px 6px rgba(var(--accent-color-rgb), 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
421
src/pages/SnsPage.tsx
Normal file
421
src/pages/SnsPage.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { RefreshCw, Heart, Search, Calendar, User, X, Filter } from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import { ImagePreview } from '../components/ImagePreview'
|
||||
import './SnsPage.scss'
|
||||
|
||||
interface SnsPost {
|
||||
id: string
|
||||
username: string
|
||||
nickname: string
|
||||
avatarUrl?: string
|
||||
createTime: number
|
||||
contentDesc: string
|
||||
type?: number
|
||||
media: { url: string; thumb: string }[]
|
||||
likes: string[]
|
||||
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
|
||||
}
|
||||
|
||||
const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => {
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="media-item error">
|
||||
<span>无法加载</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="media-item">
|
||||
<img
|
||||
src={thumb || url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onClick={onPreview}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Contact {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export default function SnsPage() {
|
||||
const [posts, setPosts] = useState<SnsPost[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
// 筛选与搜索状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
|
||||
// 联系人列表状态
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [contactSearch, setContactSearch] = useState('')
|
||||
const [contactsLoading, setContactsLoading] = useState(false)
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
|
||||
const loadPosts = useCallback(async (reset = false) => {
|
||||
if (loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const currentOffset = reset ? 0 : offset
|
||||
const limit = 20
|
||||
|
||||
// 转换日期为秒级时间戳
|
||||
const startTs = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined
|
||||
const endTs = endDate ? Math.floor(new Date(endDate).getTime() / 1000) + 86399 : undefined // 包含当天
|
||||
|
||||
const result = await window.electronAPI.sns.getTimeline(
|
||||
limit,
|
||||
currentOffset,
|
||||
selectedUsernames,
|
||||
searchKeyword,
|
||||
startTs,
|
||||
endTs
|
||||
)
|
||||
|
||||
if (result.success && result.timeline) {
|
||||
if (reset) {
|
||||
setPosts(result.timeline)
|
||||
setOffset(limit)
|
||||
setHasMore(result.timeline.length >= limit)
|
||||
} else {
|
||||
setPosts(prev => [...prev, ...result.timeline!])
|
||||
setOffset(prev => prev + limit)
|
||||
if (result.timeline.length < limit) {
|
||||
setHasMore(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load SNS timeline:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
loadingRef.current = false
|
||||
}
|
||||
}, [offset, selectedUsernames, searchKeyword, startDate, endDate])
|
||||
|
||||
// 获取联系人列表
|
||||
const loadContacts = async () => {
|
||||
setContactsLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getSessions()
|
||||
if (result.success && result.sessions) {
|
||||
// 系统账号和特殊前缀
|
||||
const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder'];
|
||||
|
||||
// 初步提取并过滤联系人
|
||||
const initialContacts = result.sessions
|
||||
.filter((s: any) => {
|
||||
if (!s.username) return false;
|
||||
const u = s.username.toLowerCase();
|
||||
|
||||
// 1. 排除群聊 (WeChat 群组以 @chatroom 结尾)
|
||||
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 排除公众号 (通常以 gh_ 开头)
|
||||
if (u.startsWith('gh_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 排除系统账号
|
||||
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((s: any) => ({
|
||||
username: s.username,
|
||||
displayName: s.displayName || s.username,
|
||||
avatarUrl: s.avatarUrl
|
||||
}))
|
||||
setContacts(initialContacts)
|
||||
|
||||
// 异步进一步富化(获取更多准确的昵称和头像)
|
||||
const usernames = initialContacts.map(c => c.username)
|
||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
|
||||
if (enriched.success && enriched.contacts) {
|
||||
setContacts(prev => prev.map(c => {
|
||||
const extra = enriched.contacts![c.username]
|
||||
if (extra) {
|
||||
return {
|
||||
...c,
|
||||
displayName: extra.displayName || c.displayName,
|
||||
avatarUrl: extra.avatarUrl || c.avatarUrl
|
||||
}
|
||||
}
|
||||
return c
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load contacts:', error)
|
||||
} finally {
|
||||
setContactsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadContacts()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts(true)
|
||||
}, [selectedUsernames, searchKeyword, startDate, endDate])
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
|
||||
if (scrollHeight - scrollTop - clientHeight < 200 && hasMore && !loading) {
|
||||
loadPosts()
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
const date = new Date(ts * 1000)
|
||||
const isCurrentYear = date.getFullYear() === new Date().getFullYear()
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: isCurrentYear ? undefined : 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const toggleUserSelection = (username: string) => {
|
||||
setSelectedUsernames(prev => {
|
||||
if (prev.includes(username)) {
|
||||
return prev.filter(u => u !== username)
|
||||
} else {
|
||||
return [...prev, username]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchKeyword('')
|
||||
setSelectedUsernames([])
|
||||
setStartDate('')
|
||||
setEndDate('')
|
||||
}
|
||||
|
||||
const filteredContacts = contacts.filter(c =>
|
||||
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="sns-page">
|
||||
<div className="sns-container">
|
||||
{/* 侧边栏:过滤与搜索 */}
|
||||
<aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}>
|
||||
<div className="sidebar-header">
|
||||
<h3>朋友圈筛选</h3>
|
||||
<button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="filter-content">
|
||||
{/* 关键词与时间 */}
|
||||
<div className="filter-group">
|
||||
<div className="filter-section">
|
||||
<label><Search size={14} /> 关键词内容</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索正文..."
|
||||
value={searchKeyword}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-section">
|
||||
<label><Calendar size={14} /> 时间范围</label>
|
||||
<div className="date-inputs">
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={e => setStartDate(e.target.value)}
|
||||
/>
|
||||
<span>至</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={e => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 联系人列表 */}
|
||||
<div className="contact-filter-section">
|
||||
<div className="section-header">
|
||||
<label><User size={14} /> 联系人筛选</label>
|
||||
{selectedUsernames.length > 0 && (
|
||||
<span className="selected-count">已选 {selectedUsernames.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="contact-search">
|
||||
<Search size={12} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索好友..."
|
||||
value={contactSearch}
|
||||
onChange={e => setContactSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="contact-list custom-scrollbar">
|
||||
{filteredContacts.map(contact => (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`}
|
||||
onClick={() => toggleUserSelection(contact.username)}
|
||||
>
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={28} shape="rounded" />
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
{selectedUsernames.includes(contact.username) && (
|
||||
<div className="check-mark">✓</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{contacts.length === 0 && !contactsLoading && (
|
||||
<div className="empty-contacts">无可显示联系人</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button className="clear-btn" onClick={clearFilters}>清除全部筛选</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="sns-main">
|
||||
<div className="sns-header">
|
||||
<div className="header-left">
|
||||
{!isSidebarOpen && (
|
||||
<button className="icon-btn" onClick={() => setIsSidebarOpen(true)}>
|
||||
<Filter size={20} />
|
||||
</button>
|
||||
)}
|
||||
<h2>朋友圈</h2>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<button onClick={() => loadPosts(true)} disabled={loading} className="icon-btn refresh-btn">
|
||||
<RefreshCw size={18} className={loading ? 'spinning' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sns-content" onScroll={handleScroll}>
|
||||
{selectedUsernames.length > 0 && (
|
||||
<div className="active-filters">
|
||||
<span>筛选中: {selectedUsernames.length} 位好友</span>
|
||||
<button onClick={() => setSelectedUsernames([])} className="clear-chip-btn">清除</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.map(post => (
|
||||
<div key={post.id} className="sns-post">
|
||||
<div className="post-header">
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
name={post.nickname}
|
||||
size={44}
|
||||
shape="rounded"
|
||||
/>
|
||||
<div className="post-info">
|
||||
<div className="nickname">{post.nickname}</div>
|
||||
<div className="time">{formatTime(post.createTime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="post-body">
|
||||
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
|
||||
|
||||
{post.type === 15 ? (
|
||||
<div className="post-video-placeholder">
|
||||
[视频]
|
||||
</div>
|
||||
) : post.media.length > 0 && (
|
||||
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
|
||||
{post.media.map((m, idx) => (
|
||||
<MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(post.likes.length > 0 || post.comments.length > 0) && (
|
||||
<div className="post-footer">
|
||||
{post.likes.length > 0 && (
|
||||
<div className="likes-section">
|
||||
<Heart size={14} className="icon" />
|
||||
<span className="likes-list">
|
||||
{post.likes.join('、')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post.comments.length > 0 && (
|
||||
<div className="comments-section">
|
||||
{post.comments.map((c, idx) => (
|
||||
<div key={idx} className="comment-item">
|
||||
<span className="comment-user">{c.nickname}</span>
|
||||
{c.refNickname && (
|
||||
<>
|
||||
<span className="reply-text">回复</span>
|
||||
<span className="comment-user">{c.refNickname}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="comment-separator">: </span>
|
||||
<span className="comment-content">{c.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loading && <div className="loading-more">加载中...</div>}
|
||||
{!hasMore && posts.length > 0 && <div className="no-more">没有更多了</div>}
|
||||
{!loading && posts.length === 0 && (
|
||||
<div className="no-results">
|
||||
<p>没有找到符合条件的朋友圈</p>
|
||||
{selectedUsernames.length > 0 && (
|
||||
<button onClick={() => setSelectedUsernames([])} className="reset-inline">
|
||||
清除人员筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{previewImage && (
|
||||
<ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,25 +6,26 @@ export interface ChatState {
|
||||
isConnected: boolean
|
||||
isConnecting: boolean
|
||||
connectionError: string | null
|
||||
|
||||
|
||||
// 会话列表
|
||||
sessions: ChatSession[]
|
||||
filteredSessions: ChatSession[]
|
||||
currentSessionId: string | null
|
||||
isLoadingSessions: boolean
|
||||
|
||||
|
||||
// 消息
|
||||
messages: Message[]
|
||||
isLoadingMessages: boolean
|
||||
isLoadingMore: boolean
|
||||
hasMoreMessages: boolean
|
||||
|
||||
hasMoreLater: boolean
|
||||
|
||||
// 联系人缓存
|
||||
contacts: Map<string, Contact>
|
||||
|
||||
|
||||
// 搜索
|
||||
searchKeyword: string
|
||||
|
||||
|
||||
// 操作
|
||||
setConnected: (connected: boolean) => void
|
||||
setConnecting: (connecting: boolean) => void
|
||||
@@ -38,6 +39,7 @@ export interface ChatState {
|
||||
setLoadingMessages: (loading: boolean) => void
|
||||
setLoadingMore: (loading: boolean) => void
|
||||
setHasMoreMessages: (hasMore: boolean) => void
|
||||
setHasMoreLater: (hasMore: boolean) => void
|
||||
setContacts: (contacts: Contact[]) => void
|
||||
addContact: (contact: Contact) => void
|
||||
setSearchKeyword: (keyword: string) => void
|
||||
@@ -56,48 +58,51 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
isLoadingMessages: false,
|
||||
isLoadingMore: false,
|
||||
hasMoreMessages: true,
|
||||
hasMoreLater: false,
|
||||
contacts: new Map(),
|
||||
searchKeyword: '',
|
||||
|
||||
setConnected: (connected) => set({ isConnected: connected }),
|
||||
setConnecting: (connecting) => set({ isConnecting: connecting }),
|
||||
setConnectionError: (error) => set({ connectionError: error }),
|
||||
|
||||
|
||||
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
|
||||
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
|
||||
|
||||
setCurrentSession: (sessionId) => set({
|
||||
|
||||
setCurrentSession: (sessionId) => set({
|
||||
currentSessionId: sessionId,
|
||||
messages: [],
|
||||
hasMoreMessages: true
|
||||
hasMoreMessages: true,
|
||||
hasMoreLater: false
|
||||
}),
|
||||
|
||||
|
||||
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
||||
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
|
||||
appendMessages: (newMessages, prepend = false) => set((state) => ({
|
||||
messages: prepend
|
||||
messages: prepend
|
||||
? [...newMessages, ...state.messages]
|
||||
: [...state.messages, ...newMessages]
|
||||
})),
|
||||
|
||||
|
||||
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
|
||||
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
|
||||
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
|
||||
|
||||
setContacts: (contacts) => set({
|
||||
contacts: new Map(contacts.map(c => [c.username, c]))
|
||||
setHasMoreLater: (hasMore) => set({ hasMoreLater: hasMore }),
|
||||
|
||||
setContacts: (contacts) => set({
|
||||
contacts: new Map(contacts.map(c => [c.username, c]))
|
||||
}),
|
||||
|
||||
|
||||
addContact: (contact) => set((state) => {
|
||||
const newContacts = new Map(state.contacts)
|
||||
newContacts.set(contact.username, contact)
|
||||
return { contacts: newContacts }
|
||||
}),
|
||||
|
||||
|
||||
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
|
||||
|
||||
|
||||
reset: () => set({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
@@ -110,6 +115,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
isLoadingMessages: false,
|
||||
isLoadingMore: false,
|
||||
hasMoreMessages: true,
|
||||
hasMoreLater: false,
|
||||
contacts: new Map(),
|
||||
searchKeyword: ''
|
||||
})
|
||||
|
||||
20
src/types/electron.d.ts
vendored
20
src/types/electron.d.ts
vendored
@@ -63,7 +63,7 @@ export interface ElectronAPI {
|
||||
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||
error?: string
|
||||
}>
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => Promise<{
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
hasMore?: boolean;
|
||||
@@ -321,6 +321,24 @@ export interface ElectronAPI {
|
||||
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
|
||||
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
|
||||
}
|
||||
sns: {
|
||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => Promise<{
|
||||
success: boolean
|
||||
timeline?: Array<{
|
||||
id: string
|
||||
username: string
|
||||
nickname: string
|
||||
avatarUrl?: string
|
||||
createTime: number
|
||||
contentDesc: string
|
||||
type?: number
|
||||
media: Array<{ url: string; thumb: string }>
|
||||
likes: Array<string>
|
||||
comments: Array<{ id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }>
|
||||
}>
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
|
||||
Reference in New Issue
Block a user