(groupRefs.current[group.key] = el)}
+ ref={(el) => {
+ groupRefs.current[group.key] = el
+ }}
>
{group.key !== 'all' && (
= ({ canEdit }) => {
fontWeight: 'bold',
color: 'var(--ss-text-main)',
marginBottom: '16px',
- paddingLeft: '4px',
display: 'flex',
alignItems: 'center',
gap: '8px',
@@ -889,4 +893,4 @@ export const Home: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
)
-}
\ No newline at end of file
+}
diff --git a/src/renderer/src/components/Leaderboard.tsx b/src/renderer/src/components/Leaderboard.tsx
index 57cd4e5..b3c29bd 100644
--- a/src/renderer/src/components/Leaderboard.tsx
+++ b/src/renderer/src/components/Leaderboard.tsx
@@ -32,12 +32,17 @@ export const Leaderboard: React.FC = () => {
const fetchRankings = useCallback(async () => {
if (!(window as any).api) return
setLoading(true)
- const res = await (window as any).api.queryLeaderboard({ range: timeRange })
- if (res.success && res.data) {
- setStartTime(res.data.startTime)
- setData(res.data.rows)
+ try {
+ const res = await (window as any).api.queryLeaderboard({ range: timeRange })
+ if (res.success && res.data) {
+ setStartTime(res.data.startTime)
+ setData(res.data.rows)
+ }
+ } catch (e) {
+ console.error('Failed to fetch rankings:', e)
+ } finally {
+ setLoading(false)
}
- setLoading(false)
}, [timeRange])
useEffect(() => {
@@ -77,48 +82,51 @@ export const Leaderboard: React.FC = () => {
}
const handleExport = () => {
- const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月'
+ // 使用 requestIdleCallback 或 setTimeout 避免阻塞 UI
+ setTimeout(() => {
+ const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月'
- const sanitizeCell = (v: unknown) => {
- if (typeof v !== 'string') return v
- if (/^[=+\-@]/.test(v)) return `'${v}`
- return v
- }
+ const sanitizeCell = (v: unknown) => {
+ if (typeof v !== 'string') return v
+ if (/^[=+\-@]/.test(v)) return `'${v}`
+ return v
+ }
- const sheetData = [
- ['排名', '姓名', '总积分', `${title}变化`],
- ...data.map((item, index) => [
- index + 1,
- sanitizeCell(item.name),
- item.score,
- item.range_change
- ])
- ]
+ const sheetData = [
+ ['排名', '姓名', '总积分', `${title}变化`],
+ ...data.map((item, index) => [
+ index + 1,
+ sanitizeCell(item.name),
+ item.score,
+ item.range_change
+ ])
+ ]
- const ws = XLSX.utils.aoa_to_sheet(sheetData)
- ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
+ const ws = XLSX.utils.aoa_to_sheet(sheetData)
+ ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
- const wb = XLSX.utils.book_new()
- XLSX.utils.book_append_sheet(wb, ws, '排行榜')
+ const wb = XLSX.utils.book_new()
+ XLSX.utils.book_append_sheet(wb, ws, '排行榜')
- const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
- const blob = new Blob([xlsxBytes], {
- type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
- })
+ const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
+ const blob = new Blob([xlsxBytes], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ })
- const link = document.createElement('a')
- const url = URL.createObjectURL(blob)
- link.setAttribute('href', url)
- link.setAttribute(
- 'download',
- `排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
- )
- link.style.visibility = 'hidden'
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- URL.revokeObjectURL(url)
- MessagePlugin.success('导出成功')
+ const link = document.createElement('a')
+ const url = URL.createObjectURL(blob)
+ link.setAttribute('href', url)
+ link.setAttribute(
+ 'download',
+ `排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
+ )
+ link.style.visibility = 'hidden'
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+ MessagePlugin.success('导出成功')
+ }, 0)
}
const columns: PrimaryTableCol
[] = [
@@ -215,6 +223,8 @@ export const Leaderboard: React.FC = () => {
loading={loading}
bordered
hover
+ pagination={{ pageSize: 30, total: data.length, defaultCurrent: 1 }}
+ scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
className="ss-table-center"
style={{ color: 'var(--ss-text-main)' }}
/>
diff --git a/src/renderer/src/components/ReasonManager.tsx b/src/renderer/src/components/ReasonManager.tsx
index 552e097..e951db8 100644
--- a/src/renderer/src/components/ReasonManager.tsx
+++ b/src/renderer/src/components/ReasonManager.tsx
@@ -35,11 +35,16 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const fetchReasons = useCallback(async () => {
if (!(window as any).api) return
setLoading(true)
- const res = await (window as any).api.queryReasons()
- if (res.success && res.data) {
- setData(res.data)
+ try {
+ const res = await (window as any).api.queryReasons()
+ if (res.success && res.data) {
+ setData(res.data)
+ }
+ } catch (e) {
+ console.error('Failed to fetch reasons:', e)
+ } finally {
+ setLoading(false)
}
- setLoading(false)
}, [])
useEffect(() => {
@@ -149,6 +154,8 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
loading={loading}
bordered
hover
+ pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }}
+ scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/>
diff --git a/src/renderer/src/components/ScoreManager.tsx b/src/renderer/src/components/ScoreManager.tsx
index fe3c44a..e1afd14 100644
--- a/src/renderer/src/components/ScoreManager.tsx
+++ b/src/renderer/src/components/ScoreManager.tsx
@@ -93,17 +93,25 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const fetchData = useCallback(async () => {
if (!(window as any).api) return
- setLoading(true)
- const [stuRes, reaRes, eveRes] = await Promise.all([
- (window as any).api.queryStudents({}),
- (window as any).api.queryReasons(),
- (window as any).api.queryEvents({ limit: 10 })
- ])
+ // 使用 setTimeout 避免 UI 阻塞
+ setTimeout(async () => {
+ setLoading(true)
+ try {
+ const [stuRes, reaRes, eveRes] = await Promise.all([
+ (window as any).api.queryStudents({}),
+ (window as any).api.queryReasons(),
+ (window as any).api.queryEvents({ limit: 10 })
+ ])
- if (stuRes.success) setStudents(stuRes.data)
- if (reaRes.success) setReasons(reaRes.data)
- if (eveRes.success) setEvents(eveRes.data)
- setLoading(false)
+ if (stuRes.success) setStudents(stuRes.data)
+ if (reaRes.success) setReasons(reaRes.data)
+ if (eveRes.success) setEvents(eveRes.data)
+ } catch (e) {
+ console.error('Failed to fetch data:', e)
+ } finally {
+ setLoading(false)
+ }
+ }, 0)
}, [])
useEffect(() => {
@@ -328,7 +336,8 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
rowKey="uuid"
loading={loading}
size="small"
- pagination={{ pageSize: 5, total: events.length }}
+ pagination={{ pageSize: 5, total: events.length, defaultCurrent: 1 }}
+ scroll={{ type: 'virtual' }}
style={{ color: 'var(--ss-text-main)' }}
/>
diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx
index a1ddd90..075bcbe 100644
--- a/src/renderer/src/components/Settings.tsx
+++ b/src/renderer/src/components/Settings.tsx
@@ -13,6 +13,7 @@ import {
MessagePlugin
} from 'tdesign-react'
import { useTheme } from '../contexts/ThemeContext'
+import { useThemeEditor } from '../contexts/ThemeEditorContext'
type permissionLevel = 'admin' | 'points' | 'view'
type appSettings = {
@@ -23,6 +24,7 @@ type appSettings = {
export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => {
const { themes, currentTheme, setTheme } = useTheme()
+ const { startEditing } = useThemeEditor()
const [activeTab, setActiveTab] = useState('appearance')
const [settings, setSettings] = useState({
is_wizard_completed: false,
@@ -261,15 +263,27 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
-
+
+
+
+
+
diff --git a/src/renderer/src/components/SettlementHistory.tsx b/src/renderer/src/components/SettlementHistory.tsx
index c64a35e..5d6524f 100644
--- a/src/renderer/src/components/SettlementHistory.tsx
+++ b/src/renderer/src/components/SettlementHistory.tsx
@@ -36,13 +36,18 @@ export const SettlementHistory: React.FC = () => {
const fetchSettlements = useCallback(async () => {
if (!(window as any).api) return
setLoading(true)
- const res = await (window as any).api.querySettlements()
- setLoading(false)
- if (!res.success) {
- MessagePlugin.error(res.message || '查询失败')
- return
+ try {
+ const res = await (window as any).api.querySettlements()
+ if (!res.success) {
+ MessagePlugin.error(res.message || '查询失败')
+ return
+ }
+ setSettlements(res.data || [])
+ } catch (e) {
+ console.error('Failed to fetch settlements:', e)
+ } finally {
+ setLoading(false)
}
- setSettlements(res.data || [])
}, [])
useEffect(() => {
@@ -62,14 +67,19 @@ export const SettlementHistory: React.FC = () => {
if (!(window as any).api) return
setSelectedId(id)
setDetailLoading(true)
- const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
- setDetailLoading(false)
- if (!res.success || !res.data) {
- MessagePlugin.error(res.message || '查询失败')
- return
+ try {
+ const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
+ if (!res.success || !res.data) {
+ MessagePlugin.error(res.message || '查询失败')
+ return
+ }
+ setSelectedSettlement(res.data.settlement)
+ setRows(res.data.rows || [])
+ } catch (e) {
+ console.error('Failed to fetch settlement leaderboard:', e)
+ } finally {
+ setDetailLoading(false)
}
- setSelectedSettlement(res.data.settlement)
- setRows(res.data.rows || [])
}
const columns: PrimaryTableCol[] = useMemo(
@@ -120,6 +130,8 @@ export const SettlementHistory: React.FC = () => {
loading={detailLoading}
bordered
hover
+ pagination={{ pageSize: 50, total: rows.length, defaultCurrent: 1 }}
+ scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
style={{ color: 'var(--ss-text-main)' }}
/>
diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx
index 9f30b8f..1c80c65 100644
--- a/src/renderer/src/components/Sidebar.tsx
+++ b/src/renderer/src/components/Sidebar.tsx
@@ -7,7 +7,7 @@ import {
ViewListIcon,
HomeIcon
} from 'tdesign-icons-react'
-import appLogo from '../assets/logo.svg'
+import appLogo from '../assets/logoHD.svg'
const { Aside } = Layout
diff --git a/src/renderer/src/components/StudentManager.tsx b/src/renderer/src/components/StudentManager.tsx
index 46d8033..d829734 100644
--- a/src/renderer/src/components/StudentManager.tsx
+++ b/src/renderer/src/components/StudentManager.tsx
@@ -1,7 +1,13 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input } from 'tdesign-react'
import type { PrimaryTableCol } from 'tdesign-react'
-import * as XLSX from 'xlsx'
+
+// 创建 XLSX Worker
+const createXlsxWorker = () => {
+ return new Worker(new URL('../workers/xlsxWorker.ts', import.meta.url), {
+ type: 'module'
+ })
+}
interface student {
id: number
@@ -20,8 +26,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [xlsxAoa, setXlsxAoa] = useState([])
const [xlsxSelectedCol, setXlsxSelectedCol] = useState(null)
const xlsxInputRef = useRef(null)
+ const xlsxWorkerRef = useRef(null)
const [form] = Form.useForm()
+ // 初始化 Worker
+ useEffect(() => {
+ xlsxWorkerRef.current = createXlsxWorker()
+ return () => {
+ xlsxWorkerRef.current?.terminate()
+ }
+ }, [])
+
const emitDataUpdated = (category: 'students' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } }))
}
@@ -29,11 +44,16 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const fetchStudents = useCallback(async () => {
if (!(window as any).api) return
setLoading(true)
- const res = await (window as any).api.queryStudents({})
- if (res.success && res.data) {
- setData(res.data)
+ try {
+ const res = await (window as any).api.queryStudents({})
+ if (res.success && res.data) {
+ setData(res.data)
+ }
+ } catch (e) {
+ console.error('Failed to fetch students:', e)
+ } finally {
+ setLoading(false)
}
- setLoading(false)
}, [])
useEffect(() => {
@@ -123,30 +143,40 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
}
const parseXlsxFile = async (file: File) => {
+ if (!xlsxWorkerRef.current) {
+ MessagePlugin.error('Worker 未初始化')
+ return
+ }
+
setXlsxLoading(true)
try {
const buf = await file.arrayBuffer()
- const wb = XLSX.read(buf, { type: 'array' })
- const firstSheetName = wb.SheetNames?.[0]
- if (!firstSheetName) {
- MessagePlugin.error('xlsx 中未找到工作表')
- return
- }
- const ws = wb.Sheets[firstSheetName]
- const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, defval: '' }) as any[][]
- if (!Array.isArray(aoa) || aoa.length === 0) {
- MessagePlugin.error('xlsx 内容为空')
- return
+
+ // 使用 Worker 处理文件解析,避免阻塞主线程
+ xlsxWorkerRef.current.postMessage({
+ type: 'parseXlsx',
+ data: { buffer: buf }
+ })
+
+ // 监听 Worker 消息
+ const handleMessage = (event: MessageEvent) => {
+ if (event.data.type === 'success') {
+ setXlsxFileName(file.name)
+ setXlsxAoa(event.data.data)
+ setXlsxSelectedCol(null)
+ setXlsxVisible(true)
+ setImportVisible(false)
+ setXlsxLoading(false)
+ } else if (event.data.type === 'error') {
+ MessagePlugin.error(event.data.error || '解析 xlsx 失败')
+ setXlsxLoading(false)
+ }
+ xlsxWorkerRef.current?.removeEventListener('message', handleMessage)
}
- setXlsxFileName(file.name)
- setXlsxAoa(aoa)
- setXlsxSelectedCol(null)
- setXlsxVisible(true)
- setImportVisible(false)
+ xlsxWorkerRef.current.addEventListener('message', handleMessage)
} catch (e: any) {
MessagePlugin.error(e?.message || '解析 xlsx 失败')
- } finally {
setXlsxLoading(false)
}
}
@@ -314,6 +344,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
loading={loading}
bordered
hover
+ pagination={{ pageSize: 50, total: data.length, defaultCurrent: 1 }}
+ scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/>
diff --git a/src/renderer/src/components/ThemeEditor.tsx b/src/renderer/src/components/ThemeEditor.tsx
new file mode 100644
index 0000000..8786d18
--- /dev/null
+++ b/src/renderer/src/components/ThemeEditor.tsx
@@ -0,0 +1,231 @@
+import React, { useEffect } from 'react'
+import {
+ Drawer,
+ Form,
+ Input,
+ Select,
+ ColorPicker,
+ Button,
+ Space,
+ Divider,
+ Row,
+ Col
+} from 'tdesign-react'
+import { useThemeEditor } from '../contexts/ThemeEditorContext'
+import { useTheme } from '../contexts/ThemeContext'
+import { generateColorMap } from '../utils/color'
+import { themeConfig } from '../../../preload/types'
+
+const variableGroups = {
+ background: {
+ title: '背景颜色',
+ items: [
+ { key: '--ss-bg-color', label: '全局背景' },
+ { key: '--ss-card-bg', label: '卡片背景' },
+ { key: '--ss-header-bg', label: '顶部栏背景' },
+ { key: '--ss-sidebar-bg', label: '侧边栏背景' }
+ ]
+ },
+ text: {
+ title: '文字颜色',
+ items: [
+ { key: '--ss-text-main', label: '主要文字' },
+ { key: '--ss-text-secondary', label: '次要文字' },
+ { key: '--ss-sidebar-active-text', label: '侧边栏选中文字' }
+ ]
+ },
+ border: {
+ title: '边框与分割线',
+ items: [{ key: '--ss-border-color', label: '通用边框' }]
+ },
+ interaction: {
+ title: '交互状态',
+ items: [
+ { key: '--ss-item-hover', label: '列表悬浮' },
+ { key: '--ss-sidebar-active-bg', label: '侧边栏选中背景' }
+ ]
+ }
+}
+
+export const ThemeEditor: React.FC = () => {
+ const {
+ isEditing,
+ editingTheme,
+ updateEditingTheme,
+ updateConfig,
+ saveEditingTheme,
+ cancelEditing
+ } = useThemeEditor()
+
+ const { currentTheme } = useTheme()
+
+ // 实时预览逻辑
+ useEffect(() => {
+ if (!isEditing || !editingTheme) return
+
+ const applyPreview = (theme: themeConfig) => {
+ const { tdesign, custom } = theme.config
+ const root = document.documentElement
+
+ // 1. 设置 TDesign 模式
+ root.setAttribute('theme-mode', theme.mode)
+
+ // 2. 设置 TDesign 品牌色
+ if (tdesign.brandColor) {
+ const colorMap = generateColorMap(tdesign.brandColor, theme.mode)
+ Object.entries(colorMap).forEach(([key, value]) => {
+ root.style.setProperty(key, value)
+ })
+ }
+
+ // 3. 应用自定义变量
+ Object.entries(custom).forEach(([key, value]) => {
+ root.style.setProperty(key, value)
+ })
+ }
+
+ applyPreview(editingTheme)
+ }, [editingTheme, isEditing])
+
+ // 关闭时恢复原有主题
+ useEffect(() => {
+ if (!isEditing && currentTheme) {
+ const { tdesign, custom } = currentTheme.config
+ const root = document.documentElement
+
+ root.setAttribute('theme-mode', currentTheme.mode)
+
+ if (tdesign.brandColor) {
+ const colorMap = generateColorMap(tdesign.brandColor, currentTheme.mode)
+ Object.entries(colorMap).forEach(([key, value]) => {
+ root.style.setProperty(key, value)
+ })
+ }
+
+ Object.entries(custom).forEach(([key, value]) => {
+ root.style.setProperty(key, value)
+ })
+ }
+ }, [isEditing, currentTheme])
+
+ if (!editingTheme) return null
+
+ return (
+
+
+
+
+ }
+ destroyOnClose
+ >
+
+
+ )
+}
diff --git a/src/renderer/src/components/TitleBar.tsx b/src/renderer/src/components/TitleBar.tsx
index a8322ef..2f87ff8 100644
--- a/src/renderer/src/components/TitleBar.tsx
+++ b/src/renderer/src/components/TitleBar.tsx
@@ -109,7 +109,11 @@ export function TitleBar({ children }: TitleBarProps): React.JSX.Element {
onClick={maximize}
style={{ width: '46px', height: '32px', borderRadius: 0 }}
>
- {isMaximized ? : }
+ {isMaximized ? (
+
+ ) : (
+
+ )}