hku-class/frontend/src/app/(app)/resources/page.tsx
2026-04-27 09:21:20 +08:00

310 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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]"> PDFWordExcelPPTZIP 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>
);
}