From 7b5fd3a067f66f1bd8219c96034e458160bdcea2 Mon Sep 17 00:00:00 2001 From: Fox_block Date: Sun, 1 Mar 2026 21:27:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=B7=E8=BE=93=E5=85=A5=E6=96=87=E6=9C=AC?= =?UTF-8?q?=EF=BC=9A=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 10 +- src/main/services/DbConnectionService.ts | 4 +- src/main/services/SettingsService.ts | 22 +- src/main/services/ThemeService.ts | 280 +++++++++------- src/main/services/WindowManager.ts | 241 +------------- src/mobile/App.tsx | 4 +- src/mobile/components/Layout.tsx | 3 +- src/mobile/pages/Score.tsx | 2 +- src/preload/index.ts | 5 - src/preload/types.ts | 14 +- src/renderer/src/App.tsx | 18 +- src/renderer/src/assets/main.css | 10 +- src/renderer/src/components/ContentArea.tsx | 29 +- src/renderer/src/components/Home.tsx | 2 +- src/renderer/src/components/Settings.tsx | 38 +-- src/renderer/src/components/Sidebar.tsx | 2 +- src/renderer/src/components/ThemeEditor.tsx | 36 ++- .../src/components/ThemeQuickSettings.tsx | 302 ++++++++++++++++++ src/renderer/src/components/Wizard.tsx | 15 +- src/renderer/src/contexts/ThemeContext.tsx | 52 ++- themes/acrylic-auto.json | 31 -- themes/acrylic-dark.json | 31 -- themes/acrylic-light.json | 31 -- themes/amber.json | 25 -- themes/cyan.json | 25 -- themes/dark.json | 26 -- themes/green.json | 25 -- themes/light.json | 25 -- themes/mica-auto.json | 31 -- themes/mica-dark.json | 31 -- themes/mica-light.json | 31 -- themes/mica.json | 26 -- themes/pastel.json | 25 -- themes/purple.json | 25 -- 34 files changed, 600 insertions(+), 877 deletions(-) create mode 100644 src/renderer/src/components/ThemeQuickSettings.tsx delete mode 100644 themes/acrylic-auto.json delete mode 100644 themes/acrylic-dark.json delete mode 100644 themes/acrylic-light.json delete mode 100644 themes/amber.json delete mode 100644 themes/cyan.json delete mode 100644 themes/dark.json delete mode 100644 themes/green.json delete mode 100644 themes/light.json delete mode 100644 themes/mica-auto.json delete mode 100644 themes/mica-dark.json delete mode 100644 themes/mica-light.json delete mode 100644 themes/mica.json delete mode 100644 themes/pastel.json delete mode 100644 themes/purple.json diff --git a/src/main/index.ts b/src/main/index.ts index a049695..ca825de 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -49,7 +49,6 @@ type mainAppConfig = { dataRoot: string configDir: string logDir: string - themeDir: string dbPath: string pgConnectionString?: string window: windowManagerOptions @@ -188,9 +187,6 @@ app.whenReady().then(async () => { const logDir = is.dev ? join(process.cwd(), 'logs') : join(dataRoot, 'logs') const configDir = is.dev ? join(process.cwd(), 'configs') : join(dataRoot, 'configs') - const themeDir = is.dev - ? join(process.cwd(), 'themes') - : ensureWritableDir(join(appRoot, 'themes'), join(dataRoot, 'themes')) const dbPath = is.dev ? join(process.cwd(), 'db.sqlite') : join(dataRoot, 'db.sqlite') const pgConnectionString = process.env['PG_CONNECTION_STRING'] || undefined @@ -201,7 +197,6 @@ app.whenReady().then(async () => { dataRoot, logDir, configDir, - themeDir, dbPath, pgConnectionString, window: { @@ -263,10 +258,7 @@ app.whenReady().then(async () => { (p) => new TagRepository((p.get(DbManagerToken) as DbManager).dataSource) ) - services.addSingleton( - ThemeServiceToken, - (p) => new ThemeService(p.get(MainContext), config.themeDir) - ) + services.addSingleton(ThemeServiceToken, (p) => new ThemeService(p.get(MainContext))) services.addSingleton( WindowManagerToken, (p) => new WindowManager(p.get(MainContext), config.window) diff --git a/src/main/services/DbConnectionService.ts b/src/main/services/DbConnectionService.ts index fd2fa68..babfaba 100644 --- a/src/main/services/DbConnectionService.ts +++ b/src/main/services/DbConnectionService.ts @@ -100,9 +100,7 @@ export class DbConnectionService extends Service { return { success: true } } catch (error: any) { if (this.testDataSource) { - try { - await this.testDataSource.destroy() - } catch {} + await this.testDataSource.destroy().catch(() => null) this.testDataSource = null } console.error('PostgreSQL connection test failed:', error) diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts index 6135992..37c0ac6 100644 --- a/src/main/services/SettingsService.ts +++ b/src/main/services/SettingsService.ts @@ -60,24 +60,10 @@ export class SettingsService extends Service { }) } }, - window_theme: { - kind: 'string', - defaultValue: 'auto', - writePermission: 'admin', - validate: (v) => v === 'auto' || v === 'dark' || v === 'light' - }, - window_effect: { - kind: 'string', - defaultValue: 'mica', - writePermission: 'admin', - validate: (v) => - ['mica', 'tabbed', 'acrylic', 'blur', 'transparent', 'none'].includes(v as string) - }, - window_radius: { - kind: 'string', - defaultValue: 'rounded', - writePermission: 'admin', - validate: (v) => v === 'rounded' || v === 'small' || v === 'square' + themes_custom: { + kind: 'json', + defaultValue: [], + writePermission: 'admin' }, auto_score_enabled: { kind: 'boolean', diff --git a/src/main/services/ThemeService.ts b/src/main/services/ThemeService.ts index 43e11f2..7531767 100644 --- a/src/main/services/ThemeService.ts +++ b/src/main/services/ThemeService.ts @@ -1,19 +1,11 @@ import { Service } from '../../shared/kernel' import { MainContext } from '../context' import { BrowserWindow } from 'electron' -import fs from 'fs' -import path from 'path' -import { FSWatcher, watch } from 'chokidar' export interface themeConfig { name: string id: string mode: 'light' | 'dark' - mica?: { - effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none' - theme: 'auto' | 'dark' | 'light' - radius: 'small' | 'medium' | 'large' - } config: { tdesign: Record custom: Record @@ -27,19 +19,118 @@ declare module '../../shared/kernel' { } export class ThemeService extends Service { - private themeDir: string - private watcher: FSWatcher | null = null private currentThemeId: string = 'light-default' + private customThemes: themeConfig[] = [] + private readonly builtinThemes: themeConfig[] = [ + { + name: '极简浅色', + id: 'light-default', + mode: 'light', + config: { + tdesign: { + brandColor: '#0052D9', + warningColor: '#ED7B2F', + errorColor: '#D54941', + successColor: '#2BA471' + }, + custom: { + '--ss-bg-color': 'linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)', + '--ss-card-bg': '#ffffff', + '--ss-text-main': '#181818', + '--ss-text-secondary': '#666666', + '--ss-border-color': '#dcdcdc', + '--ss-header-bg': 'linear-gradient(180deg, #ffffff 0%, rgba(255,255,255,0.7) 100%)', + '--ss-sidebar-bg': 'rgba(255, 255, 255, 0.88)', + '--ss-item-hover': '#f3f3f3', + '--ss-sidebar-text': '#181818', + '--ss-sidebar-active-bg': 'rgba(0, 0, 0, 0.06)', + '--ss-sidebar-active-text': '#181818' + } + } + }, + { + name: '极客深蓝', + id: 'dark-default', + mode: 'dark', + config: { + tdesign: { + brandColor: '#0052D9', + warningColor: '#E37318', + errorColor: '#D32029', + successColor: '#248232' + }, + custom: { + '--ss-bg-color': 'linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)', + '--ss-card-bg': '#1e1e1e', + '--ss-text-main': '#ffffff', + '--ss-text-secondary': '#a0a0a0', + '--ss-border-color': '#333333', + '--ss-header-bg': 'rgba(30, 30, 30, 0.92)', + '--ss-sidebar-bg': 'rgba(30, 30, 30, 0.92)', + '--ss-item-hover': '#2c2c2c', + '--ss-sidebar-text': '#ffffff', + '--ss-sidebar-active-bg': 'rgba(255, 255, 255, 0.10)', + '--ss-sidebar-active-text': '#ffffff' + } + } + }, + { + name: '冷静青蓝', + id: 'dark-cyan', + mode: 'dark', + config: { + tdesign: { + brandColor: '#16A085', + warningColor: '#F39C12', + errorColor: '#E74C3C', + successColor: '#1ABC9C' + }, + custom: { + '--ss-bg-color': 'linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)', + '--ss-card-bg': '#0f1a23', + '--ss-text-main': '#E5F7FF', + '--ss-text-secondary': '#7FA4B8', + '--ss-border-color': '#1F3645', + '--ss-header-bg': 'rgba(15, 26, 35, 0.92)', + '--ss-sidebar-bg': 'rgba(15, 26, 35, 0.92)', + '--ss-item-hover': '#182635', + '--ss-sidebar-text': '#E5F7FF', + '--ss-sidebar-active-bg': '#182635', + '--ss-sidebar-active-text': '#E5F7FF' + } + } + }, + { + name: '清新马卡龙', + id: 'light-pastel', + mode: 'light', + config: { + tdesign: { + brandColor: '#FF9AA2', + warningColor: '#FFB347', + errorColor: '#FF6F69', + successColor: '#B5EAD7' + }, + custom: { + '--ss-bg-color': 'linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)', + '--ss-card-bg': '#ffffff', + '--ss-text-main': '#3A3A3A', + '--ss-text-secondary': '#8A8A8A', + '--ss-border-color': '#F1D3D3', + '--ss-header-bg': 'rgba(255, 255, 255, 0.88)', + '--ss-sidebar-bg': 'rgba(255, 255, 255, 0.90)', + '--ss-item-hover': '#FFE7E0', + '--ss-sidebar-text': '#3A3A3A', + '--ss-sidebar-active-bg': '#FFE7E0', + '--ss-sidebar-active-text': '#3A3A3A' + } + } + } + ] - constructor(ctx: MainContext, themeDir: string) { + constructor(ctx: MainContext) { super(ctx, 'themes') - this.themeDir = themeDir - this.setupWatcher() this.registerIpc() - - ctx.effect(() => { - if (this.watcher) this.watcher.close() - }) } private get mainCtx() { @@ -47,12 +138,14 @@ export class ThemeService extends Service { } public async init() { + await this.mainCtx.settings.initialize() await this.loadSavedTheme() + await this.loadCustomThemes() } private async loadSavedTheme() { try { - const savedThemeId = await this.mainCtx.settings.getValue('current_theme_id' as any) + const savedThemeId = this.mainCtx.settings.getValue('current_theme_id') if (savedThemeId && typeof savedThemeId === 'string') { const themes = this.getThemeList() const exists = themes.some((t) => t.id === savedThemeId) @@ -67,26 +160,27 @@ export class ThemeService extends Service { private async saveCurrentTheme() { try { - await this.mainCtx.settings.setValue('current_theme_id' as any, this.currentThemeId) + await this.mainCtx.settings.setValue('current_theme_id', this.currentThemeId) } catch (e) { this.mainCtx.logger.warn('Failed to save theme', { meta: e }) } } - private setupWatcher() { - if (this.watcher) this.watcher.close() - - this.watcher = watch(this.themeDir, { - ignored: /(^|[/\\])\../, - persistent: true - }) - - this.watcher.on('change', (filePath) => { - if (filePath.endsWith('.json')) { - this.mainCtx.logger.info('Theme file changed', { filePath }) - this.notifyThemeUpdate() + private async loadCustomThemes() { + try { + const v = this.mainCtx.settings.getValue('themes_custom') + if (Array.isArray(v)) { + this.customThemes = v.filter((t) => t && typeof t === 'object') as any + } else { + this.customThemes = [] } - }) + } catch { + this.customThemes = [] + } + } + + private async saveCustomThemes() { + await this.mainCtx.settings.setValue('themes_custom', this.customThemes) } private registerIpc() { @@ -107,7 +201,6 @@ export class ThemeService extends Service { } this.currentThemeId = themeId await this.saveCurrentTheme() - this.applyMicaEffect(themeId) this.notifyThemeUpdate() return { success: true } }) @@ -117,8 +210,29 @@ export class ThemeService extends Service { return { success: false, message: 'Permission denied' } try { - const filePath = path.join(this.themeDir, `${theme.id}.json`) - fs.writeFileSync(filePath, JSON.stringify(theme, null, 2), 'utf-8') + if (!theme?.id || !theme?.name) { + return { success: false, message: 'Invalid theme' } + } + const isBuiltin = this.builtinThemes.some((t) => t.id === theme.id) + if (isBuiltin) { + return { success: false, message: 'Cannot overwrite builtin themes' } + } + + const normalized: themeConfig = { + name: String(theme.name), + id: String(theme.id), + mode: theme.mode === 'dark' ? 'dark' : 'light', + config: { + tdesign: { ...(theme.config?.tdesign || {}) }, + custom: { ...(theme.config?.custom || {}) } + } + } + + const idx = this.customThemes.findIndex((t) => t.id === normalized.id) + if (idx >= 0) this.customThemes[idx] = normalized + else this.customThemes.unshift(normalized) + + await this.saveCustomThemes() this.notifyThemeUpdate() return { success: true } } catch (e) { @@ -130,17 +244,23 @@ export class ThemeService extends Service { if (!this.mainCtx.permissions.requirePermission(event, 'admin')) return { success: false, message: 'Permission denied' } - if (themeId.startsWith('default-')) { - return { success: false, message: 'Cannot delete default themes' } - } - try { - const filePath = path.join(this.themeDir, `${themeId}.json`) - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath) + const id = String(themeId || '').trim() + if (!id) return { success: false, message: 'Invalid theme id' } + if (this.builtinThemes.some((t) => t.id === id)) { + return { success: false, message: 'Cannot delete builtin themes' } } + + const before = this.customThemes.length + this.customThemes = this.customThemes.filter((t) => t.id !== id) + if (this.customThemes.length === before) { + return { success: false, message: 'Theme not found' } + } + + await this.saveCustomThemes() if (this.currentThemeId === themeId) { - this.currentThemeId = 'light' // Fallback + this.currentThemeId = 'light-default' + await this.saveCurrentTheme() } this.notifyThemeUpdate() return { success: true } @@ -148,82 +268,10 @@ export class ThemeService extends Service { return { success: false, message: String(e) } } }) - - this.mainCtx.handle( - 'theme:set-custom', - async ( - event, - config: { - effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none' - theme: 'auto' | 'dark' | 'light' - radius: 'small' | 'medium' | 'large' - } - ) => { - if (!this.mainCtx.permissions.requirePermission(event, 'admin')) - return { success: false, message: 'Permission denied' } - - this.currentThemeId = 'custom' - await this.saveCurrentTheme() - this.applyMicaConfig(config) - this.notifyThemeUpdate() - return { success: true } - } - ) - } - - private applyMicaEffect(themeId: string) { - const theme = this.getThemeById(themeId) - if (!theme?.mica) return - - this.applyMicaConfig(theme.mica) - } - - private applyMicaConfig(config: { - effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none' - theme: 'auto' | 'dark' | 'light' - radius: 'small' | 'medium' | 'large' - }) { - const radiusMap: Record = { - small: 'small', - medium: 'rounded', - large: 'rounded' - } - - const windows = BrowserWindow.getAllWindows() - for (const win of windows) { - if (win.isDestroyed()) continue - - this.mainCtx.windows.setMicaEffect(win, config.effect) - this.mainCtx.windows.setMicaTheme(win, config.theme) - this.mainCtx.windows.setMicaCorner(win, radiusMap[config.radius] || 'rounded') - } - - this.mainCtx.settings.setValue('window_effect', config.effect) - this.mainCtx.settings.setValue('window_theme', config.theme) - this.mainCtx.settings.setValue('window_radius', radiusMap[config.radius] || 'rounded') } private getThemeList(): themeConfig[] { - try { - if (!fs.existsSync(this.themeDir)) return [] - const files = fs.readdirSync(this.themeDir) - return files - .filter((f) => f.endsWith('.json')) - .map((f) => { - try { - const content = fs.readFileSync(path.join(this.themeDir, f), 'utf-8') - return JSON.parse(content) as themeConfig - } catch { - return null - } - }) - .filter((t): t is themeConfig => t !== null) - } catch (e) { - this.mainCtx.logger.error('Failed to read themes', { - meta: e instanceof Error ? { message: e.message, stack: e.stack } : { e } - }) - return [] - } + return [...this.builtinThemes, ...this.customThemes] } private getThemeById(id: string): themeConfig | null { diff --git a/src/main/services/WindowManager.ts b/src/main/services/WindowManager.ts index 83ba1bb..f8de0a2 100644 --- a/src/main/services/WindowManager.ts +++ b/src/main/services/WindowManager.ts @@ -3,39 +3,6 @@ import { MainContext } from '../context' import { BrowserWindow, shell } from 'electron' import type { BrowserWindowConstructorOptions } from 'electron' -let micaElectron: typeof import('mica-electron') | null = null -let MicaBrowserWindow: any = null -let IS_WINDOWS_11 = false - -try { - if (process.platform === 'win32') { - const micaModule = require('mica-electron') - micaElectron = micaModule - MicaBrowserWindow = micaModule.MicaBrowserWindow - IS_WINDOWS_11 = micaModule.IS_WINDOWS_11 - } -} catch (error) { - console.warn('mica-electron not available:', error) -} - -interface MicaWindow extends BrowserWindow { - setMicaEffect(): void - setMicaTabbedEffect(): void - setMicaAcrylicEffect(): void - setAcrylic(): void - setBlur(): void - setTransparent(): void - setDarkTheme(): void - setLightTheme(): void - setAutoTheme(): void - setRoundedCorner(): void - setSmallRoundedCorner(): void - setSquareCorner(): void - setBorderColor(color: string | null): void - setCaptionColor(color: string | null): void - setTitleTextColor(color: string | null): void -} - export type windowOpenInput = { key: string title?: string @@ -97,9 +64,9 @@ export class WindowManager extends Service { height: 680, show: false, autoHideMenuBar: true, - frame: false, - transparent: true, - backgroundColor: '#00000000', + frame: true, + transparent: false, + backgroundColor: '#ffffff', icon: this.opts.icon, title: input.title, webPreferences: { @@ -109,13 +76,7 @@ export class WindowManager extends Service { ...input.options } - let win: BrowserWindow - if (MicaBrowserWindow) { - win = new MicaBrowserWindow(baseOptions) - this.applyMicaEffect(win) - } else { - win = new BrowserWindow(baseOptions) - } + const win = new BrowserWindow(baseOptions) const zoomSettings = this.mainCtx.settings const zoom = zoomSettings ? Number(zoomSettings.getValue('window_zoom')) || 1.0 : 1.0 @@ -146,18 +107,6 @@ export class WindowManager extends Service { win.webContents.send('window:maximized-changed', false) }) - win.on('blur', () => { - if (input.key === 'main') { - this.applyMicaEffect(win) - } - }) - - win.on('focus', () => { - if (input.key === 'main') { - this.applyMicaEffect(win) - } - }) - win.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } @@ -167,160 +116,6 @@ export class WindowManager extends Service { return win } - private applyMicaEffect(win: BrowserWindow) { - if (!micaElectron) return - const settings = this.mainCtx.settings - if (!settings) return - const micaWin = win as MicaWindow - const theme = settings.getValue('window_theme') - switch (theme) { - case 'dark': - micaWin.setDarkTheme() - break - case 'light': - micaWin.setLightTheme() - break - default: - micaWin.setAutoTheme() - } - - const effect = settings.getValue('window_effect') - switch (effect) { - case 'mica': - micaWin.setMicaEffect() - break - case 'tabbed': - micaWin.setMicaTabbedEffect() - break - case 'acrylic': - if (IS_WINDOWS_11) { - micaWin.setMicaAcrylicEffect() - } else { - micaWin.setAcrylic() - } - break - case 'blur': - if (!IS_WINDOWS_11) { - micaWin.setBlur() - } - break - case 'transparent': - if (!IS_WINDOWS_11) { - micaWin.setTransparent() - } - break - } - - const radius = settings.getValue('window_radius') - switch (radius) { - case 'small': - micaWin.setSmallRoundedCorner() - break - case 'square': - micaWin.setSquareCorner() - break - default: - micaWin.setRoundedCorner() - } - } - - public setMicaEffect( - win: BrowserWindow, - effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none' = 'mica' - ) { - if (!micaElectron) return - const micaWin = win as MicaWindow - - switch (effect) { - case 'mica': - micaWin.setMicaEffect() - break - case 'tabbed': - micaWin.setMicaTabbedEffect() - break - case 'acrylic': - if (IS_WINDOWS_11) { - micaWin.setMicaAcrylicEffect() - } else { - micaWin.setAcrylic() - } - break - case 'blur': - if (!IS_WINDOWS_11) { - micaWin.setBlur() - } - break - case 'transparent': - if (!IS_WINDOWS_11) { - micaWin.setTransparent() - } - break - case 'none': - if (IS_WINDOWS_11) { - micaWin.setMicaEffect() - } - break - } - } - - public setMicaTheme(win: BrowserWindow, theme: 'auto' | 'dark' | 'light' = 'auto') { - if (!micaElectron) return - const micaWin = win as MicaWindow - - switch (theme) { - case 'dark': - micaWin.setDarkTheme() - break - case 'light': - micaWin.setLightTheme() - break - default: - micaWin.setAutoTheme() - } - } - - public setMicaCorner(win: BrowserWindow, corner: 'rounded' | 'small' | 'square' = 'rounded') { - if (!micaElectron) return - const micaWin = win as MicaWindow - - switch (corner) { - case 'small': - micaWin.setSmallRoundedCorner() - break - case 'square': - micaWin.setSquareCorner() - break - default: - micaWin.setRoundedCorner() - } - } - - public setMicaBorderColor(win: BrowserWindow, color: string | null) { - if (!micaElectron) return - const micaWin = win as MicaWindow - micaWin.setBorderColor(color) - } - - public setMicaCaptionColor(win: BrowserWindow, color: string | null) { - if (!micaElectron) return - const micaWin = win as MicaWindow - micaWin.setCaptionColor(color) - } - - public setMicaTitleTextColor(win: BrowserWindow, color: string | null) { - if (!micaElectron) return - const micaWin = win as MicaWindow - micaWin.setTitleTextColor(color) - } - - public isMicaAvailable(): boolean { - return micaElectron !== null - } - - public isWindows11(): boolean { - return IS_WINDOWS_11 - } - public navigate(key: string, route: string) { const win = this.get(key) if (!win) return false @@ -436,33 +231,5 @@ export class WindowManager extends Service { }) } }) - - this.mainCtx.handle('window:mica-effect', (_event, effect: string) => { - const win = BrowserWindow.fromWebContents(_event.sender) - if (win) { - this.setMicaEffect(win, effect as any) - } - }) - - this.mainCtx.handle('window:mica-theme', (_event, theme: string) => { - const win = BrowserWindow.fromWebContents(_event.sender) - if (win) { - this.setMicaTheme(win, theme as any) - } - }) - - this.mainCtx.handle('window:mica-corner', (_event, corner: string) => { - const win = BrowserWindow.fromWebContents(_event.sender) - if (win) { - this.setMicaCorner(win, corner as any) - } - }) - - this.mainCtx.handle('window:mica-info', () => { - return { - available: this.isMicaAvailable(), - isWindows11: this.isWindows11() - } - }) } } diff --git a/src/mobile/App.tsx b/src/mobile/App.tsx index 5cdd99d..a78025a 100644 --- a/src/mobile/App.tsx +++ b/src/mobile/App.tsx @@ -1,4 +1,4 @@ -import { ConfigProvider, theme as antTheme, Layout, message } from 'antd' +import { ConfigProvider, theme as antTheme, message } from 'antd' import { HashRouter, Routes, Route, Navigate } from 'react-router-dom' import { useState, useEffect, createContext, useContext } from 'react' import { MobileHome } from './pages/Home' @@ -45,7 +45,7 @@ export const useApi = () => { function App(): React.JSX.Element { const [isDark, setIsDark] = useState(false) const [apiConfig, setApiConfig] = useState({ baseUrl: '' }) - const [messageApi, messageHolder] = message.useMessage() + const [, messageHolder] = message.useMessage() useEffect(() => { const savedTheme = localStorage.getItem('theme') diff --git a/src/mobile/components/Layout.tsx b/src/mobile/components/Layout.tsx index b978bc1..7e0e13c 100644 --- a/src/mobile/components/Layout.tsx +++ b/src/mobile/components/Layout.tsx @@ -7,7 +7,6 @@ import { SettingOutlined } from '@ant-design/icons' import { useTheme } from '../App' -import { Switch } from 'antd' const { Content } = Layout @@ -21,7 +20,7 @@ const tabs = [ export function MobileLayout(): React.JSX.Element { const location = useLocation() const navigate = useNavigate() - const { isDark, toggleTheme } = useTheme() + const { isDark } = useTheme() return ( ipcRenderer.invoke('theme:set', themeId), saveTheme: (theme: themeConfig) => ipcRenderer.invoke('theme:save', theme), deleteTheme: (themeId: string) => ipcRenderer.invoke('theme:delete', themeId), - setCustomTheme: (config: { - effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none' - theme: 'auto' | 'dark' | 'light' - radius: 'small' | 'medium' | 'large' - }) => ipcRenderer.invoke('theme:set-custom', config), onThemeChanged: (callback: (theme: themeConfig) => void) => { const subscription = (_event: any, theme: themeConfig) => callback(theme) ipcRenderer.on('theme:updated', subscription) diff --git a/src/preload/types.ts b/src/preload/types.ts index 5011e43..d3887fc 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -11,11 +11,6 @@ export interface themeConfig { name: string id: string mode: 'light' | 'dark' - mica?: { - effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none' - theme: 'auto' | 'dark' | 'light' - radius: 'small' | 'medium' | 'large' - } config: { tdesign: Record custom: Record @@ -26,9 +21,7 @@ export type settingsSpec = { is_wizard_completed: boolean log_level: logLevel window_zoom: number - window_theme: 'auto' | 'dark' | 'light' - window_effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none' - window_radius: 'rounded' | 'small' | 'square' + themes_custom: themeConfig[] auto_score_enabled: boolean auto_score_rules: any[] current_theme_id: string @@ -63,11 +56,6 @@ export interface electronApi { setTheme: (themeId: string) => Promise> saveTheme: (theme: themeConfig) => Promise> deleteTheme: (themeId: string) => Promise> - setCustomTheme: (config: { - effect: 'mica' | 'tabbed' | 'acrylic' | 'blur' | 'transparent' | 'none' - theme: 'auto' | 'dark' | 'light' - radius: 'small' | 'medium' | 'large' - }) => Promise> onThemeChanged: (callback: (theme: themeConfig) => void) => () => void // DB - Student diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 58b9b00..6248069 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,8 +5,6 @@ import { Sidebar } from './components/Sidebar' import { ContentArea } from './components/ContentArea' import { Wizard } from './components/Wizard' import { ThemeProvider, useTheme } from './contexts/ThemeContext' -import { ThemeEditorProvider } from './contexts/ThemeEditorContext' -import { ThemeEditor } from './components/ThemeEditor' function MainContent(): React.JSX.Element { const navigate = useNavigate() @@ -110,13 +108,14 @@ function MainContent(): React.JSX.Element { } const isDark = currentTheme?.mode === 'dark' + const brandColor = currentTheme?.config?.tdesign?.brandColor || '#0052D9' return ( @@ -222,14 +221,11 @@ function getPlatform(): string { function App(): React.JSX.Element { return ( - - - - } /> - - - - + + + } /> + + ) } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9aacf2a..00df8b2 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -8,6 +8,7 @@ body, padding: 0; overflow: hidden; user-select: none; + background: var(--ss-bg-color, #f5f5f5); } input, @@ -29,11 +30,10 @@ textarea { .ss-sidebar .ant-menu-item-selected { background-color: var(--ss-sidebar-active-bg, var(--ss-item-hover, transparent)) !important; - color: var(--ss-sidebar-active-text, var(--ss-sidebar-text, var(--ss-text-main))); -} - -html[theme-mode='dark'] .ss-sidebar .ant-menu-item-selected { - color: #ffffff !important; + color: var( + --ss-sidebar-active-text, + var(--ant-color-primary, var(--ss-sidebar-text, var(--ss-text-main))) + ); } @media (pointer: coarse) { diff --git a/src/renderer/src/components/ContentArea.tsx b/src/renderer/src/components/ContentArea.tsx index 1b1a482..06bf5ec 100644 --- a/src/renderer/src/components/ContentArea.tsx +++ b/src/renderer/src/components/ContentArea.tsx @@ -1,8 +1,6 @@ import React, { Suspense, lazy } from 'react' -import { Layout, Space, Button, Tag, Spin } from 'antd' +import { Layout, Space, Button, Tag } from 'antd' import { Routes, Route, Navigate } from 'react-router-dom' -import { WindowControls } from './WindowControls' -import { ThemeEditor } from './ThemeEditor' const Home = lazy(() => import('./Home').then((m) => ({ default: m.Home }))) const StudentManager = lazy(() => @@ -51,17 +49,16 @@ export function ContentArea({ display: 'flex', flexDirection: 'column', overflow: 'hidden', - backgroundColor: 'var(--ss-bg-color)' + background: 'var(--ss-bg-color)' }} >
- + {permissionTag} {hasAnyPassword && ( <> @@ -91,7 +87,6 @@ export function ContentArea({ )} -
@@ -106,7 +101,16 @@ export function ContentArea({ height: '100%' }} > - +
} > @@ -129,7 +133,6 @@ export function ContentArea({ -
) } diff --git a/src/renderer/src/components/Home.tsx b/src/renderer/src/components/Home.tsx index a48b177..c85b6d1 100644 --- a/src/renderer/src/components/Home.tsx +++ b/src/renderer/src/components/Home.tsx @@ -786,7 +786,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
0 ? 'var(--ant-color-success-bg, #f6ffed)' : customScore < 0 diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index e1f56fe..4b35bff 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Tabs, Card, Form, Select, Input, Button, Space, Divider, Tag, Modal, message } from 'antd' -import { useTheme } from '../contexts/ThemeContext' -import { useThemeEditor } from '../contexts/ThemeEditorContext' +import { ThemeQuickSettings } from './ThemeQuickSettings' type permissionLevel = 'admin' | 'points' | 'view' type appSettings = { @@ -12,8 +11,6 @@ type appSettings = { } export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => { - const { themes, currentTheme, setTheme } = useTheme() - const { startEditing } = useThemeEditor() const [activeTab, setActiveTab] = useState('appearance') const [settings, setSettings] = useState({ is_wizard_completed: false, @@ -313,22 +310,11 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission label: '外观', children: ( -
- -
- = ({ permission >
secscore://settings
secscore://score
-
secscore://sidebar/toggle
@@ -767,7 +752,14 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission ] return ( -
+
{contextHolder}
= ({ permission marginBottom: '16px' }} > -

系统设置

+

系统设置

{permissionTag}
@@ -871,7 +863,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
将把当前未结算的积分记录归档为一个阶段,并将所有学生当前积分清零。
- 学生名单不变;结算后的历史在"结算历史"页面查看。 + 学生名单不变;结算后的历史在“结算历史”页面查看。
diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index e30e9e8..4ad0933 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -158,7 +158,7 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps): className="ss-sidebar" width={200} style={{ - backgroundColor: 'var(--ss-sidebar-bg)', + background: 'var(--ss-sidebar-bg)', borderRight: '1px solid var(--ss-border-color)', display: 'flex', flexDirection: 'column' diff --git a/src/renderer/src/components/ThemeEditor.tsx b/src/renderer/src/components/ThemeEditor.tsx index cb2f83c..4a7e23a 100644 --- a/src/renderer/src/components/ThemeEditor.tsx +++ b/src/renderer/src/components/ThemeEditor.tsx @@ -178,7 +178,7 @@ export const ThemeEditor: React.FC = () => {
{group.items.map((item) => ( - +
{ > {item.label}
- - updateConfig('custom', item.key, color.toHexString()) - } - showText - /> + {groupKey === 'background' ? ( +
+
+ updateConfig('custom', item.key, e.target.value)} + placeholder="例如 #ffffff 或 linear-gradient(...)" + /> +
+ ) : ( + + updateConfig('custom', item.key, color.toHexString()) + } + showText + /> + )}
))} diff --git a/src/renderer/src/components/ThemeQuickSettings.tsx b/src/renderer/src/components/ThemeQuickSettings.tsx new file mode 100644 index 0000000..b785d06 --- /dev/null +++ b/src/renderer/src/components/ThemeQuickSettings.tsx @@ -0,0 +1,302 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Button, ColorPicker, Input, Segmented, Space, Typography, message } from 'antd' +import type { Color } from 'antd/es/color-picker' +import { useTheme } from '../contexts/ThemeContext' +import type { themeConfig } from '../../../preload/types' + +type props = { + compact?: boolean +} + +const presetPrimaryColors = [ + '#1677FF', + '#2F54EB', + '#722ED1', + '#EB2F96', + '#F5222D', + '#FA8C16', + '#FADB14', + '#52C41A', + '#13C2C2' +] + +const presetGradients: { + id: string + label: string + light: string + dark: string +}[] = [ + { + id: 'blue', + label: '清爽蓝', + light: 'linear-gradient(180deg, #f7fbff 0%, #f1f7ff 55%, #f8f9fc 100%)', + dark: 'linear-gradient(180deg, #0f1220 0%, #101524 55%, #0b0d16 100%)' + }, + { + id: 'pink', + label: '柔和粉', + light: 'linear-gradient(180deg, #fff7f1 0%, #fff1f1 55%, #f7f7fb 100%)', + dark: 'linear-gradient(180deg, #1a0f14 0%, #1d1218 55%, #120c10 100%)' + }, + { + id: 'cyan', + label: '青蓝', + light: 'linear-gradient(180deg, #f0f9ff 0%, #e0f2fe 55%, #f0f9ff 100%)', + dark: 'linear-gradient(180deg, #050b10 0%, #06121a 55%, #05070a 100%)' + }, + { + id: 'purple', + label: '紫韵', + light: 'linear-gradient(180deg, #faf5ff 0%, #f3e8ff 55%, #faf5ff 100%)', + dark: 'linear-gradient(180deg, #0f0a14 0%, #151020 55%, #0d0910 100%)' + } +] + +const deepClone = (v: T): T => JSON.parse(JSON.stringify(v)) as T + +const normalizeHex = (value: string): string | null => { + const s = String(value || '').trim() + if (!s) return null + const m = s.match(/^#?([0-9a-fA-F]{6})$/) + if (!m) return null + return `#${m[1].toUpperCase()}` +} + +const buildGradient = (a: string, b: string, dir: 'v' | 'h' | 'd'): string => { + const angle = dir === 'h' ? '90deg' : dir === 'd' ? '135deg' : '180deg' + return `linear-gradient(${angle}, ${a} 0%, ${b} 100%)` +} + +export const ThemeQuickSettings: React.FC = ({ compact }) => { + const { currentTheme, setTheme, themes, applyTheme } = useTheme() + const [messageApi, holder] = message.useMessage() + + const [workingTheme, setWorkingTheme] = useState(null) + const [saving, setSaving] = useState(false) + + const [primaryInput, setPrimaryInput] = useState('') + const primaryColor = useMemo(() => { + const fromTheme = workingTheme?.config?.tdesign?.brandColor + return normalizeHex(primaryInput) || normalizeHex(fromTheme || '') || '#1677FF' + }, [primaryInput, workingTheme]) + + const [gradientDir, setGradientDir] = useState<'v' | 'h' | 'd'>('v') + const [g1, setG1] = useState('#f7fbff') + const [g2, setG2] = useState('#f8f9fc') + + const currentBg = workingTheme?.config?.custom?.['--ss-bg-color'] || '' + + useEffect(() => { + if (!currentTheme) return + const base = deepClone(currentTheme) + const editable = + base.id === 'custom-default' || base.id.startsWith('custom-') + ? base + : { ...base, id: 'custom-default', name: '我的主题' } + setWorkingTheme(editable) + setPrimaryInput(editable.config?.tdesign?.brandColor || '') + }, [currentTheme]) + + // 实时预览主题变化 + useEffect(() => { + if (!workingTheme) return + applyTheme(workingTheme) + }, [workingTheme, applyTheme]) + + const ensureCustomThemeSelected = async (theme: themeConfig): Promise => { + if (!(window as any).api) return false + const exists = themes.some((t) => t.id === theme.id) + if (!exists) { + const res = await (window as any).api.saveTheme(theme) + if (!res?.success) return false + } + if (currentTheme?.id !== theme.id) { + await setTheme(theme.id) + } + return true + } + + const patchThemeConfig = (type: 'tdesign' | 'custom', key: string, value: string) => { + setWorkingTheme((prev) => { + if (!prev) return prev + const next = deepClone(prev) + next.config = next.config || ({} as any) + next.config[type] = { ...(next.config[type] || {}), [key]: value } + return next + }) + } + + const save = async () => { + if (!(window as any).api) return + if (!workingTheme) return + setSaving(true) + try { + const ok = await ensureCustomThemeSelected(workingTheme) + if (!ok) throw new Error('保存失败') + const res = await (window as any).api.saveTheme(workingTheme) + if (!res?.success) throw new Error(res?.message || '保存失败') + messageApi.success('已保存') + } catch (e: any) { + messageApi.error(e?.message || '保存失败') + } finally { + setSaving(false) + } + } + + const setPrimary = (hex: string) => { + setPrimaryInput(hex) + patchThemeConfig('tdesign', 'brandColor', hex) + } + + const setGradientPreset = (value: string) => { + patchThemeConfig('custom', '--ss-bg-color', value) + } + + const setGradientFromPickers = () => { + const g = buildGradient(g1, g2, gradientDir) + patchThemeConfig('custom', '--ss-bg-color', g) + } + + const setMode = async (mode: 'light' | 'dark') => { + // 根据模式切换到对应的内置主题 + const targetThemeId = mode === 'light' ? 'light-default' : 'dark-default' + await setTheme(targetThemeId) + } + + if (!workingTheme) return null + + return ( +
+ {holder} + +
+ 颜色与背景 + + + +
+ +
+ 模式 + setMode(v as any)} + options={[ + { label: '浅色', value: 'light' }, + { label: '深色', value: 'dark' } + ]} + /> +
+ +
+ 主色 +
+ setPrimaryInput(e.target.value)} + onBlur={() => { + const n = normalizeHex(primaryInput) + if (n) setPrimary(n) + else setPrimary(primaryColor) + }} + style={{ width: 120 }} + /> + {presetPrimaryColors.map((c) => ( +
+
+ +
+ 背景渐变 +
+ {presetGradients.map((g) => { + const gradientValue = g[workingTheme.mode] + const active = currentBg === gradientValue + return ( + + ) + })} +
+ +
+ setGradientDir(v as any)} + options={[ + { label: '纵向', value: 'v' }, + { label: '横向', value: 'h' }, + { label: '对角', value: 'd' } + ]} + /> + + setG1(c.toHexString())} /> + setG2(c.toHexString())} /> + + +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/Wizard.tsx b/src/renderer/src/components/Wizard.tsx index af2947d..2a207a4 100644 --- a/src/renderer/src/components/Wizard.tsx +++ b/src/renderer/src/components/Wizard.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react' -import { Modal, Form, Select, message, Typography } from 'antd' -import { useTheme } from '../contexts/ThemeContext' +import { Modal, message, Typography } from 'antd' +import { ThemeQuickSettings } from './ThemeQuickSettings' interface wizardProps { visible: boolean @@ -8,7 +8,6 @@ interface wizardProps { } export const Wizard: React.FC = ({ visible, onComplete }) => { - const { themes, currentTheme, setTheme } = useTheme() const [loading, setLoading] = useState(false) const [messageApi, contextHolder] = message.useMessage() @@ -47,15 +46,7 @@ export const Wizard: React.FC = ({ visible, onComplete }) => { 感谢选择 SecScore。在开始之前,请花一分钟完成基础配置。 - - -