移动端拍照上传、奥数区、学段约束解题与 AI 模型配置。
- 手机/平板响应式布局,支持拍照与相册上传 - 学生详情新增奥数区,按初/高中学段生成解法并禁止超纲 - 系统设置可配置 Ollama 或 OpenAI 兼容 API - 更新 frontend/dist 与使用说明
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '用户管理',
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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%"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user