新的提交

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
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
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
+214
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);
}
}
+204
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
+29
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
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
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
+23
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);
}
+12
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
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>
)
+83
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);
}
}
}
+52
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
+295
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
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
+116
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); }
}
+110
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+569
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;
}
}
}
+62
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
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
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
+521
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
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
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
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
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
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
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
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
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()
}
+70
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
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
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
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
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
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
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
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
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
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />