494 lines
19 KiB
TypeScript
494 lines
19 KiB
TypeScript
"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>
|
||
);
|
||
}
|