新的提交

This commit is contained in:
cc
2026-01-10 13:01:37 +08:00
commit 01641834de
188 changed files with 34865 additions and 0 deletions

View File

@@ -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);
}
}
}

View File

@@ -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>使WeFlowWeFlow使使</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">20251</p>
</div>
</div>
)
}
export default AgreementPage

View File

@@ -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;
}
}
}

309
src/pages/AnalyticsPage.tsx Normal file
View File

@@ -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

View File

@@ -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); }
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1845
src/pages/ChatPage.scss Normal file

File diff suppressed because it is too large Load Diff

1465
src/pages/ChatPage.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}
}

View File

@@ -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

657
src/pages/ExportPage.scss Normal file
View File

@@ -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); }
}

377
src/pages/ExportPage.tsx Normal file
View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

112
src/pages/HomePage.scss Normal file
View File

@@ -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);
}
}

24
src/pages/HomePage.tsx Normal file
View File

@@ -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

769
src/pages/SettingsPage.scss Normal file
View File

@@ -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;
}
}
}

683
src/pages/SettingsPage.tsx Normal file
View File

@@ -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

493
src/pages/WelcomePage.scss Normal file
View File

@@ -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);
}
}

561
src/pages/WelcomePage.tsx Normal file
View File

@@ -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