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

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>
);
}