403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useSession } from "next-auth/react";
|
|
import { useApi } from "@/lib/hooks/use-api";
|
|
import { trendsApi, type TrendInsight, type TrendSummary } from "@/lib/api/trends";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
|
|
import { TrendingUp, TrendingDown, RefreshCw, Eye, Clock, BarChart3, Loader2 } from "lucide-react";
|
|
|
|
interface BrandsResponse {
|
|
items: { id: string; name: string }[];
|
|
}
|
|
|
|
function TrendDirectionIcon({ direction }: { direction: string }) {
|
|
if (direction === "up" || direction === "rising") {
|
|
return <TrendingUp className="h-5 w-5 text-emerald-600" />;
|
|
}
|
|
if (direction === "down" || direction === "declining") {
|
|
return <TrendingDown className="h-5 w-5 text-red-500" />;
|
|
}
|
|
return <BarChart3 className="h-5 w-5 text-amber-500" />;
|
|
}
|
|
|
|
function TrendDirectionBadge({ direction }: { direction: string }) {
|
|
const config: Record<string, { variant: "default" | "destructive" | "secondary" | "outline"; label: string }> = {
|
|
up: { variant: "default", label: "上升" },
|
|
rising: { variant: "default", label: "上升" },
|
|
down: { variant: "destructive", label: "下降" },
|
|
declining: { variant: "destructive", label: "下降" },
|
|
stable: { variant: "secondary", label: "平稳" },
|
|
flat: { variant: "secondary", label: "平稳" },
|
|
};
|
|
const c = config[direction] || { variant: "outline" as const, label: direction };
|
|
return <Badge variant={c.variant} className="text-xs">{c.label}</Badge>;
|
|
}
|
|
|
|
function InsightTypeBadge({ type }: { type: string }) {
|
|
const typeMap: Record<string, string> = {
|
|
keyword_trend: "关键词趋势",
|
|
sentiment: "情感分析",
|
|
platform_comparison: "平台对比",
|
|
competitor_movement: "竞品动态",
|
|
content_gap: "内容缺口",
|
|
};
|
|
return <Badge variant="outline" className="text-xs">{typeMap[type] || type}</Badge>;
|
|
}
|
|
|
|
function JsonViewer({ data }: { data: Record<string, unknown> }) {
|
|
return (
|
|
<pre className="rounded-lg border bg-muted/50 p-4 text-xs leading-relaxed overflow-auto max-h-[400px]">
|
|
{JSON.stringify(data, null, 2)}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
export default function TrendsPage() {
|
|
const { data: session } = useSession();
|
|
const token = (session as { accessToken?: string })?.accessToken;
|
|
|
|
const { data: brandsData } = useApi<BrandsResponse>("/api/v1/brands/");
|
|
const brandId = brandsData?.items?.[0]?.id ?? null;
|
|
|
|
const [insights, setInsights] = useState<TrendInsight[]>([]);
|
|
const [insightsLoading, setInsightsLoading] = useState(true);
|
|
const [insightsError, setInsightsError] = useState<Error | null>(null);
|
|
|
|
const [summary, setSummary] = useState<TrendSummary | null>(null);
|
|
const [summaryLoading, setSummaryLoading] = useState(true);
|
|
const [summaryError, setSummaryError] = useState<Error | null>(null);
|
|
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [selectedInsight, setSelectedInsight] = useState<TrendInsight | null>(null);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [periodDays, setPeriodDays] = useState<string>("7");
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!token || !brandId) return;
|
|
|
|
setInsightsLoading(true);
|
|
setInsightsError(null);
|
|
trendsApi
|
|
.getBrandInsights(token, brandId)
|
|
.then((res) => setInsights(res.items ?? []))
|
|
.catch((err) => setInsightsError(err instanceof Error ? err : new Error(String(err))))
|
|
.finally(() => setInsightsLoading(false));
|
|
|
|
setSummaryLoading(true);
|
|
setSummaryError(null);
|
|
trendsApi
|
|
.getSummary(token, brandId)
|
|
.then((res) => setSummary(res))
|
|
.catch((err) => setSummaryError(err instanceof Error ? err : new Error(String(err))))
|
|
.finally(() => setSummaryLoading(false));
|
|
}, [token, brandId]);
|
|
|
|
async function handleRefresh() {
|
|
if (!token || !brandId) return;
|
|
|
|
setInsightsLoading(true);
|
|
setInsightsError(null);
|
|
trendsApi
|
|
.getBrandInsights(token, brandId)
|
|
.then((res) => setInsights(res.items ?? []))
|
|
.catch((err) => setInsightsError(err instanceof Error ? err : new Error(String(err))))
|
|
.finally(() => setInsightsLoading(false));
|
|
|
|
setSummaryLoading(true);
|
|
setSummaryError(null);
|
|
trendsApi
|
|
.getSummary(token, brandId)
|
|
.then((res) => setSummary(res))
|
|
.catch((err) => setSummaryError(err instanceof Error ? err : new Error(String(err))))
|
|
.finally(() => setSummaryLoading(false));
|
|
}
|
|
|
|
async function handleViewDetail(insightId: string) {
|
|
if (!token) return;
|
|
setDetailLoading(true);
|
|
setSelectedInsight(null);
|
|
setDetailOpen(true);
|
|
try {
|
|
const result = await trendsApi.getInsight(token, insightId);
|
|
setSelectedInsight(result);
|
|
} catch {
|
|
setSelectedInsight(null);
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleCreateInsight() {
|
|
if (!token || !brandId) return;
|
|
setCreating(true);
|
|
try {
|
|
await trendsApi.createInsight(token, {
|
|
brand_id: brandId,
|
|
period_days: Number(periodDays),
|
|
});
|
|
setCreateOpen(false);
|
|
handleRefresh();
|
|
} catch {
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}
|
|
|
|
if (!token || !brandId) {
|
|
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 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-bold tracking-tight">趋势洞察</h2>
|
|
<p className="text-muted-foreground">分析品牌趋势变化与热点关键词</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={insightsLoading}>
|
|
<RefreshCw className={`mr-2 h-4 w-4 ${insightsLoading ? "animate-spin" : ""}`} />
|
|
刷新
|
|
</Button>
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
<TrendingUp className="mr-2 h-4 w-4" />
|
|
生成洞察
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{summaryError ? (
|
|
<ErrorState error={summaryError} onRetry={handleRefresh} />
|
|
) : summaryLoading ? (
|
|
<LoadingState rows={1} rowHeight="h-32" />
|
|
) : summary ? (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<BarChart3 className="h-4 w-4" />
|
|
趋势概览
|
|
</CardTitle>
|
|
<Badge variant="outline" className="text-xs">
|
|
近 {summary.period_days} 天
|
|
</Badge>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-8">
|
|
<div className="flex items-center gap-3">
|
|
<TrendDirectionIcon direction={summary.trend_direction} />
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">趋势方向</p>
|
|
<TrendDirectionBadge direction={summary.trend_direction} />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-sm text-muted-foreground mb-2">热点关键词</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{summary.hotspot_keywords?.length > 0 ? (
|
|
summary.hotspot_keywords.map((kw) => (
|
|
<Badge key={kw} variant="secondary" className="text-xs">
|
|
{kw}
|
|
</Badge>
|
|
))
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">暂无热点关键词</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
{insightsError ? (
|
|
<ErrorState error={insightsError} onRetry={handleRefresh} />
|
|
) : insightsLoading ? (
|
|
<LoadingState rows={5} rowHeight="h-12" />
|
|
) : insights.length === 0 ? (
|
|
<EmptyState
|
|
icon={<TrendingUp className="h-6 w-6 text-muted-foreground" />}
|
|
message="暂无洞察记录"
|
|
description="点击「生成洞察」按钮创建趋势洞察分析"
|
|
action={
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
<TrendingUp className="mr-2 h-4 w-4" />
|
|
生成洞察
|
|
</Button>
|
|
}
|
|
/>
|
|
) : (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">洞察记录</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>洞察类型</TableHead>
|
|
<TableHead>分析周期</TableHead>
|
|
<TableHead>创建时间</TableHead>
|
|
<TableHead className="text-right">操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{insights.map((insight) => (
|
|
<TableRow key={insight.id}>
|
|
<TableCell>
|
|
<InsightTypeBadge type={insight.insight_type} />
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">
|
|
{insight.period_start && insight.period_end ? (
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{new Date(insight.period_start).toLocaleDateString("zh-CN")} ~ {new Date(insight.period_end).toLocaleDateString("zh-CN")}
|
|
</span>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">
|
|
{new Date(insight.created_at).toLocaleString("zh-CN")}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs"
|
|
onClick={() => handleViewDetail(insight.id)}
|
|
>
|
|
<Eye className="mr-1 h-3.5 w-3.5" />
|
|
查看详情
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Eye className="h-5 w-5" />
|
|
洞察详情
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
{detailLoading ? (
|
|
<LoadingState rows={3} rowHeight="h-16" />
|
|
) : selectedInsight ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<InsightTypeBadge type={selectedInsight.insight_type} />
|
|
{selectedInsight.period_start && selectedInsight.period_end && (
|
|
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
{new Date(selectedInsight.period_start).toLocaleDateString("zh-CN")} ~ {new Date(selectedInsight.period_end).toLocaleDateString("zh-CN")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-muted-foreground">洞察数据</p>
|
|
<JsonViewer data={selectedInsight.data} />
|
|
</div>
|
|
|
|
{selectedInsight.recommendations && selectedInsight.recommendations.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-muted-foreground">优化建议</p>
|
|
<ul className="space-y-1.5">
|
|
{selectedInsight.recommendations.map((rec, i) => (
|
|
<li key={i} className="flex items-start gap-2 text-sm">
|
|
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" />
|
|
<span>{rec}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="py-8 text-center text-muted-foreground">加载洞察详情失败</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5" />
|
|
生成趋势洞察
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">分析周期</p>
|
|
<Select value={periodDays} onValueChange={setPeriodDays}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="选择分析周期" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="7">近 7 天</SelectItem>
|
|
<SelectItem value="14">近 14 天</SelectItem>
|
|
<SelectItem value="30">近 30 天</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={creating}>
|
|
取消
|
|
</Button>
|
|
<Button onClick={handleCreateInsight} disabled={creating}>
|
|
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
生成
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|