Files
CollegeAIcenter/web/src/app/student/skill-video/page.tsx
T

160 lines
5.6 KiB
TypeScript

"use client";
import { useState } from "react";
import {
BookOpenText,
Play,
Star,
Video,
} from "lucide-react";
import { ScoreBar } 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 { UsageGuide } from "@/components/usage-guide";
import { ApiError } from "@/lib/api";
import { skillVideoApi } from "@/lib/services";
const CATEGORIES = [
{ value: "suture", label: "缝合技术" },
{ value: "catheter", label: "导尿管置入" },
{ value: "venipuncture", label: "静脉穿刺" },
{ value: "intubation", label: "气管插管" },
{ value: "cpr", label: "心肺复苏" },
{ value: "physical-exam", label: "体格检查" },
{ value: "aseptic", label: "无菌操作" },
{ value: "surgical-scrub", label: "外科刷手" },
];
export default function SkillVideoPage() {
const [videos, setVideos] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
async function search() {
setLoading(true);
setError(null);
try {
const res = await skillVideoApi.search({ query: query || undefined });
setVideos(Array.isArray(res) ? res : []);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
} finally {
setLoading(false);
}
}
return (
<div className="space-y-6">
<PageHeading
icon={<Video className="size-5" />}
title="技能视频"
description="手术与操作技能视频学习,支持分段自评与关键点标记。"
/>
<UsageGuide
steps={[
{ title: "选择技能类型", detail: "浏览或搜索缝合、穿刺、插管等临床操作视频。" },
{ title: "观看学习", detail: "视频支持分段播放,关键步骤自动高亮提示。" },
{ title: "标记要点", detail: "在关键时间点添加个人笔记与操作要点。" },
{ title: "自我评估", detail: "观看后根据视频标准进行自评,记录能力成长。" },
]}
tip="建议结合临床模拟模块进行反复练习,视频学习后尽早到模拟环境或临床场景中实操。"
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((c) => (
<Button
key={c.value}
variant="outline"
size="sm"
onClick={() => {
setQuery(c.label);
search();
}}
>
{c.label}
</Button>
))}
</div>
<div className="mt-4 flex gap-2">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索技能名称,如:清创缝合"
className="max-w-md"
/>
<Button onClick={search} disabled={loading}>
{loading ? "搜索中…" : "搜索"}
</Button>
</div>
{error && <div className="mt-4"><ErrorBanner message={error} /></div>}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{loading ? (
<Loading />
) : videos.length === 0 ? (
<div className="sm:col-span-2 lg:col-span-3">
<EmptyState message="暂无视频记录,请先添加视频或搜索。" />
</div>
) : (
videos.map((v: any, i: number) => (
<Card key={v.id ?? i} className="overflow-hidden">
<div className="relative aspect-video bg-muted">
<div className="absolute inset-0 flex items-center justify-center">
<Button size="icon" variant="secondary" className="rounded-full">
<Play className="size-5" />
</Button>
</div>
{v.duration && (
<Badge variant="secondary" className="absolute bottom-2 right-2">
{v.duration}
</Badge>
)}
</div>
<CardContent className="p-4">
<p className="text-sm font-semibold text-foreground">{v.title ?? `视频 ${i + 1}`}</p>
<p className="mt-1 text-xs text-muted-foreground">
{v.category && (
<Badge variant="outline" className="mr-1">
{CATEGORIES.find((c) => c.value === v.category)?.label ?? v.category}
</Badge>
)}
{v.description}
</p>
{v.myScore != null && (
<div className="mt-3">
<ScoreBar label="我的自评" score={v.myScore} tone="primary" />
</div>
)}
<div className="mt-3 flex items-center gap-1 text-xs text-muted-foreground">
<BookOpenText className="size-3" />
{v.noteCount ?? 0}
<Star className="ml-2 size-3" />
{v.favorite ? "已收藏" : "未收藏"}
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
);
}