mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
@@ -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()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ export {
|
||||
EventRepositoryToken,
|
||||
SettlementRepositoryToken,
|
||||
ThemeServiceToken,
|
||||
WindowManagerToken
|
||||
WindowManagerToken,
|
||||
TrayServiceToken
|
||||
} from './tokens'
|
||||
export type {
|
||||
appRuntimeContext,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '/' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[]>>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user