移动端拍照上传、奥数区、学段约束解题与 AI 模型配置。

- 手机/平板响应式布局,支持拍照与相册上传

- 学生详情新增奥数区,按初/高中学段生成解法并禁止超纲

- 系统设置可配置 Ollama 或 OpenAI 兼容 API

- 更新 frontend/dist 与使用说明
This commit is contained in:
dekun
2026-06-28 13:39:54 +08:00
parent 4375ea491e
commit 43483bf56f
26 changed files with 1193 additions and 592 deletions
+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{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
-1
View File
@@ -1 +0,0 @@
*{box-sizing:border-box}body{-webkit-font-smoothing:antialiased;background:#f5f5f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}#root{min-height:100vh}a{color:inherit}@media (width<=576px){.ant-table{font-size:12px}}
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -3,12 +3,14 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
<script type="module" crossorigin src="/assets/index-Ds_4iUm1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DHafbxey.css">
<script type="module" crossorigin src="/assets/index-BbvdgaGu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-8NG7km60.css">
</head>
<body>
<div id="root"></div>
+3 -1
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
+14 -4
View File
@@ -1,6 +1,7 @@
import axios from 'axios'
import type {
AdminUser,
AIProvider,
Exam,
PublicSettings,
ScoreInput,
@@ -12,6 +13,7 @@ import type {
TrendResponse,
User,
WrongQuestion,
WrongQuestionCategory,
} from '../types'
import type { ExamType } from '../types'
@@ -68,8 +70,15 @@ export const settingsApi = {
export const adminApi = {
getSettings: () => api.get<SystemSettings>('/admin/settings'),
updateSettings: (data: { registration_enabled?: boolean }) =>
api.patch<SystemSettings>('/admin/settings', data),
updateSettings: (data: {
registration_enabled?: boolean
ai_provider?: AIProvider
ollama_base_url?: string | null
ollama_model?: string | null
openai_base_url?: string | null
openai_model?: string | null
openai_api_key?: string
}) => api.patch<SystemSettings>('/admin/settings', data),
updateProfile: (data: {
username?: string
current_password?: string
@@ -120,11 +129,12 @@ export const examApi = {
}
export const wrongQuestionApi = {
list: (studentId: string, params?: { subject_id?: number; q?: string }) =>
list: (studentId: string, params?: { subject_id?: number; q?: string; category?: WrongQuestionCategory }) =>
api.get<WrongQuestion[]>(`/students/${studentId}/wrong-questions`, { params }),
upload: (studentId: string, subjectId: number, file: File) => {
upload: (studentId: string, subjectId: number, file: File, category: WrongQuestionCategory = 'regular') => {
const form = new FormData()
form.append('subject_id', String(subjectId))
form.append('category', category)
form.append('file', file)
return api.post<WrongQuestion>(`/students/${studentId}/wrong-questions`, form)
},
@@ -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}
/>
)}
</>
)
}
+61 -17
View File
@@ -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}>
+82 -1
View File
@@ -8,18 +8,99 @@ body {
sans-serif;
background: #f5f5f5;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
#root {
min-height: 100vh;
min-height: 100dvh;
}
a {
color: inherit;
}
@media (max-width: 576px) {
.page-container {
max-width: 1100px;
margin: 0 auto;
padding: 16px 16px 32px;
padding-bottom: max(32px, env(safe-area-inset-bottom));
}
.page-header {
margin-bottom: 16px;
width: 100%;
}
.student-tabs .ant-tabs-nav {
margin-bottom: 12px;
}
.wq-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
gap: 16px;
}
.wq-card {
border: 1px solid #f0f0f0;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: #fff;
transition: box-shadow 0.2s;
}
.wq-card:active {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.wq-card-img {
width: 100%;
height: 140px;
object-fit: cover;
background: #fafafa;
}
.wq-card-body {
padding: 12px;
}
.upload-actions .ant-btn {
min-height: 44px;
}
@media (max-width: 768px) {
.page-container {
padding: 12px 12px 24px;
}
.ant-tabs-tab {
padding: 8px 10px !important;
font-size: 14px;
}
.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 (max-width: 576px) {
.wq-card-img {
height: 120px;
}
}
+90 -2
View File
@@ -6,6 +6,7 @@ import {
Input,
Modal,
Popconfirm,
Radio,
Space,
Switch,
Table,
@@ -17,7 +18,7 @@ import { useEffect, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { adminApi } from '../api/client'
import { useAuth } from '../context/AuthContext'
import type { AdminUser, SystemSettings } from '../types'
import type { AdminUser, AIProvider, SystemSettings } from '../types'
export default function SettingsPage() {
const { user } = useAuth()
@@ -29,6 +30,8 @@ export default function SettingsPage() {
const [createOpen, setCreateOpen] = useState(false)
const [resetUser, setResetUser] = useState<AdminUser | null>(null)
const [resetForm] = Form.useForm()
const [aiForm] = Form.useForm()
const aiProvider = Form.useWatch('ai_provider', aiForm) as AIProvider | undefined
if (!user?.is_superuser) return <Navigate to="/" replace />
@@ -42,6 +45,14 @@ export default function SettingsPage() {
setSettings(settingsRes.data)
setUsers(usersRes.data)
profileForm.setFieldsValue({ username: user.username })
aiForm.setFieldsValue({
ai_provider: settingsRes.data.ai_provider,
ollama_base_url: settingsRes.data.ollama_base_url || '',
ollama_model: settingsRes.data.ollama_model || '',
openai_base_url: settingsRes.data.openai_base_url || '',
openai_model: settingsRes.data.openai_model || '',
openai_api_key: '',
})
} finally {
setLoading(false)
}
@@ -97,8 +108,32 @@ export default function SettingsPage() {
load()
}
const saveAiSettings = async (values: {
ai_provider: AIProvider
ollama_base_url?: string
ollama_model?: string
openai_base_url?: string
openai_model?: string
openai_api_key?: string
}) => {
const payload: Parameters<typeof adminApi.updateSettings>[0] = {
ai_provider: values.ai_provider,
ollama_base_url: values.ollama_base_url || null,
ollama_model: values.ollama_model || null,
openai_base_url: values.openai_base_url || null,
openai_model: values.openai_model || null,
}
if (values.openai_api_key?.trim()) {
payload.openai_api_key = values.openai_api_key.trim()
}
const { data } = await adminApi.updateSettings(payload)
setSettings(data)
aiForm.setFieldValue('openai_api_key', '')
message.success('AI 模型配置已保存')
}
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '16px 16px 32px' }}>
<div className="page-container">
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title level={3} style={{ margin: 0 }}>
@@ -156,6 +191,59 @@ export default function SettingsPage() {
</Space>
),
},
{
key: 'ai',
label: 'AI 模型',
children: (
<Card title="解题 AI 配置" loading={loading}>
<Form form={aiForm} layout="vertical" onFinish={saveAiSettings}>
<Form.Item name="ai_provider" label="AI 提供商" rules={[{ required: true }]}>
<Radio.Group>
<Radio.Button value="ollama"> Ollama</Radio.Button>
<Radio.Button value="openai">OpenAI API</Radio.Button>
</Radio.Group>
</Form.Item>
{(aiProvider || settings?.ai_provider) === 'ollama' && (
<>
<Form.Item name="ollama_base_url" label="Ollama 地址">
<Input placeholder="http://127.0.0.1:11434" />
</Form.Item>
<Form.Item name="ollama_model" label="Ollama 模型">
<Input placeholder="qwen2.5:7b" />
</Form.Item>
</>
)}
{(aiProvider || settings?.ai_provider) === 'openai' && (
<>
<Form.Item name="openai_base_url" label="API Base URL">
<Input placeholder="https://api.openai.com/v1" />
</Form.Item>
<Form.Item name="openai_model" label="模型名称">
<Input placeholder="gpt-4o-mini" />
</Form.Item>
<Form.Item
name="openai_api_key"
label="API Key"
extra={
settings?.openai_api_key_set
? '已配置 Key,留空则不修改'
: '请输入 API Key'
}
>
<Input.Password placeholder="sk-..." />
</Form.Item>
</>
)}
<Typography.Paragraph type="secondary">
//
</Typography.Paragraph>
<Button type="primary" htmlType="submit">
AI
</Button>
</Form>
</Card>
),
},
{
key: 'users',
label: '用户管理',
+69 -60
View File
@@ -6,11 +6,10 @@ import { examApi, studentApi, subjectApi, wrongQuestionApi } from '../api/client
import ScoreForm from '../components/ScoreForm'
import ScoreOverview from '../components/ScoreOverview'
import TrendChart from '../components/TrendChart'
import WrongQuestionList from '../components/WrongQuestionList'
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
import { STATUS_LABELS } from '../types'
import WrongQuestionDetail from './WrongQuestionDetail'
export default function StudentDetailPage() {
const { id } = useParams<{ id: string }>()
@@ -20,9 +19,13 @@ export default function StudentDetailPage() {
const [trend, setTrend] = useState<TrendResponse | null>(null)
const [selectedSubject, setSelectedSubject] = useState<number>()
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 subjectNames = Object.fromEntries(subjects.map((s) => [s.id, s.name]))
@@ -44,10 +47,21 @@ export default function StudentDetailPage() {
const { data } = await wrongQuestionApi.list(id, {
subject_id: wqSubjectFilter,
q: wqSearch || undefined,
category: 'regular',
})
setWrongQuestions(data)
}, [id, wqSubjectFilter, wqSearch])
const loadOlympiadQuestions = useCallback(async () => {
if (!id) return
const { data } = await wrongQuestionApi.list(id, {
subject_id: olympiadSubjectFilter,
q: olympiadSearch || undefined,
category: 'olympiad',
})
setOlympiadQuestions(data)
}, [id, olympiadSubjectFilter, olympiadSearch])
useEffect(() => {
if (!id) return
const init = async () => {
@@ -62,12 +76,13 @@ export default function StudentDetailPage() {
if (subjectRes.data.length) setSelectedSubject(subjectRes.data[0].id)
await loadExams()
await loadWrongQuestions()
await loadOlympiadQuestions()
} finally {
setLoading(false)
}
}
init()
}, [id, loadExams, loadWrongQuestions])
}, [id, loadExams, loadWrongQuestions, loadOlympiadQuestions])
useEffect(() => {
loadTrend()
@@ -98,18 +113,18 @@ export default function StudentDetailPage() {
if (!student) return <Typography.Text></Typography.Text>
const stageLabel = SCHOOL_LEVEL_LABELS[student.school_level]
return (
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '16px 16px 32px' }}>
<Space style={{ marginBottom: 16 }} wrap>
<div className="page-container">
<Space className="page-header" wrap>
<Link to="/">
<Button icon={<ArrowLeftOutlined />}></Button>
</Link>
<Typography.Title level={4} style={{ margin: 0 }}>
{student.name}
</Typography.Title>
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>
{SCHOOL_LEVEL_LABELS[student.school_level]}
</Tag>
<Tag color={student.school_level === 'senior_high' ? 'purple' : 'blue'}>{stageLabel}</Tag>
<Typography.Text type="secondary">{formatStudentMeta(student)}</Typography.Text>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
CSV
@@ -117,17 +132,13 @@ export default function StudentDetailPage() {
</Space>
<Tabs
className="student-tabs"
items={[
{
key: 'scores',
label: '成绩录入',
children: (
<ScoreForm
studentId={id!}
subjects={subjects}
exams={exams}
onRefresh={loadExams}
/>
<ScoreForm studentId={id!} subjects={subjects} exams={exams} onRefresh={loadExams} />
),
},
{
@@ -141,7 +152,7 @@ export default function StudentDetailPage() {
children: (
<div>
<Select
style={{ width: 140, marginBottom: 16 }}
style={{ width: '100%', maxWidth: 160, marginBottom: 16 }}
value={selectedSubject}
onChange={setSelectedSubject}
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
@@ -161,9 +172,13 @@ export default function StudentDetailPage() {
label: '错题库',
children: (
<div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{stageLabel}
</Typography.Paragraph>
<WrongQuestionUpload
studentId={id!}
subjects={subjects}
category="regular"
onUploaded={loadWrongQuestions}
/>
<WrongQuestionFilters
@@ -174,51 +189,45 @@ export default function StudentDetailPage() {
onRefresh={loadWrongQuestions}
subjects={subjects}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
{wrongQuestions.map((wq) => (
<div
key={wq.id}
onClick={() => setSelectedWq(wq.id)}
style={{
border: '1px solid #f0f0f0',
borderRadius: 8,
overflow: 'hidden',
cursor: 'pointer',
}}
>
<img
src={wrongQuestionApi.imageUrl(wq.id)}
alt="错题"
style={{ width: '100%', height: 140, objectFit: 'cover', background: '#fafafa' }}
/>
<div style={{ padding: 12 }}>
<Space>
<Typography.Text strong>{wq.subject_name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{STATUS_LABELS[wq.status]}
</Typography.Text>
</Space>
<Typography.Paragraph
ellipsis={{ rows: 2 }}
style={{ margin: '8px 0 0', fontSize: 13 }}
>
{wq.question_text || wq.ocr_raw_text || '处理中…'}
</Typography.Paragraph>
</div>
</div>
))}
</div>
{wrongQuestions.length === 0 && (
<Typography.Text type="secondary"></Typography.Text>
)}
{selectedWq && (
<WrongQuestionDetail
questionId={selectedWq}
open={!!selectedWq}
onClose={() => setSelectedWq(null)}
onUpdated={loadWrongQuestions}
/>
)}
<WrongQuestionList
items={wrongQuestions}
selectedId={selectedWq}
onSelect={setSelectedWq}
onRefresh={loadWrongQuestions}
emptyText="暂无错题"
/>
</div>
),
},
{
key: 'olympiad',
label: '奥数区',
children: (
<div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{stageLabel}{stageLabel}
</Typography.Paragraph>
<WrongQuestionUpload
studentId={id!}
subjects={subjects}
category="olympiad"
onUploaded={loadOlympiadQuestions}
/>
<WrongQuestionFilters
subjectId={olympiadSubjectFilter}
onSubjectChange={setOlympiadSubjectFilter}
search={olympiadSearch}
onSearchChange={setOlympiadSearch}
onRefresh={loadOlympiadQuestions}
subjects={subjects}
/>
<WrongQuestionList
items={olympiadQuestions}
selectedId={selectedOlympiad}
onSelect={setSelectedOlympiad}
onRefresh={loadOlympiadQuestions}
emptyText="暂无奥数题"
/>
</div>
),
},
+1 -1
View File
@@ -44,7 +44,7 @@ export default function StudentsPage() {
}
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '16px 16px 32px' }}>
<div className="page-container">
<div
style={{
display: 'flex',
+6 -2
View File
@@ -60,7 +60,7 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
message.success('解法已重新生成')
onUpdated()
} catch {
message.error('生成失败,请确认 Ollama 已启动')
message.error('生成失败,请检查 AI 模型配置')
} finally {
setRegenerating(false)
}
@@ -75,7 +75,11 @@ export default function WrongQuestionDetail({ questionId, open, onClose, onUpdat
return (
<Modal
title={wq ? `${wq.subject_name} · 错题详情` : '错题详情'}
title={
wq
? `${wq.subject_name}${wq.category === 'olympiad' ? ' · 奥数' : ''} · 详情`
: '详情'
}
open={open}
onCancel={onClose}
width="90%"
+11
View File
@@ -17,6 +17,12 @@ export interface PublicSettings {
export interface SystemSettings {
registration_enabled: boolean
ai_provider: AIProvider
ollama_base_url: string | null
ollama_model: string | null
openai_base_url: string | null
openai_model: string | null
openai_api_key_set: boolean
updated_at: string
}
@@ -27,6 +33,10 @@ export interface AdminUser {
created_at: string
}
export type WrongQuestionCategory = 'regular' | 'olympiad'
export type AIProvider = 'ollama' | 'openai'
export type SchoolLevel = 'junior_high' | 'senior_high'
export interface Student {
@@ -96,6 +106,7 @@ export interface WrongQuestion {
student_id: string
subject_id: number
subject_name?: string
category: WrongQuestionCategory
image_path: string
ocr_raw_text: string | null
question_text: string | null