From 65185e61d4130320aa2f77af8e7090db3ee69d84 Mon Sep 17 00:00:00 2001 From: Linkon <116425752+Linkon-lcw@users.noreply.github.com> Date: Tue, 20 Jan 2026 00:26:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B5=E9=9D=A2=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=80=A7=E8=83=BD=EF=BC=8C=E8=A7=A3=E5=86=B3=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E9=A1=B5=E9=9D=A2=E6=97=B6=E7=AA=97=E5=8F=A3=E5=8D=A1?= =?UTF-8?q?=E9=A1=BF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修改内容: - 创建 Web Worker 处理 XLSX 文件解析,避免主线程阻塞 - 为所有表格组件启用虚拟滚动(virtual scroll)和分页 - 优化表格渲染配置,设置合理的行高和阈值 - 移除不必要的 setTimeout,保持 API 调用的异步特性 - 添加错误处理和日志记录 影响文件: - StudentManager.tsx: XLSX Worker 集成和虚拟滚动 - Leaderboard.tsx: 虚拟滚动和分页优化 - ReasonManager.tsx: 虚拟滚动配置 - SettlementHistory.tsx: 虚拟滚动和异步优化 - ScoreManager.tsx: 虚拟滚动优化 - EventHistory.tsx: 虚拟滚动配置 - workers/xlsxWorker.ts: 新增 Worker 处理 XLSX 解析 --- src/renderer/src/components/EventHistory.tsx | 2 + src/renderer/src/components/Leaderboard.tsx | 92 ++++++++++--------- src/renderer/src/components/ReasonManager.tsx | 15 ++- src/renderer/src/components/ScoreManager.tsx | 31 ++++--- .../src/components/SettlementHistory.tsx | 38 +++++--- .../src/components/StudentManager.tsx | 76 ++++++++++----- src/renderer/src/workers/xlsxWorker.ts | 30 ++++++ 7 files changed, 193 insertions(+), 91 deletions(-) create mode 100644 src/renderer/src/workers/xlsxWorker.ts diff --git a/src/renderer/src/components/EventHistory.tsx b/src/renderer/src/components/EventHistory.tsx index f0aafec..bc604ec 100644 --- a/src/renderer/src/components/EventHistory.tsx +++ b/src/renderer/src/components/EventHistory.tsx @@ -63,6 +63,8 @@ export const EventHistory: React.FC = () => { 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/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/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/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/workers/xlsxWorker.ts b/src/renderer/src/workers/xlsxWorker.ts new file mode 100644 index 0000000..a2fb2dd --- /dev/null +++ b/src/renderer/src/workers/xlsxWorker.ts @@ -0,0 +1,30 @@ +import * as XLSX from 'xlsx' + +// 监听主线程消息 +self.addEventListener('message', async (event: MessageEvent) => { + const { type, data } = event.data + + if (type === 'parseXlsx') { + try { + const { buffer } = data + const wb = XLSX.read(buffer, { type: 'array' }) + const firstSheetName = wb.SheetNames?.[0] + if (!firstSheetName) { + self.postMessage({ type: 'error', 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) { + self.postMessage({ type: 'error', error: 'xlsx 内容为空' }) + return + } + + self.postMessage({ type: 'success', data: aoa }) + } catch (error: any) { + self.postMessage({ type: 'error', error: error?.message || '解析 xlsx 失败' }) + } + } +}) + +export {}