426 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|