diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a295f92..193ba21 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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') diff --git a/src/renderer/src/components/ContentArea.tsx b/src/renderer/src/components/ContentArea.tsx index e402809..abca767 100644 --- a/src/renderer/src/components/ContentArea.tsx +++ b/src/renderer/src/components/ContentArea.tsx @@ -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({ - } /> + } /> } /> } /> } /> } /> - } /> + } /> diff --git a/src/renderer/src/components/Home.tsx b/src/renderer/src/components/Home.tsx new file mode 100644 index 0000000..9288228 --- /dev/null +++ b/src/renderer/src/components/Home.tsx @@ -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([]) + const [reasons, setReasons] = useState([]) + const [loading, setLoading] = useState(false) + const [sortType, setSortType] = useState('alphabet') + const [searchKeyword, setSearchKeyword] = useState('') + + // 滚动容器引用 + const scrollContainerRef = useRef(null) + const groupRefs = useRef>({}) + + // 操作框状态 + const [selectedStudent, setSelectedStudent] = useState(null) + const [operationVisible, setOperationVisible] = useState(false) + const [customScore, setCustomScore] = useState(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 = {} + 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 = {} + 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 ( +
openOperation(student)} + style={{ cursor: 'pointer', position: 'relative' }} + > + + {rankBadge && ( +
+ {rankBadge} +
+ )} +
+
1 ? '14px' : '18px', + flexShrink: 0, + boxShadow: `0 4px 10px ${avatarColor}40` + }} + > + {avatarText} +
+
+
+ {student.name} +
+
+ 0 ? 'success' : student.score < 0 ? 'danger' : 'default'} + variant="light-outline" + size="small" + style={{ fontWeight: 'bold' }} + > + {student.score > 0 ? `+${student.score}` : student.score} + +
+
+
+
+
+ ) + } + + // 渲染分组学生卡片 + const renderGroupedCards = () => { + return groupedStudents.map((group) => ( +
(groupRefs.current[group.key] = el)} + > + {group.key !== 'all' && ( +
+ {group.key} + + ({group.students.length} 人) + +
+ )} +
+ {group.students.map((student, idx) => renderStudentCard(student, idx))} +
+
+ )) + } + + // 渲染快速导航 + const renderQuickNav = () => { + if (groupedStudents.length <= 1 || sortType === 'score' || (sortType === 'alphabet' && searchKeyword)) return null + + return ( +
+ {groupedStudents.map((group) => ( +
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} +
+ ))} +
+ ) + } + + return ( +
+ {/* 顶部工具栏 */} +
+
+

学生积分主页

+

+ 共 {students.length} 名学生,点击卡片进行积分操作 +

+
+ + + {/* 搜索 */} + } + clearable + style={{ width: '220px' }} + /> + + {/* 排序方式 */} + + +
+ + {/* 快速导航 */} + {renderQuickNav()} + + {/* 学生卡片网格 */} +
+ {loading ? ( +
+
加载中...
+
+ ) : sortedStudents.length === 0 ? ( +
+
+ {searchKeyword ? '未找到匹配的学生' : '暂无学生数据,请前往学生管理添加'} +
+ {searchKeyword && ( + + )} +
+ ) : ( + renderGroupedCards() + )} +
+ + {/* 操作框 */} + setOperationVisible(false)} + onConfirm={handleSubmit} + confirmBtn={{ content: '提交操作', loading: submitLoading }} + width="560px" + destroyOnClose + top="10%" + > + {selectedStudent && ( +
+ {/* 当前状态 */} +
+
+
+ {getDisplayText(selectedStudent.name)} +
+ {selectedStudent.name} +
+
+ 当前积分: + 0 ? 'success' : selectedStudent.score < 0 ? 'danger' : 'default'} + variant="light" + style={{ fontWeight: 'bold' }} + > + {selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score} + +
+
+ + {/* 快捷理由 */} + {groupedReasons.length > 0 && ( +
+
+ 快捷选项 + +
+
+ {groupedReasons.map(([category, items]) => ( +
+
+ {category} +
+ + {items.map((r) => ( + + ))} + +
+ ))} +
+
+ )} + + {/* 自定义分值 */} +
+
+ 调整分值 + +
+
+ {[-5, -3, -2, -1, 1, 2, 3, 5, 10].map((num) => ( + + ))} + +
+
+ setCustomScore(v as number)} + min={-99} + max={99} + step={1} + style={{ width: '140px' }} + placeholder="自定义分值" + /> + + 可在输入框微调特输入任意分值 + +
+
+ + {/* 理由内容 */} +
+
+ 操作理由 + +
+ setReasonContent('')} style={{ cursor: 'pointer' }} /> : undefined} + /> +
+ + {/* 变动预览 */} + {customScore !== undefined && ( +
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' + }} + > +
+ 变更预览: +
+
+ {selectedStudent.name}{' '} + 0 ? 'var(--td-success-color)' : customScore < 0 ? 'var(--td-error-color)' : 'inherit' }}> + {customScore > 0 ? `+${customScore}` : customScore} + {' '} + 分 + + {reasonContent ? `理由:${reasonContent}` : '(无理由)'} + +
+
+ )} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index f90b11e..26aca87 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -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):
+ }> + 主页 + } disabled={permission !== 'admin'}> 学生管理