新增作文区与 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
@@ -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={{