geo/frontend/app/(dashboard)/brands/page.tsx

296 lines
9.6 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 { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
BrandFormDialog,
AddBrandButton,
} from "@/components/brand/BrandFormDialog";
import { api } from "@/lib/api";
import { PLATFORM_MAP } from "@/lib/platforms";
import { useNotificationStore, useBrandStore } from "@/lib/stores";
import type { BrandListItem, BrandListResponse } from "@/types/brand";
import { Search, Star, Calendar, Edit, Trash2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useApi } from "@/lib/hooks/use-api";
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
export default function BrandsPage() {
const { data: session } = useSession();
const router = useRouter();
const [deleteBrand, setDeleteBrand] = useState<BrandListItem | null>(null);
const [deleting, setDeleting] = useState(false);
const { data: brandsResponse, isLoading: loading, error: apiError, refresh: fetchBrands } =
useApi<BrandListResponse>("/api/v1/brands/");
const brands: BrandListItem[] = brandsResponse?.items ?? [];
const error = apiError?.message ?? null;
// 同步 SWR 数据到 brand-store
const syncFromSWR = useBrandStore((s) => s.syncFromSWR);
useEffect(() => {
if (brands.length > 0) {
syncFromSWR(brands);
}
}, [brands, syncFromSWR]);
const addNotification = useNotificationStore((s) => s.addNotification);
const optimisticDelete = useBrandStore((s) => s.optimisticDelete);
const handleDelete = async () => {
if (!deleteBrand || !session?.accessToken) return;
try {
setDeleting(true);
const success = await optimisticDelete(
session.accessToken,
deleteBrand.id,
fetchBrands
);
if (success) {
setDeleteBrand(null);
}
} catch (err) {
addNotification({
type: "error",
message: err instanceof Error ? err.message : "删除失败",
});
} finally {
setDeleting(false);
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return "暂无";
const date = new Date(dateString);
return date.toLocaleDateString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
};
const getFrequencyLabel = (frequency: string) => {
const labels: Record<string, string> = {
daily: "每日",
weekly: "每周",
monthly: "每月",
};
return labels[frequency] || frequency;
};
const getStatusBadge = (status: string) => {
switch (status) {
case "active":
return <Badge variant="default"></Badge>;
case "inactive":
return <Badge variant="secondary"></Badge>;
case "pending":
return <Badge variant="outline"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
</div>
<LoadingState rows={3} grid cols={3} rowHeight="h-40" />
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
</div>
<ErrorState error={error} onRetry={fetchBrands} />
</div>
);
}
if (brands.length === 0) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
</div>
<EmptyState
icon={<Star className="h-6 w-6 text-primary" />}
message="暂无品牌"
description="添加您的第一个品牌开始监控其在AI搜索中的表现"
action={<AddBrandButton onSuccess={fetchBrands} />}
/>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<AddBrandButton onSuccess={fetchBrands} />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{brands.map((brand) => (
<Card
key={brand.id}
className="cursor-pointer transition-shadow hover:shadow-md"
onClick={() => router.push(`/brands/${brand.id}`)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-lg font-semibold">
{brand.name}
</CardTitle>
{getStatusBadge(brand.status)}
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* 评分 */}
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-amber-500" />
<span className="text-sm">
<span className="font-semibold text-lg">
{brand.score ?? "-"}
</span>
{brand.score !== null && (
<span className="text-muted-foreground"> </span>
)}
</span>
</div>
{/* 平台 */}
<div className="flex flex-wrap gap-1">
{brand.platforms.map((platform) => (
<Badge
key={platform}
variant="secondary"
className="text-xs"
>
{PLATFORM_MAP[platform] || platform}
</Badge>
))}
</div>
{/* 查询频率 */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>{getFrequencyLabel(brand.frequency)}</span>
</div>
{/* 上次查询 */}
<div className="text-xs text-muted-foreground">
: {formatDate(brand.last_queried_at)}
</div>
{/* 操作按钮 */}
<div
className="flex gap-2 pt-2"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="outline"
size="sm"
asChild
className="flex-1"
>
<Link href={`/brands/${brand.id}`}>
<Search className="mr-1 h-3 w-3" />
</Link>
</Button>
<BrandFormDialog
editBrand={{
id: brand.id,
name: brand.name,
aliases: brand.aliases,
website: null,
industry: null,
platforms: brand.platforms,
frequency: brand.frequency,
}}
onSuccess={fetchBrands}
trigger={
<Button variant="outline" size="icon" className="h-9 w-9">
<Edit className="h-4 w-4" />
</Button>
}
/>
<Button
variant="outline"
size="icon"
className="h-9 w-9 text-destructive hover:text-destructive"
onClick={() => setDeleteBrand(brand)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* 删除确认对话框 */}
<Dialog open={!!deleteBrand} onOpenChange={() => setDeleteBrand(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
&ldquo;{deleteBrand?.name}&rdquo;
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteBrand(null)}
disabled={deleting}
>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? "删除中..." : "确认删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}