diff --git a/src/main/context.ts b/src/main/context.ts index da3b5dc..05ff78f 100644 --- a/src/main/context.ts +++ b/src/main/context.ts @@ -2,6 +2,8 @@ import { Context as BaseContext } from '../shared/kernel' import { ipcMain } from 'electron' export class MainContext extends BaseContext { + public isQuitting = false + constructor() { super() } diff --git a/src/main/hosting/index.ts b/src/main/hosting/index.ts index 3236dc5..cab2910 100644 --- a/src/main/hosting/index.ts +++ b/src/main/hosting/index.ts @@ -13,7 +13,8 @@ export { EventRepositoryToken, SettlementRepositoryToken, ThemeServiceToken, - WindowManagerToken + WindowManagerToken, + TrayServiceToken } from './tokens' export type { appRuntimeContext, diff --git a/src/main/hosting/tokens.ts b/src/main/hosting/tokens.ts index 6321d8b..a28ad91 100644 --- a/src/main/hosting/tokens.ts +++ b/src/main/hosting/tokens.ts @@ -12,3 +12,4 @@ export const EventRepositoryToken = Symbol.for('secscore.eventRepository') export const SettlementRepositoryToken = Symbol.for('secscore.settlementRepository') export const ThemeServiceToken = Symbol.for('secscore.themeService') export const WindowManagerToken = Symbol.for('secscore.windowManager') +export const TrayServiceToken = Symbol.for('secscore.trayService') diff --git a/src/main/index.ts b/src/main/index.ts index 2b210e6..eb78310 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,7 +6,7 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/SecScore_logo.ico?asset' import { MainContext } from './context' import { DbManager } from './db/DbManager' -import { LoggerService, logLevel } from './services/LoggerService' +import { LoggerService } from './services/LoggerService' import { SettingsService } from './services/SettingsService' import { SecurityService } from './services/SecurityService' import { PermissionService } from './services/PermissionService' @@ -14,6 +14,7 @@ import { AuthService } from './services/AuthService' import { DataService } from './services/DataService' import { ThemeService } from './services/ThemeService' import { WindowManager, type windowManagerOptions } from './services/WindowManager' +import { TrayService } from './services/TrayService' import { StudentRepository } from './repos/StudentRepository' import { ReasonRepository } from './repos/ReasonRepository' import { EventRepository } from './repos/EventRepository' @@ -31,7 +32,8 @@ import { SettingsStoreToken, StudentRepositoryToken, ThemeServiceToken, - WindowManagerToken + WindowManagerToken, + TrayServiceToken } from './hosting' type mainAppConfig = { @@ -137,6 +139,10 @@ app.whenReady().then(async () => { WindowManagerToken, (p) => new WindowManager(p.get(MainContext), config.window) ) + services.addSingleton( + TrayServiceToken, + (p) => new TrayService(p.get(MainContext), config.window) + ) }) .configure(async (_builderContext, appCtx) => { const services = appCtx.services @@ -156,109 +162,32 @@ app.whenReady().then(async () => { services.get(SettlementRepositoryToken) services.get(ThemeServiceToken) services.get(WindowManagerToken) + const tray = services.get(TrayServiceToken) as TrayService + tray.initialize() - const logLevelSetting = ctx.settings.getValue('log_level') as logLevel - if (logLevelSetting) { - ctx.logger.setLevel(logLevelSetting) - } - ctx.logger.info('Application starting...') - - const mainConsole = console as any - mainConsole.log = (...args: any[]) => ctx.logger.info(String(args[0] ?? ''), ...args.slice(1)) - mainConsole.info = (...args: any[]) => - ctx.logger.info(String(args[0] ?? ''), ...args.slice(1)) - mainConsole.warn = (...args: any[]) => - ctx.logger.warn(String(args[0] ?? ''), ...args.slice(1)) - mainConsole.error = (...args: any[]) => - ctx.logger.error(String(args[0] ?? ''), ...args.slice(1)) - mainConsole.debug = (...args: any[]) => - ctx.logger.debug(String(args[0] ?? ''), ...args.slice(1)) - mainConsole.trace = (...args: any[]) => - ctx.logger.debug('console.trace', { args, stack: new Error('console.trace').stack }) - - if (!config.isDev) { - try { - if (!fs.existsSync(config.themeDir)) { - fs.mkdirSync(config.themeDir, { recursive: true }) - } - const existing = fs - .readdirSync(config.themeDir) - .filter((f) => f.toLowerCase().endsWith('.json')) - if (existing.length === 0) { - const builtinThemeDir = join(app.getAppPath(), 'themes') - if (fs.existsSync(builtinThemeDir)) { - const files = fs - .readdirSync(builtinThemeDir) - .filter((f) => f.toLowerCase().endsWith('.json')) - for (const f of files) { - const src = join(builtinThemeDir, f) - const dest = join(config.themeDir, f) - try { - fs.copyFileSync(src, dest) - } catch (e: any) { - ctx.logger.warn('Failed to copy builtin theme', { - src, - dest, - message: e?.message - }) - } - } - } - } - } catch (e: any) { - ctx.logger.warn('Failed to initialize theme directory', { message: e?.message }) + // Open Global Sidebar on startup + ctx.windows.open({ + key: 'global-sidebar', + title: 'SecScore Sidebar', + route: '/global-sidebar', + options: { + transparent: true, + alwaysOnTop: true, + hasShadow: false, + type: 'toolbar' } - } - - const uncaughtExceptionHandler = (err: any) => { - ctx.logger.error('uncaughtException', { - message: err?.message, - stack: err?.stack - }) - } - process.on('uncaughtException', uncaughtExceptionHandler) - ctx.effect(() => process.removeListener('uncaughtException', uncaughtExceptionHandler)) - - const unhandledRejectionHandler = (reason: any) => { - if (reason instanceof Error) { - ctx.logger.error('unhandledRejection', { message: reason.message, stack: reason.stack }) - } else { - ctx.logger.error('unhandledRejection', reason) - } - } - process.on('unhandledRejection', unhandledRejectionHandler) - ctx.effect(() => process.removeListener('unhandledRejection', unhandledRejectionHandler)) - - const renderProcessGoneHandler = (_: any, __: any, details: any) => { - ctx.logger.error('render-process-gone', details) - } - app.on('render-process-gone', renderProcessGoneHandler) - ctx.effect(() => app.removeListener('render-process-gone', renderProcessGoneHandler)) - - const childProcessGoneHandler = (_: any, details: any) => { - ctx.logger.error('child-process-gone', details) - } - app.on('child-process-gone', childProcessGoneHandler) - ctx.effect(() => app.removeListener('child-process-gone', childProcessGoneHandler)) - - ctx.windows.open({ key: 'main', title: 'SecScore', route: '/' }) - - const activateHandler = () => { - if (!ctx.windows.get('main')) { - ctx.windows.open({ key: 'main', title: 'SecScore', route: '/' }) - } - } - app.on('activate', activateHandler) - ctx.effect(() => app.removeListener('activate', activateHandler)) + }) }) const host = await builder.build() + const ctx = host.services.get(MainContext) as MainContext await host.start() let disposing = false const beforeQuitHandler = () => { if (disposing) return disposing = true + ctx.isQuitting = true app.removeListener('before-quit', beforeQuitHandler) void host.dispose() } diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts index 4a786ae..9c5a6b9 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 } from 'electron' +import { BrowserWindow, webContents } from 'electron' import type { IpcMainInvokeEvent } from 'electron' import type { settingsKey, settingsSpec, settingChange } from '../../preload/types' import type { permissionLevel } from './PermissionService' @@ -48,6 +48,17 @@ export class SettingsService extends Service { onChanged: (ctx, next) => { ctx.logger.setLevel(next as any) } + }, + window_zoom: { + kind: 'number', + defaultValue: 1.0, + writePermission: 'admin', + onChanged: (_ctx, next) => { + const zoom = Number(next) || 1.0 + webContents.getAllWebContents().forEach((wc: any) => { + wc.setZoomFactor(zoom) + }) + } } } diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts new file mode 100644 index 0000000..c732695 --- /dev/null +++ b/src/main/services/TrayService.ts @@ -0,0 +1,58 @@ +import { Service } from '../../shared/kernel' +import { MainContext } from '../context' +import { Tray, Menu, app, nativeImage } from 'electron' +import type { windowManagerOptions } from './WindowManager' + +export class TrayService extends Service { + private tray: Tray | null = null + + constructor( + ctx: MainContext, + private readonly opts: windowManagerOptions + ) { + super(ctx, 'tray') + } + + private get mainCtx() { + return this.ctx as MainContext + } + + public initialize() { + const icon = nativeImage.createFromPath(this.opts.icon) + this.tray = new Tray(icon) + + const contextMenu = Menu.buildFromTemplate([ + { + label: '显示主窗口', + click: () => { + this.showMainWindow() + } + }, + { type: 'separator' }, + { + label: '退出 SecScore', + click: () => { + app.quit() + } + } + ]) + + this.tray.setToolTip('SecScore 积分管理') + this.tray.setContextMenu(contextMenu) + + this.tray.on('double-click', () => { + this.showMainWindow() + }) + } + + private showMainWindow() { + const mainWin = this.mainCtx.windows.get('main') + if (mainWin) { + if (mainWin.isMinimized()) mainWin.restore() + mainWin.show() + mainWin.focus() + } else { + this.mainCtx.windows.open({ key: 'main', title: 'SecScore', route: '/' }) + } + } +} diff --git a/src/main/services/WindowManager.ts b/src/main/services/WindowManager.ts index 861ab55..bb4ffeb 100644 --- a/src/main/services/WindowManager.ts +++ b/src/main/services/WindowManager.ts @@ -1,6 +1,6 @@ import { Service } from '../../shared/kernel' import { MainContext } from '../context' -import { BrowserWindow, shell } from 'electron' +import { BrowserWindow, shell, screen } from 'electron' import type { BrowserWindowConstructorOptions } from 'electron' export type windowOpenInput = { @@ -51,7 +51,10 @@ export class WindowManager extends Service { public open(input: windowOpenInput) { const existing = this.get(input.key) if (existing) { - if (input.route) void this.loadRoute(existing, input.route) + if (input.route) { + // Use soft navigation if window exists to prevent reload + existing.webContents.send('app:navigate', input.route) + } existing.show() existing.focus() return existing @@ -72,7 +75,36 @@ export class WindowManager extends Service { ...input.options }) + // Special positioning for global sidebar + if (input.key === 'global-sidebar') { + const primaryDisplay = screen.getPrimaryDisplay() + const { width, height } = primaryDisplay.workAreaSize + const winWidth = 84 + const winHeight = 300 + win.setBounds({ + x: width - winWidth, + y: Math.floor(height / 2 - winHeight / 2), + width: winWidth, + height: winHeight + }) + win.setAlwaysOnTop(true, 'screen-saver') + win.setVisibleOnAllWorkspaces(true) + win.setSkipTaskbar(true) + win.setResizable(false) + } + + const zoom = Number(this.mainCtx.settings.getValue('window_zoom')) || 1.0 + win.webContents.setZoomFactor(zoom) + this.windows.set(input.key, win) + + win.on('close', (event) => { + if (!this.mainCtx.isQuitting && input.key === 'main') { + event.preventDefault() + win.hide() + } + }) + win.on('closed', () => { this.windows.delete(input.key) }) @@ -101,13 +133,13 @@ export class WindowManager extends Service { public navigate(key: string, route: string) { const win = this.get(key) if (!win) return false - void this.loadRoute(win, route) + win.webContents.send('app:navigate', route) return true } public navigateWindow(win: BrowserWindow, route: string) { if (win.isDestroyed()) return false - void this.loadRoute(win, route) + win.webContents.send('app:navigate', route) return true } @@ -182,6 +214,13 @@ export class WindowManager extends Service { return win ? win.isMaximized() : false }) + this.mainCtx.handle('window:set-zoom', (event, zoom: number) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (win && zoom >= 0.5 && zoom <= 2.0) { + win.webContents.setZoomFactor(zoom) + } + }) + this.mainCtx.handle('window:toggle-devtools', (event) => { const win = BrowserWindow.fromWebContents(event.sender) if (win) { @@ -192,5 +231,20 @@ export class WindowManager extends Service { } } }) + + this.mainCtx.handle('window:resize', (event, width: number, height: number) => { + 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, + y: bounds.y, + width, + height + }) + } + }) } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7789203..ca64a29 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -80,7 +80,13 @@ const api = { ipcRenderer.on('window:maximized-changed', subscription) return () => ipcRenderer.removeListener('window:maximized-changed', subscription) }, + onNavigate: (callback: (route: string) => void) => { + const subscription = (_event: any, route: string) => callback(route) + ipcRenderer.on('app:navigate', subscription) + return () => ipcRenderer.removeListener('app:navigate', subscription) + }, toggleDevTools: () => ipcRenderer.invoke('window:toggle-devtools'), + 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 a6f7e69..485a7f9 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -20,6 +20,7 @@ export interface themeConfig { export type settingsSpec = { is_wizard_completed: boolean log_level: logLevel + window_zoom: number } export type settingsKey = keyof settingsSpec @@ -121,7 +122,9 @@ export interface electronApi { windowClose: () => Promise windowIsMaximized: () => Promise onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => () => void + onNavigate: (callback: (route: string) => void) => () => void toggleDevTools: () => Promise + windowResize: (width: number, height: number) => Promise // Logger queryLogs: (lines?: number) => Promise> diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 193ba21..788a019 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,15 +1,30 @@ import { Layout, Dialog, Input, MessagePlugin } from 'tdesign-react' import { useEffect, useMemo, useState } from 'react' -import { HashRouter, useLocation, useNavigate } from 'react-router-dom' +import { HashRouter, useLocation, useNavigate, Routes, Route } from 'react-router-dom' import { Sidebar } from './components/Sidebar' import { ContentArea } from './components/ContentArea' import { Wizard } from './components/Wizard' import { ThemeProvider } from './contexts/ThemeContext' +import { GlobalSidebar } from './components/GlobalSidebar' function MainContent(): React.JSX.Element { const navigate = useNavigate() const location = useLocation() + useEffect(() => { + if (!(window as any).api) return + const unlisten = (window as any).api.onNavigate((route: string) => { + // 统一路径格式进行比对,防止 / 和 /home 导致重复跳转 + const currentPath = location.pathname === '/' ? '/home' : location.pathname + const targetPath = route === '/' ? '/home' : route + + if (currentPath !== targetPath) { + navigate(route) + } + }) + return () => unlisten() + }, [navigate, location.pathname]) + const [wizardVisible, setWizardVisible] = useState(false) const [permission, setPermission] = useState<'admin' | 'points' | 'view'>('view') const [hasAnyPassword, setHasAnyPassword] = useState(false) @@ -129,7 +144,10 @@ function App(): React.JSX.Element { return ( - + + } /> + } /> + ) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9617bae..37f2d7e 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -96,3 +96,70 @@ html[theme-mode='dark'] .ss-sidebar .t-menu__item.t-is-active svg:not([fill='non text-align: center; justify-content: center; } + +/* Global Sidebar Toggle Button */ +.global-sidebar-toggle { + width: 24px; + height: 60px; + background-color: var(--ss-card-bg); + border-radius: 8px 0 0 8px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: -2px 0 8px rgba(0,0,0,0.1); + border: 1px solid var(--ss-border-color); + border-right: none; + color: var(--ss-text-main); + z-index: 10; + flex-shrink: 0; + animation: twinkle-animation 3s infinite ease-in-out; + transition: opacity 0.07s ease, transform 0.15s ease; +} + +.global-sidebar-toggle.hidden { + opacity: 0; + pointer-events: none; + transform: translateX(10px); +} + +.sidebar-content-area { + display: flex; + flex-direction: column; + backgroundColor: 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); + border-right: none; + padding: 12px 8px; + gap: 12px; + width: 60px; + transition: opacity 0.1s ease, transform 0.1s cubic-bezier(0.34, 1.56, 0.64, 1); + backface-visibility: hidden; + transform: translateZ(0); + contain: paint; /* 性能优化:限制重绘范围 */ +} + +.sidebar-content-area.hidden { + opacity: 0; + pointer-events: none; + transform: translateX(20px); +} + +.sidebar-content-area.visible { + opacity: 1; + pointer-events: auto; + transform: translateX(0); +} + +@keyframes twinkle-animation { + 0%, 100% { + box-shadow: -2px 0 8px rgba(0,0,0,0.1); + filter: brightness(1); + } + 50% { + box-shadow: -4px 0 12px var(--td-brand-color-light), 0 0 15px var(--td-brand-color-focus); + filter: brightness(1.2); + color: var(--td-brand-color); + } +} diff --git a/src/renderer/src/components/ContentArea.tsx b/src/renderer/src/components/ContentArea.tsx index abca767..a811a6d 100644 --- a/src/renderer/src/components/ContentArea.tsx +++ b/src/renderer/src/components/ContentArea.tsx @@ -1,14 +1,16 @@ -import { Layout, Space, Button, Tag } from 'tdesign-react' +import React, { Suspense, lazy } from 'react' +import { Layout, Space, Button, Tag, Loading } from 'tdesign-react' import { Routes, Route, Navigate } from 'react-router-dom' -import { Home } from './Home' -import { StudentManager } from './StudentManager' -import { Settings } from './Settings' -import { ReasonManager } from './ReasonManager' -import { ScoreManager } from './ScoreManager' -import { Leaderboard } from './Leaderboard' -import { SettlementHistory } from './SettlementHistory' import { WindowControls } from './WindowControls' +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 interface ContentAreaProps { @@ -85,19 +87,37 @@ export function ContentArea({ - - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> - + + + + } + > + + } + /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + + ) diff --git a/src/renderer/src/components/GlobalSidebar.tsx b/src/renderer/src/components/GlobalSidebar.tsx new file mode 100644 index 0000000..27b9fc1 --- /dev/null +++ b/src/renderer/src/components/GlobalSidebar.tsx @@ -0,0 +1,127 @@ +import React, { useState } from 'react' +import { Button, Tooltip } from 'tdesign-react' +import { + HomeIcon, + ViewListIcon, + UserAddIcon, + ChevronRightIcon, + ChevronLeftIcon, + SettingIcon +} from 'tdesign-icons-react' + +export const GlobalSidebar: React.FC = () => { + const [expanded, setExpanded] = useState(false) + const [showToggle, setShowToggle] = useState(true) + + const handleExpand = () => { + // 1. 先隐藏三角 + setShowToggle(false) + + // 2. 稍后扩大窗口 + setTimeout(() => { + if ((window as any).api) { + ;(window as any).api.windowResize(84, 300) + } + // 3. 最后显示侧边栏内容 + setTimeout(() => { + setExpanded(true) + }, 20) + }, 100) + } + + const handleCollapse = () => { + // 1. 先隐藏侧边栏内容 + setExpanded(false) + + // 2. 稍后缩小窗口 + setTimeout(() => { + if ((window as any).api) { + ;(window as any).api.windowResize(24, 300) + } + // 3. 最后重新显示三角(等待透明度动画完成) + setTimeout(() => { + setShowToggle(true) + }, 150) + }, 150) + } + + const openMain = () => { + if (!(window as any).api) return + ;(window as any).api.openWindow({ key: 'main', route: '/' }) + } + + return ( +
+ {/* 日常展示的三角 */} +
+ +
+ + {/* 侧边栏内容 */} +
+ {/* 顶部的关闭/收起按钮 */} + + + + + + + + + + + + + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/Home.tsx b/src/renderer/src/components/Home.tsx index efd1ef6..d274829 100644 --- a/src/renderer/src/components/Home.tsx +++ b/src/renderer/src/components/Home.tsx @@ -11,13 +11,15 @@ import { InputNumber, Divider } from 'tdesign-react' -import { SearchIcon, AddIcon, MinusIcon, DeleteIcon } from 'tdesign-icons-react' +import { SearchIcon, DeleteIcon } from 'tdesign-icons-react' import { match, pinyin } from 'pinyin-pro' interface student { id: number name: string score: number + pinyinName?: string + pinyinFirst?: string } interface reason { @@ -51,33 +53,6 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } })) } - const fetchData = useCallback(async (silent = false) => { - if (!(window as any).api) return - if (!silent) setLoading(true) - const [stuRes, reaRes] = await Promise.all([ - (window as any).api.queryStudents({}), - (window as any).api.queryReasons() - ]) - - if (stuRes.success) setStudents(stuRes.data) - if (reaRes.success) setReasons(reaRes.data) - if (!silent) setLoading(false) - }, []) - - useEffect(() => { - fetchData() - const onDataUpdated = (e: any) => { - const category = e?.detail?.category - // 仅在学生名单、理由列表或全量更新时才重新拉取数据 - // 积分变动(events)现在由本地状态维护,不再触发全量刷新以防止页面跳动 - if (category === 'students' || category === 'reasons' || category === 'all') { - fetchData(true) - } - } - window.addEventListener('ss:data-updated', onDataUpdated as any) - return () => window.removeEventListener('ss:data-updated', onDataUpdated as any) - }, [fetchData]) - // 获取姓氏 const getSurname = (name: string) => { if (!name) return '' @@ -95,6 +70,38 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { return py ? py.toUpperCase() : '#' } + const fetchData = useCallback(async (silent = false) => { + if (!(window as any).api) return + if (!silent) setLoading(true) + const [stuRes, reaRes] = await Promise.all([ + (window as any).api.queryStudents({}), + (window as any).api.queryReasons() + ]) + + if (stuRes.success) { + const enrichedStudents = (stuRes.data as student[]).map(s => ({ + ...s, + pinyinName: pinyin(s.name, { toneType: 'none' }).toLowerCase(), + pinyinFirst: getFirstLetter(s.name) + })) + setStudents(enrichedStudents) + } + if (reaRes.success) setReasons(reaRes.data) + if (!silent) setLoading(false) + }, []) + + useEffect(() => { + fetchData() + const onDataUpdated = (e: any) => { + const category = e?.detail?.category + if (category === 'students' || category === 'reasons' || category === 'all') { + fetchData(true) + } + } + window.addEventListener('ss:data-updated', onDataUpdated as any) + return () => window.removeEventListener('ss:data-updated', onDataUpdated as any) + }, [fetchData]) + // 获取展示用的文字 const getDisplayText = (name: string) => { if (!name) return '' @@ -102,23 +109,22 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } // 拼音匹配 - const matchStudentName = useCallback((name: string, keyword: string) => { + const matchStudentName = useCallback((s: student, keyword: string) => { const q0 = keyword.trim().toLowerCase() if (!q0) return true - const nameLower = String(name).toLowerCase() + const nameLower = String(s.name).toLowerCase() if (nameLower.includes(q0)) return true + const pyLower = s.pinyinName || '' + if (pyLower.includes(q0)) return true + const q1 = q0.replace(/\s+/g, '') - if (q1 && nameLower.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(name, q0) + const m0 = match(s.name, q0) if (Array.isArray(m0)) return true - if (q1 && q1 !== q0) { - const m1 = match(name, q1) - if (Array.isArray(m1)) return true - } } catch { return false } @@ -128,13 +134,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { // 过滤和排序学生 const sortedStudents = useMemo(() => { - let filtered = students.filter((s) => matchStudentName(s.name, searchKeyword)) + const filtered = students.filter((s) => matchStudentName(s, searchKeyword)) switch (sortType) { case 'alphabet': return filtered.sort((a, b) => { - const pyA = pinyin(a.name, { toneType: 'none' }) - const pyB = pinyin(b.name, { toneType: 'none' }) + const pyA = a.pinyinName || '' + const pyB = b.pinyinName || '' return pyA.localeCompare(pyB) }) case 'surname': @@ -161,7 +167,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const groups: Record = {} sortedStudents.forEach((s) => { - const key = sortType === 'alphabet' ? getFirstLetter(s.name) : getSurname(s.name) + const key = sortType === 'alphabet' ? (s.pinyinFirst || '#') : getSurname(s.name) if (!groups[key]) groups[key] = [] groups[key].push(s) }) @@ -394,7 +400,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { }} > {group.key} - + ({group.students.length} 人) @@ -416,24 +424,27 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const navContainerRef = useRef(null) const isNavDragging = useRef(false) - const handleNavAction = useCallback((clientY: number) => { - if (!navContainerRef.current) return - const rect = navContainerRef.current.getBoundingClientRect() - const y = clientY - rect.top - const items = navContainerRef.current.children - const itemCount = items.length - if (itemCount === 0) return + const handleNavAction = useCallback( + (clientY: number) => { + if (!navContainerRef.current) return + const rect = navContainerRef.current.getBoundingClientRect() + const y = clientY - rect.top + const items = navContainerRef.current.children + const itemCount = items.length + if (itemCount === 0) return - // 计算当前指向第几个项 - const itemHeight = rect.height / itemCount - const index = Math.floor(y / itemHeight) - const safeIndex = Math.max(0, Math.min(itemCount - 1, index)) + // 计算当前指向第几个项 + const itemHeight = rect.height / itemCount + const index = Math.floor(y / itemHeight) + const safeIndex = Math.max(0, Math.min(itemCount - 1, index)) - const targetGroup = groupedStudents[safeIndex] - if (targetGroup) { - scrollToGroup(targetGroup.key) - } - }, [groupedStudents]) + const targetGroup = groupedStudents[safeIndex] + if (targetGroup) { + scrollToGroup(targetGroup.key) + } + }, + [groupedStudents] + ) const onNavMouseDown = (e: React.MouseEvent) => { isNavDragging.current = true @@ -454,14 +465,42 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { document.removeEventListener('mouseup', onGlobalMouseUp) } + // 触摸事件处理 + const onNavTouchStart = (e: React.TouchEvent) => { + isNavDragging.current = true + if (e.touches[0]) { + handleNavAction(e.touches[0].clientY) + } + } + + const onNavTouchMove = (e: React.TouchEvent) => { + if (isNavDragging.current && e.touches[0]) { + handleNavAction(e.touches[0].clientY) + // 防止触摸滑动时触发页面滚动 + if (e.cancelable) e.preventDefault() + } + } + + const onNavTouchEnd = () => { + isNavDragging.current = false + } + // 渲染快速导航 const renderQuickNav = () => { - if (groupedStudents.length <= 1 || sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) return null + if ( + groupedStudents.length <= 1 || + sortType === 'score' || + (sortType === 'alphabet' && searchKeyword) + ) + return null return (
= ({ canEdit }) => { maxHeight: '80vh', border: '1px solid var(--ss-border-color)', cursor: 'pointer', - userSelect: 'none' + userSelect: 'none', + touchAction: 'none' // 关键:禁用浏览器的默认触摸处理 }} > {groupedStudents.map((group) => ( @@ -517,7 +557,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { }} >
-

学生积分主页

+

+ 学生积分主页 +

共 {students.length} 名学生,点击卡片进行积分操作

@@ -571,7 +613,12 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { {searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'}
{searchKeyword && ( - )} @@ -625,9 +672,17 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { {selectedStudent.name}
- 当前积分: + + 当前积分: + 0 ? 'success' : selectedStudent.score < 0 ? 'danger' : 'default'} + theme={ + selectedStudent.score > 0 + ? 'success' + : selectedStudent.score < 0 + ? 'danger' + : 'default' + } variant="light" style={{ fontWeight: 'bold' }} > @@ -639,7 +694,14 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { {/* 快捷理由 */} {groupedReasons.length > 0 && (
-
+
快捷选项
@@ -673,14 +735,24 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { size="small" onClick={() => handleReasonSelect(r)} style={{ - borderColor: r.delta > 0 ? 'var(--td-success-color-3)' : r.delta < 0 ? 'var(--td-error-color-3)' : undefined + borderColor: + r.delta > 0 + ? 'var(--td-success-color-3)' + : r.delta < 0 + ? 'var(--td-error-color-3)' + : undefined }} > {r.content}{' '} 0 ? 'var(--td-success-color)' : r.delta < 0 ? 'var(--td-error-color)' : 'inherit', + color: + r.delta > 0 + ? 'var(--td-success-color)' + : r.delta < 0 + ? 'var(--td-error-color)' + : 'inherit', fontWeight: 'bold' }} > @@ -697,7 +769,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { {/* 自定义分值 */}
-
+
调整分值
@@ -741,7 +815,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { {/* 理由内容 */}
-
+
操作理由
@@ -749,7 +825,14 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { value={reasonContent} onChange={setReasonContent} placeholder="输入加分/扣分的原因(可选)" - suffixIcon={reasonContent ? setReasonContent('')} style={{ cursor: 'pointer' }} /> : undefined} + suffixIcon={ + reasonContent ? ( + setSearchKeyword('')} + style={{ cursor: 'pointer' }} + /> + ) : undefined + } />
@@ -758,18 +841,40 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
0 ? 'var(--td-success-color-1)' : customScore < 0 ? 'var(--td-error-color-1)' : 'var(--ss-bg-color)', + backgroundColor: + customScore > 0 + ? 'var(--td-success-color-1)' + : customScore < 0 + ? 'var(--td-error-color-1)' + : 'var(--ss-bg-color)', borderRadius: '8px', border: `1px solid ${customScore > 0 ? 'var(--td-success-color-2)' : customScore < 0 ? 'var(--td-error-color-2)' : 'var(--ss-border-color)'}`, marginTop: '4px' }} > -
+
变更预览:
{selectedStudent.name}{' '} - 0 ? 'var(--td-success-color)' : customScore < 0 ? 'var(--td-error-color)' : 'inherit' }}> + 0 + ? 'var(--td-success-color)' + : customScore < 0 + ? 'var(--td-error-color)' + : 'inherit' + }} + > {customScore > 0 ? `+${customScore}` : customScore} {' '} 分 @@ -784,4 +889,4 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
) -} +} \ No newline at end of file diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 0c15db9..a1ddd90 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -18,6 +18,7 @@ type permissionLevel = 'admin' | 'points' | 'view' type appSettings = { is_wizard_completed: boolean log_level: 'debug' | 'info' | 'warn' | 'error' + window_zoom?: string } export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => { @@ -25,7 +26,8 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission const [activeTab, setActiveTab] = useState('appearance') const [settings, setSettings] = useState({ is_wizard_completed: false, - log_level: 'info' + log_level: 'info', + window_zoom: '1.0' }) const [securityStatus, setSecurityStatus] = useState<{ @@ -91,6 +93,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission if (change?.key === 'log_level') return { ...prev, log_level: change.value } if (change?.key === 'is_wizard_completed') return { ...prev, is_wizard_completed: change.value } + if (change?.key === 'window_zoom') return { ...prev, window_zoom: change.value } return prev }) }) @@ -268,6 +271,39 @@ 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 26aca87..9f30b8f 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -1,5 +1,12 @@ import { Layout, Menu } from 'tdesign-react' -import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon, HomeIcon } from 'tdesign-icons-react' +import { + UserIcon, + SettingIcon, + HistoryIcon, + RootListIcon, + ViewListIcon, + HomeIcon +} from 'tdesign-icons-react' import appLogo from '../assets/logo.svg' const { Aside } = Layout