From 24e9e6f6f8ebcce859a4aa6ee4732569f84bea28 Mon Sep 17 00:00:00 2001 From: Linkon <116425752+Linkon-lcw@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:43:26 +0800 Subject: [PATCH 1/7] =?UTF-8?q?style:=20=E7=A7=BB=E9=99=A4=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E6=96=87=E4=BB=B6=E6=9C=AB=E5=B0=BE=E7=9A=84=E7=A9=BA?= =?UTF-8?q?=E8=A1=8C=E5=B9=B6=E6=A0=BC=E5=BC=8F=E5=8C=96=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(ci): 统一字符串引号风格为单引号 refactor(color): 简化数组返回语法 docs: 调整文档中的代码注释格式 chore: 添加.gitattributes文件配置行尾符 --- .gitattributes | 1 + .github/workflows/build.yml | 4 ++-- ai_ref/disposable.md | 14 +++++++------- ai_ref/work_plan.md | 1 - scripts/ci/apply-version.mjs | 5 +++-- scripts/ci/parse-commit.mjs | 1 - src/renderer/src/utils/color.ts | 6 +----- themes/amber.json | 1 - themes/cyan.json | 1 - themes/green.json | 1 - themes/pastel.json | 1 - themes/purple.json | 1 - 12 files changed, 14 insertions(+), 23 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index edbf742..ebdf03a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,10 +6,10 @@ on: workflow_dispatch: inputs: version: - description: "版本号(例如 1.2.3 或 v1.2.3)" + description: '版本号(例如 1.2.3 或 v1.2.3)' required: false build: - description: "构建命令标记(build:win|build:mac|build:linux|build:unpack|build:all)" + description: '构建命令标记(build:win|build:mac|build:linux|build:unpack|build:all)' required: false permissions: diff --git a/ai_ref/disposable.md b/ai_ref/disposable.md index e87a7d4..f2f3877 100644 --- a/ai_ref/disposable.md +++ b/ai_ref/disposable.md @@ -7,7 +7,7 @@ - 为什么我们需要可逆的插件系统? - Cordis 是如何实现资源安全的? -::: + ::: Koishi 的一切都从 Cordis 开始。但我想大部分 Koishi 的开发者都不知道 Cordis 是什么。如果让我来定义的话,Cordis 是一个**元框架 (Meta Framework)**,即一个用于构建框架的框架。 @@ -112,8 +112,8 @@ function serve(port: number) { return () => server.close() } -const dispose = serve(80) // 监听端口 80 -dispose() // 回收副作用 +const dispose = serve(80) // 监听端口 80 +dispose() // 回收副作用 ``` 在这个例子中,`serve()` 函数将会创建一个服务器并且监听 `port` 端口。同时,调用该函数也会返回一个新的函数,用于取消该端口的监听。 @@ -123,7 +123,7 @@ dispose() // 回收副作用 - $\mathcal{C}\times\mathfrak{F}$ 对应着全局环境 (我们稍后会提到全局环境的坏处,但不影响这里的理解) - `port` 对应于上面的 $\text{X}$,由于我们可以使用柯里化,所以在数学模型中并不需要考虑它 -::: + ::: 为什么需要引入这个 $\text{effect}$ 和 $\mathcal{C}\times\mathfrak{F}$ 呢?它的作用是将副作用从函数的返回值中分离出来,从而实现副作用的回收。只需定义 $\text{restore}$ 变换 (不难发现它确实是 $\text{effect}$ 的逆操作): @@ -142,9 +142,9 @@ function serve(port: number) { collectEffect(() => server.close()) } -serve(80) // 监听端口 80 并记录副作用 -serve(443) // 监听端口 443 并记录副作用 -restore() // 回收所有副作用 +serve(80) // 监听端口 80 并记录副作用 +serve(443) // 监听端口 443 并记录副作用 +restore() // 回收所有副作用 ``` 当副作用被记录到全局环境时,$\mathcal{C}\times\mathfrak{F}$ 也就变成了一个更大的 $\mathcal{C}$。我们便可以这样定义: diff --git a/ai_ref/work_plan.md b/ai_ref/work_plan.md index 2ad1b01..d9de645 100644 --- a/ai_ref/work_plan.md +++ b/ai_ref/work_plan.md @@ -91,4 +91,3 @@ - 本计划不包含“引入插件系统/插件化框架”的任何概念与实现。 - 本计划不包含“删除 hosting 目录”。它会被保留;仅确保不再作为主路径依赖。 - 本计划优先保证现有功能可用与类型安全,然后再做进一步抽象与模块扩展。 - diff --git a/scripts/ci/apply-version.mjs b/scripts/ci/apply-version.mjs index 87606b2..8370732 100644 --- a/scripts/ci/apply-version.mjs +++ b/scripts/ci/apply-version.mjs @@ -1,7 +1,9 @@ import fs from 'fs' import path from 'path' -const version = String(process.argv[2] || '').trim().replace(/^v/i, '') +const version = String(process.argv[2] || '') + .trim() + .replace(/^v/i, '') if (!version) { process.stderr.write('缺少版本号参数,例如:node scripts/ci/apply-version.mjs 1.2.3\n') process.exit(1) @@ -17,4 +19,3 @@ const pkgPath = path.join(process.cwd(), 'package.json') const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) pkg.version = version fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8') - diff --git a/scripts/ci/parse-commit.mjs b/scripts/ci/parse-commit.mjs index 6ff5598..e734af5 100644 --- a/scripts/ci/parse-commit.mjs +++ b/scripts/ci/parse-commit.mjs @@ -88,4 +88,3 @@ if (process.env.GITHUB_OUTPUT) { } else { process.stdout.write(`${JSON.stringify(outputs, null, 2)}\n`) } - diff --git a/src/renderer/src/utils/color.ts b/src/renderer/src/utils/color.ts index 7bac7e1..91b1291 100644 --- a/src/renderer/src/utils/color.ts +++ b/src/renderer/src/utils/color.ts @@ -1,11 +1,7 @@ function hexToRgb(hex: string): [number, number, number] { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) return result - ? [ - parseInt(result[1], 16), - parseInt(result[2], 16), - parseInt(result[3], 16) - ] + ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : [0, 0, 0] } diff --git a/themes/amber.json b/themes/amber.json index 56ab3f5..ba3885d 100644 --- a/themes/amber.json +++ b/themes/amber.json @@ -23,4 +23,3 @@ } } } - diff --git a/themes/cyan.json b/themes/cyan.json index d3ad460..0488f82 100644 --- a/themes/cyan.json +++ b/themes/cyan.json @@ -23,4 +23,3 @@ } } } - diff --git a/themes/green.json b/themes/green.json index 6fbc448..8e9bf9f 100644 --- a/themes/green.json +++ b/themes/green.json @@ -23,4 +23,3 @@ } } } - diff --git a/themes/pastel.json b/themes/pastel.json index 2fcb240..072e184 100644 --- a/themes/pastel.json +++ b/themes/pastel.json @@ -23,4 +23,3 @@ } } } - diff --git a/themes/purple.json b/themes/purple.json index ca7ad32..f31f9d1 100644 --- a/themes/purple.json +++ b/themes/purple.json @@ -23,4 +23,3 @@ } } } - From df5b94bdb49927b3b4bb9afcd329d6a4ef1fa6da Mon Sep 17 00:00:00 2001 From: Linkon <116425752+Linkon-lcw@users.noreply.github.com> Date: Sun, 18 Jan 2026 20:36:18 +0800 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=20sidebar=20?= =?UTF-8?q?=E5=92=8C=E5=86=85=E5=AE=B9=E5=8C=BA=E4=B8=BA=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/src/App.tsx | 116 +++----------------- src/renderer/src/components/ContentArea.tsx | 103 +++++++++++++++++ src/renderer/src/components/Sidebar.tsx | 85 ++++++++++++++ 3 files changed, 201 insertions(+), 103 deletions(-) create mode 100644 src/renderer/src/components/ContentArea.tsx create mode 100644 src/renderer/src/components/Sidebar.tsx diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d218bcc..a295f92 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,18 +1,11 @@ -import { Layout, Menu, Space, Dialog, Input, Button, Tag, MessagePlugin } from 'tdesign-react' +import { Layout, Dialog, Input, MessagePlugin } from 'tdesign-react' import { useEffect, useMemo, useState } from 'react' -import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon } from 'tdesign-icons-react' -import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' -import { StudentManager } from './components/StudentManager' -import { Settings } from './components/Settings' -import { ReasonManager } from './components/ReasonManager' -import { ScoreManager } from './components/ScoreManager' -import { Leaderboard } from './components/Leaderboard' -import { SettlementHistory } from './components/SettlementHistory' +import { HashRouter, useLocation, useNavigate } from 'react-router-dom' +import { Sidebar } from './components/Sidebar' +import { ContentArea } from './components/ContentArea' import { Wizard } from './components/Wizard' import { ThemeProvider } from './contexts/ThemeContext' -const { Header, Content, Aside } = Layout - function MainContent(): React.JSX.Element { const navigate = useNavigate() const location = useLocation() @@ -95,99 +88,16 @@ function MainContent(): React.JSX.Element { if (key === 'settings') navigate('/settings') } - const permissionTag = ( - - {permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'} - - ) - return ( - - - -
- - {permissionTag} - {hasAnyPassword && ( - <> - - - - )} - -
- - - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> - - -
+ + + setAuthVisible(true)} + onLogout={logout} + /> + setWizardVisible(false)} /> void + onLogout: () => void +} + +export function ContentArea({ + permission, + hasAnyPassword, + onAuthClick, + onLogout +}: ContentAreaProps): React.JSX.Element { + const permissionTag = ( + + {permission === 'admin' ? '管理权限' : permission === 'points' ? '积分权限' : '只读'} + + ) + + return ( + +
+
+
+ + {permissionTag} + {hasAnyPassword && ( + <> + + + + )} + + +
+
+ + + + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx new file mode 100644 index 0000000..f90b11e --- /dev/null +++ b/src/renderer/src/components/Sidebar.tsx @@ -0,0 +1,85 @@ +import { Layout, Menu } from 'tdesign-react' +import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon } from 'tdesign-icons-react' +import appLogo from '../assets/logo.svg' + +const { Aside } = Layout + +interface SidebarProps { + activeMenu: string + permission: 'admin' | 'points' | 'view' + onMenuChange: (value: string | number) => void +} + +export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps): React.JSX.Element { + return ( + + ) +} From a6f83fdfc3fe3d1215a9cab2fd3ffe4ca559a28c Mon Sep 17 00:00:00 2001 From: Linkon <116425752+Linkon-lcw@users.noreply.github.com> Date: Sun, 18 Jan 2026 20:40:29 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(=E7=AA=97=E5=8F=A3=E7=AE=A1=E7=90=86):?= =?UTF-8?q?=20=E6=B7=BB=E5=8A=A0=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=A0=8F=E5=92=8C=E7=AA=97=E5=8F=A3=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现自定义标题栏组件,包含最小化、最大化/还原和关闭按钮 添加窗口管理相关IPC通信接口 在设置页面增加开发者工具切换按钮 更新.gitignore忽略.trae目录 --- .gitignore | 1 + src/main/services/WindowManager.ts | 50 +++++++ src/preload/index.ts | 10 ++ src/preload/types.ts | 6 + src/renderer/src/assets/logo.svg | 37 +++++ src/renderer/src/components/Settings.tsx | 27 ++++ src/renderer/src/components/TitleBar.tsx | 135 ++++++++++++++++++ .../src/components/WindowControls.tsx | 80 +++++++++++ 8 files changed, 346 insertions(+) create mode 100644 src/renderer/src/assets/logo.svg create mode 100644 src/renderer/src/components/TitleBar.tsx create mode 100644 src/renderer/src/components/WindowControls.tsx diff --git a/.gitignore b/.gitignore index 1ab4fe2..338e429 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ db.sqlite !.vscode/extensions.json !.vscode/launch.json !.vscode/settings.json +/.trae/ \ No newline at end of file diff --git a/src/main/services/WindowManager.ts b/src/main/services/WindowManager.ts index 394236b..861ab55 100644 --- a/src/main/services/WindowManager.ts +++ b/src/main/services/WindowManager.ts @@ -62,6 +62,7 @@ export class WindowManager extends Service { height: 670, show: false, autoHideMenuBar: true, + frame: false, // Custom title bar icon: this.opts.icon, title: input.title, webPreferences: { @@ -80,6 +81,14 @@ export class WindowManager extends Service { win.show() }) + // Notify renderer about maximize state changes + win.on('maximize', () => { + win.webContents.send('window:maximized-changed', true) + }) + win.on('unmaximize', () => { + win.webContents.send('window:maximized-changed', false) + }) + win.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } @@ -142,5 +151,46 @@ export class WindowManager extends Service { const ok = this.navigateWindow(win, route) return ok ? { success: true } : { success: false, message: 'Window not found' } }) + + // Window controls + this.mainCtx.handle('window:minimize', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (win) win.minimize() + }) + + this.mainCtx.handle('window:maximize', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (win) { + if (win.isMaximized()) { + win.unmaximize() + return false + } else { + win.maximize() + return true + } + } + return false + }) + + this.mainCtx.handle('window:close', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (win) win.close() + }) + + this.mainCtx.handle('window:isMaximized', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + return win ? win.isMaximized() : false + }) + + this.mainCtx.handle('window:toggle-devtools', (event) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (win) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + } + }) } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 30ffdfc..b8fc4fa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -73,6 +73,16 @@ const api = { ipcRenderer.invoke('window:open', input), navigateWindow: (input: { key?: string; route: string }) => ipcRenderer.invoke('window:navigate', input), + windowMinimize: () => ipcRenderer.invoke('window:minimize'), + windowMaximize: () => ipcRenderer.invoke('window:maximize'), + windowClose: () => ipcRenderer.invoke('window:close'), + windowIsMaximized: () => ipcRenderer.invoke('window:isMaximized'), + onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => { + const subscription = (_event: any, maximized: boolean) => callback(maximized) + ipcRenderer.on('window:maximized-changed', subscription) + return () => ipcRenderer.removeListener('window:maximized-changed', subscription) + }, + toggleDevTools: () => ipcRenderer.invoke('window:toggle-devtools'), // Logger queryLogs: (lines?: number) => ipcRenderer.invoke('log:query', lines), diff --git a/src/preload/types.ts b/src/preload/types.ts index 757bb23..a6f7e69 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -116,6 +116,12 @@ export interface electronApi { options?: any }) => Promise> navigateWindow: (input: { key?: string; route: string }) => Promise> + windowMinimize: () => Promise + windowMaximize: () => Promise + windowClose: () => Promise + windowIsMaximized: () => Promise + onWindowMaximizedChanged: (callback: (maximized: boolean) => void) => () => void + toggleDevTools: () => Promise // Logger queryLogs: (lines?: number) => Promise> diff --git a/src/renderer/src/assets/logo.svg b/src/renderer/src/assets/logo.svg new file mode 100644 index 0000000..39a5ac6 --- /dev/null +++ b/src/renderer/src/assets/logo.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 1e231f3..0c15db9 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -501,6 +501,33 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
{(window as any).electron?.process?.versions?.chrome || '-'}
Node
{(window as any).electron?.process?.versions?.node || '-'}
+
IPC 状态
+
+ + {(window as any).api ? '已连接' : '未连接 (Preload 失败)'} + +
+
环境
+
+ + {import.meta.env.DEV ? 'Development' : 'Production'} + +
+
+ +
+
diff --git a/src/renderer/src/components/TitleBar.tsx b/src/renderer/src/components/TitleBar.tsx new file mode 100644 index 0000000..a8322ef --- /dev/null +++ b/src/renderer/src/components/TitleBar.tsx @@ -0,0 +1,135 @@ +import { Button } from 'tdesign-react' +import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react' +import { useEffect, useState } from 'react' +import electronLogo from '../assets/electron.svg' + +interface TitleBarProps { + children?: React.ReactNode +} + +export function TitleBar({ children }: TitleBarProps): React.JSX.Element { + const [isMaximized, setIsMaximized] = useState(false) + + useEffect(() => { + if (!(window as any).api) return // Check initial state + ;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v)) + + // Subscribe to changes + const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => { + setIsMaximized(maximized) + }) + return cleanup + }, []) + + const minimize = () => { + ;(window as any).api?.windowMinimize() + } + + const maximize = async () => { + if (!(window as any).api) return + const newState = await (window as any).api.windowMaximize() + setIsMaximized(newState) + } + + const close = () => { + ;(window as any).api?.windowClose() + } + + return ( +
+
+ logo + SecScore +
+ +
+ +
+ {children} +
+ +
+ + + +
+ + +
+ ) +} diff --git a/src/renderer/src/components/WindowControls.tsx b/src/renderer/src/components/WindowControls.tsx new file mode 100644 index 0000000..89da08b --- /dev/null +++ b/src/renderer/src/components/WindowControls.tsx @@ -0,0 +1,80 @@ +import { Button } from 'tdesign-react' +import { RemoveIcon, RectangleIcon, CloseIcon, FullscreenExitIcon } from 'tdesign-icons-react' +import { useEffect, useState } from 'react' + +export function WindowControls(): React.JSX.Element { + const [isMaximized, setIsMaximized] = useState(false) + + useEffect(() => { + if (!(window as any).api) return // Check initial state + ;(window as any).api.windowIsMaximized().then((v: boolean) => setIsMaximized(v)) + + // Subscribe to changes + const cleanup = (window as any).api.onWindowMaximizedChanged((maximized: boolean) => { + setIsMaximized(maximized) + }) + return cleanup + }, []) + + const minimize = () => { + ;(window as any).api?.windowMinimize() + } + + const maximize = async () => { + if (!(window as any).api) return + const newState = await (window as any).api.windowMaximize() + setIsMaximized(newState) + } + + const close = () => { + ;(window as any).api?.windowClose() + } + + return ( +
+ + + + +
+ ) +} From f5da065d8c4d100a241e45a359cd42cd01e004da Mon Sep 17 00:00:00 2001 From: Linkon <116425752+Linkon-lcw@users.noreply.github.com> Date: Sun, 18 Jan 2026 21:18:20 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(home):=20=E6=B7=BB=E5=8A=A0=E5=AD=A6?= =?UTF-8?q?=E7=94=9F=E7=A7=AF=E5=88=86=E4=B8=BB=E9=A1=B5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现学生积分主页功能,包含以下主要特性: - 学生卡片展示,支持按姓名、姓氏分组和积分排行三种排序方式 - 支持拼音搜索学生姓名 - 积分操作对话框,包含快捷选项和自定义分值功能 - 积分变更预览和操作记录 - 响应式布局适配不同屏幕尺寸 --- src/renderer/src/App.tsx | 4 +- src/renderer/src/components/ContentArea.tsx | 5 +- src/renderer/src/components/Home.tsx | 733 ++++++++++++++++++++ src/renderer/src/components/Sidebar.tsx | 5 +- 4 files changed, 743 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/Home.tsx diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a295f92..193ba21 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -19,13 +19,14 @@ function MainContent(): React.JSX.Element { const activeMenu = useMemo(() => { const p = location.pathname + if (p === '/' || p.startsWith('/home')) return 'home' if (p.startsWith('/students')) return 'students' if (p.startsWith('/score')) return 'score' if (p.startsWith('/leaderboard')) return 'leaderboard' if (p.startsWith('/settlements')) return 'settlements' if (p.startsWith('/reasons')) return 'reasons' if (p.startsWith('/settings')) return 'settings' - return 'score' + return 'home' }, [location.pathname]) useEffect(() => { @@ -80,6 +81,7 @@ function MainContent(): React.JSX.Element { const onMenuChange = (v: string | number) => { const key = String(v) + if (key === 'home') navigate('/') if (key === 'students') navigate('/students') if (key === 'score') navigate('/score') if (key === 'leaderboard') navigate('/leaderboard') diff --git a/src/renderer/src/components/ContentArea.tsx b/src/renderer/src/components/ContentArea.tsx index e402809..abca767 100644 --- a/src/renderer/src/components/ContentArea.tsx +++ b/src/renderer/src/components/ContentArea.tsx @@ -1,5 +1,6 @@ import { Layout, Space, Button, Tag } 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' @@ -85,7 +86,7 @@ export function ContentArea({ - } /> + } /> } /> } /> } /> } /> - } /> + } /> diff --git a/src/renderer/src/components/Home.tsx b/src/renderer/src/components/Home.tsx new file mode 100644 index 0000000..9288228 --- /dev/null +++ b/src/renderer/src/components/Home.tsx @@ -0,0 +1,733 @@ +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { + Card, + Space, + Button, + Tag, + Input, + Select, + Dialog, + MessagePlugin, + InputNumber, + Divider +} from 'tdesign-react' +import { SearchIcon, AddIcon, MinusIcon, DeleteIcon } from 'tdesign-icons-react' +import { match, pinyin } from 'pinyin-pro' + +interface student { + id: number + name: string + score: number +} + +interface reason { + id: number + content: string + delta: number + category: string +} + +type SortType = 'alphabet' | 'surname' | 'score' + +export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { + const [students, setStudents] = useState([]) + const [reasons, setReasons] = useState([]) + const [loading, setLoading] = useState(false) + const [sortType, setSortType] = useState('alphabet') + const [searchKeyword, setSearchKeyword] = useState('') + + // 滚动容器引用 + const scrollContainerRef = useRef(null) + const groupRefs = useRef>({}) + + // 操作框状态 + const [selectedStudent, setSelectedStudent] = useState(null) + const [operationVisible, setOperationVisible] = useState(false) + const [customScore, setCustomScore] = useState(undefined) + const [reasonContent, setReasonContent] = useState('') + const [submitLoading, setSubmitLoading] = useState(false) + + const emitDataUpdated = (category: 'events' | 'students' | 'reasons' | 'all') => { + window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } })) + } + + const fetchData = useCallback(async () => { + if (!(window as any).api) return + 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) + setLoading(false) + }, []) + + useEffect(() => { + fetchData() + const onDataUpdated = (e: any) => { + const category = e?.detail?.category + if (category === 'students' || category === 'reasons' || category === 'all') { + fetchData() + } + } + 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 '' + return name.charAt(0) + } + + // 获取拼音首字母 + const getFirstLetter = (name: string) => { + if (!name) return '' + const firstChar = name.charAt(0) + // 如果是英文字母 + if (/^[a-zA-Z]$/.test(firstChar)) return firstChar.toUpperCase() + // 如果是中文,转拼音 + const py = pinyin(firstChar, { pattern: 'first', toneType: 'none' }) + return py ? py.toUpperCase() : '#' + } + + // 获取展示用的文字 + const getDisplayText = (name: string) => { + if (!name) return '' + return name.length > 2 ? name.substring(name.length - 2) : name + } + + // 拼音匹配 + const matchStudentName = useCallback((name: string, keyword: string) => { + const q0 = keyword.trim().toLowerCase() + if (!q0) return true + + const nameLower = String(name).toLowerCase() + if (nameLower.includes(q0)) return true + + const q1 = q0.replace(/\s+/g, '') + if (q1 && nameLower.replace(/\s+/g, '').includes(q1)) return true + + try { + const m0 = match(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 + } + + return false + }, []) + + // 过滤和排序学生 + const sortedStudents = useMemo(() => { + let filtered = students.filter((s) => matchStudentName(s.name, searchKeyword)) + + switch (sortType) { + case 'alphabet': + return filtered.sort((a, b) => { + const pyA = pinyin(a.name, { toneType: 'none' }) + const pyB = pinyin(b.name, { toneType: 'none' }) + return pyA.localeCompare(pyB) + }) + case 'surname': + return filtered.sort((a, b) => { + const surnameA = getSurname(a.name) + const surnameB = getSurname(b.name) + if (surnameA === surnameB) { + return a.name.localeCompare(b.name, 'zh-CN') + } + return surnameA.localeCompare(surnameB, 'zh-CN') + }) + case 'score': + return filtered.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name, 'zh-CN')) + default: + return filtered + } + }, [students, searchKeyword, sortType, matchStudentName]) + + // 分组显示 + const groupedStudents = useMemo(() => { + if (sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) { + return [{ key: 'all', students: sortedStudents }] + } + + const groups: Record = {} + sortedStudents.forEach((s) => { + const key = sortType === 'alphabet' ? getFirstLetter(s.name) : getSurname(s.name) + if (!groups[key]) groups[key] = [] + groups[key].push(s) + }) + + return Object.entries(groups) + .sort(([a], [b]) => a.localeCompare(b, 'zh-CN')) + .map(([key, students]) => ({ key, students })) + }, [sortedStudents, sortType, searchKeyword]) + + // 按分类分组的理由 + const groupedReasons = useMemo(() => { + const groups: Record = {} + reasons.forEach((r) => { + const cat = r.category || '其他' + if (!groups[cat]) groups[cat] = [] + groups[cat].push(r) + }) + return Object.entries(groups).sort(([a], [b]) => { + if (a === '其他') return 1 + if (b === '其他') return -1 + return a.localeCompare(b, 'zh-CN') + }) + }, [reasons]) + + // 生成头像颜色 + const getAvatarColor = (name: string) => { + const colors = [ + '#FF6B6B', + '#4ECDC4', + '#45B7D1', + '#FFA07A', + '#98D8C8', + '#F7DC6F', + '#BB8FCE', + '#85C1E2', + '#F8B739', + '#52B788' + ] + let hash = 0 + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash) + } + const index = Math.abs(hash) % colors.length + return colors[index] + } + + // 跳转到指定分组 + const scrollToGroup = (key: string) => { + const element = groupRefs.current[key] + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + } + + // 打开操作框 + const openOperation = (student: student) => { + if (!canEdit) { + MessagePlugin.error('当前为只读权限') + return + } + setSelectedStudent(student) + setCustomScore(undefined) + setReasonContent('') + setOperationVisible(true) + } + + // 提交积分 + const handleSubmit = async () => { + if (!(window as any).api || !selectedStudent) return + if (!canEdit) { + MessagePlugin.error('当前为只读权限') + return + } + + const delta = customScore + if (delta === undefined || !Number.isFinite(delta)) { + MessagePlugin.warning('请选择或输入分值') + return + } + + const content = reasonContent || (delta > 0 ? '加分' : delta < 0 ? '扣分' : '积分变更') + + setSubmitLoading(true) + const res = await (window as any).api.createEvent({ + student_name: selectedStudent.name, + reason_content: content, + delta: delta + }) + + if (res.success) { + MessagePlugin.success('积分提交成功') + setOperationVisible(false) + fetchData() + emitDataUpdated('events') + } else { + MessagePlugin.error(res.message || '提交失败') + } + setSubmitLoading(false) + } + + // 快捷理由选择 + const handleReasonSelect = (reason: reason) => { + setCustomScore(reason.delta) + setReasonContent(reason.content) + } + + // 渲染学生卡片 + const renderStudentCard = (student: student, index: number) => { + const avatarText = getDisplayText(student.name) + const avatarColor = getAvatarColor(student.name) + + // 排行榜勋章 + let rankBadge: string | null = null + if (sortType === 'score' && !searchKeyword) { + if (index === 0) rankBadge = '🥇' + else if (index === 1) rankBadge = '🥈' + else if (index === 2) rankBadge = '🥉' + } + + return ( +
openOperation(student)} + style={{ cursor: 'pointer', position: 'relative' }} + > + + {rankBadge && ( +
+ {rankBadge} +
+ )} +
+
1 ? '14px' : '18px', + flexShrink: 0, + boxShadow: `0 4px 10px ${avatarColor}40` + }} + > + {avatarText} +
+
+
+ {student.name} +
+
+ 0 ? 'success' : student.score < 0 ? 'danger' : 'default'} + variant="light-outline" + size="small" + style={{ fontWeight: 'bold' }} + > + {student.score > 0 ? `+${student.score}` : student.score} + +
+
+
+
+
+ ) + } + + // 渲染分组学生卡片 + const renderGroupedCards = () => { + return groupedStudents.map((group) => ( +
(groupRefs.current[group.key] = el)} + > + {group.key !== 'all' && ( +
+ {group.key} + + ({group.students.length} 人) + +
+ )} +
+ {group.students.map((student, idx) => renderStudentCard(student, idx))} +
+
+ )) + } + + // 渲染快速导航 + const renderQuickNav = () => { + if (groupedStudents.length <= 1 || sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) return null + + return ( +
+ {groupedStudents.map((group) => ( +
scrollToGroup(group.key)} + style={{ + width: '24px', + height: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '11px', + fontWeight: 'bold', + color: 'var(--td-brand-color)', + cursor: 'pointer', + borderRadius: '50%', + transition: 'background 0.2s' + }} + onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--td-brand-color-1)')} + onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')} + > + {group.key} +
+ ))} +
+ ) + } + + return ( +
+ {/* 顶部工具栏 */} +
+
+

学生积分主页

+

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

+
+ + + {/* 搜索 */} + } + clearable + style={{ width: '220px' }} + /> + + {/* 排序方式 */} + + +
+ + {/* 快速导航 */} + {renderQuickNav()} + + {/* 学生卡片网格 */} +
+ {loading ? ( +
+
加载中...
+
+ ) : sortedStudents.length === 0 ? ( +
+
+ {searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'} +
+ {searchKeyword && ( + + )} +
+ ) : ( + renderGroupedCards() + )} +
+ + {/* 操作框 */} + setOperationVisible(false)} + onConfirm={handleSubmit} + confirmBtn={{ content: '提交操作', loading: submitLoading }} + width="560px" + destroyOnClose + top="10%" + > + {selectedStudent && ( +
+ {/* 当前状态 */} +
+
+
+ {getDisplayText(selectedStudent.name)} +
+ {selectedStudent.name} +
+
+ 当前积分: + 0 ? 'success' : selectedStudent.score < 0 ? 'danger' : 'default'} + variant="light" + style={{ fontWeight: 'bold' }} + > + {selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score} + +
+
+ + {/* 快捷理由 */} + {groupedReasons.length > 0 && ( +
+
+ 快捷选项 + +
+
+ {groupedReasons.map(([category, items]) => ( +
+
+ {category} +
+ + {items.map((r) => ( + + ))} + +
+ ))} +
+
+ )} + + {/* 自定义分值 */} +
+
+ 调整分值 + +
+
+ {[-5, -3, -2, -1, 1, 2, 3, 5, 10].map((num) => ( + + ))} + +
+
+ setCustomScore(v as number)} + min={-99} + max={99} + step={1} + style={{ width: '140px' }} + placeholder="自定义分值" + /> + + 可在输入框微调特输入任意分值 + +
+
+ + {/* 理由内容 */} +
+
+ 操作理由 + +
+ setReasonContent('')} style={{ cursor: 'pointer' }} /> : undefined} + /> +
+ + {/* 变动预览 */} + {customScore !== undefined && ( +
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' }}> + {customScore > 0 ? `+${customScore}` : customScore} + {' '} + 分 + + {reasonContent ? `理由:${reasonContent}` : '(无理由)'} + +
+
+ )} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index f90b11e..26aca87 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { Layout, Menu } from 'tdesign-react' -import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon } from 'tdesign-icons-react' +import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon, HomeIcon } from 'tdesign-icons-react' import appLogo from '../assets/logo.svg' const { Aside } = Layout @@ -60,6 +60,9 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
+ }> + 主页 + } disabled={permission !== 'admin'}> 学生管理 From db699f0c419007ed8e5606dacdfd52ed4f158691 Mon Sep 17 00:00:00 2001 From: Linkon <116425752+Linkon-lcw@users.noreply.github.com> Date: Sun, 18 Jan 2026 21:29:29 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(=E5=AF=BC=E8=88=AA):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=BF=AB=E9=80=9F=E5=AF=BC=E8=88=AA=E6=BB=91=E5=8A=A8?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在快速导航组件中添加滑动选择功能,提升操作便捷性 - 移除单个导航项的点击事件,改为父容器统一处理 - 禁用全局文本选择,但保留输入框的文本选择功能 - 将滚动行为从平滑滚动改为自动滚动以提高响应速度 --- src/renderer/src/assets/main.css | 6 +++ src/renderer/src/components/Home.tsx | 58 +++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index fb02805..9617bae 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -8,6 +8,12 @@ body, margin: 0; padding: 0; overflow: hidden; + user-select: none; /* 禁用文本选择 */ +} + +/* 如果某些输入框需要允许选择,可以单独开启 */ +input, textarea { + user-select: text; } .ss-sidebar { diff --git a/src/renderer/src/components/Home.tsx b/src/renderer/src/components/Home.tsx index 9288228..0724486 100644 --- a/src/renderer/src/components/Home.tsx +++ b/src/renderer/src/components/Home.tsx @@ -210,7 +210,7 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const scrollToGroup = (key: string) => { const element = groupRefs.current[key] if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }) + element.scrollIntoView({ behavior: 'auto', block: 'start' }) } } @@ -398,12 +398,56 @@ 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 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 onNavMouseDown = (e: React.MouseEvent) => { + isNavDragging.current = true + handleNavAction(e.clientY) + document.addEventListener('mousemove', onGlobalMouseMove) + document.addEventListener('mouseup', onGlobalMouseUp) + } + + const onGlobalMouseMove = (e: MouseEvent) => { + if (isNavDragging.current) { + handleNavAction(e.clientY) + } + } + + const onGlobalMouseUp = () => { + isNavDragging.current = false + document.removeEventListener('mousemove', onGlobalMouseMove) + document.removeEventListener('mouseup', onGlobalMouseUp) + } + // 渲染快速导航 const renderQuickNav = () => { if (groupedStudents.length <= 1 || sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) return null return (
= ({ canEdit }) => { transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', - gap: '2px', backgroundColor: 'var(--ss-card-bg)', padding: '8px 4px', borderRadius: '20px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', zIndex: 100, maxHeight: '80vh', - overflowY: 'auto', - border: '1px solid var(--ss-border-color)' + border: '1px solid var(--ss-border-color)', + cursor: 'pointer', + userSelect: 'none' }} > {groupedStudents.map((group) => (
scrollToGroup(group.key)} style={{ width: '24px', height: '24px', @@ -435,12 +478,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { fontSize: '11px', fontWeight: 'bold', color: 'var(--td-brand-color)', - cursor: 'pointer', borderRadius: '50%', - transition: 'background 0.2s' + pointerEvents: 'none' // 让事件由父容器统一处理 }} - onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--td-brand-color-1)')} - onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')} > {group.key}
From 30250811970a0f8d717970c2ada52e95556e49d2 Mon Sep 17 00:00:00 2001 From: Linkon <116425752+Linkon-lcw@users.noreply.github.com> Date: Sun, 18 Jan 2026 21:35:25 +0800 Subject: [PATCH 6/7] =?UTF-8?q?refactor(Home):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=8E=B7=E5=8F=96=E5=92=8C=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 fetchData 支持静默模式避免重复加载 - 提取提交逻辑到 performSubmit 方法复用 - 简化快捷理由选择的处理流程 --- src/renderer/src/components/Home.tsx | 67 ++++++++++++++++------------ 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/Home.tsx b/src/renderer/src/components/Home.tsx index 0724486..f670209 100644 --- a/src/renderer/src/components/Home.tsx +++ b/src/renderer/src/components/Home.tsx @@ -51,9 +51,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } })) } - const fetchData = useCallback(async () => { + const fetchData = useCallback(async (silent = false) => { if (!(window as any).api) return - setLoading(true) + if (!silent) setLoading(true) const [stuRes, reaRes] = await Promise.all([ (window as any).api.queryStudents({}), (window as any).api.queryReasons() @@ -61,15 +61,20 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { if (stuRes.success) setStudents(stuRes.data) if (reaRes.success) setReasons(reaRes.data) - setLoading(false) + if (!silent) setLoading(false) }, []) useEffect(() => { fetchData() const onDataUpdated = (e: any) => { const category = e?.detail?.category - if (category === 'students' || category === 'reasons' || category === 'all') { - fetchData() + if ( + category === 'students' || + category === 'reasons' || + category === 'all' || + category === 'events' + ) { + fetchData(true) } } window.addEventListener('ss:data-updated', onDataUpdated as any) @@ -226,14 +231,36 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { setOperationVisible(true) } - // 提交积分 - const handleSubmit = async () => { - if (!(window as any).api || !selectedStudent) return + // 核心提交逻辑 + const performSubmit = async (student: student, delta: number, content: string) => { + if (!(window as any).api) return if (!canEdit) { MessagePlugin.error('当前为只读权限') return } + setSubmitLoading(true) + const res = await (window as any).api.createEvent({ + student_name: student.name, + reason_content: content, + delta: delta + }) + + if (res.success) { + MessagePlugin.success(`已为 ${student.name} ${delta > 0 ? '加' : '扣'}${Math.abs(delta)}分`) + setOperationVisible(false) + fetchData(true) + emitDataUpdated('events') + } else { + MessagePlugin.error(res.message || '提交失败') + } + setSubmitLoading(false) + } + + // 手动点击确定按钮提交(用于自定义分值) + const handleSubmit = async () => { + if (!selectedStudent) return + const delta = customScore if (delta === undefined || !Number.isFinite(delta)) { MessagePlugin.warning('请选择或输入分值') @@ -241,29 +268,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { } const content = reasonContent || (delta > 0 ? '加分' : delta < 0 ? '扣分' : '积分变更') - - setSubmitLoading(true) - const res = await (window as any).api.createEvent({ - student_name: selectedStudent.name, - reason_content: content, - delta: delta - }) - - if (res.success) { - MessagePlugin.success('积分提交成功') - setOperationVisible(false) - fetchData() - emitDataUpdated('events') - } else { - MessagePlugin.error(res.message || '提交失败') - } - setSubmitLoading(false) + await performSubmit(selectedStudent, delta, content) } - // 快捷理由选择 + // 快捷理由选择:点击即提交 const handleReasonSelect = (reason: reason) => { - setCustomScore(reason.delta) - setReasonContent(reason.content) + if (!selectedStudent) return + performSubmit(selectedStudent, reason.delta, reason.content) } // 渲染学生卡片 From 8c2377c5b5fa817c5eb09cd75f6baf450a555ff5 Mon Sep 17 00:00:00 2001 From: Linkon <116425752+Linkon-lcw@users.noreply.github.com> Date: Sun, 18 Jan 2026 21:38:31 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(Home):=20=E4=BC=98=E5=8C=96=E7=A7=AF?= =?UTF-8?q?=E5=88=86=E6=9B=B4=E6=96=B0=E9=80=BB=E8=BE=91=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E8=B7=B3=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将积分变动改为本地状态维护,不再全量刷新数据 移除events类别的数据更新触发,防止页面闪烁 --- src/renderer/src/components/Home.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/Home.tsx b/src/renderer/src/components/Home.tsx index f670209..efd1ef6 100644 --- a/src/renderer/src/components/Home.tsx +++ b/src/renderer/src/components/Home.tsx @@ -68,12 +68,9 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { fetchData() const onDataUpdated = (e: any) => { const category = e?.detail?.category - if ( - category === 'students' || - category === 'reasons' || - category === 'all' || - category === 'events' - ) { + // 仅在学生名单、理由列表或全量更新时才重新拉取数据 + // 积分变动(events)现在由本地状态维护,不再触发全量刷新以防止页面跳动 + if (category === 'students' || category === 'reasons' || category === 'all') { fetchData(true) } } @@ -249,7 +246,13 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { if (res.success) { MessagePlugin.success(`已为 ${student.name} ${delta > 0 ? '加' : '扣'}${Math.abs(delta)}分`) setOperationVisible(false) - fetchData(true) + + // 【核心改进】本地增量更新分数,避免全量刷新导致的闪烁和滚动重置 + setStudents((prev) => + prev.map((s) => (s.id === student.id ? { ...s, score: s.score + delta } : s)) + ) + + // 通知其他组件数据已更新(但不在此处重复 fetchData) emitDataUpdated('events') } else { MessagePlugin.error(res.message || '提交失败')