feat: 新增汇报PPT与生成脚本,修复tsconfig弃用告警
- 新增医科高校智能学习中心汇报PPT(27页,面向高校管理者与广电领导) - 新增 pptxgenjs 生成脚本 scripts/gen-pptx.js - 新增 SYSTEM-OVERVIEW.md 系统功能全景文档 - 新增 deploy.sh 部署脚本 - 补充 rate-limit guard、ai-stream controller、exam-prep 仓储等后端模块 - 补充 exam-recommendations、markdown、mock-exam、offline-indicator 前端组件 - tsconfig.json: 显式设置 rootDir=./src,加 ignoreDeprecations=6.0 静音 baseUrl 弃用告警 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Lightbulb, TrendingDown } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { examPrepApi } from "@/lib/services";
|
||||
|
||||
interface Recommendation {
|
||||
subject: string;
|
||||
label: string;
|
||||
priority: number;
|
||||
reason: string;
|
||||
correctRate: number | null;
|
||||
totalAnswered: number;
|
||||
daysSinceLastPractice: number;
|
||||
}
|
||||
|
||||
const PRIORITY_VARIANT: Record<number, "destructive" | "warning" | "info" | "success"> = {
|
||||
1: "destructive",
|
||||
2: "info",
|
||||
3: "warning",
|
||||
4: "success",
|
||||
};
|
||||
|
||||
/**
|
||||
* 个性化备考推荐横幅:展示薄弱科目与今日复习建议。
|
||||
*/
|
||||
export function ExamRecommendations({
|
||||
onPickSubject,
|
||||
}: {
|
||||
onPickSubject?: (subject: string) => void;
|
||||
}) {
|
||||
const [recs, setRecs] = useState<Recommendation[]>([]);
|
||||
const [summary, setSummary] = useState<string>("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
examPrepApi
|
||||
.getRecommendations()
|
||||
.then((res) => {
|
||||
setRecs(res.recommendations ?? []);
|
||||
setSummary(res.summary ?? "");
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
if (!loaded || recs.length === 0) return null;
|
||||
|
||||
// 只展示优先级最高的前 3 个
|
||||
const top = recs.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-primary/15 bg-gradient-to-br from-primary/[0.06] to-transparent p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Lightbulb className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-foreground">智能备考推荐</h3>
|
||||
<p className="text-xs text-muted-foreground">{summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{top.map((r) => (
|
||||
<button
|
||||
key={r.subject}
|
||||
onClick={() => onPickSubject?.(r.subject)}
|
||||
className="flex flex-col gap-1 rounded-xl border border-border bg-card p-3 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-foreground">{r.label}</span>
|
||||
<Badge variant={PRIORITY_VARIANT[r.priority] ?? "info"}>
|
||||
{r.priority === 1 ? "薄弱" : r.priority === 2 ? "新科目" : r.priority === 3 ? "待复习" : "巩固"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{r.correctRate !== null && r.priority === 1 && <TrendingDown className="size-3" />}
|
||||
{r.reason}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 统一的 Markdown 渲染组件。
|
||||
*
|
||||
* 用于渲染 AI 生成的富文本内容(标题、列表、加粗、代码、表格等),
|
||||
* 样式与应用主题一致,支持暗色模式。可在流式输出过程中实时渲染。
|
||||
*/
|
||||
export function Markdown({
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
content: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-relaxed text-foreground",
|
||||
// 标题
|
||||
"[&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-base [&_h1]:font-bold",
|
||||
"[&_h2]:mb-2 [&_h2]:mt-3 [&_h2]:text-sm [&_h2]:font-bold",
|
||||
"[&_h3]:mb-1 [&_h3]:mt-2 [&_h3]:text-sm [&_h3]:font-semibold",
|
||||
// 段落
|
||||
"[&_p]:my-1.5 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
|
||||
// 列表
|
||||
"[&_ul]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5",
|
||||
"[&_ol]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5",
|
||||
"[&_li]:my-0.5 [&_li]:marker:text-muted-foreground",
|
||||
// 强调
|
||||
"[&_strong]:font-semibold [&_strong]:text-foreground",
|
||||
"[&_em]:italic",
|
||||
// 引用
|
||||
"[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
|
||||
// 行内代码
|
||||
"[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",
|
||||
// 代码块
|
||||
"[&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3",
|
||||
"[&_pre_code]:bg-transparent [&_pre_code]:p-0",
|
||||
// 链接
|
||||
"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
|
||||
// 表格
|
||||
"[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:text-xs",
|
||||
"[&_th]:border [&_th]:border-border [&_th]:bg-muted/50 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-semibold",
|
||||
"[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1",
|
||||
// 分割线
|
||||
"[&_hr]:my-3 [&_hr]:border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CheckCircle2, Clock, FileText, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { examPrepApi } from "@/lib/services";
|
||||
|
||||
const SUBJECTS = [
|
||||
{ value: "internal", label: "内科学" },
|
||||
{ value: "surgery", label: "外科学" },
|
||||
{ value: "pediatrics", label: "儿科学" },
|
||||
{ value: "obgyn", label: "妇产科学" },
|
||||
{ value: "pharmacology", label: "药理学" },
|
||||
{ value: "pathology", label: "病理学" },
|
||||
{ value: "diagnostics", label: "诊断学" },
|
||||
{ value: "ethics", label: "医学伦理" },
|
||||
];
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
type Phase = "config" | "exam" | "result";
|
||||
|
||||
/**
|
||||
* 模拟考试组件:限时批量答题 → 交卷统一批阅。
|
||||
*/
|
||||
export function MockExam() {
|
||||
const [phase, setPhase] = useState<Phase>("config");
|
||||
const [count, setCount] = useState(10);
|
||||
const [timeLimit, setTimeLimit] = useState(20);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [exam, setExam] = useState<any>(null);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [remainingSec, setRemainingSec] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function startExam() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const e = await examPrepApi.startMockExam({ count, timeLimitMinutes: timeLimit });
|
||||
setExam(e);
|
||||
setAnswers({});
|
||||
setResult(null);
|
||||
setPhase("exam");
|
||||
setRemainingSec(timeLimit * 60);
|
||||
timerRef.current = setInterval(() => {
|
||||
setRemainingSec((s) => {
|
||||
if (s <= 1) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
void submitExam(e, true);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "无法发起模拟考试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitExam(examData = exam, auto = false) {
|
||||
if (!examData) return;
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = {
|
||||
examId: examData.examId,
|
||||
answers: examData.questions.map((q: any) => ({
|
||||
questionId: q.id,
|
||||
answer: answers[q.id] ?? "",
|
||||
})),
|
||||
};
|
||||
const r = await examPrepApi.submitMockExam(payload);
|
||||
setResult(r);
|
||||
setPhase("result");
|
||||
if (auto) toast.warning("考试时间到,已自动交卷。");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "交卷失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setPhase("config");
|
||||
setExam(null);
|
||||
setAnswers({});
|
||||
setResult(null);
|
||||
}
|
||||
|
||||
const answeredCount = exam ? Object.keys(answers).filter((k) => answers[k]).length : 0;
|
||||
|
||||
// ── 配置阶段 ──
|
||||
if (phase === "config") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="size-4" /> 模拟考试
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
限时批量答题,模拟真实考场环境。交卷后统一批阅并生成成绩单。
|
||||
</p>
|
||||
{error && <ErrorText message={error} />}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">题目数量</label>
|
||||
<Select value={String(count)} onChange={(e) => setCount(Number(e.target.value))} className="w-28">
|
||||
<option value="10">10 题</option>
|
||||
<option value="20">20 题</option>
|
||||
<option value="30">30 题</option>
|
||||
<option value="50">50 题</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">时间限制</label>
|
||||
<Select value={String(timeLimit)} onChange={(e) => setTimeLimit(Number(e.target.value))} className="w-28">
|
||||
<option value="10">10 分钟</option>
|
||||
<option value="20">20 分钟</option>
|
||||
<option value="40">40 分钟</option>
|
||||
<option value="60">60 分钟</option>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={startExam} disabled={busy}>
|
||||
{busy ? "准备中…" : "开始考试"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 考试阶段 ──
|
||||
if (phase === "exam" && exam) {
|
||||
const mm = String(Math.floor(remainingSec / 60)).padStart(2, "0");
|
||||
const ss = String(remainingSec % 60).padStart(2, "0");
|
||||
const lowTime = remainingSec <= 60;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="sticky top-0 z-10 border-b border-border bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>模拟考试进行中</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
已答 {answeredCount} / {exam.totalQuestions}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-3 py-1 text-sm font-bold tabular-nums ${
|
||||
lowTime ? "bg-destructive/12 text-destructive" : "bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
<Clock className="size-4" /> {mm}:{ss}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{error && <ErrorText message={error} />}
|
||||
{exam.questions.map((q: any) => (
|
||||
<div key={q.id} className="rounded-lg border border-border p-4">
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<span className="text-sm font-bold text-primary">{q.index}.</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm leading-relaxed text-foreground">{q.content}</p>
|
||||
<Badge variant="info" className="mt-2">
|
||||
{q.subjectLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{q.options && (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{q.options.map((opt: string, i: number) => {
|
||||
const letter = String.fromCharCode(65 + i);
|
||||
const selected = answers[q.id] === letter;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setAnswers((a) => ({ ...a, [q.id]: letter }))}
|
||||
className={`flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-foreground/80 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold ${
|
||||
selected ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => submitExam()} disabled={busy}>
|
||||
{busy ? "交卷中…" : "交卷"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={reset} disabled={busy}>
|
||||
放弃考试
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 成绩阶段 ──
|
||||
if (phase === "result" && result) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>考试成绩单</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl bg-muted/40 p-6">
|
||||
<div
|
||||
className={`text-5xl font-bold ${
|
||||
result.score >= 80 ? "text-success" : result.score >= 60 ? "text-warning" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{result.score}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={result.passed ? "success" : "destructive"}>
|
||||
{result.passed ? "及格" : "未及格"}
|
||||
</Badge>
|
||||
{result.overtime && <Badge variant="warning">超时</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.correctCount}/{result.totalQuestions} 题正确 · 用时 {result.elapsedMinutes} 分钟
|
||||
</p>
|
||||
<p className="text-center text-sm text-foreground/80">{result.comment}</p>
|
||||
</div>
|
||||
|
||||
{/* 科目分布 */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">各科目表现</p>
|
||||
{Object.entries(result.bySubject).map(([key, val]: [string, any]) => {
|
||||
const label = SUBJECTS.find((s) => s.value === key)?.label ?? key;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
val.rate >= 80 ? "text-success" : val.rate >= 60 ? "text-warning" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{val.correct}/{val.total}({val.rate}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 逐题解析 */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">逐题解析</p>
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{result.results.map((r: any, i: number) => (
|
||||
<div key={r.questionId} className="rounded-lg border border-border p-3">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
{r.correct ? (
|
||||
<CheckCircle2 className="size-4 text-success" />
|
||||
) : (
|
||||
<XCircle className="size-4 text-destructive" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">第 {i + 1} 题</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/90">{r.content}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span>你的答案:{r.userAnswer}</span>
|
||||
<span>正确答案:{r.correctAnswer}</span>
|
||||
</div>
|
||||
{r.explanation && (
|
||||
<p className="mt-2 rounded bg-muted/50 p-2 text-xs text-foreground/70">{r.explanation}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={reset}>再考一次</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ErrorText({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">{message}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { WifiOff } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 离线状态指示器:检测网络断开,提示用户当前展示的是缓存数据。
|
||||
*/
|
||||
export function OfflineIndicator() {
|
||||
const [offline, setOffline] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator === "undefined") return;
|
||||
setOffline(!navigator.onLine);
|
||||
|
||||
const onOffline = () => setOffline(true);
|
||||
const onOnline = () => setOffline(false);
|
||||
window.addEventListener("offline", onOffline);
|
||||
window.addEventListener("online", onOnline);
|
||||
return () => {
|
||||
window.removeEventListener("offline", onOffline);
|
||||
window.removeEventListener("online", onOnline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!offline) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-amber-500/90 px-2.5 py-0.5 text-[11px] font-medium text-white">
|
||||
<WifiOff className="size-3" />
|
||||
<span className="hidden sm:inline">离线模式(显示缓存数据)</span>
|
||||
<span className="sm:hidden">离线</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user