修复错题图片显示、Tab 刷新跳转,奥数仅数学并支持删除。
- 图片通过带 Token 的 blob 请求加载,修复不显示 - URL ?tab= 保持当前标签,刷新列表不再重置到成绩录入 - 奥数区固定数学科目;错题卡片与详情增加删除
This commit is contained in:
@@ -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 type { WrongQuestion } from '../types'
|
||||
import { STATUS_LABELS } from '../types'
|
||||
import AuthenticatedImage from './AuthenticatedImage'
|
||||
import WrongQuestionDetail from '../pages/WrongQuestionDetail'
|
||||
|
||||
interface Props {
|
||||
@@ -19,30 +21,51 @@ export default function WrongQuestionList({
|
||||
onRefresh,
|
||||
emptyText = '暂无记录',
|
||||
}: Props) {
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await wrongQuestionApi.remove(id)
|
||||
message.success('已删除')
|
||||
if (selectedId === id) onSelect(null)
|
||||
onRefresh()
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="wq-grid">
|
||||
{items.map((wq) => (
|
||||
<div key={wq.id} className="wq-card" onClick={() => onSelect(wq.id)}>
|
||||
<img
|
||||
src={wrongQuestionApi.imageUrl(wq.id)}
|
||||
alt="题目"
|
||||
className="wq-card-img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="wq-card-body">
|
||||
<Typography.Text strong>{wq.subject_name}</Typography.Text>
|
||||
{wq.category === 'olympiad' && (
|
||||
<Tag color="gold" style={{ marginLeft: 4 }}>
|
||||
奥数
|
||||
</Tag>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
{STATUS_LABELS[wq.status]}
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph ellipsis={{ rows: 2 }} style={{ margin: '8px 0 0', fontSize: 13 }}>
|
||||
{wq.question_text || wq.ocr_raw_text || '处理中…'}
|
||||
</Typography.Paragraph>
|
||||
<div key={wq.id} className="wq-card">
|
||||
<div className="wq-card-click" onClick={() => onSelect(wq.id)}>
|
||||
<AuthenticatedImage questionId={wq.id} alt="题目" className="wq-card-img" />
|
||||
<div className="wq-card-body">
|
||||
<Typography.Text strong>{wq.subject_name}</Typography.Text>
|
||||
{wq.category === 'olympiad' && (
|
||||
<Tag color="gold" style={{ marginLeft: 4 }}>
|
||||
奥数
|
||||
</Tag>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
{STATUS_LABELS[wq.status]}
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph ellipsis={{ rows: 2 }} style={{ margin: '8px 0 0', fontSize: 13 }}>
|
||||
{wq.question_text || wq.ocr_raw_text || '处理中…'}
|
||||
</Typography.Paragraph>
|
||||
</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>
|
||||
))}
|
||||
@@ -54,6 +77,10 @@ export default function WrongQuestionList({
|
||||
open={!!selectedId}
|
||||
onClose={() => onSelect(null)}
|
||||
onUpdated={onRefresh}
|
||||
onDeleted={() => {
|
||||
onSelect(null)
|
||||
onRefresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CameraOutlined, PictureOutlined, ReloadOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { Button, Input, Select, Space, Upload, message } from 'antd'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Button, Input, Select, Space, Typography, Upload, message } from 'antd'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { Subject, WrongQuestionCategory } from '../types'
|
||||
|
||||
@@ -12,13 +12,24 @@ interface 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 cameraRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (availableSubjects.length) {
|
||||
setSubjectId(availableSubjects[0].id)
|
||||
}
|
||||
}, [availableSubjects])
|
||||
|
||||
const doUpload = async (file: File) => {
|
||||
if (!subjectId) {
|
||||
message.warning('请选择科目')
|
||||
message.warning(isOlympiad ? '未找到数学科目' : '请选择科目')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
@@ -44,17 +55,19 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
|
||||
if (file) await doUpload(file)
|
||||
}
|
||||
|
||||
const isOlympiad = category === 'olympiad'
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Select
|
||||
style={{ width: '100%', maxWidth: 200 }}
|
||||
placeholder="选择科目"
|
||||
value={subjectId}
|
||||
onChange={setSubjectId}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
{isOlympiad ? (
|
||||
<Typography.Text>科目:数学(奥数区仅支持数学)</Typography.Text>
|
||||
) : (
|
||||
<Select
|
||||
style={{ width: '100%', maxWidth: 200 }}
|
||||
placeholder="选择科目"
|
||||
value={subjectId}
|
||||
onChange={setSubjectId}
|
||||
options={availableSubjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
)}
|
||||
<Space wrap className="upload-actions">
|
||||
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
|
||||
<Button icon={<PictureOutlined />} loading={uploading} type="primary" size="large">
|
||||
@@ -87,7 +100,7 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
|
||||
</Space>
|
||||
{isOlympiad && (
|
||||
<span style={{ color: '#666', fontSize: 13 }}>
|
||||
奥数区将按学生学段(初中/高中)生成解题思路,严禁超纲方法
|
||||
奥数区仅数学,按学生学段(初中/高中)生成解题思路,严禁超纲
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
@@ -96,11 +109,12 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
|
||||
|
||||
interface SearchProps {
|
||||
subjectId?: number
|
||||
onSubjectChange: (id?: number) => void
|
||||
onSubjectChange?: (id?: number) => void
|
||||
search: string
|
||||
onSearchChange: (q: string) => void
|
||||
onRefresh: () => void
|
||||
subjects: Subject[]
|
||||
hideSubjectFilter?: boolean
|
||||
}
|
||||
|
||||
export function WrongQuestionFilters({
|
||||
@@ -110,22 +124,25 @@ export function WrongQuestionFilters({
|
||||
onSearchChange,
|
||||
onRefresh,
|
||||
subjects,
|
||||
hideSubjectFilter,
|
||||
}: SearchProps) {
|
||||
return (
|
||||
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: '100%', maxWidth: 140 }}
|
||||
placeholder="全部科目"
|
||||
value={subjectId}
|
||||
onChange={onSubjectChange}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
{!hideSubjectFilter && (
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: '100%', maxWidth: 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}
|
||||
onSearch={() => onRefresh()}
|
||||
style={{ width: '100%', maxWidth: 260 }}
|
||||
allowClear
|
||||
/>
|
||||
|
||||
+15
-3
@@ -46,13 +46,25 @@ a {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
transition: box-shadow 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wq-card:active {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
.wq-card-click {
|
||||
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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { examApi, studentApi, subjectApi, wrongQuestionApi } from '../api/client'
|
||||
import ScoreForm from '../components/ScoreForm'
|
||||
import ScoreOverview from '../components/ScoreOverview'
|
||||
@@ -11,8 +11,15 @@ import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQu
|
||||
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
||||
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() {
|
||||
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 [subjects, setSubjects] = useState<Subject[]>([])
|
||||
const [exams, setExams] = useState<Exam[]>([])
|
||||
@@ -21,13 +28,13 @@ export default function StudentDetailPage() {
|
||||
const [wrongQuestions, setWrongQuestions] = useState<WrongQuestion[]>([])
|
||||
const [olympiadQuestions, setOlympiadQuestions] = useState<WrongQuestion[]>([])
|
||||
const [wqSubjectFilter, setWqSubjectFilter] = useState<number>()
|
||||
const [olympiadSubjectFilter, setOlympiadSubjectFilter] = useState<number>()
|
||||
const [wqSearch, setWqSearch] = useState('')
|
||||
const [olympiadSearch, setOlympiadSearch] = useState('')
|
||||
const [selectedWq, setSelectedWq] = useState<string | null>(null)
|
||||
const [selectedOlympiad, setSelectedOlympiad] = useState<string | null>(null)
|
||||
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 loadExams = useCallback(async () => {
|
||||
@@ -55,39 +62,54 @@ export default function StudentDetailPage() {
|
||||
const loadOlympiadQuestions = useCallback(async () => {
|
||||
if (!id) return
|
||||
const { data } = await wrongQuestionApi.list(id, {
|
||||
subject_id: olympiadSubjectFilter,
|
||||
subject_id: mathSubject?.id,
|
||||
q: olympiadSearch || undefined,
|
||||
category: 'olympiad',
|
||||
})
|
||||
setOlympiadQuestions(data)
|
||||
}, [id, olympiadSubjectFilter, olympiadSearch])
|
||||
}, [id, mathSubject?.id, olympiadSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const init = async () => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [studentRes, subjectRes] = await Promise.all([
|
||||
studentApi.get(id),
|
||||
subjectApi.list(),
|
||||
])
|
||||
if (cancelled) return
|
||||
setStudent(studentRes.data)
|
||||
setSubjects(subjectRes.data)
|
||||
if (subjectRes.data.length) setSelectedSubject(subjectRes.data[0].id)
|
||||
await loadExams()
|
||||
await loadWrongQuestions()
|
||||
await loadOlympiadQuestions()
|
||||
const examRes = await examApi.list(id)
|
||||
if (!cancelled) setExams(examRes.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
init()
|
||||
}, [id, loadExams, loadWrongQuestions, loadOlympiadQuestions])
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
loadWrongQuestions()
|
||||
}, [loadWrongQuestions])
|
||||
|
||||
useEffect(() => {
|
||||
loadOlympiadQuestions()
|
||||
}, [loadOlympiadQuestions])
|
||||
|
||||
useEffect(() => {
|
||||
loadTrend()
|
||||
}, [loadTrend])
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setSearchParams({ tab: key }, { replace: true })
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
@@ -133,6 +155,9 @@ export default function StudentDetailPage() {
|
||||
|
||||
<Tabs
|
||||
className="student-tabs"
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
destroyInactiveTabPane={false}
|
||||
items={[
|
||||
{
|
||||
key: 'scores',
|
||||
@@ -205,7 +230,7 @@ export default function StudentDetailPage() {
|
||||
children: (
|
||||
<div>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
{stageLabel}奥数解题思路,严格限制在{stageLabel}奥数培优范围内,禁止超纲
|
||||
{stageLabel}数学奥数,严格限制在{stageLabel}奥数培优范围内,禁止超纲
|
||||
</Typography.Paragraph>
|
||||
<WrongQuestionUpload
|
||||
studentId={id!}
|
||||
@@ -214,12 +239,11 @@ export default function StudentDetailPage() {
|
||||
onUploaded={loadOlympiadQuestions}
|
||||
/>
|
||||
<WrongQuestionFilters
|
||||
subjectId={olympiadSubjectFilter}
|
||||
onSubjectChange={setOlympiadSubjectFilter}
|
||||
search={olympiadSearch}
|
||||
onSearchChange={setOlympiadSearch}
|
||||
onRefresh={loadOlympiadQuestions}
|
||||
subjects={subjects}
|
||||
hideSubjectFilter
|
||||
/>
|
||||
<WrongQuestionList
|
||||
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 ReactMarkdown from 'react-markdown'
|
||||
import AuthenticatedImage from '../components/AuthenticatedImage'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { WrongQuestion } from '../types'
|
||||
import { STATUS_LABELS } from '../types'
|
||||
@@ -10,15 +11,23 @@ interface Props {
|
||||
open: boolean
|
||||
onClose: () => 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 [loading, setLoading] = useState(false)
|
||||
const [questionText, setQuestionText] = useState('')
|
||||
const [solutionText, setSolutionText] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [regenerating, setRegenerating] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
@@ -73,6 +82,20 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true)
|
||||
try {
|
||||
await wrongQuestionApi.remove(questionId)
|
||||
message.success('已删除')
|
||||
onDeleted?.()
|
||||
onClose()
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
@@ -86,6 +109,11 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
||||
style={{ maxWidth: 960 }}
|
||||
footer={
|
||||
<Space wrap>
|
||||
<Popconfirm title="确定删除该题?" onConfirm={handleDelete}>
|
||||
<Button danger loading={deleting}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button onClick={handleRetryOcr}>重新 OCR</Button>
|
||||
<Button loading={regenerating} onClick={handleRegenerate}>
|
||||
重新生成解法
|
||||
@@ -110,8 +138,8 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
|
||||
)}
|
||||
<Row gutter={16} style={{ marginTop: 12 }}>
|
||||
<Col xs={24} md={10}>
|
||||
<img
|
||||
src={wrongQuestionApi.imageUrl(wq.id)}
|
||||
<AuthenticatedImage
|
||||
questionId={wq.id}
|
||||
alt="原题"
|
||||
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user