310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useActiveClass } from "@/hooks/use-active-class";
|
||
import { fetchAPI, postAPI, deleteAPI, uploadAPI, getErrorMessage } from "@/lib/api";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { RoleGuard } from "@/components/role-guard";
|
||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||
import { ErrorState } from "@/components/error-state";
|
||
import { Pagination } from "@/components/pagination";
|
||
import { toast } from "sonner";
|
||
import type { PageResponse, Resource } from "@/lib/types";
|
||
|
||
type ResourceDownloadResponse = {
|
||
file_url: string;
|
||
};
|
||
|
||
const RESOURCE_CATEGORIES: Record<string, string> = {
|
||
all: "全部",
|
||
course_material: "课件资料",
|
||
assignment: "作业",
|
||
reading: "阅读材料",
|
||
other: "其他",
|
||
};
|
||
|
||
function formatFileSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function getFileIcon(fileType: string): string {
|
||
if (fileType.startsWith("image/")) return "🖼️";
|
||
if (fileType.includes("pdf")) return "📄";
|
||
if (fileType.includes("word") || fileType.includes("document")) return "📝";
|
||
if (fileType.includes("sheet") || fileType.includes("excel")) return "📊";
|
||
if (fileType.includes("presentation") || fileType.includes("powerpoint")) return "📽️";
|
||
if (fileType.includes("zip")) return "📦";
|
||
return "📎";
|
||
}
|
||
|
||
export default function ResourcesPage() {
|
||
const { activeClassId } = useActiveClass();
|
||
const [resources, setResources] = useState<Resource[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [category, setCategory] = useState("all");
|
||
const [page, setPage] = useState(1);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
|
||
// Upload dialog state
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [formTitle, setFormTitle] = useState("");
|
||
const [formDesc, setFormDesc] = useState("");
|
||
const [formCategory, setFormCategory] = useState("course_material");
|
||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Delete state
|
||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
|
||
|
||
const loadResources = useCallback(async () => {
|
||
if (!activeClassId) {
|
||
setResources([]);
|
||
setTotalPages(1);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setError(null);
|
||
setLoading(true);
|
||
try {
|
||
const params: Record<string, string> = {
|
||
page_size: "20",
|
||
page: String(page),
|
||
class_id: String(activeClassId),
|
||
};
|
||
if (category !== "all") params.category = category;
|
||
const res = await fetchAPI<PageResponse<Resource>>("/api/resources/", params);
|
||
setResources(res.items || []);
|
||
setTotalPages(res.total_pages || 1);
|
||
} catch (err: unknown) {
|
||
setError(getErrorMessage(err, "加载失败"));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [activeClassId, category, page]);
|
||
|
||
useEffect(() => {
|
||
void loadResources();
|
||
}, [loadResources]);
|
||
|
||
const resetForm = () => {
|
||
setFormTitle("");
|
||
setFormDesc("");
|
||
setFormCategory("course_material");
|
||
setSelectedFile(null);
|
||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!formTitle.trim() || !selectedFile) return;
|
||
setSubmitting(true);
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append("title", formTitle);
|
||
formData.append("category", formCategory);
|
||
formData.append("file", selectedFile);
|
||
if (formDesc.trim()) formData.append("description", formDesc);
|
||
if (activeClassId) formData.append("class_id", String(activeClassId));
|
||
|
||
await uploadAPI("/api/resources/", formData);
|
||
toast.success("资源已上传");
|
||
setDialogOpen(false);
|
||
resetForm();
|
||
loadResources();
|
||
} catch (err: unknown) {
|
||
toast.error(getErrorMessage(err, "上传失败"));
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleDownload = async (resource: Resource) => {
|
||
try {
|
||
const res = await postAPI<ResourceDownloadResponse>(`/api/resources/${resource.id}/download`);
|
||
window.open(res.file_url, "_blank");
|
||
} catch (err: unknown) {
|
||
toast.error(getErrorMessage(err, "下载失败"));
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: number) => {
|
||
try {
|
||
await deleteAPI(`/api/resources/${id}`);
|
||
toast.success("已删除");
|
||
setDeleteTarget(null);
|
||
loadResources();
|
||
} catch (err: unknown) {
|
||
toast.error(getErrorMessage(err, "删除失败"));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="text-[11px] uppercase tracking-[0.24em] text-[#976746]">Library</div>
|
||
<h1 className="mt-2 text-3xl font-semibold text-[#4e1d1a]">资源库</h1>
|
||
<p className="mt-2 text-[#765a4d]">共享课件、阅读材料与班级文档档案</p>
|
||
</div>
|
||
<RoleGuard permissions={["resource_manage"]}>
|
||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||
setDialogOpen(open);
|
||
if (!open) resetForm();
|
||
}}>
|
||
<DialogTrigger>
|
||
<Button>上传资源</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="border-[#eadbc8] bg-[#fffdf8] sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-xl text-[#4e1d1a]">上传资源</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 pt-2">
|
||
<Input
|
||
placeholder="资源标题"
|
||
value={formTitle}
|
||
onChange={(e) => setFormTitle(e.target.value)}
|
||
/>
|
||
<Textarea
|
||
placeholder="描述(选填)"
|
||
value={formDesc}
|
||
onChange={(e) => setFormDesc(e.target.value)}
|
||
rows={3}
|
||
/>
|
||
<Select value={formCategory} onValueChange={(v) => v && setFormCategory(v)}>
|
||
<SelectTrigger>
|
||
<SelectValue>{RESOURCE_CATEGORIES[formCategory] || formCategory}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="course_material">课件资料</SelectItem>
|
||
<SelectItem value="assignment">作业</SelectItem>
|
||
<SelectItem value="reading">阅读材料</SelectItem>
|
||
<SelectItem value="other">其他</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
className="block w-full text-sm text-[#7a5e4f] file:mr-4 file:rounded-xl file:border-0 file:bg-[#f3e4cf] file:px-4 file:py-2 file:text-sm file:font-medium file:text-[#74411f] hover:file:bg-[#ecd6b7]"
|
||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||
/>
|
||
<p className="mt-1 text-xs text-[#9d806f]">支持 PDF、Word、Excel、PPT、ZIP 等,最大 50MB</p>
|
||
</div>
|
||
<Button onClick={handleSubmit} disabled={submitting || !selectedFile} className="w-full">
|
||
{submitting ? "上传中..." : "上传"}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</RoleGuard>
|
||
</div>
|
||
|
||
{/* Category tabs */}
|
||
<div className="flex flex-wrap gap-2 rounded-[1.5rem] border border-[#eadbc8] bg-[#fffaf2] p-2">
|
||
{Object.entries(RESOURCE_CATEGORIES).map(([key, label]) => (
|
||
<Button
|
||
key={key}
|
||
variant={category === key ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={() => { setCategory(key); setPage(1); }}
|
||
>
|
||
{label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="space-y-2">
|
||
{[1, 2, 3].map((i) => (
|
||
<Card key={i} className="animate-pulse bg-[#fffaf2]">
|
||
<CardContent className="p-4">
|
||
<div className="h-16 bg-gray-200 rounded" />
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
) : error ? (
|
||
<ErrorState message={error} onRetry={loadResources} />
|
||
) : resources.length === 0 ? (
|
||
<div className="rounded-[2rem] border border-dashed border-[#dcc6ab] bg-[#fffaf2] py-12 text-center text-[#9d806f]">暂无资源</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{resources.map((r) => (
|
||
<Card key={r.id} className="bg-[#fffdf8]">
|
||
<CardContent className="flex items-center justify-between p-4">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-2xl">{getFileIcon(r.file_type)}</span>
|
||
<div>
|
||
<p className="font-medium text-[#4e1d1a]">{r.title}</p>
|
||
<p className="text-sm text-[#7a5e4f]">
|
||
{r.uploader_name} · {formatFileSize(r.file_size)} · 下载 {r.download_count} 次
|
||
</p>
|
||
{r.description && (
|
||
<p className="mt-1 text-xs text-[#9d806f]">{r.description}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="outline" className="border-[#dcc6ab] bg-[#fff8ef] text-[#7a5e4f]">
|
||
{RESOURCE_CATEGORIES[r.category] || r.category}
|
||
</Badge>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => handleDownload(r)}
|
||
>
|
||
下载
|
||
</Button>
|
||
<RoleGuard permissions={["resource_manage"]}>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="text-red-500"
|
||
onClick={() => setDeleteTarget(r.id)}
|
||
>
|
||
删除
|
||
</Button>
|
||
</RoleGuard>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||
|
||
<ConfirmDialog
|
||
open={deleteTarget !== null}
|
||
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
|
||
title="删除资源"
|
||
description="确定删除该资源?此操作不可恢复。"
|
||
confirmText="删除"
|
||
variant="destructive"
|
||
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|