修复错题图片显示、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
-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="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
<script type="module" crossorigin src="/assets/index-BbvdgaGu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-8NG7km60.css">
<script type="module" crossorigin src="/assets/index-FkWLM-t9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css">
</head>
<body>
<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} />
}
+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
/>
+15 -3
View File
@@ -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 {
+39 -15
View File
@@ -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}
+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 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' }}
/>