全新出发

This commit is contained in:
cc committed 2026-06-30 00:18:31 +08:00
1 parent b06c117b1e
commit fa4edebed6
463 files changed
+181610 -125

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
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 SelfSentDailyDistribution {
unit: 'day'
dailyDistribution: Record<string, number>
totalMessages: number
firstMessageTime: number | null
lastMessageTime: number | null
beginTimestamp: number
endTimestamp: number
}
interface AnalyticsState {
// 数据
statistics: ChatStatistics | null
rankings: ContactRanking[]
timeDistribution: TimeDistribution | null
selfSentDailyDistribution: SelfSentDailyDistribution | null
// 状态
isLoaded: boolean
lastLoadTime: number | null
// Actions
setStatistics: (data: ChatStatistics) => void
setRankings: (data: ContactRanking[]) => void
setTimeDistribution: (data: TimeDistribution) => void
setSelfSentDailyDistribution: (data: SelfSentDailyDistribution) => void
markLoaded: () => void
clearCache: () => void
}
export const useAnalyticsStore = create<AnalyticsState>()(
persist(
(set) => ({
statistics: null,
rankings: [],
timeDistribution: null,
selfSentDailyDistribution: null,
isLoaded: false,
lastLoadTime: null,
setStatistics: (data) => set({ statistics: data }),
setRankings: (data) => set({ rankings: data }),
setTimeDistribution: (data) => set({ timeDistribution: data }),
setSelfSentDailyDistribution: (data) => set({ selfSentDailyDistribution: data }),
markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }),
clearCache: () => set({
statistics: null,
rankings: [],
timeDistribution: null,
selfSentDailyDistribution: null,
isLoaded: false,
lastLoadTime: null
}),
}),
{
name: 'analytics-storage',
}
)
)
+91
View File
@@ -0,0 +1,91 @@
import { create } from 'zustand'
export interface AppState {
// 数据库状态
isDbConnected: boolean
dbPath: string | null
myWxid: string | null
// 加载状态
isLoading: boolean
loadingText: string
// 更新状态
updateInfo: {
hasUpdate: boolean
version?: string
releaseNotes?: string
} | null
isDownloading: boolean
downloadProgress: any
showUpdateDialog: boolean
updateError: string | null
// 操作
setDbConnected: (connected: boolean, path?: string) => void
setMyWxid: (wxid: string) => void
setLoading: (loading: boolean, text?: string) => void
// 更新操作
setUpdateInfo: (info: any) => void
setIsDownloading: (isDownloading: boolean) => void
setDownloadProgress: (progress: any) => void
setShowUpdateDialog: (show: boolean) => void
setUpdateError: (error: string | null) => void
// 锁定状态
isLocked: boolean
setLocked: (locked: boolean) => void
reset: () => void
}
export const useAppStore = create<AppState>((set) => ({
isDbConnected: false,
dbPath: null,
myWxid: null,
isLoading: false,
loadingText: '',
isLocked: false,
// 更新状态初始化
updateInfo: null,
isDownloading: false,
downloadProgress: { percent: 0 },
showUpdateDialog: false,
updateError: null,
setDbConnected: (connected, path) => set({
isDbConnected: connected,
dbPath: path ?? null
}),
setMyWxid: (wxid) => set({ myWxid: wxid }),
setLoading: (loading, text) => set({
isLoading: loading,
loadingText: text ?? ''
}),
setLocked: (locked) => set({ isLocked: locked }),
setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }),
setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }),
setDownloadProgress: (progress) => set({ downloadProgress: progress }),
setShowUpdateDialog: (show) => set({ showUpdateDialog: show }),
setUpdateError: (error) => set({ updateError: error }),
reset: () => set({
isDbConnected: false,
dbPath: null,
myWxid: null,
isLoading: false,
loadingText: '',
isLocked: false,
updateInfo: null,
isDownloading: false,
downloadProgress: { percent: 0 },
showUpdateDialog: false,
updateError: null
})
}))
+216
View File
@@ -0,0 +1,216 @@
import { create } from 'zustand'
import {
finishBackgroundTask,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import type { BackgroundTaskSourcePage, BackgroundTaskStatus } from '../types/backgroundTask'
interface BatchDecryptTaskControls {
cancelable?: boolean
resumable?: boolean
onCancel?: () => void | Promise<void>
onPause?: () => void | Promise<void>
onResume?: () => void | Promise<void>
}
interface BatchDecryptFinishOptions {
status?: Extract<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>
detail?: string
progressText?: string
}
export interface BatchImageDecryptState {
isBatchDecrypting: boolean
progress: { current: number; total: number }
showToast: boolean
showResultToast: boolean
result: { success: number; fail: number }
startTime: number
sessionName: string
taskId: string | null
startDecrypt: (
total: number,
sessionName: string,
sourcePage?: BackgroundTaskSourcePage,
controls?: BatchDecryptTaskControls
) => void
updateProgress: (current: number, total: number) => void
setTaskStatus: (detail: string, progressText?: string, status?: BackgroundTaskStatus) => void
finishDecrypt: (success: number, fail: number, options?: BatchDecryptFinishOptions) => void
setShowToast: (show: boolean) => void
setShowResultToast: (show: boolean) => void
reset: () => void
}
const clampProgress = (current: number, total: number): { current: number; total: number } => {
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0
const normalizedCurrentRaw = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : 0
const normalizedCurrent = normalizedTotal > 0
? Math.min(normalizedCurrentRaw, normalizedTotal)
: normalizedCurrentRaw
return { current: normalizedCurrent, total: normalizedTotal }
}
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const taskProgressUpdateMeta = new Map<string, { lastAt: number; lastBucket: number; step: number }>()
const calcProgressStep = (total: number): number => {
if (total <= 0) return 1
return Math.max(1, Math.floor(total / TASK_PROGRESS_UPDATE_MAX_STEPS))
}
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, get) => ({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: '',
taskId: null,
startDecrypt: (total, sessionName, sourcePage = 'chat', controls) => {
const previousTaskId = get().taskId
if (previousTaskId) {
taskProgressUpdateMeta.delete(previousTaskId)
finishBackgroundTask(previousTaskId, 'canceled', {
detail: '已被新的批量解密任务替换',
progressText: '已替换'
})
}
const normalizedProgress = clampProgress(0, total)
const normalizedSessionName = String(sessionName || '').trim()
const title = normalizedSessionName
? `图片批量解密(${normalizedSessionName})`
: '图片批量解密'
const taskId = registerBackgroundTask({
sourcePage,
title,
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
cancelable: controls?.cancelable !== false,
resumable: controls?.resumable === true,
onCancel: controls?.onCancel,
onPause: controls?.onPause,
onResume: controls?.onResume
})
taskProgressUpdateMeta.set(taskId, {
lastAt: Date.now(),
lastBucket: 0,
step: calcProgressStep(normalizedProgress.total)
})
set({
isBatchDecrypting: true,
progress: normalizedProgress,
showToast: true,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: Date.now(),
sessionName: normalizedSessionName,
taskId
})
},
updateProgress: (current, total) => {
const previousProgress = get().progress
const normalizedProgress = clampProgress(current, total)
const taskId = get().taskId
let shouldCommitUi = true
if (taskId) {
const now = Date.now()
const meta = taskProgressUpdateMeta.get(taskId)
const step = meta?.step || calcProgressStep(normalizedProgress.total)
const bucket = Math.floor(normalizedProgress.current / step)
const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS)
const crossedBucket = !meta || bucket !== meta.lastBucket
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
const shouldPublish = crossedBucket || intervalReached || isFinal
shouldCommitUi = shouldPublish
if (shouldPublish) {
updateBackgroundTask(taskId, {
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
})
taskProgressUpdateMeta.set(taskId, {
lastAt: now,
lastBucket: bucket,
step
})
}
}
if (shouldCommitUi && (
previousProgress.current !== normalizedProgress.current ||
previousProgress.total !== normalizedProgress.total
)) {
set({
progress: normalizedProgress
})
}
},
setTaskStatus: (detail, progressText, status) => {
const taskId = get().taskId
if (!taskId) return
const normalizedDetail = String(detail || '').trim()
if (!normalizedDetail) return
updateBackgroundTask(taskId, {
detail: normalizedDetail,
progressText,
status
})
},
finishDecrypt: (success, fail, options) => {
const taskId = get().taskId
const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0
const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
const status = options?.status || (normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed')
finishBackgroundTask(taskId, status, {
detail: options?.detail || `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
progressText: options?.progressText || `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
})
}
set({
isBatchDecrypting: false,
showToast: false,
showResultToast: true,
result: { success: normalizedSuccess, fail: normalizedFail },
startTime: 0,
taskId: null
})
},
setShowToast: (show) => set({ showToast: show }),
setShowResultToast: (show) => set({ showResultToast: show }),
reset: () => {
const taskId = get().taskId
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
finishBackgroundTask(taskId, 'canceled', {
detail: '批量解密任务已重置',
progressText: '已停止'
})
}
set({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: '',
taskId: null
})
}
}))
+234
View File
@@ -0,0 +1,234 @@
import { create } from 'zustand'
import {
finishBackgroundTask,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import type { BackgroundTaskSourcePage, BackgroundTaskStatus } from '../types/backgroundTask'
export type BatchVoiceTaskType = 'transcribe' | 'decrypt'
interface BatchVoiceTaskControls {
cancelable?: boolean
resumable?: boolean
onCancel?: () => void | Promise<void>
onPause?: () => void | Promise<void>
onResume?: () => void | Promise<void>
}
interface BatchVoiceTaskFinishOptions {
status?: Extract<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>
detail?: string
progressText?: string
}
export interface BatchTranscribeState {
/** 是否正在批量转写 */
isBatchTranscribing: boolean
/** 当前批量任务类型 */
taskType: BatchVoiceTaskType
/** 转写进度 */
progress: { current: number; total: number }
/** 是否显示进度浮窗 */
showToast: boolean
/** 是否显示结果弹窗 */
showResult: boolean
/** 转写结果 */
result: { success: number; fail: number }
/** 当前转写的会话名 */
startTime: number
sessionName: string
taskId: string | null
// Actions
startTranscribe: (
total: number,
sessionName: string,
taskType?: BatchVoiceTaskType,
sourcePage?: BackgroundTaskSourcePage,
controls?: BatchVoiceTaskControls
) => void
updateProgress: (current: number, total: number) => void
setTaskStatus: (detail: string, progressText?: string, status?: BackgroundTaskStatus) => void
finishTranscribe: (success: number, fail: number, options?: BatchVoiceTaskFinishOptions) => void
setShowToast: (show: boolean) => void
setShowResult: (show: boolean) => void
reset: () => void
}
const clampProgress = (current: number, total: number): { current: number; total: number } => {
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0
const normalizedCurrentRaw = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : 0
const normalizedCurrent = normalizedTotal > 0
? Math.min(normalizedCurrentRaw, normalizedTotal)
: normalizedCurrentRaw
return { current: normalizedCurrent, total: normalizedTotal }
}
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const taskProgressUpdateMeta = new Map<string, { lastAt: number; lastBucket: number; step: number }>()
const calcProgressStep = (total: number): number => {
if (total <= 0) return 1
return Math.max(1, Math.floor(total / TASK_PROGRESS_UPDATE_MAX_STEPS))
}
export const useBatchTranscribeStore = create<BatchTranscribeState>((set, get) => ({
isBatchTranscribing: false,
taskType: 'transcribe',
progress: { current: 0, total: 0 },
showToast: false,
showResult: false,
result: { success: 0, fail: 0 },
sessionName: '',
startTime: 0,
taskId: null,
startTranscribe: (total, sessionName, taskType = 'transcribe', sourcePage = 'chat', controls) => {
const previousTaskId = get().taskId
if (previousTaskId) {
taskProgressUpdateMeta.delete(previousTaskId)
finishBackgroundTask(previousTaskId, 'canceled', {
detail: '已被新的语音批量任务替换',
progressText: '已替换'
})
}
const normalizedProgress = clampProgress(0, total)
const normalizedSessionName = String(sessionName || '').trim()
const taskLabel = taskType === 'decrypt' ? '语音批量解密' : '语音批量转写'
const title = normalizedSessionName
? `${taskLabel}(${normalizedSessionName})`
: taskLabel
const taskId = registerBackgroundTask({
sourcePage,
title,
detail: `正在准备${taskType === 'decrypt' ? '语音解密' : '语音转写'}任务...`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
cancelable: controls?.cancelable !== false,
resumable: controls?.resumable === true,
onCancel: controls?.onCancel,
onPause: controls?.onPause,
onResume: controls?.onResume
})
taskProgressUpdateMeta.set(taskId, {
lastAt: Date.now(),
lastBucket: 0,
step: calcProgressStep(normalizedProgress.total)
})
set({
isBatchTranscribing: true,
taskType,
showToast: false,
progress: normalizedProgress,
showResult: false,
result: { success: 0, fail: 0 },
sessionName: normalizedSessionName,
startTime: Date.now(),
taskId
})
},
updateProgress: (current, total) => {
const previousProgress = get().progress
const normalizedProgress = clampProgress(current, total)
const taskId = get().taskId
let shouldCommitUi = true
if (taskId) {
const now = Date.now()
const meta = taskProgressUpdateMeta.get(taskId)
const step = meta?.step || calcProgressStep(normalizedProgress.total)
const bucket = Math.floor(normalizedProgress.current / step)
const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS)
const crossedBucket = !meta || bucket !== meta.lastBucket
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
const shouldPublish = crossedBucket || intervalReached || isFinal
shouldCommitUi = shouldPublish
if (shouldPublish) {
const taskVerb = get().taskType === 'decrypt' ? '解密语音' : '转写语音'
updateBackgroundTask(taskId, {
detail: `正在${taskVerb}(${normalizedProgress.current}/${normalizedProgress.total})`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
})
taskProgressUpdateMeta.set(taskId, {
lastAt: now,
lastBucket: bucket,
step
})
}
}
if (shouldCommitUi && (
previousProgress.current !== normalizedProgress.current ||
previousProgress.total !== normalizedProgress.total
)) {
set({
progress: normalizedProgress
})
}
},
setTaskStatus: (detail, progressText, status) => {
const taskId = get().taskId
if (!taskId) return
const normalizedDetail = String(detail || '').trim()
if (!normalizedDetail) return
updateBackgroundTask(taskId, {
detail: normalizedDetail,
progressText,
status
})
},
finishTranscribe: (success, fail, options) => {
const taskId = get().taskId
const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0
const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0
const taskType = get().taskType
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
const status = options?.status || (normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed')
const taskLabel = taskType === 'decrypt' ? '语音批量解密' : '语音批量转写'
finishBackgroundTask(taskId, status, {
detail: options?.detail || `${taskLabel}完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
progressText: options?.progressText || `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
})
}
set({
isBatchTranscribing: false,
showToast: false,
showResult: false,
result: { success: normalizedSuccess, fail: normalizedFail },
startTime: 0,
taskId: null
})
},
setShowToast: (show) => set({ showToast: show }),
setShowResult: (show) => set({ showResult: show }),
reset: () => {
const taskId = get().taskId
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
finishBackgroundTask(taskId, 'canceled', {
detail: '语音批量任务已重置',
progressText: '已停止'
})
}
set({
isBatchTranscribing: false,
taskType: 'transcribe',
progress: { current: 0, total: 0 },
showToast: false,
showResult: false,
result: { success: 0, fail: 0 },
sessionName: '',
startTime: 0,
taskId: null
})
}
}))
+206
View File
@@ -0,0 +1,206 @@
import { create } from 'zustand'
import type { ChatSession, Message, Contact } from '../types/models'
const messageAliasIndex = new Set<string>()
function buildPrimaryMessageKey(message: Message, sourceScope?: string): string {
if (message.messageKey) return String(message.messageKey)
const normalizedSourceScope = sourceScope ?? String(message._db_path || '').trim()
return `fallback:${normalizedSourceScope}:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}`
}
function buildMessageAliasKeys(message: Message): string[] {
const sourceScope = String(message._db_path || '').trim()
const keys = [buildPrimaryMessageKey(message, sourceScope)]
const localId = Math.max(0, Number(message.localId || 0))
const serverId = Math.max(0, Number(message.serverId || 0))
const createTime = Math.max(0, Number(message.createTime || 0))
const localType = Math.floor(Number(message.localType || 0))
const sender = String(message.senderUsername || '')
const isSend = Number(message.isSend ?? -1)
if (localId > 0) {
// 跨 message_*.db 时 local_id 可能重复,必须带分库上下文避免误去重。
if (sourceScope) {
keys.push(`lid:${sourceScope}:${localId}`)
} else {
// 缺库信息时使用更保守组合,尽量避免把不同消息误判成重复。
keys.push(`lid_fallback:${localId}:${createTime}:${sender}:${localType}:${serverId}`)
}
}
if (serverId > 0) {
// server_id 在跨库场景并非绝对全局唯一;必须带来源作用域避免误去重。
if (sourceScope) {
keys.push(`sid:${sourceScope}:${serverId}`)
} else {
keys.push(`sid_fallback:${serverId}:${createTime}:${sender}:${localType}`)
}
}
return keys
}
function rebuildMessageAliasIndex(messages: Message[]): void {
messageAliasIndex.clear()
for (const message of messages) {
const aliasKeys = buildMessageAliasKeys(message)
for (const key of aliasKeys) {
messageAliasIndex.add(key)
}
}
}
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
hasMoreLater: 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, options?: { preserveMessages?: boolean }) => 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
setHasMoreLater: (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,
hasMoreLater: false,
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, options) => set((state) => {
const nextMessages = options?.preserveMessages ? state.messages : []
rebuildMessageAliasIndex(nextMessages)
return {
currentSessionId: sessionId,
messages: nextMessages,
hasMoreMessages: true,
hasMoreLater: false
}
}),
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
setMessages: (messages) => set(() => {
rebuildMessageAliasIndex(messages || [])
return { messages }
}),
appendMessages: (newMessages, prepend = false) => set((state) => {
const currentMessages = state.messages || []
if (messageAliasIndex.size === 0 && currentMessages.length > 0) {
rebuildMessageAliasIndex(currentMessages)
}
const filtered: Message[] = []
newMessages.forEach((msg) => {
const aliasKeys = buildMessageAliasKeys(msg)
let exists = false
for (const key of aliasKeys) {
if (messageAliasIndex.has(key)) {
exists = true
break
}
}
if (exists) return
filtered.push(msg)
for (const key of aliasKeys) {
messageAliasIndex.add(key)
}
})
if (filtered.length === 0) return state
return {
messages: prepend
? [...filtered, ...currentMessages]
: [...currentMessages, ...filtered]
}
}),
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
setHasMoreLater: (hasMore) => set({ hasMoreLater: 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(() => {
messageAliasIndex.clear()
return {
isConnected: false,
isConnecting: false,
connectionError: null,
sessions: [],
filteredSessions: [],
currentSessionId: null,
isLoadingSessions: false,
messages: [],
isLoadingMessages: false,
isLoadingMore: false,
hasMoreMessages: true,
hasMoreLater: false,
contacts: new Map(),
searchKeyword: ''
}
})
}))
+115
View File
@@ -0,0 +1,115 @@
import { create } from 'zustand'
import type { ContactInfo } from '../types/models'
export interface ContactTypeTabCounts {
private: number
group: number
official: number
former_friend: number
}
export interface ContactTypeCardCounts {
friends: number
groups: number
officials: number
deletedFriends: number
}
const emptyTabCounts: ContactTypeTabCounts = {
private: 0,
group: 0,
official: 0,
former_friend: 0
}
let inflightPromise: Promise<ContactTypeTabCounts> | null = null
const normalizeCounts = (counts?: Partial<ContactTypeTabCounts> | null): ContactTypeTabCounts => {
return {
private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0,
group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0,
official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0,
former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0
}
}
export const toContactTypeTabCountsFromContacts = (contacts: ContactInfo[]): ContactTypeTabCounts => {
const next = { ...emptyTabCounts }
for (const contact of contacts || []) {
if (contact.type === 'friend') next.private += 1
if (contact.type === 'group') next.group += 1
if (contact.type === 'official') next.official += 1
if (contact.type === 'former_friend') next.former_friend += 1
}
return next
}
export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTypeCardCounts => {
return {
friends: counts.private,
groups: counts.group,
officials: counts.official,
deletedFriends: counts.former_friend
}
}
interface ContactTypeCountsState {
tabCounts: ContactTypeTabCounts
isLoading: boolean
isReady: boolean
updatedAt: number
setTabCounts: (counts: ContactTypeTabCounts) => void
syncFromContacts: (contacts: ContactInfo[]) => void
ensureLoaded: (options?: { force?: boolean }) => Promise<ContactTypeTabCounts>
}
export const useContactTypeCountsStore = create<ContactTypeCountsState>((set, get) => ({
tabCounts: { ...emptyTabCounts },
isLoading: false,
isReady: false,
updatedAt: 0,
setTabCounts: (counts) => {
const normalized = normalizeCounts(counts)
set({
tabCounts: normalized,
isReady: true,
updatedAt: Date.now()
})
},
syncFromContacts: (contacts) => {
const fromContacts = toContactTypeTabCountsFromContacts(contacts || [])
get().setTabCounts(fromContacts)
},
ensureLoaded: async (options) => {
if (!options?.force && get().isReady) {
return get().tabCounts
}
if (inflightPromise) {
return inflightPromise
}
set({ isLoading: true })
inflightPromise = (async () => {
try {
const result = await window.electronAPI.chat.getContactTypeCounts()
if (result?.success && result.counts) {
const normalized = normalizeCounts(result.counts)
set({
tabCounts: normalized,
isReady: true,
updatedAt: Date.now()
})
return normalized
}
} catch (error) {
console.error('加载联系人类型计数失败:', error)
}
return get().tabCounts
})().finally(() => {
inflightPromise = null
set({ isLoading: false })
})
return inflightPromise
}
}))
+91
View File
@@ -0,0 +1,91 @@
/**
* Export Task Store (Zustand)
*
* Replaces the event-based ExportBridge with a reactive Zustand store.
* Used by:
* - ExportV2Page to publish export session status
* - Sidebar to subscribe and display active task badge
* - Other pages to trigger single-export navigation
*/
import { create } from 'zustand'
// ─── Types ───────────────────────────────────────────────────
export interface ExportSessionStatus {
/** Session IDs that are currently being exported */
inProgressSessionIds: string[]
/** Number of active export tasks */
activeTaskCount: number
}
export interface OpenSingleExportRequest {
sessionId: string
sessionName?: string
requestId: string
timestamp: number
}
export interface SingleExportDialogStatus {
requestId: string
status: 'initializing' | 'opened' | 'failed'
message?: string
}
export interface ExportTaskStoreState {
// ── Session status (for Sidebar badge) ──
sessionStatus: ExportSessionStatus
setSessionStatus: (status: ExportSessionStatus) => void
// ── Single export request (other pages → export page) ──
pendingSingleExport: OpenSingleExportRequest | null
requestSingleExport: (sessionId: string, sessionName?: string) => string
consumeSingleExport: () => OpenSingleExportRequest | null
// ── Single export dialog feedback ──
lastDialogStatus: SingleExportDialogStatus | null
setDialogStatus: (status: SingleExportDialogStatus) => void
clearDialogStatus: () => void
}
// ─── Store ───────────────────────────────────────────────────
let requestSequence = 0
export const useExportTaskStore = create<ExportTaskStoreState>((set, get) => ({
sessionStatus: {
inProgressSessionIds: [],
activeTaskCount: 0
},
setSessionStatus: (status) => set({ sessionStatus: status }),
pendingSingleExport: null,
requestSingleExport: (sessionId, sessionName) => {
requestSequence += 1
const requestId = `single-export-${Date.now()}-${requestSequence}`
set({
pendingSingleExport: {
sessionId,
sessionName,
requestId,
timestamp: Date.now()
}
})
return requestId
},
consumeSingleExport: () => {
const current = get().pendingSingleExport
if (!current) return null
set({ pendingSingleExport: null })
return current
},
lastDialogStatus: null,
setDialogStatus: (status) => set({ lastDialogStatus: status }),
clearDialogStatus: () => set({ lastDialogStatus: null })
}))
+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
})
}))
+96
View File
@@ -0,0 +1,96 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' | 'blossom-dream' | 'geist'
export type ThemeMode = 'light' | 'dark' | 'system'
export interface ThemeInfo {
id: ThemeId
name: string
description: string
primaryColor: string
bgColor: string
// 可选副色,用于多彩主题的渐变预览
accentColor?: string
}
export const themes: ThemeInfo[] = [
{
id: 'cloud-dancer',
name: '云上舞白',
description: 'Pantone 2026 年度色',
primaryColor: '#8B7355',
bgColor: '#F0EEE9'
},
{
id: 'blossom-dream',
name: '繁花如梦',
description: '晨曦花境 · 夜阑幽梦',
primaryColor: '#D4849A',
bgColor: '#FCF9FB',
accentColor: '#FFBE98'
},
{
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'
},
{
id: 'geist',
name: 'Geist',
description: 'Vercel · 极简黑白',
primaryColor: '#000000',
bgColor: '#ffffff'
}
]
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]
}