mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
优化页面加载性能,解决切换页面时窗口卡顿问题
修改内容: - 创建 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:
@@ -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)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<studentRank>[] = [
|
||||
@@ -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)' }}
|
||||
/>
|
||||
|
||||
@@ -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)' }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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)' }}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
|
||||
@@ -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<settlementLeaderboardRow>[] = 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)' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -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<any[][]>([])
|
||||
const [xlsxSelectedCol, setXlsxSelectedCol] = useState<number | null>(null)
|
||||
const xlsxInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const xlsxWorkerRef = useRef<Worker | null>(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)' }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user