Merge pull request #3 from Linkon-lcw/main

彳亍
This commit is contained in:
Fox-block-offcial
2026-01-19 14:09:00 +08:00
committed by GitHub
16 changed files with 643 additions and 198 deletions
+2
View File
@@ -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()
}
+2 -1
View File
@@ -13,7 +13,8 @@ export {
EventRepositoryToken,
SettlementRepositoryToken,
ThemeServiceToken,
WindowManagerToken
WindowManagerToken,
TrayServiceToken
} from './tokens'
export type {
appRuntimeContext,
+1
View File
@@ -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')
+23 -94
View File
@@ -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()
}
+12 -1
View File
@@ -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)
})
}
}
}
+58
View File
@@ -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: '/' })
}
}
}
+58 -4
View File
@@ -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
})
}
})
}
}
+6
View File
@@ -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),
+3
View File
@@ -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<void>
windowIsMaximized: () => Promise<boolean>
onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => () => void
onNavigate: (callback: (route: string) => void) => () => void
toggleDevTools: () => Promise<void>
windowResize: (width: number, height: number) => Promise<void>
// Logger
queryLogs: (lines?: number) => Promise<ipcResponse<string[]>>
+20 -2
View File
@@ -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 (
<ThemeProvider>
<HashRouter>
<MainContent />
<Routes>
<Route path="/global-sidebar" element={<GlobalSidebar />} />
<Route path="/*" element={<MainContent />} />
</Routes>
</HashRouter>
</ThemeProvider>
)
+67
View File
@@ -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);
}
}
+41 -21
View File
@@ -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({
</div>
<Content style={{ flex: 1, overflowY: 'auto' }}>
<Routes>
<Route path="/" element={<Home canEdit={permission === 'admin' || permission === 'points'} />} />
<Route path="/students" element={<StudentManager canEdit={permission === 'admin'} />} />
<Route
path="/score"
element={<ScoreManager canEdit={permission === 'admin' || permission === 'points'} />}
/>
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="/settlements" element={<SettlementHistory />} />
<Route path="/reasons" element={<ReasonManager canEdit={permission === 'admin'} />} />
<Route path="/settings" element={<Settings permission={permission} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Suspense
fallback={
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%'
}}
>
<Loading text="正在载入页面..." />
</div>
}
>
<Routes>
<Route
path="/"
element={<Home canEdit={permission === 'admin' || permission === 'points'} />}
/>
<Route path="/students" element={<StudentManager canEdit={permission === 'admin'} />} />
<Route
path="/score"
element={<ScoreManager canEdit={permission === 'admin' || permission === 'points'} />}
/>
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="/settlements" element={<SettlementHistory />} />
<Route path="/reasons" element={<ReasonManager canEdit={permission === 'admin'} />} />
<Route path="/settings" element={<Settings permission={permission} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</Content>
</Layout>
)
@@ -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 (
<div
style={{
height: '100vh',
width: '84px',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
overflow: 'hidden',
background: 'transparent'
}}
>
{/* 日常展示的三角 */}
<div
onClick={handleExpand}
className={`global-sidebar-toggle ${!showToggle ? 'hidden' : ''}`}
style={{ willChange: 'opacity, transform' }}
>
<ChevronLeftIcon />
</div>
{/* 侧边栏内容 */}
<div
className={`sidebar-content-area ${expanded ? 'visible' : 'hidden'}`}
style={{
backgroundColor: 'var(--ss-card-bg)',
height: 'fit-content',
willChange: 'opacity, transform'
}}
>
{/* 顶部的关闭/收起按钮 */}
<Button
shape="circle"
variant="text"
onClick={handleCollapse}
style={{ marginBottom: '8px', color: 'var(--td-brand-color)' }}
>
<ChevronRightIcon size="20px" />
</Button>
<Tooltip content="主界面" placement="left">
<Button shape="circle" variant="text" onClick={openMain}>
<HomeIcon size="24px" />
</Button>
</Tooltip>
<Tooltip content="积分操作" placement="left">
<Button shape="circle" variant="text" onClick={() => openMain()}>
<UserAddIcon size="24px" />
</Button>
</Tooltip>
<Tooltip content="排行榜" placement="left">
<Button
shape="circle"
variant="text"
onClick={() => (window as any).api.openWindow({ key: 'main', route: '/leaderboard' })}
>
<ViewListIcon size="24px" />
</Button>
</Tooltip>
<Tooltip content="设置" placement="left">
<Button
shape="circle"
variant="text"
onClick={() => (window as any).api.openWindow({ key: 'main', route: '/settings' })}
>
<SettingIcon size="24px" />
</Button>
</Tooltip>
</div>
</div>
)
}
+178 -73
View File
@@ -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<string, student[]> = {}
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 }) => {
}}
>
<span style={{ color: 'var(--td-brand-color)' }}>{group.key}</span>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', fontWeight: 'normal' }}>
<span
style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', fontWeight: 'normal' }}
>
({group.students.length} )
</span>
</div>
@@ -416,24 +424,27 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const navContainerRef = useRef<HTMLDivElement>(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 (
<div
ref={navContainerRef}
onMouseDown={onNavMouseDown}
onTouchStart={onNavTouchStart}
onTouchMove={onNavTouchMove}
onTouchEnd={onNavTouchEnd}
style={{
position: 'fixed',
right: '12px',
@@ -477,7 +516,8 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ 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 }) => {
}}
>
<div>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)', fontSize: '24px' }}></h2>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)', fontSize: '24px' }}>
</h2>
<p style={{ margin: '4px 0 0', color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
{students.length}
</p>
@@ -571,7 +613,12 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
{searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'}
</div>
{searchKeyword && (
<Button variant="text" theme="primary" onClick={() => setSearchKeyword('')} style={{ marginTop: '8px' }}>
<Button
variant="text"
theme="primary"
onClick={() => setSearchKeyword('')}
style={{ marginTop: '8px' }}
>
</Button>
)}
@@ -625,9 +672,17 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<span style={{ fontWeight: 600 }}>{selectedStudent.name}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ color: 'var(--ss-text-secondary)', fontSize: '13px' }}></span>
<span style={{ color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
</span>
<Tag
theme={selectedStudent.score > 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 && (
<div>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
<span style={{ fontWeight: 600, fontSize: '14px' }}></span>
<Divider style={{ flex: 1, margin: 0 }} />
</div>
@@ -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}{' '}
<span
style={{
marginLeft: '4px',
color: r.delta > 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 }) => {
{/* 自定义分值 */}
<div>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}
>
<span style={{ fontWeight: 600, fontSize: '14px' }}></span>
<Divider style={{ flex: 1, margin: 0 }} />
</div>
@@ -741,7 +815,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
{/* 理由内容 */}
<div>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<div
style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}
>
<span style={{ fontWeight: 600, fontSize: '14px' }}></span>
<Divider style={{ flex: 1, margin: 0 }} />
</div>
@@ -749,7 +825,14 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
value={reasonContent}
onChange={setReasonContent}
placeholder="输入加分/扣分的原因(可选)"
suffixIcon={reasonContent ? <DeleteIcon onClick={() => setReasonContent('')} style={{ cursor: 'pointer' }} /> : undefined}
suffixIcon={
reasonContent ? (
<DeleteIcon
onClick={() => setSearchKeyword('')}
style={{ cursor: 'pointer' }}
/>
) : undefined
}
/>
</div>
@@ -758,18 +841,40 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
<div
style={{
padding: '16px',
backgroundColor: customScore > 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'
}}
>
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '4px', color: 'var(--ss-text-main)' }}>
<div
style={{
fontSize: '13px',
fontWeight: 600,
marginBottom: '4px',
color: 'var(--ss-text-main)'
}}
>
</div>
<div style={{ fontSize: '15px' }}>
{selectedStudent.name}{' '}
<span style={{ fontWeight: 'bold', color: customScore > 0 ? 'var(--td-success-color)' : customScore < 0 ? 'var(--td-error-color)' : 'inherit' }}>
<span
style={{
fontWeight: 'bold',
color:
customScore > 0
? 'var(--td-success-color)'
: customScore < 0
? 'var(--td-error-color)'
: 'inherit'
}}
>
{customScore > 0 ? `+${customScore}` : customScore}
</span>{' '}
@@ -784,4 +889,4 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
</Dialog>
</div>
)
}
}
+37 -1
View File
@@ -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<appSettings>({
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
))}
</Select>
</Form.FormItem>
<Form.FormItem label="界面缩放">
<Select
value={settings.window_zoom || '1.0'}
onChange={async (v) => {
if (!(window as any).api) return
const next = String(v)
const res = await (window as any).api.setSetting('window_zoom', next)
if (res.success) {
setSettings((prev) => ({ ...prev, window_zoom: next }))
MessagePlugin.success('界面缩放已更新')
} else {
MessagePlugin.error(res.message || '更新失败')
}
}}
style={{ width: '320px' }}
disabled={!canAdmin}
>
<Select.Option value="0.7" label="70% (较小)" />
<Select.Option value="0.8" label="80%" />
<Select.Option value="0.9" label="90%" />
<Select.Option value="1.0" label="100% (默认)" />
<Select.Option value="1.1" label="110%" />
<Select.Option value="1.2" label="120%" />
<Select.Option value="1.3" label="130%" />
<Select.Option value="1.5" label="150% (较大)" />
</Select>
<div
style={{ marginTop: '4px', fontSize: '12px', color: 'var(--ss-text-secondary)' }}
>
</div>
</Form.FormItem>
</Form>
</Card>
</Tabs.TabPanel>
+8 -1
View File
@@ -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