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

494 lines
19 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, useCallback } from "react";
import { useSession } from "next-auth/react";
import { useApi } from "@/lib/hooks/use-api";
import { competitorApi, type Competitor, type CompetitorRecommendation, type CompetitorGapSummary, type CompetitorInsight } from "@/lib/api/competitor";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
import { CompetitorRadarChart } from "@/components/charts/CompetitorRadarChart";
import {
Users,
Plus,
Trash2,
Loader2,
Lightbulb,
BarChart3,
Play,
AlertTriangle,
} from "lucide-react";
const ANALYSIS_TYPES = [
{ value: "visibility", label: "可见度分析" },
{ value: "sentiment", label: "情感分析" },
{ value: "citation", label: "引用对比" },
{ value: "content_gap", label: "内容差距" },
];
const RADAR_COLORS = [
"hsl(346.8 77.2% 49.8%)",
"hsl(24.6 95% 53.1%)",
"hsl(142.1 76.2% 36.3%)",
"hsl(262.1 83.3% 57.8%)",
"hsl(45 93.5% 47.6%)",
];
export default function CompetitorsPage() {
const { data: session } = useSession();
const token = session?.accessToken ?? "";
const { data: brandsData, isLoading: brandsLoading } = useApi<{ items: { id: string; name: string }[] }>("/api/v1/brands/?limit=1");
const brandId = brandsData?.items?.[0]?.id ?? null;
const brandName = brandsData?.items?.[0]?.name ?? "";
const competitorsUrl = brandId ? `/api/v1/brands/${brandId}/competitors/` : null;
const { data: competitorsData, isLoading: competitorsLoading, error: competitorsError, refresh: refreshCompetitors } = useApi<{ items: Competitor[]; total: number }>(competitorsUrl);
const recommendationsUrl = brandId ? `/api/v1/brands/${brandId}/competitors/recommendations/` : null;
const { data: recommendationsData, isLoading: recommendationsLoading, error: recommendationsError, refresh: refreshRecommendations } = useApi<CompetitorRecommendation[]>(recommendationsUrl);
const gapSummaryUrl = brandId ? `/api/v1/competitor/brand/${brandId}/gap-summary` : null;
const { data: gapSummaryData, isLoading: gapSummaryLoading, error: gapSummaryError } = useApi<CompetitorGapSummary[]>(gapSummaryUrl);
const insightsUrl = brandId ? `/api/v1/competitor/brand/${brandId}` : null;
const { data: insightsData, isLoading: insightsLoading, refresh: refreshInsights } = useApi<{ items: CompetitorInsight[]; total: number }>(insightsUrl);
const competitors = competitorsData?.items ?? [];
const recommendations = recommendationsData ?? [];
const gapSummaries = gapSummaryData ?? [];
const insights = insightsData?.items ?? [];
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [manualName, setManualName] = useState("");
const [adding, setAdding] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [analysisCompetitorId, setAnalysisCompetitorId] = useState<string>("");
const [analysisType, setAnalysisType] = useState<string>("");
const [analyzing, setAnalyzing] = useState(false);
const [analysisError, setAnalysisError] = useState<string | null>(null);
const handleAddFromRecommendation = useCallback(async (name: string) => {
if (!token || !brandId) return;
setAdding(true);
try {
await competitorApi.add(token, brandId, { name });
refreshCompetitors();
} catch {
} finally {
setAdding(false);
}
}, [token, brandId, refreshCompetitors]);
const handleAddManual = useCallback(async () => {
const trimmed = manualName.trim();
if (!trimmed || !token || !brandId) return;
setAdding(true);
try {
await competitorApi.add(token, brandId, { name: trimmed });
setManualName("");
refreshCompetitors();
setAddDialogOpen(false);
} catch {
} finally {
setAdding(false);
}
}, [manualName, token, brandId, refreshCompetitors]);
const handleDelete = useCallback(async (competitorId: string) => {
if (!token || !brandId) return;
setDeletingId(competitorId);
try {
await competitorApi.delete(token, brandId, competitorId);
refreshCompetitors();
} catch {
} finally {
setDeletingId(null);
}
}, [token, brandId, refreshCompetitors]);
const handleAnalyze = useCallback(async () => {
if (!token || !brandId || !analysisCompetitorId || !analysisType) return;
setAnalyzing(true);
setAnalysisError(null);
try {
await competitorApi.analyze(token, {
brand_id: brandId,
competitor_id: analysisCompetitorId,
analysis_type: analysisType,
});
refreshInsights();
} catch (err) {
setAnalysisError(err instanceof Error ? err.message : "分析失败");
} finally {
setAnalyzing(false);
}
}, [token, brandId, analysisCompetitorId, analysisType, refreshInsights]);
const radarData = (() => {
if (!gapSummaries.length) return [];
const dimensions = Object.keys(gapSummaries[0]?.dimensions || {});
return dimensions.map((dim) => {
const item: { label: string; [key: string]: string | number | undefined } = { label: dim, [brandName]: 50 };
gapSummaries.forEach((gs) => {
const val = gs.dimensions[dim];
item[gs.competitor_name] = typeof val === "number" ? val : 0;
});
return item;
});
})();
const radarCompetitors = gapSummaries.map((gs, i) => ({
name: gs.competitor_name,
color: RADAR_COLORS[i % RADAR_COLORS.length],
}));
if (brandsLoading) {
return (
<div className="space-y-4">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<LoadingState rows={4} rowHeight="h-24" />
</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>
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogTrigger asChild>
<Button disabled={competitors.length >= 5}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<Tabs defaultValue="recommend">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="recommend"></TabsTrigger>
<TabsTrigger value="manual"></TabsTrigger>
</TabsList>
<TabsContent value="recommend" className="mt-4 space-y-3">
{recommendationsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">...</span>
</div>
) : recommendationsError ? (
<ErrorState error={recommendationsError} onRetry={refreshRecommendations} />
) : recommendations.length === 0 ? (
<EmptyState message="暂无推荐竞品" description="请尝试手动输入竞品名称" />
) : (
<div className="grid gap-2">
{recommendations.map((rec) => {
const alreadyAdded = competitors.some((c) => c.name === rec.name);
return (
<div
key={rec.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{rec.name}</p>
<p className="text-xs text-muted-foreground truncate">{rec.reason}</p>
</div>
<Button
size="sm"
variant={alreadyAdded ? "ghost" : "outline"}
disabled={alreadyAdded || adding}
onClick={() => handleAddFromRecommendation(rec.name)}
>
{alreadyAdded ? "已添加" : <Plus className="h-4 w-4" />}
</Button>
</div>
);
})}
</div>
)}
</TabsContent>
<TabsContent value="manual" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="competitor-name"></Label>
<div className="flex gap-2">
<Input
id="competitor-name"
value={manualName}
onChange={(e) => setManualName(e.target.value)}
placeholder="输入竞品名称"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddManual();
}
}}
/>
<Button onClick={handleAddManual} disabled={!manualName.trim() || adding}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : "添加"}
</Button>
</div>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4" />
<Badge variant="secondary" className="ml-auto">
{competitors.length}/5
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{competitorsLoading ? (
<LoadingState rows={3} rowHeight="h-16" />
) : competitorsError ? (
<ErrorState error={competitorsError} onRetry={refreshCompetitors} />
) : competitors.length === 0 ? (
<EmptyState
message="暂无竞品"
description="点击上方「添加竞品」按钮开始"
icon={<Users className="h-6 w-6 text-gray-400" />}
/>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{competitors.map((comp) => (
<div
key={comp.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{comp.name}</p>
<p className="text-xs text-muted-foreground">
{new Date(comp.created_at).toLocaleDateString("zh-CN")}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="ml-2 h-8 w-8 text-muted-foreground hover:text-destructive"
disabled={deletingId === comp.id}
onClick={() => handleDelete(comp.id)}
>
{deletingId === comp.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label></Label>
<Select value={analysisCompetitorId} onValueChange={setAnalysisCompetitorId}>
<SelectTrigger>
<SelectValue placeholder="选择竞品" />
</SelectTrigger>
<SelectContent>
{competitors.map((comp) => (
<SelectItem key={comp.id} value={comp.id}>
{comp.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={analysisType} onValueChange={setAnalysisType}>
<SelectTrigger>
<SelectValue placeholder="选择分析类型" />
</SelectTrigger>
<SelectContent>
{ANALYSIS_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button
className="w-full"
disabled={!analysisCompetitorId || !analysisType || analyzing}
onClick={handleAnalyze}
>
{analyzing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Play className="mr-2 h-4 w-4" />
)}
</Button>
</div>
</div>
{analysisError && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
{analysisError}
</div>
)}
{insightsLoading ? (
<LoadingState rows={2} rowHeight="h-20" />
) : insights.length > 0 ? (
<div className="space-y-3">
{insights.map((insight) => (
<div key={insight.id} className="rounded-lg border p-4 space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline">{insight.insight_type}</Badge>
{insight.competitor_name && (
<Badge variant="secondary">{insight.competitor_name}</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground">
{new Date(insight.created_at).toLocaleString("zh-CN")}
</span>
</div>
{insight.recommendations && insight.recommendations.length > 0 && (
<div className="space-y-1">
{insight.recommendations.map((rec, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<Lightbulb className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<span>{rec}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
) : null}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
{gapSummaryLoading ? (
<LoadingState rows={1} rowHeight="h-80" />
) : gapSummaryError ? (
<ErrorState error={gapSummaryError} />
) : radarData.length > 0 ? (
<CompetitorRadarChart
data={radarData}
brandName={brandName}
competitors={radarCompetitors}
/>
) : (
<EmptyState
message="暂无对比数据"
description="添加竞品并执行分析后将显示雷达图"
icon={<BarChart3 className="h-6 w-6 text-gray-400" />}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent>
{gapSummaryLoading ? (
<LoadingState rows={3} rowHeight="h-20" grid cols={2} />
) : gapSummaryError ? (
<ErrorState error={gapSummaryError} />
) : gapSummaries.length === 0 ? (
<EmptyState
message="暂无差距评分"
description="执行分析后将显示差距评分"
icon={<AlertTriangle className="h-6 w-6 text-gray-400" />}
/>
) : (
<div className="space-y-3">
{gapSummaries.map((gs) => (
<div key={gs.competitor_name} className="rounded-lg border p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium">{gs.competitor_name}</span>
<Badge
variant={gs.gap_score > 50 ? "destructive" : gs.gap_score > 20 ? "secondary" : "default"}
>
{gs.gap_score.toFixed(1)}
</Badge>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(100, gs.gap_score)}%` }}
/>
</div>
<div className="flex flex-wrap gap-2">
{Object.entries(gs.dimensions).map(([key, val]) => (
<div key={key} className="text-xs text-muted-foreground">
{key}: <span className="font-medium text-foreground">{typeof val === "number" ? val.toFixed(1) : String(val)}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}