157 lines
5.7 KiB
TypeScript
157 lines
5.7 KiB
TypeScript
|
|
import { useAuth } from "@/hooks/useAuth";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Card, CardContent } from "@/components/ui/card";
|
||
|
|
import { Navbar } from "@/components/Navbar";
|
||
|
|
import { BOUNTY_STATUS_MAP } from "@/const";
|
||
|
|
import { useBounties, useCreateBounty } from "@/hooks/useApi";
|
||
|
|
import { useDebounce } from "@/hooks/useDebounce";
|
||
|
|
import { useState, useMemo } from "react";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||
|
|
import { Trophy, Sparkles, Plus, Loader2 } from "lucide-react";
|
||
|
|
import BountiesHeader from "@/features/bounties/components/BountiesHeader";
|
||
|
|
import BountiesGrid from "@/features/bounties/components/BountiesGrid";
|
||
|
|
|
||
|
|
const statusMap: Record<string, { label: string; class: string }> = {
|
||
|
|
open: { label: "开放中", class: "badge-open" },
|
||
|
|
in_progress: { label: "进行中", class: "badge-in-progress" },
|
||
|
|
completed: { label: "已完成", class: "badge-completed" },
|
||
|
|
cancelled: { label: "已取消", class: "badge-cancelled" },
|
||
|
|
disputed: { label: "争议中", class: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" },
|
||
|
|
};
|
||
|
|
|
||
|
|
export default function Bounties() {
|
||
|
|
const { user, isAuthenticated } = useAuth();
|
||
|
|
const [searchQuery, setSearchQuery] = useState("");
|
||
|
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||
|
|
const [newBounty, setNewBounty] = useState({
|
||
|
|
title: "",
|
||
|
|
description: "",
|
||
|
|
reward: "",
|
||
|
|
deadline: "",
|
||
|
|
});
|
||
|
|
|
||
|
|
const { data: bountiesData, isLoading, refetch } = useBounties({
|
||
|
|
status: statusFilter === "all" ? undefined : statusFilter
|
||
|
|
});
|
||
|
|
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||
|
|
|
||
|
|
const bounties = bountiesData?.items || [];
|
||
|
|
|
||
|
|
const createBountyMutation = useCreateBounty();
|
||
|
|
|
||
|
|
const filteredBounties = useMemo(() => {
|
||
|
|
if (!bounties) return [];
|
||
|
|
const query = debouncedSearchQuery.trim().toLowerCase();
|
||
|
|
if (!query) return bounties;
|
||
|
|
return bounties.filter((b) =>
|
||
|
|
b.title.toLowerCase().includes(query) ||
|
||
|
|
b.description.toLowerCase().includes(query)
|
||
|
|
);
|
||
|
|
}, [bounties, debouncedSearchQuery]);
|
||
|
|
|
||
|
|
const handleCreateBounty = () => {
|
||
|
|
if (!newBounty.title.trim()) {
|
||
|
|
toast.error("请输入悬赏标题");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!newBounty.description.trim()) {
|
||
|
|
toast.error("请输入悬赏描述");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const rewardValue = Number(newBounty.reward);
|
||
|
|
if (!newBounty.reward || !Number.isFinite(rewardValue) || rewardValue <= 0) {
|
||
|
|
toast.error("请输入有效的赏金金额");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
createBountyMutation.mutate({
|
||
|
|
title: newBounty.title,
|
||
|
|
description: newBounty.description,
|
||
|
|
reward: rewardValue.toFixed(2),
|
||
|
|
deadline: newBounty.deadline || undefined,
|
||
|
|
}, {
|
||
|
|
onSuccess: () => {
|
||
|
|
toast.success("悬赏发布成功!");
|
||
|
|
setIsCreateOpen(false);
|
||
|
|
setNewBounty({ title: "", description: "", reward: "", deadline: "" });
|
||
|
|
refetch();
|
||
|
|
},
|
||
|
|
onError: (error: unknown) => {
|
||
|
|
const { title, description } = getErrorCopy(error, { context: "bounty.create" });
|
||
|
|
toast.error(title, { description });
|
||
|
|
},
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="min-h-screen bg-background">
|
||
|
|
<Navbar />
|
||
|
|
|
||
|
|
<BountiesHeader
|
||
|
|
searchQuery={searchQuery}
|
||
|
|
setSearchQuery={setSearchQuery}
|
||
|
|
statusFilter={statusFilter}
|
||
|
|
setStatusFilter={setStatusFilter}
|
||
|
|
isAuthenticated={isAuthenticated}
|
||
|
|
isCreateOpen={isCreateOpen}
|
||
|
|
setIsCreateOpen={setIsCreateOpen}
|
||
|
|
newBounty={newBounty}
|
||
|
|
setNewBounty={setNewBounty}
|
||
|
|
onCreate={handleCreateBounty}
|
||
|
|
isCreating={createBountyMutation.isPending}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Content */}
|
||
|
|
<section className="pb-20">
|
||
|
|
<div className="container">
|
||
|
|
{isLoading ? (
|
||
|
|
<div className="flex items-center justify-center py-20">
|
||
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||
|
|
</div>
|
||
|
|
) : filteredBounties.length === 0 ? (
|
||
|
|
<Card className="card-elegant">
|
||
|
|
<CardContent className="py-16 text-center">
|
||
|
|
<Trophy className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
|
||
|
|
<h3 className="text-xl font-semibold mb-2">暂无悬赏</h3>
|
||
|
|
<p className="text-muted-foreground mb-6">
|
||
|
|
{statusFilter === "all"
|
||
|
|
? "还没有人发布悬赏,成为第一个发布者吧!"
|
||
|
|
: "该状态下暂无悬赏"}
|
||
|
|
</p>
|
||
|
|
{isAuthenticated && (
|
||
|
|
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||
|
|
<Plus className="w-4 h-4" />
|
||
|
|
发布悬赏
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
) : (
|
||
|
|
<BountiesGrid bounties={filteredBounties} statusMap={statusMap} />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{/* Footer */}
|
||
|
|
<footer className="py-12 border-t border-border">
|
||
|
|
<div className="container">
|
||
|
|
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||
|
|
<Sparkles className="w-5 h-5 text-primary-foreground" />
|
||
|
|
</div>
|
||
|
|
<span className="font-semibold">资源聚合平台</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
© 2026 资源聚合平台. All rights reserved.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</footer>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|