新增作文区与 AI 解读开关,修复 CSV 导出。

系统设置可关闭成绩复盘 AI;学生详情增加作文区(OCR/手动题目、方案与范文、历史与 MD 下载);导出改用 UTF-8 文件名响应。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 17:42:17 +08:00
parent aaa08cdf38
commit 1cb3c7fad5
20 changed files with 1441 additions and 555 deletions
+25 -1
View File
@@ -2,6 +2,9 @@ import axios from 'axios'
import type {
AdminUser,
AIProvider,
AppFeatures,
Composition,
CompositionInputMode,
Exam,
PublicSettings,
ScoreInput,
@@ -66,12 +69,14 @@ export const authApi = {
export const settingsApi = {
public: () => api.get<PublicSettings>('/settings/public'),
appFeatures: () => api.get<AppFeatures>('/settings/app-features'),
}
export const adminApi = {
getSettings: () => api.get<SystemSettings>('/admin/settings'),
updateSettings: (data: {
registration_enabled?: boolean
ai_review_enabled?: boolean
ai_provider?: AIProvider
ollama_base_url?: string | null
ollama_model?: string | null
@@ -130,13 +135,32 @@ export const examApi = {
params: { subject_id: subjectId },
}),
exportCsv: (studentId: string) =>
api.get(`/students/${studentId}/scores/export`, { responseType: 'blob' }),
api.get<Blob>(`/students/${studentId}/scores/export`, {
responseType: 'blob',
validateStatus: (status) => status >= 200 && status < 300,
}),
reviewInsight: (studentId: string, subjectName: string) =>
api.post<{ insight: string }>(`/students/${studentId}/review-insight`, {
subject_name: subjectName,
}),
}
export const compositionApi = {
list: (studentId: string) => api.get<Composition[]>(`/students/${studentId}/compositions`),
create: (studentId: string, data: { topic: string; input_mode?: CompositionInputMode }) =>
api.post<Composition>(`/students/${studentId}/compositions`, data),
get: (id: string) => api.get<Composition>(`/compositions/${id}`),
remove: (id: string) => api.delete(`/compositions/${id}`),
regenerate: (id: string) => api.post<Composition>(`/compositions/${id}/regenerate`),
ocr: (studentId: string, file: File) => {
const form = new FormData()
form.append('file', file)
return api.post<{ text: string }>(`/students/${studentId}/compositions/ocr`, form)
},
download: (id: string) =>
api.get(`/compositions/${id}/download`, { responseType: 'blob' }),
}
export const wrongQuestionApi = {
list: (studentId: string, params?: { subject_id?: number; q?: string; category?: WrongQuestionCategory }) =>
api.get<WrongQuestion[]>(`/students/${studentId}/wrong-questions`, { params }),
@@ -0,0 +1,113 @@
import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons'
import { Alert, Button, Modal, Popconfirm, Space, Spin, Tag, Typography } from 'antd'
import ReactMarkdown from 'react-markdown'
import type { Composition } from '../types'
import { COMPOSITION_STATUS_LABELS } from '../types'
interface Props {
item: Composition | null
open: boolean
onClose: () => void
onRegenerate: (id: string) => void
onDelete: (id: string) => void
onDownload: (item: Composition) => void
regenerating?: boolean
}
export default function CompositionDetailModal({
item,
open,
onClose,
onRegenerate,
onDelete,
onDownload,
regenerating,
}: Props) {
if (!item) return null
const processing = item.status === 'pending' || item.status === 'generating'
return (
<Modal
title="作文详情"
open={open}
onCancel={onClose}
width="92%"
style={{ maxWidth: 860 }}
footer={
<Space wrap>
<Popconfirm title="确定删除?" onConfirm={() => onDelete(item.id)}>
<Button danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
<Button
icon={<ReloadOutlined />}
loading={regenerating}
onClick={() => onRegenerate(item.id)}
>
</Button>
<Button
type="primary"
icon={<DownloadOutlined />}
disabled={item.status !== 'done'}
onClick={() => onDownload(item)}
>
Markdown
</Button>
</Space>
}
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Tag>{COMPOSITION_STATUS_LABELS[item.status]}</Tag>
<Tag>{item.input_mode === 'ocr' ? 'OCR 识别' : '手动输入'}</Tag>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{new Date(item.created_at).toLocaleString()}
</Typography.Text>
</div>
<div>
<Typography.Text strong></Typography.Text>
<pre
style={{
marginTop: 8,
background: '#fafafa',
padding: 12,
borderRadius: 8,
whiteSpace: 'pre-wrap',
}}
>
{item.topic}
</pre>
</div>
{item.error_message && <Alert type="error" message={item.error_message} showIcon />}
{processing && (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin tip="正在生成写作方案与范文…" />
</div>
)}
{item.status === 'done' && (
<>
{item.writing_plan && (
<div>
<Typography.Title level={5}></Typography.Title>
<div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}>
<ReactMarkdown>{item.writing_plan}</ReactMarkdown>
</div>
</div>
)}
{item.sample_essay && (
<div>
<Typography.Title level={5}></Typography.Title>
<div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}>
<ReactMarkdown>{item.sample_essay}</ReactMarkdown>
</div>
</div>
)}
</>
)}
</Space>
</Modal>
)
}
@@ -0,0 +1,211 @@
import { UploadOutlined } from '@ant-design/icons'
import { Button, Input, List, Space, Tag, Typography, Upload, message } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { compositionApi } from '../api/client'
import type { Composition, CompositionInputMode, Student } from '../types'
import { COMPOSITION_STATUS_LABELS } from '../types'
import { SCHOOL_LEVEL_LABELS } from '../constants/school'
import CompositionDetailModal from './CompositionDetailModal'
interface Props {
studentId: string
student: Student
}
export default function CompositionPanel({ studentId, student }: Props) {
const [topic, setTopic] = useState('')
const [inputMode, setInputMode] = useState<CompositionInputMode>('manual')
const [items, setItems] = useState<Composition[]>([])
const [loading, setLoading] = useState(false)
const [ocrLoading, setOcrLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [selected, setSelected] = useState<Composition | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [regenerating, setRegenerating] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const { data } = await compositionApi.list(studentId)
setItems(data)
setSelected((prev) => {
if (!prev) return prev
return data.find((item) => item.id === prev.id) ?? prev
})
} finally {
setLoading(false)
}
}, [studentId])
useEffect(() => {
load()
}, [load])
useEffect(() => {
const hasProcessing = items.some(
(item) => item.status === 'pending' || item.status === 'generating',
)
if (!hasProcessing) return
const timer = window.setInterval(load, 4000)
return () => window.clearInterval(timer)
}, [items, load])
const handleOcr = async (file: File) => {
setOcrLoading(true)
try {
const { data } = await compositionApi.ocr(studentId, file)
setTopic(data.text)
setInputMode('ocr')
message.success('题目识别完成')
} catch {
message.error('题目识别失败')
} finally {
setOcrLoading(false)
}
return false
}
const handleGenerate = async () => {
if (!topic.trim()) {
message.warning('请输入或识别作文题目')
return
}
setCreating(true)
try {
const { data } = await compositionApi.create(studentId, {
topic: topic.trim(),
input_mode: inputMode,
})
message.success('已开始生成,请稍后在历史记录中查看')
setTopic('')
setInputMode('manual')
await load()
setSelected(data)
setDetailOpen(true)
} catch {
message.error('创建失败')
} finally {
setCreating(false)
}
}
const openDetail = (item: Composition) => {
setSelected(item)
setDetailOpen(true)
}
const handleRegenerate = async (id: string) => {
setRegenerating(true)
try {
const { data } = await compositionApi.regenerate(id)
message.info('正在重新生成…')
setSelected(data)
await load()
} catch {
message.error('重新生成失败')
} finally {
setRegenerating(false)
}
}
const handleDelete = async (id: string) => {
await compositionApi.remove(id)
message.success('已删除')
setDetailOpen(false)
setSelected(null)
load()
}
const handleDownload = async (item: Composition) => {
try {
const { data } = await compositionApi.download(item.id)
const url = URL.createObjectURL(data)
const a = document.createElement('a')
a.href = url
a.download = `${item.topic.slice(0, 20).replace(/[\\/:*?"<>|]/g, '_') || 'composition'}.md`
a.click()
URL.revokeObjectURL(url)
} catch {
message.error('下载失败')
}
}
const stage = SCHOOL_LEVEL_LABELS[student.school_level]
const gradeText = [stage, student.grade, student.class_name].filter(Boolean).join(' · ')
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
{gradeText || stage} OCR
</Typography.Paragraph>
<div>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={4}
value={topic}
onChange={(e) => {
setTopic(e.target.value)
setInputMode('manual')
}}
placeholder="输入作文题目,或通过下方上传题目图片 OCR 识别"
style={{ marginTop: 8 }}
/>
<Space wrap style={{ marginTop: 12 }}>
<Upload beforeUpload={handleOcr} showUploadList={false} accept="image/*">
<Button icon={<UploadOutlined />} loading={ocrLoading}>
OCR
</Button>
</Upload>
<Button type="primary" loading={creating} onClick={handleGenerate}>
</Button>
</Space>
</div>
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
<List
loading={loading}
dataSource={items}
locale={{ emptyText: '暂无记录,请在上方输入题目并生成' }}
renderItem={(item) => (
<List.Item
actions={[
<Button type="link" onClick={() => openDetail(item)}>
</Button>,
]}
>
<List.Item.Meta
title={
<Space wrap>
<Typography.Text ellipsis style={{ maxWidth: 420 }}>
{item.topic}
</Typography.Text>
<Tag color={item.status === 'done' ? 'green' : item.status === 'failed' ? 'red' : 'blue'}>
{COMPOSITION_STATUS_LABELS[item.status]}
</Tag>
</Space>
}
description={new Date(item.created_at).toLocaleString()}
/>
</List.Item>
)}
/>
</div>
<CompositionDetailModal
item={selected}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
onDownload={handleDownload}
regenerating={regenerating}
/>
</Space>
)
}
+31 -19
View File
@@ -1,21 +1,33 @@
import { ReloadOutlined } from '@ant-design/icons'
import { Alert, Button, Spin, Typography } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { examApi } from '../api/client'
import { examApi, settingsApi } from '../api/client'
interface Props {
studentId: string
subjectName: string | null
}
function apiErrorMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object' && 'response' in err) {
const detail = (err as { response?: { data?: { detail?: unknown } } }).response?.data?.detail
if (typeof detail === 'string') return detail
}
return fallback
}
export default function ReviewAiInsight({ studentId, subjectName }: Props) {
const [enabled, setEnabled] = useState(true)
const [insight, setInsight] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
settingsApi.appFeatures().then(({ data }) => setEnabled(data.ai_review_enabled)).catch(() => {})
}, [])
const loadInsight = useCallback(async () => {
if (!subjectName) {
if (!subjectName || !enabled) {
setInsight(null)
setError(null)
return
@@ -25,17 +37,13 @@ export default function ReviewAiInsight({ studentId, subjectName }: Props) {
try {
const { data } = await examApi.reviewInsight(studentId, subjectName)
setInsight(data.insight)
} catch (err: unknown) {
const detail =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null
setError(typeof detail === 'string' ? detail : 'AI 解读生成失败,请检查模型配置')
} catch (err) {
setError(apiErrorMessage(err, 'AI 解读生成失败,请检查模型配置'))
setInsight(null)
} finally {
setLoading(false)
}
}, [studentId, subjectName])
}, [studentId, subjectName, enabled])
useEffect(() => {
loadInsight()
@@ -43,18 +51,24 @@ export default function ReviewAiInsight({ studentId, subjectName }: Props) {
if (!subjectName) return null
if (!enabled) {
return (
<Alert
style={{ marginTop: 20 }}
type="info"
showIcon
message="AI 复盘解读已在系统设置中关闭"
/>
)
}
return (
<div style={{ marginTop: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 15 }}>
AI · {subjectName}
</Typography.Text>
<Button
size="small"
icon={<ReloadOutlined />}
loading={loading}
onClick={loadInsight}
>
<Button size="small" loading={loading} onClick={loadInsight}>
</Button>
</div>
@@ -63,9 +77,7 @@ export default function ReviewAiInsight({ studentId, subjectName }: Props) {
<Spin tip="AI 正在分析复盘数据…" />
</div>
)}
{error && (
<Alert type="error" message={error} showIcon style={{ marginBottom: 12 }} />
)}
{error && <Alert type="error" message={error} showIcon style={{ marginBottom: 12 }} />}
{insight && (
<div
style={{
+19
View File
@@ -69,6 +69,12 @@ export default function SettingsPage() {
message.success(checked ? '已开放注册' : '已关闭注册')
}
const toggleAiReview = async (checked: boolean) => {
const { data } = await adminApi.updateSettings({ ai_review_enabled: checked })
setSettings(data)
message.success(checked ? '已开启 AI 复盘解读' : '已关闭 AI 复盘解读')
}
const saveProfile = async (values: {
username: string
current_password?: string
@@ -247,6 +253,19 @@ export default function SettingsPage() {
<Typography.Paragraph type="secondary">
//
</Typography.Paragraph>
<Form.Item label="AI 复盘解读">
<Space>
<Switch
checked={settings?.ai_review_enabled ?? true}
onChange={toggleAiReview}
/>
<Typography.Text>
{settings?.ai_review_enabled !== false
? '已开启:成绩复盘页可生成 AI 解读'
: '已关闭:成绩复盘页不调用 AI'}
</Typography.Text>
</Space>
</Form.Item>
<Button type="primary" htmlType="submit">
AI
</Button>
+29 -4
View File
@@ -9,10 +9,11 @@ import TrendChart from '../components/TrendChart'
import WrongQuestionList from '../components/WrongQuestionList'
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
import ExamReviewPanel from '../components/ExamReviewPanel'
import CompositionPanel from '../components/CompositionPanel'
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
const TAB_KEYS = ['scores', 'overview', 'trend', 'review', 'wrong', 'olympiad'] as const
const TAB_KEYS = ['scores', 'overview', 'trend', 'review', 'composition', 'wrong', 'olympiad'] as const
type TabKey = (typeof TAB_KEYS)[number]
export default function StudentDetailPage() {
@@ -114,11 +115,30 @@ export default function StudentDetailPage() {
const handleExport = async () => {
if (!id) return
try {
const { data } = await examApi.exportCsv(id)
const url = URL.createObjectURL(data)
const res = await examApi.exportCsv(id)
const blob = res.data
if (blob.type?.includes('application/json')) {
const text = await blob.text()
try {
const err = JSON.parse(text) as { detail?: string }
message.error(err.detail || '导出失败')
} catch {
message.error('导出失败')
}
return
}
const disposition = res.headers['content-disposition'] as string | undefined
let filename = `${student?.name || 'student'}_scores.csv`
if (disposition) {
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
const plainMatch = disposition.match(/filename="?([^";]+)"?/i)
if (utf8Match?.[1]) filename = decodeURIComponent(utf8Match[1])
else if (plainMatch?.[1]) filename = plainMatch[1]
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${student?.name || 'student'}_scores.csv`
a.download = filename
a.click()
URL.revokeObjectURL(url)
} catch {
@@ -198,6 +218,11 @@ export default function StudentDetailPage() {
label: '成绩复盘',
children: <ExamReviewPanel studentId={id!} exams={exams} onRefresh={loadExams} />,
},
{
key: 'composition',
label: '作文区',
children: <CompositionPanel studentId={id!} student={student} />,
},
{
key: 'wrong',
label: '错题库',
+28
View File
@@ -15,8 +15,13 @@ export interface PublicSettings {
registration_enabled: boolean
}
export interface AppFeatures {
ai_review_enabled: boolean
}
export interface SystemSettings {
registration_enabled: boolean
ai_review_enabled: boolean
ai_provider: AIProvider
ollama_base_url: string | null
ollama_model: string | null
@@ -167,3 +172,26 @@ export const STATUS_LABELS: Record<WrongQuestionStatus, string> = {
solved: '已生成解法',
failed: '失败',
}
export type CompositionStatus = 'pending' | 'generating' | 'done' | 'failed'
export type CompositionInputMode = 'manual' | 'ocr'
export interface Composition {
id: string
student_id: string
topic: string
input_mode: CompositionInputMode
writing_plan: string | null
sample_essay: string | null
error_message: string | null
status: CompositionStatus
created_at: string
updated_at: string
}
export const COMPOSITION_STATUS_LABELS: Record<CompositionStatus, string> = {
pending: '等待生成',
generating: '生成中',
done: '已完成',
failed: '失败',
}