|
@@ -0,0 +1,782 @@
|
|
|
|
|
+import { useEffect, useRef, useState } from 'react'
|
|
|
|
|
+import { Button, Picker, Text, View } from '@tarojs/components'
|
|
|
|
|
+import Image from '../../components/PhotoImage'
|
|
|
|
|
+import Taro, { useRouter } from '@tarojs/taro'
|
|
|
|
|
+import { Actions, Chips, Empty, Field, Page, QuestionCard, Select, Swipe } from '../../components/WorkbenchUI'
|
|
|
|
|
+import StudentPicker from '../../components/StudentPicker'
|
|
|
|
|
+import {
|
|
|
|
|
+ addRecord,
|
|
|
|
|
+ difficulties,
|
|
|
|
|
+ formatTime,
|
|
|
|
|
+ paperTypes,
|
|
|
|
|
+ provinces,
|
|
|
|
|
+ recognizePhotos,
|
|
|
|
|
+ topics,
|
|
|
|
|
+ validateUpload,
|
|
|
|
|
+ type Photo,
|
|
|
|
|
+ type Question,
|
|
|
|
|
+ type RecordItem,
|
|
|
|
|
+ type Student,
|
|
|
|
|
+ type UploadKind
|
|
|
|
|
+} from '../../data/workbench'
|
|
|
|
|
+import { demoPhoto, newId, photoSource, pickDocuments, pickPhotos, preservePhotos } from '../../data/local-media'
|
|
|
|
|
+import { showError, useWorkbench } from '../../data/workbench-store'
|
|
|
|
|
+import captureReference from '../../assets/paper-capture-reference.png'
|
|
|
|
|
+import answerReference from '../../assets/answer-capture-reference.png'
|
|
|
|
|
+import './index.scss'
|
|
|
|
|
+
|
|
|
|
|
+type Screen = 'capture' | 'guide' | 'gallery' | 'preview' | 'review' | 'success' | 'failure'
|
|
|
|
|
+const titles = { mistakes: '上传错题', papers: '上传试卷', answers: '上传答题卡' }
|
|
|
|
|
+function AnswerGuide() {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <View className="answer-intro">
|
|
|
|
|
+ <Text className="answer-intro-title">答题卡拍摄规范</Text>
|
|
|
|
|
+ <Text className="answer-intro-description">将答题卡平放,镜头正对纸面{'\n'}每张照片拍摄一页,四周边缘完整入镜</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <View className="answer-stage">
|
|
|
|
|
+ <View className="answer-stage-heading"><Text className="answer-stage-label">A4 答题卡拍摄参考</Text><Text>正面 · 完整拍摄</Text></View>
|
|
|
|
|
+ <Image className="answer-reference" src={answerReference} mode="aspectFit" />
|
|
|
|
|
+ <Text className="answer-caption">{'✓ 姓名清晰\u3000✓ 二维码完整\u3000✓ 答案无遮挡'}</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <View className="answer-rules">
|
|
|
|
|
+ <View className="answer-rule"><Text className="answer-rule-title">▣ 拍全纸张</Text><Text>不裁边、不缺角</Text></View>
|
|
|
|
|
+ <View className="answer-rule"><Text className="answer-rule-title">☀ 光线均匀</Text><Text>无反光、无阴影</Text></View>
|
|
|
|
|
+ <View className="answer-rule"><Text className="answer-rule-title">▤ 字迹清晰</Text><Text>正对拍摄、不模糊</Text></View>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+function PaperSelect({ label, value, choices, onChange }: {
|
|
|
|
|
+ label: string
|
|
|
|
|
+ value: string
|
|
|
|
|
+ choices: string[]
|
|
|
|
|
+ onChange: (value: string) => void
|
|
|
|
|
+}) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Picker className="paper-meta-select" mode="selector" range={choices} value={Math.max(0, choices.indexOf(value))} onChange={(e) => onChange(choices[Number(e.detail.value)])}>
|
|
|
|
|
+ <View className="paper-meta-field select-field">
|
|
|
|
|
+ <Text className="field-label">{label}</Text>
|
|
|
|
|
+ <View className="paper-select-control">
|
|
|
|
|
+ <Text className={value ? '' : 'muted-copy'}>{value || `请选择${label}`}</Text>
|
|
|
|
|
+ <Text className="paper-select-arrow">⌄</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </Picker>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+export default function Upload() {
|
|
|
|
|
+ const route = useRouter()
|
|
|
|
|
+ const kind: UploadKind =
|
|
|
|
|
+ route.params.kind === 'papers' || route.params.kind === 'answers' ? route.params.kind : 'mistakes'
|
|
|
|
|
+ const { state, update } = useWorkbench()
|
|
|
|
|
+ const [screen, setScreen] = useState<Screen>('capture')
|
|
|
|
|
+ const [guideReturn, setGuideReturn] = useState<Screen>('capture')
|
|
|
|
|
+ const [photos, setPhotos] = useState<Photo[]>([])
|
|
|
|
|
+ const [questions, setQuestions] = useState<Question[]>([])
|
|
|
|
|
+ const [selectedStudents, setSelectedStudents] = useState<Student[]>([])
|
|
|
|
|
+ const [studentPicker, setStudentPicker] = useState(false)
|
|
|
|
|
+ const [name, setName] = useState('')
|
|
|
|
|
+ const [year, setYear] = useState('')
|
|
|
|
|
+ const [paperType, setPaperType] = useState('')
|
|
|
|
|
+ const [province, setProvince] = useState('')
|
|
|
|
|
+ const [index, setIndex] = useState(0)
|
|
|
|
|
+ const [photoIndex, setPhotoIndex] = useState(0)
|
|
|
|
|
+ const [busy, setBusy] = useState('')
|
|
|
|
|
+ const [savedId, setSavedId] = useState('')
|
|
|
|
|
+ const [failure, setFailure] = useState('')
|
|
|
|
|
+ const [retakeId, setRetakeId] = useState<string | null>(null)
|
|
|
|
|
+ const lock = useRef(false)
|
|
|
|
|
+ const timer = useRef<ReturnType<typeof setTimeout>>()
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ void Taro.pageScrollTo({ scrollTop: 0, duration: 0 })
|
|
|
|
|
+ }, [screen])
|
|
|
|
|
+ useEffect(
|
|
|
|
|
+ () => () => {
|
|
|
|
|
+ if (timer.current) clearTimeout(timer.current)
|
|
|
|
|
+ },
|
|
|
|
|
+ []
|
|
|
|
|
+ )
|
|
|
|
|
+ const metadata = { name, year, paperType, province }
|
|
|
|
|
+ const current = questions[index]
|
|
|
|
|
+ const record = state.records.find((r) => r.id === savedId)
|
|
|
|
|
+ const home = () => void Taro.switchTab({ url: '/pages/home/index' })
|
|
|
|
|
+ const toast = (title: string) => void Taro.showToast({ title, icon: 'none' })
|
|
|
|
|
+ const check = () => {
|
|
|
|
|
+ const message = validateUpload(kind, photos, selectedStudents, metadata)
|
|
|
|
|
+ if (message) {
|
|
|
|
|
+ if (kind === 'mistakes' && !selectedStudents.length) setStudentPicker(true)
|
|
|
|
|
+ else toast(message)
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ const addPhotos = (added: Photo[], replacement = retakeId) => {
|
|
|
|
|
+ if (!added.length) return
|
|
|
|
|
+ if (replacement) {
|
|
|
|
|
+ setPhotos((items) => items.map((p) => (p.id === replacement ? { ...added[0], id: p.id } : p)))
|
|
|
|
|
+ setRetakeId(null)
|
|
|
|
|
+ } else setPhotos((items) => [...items, ...added])
|
|
|
|
|
+ if (kind === 'answers') {
|
|
|
|
|
+ setPhotoIndex(replacement ? photos.findIndex((p) => p.id === replacement) : photos.length)
|
|
|
|
|
+ setScreen('preview')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const pick = async (camera: boolean, replacement = retakeId) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const added = await pickPhotos(camera)
|
|
|
|
|
+ addPhotos(camera ? added.map((item, i) => ({ ...item, name: `拍摄照片 ${photos.length + i + 1}` })) : added, replacement)
|
|
|
|
|
+ if (added.length && screen === 'guide' && kind !== 'answers') setScreen('capture')
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ showError(error)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const addExample = (replacement = retakeId) => {
|
|
|
|
|
+ addPhotos([demoPhoto()], replacement)
|
|
|
|
|
+ }
|
|
|
|
|
+ const importDocument = async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ addPhotos(await pickDocuments(), null)
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ showError(error)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const removePhoto = (id: string) => {
|
|
|
|
|
+ setPhotos((items) => items.filter((p) => p.id !== id))
|
|
|
|
|
+ setQuestions((items) => items.filter((q) => !q.id.startsWith(`${id}:`)))
|
|
|
|
|
+ setPhotoIndex((i) => Math.max(0, Math.min(i, photos.length - 2)))
|
|
|
|
|
+ if (photos.length === 1) setScreen('capture')
|
|
|
|
|
+ }
|
|
|
|
|
+ const recognize = () => {
|
|
|
|
|
+ if (lock.current || !check()) return
|
|
|
|
|
+ lock.current = true
|
|
|
|
|
+ setBusy('正在识别题目…')
|
|
|
|
|
+ timer.current = setTimeout(() => {
|
|
|
|
|
+ const next = recognizePhotos(photos, questions)
|
|
|
|
|
+ setQuestions(next)
|
|
|
|
|
+ setIndex(Math.min(index, next.length - 1))
|
|
|
|
|
+ setScreen('review')
|
|
|
|
|
+ setBusy('')
|
|
|
|
|
+ lock.current = false
|
|
|
|
|
+ }, 750)
|
|
|
|
|
+ }
|
|
|
|
|
+ const submit = async (simulateFailure = false) => {
|
|
|
|
|
+ if (lock.current || !check()) return
|
|
|
|
|
+ const chosen = kind === 'mistakes' ? questions.filter((q) => q.isWrong) : questions
|
|
|
|
|
+ if (kind !== 'answers' && !chosen.length) {
|
|
|
|
|
+ toast(kind === 'mistakes' ? '请至少标记一道错题' : '请先识别题目')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (chosen.some((q) => !q.content.trim() || !q.answer.trim() || q.options.some((option) => !option.trim()))) {
|
|
|
|
|
+ toast('请补齐题目、选项和参考答案')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ lock.current = true
|
|
|
|
|
+ setBusy('正在保存…')
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (simulateFailure) {
|
|
|
|
|
+ if (kind === 'answers')
|
|
|
|
|
+ setPhotos((items) =>
|
|
|
|
|
+ items.map((photo, i) =>
|
|
|
|
|
+ i === items.length - 1
|
|
|
|
|
+ ? { ...photo, failure: '照片模糊或纸张边缘不完整,请重新拍摄。' }
|
|
|
|
|
+ : { ...photo, uploaded: true, failure: undefined }
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ setFailure(
|
|
|
|
|
+ kind === 'answers'
|
|
|
|
|
+ ? '部分答题卡识别失败,已成功的图片无需重复上传。'
|
|
|
|
|
+ : '本次模拟上传未完成,已填写和识别的内容已保留。'
|
|
|
|
|
+ )
|
|
|
|
|
+ setScreen('failure')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (photos.some((p) => p.failure)) {
|
|
|
|
|
+ toast('请先重新拍摄识别失败的照片')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const preserved = await preservePhotos(photos)
|
|
|
|
|
+ const record: RecordItem = {
|
|
|
|
|
+ id: newId('record'),
|
|
|
|
|
+ kind,
|
|
|
|
|
+ ...metadata,
|
|
|
|
|
+ name: kind === 'papers' ? name.trim() : kind === 'answers' ? '答题卡' : '错题采集',
|
|
|
|
|
+ photos: preserved,
|
|
|
|
|
+ questions: chosen,
|
|
|
|
|
+ students: kind === 'mistakes' ? selectedStudents : [],
|
|
|
|
|
+ submittedAt: new Date().toISOString(),
|
|
|
|
|
+ ...(kind === 'papers' ? { reviewStatus: 'pending' as const } : {})
|
|
|
|
|
+ }
|
|
|
|
|
+ update((previous) => addRecord(previous, record))
|
|
|
|
|
+ setSavedId(record.id)
|
|
|
|
|
+ setScreen('success')
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ setFailure('本地保存未完成,请检查设备存储空间后重试。')
|
|
|
|
|
+ setScreen('failure')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ lock.current = false
|
|
|
|
|
+ setBusy('')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const back = async () => {
|
|
|
|
|
+ if (screen === 'guide') {
|
|
|
|
|
+ setScreen(guideReturn)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (screen === 'gallery' || screen === 'review' || screen === 'preview') {
|
|
|
|
|
+ setScreen('capture')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (screen === 'failure') {
|
|
|
|
|
+ setScreen(kind === 'answers' ? 'preview' : 'review')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (screen === 'success') {
|
|
|
|
|
+ home()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (photos.length) {
|
|
|
|
|
+ const answer = await Taro.showModal({
|
|
|
|
|
+ title: '离开本次采集?',
|
|
|
|
|
+ content: '未提交的照片和编辑内容将丢失。',
|
|
|
|
|
+ confirmText: '离开',
|
|
|
|
|
+ cancelText: '继续编辑'
|
|
|
|
|
+ })
|
|
|
|
|
+ if (!answer.confirm) return
|
|
|
|
|
+ }
|
|
|
|
|
+ void Taro.navigateBack()
|
|
|
|
|
+ }
|
|
|
|
|
+ const reset = () => {
|
|
|
|
|
+ setPhotos([])
|
|
|
|
|
+ setSelectedStudents([])
|
|
|
|
|
+ setQuestions([])
|
|
|
|
|
+ setSavedId('')
|
|
|
|
|
+ setIndex(0)
|
|
|
|
|
+ setPhotoIndex(0)
|
|
|
|
|
+ setRetakeId(null)
|
|
|
|
|
+ setScreen('capture')
|
|
|
|
|
+ }
|
|
|
|
|
+ const changeQuestion = (q: Question) => setQuestions((items) => items.map((item) => (item.id === q.id ? q : item)))
|
|
|
|
|
+ const goQuestion = (next: number) => setIndex(Math.max(0, Math.min(next, questions.length - 1)))
|
|
|
|
|
+ const goPage = (direction: number) => {
|
|
|
|
|
+ const next = questions.findIndex((q) => q.page === current.page + direction)
|
|
|
|
|
+ if (next >= 0) setIndex(next)
|
|
|
|
|
+ }
|
|
|
|
|
+ const photo = photos[photoIndex]
|
|
|
|
|
+ const previewPhoto = (item: Photo) => {
|
|
|
|
|
+ if (item.document) return
|
|
|
|
|
+ void Taro.previewImage({
|
|
|
|
|
+ current: photoSource(item, kind),
|
|
|
|
|
+ urls: photos.filter((p) => !p.document).map((p) => photoSource(p, kind))
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ const openGuide = () => {
|
|
|
|
|
+ setGuideReturn(screen)
|
|
|
|
|
+ setScreen('guide')
|
|
|
|
|
+ }
|
|
|
|
|
+ const pageTitle =
|
|
|
|
|
+ screen === 'capture'
|
|
|
|
|
+ ? titles[kind]
|
|
|
|
|
+ : screen === 'guide'
|
|
|
|
|
+ ? kind === 'answers'
|
|
|
|
|
+ ? '答题卡拍照示范'
|
|
|
|
|
+ : '拍摄示例'
|
|
|
|
|
+ : screen === 'gallery'
|
|
|
|
|
+ ? '已添加照片'
|
|
|
|
|
+ : screen === 'preview'
|
|
|
|
|
+ ? '确认答题卡'
|
|
|
|
|
+ : screen === 'review'
|
|
|
|
|
+ ? kind === 'mistakes'
|
|
|
|
|
+ ? '勾选错题'
|
|
|
|
|
+ : '题目识别'
|
|
|
|
|
+ : screen === 'failure'
|
|
|
|
|
+ ? '上传失败'
|
|
|
|
|
+ : '上传成功'
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Page
|
|
|
|
|
+ title={pageTitle}
|
|
|
|
|
+ className={screen === 'capture' ? kind === 'papers' ? 'paper-capture-page' : kind === 'answers' ? 'answer-capture-page' : 'mistake-capture-page' : screen === 'guide' ? kind === 'answers' ? 'answer-guide-page' : 'photo-guide-page' : screen === 'gallery' ? 'photo-gallery-page' : ''}
|
|
|
|
|
+ onBack={() => void back()}
|
|
|
|
|
+ action={
|
|
|
|
|
+ screen === 'gallery' ? (
|
|
|
|
|
+ <Button className="text-button" onClick={() => void pick(true)}>继续拍照</Button>
|
|
|
|
|
+ ) : (screen === 'capture' && kind !== 'answers') || screen === 'preview' ? (
|
|
|
|
|
+ <Button className="text-button" onClick={openGuide}>
|
|
|
|
|
+ 查看示例
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ ) : undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ >
|
|
|
|
|
+ {!['capture', 'guide', 'gallery'].includes(screen) && (
|
|
|
|
|
+ <View className="demo-banner">当前为本地体验,识别、上传、审核结果均为模拟数据。</View>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <View className="workflow-body">
|
|
|
|
|
+ {screen === 'capture' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ {kind === 'mistakes' && (
|
|
|
|
|
+ <View className="student-link-bar">
|
|
|
|
|
+ <View className="student-link-copy">
|
|
|
|
|
+ <Text className="panel-title">关联学生</Text>
|
|
|
|
|
+ <Text className="panel-description">
|
|
|
|
|
+ {selectedStudents.length
|
|
|
|
|
+ ? selectedStudents.map((s) => `${s.className} · ${s.name}`).join('、')
|
|
|
|
|
+ : '识别前请选择学生,支持跨班级多选'}
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <Button className="text-button" onClick={() => setStudentPicker(true)}>
|
|
|
|
|
+ 选择学生
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {kind === 'papers' && (
|
|
|
|
|
+ <View className="paper-metadata">
|
|
|
|
|
+ <Field label="试卷名称" placeholder="请输入试卷名称" value={name} onChange={setName} />
|
|
|
|
|
+ <View className="paper-metadata-row">
|
|
|
|
|
+ <PaperSelect label="年份" value={year} choices={['2026', '2025', '2024', '2023', '2022']} onChange={setYear} />
|
|
|
|
|
+ <PaperSelect label="试卷类型" value={paperType} choices={paperTypes} onChange={setPaperType} />
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <PaperSelect label="省份" value={province} choices={provinces} onChange={setProvince} />
|
|
|
|
|
+ </View>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {kind === 'answers' && <AnswerGuide />}
|
|
|
|
|
+ {kind !== 'answers' && (
|
|
|
|
|
+ <View className="capture-zone" onClick={() => void pick(true)}>
|
|
|
|
|
+ <Text className="capture-symbol">📸</Text>
|
|
|
|
|
+ <Text className="panel-title">拍照</Text>
|
|
|
|
|
+ <Text className="panel-description">支持连续拍摄,多页统一识别</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {kind !== 'answers' ? (
|
|
|
|
|
+ <>
|
|
|
|
|
+ {!!photos.length && (
|
|
|
|
|
+ <View className="capture-batch">
|
|
|
|
|
+ <Button className="text-button capture-count" onClick={() => setScreen('gallery')}>
|
|
|
|
|
+ 已添加 {photos.length} 张 · 查看 ›
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button className="primary-button" onClick={recognize}>识别全部照片</Button>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <View className="capture-imports">
|
|
|
|
|
+ <Button className="capture-import-card" onClick={() => void pick(false)}>
|
|
|
|
|
+ <Text className="capture-import-icon">🖼️</Text>
|
|
|
|
|
+ <Text>从相册选择</Text>
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button className="capture-import-card" onClick={() => void importDocument()}>
|
|
|
|
|
+ <Text className="capture-import-icon">📁</Text>
|
|
|
|
|
+ <Text>Word / PDF 文件</Text>
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <View className="answer-buttons">
|
|
|
|
|
+ <Button className="secondary-button answer-album-button" onClick={() => void pick(false)}>
|
|
|
|
|
+ 🖼 从相册多选
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button className="primary-button answer-camera-button" onClick={() => void pick(true)}>
|
|
|
|
|
+ 📸 拍摄答题卡
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ {!!photos.length && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ className="secondary-button"
|
|
|
|
|
+ onClick={() => setScreen('preview')}
|
|
|
|
|
+ >
|
|
|
|
|
+ 查看已添加的 {photos.length} 张照片
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Actions>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ className="primary-button"
|
|
|
|
|
+ onClick={() => setScreen('preview')}
|
|
|
|
|
+ >
|
|
|
|
|
+ 检查答题卡
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Actions>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {kind !== 'answers' && questions.length > 0 && (
|
|
|
|
|
+ <Button className="text-button" onClick={recognize}>
|
|
|
|
|
+ 返回核对题目
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {screen === 'guide' && kind !== 'answers' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <View className="photo-guide-intro">
|
|
|
|
|
+ <Text className="photo-guide-title">试卷拍摄规范</Text>
|
|
|
|
|
+ <Text className="photo-guide-description">将试卷平放,镜头正对纸面{'\n'}一次拍摄一页,题目与选项完整入镜</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <View className="photo-guide-stage">
|
|
|
|
|
+ <View className="photo-guide-stage-heading">
|
|
|
|
|
+ <Text className="photo-guide-label">试卷拍摄参考</Text>
|
|
|
|
|
+ <Text>正对纸面 · 完整拍摄</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <View className="photo-guide-frame">
|
|
|
|
|
+ <Image className="photo-guide-reference" src={captureReference} mode="aspectFit" />
|
|
|
|
|
+ {['tl', 'tr', 'bl', 'br'].map((corner) => <View key={corner} className={`photo-guide-corner ${corner}`} />)}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <Text className="photo-guide-caption">{'✓ 四角完整\u3000✓ 题目清晰\u3000✓ 内容无遮挡'}</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <View className="photo-guide-rules">
|
|
|
|
|
+ <View className="photo-guide-rule"><Text className="photo-guide-rule-title">▣ 拍全纸张</Text><Text>保留四角与页码{'\n'}题干、选项不缺失</Text></View>
|
|
|
|
|
+ <View className="photo-guide-rule"><Text className="photo-guide-rule-title">☀ 光线均匀</Text><Text>避免反光和阴影{'\n'}手指不遮挡内容</Text></View>
|
|
|
|
|
+ <View className="photo-guide-rule"><Text className="photo-guide-rule-title">▤ 字迹清晰</Text><Text>镜头正对纸面{'\n'}对焦后保持稳定</Text></View>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <View className="photo-guide-multipage">
|
|
|
|
|
+ <Text className="photo-guide-rule-title">多页试卷怎么拍?</Text>
|
|
|
|
|
+ <Text>按页码顺序逐页拍摄,全部拍完后统一识别。{'\n'}跨页题请拍全相关页面,避免遗漏题干或选项。</Text>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {screen === 'guide' && kind === 'answers' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <AnswerGuide />
|
|
|
|
|
+ <Actions>
|
|
|
|
|
+ <Button className="secondary-button" onClick={() => setScreen(guideReturn)}>
|
|
|
|
|
+ 返回采集
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button className="primary-button" onClick={() => void pick(true)}>
|
|
|
|
|
+ 拍摄
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Actions>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {screen === 'gallery' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <View className="gallery-list">
|
|
|
|
|
+ {photos.map((p, i) => (
|
|
|
|
|
+ <View className={`gallery-row${p.document ? ' is-document' : ''}`} key={p.id}>
|
|
|
|
|
+ {p.document ? (
|
|
|
|
|
+ <View className="gallery-document-icon"><Text>≡</Text></View>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Image className="gallery-thumbnail" src={photoSource(p, kind)} mode="aspectFit" onClick={() => previewPhoto(p)} />
|
|
|
|
|
+ )}
|
|
|
|
|
+ <View className="gallery-file-copy">
|
|
|
|
|
+ <Text className="gallery-file-name">{i + 1}. {p.name}</Text>
|
|
|
|
|
+ {!p.document && <Button className="text-button gallery-view" onClick={() => previewPhoto(p)}>查看照片</Button>}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <Button className="text-button gallery-delete" onClick={() => removePhoto(p.id)}>
|
|
|
|
|
+ 删除
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <View className="gallery-footer">
|
|
|
|
|
+ <Button className="primary-button" onClick={() => setScreen('capture')}>完成</Button>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {screen === 'preview' &&
|
|
|
|
|
+ (photo ? (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <Swipe
|
|
|
|
|
+ onSwipe={(direction) => setPhotoIndex((i) => Math.max(0, Math.min(i + direction, photos.length - 1)))}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Image
|
|
|
|
|
+ className="original-image"
|
|
|
|
|
+ src={photoSource(photo, kind)}
|
|
|
|
|
+ mode="widthFix"
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ void Taro.previewImage({
|
|
|
|
|
+ current: photoSource(photo, kind),
|
|
|
|
|
+ urls: photos.map((p) => photoSource(p, kind))
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ />
|
|
|
|
|
+ </Swipe>
|
|
|
|
|
+ <Chips
|
|
|
|
|
+ values={photos.map((_, i) => `第 ${i + 1} 张`)}
|
|
|
|
|
+ selected={`第 ${photoIndex + 1} 张`}
|
|
|
|
|
+ onChange={(value) => setPhotoIndex(Number(value.match(/\d+/)?.[0]) - 1)}
|
|
|
|
|
+ />
|
|
|
|
|
+ {photo.uploaded && <Text className="answer-content">本张已成功,无需重复提交</Text>}
|
|
|
|
|
+ {photo.failure && <Text className="failure-copy">{photo.failure}</Text>}
|
|
|
|
|
+ <Actions>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ className="secondary-button"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setRetakeId(photo.id)
|
|
|
|
|
+ void pick(true, photo.id)
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ 重新拍摄
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button className="secondary-button" onClick={() => removePhoto(photo.id)}>
|
|
|
|
|
+ 删除本张
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Actions>
|
|
|
|
|
+ {retakeId && (
|
|
|
|
|
+ <Button className="text-button" onClick={() => addExample()}>
|
|
|
|
|
+ 用示例重新拍摄
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <Text className="panel-title">上传前请确认</Text>
|
|
|
|
|
+ <Text className="panel-description">
|
|
|
|
|
+ ✓ 纸张四角、作答区域完整入镜{'\n'}✓ 姓名、班级、二维码清晰可辨{'\n'}✓ 字迹清楚,无反光、阴影或遮挡
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ <Actions>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ className="secondary-button"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setRetakeId(null)
|
|
|
|
|
+ void pick(true, null)
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ 继续拍摄
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button className="primary-button" onClick={() => void submit()}>
|
|
|
|
|
+ 确认上传({photos.length} 张)
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Actions>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ className="text-button"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setRetakeId(null)
|
|
|
|
|
+ addExample(null)
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ 再添加一张示例
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button className="text-button" onClick={() => void submit(true)}>
|
|
|
|
|
+ 体验上传失败与重试
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Empty>暂无照片,请返回采集</Empty>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ {screen === 'review' && current && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <View className="panel-card">
|
|
|
|
|
+ <Text className="panel-title">{kind === 'papers' ? name : '试卷识别结果'}</Text>
|
|
|
|
|
+ <Text className="panel-description">
|
|
|
|
|
+ {photos.length} 页 · 已识别 {questions.length} 道题
|
|
|
|
|
+ {kind === 'papers' ? ` · ${year}年 · ${paperType} · ${province}` : ''}
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ {kind === 'mistakes' && (
|
|
|
|
|
+ <Button className="text-button" onClick={() => setStudentPicker(true)}>
|
|
|
|
|
+ 关联:{selectedStudents.map((s) => `${s.className} ${s.name}`).join('、')}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <Swipe onSwipe={goPage}>
|
|
|
|
|
+ <View className="review-original">
|
|
|
|
|
+ <Image
|
|
|
|
|
+ className="original-image"
|
|
|
|
|
+ src={photoSource(photos[current.page - 1], kind)}
|
|
|
|
|
+ mode="aspectFit"
|
|
|
|
|
+ />
|
|
|
|
|
+ <View className="split-boxes">
|
|
|
|
|
+ {questions
|
|
|
|
|
+ .filter((q) => q.page === current.page)
|
|
|
|
|
+ .map((q) => (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ className={`split-box ${q.id === current.id ? 'selected' : ''}`}
|
|
|
|
|
+ key={q.id}
|
|
|
|
|
+ onClick={() => setIndex(questions.findIndex((item) => item.id === q.id))}
|
|
|
|
|
+ >
|
|
|
|
|
+ 第 {q.num} 题 · {q.type}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </Swipe>
|
|
|
|
|
+ <Chips
|
|
|
|
|
+ values={photos.map((_, i) => `第 ${i + 1} 页`)}
|
|
|
|
|
+ selected={`第 ${current.page} 页`}
|
|
|
|
|
+ onChange={(value) => {
|
|
|
|
|
+ const page = Number(value.match(/\d+/)?.[0])
|
|
|
|
|
+ setIndex(questions.findIndex((q) => q.page === page))
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <View className="number-row">
|
|
|
|
|
+ {questions.map((q, i) => (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ aria-label={`第 ${q.num} 题`}
|
|
|
|
|
+ className={`number-chip ${i === index ? 'selected' : ''}`}
|
|
|
|
|
+ key={q.id}
|
|
|
|
|
+ onClick={() => goQuestion(i)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {q.num}
|
|
|
|
|
+ {q.isWrong ? '•' : ''}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <Swipe onSwipe={(direction) => goQuestion(index + direction)}>
|
|
|
|
|
+ <QuestionCard question={current} onChange={changeQuestion} wrong={kind === 'mistakes'} />
|
|
|
|
|
+ </Swipe>
|
|
|
|
|
+ <View className="panel-card">
|
|
|
|
|
+ <Select
|
|
|
|
|
+ label="年级"
|
|
|
|
|
+ choices={['初一', '初二', '初三']}
|
|
|
|
|
+ value={current.grade}
|
|
|
|
|
+ onChange={(grade) => changeQuestion({ ...current, grade })}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Select
|
|
|
|
|
+ label="难度"
|
|
|
|
|
+ choices={difficulties}
|
|
|
|
|
+ value={current.diff}
|
|
|
|
|
+ onChange={(diff) => changeQuestion({ ...current, diff })}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Text className="field-label">知识点(点击移除)</Text>
|
|
|
|
|
+ <View className="chip-row">
|
|
|
|
|
+ {current.tags.map((tag) => (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ key={tag}
|
|
|
|
|
+ className="chip selected"
|
|
|
|
|
+ onClick={() => changeQuestion({ ...current, tags: current.tags.filter((v) => v !== tag) })}
|
|
|
|
|
+ >
|
|
|
|
|
+ {tag} ×
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <Select
|
|
|
|
|
+ label="添加知识点"
|
|
|
|
|
+ choices={topics}
|
|
|
|
|
+ value=""
|
|
|
|
|
+ onChange={(tag) => {
|
|
|
|
|
+ if (!current.tags.includes(tag)) changeQuestion({ ...current, tags: [...current.tags, tag] })
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <Actions>
|
|
|
|
|
+ <Button className="secondary-button" disabled={index === 0} onClick={() => goQuestion(index - 1)}>
|
|
|
|
|
+ 上一题
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ className="secondary-button"
|
|
|
|
|
+ disabled={index === questions.length - 1}
|
|
|
|
|
+ onClick={() => goQuestion(index + 1)}
|
|
|
|
|
+ >
|
|
|
|
|
+ 下一题
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Actions>
|
|
|
|
|
+ <Actions>
|
|
|
|
|
+ <Button className="secondary-button" onClick={() => setScreen('capture')}>
|
|
|
|
|
+ 继续拍照
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button className="primary-button" onClick={() => void submit()}>
|
|
|
|
|
+ {kind === 'mistakes' ? `上传错题(${questions.filter((q) => q.isWrong).length} 道)` : '上传试卷'}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Actions>
|
|
|
|
|
+ <Button className="text-button" onClick={() => void submit(true)}>
|
|
|
|
|
+ 体验上传失败与重试
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {screen === 'failure' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <View className="result-mark">⚠</View>
|
|
|
|
|
+ <View className="result-heading">
|
|
|
|
|
+ {kind === 'answers' && photos.some((p) => p.failure)
|
|
|
|
|
+ ? `${photos.filter((p) => p.failure).length} 张图片识别失败`
|
|
|
|
|
+ : '上传失败'}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <Text className="result-caption">{failure}</Text>
|
|
|
|
|
+ {photos
|
|
|
|
|
+ .filter((p) => p.failure)
|
|
|
|
|
+ .map((p, i) => (
|
|
|
|
|
+ <View className="panel-card" key={p.id}>
|
|
|
|
|
+ <Text className="panel-title">第 {photos.indexOf(p) + 1} 张</Text>
|
|
|
|
|
+ <Image className="photo-thumb" src={photoSource(p, kind)} mode="aspectFit" />
|
|
|
|
|
+ <Text className="failure-copy">{p.failure}</Text>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ className="secondary-button"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setPhotoIndex(photos.indexOf(p))
|
|
|
|
|
+ setRetakeId(p.id)
|
|
|
|
|
+ setScreen('preview')
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ 重新拍摄{i ? '' : '失败照片'}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ <Actions>
|
|
|
|
|
+ <Button className="secondary-button" onClick={home}>
|
|
|
|
|
+ 回到首页
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button className="primary-button" onClick={() => setScreen(kind === 'answers' ? 'preview' : 'review')}>
|
|
|
|
|
+ 返回重试
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Actions>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {screen === 'success' && record && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <View className="result-mark">✅</View>
|
|
|
|
|
+ <View className="result-heading">
|
|
|
|
|
+ {kind === 'papers'
|
|
|
|
|
+ ? record.reviewStatus === 'approved'
|
|
|
|
|
+ ? '试卷审核通过'
|
|
|
|
|
+ : '上传成功,待审核'
|
|
|
|
|
+ : kind === 'answers'
|
|
|
|
|
+ ? '上传成功'
|
|
|
|
|
+ : '题目已存入题库'}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <Text className="result-caption">
|
|
|
|
|
+ {kind === 'answers'
|
|
|
|
|
+ ? '解析结果请到客户端的【批量解析】中查看'
|
|
|
|
|
+ : kind === 'papers'
|
|
|
|
|
+ ? `${record.name} · 共 ${record.photos.length} 页 / ${record.questions.length} 道题。审核通过后将发送通知。`
|
|
|
|
|
+ : `已上传 ${record.questions.length} 道错题`}
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ {kind === 'mistakes' && (
|
|
|
|
|
+ <View className="panel-card">
|
|
|
|
|
+ <Text className="panel-title">关联学生</Text>
|
|
|
|
|
+ <Text className="panel-description">
|
|
|
|
|
+ {record.students.map((s) => `${s.className} · ${s.name}`).join('、')}
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ {record.questions.map((q) => (
|
|
|
|
|
+ <Text className="question-content" key={q.id}>
|
|
|
|
|
+ 第 {q.num} 题 · {q.content}
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <Text className="panel-description">提交时间:{formatTime(record.submittedAt)}</Text>
|
|
|
|
|
+ <Actions>
|
|
|
|
|
+ <Button className="secondary-button" onClick={reset}>
|
|
|
|
|
+ 继续上传
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button className="primary-button" onClick={home}>
|
|
|
|
|
+ 回到首页
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Actions>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ className="text-button"
|
|
|
|
|
+ onClick={() => void Taro.navigateTo({ url: `/pages/messages/index?record=${record.id}` })}
|
|
|
|
|
+ >
|
|
|
|
|
+ 查看上传记录
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ {studentPicker && (
|
|
|
|
|
+ <StudentPicker
|
|
|
|
|
+ selected={selectedStudents}
|
|
|
|
|
+ onClose={() => setStudentPicker(false)}
|
|
|
|
|
+ onConfirm={(selected) => {
|
|
|
|
|
+ setSelectedStudents(selected)
|
|
|
|
|
+ setStudentPicker(false)
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ {!!busy && (
|
|
|
|
|
+ <View className="busy-layer">
|
|
|
|
|
+ <View className="busy-box">
|
|
|
|
|
+ <Text className="panel-title">{busy}</Text>
|
|
|
|
|
+ <Text className="muted-copy">请稍候,保留当前页面</Text>
|
|
|
|
|
+ <View className="progress-track">
|
|
|
|
|
+ <View className="progress-fill" style={{ width: '75%' }} />
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </View>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </Page>
|
|
|
|
|
+ )
|
|
|
|
|
+}
|