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:
selfrelease
2026-08-10 12:13:52 +08:00
parent 32bf3a2547
commit 8809a28e9c
16 changed files with 2726 additions and 11 deletions
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+322
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}