Files
weflow-new/src/pages/MyFootprintPage.tsx
T
2026-04-11 19:52:40 +08:00

984 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { AlertCircle, AtSign, CheckCircle2, Download, Loader2, MessageCircle, RefreshCw, Search, Sparkles, Users } from 'lucide-react'
import DateRangePicker from '../components/DateRangePicker'
import './MyFootprintPage.scss'
type RangePreset = 'today' | 'yesterday' | 'this_week' | 'last_week' | 'custom'
type TimelineMode = 'all' | 'mention' | 'private'
type TimelineTimeMode = 'clock' | 'month_day_clock' | 'full_date_clock'
type PrivateDotVariant = 'both' | 'inbound_only' | 'outbound_only'
type ExportModalStatus = 'idle' | 'progress' | 'success' | 'error'
type FootprintAiStatus = 'idle' | 'loading' | 'success' | 'error'
interface MyFootprintSummary {
private_inbound_people: number
private_replied_people: number
private_outbound_people: number
private_reply_rate: number
mention_count: number
mention_group_count: number
}
interface MyFootprintPrivateSession {
session_id: string
incoming_count: number
outgoing_count: number
replied: boolean
first_incoming_ts: number
first_reply_ts: number
latest_ts: number
anchor_local_id: number
anchor_create_time: number
displayName?: string
avatarUrl?: string
}
interface MyFootprintPrivateSegment {
session_id: string
segment_index: number
start_ts: number
end_ts: number
duration_sec: number
incoming_count: number
outgoing_count: number
message_count: number
replied: boolean
first_incoming_ts: number
first_reply_ts: number
latest_ts: number
anchor_local_id: number
anchor_create_time: number
displayName?: string
avatarUrl?: string
}
interface MyFootprintMention {
session_id: string
local_id: number
create_time: number
sender_username: string
message_content: string
source: string
sessionDisplayName?: string
senderDisplayName?: string
senderAvatarUrl?: string
}
interface MyFootprintMentionGroup {
session_id: string
count: number
latest_ts: number
displayName?: string
avatarUrl?: string
}
interface MyFootprintDiagnostics {
truncated: boolean
scanned_dbs: number
elapsed_ms: number
mention_truncated?: boolean
private_truncated?: boolean
}
interface MyFootprintData {
summary: MyFootprintSummary
private_sessions: MyFootprintPrivateSession[]
private_segments: MyFootprintPrivateSegment[]
mentions: MyFootprintMention[]
mention_groups: MyFootprintMentionGroup[]
diagnostics: MyFootprintDiagnostics
}
interface TimelineBoundaryItem {
kind: 'boundary'
edge: 'start' | 'end'
key: string
time: number
label: string
}
interface TimelineMentionItem {
kind: 'mention'
key: string
time: number
sessionId: string
localId: number
createTime: number
groupName: string
groupAvatarUrl?: string
senderName: string
messageContent: string
}
interface TimelinePrivateItem {
kind: 'private'
key: string
time: number
endTime: number
sessionId: string
anchorLocalId: number
anchorCreateTime: number
displayName: string
avatarUrl?: string
subtitle: string
totalInteractions: number
summaryText: string
dotVariant: PrivateDotVariant
isRange: boolean
}
type TimelineItem = TimelineBoundaryItem | TimelineMentionItem | TimelinePrivateItem
const EMPTY_DATA: MyFootprintData = {
summary: {
private_inbound_people: 0,
private_replied_people: 0,
private_outbound_people: 0,
private_reply_rate: 0,
mention_count: 0,
mention_group_count: 0
},
private_sessions: [],
private_segments: [],
mentions: [],
mention_groups: [],
diagnostics: {
truncated: false,
scanned_dbs: 0,
elapsed_ms: 0,
mention_truncated: false,
private_truncated: false
}
}
function toDayStart(date: Date): Date {
const next = new Date(date)
next.setHours(0, 0, 0, 0)
return next
}
function toDayEnd(date: Date): Date {
const next = new Date(date)
next.setHours(23, 59, 59, 999)
return next
}
function toSeconds(date: Date): number {
return Math.floor(date.getTime() / 1000)
}
function toDateInputValue(date: Date): string {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}`
}
function getWeekStart(date: Date): Date {
const base = toDayStart(date)
const day = base.getDay()
const diff = day === 0 ? -6 : 1 - day
base.setDate(base.getDate() + diff)
return base
}
function formatTimelineMoment(seconds: number, mode: TimelineTimeMode): string {
if (!seconds || !Number.isFinite(seconds)) return '--'
const date = new Date(seconds * 1000)
const yyyy = `${date.getFullYear()}`
const mm = `${date.getMonth() + 1}`.padStart(2, '0')
const dd = `${date.getDate()}`.padStart(2, '0')
const hh = `${date.getHours()}`.padStart(2, '0')
const min = `${date.getMinutes()}`.padStart(2, '0')
if (mode === 'full_date_clock') {
return `${yyyy}-${mm}-${dd} ${hh}:${min}`
}
if (mode === 'month_day_clock') {
return `${mm}-${dd} ${hh}:${min}`
}
return `${hh}:${min}`
}
function formatPercent(value: number): string {
const safe = Number.isFinite(value) ? value : 0
return `${(safe * 100).toFixed(1)}%`
}
function decodeHtmlEntities(content: string): string {
return String(content || '')
.replace(/&/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
}
function stripGroupSenderPrefix(content: string): string {
return String(content || '')
.replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|<br\s*\/?>)\s*|\s*)/i, '')
.replace(/^[a-zA-Z0-9]+@openim:\n?/i, '')
}
function normalizeFootprintMessageContent(content: string): string {
const decoded = decodeHtmlEntities(content || '')
const stripped = stripGroupSenderPrefix(decoded)
return stripped.trim()
}
function renderMentionContent(content: string): ReactNode {
const normalized = String(content || '').trim() || '[空消息]'
const parts = normalized.split(/(@我|@我)/g)
if (parts.length <= 1) return normalized
return parts.map((part, index) => {
if (part === '@我' || part === '@我') {
return (
<span key={index} className="mention-token">
{part}
</span>
)
}
return <span key={index}>{part}</span>
})
}
function formatDurationLabel(beginTimestamp: number, endTimestamp: number): string {
if (!beginTimestamp || !endTimestamp || endTimestamp <= beginTimestamp) {
return '持续不足 1 分钟'
}
const minutes = Math.max(1, Math.round((endTimestamp - beginTimestamp) / 60))
return `持续 ${minutes} 分钟`
}
function resolveRangePresetLabel(preset: RangePreset): string {
switch (preset) {
case 'today':
return '今天'
case 'yesterday':
return '昨天'
case 'this_week':
return '本周'
case 'last_week':
return '上周'
default:
return '自定义'
}
}
function buildRange(preset: RangePreset, customStart: string, customEnd: string): { begin: number; end: number; label: string } {
const now = new Date()
if (preset === 'today') {
return {
begin: toSeconds(toDayStart(now)),
end: toSeconds(now),
label: '今天'
}
}
if (preset === 'yesterday') {
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
return {
begin: toSeconds(toDayStart(yesterday)),
end: toSeconds(toDayEnd(yesterday)),
label: '昨天'
}
}
if (preset === 'this_week') {
const weekStart = getWeekStart(now)
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekStart.getDate() + 6)
return {
begin: toSeconds(toDayStart(weekStart)),
end: toSeconds(toDayEnd(weekEnd)),
label: '本周'
}
}
if (preset === 'last_week') {
const thisWeekStart = getWeekStart(now)
const lastWeekStart = new Date(thisWeekStart)
lastWeekStart.setDate(lastWeekStart.getDate() - 7)
const lastWeekEnd = new Date(thisWeekStart)
lastWeekEnd.setDate(lastWeekEnd.getDate() - 1)
return {
begin: toSeconds(toDayStart(lastWeekStart)),
end: toSeconds(toDayEnd(lastWeekEnd)),
label: '上周'
}
}
const customStartDate = customStart ? new Date(`${customStart}T00:00:00`) : toDayStart(now)
const customEndDate = customEnd ? new Date(`${customEnd}T23:59:59`) : toDayEnd(now)
const begin = toSeconds(customStartDate)
const end = Math.max(begin, toSeconds(customEndDate))
return {
begin,
end,
label: `${toDateInputValue(customStartDate)} 至 ${toDateInputValue(customEndDate)}`
}
}
function MyFootprintPage() {
const navigate = useNavigate()
const [preset, setPreset] = useState<RangePreset>('today')
const [customStartDate, setCustomStartDate] = useState(() => toDateInputValue(toDayStart(new Date())))
const [customEndDate, setCustomEndDate] = useState(() => toDateInputValue(toDayStart(new Date())))
const [searchKeyword, setSearchKeyword] = useState('')
const [timelineMode, setTimelineMode] = useState<TimelineMode>('all')
const [data, setData] = useState<MyFootprintData>(EMPTY_DATA)
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [exportModalStatus, setExportModalStatus] = useState<ExportModalStatus>('idle')
const [exportModalTitle, setExportModalTitle] = useState('')
const [exportModalDescription, setExportModalDescription] = useState('')
const [exportModalPath, setExportModalPath] = useState('')
const [error, setError] = useState<string | null>(null)
const [footprintAiStatus, setFootprintAiStatus] = useState<FootprintAiStatus>('idle')
const [footprintAiText, setFootprintAiText] = useState('')
const inflightRangeKeyRef = useRef<string | null>(null)
const currentRange = useMemo(() => buildRange(preset, customStartDate, customEndDate), [preset, customStartDate, customEndDate])
const timelineTimeMode = useMemo<TimelineTimeMode>(() => {
const span = Math.max(0, currentRange.end - currentRange.begin)
if (span > 365 * 24 * 60 * 60) return 'full_date_clock'
if (span > 24 * 60 * 60) return 'month_day_clock'
return 'clock'
}, [currentRange.begin, currentRange.end])
const handleJump = useCallback((sessionId: string, localId: number, createTime: number) => {
if (!sessionId || !localId || !createTime) return
const query = new URLSearchParams({
sessionId,
jumpLocalId: String(localId),
jumpCreateTime: String(createTime),
jumpSource: 'footprint'
})
navigate(`/chat?${query.toString()}`)
}, [navigate])
const loadData = useCallback(async () => {
const rangeKey = `${currentRange.begin}-${currentRange.end}`
if (inflightRangeKeyRef.current === rangeKey) {
return
}
inflightRangeKeyRef.current = rangeKey
setLoading(true)
setError(null)
try {
const result = await window.electronAPI.chat.getMyFootprintStats(currentRange.begin, currentRange.end)
if (!result.success || !result.data) {
setError(result.error || '读取统计失败')
setData(EMPTY_DATA)
return
}
setData({
...result.data,
private_segments: Array.isArray(result.data.private_segments) ? result.data.private_segments : []
})
} catch (loadError) {
setError(String(loadError))
setData(EMPTY_DATA)
} finally {
setLoading(false)
if (inflightRangeKeyRef.current === rangeKey) {
inflightRangeKeyRef.current = null
}
}
}, [currentRange.begin, currentRange.end])
useEffect(() => {
void loadData()
}, [loadData])
const keyword = searchKeyword.trim().toLowerCase()
const privateSessionMetaMap = useMemo(() => {
const map = new Map<string, { displayName?: string; avatarUrl?: string }>()
for (const item of data.private_sessions) {
map.set(item.session_id, {
displayName: item.displayName,
avatarUrl: item.avatarUrl
})
}
for (const item of data.private_segments) {
if (!map.has(item.session_id)) {
map.set(item.session_id, {
displayName: item.displayName,
avatarUrl: item.avatarUrl
})
}
}
return map
}, [data.private_sessions, data.private_segments])
const filteredMentions = useMemo(() => {
if (!keyword) return data.mentions
return data.mentions.filter((item) => {
const sessionName = (item.sessionDisplayName || '').toLowerCase()
const senderName = (item.senderDisplayName || '').toLowerCase()
const sender = item.sender_username.toLowerCase()
const content = normalizeFootprintMessageContent(item.message_content).toLowerCase()
return sessionName.includes(keyword) || senderName.includes(keyword) || sender.includes(keyword) || content.includes(keyword)
})
}, [data.mentions, keyword])
const filteredPrivateSegments = useMemo(() => {
const rawSegments = data.private_segments.length > 0
? data.private_segments
: data.private_sessions.map((item, index) => ({
session_id: item.session_id,
segment_index: index + 1,
start_ts: item.first_incoming_ts > 0
? item.first_incoming_ts
: item.first_reply_ts > 0
? item.first_reply_ts
: item.latest_ts,
end_ts: item.latest_ts,
duration_sec: Math.max(0, item.latest_ts - (item.first_incoming_ts || item.first_reply_ts || item.latest_ts)),
incoming_count: item.incoming_count,
outgoing_count: item.outgoing_count,
message_count: Math.max(0, item.incoming_count + item.outgoing_count),
replied: item.replied,
first_incoming_ts: item.first_incoming_ts,
first_reply_ts: item.first_reply_ts,
latest_ts: item.latest_ts,
anchor_local_id: item.anchor_local_id,
anchor_create_time: item.anchor_create_time,
displayName: item.displayName,
avatarUrl: item.avatarUrl
}))
if (!keyword) return rawSegments
return rawSegments.filter((item) => {
const meta = privateSessionMetaMap.get(item.session_id)
const name = String(item.displayName || meta?.displayName || '').toLowerCase()
const id = item.session_id.toLowerCase()
return name.includes(keyword) || id.includes(keyword)
})
}, [data.private_segments, data.private_sessions, keyword, privateSessionMetaMap])
const mentionGroupMetaMap = useMemo(() => {
const map = new Map<string, { displayName?: string; avatarUrl?: string }>()
for (const item of data.mention_groups) {
map.set(item.session_id, { displayName: item.displayName, avatarUrl: item.avatarUrl })
}
for (const item of data.private_sessions) {
if (!map.has(item.session_id)) {
map.set(item.session_id, { displayName: item.displayName, avatarUrl: item.avatarUrl })
}
}
return map
}, [data.mention_groups, data.private_sessions])
const mentionTimelineItems = useMemo<TimelineMentionItem[]>(() => {
return filteredMentions
.filter((item) => item.create_time > 0)
.map((item) => {
const groupMeta = mentionGroupMetaMap.get(item.session_id)
return {
kind: 'mention' as const,
key: `mention:${item.session_id}:${item.local_id}`,
time: item.create_time,
sessionId: item.session_id,
localId: item.local_id,
createTime: item.create_time,
groupName: item.sessionDisplayName || groupMeta?.displayName || item.session_id,
groupAvatarUrl: groupMeta?.avatarUrl,
senderName: item.senderDisplayName || item.sender_username || '未知',
messageContent: normalizeFootprintMessageContent(item.message_content)
}
})
}, [filteredMentions, mentionGroupMetaMap])
const privateTimelineItems = useMemo<TimelinePrivateItem[]>(() => {
return filteredPrivateSegments
.map((item) => {
const startTime = item.start_ts > 0
? item.start_ts
: item.first_incoming_ts > 0
? item.first_incoming_ts
: item.first_reply_ts > 0
? item.first_reply_ts
: item.latest_ts
const endTime = item.end_ts > 0 ? item.end_ts : item.latest_ts
const isRange = endTime > startTime + 60
const totalInteractions = Math.max(0, item.message_count || (item.incoming_count + item.outgoing_count))
const durationLabel = item.duration_sec > 0
? `持续 ${Math.max(1, Math.round(item.duration_sec / 60))} 分钟`
: formatDurationLabel(startTime, endTime)
const subtitle = isRange
? `${formatTimelineMoment(startTime, timelineTimeMode)} 至 ${formatTimelineMoment(endTime || startTime, timelineTimeMode)} · ${durationLabel}`
: ''
const summaryText = `收到 ${item.incoming_count} 条 / 发送 ${item.outgoing_count} 条${item.replied ? ' · 已回复' : ''}`
const sessionMeta = privateSessionMetaMap.get(item.session_id)
let dotVariant: PrivateDotVariant = 'both'
if (item.incoming_count > 0 && item.outgoing_count === 0) {
dotVariant = 'inbound_only'
} else if (item.incoming_count === 0 && item.outgoing_count > 0) {
dotVariant = 'outbound_only'
}
return {
kind: 'private' as const,
key: `private:${item.session_id}:${item.segment_index}:${item.start_ts}`,
time: startTime,
endTime,
sessionId: item.session_id,
anchorLocalId: item.anchor_local_id,
anchorCreateTime: item.anchor_create_time,
displayName: item.displayName || sessionMeta?.displayName || item.session_id,
avatarUrl: item.avatarUrl || sessionMeta?.avatarUrl,
subtitle,
totalInteractions,
summaryText,
dotVariant,
isRange
}
})
.filter((item) => item.time > 0)
}, [filteredPrivateSegments, privateSessionMetaMap, timelineTimeMode])
const timelineItems = useMemo<TimelineItem[]>(() => {
const events: TimelineItem[] = []
if (timelineMode !== 'private') {
events.push(...mentionTimelineItems)
}
if (timelineMode !== 'mention') {
events.push(...privateTimelineItems)
}
events.sort((a, b) => {
if (a.time !== b.time) return a.time - b.time
const rankA = a.kind === 'mention' ? 0 : a.kind === 'private' ? 1 : 2
const rankB = b.kind === 'mention' ? 0 : b.kind === 'private' ? 1 : 2
return rankA - rankB
})
const presetLabel = resolveRangePresetLabel(preset)
const startNode: TimelineBoundaryItem = {
kind: 'boundary',
edge: 'start',
key: 'boundary:start',
time: currentRange.begin,
label: `区域时间开始(${presetLabel})`
}
const endNode: TimelineBoundaryItem = {
kind: 'boundary',
edge: 'end',
key: 'boundary:end',
time: currentRange.end,
label: `区域时间结束(${preset === 'today' ? '现在' : presetLabel})`
}
return [startNode, ...events, endNode]
}, [timelineMode, mentionTimelineItems, privateTimelineItems, currentRange.begin, currentRange.end, preset])
const timelineEventCount = useMemo(
() => timelineItems.filter((item) => item.kind !== 'boundary').length,
[timelineItems]
)
const handleExport = useCallback(async (format: 'csv' | 'json') => {
try {
setExporting(true)
setExportModalStatus('progress')
setExportModalTitle(`正在准备导出 ${format.toUpperCase()}`)
setExportModalDescription('正在准备文件保存信息...')
setExportModalPath('')
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/'
const rangeName = currentRange.label.replace(/[\\/:*?"<>|\s]+/g, '_')
const suggestedName = `my_footprint_${rangeName}_${Date.now()}.${format}`
const defaultPath = downloadsPath ? `${downloadsPath}${separator}${suggestedName}` : suggestedName
setExportModalDescription('请在弹窗中选择导出路径...')
const saveResult = await window.electronAPI.dialog.saveFile({
title: format === 'csv' ? '导出我的足迹 CSV' : '导出我的足迹 JSON',
defaultPath,
filters: format === 'csv'
? [{ name: 'CSV', extensions: ['csv'] }]
: [{ name: 'JSON', extensions: ['json'] }]
})
if (saveResult.canceled || !saveResult.filePath) {
setExportModalStatus('idle')
setExportModalTitle('')
setExportModalDescription('')
setExportModalPath('')
return
}
setExportModalDescription('正在导出数据,请稍候...')
setExportModalPath(saveResult.filePath)
const exportResult = await window.electronAPI.chat.exportMyFootprint(
currentRange.begin,
currentRange.end,
format,
saveResult.filePath
)
if (!exportResult.success) {
setExportModalStatus('error')
setExportModalTitle('导出失败')
setExportModalDescription(exportResult.error || '未知错误')
setExportModalPath(saveResult.filePath)
return
}
setExportModalStatus('success')
setExportModalTitle('导出完成')
setExportModalDescription(`文件已成功导出为 ${format.toUpperCase()}。`)
setExportModalPath(exportResult.filePath || saveResult.filePath)
} catch (exportError) {
setExportModalStatus('error')
setExportModalTitle('导出失败')
setExportModalDescription(String(exportError))
} finally {
setExporting(false)
}
}, [currentRange.begin, currentRange.end, currentRange.label])
const handleGenerateAiSummary = useCallback(async () => {
setFootprintAiStatus('loading')
setFootprintAiText('')
try {
const privateSegments = (data.private_segments.length > 0 ? data.private_segments : data.private_sessions).slice(0, 12)
const result = await window.electronAPI.insight.generateFootprintInsight({
rangeLabel: currentRange.label,
summary: data.summary,
privateSegments: privateSegments.map((item: MyFootprintPrivateSegment | MyFootprintPrivateSession) => ({
session_id: item.session_id,
displayName: item.displayName,
incoming_count: item.incoming_count,
outgoing_count: item.outgoing_count,
message_count: 'message_count' in item ? item.message_count : item.incoming_count + item.outgoing_count,
replied: item.replied
})),
mentionGroups: data.mention_groups.slice(0, 12).map((item) => ({
session_id: item.session_id,
displayName: item.displayName,
count: item.count
}))
})
if (!result.success || !result.insight) {
setFootprintAiStatus('error')
setFootprintAiText(result.message || '生成失败')
return
}
setFootprintAiStatus('success')
setFootprintAiText(result.insight)
} catch (generateError) {
setFootprintAiStatus('error')
setFootprintAiText(String(generateError))
}
}, [currentRange.label, data])
return (
<div className="my-footprint-page">
<section className="footprint-header">
<div className="footprint-title-wrap">
<h1>我的微信足迹</h1>
<p>范围:{currentRange.label}</p>
</div>
<div className="footprint-toolbar">
<div className="range-preset-group">
{[
{ value: 'today', label: '今天' },
{ value: 'yesterday', label: '昨天' },
{ value: 'this_week', label: '本周' },
{ value: 'last_week', label: '上周' },
{ value: 'custom', label: '自定义' }
].map((item) => (
<button
key={item.value}
type="button"
className={`preset-chip ${preset === item.value ? 'active' : ''}`}
onClick={() => setPreset(item.value as RangePreset)}
>
{item.label}
</button>
))}
</div>
{preset === 'custom' && (
<div className="custom-range-row">
<DateRangePicker
startDate={customStartDate}
endDate={customEndDate}
onStartDateChange={setCustomStartDate}
onEndDateChange={setCustomEndDate}
/>
</div>
)}
<div className="toolbar-actions">
<div className="search-input">
<Search size={15} />
<input
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
placeholder="搜索联系人/群聊/内容"
/>
</div>
<button type="button" className="action-btn" onClick={() => void loadData()} disabled={loading}>
<RefreshCw size={15} className={loading ? 'spin' : ''} />
<span>刷新</span>
</button>
<button type="button" className="action-btn" onClick={() => void handleGenerateAiSummary()} disabled={loading || footprintAiStatus === 'loading'}>
{footprintAiStatus === 'loading' ? <Loader2 size={15} className="spin" /> : <Sparkles size={15} />}
<span>{footprintAiStatus === 'loading' ? '生成中...' : 'AI 总结'}</span>
</button>
<button type="button" className="action-btn" onClick={() => void handleExport('csv')} disabled={exporting || loading}>
<Download size={15} />
<span>导出 CSV</span>
</button>
<button type="button" className="action-btn" onClick={() => void handleExport('json')} disabled={exporting || loading}>
<Download size={15} />
<span>导出 JSON</span>
</button>
</div>
</div>
</section>
{loading ? (
<section className="footprint-loading" aria-live="polite">
<div className="kpi-skeleton-grid">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="kpi-skeleton-card skeleton-shimmer" />
))}
</div>
<div className="timeline-skeleton-list">
{Array.from({ length: 7 }).map((_, index) => (
<div key={index} className="timeline-skeleton-item skeleton-shimmer" />
))}
</div>
</section>
) : error ? (
<section className="footprint-error" role="alert">
<h3>读取我的足迹失败</h3>
<p>{error}</p>
<button type="button" className="action-btn" onClick={() => void loadData()}>
<RefreshCw size={15} />
<span>重试</span>
</button>
</section>
) : (
<>
<section className="kpi-grid">
<button type="button" className="kpi-card" onClick={() => setTimelineMode('private')}>
<span className="kpi-label">有聊天的人数</span>
<strong>{data.summary.private_inbound_people}</strong>
<small>回复了其中 {data.summary.private_replied_people} 人</small>
</button>
<button type="button" className="kpi-card" onClick={() => setTimelineMode('private')}>
<span className="kpi-label">我有回复的人数</span>
<strong>{data.summary.private_outbound_people}</strong>
<small>回复率 {formatPercent(data.summary.private_reply_rate)}</small>
</button>
<button type="button" className="kpi-card" onClick={() => setTimelineMode('mention')}>
<span className="kpi-label">@我次数</span>
<strong>{data.summary.mention_count}</strong>
<small>可点击查看原消息</small>
</button>
<button type="button" className="kpi-card" onClick={() => setTimelineMode('mention')}>
<span className="kpi-label">涉及群聊</span>
<strong>{data.summary.mention_group_count}</strong>
<small>按群聚合 @我消息</small>
</button>
</section>
{footprintAiStatus !== 'idle' && (
<section className={`footprint-ai-result footprint-ai-result-${footprintAiStatus}`}>
<div className="footprint-ai-head">
<strong>AI 足迹总结</strong>
<span>{currentRange.label}</span>
</div>
<p>{footprintAiText}</p>
</section>
)}
<section
className={`footprint-timeline timeline-time-${timelineTimeMode}`}
key={`${timelineMode}:${currentRange.begin}:${currentRange.end}`}
>
<div className="timeline-head">
<div className="timeline-head-left">
<h2>联络时间线</h2>
<p>最上方是时间区间开始,最下方是时间区间终点,中间按时间展示群聊 @我 与私聊分段会话节点。</p>
</div>
<div className="timeline-mode-row">
<button
type="button"
className={`timeline-mode-chip ${timelineMode === 'all' ? 'active' : ''}`}
onClick={() => setTimelineMode('all')}
>
全部 {timelineEventCount}
</button>
<button
type="button"
className={`timeline-mode-chip ${timelineMode === 'mention' ? 'active' : ''}`}
onClick={() => setTimelineMode('mention')}
>
@我群聊 {mentionTimelineItems.length}
</button>
<button
type="button"
className={`timeline-mode-chip ${timelineMode === 'private' ? 'active' : ''}`}
onClick={() => setTimelineMode('private')}
>
私聊 {privateTimelineItems.length}
</button>
</div>
</div>
{timelineEventCount === 0 ? (
<div className="panel-empty-state">当前区间暂无联络事件,试试切换日期范围或清空关键词筛选。</div>
) : (
<div className="timeline-stream">
{timelineItems.map((item, index) => (
<div
key={item.key}
className={`timeline-item timeline-item-${item.kind}`}
style={{ animationDelay: `${Math.min(index, 12) * 0.04}s` }}
>
<div className={`timeline-time timeline-time-${item.kind}`}>
{item.kind === 'private' ? (
<div className="timeline-time-range">
<span className="timeline-time-main">{formatTimelineMoment(item.time, timelineTimeMode)}</span>
{item.isRange && (
<span className="timeline-time-end-wrap">
<span className="timeline-time-end">{formatTimelineMoment(item.endTime, timelineTimeMode)}</span>
</span>
)}
</div>
) : (
formatTimelineMoment(item.time, timelineTimeMode)
)}
</div>
<div className={`timeline-dot-col timeline-dot-col-${item.kind}`}>
{item.kind === 'private' ? (
<div className="timeline-dot-range">
<div className={`timeline-dot timeline-dot-private timeline-dot-private-start timeline-dot-private-${item.dotVariant}`} />
{item.isRange && (
<>
<div className={`timeline-dot-range-line timeline-dot-range-line-${item.dotVariant}`} />
<div className={`timeline-dot timeline-dot-private timeline-dot-private-end timeline-dot-private-${item.dotVariant}`} />
</>
)}
</div>
) : (
<div className={`timeline-dot timeline-dot-${item.kind}${item.kind === 'boundary' ? ` timeline-dot-${item.edge}` : ''}`} />
)}
</div>
<div className="timeline-content-wrap">
{item.kind === 'boundary' && (
<div className={`timeline-boundary timeline-boundary-${item.edge}`}>{item.label}</div>
)}
{item.kind === 'mention' && (
<div className="timeline-card timeline-card-mention">
<div className="timeline-card-head">
<div className="timeline-identity">
<div className="timeline-avatar">
{item.groupAvatarUrl ? (
<img src={item.groupAvatarUrl} alt={item.groupName} />
) : (
<Users size={16} />
)}
</div>
<div className="timeline-title-group">
<div className="timeline-title">{item.groupName}</div>
<div className="timeline-subtitle">发送人:{item.senderName}</div>
</div>
</div>
<button
type="button"
className="jump-btn timeline-jump-btn"
onClick={() => handleJump(item.sessionId, item.localId, item.createTime)}
>
跳转
</button>
</div>
<div className="timeline-message mention-message">{renderMentionContent(item.messageContent)}</div>
</div>
)}
{item.kind === 'private' && (
<div className="timeline-card timeline-card-private">
<div className="timeline-card-head">
<div className="timeline-identity">
<div className="timeline-avatar timeline-avatar-private">
{item.avatarUrl ? (
<img src={item.avatarUrl} alt={item.displayName} />
) : (
<MessageCircle size={16} />
)}
</div>
<div className="timeline-title-group">
<div className="timeline-title">{item.displayName}</div>
<div className="timeline-subtitle">{item.subtitle}</div>
</div>
</div>
<div className="timeline-right-tools">
<span className="timeline-count-badge">共 {item.totalInteractions} 条</span>
<button
type="button"
className="jump-btn timeline-jump-btn"
disabled={!item.anchorLocalId || !item.anchorCreateTime}
onClick={() => handleJump(item.sessionId, item.anchorLocalId, item.anchorCreateTime)}
>
跳转
</button>
</div>
</div>
<div className="timeline-message private-message">{item.summaryText}</div>
</div>
)}
</div>
</div>
))}
</div>
)}
</section>
</>
)}
{exportModalStatus !== 'idle' && (
<div className="footprint-export-modal-mask" role="presentation">
<div className="footprint-export-modal" role="dialog" aria-modal="true" aria-live="polite">
<div className={`export-modal-icon export-modal-icon-${exportModalStatus}`}>
{exportModalStatus === 'progress' && <RefreshCw size={20} className="spin" />}
{exportModalStatus === 'success' && <CheckCircle2 size={20} />}
{exportModalStatus === 'error' && <AlertCircle size={20} />}
</div>
<h3>{exportModalTitle}</h3>
<p>{exportModalDescription}</p>
{exportModalPath && <code className="export-modal-path">{exportModalPath}</code>}
{exportModalStatus !== 'progress' && (
<div className="export-modal-actions">
<button
type="button"
className="action-btn"
onClick={() => {
setExportModalStatus('idle')
setExportModalTitle('')
setExportModalDescription('')
setExportModalPath('')
}}
>
知道了
</button>
</div>
)}
</div>
</div>
)}
</div>
)
}
export default MyFootprintPage