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

625 lines
23 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 { useEffect, useState, useCallback } from "react";
import { useActiveClass } from "@/hooks/use-active-class";
import { useAuth } from "@/hooks/use-auth";
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 { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ErrorState } from "@/components/error-state";
import { Pagination } from "@/components/pagination";
import { toast } from "sonner";
import type { Vote, PageResponse } from "@/lib/types";
import { hasClassPermission } from "@/lib/permissions";
export default function VotesPage() {
const { activeClassId } = useActiveClass();
const { user } = useAuth();
// List state
const [votes, setVotes] = useState<Vote[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
// Create dialog state
const [createOpen, setCreateOpen] = useState(false);
const [formTitle, setFormTitle] = useState("");
const [formDesc, setFormDesc] = useState("");
const [formVoteType, setFormVoteType] = useState<"single" | "multiple">("single");
const [formAnonymous, setFormAnonymous] = useState(false);
const [formMaxChoices, setFormMaxChoices] = useState(2);
const [formDeadline, setFormDeadline] = useState("");
const [formOptions, setFormOptions] = useState<string[]>(["", ""]);
const [submitting, setSubmitting] = useState(false);
// Detail dialog state
const [detailOpen, setDetailOpen] = useState(false);
const [detailVote, setDetailVote] = useState<Vote | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedOptions, setSelectedOptions] = useState<number[]>([]);
const [submittingVote, setSubmittingVote] = useState(false);
// Delete confirm state
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const loadVotes = useCallback(async () => {
if (!activeClassId) return;
setError(null);
setLoading(true);
try {
const res = await fetchAPI<PageResponse<Vote>>("/api/votes/", {
class_id: String(activeClassId),
page: String(page),
page_size: "10",
});
setVotes(res.items || []);
setTotalPages(res.total_pages || 1);
} catch (err: unknown) {
setError(getErrorMessage(err, "加载失败"));
} finally {
setLoading(false);
}
}, [activeClassId, page]);
useEffect(() => {
loadVotes();
}, [loadVotes]);
// Reset page when class changes
useEffect(() => {
setPage(1);
}, [activeClassId]);
// ---------- Create ----------
const resetCreateForm = () => {
setFormTitle("");
setFormDesc("");
setFormVoteType("single");
setFormAnonymous(false);
setFormMaxChoices(2);
setFormDeadline("");
setFormOptions(["", ""]);
};
const handleCreate = async () => {
if (!formTitle.trim()) {
toast.error("请输入投票标题");
return;
}
const validOptions = formOptions.filter((o) => o.trim());
if (validOptions.length < 2) {
toast.error("至少需要两个选项");
return;
}
if (formVoteType === "multiple" && formMaxChoices < 2) {
toast.error("多选投票至少允许选择2项");
return;
}
if (formVoteType === "multiple" && formMaxChoices > validOptions.length) {
toast.error("最多可选择数不能超过选项总数");
return;
}
setSubmitting(true);
try {
await postAPI(`/api/votes/?class_id=${activeClassId}`, {
title: formTitle.trim(),
description: formDesc.trim() || null,
vote_type: formVoteType,
is_anonymous: formAnonymous,
max_choices: formVoteType === "multiple" ? formMaxChoices : 1,
deadline: formDeadline || null,
options: validOptions,
});
toast.success("投票已创建");
setCreateOpen(false);
resetCreateForm();
loadVotes();
} catch (err: unknown) {
toast.error(getErrorMessage(err, "创建失败"));
} finally {
setSubmitting(false);
}
};
const addOption = () => setFormOptions([...formOptions, ""]);
const removeOption = (index: number) => {
if (formOptions.length <= 2) return;
setFormOptions(formOptions.filter((_, i) => i !== index));
};
const updateOption = (index: number, value: string) => {
const updated = [...formOptions];
updated[index] = value;
setFormOptions(updated);
};
// ---------- Detail ----------
const openDetail = async (voteId: number) => {
setDetailOpen(true);
setDetailLoading(true);
setSelectedOptions([]);
try {
const data = await fetchAPI<Vote>(`/api/votes/${voteId}`);
setDetailVote(data);
} catch (err: unknown) {
toast.error(getErrorMessage(err, "加载投票详情失败"));
setDetailOpen(false);
} finally {
setDetailLoading(false);
}
};
const handleSubmitVote = async () => {
if (!detailVote) return;
if (selectedOptions.length === 0) {
toast.error("请至少选择一个选项");
return;
}
if (detailVote.vote_type === "multiple" && selectedOptions.length > detailVote.max_choices) {
toast.error(`最多只能选择${detailVote.max_choices}`);
return;
}
setSubmittingVote(true);
try {
await postAPI(`/api/votes/${detailVote.id}/submit`, {
option_ids: selectedOptions,
});
toast.success("投票成功");
// Refresh detail
const data = await fetchAPI<Vote>(`/api/votes/${detailVote.id}`);
setDetailVote(data);
setSelectedOptions([]);
loadVotes();
} catch (err: unknown) {
toast.error(getErrorMessage(err, "投票失败"));
} finally {
setSubmittingVote(false);
}
};
const handleCloseVote = async (voteId: number) => {
try {
await putAPI(`/api/votes/${voteId}/close`);
toast.success("投票已关闭");
setDetailOpen(false);
loadVotes();
} catch (err: unknown) {
toast.error(getErrorMessage(err, "关闭失败"));
}
};
const handleDelete = async (id: number) => {
try {
await deleteAPI(`/api/votes/${id}`);
toast.success("已删除");
setDeleteTarget(null);
setDetailOpen(false);
loadVotes();
} catch (err: unknown) {
toast.error(getErrorMessage(err, "删除失败"));
}
};
const canManage = (vote: Vote) =>
user &&
(
hasClassPermission(user, "vote_manage", vote.class_id) ||
user.id === vote.creator_id
);
// ---------- Render helpers ----------
const renderStatusBadge = (status: Vote["status"]) => {
if (status === "open") {
return <Badge className="bg-green-500 text-white text-xs"></Badge>;
}
return <Badge variant="secondary" className="text-xs"></Badge>;
};
const formatDeadline = (deadline: string | null) => {
if (!deadline) return "无截止时间";
return new Date(deadline).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
const getPercentage = (count: number, total: number) => {
if (total === 0) return 0;
return Math.round((count / total) * 100);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-gray-500 mt-1"></p>
</div>
<Dialog open={createOpen} onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetCreateForm();
}}>
<DialogTrigger>
<Button></Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
{/* Title */}
<div>
<Label className="mb-1.5 block text-sm font-medium"> *</Label>
<Input
placeholder="投票标题"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
/>
</div>
{/* Description */}
<div>
<Label className="mb-1.5 block text-sm font-medium"></Label>
<Textarea
placeholder="投票描述(可选)"
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
rows={3}
/>
</div>
{/* Vote type */}
<div>
<Label className="mb-1.5 block text-sm font-medium"></Label>
<div className="flex gap-2">
<Button
type="button"
variant={formVoteType === "single" ? "default" : "outline"}
size="sm"
onClick={() => setFormVoteType("single")}
>
</Button>
<Button
type="button"
variant={formVoteType === "multiple" ? "default" : "outline"}
size="sm"
onClick={() => setFormVoteType("multiple")}
>
</Button>
</div>
</div>
{/* Max choices (multiple only) */}
{formVoteType === "multiple" && (
<div>
<Label className="mb-1.5 block text-sm font-medium"></Label>
<Input
type="number"
min={2}
max={formOptions.filter((o) => o.trim()).length || 2}
value={formMaxChoices}
onChange={(e) => setFormMaxChoices(parseInt(e.target.value) || 2)}
className="w-24"
/>
</div>
)}
{/* Anonymous */}
<div className="flex items-center gap-3">
<Switch
id="anonymous"
checked={formAnonymous}
onCheckedChange={setFormAnonymous}
/>
<Label htmlFor="anonymous" className="cursor-pointer"></Label>
</div>
{/* Deadline */}
<div>
<Label className="mb-1.5 block text-sm font-medium"></Label>
<Input
type="datetime-local"
value={formDeadline}
onChange={(e) => setFormDeadline(e.target.value)}
/>
</div>
{/* Options */}
<div>
<Label className="mb-1.5 block text-sm font-medium"> *</Label>
<div className="space-y-2">
{formOptions.map((opt, i) => (
<div key={i} className="flex gap-2">
<Input
placeholder={`选项 ${i + 1}`}
value={opt}
onChange={(e) => updateOption(i, e.target.value)}
/>
{formOptions.length > 2 && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-500 shrink-0"
onClick={() => removeOption(i)}
>
</Button>
)}
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={addOption}
>
+
</Button>
</div>
{/* Submit */}
<Button onClick={handleCreate} disabled={submitting} className="w-full">
{submitting ? "创建中..." : "创建投票"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{/* Loading */}
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="p-6">
<div className="h-24 bg-gray-200 rounded" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<ErrorState message={error} onRetry={loadVotes} />
) : votes.length === 0 ? (
<div className="text-center py-12 text-gray-400"></div>
) : (
<div className="space-y-4">
{votes.map((vote) => (
<Card
key={vote.id}
className="cursor-pointer hover:shadow-md transition-shadow"
onClick={() => openDetail(vote.id)}
>
<CardContent className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{renderStatusBadge(vote.status)}
{vote.is_anonymous && (
<Badge variant="outline" className="text-xs"></Badge>
)}
<Badge variant="outline" className="text-xs">
{vote.vote_type === "single" ? "单选" : "多选"}
</Badge>
<h3 className="text-lg font-semibold truncate">{vote.title}</h3>
</div>
{vote.description && (
<p className="mt-2 text-gray-600 text-sm line-clamp-2">{vote.description}</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm text-gray-400 flex-wrap">
<span>{vote.creator_name}</span>
<span>{formatDeadline(vote.deadline)}</span>
<span>{vote.total_voters} </span>
</div>
</div>
<div className="shrink-0">
{vote.has_voted ? (
<Badge className="bg-gray-100 text-gray-500 text-xs"></Badge>
) : vote.status === "open" ? (
<Badge className="bg-blue-500 text-white text-xs"></Badge>
) : null}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
{/* Detail dialog */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="py-8 text-center text-gray-400">...</div>
) : detailVote ? (
<div className="space-y-5 pt-2">
{/* Header info */}
<div>
<div className="flex items-center gap-2 flex-wrap mb-2">
{renderStatusBadge(detailVote.status)}
{detailVote.is_anonymous && (
<Badge variant="outline" className="text-xs"></Badge>
)}
<Badge variant="outline" className="text-xs">
{detailVote.vote_type === "single" ? "单选" : `多选(最多${detailVote.max_choices}项)`}
</Badge>
</div>
<h3 className="text-lg font-semibold">{detailVote.title}</h3>
{detailVote.description && (
<p className="mt-1 text-gray-600 text-sm">{detailVote.description}</p>
)}
<div className="mt-2 flex items-center gap-4 text-sm text-gray-400 flex-wrap">
<span>: {detailVote.creator_name}</span>
<span>: {formatDeadline(detailVote.deadline)}</span>
<span>{detailVote.total_voters} </span>
</div>
</div>
{/* Options: vote or results */}
{detailVote.status === "open" && !detailVote.has_voted ? (
/* Voting form */
<div className="space-y-3">
<p className="text-sm font-medium">
{detailVote.vote_type === "single" ? "请选择一个选项:" : `请选择选项(最多${detailVote.max_choices}项):`}
</p>
{detailVote.options.map((option) => (
<label
key={option.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedOptions.includes(option.id)
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
>
<input
type={detailVote.vote_type === "single" ? "radio" : "checkbox"}
name="vote-option"
className="shrink-0"
checked={selectedOptions.includes(option.id)}
onChange={() => {
if (detailVote.vote_type === "single") {
setSelectedOptions([option.id]);
} else {
setSelectedOptions((prev) =>
prev.includes(option.id)
? prev.filter((id) => id !== option.id)
: prev.length < detailVote.max_choices
? [...prev, option.id]
: (toast.error(`最多只能选择${detailVote.max_choices}`), prev)
);
}
}}
/>
<span className="text-sm">{option.content}</span>
</label>
))}
<Button
onClick={handleSubmitVote}
disabled={submittingVote || selectedOptions.length === 0}
className="w-full"
>
{submittingVote ? "提交中..." : "提交投票"}
</Button>
</div>
) : (
/* Results */
<div className="space-y-3">
{detailVote.options.map((option) => {
const pct = getPercentage(option.vote_count, detailVote.total_voters);
const isMyChoice = detailVote.my_option_ids?.includes(option.id);
return (
<div key={option.id}>
<div className="flex items-center justify-between mb-1">
<span className={`text-sm ${isMyChoice ? "font-semibold text-blue-600" : ""}`}>
{option.content}
{isMyChoice && <span className="ml-1 text-xs text-blue-500">()</span>}
</span>
<span className="text-sm text-gray-500">
{option.vote_count} ({pct}%)
</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all ${
isMyChoice ? "bg-blue-500" : "bg-gray-400"
}`}
style={{ width: `${pct}%` }}
/>
</div>
{/* Voter names */}
{!detailVote.is_anonymous &&
option.voter_names &&
option.voter_names.length > 0 && (
<p className="text-xs text-gray-400 mt-1">
{option.voter_names.join("、")}
</p>
)}
</div>
);
})}
</div>
)}
{/* Actions for creator/admin */}
{canManage(detailVote) && (
<div className="flex gap-2 pt-2 border-t">
{detailVote.status === "open" && (
<Button
variant="outline"
size="sm"
onClick={() => handleCloseVote(detailVote.id)}
>
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-red-500"
onClick={() => setDeleteTarget(detailVote.id)}
>
</Button>
</div>
)}
</div>
) : null}
</DialogContent>
</Dialog>
{/* Delete confirm */}
<Dialog open={deleteTarget !== null} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<p className="text-gray-500 text-sm"></p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => setDeleteTarget(null)}>
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => deleteTarget && handleDelete(deleteTarget)}
>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}