hku-class/frontend/src/app/(app)/schedule/page.tsx
2026-04-27 11:04:00 +08:00

426 lines
16 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { useActiveClass } from "@/hooks/use-active-class";
import { fetchAPI, postAPI, putAPI, deleteAPI, getErrorMessage } from "@/lib/api";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
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 { CalendarView } from "@/components/calendar-view";
import { toast } from "sonner";
import type { PageResponse, ScheduleItem } from "@/lib/types";
import { SCHEDULE_TYPES } from "@/lib/constants";
export default function SchedulePage() {
const { activeClassId } = useActiveClass();
const [items, setItems] = useState<ScheduleItem[]>([]);
const [upcoming, setUpcoming] = useState<ScheduleItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [viewMode, setViewMode] = useState<"list" | "calendar">("list");
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
// Form state
const [formType, setFormType] = useState("course");
const [formTitle, setFormTitle] = useState("");
const [formStartTime, setFormStartTime] = useState("");
const [formEndTime, setFormEndTime] = useState("");
const [formLocation, setFormLocation] = useState("");
const [formDesc, setFormDesc] = useState("");
const isDeadlineType = formType === "deadline";
const getDisplayTime = (item: ScheduleItem) =>
item.type === "deadline" && item.end_time ? item.end_time : item.start_time;
const loadData = useCallback(async () => {
if (!activeClassId) {
setItems([]);
setUpcoming([]);
setError(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const params = { class_id: String(activeClassId), page: String(page), page_size: "20" };
const [allRes, upcomingRes] = await Promise.all([
fetchAPI<PageResponse<ScheduleItem>>("/api/schedule/", params),
fetchAPI<ScheduleItem[]>("/api/schedule/upcoming", { limit: "10", class_id: String(activeClassId) }),
]);
setItems(allRes.items ?? []);
setTotalPages(allRes.total_pages ?? 1);
setUpcoming(upcomingRes);
} catch (err: unknown) {
setError(getErrorMessage(err, "加载失败"));
} finally {
setLoading(false);
}
}, [activeClassId, page]);
useEffect(() => {
void loadData();
}, [loadData]);
const getCountdown = (dateTime: string) => {
const diff = new Date(dateTime).getTime() - Date.now();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days <= 0) return { text: "已截止", urgent: false };
if (days === 1) return { text: "明天", urgent: true };
if (days <= 3) return { text: `${days}天后`, urgent: true };
if (days <= 7) return { text: `${days}天后`, urgent: false };
return { text: `${days}天后`, urgent: false };
};
const handleSubmit = async () => {
const primaryTime = isDeadlineType ? formEndTime : formStartTime;
if (!formTitle.trim() || !primaryTime) return;
setSubmitting(true);
try {
const payload = {
type: formType,
title: formTitle,
start_time: isDeadlineType ? primaryTime : formStartTime,
end_time: isDeadlineType ? primaryTime : formEndTime || null,
location: isDeadlineType ? null : formLocation || null,
description: formDesc || null,
};
if (editingId) {
await putAPI(`/api/schedule/${editingId}`, payload);
toast.success("排期已更新");
} else {
await postAPI(`/api/schedule/?class_id=${activeClassId}`, payload);
toast.success("排期已创建");
}
setDialogOpen(false);
resetForm();
await loadData();
} catch (err: unknown) {
toast.error(getErrorMessage(err, editingId ? "更新失败" : "创建失败"));
} finally {
setSubmitting(false);
}
};
const openEdit = (item: ScheduleItem) => {
setEditingId(item.id);
setFormType(item.type);
setFormTitle(item.title);
// Format datetime for datetime-local input (YYYY-MM-DDTHH:mm)
const pad = (n: number) => String(n).padStart(2, "0");
const fmt = (d: string) => {
const dt = new Date(d);
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
};
const effectiveTime = item.type === "deadline" && item.end_time ? item.end_time : item.start_time;
setFormStartTime(item.type === "deadline" ? "" : fmt(item.start_time));
setFormEndTime(item.type === "deadline" ? fmt(effectiveTime) : item.end_time ? fmt(item.end_time) : "");
setFormLocation(item.type === "deadline" ? "" : item.location || "");
setFormDesc(item.description || "");
setDialogOpen(true);
};
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const handleDelete = async (id: number) => {
try {
await deleteAPI(`/api/schedule/${id}`);
toast.success("已删除");
setDeleteTarget(null);
await loadData();
} catch (err: unknown) {
toast.error(getErrorMessage(err, "删除失败"));
}
};
const resetForm = () => {
setEditingId(null);
setFormType("course");
setFormTitle("");
setFormStartTime("");
setFormEndTime("");
setFormLocation("");
setFormDesc("");
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<div className="flex items-center gap-2">
{/* View mode toggle */}
<div className="flex border rounded-lg p-0.5">
<Button
variant={viewMode === "list" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("list")}
>
</Button>
<Button
variant={viewMode === "calendar" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("calendar")}
>
</Button>
</div>
<RoleGuard permissions={["schedule_manage"]}>
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
if (!open) resetForm();
}}>
<DialogTrigger>
<Button onClick={() => resetForm()}></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? "编辑排期" : "添加排期"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<Select value={formType} onValueChange={(v) => v && setFormType(v)}>
<SelectTrigger>
<SelectValue>{SCHEDULE_TYPES[formType as keyof typeof SCHEDULE_TYPES]?.label || formType}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="course"></SelectItem>
<SelectItem value="deadline"></SelectItem>
<SelectItem value="activity"></SelectItem>
</SelectContent>
</Select>
<Input
placeholder="标题"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
/>
{isDeadlineType ? (
<div>
<label className="text-sm text-gray-500"></label>
<Input
type="datetime-local"
value={formEndTime}
onChange={(e) => setFormEndTime(e.target.value)}
/>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm text-gray-500"></label>
<Input
type="datetime-local"
value={formStartTime}
onChange={(e) => setFormStartTime(e.target.value)}
/>
</div>
<div>
<label className="text-sm text-gray-500"></label>
<Input
type="datetime-local"
value={formEndTime}
onChange={(e) => setFormEndTime(e.target.value)}
/>
</div>
</div>
<Input
placeholder="地点"
value={formLocation}
onChange={(e) => setFormLocation(e.target.value)}
/>
</>
)}
<Textarea
placeholder="描述"
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
rows={3}
/>
<Button onClick={handleSubmit} disabled={submitting} className="w-full">
{submitting ? (editingId ? "保存中..." : "创建中...") : (editingId ? "保存" : "创建")}
</Button>
</div>
</DialogContent>
</Dialog>
</RoleGuard>
</div>
</div>
{/* Calendar view or list view */}
{viewMode === "calendar" ? (
<CalendarView events={items} onEventClick={openEdit} />
) : (
<>
{/* Upcoming with countdown */}
{upcoming.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{upcoming.map((item) => {
const displayTime = getDisplayTime(item);
const countdown = getCountdown(displayTime);
const typeInfo = SCHEDULE_TYPES[item.type] || { label: item.type, color: "bg-gray-400" };
return (
<Card key={item.id} className={countdown.urgent ? "border-red-200 bg-red-50/30" : ""}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${typeInfo.color}`} />
<Badge variant="outline" className="text-xs">
{typeInfo.label}
</Badge>
</div>
<Badge
variant={countdown.urgent ? "destructive" : "secondary"}
className="text-xs"
>
{countdown.text}
</Badge>
</div>
<h3 className="font-medium mt-2">{item.title}</h3>
<p className="text-sm text-gray-500 mt-1">
{new Date(displayTime).toLocaleString("zh-CN", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
{item.location && (
<p className="text-sm text-gray-400 mt-1">{item.location}</p>
)}
<RoleGuard permissions={["schedule_manage"]}>
<div className="flex gap-2 mt-2">
<Button
variant="ghost"
size="sm"
className="p-0 h-auto text-gray-500 hover:text-gray-700"
onClick={() => openEdit(item)}
>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-500 p-0 h-auto"
onClick={() => setDeleteTarget(item.id)}
>
</Button>
</div>
</RoleGuard>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
{/* All schedules */}
<div>
<h2 className="text-lg font-semibold mb-3"></h2>
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-4">
<div className="h-12 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadData} />
) : items.length === 0 ? (
<div className="text-center py-8 text-gray-400"></div>
) : (
<div className="space-y-2">
{items.map((item) => {
const typeInfo = SCHEDULE_TYPES[item.type] || { label: item.type, color: "bg-gray-400" };
return (
<Card key={item.id}>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${typeInfo.color}`} />
<div>
<p className="font-medium">{item.title}</p>
<p className="text-sm text-gray-500">
{new Date(getDisplayTime(item)).toLocaleString("zh-CN")}
{item.type !== "deadline" && item.location ? ` · ${item.location}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{typeInfo.label}</Badge>
<RoleGuard permissions={["schedule_manage"]}>
<Button
variant="ghost"
size="sm"
className="text-gray-500 hover:text-gray-700"
onClick={() => openEdit(item)}
>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => setDeleteTarget(item.id)}
>
</Button>
</RoleGuard>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
</>
)}
{/* Delete confirmation */}
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
title="删除排期"
description="确定删除这条排期?此操作不可恢复。"
confirmText="删除"
variant="destructive"
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
/>
</div>
);
}