学生资料设置、头像与自动备份恢复。
首页卡片支持修改/删除;详情页设置 Tab 可维护学校、年级与头像;系统设置新增数据备份下载与恢复;备份默认存 /root/grade-archive-backups,详见 docs/BACKUP.md。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user