新的提交
This commit is contained in:
commit
01641834de
188 files changed
+34865
No files matched your search
@@ -0,0 +1,83 @@
|
||||
.agreement-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.agreement-titlebar {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
-webkit-app-region: drag;
|
||||
flex-shrink: 0;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-content {
|
||||
flex: 1;
|
||||
padding: 32px 48px;
|
||||
overflow-y: auto;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 24px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--primary);
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 24px 0 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.agreement-footer-text {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import './AgreementPage.scss'
|
||||
|
||||
function AgreementPage() {
|
||||
return (
|
||||
<div className="agreement-page">
|
||||
<div className="agreement-titlebar">
|
||||
<span>用户协议与隐私政策</span>
|
||||
</div>
|
||||
<div className="agreement-content">
|
||||
{/* 协议内容 - 请替换为完整的协议文本 */}
|
||||
<h2>用户协议</h2>
|
||||
|
||||
<h3>一、总则</h3>
|
||||
<p>欢迎使用WeFlow(WeFlow)软件。请在使用本软件前仔细阅读本协议。一旦您开始使用本软件,即表示您已充分理解并同意本协议的全部内容。</p>
|
||||
|
||||
<h3>二、软件说明</h3>
|
||||
<p>WeFlow是一款本地化的微信聊天记录查看与分析工具,所有数据处理均在用户本地设备上完成。</p>
|
||||
|
||||
<h3>三、使用条款</h3>
|
||||
<p>1. 本软件仅供个人学习、研究使用,严禁用于任何商业用途或非法目的。</p>
|
||||
<p>2. 用户应确保所查看、分析的数据为本人所有或已获得合法授权。</p>
|
||||
<p>3. 用户不得利用本软件侵犯他人隐私、窃取他人信息或从事其他违法活动。</p>
|
||||
|
||||
<h3>四、免责声明</h3>
|
||||
<p>1. 本软件按"现状"提供,开发者不对软件的适用性、可靠性、准确性作任何明示或暗示的保证。</p>
|
||||
<p>2. 因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊或后果性损害,开发者不承担任何责任。</p>
|
||||
<p>3. 用户因违反本协议或相关法律法规而产生的一切后果由用户自行承担。</p>
|
||||
|
||||
<h3>五、知识产权</h3>
|
||||
<p>本软件的所有权、知识产权及相关权益均归开发者所有。未经授权,不得复制、修改、传播本软件。</p>
|
||||
|
||||
<h2>隐私政策</h2>
|
||||
|
||||
<h3>一、数据收集</h3>
|
||||
<p>本软件不收集、不上传、不存储任何用户个人信息或聊天数据。所有数据处理均在本地完成。</p>
|
||||
|
||||
<h3>二、数据安全</h3>
|
||||
<p>您的聊天记录和个人数据完全存储在您的本地设备上,本软件不会将任何数据传输至外部服务器。</p>
|
||||
|
||||
<h3>三、网络请求</h3>
|
||||
<p>本软件仅在检查更新时会访问更新服务器获取版本信息,不涉及任何用户数据的传输。</p>
|
||||
|
||||
<h3>四、第三方服务</h3>
|
||||
<p>本软件不集成任何第三方数据分析、广告或追踪服务。</p>
|
||||
|
||||
<p className="agreement-footer-text">最后更新日期:2025年1月</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AgreementPage
|
||||
@@ -0,0 +1,295 @@
|
||||
// 加载和错误状态
|
||||
.loading-container,
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
gap: 16px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
p.loading-status {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.progress-bar-wrapper {
|
||||
width: 300px;
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: var(--primary-gradient);
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 0 10px rgba(139, 115, 85, 0.3);
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 统计卡片
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary-light);
|
||||
border-radius: 12px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 20px;
|
||||
|
||||
&.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.rankings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.top {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.medal {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-primary);
|
||||
|
||||
&.medal-1 {
|
||||
background: linear-gradient(135deg, #ffd700, #ffb800);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(255, 184, 0, 0.4);
|
||||
}
|
||||
|
||||
&.medal-2 {
|
||||
background: linear-gradient(135deg, #c0c0c0, #a8a8a8);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(168, 168, 168, 0.4);
|
||||
}
|
||||
|
||||
&.medal-3 {
|
||||
background: linear-gradient(135deg, #cd7f32, #b87333);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(184, 115, 51, 0.4);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
.contact-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-stats {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.message-count {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 1200px) {
|
||||
.stats-overview {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.stats-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
.chart-card.wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import './AnalyticsPage.scss'
|
||||
import './DataManagementPage.scss'
|
||||
|
||||
function AnalyticsPage() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingStatus, setLoadingStatus] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const themeMode = useThemeStore((state) => state.themeMode)
|
||||
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
|
||||
const loadData = async (forceRefresh = false) => {
|
||||
if (isLoaded && !forceRefresh) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setProgress(0)
|
||||
|
||||
// 监听后台推送的进度
|
||||
const removeListener = window.electronAPI.analytics.onProgress?.((payload: { status: string; progress: number }) => {
|
||||
setLoadingStatus(payload.status)
|
||||
setProgress(payload.progress)
|
||||
})
|
||||
|
||||
try {
|
||||
setLoadingStatus('正在统计消息数据...')
|
||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics()
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatistics(statsResult.data)
|
||||
} else {
|
||||
setError(statsResult.error || '加载统计数据失败')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
setLoadingStatus('正在分析联系人排名...')
|
||||
const rankingsResult = await window.electronAPI.analytics.getContactRankings(20)
|
||||
if (rankingsResult.success && rankingsResult.data) {
|
||||
setRankings(rankingsResult.data)
|
||||
}
|
||||
setLoadingStatus('正在计算时间分布...')
|
||||
const timeResult = await window.electronAPI.analytics.getTimeDistribution()
|
||||
if (timeResult.success && timeResult.data) {
|
||||
setTimeDistribution(timeResult.data)
|
||||
}
|
||||
markLoaded()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (removeListener) removeListener()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
|
||||
const handleRefresh = () => loadData(true)
|
||||
|
||||
const formatDate = (timestamp: number | null) => {
|
||||
if (!timestamp) return '-'
|
||||
const date = new Date(timestamp * 1000)
|
||||
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 10000) return (num / 10000).toFixed(1) + '万'
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
const getChartLabelColors = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { text: '#333333', line: '#999999' }
|
||||
}
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
const text = styles.getPropertyValue('--text-primary').trim() || '#333333'
|
||||
const line = styles.getPropertyValue('--text-tertiary').trim() || '#999999'
|
||||
return { text, line }
|
||||
}
|
||||
|
||||
const chartLabelColors = getChartLabelColors()
|
||||
|
||||
const getTypeChartOption = () => {
|
||||
if (!statistics) return {}
|
||||
const data = [
|
||||
{ name: '文本', value: statistics.textMessages },
|
||||
{ name: '图片', value: statistics.imageMessages },
|
||||
{ name: '语音', value: statistics.voiceMessages },
|
||||
{ name: '视频', value: statistics.videoMessages },
|
||||
{ name: '表情', value: statistics.emojiMessages },
|
||||
{ name: '其他', value: statistics.otherMessages },
|
||||
].filter(d => d.value > 0)
|
||||
return {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 8, borderColor: 'transparent', borderWidth: 0 },
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}\n{d}%',
|
||||
textStyle: {
|
||||
color: chartLabelColors.text,
|
||||
textShadowBlur: 0,
|
||||
textShadowColor: 'transparent',
|
||||
textShadowOffsetX: 0,
|
||||
textShadowOffsetY: 0,
|
||||
textBorderWidth: 0,
|
||||
textBorderColor: 'transparent',
|
||||
},
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
color: chartLabelColors.line,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'transparent',
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
},
|
||||
label: {
|
||||
color: chartLabelColors.text,
|
||||
textShadowBlur: 0,
|
||||
textShadowColor: 'transparent',
|
||||
textBorderWidth: 0,
|
||||
textBorderColor: 'transparent',
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
color: chartLabelColors.line,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'transparent',
|
||||
},
|
||||
},
|
||||
},
|
||||
data,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
const getSendReceiveOption = () => {
|
||||
if (!statistics) return {}
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{
|
||||
type: 'pie', radius: ['50%', '70%'], data: [
|
||||
{ name: '发送', value: statistics.sentMessages, itemStyle: { color: '#07c160' } },
|
||||
{ name: '接收', value: statistics.receivedMessages, itemStyle: { color: '#1989fa' } }
|
||||
],
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}: {c}',
|
||||
textStyle: {
|
||||
color: chartLabelColors.text,
|
||||
textShadowBlur: 0,
|
||||
textShadowColor: 'transparent',
|
||||
textShadowOffsetX: 0,
|
||||
textShadowOffsetY: 0,
|
||||
textBorderWidth: 0,
|
||||
textBorderColor: 'transparent',
|
||||
},
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
color: chartLabelColors.line,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'transparent',
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
},
|
||||
label: {
|
||||
color: chartLabelColors.text,
|
||||
textShadowBlur: 0,
|
||||
textShadowColor: 'transparent',
|
||||
textBorderWidth: 0,
|
||||
textBorderColor: 'transparent',
|
||||
},
|
||||
labelLine: {
|
||||
lineStyle: {
|
||||
color: chartLabelColors.line,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'transparent',
|
||||
},
|
||||
},
|
||||
},
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
const getHourlyOption = () => {
|
||||
if (!timeDistribution) return {}
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
const data = hours.map(h => timeDistribution.hourlyDistribution[h] || 0)
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: hours.map(h => `${h}时`) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ type: 'bar', data, itemStyle: { color: '#07c160', borderRadius: [4, 4, 0, 0] } }]
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading && !isLoaded) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<Loader2 size={48} className="spin" />
|
||||
<p className="loading-status">{loadingStatus}</p>
|
||||
<div className="progress-bar-wrapper">
|
||||
<div className="progress-bar-fill" style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
<span className="progress-percent">{progress}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !isLoaded) {
|
||||
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>私聊分析</h1>
|
||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||
{isLoading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="page-scroll">
|
||||
<section className="page-section">
|
||||
<div className="stats-overview">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon"><MessageSquare size={24} /></div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{formatNumber(statistics?.totalMessages || 0)}</span>
|
||||
<span className="stat-label">总消息数</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon"><Send size={24} /></div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{formatNumber(statistics?.sentMessages || 0)}</span>
|
||||
<span className="stat-label">发送消息</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon"><Inbox size={24} /></div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{formatNumber(statistics?.receivedMessages || 0)}</span>
|
||||
<span className="stat-label">接收消息</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon"><Calendar size={24} /></div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{statistics?.activeDays || 0}</span>
|
||||
<span className="stat-label">活跃天数</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{statistics && (
|
||||
<div className="time-range">
|
||||
<Clock size={16} />
|
||||
<span>数据范围: {formatDate(statistics.firstMessageTime)} - {formatDate(statistics.lastMessageTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="charts-grid">
|
||||
<div className="chart-card"><h3>消息类型分布</h3><ReactECharts option={getTypeChartOption()} style={{ height: 300 }} /></div>
|
||||
<div className="chart-card"><h3>发送/接收比例</h3><ReactECharts option={getSendReceiveOption()} style={{ height: 300 }} /></div>
|
||||
<div className="chart-card wide"><h3>每小时消息分布</h3><ReactECharts option={getHourlyOption()} style={{ height: 250 }} /></div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="page-section">
|
||||
<div className="section-header"><div><h2><Users size={20} /> 聊天排名 Top 20</h2></div></div>
|
||||
<div className="rankings-list">
|
||||
{rankings.map((contact, index) => (
|
||||
<div key={contact.username} className="ranking-item">
|
||||
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||
<div className="contact-avatar">
|
||||
{contact.avatarUrl ? <img src={contact.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
<span className="contact-stats">发送 {contact.sentCount} / 接收 {contact.receivedCount}</span>
|
||||
</div>
|
||||
<span className="message-count">{formatNumber(contact.messageCount)} 条</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnalyticsPage
|
||||
@@ -0,0 +1,116 @@
|
||||
.annual-report-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: var(--primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 48px;
|
||||
}
|
||||
|
||||
.year-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
max-width: 600px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.year-card {
|
||||
width: 120px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--card-bg);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-light);
|
||||
|
||||
.year-number {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.year-number {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 40px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 80%, #000) 100%);
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 16px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 40%, transparent);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Calendar, Loader2, Sparkles } from 'lucide-react'
|
||||
import './AnnualReportPage.scss'
|
||||
|
||||
function AnnualReportPage() {
|
||||
const navigate = useNavigate()
|
||||
const [availableYears, setAvailableYears] = useState<number[]>([])
|
||||
const [selectedYear, setSelectedYear] = useState<number | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadAvailableYears()
|
||||
}, [])
|
||||
|
||||
const loadAvailableYears = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const result = await window.electronAPI.annualReport.getAvailableYears()
|
||||
if (result.success && result.data && result.data.length > 0) {
|
||||
setAvailableYears(result.data)
|
||||
setSelectedYear(result.data[0])
|
||||
} else if (!result.success) {
|
||||
setLoadError(result.error || '加载年度数据失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setLoadError(String(e))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (!selectedYear) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
navigate(`/annual-report/view?year=${selectedYear}`)
|
||||
} catch (e) {
|
||||
console.error('生成报告失败:', e)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>正在加载年份数据...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (availableYears.length === 0) {
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
|
||||
<h2 style={{ fontSize: 20, fontWeight: 600, color: 'var(--text-primary)', margin: '16px 0 8px' }}>暂无聊天记录</h2>
|
||||
<p style={{ color: 'var(--text-tertiary)', margin: 0 }}>
|
||||
{loadError || '请先解密数据库后再生成年度报告'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<Sparkles size={32} className="header-icon" />
|
||||
<h1 className="page-title">年度报告</h1>
|
||||
<p className="page-desc">选择年份,生成你的微信聊天年度回顾</p>
|
||||
|
||||
<div className="year-grid">
|
||||
{availableYears.map(year => (
|
||||
<div
|
||||
key={year}
|
||||
className={`year-card ${selectedYear === year ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(year)}
|
||||
>
|
||||
<span className="year-number">{year}</span>
|
||||
<span className="year-label">年</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="generate-btn"
|
||||
onClick={handleGenerateReport}
|
||||
disabled={!selectedYear || isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>正在生成...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={20} />
|
||||
<span>生成 {selectedYear} 年度报告</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnnualReportPage
|
||||
@@ -0,0 +1,1281 @@
|
||||
.annual-report-window {
|
||||
// 使用全局主题变量,带回退值
|
||||
--ar-primary: var(--primary, #07C160);
|
||||
--ar-accent: var(--accent, #F2AA00);
|
||||
--ar-text-main: var(--text-primary, #222222);
|
||||
--ar-text-sub: var(--text-secondary, #555555);
|
||||
--ar-bg-color: var(--bg-primary, #F9F8F6);
|
||||
--ar-card-bg: var(--bg-secondary, rgba(255, 255, 255, 0.5));
|
||||
--ar-card-bg-hover: var(--bg-tertiary, rgba(255, 255, 255, 0.8));
|
||||
--ar-rank-bg: var(--bg-secondary, #f0f0f0);
|
||||
--ar-rank-color: var(--text-secondary, #666);
|
||||
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: var(--chat-pattern);
|
||||
background-color: var(--ar-bg-color);
|
||||
// overflow-y: auto; // Moved to .report-scroll-view
|
||||
overflow: hidden; // Contain everything
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag; // 确保主容器不可拖动
|
||||
|
||||
// 隐藏滚动条
|
||||
/* scrollbar-width: none; */
|
||||
// Moved
|
||||
/* -ms-overflow-style: none; */
|
||||
}
|
||||
|
||||
.report-scroll-view {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: 1;
|
||||
|
||||
// 隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 背景装饰圆点 - 毛玻璃效果
|
||||
.bg-decoration {
|
||||
position: absolute; // Changed from fixed
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deco-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
|
||||
&.c1 {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
top: -80px;
|
||||
right: -60px;
|
||||
animation: float1 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.c2 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
bottom: 15%;
|
||||
left: -70px;
|
||||
animation: float2 25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.c3 {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
top: 45%;
|
||||
right: -40px;
|
||||
animation: float3 18s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.c4 {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
top: 25%;
|
||||
left: 8%;
|
||||
animation: float1 22s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
&.c5 {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
bottom: 25%;
|
||||
right: 12%;
|
||||
animation: float2 15s ease-in-out infinite reverse;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float1 {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-15px, 15px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float2 {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(12px, -12px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float3 {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-8px, -15px);
|
||||
}
|
||||
}
|
||||
|
||||
.annual-report-window {
|
||||
|
||||
// 所有子元素默认不可拖动
|
||||
* {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
// 背景渐变灯光
|
||||
&::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle 500px at 0% 0%, rgba(7, 193, 96, 0.06), transparent),
|
||||
radial-gradient(circle 500px at 100% 0%, rgba(242, 170, 0, 0.05), transparent),
|
||||
radial-gradient(circle 500px at 0% 100%, rgba(242, 170, 0, 0.05), transparent),
|
||||
radial-gradient(circle 500px at 100% 100%, rgba(7, 193, 96, 0.06), transparent);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&.loading,
|
||||
&.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--ar-text-sub);
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading {
|
||||
.loading-ring {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.ring-bg {
|
||||
fill: none;
|
||||
stroke: rgba(0, 0, 0, 0.08);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
.ring-progress {
|
||||
fill: none;
|
||||
stroke: var(--ar-primary);
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 264;
|
||||
transition: stroke-dashoffset 0.3s ease;
|
||||
}
|
||||
|
||||
.ring-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-stage {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-text-main);
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.loading-hint {
|
||||
font-size: 14px;
|
||||
color: var(--ar-text-sub);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.report-container {
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
padding: 32px 5% 60px;
|
||||
padding-top: 48px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.exporting-snapshot *::selection {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.exporting-snapshot * {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.exporting-snapshot {
|
||||
.hero-title,
|
||||
.label-text,
|
||||
.hero-desc,
|
||||
.stat-num,
|
||||
.stat-unit,
|
||||
.hl,
|
||||
.gold {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
min-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
font-size: 12px;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
color: var(--ar-text-sub);
|
||||
margin-bottom: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(28px, 5vw, 44px);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 16px;
|
||||
color: var(--ar-text-main);
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
color: var(--ar-text-sub);
|
||||
max-width: 500px;
|
||||
|
||||
&.active-time {
|
||||
font-size: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.big-stat {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: clamp(40px, 8vw, 64px);
|
||||
font-weight: 700;
|
||||
color: var(--ar-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 18px;
|
||||
color: var(--ar-text-sub);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 50px;
|
||||
height: 3px;
|
||||
background: var(--ar-accent);
|
||||
margin: 24px 0;
|
||||
border: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hl {
|
||||
color: var(--ar-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gold {
|
||||
color: var(--ar-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 头像组件
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&.sm {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&.md {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&.lg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 20px;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 6px 20px rgba(7, 193, 96, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// 月度好友环形布局
|
||||
.monthly-orbit {
|
||||
--radius: 180px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: 500px;
|
||||
margin: 30px auto 0;
|
||||
}
|
||||
|
||||
.monthly-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.monthly-item {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 90px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%) rotate(calc(var(--i) * 30deg)) translateY(calc(-1 * var(--radius))) rotate(calc(var(--i) * -30deg));
|
||||
z-index: 1;
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-size: 11px;
|
||||
color: var(--ar-text-sub);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.month-name {
|
||||
font-size: 11px;
|
||||
color: var(--ar-text-sub);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 热力图
|
||||
.heatmap-wrapper {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 3px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--ar-text-sub);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.time-labels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.heatmap-week-col {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(7, 1fr);
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
color: var(--ar-text-sub);
|
||||
}
|
||||
|
||||
.week-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.h-cell {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 2px;
|
||||
min-height: 10px;
|
||||
transition: transform 0.15s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.3);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 好友列表
|
||||
.friend-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.friend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: var(--ar-card-bg);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 10px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--ar-card-bg-hover);
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--ar-rank-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-rank-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.top {
|
||||
background: linear-gradient(135deg, #ffd700, #ffb800);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: var(--ar-text-sub);
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-size: 13px;
|
||||
color: var(--ar-primary);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 领奖台布局
|
||||
.podium {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 40px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.podium-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
|
||||
.avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 3px solid var(--ar-card-bg);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&.first {
|
||||
.avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-color: #ffd700;
|
||||
}
|
||||
|
||||
.crown {
|
||||
font-size: 28px;
|
||||
margin-bottom: -8px;
|
||||
animation: crownBounce 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.podium-stand {
|
||||
height: 100px;
|
||||
background: linear-gradient(180deg, #ffd700, #ffb800);
|
||||
}
|
||||
}
|
||||
|
||||
&.second {
|
||||
.podium-stand {
|
||||
height: 70px;
|
||||
background: linear-gradient(180deg, #e0e0e0, #c0c0c0);
|
||||
}
|
||||
}
|
||||
|
||||
&.third {
|
||||
.podium-stand {
|
||||
height: 50px;
|
||||
background: linear-gradient(180deg, #cd9b6a, #b87333);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.podium-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-text-main);
|
||||
max-width: 90px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.podium-count {
|
||||
font-size: 11px;
|
||||
color: var(--ar-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.podium-stand {
|
||||
width: 90px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.podium-rank {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@keyframes crownBounce {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
// 第4-5名列表
|
||||
.runner-up-list {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.runner-up-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--ar-card-bg);
|
||||
border-radius: 10px;
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.runner-up-rank {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--ar-rank-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-rank-color);
|
||||
}
|
||||
|
||||
.runner-up-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ar-text-main);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.runner-up-count {
|
||||
font-size: 12px;
|
||||
color: var(--ar-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 结尾
|
||||
.ending {
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ending-year {
|
||||
font-size: 100px;
|
||||
font-weight: 700;
|
||||
color: var(--ar-primary);
|
||||
opacity: 0.1;
|
||||
margin-top: 30px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ending-brand {
|
||||
font-size: 14px;
|
||||
letter-spacing: 4px;
|
||||
color: var(--ar-text-sub);
|
||||
margin-top: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 双向奔赴 - 新样式
|
||||
.mutual-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin: 40px 0 24px;
|
||||
}
|
||||
|
||||
.mutual-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
&.friend {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.mutual-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.arrow-count {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-primary);
|
||||
}
|
||||
|
||||
.arrow-line {
|
||||
font-size: 20px;
|
||||
color: var(--ar-text-sub);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.reverse {
|
||||
.arrow-count {
|
||||
color: var(--ar-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mutual-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 20px;
|
||||
|
||||
.mutual-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.mutual-ratio {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--ar-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.mutual-name-tag {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-text-main);
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
// 常用语列表
|
||||
.phrase-list {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.phrase-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: var(--ar-card-bg);
|
||||
border-radius: 12px;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.phrase-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--ar-text-main);
|
||||
}
|
||||
|
||||
.phrase-count {
|
||||
font-size: 14px;
|
||||
color: var(--ar-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 加载动画
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 顶部拖动区域
|
||||
.drag-region {
|
||||
position: absolute; // Changed from fixed
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0; // Changed from right: 138px (since it's now inside the window container)
|
||||
height: 32px;
|
||||
-webkit-app-region: drag !important;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
// 浮动操作按钮
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
bottom: 64px;
|
||||
right: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 99;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fab-main {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--ar-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(7, 193, 96, 0.4);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
order: 99;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 24px rgba(7, 193, 96, 0.5);
|
||||
}
|
||||
|
||||
.fab-container.open & {
|
||||
transform: rotate(45deg);
|
||||
background: var(--ar-text-sub);
|
||||
}
|
||||
}
|
||||
|
||||
.fab-item {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--ar-card-bg);
|
||||
color: var(--ar-text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0;
|
||||
transform: scale(0.5) translateY(20px);
|
||||
pointer-events: none;
|
||||
|
||||
.fab-container.open & {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
pointer-events: auto;
|
||||
|
||||
&:nth-child(1) {
|
||||
transition-delay: 0.05s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
transition-delay: 0.1s;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--ar-primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出遮罩和弹窗
|
||||
.export-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出进度弹窗
|
||||
.export-progress-modal {
|
||||
background: var(--bg-primary, #fff);
|
||||
padding: 40px 48px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
animation: scaleIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.export-spinner {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.spinner-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 3px solid rgba(0, 0, 0, 0.08);
|
||||
border-top-color: var(--ar-primary);
|
||||
border-radius: 50%;
|
||||
animation: spinRing 1s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-icon {
|
||||
color: var(--ar-primary);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinRing {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.export-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--ar-text-main);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.export-status {
|
||||
font-size: 14px;
|
||||
color: var(--ar-text-sub);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.export-modal {
|
||||
background: var(--bg-primary, #fff);
|
||||
padding: 0;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
min-width: 280px;
|
||||
color: var(--ar-text-main);
|
||||
overflow: hidden;
|
||||
|
||||
p {
|
||||
margin-top: 12px;
|
||||
color: var(--ar-text-sub);
|
||||
}
|
||||
|
||||
&.section-selector {
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ar-text-sub);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
padding: 20px 24px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
position: relative;
|
||||
padding: 16px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--ar-card-bg);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--ar-card-bg-hover);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--ar-primary);
|
||||
background: rgba(7, 193, 96, 0.08);
|
||||
}
|
||||
|
||||
.card-check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--ar-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&.selected .card-check {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
|
||||
.select-all-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
color: var(--ar-text-sub);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: var(--ar-primary);
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 词云样式
|
||||
.word-cloud-wrapper {
|
||||
margin: 24px auto 0;
|
||||
padding: 0;
|
||||
max-width: 520px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
--cloud-scale: clamp(0.72, 80vw / 520, 1);
|
||||
}
|
||||
|
||||
.word-cloud-inner {
|
||||
position: relative;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
margin: 0;
|
||||
border-radius: 50%;
|
||||
transform: scale(var(--cloud-scale));
|
||||
transform-origin: center;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -6%;
|
||||
background:
|
||||
radial-gradient(circle at 35% 45%, rgba(7, 193, 96, 0.12), transparent 55%),
|
||||
radial-gradient(circle at 65% 50%, rgba(242, 170, 0, 0.1), transparent 58%),
|
||||
radial-gradient(circle at 50% 65%, rgba(0, 0, 0, 0.04), transparent 60%);
|
||||
filter: blur(18px);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.word-tag {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
transition: transform 0.2s ease, color 0.2s ease;
|
||||
cursor: default;
|
||||
color: var(--ar-text-main);
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
animation: wordPopIn 0.55s ease forwards;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
|
||||
&:hover {
|
||||
transform: translate(-50%, -50%) scale(1.08);
|
||||
color: var(--ar-primary);
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wordPopIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.6);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: var(--final-opacity, 1);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.word-cloud-note {
|
||||
margin-top: 24px;
|
||||
font-size: 14px !important;
|
||||
color: var(--ar-text-sub) !important;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,1076 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Loader2, Download, Image, Check, X } from 'lucide-react'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import './AnnualReportWindow.scss'
|
||||
|
||||
// SVG 背景图案 (用于导出)
|
||||
const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
|
||||
|
||||
const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
|
||||
|
||||
// 绘制 SVG 图案背景到 canvas
|
||||
const drawPatternBackground = async (ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: string, isDark: boolean) => {
|
||||
// 先填充背景色
|
||||
ctx.fillStyle = bgColor
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
// 加载 SVG 图案
|
||||
const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const img = new window.Image()
|
||||
img.onload = () => {
|
||||
// 平铺绘制图案
|
||||
const pattern = ctx.createPattern(img, 'repeat')
|
||||
if (pattern) {
|
||||
ctx.fillStyle = pattern
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
}
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
interface TopContact {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
messageCount: number
|
||||
sentCount: number
|
||||
receivedCount: number
|
||||
}
|
||||
|
||||
interface MonthlyTopFriend {
|
||||
month: number
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
interface AnnualReportData {
|
||||
year: number
|
||||
totalMessages: number
|
||||
totalFriends: number
|
||||
coreFriends: TopContact[]
|
||||
monthlyTopFriends: MonthlyTopFriend[]
|
||||
peakDay: { date: string; messageCount: number; topFriend?: string; topFriendCount?: number } | null
|
||||
longestStreak: { friendName: string; days: number; startDate: string; endDate: string } | null
|
||||
activityHeatmap: { data: number[][] }
|
||||
midnightKing: { displayName: string; count: number; percentage: number } | null
|
||||
selfAvatarUrl?: string
|
||||
mutualFriend?: { displayName: string; avatarUrl?: string; sentCount: number; receivedCount: number; ratio: number } | null
|
||||
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
|
||||
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
|
||||
topPhrases?: { phrase: string; count: number }[]
|
||||
}
|
||||
|
||||
interface SectionInfo {
|
||||
id: string
|
||||
name: string
|
||||
ref: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
// 头像组件
|
||||
const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?: 'sm' | 'md' | 'lg' }) => {
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const initial = name?.[0] || '友'
|
||||
|
||||
return (
|
||||
<div className={`avatar ${size}`}>
|
||||
{url && !imgError ? (
|
||||
<img src={url} alt="" onError={() => setImgError(true)} crossOrigin="anonymous" />
|
||||
) : (
|
||||
<span>{initial}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 热力图组件
|
||||
const Heatmap = ({ data }: { data: number[][] }) => {
|
||||
const maxHeat = Math.max(...data.flat())
|
||||
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
|
||||
return (
|
||||
<div className="heatmap-wrapper">
|
||||
<div className="heatmap-header">
|
||||
<div></div>
|
||||
<div className="time-labels">
|
||||
{[0, 6, 12, 18].map(h => (
|
||||
<span key={h} style={{ gridColumn: h + 1 }}>{h}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="heatmap">
|
||||
<div className="heatmap-week-col">
|
||||
{weekLabels.map(w => <div key={w} className="week-label">{w}</div>)}
|
||||
</div>
|
||||
<div className="heatmap-grid">
|
||||
{data.map((row, wi) =>
|
||||
row.map((val, hi) => {
|
||||
const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1'
|
||||
return (
|
||||
<div
|
||||
key={`${wi}-${hi}`}
|
||||
className="h-cell"
|
||||
style={{ background: `rgba(7, 193, 96, ${alpha})` }}
|
||||
title={`${weekLabels[wi]} ${hi}:00 - ${val}条`}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 词云组件
|
||||
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
|
||||
const maxCount = words.length > 0 ? words[0].count : 1
|
||||
const topWords = words.slice(0, 32)
|
||||
const baseSize = 520
|
||||
|
||||
// 使用确定性随机数生成器
|
||||
const seededRandom = (seed: number) => {
|
||||
const x = Math.sin(seed) * 10000
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
// 计算词云位置
|
||||
const placedItems: { x: number; y: number; w: number; h: number }[] = []
|
||||
|
||||
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
|
||||
const halfW = w / 2
|
||||
const halfH = h / 2
|
||||
const dx = x - 50
|
||||
const dy = y - 50
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const maxR = 49 - Math.max(halfW, halfH)
|
||||
if (dist > maxR) return false
|
||||
|
||||
const pad = 1.8
|
||||
for (const p of placedItems) {
|
||||
if ((x - halfW - pad) < (p.x + p.w / 2) &&
|
||||
(x + halfW + pad) > (p.x - p.w / 2) &&
|
||||
(y - halfH - pad) < (p.y + p.h / 2) &&
|
||||
(y + halfH + pad) > (p.y - p.h / 2)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const wordItems = topWords.map((item, i) => {
|
||||
const ratio = item.count / maxCount
|
||||
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
|
||||
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
|
||||
const delay = (i * 0.04).toFixed(2)
|
||||
|
||||
// 计算词语宽度
|
||||
const charCount = Math.max(1, item.phrase.length)
|
||||
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
|
||||
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
|
||||
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
|
||||
const widthPx = fontSize * (charCount * widthFactor)
|
||||
const heightPx = fontSize * 1.1
|
||||
const widthPct = (widthPx / baseSize) * 100
|
||||
const heightPct = (heightPx / baseSize) * 100
|
||||
|
||||
// 寻找位置
|
||||
let x = 50, y = 50
|
||||
let placedOk = false
|
||||
const tries = i === 0 ? 1 : 420
|
||||
|
||||
for (let t = 0; t < tries; t++) {
|
||||
if (i === 0) {
|
||||
x = 50
|
||||
y = 50
|
||||
} else {
|
||||
const idx = i + t * 0.28
|
||||
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
|
||||
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
|
||||
x = 50 + radius * Math.cos(angle)
|
||||
y = 50 + radius * Math.sin(angle)
|
||||
}
|
||||
if (canPlace(x, y, widthPct, heightPct)) {
|
||||
placedOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!placedOk) return null
|
||||
placedItems.push({ x, y, w: widthPct, h: heightPct })
|
||||
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="word-tag"
|
||||
style={{
|
||||
'--final-opacity': opacity,
|
||||
left: `${x.toFixed(2)}%`,
|
||||
top: `${y.toFixed(2)}%`,
|
||||
fontSize: `${fontSize}px`,
|
||||
animationDelay: `${delay}s`,
|
||||
} as React.CSSProperties}
|
||||
title={`${item.phrase} (出现 ${item.count} 次)`}
|
||||
>
|
||||
{item.phrase}
|
||||
</span>
|
||||
)
|
||||
}).filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="word-cloud-wrapper">
|
||||
<div className="word-cloud-inner">
|
||||
{wordItems}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AnnualReportWindow() {
|
||||
const [reportData, setReportData] = useState<AnnualReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState('')
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
const [selectedSections, setSelectedSections] = useState<Set<string>>(new Set())
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [loadingStage, setLoadingStage] = useState('正在初始化...')
|
||||
|
||||
const { currentTheme, themeMode } = useThemeStore()
|
||||
|
||||
// 应用主题到独立窗口
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||
document.documentElement.setAttribute('data-mode', themeMode)
|
||||
}, [currentTheme, themeMode])
|
||||
|
||||
// Section refs
|
||||
const sectionRefs = {
|
||||
cover: useRef<HTMLElement>(null),
|
||||
overview: useRef<HTMLElement>(null),
|
||||
bestFriend: useRef<HTMLElement>(null),
|
||||
monthlyFriends: useRef<HTMLElement>(null),
|
||||
mutualFriend: useRef<HTMLElement>(null),
|
||||
socialInitiative: useRef<HTMLElement>(null),
|
||||
peakDay: useRef<HTMLElement>(null),
|
||||
streak: useRef<HTMLElement>(null),
|
||||
heatmap: useRef<HTMLElement>(null),
|
||||
midnightKing: useRef<HTMLElement>(null),
|
||||
responseSpeed: useRef<HTMLElement>(null),
|
||||
topPhrases: useRef<HTMLElement>(null),
|
||||
ranking: useRef<HTMLElement>(null),
|
||||
ending: useRef<HTMLElement>(null),
|
||||
}
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const yearParam = params.get('year')
|
||||
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
|
||||
generateReport(year)
|
||||
}, [])
|
||||
|
||||
const generateReport = async (year: number) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setLoadingProgress(0)
|
||||
|
||||
const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => {
|
||||
setLoadingProgress(payload.progress)
|
||||
setLoadingStage(payload.status)
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.annualReport.generateReport(year)
|
||||
removeProgressListener?.()
|
||||
setLoadingProgress(100)
|
||||
setLoadingStage('完成')
|
||||
|
||||
if (result.success && result.data) {
|
||||
setTimeout(() => {
|
||||
setReportData(result.data!)
|
||||
setIsLoading(false)
|
||||
}, 300)
|
||||
} else {
|
||||
setError(result.error || '生成报告失败')
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (e) {
|
||||
removeProgressListener?.()
|
||||
setError(String(e))
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => num.toLocaleString()
|
||||
|
||||
const getMostActiveTime = (data: number[][]) => {
|
||||
let maxHour = 0, maxWeekday = 0, maxVal = 0
|
||||
data.forEach((row, w) => {
|
||||
row.forEach((val, h) => {
|
||||
if (val > maxVal) { maxVal = val; maxHour = h; maxWeekday = w }
|
||||
})
|
||||
})
|
||||
const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
return { weekday: weekdayNames[maxWeekday], hour: maxHour }
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
if (seconds < 60) return `${seconds}秒`
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}分钟`
|
||||
return `${Math.round(seconds / 3600)}小时`
|
||||
}
|
||||
|
||||
// 获取可用的板块列表
|
||||
const getAvailableSections = (): SectionInfo[] => {
|
||||
if (!reportData) return []
|
||||
const sections: SectionInfo[] = [
|
||||
{ id: 'cover', name: '封面', ref: sectionRefs.cover },
|
||||
{ id: 'overview', name: '年度概览', ref: sectionRefs.overview },
|
||||
]
|
||||
if (reportData.coreFriends[0]) {
|
||||
sections.push({ id: 'bestFriend', name: '年度挚友', ref: sectionRefs.bestFriend })
|
||||
}
|
||||
sections.push({ id: 'monthlyFriends', name: '月度好友', ref: sectionRefs.monthlyFriends })
|
||||
if (reportData.mutualFriend) {
|
||||
sections.push({ id: 'mutualFriend', name: '双向奔赴', ref: sectionRefs.mutualFriend })
|
||||
}
|
||||
if (reportData.socialInitiative) {
|
||||
sections.push({ id: 'socialInitiative', name: '社交主动性', ref: sectionRefs.socialInitiative })
|
||||
}
|
||||
if (reportData.peakDay) {
|
||||
sections.push({ id: 'peakDay', name: '巅峰时刻', ref: sectionRefs.peakDay })
|
||||
}
|
||||
if (reportData.longestStreak) {
|
||||
sections.push({ id: 'streak', name: '聊天火花', ref: sectionRefs.streak })
|
||||
}
|
||||
sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap })
|
||||
if (reportData.midnightKing) {
|
||||
sections.push({ id: 'midnightKing', name: '深夜好友', ref: sectionRefs.midnightKing })
|
||||
}
|
||||
if (reportData.responseSpeed) {
|
||||
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed })
|
||||
}
|
||||
if (reportData.topPhrases && reportData.topPhrases.length > 0) {
|
||||
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
|
||||
}
|
||||
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
|
||||
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
||||
return sections
|
||||
}
|
||||
|
||||
// 导出单个板块 - 统一 16:9 尺寸
|
||||
const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => {
|
||||
const element = section.ref.current
|
||||
if (!element) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 固定输出尺寸 1920x1080 (16:9)
|
||||
const OUTPUT_WIDTH = 1920
|
||||
const OUTPUT_HEIGHT = 1080
|
||||
|
||||
try {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
activeEl?.blur?.()
|
||||
document.body.classList.add('exporting-snapshot')
|
||||
document.documentElement.classList.add('exporting-snapshot')
|
||||
|
||||
const originalStyle = element.style.cssText
|
||||
element.style.minHeight = 'auto'
|
||||
element.style.padding = '40px 20px'
|
||||
element.style.background = 'transparent'
|
||||
element.style.backgroundColor = 'transparent'
|
||||
element.style.boxShadow = 'none'
|
||||
|
||||
// 修复词云
|
||||
const wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement
|
||||
const wordTags = element.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
||||
let wordCloudOriginalStyle = ''
|
||||
const wordTagOriginalStyles: string[] = []
|
||||
|
||||
if (wordCloudInner) {
|
||||
wordCloudOriginalStyle = wordCloudInner.style.cssText
|
||||
wordCloudInner.style.transform = 'none'
|
||||
}
|
||||
|
||||
wordTags.forEach((tag, i) => {
|
||||
wordTagOriginalStyles[i] = tag.style.cssText
|
||||
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
|
||||
tag.style.animation = 'none'
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
|
||||
|
||||
const canvas = await html2canvas(element, {
|
||||
backgroundColor: 'transparent', // 透明背景,让 SVG 图案显示
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
onclone: (clonedDoc) => {
|
||||
clonedDoc.body.classList.add('exporting-snapshot')
|
||||
clonedDoc.documentElement.classList.add('exporting-snapshot')
|
||||
clonedDoc.getSelection?.()?.removeAllRanges()
|
||||
},
|
||||
})
|
||||
|
||||
// 恢复样式
|
||||
element.style.cssText = originalStyle
|
||||
if (wordCloudInner) {
|
||||
wordCloudInner.style.cssText = wordCloudOriginalStyle
|
||||
}
|
||||
wordTags.forEach((tag, i) => {
|
||||
tag.style.cssText = wordTagOriginalStyles[i]
|
||||
})
|
||||
document.body.classList.remove('exporting-snapshot')
|
||||
document.documentElement.classList.remove('exporting-snapshot')
|
||||
|
||||
// 创建固定 16:9 尺寸的画布
|
||||
const outputCanvas = document.createElement('canvas')
|
||||
outputCanvas.width = OUTPUT_WIDTH
|
||||
outputCanvas.height = OUTPUT_HEIGHT
|
||||
const ctx = outputCanvas.getContext('2d')!
|
||||
|
||||
// 绘制带 SVG 图案的背景
|
||||
const isDark = themeMode === 'dark'
|
||||
await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark)
|
||||
|
||||
// 边距 (留出更多空白)
|
||||
const PADDING = 80
|
||||
const contentWidth = OUTPUT_WIDTH - PADDING * 2
|
||||
const contentHeight = OUTPUT_HEIGHT - PADDING * 2
|
||||
|
||||
// 计算缩放和居中位置
|
||||
const srcRatio = canvas.width / canvas.height
|
||||
const dstRatio = contentWidth / contentHeight
|
||||
let drawWidth: number, drawHeight: number, drawX: number, drawY: number
|
||||
|
||||
if (srcRatio > dstRatio) {
|
||||
// 源图更宽,以宽度为准
|
||||
drawWidth = contentWidth
|
||||
drawHeight = contentWidth / srcRatio
|
||||
drawX = PADDING
|
||||
drawY = PADDING + (contentHeight - drawHeight) / 2
|
||||
} else {
|
||||
// 源图更高,以高度为准
|
||||
drawHeight = contentHeight
|
||||
drawWidth = contentHeight * srcRatio
|
||||
drawX = PADDING + (contentWidth - drawWidth) / 2
|
||||
drawY = PADDING
|
||||
}
|
||||
|
||||
ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight)
|
||||
|
||||
return { name: section.name, data: outputCanvas.toDataURL('image/png') }
|
||||
} catch (e) {
|
||||
document.body.classList.remove('exporting-snapshot')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 导出整个报告为长图
|
||||
const exportFullReport = async () => {
|
||||
if (!containerRef.current) {
|
||||
return
|
||||
}
|
||||
setIsExporting(true)
|
||||
setExportProgress('正在生成长图...')
|
||||
|
||||
try {
|
||||
const selection = window.getSelection()
|
||||
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
activeEl?.blur?.()
|
||||
document.body.classList.add('exporting-snapshot')
|
||||
document.documentElement.classList.add('exporting-snapshot')
|
||||
|
||||
const container = containerRef.current
|
||||
const sections = container.querySelectorAll('.section')
|
||||
const originalStyles: string[] = []
|
||||
|
||||
sections.forEach((section, i) => {
|
||||
const el = section as HTMLElement
|
||||
originalStyles[i] = el.style.cssText
|
||||
el.style.minHeight = 'auto'
|
||||
el.style.padding = '40px 0'
|
||||
})
|
||||
|
||||
// 修复词云导出问题
|
||||
const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement
|
||||
const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
||||
let wordCloudOriginalStyle = ''
|
||||
const wordTagOriginalStyles: string[] = []
|
||||
|
||||
if (wordCloudInner) {
|
||||
wordCloudOriginalStyle = wordCloudInner.style.cssText
|
||||
wordCloudInner.style.transform = 'none'
|
||||
}
|
||||
|
||||
wordTags.forEach((tag, i) => {
|
||||
wordTagOriginalStyles[i] = tag.style.cssText
|
||||
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
|
||||
tag.style.animation = 'none'
|
||||
})
|
||||
|
||||
// 等待样式生效
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
|
||||
// 获取计算后的背景色
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
backgroundColor: 'transparent', // 透明背景
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
onclone: (clonedDoc) => {
|
||||
clonedDoc.body.classList.add('exporting-snapshot')
|
||||
clonedDoc.documentElement.classList.add('exporting-snapshot')
|
||||
clonedDoc.getSelection?.()?.removeAllRanges()
|
||||
},
|
||||
})
|
||||
|
||||
// 恢复原始样式
|
||||
sections.forEach((section, i) => {
|
||||
const el = section as HTMLElement
|
||||
el.style.cssText = originalStyles[i]
|
||||
})
|
||||
|
||||
if (wordCloudInner) {
|
||||
wordCloudInner.style.cssText = wordCloudOriginalStyle
|
||||
}
|
||||
|
||||
wordTags.forEach((tag, i) => {
|
||||
tag.style.cssText = wordTagOriginalStyles[i]
|
||||
})
|
||||
document.body.classList.remove('exporting-snapshot')
|
||||
document.documentElement.classList.remove('exporting-snapshot')
|
||||
|
||||
// 创建带 SVG 图案背景的输出画布
|
||||
const outputCanvas = document.createElement('canvas')
|
||||
outputCanvas.width = canvas.width
|
||||
outputCanvas.height = canvas.height
|
||||
const ctx = outputCanvas.getContext('2d')!
|
||||
|
||||
// 绘制 SVG 图案背景
|
||||
const isDark = themeMode === 'dark'
|
||||
await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark)
|
||||
|
||||
// 绘制内容
|
||||
ctx.drawImage(canvas, 0, 0)
|
||||
|
||||
const dataUrl = outputCanvas.toDataURL('image/png')
|
||||
const link = document.createElement('a')
|
||||
link.download = `${reportData?.year}年度报告.png`
|
||||
link.href = dataUrl
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} catch (e) {
|
||||
alert('导出失败: ' + String(e))
|
||||
} finally {
|
||||
document.body.classList.remove('exporting-snapshot')
|
||||
document.documentElement.classList.remove('exporting-snapshot')
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
}
|
||||
}
|
||||
|
||||
// 导出选中的板块
|
||||
const exportSelectedSections = async () => {
|
||||
const sections = getAvailableSections().filter(s => selectedSections.has(s.id))
|
||||
if (sections.length === 0) {
|
||||
alert('请至少选择一个板块')
|
||||
return
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
setShowExportModal(false)
|
||||
|
||||
const exportedImages: { name: string; data: string }[] = []
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i]
|
||||
setExportProgress(`正在导出: ${section.name} (${i + 1}/${sections.length})`)
|
||||
|
||||
const result = await exportSection(section)
|
||||
if (result) {
|
||||
exportedImages.push(result)
|
||||
}
|
||||
}
|
||||
|
||||
if (exportedImages.length === 0) {
|
||||
alert('导出失败')
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
return
|
||||
}
|
||||
|
||||
const dirResult = await window.electronAPI.dialog.openDirectory({
|
||||
title: '选择导出文件夹',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
if (dirResult.canceled || !dirResult.filePaths?.[0]) {
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
return
|
||||
}
|
||||
|
||||
setExportProgress('正在写入文件...')
|
||||
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||
baseDir: dirResult.filePaths[0],
|
||||
folderName: `${reportData?.year}年度报告_分模块`,
|
||||
images: exportedImages.map((img) => ({
|
||||
name: `${reportData?.year}年度报告_${img.name}.png`,
|
||||
dataUrl: img.data
|
||||
}))
|
||||
})
|
||||
|
||||
if (!exportResult.success) {
|
||||
alert('导出失败: ' + (exportResult.error || '未知错误'))
|
||||
}
|
||||
|
||||
setIsExporting(false)
|
||||
setExportProgress('')
|
||||
setSelectedSections(new Set())
|
||||
}
|
||||
|
||||
// 切换板块选择
|
||||
const toggleSection = (id: string) => {
|
||||
const newSet = new Set(selectedSections)
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id)
|
||||
} else {
|
||||
newSet.add(id)
|
||||
}
|
||||
setSelectedSections(newSet)
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const toggleAll = () => {
|
||||
const sections = getAvailableSections()
|
||||
if (selectedSections.size === sections.length) {
|
||||
setSelectedSections(new Set())
|
||||
} else {
|
||||
setSelectedSections(new Set(sections.map(s => s.id)))
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="annual-report-window loading">
|
||||
<div className="loading-ring">
|
||||
<svg viewBox="0 0 100 100">
|
||||
<circle className="ring-bg" cx="50" cy="50" r="42" />
|
||||
<circle
|
||||
className="ring-progress"
|
||||
cx="50" cy="50" r="42"
|
||||
style={{ strokeDashoffset: 264 - (264 * loadingProgress / 100) }}
|
||||
/>
|
||||
</svg>
|
||||
<span className="ring-text">{loadingProgress}%</span>
|
||||
</div>
|
||||
<p className="loading-stage">{loadingStage}</p>
|
||||
<p className="loading-hint">进行中</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="annual-report-window error">
|
||||
<p>生成报告失败: {error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!reportData) {
|
||||
return (
|
||||
<div className="annual-report-window error">
|
||||
<p>暂无数据</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData
|
||||
const topFriend = coreFriends[0]
|
||||
const mostActive = getMostActiveTime(activityHeatmap.data)
|
||||
const socialStoryName = topFriend?.displayName || '好友'
|
||||
|
||||
return (
|
||||
<div className="annual-report-window">
|
||||
<div className="drag-region" />
|
||||
|
||||
{/* 背景装饰 */}
|
||||
<div className="bg-decoration">
|
||||
<div className="deco-circle c1" />
|
||||
<div className="deco-circle c2" />
|
||||
<div className="deco-circle c3" />
|
||||
<div className="deco-circle c4" />
|
||||
<div className="deco-circle c5" />
|
||||
</div>
|
||||
|
||||
{/* 浮动操作按钮 */}
|
||||
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); setShowExportModal(true) }} title="分模块导出">
|
||||
<Image size={18} />
|
||||
</button>
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图">
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button className="fab-main" onClick={() => setFabOpen(!fabOpen)}>
|
||||
{fabOpen ? <X size={22} /> : <Download size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 导出进度 */}
|
||||
{isExporting && (
|
||||
<div className="export-overlay">
|
||||
<div className="export-progress-modal">
|
||||
<div className="export-spinner">
|
||||
<div className="spinner-ring"></div>
|
||||
<Download size={24} className="spinner-icon" />
|
||||
</div>
|
||||
<p className="export-title">正在导出</p>
|
||||
<p className="export-status">{exportProgress}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模块选择弹窗 */}
|
||||
{showExportModal && (
|
||||
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
|
||||
<div className="export-modal section-selector" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>选择要导出的板块</h3>
|
||||
<button className="close-btn" onClick={() => setShowExportModal(false)}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="section-grid">
|
||||
{getAvailableSections().map(section => (
|
||||
<div
|
||||
key={section.id}
|
||||
className={`section-card ${selectedSections.has(section.id) ? 'selected' : ''}`}
|
||||
onClick={() => toggleSection(section.id)}
|
||||
>
|
||||
<div className="card-check">
|
||||
{selectedSections.has(section.id) && <Check size={14} />}
|
||||
</div>
|
||||
<span>{section.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="select-all-btn" onClick={toggleAll}>
|
||||
{selectedSections.size === getAvailableSections().length ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<button
|
||||
className="confirm-btn"
|
||||
onClick={exportSelectedSections}
|
||||
disabled={selectedSections.size === 0}
|
||||
>
|
||||
导出 {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="report-scroll-view">
|
||||
<div className="report-container" ref={containerRef}>
|
||||
|
||||
{/* 封面 */}
|
||||
<section className="section" ref={sectionRefs.cover}>
|
||||
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
|
||||
<h1 className="hero-title">{year}年<br />微信聊天报告</h1>
|
||||
<hr className="divider" />
|
||||
<p className="hero-desc">每一条消息背后<br />都藏着一段独特的故事</p>
|
||||
</section>
|
||||
|
||||
{/* 年度概览 */}
|
||||
<section className="section" ref={sectionRefs.overview}>
|
||||
<div className="label-text">年度概览</div>
|
||||
<h2 className="hero-title">你和你的朋友们<br />互相发过</h2>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{formatNumber(totalMessages)}</span>
|
||||
<span className="stat-unit">条消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
在这段时光里,你与 <span className="hl">{formatNumber(totalFriends)}</span> 位好友交换过喜怒哀乐。
|
||||
<br />每一个对话,都是一段故事的开始。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 年度挚友 */}
|
||||
{topFriend && (
|
||||
<section className="section" ref={sectionRefs.bestFriend}>
|
||||
<div className="label-text">年度挚友</div>
|
||||
<h2 className="hero-title">{topFriend.displayName}</h2>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{formatNumber(topFriend.messageCount)}</span>
|
||||
<span className="stat-unit">条消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
你发出 <span className="hl">{formatNumber(topFriend.sentCount)}</span> 条 ·
|
||||
TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span> 条
|
||||
</p>
|
||||
<br/>
|
||||
<p className="hero-desc">
|
||||
在一起,就可以
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 月度好友 */}
|
||||
<section className="section" ref={sectionRefs.monthlyFriends}>
|
||||
<div className="label-text">月度好友</div>
|
||||
<h2 className="hero-title">{year}年月度好友</h2>
|
||||
<p className="hero-desc">根据12个月的聊天习惯</p>
|
||||
<div className="monthly-orbit">
|
||||
{monthlyTopFriends.map((m, i) => (
|
||||
<div key={m.month} className="monthly-item" style={{ '--i': i } as React.CSSProperties}>
|
||||
<div className="month-label">{m.month}月</div>
|
||||
<Avatar url={m.avatarUrl} name={m.displayName} size="sm" />
|
||||
<div className="month-name">{m.displayName}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="monthly-center">
|
||||
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="hero-desc">只要你想<br />我一直在</p>
|
||||
</section>
|
||||
|
||||
{/* 双向奔赴 */}
|
||||
{mutualFriend && (
|
||||
<section className="section" ref={sectionRefs.mutualFriend}>
|
||||
<div className="label-text">双向奔赴</div>
|
||||
<h2 className="hero-title">默契与平衡</h2>
|
||||
<div className="mutual-visual">
|
||||
<div className="mutual-side you">
|
||||
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||
<div className="mutual-arrow">
|
||||
<span className="arrow-count">{formatNumber(mutualFriend.sentCount)}</span>
|
||||
<div className="arrow-line">→</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mutual-center">
|
||||
<div className="mutual-icon">🤝</div>
|
||||
<div className="mutual-ratio">{mutualFriend.ratio}</div>
|
||||
</div>
|
||||
<div className="mutual-side friend">
|
||||
<div className="mutual-arrow reverse">
|
||||
<span className="arrow-count">{formatNumber(mutualFriend.receivedCount)}</span>
|
||||
<div className="arrow-line">←</div>
|
||||
</div>
|
||||
<Avatar url={mutualFriend.avatarUrl} name={mutualFriend.displayName} size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mutual-name-tag">{mutualFriend.displayName}</div>
|
||||
<p className="hero-desc">
|
||||
你们的互动比例接近 <span className="hl">{mutualFriend.ratio}</span>。
|
||||
<br />你来我往,势均力敌。
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 社交主动性 */}
|
||||
{socialInitiative && (
|
||||
<section className="section" ref={sectionRefs.socialInitiative}>
|
||||
<div className="label-text">社交主动性</div>
|
||||
<h2 className="hero-title">主动才有故事</h2>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{socialInitiative.initiativeRate}%</span>
|
||||
<span className="stat-unit">的对话由你发起</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
面对 <span className="hl">{socialStoryName}</span> 的时候,你总是那个先开口的人。
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 巅峰时刻 */}
|
||||
{peakDay && (
|
||||
<section className="section" ref={sectionRefs.peakDay}>
|
||||
<div className="label-text">巅峰时刻</div>
|
||||
<h2 className="hero-title">{peakDay.date}</h2>
|
||||
<p className="hero-desc">一天里你一共发了</p>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{formatNumber(peakDay.messageCount)}</span>
|
||||
<span className="stat-unit">条消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
在这个快节奏的世界,有人正陪在你身边听你慢慢地讲
|
||||
<br />那天,你和 <span className="hl">{peakDay.topFriend || '好友'}</span> 的 {formatNumber(peakDay.topFriendCount || 0)} 条消息见证着这一切
|
||||
<br />有些话,只想对你说
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 聊天火花 */}
|
||||
{longestStreak && (
|
||||
<section className="section" ref={sectionRefs.streak}>
|
||||
<div className="label-text">持之以恒</div>
|
||||
<h2 className="hero-title">聊天火花</h2>
|
||||
<p className="hero-desc">与 <span className="hl">{longestStreak.friendName}</span> 持续了</p>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{longestStreak.days}</span>
|
||||
<span className="stat-unit">天</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
从 {longestStreak.startDate} 到 {longestStreak.endDate}
|
||||
</p>
|
||||
<p className="hero-desc">陪伴,是最长情的告白</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 作息规律 */}
|
||||
<section className="section" ref={sectionRefs.heatmap}>
|
||||
<div className="label-text">作息规律</div>
|
||||
<h2 className="hero-title">时间的痕迹</h2>
|
||||
<p className="hero-desc active-time">
|
||||
在 <span className="hl">{mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00</span> 最活跃
|
||||
</p>
|
||||
<Heatmap data={activityHeatmap.data} />
|
||||
</section>
|
||||
|
||||
{/* 深夜好友 */}
|
||||
{midnightKing && (
|
||||
<section className="section" ref={sectionRefs.midnightKing}>
|
||||
<div className="label-text">深夜好友</div>
|
||||
<h2 className="hero-title">当城市睡去</h2>
|
||||
<p className="hero-desc">这一年你留下了</p>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{midnightKing.count}</span>
|
||||
<span className="stat-unit">条深夜的消息</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你。
|
||||
<br />你和Ta的对话占深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>。
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 回应速度 */}
|
||||
{responseSpeed && (
|
||||
<section className="section" ref={sectionRefs.responseSpeed}>
|
||||
<div className="label-text">回应速度</div>
|
||||
<h2 className="hero-title">念念不忘,必有回响</h2>
|
||||
<div className="big-stat">
|
||||
<span className="stat-num">{formatTime(responseSpeed.avgResponseTime)}</span>
|
||||
<span className="stat-unit">是你的平均回复时间</span>
|
||||
</div>
|
||||
<p className="hero-desc">
|
||||
你回复 <span className="hl">{responseSpeed.fastestFriend}</span> 最快
|
||||
<br />平均只需 <span className="gold">{formatTime(responseSpeed.fastestTime)}</span>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 年度常用语 - 词云 */}
|
||||
{topPhrases && topPhrases.length > 0 && (
|
||||
<section className="section" ref={sectionRefs.topPhrases}>
|
||||
<div className="label-text">年度常用语</div>
|
||||
<h2 className="hero-title">你在{year}年的年度常用语</h2>
|
||||
<p className="hero-desc">
|
||||
这一年,你说得最多的是:
|
||||
<br />
|
||||
<span className="hl" style={{ fontSize: '20px' }}>
|
||||
{topPhrases.slice(0, 3).map(p => p.phrase).join('、')}
|
||||
</span>
|
||||
</p>
|
||||
<WordCloud words={topPhrases} />
|
||||
<p className="hero-desc word-cloud-note">颜色越深代表出现频率越高</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 好友排行 */}
|
||||
<section className="section" ref={sectionRefs.ranking}>
|
||||
<div className="label-text">好友排行</div>
|
||||
<h2 className="hero-title">聊得最多的人</h2>
|
||||
|
||||
{/* 领奖台 - 前三名 */}
|
||||
<div className="podium">
|
||||
{/* 第二名 - 左边 */}
|
||||
{coreFriends[1] && (
|
||||
<div className="podium-item second">
|
||||
<Avatar url={coreFriends[1].avatarUrl} name={coreFriends[1].displayName} size="lg" />
|
||||
<div className="podium-name">{coreFriends[1].displayName}</div>
|
||||
<div className="podium-count">{formatNumber(coreFriends[1].messageCount)} 条</div>
|
||||
<div className="podium-stand">
|
||||
<span className="podium-rank">2</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 第一名 - 中间最高 */}
|
||||
{coreFriends[0] && (
|
||||
<div className="podium-item first">
|
||||
<div className="crown">👑</div>
|
||||
<Avatar url={coreFriends[0].avatarUrl} name={coreFriends[0].displayName} size="lg" />
|
||||
<div className="podium-name">{coreFriends[0].displayName}</div>
|
||||
<div className="podium-count">{formatNumber(coreFriends[0].messageCount)} 条</div>
|
||||
<div className="podium-stand">
|
||||
<span className="podium-rank">1</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 第三名 - 右边 */}
|
||||
{coreFriends[2] && (
|
||||
<div className="podium-item third">
|
||||
<Avatar url={coreFriends[2].avatarUrl} name={coreFriends[2].displayName} size="lg" />
|
||||
<div className="podium-name">{coreFriends[2].displayName}</div>
|
||||
<div className="podium-count">{formatNumber(coreFriends[2].messageCount)} 条</div>
|
||||
<div className="podium-stand">
|
||||
<span className="podium-rank">3</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 结尾 */}
|
||||
<section className="section ending" ref={sectionRefs.ending}>
|
||||
<h2 className="hero-title">尾声</h2>
|
||||
<p className="hero-desc">
|
||||
我们总是在向前走
|
||||
<br />却很少有机会回头看看
|
||||
<br />如果这份报告让你有所触动,不妨把它分享给你在意的人
|
||||
<br />愿新的一年,
|
||||
<br />所有期待,皆有回声。
|
||||
</p>
|
||||
<div className="ending-year">{year}</div>
|
||||
<div className="ending-brand">WEFLOW</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnnualReportWindow
|
||||
@@ -0,0 +1,1845 @@
|
||||
.chat-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
|
||||
// 独立窗口模式 - EchoTrace 特色风格(使用主题变量)
|
||||
&.standalone {
|
||||
height: 100vh;
|
||||
gap: 0;
|
||||
background: var(--bg-gradient);
|
||||
|
||||
.session-sidebar {
|
||||
flex: none;
|
||||
background: var(--card-bg);
|
||||
border-radius: 0;
|
||||
border-right: 1px solid var(--border-color);
|
||||
-webkit-app-region: no-drag;
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.session-header {
|
||||
padding-top: 38px;
|
||||
padding-bottom: 12px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
background: transparent;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
h2 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-light);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-search {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-list {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 12px 16px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary-light);
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-time {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.session-summary {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.session-avatar {
|
||||
border-radius: 50%;
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
background: var(--primary-gradient);
|
||||
box-shadow: 0 2px 8px var(--primary-light);
|
||||
}
|
||||
|
||||
.connection-error {
|
||||
background: rgba(220, 53, 69, 0.08);
|
||||
border: 1px solid rgba(220, 53, 69, 0.2);
|
||||
margin: 8px 16px;
|
||||
border-radius: 10px;
|
||||
|
||||
svg,
|
||||
span {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--danger);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-sessions {
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
.skeleton-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.skeleton-content .skeleton-line {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-sessions {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
opacity: 0.3;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-area {
|
||||
border-radius: 0;
|
||||
background: var(--bg-secondary);
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.message-header {
|
||||
height: auto;
|
||||
padding: 0 24px;
|
||||
padding-top: 38px;
|
||||
padding-bottom: 12px;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
-webkit-app-region: drag;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.session-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
background: var(--chat-pattern);
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 20px 24px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
opacity: 0.3;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-to-bottom {
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 15px var(--primary-light);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
box-shadow: 0 6px 20px var(--primary-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 65%;
|
||||
|
||||
&.sent {
|
||||
.bubble-content {
|
||||
background: var(--primary-gradient);
|
||||
color: #fff;
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
box-shadow: 0 2px 10px var(--primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
&.received {
|
||||
.bubble-content {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.system {
|
||||
max-width: 100%;
|
||||
|
||||
.bubble-content {
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.emoji {
|
||||
.bubble-content {
|
||||
background: transparent !important;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bubble-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.quoted-message {
|
||||
background: var(--bg-tertiary);
|
||||
border-left: 2px solid var(--primary);
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
.quoted-sender {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.quoted-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.time-divider,
|
||||
.date-divider {
|
||||
span {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.date-divider span {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.load-more-trigger {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-chat {
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-messages {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
width: 4px;
|
||||
margin-left: -2px;
|
||||
margin-right: -2px;
|
||||
background: transparent;
|
||||
cursor: col-resize;
|
||||
-webkit-app-region: no-drag;
|
||||
transition: background 0.2s;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&.resizing .resize-handle {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 左侧会话列表
|
||||
.session-sidebar {
|
||||
flex: 0 0 30%;
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
padding: 16px 16px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 56px;
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
animation: searchExpand 0.25s ease-out;
|
||||
min-width: 0;
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.close-search {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes searchExpand {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scaleX(0.8);
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
transform-origin: right center;
|
||||
}
|
||||
}
|
||||
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
// 滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
.session-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-letter {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.avatar-skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg-tertiary);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
&.group {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
}
|
||||
|
||||
&.loading {
|
||||
background: var(--bg-tertiary);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.session-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.session-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-summary {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: #f56c6c;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 右侧消息区域
|
||||
.message-area {
|
||||
flex: 1 1 70%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.session-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-messages-btn {
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: var(--chat-pattern);
|
||||
background-color: var(--bg-tertiary);
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag !important;
|
||||
|
||||
// 滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-list * {
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
// 回到底部按钮
|
||||
.scroll-to-bottom {
|
||||
position: sticky;
|
||||
bottom: 20px;
|
||||
align-self: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// 拖动调节条
|
||||
.resize-handle {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
// 拖动时禁用选择
|
||||
.chat-page.resizing {
|
||||
user-select: none;
|
||||
|
||||
.resize-handle {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
// 消息包裹器 - 控制整行对齐
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&.sent {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&.received {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&.system {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.message-wrapper.new-message {
|
||||
animation: messagePop 0.35s ease-out;
|
||||
}
|
||||
|
||||
@keyframes messagePop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-trigger {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
|
||||
&.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息气泡
|
||||
.message-bubble {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 70%;
|
||||
align-items: flex-start;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
// 自己发送的消息 - 右侧绿色
|
||||
&.sent {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.bubble-content {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// 对方发送的消息 - 左侧白色
|
||||
&.received {
|
||||
.bubble-content {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.system {
|
||||
max-width: 85%;
|
||||
|
||||
.bubble-avatar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bubble-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bubble-content {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bubble-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-letter {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble-content {
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// 表情包消息
|
||||
.message-bubble.emoji {
|
||||
.bubble-content {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-image {
|
||||
max-width: 120px;
|
||||
max-height: 120px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.emoji-loading {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-unavailable {
|
||||
width: 100px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
color: var(--text-quaternary);
|
||||
|
||||
svg {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
// 图片消息
|
||||
.message-bubble.image {
|
||||
.bubble-content {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image-message {
|
||||
max-width: 260px;
|
||||
max-height: 220px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.image-message-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.image-update-button {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.image-update-button:hover {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.image-loading,
|
||||
.image-unavailable {
|
||||
width: 160px;
|
||||
height: 120px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.image-unavailable {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: var(--bg-tertiary);
|
||||
text-align: center;
|
||||
-webkit-app-region: no-drag;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
svg {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.image-unavailable.clicked {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 0 0 2px var(--primary-light);
|
||||
}
|
||||
|
||||
.image-unavailable:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.image-action {
|
||||
font-size: 11px;
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
|
||||
.image-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
|
||||
img {
|
||||
max-width: 88vw;
|
||||
max-height: 88vh;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// 语音消息
|
||||
.voice-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
min-width: 140px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-bubble.sent .voice-message {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.voice-play-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.voice-message.playing .voice-play-btn {
|
||||
box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.voice-wave {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 18px;
|
||||
|
||||
span {
|
||||
width: 3px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--text-tertiary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-message.playing .voice-wave span {
|
||||
animation: voicePulse 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.voice-message.playing .voice-wave span:nth-child(2) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.voice-message.playing .voice-wave span:nth-child(3) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.voice-message.playing .voice-wave span:nth-child(4) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.voice-message.playing .voice-wave span:nth-child(5) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.voice-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
|
||||
.voice-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.voice-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.voice-duration {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.voice-loading,
|
||||
.voice-error {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.voice-error {
|
||||
color: #d9480f;
|
||||
}
|
||||
|
||||
@keyframes voicePulse {
|
||||
0% {
|
||||
height: 6px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
height: 16px;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
height: 6px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 群聊发送者名称
|
||||
.sender-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
// 引用消息样式
|
||||
.quoted-message {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-left: 2px solid var(--primary);
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
|
||||
.quoted-sender {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
|
||||
&::after {
|
||||
content: ':';
|
||||
}
|
||||
}
|
||||
|
||||
.quoted-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// 自己发送的消息中的引用样式
|
||||
.message-bubble.sent .quoted-message {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-left-color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
.quoted-sender {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.quoted-text {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 气泡内容区域(包含名字和内容)
|
||||
.bubble-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.bubble-content {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
// 时间分隔
|
||||
.time-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
// 日期分隔
|
||||
.date-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.empty-chat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
gap: 12px;
|
||||
|
||||
svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-sessions {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
|
||||
&.hint {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
.loading-sessions {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
|
||||
.skeleton-line {
|
||||
height: 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
animation: pulse 1.5s infinite;
|
||||
|
||||
&:first-child {
|
||||
width: 50%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
width: 80%;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 连接错误
|
||||
.connection-error {
|
||||
margin: 0 16px 12px;
|
||||
padding: 10px 12px;
|
||||
background: #fef0f0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
svg {
|
||||
color: #f56c6c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
background: #f56c6c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f78989;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 消息加载
|
||||
.loading-messages {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// 会话详情面板
|
||||
.detail-panel {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
background: var(--card-bg);
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideInRight 0.2s ease;
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
h4 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
|
||||
&.highlight {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
|
||||
.db-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-count {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1465 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import type { ChatSession, Message } from '../types/models'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
import './ChatPage.scss'
|
||||
|
||||
interface ChatPageProps {
|
||||
// 保留接口以备将来扩展
|
||||
}
|
||||
|
||||
|
||||
interface SessionDetail {
|
||||
wxid: string
|
||||
displayName: string
|
||||
remark?: string
|
||||
nickName?: string
|
||||
alias?: string
|
||||
avatarUrl?: string
|
||||
messageCount: number
|
||||
firstMessageTime?: number
|
||||
latestMessageTime?: number
|
||||
messageTables: { dbName: string; tableName: string; count: number }[]
|
||||
}
|
||||
|
||||
// 头像组件 - 支持骨架屏加载
|
||||
function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) {
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const isGroup = session.username.includes('@chatroom')
|
||||
|
||||
const getAvatarLetter = (): string => {
|
||||
const name = session.displayName || session.username
|
||||
if (!name) return '?'
|
||||
const chars = [...name]
|
||||
return chars[0] || '?'
|
||||
}
|
||||
|
||||
// 当 avatarUrl 变化时重置状态
|
||||
useEffect(() => {
|
||||
setImageLoaded(false)
|
||||
setImageError(false)
|
||||
}, [session.avatarUrl])
|
||||
|
||||
// 检查图片是否已经从缓存加载完成
|
||||
useEffect(() => {
|
||||
if (imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
||||
setImageLoaded(true)
|
||||
}
|
||||
}, [session.avatarUrl])
|
||||
|
||||
const hasValidUrl = session.avatarUrl && !imageError
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`session-avatar ${isGroup ? 'group' : ''} ${hasValidUrl && !imageLoaded ? 'loading' : ''}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{hasValidUrl ? (
|
||||
<>
|
||||
{!imageLoaded && <div className="avatar-skeleton" />}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={session.avatarUrl}
|
||||
alt=""
|
||||
className={imageLoaded ? 'loaded' : ''}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span className="avatar-letter">{getAvatarLetter()}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatPage(_props: ChatPageProps) {
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connectionError,
|
||||
sessions,
|
||||
filteredSessions,
|
||||
currentSessionId,
|
||||
isLoadingSessions,
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
isLoadingMore,
|
||||
hasMoreMessages,
|
||||
searchKeyword,
|
||||
setConnected,
|
||||
setConnecting,
|
||||
setConnectionError,
|
||||
setSessions,
|
||||
setFilteredSessions,
|
||||
setCurrentSession,
|
||||
setLoadingSessions,
|
||||
setMessages,
|
||||
appendMessages,
|
||||
setLoadingMessages,
|
||||
setLoadingMore,
|
||||
setHasMoreMessages,
|
||||
setSearchKeyword
|
||||
} = useChatStore()
|
||||
|
||||
const messageListRef = useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const [currentOffset, setCurrentOffset] = useState(0)
|
||||
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(260)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [showDetailPanel, setShowDetailPanel] = useState(false)
|
||||
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
|
||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||
|
||||
|
||||
const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys])
|
||||
const messageKeySetRef = useRef<Set<string>>(new Set())
|
||||
const lastMessageTimeRef = useRef(0)
|
||||
const sessionMapRef = useRef<Map<string, ChatSession>>(new Map())
|
||||
const sessionsRef = useRef<ChatSession[]>([])
|
||||
const currentSessionRef = useRef<string | null>(null)
|
||||
const isLoadingMessagesRef = useRef(false)
|
||||
const isLoadingMoreRef = useRef(false)
|
||||
const isConnectedRef = useRef(false)
|
||||
const searchKeywordRef = useRef('')
|
||||
const preloadImageKeysRef = useRef<Set<string>>(new Set())
|
||||
const lastPreloadSessionRef = useRef<string | null>(null)
|
||||
|
||||
// 加载当前用户头像
|
||||
const loadMyAvatar = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMyAvatarUrl()
|
||||
if (result.success && result.avatarUrl) {
|
||||
setMyAvatarUrl(result.avatarUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载用户头像失败:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 加载会话详情
|
||||
const loadSessionDetail = useCallback(async (sessionId: string) => {
|
||||
setIsLoadingDetail(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getSessionDetail(sessionId)
|
||||
if (result.success && result.detail) {
|
||||
setSessionDetail(result.detail)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载会话详情失败:', e)
|
||||
} finally {
|
||||
setIsLoadingDetail(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 切换详情面板
|
||||
const toggleDetailPanel = useCallback(() => {
|
||||
if (!showDetailPanel && currentSessionId) {
|
||||
loadSessionDetail(currentSessionId)
|
||||
}
|
||||
setShowDetailPanel(!showDetailPanel)
|
||||
}, [showDetailPanel, currentSessionId, loadSessionDetail])
|
||||
|
||||
// 连接数据库
|
||||
const connect = useCallback(async () => {
|
||||
setConnecting(true)
|
||||
setConnectionError(null)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.connect()
|
||||
if (result.success) {
|
||||
setConnected(true)
|
||||
await loadSessions()
|
||||
await loadMyAvatar()
|
||||
} else {
|
||||
setConnectionError(result.error || '连接失败')
|
||||
}
|
||||
} catch (e) {
|
||||
setConnectionError(String(e))
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}, [loadMyAvatar])
|
||||
|
||||
// 加载会话列表
|
||||
const loadSessions = async (options?: { silent?: boolean }) => {
|
||||
if (options?.silent) {
|
||||
setIsRefreshingSessions(true)
|
||||
} else {
|
||||
setLoadingSessions(true)
|
||||
}
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getSessions()
|
||||
if (result.success && result.sessions) {
|
||||
const nextSessions = options?.silent ? mergeSessions(result.sessions) : result.sessions
|
||||
setSessions(nextSessions)
|
||||
} else if (!result.success) {
|
||||
setConnectionError(result.error || '获取会话失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载会话失败:', e)
|
||||
setConnectionError('加载会话失败')
|
||||
} finally {
|
||||
if (options?.silent) {
|
||||
setIsRefreshingSessions(false)
|
||||
} else {
|
||||
setLoadingSessions(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新会话列表
|
||||
const handleRefresh = async () => {
|
||||
await loadSessions({ silent: true })
|
||||
}
|
||||
|
||||
// 刷新当前会话消息(增量更新新消息)
|
||||
const [isRefreshingMessages, setIsRefreshingMessages] = useState(false)
|
||||
const handleRefreshMessages = async () => {
|
||||
if (!currentSessionId || isRefreshingMessages) return
|
||||
setIsRefreshingMessages(true)
|
||||
try {
|
||||
// 获取最新消息并增量添加
|
||||
const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50)
|
||||
if (!result.success || !result.messages) {
|
||||
return
|
||||
}
|
||||
const existing = new Set(messages.map(getMessageKey))
|
||||
const lastMsg = messages[messages.length - 1]
|
||||
const lastTime = lastMsg?.createTime ?? 0
|
||||
const newMessages = result.messages.filter((msg) => {
|
||||
const key = getMessageKey(msg)
|
||||
if (existing.has(key)) return false
|
||||
if (lastTime > 0 && msg.createTime < lastTime) return false
|
||||
return true
|
||||
})
|
||||
if (newMessages.length > 0) {
|
||||
appendMessages(newMessages, false)
|
||||
flashNewMessages(newMessages.map(getMessageKey))
|
||||
// 滚动到底部
|
||||
requestAnimationFrame(() => {
|
||||
if (messageListRef.current) {
|
||||
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('刷新消息失败:', e)
|
||||
} finally {
|
||||
setIsRefreshingMessages(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载消息
|
||||
const loadMessages = async (sessionId: string, offset = 0) => {
|
||||
const listEl = messageListRef.current
|
||||
const session = sessionMapRef.current.get(sessionId)
|
||||
const unreadCount = session?.unreadCount ?? 0
|
||||
const messageLimit = offset === 0 && unreadCount > 99 ? 30 : 50
|
||||
|
||||
if (offset === 0) {
|
||||
setLoadingMessages(true)
|
||||
setMessages([])
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
// 记录加载前的第一条消息元素
|
||||
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit)
|
||||
if (result.success && result.messages) {
|
||||
if (offset === 0) {
|
||||
setMessages(result.messages)
|
||||
// 首次加载滚动到底部
|
||||
requestAnimationFrame(() => {
|
||||
if (messageListRef.current) {
|
||||
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
|
||||
}
|
||||
})
|
||||
} else {
|
||||
appendMessages(result.messages, true)
|
||||
// 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置
|
||||
if (firstMsgEl && listEl) {
|
||||
requestAnimationFrame(() => {
|
||||
listEl.scrollTop = firstMsgEl.offsetTop - 80
|
||||
})
|
||||
}
|
||||
}
|
||||
setHasMoreMessages(result.hasMore ?? false)
|
||||
setCurrentOffset(offset + result.messages.length)
|
||||
} else if (!result.success) {
|
||||
setConnectionError(result.error || '加载消息失败')
|
||||
setHasMoreMessages(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载消息失败:', e)
|
||||
setConnectionError('加载消息失败')
|
||||
setHasMoreMessages(false)
|
||||
} finally {
|
||||
setLoadingMessages(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 选择会话
|
||||
const handleSelectSession = (session: ChatSession) => {
|
||||
if (session.username === currentSessionId) return
|
||||
setCurrentSession(session.username)
|
||||
setCurrentOffset(0)
|
||||
loadMessages(session.username, 0)
|
||||
// 重置详情面板
|
||||
setSessionDetail(null)
|
||||
if (showDetailPanel) {
|
||||
loadSessionDetail(session.username)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
const handleSearch = (keyword: string) => {
|
||||
setSearchKeyword(keyword)
|
||||
if (!keyword.trim()) {
|
||||
setFilteredSessions(sessions)
|
||||
return
|
||||
}
|
||||
const lower = keyword.toLowerCase()
|
||||
const filtered = sessions.filter(s =>
|
||||
s.displayName?.toLowerCase().includes(lower) ||
|
||||
s.username.toLowerCase().includes(lower) ||
|
||||
s.summary.toLowerCase().includes(lower)
|
||||
)
|
||||
setFilteredSessions(filtered)
|
||||
}
|
||||
|
||||
// 关闭搜索框
|
||||
const handleCloseSearch = () => {
|
||||
setSearchKeyword('')
|
||||
setFilteredSessions(sessions)
|
||||
}
|
||||
|
||||
// 滚动加载更多 + 显示/隐藏回到底部按钮
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!messageListRef.current) return
|
||||
|
||||
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current
|
||||
|
||||
// 显示回到底部按钮:距离底部超过 300px
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
setShowScrollToBottom(distanceFromBottom > 300)
|
||||
|
||||
// 预加载:当滚动到顶部 30% 区域时开始加载
|
||||
if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) {
|
||||
const threshold = clientHeight * 0.3
|
||||
if (scrollTop < threshold) {
|
||||
loadMessages(currentSessionId, currentOffset)
|
||||
}
|
||||
}
|
||||
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset])
|
||||
|
||||
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 (
|
||||
prev.username === next.username &&
|
||||
prev.type === next.type &&
|
||||
prev.unreadCount === next.unreadCount &&
|
||||
prev.summary === next.summary &&
|
||||
prev.sortTimestamp === next.sortTimestamp &&
|
||||
prev.lastTimestamp === next.lastTimestamp &&
|
||||
prev.lastMsgType === next.lastMsgType &&
|
||||
prev.displayName === next.displayName &&
|
||||
prev.avatarUrl === next.avatarUrl
|
||||
)
|
||||
}, [])
|
||||
|
||||
const mergeSessions = useCallback((nextSessions: ChatSession[]) => {
|
||||
if (sessionsRef.current.length === 0) return nextSessions
|
||||
const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s]))
|
||||
return nextSessions.map((next) => {
|
||||
const prev = prevMap.get(next.username)
|
||||
if (!prev) return next
|
||||
return isSameSession(prev, next) ? prev : next
|
||||
})
|
||||
}, [isSameSession])
|
||||
|
||||
const flashNewMessages = useCallback((keys: string[]) => {
|
||||
if (keys.length === 0) return
|
||||
setHighlightedMessageKeys((prev) => [...prev, ...keys])
|
||||
window.setTimeout(() => {
|
||||
setHighlightedMessageKeys((prev) => prev.filter((k) => !keys.includes(k)))
|
||||
}, 2500)
|
||||
}, [])
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (messageListRef.current) {
|
||||
messageListRef.current.scrollTo({
|
||||
top: messageListRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 拖动调节侧边栏宽度
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
|
||||
const startX = e.clientX
|
||||
const startWidth = sidebarWidth
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - startX
|
||||
const newWidth = Math.min(Math.max(startWidth + delta, 200), 400)
|
||||
setSidebarWidth(newWidth)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [sidebarWidth])
|
||||
|
||||
// 初始化连接
|
||||
useEffect(() => {
|
||||
if (!isConnected && !isConnecting) {
|
||||
connect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const nextSet = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
nextSet.add(getMessageKey(msg))
|
||||
}
|
||||
messageKeySetRef.current = nextSet
|
||||
const lastMsg = messages[messages.length - 1]
|
||||
lastMessageTimeRef.current = lastMsg?.createTime ?? 0
|
||||
}, [messages, getMessageKey])
|
||||
|
||||
useEffect(() => {
|
||||
currentSessionRef.current = currentSessionId
|
||||
}, [currentSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSessionId !== lastPreloadSessionRef.current) {
|
||||
preloadImageKeysRef.current.clear()
|
||||
lastPreloadSessionRef.current = currentSessionId
|
||||
}
|
||||
}, [currentSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSessionId || messages.length === 0) return
|
||||
const preloadEdgeCount = 40
|
||||
const maxPreload = 30
|
||||
const head = messages.slice(0, preloadEdgeCount)
|
||||
const tail = messages.slice(-preloadEdgeCount)
|
||||
const candidates = [...head, ...tail]
|
||||
const queued = preloadImageKeysRef.current
|
||||
const seen = new Set<string>()
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
|
||||
for (const msg of candidates) {
|
||||
if (payloads.length >= maxPreload) break
|
||||
if (msg.localType !== 3) continue
|
||||
const cacheKey = msg.imageMd5 || msg.imageDatName || `local:${msg.localId}`
|
||||
if (!msg.imageMd5 && !msg.imageDatName) continue
|
||||
if (imageDataUrlCache.has(cacheKey)) continue
|
||||
const taskKey = `${currentSessionId}|${cacheKey}`
|
||||
if (queued.has(taskKey) || seen.has(taskKey)) continue
|
||||
queued.add(taskKey)
|
||||
seen.add(taskKey)
|
||||
payloads.push({
|
||||
sessionId: currentSessionId,
|
||||
imageMd5: msg.imageMd5 || undefined,
|
||||
imageDatName: msg.imageDatName
|
||||
})
|
||||
}
|
||||
if (payloads.length > 0) {
|
||||
window.electronAPI.image.preload(payloads).catch(() => {})
|
||||
}
|
||||
}, [currentSessionId, messages])
|
||||
|
||||
useEffect(() => {
|
||||
const nextMap = new Map<string, ChatSession>()
|
||||
for (const session of sessions) {
|
||||
nextMap.set(session.username, session)
|
||||
}
|
||||
sessionMapRef.current = nextMap
|
||||
}, [sessions])
|
||||
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions
|
||||
}, [sessions])
|
||||
|
||||
useEffect(() => {
|
||||
isLoadingMessagesRef.current = isLoadingMessages
|
||||
isLoadingMoreRef.current = isLoadingMore
|
||||
}, [isLoadingMessages, isLoadingMore])
|
||||
|
||||
useEffect(() => {
|
||||
isConnectedRef.current = isConnected
|
||||
}, [isConnected])
|
||||
|
||||
useEffect(() => {
|
||||
searchKeywordRef.current = searchKeyword
|
||||
}, [searchKeyword])
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchKeyword.trim()) return
|
||||
const lower = searchKeyword.toLowerCase()
|
||||
const filtered = sessions.filter(s =>
|
||||
s.displayName?.toLowerCase().includes(lower) ||
|
||||
s.username.toLowerCase().includes(lower) ||
|
||||
s.summary.toLowerCase().includes(lower)
|
||||
)
|
||||
setFilteredSessions(filtered)
|
||||
}, [sessions, searchKeyword, setFilteredSessions])
|
||||
|
||||
|
||||
// 格式化会话时间(相对时间)- 与原项目一致
|
||||
const formatSessionTime = (timestamp: number): string => {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return ''
|
||||
|
||||
const now = Date.now()
|
||||
const msgTime = timestamp * 1000
|
||||
const diff = now - msgTime
|
||||
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
|
||||
// 超过24小时显示日期
|
||||
const date = new Date(msgTime)
|
||||
const nowDate = new Date()
|
||||
|
||||
if (date.getFullYear() === nowDate.getFullYear()) {
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
// 获取当前会话信息
|
||||
const currentSession = sessions.find(s => s.username === currentSessionId)
|
||||
|
||||
// 判断是否为群聊
|
||||
const isGroupChat = (username: string) => username.includes('@chatroom')
|
||||
|
||||
// 渲染日期分隔
|
||||
const shouldShowDateDivider = (msg: Message, prevMsg?: Message): boolean => {
|
||||
if (!prevMsg) return true
|
||||
const date = new Date(msg.createTime * 1000).toDateString()
|
||||
const prevDate = new Date(prevMsg.createTime * 1000).toDateString()
|
||||
return date !== prevDate
|
||||
}
|
||||
|
||||
const formatDateDivider = (timestamp: number): string => {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间'
|
||||
const date = new Date(timestamp * 1000)
|
||||
const now = new Date()
|
||||
const isToday = date.toDateString() === now.toDateString()
|
||||
|
||||
if (isToday) return '今天'
|
||||
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
if (date.toDateString() === yesterday.toDateString()) return '昨天'
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`chat-page ${isResizing ? 'resizing' : ''}`}>
|
||||
{/* 左侧会话列表 */}
|
||||
<div
|
||||
className="session-sidebar"
|
||||
ref={sidebarRef}
|
||||
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
||||
>
|
||||
<div className="session-header">
|
||||
<div className="search-row">
|
||||
<div className="search-box expanded">
|
||||
<Search size={14} />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="搜索"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
{searchKeyword && (
|
||||
<button className="close-search" onClick={handleCloseSearch}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
|
||||
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connectionError && (
|
||||
<div className="connection-error">
|
||||
<AlertCircle size={16} />
|
||||
<span>{connectionError}</span>
|
||||
<button onClick={connect}>重试</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoadingSessions ? (
|
||||
<div className="loading-sessions">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="skeleton-item">
|
||||
<div className="skeleton-avatar" />
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-line" />
|
||||
<div className="skeleton-line" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredSessions.length > 0 ? (
|
||||
<div className="session-list">
|
||||
{filteredSessions.map(session => (
|
||||
<div
|
||||
key={session.username}
|
||||
className={`session-item ${currentSessionId === session.username ? 'active' : ''}`}
|
||||
onClick={() => handleSelectSession(session)}
|
||||
>
|
||||
<SessionAvatar session={session} size={48} />
|
||||
<div className="session-info">
|
||||
<div className="session-top">
|
||||
<span className="session-name">{session.displayName || session.username}</span>
|
||||
<span className="session-time">{formatSessionTime(session.lastTimestamp || session.sortTimestamp)}</span>
|
||||
</div>
|
||||
<div className="session-bottom">
|
||||
<span className="session-summary">{session.summary || '暂无消息'}</span>
|
||||
{session.unreadCount > 0 && (
|
||||
<span className="unread-badge">
|
||||
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-sessions">
|
||||
<MessageSquare />
|
||||
<p>暂无会话</p>
|
||||
<p className="hint">请先在数据管理页面解密数据库</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 拖动调节条 */}
|
||||
<div className="resize-handle" onMouseDown={handleResizeStart} />
|
||||
|
||||
{/* 右侧消息区域 */}
|
||||
<div className="message-area">
|
||||
{currentSession ? (
|
||||
<>
|
||||
<div className="message-header">
|
||||
<SessionAvatar session={currentSession} size={40} />
|
||||
<div className="header-info">
|
||||
<h3>{currentSession.displayName || currentSession.username}</h3>
|
||||
{isGroupChat(currentSession.username) && (
|
||||
<div className="header-subtitle">群聊</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="icon-btn refresh-messages-btn"
|
||||
onClick={handleRefreshMessages}
|
||||
disabled={isRefreshingMessages || isLoadingMessages}
|
||||
title="刷新消息"
|
||||
>
|
||||
<RefreshCw size={18} className={isRefreshingMessages ? 'spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`}
|
||||
onClick={toggleDetailPanel}
|
||||
title="会话详情"
|
||||
>
|
||||
<Info size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="message-content-wrapper">
|
||||
{isLoadingMessages ? (
|
||||
<div className="loading-messages">
|
||||
<Loader2 size={24} />
|
||||
<span>加载消息中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="message-list"
|
||||
ref={messageListRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{hasMoreMessages && (
|
||||
<div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 size={14} />
|
||||
<span>加载更多...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>向上滚动加载更多</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => {
|
||||
const prevMsg = index > 0 ? messages[index - 1] : undefined
|
||||
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
|
||||
|
||||
// 显示时间:第一条消息,或者与上一条消息间隔超过5分钟
|
||||
const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300)
|
||||
const isSent = msg.isSend === 1
|
||||
const isSystem = msg.localType === 10000
|
||||
|
||||
// 系统消息居中显示
|
||||
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
|
||||
|
||||
const messageKey = getMessageKey(msg)
|
||||
return (
|
||||
<div key={messageKey} className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
|
||||
{showDateDivider && (
|
||||
<div className="date-divider">
|
||||
<span>{formatDateDivider(msg.createTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
session={currentSession}
|
||||
showTime={!showDateDivider && showTime}
|
||||
myAvatarUrl={myAvatarUrl}
|
||||
isGroupChat={isGroupChat(currentSession.username)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 回到底部按钮 */}
|
||||
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
|
||||
<ChevronDown size={16} />
|
||||
<span>回到底部</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 会话详情面板 */}
|
||||
{showDetailPanel && (
|
||||
<div className="detail-panel">
|
||||
<div className="detail-header">
|
||||
<h4>会话详情</h4>
|
||||
<button className="close-btn" onClick={() => setShowDetailPanel(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{isLoadingDetail ? (
|
||||
<div className="detail-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : sessionDetail ? (
|
||||
<div className="detail-content">
|
||||
<div className="detail-section">
|
||||
<div className="detail-item">
|
||||
<Hash size={14} />
|
||||
<span className="label">微信ID</span>
|
||||
<span className="value">{sessionDetail.wxid}</span>
|
||||
</div>
|
||||
{sessionDetail.remark && (
|
||||
<div className="detail-item">
|
||||
<span className="label">备注</span>
|
||||
<span className="value">{sessionDetail.remark}</span>
|
||||
</div>
|
||||
)}
|
||||
{sessionDetail.nickName && (
|
||||
<div className="detail-item">
|
||||
<span className="label">昵称</span>
|
||||
<span className="value">{sessionDetail.nickName}</span>
|
||||
</div>
|
||||
)}
|
||||
{sessionDetail.alias && (
|
||||
<div className="detail-item">
|
||||
<span className="label">微信号</span>
|
||||
<span className="value">{sessionDetail.alias}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<div className="section-title">
|
||||
<MessageSquare size={14} />
|
||||
<span>消息统计</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="label">消息总数</span>
|
||||
<span className="value highlight">
|
||||
{Number.isFinite(sessionDetail.messageCount)
|
||||
? sessionDetail.messageCount.toLocaleString()
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
{sessionDetail.firstMessageTime && (
|
||||
<div className="detail-item">
|
||||
<Calendar size={14} />
|
||||
<span className="label">首条消息</span>
|
||||
<span className="value">
|
||||
{Number.isFinite(sessionDetail.firstMessageTime)
|
||||
? new Date(sessionDetail.firstMessageTime * 1000).toLocaleDateString('zh-CN')
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{sessionDetail.latestMessageTime && (
|
||||
<div className="detail-item">
|
||||
<Calendar size={14} />
|
||||
<span className="label">最新消息</span>
|
||||
<span className="value">
|
||||
{Number.isFinite(sessionDetail.latestMessageTime)
|
||||
? new Date(sessionDetail.latestMessageTime * 1000).toLocaleDateString('zh-CN')
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<div className="section-title">
|
||||
<Database size={14} />
|
||||
<span>数据库分布</span>
|
||||
</div>
|
||||
<div className="table-list">
|
||||
{sessionDetail.messageTables.map((t, i) => (
|
||||
<div key={i} className="table-item">
|
||||
<span className="db-name">{t.dbName}</span>
|
||||
<span className="table-count">{t.count.toLocaleString()} 条</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="detail-empty">暂无详情</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-chat">
|
||||
<MessageSquare />
|
||||
<p>选择一个会话开始查看聊天记录</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 前端表情包缓存
|
||||
const emojiDataUrlCache = new Map<string, string>()
|
||||
const imageDataUrlCache = new Map<string, string>()
|
||||
const voiceDataUrlCache = new Map<string, string>()
|
||||
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||
|
||||
// 消息气泡组件
|
||||
function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: {
|
||||
message: Message;
|
||||
session: ChatSession;
|
||||
showTime?: boolean;
|
||||
myAvatarUrl?: string;
|
||||
isGroupChat?: boolean;
|
||||
}) {
|
||||
const isSystem = message.localType === 10000
|
||||
const isEmoji = message.localType === 47
|
||||
const isImage = message.localType === 3
|
||||
const isVoice = message.localType === 34
|
||||
const isSent = message.isSend === 1
|
||||
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
|
||||
const [senderName, setSenderName] = useState<string | undefined>(undefined)
|
||||
const [emojiError, setEmojiError] = useState(false)
|
||||
const [emojiLoading, setEmojiLoading] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [imageHasUpdate, setImageHasUpdate] = useState(false)
|
||||
const [imageClicked, setImageClicked] = useState(false)
|
||||
const imageUpdateCheckedRef = useRef<string | null>(null)
|
||||
const imageClickTimerRef = useRef<number | null>(null)
|
||||
const [voiceError, setVoiceError] = useState(false)
|
||||
const [voiceLoading, setVoiceLoading] = useState(false)
|
||||
const [isVoicePlaying, setIsVoicePlaying] = useState(false)
|
||||
const voiceAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [showImagePreview, setShowImagePreview] = useState(false)
|
||||
|
||||
// 从缓存获取表情包 data URL
|
||||
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
|
||||
const [emojiLocalPath, setEmojiLocalPath] = useState<string | undefined>(
|
||||
() => emojiDataUrlCache.get(cacheKey)
|
||||
)
|
||||
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
|
||||
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
|
||||
() => imageDataUrlCache.get(imageCacheKey)
|
||||
)
|
||||
const voiceCacheKey = `voice:${message.localId}`
|
||||
const [voiceDataUrl, setVoiceDataUrl] = useState<string | undefined>(
|
||||
() => voiceDataUrlCache.get(voiceCacheKey)
|
||||
)
|
||||
|
||||
const formatTime = (timestamp: number): string => {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return '未知时间'
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}) + ' ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
const detectImageMimeFromBase64 = useCallback((base64: string): string => {
|
||||
try {
|
||||
const head = window.atob(base64.slice(0, 48))
|
||||
const bytes = new Uint8Array(head.length)
|
||||
for (let i = 0; i < head.length; i++) {
|
||||
bytes[i] = head.charCodeAt(i)
|
||||
}
|
||||
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif'
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png'
|
||||
if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg'
|
||||
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
|
||||
return 'image/webp'
|
||||
}
|
||||
} catch {}
|
||||
return 'image/jpeg'
|
||||
}, [])
|
||||
|
||||
// 获取头像首字母
|
||||
const getAvatarLetter = (name: string): string => {
|
||||
if (!name) return '?'
|
||||
const chars = [...name]
|
||||
return chars[0] || '?'
|
||||
}
|
||||
|
||||
// 下载表情包
|
||||
const downloadEmoji = () => {
|
||||
if (!message.emojiCdnUrl || emojiLoading) return
|
||||
|
||||
// 先检查缓存
|
||||
const cached = emojiDataUrlCache.get(cacheKey)
|
||||
if (cached) {
|
||||
setEmojiLocalPath(cached)
|
||||
setEmojiError(false)
|
||||
return
|
||||
}
|
||||
|
||||
setEmojiLoading(true)
|
||||
setEmojiError(false)
|
||||
window.electronAPI.chat.downloadEmoji(message.emojiCdnUrl, message.emojiMd5).then((result: { success: boolean; localPath?: string; error?: string }) => {
|
||||
if (result.success && result.localPath) {
|
||||
emojiDataUrlCache.set(cacheKey, result.localPath)
|
||||
setEmojiLocalPath(result.localPath)
|
||||
} else {
|
||||
setEmojiError(true)
|
||||
}
|
||||
}).catch(() => {
|
||||
setEmojiError(true)
|
||||
}).finally(() => {
|
||||
setEmojiLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
// 群聊中获取发送者信息
|
||||
useEffect(() => {
|
||||
if (isGroupChat && !isSent && message.senderUsername) {
|
||||
const sender = message.senderUsername
|
||||
const cached = senderAvatarCache.get(sender)
|
||||
if (cached) {
|
||||
setSenderAvatarUrl(cached.avatarUrl)
|
||||
setSenderName(cached.displayName)
|
||||
return
|
||||
}
|
||||
const pending = senderAvatarLoading.get(sender)
|
||||
if (pending) {
|
||||
pending.then((result) => {
|
||||
if (result) {
|
||||
setSenderAvatarUrl(result.avatarUrl)
|
||||
setSenderName(result.displayName)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
const request = window.electronAPI.chat.getContactAvatar(sender)
|
||||
senderAvatarLoading.set(sender, request)
|
||||
request.then((result: { avatarUrl?: string; displayName?: string } | null) => {
|
||||
if (result) {
|
||||
senderAvatarCache.set(sender, result)
|
||||
setSenderAvatarUrl(result.avatarUrl)
|
||||
setSenderName(result.displayName)
|
||||
}
|
||||
}).catch(() => {}).finally(() => {
|
||||
senderAvatarLoading.delete(sender)
|
||||
})
|
||||
}
|
||||
}, [isGroupChat, isSent, message.senderUsername])
|
||||
|
||||
// 自动下载表情包
|
||||
useEffect(() => {
|
||||
if (emojiLocalPath) return
|
||||
if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) {
|
||||
downloadEmoji()
|
||||
}
|
||||
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
|
||||
|
||||
const requestImageDecrypt = useCallback(async (forceUpdate = false) => {
|
||||
if (!isImage || imageLoading) return
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
try {
|
||||
if (message.imageMd5 || message.imageDatName) {
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName,
|
||||
force: forceUpdate
|
||||
})
|
||||
if (result.success && result.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageHasUpdate(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = await window.electronAPI.chat.getImageData(session.username, String(message.localId))
|
||||
if (fallback.success && fallback.data) {
|
||||
const mime = detectImageMimeFromBase64(fallback.data)
|
||||
const dataUrl = `data:${mime};base64,${fallback.data}`
|
||||
imageDataUrlCache.set(imageCacheKey, dataUrl)
|
||||
setImageLocalPath(dataUrl)
|
||||
setImageHasUpdate(false)
|
||||
return
|
||||
}
|
||||
setImageError(true)
|
||||
} catch {
|
||||
setImageError(true)
|
||||
} finally {
|
||||
setImageLoading(false)
|
||||
}
|
||||
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
|
||||
|
||||
const handleImageClick = useCallback(() => {
|
||||
if (imageClickTimerRef.current) {
|
||||
window.clearTimeout(imageClickTimerRef.current)
|
||||
}
|
||||
setImageClicked(true)
|
||||
imageClickTimerRef.current = window.setTimeout(() => {
|
||||
setImageClicked(false)
|
||||
}, 800)
|
||||
console.info('[UI] image decrypt click', {
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5,
|
||||
imageDatName: message.imageDatName,
|
||||
localId: message.localId
|
||||
})
|
||||
void requestImageDecrypt()
|
||||
}, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageClickTimerRef.current) {
|
||||
window.clearTimeout(imageClickTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage || imageLoading) return
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
if (imageUpdateCheckedRef.current === imageCacheKey) return
|
||||
imageUpdateCheckedRef.current = imageCacheKey
|
||||
let cancelled = false
|
||||
window.electronAPI.image.resolveCache({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName
|
||||
}).then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.success && result.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
if (!imageLocalPath || imageLocalPath !== result.localPath) {
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageError(false)
|
||||
}
|
||||
setImageHasUpdate(Boolean(result.hasUpdate))
|
||||
}
|
||||
}).catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload) => {
|
||||
const matchesCacheKey =
|
||||
payload.cacheKey === message.imageMd5 ||
|
||||
payload.cacheKey === message.imageDatName ||
|
||||
(payload.imageMd5 && payload.imageMd5 === message.imageMd5) ||
|
||||
(payload.imageDatName && payload.imageDatName === message.imageDatName)
|
||||
if (matchesCacheKey) {
|
||||
setImageHasUpdate(true)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [isImage, message.imageDatName, message.imageMd5])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
const unsubscribe = window.electronAPI.image.onCacheResolved((payload) => {
|
||||
const matchesCacheKey =
|
||||
payload.cacheKey === message.imageMd5 ||
|
||||
payload.cacheKey === message.imageDatName ||
|
||||
(payload.imageMd5 && payload.imageMd5 === message.imageMd5) ||
|
||||
(payload.imageDatName && payload.imageDatName === message.imageDatName)
|
||||
if (matchesCacheKey) {
|
||||
imageDataUrlCache.set(imageCacheKey, payload.localPath)
|
||||
setImageLocalPath(payload.localPath)
|
||||
setImageError(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [isImage, imageCacheKey, message.imageDatName, message.imageMd5])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVoice) return
|
||||
if (!voiceAudioRef.current) {
|
||||
voiceAudioRef.current = new Audio()
|
||||
}
|
||||
const audio = voiceAudioRef.current
|
||||
if (!audio) return
|
||||
const handlePlay = () => setIsVoicePlaying(true)
|
||||
const handlePause = () => setIsVoicePlaying(false)
|
||||
const handleEnded = () => setIsVoicePlaying(false)
|
||||
audio.addEventListener('play', handlePlay)
|
||||
audio.addEventListener('pause', handlePause)
|
||||
audio.addEventListener('ended', handleEnded)
|
||||
return () => {
|
||||
audio.pause()
|
||||
audio.removeEventListener('play', handlePlay)
|
||||
audio.removeEventListener('pause', handlePause)
|
||||
audio.removeEventListener('ended', handleEnded)
|
||||
}
|
||||
}, [isVoice])
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="message-bubble system">
|
||||
<div className="bubble-content">{message.parsedContent}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const bubbleClass = isSent ? 'sent' : 'received'
|
||||
|
||||
// 头像逻辑:
|
||||
// - 自己发的:使用 myAvatarUrl
|
||||
// - 群聊中对方发的:使用发送者头像
|
||||
// - 私聊中对方发的:使用会话头像
|
||||
const avatarUrl = isSent
|
||||
? myAvatarUrl
|
||||
: (isGroupChat ? senderAvatarUrl : session.avatarUrl)
|
||||
const avatarLetter = isSent
|
||||
? '我'
|
||||
: getAvatarLetter(isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username))
|
||||
|
||||
// 是否有引用消息
|
||||
const hasQuote = message.quotedContent && message.quotedContent.length > 0
|
||||
|
||||
// 解析混合文本和表情
|
||||
const renderTextWithEmoji = (text: string) => {
|
||||
if (!text) return text
|
||||
const parts = text.split(/\[(.*?)\]/g)
|
||||
return parts.map((part, index) => {
|
||||
// 奇数索引是捕获组的内容(即括号内的文字)
|
||||
if (index % 2 === 1) {
|
||||
// @ts-ignore
|
||||
const path = getEmojiPath(part as any)
|
||||
if (path) {
|
||||
// path 例如 'assets/face/微笑.png',需要添加 base 前缀
|
||||
return (
|
||||
<img
|
||||
key={index}
|
||||
src={`${import.meta.env.BASE_URL}${path}`}
|
||||
alt={`[${part}]`}
|
||||
className="inline-emoji"
|
||||
style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return `[${part}]`
|
||||
}
|
||||
return part
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染消息内容
|
||||
const renderContent = () => {
|
||||
if (isImage) {
|
||||
if (imageLoading) {
|
||||
return (
|
||||
<div className="image-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (imageError || !imageLocalPath) {
|
||||
return (
|
||||
<button
|
||||
className={`image-unavailable ${imageClicked ? 'clicked' : ''}`}
|
||||
onClick={handleImageClick}
|
||||
disabled={imageLoading}
|
||||
type="button"
|
||||
>
|
||||
<ImageIcon size={24} />
|
||||
<span>图片未解密</span>
|
||||
<span className="image-action">{imageClicked ? '已点击…' : '点击解密'}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="image-message-wrapper">
|
||||
<img
|
||||
src={imageLocalPath}
|
||||
alt="图片"
|
||||
className="image-message"
|
||||
onClick={() => setShowImagePreview(true)}
|
||||
onLoad={() => setImageError(false)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
{imageHasUpdate && (
|
||||
<button
|
||||
className="image-update-button"
|
||||
type="button"
|
||||
title="发现更高清图片,点击更新"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void requestImageDecrypt(true)
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showImagePreview && (
|
||||
<div className="image-preview-overlay" onClick={() => setShowImagePreview(false)}>
|
||||
<img src={imageLocalPath} alt="图片预览" onClick={(e) => e.stopPropagation()} />
|
||||
<button className="image-preview-close" onClick={() => setShowImagePreview(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (isVoice) {
|
||||
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
|
||||
const handleToggle = async () => {
|
||||
if (voiceLoading) return
|
||||
const audio = voiceAudioRef.current || new Audio()
|
||||
if (!voiceAudioRef.current) {
|
||||
voiceAudioRef.current = audio
|
||||
}
|
||||
if (isVoicePlaying) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
return
|
||||
}
|
||||
if (!voiceDataUrl) {
|
||||
setVoiceLoading(true)
|
||||
setVoiceError(false)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getVoiceData(session.username, String(message.localId))
|
||||
if (result.success && result.data) {
|
||||
const url = `data:audio/wav;base64,${result.data}`
|
||||
voiceDataUrlCache.set(voiceCacheKey, url)
|
||||
setVoiceDataUrl(url)
|
||||
} else {
|
||||
setVoiceError(true)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
setVoiceError(true)
|
||||
return
|
||||
} finally {
|
||||
setVoiceLoading(false)
|
||||
}
|
||||
}
|
||||
const source = voiceDataUrlCache.get(voiceCacheKey) || voiceDataUrl
|
||||
if (!source) {
|
||||
setVoiceError(true)
|
||||
return
|
||||
}
|
||||
audio.src = source
|
||||
try {
|
||||
await audio.play()
|
||||
} catch {
|
||||
setVoiceError(true)
|
||||
}
|
||||
}
|
||||
|
||||
const showDecryptHint = !voiceDataUrl && !voiceLoading && !isVoicePlaying
|
||||
|
||||
return (
|
||||
<div className={`voice-message ${isVoicePlaying ? 'playing' : ''}`} onClick={handleToggle}>
|
||||
<button
|
||||
className="voice-play-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggle()
|
||||
}}
|
||||
aria-label="播放语音"
|
||||
type="button"
|
||||
>
|
||||
{isVoicePlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
<div className="voice-wave">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<div className="voice-info">
|
||||
<span className="voice-label">语音</span>
|
||||
{durationText && <span className="voice-duration">{durationText}</span>}
|
||||
{voiceLoading && <span className="voice-loading">解码中...</span>}
|
||||
{showDecryptHint && <span className="voice-hint">点击解密</span>}
|
||||
{voiceError && <span className="voice-error">播放失败</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 表情包消息
|
||||
if (isEmoji) {
|
||||
// ... (keep existing emoji logic)
|
||||
// 没有 cdnUrl 或加载失败,显示占位符
|
||||
if (!message.emojiCdnUrl || emojiError) {
|
||||
return (
|
||||
<div className="emoji-unavailable">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M8 15s1.5 2 4 2 4-2 4-2" />
|
||||
<line x1="9" y1="9" x2="9.01" y2="9" />
|
||||
<line x1="15" y1="9" x2="15.01" y2="9" />
|
||||
</svg>
|
||||
<span>表情包未缓存</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 显示加载中
|
||||
if (emojiLoading || !emojiLocalPath) {
|
||||
return (
|
||||
<div className="emoji-loading">
|
||||
<Loader2 size={20} className="spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 显示表情图片
|
||||
return (
|
||||
<img
|
||||
src={emojiLocalPath}
|
||||
alt="表情"
|
||||
className="emoji-image"
|
||||
onError={() => setEmojiError(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// 带引用的消息
|
||||
if (hasQuote) {
|
||||
return (
|
||||
<div className="bubble-content">
|
||||
<div className="quoted-message">
|
||||
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
|
||||
<span className="quoted-text">{renderTextWithEmoji(message.quotedContent || '')}</span>
|
||||
</div>
|
||||
<div className="message-text">{renderTextWithEmoji(message.parsedContent)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// 普通消息
|
||||
return <div className="bubble-content">{renderTextWithEmoji(message.parsedContent)}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showTime && (
|
||||
<div className="time-divider">
|
||||
<span>{formatTime(message.createTime)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`message-bubble ${bubbleClass} ${isEmoji && message.emojiCdnUrl && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVoice ? 'voice' : ''}`}>
|
||||
<div className="bubble-avatar">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" />
|
||||
) : (
|
||||
<span className="avatar-letter">{avatarLetter}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bubble-body">
|
||||
{/* 群聊中显示发送者名称 */}
|
||||
{isGroupChat && !isSent && (
|
||||
<div className="sender-name">
|
||||
{senderName || message.senderUsername || '群成员'}
|
||||
</div>
|
||||
)}
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatPage
|
||||
@@ -0,0 +1,569 @@
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-section {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.database-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.database-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
|
||||
&.decrypted {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.needs-update {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.db-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.db-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.db-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.db-status {
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.decrypted {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
&.needs-update {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #b45309;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
|
||||
&.hint {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.unavailable-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 20px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.hint {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-toast {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 24px;
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
z-index: 100;
|
||||
animation: slideDown 0.3s ease;
|
||||
|
||||
&.success {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.decrypt-progress-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
|
||||
.progress-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
padding: 32px 40px;
|
||||
min-width: 400px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.progress-file {
|
||||
margin: 0 0 20px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 9999px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 图片列表样式
|
||||
.current-dir {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
|
||||
.dir-label {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dir-path {
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.image-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
|
||||
.decrypt-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.decrypted {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.pending {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.img-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.img-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.img-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
&.v3 {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&.v4 {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: #a855f7;
|
||||
}
|
||||
}
|
||||
|
||||
.img-size {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.decrypt-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.more-hint {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
|
||||
// 账号选择器
|
||||
.account-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.account-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import * as configService from '../services/config'
|
||||
import './DataManagementPage.scss'
|
||||
|
||||
function DataManagementPage() {
|
||||
const [dbPath, setDbPath] = useState<string | null>(null)
|
||||
const [wxid, setWxid] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
const [path, id] = await Promise.all([
|
||||
configService.getDbPath(),
|
||||
configService.getMyWxid()
|
||||
])
|
||||
setDbPath(path)
|
||||
setWxid(id)
|
||||
}
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>数据管理</h1>
|
||||
</div>
|
||||
|
||||
<div className="page-scroll">
|
||||
<section className="page-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>WCDB 直连模式</h2>
|
||||
<p className="section-desc">
|
||||
当前版本通过 WCDB DLL 直接读取加密数据库,不再需要解密流程。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="database-list">
|
||||
<div className="database-item decrypted">
|
||||
<div className="db-info">
|
||||
<div className="db-name">
|
||||
数据库目录
|
||||
</div>
|
||||
<div className="db-path">{dbPath || '未配置'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="database-item decrypted">
|
||||
<div className="db-info">
|
||||
<div className="db-name">
|
||||
微信ID
|
||||
</div>
|
||||
<div className="db-path">{wxid || '未配置'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataManagementPage
|
||||
@@ -0,0 +1,657 @@
|
||||
.export-page {
|
||||
display: flex;
|
||||
height: calc(100% + 48px);
|
||||
margin: -24px;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
|
||||
// 左侧会话选择面板
|
||||
.session-panel {
|
||||
width: 380px;
|
||||
min-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: exportSpin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 16px 20px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px 12px;
|
||||
|
||||
.select-all-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
|
||||
.spin {
|
||||
animation: exportSpin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.export-session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 12px 12px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 3px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.export-session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
|
||||
.check-box {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.check-box {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.export-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.export-session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.export-session-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.export-session-summary {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧设置面板
|
||||
.settings-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-section {
|
||||
margin-bottom: 28px;
|
||||
|
||||
h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.format-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.05);
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.format-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.format-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.time-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
&.main-toggle {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.change-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.folder-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.02);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.folder-path {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.export-path-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.path-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.export-action {
|
||||
padding: 20px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 24px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: exportSpin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出进度弹窗
|
||||
.export-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.export-progress-modal {
|
||||
background: var(--card-bg);
|
||||
padding: 32px 40px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
text-align: center;
|
||||
min-width: 320px;
|
||||
|
||||
.progress-spinner {
|
||||
margin-bottom: 20px;
|
||||
color: var(--primary);
|
||||
|
||||
.spin {
|
||||
animation: exportSpin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 20px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.export-result-modal {
|
||||
background: var(--card-bg);
|
||||
padding: 32px 40px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
text-align: center;
|
||||
min-width: 320px;
|
||||
|
||||
.result-icon {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.success {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 24px;
|
||||
|
||||
&.error {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.open-folder-btn {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exportSpin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
|
||||
import * as configService from '../services/config'
|
||||
import './ExportPage.scss'
|
||||
|
||||
interface ChatSession {
|
||||
username: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
summary: string
|
||||
lastTimestamp: number
|
||||
}
|
||||
|
||||
interface ExportOptions {
|
||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
|
||||
dateRange: { start: Date; end: Date } | null
|
||||
useAllTime: boolean
|
||||
}
|
||||
|
||||
interface ExportResult {
|
||||
success: boolean
|
||||
successCount?: number
|
||||
failCount?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
function ExportPage() {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
||||
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [exportFolder, setExportFolder] = useState<string>('')
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' })
|
||||
const [exportResult, setExportResult] = useState<ExportResult | null>(null)
|
||||
|
||||
const [options, setOptions] = useState<ExportOptions>({
|
||||
format: 'chatlab',
|
||||
dateRange: {
|
||||
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
end: new Date()
|
||||
},
|
||||
useAllTime: true
|
||||
})
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.chat.connect()
|
||||
if (!result.success) {
|
||||
console.error('连接失败:', result.error)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
const sessionsResult = await window.electronAPI.chat.getSessions()
|
||||
if (sessionsResult.success && sessionsResult.sessions) {
|
||||
setSessions(sessionsResult.sessions)
|
||||
setFilteredSessions(sessionsResult.sessions)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载会话失败:', e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadExportPath = useCallback(async () => {
|
||||
try {
|
||||
const savedPath = await configService.getExportPath()
|
||||
if (savedPath) {
|
||||
setExportFolder(savedPath)
|
||||
} else {
|
||||
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
|
||||
setExportFolder(downloadsPath)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载导出路径失败:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
loadExportPath()
|
||||
}, [loadSessions, loadExportPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchKeyword.trim()) {
|
||||
setFilteredSessions(sessions)
|
||||
return
|
||||
}
|
||||
const lower = searchKeyword.toLowerCase()
|
||||
setFilteredSessions(sessions.filter(s =>
|
||||
s.displayName?.toLowerCase().includes(lower) ||
|
||||
s.username.toLowerCase().includes(lower)
|
||||
))
|
||||
}, [searchKeyword, sessions])
|
||||
|
||||
const toggleSession = (username: string) => {
|
||||
const newSet = new Set(selectedSessions)
|
||||
if (newSet.has(username)) {
|
||||
newSet.delete(username)
|
||||
} else {
|
||||
newSet.add(username)
|
||||
}
|
||||
setSelectedSessions(newSet)
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedSessions.size === filteredSessions.length) {
|
||||
setSelectedSessions(new Set())
|
||||
} else {
|
||||
setSelectedSessions(new Set(filteredSessions.map(s => s.username)))
|
||||
}
|
||||
}
|
||||
|
||||
const getAvatarLetter = (name: string) => {
|
||||
if (!name) return '?'
|
||||
return [...name][0] || '?'
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
const openExportFolder = async () => {
|
||||
if (exportFolder) {
|
||||
await window.electronAPI.shell.openPath(exportFolder)
|
||||
}
|
||||
}
|
||||
|
||||
const startExport = async () => {
|
||||
if (selectedSessions.size === 0 || !exportFolder) return
|
||||
|
||||
setIsExporting(true)
|
||||
setExportProgress({ current: 0, total: selectedSessions.size, currentName: '' })
|
||||
setExportResult(null)
|
||||
|
||||
try {
|
||||
const sessionList = Array.from(selectedSessions)
|
||||
const exportOptions = {
|
||||
format: options.format,
|
||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||
end: Math.floor(options.dateRange.end.getTime() / 1000)
|
||||
} : null
|
||||
}
|
||||
|
||||
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json') {
|
||||
const result = await window.electronAPI.export.exportSessions(
|
||||
sessionList,
|
||||
exportFolder,
|
||||
exportOptions
|
||||
)
|
||||
setExportResult(result)
|
||||
} else {
|
||||
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('导出失败:', e)
|
||||
setExportResult({ success: false, error: String(e) })
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatOptions = [
|
||||
{ value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' },
|
||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' },
|
||||
{ value: 'json', label: 'JSON', icon: FileJson, desc: '详细格式,包含完整消息信息' },
|
||||
{ value: 'html', label: 'HTML', icon: FileText, desc: '网页格式,可直接浏览' },
|
||||
{ value: 'txt', label: 'TXT', icon: Table, desc: '纯文本,通用格式' },
|
||||
{ value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' },
|
||||
{ value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="export-page">
|
||||
<div className="session-panel">
|
||||
<div className="panel-header">
|
||||
<h2>选择会话</h2>
|
||||
<button className="icon-btn" onClick={loadSessions} disabled={isLoading}>
|
||||
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="search-bar">
|
||||
<Search size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索联系人或群组..."
|
||||
value={searchKeyword}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
{searchKeyword && (
|
||||
<button className="clear-btn" onClick={() => setSearchKeyword('')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="select-actions">
|
||||
<button className="select-all-btn" onClick={toggleSelectAll}>
|
||||
{selectedSessions.size === filteredSessions.length && filteredSessions.length > 0 ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<span className="selected-count">已选 {selectedSessions.size} 个</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="loading-state">
|
||||
<Loader2 size={24} className="spin" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<span>暂无会话</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="export-session-list">
|
||||
{filteredSessions.map(session => (
|
||||
<div
|
||||
key={session.username}
|
||||
className={`export-session-item ${selectedSessions.has(session.username) ? 'selected' : ''}`}
|
||||
onClick={() => toggleSession(session.username)}
|
||||
>
|
||||
<div className="check-box">
|
||||
{selectedSessions.has(session.username) && <Check size={14} />}
|
||||
</div>
|
||||
<div className="export-avatar">
|
||||
{session.avatarUrl ? (
|
||||
<img src={session.avatarUrl} alt="" />
|
||||
) : (
|
||||
<span>{getAvatarLetter(session.displayName || session.username)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="export-session-info">
|
||||
<div className="export-session-name">{session.displayName || session.username}</div>
|
||||
<div className="export-session-summary">{session.summary || '暂无消息'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-panel">
|
||||
<div className="panel-header">
|
||||
<h2>导出设置</h2>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
<div className="setting-section">
|
||||
<h3>导出格式</h3>
|
||||
<div className="format-options">
|
||||
{formatOptions.map(fmt => (
|
||||
<div
|
||||
key={fmt.value}
|
||||
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
|
||||
onClick={() => setOptions({ ...options, format: fmt.value as any })}
|
||||
>
|
||||
<fmt.icon size={24} />
|
||||
<span className="format-label">{fmt.label}</span>
|
||||
<span className="format-desc">{fmt.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>时间范围</h3>
|
||||
<div className="time-options">
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.useAllTime}
|
||||
onChange={e => setOptions({ ...options, useAllTime: e.target.checked })}
|
||||
/>
|
||||
<span>导出全部时间</span>
|
||||
</label>
|
||||
{!options.useAllTime && options.dateRange && (
|
||||
<div className="date-range">
|
||||
<Calendar size={16} />
|
||||
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
|
||||
<button className="change-btn">
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出位置</h3>
|
||||
<div className="export-path-display">
|
||||
<FolderOpen size={16} />
|
||||
<span>{exportFolder || '未设置'}</span>
|
||||
</div>
|
||||
<p className="path-hint">可在设置页面修改导出目录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="export-action">
|
||||
<button
|
||||
className="export-btn"
|
||||
onClick={startExport}
|
||||
disabled={selectedSessions.size === 0 || !exportFolder || isExporting}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 size={18} className="spin" />
|
||||
<span>导出中 ({exportProgress.current}/{exportProgress.total})</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={18} />
|
||||
<span>开始导出</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导出进度弹窗 */}
|
||||
{isExporting && (
|
||||
<div className="export-overlay">
|
||||
<div className="export-progress-modal">
|
||||
<div className="progress-spinner">
|
||||
<Loader2 size={32} className="spin" />
|
||||
</div>
|
||||
<h3>正在导出</h3>
|
||||
<p className="progress-text">{exportProgress.currentName}</p>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${(exportProgress.current / exportProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="progress-count">{exportProgress.current} / {exportProgress.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导出结果弹窗 */}
|
||||
{exportResult && (
|
||||
<div className="export-overlay">
|
||||
<div className="export-result-modal">
|
||||
<div className={`result-icon ${exportResult.success ? 'success' : 'error'}`}>
|
||||
{exportResult.success ? <CheckCircle size={48} /> : <XCircle size={48} />}
|
||||
</div>
|
||||
<h3>{exportResult.success ? '导出完成' : '导出失败'}</h3>
|
||||
{exportResult.success ? (
|
||||
<p className="result-text">
|
||||
成功导出 {exportResult.successCount} 个会话
|
||||
{exportResult.failCount ? `,${exportResult.failCount} 个失败` : ''}
|
||||
</p>
|
||||
) : (
|
||||
<p className="result-text error">{exportResult.error}</p>
|
||||
)}
|
||||
<div className="result-actions">
|
||||
{exportResult.success && (
|
||||
<button className="open-folder-btn" onClick={openExportFolder}>
|
||||
<ExternalLink size={16} />
|
||||
<span>打开文件夹</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="close-btn" onClick={() => setExportResult(null)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExportPage
|
||||
@@ -0,0 +1,1167 @@
|
||||
.group-analytics-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
|
||||
&.standalone {
|
||||
height: 100vh;
|
||||
gap: 0;
|
||||
background: var(--bg-gradient);
|
||||
|
||||
.group-sidebar {
|
||||
background: var(--card-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.sidebar-header {
|
||||
padding-top: 38px;
|
||||
padding-bottom: 12px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-light);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close-search {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
opacity: 0.3;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.group-item {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 12px 16px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary-light);
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
width: 4px;
|
||||
margin-left: -2px;
|
||||
margin-right: -2px;
|
||||
background: transparent;
|
||||
cursor: col-resize;
|
||||
-webkit-app-region: no-drag;
|
||||
transition: background 0.2s;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&.resizing .resize-handle {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.detail-area {
|
||||
flex: 1;
|
||||
background: var(--chat-pattern);
|
||||
background-color: var(--bg-secondary);
|
||||
-webkit-app-region: no-drag;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.group-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 250px;
|
||||
max-width: 450px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px 16px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 56px;
|
||||
|
||||
.search-row {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
animation: searchExpand 0.25s ease-out;
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.close-search {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.group-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.group-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.group-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.group-members {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-groups {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
|
||||
.skeleton-line {
|
||||
height: 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
animation: pulse 1.5s infinite;
|
||||
|
||||
&:first-child {
|
||||
width: 50%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
width: 80%;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-groups {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-tertiary);
|
||||
|
||||
svg {
|
||||
opacity: 0.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
gap: 16px;
|
||||
|
||||
svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.group-analytics-page.resizing {
|
||||
user-select: none;
|
||||
|
||||
.resize-handle {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.function-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
|
||||
.selected-group-info {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.group-avatar.large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin: 0 auto 16px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.function-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.function-card {
|
||||
width: 140px;
|
||||
padding: 24px 16px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.function-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 24px;
|
||||
padding-top: 38px;
|
||||
padding-bottom: 12px;
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
-webkit-app-region: drag;
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.back-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
-webkit-app-region: no-drag;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .chart-container,
|
||||
> .media-stats {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
opacity: 0.3;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.members-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 12px;
|
||||
|
||||
.member-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 8px;
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rankings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
|
||||
.rank {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.top {
|
||||
background: linear-gradient(135deg, #ffd700, #ffb800);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-avatar {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.medal {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--card-bg);
|
||||
|
||||
&.medal-1 {
|
||||
background: linear-gradient(135deg, #ffd700, #ffb800);
|
||||
color: #fff;
|
||||
}
|
||||
&.medal-2 {
|
||||
background: linear-gradient(135deg, #c0c0c0, #a8a8a8);
|
||||
color: #fff;
|
||||
}
|
||||
&.medal-3 {
|
||||
background: linear-gradient(135deg, #cd7f32, #b87333);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.contact-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.message-count {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.media-stats {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.media-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
min-height: 0;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.media-legend {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
@media (max-width: 800px) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.legend-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend-percent {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.stat-card {
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
|
||||
.value {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes searchExpand {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scaleX(0.8);
|
||||
transform-origin: right center;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scaleX(1);
|
||||
transform-origin: right center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
|
||||
// 暗色模式适配
|
||||
[data-mode="dark"] {
|
||||
.function-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.member-card:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.member-modal {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// 成员详情弹框
|
||||
.member-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.member-modal {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
padding: 32px;
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
position: relative;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-avatar.large {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.member-display-name {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.member-details {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
|
||||
.detail-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import DateRangePicker from '../components/DateRangePicker'
|
||||
import './GroupAnalyticsPage.scss'
|
||||
|
||||
interface GroupChatInfo {
|
||||
username: string
|
||||
displayName: string
|
||||
memberCount: number
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
interface GroupMember {
|
||||
username: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
interface GroupMessageRank {
|
||||
member: GroupMember
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||
|
||||
function GroupAnalyticsPage() {
|
||||
const [groups, setGroups] = useState<GroupChatInfo[]>([])
|
||||
const [filteredGroups, setFilteredGroups] = useState<GroupChatInfo[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedGroup, setSelectedGroup] = useState<GroupChatInfo | null>(null)
|
||||
const [selectedFunction, setSelectedFunction] = useState<AnalysisFunction | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// 功能数据
|
||||
const [members, setMembers] = useState<GroupMember[]>([])
|
||||
const [rankings, setRankings] = useState<GroupMessageRank[]>([])
|
||||
const [activeHours, setActiveHours] = useState<Record<number, number>>({})
|
||||
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
|
||||
const [functionLoading, setFunctionLoading] = useState(false)
|
||||
|
||||
// 成员详情弹框
|
||||
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||
|
||||
// 时间范围
|
||||
const [startDate, setStartDate] = useState<string>('')
|
||||
const [endDate, setEndDate] = useState<string>('')
|
||||
const [dateRangeReady, setDateRangeReady] = useState(false)
|
||||
|
||||
// 拖动调整宽度
|
||||
const [sidebarWidth, setSidebarWidth] = useState(300)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery) {
|
||||
setFilteredGroups(groups.filter(g => g.displayName.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||
} else {
|
||||
setFilteredGroups(groups)
|
||||
}
|
||||
}, [searchQuery, groups])
|
||||
|
||||
// 拖动调整宽度
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing || !containerRef.current) return
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const newWidth = e.clientX - containerRect.left
|
||||
setSidebarWidth(Math.max(250, Math.min(450, newWidth)))
|
||||
}
|
||||
const handleMouseUp = () => setIsResizing(false)
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isResizing])
|
||||
|
||||
// 日期范围变化时自动刷新
|
||||
useEffect(() => {
|
||||
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') {
|
||||
setDateRangeReady(false)
|
||||
loadFunctionData(selectedFunction)
|
||||
}
|
||||
}, [dateRangeReady])
|
||||
|
||||
const loadGroups = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
||||
if (result.success && result.data) {
|
||||
setGroups(result.data)
|
||||
setFilteredGroups(result.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGroupSelect = (group: GroupChatInfo) => {
|
||||
if (selectedGroup?.username !== group.username) {
|
||||
setSelectedGroup(group)
|
||||
setSelectedFunction(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleFunctionSelect = async (func: AnalysisFunction) => {
|
||||
if (!selectedGroup) return
|
||||
setSelectedFunction(func)
|
||||
await loadFunctionData(func)
|
||||
}
|
||||
|
||||
const loadFunctionData = async (func: AnalysisFunction) => {
|
||||
if (!selectedGroup) return
|
||||
setFunctionLoading(true)
|
||||
|
||||
// 计算时间戳
|
||||
const startTime = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined
|
||||
const endTime = endDate ? Math.floor(new Date(endDate + 'T23:59:59').getTime() / 1000) : undefined
|
||||
|
||||
try {
|
||||
switch (func) {
|
||||
case 'members': {
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
||||
if (result.success && result.data) setMembers(result.data)
|
||||
break
|
||||
}
|
||||
case 'ranking': {
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
||||
if (result.success && result.data) setRankings(result.data)
|
||||
break
|
||||
}
|
||||
case 'activeHours': {
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime)
|
||||
if (result.success && result.data) setActiveHours(result.data.hourlyDistribution)
|
||||
break
|
||||
}
|
||||
case 'mediaStats': {
|
||||
const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime)
|
||||
if (result.success && result.data) setMediaStats(result.data)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setFunctionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 10000) return (num / 10000).toFixed(1) + '万'
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
const getHourlyOption = () => {
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
const data = hours.map(h => activeHours[h] || 0)
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: hours.map(h => `${h}时`) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [{ type: 'bar', data, itemStyle: { color: '#07c160', borderRadius: [4, 4, 0, 0] } }]
|
||||
}
|
||||
}
|
||||
|
||||
const getMediaOption = () => {
|
||||
if (!mediaStats || mediaStats.typeCounts.length === 0) return {}
|
||||
|
||||
// 定义颜色映射
|
||||
const colorMap: Record<number, string> = {
|
||||
1: '#3b82f6', // 文本 - 蓝色
|
||||
3: '#22c55e', // 图片 - 绿色
|
||||
34: '#f97316', // 语音 - 橙色
|
||||
43: '#a855f7', // 视频 - 紫色
|
||||
47: '#ec4899', // 表情包 - 粉色
|
||||
49: '#14b8a6', // 链接/文件 - 青色
|
||||
[-1]: '#6b7280', // 其他 - 灰色
|
||||
}
|
||||
|
||||
const data = mediaStats.typeCounts.map(item => ({
|
||||
name: item.name,
|
||||
value: item.count,
|
||||
itemStyle: { color: colorMap[item.type] || '#6b7280' }
|
||||
}))
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '50%'],
|
||||
itemStyle: { borderRadius: 8, borderColor: 'rgba(255,255,255,0.1)', borderWidth: 2 },
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { name: string; percent: number }) => {
|
||||
// 只显示占比大于3%的标签
|
||||
return params.percent > 3 ? `${params.name}\n${params.percent.toFixed(1)}%` : ''
|
||||
},
|
||||
color: '#fff'
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 10,
|
||||
length2: 10
|
||||
},
|
||||
data
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (selectedFunction) {
|
||||
loadFunctionData(selectedFunction)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDateRangeComplete = () => {
|
||||
setDateRangeReady(true)
|
||||
}
|
||||
|
||||
const handleMemberClick = (member: GroupMember) => {
|
||||
setSelectedMember(member)
|
||||
setCopiedField(null)
|
||||
}
|
||||
|
||||
const handleCopy = async (text: string, field: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedField(field)
|
||||
setTimeout(() => setCopiedField(null), 2000)
|
||||
} catch (e) {
|
||||
console.error('复制失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const renderMemberModal = () => {
|
||||
if (!selectedMember) return null
|
||||
|
||||
return (
|
||||
<div className="member-modal-overlay" onClick={() => setSelectedMember(null)}>
|
||||
<div className="member-modal" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={() => setSelectedMember(null)}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="modal-content">
|
||||
<div className="member-avatar large">
|
||||
{selectedMember.avatarUrl ? (
|
||||
<img src={selectedMember.avatarUrl} alt="" />
|
||||
) : (
|
||||
<div className="avatar-placeholder"><User size={48} /></div>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="member-display-name">{selectedMember.displayName}</h3>
|
||||
<div className="member-details">
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">微信ID</span>
|
||||
<span className="detail-value">{selectedMember.username}</span>
|
||||
<button className="copy-btn" onClick={() => handleCopy(selectedMember.username, 'username')}>
|
||||
{copiedField === 'username' ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">昵称</span>
|
||||
<span className="detail-value">{selectedMember.displayName}</span>
|
||||
<button className="copy-btn" onClick={() => handleCopy(selectedMember.displayName, 'displayName')}>
|
||||
{copiedField === 'displayName' ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGroupList = () => (
|
||||
<div className="group-sidebar" style={{ width: sidebarWidth }}>
|
||||
<div className="sidebar-header">
|
||||
<div className="search-row">
|
||||
<div className="search-box">
|
||||
<Search size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索群聊..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button className="close-search" onClick={() => setSearchQuery('')}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="refresh-btn" onClick={loadGroups} disabled={isLoading}>
|
||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group-list">
|
||||
{isLoading ? (
|
||||
<div className="loading-groups">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="skeleton-item">
|
||||
<div className="skeleton-avatar" />
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-line" />
|
||||
<div className="skeleton-line" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="empty-groups">
|
||||
<Users size={48} />
|
||||
<p>{searchQuery ? '未找到匹配的群聊' : '暂无群聊数据'}</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredGroups.map(group => (
|
||||
<div
|
||||
key={group.username}
|
||||
className={`group-item ${selectedGroup?.username === group.username ? 'active' : ''}`}
|
||||
onClick={() => handleGroupSelect(group)}
|
||||
>
|
||||
<div className="group-avatar">
|
||||
{group.avatarUrl ? <img src={group.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={20} /></div>}
|
||||
</div>
|
||||
<div className="group-info">
|
||||
<span className="group-name">{group.displayName}</span>
|
||||
<span className="group-members">{group.memberCount} 位成员</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
const renderFunctionMenu = () => (
|
||||
<div className="function-menu">
|
||||
<div className="selected-group-info">
|
||||
<div className="group-avatar large">
|
||||
{selectedGroup?.avatarUrl ? <img src={selectedGroup.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={40} /></div>}
|
||||
</div>
|
||||
<h2>{selectedGroup?.displayName}</h2>
|
||||
<p>{selectedGroup?.memberCount} 位成员</p>
|
||||
</div>
|
||||
<div className="function-grid">
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('members')}>
|
||||
<Users size={32} />
|
||||
<span>群成员查看</span>
|
||||
</div>
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
||||
<BarChart3 size={32} />
|
||||
<span>群聊发言排行</span>
|
||||
</div>
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('activeHours')}>
|
||||
<Clock size={32} />
|
||||
<span>群聊活跃时段</span>
|
||||
</div>
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('mediaStats')}>
|
||||
<Image size={32} />
|
||||
<span>媒体内容统计</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderFunctionContent = () => {
|
||||
const getFunctionTitle = () => {
|
||||
switch (selectedFunction) {
|
||||
case 'members': return '群成员查看'
|
||||
case 'ranking': return '群聊发言排行'
|
||||
case 'activeHours': return '群聊活跃时段'
|
||||
case 'mediaStats': return '媒体内容统计'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const showDateRange = selectedFunction !== 'members'
|
||||
|
||||
return (
|
||||
<div className="function-content">
|
||||
<div className="content-header">
|
||||
<button className="back-btn" onClick={() => setSelectedFunction(null)}>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<div className="header-info">
|
||||
<h3>{getFunctionTitle()}</h3>
|
||||
<span className="header-subtitle">{selectedGroup?.displayName}</span>
|
||||
</div>
|
||||
{showDateRange && (
|
||||
<DateRangePicker
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onRangeComplete={handleDateRangeComplete}
|
||||
/>
|
||||
)}
|
||||
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
|
||||
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="content-body">
|
||||
{functionLoading ? (
|
||||
<div className="content-loading"><Loader2 size={32} className="spin" /></div>
|
||||
) : (
|
||||
<>
|
||||
{selectedFunction === 'members' && (
|
||||
<div className="members-grid">
|
||||
{members.map(member => (
|
||||
<div key={member.username} className="member-card" onClick={() => handleMemberClick(member)}>
|
||||
<div className="member-avatar">
|
||||
{member.avatarUrl ? <img src={member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
</div>
|
||||
<span className="member-name">{member.displayName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedFunction === 'ranking' && (
|
||||
<div className="rankings-list">
|
||||
{rankings.map((item, index) => (
|
||||
<div key={item.member.username} className="ranking-item">
|
||||
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||
<div className="contact-avatar">
|
||||
{item.member.avatarUrl ? <img src={item.member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
<span className="contact-name">{item.member.displayName}</span>
|
||||
</div>
|
||||
<span className="message-count">{formatNumber(item.messageCount)} 条</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedFunction === 'activeHours' && (
|
||||
<div className="chart-container">
|
||||
<ReactECharts option={getHourlyOption()} style={{ height: '100%', minHeight: 300 }} />
|
||||
</div>
|
||||
)}
|
||||
{selectedFunction === 'mediaStats' && mediaStats && (
|
||||
<div className="media-stats">
|
||||
<div className="media-layout">
|
||||
<div className="chart-container">
|
||||
<ReactECharts option={getMediaOption()} style={{ height: '100%', minHeight: 300 }} />
|
||||
</div>
|
||||
<div className="media-legend">
|
||||
{mediaStats.typeCounts.map(item => {
|
||||
const colorMap: Record<number, string> = {
|
||||
1: '#3b82f6', 3: '#22c55e', 34: '#f97316',
|
||||
43: '#a855f7', 47: '#ec4899', 49: '#14b8a6', [-1]: '#6b7280'
|
||||
}
|
||||
const percentage = mediaStats.total > 0 ? ((item.count / mediaStats.total) * 100).toFixed(1) : '0'
|
||||
return (
|
||||
<div key={item.type} className="legend-item">
|
||||
<span className="legend-color" style={{ backgroundColor: colorMap[item.type] || '#6b7280' }} />
|
||||
<span className="legend-name">{item.name}</span>
|
||||
<span className="legend-count">{formatNumber(item.count)} 条</span>
|
||||
<span className="legend-percent">({percentage}%)</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="legend-total">
|
||||
<span>总计</span>
|
||||
<span>{formatNumber(mediaStats.total)} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const renderDetailPanel = () => {
|
||||
if (!selectedGroup) {
|
||||
return (
|
||||
<div className="placeholder">
|
||||
<Users size={64} />
|
||||
<p>请从左侧选择一个群聊进行分析</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!selectedFunction) {
|
||||
return renderFunctionMenu()
|
||||
}
|
||||
return renderFunctionContent()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`group-analytics-page ${isResizing ? 'resizing' : ''}`} ref={containerRef}>
|
||||
{renderGroupList()}
|
||||
<div className="resize-handle" onMouseDown={() => setIsResizing(true)} />
|
||||
<div className="detail-area">
|
||||
{renderDetailPanel()}
|
||||
</div>
|
||||
{renderMemberModal()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupAnalyticsPage
|
||||
@@ -0,0 +1,112 @@
|
||||
.home-page {
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.home-bg-blobs {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: blur(80px);
|
||||
z-index: 0;
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: moveBlob 20s infinite alternate ease-in-out;
|
||||
}
|
||||
|
||||
.blob-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: rgba(139, 115, 85, 0.25);
|
||||
top: -100px;
|
||||
left: -50px;
|
||||
animation-duration: 25s;
|
||||
}
|
||||
|
||||
.blob-2 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: rgba(139, 115, 85, 0.15);
|
||||
bottom: -50px;
|
||||
right: -50px;
|
||||
animation-duration: 30s;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.blob-3 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
top: 40%;
|
||||
left: 30%;
|
||||
animation-duration: 22s;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
[data-mode="dark"] .blob-3 {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.home-content {
|
||||
z-index: 1;
|
||||
animation: fadeScaleUp 1s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 64px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 16px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -2px;
|
||||
background: linear-gradient(135deg, var(--text-primary) 0%, rgba(139, 115, 85, 0.8) 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@keyframes moveBlob {
|
||||
from {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(100px, 50px) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeScaleUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { FolderOpen, ShieldCheck, Sparkles, Waves } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import './HomePage.scss'
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="home-page">
|
||||
<div className="home-bg-blobs">
|
||||
<div className="blob blob-1"></div>
|
||||
<div className="blob blob-2"></div>
|
||||
<div className="blob blob-3"></div>
|
||||
</div>
|
||||
|
||||
<div className="home-content">
|
||||
<div className="hero">
|
||||
<h1 className="hero-title">WeFlow</h1>
|
||||
<p className="hero-subtitle">每一条消息的背后,都藏着一段温暖的时光</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
@@ -0,0 +1,769 @@
|
||||
.settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin: -24px;
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--card-bg);
|
||||
color: var(--primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
|
||||
.section-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.unavailable-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
|
||||
.optional {
|
||||
font-weight: 400;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
margin-top: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.manual-prompt {
|
||||
background: rgba(139, 115, 85, 0.1);
|
||||
border: 1px dashed rgba(139, 115, 85, 0.3);
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 6px 0 8px;
|
||||
|
||||
.prompt-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.key-status {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
margin-bottom: 10px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&:read-only {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.input-with-toggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
|
||||
input {
|
||||
margin-bottom: 0;
|
||||
padding-right: 70px;
|
||||
}
|
||||
|
||||
.toggle-visibility {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.log-toggle-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
height: 24px;
|
||||
display: inline-block;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.switch-input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.switch-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
top: 2px;
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.switch-input:checked + .switch-slider {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.switch-input:checked + .switch-slider::before {
|
||||
transform: translateX(22px);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-actions .btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-toast {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 24px;
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
z-index: 100;
|
||||
animation: slideDown 0.3s ease;
|
||||
|
||||
&.success {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
// 主题选择器
|
||||
.theme-mode-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
width: fit-content;
|
||||
|
||||
.mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--card-bg);
|
||||
color: var(--primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
position: relative;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: var(--bg-primary);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--primary);
|
||||
|
||||
.theme-preview {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.theme-accent {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.theme-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.theme-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 关于页面
|
||||
.about-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
|
||||
.about-logo {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-tertiary);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.about-name {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.about-slogan {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.about-version {
|
||||
margin: 16px 0 0;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.about-update {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.update-hint {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.download-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 200px;
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 3px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 35px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.about-footer {
|
||||
margin-top: auto;
|
||||
padding-top: 24px;
|
||||
text-align: center;
|
||||
|
||||
.about-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.about-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.copyright {
|
||||
margin: 16px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
// 协议弹窗
|
||||
.agreement-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.agreement-modal {
|
||||
width: 500px;
|
||||
max-height: 70vh;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.agreement-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useThemeStore, themes } from '../stores/themeStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Save, Plug, Check, Sun, Moon,
|
||||
Palette, Database, Download, HardDrive, Info, RefreshCw
|
||||
} from 'lucide-react'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'database' | 'export' | 'cache' | 'about'
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'database', label: '数据库连接', icon: Database },
|
||||
{ id: 'export', label: '导出', icon: Download },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'about', label: '关于', icon: Info }
|
||||
]
|
||||
|
||||
function SettingsPage() {
|
||||
const { setDbConnected, setLoading, reset } = useAppStore()
|
||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
|
||||
const [decryptKey, setDecryptKey] = useState('')
|
||||
const [imageXorKey, setImageXorKey] = useState('')
|
||||
const [imageAesKey, setImageAesKey] = useState('')
|
||||
const [dbPath, setDbPath] = useState('')
|
||||
const [wxid, setWxid] = useState('')
|
||||
const [cachePath, setCachePath] = useState('')
|
||||
const [exportPath, setExportPath] = useState('')
|
||||
const [defaultExportPath, setDefaultExportPath] = useState('')
|
||||
const [logEnabled, setLogEnabled] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoadingState] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
const [isDetectingPath, setIsDetectingPath] = useState(false)
|
||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
|
||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
const [appVersion, setAppVersion] = useState('')
|
||||
const [updateInfo, setUpdateInfo] = useState<{ hasUpdate: boolean; version?: string; releaseNotes?: string } | null>(null)
|
||||
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
||||
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
loadDefaultExportPath()
|
||||
loadAppVersion()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
||||
setDbKeyStatus(payload.message)
|
||||
})
|
||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => {
|
||||
setImageKeyStatus(payload.message)
|
||||
})
|
||||
return () => {
|
||||
removeDb?.()
|
||||
removeImage?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const savedKey = await configService.getDecryptKey()
|
||||
const savedPath = await configService.getDbPath()
|
||||
const savedWxid = await configService.getMyWxid()
|
||||
const savedCachePath = await configService.getCachePath()
|
||||
const savedExportPath = await configService.getExportPath()
|
||||
const savedLogEnabled = await configService.getLogEnabled()
|
||||
const savedImageXorKey = await configService.getImageXorKey()
|
||||
const savedImageAesKey = await configService.getImageAesKey()
|
||||
|
||||
if (savedKey) setDecryptKey(savedKey)
|
||||
if (savedPath) setDbPath(savedPath)
|
||||
if (savedWxid) setWxid(savedWxid)
|
||||
if (savedCachePath) setCachePath(savedCachePath)
|
||||
if (savedExportPath) setExportPath(savedExportPath)
|
||||
if (savedImageXorKey != null) {
|
||||
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
if (savedImageAesKey) setImageAesKey(savedImageAesKey)
|
||||
setLogEnabled(savedLogEnabled)
|
||||
} catch (e) {
|
||||
console.error('加载配置失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadDefaultExportPath = async () => {
|
||||
try {
|
||||
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
|
||||
setDefaultExportPath(downloadsPath)
|
||||
} catch (e) {
|
||||
console.error('获取默认导出路径失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAppVersion = async () => {
|
||||
try {
|
||||
const version = await window.electronAPI.app.getVersion()
|
||||
setAppVersion(version)
|
||||
} catch (e) {
|
||||
console.error('获取版本号失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听下载进度
|
||||
useEffect(() => {
|
||||
const removeListener = window.electronAPI.app.onDownloadProgress?.((progress: number) => {
|
||||
setDownloadProgress(progress)
|
||||
})
|
||||
return () => removeListener?.()
|
||||
}, [])
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setIsCheckingUpdate(true)
|
||||
setUpdateInfo(null)
|
||||
try {
|
||||
const result = await window.electronAPI.app.checkForUpdates()
|
||||
if (result.hasUpdate) {
|
||||
setUpdateInfo(result)
|
||||
showMessage(`发现新版本 ${result.version}`, true)
|
||||
} else {
|
||||
showMessage('当前已是最新版本', true)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`检查更新失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsCheckingUpdate(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateNow = async () => {
|
||||
setIsDownloading(true)
|
||||
setDownloadProgress(0)
|
||||
try {
|
||||
showMessage('正在下载更新...', true)
|
||||
await window.electronAPI.app.downloadAndInstall()
|
||||
} catch (e) {
|
||||
showMessage(`更新失败: ${e}`, false)
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showMessage = (text: string, success: boolean) => {
|
||||
setMessage({ text, success })
|
||||
setTimeout(() => setMessage(null), 3000)
|
||||
}
|
||||
|
||||
const handleAutoDetectPath = async () => {
|
||||
if (isDetectingPath) return
|
||||
setIsDetectingPath(true)
|
||||
try {
|
||||
const result = await window.electronAPI.dbPath.autoDetect()
|
||||
if (result.success && result.path) {
|
||||
setDbPath(result.path)
|
||||
await configService.setDbPath(result.path)
|
||||
showMessage(`自动检测成功:${result.path}`, true)
|
||||
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
|
||||
if (wxids.length === 1) {
|
||||
setWxid(wxids[0].wxid)
|
||||
await configService.setMyWxid(wxids[0].wxid)
|
||||
showMessage(`已检测到账号:${wxids[0].wxid}`, true)
|
||||
} else if (wxids.length > 1) {
|
||||
showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true)
|
||||
}
|
||||
} else {
|
||||
showMessage(result.error || '未能自动检测到数据库目录', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`自动检测失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsDetectingPath(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectDbPath = async () => {
|
||||
try {
|
||||
const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] })
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setDbPath(result.filePaths[0])
|
||||
showMessage('已选择数据库目录', true)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('选择目录失败', false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanWxid = async (silent = false) => {
|
||||
if (!dbPath) {
|
||||
if (!silent) showMessage('请先选择数据库目录', false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
if (wxids.length === 1) {
|
||||
setWxid(wxids[0].wxid)
|
||||
await configService.setMyWxid(wxids[0].wxid)
|
||||
if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true)
|
||||
} else if (wxids.length > 1) {
|
||||
if (!silent) showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true)
|
||||
} else {
|
||||
if (!silent) showMessage('未检测到账号目录,请检查路径', false)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!silent) showMessage(`扫描失败: ${e}`, false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectCachePath = async () => {
|
||||
try {
|
||||
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setCachePath(result.filePaths[0])
|
||||
showMessage('已选择缓存目录', true)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('选择目录失败', false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectExportPath = async () => {
|
||||
try {
|
||||
const result = await dialog.openFile({ title: '选择导出目录', properties: ['openDirectory'] })
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setExportPath(result.filePaths[0])
|
||||
await configService.setExportPath(result.filePaths[0])
|
||||
showMessage('已设置导出目录', true)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('选择目录失败', false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoGetDbKey = async () => {
|
||||
if (isFetchingDbKey) return
|
||||
setIsFetchingDbKey(true)
|
||||
setIsManualStartPrompt(false)
|
||||
setDbKeyStatus('正在连接微信进程...')
|
||||
try {
|
||||
const result = await window.electronAPI.key.autoGetDbKey()
|
||||
if (result.success && result.key) {
|
||||
setDecryptKey(result.key)
|
||||
setDbKeyStatus('密钥获取成功')
|
||||
showMessage('已自动获取解密密钥', true)
|
||||
await handleScanWxid(true)
|
||||
} else {
|
||||
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
||||
setIsManualStartPrompt(true)
|
||||
setDbKeyStatus('需要手动启动微信')
|
||||
} else {
|
||||
showMessage(result.error || '自动获取密钥失败', false)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`自动获取密钥失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsFetchingDbKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualConfirm = async () => {
|
||||
setIsManualStartPrompt(false)
|
||||
handleAutoGetDbKey()
|
||||
}
|
||||
|
||||
const handleAutoGetImageKey = async () => {
|
||||
if (isFetchingImageKey) return
|
||||
if (!dbPath) {
|
||||
showMessage('请先选择数据库目录', false)
|
||||
return
|
||||
}
|
||||
setIsFetchingImageKey(true)
|
||||
setImageKeyStatus('正在准备获取图片密钥...')
|
||||
try {
|
||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
|
||||
if (result.success && result.aesKey) {
|
||||
if (typeof result.xorKey === 'number') {
|
||||
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
setImageAesKey(result.aesKey)
|
||||
setImageKeyStatus('已获取图片密钥')
|
||||
showMessage('已自动获取图片密钥', true)
|
||||
} else {
|
||||
showMessage(result.error || '自动获取图片密钥失败', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`自动获取图片密钥失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsFetchingImageKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetExportPath = async () => {
|
||||
try {
|
||||
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
|
||||
setExportPath(downloadsPath)
|
||||
await configService.setExportPath(downloadsPath)
|
||||
showMessage('已恢复为下载目录', true)
|
||||
} catch (e) {
|
||||
showMessage('恢复默认失败', false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!dbPath) { showMessage('请先选择数据库目录', false); return }
|
||||
if (!decryptKey) { showMessage('请先输入解密密钥', false); return }
|
||||
if (decryptKey.length !== 64) { showMessage('密钥长度必须为64个字符', false); return }
|
||||
if (!wxid) { showMessage('请先输入或扫描 wxid', false); return }
|
||||
|
||||
setIsTesting(true)
|
||||
try {
|
||||
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
|
||||
if (result.success) {
|
||||
showMessage('连接测试成功!数据库可正常访问', true)
|
||||
} else {
|
||||
showMessage(result.error || '连接测试失败', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`连接测试失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!decryptKey) { showMessage('请输入解密密钥', false); return }
|
||||
if (decryptKey.length !== 64) { showMessage('密钥长度必须为64个字符', false); return }
|
||||
if (!dbPath) { showMessage('请选择数据库目录', false); return }
|
||||
if (!wxid) { showMessage('请输入 wxid', false); return }
|
||||
|
||||
setIsLoadingState(true)
|
||||
setLoading(true, '正在保存配置...')
|
||||
|
||||
try {
|
||||
await configService.setDecryptKey(decryptKey)
|
||||
await configService.setDbPath(dbPath)
|
||||
await configService.setMyWxid(wxid)
|
||||
await configService.setCachePath(cachePath)
|
||||
if (imageXorKey) {
|
||||
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
await configService.setImageXorKey(parsed)
|
||||
}
|
||||
} else {
|
||||
await configService.setImageXorKey(0)
|
||||
}
|
||||
if (imageAesKey) {
|
||||
await configService.setImageAesKey(imageAesKey)
|
||||
} else {
|
||||
await configService.setImageAesKey('')
|
||||
}
|
||||
await configService.setOnboardingDone(true)
|
||||
|
||||
showMessage('配置保存成功,正在测试连接...', true)
|
||||
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
|
||||
|
||||
if (result.success) {
|
||||
setDbConnected(true, dbPath)
|
||||
showMessage('配置保存成功!数据库连接正常', true)
|
||||
} else {
|
||||
showMessage(result.error || '数据库连接失败,请检查配置', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`保存配置失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsLoadingState(false)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearConfig = async () => {
|
||||
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置。')
|
||||
if (!confirmed) return
|
||||
setIsLoadingState(true)
|
||||
setLoading(true, '正在清除配置...')
|
||||
try {
|
||||
await window.electronAPI.wcdb.close()
|
||||
await configService.clearConfig()
|
||||
reset()
|
||||
setDecryptKey('')
|
||||
setImageXorKey('')
|
||||
setImageAesKey('')
|
||||
setDbPath('')
|
||||
setWxid('')
|
||||
setCachePath('')
|
||||
setExportPath('')
|
||||
setLogEnabled(false)
|
||||
setDbConnected(false)
|
||||
await window.electronAPI.window.openOnboardingWindow()
|
||||
} catch (e) {
|
||||
showMessage(`清除配置失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsLoadingState(false)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenLog = async () => {
|
||||
try {
|
||||
const logPath = await window.electronAPI.log.getPath()
|
||||
await window.electronAPI.shell.openPath(logPath)
|
||||
} catch (e) {
|
||||
showMessage(`打开日志失败: ${e}`, false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyLog = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.log.read()
|
||||
if (!result.success) {
|
||||
showMessage(result.error || '读取日志失败', false)
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.writeText(result.content || '')
|
||||
showMessage('日志已复制到剪贴板', true)
|
||||
} catch (e) {
|
||||
showMessage(`复制日志失败: ${e}`, false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderAppearanceTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="theme-mode-toggle">
|
||||
<button className={`mode-btn ${themeMode === 'light' ? 'active' : ''}`} onClick={() => setThemeMode('light')}>
|
||||
<Sun size={16} /> 浅色
|
||||
</button>
|
||||
<button className={`mode-btn ${themeMode === 'dark' ? 'active' : ''}`} onClick={() => setThemeMode('dark')}>
|
||||
<Moon size={16} /> 深色
|
||||
</button>
|
||||
</div>
|
||||
<div className="theme-grid">
|
||||
{themes.map((theme) => (
|
||||
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
|
||||
<div className="theme-preview" style={{ background: themeMode === 'dark' ? 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)' : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)` }}>
|
||||
<div className="theme-accent" style={{ background: theme.primaryColor }} />
|
||||
</div>
|
||||
<div className="theme-info">
|
||||
<span className="theme-name">{theme.name}</span>
|
||||
<span className="theme-desc">{theme.description}</span>
|
||||
</div>
|
||||
{currentTheme === theme.id && <div className="theme-check"><Check size={14} /></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderDatabaseTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>解密密钥</label>
|
||||
<span className="form-hint">64位十六进制密钥</span>
|
||||
<div className="input-with-toggle">
|
||||
<input type={showDecryptKey ? 'text' : 'password'} placeholder="例如: a1b2c3d4e5f6..." value={decryptKey} onChange={(e) => setDecryptKey(e.target.value)} />
|
||||
<button type="button" className="toggle-visibility" onClick={() => setShowDecryptKey(!showDecryptKey)}>
|
||||
{showDecryptKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{isManualStartPrompt ? (
|
||||
<div className="manual-prompt">
|
||||
<p className="prompt-text">未能自动启动微信,请手动启动并登录后点击下方确认</p>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleManualConfirm}>
|
||||
我已启动微信,继续检测
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
|
||||
<Plug size={14} /> {isFetchingDbKey ? '获取中...' : '自动获取密钥'}
|
||||
</button>
|
||||
)}
|
||||
{dbKeyStatus && <div className="form-hint status-text">{dbKeyStatus}</div>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>数据库根目录</label>
|
||||
<span className="form-hint">xwechat_files 目录</span>
|
||||
<input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} />
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
||||
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleSelectDbPath}><FolderOpen size={16} /> 浏览选择</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>账号 wxid</label>
|
||||
<span className="form-hint">微信账号标识</span>
|
||||
<input type="text" placeholder="例如: wxid_xxxxxx" value={wxid} onChange={(e) => setWxid(e.target.value)} />
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> 扫描 wxid</button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>图片 XOR 密钥 <span className="optional">(可选)</span></label>
|
||||
<span className="form-hint">用于解密图片缓存</span>
|
||||
<input type="text" placeholder="例如: 0xA4" value={imageXorKey} onChange={(e) => setImageXorKey(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>图片 AES 密钥 <span className="optional">(可选)</span></label>
|
||||
<span className="form-hint">16 位密钥</span>
|
||||
<input type="text" placeholder="16 位 AES 密钥" value={imageAesKey} onChange={(e) => setImageAesKey(e.target.value)} />
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||
</button>
|
||||
{imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>缓存目录 <span className="optional">(可选)</span></label>
|
||||
<span className="form-hint">留空使用默认目录</span>
|
||||
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> 浏览选择</button>
|
||||
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> 恢复默认</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>调试日志</label>
|
||||
<span className="form-hint">开启后写入 WCDB 调试日志,便于排查连接问题</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{logEnabled ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="log-enabled-toggle">
|
||||
<input
|
||||
id="log-enabled-toggle"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={logEnabled}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setLogEnabled(enabled)
|
||||
await configService.setLogEnabled(enabled)
|
||||
showMessage(enabled ? '已开启日志' : '已关闭日志', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="log-actions">
|
||||
<button className="btn btn-secondary" onClick={handleOpenLog}>
|
||||
<FolderOpen size={16} /> 打开日志文件
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleCopyLog}>
|
||||
<Copy size={16} /> 复制日志内容
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderExportTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>导出目录</label>
|
||||
<span className="form-hint">聊天记录导出的默认保存位置</span>
|
||||
<input type="text" placeholder={defaultExportPath || '系统下载目录'} value={exportPath || defaultExportPath} onChange={(e) => setExportPath(e.target.value)} />
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-secondary" onClick={handleSelectExportPath}><FolderOpen size={16} /> 浏览选择</button>
|
||||
<button className="btn btn-secondary" onClick={handleResetExportPath}><RotateCcw size={16} /> 恢复默认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderCacheTab = () => (
|
||||
<div className="tab-content">
|
||||
<p className="section-desc">管理应用缓存数据</p>
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-secondary"><Trash2 size={16} /> 清除分析缓存</button>
|
||||
<button className="btn btn-secondary"><Trash2 size={16} /> 清除图片缓存</button>
|
||||
<button className="btn btn-danger"><Trash2 size={16} /> 清除所有缓存</button>
|
||||
</div>
|
||||
<div className="divider" />
|
||||
<p className="section-desc">清除当前配置并重新开始首次引导</p>
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-danger" onClick={handleClearConfig}>
|
||||
<RefreshCw size={16} /> 清除当前配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderAboutTab = () => (
|
||||
<div className="tab-content about-tab">
|
||||
<div className="about-card">
|
||||
<div className="about-logo">
|
||||
<img src="./logo.png" alt="WeFlow" />
|
||||
</div>
|
||||
<h2 className="about-name">WeFlow</h2>
|
||||
<p className="about-slogan">WeFlow</p>
|
||||
<p className="about-version">v{appVersion || '...'}</p>
|
||||
|
||||
<div className="about-update">
|
||||
{updateInfo?.hasUpdate ? (
|
||||
<>
|
||||
<p className="update-hint">新版本 v{updateInfo.version} 可用</p>
|
||||
{isDownloading ? (
|
||||
<div className="download-progress">
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
|
||||
</div>
|
||||
<span>{downloadProgress.toFixed(0)}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={handleUpdateNow}>
|
||||
<Download size={16} /> 立即更新
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
|
||||
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
||||
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="about-footer">
|
||||
<p className="about-desc">微信聊天记录分析工具</p>
|
||||
<div className="about-links">
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://github.com/hicccc77/WeFlow') }}>官网</a>
|
||||
<span>·</span>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://chatlab.fun') }}>ChatLab</a>
|
||||
<span>·</span>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.window.openAgreementWindow() }}>用户协议</a>
|
||||
</div>
|
||||
<p className="copyright">© 2025 WeFlow. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
|
||||
|
||||
<div className="settings-header">
|
||||
<h1>设置</h1>
|
||||
<div className="settings-actions">
|
||||
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
|
||||
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSaveConfig} disabled={isLoading}>
|
||||
<Save size={16} /> {isLoading ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button key={tab.id} className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`} onClick={() => setActiveTab(tab.id)}>
|
||||
<tab.icon size={16} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
{activeTab === 'about' && renderAboutTab()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
@@ -0,0 +1,493 @@
|
||||
.welcome-page {
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.6), transparent 55%),
|
||||
radial-gradient(circle at 80% 20%, rgba(139, 115, 85, 0.18), transparent 45%),
|
||||
var(--bg-gradient);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welcome-page.is-standalone {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 22px;
|
||||
padding: 20px;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.welcome-page.is-standalone .welcome-shell {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.welcome-page.is-standalone .window-controls {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(25, 25, 25, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 3;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.welcome-page.is-standalone .window-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
.welcome-page.is-standalone .window-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.welcome-page.is-standalone .window-btn.is-close:hover {
|
||||
background: rgba(219, 92, 92, 0.35);
|
||||
}
|
||||
|
||||
.welcome-page.is-closing {
|
||||
animation: fadeOut 0.45s ease forwards;
|
||||
}
|
||||
|
||||
.welcome-page::before,
|
||||
.welcome-page::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
filter: blur(0px);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.welcome-page::before {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
top: -120px;
|
||||
right: 10%;
|
||||
background: rgba(139, 115, 85, 0.15);
|
||||
}
|
||||
|
||||
.welcome-page::after {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
bottom: -80px;
|
||||
left: 12%;
|
||||
}
|
||||
|
||||
.welcome-shell {
|
||||
width: min(980px, 92vw);
|
||||
display: grid;
|
||||
grid-template-columns: 0.95fr 1.05fr;
|
||||
gap: 28px;
|
||||
z-index: 1;
|
||||
animation: fadeUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.welcome-panel,
|
||||
.setup-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.welcome-panel {
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.panel-kicker {
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.welcome-panel h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.step-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
[data-mode="dark"] .step-item {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.step-item.active {
|
||||
background: var(--primary-light);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.step-item.done {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.step-index {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--primary-gradient);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setup-header h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.setup-header p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.setup-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.setup-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.intro-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
[data-mode="dark"] .intro-card {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.intro-card h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.intro-card p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-light);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wxid-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wxid-option {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wxid-option:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(139, 115, 85, 0.4);
|
||||
box-shadow: 0 8px 16px rgba(15, 15, 15, 0.08);
|
||||
}
|
||||
|
||||
.wxid-option.is-selected {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-light);
|
||||
}
|
||||
|
||||
.wxid-option-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wxid-option-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
align-self: flex-end;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-with-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.welcome-page .btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.welcome-page .btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.welcome-page .btn-primary {
|
||||
color: #fff;
|
||||
background: var(--primary-gradient);
|
||||
box-shadow: 0 10px 18px rgba(139, 115, 85, 0.25);
|
||||
}
|
||||
|
||||
.welcome-page .btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.welcome-page .btn-secondary {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.welcome-page .btn-tertiary {
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.welcome-page .btn-inline {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.welcome-page .btn-full {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.setup-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(250, 81, 81, 0.1);
|
||||
color: var(--danger);
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.manual-prompt {
|
||||
background: rgba(139, 115, 85, 0.1);
|
||||
border: 1px dashed rgba(139, 115, 85, 0.3);
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 4px 0;
|
||||
|
||||
.prompt-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.welcome-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
import {
|
||||
ArrowLeft, ArrowRight, CheckCircle2, Database, Eye, EyeOff,
|
||||
FolderOpen, FolderSearch, KeyRound, ShieldCheck, Sparkles,
|
||||
UserRound, Wand2, Minus, X, HardDrive, RotateCcw
|
||||
} from 'lucide-react'
|
||||
import './WelcomePage.scss'
|
||||
|
||||
const steps = [
|
||||
{ id: 'intro', title: '欢迎', desc: '准备开始你的本地数据探索' },
|
||||
{ id: 'db', title: '数据库目录', desc: '定位 xwechat_files 目录' },
|
||||
{ id: 'cache', title: '缓存目录', desc: '设置本地缓存存储位置(可选)' },
|
||||
{ id: 'key', title: '解密密钥', desc: '获取密钥与自动识别账号' },
|
||||
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' }
|
||||
]
|
||||
|
||||
interface WelcomePageProps {
|
||||
standalone?: boolean
|
||||
}
|
||||
|
||||
function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const navigate = useNavigate()
|
||||
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
|
||||
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [dbPath, setDbPath] = useState('')
|
||||
const [decryptKey, setDecryptKey] = useState('')
|
||||
const [imageXorKey, setImageXorKey] = useState('')
|
||||
const [imageAesKey, setImageAesKey] = useState('')
|
||||
const [cachePath, setCachePath] = useState('')
|
||||
const [wxid, setWxid] = useState('')
|
||||
const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([])
|
||||
const [error, setError] = useState('')
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [isDetectingPath, setIsDetectingPath] = useState(false)
|
||||
const [isScanningWxid, setIsScanningWxid] = useState(false)
|
||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
|
||||
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
||||
setDbKeyStatus(payload.message)
|
||||
})
|
||||
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => {
|
||||
setImageKeyStatus(payload.message)
|
||||
})
|
||||
return () => {
|
||||
removeDb?.()
|
||||
removeImage?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDbConnected && !standalone) {
|
||||
navigate('/home')
|
||||
}
|
||||
}, [isDbConnected, standalone, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
setWxidOptions([])
|
||||
setWxid('')
|
||||
}, [dbPath])
|
||||
|
||||
const currentStep = steps[stepIndex]
|
||||
const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}`
|
||||
const showWindowControls = standalone
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.electronAPI.window.minimize()
|
||||
}
|
||||
|
||||
const handleCloseWindow = () => {
|
||||
window.electronAPI.window.close()
|
||||
}
|
||||
|
||||
const handleSelectPath = async () => {
|
||||
try {
|
||||
const result = await dialog.openFile({
|
||||
title: '选择微信数据库目录',
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setDbPath(result.filePaths[0])
|
||||
setError('')
|
||||
}
|
||||
} catch (e) {
|
||||
setError('选择目录失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoDetectPath = async () => {
|
||||
if (isDetectingPath) return
|
||||
setIsDetectingPath(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await window.electronAPI.dbPath.autoDetect()
|
||||
if (result.success && result.path) {
|
||||
setDbPath(result.path)
|
||||
setError('')
|
||||
} else {
|
||||
setError(result.error || '未能检测到数据库目录')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`自动检测失败: ${e}`)
|
||||
} finally {
|
||||
setIsDetectingPath(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectCachePath = async () => {
|
||||
try {
|
||||
const result = await dialog.openFile({
|
||||
title: '选择缓存目录',
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setCachePath(result.filePaths[0])
|
||||
setError('')
|
||||
}
|
||||
} catch (e) {
|
||||
setError('选择缓存目录失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanWxid = async (silent = false) => {
|
||||
if (!dbPath) {
|
||||
if (!silent) setError('请先选择数据库目录')
|
||||
return
|
||||
}
|
||||
if (isScanningWxid) return
|
||||
setIsScanningWxid(true)
|
||||
if (!silent) setError('')
|
||||
try {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
setWxidOptions(wxids)
|
||||
if (wxids.length > 0) {
|
||||
// scanWxids 已经按时间排过序了,直接取第一个
|
||||
setWxid(wxids[0].wxid)
|
||||
if (!silent) setError('')
|
||||
} else {
|
||||
if (!silent) setError('未检测到账号目录,请检查路径')
|
||||
}
|
||||
} catch (e) {
|
||||
if (!silent) setError(`扫描失败: ${e}`)
|
||||
} finally {
|
||||
setIsScanningWxid(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoGetDbKey = async () => {
|
||||
if (isFetchingDbKey) return
|
||||
setIsFetchingDbKey(true)
|
||||
setError('')
|
||||
setIsManualStartPrompt(false)
|
||||
setDbKeyStatus('正在连接微信进程...')
|
||||
try {
|
||||
const result = await window.electronAPI.key.autoGetDbKey()
|
||||
if (result.success && result.key) {
|
||||
setDecryptKey(result.key)
|
||||
setDbKeyStatus('密钥获取成功')
|
||||
setError('')
|
||||
// 获取成功后自动扫描并填入 wxid
|
||||
await handleScanWxid(true)
|
||||
} else {
|
||||
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
|
||||
setIsManualStartPrompt(true)
|
||||
setDbKeyStatus('需要手动启动微信')
|
||||
} else {
|
||||
setError(result.error || '自动获取密钥失败')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`自动获取密钥失败: ${e}`)
|
||||
} finally {
|
||||
setIsFetchingDbKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualConfirm = async () => {
|
||||
setIsManualStartPrompt(false)
|
||||
handleAutoGetDbKey()
|
||||
}
|
||||
|
||||
const handleAutoGetImageKey = async () => {
|
||||
if (isFetchingImageKey) return
|
||||
if (!dbPath) {
|
||||
setError('请先选择数据库目录')
|
||||
return
|
||||
}
|
||||
setIsFetchingImageKey(true)
|
||||
setError('')
|
||||
setImageKeyStatus('正在准备获取图片密钥...')
|
||||
try {
|
||||
// 拼接完整的账号目录,确保 KeyService 能准确找到模板文件
|
||||
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
|
||||
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
|
||||
if (result.success && result.aesKey) {
|
||||
if (typeof result.xorKey === 'number') {
|
||||
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
setImageAesKey(result.aesKey)
|
||||
setImageKeyStatus('已获取图片密钥')
|
||||
} else {
|
||||
setError(result.error || '自动获取图片密钥失败')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`自动获取图片密钥失败: ${e}`)
|
||||
} finally {
|
||||
setIsFetchingImageKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canGoNext = () => {
|
||||
if (currentStep.id === 'intro') return true
|
||||
if (currentStep.id === 'db') return Boolean(dbPath)
|
||||
if (currentStep.id === 'cache') return true
|
||||
if (currentStep.id === 'key') return decryptKey.length === 64 && Boolean(wxid)
|
||||
if (currentStep.id === 'image') return true
|
||||
return false
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (!canGoNext()) {
|
||||
if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录')
|
||||
if (currentStep.id === 'key') {
|
||||
if (decryptKey.length !== 64) setError('密钥长度必须为 64 个字符')
|
||||
else if (!wxid) setError('未能自动识别 wxid,请尝试重新获取或检查目录')
|
||||
}
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setStepIndex((prev) => Math.min(prev + 1, steps.length - 1))
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
setError('')
|
||||
setStepIndex((prev) => Math.max(prev - 1, 0))
|
||||
}
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!dbPath) { setError('请先选择数据库目录'); return }
|
||||
if (!wxid) { setError('请填写微信ID'); return }
|
||||
if (!decryptKey || decryptKey.length !== 64) { setError('请填写 64 位解密密钥'); return }
|
||||
|
||||
setIsConnecting(true)
|
||||
setError('')
|
||||
setLoading(true, '正在连接数据库...')
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
|
||||
if (!result.success) {
|
||||
setError(result.error || 'WCDB 连接失败')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
await configService.setDbPath(dbPath)
|
||||
await configService.setDecryptKey(decryptKey)
|
||||
await configService.setMyWxid(wxid)
|
||||
await configService.setCachePath(cachePath)
|
||||
if (imageXorKey) {
|
||||
const parsed = parseInt(imageXorKey.replace(/^0x/i, ''), 16)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
await configService.setImageXorKey(parsed)
|
||||
}
|
||||
}
|
||||
if (imageAesKey) {
|
||||
await configService.setImageAesKey(imageAesKey)
|
||||
}
|
||||
await configService.setOnboardingDone(true)
|
||||
|
||||
setDbConnected(true, dbPath)
|
||||
setLoading(false)
|
||||
|
||||
if (standalone) {
|
||||
setIsClosing(true)
|
||||
setTimeout(() => {
|
||||
window.electronAPI.window.completeOnboarding()
|
||||
}, 450)
|
||||
} else {
|
||||
navigate('/home')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`连接失败: ${e}`)
|
||||
setLoading(false)
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatModifiedTime = (time: number) => {
|
||||
if (!time) return '未知时间'
|
||||
const date = new Date(time)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
if (isDbConnected) {
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
{showWindowControls && (
|
||||
<div className="window-controls">
|
||||
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="welcome-shell">
|
||||
<div className="welcome-panel">
|
||||
<div className="panel-header">
|
||||
<img src="./logo.png" alt="WeFlow" className="panel-logo" />
|
||||
<div>
|
||||
<p className="panel-kicker">WeFlow</p>
|
||||
<h1>已连接数据库</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-note">
|
||||
<CheckCircle2 size={16} />
|
||||
<span>配置已完成,可直接进入首页</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-full"
|
||||
onClick={() => {
|
||||
if (standalone) {
|
||||
setIsClosing(true)
|
||||
setTimeout(() => {
|
||||
window.electronAPI.window.completeOnboarding()
|
||||
}, 450)
|
||||
} else {
|
||||
navigate('/home')
|
||||
}
|
||||
}}
|
||||
>
|
||||
进入首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
{showWindowControls && (
|
||||
<div className="window-controls">
|
||||
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="welcome-shell">
|
||||
<div className="welcome-panel">
|
||||
<div className="panel-header">
|
||||
<img src="./logo.png" alt="WeFlow" className="panel-logo" />
|
||||
<div>
|
||||
<p className="panel-kicker">首次配置</p>
|
||||
<h1>WeFlow 初始引导</h1>
|
||||
<p className="panel-subtitle">一步一步完成数据库与密钥设置</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="step-list">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className={`step-item ${index === stepIndex ? 'active' : ''} ${index < stepIndex ? 'done' : ''}`}>
|
||||
<div className="step-index">{index < stepIndex ? <CheckCircle2 size={14} /> : index + 1}</div>
|
||||
<div>
|
||||
<div className="step-title">{step.title}</div>
|
||||
<div className="step-desc">{step.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="panel-foot">
|
||||
<ShieldCheck size={16} />
|
||||
<span>数据仅在本地处理,不上传服务器</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setup-card">
|
||||
<div className="setup-header">
|
||||
<div className="setup-icon">
|
||||
{currentStep.id === 'intro' && <Sparkles size={18} />}
|
||||
{currentStep.id === 'db' && <Database size={18} />}
|
||||
{currentStep.id === 'cache' && <HardDrive size={18} />}
|
||||
{currentStep.id === 'key' && <KeyRound size={18} />}
|
||||
{currentStep.id === 'image' && <ShieldCheck size={18} />}
|
||||
</div>
|
||||
<div>
|
||||
<h2>{currentStep.title}</h2>
|
||||
<p>{currentStep.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStep.id === 'intro' && (
|
||||
<div className="setup-body">
|
||||
<div className="intro-card">
|
||||
<Wand2 size={18} />
|
||||
<div>
|
||||
<h3>准备好了吗?</h3>
|
||||
<p>接下来只需配置数据库目录和获取解密密钥。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'db' && (
|
||||
<div className="setup-body">
|
||||
<label className="field-label">数据库根目录</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="例如:C:\\Users\\xxx\\Documents\\xwechat_files"
|
||||
value={dbPath}
|
||||
onChange={(e) => setDbPath(e.target.value)}
|
||||
/>
|
||||
<div className="button-row">
|
||||
<button className="btn btn-secondary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
||||
<FolderSearch size={16} /> {isDetectingPath ? '检测中...' : '自动检测'}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSelectPath}>
|
||||
<FolderOpen size={16} /> 浏览选择
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-hint">建议选择包含 xwechat_files 的目录</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'cache' && (
|
||||
<div className="setup-body">
|
||||
<label className="field-label">缓存目录</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="留空使用默认目录"
|
||||
value={cachePath}
|
||||
onChange={(e) => setCachePath(e.target.value)}
|
||||
/>
|
||||
<div className="button-row">
|
||||
<button className="btn btn-primary" onClick={handleSelectCachePath}>
|
||||
<FolderOpen size={16} /> 浏览选择
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setCachePath('')}>
|
||||
<RotateCcw size={16} /> 使用默认
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-hint">用于头像、表情与图片缓存,留空使用默认目录</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'key' && (
|
||||
<div className="setup-body">
|
||||
<label className="field-label">微信账号 wxid</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="获取密钥后将自动填充"
|
||||
value={wxid}
|
||||
onChange={(e) => setWxid(e.target.value)}
|
||||
/>
|
||||
<label className="field-label">解密密钥</label>
|
||||
<div className="field-with-toggle">
|
||||
<input
|
||||
type={showDecryptKey ? 'text' : 'password'}
|
||||
className="field-input"
|
||||
placeholder="64 位十六进制密钥"
|
||||
value={decryptKey}
|
||||
onChange={(e) => setDecryptKey(e.target.value.trim())}
|
||||
/>
|
||||
<button type="button" className="toggle-btn" onClick={() => setShowDecryptKey(!showDecryptKey)}>
|
||||
{showDecryptKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isManualStartPrompt ? (
|
||||
<div className="manual-prompt">
|
||||
<p className="prompt-text">未能自动启动微信,请手动启动并登录后点击下方确认</p>
|
||||
<button className="btn btn-primary" onClick={handleManualConfirm}>
|
||||
我已启动微信,继续检测
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetDbKey} disabled={isFetchingDbKey}>
|
||||
{isFetchingDbKey ? '获取中...' : '自动获取密钥'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
||||
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'image' && (
|
||||
<div className="setup-body">
|
||||
<label className="field-label">图片 XOR 密钥</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="例如:0xA4"
|
||||
value={imageXorKey}
|
||||
onChange={(e) => setImageXorKey(e.target.value)}
|
||||
/>
|
||||
<label className="field-label">图片 AES 密钥</label>
|
||||
<input
|
||||
type="text"
|
||||
className="field-input"
|
||||
placeholder="16 位密钥"
|
||||
value={imageAesKey}
|
||||
onChange={(e) => setImageAesKey(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-secondary btn-inline" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
|
||||
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||
</button>
|
||||
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
||||
<div className="field-hint">如获取失败,请先打开朋友圈图片再重试</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="setup-actions">
|
||||
<button className="btn btn-tertiary" onClick={handleBack} disabled={stepIndex === 0}>
|
||||
<ArrowLeft size={16} /> 上一步
|
||||
</button>
|
||||
{stepIndex < steps.length - 1 ? (
|
||||
<button className="btn btn-primary" onClick={handleNext} disabled={!canGoNext()}>
|
||||
下一步 <ArrowRight size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={handleConnect} disabled={isConnecting || !canGoNext()}>
|
||||
{isConnecting ? '连接中...' : '测试并完成'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WelcomePage
|
||||
|
||||
Reference in new issue
Block a user