Initial commit: secondary school grade archive system.
Add FastAPI/React app with Docker deployment, Ubuntu one-click install, and docs for junior/senior high score tracking and mistake bank. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { useAuth } from './context/AuthContext'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import StudentDetailPage from './pages/StudentDetailPage'
|
||||
import StudentsPage from './pages/StudentsPage'
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return null
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<StudentsPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/students/:id"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<StudentDetailPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import axios from 'axios'
|
||||
import type {
|
||||
Exam,
|
||||
ScoreInput,
|
||||
SchoolLevel,
|
||||
Student,
|
||||
Subject,
|
||||
TokenResponse,
|
||||
TrendResponse,
|
||||
User,
|
||||
WrongQuestion,
|
||||
} from '../types'
|
||||
import type { ExamType } from '../types'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (error) => {
|
||||
const original = error.config
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true
|
||||
const refresh = localStorage.getItem('refresh_token')
|
||||
if (refresh) {
|
||||
try {
|
||||
const { data } = await axios.post<TokenResponse>('/api/auth/refresh', {
|
||||
refresh_token: refresh,
|
||||
})
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
localStorage.setItem('refresh_token', data.refresh_token)
|
||||
original.headers.Authorization = `Bearer ${data.access_token}`
|
||||
return api(original)
|
||||
} catch {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export const authApi = {
|
||||
register: (username: string, password: string) =>
|
||||
api.post<User>('/auth/register', { username, password }),
|
||||
login: (username: string, password: string) =>
|
||||
api.post<TokenResponse>('/auth/login', { username, password }),
|
||||
me: () => api.get<User>('/auth/me'),
|
||||
}
|
||||
|
||||
export const studentApi = {
|
||||
list: () => api.get<Student[]>('/students'),
|
||||
create: (data: {
|
||||
name: string
|
||||
school_level?: SchoolLevel
|
||||
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),
|
||||
remove: (id: string) => api.delete(`/students/${id}`),
|
||||
}
|
||||
|
||||
export const subjectApi = {
|
||||
list: () => api.get<Subject[]>('/subjects'),
|
||||
}
|
||||
|
||||
export const examApi = {
|
||||
list: (studentId: string) => api.get<Exam[]>(`/students/${studentId}/exams`),
|
||||
create: (
|
||||
studentId: string,
|
||||
data: { exam_type: ExamType; exam_date: string; title?: string; scores: ScoreInput[] },
|
||||
) => api.post<Exam>(`/students/${studentId}/exams`, data),
|
||||
update: (
|
||||
examId: string,
|
||||
data: Partial<{ exam_type: ExamType; exam_date: string; title: string; scores: ScoreInput[] }>,
|
||||
) => api.patch<Exam>(`/exams/${examId}`, data),
|
||||
remove: (examId: string) => api.delete(`/exams/${examId}`),
|
||||
trend: (studentId: string, subjectId: number) =>
|
||||
api.get<TrendResponse>(`/students/${studentId}/scores/trend`, {
|
||||
params: { subject_id: subjectId },
|
||||
}),
|
||||
exportCsv: (studentId: string) =>
|
||||
api.get(`/students/${studentId}/scores/export`, { responseType: 'blob' }),
|
||||
}
|
||||
|
||||
export const wrongQuestionApi = {
|
||||
list: (studentId: string, params?: { subject_id?: number; q?: string }) =>
|
||||
api.get<WrongQuestion[]>(`/students/${studentId}/wrong-questions`, { params }),
|
||||
upload: (studentId: string, subjectId: number, file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('subject_id', String(subjectId))
|
||||
form.append('file', file)
|
||||
return api.post<WrongQuestion>(`/students/${studentId}/wrong-questions`, form)
|
||||
},
|
||||
get: (id: string) => api.get<WrongQuestion>(`/wrong-questions/${id}`),
|
||||
update: (id: string, data: Partial<WrongQuestion>) =>
|
||||
api.patch<WrongQuestion>(`/wrong-questions/${id}`, data),
|
||||
remove: (id: string) => api.delete(`/wrong-questions/${id}`),
|
||||
retryOcr: (id: string) => api.post<WrongQuestion>(`/wrong-questions/${id}/retry-ocr`),
|
||||
regenerate: (id: string) =>
|
||||
api.post<WrongQuestion>(`/wrong-questions/${id}/regenerate-solution`),
|
||||
imageUrl: (id: string) => `/api/wrong-questions/${id}/image`,
|
||||
}
|
||||
|
||||
export default api
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,241 @@
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { Button, DatePicker, Form, Input, InputNumber, Modal, Select, Space, Table, message } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { examApi } from '../api/client'
|
||||
import type { Exam, ExamType, ScoreInput, Subject } from '../types'
|
||||
import { EXAM_TYPE_LABELS } from '../types'
|
||||
|
||||
interface Props {
|
||||
studentId: string
|
||||
subjects: Subject[]
|
||||
exams: Exam[]
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Props) {
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Exam | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (modalOpen && editing) {
|
||||
form.setFieldsValue({
|
||||
exam_type: editing.exam_type,
|
||||
exam_date: dayjs(editing.exam_date),
|
||||
title: editing.title,
|
||||
scores: subjects.map((s) => {
|
||||
const found = editing.scores.find((sc) => sc.subject_id === s.id)
|
||||
return found
|
||||
? { subject_id: s.id, total_score: found.total_score, obtained_score: found.obtained_score }
|
||||
: { subject_id: s.id, total_score: undefined, obtained_score: undefined }
|
||||
}),
|
||||
})
|
||||
} else if (modalOpen) {
|
||||
form.setFieldsValue({
|
||||
exam_type: 'weekly',
|
||||
exam_date: dayjs(),
|
||||
scores: subjects.map((s) => ({ subject_id: s.id })),
|
||||
})
|
||||
}
|
||||
}, [modalOpen, editing, subjects, form])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (exam: Exam) => {
|
||||
setEditing(exam)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const scores: ScoreInput[] = (values.scores || [])
|
||||
.map((s: ScoreInput, idx: number) => ({
|
||||
subject_id: subjects[idx]?.id ?? s.subject_id,
|
||||
total_score: s.total_score,
|
||||
obtained_score: s.obtained_score,
|
||||
}))
|
||||
.filter(
|
||||
(s: ScoreInput) =>
|
||||
s.subject_id != null &&
|
||||
s.total_score != null &&
|
||||
s.obtained_score != null &&
|
||||
s.total_score > 0,
|
||||
)
|
||||
.map((s: ScoreInput) => ({
|
||||
subject_id: s.subject_id,
|
||||
total_score: Number(s.total_score),
|
||||
obtained_score: Number(s.obtained_score),
|
||||
}))
|
||||
|
||||
if (scores.length === 0) {
|
||||
message.warning('请至少录入一科成绩')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const payload = {
|
||||
exam_type: values.exam_type as ExamType,
|
||||
exam_date: values.exam_date.format('YYYY-MM-DD'),
|
||||
title: values.title || undefined,
|
||||
scores,
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
await examApi.update(editing.id, payload)
|
||||
message.success('已更新')
|
||||
} else {
|
||||
await examApi.create(studentId, payload)
|
||||
message.success('已添加')
|
||||
}
|
||||
setModalOpen(false)
|
||||
onRefresh()
|
||||
} catch {
|
||||
/* validation */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (exam: Exam) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除该考试记录?',
|
||||
onOk: async () => {
|
||||
await examApi.remove(exam.id)
|
||||
message.success('已删除')
|
||||
onRefresh()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '日期', dataIndex: 'exam_date', key: 'exam_date', width: 110 },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'exam_type',
|
||||
key: 'exam_type',
|
||||
width: 80,
|
||||
render: (t: ExamType) => EXAM_TYPE_LABELS[t],
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{
|
||||
title: '科目数',
|
||||
key: 'count',
|
||||
width: 80,
|
||||
render: (_: unknown, r: Exam) => r.scores.length,
|
||||
},
|
||||
{
|
||||
title: '平均占比',
|
||||
key: 'avg',
|
||||
width: 100,
|
||||
render: (_: unknown, r: Exam) => {
|
||||
if (!r.scores.length) return '-'
|
||||
const avg = r.scores.reduce((a, s) => a + s.ratio, 0) / r.scores.length
|
||||
return `${(avg * 100).toFixed(1)}%`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: unknown, r: Exam) => (
|
||||
<Space>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(r)} />
|
||||
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(r)} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button type="primary" onClick={openCreate} style={{ marginBottom: 16 }}>
|
||||
录入成绩
|
||||
</Button>
|
||||
<Table rowKey="id" columns={columns} dataSource={exams} pagination={{ pageSize: 10 }} scroll={{ x: 600 }} />
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑考试' : '录入成绩'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={loading}
|
||||
width={720}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="exam_type" label="考试类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(EXAM_TYPE_LABELS).map(([k, v]) => ({ value: k, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="exam_date" label="考试日期" rules={[{ required: true }]}>
|
||||
<DatePicker />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="备注标题">
|
||||
<Input placeholder="可选" style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.List name="scores">
|
||||
{(fields) => (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={fields.map((f, i) => ({ ...f, subject: subjects[i] }))}
|
||||
rowKey="key"
|
||||
columns={[
|
||||
{
|
||||
title: '科目',
|
||||
render: (_, row) => (
|
||||
<>
|
||||
<Form.Item name={[row.name, 'subject_id']} hidden initialValue={row.subject?.id}>
|
||||
<InputNumber />
|
||||
</Form.Item>
|
||||
{row.subject?.name}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '总分',
|
||||
render: (_, row) => (
|
||||
<Form.Item name={[row.name, 'total_score']} noStyle>
|
||||
<InputNumber min={0} placeholder="总分" style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '得分',
|
||||
render: (_, row) => (
|
||||
<Form.Item name={[row.name, 'obtained_score']} noStyle>
|
||||
<InputNumber min={0} placeholder="得分" style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '占比',
|
||||
render: (_, row) => {
|
||||
const total = form.getFieldValue(['scores', row.name, 'total_score'])
|
||||
const obtained = form.getFieldValue(['scores', row.name, 'obtained_score'])
|
||||
if (total > 0 && obtained != null) {
|
||||
return `${((obtained / total) * 100).toFixed(1)}%`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Table, Tag } from 'antd'
|
||||
import type { Exam } from '../types'
|
||||
import { EXAM_TYPE_LABELS } from '../types'
|
||||
|
||||
interface Props {
|
||||
exams: Exam[]
|
||||
subjectNames: Record<number, string>
|
||||
}
|
||||
|
||||
export default function ScoreOverview({ exams, subjectNames }: Props) {
|
||||
const subjectIds = new Set<number>()
|
||||
exams.forEach((e) => e.scores.forEach((s) => subjectIds.add(s.subject_id)))
|
||||
const sortedSubjects = [...subjectIds].sort((a, b) => a - b)
|
||||
|
||||
const columns = [
|
||||
{ title: '日期', dataIndex: 'exam_date', key: 'date', width: 110, fixed: 'left' as const },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'exam_type',
|
||||
key: 'type',
|
||||
width: 80,
|
||||
render: (t: keyof typeof EXAM_TYPE_LABELS) => (
|
||||
<Tag>{EXAM_TYPE_LABELS[t]}</Tag>
|
||||
),
|
||||
},
|
||||
...sortedSubjects.map((sid) => ({
|
||||
title: subjectNames[sid] || `科目${sid}`,
|
||||
key: `s${sid}`,
|
||||
width: 100,
|
||||
render: (_: unknown, exam: Exam) => {
|
||||
const score = exam.scores.find((s) => s.subject_id === sid)
|
||||
if (!score) return '-'
|
||||
return `${score.obtained_score}/${score.total_score} (${(score.ratio * 100).toFixed(1)}%)`
|
||||
},
|
||||
})),
|
||||
]
|
||||
|
||||
const volatileExams = exams.filter((exam) => {
|
||||
return exam.scores.some((s) => {
|
||||
const allScores = exams
|
||||
.filter((e) => e.exam_date <= exam.exam_date)
|
||||
.flatMap((e) => e.scores.filter((sc) => sc.subject_id === s.subject_id))
|
||||
.sort((a, b) => {
|
||||
const ea = exams.find((e) => e.scores.includes(a))
|
||||
const eb = exams.find((e) => e.scores.includes(b))
|
||||
return (ea?.exam_date || '').localeCompare(eb?.exam_date || '')
|
||||
})
|
||||
const idx = allScores.findIndex((sc) => sc.id === s.id)
|
||||
if (idx <= 0) return false
|
||||
return Math.abs(allScores[idx].ratio - allScores[idx - 1].ratio) >= 0.08
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{volatileExams.length > 0 && (
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#fff7e6', borderRadius: 8 }}>
|
||||
<strong>波动预警:</strong>
|
||||
{volatileExams.slice(0, 5).map((e) => (
|
||||
<Tag key={e.id} color="orange" style={{ marginTop: 4 }}>
|
||||
{e.exam_date} {EXAM_TYPE_LABELS[e.exam_type]}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={[...exams].sort((a, b) => b.exam_date.localeCompare(a.exam_date))}
|
||||
pagination={{ pageSize: 15 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import type { TrendPoint } from '../types'
|
||||
import { EXAM_TYPE_LABELS } from '../types'
|
||||
|
||||
interface Props {
|
||||
points: TrendPoint[]
|
||||
subjectName: string
|
||||
threshold: number
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
up: '#52c41a',
|
||||
down: '#ff4d4f',
|
||||
flat: '#8c8c8c',
|
||||
volatile: '#fa8c16',
|
||||
}
|
||||
|
||||
export default function TrendChart({ points, subjectName, threshold }: Props) {
|
||||
if (points.length === 0) {
|
||||
return <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无成绩数据</div>
|
||||
}
|
||||
|
||||
const dates = points.map((p) => p.exam_date)
|
||||
const values = points.map((p) => p.ratio_percent)
|
||||
|
||||
const lineSeries = points.slice(1).map((point, i) => {
|
||||
let color = COLORS.flat
|
||||
if (point.direction === 'up') color = COLORS.up
|
||||
if (point.direction === 'down') color = COLORS.down
|
||||
|
||||
return {
|
||||
type: 'line' as const,
|
||||
data: dates.map((_, idx) => (idx === i || idx === i + 1 ? values[idx] : null)),
|
||||
connectNulls: false,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 3, color },
|
||||
tooltip: { show: false },
|
||||
silent: true,
|
||||
}
|
||||
})
|
||||
|
||||
const markPoints = points
|
||||
.map((point, i) => ({ point, i }))
|
||||
.filter(({ point }) => point.is_volatile)
|
||||
.map(({ i }) => ({
|
||||
coord: [dates[i], values[i]],
|
||||
symbol: 'circle',
|
||||
symbolSize: 18,
|
||||
itemStyle: {
|
||||
color: COLORS.volatile,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: { show: false },
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: `${subjectName} 成绩占比趋势`,
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 16 },
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: Array<{ dataIndex: number; value: number }>) => {
|
||||
const idx = params[0]?.dataIndex ?? 0
|
||||
const p = points[idx]
|
||||
if (!p) return ''
|
||||
const typeLabel = EXAM_TYPE_LABELS[p.exam_type]
|
||||
let html = `<strong>${p.exam_date}</strong> (${typeLabel})<br/>占比: ${p.ratio_percent}%`
|
||||
if (p.title) html += `<br/>${p.title}`
|
||||
if (p.delta_percent !== null) {
|
||||
const sign = p.delta_percent > 0 ? '+' : ''
|
||||
html += `<br/>较上次: ${sign}${p.delta_percent}%`
|
||||
if (p.is_volatile) html += ' <span style="color:#fa8c16">[大幅波动]</span>'
|
||||
}
|
||||
return html
|
||||
},
|
||||
},
|
||||
grid: { left: 50, right: 30, top: 60, bottom: 50 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { rotate: 30 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '占比 (%)',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: values,
|
||||
symbol: 'circle',
|
||||
symbolSize: (_val: number, params: { dataIndex: number }) =>
|
||||
points[params.dataIndex]?.is_volatile ? 14 : 8,
|
||||
itemStyle: {
|
||||
color: (params: { dataIndex: number }) => {
|
||||
const p = points[params.dataIndex]
|
||||
if (p?.is_volatile) return COLORS.volatile
|
||||
if (p?.direction === 'up') return COLORS.up
|
||||
if (p?.direction === 'down') return COLORS.down
|
||||
return '#1677ff'
|
||||
},
|
||||
},
|
||||
lineStyle: { opacity: 0 },
|
||||
markPoint: markPoints.length ? { data: markPoints } : undefined,
|
||||
z: 10,
|
||||
},
|
||||
...lineSeries,
|
||||
],
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: [
|
||||
{ name: '上升', itemStyle: { color: COLORS.up } },
|
||||
{ name: '下降', itemStyle: { color: COLORS.down } },
|
||||
{ name: '大幅波动', itemStyle: { color: COLORS.volatile } },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactECharts option={option} style={{ height: 400, width: '100%' }} notMerge />
|
||||
<p style={{ color: '#888', fontSize: 12, marginTop: 8 }}>
|
||||
波动阈值: {(threshold * 100).toFixed(0)}%,超过此变化幅度将高亮显示
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ReloadOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { Button, Input, Select, Space, Upload, message } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { Subject } from '../types'
|
||||
|
||||
interface Props {
|
||||
studentId: string
|
||||
subjects: Subject[]
|
||||
onUploaded: () => void
|
||||
}
|
||||
|
||||
export default function WrongQuestionUpload({ studentId, subjects, onUploaded }: Props) {
|
||||
const [subjectId, setSubjectId] = useState<number | undefined>(subjects[0]?.id)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!subjectId) {
|
||||
message.warning('请选择科目')
|
||||
return false
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
await wrongQuestionApi.upload(studentId, subjectId, file)
|
||||
message.success('上传成功,正在 OCR 识别并生成解法…')
|
||||
onUploaded()
|
||||
} catch {
|
||||
message.error('上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
placeholder="选择科目"
|
||||
value={subjectId}
|
||||
onChange={setSubjectId}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
|
||||
<Button icon={<UploadOutlined />} loading={uploading} type="primary">
|
||||
上传错题图片
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
interface SearchProps {
|
||||
subjectId?: number
|
||||
onSubjectChange: (id?: number) => void
|
||||
search: string
|
||||
onSearchChange: (q: string) => void
|
||||
onRefresh: () => void
|
||||
subjects: Subject[]
|
||||
}
|
||||
|
||||
export function WrongQuestionFilters({
|
||||
subjectId,
|
||||
onSubjectChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
onRefresh,
|
||||
subjects,
|
||||
}: SearchProps) {
|
||||
return (
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部科目"
|
||||
value={subjectId}
|
||||
onChange={onSubjectChange}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
<Input.Search
|
||||
placeholder="搜索题目/解法"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onSearch={onRefresh}
|
||||
style={{ width: 220 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={onRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SchoolLevel } from '../types'
|
||||
|
||||
export type { SchoolLevel }
|
||||
|
||||
export const SCHOOL_LEVEL_LABELS: Record<SchoolLevel, string> = {
|
||||
junior_high: '初中',
|
||||
senior_high: '高中',
|
||||
}
|
||||
|
||||
export const GRADE_OPTIONS: Record<SchoolLevel, string[]> = {
|
||||
junior_high: ['初一', '初二', '初三'],
|
||||
senior_high: ['高一', '高二', '高三'],
|
||||
}
|
||||
|
||||
export function formatStudentMeta(student: {
|
||||
school_level: SchoolLevel
|
||||
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(' · ') : '未设置学段年级'
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { authApi } from '../api/client'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
register: (username: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
authApi
|
||||
.me()
|
||||
.then((res) => setUser(res.data))
|
||||
.catch(() => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const { data } = await authApi.login(username, password)
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
localStorage.setItem('refresh_token', data.refresh_token)
|
||||
const me = await authApi.me()
|
||||
setUser(me.data)
|
||||
}
|
||||
|
||||
const register = async (username: string, password: string) => {
|
||||
await authApi.register(username, password)
|
||||
await login(username, password)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
sans-serif;
|
||||
background: #f5f5f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.ant-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Form, Input, Tabs, Typography, message } from 'antd'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { user, login, register, loading } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [loginForm] = Form.useForm()
|
||||
const [registerForm] = Form.useForm()
|
||||
|
||||
if (!loading && user) return <Navigate to="/" replace />
|
||||
|
||||
const onLogin = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
await login(values.username, values.password)
|
||||
message.success('登录成功')
|
||||
navigate('/')
|
||||
} catch {
|
||||
message.error('用户名或密码错误')
|
||||
}
|
||||
}
|
||||
|
||||
const onRegister = async (values: { username: string; password: string; confirm: string }) => {
|
||||
if (values.password !== values.confirm) {
|
||||
message.error('两次密码不一致')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await register(values.username, values.password)
|
||||
message.success('注册成功')
|
||||
navigate('/')
|
||||
} catch {
|
||||
message.error('注册失败,用户名可能已存在')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: '100%', maxWidth: 420 }} title="中学生成绩档案">
|
||||
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
|
||||
成绩录入 · 趋势分析 · 错题管理
|
||||
</Typography.Paragraph>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'login',
|
||||
label: '登录',
|
||||
children: (
|
||||
<Form form={loginForm} onFinish={onLogin} layout="vertical">
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'register',
|
||||
label: '注册',
|
||||
children: (
|
||||
<Form form={registerForm} onFinish={onRegister} layout="vertical">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, message: '至少3个字符' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '至少6个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirm"
|
||||
rules={[{ required: true, message: '请确认密码' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="确认密码" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
注册
|
||||
</Button>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
style={{ textAlign: 'center', fontSize: 12, marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
© 马建军 · 微信 dekun03 · 18364911125
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||
import { Button, Select, Space, Spin, Tabs, Tag, Typography, message } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { examApi, studentApi, subjectApi, wrongQuestionApi } from '../api/client'
|
||||
import ScoreForm from '../components/ScoreForm'
|
||||
import ScoreOverview from '../components/ScoreOverview'
|
||||
import TrendChart from '../components/TrendChart'
|
||||
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
|
||||
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
||||
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
|
||||
import { STATUS_LABELS } from '../types'
|
||||
import WrongQuestionDetail from './WrongQuestionDetail'
|
||||
|
||||
export default function StudentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [student, setStudent] = useState<Student | null>(null)
|
||||
const [subjects, setSubjects] = useState<Subject[]>([])
|
||||
const [exams, setExams] = useState<Exam[]>([])
|
||||
const [trend, setTrend] = useState<TrendResponse | null>(null)
|
||||
const [selectedSubject, setSelectedSubject] = useState<number>()
|
||||
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
|
||||
const [wqSubjectFilter, setWqSubjectFilter] = useState<number>()
|
||||
const [wqSearch, setWqSearch] = useState('')
|
||||
const [selectedWq, setSelectedWq] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const subjectNames = Object.fromEntries(subjects.map((s) => [s.id, s.name]))
|
||||
|
||||
const loadExams = useCallback(async () => {
|
||||
if (!id) return
|
||||
const { data } = await examApi.list(id)
|
||||
setExams(data)
|
||||
}, [id])
|
||||
|
||||
const loadTrend = useCallback(async () => {
|
||||
if (!id || !selectedSubject) return
|
||||
const { data } = await examApi.trend(id, selectedSubject)
|
||||
setTrend(data)
|
||||
}, [id, selectedSubject])
|
||||
|
||||
const loadWrongQuestions = useCallback(async () => {
|
||||
if (!id) return
|
||||
const { data } = await wrongQuestionApi.list(id, {
|
||||
subject_id: wqSubjectFilter,
|
||||
q: wqSearch || undefined,
|
||||
})
|
||||
setWrongQuestions(data)
|
||||
}, [id, wqSubjectFilter, wqSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const init = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [studentRes, subjectRes] = await Promise.all([
|
||||
studentApi.get(id),
|
||||
subjectApi.list(),
|
||||
])
|
||||
setStudent(studentRes.data)
|
||||
setSubjects(subjectRes.data)
|
||||
if (subjectRes.data.length) setSelectedSubject(subjectRes.data[0].id)
|
||||
await loadExams()
|
||||
await loadWrongQuestions()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [id, loadExams, loadWrongQuestions])
|
||||
|
||||
useEffect(() => {
|
||||
loadTrend()
|
||||
}, [loadTrend])
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const { data } = await examApi.exportCsv(id)
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${student?.name || 'student'}_scores.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!student) return <Typography.Text>学生不存在</Typography.Text>
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '16px 16px 32px' }}>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Link to="/">
|
||||
<Button icon={<ArrowLeftOutlined />}>返回</Button>
|
||||
</Link>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{student.name}
|
||||
</Typography.Title>
|
||||
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>
|
||||
{SCHOOL_LEVEL_LABELS[student.school_level]}
|
||||
</Tag>
|
||||
<Typography.Text type="secondary">{formatStudentMeta(student)}</Typography.Text>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||
导出 CSV
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'scores',
|
||||
label: '成绩录入',
|
||||
children: (
|
||||
<ScoreForm
|
||||
studentId={id!}
|
||||
subjects={subjects}
|
||||
exams={exams}
|
||||
onRefresh={loadExams}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'overview',
|
||||
label: '成绩总览',
|
||||
children: <ScoreOverview exams={exams} subjectNames={subjectNames} />,
|
||||
},
|
||||
{
|
||||
key: 'trend',
|
||||
label: '分科曲线',
|
||||
children: (
|
||||
<div>
|
||||
<Select
|
||||
style={{ width: 140, marginBottom: 16 }}
|
||||
value={selectedSubject}
|
||||
onChange={setSelectedSubject}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
{trend && (
|
||||
<TrendChart
|
||||
points={trend.points}
|
||||
subjectName={trend.subject_name}
|
||||
threshold={trend.threshold}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'wrong',
|
||||
label: '错题库',
|
||||
children: (
|
||||
<div>
|
||||
<WrongQuestionUpload
|
||||
studentId={id!}
|
||||
subjects={subjects}
|
||||
onUploaded={loadWrongQuestions}
|
||||
/>
|
||||
<WrongQuestionFilters
|
||||
subjectId={wqSubjectFilter}
|
||||
onSubjectChange={setWqSubjectFilter}
|
||||
search={wqSearch}
|
||||
onSearchChange={setWqSearch}
|
||||
onRefresh={loadWrongQuestions}
|
||||
subjects={subjects}
|
||||
/>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{wrongQuestions.map((wq) => (
|
||||
<div
|
||||
key={wq.id}
|
||||
onClick={() => setSelectedWq(wq.id)}
|
||||
style={{
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={wrongQuestionApi.imageUrl(wq.id)}
|
||||
alt="错题"
|
||||
style={{ width: '100%', height: 140, objectFit: 'cover', background: '#fafafa' }}
|
||||
/>
|
||||
<div style={{ padding: 12 }}>
|
||||
<Space>
|
||||
<Typography.Text strong>{wq.subject_name}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{STATUS_LABELS[wq.status]}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ margin: '8px 0 0', fontSize: 13 }}
|
||||
>
|
||||
{wq.question_text || wq.ocr_raw_text || '处理中…'}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{wrongQuestions.length === 0 && (
|
||||
<Typography.Text type="secondary">暂无错题</Typography.Text>
|
||||
)}
|
||||
{selectedWq && (
|
||||
<WrongQuestionDetail
|
||||
questionId={selectedWq}
|
||||
open={!!selectedWq}
|
||||
onClose={() => setSelectedWq(null)}
|
||||
onUpdated={loadWrongQuestions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { LogoutOutlined, PlusOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Col, Form, Input, Modal, Row, Select, 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 { useAuth } from '../context/AuthContext'
|
||||
import type { SchoolLevel, 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 load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await studentApi.list()
|
||||
setStudents(data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const openCreate = () => {
|
||||
form.setFieldsValue({ school_level: 'junior_high', grade: undefined })
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields()
|
||||
await studentApi.create(values)
|
||||
message.success('学生已添加')
|
||||
setModalOpen(false)
|
||||
form.resetFields()
|
||||
load()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '16px 16px 32px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||
学生档案
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">欢迎,{user?.username}</Typography.Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
添加学生
|
||||
</Button>
|
||||
<Button icon={<LogoutOutlined />} onClick={logout}>
|
||||
退出
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<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>
|
||||
<Space align="start">
|
||||
<UserOutlined style={{ fontSize: 24, color: '#1677ff' }} />
|
||||
<div>
|
||||
<Space size={4}>
|
||||
<Typography.Text strong>{s.name}</Typography.Text>
|
||||
<Tag color={s.school_level === 'senior_high' ? 'purple' : 'blue'}>
|
||||
{SCHOOL_LEVEL_LABELS[s.school_level]}
|
||||
</Tag>
|
||||
</Space>
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatStudentMeta(s)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Link>
|
||||
</Col>
|
||||
))}
|
||||
{!loading && students.length === 0 && (
|
||||
<Col span={24}>
|
||||
<Card>
|
||||
<Typography.Text type="secondary">暂无学生,点击「添加学生」开始</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Spin>
|
||||
|
||||
<Modal
|
||||
title="添加学生"
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={handleCreate}
|
||||
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>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Alert, Button, Col, Input, Modal, Row, Space, Spin, Typography, message } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { WrongQuestion } from '../types'
|
||||
import { STATUS_LABELS } from '../types'
|
||||
|
||||
interface Props {
|
||||
questionId: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onUpdated: () => void
|
||||
}
|
||||
|
||||
export default function WrongQuestionDetail({ questionId, open, onClose, onUpdated }: Props) {
|
||||
const [wq, setWq] = useState<WrongQuestion | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [questionText, setQuestionText] = useState('')
|
||||
const [solutionText, setSolutionText] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [regenerating, setRegenerating] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await wrongQuestionApi.get(questionId)
|
||||
setWq(data)
|
||||
setQuestionText(data.question_text || '')
|
||||
setSolutionText(data.solution_text || '')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open && questionId) load()
|
||||
}, [open, questionId])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await wrongQuestionApi.update(questionId, {
|
||||
question_text: questionText,
|
||||
solution_text: solutionText,
|
||||
})
|
||||
message.success('已保存')
|
||||
onUpdated()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
setRegenerating(true)
|
||||
try {
|
||||
const { data } = await wrongQuestionApi.regenerate(questionId)
|
||||
setWq(data)
|
||||
setQuestionText(data.question_text || '')
|
||||
setSolutionText(data.solution_text || '')
|
||||
message.success('解法已重新生成')
|
||||
onUpdated()
|
||||
} catch {
|
||||
message.error('生成失败,请确认 Ollama 已启动')
|
||||
} finally {
|
||||
setRegenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetryOcr = async () => {
|
||||
await wrongQuestionApi.retryOcr(questionId)
|
||||
message.info('已重新识别,请稍后刷新')
|
||||
onUpdated()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={wq ? `${wq.subject_name} · 错题详情` : '错题详情'}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width="90%"
|
||||
style={{ maxWidth: 960 }}
|
||||
footer={
|
||||
<Space wrap>
|
||||
<Button onClick={handleRetryOcr}>重新 OCR</Button>
|
||||
<Button loading={regenerating} onClick={handleRegenerate}>
|
||||
重新生成解法
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} onClick={handleSave}>
|
||||
保存编辑
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{wq && (
|
||||
<>
|
||||
<Typography.Text type="secondary">状态:{STATUS_LABELS[wq.status]}</Typography.Text>
|
||||
{wq.solution_text && (
|
||||
<Alert
|
||||
message="AI 生成内容,请核对后再使用"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ margin: '12px 0' }}
|
||||
/>
|
||||
)}
|
||||
<Row gutter={16} style={{ marginTop: 12 }}>
|
||||
<Col xs={24} md={10}>
|
||||
<img
|
||||
src={wrongQuestionApi.imageUrl(wq.id)}
|
||||
alt="原题"
|
||||
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
{wq.ocr_raw_text && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Typography.Text strong>OCR 原文</Typography.Text>
|
||||
<pre
|
||||
style={{
|
||||
background: '#fafafa',
|
||||
padding: 8,
|
||||
fontSize: 12,
|
||||
maxHeight: 150,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{wq.ocr_raw_text}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={24} md={14}>
|
||||
<Typography.Text strong>识别题目(可编辑)</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
value={questionText}
|
||||
onChange={(e) => setQuestionText(e.target.value)}
|
||||
style={{ marginTop: 8, marginBottom: 16 }}
|
||||
/>
|
||||
<Typography.Text strong>解法</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={8}
|
||||
value={solutionText}
|
||||
onChange={(e) => setSolutionText(e.target.value)}
|
||||
style={{ marginTop: 8, marginBottom: 12 }}
|
||||
/>
|
||||
{solutionText && (
|
||||
<div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
预览
|
||||
</Typography.Text>
|
||||
<ReactMarkdown>{solutionText}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type SchoolLevel = 'junior_high' | 'senior_high'
|
||||
|
||||
export interface Student {
|
||||
id: string
|
||||
name: string
|
||||
school_level: SchoolLevel
|
||||
grade: string | null
|
||||
class_name: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Subject {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Score {
|
||||
id: string
|
||||
subject_id: number
|
||||
subject_name?: string
|
||||
total_score: number
|
||||
obtained_score: number
|
||||
ratio: number
|
||||
}
|
||||
|
||||
export type ExamType = 'weekly' | 'monthly' | 'final'
|
||||
|
||||
export interface Exam {
|
||||
id: string
|
||||
exam_type: ExamType
|
||||
exam_date: string
|
||||
title: string | null
|
||||
created_at: string
|
||||
scores: Score[]
|
||||
}
|
||||
|
||||
export interface ScoreInput {
|
||||
subject_id: number
|
||||
total_score: number
|
||||
obtained_score: number
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
exam_id: string
|
||||
exam_type: ExamType
|
||||
exam_date: string
|
||||
title: string | null
|
||||
ratio: number
|
||||
ratio_percent: number
|
||||
delta: number | null
|
||||
delta_percent: number | null
|
||||
is_volatile: boolean
|
||||
direction: 'up' | 'down' | 'flat' | null
|
||||
}
|
||||
|
||||
export interface TrendResponse {
|
||||
subject_id: number
|
||||
subject_name: string
|
||||
threshold: number
|
||||
points: TrendPoint[]
|
||||
}
|
||||
|
||||
export type WrongQuestionStatus = 'pending' | 'ocr_done' | 'solved' | 'failed'
|
||||
|
||||
export interface WrongQuestion {
|
||||
id: string
|
||||
student_id: string
|
||||
subject_id: number
|
||||
subject_name?: string
|
||||
image_path: string
|
||||
ocr_raw_text: string | null
|
||||
question_text: string | null
|
||||
solution_text: string | null
|
||||
status: WrongQuestionStatus
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const EXAM_TYPE_LABELS: Record<ExamType, string> = {
|
||||
weekly: '周考',
|
||||
monthly: '月考',
|
||||
final: '期末',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<WrongQuestionStatus, string> = {
|
||||
pending: '处理中',
|
||||
ocr_done: '已识别',
|
||||
solved: '已生成解法',
|
||||
failed: '失败',
|
||||
}
|
||||
Reference in New Issue
Block a user