feat(home): 添加学生积分主页功能

实现学生积分主页功能,包含以下主要特性:
- 学生卡片展示,支持按姓名、姓氏分组和积分排行三种排序方式
- 支持拼音搜索学生姓名
- 积分操作对话框,包含快捷选项和自定义分值功能
- 积分变更预览和操作记录
- 响应式布局适配不同屏幕尺寸
This commit is contained in:
Linkon
2026-01-18 21:18:20 +08:00
parent a6f83fdfc3
commit f5da065d8c
4 changed files with 743 additions and 4 deletions
+3 -1
View File
@@ -19,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(() => {
@@ -80,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')
+3 -2
View File
@@ -1,5 +1,6 @@
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'
@@ -85,7 +86,7 @@ export function ContentArea({
<Content style={{ flex: 1, overflowY: 'auto' }}>
<Routes>
<Route path="/" element={<Navigate to="/score" replace />} />
<Route path="/" element={<Home canEdit={permission === 'admin' || permission === 'points'} />} />
<Route path="/students" element={<StudentManager canEdit={permission === 'admin'} />} />
<Route
path="/score"
@@ -95,7 +96,7 @@ export function ContentArea({
<Route path="/settlements" element={<SettlementHistory />} />
<Route path="/reasons" element={<ReasonManager canEdit={permission === 'admin'} />} />
<Route path="/settings" element={<Settings permission={permission} />} />
<Route path="*" element={<Navigate to="/score" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Content>
</Layout>
+733
View File
@@ -0,0 +1,733 @@
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<student[]>([])
const [reasons, setReasons] = useState<reason[]>([])
const [loading, setLoading] = useState(false)
const [sortType, setSortType] = useState<SortType>('alphabet')
const [searchKeyword, setSearchKeyword] = useState('')
// 滚动容器引用
const scrollContainerRef = useRef<HTMLDivElement>(null)
const groupRefs = useRef<Record<string, HTMLDivElement | null>>({})
// 操作框状态
const [selectedStudent, setSelectedStudent] = useState<student | null>(null)
const [operationVisible, setOperationVisible] = useState(false)
const [customScore, setCustomScore] = useState<number | undefined>(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 () => {
if (!(window as any).api) return
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)
setLoading(false)
}, [])
useEffect(() => {
fetchData()
const onDataUpdated = (e: any) => {
const category = e?.detail?.category
if (category === 'students' || category === 'reasons' || category === 'all') {
fetchData()
}
}
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<string, student[]> = {}
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<string, reason[]> = {}
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: 'smooth', block: 'start' })
}
}
// 打开操作框
const openOperation = (student: student) => {
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
return
}
setSelectedStudent(student)
setCustomScore(undefined)
setReasonContent('')
setOperationVisible(true)
}
// 提交积分
const handleSubmit = async () => {
if (!(window as any).api || !selectedStudent) return
if (!canEdit) {
MessagePlugin.error('当前为只读权限')
return
}
const delta = customScore
if (delta === undefined || !Number.isFinite(delta)) {
MessagePlugin.warning('请选择或输入分值')
return
}
const content = reasonContent || (delta > 0 ? '加分' : delta < 0 ? '扣分' : '积分变更')
setSubmitLoading(true)
const res = await (window as any).api.createEvent({
student_name: selectedStudent.name,
reason_content: content,
delta: delta
})
if (res.success) {
MessagePlugin.success('积分提交成功')
setOperationVisible(false)
fetchData()
emitDataUpdated('events')
} else {
MessagePlugin.error(res.message || '提交失败')
}
setSubmitLoading(false)
}
// 快捷理由选择
const handleReasonSelect = (reason: reason) => {
setCustomScore(reason.delta)
setReasonContent(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 (
<div
key={student.id}
onClick={() => openOperation(student)}
style={{ cursor: 'pointer', position: 'relative' }}
>
<Card
style={{
backgroundColor: 'var(--ss-card-bg)',
transition: 'all 0.2s cubic-bezier(0.38, 0, 0.24, 1)',
border: '1px solid var(--ss-border-color)',
overflow: 'visible'
}}
hover
>
{rankBadge && (
<div
style={{
position: 'absolute',
top: '-10px',
left: '-10px',
fontSize: '24px',
zIndex: 1
}}
>
{rankBadge}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '12px',
backgroundColor: avatarColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 'bold',
fontSize: avatarText.length > 1 ? '14px' : '18px',
flexShrink: 0,
boxShadow: `0 4px 10px ${avatarColor}40`
}}
>
{avatarText}
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<div
style={{
fontWeight: 600,
fontSize: '15px',
color: 'var(--ss-text-main)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{student.name}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px' }}>
<Tag
theme={student.score > 0 ? 'success' : student.score < 0 ? 'danger' : 'default'}
variant="light-outline"
size="small"
style={{ fontWeight: 'bold' }}
>
{student.score > 0 ? `+${student.score}` : student.score}
</Tag>
</div>
</div>
</div>
</Card>
</div>
)
}
// 渲染分组学生卡片
const renderGroupedCards = () => {
return groupedStudents.map((group) => (
<div
key={group.key}
style={{ marginBottom: '32px' }}
ref={(el) => (groupRefs.current[group.key] = el)}
>
{group.key !== 'all' && (
<div
style={{
fontSize: '18px',
fontWeight: 'bold',
color: 'var(--ss-text-main)',
marginBottom: '16px',
paddingLeft: '4px',
display: 'flex',
alignItems: 'center',
gap: '8px',
borderLeft: '4px solid var(--td-brand-color)',
paddingLeft: '12px'
}}
>
<span style={{ color: 'var(--td-brand-color)' }}>{group.key}</span>
<span style={{ fontSize: '12px', color: 'var(--ss-text-secondary)', fontWeight: 'normal' }}>
({group.students.length} )
</span>
</div>
)}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '16px'
}}
>
{group.students.map((student, idx) => renderStudentCard(student, idx))}
</div>
</div>
))
}
// 渲染快速导航
const renderQuickNav = () => {
if (groupedStudents.length <= 1 || sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) return null
return (
<div
style={{
position: 'fixed',
right: '12px',
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
flexDirection: 'column',
gap: '2px',
backgroundColor: 'var(--ss-card-bg)',
padding: '8px 4px',
borderRadius: '20px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
zIndex: 100,
maxHeight: '80vh',
overflowY: 'auto',
border: '1px solid var(--ss-border-color)'
}}
>
{groupedStudents.map((group) => (
<div
key={group.key}
onClick={() => scrollToGroup(group.key)}
style={{
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: 'bold',
color: 'var(--td-brand-color)',
cursor: 'pointer',
borderRadius: '50%',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'var(--td-brand-color-1)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
>
{group.key}
</div>
))}
</div>
)
}
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto', position: 'relative' }}>
{/* 顶部工具栏 */}
<div
style={{
marginBottom: '32px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '16px',
flexWrap: 'wrap'
}}
>
<div>
<h2 style={{ margin: 0, color: 'var(--ss-text-main)', fontSize: '24px' }}></h2>
<p style={{ margin: '4px 0 0', color: 'var(--ss-text-secondary)', fontSize: '13px' }}>
{students.length}
</p>
</div>
<Space size="medium">
{/* 搜索 */}
<Input
value={searchKeyword}
onChange={setSearchKeyword}
placeholder="搜索姓名/拼音..."
prefixIcon={<SearchIcon />}
clearable
style={{ width: '220px' }}
/>
{/* 排序方式 */}
<Select
value={sortType}
onChange={(v) => setSortType(v as SortType)}
style={{ width: '140px' }}
autoWidth
>
<Select.Option value="alphabet" label="姓名排序" />
<Select.Option value="surname" label="姓氏分组" />
<Select.Option value="score" label="积分排行" />
</Select>
</Space>
</div>
{/* 快速导航 */}
{renderQuickNav()}
{/* 学生卡片网格 */}
<div style={{ minHeight: '400px' }} ref={scrollContainerRef}>
{loading ? (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<div style={{ color: 'var(--ss-text-secondary)' }}>...</div>
</div>
) : sortedStudents.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '100px 0',
backgroundColor: 'var(--ss-card-bg)',
borderRadius: '12px',
border: '1px dashed var(--ss-border-color)'
}}
>
<div style={{ fontSize: '16px', color: 'var(--ss-text-secondary)' }}>
{searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'}
</div>
{searchKeyword && (
<Button variant="text" theme="primary" onClick={() => setSearchKeyword('')} style={{ marginTop: '8px' }}>
</Button>
)}
</div>
) : (
renderGroupedCards()
)}
</div>
{/* 操作框 */}
<Dialog
header={`积分操作:${selectedStudent?.name}`}
visible={operationVisible}
onClose={() => setOperationVisible(false)}
onConfirm={handleSubmit}
confirmBtn={{ content: '提交操作', loading: submitLoading }}
width="560px"
destroyOnClose
top="10%"
>
{selectedStudent && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', padding: '8px 0' }}>
{/* 当前状态 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
backgroundColor: 'var(--ss-bg-color)',
borderRadius: '8px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: getAvatarColor(selectedStudent.name),
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: 'bold'
}}
>
{getDisplayText(selectedStudent.name)}
</div>
<span style={{ fontWeight: 600 }}>{selectedStudent.name}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ color: 'var(--ss-text-secondary)', fontSize: '13px' }}></span>
<Tag
theme={selectedStudent.score > 0 ? 'success' : selectedStudent.score < 0 ? 'danger' : 'default'}
variant="light"
style={{ fontWeight: 'bold' }}
>
{selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score}
</Tag>
</div>
</div>
{/* 快捷理由 */}
{groupedReasons.length > 0 && (
<div>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '14px' }}></span>
<Divider style={{ flex: 1, margin: 0 }} />
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
maxHeight: '240px',
overflowY: 'auto',
paddingRight: '4px'
}}
>
{groupedReasons.map(([category, items]) => (
<div key={category}>
<div
style={{
fontSize: '12px',
color: 'var(--ss-text-secondary)',
marginBottom: '6px',
paddingLeft: '2px'
}}
>
{category}
</div>
<Space breakLine size="small">
{items.map((r) => (
<Button
key={r.id}
variant="outline"
size="small"
onClick={() => handleReasonSelect(r)}
style={{
borderColor: r.delta > 0 ? 'var(--td-success-color-3)' : r.delta < 0 ? 'var(--td-error-color-3)' : undefined
}}
>
{r.content}{' '}
<span
style={{
marginLeft: '4px',
color: r.delta > 0 ? 'var(--td-success-color)' : r.delta < 0 ? 'var(--td-error-color)' : 'inherit',
fontWeight: 'bold'
}}
>
{r.delta > 0 ? `+${r.delta}` : r.delta}
</span>
</Button>
))}
</Space>
</div>
))}
</div>
</div>
)}
{/* 自定义分值 */}
<div>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '14px' }}></span>
<Divider style={{ flex: 1, margin: 0 }} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
{[-5, -3, -2, -1, 1, 2, 3, 5, 10].map((num) => (
<Button
key={num}
size="small"
variant={customScore === num ? 'base' : 'outline'}
theme={num > 0 ? 'success' : 'danger'}
onClick={() => setCustomScore(num)}
style={{ minWidth: '42px' }}
>
{num > 0 ? `+${num}` : num}
</Button>
))}
<Button
size="small"
variant="outline"
onClick={() => setCustomScore(0)}
style={{ minWidth: '42px' }}
>
0
</Button>
</div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<InputNumber
value={customScore}
onChange={(v) => setCustomScore(v as number)}
min={-99}
max={99}
step={1}
style={{ width: '140px' }}
placeholder="自定义分值"
/>
<span style={{ fontSize: '13px', color: 'var(--ss-text-secondary)' }}>
</span>
</div>
</div>
{/* 理由内容 */}
<div>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '14px' }}></span>
<Divider style={{ flex: 1, margin: 0 }} />
</div>
<Input
value={reasonContent}
onChange={setReasonContent}
placeholder="输入加分/扣分的原因(可选)"
suffixIcon={reasonContent ? <DeleteIcon onClick={() => setReasonContent('')} style={{ cursor: 'pointer' }} /> : undefined}
/>
</div>
{/* 变动预览 */}
{customScore !== undefined && (
<div
style={{
padding: '16px',
backgroundColor: customScore > 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'
}}
>
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '4px', color: 'var(--ss-text-main)' }}>
</div>
<div style={{ fontSize: '15px' }}>
{selectedStudent.name}{' '}
<span style={{ fontWeight: 'bold', color: customScore > 0 ? 'var(--td-success-color)' : customScore < 0 ? 'var(--td-error-color)' : 'inherit' }}>
{customScore > 0 ? `+${customScore}` : customScore}
</span>{' '}
<span style={{ color: 'var(--ss-text-secondary)', marginLeft: '8px' }}>
{reasonContent ? `理由:${reasonContent}` : '(无理由)'}
</span>
</div>
</div>
)}
</div>
)}
</Dialog>
</div>
)
}
+4 -1
View File
@@ -1,5 +1,5 @@
import { Layout, Menu } from 'tdesign-react'
import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon } from 'tdesign-icons-react'
import { UserIcon, SettingIcon, HistoryIcon, RootListIcon, ViewListIcon, HomeIcon } from 'tdesign-icons-react'
import appLogo from '../assets/logo.svg'
const { Aside } = Layout
@@ -60,6 +60,9 @@ export function Sidebar({ activeMenu, permission, onMenuChange }: SidebarProps):
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<Menu value={activeMenu} onChange={onMenuChange} style={{ width: '100%', border: 'none' }}>
<Menu.MenuItem value="home" icon={<HomeIcon />}>
</Menu.MenuItem>
<Menu.MenuItem value="students" icon={<UserIcon />} disabled={permission !== 'admin'}>
</Menu.MenuItem>