学生资料设置、头像与自动备份恢复。

首页卡片支持修改/删除;详情页设置 Tab 可维护学校、年级与头像;系统设置新增数据备份下载与恢复;备份默认存 /root/grade-archive-backups,详见 docs/BACKUP.md。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 17:56:09 +08:00
parent 1cb3c7fad5
commit 530a8b70a1
25 changed files with 1230 additions and 194 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
<script type="module" crossorigin src="/assets/index-BFUIx7uW.js"></script>
<script type="module" crossorigin src="/assets/index-C01Hd5WH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
</head>
<body>
+27 -1
View File
@@ -3,6 +3,7 @@ import type {
AdminUser,
AIProvider,
AppFeatures,
BackupInfo,
Composition,
CompositionInputMode,
Exam,
@@ -96,6 +97,15 @@ export const adminApi = {
resetUserPassword: (id: string, password: string) =>
api.patch<AdminUser>(`/admin/users/${id}`, { password }),
deleteUser: (id: string) => api.delete(`/admin/users/${id}`),
listBackups: () => api.get<BackupInfo[]>('/admin/backups'),
runBackup: () => api.post<BackupInfo>('/admin/backups/run'),
downloadBackup: (filename: string) =>
api.get(`/admin/backups/${encodeURIComponent(filename)}/download`, { responseType: 'blob' }),
restoreBackup: (file: File) => {
const form = new FormData()
form.append('file', file)
return api.post<{ ok: boolean; message: string }>('/admin/backups/restore', form)
},
}
export const studentApi = {
@@ -103,12 +113,28 @@ export const studentApi = {
create: (data: {
name: string
school_level?: SchoolLevel
school_name?: string
grade?: string
class_name?: string
}) => api.post<Student>('/students', data),
get: (id: string) => api.get<Student>(`/students/${id}`),
update: (id: string, data: Partial<Student>) => api.patch<Student>(`/students/${id}`, data),
update: (
id: string,
data: Partial<{
name: string
school_level: SchoolLevel
school_name: string
grade: string
class_name: string
}>,
) => api.patch<Student>(`/students/${id}`, data),
remove: (id: string) => api.delete(`/students/${id}`),
uploadAvatar: (id: string, file: File) => {
const form = new FormData()
form.append('file', file)
return api.post<Student>(`/students/${id}/avatar`, form)
},
removeAvatar: (id: string) => api.delete<Student>(`/students/${id}/avatar`),
}
export const subjectApi = {
+48
View File
@@ -0,0 +1,48 @@
import { UserOutlined } from '@ant-design/icons'
import { Avatar } from 'antd'
import { useEffect, useState } from 'react'
import api from '../api/client'
import type { Student } from '../types'
interface Props {
student: Pick<Student, 'id' | 'name' | 'has_avatar'>
size?: number
}
export default function StudentAvatar({ student, size = 40 }: Props) {
const [src, setSrc] = useState<string | null>(null)
useEffect(() => {
if (!student.has_avatar) {
setSrc(null)
return
}
let objectUrl: string | null = null
let cancelled = false
api
.get(`/students/${student.id}/avatar`, { responseType: 'blob' })
.then((res) => {
if (cancelled) return
objectUrl = URL.createObjectURL(res.data)
setSrc(objectUrl)
})
.catch(() => {
if (!cancelled) setSrc(null)
})
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [student.id, student.has_avatar])
return (
<Avatar
size={size}
src={src || undefined}
icon={!src ? <UserOutlined /> : undefined}
style={{ backgroundColor: src ? undefined : '#1677ff', flexShrink: 0 }}
>
{!src ? student.name.slice(0, 1) : null}
</Avatar>
)
}
@@ -0,0 +1,49 @@
import { Form, Input, Select } from 'antd'
import { GRADE_OPTIONS, SCHOOL_LEVEL_LABELS } from '../constants/school'
import type { SchoolLevel } from '../types'
export interface StudentFormValues {
name: string
school_level: SchoolLevel
school_name?: string
grade?: string
class_name?: string
}
interface Props {
form: ReturnType<typeof Form.useForm<StudentFormValues>>[0]
}
export default function StudentFormFields({ form }: Props) {
const schoolLevel = Form.useWatch('school_level', form) as SchoolLevel | undefined
return (
<>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="学生姓名" />
</Form.Item>
<Form.Item name="school_name" label="学校">
<Input placeholder="如:XX中学" />
</Form.Item>
<Form.Item name="school_level" label="学段" rules={[{ required: true }]}>
<Select
options={Object.entries(SCHOOL_LEVEL_LABELS).map(([value, label]) => ({ value, label }))}
onChange={() => form.setFieldValue('grade', undefined)}
/>
</Form.Item>
<Form.Item name="grade" label="年级" rules={[{ required: true, message: '请选择年级' }]}>
<Select
allowClear
placeholder={schoolLevel === 'senior_high' ? '请选择高几' : '请选择初几'}
options={(GRADE_OPTIONS[schoolLevel || 'junior_high'] || []).map((item) => ({
value: item.value,
label: item.label,
}))}
/>
</Form.Item>
<Form.Item name="class_name" label="班级">
<Input placeholder="如:3 或 3班" />
</Form.Item>
</>
)
}
@@ -0,0 +1,115 @@
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons'
import { Button, Form, Popconfirm, Space, Typography, Upload, message } from 'antd'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { studentApi } from '../api/client'
import type { Student } from '../types'
import StudentAvatar from './StudentAvatar'
import StudentFormFields, { type StudentFormValues } from './StudentFormFields'
interface Props {
student: Student
onUpdated: (student: Student) => void
}
export default function StudentSettingsPanel({ student, onUpdated }: Props) {
const navigate = useNavigate()
const [form] = Form.useForm<StudentFormValues>()
const [saving, setSaving] = useState(false)
const [avatarLoading, setAvatarLoading] = useState(false)
const [current, setCurrent] = useState(student)
useEffect(() => {
setCurrent(student)
form.setFieldsValue({
name: student.name,
school_name: student.school_name || undefined,
school_level: student.school_level,
grade: student.grade || undefined,
class_name: student.class_name || undefined,
})
}, [student, form])
const handleSave = async () => {
const values = await form.validateFields()
setSaving(true)
try {
const { data } = await studentApi.update(student.id, values)
setCurrent(data)
onUpdated(data)
message.success('学生资料已保存')
} finally {
setSaving(false)
}
}
const handleAvatar = async (file: File) => {
setAvatarLoading(true)
try {
const { data } = await studentApi.uploadAvatar(student.id, file)
setCurrent(data)
onUpdated(data)
message.success('头像已更新')
} catch {
message.error('头像上传失败')
} finally {
setAvatarLoading(false)
}
return false
}
const handleRemoveAvatar = async () => {
const { data } = await studentApi.removeAvatar(student.id)
setCurrent(data)
onUpdated(data)
message.success('头像已移除')
}
const handleDelete = async () => {
await studentApi.remove(student.id)
message.success('学生已删除')
navigate('/')
}
return (
<Space direction="vertical" size="large" style={{ width: '100%', maxWidth: 520 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
/ AI 使
</Typography.Paragraph>
<Space align="center" size="middle">
<StudentAvatar student={current} size={72} />
<Space direction="vertical" size={8}>
<Upload beforeUpload={handleAvatar} showUploadList={false} accept="image/*">
<Button icon={<UploadOutlined />} loading={avatarLoading}>
</Button>
</Upload>
{current.has_avatar && (
<Button size="small" danger onClick={handleRemoveAvatar}>
</Button>
)}
</Space>
</Space>
<Form form={form} layout="vertical">
<StudentFormFields form={form} />
<Space wrap>
<Button type="primary" loading={saving} onClick={handleSave}>
</Button>
<Popconfirm
title="确定删除该学生?"
description="将同时删除其成绩、错题、作文等全部数据,且不可恢复。"
onConfirm={handleDelete}
>
<Button danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
</Form>
</Space>
)
}
+34 -10
View File
@@ -7,20 +7,44 @@ export const SCHOOL_LEVEL_LABELS: Record<SchoolLevel, string> = {
senior_high: '高中',
}
export const GRADE_OPTIONS: Record<SchoolLevel, string[]> = {
junior_high: ['初一', '初二', '初三'],
senior_high: ['一', '高二', '高三'],
export const GRADE_OPTIONS: Record<SchoolLevel, { value: string; label: string }[]> = {
junior_high: [
{ value: '一', label: '初一(七年级)' },
{ value: '初二', label: '初二(八年级)' },
{ value: '初三', label: '初三(九年级)' },
],
senior_high: [
{ value: '高一', label: '高一(十年级)' },
{ value: '高二', label: '高二(十一年级)' },
{ value: '高三', label: '高三(十二年级)' },
],
}
export function formatStudentMeta(student: {
export function formatStudentMeta(
student: {
school_level: SchoolLevel
school_name?: string | null
grade?: string | null
class_name?: string | null
},
options?: { includeLevel?: boolean },
): string {
const parts: string[] = []
if (student.school_name?.trim()) parts.push(student.school_name.trim())
if (options?.includeLevel !== false) parts.push(SCHOOL_LEVEL_LABELS[student.school_level])
if (student.grade) parts.push(student.grade)
if (student.class_name) {
const cls = student.class_name.trim()
parts.push(cls.endsWith('班') ? cls : `${cls}`)
}
return parts.length ? parts.join(' · ') : '未设置年级信息'
}
export function formatStudentSubtitle(student: {
school_level: SchoolLevel
school_name?: string | null
grade?: string | null
class_name?: string | null
}): string {
const parts = [
SCHOOL_LEVEL_LABELS[student.school_level],
student.grade,
student.class_name,
].filter(Boolean)
return parts.length ? parts.join(' · ') : '未设置学段年级'
return formatStudentMeta(student, { includeLevel: false })
}
+133 -3
View File
@@ -1,5 +1,6 @@
import { LockOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'
import { DownloadOutlined, LockOutlined, SettingOutlined, UploadOutlined, UserOutlined } from '@ant-design/icons'
import {
Alert,
Button,
Card,
Form,
@@ -12,13 +13,20 @@ import {
Table,
Tabs,
Typography,
Upload,
message,
} from 'antd'
import { useEffect, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { adminApi } from '../api/client'
import { useAuth } from '../context/AuthContext'
import type { AdminUser, AIProvider, SystemSettings } from '../types'
import type { AdminUser, AIProvider, BackupInfo, SystemSettings } from '../types'
function formatBytes(size: number): string {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
export default function SettingsPage() {
const { user } = useAuth()
@@ -31,6 +39,10 @@ export default function SettingsPage() {
const [resetUser, setResetUser] = useState<AdminUser | null>(null)
const [resetForm] = Form.useForm()
const [aiForm] = Form.useForm()
const [backups, setBackups] = useState<BackupInfo[]>([])
const [backupLoading, setBackupLoading] = useState(false)
const [runningBackup, setRunningBackup] = useState(false)
const [restoring, setRestoring] = useState(false)
const aiProvider = Form.useWatch('ai_provider', aiForm) as AIProvider | undefined
if (!user?.is_superuser) return <Navigate to="/" replace />
@@ -38,12 +50,14 @@ export default function SettingsPage() {
const load = async () => {
setLoading(true)
try {
const [settingsRes, usersRes] = await Promise.all([
const [settingsRes, usersRes, backupsRes] = await Promise.all([
adminApi.getSettings(),
adminApi.listUsers(),
adminApi.listBackups().catch(() => ({ data: [] as BackupInfo[] })),
])
setSettings(settingsRes.data)
setUsers(usersRes.data)
setBackups(backupsRes.data)
profileForm.setFieldsValue({ username: user.username })
aiForm.setFieldsValue({
ai_provider: settingsRes.data.ai_provider,
@@ -141,6 +155,57 @@ export default function SettingsPage() {
message.success('AI 模型配置已保存')
}
const loadBackups = async () => {
setBackupLoading(true)
try {
const { data } = await adminApi.listBackups()
setBackups(data)
} finally {
setBackupLoading(false)
}
}
const handleRunBackup = async () => {
setRunningBackup(true)
try {
await adminApi.runBackup()
message.success('备份已完成')
await loadBackups()
} catch {
message.error('备份失败,请检查服务器 pg_dump 与目录权限')
} finally {
setRunningBackup(false)
}
}
const handleDownloadBackup = async (filename: string) => {
try {
const { data } = await adminApi.downloadBackup(filename)
const url = URL.createObjectURL(data)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
} catch {
message.error('下载失败')
}
}
const handleRestore = async (file: File) => {
setRestoring(true)
try {
const { data } = await adminApi.restoreBackup(file)
message.success(data.message || '数据已恢复')
await loadBackups()
} catch {
message.error('恢复失败,请确认备份包完整且未损坏')
} finally {
setRestoring(false)
}
return false
}
return (
<div className="page-container">
<Space direction="vertical" size="large" style={{ width: '100%' }}>
@@ -273,6 +338,71 @@ export default function SettingsPage() {
</Card>
),
},
{
key: 'backup',
label: '数据备份',
children: (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="自动备份"
description="系统默认每 24 小时自动备份到 /root/grade-archive-backups,并保留最近 30 天。更换服务器时可下载备份包,在新服务器上传恢复。"
/>
<Card
title="备份管理"
extra={
<Button type="primary" loading={runningBackup} onClick={handleRunBackup}>
</Button>
}
>
<Table
rowKey="filename"
loading={backupLoading}
dataSource={backups}
pagination={{ pageSize: 8 }}
locale={{ emptyText: '暂无备份,请点击「立即备份」' }}
columns={[
{ title: '文件名', dataIndex: 'filename' },
{
title: '大小',
dataIndex: 'size_bytes',
render: (v: number) => formatBytes(v),
},
{
title: '时间',
dataIndex: 'created_at',
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: '操作',
render: (_, record) => (
<Button
icon={<DownloadOutlined />}
onClick={() => handleDownloadBackup(record.filename)}
>
</Button>
),
},
]}
/>
</Card>
<Card title="数据恢复(迁移服务器)">
<Typography.Paragraph type="secondary">
<Typography.Text code>grade-archive_*.tar.gz</Typography.Text>{' '}
uploads
</Typography.Paragraph>
<Upload beforeUpload={handleRestore} showUploadList={false} accept=".tar.gz,application/gzip">
<Button icon={<UploadOutlined />} loading={restoring} danger>
</Button>
</Upload>
</Card>
</Space>
),
},
{
key: 'users',
label: '用户管理',
+16 -3
View File
@@ -10,10 +10,12 @@ import WrongQuestionList from '../components/WrongQuestionList'
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
import ExamReviewPanel from '../components/ExamReviewPanel'
import CompositionPanel from '../components/CompositionPanel'
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
import StudentAvatar from '../components/StudentAvatar'
import StudentSettingsPanel from '../components/StudentSettingsPanel'
import { formatStudentSubtitle, SCHOOL_LEVEL_LABELS } from '../constants/school'
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
const TAB_KEYS = ['scores', 'overview', 'trend', 'review', 'composition', 'wrong', 'olympiad'] as const
const TAB_KEYS = ['scores', 'overview', 'trend', 'review', 'composition', 'wrong', 'olympiad', 'settings'] as const
type TabKey = (typeof TAB_KEYS)[number]
export default function StudentDetailPage() {
@@ -164,11 +166,12 @@ export default function StudentDetailPage() {
<Link to="/">
<Button icon={<ArrowLeftOutlined />}></Button>
</Link>
<StudentAvatar student={student} size={40} />
<Typography.Title level={4} style={{ margin: 0 }}>
{student.name}
</Typography.Title>
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>{stageLabel}</Tag>
<Typography.Text type="secondary">{formatStudentMeta(student)}</Typography.Text>
<Typography.Text type="secondary">{formatStudentSubtitle(student)}</Typography.Text>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
CSV
</Button>
@@ -286,6 +289,16 @@ export default function StudentDetailPage() {
</div>
),
},
{
key: 'settings',
label: '设置',
children: (
<StudentSettingsPanel
student={student}
onUpdated={(updated) => setStudent(updated)}
/>
),
},
]}
/>
</div>
+68 -44
View File
@@ -1,19 +1,21 @@
import { LogoutOutlined, PlusOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'
import { Button, Card, Col, Form, Input, Modal, Row, Select, Space, Spin, Tag, Typography, message } from 'antd'
import { DeleteOutlined, EditOutlined, LogoutOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons'
import { Button, Card, Col, Form, Modal, Popconfirm, Row, Space, Spin, Tag, Typography, message } from 'antd'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { studentApi } from '../api/client'
import { formatStudentMeta, GRADE_OPTIONS, SCHOOL_LEVEL_LABELS } from '../constants/school'
import StudentAvatar from '../components/StudentAvatar'
import StudentFormFields, { type StudentFormValues } from '../components/StudentFormFields'
import { formatStudentSubtitle, SCHOOL_LEVEL_LABELS } from '../constants/school'
import { useAuth } from '../context/AuthContext'
import type { SchoolLevel, Student } from '../types'
import type { Student } from '../types'
export default function StudentsPage() {
const { user, logout } = useAuth()
const [students, setStudents] = useState<Student[]>([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [form] = Form.useForm()
const schoolLevel = Form.useWatch('school_level', form) as SchoolLevel | undefined
const [editing, setEditing] = useState<Student | null>(null)
const [form] = Form.useForm<StudentFormValues>()
const load = async () => {
setLoading(true)
@@ -30,19 +32,43 @@ export default function StudentsPage() {
}, [])
const openCreate = () => {
form.setFieldsValue({ school_level: 'junior_high', grade: undefined })
setEditing(null)
form.setFieldsValue({ school_level: 'junior_high', grade: undefined, school_name: undefined })
setModalOpen(true)
}
const handleCreate = async () => {
const openEdit = (student: Student) => {
setEditing(student)
form.setFieldsValue({
name: student.name,
school_name: student.school_name || undefined,
school_level: student.school_level,
grade: student.grade || undefined,
class_name: student.class_name || undefined,
})
setModalOpen(true)
}
const handleSubmit = async () => {
const values = await form.validateFields()
await studentApi.create(values)
message.success('学生已添加')
if (editing) {
await studentApi.update(editing.id, values)
message.success('学生资料已更新')
} else {
await studentApi.create(values)
message.success('学生已添加')
}
setModalOpen(false)
form.resetFields()
load()
}
const handleDelete = async (student: Student) => {
await studentApi.remove(student.id)
message.success('学生已删除')
load()
}
return (
<div className="page-container">
<div
@@ -80,12 +106,34 @@ export default function StudentsPage() {
<Row gutter={[16, 16]}>
{students.map((s) => (
<Col xs={24} sm={12} md={8} key={s.id}>
<Link to={`/students/${s.id}`} style={{ textDecoration: 'none' }}>
<Card hoverable>
<Card
hoverable
actions={[
<Button
type="link"
icon={<EditOutlined />}
onClick={() => openEdit(s)}
key="edit"
>
</Button>,
<Popconfirm
key="delete"
title="确定删除该学生?"
description="将删除其全部成绩与错题数据"
onConfirm={() => handleDelete(s)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>,
]}
>
<Link to={`/students/${s.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
<Space align="start">
<UserOutlined style={{ fontSize: 24, color: '#1677ff' }} />
<StudentAvatar student={s} size={48} />
<div>
<Space size={4}>
<Space size={4} wrap>
<Typography.Text strong>{s.name}</Typography.Text>
<Tag color={s.school_level === 'senior_high' ? 'purple' : 'blue'}>
{SCHOOL_LEVEL_LABELS[s.school_level]}
@@ -93,12 +141,12 @@ export default function StudentsPage() {
</Space>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatStudentMeta(s)}
{formatStudentSubtitle(s)}
</Typography.Text>
</div>
</Space>
</Card>
</Link>
</Link>
</Card>
</Col>
))}
{!loading && students.length === 0 && (
@@ -112,38 +160,14 @@ export default function StudentsPage() {
</Spin>
<Modal
title="添加学生"
title={editing ? '修改学生' : '添加学生'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleCreate}
onOk={handleSubmit}
destroyOnHidden
>
<Form form={form} layout="vertical" initialValues={{ school_level: 'junior_high' }}>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="school_level" label="学段" rules={[{ required: true }]}>
<Select
options={Object.entries(SCHOOL_LEVEL_LABELS).map(([value, label]) => ({
value,
label,
}))}
onChange={() => form.setFieldValue('grade', undefined)}
/>
</Form.Item>
<Form.Item name="grade" label="年级">
<Select
allowClear
placeholder={schoolLevel === 'senior_high' ? '如:高一' : '如:初二'}
options={(GRADE_OPTIONS[schoolLevel || 'junior_high'] || []).map((g) => ({
value: g,
label: g,
}))}
/>
</Form.Item>
<Form.Item name="class_name" label="班级">
<Input placeholder="如:3班" />
</Form.Item>
<StudentFormFields form={form} />
</Form>
</Modal>
</div>
+8
View File
@@ -51,6 +51,14 @@ export interface Student {
school_level: SchoolLevel
grade: string | null
class_name: string | null
school_name: string | null
has_avatar: boolean
created_at: string
}
export interface BackupInfo {
filename: string
size_bytes: number
created_at: string
}