移动端拍照上传、奥数区、学段约束解题与 AI 模型配置。
- 手机/平板响应式布局,支持拍照与相册上传 - 学生详情新增奥数区,按初/高中学段生成解法并禁止超纲 - 系统设置可配置 Ollama 或 OpenAI 兼容 API - 更新 frontend/dist 与使用说明
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { Tag, Typography } from 'antd'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { WrongQuestion } from '../types'
|
||||
import { STATUS_LABELS } from '../types'
|
||||
import WrongQuestionDetail from '../pages/WrongQuestionDetail'
|
||||
|
||||
interface Props {
|
||||
items: WrongQuestion[]
|
||||
selectedId: string | null
|
||||
onSelect: (id: string | null) => void
|
||||
onRefresh: () => void
|
||||
emptyText?: string
|
||||
}
|
||||
|
||||
export default function WrongQuestionList({
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onRefresh,
|
||||
emptyText = '暂无记录',
|
||||
}: Props) {
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{items.length === 0 && <Typography.Text type="secondary">{emptyText}</Typography.Text>}
|
||||
{selectedId && (
|
||||
<WrongQuestionDetail
|
||||
questionId={selectedId}
|
||||
open={!!selectedId}
|
||||
onClose={() => onSelect(null)}
|
||||
onUpdated={onRefresh}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +1,95 @@
|
||||
import { 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 { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { Subject } from '../types'
|
||||
import type { Subject, WrongQuestionCategory } from '../types'
|
||||
|
||||
interface Props {
|
||||
studentId: string
|
||||
subjects: Subject[]
|
||||
category: WrongQuestionCategory
|
||||
onUploaded: () => void
|
||||
}
|
||||
|
||||
export default function WrongQuestionUpload({ studentId, subjects, onUploaded }: Props) {
|
||||
export default function WrongQuestionUpload({ studentId, subjects, category, onUploaded }: Props) {
|
||||
const [subjectId, setSubjectId] = useState<number | undefined>(subjects[0]?.id)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const doUpload = async (file: File) => {
|
||||
if (!subjectId) {
|
||||
message.warning('请选择科目')
|
||||
return false
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
await wrongQuestionApi.upload(studentId, subjectId, file)
|
||||
message.success('上传成功,正在 OCR 识别并生成解法…')
|
||||
await wrongQuestionApi.upload(studentId, subjectId, file, category)
|
||||
message.success('上传成功,正在识别并生成解法…')
|
||||
onUploaded()
|
||||
} catch {
|
||||
message.error('上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
await doUpload(file)
|
||||
return false
|
||||
}
|
||||
|
||||
const handleCamera = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (file) await doUpload(file)
|
||||
}
|
||||
|
||||
const isOlympiad = category === 'olympiad'
|
||||
|
||||
return (
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
style={{ width: '100%', maxWidth: 200 }}
|
||||
placeholder="选择科目"
|
||||
value={subjectId}
|
||||
onChange={setSubjectId}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
|
||||
<Button icon={<UploadOutlined />} loading={uploading} type="primary">
|
||||
上传错题图片
|
||||
<Space wrap className="upload-actions">
|
||||
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
|
||||
<Button icon={<PictureOutlined />} loading={uploading} type="primary" size="large">
|
||||
相册选图
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button
|
||||
icon={<CameraOutlined />}
|
||||
loading={uploading}
|
||||
size="large"
|
||||
onClick={() => cameraRef.current?.click()}
|
||||
>
|
||||
拍照上传
|
||||
</Button>
|
||||
</Upload>
|
||||
<input
|
||||
ref={cameraRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleCamera}
|
||||
/>
|
||||
{!isOlympiad && (
|
||||
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
|
||||
<Button icon={<UploadOutlined />} loading={uploading} size="large">
|
||||
上传图片
|
||||
</Button>
|
||||
</Upload>
|
||||
)}
|
||||
</Space>
|
||||
{isOlympiad && (
|
||||
<span style={{ color: '#666', fontSize: 13 }}>
|
||||
奥数区将按学生学段(初中/高中)生成解题思路,严禁超纲方法
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -68,10 +112,10 @@ export function WrongQuestionFilters({
|
||||
subjects,
|
||||
}: SearchProps) {
|
||||
return (
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
style={{ width: '100%', maxWidth: 140 }}
|
||||
placeholder="全部科目"
|
||||
value={subjectId}
|
||||
onChange={onSubjectChange}
|
||||
@@ -82,7 +126,7 @@ export function WrongQuestionFilters({
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onSearch={onRefresh}
|
||||
style={{ width: 220 }}
|
||||
style={{ width: '100%', maxWidth: 260 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={onRefresh}>
|
||||
|
||||
Reference in New Issue
Block a user