修复错题图片显示、Tab 刷新跳转,奥数仅数学并支持删除。
- 图片通过带 Token 的 blob 请求加载,修复不显示 - URL ?tab= 保持当前标签,刷新列表不再重置到成绩录入 - 奥数区固定数学科目;错题卡片与详情增加删除
This commit is contained in:
@@ -142,6 +142,8 @@ async def upload_wrong_question(
|
|||||||
subject = db.get(Subject, subject_id)
|
subject = db.get(Subject, subject_id)
|
||||||
if subject is None:
|
if subject is None:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="科目不存在")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="科目不存在")
|
||||||
|
if category == WrongQuestionCategoryEnum.olympiad and subject.name != "数学":
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="奥数区仅支持数学科目")
|
||||||
|
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
if len(content) > settings.MAX_UPLOAD_SIZE:
|
if len(content) > settings.MAX_UPLOAD_SIZE:
|
||||||
|
|||||||
-1
@@ -1 +0,0 @@
|
|||||||
*{box-sizing:border-box}body{-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent;background:#f5f5f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}#root{min-height:100dvh}a{color:inherit}.page-container{max-width:1100px;padding:16px 16px 32px;padding-bottom:max(32px, env(safe-area-inset-bottom));margin:0 auto}.page-header{width:100%;margin-bottom:16px}.student-tabs .ant-tabs-nav{margin-bottom:12px}.wq-grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:16px;display:grid}.wq-card{cursor:pointer;background:#fff;border:1px solid #f0f0f0;border-radius:8px;transition:box-shadow .2s;overflow:hidden}.wq-card:active{box-shadow:0 2px 8px #00000014}.wq-card-img{object-fit:cover;background:#fafafa;width:100%;height:140px}.wq-card-body{padding:12px}.upload-actions .ant-btn{min-height:44px}@media (width<=768px){.page-container{padding:12px 12px 24px}.ant-tabs-tab{font-size:14px;padding:8px 10px!important}.ant-modal{max-width:calc(100vw - 16px)!important;margin:8px auto!important}.ant-table{font-size:12px}.upload-actions{width:100%}.upload-actions .ant-btn{flex:1;min-width:120px}}@media (width<=576px){.wq-card-img{height:120px}}
|
|
||||||
-432
File diff suppressed because one or more lines are too long
+432
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
*{box-sizing:border-box}body{-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent;background:#f5f5f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}#root{min-height:100dvh}a{color:inherit}.page-container{max-width:1100px;padding:16px 16px 32px;padding-bottom:max(32px, env(safe-area-inset-bottom));margin:0 auto}.page-header{width:100%;margin-bottom:16px}.student-tabs .ant-tabs-nav{margin-bottom:12px}.wq-grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:16px;display:grid}.wq-card{background:#fff;border:1px solid #f0f0f0;border-radius:8px;flex-direction:column;transition:box-shadow .2s;display:flex;overflow:hidden}.wq-card-click{cursor:pointer;flex:1}.wq-card-click:active{opacity:.95}.wq-card-actions{text-align:right;border-top:1px solid #f5f5f5;padding:4px 8px 8px}.wq-card-img{object-fit:cover;background:#fafafa;width:100%;height:140px}.wq-card-body{padding:12px}.upload-actions .ant-btn{min-height:44px}@media (width<=768px){.page-container{padding:12px 12px 24px}.ant-tabs-tab{font-size:14px;padding:8px 10px!important}.ant-modal{max-width:calc(100vw - 16px)!important;margin:8px auto!important}.ant-table{font-size:12px}.upload-actions{width:100%}.upload-actions .ant-btn{flex:1;min-width:120px}}@media (width<=576px){.wq-card-img{height:120px}}
|
||||||
Vendored
+2
-2
@@ -9,8 +9,8 @@
|
|||||||
<meta name="author" content="马建军" />
|
<meta name="author" content="马建军" />
|
||||||
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
||||||
<title>中学成绩档案</title>
|
<title>中学成绩档案</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BbvdgaGu.js"></script>
|
<script type="module" crossorigin src="/assets/index-FkWLM-t9.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-8NG7km60.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
questionId: string
|
||||||
|
className?: string
|
||||||
|
alt?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthenticatedImage({
|
||||||
|
questionId,
|
||||||
|
className,
|
||||||
|
alt = '题目',
|
||||||
|
style,
|
||||||
|
}: Props) {
|
||||||
|
const [src, setSrc] = useState<string | null>(null)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let objectUrl: string | null = null
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
api
|
||||||
|
.get(`/wrong-questions/${questionId}/image`, { responseType: 'blob' })
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return
|
||||||
|
objectUrl = URL.createObjectURL(res.data)
|
||||||
|
setSrc(objectUrl)
|
||||||
|
setFailed(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFailed(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
||||||
|
}
|
||||||
|
}, [questionId])
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ ...style, background: '#fafafa', color: '#999', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12 }}>
|
||||||
|
图片加载失败
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ ...style, background: '#fafafa' }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <img src={src} alt={alt} className={className} style={style} />
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Tag, Typography } from 'antd'
|
import { DeleteOutlined } from '@ant-design/icons'
|
||||||
|
import { Button, Popconfirm, Tag, Typography, message } from 'antd'
|
||||||
import { wrongQuestionApi } from '../api/client'
|
import { wrongQuestionApi } from '../api/client'
|
||||||
import type { WrongQuestion } from '../types'
|
import type { WrongQuestion } from '../types'
|
||||||
import { STATUS_LABELS } from '../types'
|
import { STATUS_LABELS } from '../types'
|
||||||
|
import AuthenticatedImage from './AuthenticatedImage'
|
||||||
import WrongQuestionDetail from '../pages/WrongQuestionDetail'
|
import WrongQuestionDetail from '../pages/WrongQuestionDetail'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,17 +21,24 @@ export default function WrongQuestionList({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
emptyText = '暂无记录',
|
emptyText = '暂无记录',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await wrongQuestionApi.remove(id)
|
||||||
|
message.success('已删除')
|
||||||
|
if (selectedId === id) onSelect(null)
|
||||||
|
onRefresh()
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="wq-grid">
|
<div className="wq-grid">
|
||||||
{items.map((wq) => (
|
{items.map((wq) => (
|
||||||
<div key={wq.id} className="wq-card" onClick={() => onSelect(wq.id)}>
|
<div key={wq.id} className="wq-card">
|
||||||
<img
|
<div className="wq-card-click" onClick={() => onSelect(wq.id)}>
|
||||||
src={wrongQuestionApi.imageUrl(wq.id)}
|
<AuthenticatedImage questionId={wq.id} alt="题目" className="wq-card-img" />
|
||||||
alt="题目"
|
|
||||||
className="wq-card-img"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div className="wq-card-body">
|
<div className="wq-card-body">
|
||||||
<Typography.Text strong>{wq.subject_name}</Typography.Text>
|
<Typography.Text strong>{wq.subject_name}</Typography.Text>
|
||||||
{wq.category === 'olympiad' && (
|
{wq.category === 'olympiad' && (
|
||||||
@@ -45,6 +54,20 @@ export default function WrongQuestionList({
|
|||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="wq-card-actions">
|
||||||
|
<Popconfirm title="确定删除该题?" onConfirm={() => handleDelete(wq.id)}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{items.length === 0 && <Typography.Text type="secondary">{emptyText}</Typography.Text>}
|
{items.length === 0 && <Typography.Text type="secondary">{emptyText}</Typography.Text>}
|
||||||
@@ -54,6 +77,10 @@ export default function WrongQuestionList({
|
|||||||
open={!!selectedId}
|
open={!!selectedId}
|
||||||
onClose={() => onSelect(null)}
|
onClose={() => onSelect(null)}
|
||||||
onUpdated={onRefresh}
|
onUpdated={onRefresh}
|
||||||
|
onDeleted={() => {
|
||||||
|
onSelect(null)
|
||||||
|
onRefresh()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CameraOutlined, PictureOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons'
|
import { CameraOutlined, PictureOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons'
|
||||||
import { Button, Input, Select, Space, Upload, message } from 'antd'
|
import { Button, Input, Select, Space, Typography, Upload, message } from 'antd'
|
||||||
import { useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { wrongQuestionApi } from '../api/client'
|
import { wrongQuestionApi } from '../api/client'
|
||||||
import type { Subject, WrongQuestionCategory } from '../types'
|
import type { Subject, WrongQuestionCategory } from '../types'
|
||||||
|
|
||||||
@@ -12,13 +12,24 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function WrongQuestionUpload({ studentId, subjects, category, onUploaded }: Props) {
|
export default function WrongQuestionUpload({ studentId, subjects, category, onUploaded }: Props) {
|
||||||
const [subjectId, setSubjectId] = useState<number | undefined>(subjects[0]?.id)
|
const isOlympiad = category === 'olympiad'
|
||||||
|
const availableSubjects = useMemo(
|
||||||
|
() => (isOlympiad ? subjects.filter((s) => s.name === '数学') : subjects),
|
||||||
|
[subjects, isOlympiad],
|
||||||
|
)
|
||||||
|
const [subjectId, setSubjectId] = useState<number | undefined>()
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const cameraRef = useRef<HTMLInputElement>(null)
|
const cameraRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (availableSubjects.length) {
|
||||||
|
setSubjectId(availableSubjects[0].id)
|
||||||
|
}
|
||||||
|
}, [availableSubjects])
|
||||||
|
|
||||||
const doUpload = async (file: File) => {
|
const doUpload = async (file: File) => {
|
||||||
if (!subjectId) {
|
if (!subjectId) {
|
||||||
message.warning('请选择科目')
|
message.warning(isOlympiad ? '未找到数学科目' : '请选择科目')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
@@ -44,17 +55,19 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
|
|||||||
if (file) await doUpload(file)
|
if (file) await doUpload(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOlympiad = category === 'olympiad'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%', marginBottom: 16 }}>
|
<Space direction="vertical" size="middle" style={{ width: '100%', marginBottom: 16 }}>
|
||||||
|
{isOlympiad ? (
|
||||||
|
<Typography.Text>科目:数学(奥数区仅支持数学)</Typography.Text>
|
||||||
|
) : (
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '100%', maxWidth: 200 }}
|
style={{ width: '100%', maxWidth: 200 }}
|
||||||
placeholder="选择科目"
|
placeholder="选择科目"
|
||||||
value={subjectId}
|
value={subjectId}
|
||||||
onChange={setSubjectId}
|
onChange={setSubjectId}
|
||||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
options={availableSubjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<Space wrap className="upload-actions">
|
<Space wrap className="upload-actions">
|
||||||
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
|
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
|
||||||
<Button icon={<PictureOutlined />} loading={uploading} type="primary" size="large">
|
<Button icon={<PictureOutlined />} loading={uploading} type="primary" size="large">
|
||||||
@@ -87,7 +100,7 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
|
|||||||
</Space>
|
</Space>
|
||||||
{isOlympiad && (
|
{isOlympiad && (
|
||||||
<span style={{ color: '#666', fontSize: 13 }}>
|
<span style={{ color: '#666', fontSize: 13 }}>
|
||||||
奥数区将按学生学段(初中/高中)生成解题思路,严禁超纲方法
|
奥数区仅数学,按学生学段(初中/高中)生成解题思路,严禁超纲
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -96,11 +109,12 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
|
|||||||
|
|
||||||
interface SearchProps {
|
interface SearchProps {
|
||||||
subjectId?: number
|
subjectId?: number
|
||||||
onSubjectChange: (id?: number) => void
|
onSubjectChange?: (id?: number) => void
|
||||||
search: string
|
search: string
|
||||||
onSearchChange: (q: string) => void
|
onSearchChange: (q: string) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
subjects: Subject[]
|
subjects: Subject[]
|
||||||
|
hideSubjectFilter?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WrongQuestionFilters({
|
export function WrongQuestionFilters({
|
||||||
@@ -110,9 +124,11 @@ export function WrongQuestionFilters({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
subjects,
|
subjects,
|
||||||
|
hideSubjectFilter,
|
||||||
}: SearchProps) {
|
}: SearchProps) {
|
||||||
return (
|
return (
|
||||||
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
|
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
|
||||||
|
{!hideSubjectFilter && (
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: '100%', maxWidth: 140 }}
|
style={{ width: '100%', maxWidth: 140 }}
|
||||||
@@ -121,11 +137,12 @@ export function WrongQuestionFilters({
|
|||||||
onChange={onSubjectChange}
|
onChange={onSubjectChange}
|
||||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder="搜索题目/解法"
|
placeholder="搜索题目/解法"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
onSearch={onRefresh}
|
onSearch={() => onRefresh()}
|
||||||
style={{ width: '100%', maxWidth: 260 }}
|
style={{ width: '100%', maxWidth: 260 }}
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
|
|||||||
+15
-3
@@ -46,13 +46,25 @@ a {
|
|||||||
border: 1px solid #f0f0f0;
|
border: 1px solid #f0f0f0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
transition: box-shadow 0.2s;
|
transition: box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wq-card:active {
|
.wq-card-click {
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wq-card-click:active {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wq-card-actions {
|
||||||
|
padding: 4px 8px 8px;
|
||||||
|
border-top: 1px solid #f5f5f5;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wq-card-img {
|
.wq-card-img {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons'
|
import { ArrowLeftOutlined, DownloadOutlined } from '@ant-design/icons'
|
||||||
import { Button, Select, Space, Spin, Tabs, Tag, Typography, message } from 'antd'
|
import { Button, Select, Space, Spin, Tabs, Tag, Typography, message } from 'antd'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { examApi, studentApi, subjectApi, wrongQuestionApi } from '../api/client'
|
import { examApi, studentApi, subjectApi, wrongQuestionApi } from '../api/client'
|
||||||
import ScoreForm from '../components/ScoreForm'
|
import ScoreForm from '../components/ScoreForm'
|
||||||
import ScoreOverview from '../components/ScoreOverview'
|
import ScoreOverview from '../components/ScoreOverview'
|
||||||
@@ -11,8 +11,15 @@ import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQu
|
|||||||
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
||||||
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
|
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
|
||||||
|
|
||||||
|
const TAB_KEYS = ['scores', 'overview', 'trend', 'wrong', 'olympiad'] as const
|
||||||
|
type TabKey = (typeof TAB_KEYS)[number]
|
||||||
|
|
||||||
export default function StudentDetailPage() {
|
export default function StudentDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const tabParam = searchParams.get('tab')
|
||||||
|
const activeTab: TabKey = TAB_KEYS.includes(tabParam as TabKey) ? (tabParam as TabKey) : 'scores'
|
||||||
|
|
||||||
const [student, setStudent] = useState<Student | null>(null)
|
const [student, setStudent] = useState<Student | null>(null)
|
||||||
const [subjects, setSubjects] = useState<Subject[]>([])
|
const [subjects, setSubjects] = useState<Subject[]>([])
|
||||||
const [exams, setExams] = useState<Exam[]>([])
|
const [exams, setExams] = useState<Exam[]>([])
|
||||||
@@ -21,13 +28,13 @@ export default function StudentDetailPage() {
|
|||||||
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
|
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
|
||||||
const [olympiadQuestions, setOlympiadQuestions] = useState<WrongQuestion[]>([])
|
const [olympiadQuestions, setOlympiadQuestions] = useState<WrongQuestion[]>([])
|
||||||
const [wqSubjectFilter, setWqSubjectFilter] = useState<number>()
|
const [wqSubjectFilter, setWqSubjectFilter] = useState<number>()
|
||||||
const [olympiadSubjectFilter, setOlympiadSubjectFilter] = useState<number>()
|
|
||||||
const [wqSearch, setWqSearch] = useState('')
|
const [wqSearch, setWqSearch] = useState('')
|
||||||
const [olympiadSearch, setOlympiadSearch] = useState('')
|
const [olympiadSearch, setOlympiadSearch] = useState('')
|
||||||
const [selectedWq, setSelectedWq] = useState<string | null>(null)
|
const [selectedWq, setSelectedWq] = useState<string | null>(null)
|
||||||
const [selectedOlympiad, setSelectedOlympiad] = useState<string | null>(null)
|
const [selectedOlympiad, setSelectedOlympiad] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const mathSubject = useMemo(() => subjects.find((s) => s.name === '数学'), [subjects])
|
||||||
const subjectNames = Object.fromEntries(subjects.map((s) => [s.id, s.name]))
|
const subjectNames = Object.fromEntries(subjects.map((s) => [s.id, s.name]))
|
||||||
|
|
||||||
const loadExams = useCallback(async () => {
|
const loadExams = useCallback(async () => {
|
||||||
@@ -55,39 +62,54 @@ export default function StudentDetailPage() {
|
|||||||
const loadOlympiadQuestions = useCallback(async () => {
|
const loadOlympiadQuestions = useCallback(async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
const { data } = await wrongQuestionApi.list(id, {
|
const { data } = await wrongQuestionApi.list(id, {
|
||||||
subject_id: olympiadSubjectFilter,
|
subject_id: mathSubject?.id,
|
||||||
q: olympiadSearch || undefined,
|
q: olympiadSearch || undefined,
|
||||||
category: 'olympiad',
|
category: 'olympiad',
|
||||||
})
|
})
|
||||||
setOlympiadQuestions(data)
|
setOlympiadQuestions(data)
|
||||||
}, [id, olympiadSubjectFilter, olympiadSearch])
|
}, [id, mathSubject?.id, olympiadSearch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
const init = async () => {
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [studentRes, subjectRes] = await Promise.all([
|
const [studentRes, subjectRes] = await Promise.all([
|
||||||
studentApi.get(id),
|
studentApi.get(id),
|
||||||
subjectApi.list(),
|
subjectApi.list(),
|
||||||
])
|
])
|
||||||
|
if (cancelled) return
|
||||||
setStudent(studentRes.data)
|
setStudent(studentRes.data)
|
||||||
setSubjects(subjectRes.data)
|
setSubjects(subjectRes.data)
|
||||||
if (subjectRes.data.length) setSelectedSubject(subjectRes.data[0].id)
|
if (subjectRes.data.length) setSelectedSubject(subjectRes.data[0].id)
|
||||||
await loadExams()
|
const examRes = await examApi.list(id)
|
||||||
await loadWrongQuestions()
|
if (!cancelled) setExams(examRes.data)
|
||||||
await loadOlympiadQuestions()
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (!cancelled) setLoading(false)
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
}
|
}
|
||||||
init()
|
}, [id])
|
||||||
}, [id, loadExams, loadWrongQuestions, loadOlympiadQuestions])
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWrongQuestions()
|
||||||
|
}, [loadWrongQuestions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOlympiadQuestions()
|
||||||
|
}, [loadOlympiadQuestions])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTrend()
|
loadTrend()
|
||||||
}, [loadTrend])
|
}, [loadTrend])
|
||||||
|
|
||||||
|
const handleTabChange = (key: string) => {
|
||||||
|
setSearchParams({ tab: key }, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
@@ -133,6 +155,9 @@ export default function StudentDetailPage() {
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
className="student-tabs"
|
className="student-tabs"
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
destroyInactiveTabPane={false}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'scores',
|
key: 'scores',
|
||||||
@@ -205,7 +230,7 @@ export default function StudentDetailPage() {
|
|||||||
children: (
|
children: (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
{stageLabel}奥数解题思路,严格限制在{stageLabel}奥数培优范围内,禁止超纲
|
{stageLabel}数学奥数,严格限制在{stageLabel}奥数培优范围内,禁止超纲
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<WrongQuestionUpload
|
<WrongQuestionUpload
|
||||||
studentId={id!}
|
studentId={id!}
|
||||||
@@ -214,12 +239,11 @@ export default function StudentDetailPage() {
|
|||||||
onUploaded={loadOlympiadQuestions}
|
onUploaded={loadOlympiadQuestions}
|
||||||
/>
|
/>
|
||||||
<WrongQuestionFilters
|
<WrongQuestionFilters
|
||||||
subjectId={olympiadSubjectFilter}
|
|
||||||
onSubjectChange={setOlympiadSubjectFilter}
|
|
||||||
search={olympiadSearch}
|
search={olympiadSearch}
|
||||||
onSearchChange={setOlympiadSearch}
|
onSearchChange={setOlympiadSearch}
|
||||||
onRefresh={loadOlympiadQuestions}
|
onRefresh={loadOlympiadQuestions}
|
||||||
subjects={subjects}
|
subjects={subjects}
|
||||||
|
hideSubjectFilter
|
||||||
/>
|
/>
|
||||||
<WrongQuestionList
|
<WrongQuestionList
|
||||||
items={olympiadQuestions}
|
items={olympiadQuestions}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Alert, Button, Col, Input, Modal, Row, Space, Spin, Typography, message } from 'antd'
|
import { Alert, Button, Col, Input, Modal, Popconfirm, Row, Space, Spin, Typography, message } from 'antd'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import AuthenticatedImage from '../components/AuthenticatedImage'
|
||||||
import { wrongQuestionApi } from '../api/client'
|
import { wrongQuestionApi } from '../api/client'
|
||||||
import type { WrongQuestion } from '../types'
|
import type { WrongQuestion } from '../types'
|
||||||
import { STATUS_LABELS } from '../types'
|
import { STATUS_LABELS } from '../types'
|
||||||
@@ -10,15 +11,23 @@ interface Props {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onUpdated: () => void
|
onUpdated: () => void
|
||||||
|
onDeleted?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WrongQuestionDetail({ questionId, open, onClose, onUpdated }: Props) {
|
export default function WrongQuestionDetail({
|
||||||
|
questionId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onUpdated,
|
||||||
|
onDeleted,
|
||||||
|
}: Props) {
|
||||||
const [wq, setWq] = useState<WrongQuestion | null>(null)
|
const [wq, setWq] = useState<WrongQuestion | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [questionText, setQuestionText] = useState('')
|
const [questionText, setQuestionText] = useState('')
|
||||||
const [solutionText, setSolutionText] = useState('')
|
const [solutionText, setSolutionText] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [regenerating, setRegenerating] = useState(false)
|
const [regenerating, setRegenerating] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -73,6 +82,20 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await wrongQuestionApi.remove(questionId)
|
||||||
|
message.success('已删除')
|
||||||
|
onDeleted?.()
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败')
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title={
|
||||||
@@ -86,6 +109,11 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
|||||||
style={{ maxWidth: 960 }}
|
style={{ maxWidth: 960 }}
|
||||||
footer={
|
footer={
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
<Popconfirm title="确定删除该题?" onConfirm={handleDelete}>
|
||||||
|
<Button danger loading={deleting}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
<Button onClick={handleRetryOcr}>重新 OCR</Button>
|
<Button onClick={handleRetryOcr}>重新 OCR</Button>
|
||||||
<Button loading={regenerating} onClick={handleRegenerate}>
|
<Button loading={regenerating} onClick={handleRegenerate}>
|
||||||
重新生成解法
|
重新生成解法
|
||||||
@@ -110,8 +138,8 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
|||||||
)}
|
)}
|
||||||
<Row gutter={16} style={{ marginTop: 12 }}>
|
<Row gutter={16} style={{ marginTop: 12 }}>
|
||||||
<Col xs={24} md={10}>
|
<Col xs={24} md={10}>
|
||||||
<img
|
<AuthenticatedImage
|
||||||
src={wrongQuestionApi.imageUrl(wq.id)}
|
questionId={wq.id}
|
||||||
alt="原题"
|
alt="原题"
|
||||||
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
|
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user