新的提交

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

309
src/App.scss Normal file
View File

@@ -0,0 +1,309 @@
.app-container {
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-primary);
animation: appFadeIn 0.35s ease-out;
}
.main-layout {
flex: 1;
display: flex;
overflow: hidden;
}
.content {
flex: 1;
overflow: auto;
padding: 24px;
}
@keyframes appFadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 更新提示条
.update-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: var(--primary);
color: white;
font-size: 14px;
.update-text {
flex: 1;
strong {
font-weight: 600;
}
}
.update-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
color: white;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
.dismiss-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: white;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
.update-progress {
display: flex;
align-items: center;
gap: 10px;
min-width: 150px;
.progress-bar {
flex: 1;
height: 6px;
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: white;
border-radius: 3px;
transition: width 0.2s ease;
}
}
span {
font-size: 12px;
min-width: 35px;
}
}
}
// 用户协议弹窗
.agreement-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.agreement-modal {
width: 520px;
max-height: 80vh;
background: var(--bg-primary);
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.agreement-header {
display: flex;
align-items: center;
gap: 12px;
padding: 24px 28px;
border-bottom: 1px solid var(--border-color);
svg {
color: var(--primary);
}
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
}
.agreement-content {
flex: 1;
padding: 20px 28px;
overflow-y: auto;
> p {
margin: 0 0 16px;
font-size: 14px;
color: var(--text-secondary);
}
}
.agreement-notice {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 160, 0, 0.35);
background: rgba(255, 160, 0, 0.12);
color: var(--text-primary);
strong {
font-size: 15px;
font-weight: 700;
}
.agreement-notice-link {
font-size: 12px;
color: var(--text-secondary);
}
a {
font-size: 12px;
color: var(--primary);
text-decoration: none;
word-break: break-all;
&:hover {
text-decoration: underline;
}
}
}
.agreement-text {
background: var(--bg-secondary);
border-radius: 10px;
padding: 20px;
max-height: 280px;
overflow-y: auto;
h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
&:not(:first-child) {
margin-top: 16px;
}
}
p {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
}
.agreement-footer {
padding: 20px 28px;
border-top: 1px solid var(--border-color);
}
.agreement-checkbox {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
cursor: pointer;
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
}
span {
font-size: 14px;
color: var(--text-primary);
}
}
.agreement-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
.btn {
padding: 10px 24px;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: var(--border-color);
}
}
.btn-primary {
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:hover:not(:disabled) {
opacity: 0.9;
}
}
}

325
src/App.tsx Normal file
View File

@@ -0,0 +1,325 @@
import { useEffect, useState } from 'react'
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
import TitleBar from './components/TitleBar'
import Sidebar from './components/Sidebar'
import RouteGuard from './components/RouteGuard'
import WelcomePage from './pages/WelcomePage'
import HomePage from './pages/HomePage'
import ChatPage from './pages/ChatPage'
import AnalyticsPage from './pages/AnalyticsPage'
import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow'
import AgreementPage from './pages/AgreementPage'
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import DataManagementPage from './pages/DataManagementPage'
import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
import * as configService from './services/config'
import { Download, X, Shield } from 'lucide-react'
import './App.scss'
function App() {
const navigate = useNavigate()
const location = useLocation()
const { setDbConnected } = useAppStore()
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const isAgreementWindow = location.pathname === '/agreement-window'
const isOnboardingWindow = location.pathname === '/onboarding-window'
const [themeHydrated, setThemeHydrated] = useState(false)
// 协议同意状态
const [showAgreement, setShowAgreement] = useState(false)
const [agreementChecked, setAgreementChecked] = useState(false)
const [agreementLoading, setAgreementLoading] = useState(true)
// 更新提示状态
const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
useEffect(() => {
const root = document.documentElement
const body = document.body
const appRoot = document.getElementById('app')
if (isOnboardingWindow) {
root.style.background = 'transparent'
body.style.background = 'transparent'
body.style.overflow = 'hidden'
if (appRoot) {
appRoot.style.background = 'transparent'
appRoot.style.overflow = 'hidden'
}
} else {
root.style.background = 'var(--bg-primary)'
body.style.background = 'var(--bg-primary)'
body.style.overflow = ''
if (appRoot) {
appRoot.style.background = ''
appRoot.style.overflow = ''
}
}
}, [isOnboardingWindow])
// 应用主题
useEffect(() => {
document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', themeMode)
// 更新窗口控件颜色以适配主题
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
if (!isOnboardingWindow) {
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
}
}, [currentTheme, themeMode, isOnboardingWindow])
// 读取已保存的主题设置
useEffect(() => {
const loadTheme = async () => {
try {
const [savedThemeId, savedThemeMode] = await Promise.all([
configService.getThemeId(),
configService.getTheme()
])
if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) {
setTheme(savedThemeId as ThemeId)
}
if (savedThemeMode === 'light' || savedThemeMode === 'dark') {
setThemeMode(savedThemeMode)
}
} catch (e) {
console.error('读取主题配置失败:', e)
} finally {
setThemeHydrated(true)
}
}
loadTheme()
}, [setTheme, setThemeMode])
// 保存主题设置
useEffect(() => {
if (!themeHydrated) return
const saveTheme = async () => {
try {
await Promise.all([
configService.setThemeId(currentTheme),
configService.setTheme(themeMode)
])
} catch (e) {
console.error('保存主题配置失败:', e)
}
}
saveTheme()
}, [currentTheme, themeMode, themeHydrated])
// 检查是否已同意协议
useEffect(() => {
const checkAgreement = async () => {
try {
const agreed = await configService.getAgreementAccepted()
if (!agreed) {
setShowAgreement(true)
}
} catch (e) {
console.error('检查协议状态失败:', e)
} finally {
setAgreementLoading(false)
}
}
checkAgreement()
}, [])
const handleAgree = async () => {
if (!agreementChecked) return
await configService.setAgreementAccepted(true)
setShowAgreement(false)
}
const handleDisagree = () => {
window.electronAPI.window.close()
}
// 监听启动时的更新通知
useEffect(() => {
const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info) => {
setUpdateInfo(info)
})
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
setDownloadProgress(progress)
})
return () => {
removeUpdateListener?.()
removeProgressListener?.()
}
}, [])
const handleUpdateNow = async () => {
setIsDownloading(true)
setDownloadProgress(0)
try {
await window.electronAPI.app.downloadAndInstall()
} catch (e) {
console.error('更新失败:', e)
setIsDownloading(false)
}
}
const dismissUpdate = () => {
setUpdateInfo(null)
}
// 启动时自动检查配置并连接数据库
useEffect(() => {
if (isAgreementWindow || isOnboardingWindow) return
const autoConnect = async () => {
try {
const dbPath = await configService.getDbPath()
const decryptKey = await configService.getDecryptKey()
const wxid = await configService.getMyWxid()
const onboardingDone = await configService.getOnboardingDone()
// 如果配置完整,自动测试连接
if (dbPath && decryptKey && wxid) {
if (!onboardingDone) {
await configService.setOnboardingDone(true)
}
console.log('检测到已保存的配置,正在自动连接...')
const result = await window.electronAPI.chat.connect()
if (result.success) {
console.log('自动连接成功')
setDbConnected(true, dbPath)
// 如果当前在欢迎页,跳转到首页
if (window.location.hash === '#/' || window.location.hash === '') {
navigate('/home')
}
} else {
console.log('自动连接失败:', result.error)
}
}
} catch (e) {
console.error('自动连接出错:', e)
}
}
autoConnect()
}, [isAgreementWindow, isOnboardingWindow, navigate, setDbConnected])
// 独立协议窗口
if (isAgreementWindow) {
return <AgreementPage />
}
if (isOnboardingWindow) {
return <WelcomePage standalone />
}
// 主窗口 - 完整布局
return (
<div className="app-container">
<TitleBar />
{/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && (
<div className="agreement-overlay">
<div className="agreement-modal">
<div className="agreement-header">
<Shield size={32} />
<h2></h2>
</div>
<div className="agreement-content">
<p>使WeFlow使</p>
<div className="agreement-notice">
<strong></strong>
<span className="agreement-notice-link">
<a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer">
https://github.com/hicccc77/WeFlow
</a>
</span>
</div>
<div className="agreement-text">
<h4>1. </h4>
<p></p>
<h4>2. 使</h4>
<p>使使</p>
<h4>3. </h4>
<p>使使</p>
<h4>4. </h4>
<p></p>
</div>
</div>
<div className="agreement-footer">
<label className="agreement-checkbox">
<input
type="checkbox"
checked={agreementChecked}
onChange={(e) => setAgreementChecked(e.target.checked)}
/>
<span></span>
</label>
<div className="agreement-actions">
<button className="btn btn-secondary" onClick={handleDisagree}></button>
<button className="btn btn-primary" onClick={handleAgree} disabled={!agreementChecked}></button>
</div>
</div>
</div>
</div>
)}
{/* 更新提示条 */}
{updateInfo && (
<div className="update-banner">
<span className="update-text">
<strong>v{updateInfo.version}</strong>
</span>
{isDownloading ? (
<div className="update-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span>{downloadProgress.toFixed(0)}%</span>
</div>
) : (
<>
<button className="update-btn" onClick={handleUpdateNow}>
<Download size={14} />
</button>
<button className="dismiss-btn" onClick={dismissUpdate}>
<X size={14} />
</button>
</>
)}
</div>
)}
<div className="main-layout">
<Sidebar />
<main className="content">
<RouteGuard>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
<Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
<Route path="/data-management" element={<DataManagementPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} />
</Routes>
</RouteGuard>
</main>
</div>
</div>
)
}
export default App

View File

@@ -0,0 +1,214 @@
.date-range-picker {
position: relative;
-webkit-app-region: no-drag;
.picker-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 32px;
background: var(--bg-tertiary);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
}
svg {
color: var(--text-tertiary);
flex-shrink: 0;
}
.picker-text {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
background: var(--bg-hover);
border-radius: 50%;
cursor: pointer;
color: var(--text-tertiary);
margin-left: 4px;
&:hover {
background: var(--border-color);
color: var(--text-primary);
}
}
}
.picker-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--card-bg);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
z-index: 1000;
display: flex;
overflow: hidden;
animation: dropdownFadeIn 0.15s ease-out;
}
@keyframes dropdownFadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.quick-options {
display: flex;
flex-direction: column;
padding: 12px;
border-right: 1px solid var(--border-color);
min-width: 100px;
.quick-option {
padding: 8px 12px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
text-align: left;
transition: all 0.15s;
white-space: nowrap;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.calendar-section {
padding: 16px;
min-width: 280px;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: var(--bg-tertiary);
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.month-year {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.weekday-header {
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
padding: 8px 0;
font-weight: 500;
}
.calendar-day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: var(--text-tertiary);
border-radius: 8px;
cursor: default;
position: relative;
&.valid {
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
}
}
&.today {
font-weight: 600;
color: var(--primary);
}
&.in-range {
background: var(--primary-light);
border-radius: 0;
}
&.start {
background: var(--primary);
color: #fff;
border-radius: 8px 0 0 8px;
&.end {
border-radius: 8px;
}
}
&.end {
background: var(--primary);
color: #fff;
border-radius: 0 8px 8px 0;
}
}
.selection-hint {
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
}

View File

@@ -0,0 +1,204 @@
import { useState, useRef, useEffect } from 'react'
import { Calendar, ChevronLeft, ChevronRight, X } from 'lucide-react'
import './DateRangePicker.scss'
interface DateRangePickerProps {
startDate: string
endDate: string
onStartDateChange: (date: string) => void
onEndDateChange: (date: string) => void
onRangeComplete?: () => void
}
const MONTH_NAMES = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
const WEEKDAY_NAMES = ['日', '一', '二', '三', '四', '五', '六']
// 快捷选项
const QUICK_OPTIONS = [
{ label: '最近7天', days: 7 },
{ label: '最近30天', days: 30 },
{ label: '最近90天', days: 90 },
{ label: '最近一年', days: 365 },
{ label: '全部时间', days: 0 },
]
function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChange, onRangeComplete }: DateRangePickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
const formatDisplayDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
}
const getDisplayText = () => {
if (!startDate && !endDate) return '选择时间范围'
if (startDate && endDate) return `${formatDisplayDate(startDate)} - ${formatDisplayDate(endDate)}`
if (startDate) return `${formatDisplayDate(startDate)} - ?`
return `? - ${formatDisplayDate(endDate)}`
}
const handleQuickOption = (days: number) => {
if (days === 0) {
onStartDateChange('')
onEndDateChange('')
} else {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - days)
onStartDateChange(start.toISOString().split('T')[0])
onEndDateChange(end.toISOString().split('T')[0])
}
setIsOpen(false)
setTimeout(() => onRangeComplete?.(), 0)
}
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
onStartDateChange('')
onEndDateChange('')
}
const getDaysInMonth = (date: Date) => {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
}
const getFirstDayOfMonth = (date: Date) => {
return new Date(date.getFullYear(), date.getMonth(), 1).getDay()
}
const handleDateClick = (day: number) => {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
if (selectingStart) {
onStartDateChange(dateStr)
if (endDate && dateStr > endDate) {
onEndDateChange('')
}
setSelectingStart(false)
} else {
if (dateStr < startDate) {
onStartDateChange(dateStr)
onEndDateChange(startDate)
} else {
onEndDateChange(dateStr)
}
setSelectingStart(true)
setIsOpen(false)
setTimeout(() => onRangeComplete?.(), 0)
}
}
const isInRange = (day: number) => {
if (!startDate || !endDate) return false
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return dateStr >= startDate && dateStr <= endDate
}
const isStartDate = (day: number) => {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return dateStr === startDate
}
const isEndDate = (day: number) => {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return dateStr === endDate
}
const isToday = (day: number) => {
const today = new Date()
return currentMonth.getFullYear() === today.getFullYear() &&
currentMonth.getMonth() === today.getMonth() &&
day === today.getDate()
}
const renderCalendar = () => {
const daysInMonth = getDaysInMonth(currentMonth)
const firstDay = getFirstDayOfMonth(currentMonth)
const days: (number | null)[] = []
for (let i = 0; i < firstDay; i++) {
days.push(null)
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return (
<div className="calendar-grid">
{WEEKDAY_NAMES.map(name => (
<div key={name} className="weekday-header">{name}</div>
))}
{days.map((day, index) => (
<div
key={index}
className={`calendar-day ${day ? 'valid' : ''} ${day && isInRange(day) ? 'in-range' : ''} ${day && isStartDate(day) ? 'start' : ''} ${day && isEndDate(day) ? 'end' : ''} ${day && isToday(day) ? 'today' : ''}`}
onClick={() => day && handleDateClick(day)}
>
{day}
</div>
))}
</div>
)
}
return (
<div className="date-range-picker" ref={containerRef}>
<button className="picker-trigger" onClick={() => setIsOpen(!isOpen)}>
<Calendar size={14} />
<span className="picker-text">{getDisplayText()}</span>
{(startDate || endDate) && (
<button className="clear-btn" onClick={handleClear}>
<X size={12} />
</button>
)}
</button>
{isOpen && (
<div className="picker-dropdown">
<div className="quick-options">
{QUICK_OPTIONS.map(opt => (
<button key={opt.label} className="quick-option" onClick={() => handleQuickOption(opt.days)}>
{opt.label}
</button>
))}
</div>
<div className="calendar-section">
<div className="calendar-header">
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
<ChevronLeft size={16} />
</button>
<span className="month-year">{currentMonth.getFullYear()} {MONTH_NAMES[currentMonth.getMonth()]}</span>
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
<ChevronRight size={16} />
</button>
</div>
{renderCalendar()}
<div className="selection-hint">
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
</div>
</div>
</div>
)}
</div>
)
}
export default DateRangePicker

View File

@@ -0,0 +1,29 @@
import { useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAppStore } from '../stores/appStore'
interface RouteGuardProps {
children: React.ReactNode
}
// 不需要数据库连接的页面
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/data-management']
function RouteGuard({ children }: RouteGuardProps) {
const navigate = useNavigate()
const location = useLocation()
const isDbConnected = useAppStore(state => state.isDbConnected)
useEffect(() => {
const isPublicRoute = PUBLIC_ROUTES.includes(location.pathname)
// 未连接数据库且不在公开页面,跳转到欢迎页
if (!isDbConnected && !isPublicRoute) {
navigate('/', { replace: true })
}
}, [isDbConnected, location.pathname, navigate])
return <>{children}</>
}
export default RouteGuard

107
src/components/Sidebar.scss Normal file
View File

@@ -0,0 +1,107 @@
.sidebar {
width: 200px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 16px 0;
transition: width 0.25s ease;
&.collapsed {
width: 64px;
.nav-menu,
.sidebar-footer {
padding: 0 8px;
}
.nav-label {
display: none;
}
.nav-item {
justify-content: center;
padding: 10px;
gap: 0;
}
}
}
.nav-menu {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 9999px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s ease;
white-space: nowrap;
border: none;
background: transparent;
cursor: pointer;
font-family: inherit;
width: 100%;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: white;
}
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
}
.nav-label {
font-size: 14px;
font-weight: 500;
}
.sidebar-footer {
padding: 0 8px;
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 9999px;
transition: all 0.2s ease;
margin-top: 4px;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}

112
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,112 @@
import { useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download } from 'lucide-react'
import './Sidebar.scss'
function Sidebar() {
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
}
return (
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<nav className="nav-menu">
{/* 首页 */}
<NavLink
to="/home"
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
title={collapsed ? '首页' : undefined}
>
<span className="nav-icon"><Home size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 聊天 */}
<NavLink
to="/chat"
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
title={collapsed ? '聊天' : undefined}
>
<span className="nav-icon"><MessageSquare size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 私聊分析 */}
<NavLink
to="/analytics"
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
title={collapsed ? '私聊分析' : undefined}
>
<span className="nav-icon"><BarChart3 size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 群聊分析 */}
<NavLink
to="/group-analytics"
className={`nav-item ${isActive('/group-analytics') ? 'active' : ''}`}
title={collapsed ? '群聊分析' : undefined}
>
<span className="nav-icon"><Users size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 年度报告 */}
<NavLink
to="/annual-report"
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
title={collapsed ? '年度报告' : undefined}
>
<span className="nav-icon"><FileText size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 导出 */}
<NavLink
to="/export"
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
title={collapsed ? '导出' : undefined}
>
<span className="nav-icon"><Download size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 数据管理 */}
<NavLink
to="/data-management"
className={`nav-item ${isActive('/data-management') ? 'active' : ''}`}
title={collapsed ? '数据管理' : undefined}
>
<span className="nav-icon"><Database size={20} /></span>
<span className="nav-label"></span>
</NavLink>
</nav>
<div className="sidebar-footer">
<NavLink
to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
title={collapsed ? '设置' : undefined}
>
<span className="nav-icon">
<Settings size={20} />
</span>
<span className="nav-label"></span>
</NavLink>
<button
className="collapse-btn"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? '展开菜单' : '收起菜单'}
>
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
</aside>
)
}
export default Sidebar

View File

@@ -0,0 +1,23 @@
.title-bar {
height: 41px;
background: var(--bg-secondary);
display: flex;
align-items: center;
padding-left: 16px;
border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag;
flex-shrink: 0;
gap: 8px;
}
.title-logo {
width: 20px;
height: 20px;
object-fit: contain;
}
.titles {
font-size: 15px;
font-weight: 500;
color: var(--text-secondary);
}

View File

@@ -0,0 +1,12 @@
import './TitleBar.scss'
function TitleBar() {
return (
<div className="title-bar">
<img src="./logo.png" alt="WeFlow" className="title-logo" />
<span className="titles">WeFlow</span>
</div>
)
}
export default TitleBar

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import App from './App'
import './styles/main.scss'
ReactDOM.createRoot(document.getElementById('app')!).render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>
)

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

172
src/services/config.ts Normal file
View File

@@ -0,0 +1,172 @@
// 配置服务 - 封装 Electron Store
import { config } from './ipc'
// 配置键名
export const CONFIG_KEYS = {
DECRYPT_KEY: 'decryptKey',
DB_PATH: 'dbPath',
MY_WXID: 'myWxid',
THEME: 'theme',
THEME_ID: 'themeId',
LAST_SESSION: 'lastSession',
WINDOW_BOUNDS: 'windowBounds',
CACHE_PATH: 'cachePath',
EXPORT_PATH: 'exportPath',
AGREEMENT_ACCEPTED: 'agreementAccepted',
LOG_ENABLED: 'logEnabled',
ONBOARDING_DONE: 'onboardingDone',
IMAGE_XOR_KEY: 'imageXorKey',
IMAGE_AES_KEY: 'imageAesKey'
} as const
// 获取解密密钥
export async function getDecryptKey(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
return value as string | null
}
// 设置解密密钥
export async function setDecryptKey(key: string): Promise<void> {
await config.set(CONFIG_KEYS.DECRYPT_KEY, key)
}
// 获取数据库路径
export async function getDbPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.DB_PATH)
return value as string | null
}
// 设置数据库路径
export async function setDbPath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.DB_PATH, path)
}
// 获取当前用户 wxid
export async function getMyWxid(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.MY_WXID)
return value as string | null
}
// 设置当前用户 wxid
export async function setMyWxid(wxid: string): Promise<void> {
await config.set(CONFIG_KEYS.MY_WXID, wxid)
}
// 获取主题
export async function getTheme(): Promise<'light' | 'dark'> {
const value = await config.get(CONFIG_KEYS.THEME)
return (value as 'light' | 'dark') || 'light'
}
// 设置主题
export async function setTheme(theme: 'light' | 'dark'): Promise<void> {
await config.set(CONFIG_KEYS.THEME, theme)
}
// 获取主题配色
export async function getThemeId(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.THEME_ID)
return (value as string) || null
}
// 设置主题配色
export async function setThemeId(themeId: string): Promise<void> {
await config.set(CONFIG_KEYS.THEME_ID, themeId)
}
// 获取上次打开的会话
export async function getLastSession(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.LAST_SESSION)
return value as string | null
}
// 设置上次打开的会话
export async function setLastSession(sessionId: string): Promise<void> {
await config.set(CONFIG_KEYS.LAST_SESSION, sessionId)
}
// 获取缓存路径
export async function getCachePath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.CACHE_PATH)
return value as string | null
}
// 设置缓存路径
export async function setCachePath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.CACHE_PATH, path)
}
// 获取导出路径
export async function getExportPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_PATH)
return value as string | null
}
// 设置导出路径
export async function setExportPath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_PATH, path)
}
// 获取协议同意状态
export async function getAgreementAccepted(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.AGREEMENT_ACCEPTED)
return value === true
}
// 设置协议同意状态
export async function setAgreementAccepted(accepted: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AGREEMENT_ACCEPTED, accepted)
}
// 获取日志开关
export async function getLogEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.LOG_ENABLED)
return value === true
}
// 设置日志开关
export async function setLogEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
}
// 清除所有配置
export async function clearConfig(): Promise<void> {
await config.clear()
}
// 获取图片 XOR 密钥
export async function getImageXorKey(): Promise<number | null> {
const value = await config.get(CONFIG_KEYS.IMAGE_XOR_KEY)
if (typeof value === 'number' && Number.isFinite(value)) return value
return null
}
// 设置图片 XOR 密钥
export async function setImageXorKey(key: number): Promise<void> {
await config.set(CONFIG_KEYS.IMAGE_XOR_KEY, key)
}
// 获取图片 AES 密钥
export async function getImageAesKey(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.IMAGE_AES_KEY)
return (value as string) || null
}
// 设置图片 AES 密钥
export async function setImageAesKey(key: string): Promise<void> {
await config.set(CONFIG_KEYS.IMAGE_AES_KEY, key)
}
// 获取是否完成首次配置引导
export async function getOnboardingDone(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.ONBOARDING_DONE)
return value === true
}
// 设置首次配置引导完成
export async function setOnboardingDone(done: boolean): Promise<void> {
await config.set(CONFIG_KEYS.ONBOARDING_DONE, done)
}

23
src/services/ipc.ts Normal file
View File

@@ -0,0 +1,23 @@
// Electron IPC 通信封装
// 配置
export const config = {
get: (key: string) => window.electronAPI.config.get(key),
set: (key: string, value: unknown) => window.electronAPI.config.set(key, value),
clear: () => window.electronAPI.config.clear()
}
// 对话框
export const dialog = {
openFile: (options?: Electron.OpenDialogOptions) =>
window.electronAPI.dialog.openFile(options),
saveFile: (options?: Electron.SaveDialogOptions) =>
window.electronAPI.dialog.saveFile(options)
}
// 窗口控制
export const windowControl = {
minimize: () => window.electronAPI.window.minimize(),
maximize: () => window.electronAPI.window.maximize(),
close: () => window.electronAPI.window.close()
}

View File

@@ -0,0 +1,70 @@
import { create } from 'zustand'
interface ChatStatistics {
totalMessages: number
textMessages: number
imageMessages: number
voiceMessages: number
videoMessages: number
emojiMessages: number
otherMessages: number
sentMessages: number
receivedMessages: number
firstMessageTime: number | null
lastMessageTime: number | null
activeDays: number
messageTypeCounts: Record<number, number>
}
interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
messageCount: number
sentCount: number
receivedCount: number
lastMessageTime: number | null
}
interface TimeDistribution {
hourlyDistribution: Record<number, number>
monthlyDistribution: Record<string, number>
}
interface AnalyticsState {
// 数据
statistics: ChatStatistics | null
rankings: ContactRanking[]
timeDistribution: TimeDistribution | null
// 状态
isLoaded: boolean
lastLoadTime: number | null
// Actions
setStatistics: (data: ChatStatistics) => void
setRankings: (data: ContactRanking[]) => void
setTimeDistribution: (data: TimeDistribution) => void
markLoaded: () => void
clearCache: () => void
}
export const useAnalyticsStore = create<AnalyticsState>((set) => ({
statistics: null,
rankings: [],
timeDistribution: null,
isLoaded: false,
lastLoadTime: null,
setStatistics: (data) => set({ statistics: data }),
setRankings: (data) => set({ rankings: data }),
setTimeDistribution: (data) => set({ timeDistribution: data }),
markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }),
clearCache: () => set({
statistics: null,
rankings: [],
timeDistribution: null,
isLoaded: false,
lastLoadTime: null
}),
}))

46
src/stores/appStore.ts Normal file
View File

@@ -0,0 +1,46 @@
import { create } from 'zustand'
export interface AppState {
// 数据库状态
isDbConnected: boolean
dbPath: string | null
myWxid: string | null
// 加载状态
isLoading: boolean
loadingText: string
// 操作
setDbConnected: (connected: boolean, path?: string) => void
setMyWxid: (wxid: string) => void
setLoading: (loading: boolean, text?: string) => void
reset: () => void
}
export const useAppStore = create<AppState>((set) => ({
isDbConnected: false,
dbPath: null,
myWxid: null,
isLoading: false,
loadingText: '',
setDbConnected: (connected, path) => set({
isDbConnected: connected,
dbPath: path ?? null
}),
setMyWxid: (wxid) => set({ myWxid: wxid }),
setLoading: (loading, text) => set({
isLoading: loading,
loadingText: text ?? ''
}),
reset: () => set({
isDbConnected: false,
dbPath: null,
myWxid: null,
isLoading: false,
loadingText: ''
})
}))

116
src/stores/chatStore.ts Normal file
View File

@@ -0,0 +1,116 @@
import { create } from 'zustand'
import type { ChatSession, Message, Contact } from '../types/models'
export interface ChatState {
// 连接状态
isConnected: boolean
isConnecting: boolean
connectionError: string | null
// 会话列表
sessions: ChatSession[]
filteredSessions: ChatSession[]
currentSessionId: string | null
isLoadingSessions: boolean
// 消息
messages: Message[]
isLoadingMessages: boolean
isLoadingMore: boolean
hasMoreMessages: boolean
// 联系人缓存
contacts: Map<string, Contact>
// 搜索
searchKeyword: string
// 操作
setConnected: (connected: boolean) => void
setConnecting: (connecting: boolean) => void
setConnectionError: (error: string | null) => void
setSessions: (sessions: ChatSession[]) => void
setFilteredSessions: (sessions: ChatSession[]) => void
setCurrentSession: (sessionId: string | null) => void
setLoadingSessions: (loading: boolean) => void
setMessages: (messages: Message[]) => void
appendMessages: (messages: Message[], prepend?: boolean) => void
setLoadingMessages: (loading: boolean) => void
setLoadingMore: (loading: boolean) => void
setHasMoreMessages: (hasMore: boolean) => void
setContacts: (contacts: Contact[]) => void
addContact: (contact: Contact) => void
setSearchKeyword: (keyword: string) => void
reset: () => void
}
export const useChatStore = create<ChatState>((set, get) => ({
isConnected: false,
isConnecting: false,
connectionError: null,
sessions: [],
filteredSessions: [],
currentSessionId: null,
isLoadingSessions: false,
messages: [],
isLoadingMessages: false,
isLoadingMore: false,
hasMoreMessages: true,
contacts: new Map(),
searchKeyword: '',
setConnected: (connected) => set({ isConnected: connected }),
setConnecting: (connecting) => set({ isConnecting: connecting }),
setConnectionError: (error) => set({ connectionError: error }),
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
setCurrentSession: (sessionId) => set({
currentSessionId: sessionId,
messages: [],
hasMoreMessages: true
}),
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
setMessages: (messages) => set({ messages }),
appendMessages: (newMessages, prepend = false) => set((state) => ({
messages: prepend
? [...newMessages, ...state.messages]
: [...state.messages, ...newMessages]
})),
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
setContacts: (contacts) => set({
contacts: new Map(contacts.map(c => [c.username, c]))
}),
addContact: (contact) => set((state) => {
const newContacts = new Map(state.contacts)
newContacts.set(contact.username, contact)
return { contacts: newContacts }
}),
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
reset: () => set({
isConnected: false,
isConnecting: false,
connectionError: null,
sessions: [],
filteredSessions: [],
currentSessionId: null,
isLoadingSessions: false,
messages: [],
isLoadingMessages: false,
isLoadingMore: false,
hasMoreMessages: true,
contacts: new Map(),
searchKeyword: ''
})
}))

173
src/stores/imageStore.ts Normal file
View File

@@ -0,0 +1,173 @@
import { create } from 'zustand'
export interface ImageFileInfo {
fileName: string
filePath: string
fileSize: number
isDecrypted: boolean
decryptedPath?: string
version: number
isDecrypting?: boolean
}
export interface ImageDirectory {
wxid: string
path: string
}
/**
* 检测图片质量(原图/缩略图)
* 逻辑来自原项目 app_state.dart 的 _detectImageQuality
*/
function detectImageQuality(img: ImageFileInfo): 'original' | 'thumbnail' {
const fileNameLower = img.fileName.toLowerCase()
const fileSize = img.fileSize
// 小于 50KB 是缩略图
if (fileSize < 50 * 1024) return 'thumbnail'
// 大于 500KB 是原图
if (fileSize > 500 * 1024) return 'original'
// 文件名包含 thumb/small 关键词
if (fileNameLower.includes('thumb') || fileNameLower.includes('small')) {
return 'thumbnail'
}
// 文件名以 _thumb.dat 或 _small.dat 结尾
if (fileNameLower.endsWith('_thumb.dat') || fileNameLower.endsWith('_small.dat')) {
return 'thumbnail'
}
// 路径层级判断(通过 filePath 中的分隔符数量)
const pathParts = img.filePath.split(/[/\\]/)
// 找到账号目录后的相对路径层级
// 如果层级太深,可能是缩略图
if (pathParts.length > 10) return 'thumbnail'
return 'original'
}
interface ImageState {
// 图片列表
images: ImageFileInfo[]
// 目录列表
directories: ImageDirectory[]
// 当前选中的目录
selectedDir: ImageDirectory | null
// 扫描状态
isScanning: boolean
scanCompleted: boolean
// 错误信息
error: string | null
// 统计
originalCount: number
thumbnailCount: number
decryptedCount: number
// 操作
setDirectories: (dirs: ImageDirectory[]) => void
setSelectedDir: (dir: ImageDirectory | null) => void
setScanning: (scanning: boolean) => void
setScanCompleted: (completed: boolean) => void
setError: (error: string | null) => void
addImages: (newImages: ImageFileInfo[]) => void
clearImages: () => void
updateImage: (index: number, updates: Partial<ImageFileInfo>) => void
updateStats: () => void
reset: () => void
}
export const useImageStore = create<ImageState>((set, get) => ({
images: [],
directories: [],
selectedDir: null,
isScanning: false,
scanCompleted: false,
error: null,
originalCount: 0,
thumbnailCount: 0,
decryptedCount: 0,
setDirectories: (dirs) => set({ directories: dirs }),
setSelectedDir: (dir) => set({ selectedDir: dir }),
setScanning: (scanning) => set({ isScanning: scanning }),
setScanCompleted: (completed) => set({ scanCompleted: completed }),
setError: (error) => set({ error }),
addImages: (newImages) => {
set((state) => {
const updated = [...state.images, ...newImages]
// 计算统计
let original = 0
let thumbnail = 0
let decrypted = 0
for (const img of updated) {
if (detectImageQuality(img) === 'original') {
original++
} else {
thumbnail++
}
if (img.isDecrypted) decrypted++
}
return {
images: updated,
originalCount: original,
thumbnailCount: thumbnail,
decryptedCount: decrypted
}
})
},
clearImages: () => set({
images: [],
originalCount: 0,
thumbnailCount: 0,
decryptedCount: 0,
scanCompleted: false
}),
updateImage: (index, updates) => {
set((state) => {
const images = [...state.images]
if (index >= 0 && index < images.length) {
images[index] = { ...images[index], ...updates }
}
// 重新计算已解密数量
const decryptedCount = images.filter(img => img.isDecrypted).length
return { images, decryptedCount }
})
},
updateStats: () => {
const { images } = get()
let original = 0
let thumbnail = 0
let decrypted = 0
for (const img of images) {
if (detectImageQuality(img) === 'original') {
original++
} else {
thumbnail++
}
if (img.isDecrypted) decrypted++
}
set({ originalCount: original, thumbnailCount: thumbnail, decryptedCount: decrypted })
},
reset: () => set({
images: [],
directories: [],
selectedDir: null,
isScanning: false,
scanCompleted: false,
error: null,
originalCount: 0,
thumbnailCount: 0,
decryptedCount: 0
})
}))

79
src/stores/themeStore.ts Normal file
View File

@@ -0,0 +1,79 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water'
export type ThemeMode = 'light' | 'dark'
export interface ThemeInfo {
id: ThemeId
name: string
description: string
primaryColor: string
bgColor: string
}
export const themes: ThemeInfo[] = [
{
id: 'cloud-dancer',
name: '云上舞白',
description: 'Pantone 2026 年度色',
primaryColor: '#8B7355',
bgColor: '#F0EEE9'
},
{
id: 'corundum-blue',
name: '刚玉蓝',
description: 'RAL 220 40 10',
primaryColor: '#4A6670',
bgColor: '#E8EEF0'
},
{
id: 'kiwi-green',
name: '冰猕猴桃汁绿',
description: 'RAL 120 90 20',
primaryColor: '#7A9A5C',
bgColor: '#E8F0E4'
},
{
id: 'spicy-red',
name: '辛辣红',
description: 'RAL 030 40 40',
primaryColor: '#8B4049',
bgColor: '#F0E8E8'
},
{
id: 'teal-water',
name: '明水鸭色',
description: 'RAL 180 80 10',
primaryColor: '#5A8A8A',
bgColor: '#E4F0F0'
}
]
interface ThemeState {
currentTheme: ThemeId
themeMode: ThemeMode
setTheme: (theme: ThemeId) => void
setThemeMode: (mode: ThemeMode) => void
toggleThemeMode: () => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
currentTheme: 'cloud-dancer',
themeMode: 'light',
setTheme: (theme) => set({ currentTheme: theme }),
setThemeMode: (mode) => set({ themeMode: mode }),
toggleThemeMode: () => set({ themeMode: get().themeMode === 'light' ? 'dark' : 'light' })
}),
{
name: 'echotrace-theme'
}
)
)
// 获取当前主题信息
export const getThemeInfo = (themeId: ThemeId): ThemeInfo => {
return themes.find(t => t.id === themeId) || themes[0]
}

File diff suppressed because one or more lines are too long

309
src/styles/main.scss Normal file
View File

@@ -0,0 +1,309 @@
// CSS 变量 - 主题
@use './chat-patterns.scss';
:root {
// 颜色
--primary: #8B7355;
--primary-hover: #7A6548;
--primary-light: rgba(139, 115, 85, 0.1);
--danger: #dc3545;
--warning: #ffc107;
// 背景
--bg-primary: #F0EEE9;
--bg-secondary: rgba(255, 255, 255, 0.7);
--bg-tertiary: rgba(0, 0, 0, 0.03);
--bg-hover: rgba(0, 0, 0, 0.05);
// 文字
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
// 边框
--border-color: rgba(0, 0, 0, 0.08);
--border-radius: 9999px;
// 阴影
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
// 侧边栏
--sidebar-width: 220px;
// 主题渐变
--bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%);
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
// 卡片背景
--card-bg: rgba(255, 255, 255, 0.7);
}
// ==================== 浅色主题 ====================
// 云上舞白主题 (默认)
[data-theme="cloud-dancer"][data-mode="light"],
[data-theme="cloud-dancer"]:not([data-mode]) {
--primary: #8B7355;
--primary-hover: #7A6548;
--primary-light: rgba(139, 115, 85, 0.1);
--bg-primary: #F0EEE9;
--bg-secondary: rgba(255, 255, 255, 0.7);
--bg-tertiary: rgba(0, 0, 0, 0.03);
--bg-hover: rgba(0, 0, 0, 0.05);
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
--border-color: rgba(0, 0, 0, 0.08);
--bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%);
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
--card-bg: rgba(255, 255, 255, 0.7);
}
// 刚玉蓝主题
[data-theme="corundum-blue"][data-mode="light"],
[data-theme="corundum-blue"]:not([data-mode]) {
--primary: #4A6670;
--primary-hover: #3D565E;
--primary-light: rgba(74, 102, 112, 0.1);
--bg-primary: #E8EEF0;
--bg-secondary: rgba(255, 255, 255, 0.7);
--bg-tertiary: rgba(0, 0, 0, 0.03);
--bg-hover: rgba(0, 0, 0, 0.05);
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
--border-color: rgba(0, 0, 0, 0.08);
--bg-gradient: linear-gradient(135deg, #E8EEF0 0%, #D8E4E8 100%);
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%);
--card-bg: rgba(255, 255, 255, 0.7);
}
// 冰猕猴桃汁绿主题
[data-theme="kiwi-green"][data-mode="light"],
[data-theme="kiwi-green"]:not([data-mode]) {
--primary: #7A9A5C;
--primary-hover: #6A8A4C;
--primary-light: rgba(122, 154, 92, 0.1);
--bg-primary: #E8F0E4;
--bg-secondary: rgba(255, 255, 255, 0.7);
--bg-tertiary: rgba(0, 0, 0, 0.03);
--bg-hover: rgba(0, 0, 0, 0.05);
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
--border-color: rgba(0, 0, 0, 0.08);
--bg-gradient: linear-gradient(135deg, #E8F0E4 0%, #D8E8D0 100%);
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%);
--card-bg: rgba(255, 255, 255, 0.7);
}
// 辛辣红主题
[data-theme="spicy-red"][data-mode="light"],
[data-theme="spicy-red"]:not([data-mode]) {
--primary: #8B4049;
--primary-hover: #7A3540;
--primary-light: rgba(139, 64, 73, 0.1);
--bg-primary: #F0E8E8;
--bg-secondary: rgba(255, 255, 255, 0.7);
--bg-tertiary: rgba(0, 0, 0, 0.03);
--bg-hover: rgba(0, 0, 0, 0.05);
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
--border-color: rgba(0, 0, 0, 0.08);
--bg-gradient: linear-gradient(135deg, #F0E8E8 0%, #E8D8D8 100%);
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%);
--card-bg: rgba(255, 255, 255, 0.7);
}
// 明水鸭色主题
[data-theme="teal-water"][data-mode="light"],
[data-theme="teal-water"]:not([data-mode]) {
--primary: #5A8A8A;
--primary-hover: #4A7A7A;
--primary-light: rgba(90, 138, 138, 0.1);
--bg-primary: #E4F0F0;
--bg-secondary: rgba(255, 255, 255, 0.7);
--bg-tertiary: rgba(0, 0, 0, 0.03);
--bg-hover: rgba(0, 0, 0, 0.05);
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
--border-color: rgba(0, 0, 0, 0.08);
--bg-gradient: linear-gradient(135deg, #E4F0F0 0%, #D4E8E8 100%);
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%);
--card-bg: rgba(255, 255, 255, 0.7);
}
// ==================== 深色主题 ====================
// 云上舞白 - 深色
[data-theme="cloud-dancer"][data-mode="dark"] {
--primary: #C9A86C;
--primary-hover: #D9B87C;
--primary-light: rgba(201, 168, 108, 0.15);
--bg-primary: #1a1816;
--bg-secondary: rgba(40, 36, 32, 0.9);
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #F0EEE9;
--text-secondary: #b3b0aa;
--text-tertiary: #807d78;
--border-color: rgba(255, 255, 255, 0.1);
--bg-gradient: linear-gradient(135deg, #1a1816 0%, #252220 100%);
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%);
--card-bg: rgba(40, 36, 32, 0.9);
}
// 刚玉蓝 - 深色
[data-theme="corundum-blue"][data-mode="dark"] {
--primary: #6A9AAA;
--primary-hover: #7AAABA;
--primary-light: rgba(106, 154, 170, 0.15);
--bg-primary: #141a1c;
--bg-secondary: rgba(30, 40, 44, 0.9);
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #E8EEF0;
--text-secondary: #a8b4b8;
--text-tertiary: #6a7a80;
--border-color: rgba(255, 255, 255, 0.1);
--bg-gradient: linear-gradient(135deg, #141a1c 0%, #1e282c 100%);
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%);
--card-bg: rgba(30, 40, 44, 0.9);
}
// 冰猕猴桃汁绿 - 深色
[data-theme="kiwi-green"][data-mode="dark"] {
--primary: #9ABA7C;
--primary-hover: #AACA8C;
--primary-light: rgba(154, 186, 124, 0.15);
--bg-primary: #161a14;
--bg-secondary: rgba(34, 42, 30, 0.9);
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #E8F0E4;
--text-secondary: #a8b4a0;
--text-tertiary: #6a7a60;
--border-color: rgba(255, 255, 255, 0.1);
--bg-gradient: linear-gradient(135deg, #161a14 0%, #222a1e 100%);
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%);
--card-bg: rgba(34, 42, 30, 0.9);
}
// 辛辣红 - 深色
[data-theme="spicy-red"][data-mode="dark"] {
--primary: #C06068;
--primary-hover: #D07078;
--primary-light: rgba(192, 96, 104, 0.15);
--bg-primary: #1a1416;
--bg-secondary: rgba(42, 32, 34, 0.9);
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #F0E8E8;
--text-secondary: #b4a8aa;
--text-tertiary: #7a6a6c;
--border-color: rgba(255, 255, 255, 0.1);
--bg-gradient: linear-gradient(135deg, #1a1416 0%, #2a2022 100%);
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%);
--card-bg: rgba(42, 32, 34, 0.9);
}
// 明水鸭色 - 深色
[data-theme="teal-water"][data-mode="dark"] {
--primary: #7ABAAA;
--primary-hover: #8ACABA;
--primary-light: rgba(122, 186, 170, 0.15);
--bg-primary: #121a1a;
--bg-secondary: rgba(28, 42, 42, 0.9);
--bg-tertiary: rgba(255, 255, 255, 0.05);
--bg-hover: rgba(255, 255, 255, 0.08);
--text-primary: #E4F0F0;
--text-secondary: #a0b4b4;
--text-tertiary: #607a7a;
--border-color: rgba(255, 255, 255, 0.1);
--bg-gradient: linear-gradient(135deg, #121a1a 0%, #1c2a2a 100%);
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%);
--card-bg: rgba(28, 42, 42, 0.9);
}
// 重置样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: 'HarmonyOS Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
color: var(--text-primary);
background: var(--bg-primary);
-webkit-font-smoothing: antialiased;
user-select: none;
}
#app {
height: 100%;
}
// 滚动条样式
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 3px;
&:hover {
background: var(--text-secondary);
}
}
// 按钮基础样式
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border: none;
border-radius: var(--border-radius);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
&-primary {
background: var(--primary);
color: white;
&:hover {
background: var(--primary-hover);
}
}
&-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
&:hover {
background: var(--border-color);
}
}
}
// 卡片样式
.card {
background: var(--bg-secondary);
border-radius: 16px;
box-shadow: var(--shadow-sm);
padding: 16px;
}

91
src/types/analytics.ts Normal file
View File

@@ -0,0 +1,91 @@
// 分析数据类型定义
// 聊天统计数据
export interface ChatStatistics {
totalMessages: number
textMessages: number
imageMessages: number
voiceMessages: number
videoMessages: number
emojiMessages: number
otherMessages: number
sentMessages: number
receivedMessages: number
firstMessageTime: number | null // Unix timestamp
lastMessageTime: number | null
activeDays: number
messageTypeCounts: Record<number, number>
}
// 时间分布统计
export interface TimeDistribution {
hourlyDistribution: Record<number, number> // 0-23
weekdayDistribution: Record<number, number> // 1-7
monthlyDistribution: Record<string, number> // YYYY-MM
}
// 联系人排名
export interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
messageCount: number
sentCount: number
receivedCount: number
lastMessageTime: number | null
}
// 消息类型标签映射
export const MESSAGE_TYPE_LABELS: Record<number, string> = {
1: '文本',
244813135921: '文本',
3: '图片',
34: '语音',
43: '视频',
47: '表情',
48: '位置',
49: '链接/文件',
42: '名片',
10000: '系统消息',
}
// 星期几名称
export const WEEKDAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
// 获取消息类型分布(用于图表)
export function getMessageTypeDistribution(stats: ChatStatistics): Record<string, number> {
if (Object.keys(stats.messageTypeCounts).length > 0) {
const distribution: Record<string, number> = {}
for (const [type, count] of Object.entries(stats.messageTypeCounts)) {
const typeNum = parseInt(type)
const label = MESSAGE_TYPE_LABELS[typeNum] || '其他'
distribution[label] = (distribution[label] || 0) + count
}
return distribution
}
return {
'文本': stats.textMessages,
'图片': stats.imageMessages,
'语音': stats.voiceMessages,
'视频': stats.videoMessages,
'表情': stats.emojiMessages,
'其他': stats.otherMessages,
}
}
// 计算聊天时长(天数)
export function getChatDurationDays(stats: ChatStatistics): number {
if (!stats.firstMessageTime || !stats.lastMessageTime) return 0
const diffMs = (stats.lastMessageTime - stats.firstMessageTime) * 1000
return Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1
}
// 平均每天消息数
export function getAverageMessagesPerDay(stats: ChatStatistics): number {
const days = getChatDurationDays(stats)
if (days === 0) return 0
return stats.totalMessages / days
}

329
src/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,329 @@
import type { ChatSession, Message, Contact } from './models'
export interface ElectronAPI {
window: {
minimize: () => void
maximize: () => void
close: () => void
openAgreementWindow: () => Promise<boolean>
completeOnboarding: () => Promise<boolean>
openOnboardingWindow: () => Promise<boolean>
setTitleBarOverlay: (options: { symbolColor: string }) => void
}
config: {
get: (key: string) => Promise<unknown>
set: (key: string, value: unknown) => Promise<void>
clear: () => Promise<boolean>
}
dialog: {
openFile: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
openDirectory: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
saveFile: (options?: Electron.SaveDialogOptions) => Promise<Electron.SaveDialogReturnValue>
}
shell: {
openPath: (path: string) => Promise<string>
openExternal: (url: string) => Promise<void>
}
app: {
getDownloadsPath: () => Promise<string>
getVersion: () => Promise<string>
checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }>
downloadAndInstall: () => Promise<void>
onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
}
log: {
getPath: () => Promise<string>
read: () => Promise<{ success: boolean; content?: string; error?: string }>
}
dbPath: {
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
scanWxids: (rootPath: string) => Promise<WxidInfo[]>
getDefault: () => Promise<string>
}
wcdb: {
testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }>
open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean>
close: () => Promise<boolean>
}
key: {
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
autoGetImageKey: (manualDir?: string) => Promise<{ success: boolean; xorKey?: number; aesKey?: string; error?: string }>
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => () => void
onImageKeyStatus: (callback: (payload: { message: string }) => void) => () => void
}
chat: {
connect: () => Promise<{ success: boolean; error?: string }>
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
success: boolean;
messages?: Message[];
hasMore?: boolean;
error?: string
}>
getLatestMessages: (sessionId: string, limit?: number) => Promise<{
success: boolean
messages?: Message[]
error?: string
}>
getContact: (username: string) => Promise<Contact | null>
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
getMyAvatarUrl: () => Promise<{ success: boolean; avatarUrl?: string; error?: string }>
downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }>
close: () => Promise<boolean>
getSessionDetail: (sessionId: string) => Promise<{
success: boolean
detail?: {
wxid: string
displayName: string
remark?: string
nickName?: string
alias?: string
avatarUrl?: string
messageCount: number
firstMessageTime?: number
latestMessageTime?: number
messageTables: { dbName: string; tableName: string; count: number }[]
}
error?: string
}>
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
}
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
}
analytics: {
getOverallStatistics: () => Promise<{
success: boolean
data?: {
totalMessages: number
textMessages: number
imageMessages: number
voiceMessages: number
videoMessages: number
emojiMessages: number
otherMessages: number
sentMessages: number
receivedMessages: number
firstMessageTime: number | null
lastMessageTime: number | null
activeDays: number
messageTypeCounts: Record<number, number>
}
error?: string
}>
getContactRankings: (limit?: number) => Promise<{
success: boolean
data?: Array<{
username: string
displayName: string
avatarUrl?: string
messageCount: number
sentCount: number
receivedCount: number
lastMessageTime: number | null
}>
error?: string
}>
getTimeDistribution: () => Promise<{
success: boolean
data?: {
hourlyDistribution: Record<number, number>
weekdayDistribution: Record<number, number>
monthlyDistribution: Record<string, number>
}
error?: string
}>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
}
groupAnalytics: {
getGroupChats: () => Promise<{
success: boolean
data?: Array<{
username: string
displayName: string
memberCount: number
avatarUrl?: string
}>
error?: string
}>
getGroupMembers: (chatroomId: string) => Promise<{
success: boolean
data?: Array<{
username: string
displayName: string
avatarUrl?: string
}>
error?: string
}>
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => Promise<{
success: boolean
data?: Array<{
member: {
username: string
displayName: string
avatarUrl?: string
}
messageCount: number
}>
error?: string
}>
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => Promise<{
success: boolean
data?: {
hourlyDistribution: Record<number, number>
}
error?: string
}>
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => Promise<{
success: boolean
data?: {
typeCounts: Array<{
type: number
name: string
count: number
}>
total: number
}
error?: string
}>
}
annualReport: {
getAvailableYears: () => Promise<{
success: boolean
data?: number[]
error?: string
}>
generateReport: (year: number) => Promise<{
success: boolean
data?: {
year: number
totalMessages: number
totalFriends: number
coreFriends: Array<{
username: string
displayName: string
avatarUrl?: string
messageCount: number
sentCount: number
receivedCount: number
}>
monthlyTopFriends: Array<{
month: number
displayName: string
avatarUrl?: string
messageCount: number
}>
peakDay: {
date: string
messageCount: number
topFriend?: string
topFriendCount?: number
} | null
longestStreak: {
friendName: string
days: number
startDate: string
endDate: string
} | null
activityHeatmap: {
data: number[][]
}
midnightKing: {
displayName: string
count: number
percentage: number
} | null
selfAvatarUrl?: string
mutualFriend: {
displayName: string
avatarUrl?: string
sentCount: number
receivedCount: number
ratio: number
} | null
socialInitiative: {
initiatedChats: number
receivedChats: number
initiativeRate: number
} | null
responseSpeed: {
avgResponseTime: number
fastestFriend: string
fastestTime: number
} | null
topPhrases: Array<{
phrase: string
count: number
}>
}
error?: string
}>
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => Promise<{
success: boolean
dir?: string
error?: string
}>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
}
export: {
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
success: boolean
successCount?: number
failCount?: number
error?: string
}>
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
success: boolean
error?: string
}>
}
}
export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
dateRange?: { start: number; end: number } | null
exportMedia?: boolean
exportAvatars?: boolean
}
export interface WxidInfo {
wxid: string
modifiedTime: number
}
declare global {
interface Window {
electronAPI: ElectronAPI
}
// Electron 类型声明
namespace Electron {
interface OpenDialogOptions {
title?: string
defaultPath?: string
filters?: { name: string; extensions: string[] }[]
properties?: ('openFile' | 'openDirectory' | 'multiSelections' | 'createDirectory')[]
}
interface OpenDialogReturnValue {
canceled: boolean
filePaths: string[]
}
interface SaveDialogOptions {
title?: string
defaultPath?: string
filters?: { name: string; extensions: string[] }[]
}
interface SaveDialogReturnValue {
canceled: boolean
filePath?: string
}
}
}
export { }

55
src/types/models.ts Normal file
View File

@@ -0,0 +1,55 @@
// 聊天会话
export interface ChatSession {
username: string
type: number
unreadCount: number
summary: string
sortTimestamp: number // 用于排序
lastTimestamp: number // 用于显示时间
lastMsgType: number
displayName?: string
avatarUrl?: string
}
// 联系人
export interface Contact {
id: number
username: string
localType: number
alias: string
remark: string
nickName: string
bigHeadUrl: string
smallHeadUrl: string
}
// 消息
export interface Message {
localId: number
serverId: number
localType: number
createTime: number
sortSeq: number
isSend: number | null
senderUsername: string | null
parsedContent: string
imageMd5?: string
imageDatName?: string
emojiCdnUrl?: string
emojiMd5?: string
voiceDurationSeconds?: number
// 引用消息
quotedContent?: string
quotedSender?: string
}
// 分析数据
export interface AnalyticsData {
totalMessages: number
totalDays: number
myMessages: number
otherMessages: number
messagesByType: Record<number, number>
messagesByHour: number[]
messagesByDay: number[]
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />