160 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|