新增作文区与 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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
<script type="module" crossorigin src="/assets/index-CmdQeYPX.js"></script>
<script type="module" crossorigin src="/assets/index-BFUIx7uW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
</head>
<body>
+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: '失败',
}