Files
Thing/src/stores/appStore.ts
T
2026-08-06 10:33:16 +08:00

505 lines
17 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getCurrentWindow, Effect, EffectState } from '@tauri-apps/api/window'
import { enable, isEnabled, disable } from '@tauri-apps/plugin-autostart'
import { moduleRegistry } from '@/modules/registry'
import { useSearchStore } from '@/stores/searchStore'
import { useProcessStore } from '@/stores/processStore'
import { toast } from 'vue-sonner'
import { createLogger } from '@/lib/logger'
import { STORAGE_KEYS } from '@/lib/constants'
import type { ModuleCategory } from '@/types/module'
const logger = createLogger('app')
export type Theme = 'light' | 'dark' | 'system'
export type EffectType = 'normal' | 'mica' | 'acrylic'
export interface ModuleInfo {
id: string
name: string
icon: string
enabled: boolean
description: string
category: ModuleCategory
hasProcess: boolean
builtin: boolean
}
/** localStorage 版本号 —— 结构变更时递增,自动清除旧数据 */
const SETTINGS_VERSION = 4
const STORAGE_KEY = STORAGE_KEYS.appSettings
/** 从模块注册表初始化模块元信息 */
const initModulesFromRegistry = (): ModuleInfo[] => {
return moduleRegistry.getAllMetas().map(meta => ({
id: meta.id,
name: meta.name,
icon: meta.icon,
enabled: meta.enabled,
description: meta.description,
category: meta.category,
hasProcess: meta.hasProcess,
builtin: meta.builtin
}))
}
/** 从注册表初始化模块排序(仅用户模块,按 order 字段排序) */
const initModuleOrder = (): string[] => {
return moduleRegistry
.getAllMetas()
.filter(m => !m.builtin)
.sort((a, b) => a.order - b.order)
.map(m => m.id)
}
export const useAppStore = defineStore('app', () => {
const theme = ref<Theme>('system')
const effect = ref<EffectType>('mica')
const isAutoStart = ref(false)
const isInitialized = ref(false)
const modules = ref<ModuleInfo[]>(initModulesFromRegistry())
const moduleOrder = ref<string[]>(initModuleOrder())
/**
* 系统真实的深浅色偏好(不受应用 setTheme 影响)。
* 应用启动时用 matchMedia 初始化(此时未调用 setTheme,结果准确),
* 之后仅通过 Tauri onThemeChanged 事件更新(该事件反映系统主题变化,不受 setTheme 影响)。
* 用于"跟随系统"卡片色块等需要反映系统真实状态的 UI。
*/
const systemDark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)
/** 正在处理切换的模块 ID 集合(防止重复点击) */
const togglingModules = ref<Set<string>>(new Set())
const loadSettings = async () => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const settings = JSON.parse(saved)
// 版本不匹配,清除旧数据
if (settings.version !== SETTINGS_VERSION) {
console.warn('[appStore] Settings version mismatch, clearing old data')
localStorage.removeItem(STORAGE_KEY)
saveSettings()
return
}
if (settings.theme) theme.value = settings.theme
if (settings.effect) effect.value = settings.effect
if (settings.modules) {
const savedModules = settings.modules as Array<{ id: string; enabled: boolean }>
savedModules.forEach(sm => {
const m = modules.value.find(mod => mod.id === sm.id)
if (m) {
m.enabled = sm.enabled
}
})
}
// 恢复模块排序:保留已保存的顺序,追加新增模块到末尾
if (settings.moduleOrder) {
const savedOrder = settings.moduleOrder as string[]
const allUserIds = moduleRegistry
.getAllMetas()
.filter(m => !m.builtin)
.map(m => m.id)
const known = savedOrder.filter(id => allUserIds.includes(id))
const newlyAdded = allUserIds.filter(id => !savedOrder.includes(id))
moduleOrder.value = [...known, ...newlyAdded]
}
}
} catch {
console.error('Failed to load settings from localStorage')
localStorage.removeItem(STORAGE_KEY)
}
}
const saveSettings = () => {
try {
const modulesData = modules.value.map(m => ({
id: m.id,
enabled: m.enabled
}))
localStorage.setItem(STORAGE_KEY, JSON.stringify({
version: SETTINGS_VERSION,
theme: theme.value,
effect: effect.value,
isAutoStart: isAutoStart.value,
modules: modulesData,
moduleOrder: moduleOrder.value
}))
} catch {
console.error('Failed to save settings to localStorage')
}
}
const enabledModules = computed(() => modules.value.filter(m => m.enabled))
const getModule = (id: string) => modules.value.find(m => m.id === id)
/** 重新排序模块 */
const reorderModules = (newOrder: string[]) => {
moduleOrder.value = newOrder
saveSettings()
}
const toggleModule = async (moduleId: string, enabled?: boolean) => {
const moduleIndex = modules.value.findIndex(m => m.id === moduleId)
if (moduleIndex === -1) {
console.warn(`[toggleModule] Module "${moduleId}" not found`)
return false
}
const moduleInfo = modules.value[moduleIndex]
// 内置模块不可禁用
if (moduleInfo.builtin && enabled === false) {
toast.warning('内置模块无法禁用')
return false
}
// 防止重复操作
if (togglingModules.value.has(moduleId)) {
console.log(`[toggleModule] Module "${moduleId}" is already being toggled`)
return false
}
const targetState = enabled !== undefined ? enabled : !moduleInfo.enabled
if (targetState === moduleInfo.enabled) {
console.log(`[toggleModule] Module "${moduleId}" is already ${targetState ? 'enabled' : 'disabled'}`)
return false
}
console.log(`[toggleModule] Toggling "${moduleId}" from ${moduleInfo.enabled} to ${targetState}`)
togglingModules.value.add(moduleId)
try {
if (!targetState) {
// ===== 禁用模块 =====
// 1. 先更新状态(让开关立即响应)
modules.value[moduleIndex] = { ...moduleInfo, enabled: false }
saveSettings()
// 2. 清理搜索项
try {
useSearchStore().unregisterModule(moduleId)
} catch (e) {
console.error(`[toggleModule] Failed to unregister search items for "${moduleId}":`, e)
logger.error(`禁用模块 "${moduleId}" 时清理搜索项失败: ${e}`)
}
// 3. 停止进程
if (moduleInfo.hasProcess) {
try {
const processStore = useProcessStore()
const status = processStore.getProcessStatus(moduleId)
if (status && status.status === 'running') {
toast.loading(`正在停止 ${moduleInfo.name} 后台进程...`, { id: `stop-${moduleId}` })
await processStore.stopByModule(moduleId)
toast.success(`${moduleInfo.name} 进程已停止`, { id: `stop-${moduleId}` })
}
} catch (e) {
console.error(`[toggleModule] Failed to stop process for "${moduleId}":`, e)
logger.error(`停止模块 "${moduleId}" 进程失败: ${e}`)
toast.error(`停止 ${moduleInfo.name} 进程失败`, { id: `stop-${moduleId}` })
}
}
// 4. 调用生命周期钩子
try {
const config = moduleRegistry.getConfig(moduleId)
await config?.lifecycle?.onDisable?.()
} catch (e) {
console.error(`[toggleModule] Module onDisable hook failed for "${moduleId}":`, e)
logger.error(`模块 "${moduleId}" onDisable 钩子失败: ${e}`)
}
// 5. 清理组件缓存,释放内存
moduleRegistry.clearComponentCache(moduleId)
logger.info(`已禁用模块: ${moduleInfo.name}`)
toast.success(`已禁用 ${moduleInfo.name}`)
} else {
// ===== 启用模块 =====
// 1. 先更新状态(让开关立即响应)
modules.value[moduleIndex] = { ...moduleInfo, enabled: true }
saveSettings()
// 2. 调用生命周期钩子
try {
const config = moduleRegistry.getConfig(moduleId)
await config?.lifecycle?.onEnable?.()
} catch (e) {
console.error(`[toggleModule] Module onEnable hook failed for "${moduleId}":`, e)
logger.error(`模块 "${moduleId}" onEnable 钩子失败: ${e}`)
}
// 3. 恢复搜索项
try {
const searchStore = useSearchStore()
const config = moduleRegistry.getConfig(moduleId)
if (config?.searchItems) {
config.searchItems.forEach((item, index) => {
searchStore.registerItem({
id: `${moduleId}-search-${index}`,
moduleId,
title: item.title,
description: item.description,
keywords: item.keywords
})
})
}
} catch (e) {
console.error(`[toggleModule] Failed to register search items for "${moduleId}":`, e)
logger.error(`启用模块 "${moduleId}" 时注册搜索项失败: ${e}`)
}
// 4. 如果配置了 autoStart,启动进程
if (moduleInfo.hasProcess) {
const config = moduleRegistry.getConfig(moduleId)
if (config?.process?.autoStart) {
try {
const processStore = useProcessStore()
toast.loading(`正在启动 ${moduleInfo.name} 后台进程...`, { id: `start-${moduleId}` })
await processStore.startByModule(moduleId)
toast.success(`${moduleInfo.name} 进程已启动`, { id: `start-${moduleId}` })
} catch (e) {
console.error(`[toggleModule] Failed to start process for "${moduleId}":`, e)
logger.error(`启动模块 "${moduleId}" 进程失败: ${e}`)
toast.error(`启动 ${moduleInfo.name} 进程失败`, { id: `start-${moduleId}` })
}
}
}
logger.info(`已启用模块: ${moduleInfo.name}`)
toast.success(`已启用 ${moduleInfo.name}`)
}
return true
} catch (e) {
console.error(`[toggleModule] Failed to toggle module "${moduleId}":`, e)
logger.error(`模块 "${moduleId}" 切换失败: ${(e as Error).message}`)
toast.error(`操作失败: ${(e as Error).message}`)
return false
} finally {
togglingModules.value.delete(moduleId)
}
}
const setTheme = async (newTheme: Theme) => {
theme.value = newTheme
if (newTheme !== 'system' && effect.value === 'acrylic') {
effect.value = 'mica'
}
await applyTheme()
saveSettings()
}
const setEffect = async (newEffect: EffectType) => {
effect.value = newEffect
await applyEffect()
saveSettings()
}
const toggleAutoStart = async (checked?: boolean) => {
const targetState = checked !== undefined ? checked : !isAutoStart.value
const previousState = isAutoStart.value
try {
isAutoStart.value = targetState
if (targetState) {
await enable()
} else {
await disable()
}
saveSettings()
} catch (e) {
isAutoStart.value = previousState
console.error('Failed to toggle auto-start:', e)
throw e
}
}
const applyTheme = async () => {
const root = document.documentElement
root.classList.remove('dark')
let isDark = false
if (theme.value === 'dark') {
isDark = true
root.classList.add('dark')
} else if (theme.value === 'system') {
// 通过 Tauri 获取系统真实主题,避免 matchMedia 被 setTheme 污染
try {
const tauriWindow = getCurrentWindow()
const sysTheme = await tauriWindow.theme()
isDark = sysTheme === 'dark'
} catch {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
}
if (isDark) {
root.classList.add('dark')
}
}
try {
const tauriWindow = getCurrentWindow()
if (theme.value === 'system') {
// 让窗口原生跟随系统主题,而不是固定成当前匹配值
// 固定主题会导致系统切换深浅色时窗口标题栏等原生 UI 不同步
await tauriWindow.setTheme(null)
} else {
await tauriWindow.setTheme(isDark ? 'dark' : 'light')
}
} catch (e) {
// 非 Tauri 环境下忽略
}
await applyEffect()
}
const applyEffect = async () => {
const root = document.documentElement
root.classList.remove('effect-mica', 'effect-acrylic', 'effect-normal')
root.classList.add(`effect-${effect.value}`)
try {
const tauriWindow = getCurrentWindow()
const isDark = root.classList.contains('dark')
await tauriWindow.clearEffects()
if (effect.value === 'normal') {
await tauriWindow.setBackgroundColor(isDark ? '#0f172a' : '#ffffff')
} else if (effect.value === 'mica') {
const micaEffect = (isDark ? 'micaDark' : 'micaLight') as unknown as Effect
await tauriWindow.setEffects({
effects: [micaEffect],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0]
})
await tauriWindow.setBackgroundColor('#00000000')
} else if (effect.value === 'acrylic') {
await tauriWindow.setEffects({
effects: [Effect.Acrylic],
state: EffectState.FollowsWindowActiveState,
color: isDark ? [30, 41, 59, 0] : [248, 250, 252, 0]
})
await tauriWindow.setBackgroundColor('#00000000')
}
} catch (e) {
// 非 Tauri 环境下忽略
}
// 切换效果后修复 snap-layout 子窗口背景(clearEffects 触发重绘会导致白色背景显现)
try {
const { invoke } = await import('@tauri-apps/api/core')
invoke('fix_snap_background').catch(() => {})
} catch {
/* 非 Tauri 环境忽略 */
}
}
// 系统主题变化时,若当前为"跟随系统"模式,同步更新 DOM 和窗口效果。
// 使用 Tauri 的 onThemeChanged 事件(而非 matchMedia),因为 setTheme('dark'/'light')
// 会改变 WebView 的 prefers-color-scheme 媒体查询结果,导致 matchMedia 误触发。
//
// 注意:onThemeChanged 也会被 setTheme('dark'/'light') 触发(非系统真实变化)。
// 因此 systemDark 只在 theme.value === 'system' 时更新:
// - system 模式下 setTheme(null) 不固定主题,onThemeChanged 反映系统真实色
// - light/dark 模式下 setTheme 引起的 onThemeChanged 被忽略,systemDark 保持不变
// - 用户切回 system 模式时,setTheme(null) 会触发 onThemeChanged 反映系统真实色,自动修正
const handleSystemThemeChange = async (newTheme: 'light' | 'dark' | null) => {
if (theme.value === 'system') {
systemDark.value = newTheme === 'dark'
const root = document.documentElement
if (newTheme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
// 窗口已通过 setTheme(null) 原生跟随系统,无需再手动 setTheme
await applyEffect()
}
}
const init = async () => {
try {
await loadSettings()
try {
const saved = localStorage.getItem(STORAGE_KEY)
const savedAutoStart = saved ? JSON.parse(saved).isAutoStart : false
const systemAutoStart = await isEnabled()
isAutoStart.value = systemAutoStart
if (savedAutoStart !== systemAutoStart) {
if (savedAutoStart) {
await enable()
isAutoStart.value = true
} else {
await disable()
isAutoStart.value = false
}
saveSettings()
}
} catch (e) {
console.error('Failed to sync auto-start during init:', e)
}
await applyTheme()
// 使用 Tauri 的 onThemeChanged 监听系统主题变化,而非 matchMedia。
// 因为 setTheme('dark'/'light') 会改变 WebView 的 prefers-color-scheme 媒体查询,
// 导致 matchMedia 在非 system 模式下也误触发(虽有 theme.value 守卫,但更可靠)。
try {
const tauriWindow = getCurrentWindow()
await tauriWindow.onThemeChanged(({ payload }) => {
handleSystemThemeChange(payload)
})
} catch {
// 非 Tauri 环境回退到 matchMedia
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', (e) => {
handleSystemThemeChange(e.matches ? 'dark' : 'light')
})
}
isInitialized.value = true
} finally {
try {
const tauriWindow = getCurrentWindow()
await tauriWindow.show()
} catch (e) {
// 非 Tauri 环境下忽略
}
}
}
return {
theme,
effect,
systemDark,
isAutoStart,
isInitialized,
modules,
moduleOrder,
enabledModules,
togglingModules,
getModule,
toggleModule,
reorderModules,
setTheme,
setEffect,
toggleAutoStart,
applyTheme,
applyEffect,
init,
loadSettings
}
})