新增作文区与 AI 解读开关,修复 CSV 导出。
系统设置可关闭成绩复盘 AI;学生详情增加作文区(OCR/手动题目、方案与范文、历史与 MD 下载);导出改用 UTF-8 文件名响应。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+518
File diff suppressed because one or more lines are too long
-518
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '错题库',
|
||||
|
||||
@@ -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: '失败',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user