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

589 lines
22 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 } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
FileText,
Eye,
MessageCircle,
Quote,
TrendingUp,
Lightbulb,
AlertTriangle,
Check,
BarChart3,
} from "lucide-react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Line,
ComposedChart,
Legend,
} from "recharts";
import {
analyticsApi,
type OverviewStatsResponse,
type TopContentItem,
type InsightResponse,
} from "@/lib/api";
import { useApi } from "@/lib/hooks/use-api";
import { LoadingState, ErrorState } from "@/components/ui/api-states";
// ─── Types ───────────────────────────────────────────────────────────────────
interface InsightItem {
type: "opportunity" | "suggestion" | "anomaly";
icon: string;
title: string;
description: string;
recommendation: string;
severity: "success" | "info" | "warning";
id: string;
applied: boolean;
}
// ─── Static Options ───────────────────────────────────────────────────────────
const platformFilterOptions = [
{ key: "wechat", label: "微信" },
{ key: "zhihu", label: "知乎" },
{ key: "xiaohongshu", label: "小红书" },
{ key: "douyin", label: "抖音" },
{ key: "baijiahao", label: "百家号" },
];
const timeRangeOptions = [
{ value: "7", label: "最近7天" },
{ value: "30", label: "最近30天" },
{ value: "90", label: "最近90天" },
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function mapInsightSeverity(severity: string): "success" | "info" | "warning" {
if (severity === "high") return "warning";
if (severity === "low") return "success";
return "info";
}
function mapInsightType(insightType: string): "opportunity" | "suggestion" | "anomaly" {
if (insightType === "anomaly") return "anomaly";
if (insightType === "opportunity") return "opportunity";
return "suggestion";
}
function mapInsightIcon(insightType: string): string {
if (insightType === "anomaly") return "AlertTriangle";
if (insightType === "opportunity") return "TrendingUp";
return "Lightbulb";
}
function mapApiInsights(apiInsights: InsightResponse[]): InsightItem[] {
return apiInsights.map((ins) => ({
id: ins.id,
type: mapInsightType(ins.insight_type),
icon: mapInsightIcon(ins.insight_type),
title: ins.title,
description: ins.description,
recommendation: ins.recommendation,
severity: mapInsightSeverity(ins.severity),
applied: ins.applied,
}));
}
function getSeverityStyles(severity: InsightItem["severity"]) {
switch (severity) {
case "success":
return {
iconBg: "bg-emerald-50",
iconColor: "text-emerald-500",
border: "border-emerald-200",
};
case "info":
return {
iconBg: "bg-blue-50",
iconColor: "text-blue-500",
border: "border-blue-200",
};
case "warning":
return {
iconBg: "bg-amber-50",
iconColor: "text-amber-500",
border: "border-amber-200",
};
}
}
function InsightIcon({ name, className }: { name: string; className?: string }) {
switch (name) {
case "TrendingUp":
return <TrendingUp className={className} />;
case "Lightbulb":
return <Lightbulb className={className} />;
case "AlertTriangle":
return <AlertTriangle className={className} />;
default:
return <BarChart3 className={className} />;
}
}
function getPlatformBadgeClass(platform: string) {
const map: Record<string, string> = {
: "bg-green-50 text-green-700 border-green-200",
: "bg-blue-50 text-blue-700 border-blue-200",
: "bg-red-50 text-red-700 border-red-200",
: "bg-slate-50 text-slate-700 border-slate-200",
: "bg-orange-50 text-orange-700 border-orange-200",
wechat: "bg-green-50 text-green-700 border-green-200",
zhihu: "bg-blue-50 text-blue-700 border-blue-200",
xiaohongshu: "bg-red-50 text-red-700 border-red-200",
douyin: "bg-slate-50 text-slate-700 border-slate-200",
baijiahao: "bg-orange-50 text-orange-700 border-orange-200",
};
return map[platform] ?? "bg-gray-50 text-gray-600 border-gray-200";
}
// ─── Sub-components ──────────────────────────────────────────────────────────
function MetricCard({
label,
value,
icon,
iconBg,
highlight,
subtext,
}: {
label: string;
value: string | number;
icon: React.ReactNode;
iconBg: string;
highlight?: boolean;
subtext?: string;
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-gray-500">{label}</p>
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${iconBg}`}>
{icon}
</div>
</div>
<p
className="text-3xl font-bold text-gray-900 leading-none"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{typeof value === "number" ? value.toLocaleString() : value}
</p>
{subtext && <p className="mt-1.5 text-xs text-emerald-600 font-medium">{subtext}</p>}
</div>
);
}
function EmptyTopContent() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-10 w-10 text-gray-300 mb-3" />
<p className="text-sm text-gray-500"></p>
<p className="text-xs text-gray-400 mt-1"></p>
</div>
);
}
function EmptyInsights() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Lightbulb className="h-10 w-10 text-gray-300 mb-3" />
<p className="text-sm text-gray-500">AI洞察</p>
<p className="text-xs text-gray-400 mt-1">AI将为您生成优化建议</p>
</div>
);
}
// ─── Main Page ───────────────────────────────────────────────────────────────
export default function AnalyticsPage() {
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
const [timeRange, setTimeRange] = useState("30");
const [appliedInsights, setAppliedInsights] = useState<Set<string>>(new Set());
// SWR 数据获取
const {
data: overview,
isLoading: overviewLoading,
error: overviewError,
refresh: refreshOverview,
} = useApi<OverviewStatsResponse>("/api/v1/analytics/overview");
const {
data: topContentData,
isLoading: topLoading,
error: topError,
refresh: refreshTop,
} = useApi<{ items: TopContentItem[]; sort_by: string; total: number }>("/api/v1/analytics/top?limit=5");
const {
data: insightsData,
isLoading: insightsLoading,
error: insightsError,
refresh: refreshInsights,
} = useApi<InsightResponse[]>("/api/v1/analytics/insights?limit=6");
const loading = overviewLoading || topLoading || insightsLoading;
// "用户未关联组织" 类错误视为空状态
const isOrgError = (err: Error | undefined) =>
err?.message.includes("未关联组织") || err?.message.includes("No organization");
const hasOrgError = isOrgError(overviewError) || isOrgError(topError) || isOrgError(insightsError);
const error = !hasOrgError && (overviewError || topError || insightsError)
? overviewError || topError || insightsError
: undefined;
const topContent: TopContentItem[] = topContentData?.items ?? [];
const rawInsights: InsightResponse[] = insightsData ?? [];
const insights = mapApiInsights(rawInsights).map((ins) =>
appliedInsights.has(ins.id) ? { ...ins, applied: true } : ins
);
const handleRetry = () => {
refreshOverview();
refreshTop();
refreshInsights();
};
const togglePlatform = (key: string) => {
setSelectedPlatforms((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
);
};
const handleApplyInsight = async (insightId: string) => {
try {
await analyticsApi.applyInsight(undefined, insightId);
setAppliedInsights((prev) => new Set(prev).add(insightId));
} catch (err) {
console.error("Apply insight error:", err);
}
};
if (loading) {
return (
<div className="space-y-6">
<LoadingState rows={1} rowHeight="h-8" />
<LoadingState rows={4} grid cols={4} rowHeight="h-32" />
<LoadingState rows={1} rowHeight="h-72" />
<LoadingState rows={1} rowHeight="h-64" />
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<ErrorState error={error} onRetry={handleRetry} />
</div>
);
}
const platformDistribution = overview?.platform_distribution ?? {};
const trendData = Object.entries(platformDistribution).map(([key, count]) => ({
date: key,
views: count,
interactions: Math.floor(count * 0.08),
}));
return (
<div className="space-y-6">
{/* Top Area */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">
</h1>
<p className="mt-1 text-sm text-gray-500">
AI引用洞察
</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-geo-text-secondary mr-1">:</span>
{platformFilterOptions.map((opt) => {
const selected = selectedPlatforms.includes(opt.key);
return (
<button
key={opt.key}
onClick={() => togglePlatform(opt.key)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 border ${
selected
? "bg-primary text-primary-foreground border-primary"
: "bg-white text-geo-text-secondary border-geo-border hover:border-primary/30"
}`}
>
{opt.label}
</button>
);
})}
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-geo-text-secondary mr-1">:</span>
{timeRangeOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setTimeRange(opt.value)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 ${
timeRange === opt.value
? "bg-primary text-primary-foreground"
: "bg-white text-geo-text-secondary border border-geo-border hover:border-primary/30"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Overview Metrics */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
label="总发布"
value={overview?.total_published ?? 0}
icon={<FileText className="h-4 w-4 text-blue-500" />}
iconBg="bg-blue-50"
/>
<MetricCard
label="总曝光"
value={overview?.total_views ?? 0}
icon={<Eye className="h-4 w-4 text-purple-500" />}
iconBg="bg-purple-50"
/>
<MetricCard
label="总互动"
value={overview?.total_interactions ?? 0}
icon={<MessageCircle className="h-4 w-4 text-amber-500" />}
iconBg="bg-amber-50"
/>
<MetricCard
label="AI引用数"
value={overview?.total_ai_citations ?? 0}
icon={<Quote className="h-4 w-4 text-emerald-500" />}
iconBg="bg-emerald-50"
subtext={
overview?.avg_engagement_rate
? `互动率 ${(overview.avg_engagement_rate * 100).toFixed(1)}%`
: undefined
}
/>
</div>
{/* Trend Chart */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-500"></h3>
<Badge variant="outline" className="text-xs">
</Badge>
</div>
{trendData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={trendData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E5E7EB" />
<XAxis dataKey="date" tick={{ fontSize: 12, fill: "#6B7280" }} axisLine={false} tickLine={false} />
<YAxis
yAxisId="left"
tick={{ fontSize: 12, fill: "#6B7280" }}
axisLine={false}
tickLine={false}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12, fill: "#6B7280" }}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "#FFFFFF",
border: "1px solid #E5E7EB",
borderRadius: "12px",
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12, paddingTop: 8 }} iconType="circle" iconSize={8} />
<Bar yAxisId="left" dataKey="views" name="发布数" fill="#10B981" radius={[4, 4, 0, 0]} opacity={0.85} />
<Line
yAxisId="right"
type="monotone"
dataKey="interactions"
name="互动量"
stroke="#F59E0B"
strokeWidth={2}
dot={{ r: 3, fill: "#F59E0B" }}
activeDot={{ r: 5 }}
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="flex flex-col items-center justify-center h-48 text-center">
<BarChart3 className="h-10 w-10 text-gray-300 mb-3" />
<p className="text-sm text-gray-500"></p>
</div>
)}
</div>
{/* Performance Table */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-500"></h3>
<Badge variant="outline" className="text-xs">
Top {topContent.length}
</Badge>
</div>
{topContent.length === 0 ? (
<EmptyTopContent />
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider w-16"></TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider"></TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider"></TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider text-right"></TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider text-right"></TableHead>
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">AI引用数</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topContent.map((item, idx) => {
const rank = idx + 1;
const interactionRate =
item.search_impressions > 0
? ((item.search_clicks / item.search_impressions) * 100).toFixed(1)
: "0.0";
return (
<TableRow key={item.publish_record_id} className="hover:bg-gray-50">
<TableCell>
<span
className={`inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${
rank === 1
? "bg-amber-50 text-amber-600 border border-amber-200"
: rank === 2
? "bg-slate-50 text-slate-600 border border-slate-200"
: rank === 3
? "bg-orange-50 text-orange-600 border border-orange-200"
: "text-geo-text-muted"
}`}
>
{rank}
</span>
</TableCell>
<TableCell className="text-sm font-medium text-gray-900 max-w-[240px] truncate">
{item.content_title}
</TableCell>
<TableCell>
<Badge variant="outline" className={`text-xs font-medium ${getPlatformBadgeClass(item.platform)}`}>
{item.platform}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-500 text-right" style={{ fontVariantNumeric: "tabular-nums" }}>
{(item.views || item.search_impressions).toLocaleString()}
</TableCell>
<TableCell className="text-sm text-gray-500 text-right" style={{ fontVariantNumeric: "tabular-nums" }}>
{interactionRate}%
</TableCell>
<TableCell className="text-right">
<span className="inline-flex items-center gap-1 text-sm font-semibold text-primary">
<Quote className="h-3.5 w-3.5" />
{item.ai_citation_count}
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
{/* AI Insights */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-gray-500 flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-primary" />
AI
</h3>
{insights.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<EmptyInsights />
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{insights.map((insight) => {
const styles = getSeverityStyles(insight.severity);
return (
<div
key={insight.id}
className={`rounded-xl border bg-white ${styles.border} p-5`}
>
<CardContent className="p-5">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${styles.iconBg}">
<InsightIcon name={insight.icon} className={`h-4 w-4 ${styles.iconColor}`} />
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-gray-900 leading-snug">
{insight.title}
</h4>
<p className="text-xs text-gray-500 mt-1 leading-relaxed">
{insight.description}
</p>
{insight.recommendation && (
<p className="text-xs text-primary mt-1 font-medium leading-relaxed">
{insight.recommendation}
</p>
)}
</div>
</div>
<div className="mt-4 flex items-center justify-end">
<Button
variant="outline"
size="sm"
disabled={insight.applied}
onClick={() => handleApplyInsight(insight.id)}
className="rounded-xl text-xs h-8 border-geo-border hover:border-primary/30 hover:bg-primary/[0.02]"
>
<Check className="mr-1 h-3.5 w-3.5" />
{insight.applied ? "已采纳" : "采纳建议"}
</Button>
</div>
</CardContent>
</div>
);
})}
</div>
)}
</div>
</div>
);
}