修复错题图片显示、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
@@ -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} />
}
+48 -21
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 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()
}}
/>
)}
</>
+41 -24
View File
@@ -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
/>