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/.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/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/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 f3b1b6e..7789203 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -71,6 +71,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/App.tsx b/src/renderer/src/App.tsx index d218bcc..193ba21 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() @@ -26,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(() => { @@ -87,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') @@ -95,99 +90,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)} /> + + + + + + + + + + + + + + + + + + + + + + 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/ContentArea.tsx b/src/renderer/src/components/ContentArea.tsx new file mode 100644 index 0000000..abca767 --- /dev/null +++ b/src/renderer/src/components/ContentArea.tsx @@ -0,0 +1,104 @@ +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' +import { ScoreManager } from './ScoreManager' +import { Leaderboard } from './Leaderboard' +import { SettlementHistory } from './SettlementHistory' +import { WindowControls } from './WindowControls' + +const { Content } = Layout + +interface ContentAreaProps { + permission: 'admin' | 'points' | 'view' + hasAnyPassword: boolean + onAuthClick: () => 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/Home.tsx b/src/renderer/src/components/Home.tsx new file mode 100644 index 0000000..efd1ef6 --- /dev/null +++ b/src/renderer/src/components/Home.tsx @@ -0,0 +1,787 @@ +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 (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 '' + 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: 'auto', block: 'start' }) + } + } + + // 打开操作框 + const openOperation = (student: student) => { + if (!canEdit) { + MessagePlugin.error('当前为只读权限') + return + } + setSelectedStudent(student) + setCustomScore(undefined) + setReasonContent('') + setOperationVisible(true) + } + + // 核心提交逻辑 + 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) + + // 【核心改进】本地增量更新分数,避免全量刷新导致的闪烁和滚动重置 + setStudents((prev) => + prev.map((s) => (s.id === student.id ? { ...s, score: s.score + delta } : s)) + ) + + // 通知其他组件数据已更新(但不在此处重复 fetchData) + 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('请选择或输入分值') + return + } + + const content = reasonContent || (delta > 0 ? '加分' : delta < 0 ? '扣分' : '积分变更') + await performSubmit(selectedStudent, delta, content) + } + + // 快捷理由选择:点击即提交 + const handleReasonSelect = (reason: reason) => { + if (!selectedStudent) return + performSubmit(selectedStudent, reason.delta, 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 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 ( +
+ {groupedStudents.map((group) => ( +
+ {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/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/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx new file mode 100644 index 0000000..26aca87 --- /dev/null +++ b/src/renderer/src/components/Sidebar.tsx @@ -0,0 +1,88 @@ +import { Layout, Menu } from 'tdesign-react' +import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon, HomeIcon } 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 ( + + ) +} 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 ( +
+ + + + +
+ ) +} 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 @@ } } } -