diff --git a/README.md b/README.md index 0bac02c..5a7ec28 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,23 @@ # SecScore -一款本地离线(未来加入远程模式)的“教育积分管理”桌面软件(Electron + React + TypeScript),用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供基础的权限保护与数据备份。 + +

+ GitHub release (latest by date) + CI + Downloads + License + GitHub issues + GitHub pull requests + Electron + React + TypeScript + Vite +

+ +SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护与数据备份。 + + +SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScript 开发,用于管理学生名单、记录加/扣分、查看排行榜与结算历史,并提供权限保护与数据备份。 ## 主要功能 diff --git a/package.json b/package.json index 5bfadff..f678115 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "secscore", - "version": "1.0.0", - "description": "An Electron application with React and TypeScript", + "version": "1.0.1", + "description": "SecScore – Your Education Points Management Expert", "main": "./out/main/index.js", "author": "example.com", - "homepage": "https://electron-vite.org", + "homepage": "https://example.org", "scripts": { "format": "prettier --write .", "lint": "eslint --cache .", + "lint:fix": "eslint --cache . --fix", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "pnpm -s typecheck:node && pnpm -s typecheck:web", @@ -25,6 +26,7 @@ "@electron-toolkit/utils": "^4.0.0", "better-sqlite3": "^12.6.0", "chokidar": "^5.0.0", + "mica-electron": "^1.5.16", "pinyin-pro": "^3.27.0", "react-router-dom": "^6.28.0", "reflect-metadata": "^0.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 658f469..d4329d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: chokidar: specifier: ^5.0.0 version: 5.0.0 + mica-electron: + specifier: ^1.5.16 + version: 1.5.16 pinyin-pro: specifier: ^3.27.0 version: 3.27.0 @@ -753,66 +756,79 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -2199,6 +2215,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mica-electron@1.5.16: + resolution: {integrity: sha512-08/o9vv4cr4ozltO2U4/37R70Y6hqAjH2nr7GzpDLAdqCWkeAAge5ZPrzl+ndUDhO6IsoL2gFmpbU17Jj406Zg==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -5568,6 +5587,8 @@ snapshots: math-intrinsics@1.1.0: {} + mica-electron@1.5.16: {} + mime-db@1.52.0: {} mime-types@2.1.35: diff --git a/scripts/ci/parse-commit.mjs b/scripts/ci/parse-commit.mjs index e734af5..cfacb09 100644 --- a/scripts/ci/parse-commit.mjs +++ b/scripts/ci/parse-commit.mjs @@ -13,19 +13,27 @@ const rawMessage = '' const message = String(rawMessage || '').trim() +// 是否以 release: 开头 +const isReleasePrefix = /^release:/i.test(message) + const semverRe = /\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/ const versionMatch = message.match(semverRe) const version = versionMatch ? versionMatch[1] : '' const buildToken = message.match(/\b(build:(?:win|mac|linux|unpack|all))\b/i)?.[1]?.toLowerCase() ?? '' -const buildScript = +let buildScript = buildToken === 'build:all' ? 'build:all' : buildToken && buildToken.startsWith('build:') ? buildToken : '' +// 如果是 release: 开头且没有显式指定构建脚本,默认为 build:all +if (isReleasePrefix && !buildScript) { + buildScript = 'build:all' +} + const supportedBuildScripts = new Set(['build:win', 'build:mac', 'build:linux', 'build:unpack']) const hasBuildScript = buildScript ? buildScript === 'build:all' @@ -38,7 +46,7 @@ const fail = (text) => { process.exit(1) } -const hasAnySignal = Boolean(version || buildScript) +const hasAnySignal = Boolean(isReleasePrefix || version || buildScript) const shouldSkip = !hasAnySignal if (!shouldSkip) { @@ -46,7 +54,7 @@ if (!shouldSkip) { fail( [ '未在提交信息中检测到版本号。', - '示例:release: v1.2.3 build:win', + '示例:release: v1.2.3 build:win 或 release: 1.2.3', '支持格式:v1.2.3 或 1.2.3(可带 -beta.1 等后缀)' ].join('\n') ) diff --git a/scripts/clean-db.mjs b/scripts/clean-db.mjs index 8ce8e19..53f062a 100644 --- a/scripts/clean-db.mjs +++ b/scripts/clean-db.mjs @@ -12,6 +12,10 @@ for (const name of targets) { console.log(`[clean-db] removed ${filePath}`) } } catch (e) { - console.error(`[clean-db] failed to remove ${filePath}:`, e?.message || e) + if (e.code === 'EPERM' || e.code === 'EACCES') { + console.warn(`[clean-db] skipped ${filePath}: file in use or permission denied`) + } else { + console.error(`[clean-db] failed to remove ${filePath}:`, e?.message || e) + } } } diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts index 9c5a6b9..0ee83ff 100644 --- a/src/main/services/SettingsService.ts +++ b/src/main/services/SettingsService.ts @@ -1,6 +1,6 @@ import { Service } from '../../shared/kernel' import { MainContext } from '../context' -import { BrowserWindow, webContents } from 'electron' +import { BrowserWindow, webContents, screen } from 'electron' import type { IpcMainInvokeEvent } from 'electron' import type { settingsKey, settingsSpec, settingChange } from '../../preload/types' import type { permissionLevel } from './PermissionService' @@ -58,7 +58,47 @@ export class SettingsService extends Service { webContents.getAllWebContents().forEach((wc: any) => { wc.setZoomFactor(zoom) }) + + const sidebarWindow = BrowserWindow.getAllWindows().find((win) => { + try { + return win.webContents.getURL().includes('#global-sidebar') + } catch { + return false + } + }) + + if (sidebarWindow && !sidebarWindow.isDestroyed()) { + const primaryDisplay = screen.getPrimaryDisplay() + const { width, height } = primaryDisplay.workAreaSize + const bounds = sidebarWindow.getBounds() + + sidebarWindow.setBounds({ + x: width - bounds.width, + y: Math.floor(height / 2 - bounds.height / 2), + width: bounds.width, + height: bounds.height + }) + } } + }, + 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' } } diff --git a/src/main/services/ThemeService.ts b/src/main/services/ThemeService.ts index 4892ad1..0493893 100644 --- a/src/main/services/ThemeService.ts +++ b/src/main/services/ThemeService.ts @@ -9,6 +9,11 @@ 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 @@ -78,9 +83,99 @@ export class ThemeService extends Service { return { success: false, message: 'Permission denied' } } this.currentThemeId = themeId + this.applyMicaEffect(themeId) this.notifyThemeUpdate() return { success: true } }) + + this.mainCtx.handle('theme:save', async (event, theme: themeConfig) => { + if (!this.mainCtx.permissions.requirePermission(event, 'admin')) + 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') + this.notifyThemeUpdate() + return { success: true } + } catch (e) { + return { success: false, message: String(e) } + } + }) + + this.mainCtx.handle('theme:delete', async (event, themeId: string) => { + 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) + } + if (this.currentThemeId === themeId) { + this.currentThemeId = 'light' // Fallback + } + this.notifyThemeUpdate() + return { success: true } + } catch (e) { + 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' + 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[] { diff --git a/src/main/services/WindowManager.ts b/src/main/services/WindowManager.ts index bb4ffeb..8e68065 100644 --- a/src/main/services/WindowManager.ts +++ b/src/main/services/WindowManager.ts @@ -3,6 +3,39 @@ import { MainContext } from '../context' import { BrowserWindow, shell, screen } 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 @@ -52,7 +85,6 @@ export class WindowManager extends Service { const existing = this.get(input.key) if (existing) { if (input.route) { - // Use soft navigation if window exists to prevent reload existing.webContents.send('app:navigate', input.route) } existing.show() @@ -60,12 +92,14 @@ export class WindowManager extends Service { return existing } - const win = new BrowserWindow({ + const baseOptions: BrowserWindowConstructorOptions = { width: 900, height: 670, show: false, autoHideMenuBar: true, - frame: false, // Custom title bar + frame: false, + transparent: true, + backgroundColor: '#00000000', icon: this.opts.icon, title: input.title, webPreferences: { @@ -73,7 +107,15 @@ export class WindowManager extends Service { sandbox: false }, ...input.options - }) + } + + let win: BrowserWindow + if (MicaBrowserWindow) { + win = new MicaBrowserWindow(baseOptions) + this.applyMicaEffect(win) + } else { + win = new BrowserWindow(baseOptions) + } // Special positioning for global sidebar if (input.key === 'global-sidebar') { @@ -121,6 +163,18 @@ export class WindowManager extends Service { win.webContents.send('window:maximized-changed', false) }) + win.on('blur', () => { + if (input.key === 'global-sidebar' || input.key === 'main') { + this.applyMicaEffect(win) + } + }) + + win.on('focus', () => { + if (input.key === 'global-sidebar' || input.key === 'main') { + this.applyMicaEffect(win) + } + }) + win.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } @@ -130,6 +184,159 @@ export class WindowManager extends Service { return win } + private applyMicaEffect(win: BrowserWindow) { + if (!micaElectron) return + const micaWin = win as MicaWindow + + const theme = this.mainCtx.settings.getValue('window_theme') + switch (theme) { + case 'dark': + micaWin.setDarkTheme() + break + case 'light': + micaWin.setLightTheme() + break + default: + micaWin.setAutoTheme() + } + + const effect = this.mainCtx.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 = this.mainCtx.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 @@ -236,7 +443,6 @@ export class WindowManager extends Service { const win = BrowserWindow.fromWebContents(event.sender) if (win) { const bounds = win.getBounds() - // Keep right side pinned const newX = bounds.x + (bounds.width - width) win.setBounds({ x: newX, @@ -246,5 +452,33 @@ 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/preload/index.ts b/src/preload/index.ts index ca64a29..d169f51 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,6 +7,13 @@ const api = { getThemes: () => ipcRenderer.invoke('theme:list'), getCurrentTheme: () => ipcRenderer.invoke('theme:current'), setTheme: (themeId: string) => 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) @@ -86,7 +93,8 @@ const api = { return () => ipcRenderer.removeListener('app:navigate', subscription) }, toggleDevTools: () => ipcRenderer.invoke('window:toggle-devtools'), - windowResize: (width: number, height: number) => ipcRenderer.invoke('window:resize', width, height), + windowResize: (width: number, height: number) => + ipcRenderer.invoke('window:resize', width, height), // Logger queryLogs: (lines?: number) => ipcRenderer.invoke('log:query', lines), diff --git a/src/preload/types.ts b/src/preload/types.ts index 485a7f9..a456e31 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -11,6 +11,11 @@ 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 @@ -21,6 +26,9 @@ 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' } export type settingsKey = keyof settingsSpec @@ -35,6 +43,13 @@ export interface electronApi { getThemes: () => Promise> getCurrentTheme: () => Promise> 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 788a019..e6a860e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,7 +5,9 @@ import { Sidebar } from './components/Sidebar' import { ContentArea } from './components/ContentArea' import { Wizard } from './components/Wizard' import { ThemeProvider } from './contexts/ThemeContext' +import { ThemeEditorProvider } from './contexts/ThemeEditorContext' import { GlobalSidebar } from './components/GlobalSidebar' +import { ThemeEditor } from './components/ThemeEditor' function MainContent(): React.JSX.Element { const navigate = useNavigate() @@ -17,7 +19,7 @@ function MainContent(): React.JSX.Element { // 统一路径格式进行比对,防止 / 和 /home 导致重复跳转 const currentPath = location.pathname === '/' ? '/home' : location.pathname const targetPath = route === '/' ? '/home' : route - + if (currentPath !== targetPath) { navigate(route) } @@ -143,12 +145,15 @@ function MainContent(): React.JSX.Element { function App(): React.JSX.Element { return ( - - - } /> - } /> - - + + + + } /> + } /> + + + + ) } diff --git a/src/renderer/src/assets/logoHD-dark.svg b/src/renderer/src/assets/logoHD-dark.svg new file mode 100644 index 0000000..b190979 --- /dev/null +++ b/src/renderer/src/assets/logoHD-dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/renderer/src/assets/logoHD.svg b/src/renderer/src/assets/logoHD.svg new file mode 100644 index 0000000..51ff5ea --- /dev/null +++ b/src/renderer/src/assets/logoHD.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 37f2d7e..4ac71f1 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1,6 +1,15 @@ @import 'tdesign-react/dist/reset.css'; @import 'tdesign-react/dist/tdesign.css'; +/* 没用过tdesign,有ai或者人类会用顺便帮我改一下 */ +.t-layout { + background-color: var(--ss-bg-color) !important; +} +.t-default-menu{ + background-color: transparent !important; +} +/* 没用过tdesign,有ai或者人类会用顺便帮我改一下 */ + html, body, #root { @@ -99,10 +108,10 @@ html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg:not([fill='non /* Global Sidebar Toggle Button */ .global-sidebar-toggle { - width: 24px; - height: 60px; - background-color: var(--ss-card-bg); - border-radius: 8px 0 0 8px; + width: 100vw; + height: 100vh; + background-color: var(--ss-bg-color); + border-radius: 0px 0 0 0px; display: flex; align-items: center; justify-content: center; @@ -126,7 +135,7 @@ html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg:not([fill='non .sidebar-content-area { display: flex; flex-direction: column; - backgroundColor: var(--ss-card-bg); + background-color: var(--ss-card-bg); border-radius: 12px 0 0 12px; box-shadow: -4px 0 16px rgba(0,0,0,0.15); border: 1px solid var(--ss-border-color); diff --git a/src/renderer/src/components/ContentArea.tsx b/src/renderer/src/components/ContentArea.tsx index a811a6d..a049d52 100644 --- a/src/renderer/src/components/ContentArea.tsx +++ b/src/renderer/src/components/ContentArea.tsx @@ -2,14 +2,21 @@ import React, { Suspense, lazy } from 'react' import { Layout, Space, Button, Tag, Loading } from 'tdesign-react' 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(() => import('./StudentManager').then(m => ({ default: m.StudentManager }))) -const Settings = lazy(() => import('./Settings').then(m => ({ default: m.Settings }))) -const ReasonManager = lazy(() => import('./ReasonManager').then(m => ({ default: m.ReasonManager }))) -const ScoreManager = lazy(() => import('./ScoreManager').then(m => ({ default: m.ScoreManager }))) -const Leaderboard = lazy(() => import('./Leaderboard').then(m => ({ default: m.Leaderboard }))) -const SettlementHistory = lazy(() => import('./SettlementHistory').then(m => ({ default: m.SettlementHistory }))) +const Home = lazy(() => import('./Home').then((m) => ({ default: m.Home }))) +const StudentManager = lazy(() => + import('./StudentManager').then((m) => ({ default: m.StudentManager })) +) +const Settings = lazy(() => import('./Settings').then((m) => ({ default: m.Settings }))) +const ReasonManager = lazy(() => + import('./ReasonManager').then((m) => ({ default: m.ReasonManager })) +) +const ScoreManager = lazy(() => import('./ScoreManager').then((m) => ({ default: m.ScoreManager }))) +const Leaderboard = lazy(() => import('./Leaderboard').then((m) => ({ default: m.Leaderboard }))) +const SettlementHistory = lazy(() => + import('./SettlementHistory').then((m) => ({ default: m.SettlementHistory })) +) const { Content } = Layout @@ -119,6 +126,7 @@ export function ContentArea({ + ) } diff --git a/src/renderer/src/components/EventHistory.tsx b/src/renderer/src/components/EventHistory.tsx index f0aafec..bc604ec 100644 --- a/src/renderer/src/components/EventHistory.tsx +++ b/src/renderer/src/components/EventHistory.tsx @@ -63,6 +63,8 @@ export const EventHistory: React.FC = () => { loading={loading} bordered hover + pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }} + scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }} style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }} /> diff --git a/src/renderer/src/components/GlobalSidebar.tsx b/src/renderer/src/components/GlobalSidebar.tsx index 27b9fc1..c8fa6d1 100644 --- a/src/renderer/src/components/GlobalSidebar.tsx +++ b/src/renderer/src/components/GlobalSidebar.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { Button, Tooltip } from 'tdesign-react' import { HomeIcon, @@ -6,12 +6,50 @@ import { UserAddIcon, ChevronRightIcon, ChevronLeftIcon, - SettingIcon + SettingIcon, + CodeIcon } from 'tdesign-icons-react' export const GlobalSidebar: React.FC = () => { const [expanded, setExpanded] = useState(false) const [showToggle, setShowToggle] = useState(true) + const [zoom, setZoom] = useState(1.0) + + useEffect(() => { + if (!(window as any).api) return + + // 加载初始缩放值 + const loadZoom = async () => { + const res = await (window as any).api.getSetting('window_zoom') + if (res.success && res.data) { + setZoom(res.data) + } + } + loadZoom() + + // 监听缩放变化 + const unsubscribe = (window as any).api.onSettingChanged((change: any) => { + if (change?.key === 'window_zoom') { + setZoom(change.value) + // 缩放变化时,重新应用当前展开/收缩状态的窗口大小 + if ((window as any).api) { + if (expanded) { + const width = Math.round(84 * change.value) + const height = Math.round(300 * change.value) + ;(window as any).api.windowResize(width, height) + } else { + const width = Math.round(24 * change.value) + const height = Math.round(300 * change.value) + ;(window as any).api.windowResize(width, height) + } + } + } + }) + + return () => { + if (typeof unsubscribe === 'function') unsubscribe() + } + }, [expanded]) const handleExpand = () => { // 1. 先隐藏三角 @@ -20,7 +58,9 @@ export const GlobalSidebar: React.FC = () => { // 2. 稍后扩大窗口 setTimeout(() => { if ((window as any).api) { - ;(window as any).api.windowResize(84, 300) + const width = Math.round(60 * zoom) + const height = Math.round(300 * zoom) + ;(window as any).api.windowResize(width, height) } // 3. 最后显示侧边栏内容 setTimeout(() => { @@ -36,7 +76,9 @@ export const GlobalSidebar: React.FC = () => { // 2. 稍后缩小窗口 setTimeout(() => { if ((window as any).api) { - ;(window as any).api.windowResize(24, 300) + const width = Math.round(24 * zoom) + const height = Math.round(58 * zoom) + ;(window as any).api.windowResize(width, height) } // 3. 最后重新显示三角(等待透明度动画完成) setTimeout(() => { @@ -54,10 +96,10 @@ export const GlobalSidebar: React.FC = () => {
{ @@ -75,9 +122,11 @@ export const GlobalSidebar: React.FC = () => {
{/* 顶部的关闭/收起按钮 */} @@ -90,19 +139,19 @@ export const GlobalSidebar: React.FC = () => { - + - + - + - + + + {import.meta.env.DEV && ( + + + + )}
) diff --git a/src/renderer/src/components/Home.tsx b/src/renderer/src/components/Home.tsx index d274829..25311f6 100644 --- a/src/renderer/src/components/Home.tsx +++ b/src/renderer/src/components/Home.tsx @@ -79,7 +79,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { ]) if (stuRes.success) { - const enrichedStudents = (stuRes.data as student[]).map(s => ({ + const enrichedStudents = (stuRes.data as student[]).map((s) => ({ ...s, pinyinName: pinyin(s.name, { toneType: 'none' }).toLowerCase(), pinyinFirst: getFirstLetter(s.name) @@ -120,7 +120,11 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { if (pyLower.includes(q0)) return true const q1 = q0.replace(/\s+/g, '') - if (q1 && (nameLower.replace(/\s+/g, '').includes(q1) || pyLower.replace(/\s+/g, '').includes(q1))) return true + if ( + q1 && + (nameLower.replace(/\s+/g, '').includes(q1) || pyLower.replace(/\s+/g, '').includes(q1)) + ) + return true try { const m0 = match(s.name, q0) @@ -167,7 +171,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const groups: Record = {} sortedStudents.forEach((s) => { - const key = sortType === 'alphabet' ? (s.pinyinFirst || '#') : getSurname(s.name) + const key = sortType === 'alphabet' ? s.pinyinFirst || '#' : getSurname(s.name) if (!groups[key]) groups[key] = [] groups[key].push(s) }) @@ -312,7 +316,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { border: '1px solid var(--ss-border-color)', overflow: 'visible' }} - hover > {rankBadge && (
= ({ canEdit }) => {
(groupRefs.current[group.key] = el)} + ref={(el) => { + groupRefs.current[group.key] = el + }} > {group.key !== 'all' && (
= ({ canEdit }) => { fontWeight: 'bold', color: 'var(--ss-text-main)', marginBottom: '16px', - paddingLeft: '4px', display: 'flex', alignItems: 'center', gap: '8px', @@ -889,4 +893,4 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
) -} \ No newline at end of file +} diff --git a/src/renderer/src/components/Leaderboard.tsx b/src/renderer/src/components/Leaderboard.tsx index 57cd4e5..b3c29bd 100644 --- a/src/renderer/src/components/Leaderboard.tsx +++ b/src/renderer/src/components/Leaderboard.tsx @@ -32,12 +32,17 @@ export const Leaderboard: React.FC = () => { const fetchRankings = useCallback(async () => { if (!(window as any).api) return setLoading(true) - const res = await (window as any).api.queryLeaderboard({ range: timeRange }) - if (res.success && res.data) { - setStartTime(res.data.startTime) - setData(res.data.rows) + try { + const res = await (window as any).api.queryLeaderboard({ range: timeRange }) + if (res.success && res.data) { + setStartTime(res.data.startTime) + setData(res.data.rows) + } + } catch (e) { + console.error('Failed to fetch rankings:', e) + } finally { + setLoading(false) } - setLoading(false) }, [timeRange]) useEffect(() => { @@ -77,48 +82,51 @@ export const Leaderboard: React.FC = () => { } const handleExport = () => { - const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月' + // 使用 requestIdleCallback 或 setTimeout 避免阻塞 UI + setTimeout(() => { + const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月' - const sanitizeCell = (v: unknown) => { - if (typeof v !== 'string') return v - if (/^[=+\-@]/.test(v)) return `'${v}` - return v - } + const sanitizeCell = (v: unknown) => { + if (typeof v !== 'string') return v + if (/^[=+\-@]/.test(v)) return `'${v}` + return v + } - const sheetData = [ - ['排名', '姓名', '总积分', `${title}变化`], - ...data.map((item, index) => [ - index + 1, - sanitizeCell(item.name), - item.score, - item.range_change - ]) - ] + const sheetData = [ + ['排名', '姓名', '总积分', `${title}变化`], + ...data.map((item, index) => [ + index + 1, + sanitizeCell(item.name), + item.score, + item.range_change + ]) + ] - const ws = XLSX.utils.aoa_to_sheet(sheetData) - ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }] + const ws = XLSX.utils.aoa_to_sheet(sheetData) + ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }] - const wb = XLSX.utils.book_new() - XLSX.utils.book_append_sheet(wb, ws, '排行榜') + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, '排行榜') - const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) - const blob = new Blob([xlsxBytes], { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - }) + const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) + const blob = new Blob([xlsxBytes], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) - const link = document.createElement('a') - const url = URL.createObjectURL(blob) - link.setAttribute('href', url) - link.setAttribute( - 'download', - `排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx` - ) - link.style.visibility = 'hidden' - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(url) - MessagePlugin.success('导出成功') + const link = document.createElement('a') + const url = URL.createObjectURL(blob) + link.setAttribute('href', url) + link.setAttribute( + 'download', + `排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx` + ) + link.style.visibility = 'hidden' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + MessagePlugin.success('导出成功') + }, 0) } const columns: PrimaryTableCol[] = [ @@ -215,6 +223,8 @@ export const Leaderboard: React.FC = () => { loading={loading} bordered hover + pagination={{ pageSize: 30, total: data.length, defaultCurrent: 1 }} + scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }} className="ss-table-center" style={{ color: 'var(--ss-text-main)' }} /> diff --git a/src/renderer/src/components/ReasonManager.tsx b/src/renderer/src/components/ReasonManager.tsx index 552e097..e951db8 100644 --- a/src/renderer/src/components/ReasonManager.tsx +++ b/src/renderer/src/components/ReasonManager.tsx @@ -35,11 +35,16 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const fetchReasons = useCallback(async () => { if (!(window as any).api) return setLoading(true) - const res = await (window as any).api.queryReasons() - if (res.success && res.data) { - setData(res.data) + try { + const res = await (window as any).api.queryReasons() + if (res.success && res.data) { + setData(res.data) + } + } catch (e) { + console.error('Failed to fetch reasons:', e) + } finally { + setLoading(false) } - setLoading(false) }, []) useEffect(() => { @@ -149,6 +154,8 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { loading={loading} bordered hover + pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }} + scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }} style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }} /> diff --git a/src/renderer/src/components/ScoreManager.tsx b/src/renderer/src/components/ScoreManager.tsx index fe3c44a..e1afd14 100644 --- a/src/renderer/src/components/ScoreManager.tsx +++ b/src/renderer/src/components/ScoreManager.tsx @@ -93,17 +93,25 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const fetchData = useCallback(async () => { if (!(window as any).api) return - setLoading(true) - const [stuRes, reaRes, eveRes] = await Promise.all([ - (window as any).api.queryStudents({}), - (window as any).api.queryReasons(), - (window as any).api.queryEvents({ limit: 10 }) - ]) + // 使用 setTimeout 避免 UI 阻塞 + setTimeout(async () => { + setLoading(true) + try { + const [stuRes, reaRes, eveRes] = await Promise.all([ + (window as any).api.queryStudents({}), + (window as any).api.queryReasons(), + (window as any).api.queryEvents({ limit: 10 }) + ]) - if (stuRes.success) setStudents(stuRes.data) - if (reaRes.success) setReasons(reaRes.data) - if (eveRes.success) setEvents(eveRes.data) - setLoading(false) + if (stuRes.success) setStudents(stuRes.data) + if (reaRes.success) setReasons(reaRes.data) + if (eveRes.success) setEvents(eveRes.data) + } catch (e) { + console.error('Failed to fetch data:', e) + } finally { + setLoading(false) + } + }, 0) }, []) useEffect(() => { @@ -328,7 +336,8 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { rowKey="uuid" loading={loading} size="small" - pagination={{ pageSize: 5, total: events.length }} + pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }} + scroll={{ type: 'virtual' }} style={{ color: 'var(--ss-text-main)' }} /> diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index a1ddd90..075bcbe 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -13,6 +13,7 @@ import { MessagePlugin } from 'tdesign-react' import { useTheme } from '../contexts/ThemeContext' +import { useThemeEditor } from '../contexts/ThemeEditorContext' type permissionLevel = 'admin' | 'points' | 'view' type appSettings = { @@ -23,6 +24,7 @@ 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, @@ -261,15 +263,27 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
- +
+ + + +
diff --git a/src/renderer/src/components/SettlementHistory.tsx b/src/renderer/src/components/SettlementHistory.tsx index c64a35e..5d6524f 100644 --- a/src/renderer/src/components/SettlementHistory.tsx +++ b/src/renderer/src/components/SettlementHistory.tsx @@ -36,13 +36,18 @@ export const SettlementHistory: React.FC = () => { const fetchSettlements = useCallback(async () => { if (!(window as any).api) return setLoading(true) - const res = await (window as any).api.querySettlements() - setLoading(false) - if (!res.success) { - MessagePlugin.error(res.message || '查询失败') - return + try { + const res = await (window as any).api.querySettlements() + if (!res.success) { + MessagePlugin.error(res.message || '查询失败') + return + } + setSettlements(res.data || []) + } catch (e) { + console.error('Failed to fetch settlements:', e) + } finally { + setLoading(false) } - setSettlements(res.data || []) }, []) useEffect(() => { @@ -62,14 +67,19 @@ export const SettlementHistory: React.FC = () => { if (!(window as any).api) return setSelectedId(id) setDetailLoading(true) - const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id }) - setDetailLoading(false) - if (!res.success || !res.data) { - MessagePlugin.error(res.message || '查询失败') - return + try { + const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id }) + if (!res.success || !res.data) { + MessagePlugin.error(res.message || '查询失败') + return + } + setSelectedSettlement(res.data.settlement) + setRows(res.data.rows || []) + } catch (e) { + console.error('Failed to fetch settlement leaderboard:', e) + } finally { + setDetailLoading(false) } - setSelectedSettlement(res.data.settlement) - setRows(res.data.rows || []) } const columns: PrimaryTableCol[] = useMemo( @@ -120,6 +130,8 @@ export const SettlementHistory: React.FC = () => { loading={detailLoading} bordered hover + pagination={{ pageSize: 50, total: rows.length, defaultCurrent: 1 }} + scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }} style={{ color: 'var(--ss-text-main)' }} /> diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 9f30b8f..1c80c65 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { ViewListIcon, HomeIcon } from 'tdesign-icons-react' -import appLogo from '../assets/logo.svg' +import appLogo from '../assets/logoHD.svg' const { Aside } = Layout diff --git a/src/renderer/src/components/StudentManager.tsx b/src/renderer/src/components/StudentManager.tsx index 46d8033..d829734 100644 --- a/src/renderer/src/components/StudentManager.tsx +++ b/src/renderer/src/components/StudentManager.tsx @@ -1,7 +1,13 @@ import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react' import { Table, Button, Space, MessagePlugin, Dialog, Form, Input } from 'tdesign-react' import type { PrimaryTableCol } from 'tdesign-react' -import * as XLSX from 'xlsx' + +// 创建 XLSX Worker +const createXlsxWorker = () => { + return new Worker(new URL('../workers/xlsxWorker.ts', import.meta.url), { + type: 'module' + }) +} interface student { id: number @@ -20,8 +26,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [xlsxAoa, setXlsxAoa] = useState([]) const [xlsxSelectedCol, setXlsxSelectedCol] = useState(null) const xlsxInputRef = useRef(null) + const xlsxWorkerRef = useRef(null) const [form] = Form.useForm() + // 初始化 Worker + useEffect(() => { + xlsxWorkerRef.current = createXlsxWorker() + return () => { + xlsxWorkerRef.current?.terminate() + } + }, []) + const emitDataUpdated = (category: 'students' | 'all') => { window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } })) } @@ -29,11 +44,16 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const fetchStudents = useCallback(async () => { if (!(window as any).api) return setLoading(true) - const res = await (window as any).api.queryStudents({}) - if (res.success && res.data) { - setData(res.data) + try { + const res = await (window as any).api.queryStudents({}) + if (res.success && res.data) { + setData(res.data) + } + } catch (e) { + console.error('Failed to fetch students:', e) + } finally { + setLoading(false) } - setLoading(false) }, []) useEffect(() => { @@ -123,30 +143,40 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } const parseXlsxFile = async (file: File) => { + if (!xlsxWorkerRef.current) { + MessagePlugin.error('Worker 未初始化') + return + } + setXlsxLoading(true) try { const buf = await file.arrayBuffer() - const wb = XLSX.read(buf, { type: 'array' }) - const firstSheetName = wb.SheetNames?.[0] - if (!firstSheetName) { - MessagePlugin.error('xlsx 中未找到工作表') - return - } - const ws = wb.Sheets[firstSheetName] - const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, defval: '' }) as any[][] - if (!Array.isArray(aoa) || aoa.length === 0) { - MessagePlugin.error('xlsx 内容为空') - return + + // 使用 Worker 处理文件解析,避免阻塞主线程 + xlsxWorkerRef.current.postMessage({ + type: 'parseXlsx', + data: { buffer: buf } + }) + + // 监听 Worker 消息 + const handleMessage = (event: MessageEvent) => { + if (event.data.type === 'success') { + setXlsxFileName(file.name) + setXlsxAoa(event.data.data) + setXlsxSelectedCol(null) + setXlsxVisible(true) + setImportVisible(false) + setXlsxLoading(false) + } else if (event.data.type === 'error') { + MessagePlugin.error(event.data.error || '解析 xlsx 失败') + setXlsxLoading(false) + } + xlsxWorkerRef.current?.removeEventListener('message', handleMessage) } - setXlsxFileName(file.name) - setXlsxAoa(aoa) - setXlsxSelectedCol(null) - setXlsxVisible(true) - setImportVisible(false) + xlsxWorkerRef.current.addEventListener('message', handleMessage) } catch (e: any) { MessagePlugin.error(e?.message || '解析 xlsx 失败') - } finally { setXlsxLoading(false) } } @@ -314,6 +344,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { loading={loading} bordered hover + pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }} + scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }} style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }} /> diff --git a/src/renderer/src/components/ThemeEditor.tsx b/src/renderer/src/components/ThemeEditor.tsx new file mode 100644 index 0000000..8786d18 --- /dev/null +++ b/src/renderer/src/components/ThemeEditor.tsx @@ -0,0 +1,231 @@ +import React, { useEffect } from 'react' +import { + Drawer, + Form, + Input, + Select, + ColorPicker, + Button, + Space, + Divider, + Row, + Col +} from 'tdesign-react' +import { useThemeEditor } from '../contexts/ThemeEditorContext' +import { useTheme } from '../contexts/ThemeContext' +import { generateColorMap } from '../utils/color' +import { themeConfig } from '../../../preload/types' + +const variableGroups = { + background: { + title: '背景颜色', + items: [ + { key: '--ss-bg-color', label: '全局背景' }, + { key: '--ss-card-bg', label: '卡片背景' }, + { key: '--ss-header-bg', label: '顶部栏背景' }, + { key: '--ss-sidebar-bg', label: '侧边栏背景' } + ] + }, + text: { + title: '文字颜色', + items: [ + { key: '--ss-text-main', label: '主要文字' }, + { key: '--ss-text-secondary', label: '次要文字' }, + { key: '--ss-sidebar-active-text', label: '侧边栏选中文字' } + ] + }, + border: { + title: '边框与分割线', + items: [{ key: '--ss-border-color', label: '通用边框' }] + }, + interaction: { + title: '交互状态', + items: [ + { key: '--ss-item-hover', label: '列表悬浮' }, + { key: '--ss-sidebar-active-bg', label: '侧边栏选中背景' } + ] + } +} + +export const ThemeEditor: React.FC = () => { + const { + isEditing, + editingTheme, + updateEditingTheme, + updateConfig, + saveEditingTheme, + cancelEditing + } = useThemeEditor() + + const { currentTheme } = useTheme() + + // 实时预览逻辑 + useEffect(() => { + if (!isEditing || !editingTheme) return + + const applyPreview = (theme: themeConfig) => { + const { tdesign, custom } = theme.config + const root = document.documentElement + + // 1. 设置 TDesign 模式 + root.setAttribute('theme-mode', theme.mode) + + // 2. 设置 TDesign 品牌色 + if (tdesign.brandColor) { + const colorMap = generateColorMap(tdesign.brandColor, theme.mode) + Object.entries(colorMap).forEach(([key, value]) => { + root.style.setProperty(key, value) + }) + } + + // 3. 应用自定义变量 + Object.entries(custom).forEach(([key, value]) => { + root.style.setProperty(key, value) + }) + } + + applyPreview(editingTheme) + }, [editingTheme, isEditing]) + + // 关闭时恢复原有主题 + useEffect(() => { + if (!isEditing && currentTheme) { + const { tdesign, custom } = currentTheme.config + const root = document.documentElement + + root.setAttribute('theme-mode', currentTheme.mode) + + if (tdesign.brandColor) { + const colorMap = generateColorMap(tdesign.brandColor, currentTheme.mode) + Object.entries(colorMap).forEach(([key, value]) => { + root.style.setProperty(key, value) + }) + } + + Object.entries(custom).forEach(([key, value]) => { + root.style.setProperty(key, value) + }) + } + }, [isEditing, currentTheme]) + + if (!editingTheme) return null + + return ( + + + + + } + destroyOnClose + > + + + {/* 基本信息 */} +
+ 基本信息 + + + + updateEditingTheme({ name: v })} + placeholder="请输入主题名称" + /> + + + + + updateEditingTheme({ id: v })} + placeholder="请输入主题 ID" + /> + + + +
+ + {/* TDesign 品牌色 */} +
+ 品牌色 (Brand) + + updateConfig('tdesign', 'brandColor', v)} + enableAlpha={false} + format="HEX" + /> + +
+ + {/* 业务自定义变量 */} +
+ 界面配色 (Custom) + {Object.entries(variableGroups).map(([groupKey, group]) => ( +
+
+ {group.title} +
+ + {group.items.map((item) => ( + +
+
+ {item.label} +
+ updateConfig('custom', item.key, v)} + enableAlpha + format="HEX" + /> +
+ + ))} +
+
+ ))} +
+
+ +
+ ) +} diff --git a/src/renderer/src/components/TitleBar.tsx b/src/renderer/src/components/TitleBar.tsx index a8322ef..2f87ff8 100644 --- a/src/renderer/src/components/TitleBar.tsx +++ b/src/renderer/src/components/TitleBar.tsx @@ -109,7 +109,11 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element { onClick={maximize} style={{ width: '46px', height: '32px', borderRadius: 0 }} > - {isMaximized ? : } + {isMaximized ? ( + + ) : ( + + )}