全新出发
This commit is contained in:
1 parent
b06c117b1e
commit
fa4edebed6
463 files changed
+181610
-125
No files matched your search
@@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
})
|
||||
}))
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}))
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}))
|
||||
@@ -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: ''
|
||||
}
|
||||
})
|
||||
}))
|
||||
@@ -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
|
||||
}
|
||||
}))
|
||||
@@ -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 })
|
||||
}))
|
||||
@@ -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
|
||||
})
|
||||
}))
|
||||
@@ -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]
|
||||
}
|
||||
Reference in new issue
Block a user