修复错题图片显示、Tab 刷新跳转,奥数仅数学并支持删除。

- 图片通过带 Token 的 blob 请求加载,修复不显示

- URL ?tab= 保持当前标签,刷新列表不再重置到成绩录入

- 奥数区固定数学科目;错题卡片与详情增加删除
This commit is contained in:
dekun
2026-06-28 13:47:53 +08:00
parent 43483bf56f
commit c30e21b51e
12 changed files with 669 additions and 502 deletions
+2
View File
@@ -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
View File
@@ -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}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -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}}
+2 -2
View File
@@ -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} />
}
+35 -8
View File
@@ -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()
}}
/> />
)} )}
</> </>
+27 -10
View File
@@ -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
View File
@@ -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 {
+39 -15
View File
@@ -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}
+32 -4
View File
@@ -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' }}
/> />