505 lines
17 KiB
TypeScript
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
|
|
}
|
|
})
|