589 lines
22 KiB
TypeScript
589 lines
22 KiB
TypeScript
"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>
|
||
);
|
||
}
|