625 lines
23 KiB
TypeScript
625 lines
23 KiB
TypeScript
"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>
|
||
);
|
||
}
|