feat: add four new student modules (rotation, skill-video, exam-prep, academic) with backend APIs and frontend pages; add exam-prep question history with DB persistence

This commit is contained in:
freedakgmail
2026-06-04 11:50:25 +08:00
commit 995f4ecfbd
399 changed files with 73529 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import {
BookOpenText,
MessageSquareText,
Send,
Users,
} from "lucide-react";
import { RawDetails } from "@/components/display";
import {
EmptyState,
ErrorBanner,
Loading,
PageHeading,
} from "@/components/feedback";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { academicApi } from "@/lib/services";
const TYPES = [
{ value: "journal-club", label: "文献汇报" },
{ value: "case-discussion", label: "病例讨论" },
{ value: "seminar", label: "专题讲座" },
{ value: "rounds", label: "教学查房" },
];
export default function AcademicPage() {
const [records, setRecords] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [type, setType] = useState("");
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [mentorFeedback, setMentorFeedback] = useState("");
const [submitting, setSubmitting] = useState(false);
async function loadRecords() {
setLoading(true);
setError(null);
try {
const res = await academicApi.listRecords();
setRecords(Array.isArray(res) ? res : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await academicApi.addRecord({
type: type || undefined,
title,
content,
mentorFeedback: mentorFeedback || undefined,
});
setType("");
setTitle("");
setContent("");
setMentorFeedback("");
await loadRecords();
} catch (err) {
setError(err instanceof ApiError ? err.message : "提交失败");
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<Users className="size-5" />}
title="学术交流"
description="文献汇报、病例讨论记录与导师反馈,沉淀学术交流轨迹。"
/>
<UsageGuide
steps={[
{ title: "选择交流类型", detail: "文献汇报、病例讨论、专题讲座或教学查房。" },
{ title: "填写主题与内容", detail: "记录汇报主题、核心观点与个人收获。" },
{ title: "记录导师反馈", detail: "将导师的点评与建议同步记录,便于后续回顾。" },
{ title: "查看交流轨迹", detail: "按时间线查看所有学术交流记录与成长脉络。" },
]}
tip="高质量的学术交流记录是胜任力画像中「学术与科研」维度的重要数据来源,建议每次活动后 24 小时内完成记录。"
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="size-4" />
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<Label></Label>
<Select value={type} onChange={(e) => setType(e.target.value)} required>
<option value=""></option>
{TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:糖尿病肾病最新诊疗进展"
required
/>
</div>
<div className="sm:col-span-2">
<Label></Label>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="记录汇报内容、核心观点、个人收获…"
rows={4}
required
/>
</div>
<div className="sm:col-span-2">
<Label></Label>
<Textarea
value={mentorFeedback}
onChange={(e) => setMentorFeedback(e.target.value)}
placeholder="导师点评、建议与改进方向…"
rows={3}
/>
</div>
<div className="flex items-center gap-3 sm:col-span-2">
<Button type="submit" disabled={submitting}>
{submitting ? "提交中…" : "提交记录"}
</Button>
<Button type="button" variant="outline" onClick={loadRecords} disabled={loading}>
{loading ? "加载中…" : "刷新记录"}
</Button>
</div>
{error && (
<div className="sm:col-span-2">
<ErrorBanner message={error} />
</div>
)}
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpenText className="size-4" />
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Loading />
) : records.length === 0 ? (
<EmptyState message="暂无学术交流记录,请添加第一条记录。" />
) : (
<div className="space-y-4">
{records.map((r: any, i: number) => (
<div
key={r.id ?? i}
className="rounded-lg border border-border p-4"
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline">
{TYPES.find((t) => t.value === r.type)?.label ?? r.type}
</Badge>
<span className="text-sm font-semibold text-foreground">{r.title}</span>
</div>
{r.date && (
<span className="text-xs text-muted-foreground">{r.date}</span>
)}
</div>
<p className="text-sm leading-relaxed text-foreground/80">{r.content}</p>
{r.mentorFeedback && (
<div className="mt-3 rounded-lg bg-muted/50 p-3">
<p className="mb-1 flex items-center gap-1 text-xs font-medium text-muted-foreground">
<MessageSquareText className="size-3" />
</p>
<p className="text-sm text-foreground/80">{r.mentorFeedback}</p>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}