优化页面加载性能,解决切换页面时窗口卡顿问题

修改内容:
- 创建 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 解析
This commit is contained in:
Linkon
2026-01-20 00:26:42 +08:00
parent 8b4da6a44f
commit 65185e61d4
7 changed files with 193 additions and 91 deletions
@@ -63,6 +63,8 @@ export const EventHistory: React.FC = () => {
loading={loading} loading={loading}
bordered bordered
hover 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)' }} style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/> />
</div> </div>
+51 -41
View File
@@ -32,12 +32,17 @@ export const Leaderboard: React.FC = () => {
const fetchRankings = useCallback(async () => { const fetchRankings = useCallback(async () => {
if (!(window as any).api) return if (!(window as any).api) return
setLoading(true) setLoading(true)
const res = await (window as any).api.queryLeaderboard({ range: timeRange }) try {
if (res.success && res.data) { const res = await (window as any).api.queryLeaderboard({ range: timeRange })
setStartTime(res.data.startTime) if (res.success && res.data) {
setData(res.data.rows) setStartTime(res.data.startTime)
setData(res.data.rows)
}
} catch (e) {
console.error('Failed to fetch rankings:', e)
} finally {
setLoading(false)
} }
setLoading(false)
}, [timeRange]) }, [timeRange])
useEffect(() => { useEffect(() => {
@@ -77,48 +82,51 @@ export const Leaderboard: React.FC = () => {
} }
const handleExport = () => { const handleExport = () => {
const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月' // 使用 requestIdleCallback 或 setTimeout 避免阻塞 UI
setTimeout(() => {
const title = timeRange === 'today' ? '今天' : timeRange === 'week' ? '本周' : '本月'
const sanitizeCell = (v: unknown) => { const sanitizeCell = (v: unknown) => {
if (typeof v !== 'string') return v if (typeof v !== 'string') return v
if (/^[=+\-@]/.test(v)) return `'${v}` if (/^[=+\-@]/.test(v)) return `'${v}`
return v return v
} }
const sheetData = [ const sheetData = [
['排名', '姓名', '总积分', `${title}变化`], ['排名', '姓名', '总积分', `${title}变化`],
...data.map((item, index) => [ ...data.map((item, index) => [
index + 1, index + 1,
sanitizeCell(item.name), sanitizeCell(item.name),
item.score, item.score,
item.range_change item.range_change
]) ])
] ]
const ws = XLSX.utils.aoa_to_sheet(sheetData) const ws = XLSX.utils.aoa_to_sheet(sheetData)
ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }] ws['!cols'] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
const wb = XLSX.utils.book_new() const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '排行榜') XLSX.utils.book_append_sheet(wb, ws, '排行榜')
const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }) const xlsxBytes = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
const blob = new Blob([xlsxBytes], { const blob = new Blob([xlsxBytes], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}) })
const link = document.createElement('a') const link = document.createElement('a')
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
link.setAttribute('href', url) link.setAttribute('href', url)
link.setAttribute( link.setAttribute(
'download', 'download',
`排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx` `排行榜_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
) )
link.style.visibility = 'hidden' link.style.visibility = 'hidden'
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
MessagePlugin.success('导出成功') MessagePlugin.success('导出成功')
}, 0)
} }
const columns: PrimaryTableCol<studentRank>[] = [ const columns: PrimaryTableCol<studentRank>[] = [
@@ -215,6 +223,8 @@ export const Leaderboard: React.FC = () => {
loading={loading} loading={loading}
bordered bordered
hover hover
pagination={{ pageSize: 30, total: data.length, defaultCurrent: 1 }}
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
className="ss-table-center" className="ss-table-center"
style={{ color: 'var(--ss-text-main)' }} style={{ color: 'var(--ss-text-main)' }}
/> />
+11 -4
View File
@@ -35,11 +35,16 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const fetchReasons = useCallback(async () => { const fetchReasons = useCallback(async () => {
if (!(window as any).api) return if (!(window as any).api) return
setLoading(true) setLoading(true)
const res = await (window as any).api.queryReasons() try {
if (res.success && res.data) { const res = await (window as any).api.queryReasons()
setData(res.data) if (res.success && res.data) {
setData(res.data)
}
} catch (e) {
console.error('Failed to fetch reasons:', e)
} finally {
setLoading(false)
} }
setLoading(false)
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -149,6 +154,8 @@ export const ReasonManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
loading={loading} loading={loading}
bordered bordered
hover 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)' }} style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/> />
+20 -11
View File
@@ -93,17 +93,25 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
if (!(window as any).api) return if (!(window as any).api) return
setLoading(true) // 使用 setTimeout 避免 UI 阻塞
const [stuRes, reaRes, eveRes] = await Promise.all([ setTimeout(async () => {
(window as any).api.queryStudents({}), setLoading(true)
(window as any).api.queryReasons(), try {
(window as any).api.queryEvents({ limit: 10 }) 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 (stuRes.success) setStudents(stuRes.data)
if (reaRes.success) setReasons(reaRes.data) if (reaRes.success) setReasons(reaRes.data)
if (eveRes.success) setEvents(eveRes.data) if (eveRes.success) setEvents(eveRes.data)
setLoading(false) } catch (e) {
console.error('Failed to fetch data:', e)
} finally {
setLoading(false)
}
}, 0)
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -328,7 +336,8 @@ export const ScoreManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
rowKey="uuid" rowKey="uuid"
loading={loading} loading={loading}
size="small" 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)' }} style={{ color: 'var(--ss-text-main)' }}
/> />
</Collapse.Panel> </Collapse.Panel>
@@ -36,13 +36,18 @@ export const SettlementHistory: React.FC = () => {
const fetchSettlements = useCallback(async () => { const fetchSettlements = useCallback(async () => {
if (!(window as any).api) return if (!(window as any).api) return
setLoading(true) setLoading(true)
const res = await (window as any).api.querySettlements() try {
setLoading(false) const res = await (window as any).api.querySettlements()
if (!res.success) { if (!res.success) {
MessagePlugin.error(res.message || '查询失败') MessagePlugin.error(res.message || '查询失败')
return return
}
setSettlements(res.data || [])
} catch (e) {
console.error('Failed to fetch settlements:', e)
} finally {
setLoading(false)
} }
setSettlements(res.data || [])
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -62,14 +67,19 @@ export const SettlementHistory: React.FC = () => {
if (!(window as any).api) return if (!(window as any).api) return
setSelectedId(id) setSelectedId(id)
setDetailLoading(true) setDetailLoading(true)
const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id }) try {
setDetailLoading(false) const res = await (window as any).api.querySettlementLeaderboard({ settlement_id: id })
if (!res.success || !res.data) { if (!res.success || !res.data) {
MessagePlugin.error(res.message || '查询失败') MessagePlugin.error(res.message || '查询失败')
return 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<settlementLeaderboardRow>[] = useMemo( const columns: PrimaryTableCol<settlementLeaderboardRow>[] = useMemo(
@@ -120,6 +130,8 @@ export const SettlementHistory: React.FC = () => {
loading={detailLoading} loading={detailLoading}
bordered bordered
hover hover
pagination={{ pageSize: 50, total: rows.length, defaultCurrent: 1 }}
scroll={{ type: 'virtual', rowHeight: 48, threshold: 100 }}
style={{ color: 'var(--ss-text-main)' }} style={{ color: 'var(--ss-text-main)' }}
/> />
</Card> </Card>
+54 -22
View File
@@ -1,7 +1,13 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react' import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Table, Button, Space, MessagePlugin, Dialog, Form, Input } from 'tdesign-react' import { Table, Button, Space, MessagePlugin, Dialog, Form, Input } from 'tdesign-react'
import type { PrimaryTableCol } 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 { interface student {
id: number id: number
@@ -20,8 +26,17 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [xlsxAoa, setXlsxAoa] = useState<any[][]>([]) const [xlsxAoa, setXlsxAoa] = useState<any[][]>([])
const [xlsxSelectedCol, setXlsxSelectedCol] = useState<number | null>(null) const [xlsxSelectedCol, setXlsxSelectedCol] = useState<number | null>(null)
const xlsxInputRef = useRef<HTMLInputElement | null>(null) const xlsxInputRef = useRef<HTMLInputElement | null>(null)
const xlsxWorkerRef = useRef<Worker | null>(null)
const [form] = Form.useForm() const [form] = Form.useForm()
// 初始化 Worker
useEffect(() => {
xlsxWorkerRef.current = createXlsxWorker()
return () => {
xlsxWorkerRef.current?.terminate()
}
}, [])
const emitDataUpdated = (category: 'students' | 'all') => { const emitDataUpdated = (category: 'students' | 'all') => {
window.dispatchEvent(new CustomEvent('ss:data-updated', { detail: { category } })) 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 () => { const fetchStudents = useCallback(async () => {
if (!(window as any).api) return if (!(window as any).api) return
setLoading(true) setLoading(true)
const res = await (window as any).api.queryStudents({}) try {
if (res.success && res.data) { const res = await (window as any).api.queryStudents({})
setData(res.data) if (res.success && res.data) {
setData(res.data)
}
} catch (e) {
console.error('Failed to fetch students:', e)
} finally {
setLoading(false)
} }
setLoading(false)
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -123,30 +143,40 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
} }
const parseXlsxFile = async (file: File) => { const parseXlsxFile = async (file: File) => {
if (!xlsxWorkerRef.current) {
MessagePlugin.error('Worker 未初始化')
return
}
setXlsxLoading(true) setXlsxLoading(true)
try { try {
const buf = await file.arrayBuffer() const buf = await file.arrayBuffer()
const wb = XLSX.read(buf, { type: 'array' })
const firstSheetName = wb.SheetNames?.[0] // 使用 Worker 处理文件解析,避免阻塞主线程
if (!firstSheetName) { xlsxWorkerRef.current.postMessage({
MessagePlugin.error('xlsx 中未找到工作表') type: 'parseXlsx',
return data: { buffer: buf }
} })
const ws = wb.Sheets[firstSheetName]
const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, defval: '' }) as any[][] // 监听 Worker 消息
if (!Array.isArray(aoa) || aoa.length === 0) { const handleMessage = (event: MessageEvent) => {
MessagePlugin.error('xlsx 内容为空') if (event.data.type === 'success') {
return 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) xlsxWorkerRef.current.addEventListener('message', handleMessage)
setXlsxAoa(aoa)
setXlsxSelectedCol(null)
setXlsxVisible(true)
setImportVisible(false)
} catch (e: any) { } catch (e: any) {
MessagePlugin.error(e?.message || '解析 xlsx 失败') MessagePlugin.error(e?.message || '解析 xlsx 失败')
} finally {
setXlsxLoading(false) setXlsxLoading(false)
} }
} }
@@ -314,6 +344,8 @@ export const StudentManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
loading={loading} loading={loading}
bordered bordered
hover 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)' }} style={{ backgroundColor: 'var(--ss-card-bg)', color: 'var(--ss-text-main)' }}
/> />
+30
View File
@@ -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 {}