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

418 lines
15 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 { useRouter } from "next/navigation";
import Link from "next/link";
import { MetricCard, StageProgress } from "@/components/business";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Sparkles,
Search,
FileText,
Send,
BarChart3,
Target,
Plus,
ArrowRight,
Zap,
Lock,
} from "lucide-react";
import { type GeoProject, type LifecycleStats } from "@/lib/api";
import { useApi } from "@/lib/hooks/use-api";
import {
LoadingState,
ErrorState,
EmptyState,
} from "@/components/ui/api-states";
import { SubscriptionStatus } from "@/components/subscription/SubscriptionStatus";
import { UsageProgress } from "@/components/subscription/UsageProgress";
import { ROICard } from "@/components/dashboard/ROICard";
/* ─── Helpers ─────────────────────────────────────────────────────────────────*/
const STAGE_CONFIG = [
{ id: "diagnosis", label: "诊断分析" },
{ id: "strategy", label: "策略制定" },
{ id: "content", label: "内容生产" },
{ id: "publishing", label: "分发执行" },
{ id: "monitoring", label: "监测优化" },
];
function buildStages(currentStage: GeoProject["current_stage"]) {
const currentIndex = STAGE_CONFIG.findIndex((s) => s.id === currentStage);
return STAGE_CONFIG.map((stage, idx) => {
let status: "completed" | "active" | "pending" | "error" = "pending";
if (idx < currentIndex) status = "completed";
else if (idx === currentIndex) status = "active";
return { id: stage.id, label: stage.label, status };
});
}
function getRecommendation(stage: GeoProject["current_stage"]) {
const map: Record<
GeoProject["current_stage"],
{ title: string; description: string; icon: React.ReactNode; href: string }
> = {
diagnosis: {
title: "开始诊断分析",
description: "诊断您品牌在AI搜索中的可见性现状",
icon: <Search className="h-5 w-5" />,
href: "/dashboard/diagnosis",
},
strategy: {
title: "生成优化策略",
description: "基于诊断结果制定GEO+SEO优化方案",
icon: <Target className="h-5 w-5" />,
href: "/dashboard/strategy",
},
content: {
title: "创建新内容",
description: "使用AI Agent批量生成优化内容",
icon: <FileText className="h-5 w-5" />,
href: "/dashboard/content",
},
publishing: {
title: "配置分发渠道",
description: "将内容分发到各大AI平台和搜索引擎",
icon: <Send className="h-5 w-5" />,
href: "/dashboard/publishing",
},
monitoring: {
title: "查看监测报告",
description: "分析内容分发后的引用和排名数据",
icon: <BarChart3 className="h-5 w-5" />,
href: "/dashboard/monitoring",
},
};
return map[stage];
}
/* ─── Component ───────────────────────────────────────────────────────────────*/
export default function DashboardPage() {
const router = useRouter();
const {
data: projects,
isLoading: projectsLoading,
error: projectsError,
refresh: refreshProjects,
} = useApi<GeoProject[]>("/api/v1/lifecycle/projects/");
const {
data: stats,
isLoading: statsLoading,
error: statsError,
refresh: refreshStats,
} = useApi<LifecycleStats>("/api/v1/lifecycle/projects/stats");
const loading = projectsLoading || statsLoading;
// "用户未关联组织" 类错误视为空状态
const isOrgError = (err: Error | undefined) =>
err?.message.includes("未关联组织") ||
err?.message.includes("No organization");
const hasOrgError = isOrgError(projectsError) || isOrgError(statsError);
const error =
!hasOrgError && (projectsError || statsError)
? projectsError || statsError
: undefined;
const safeProjects: GeoProject[] = hasOrgError ? [] : (projects ?? []);
const safeStats: LifecycleStats | null = hasOrgError ? null : (stats ?? null);
const handleRetry = () => {
refreshProjects();
refreshStats();
};
/* ─── Loading State ────────────────────────────────────────────────────────*/
if (loading) {
return (
<div className="space-y-6">
<div className="mb-8">
<LoadingState rows={1} rowHeight="h-8" />
<div className="mt-2">
<LoadingState rows={1} rowHeight="h-4" />
</div>
</div>
<LoadingState rows={4} grid cols={4} rowHeight="h-24" />
<LoadingState rows={1} rowHeight="h-28" />
<LoadingState rows={2} grid cols={2} rowHeight="h-48" />
</div>
);
}
/* ─── Error State ──────────────────────────────────────────────────────────*/
if (error) {
return (
<div className="space-y-6">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<ErrorState error={error} onRetry={handleRetry} />
</div>
);
}
/* ─── Empty State ──────────────────────────────────────────────────────────*/
if (safeProjects.length === 0) {
return (
<div className="space-y-6">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-500">
GEO和SEO是AI营销时代的共生体
</p>
</div>
<EmptyState
icon={<Sparkles className="h-6 w-6 text-gray-400" />}
message="开始优化您的AI可见性"
description="创建第一个项目,系统将自动引导您完成从诊断分析到监测优化的全生命周期管理。"
action={
<Link href="/dashboard/lifecycle/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
}
/>
</div>
);
}
/* ─── Active Dashboard ─────────────────────────────────────────────────────*/
const project = safeProjects[0];
const stages = buildStages(project.current_stage);
const recommendation = getRecommendation(project.current_stage);
const citationRate =
safeStats?.avg_ai_citation_rate != null
? `${(safeStats.avg_ai_citation_rate * 100).toFixed(1)}%`
: "—";
const userPlan = "free";
const planExpiresAt = undefined;
const usageData = {
queries: { current: 2, limit: 3 },
brands: { current: 1, limit: 1 },
alerts: { current: 0, limit: 0 },
};
const roiData = {
roiPercentage: 0,
valueGenerated: 0,
subscriptionCost: 0,
};
const isFreePlan = userPlan === "free";
return (
<div className="space-y-6">
{/* Subscription Status Bar */}
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 px-6 py-3">
<SubscriptionStatus plan={userPlan} expiresAt={planExpiresAt} />
<div className="flex items-center gap-6">
<UsageProgress
label="查询次数"
current={usageData.queries.current}
limit={usageData.queries.limit}
className="w-40"
/>
<UsageProgress
label="品牌监控"
current={usageData.brands.current}
limit={usageData.brands.limit}
className="w-40"
/>
<UsageProgress
label="告警"
current={usageData.alerts.current}
limit={usageData.alerts.limit}
className="w-40"
/>
</div>
</div>
{/* 1. Page Title */}
<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">
{project.brand_name} {project.name}
</p>
</div>
<Link href="/dashboard/lifecycle/new">
<Button variant="outline" className="shrink-0">
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
{/* 2. KPI Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<MetricCard
label="活跃项目数"
value={safeStats?.active_projects ?? 0}
trend="neutral"
trendLabel={`${safeStats?.total_projects ?? 0} 个项目`}
size="default"
/>
<MetricCard
label="内容产出统计"
value={safeStats?.contents_produced ?? 0}
trend="up"
trendLabel="全部内容"
size="default"
/>
<MetricCard
label="AI引用率"
value={citationRate}
trend={safeStats?.avg_ai_citation_rate ? "up" : "neutral"}
trendLabel="平均引用率"
size="default"
/>
<MetricCard
label="已完成项目"
value={safeStats?.completed_projects ?? 0}
trend="neutral"
trendLabel={`活跃 ${safeStats?.active_projects ?? 0}`}
size="default"
/>
</div>
{/* ROI Card + Feature Lock */}
<div className="grid gap-4 lg:grid-cols-2">
<ROICard
roiPercentage={roiData.roiPercentage}
valueGenerated={roiData.valueGenerated}
subscriptionCost={roiData.subscriptionCost}
/>
{isFreePlan && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<p className="text-sm font-medium text-gray-500 mb-4">
</p>
<div className="space-y-3">
{[
{ label: "多品牌监控", desc: "同时监控3个以上品牌" },
{ label: "无限告警", desc: "不限制告警通知数量" },
{ label: "竞品对比", desc: "完整的竞品雷达图分析" },
{ label: "AI优化建议", desc: "基于DeepSeek的个性化建议" },
].map((feature) => (
<div
key={feature.label}
className="flex items-center gap-3 rounded-lg border border-gray-100 bg-gray-50 p-3 opacity-60"
>
<Lock className="h-4 w-4 text-gray-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700">
{feature.label}
</p>
<p className="text-xs text-gray-400">{feature.desc}</p>
</div>
</div>
))}
</div>
<Link href="/dashboard/subscription">
<Button className="w-full mt-4" size="sm">
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</Button>
</Link>
</div>
)}
</div>
{/* 3. Stage Progress */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-medium text-gray-500"></p>
<Badge variant="secondary" className="text-xs">
{STAGE_CONFIG.find((s) => s.id === project.current_stage)?.label}
</Badge>
</div>
<StageProgress
stages={stages}
onStageClick={(stage) => {
router.push(`/dashboard/${stage.id}`);
}}
/>
</div>
{/* 4. Two-column: Recommendation + Agent Activity */}
<div className="grid gap-4 lg:grid-cols-2">
{/* Recommendation */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<p className="text-sm font-medium text-gray-500 mb-4"></p>
<div className="flex items-center gap-4 rounded-lg border border-gray-100 bg-gray-50 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{recommendation.icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900">
{recommendation.title}
</p>
<p className="text-xs text-gray-500 mt-0.5">
{recommendation.description}
</p>
</div>
<Button
size="sm"
className="shrink-0"
onClick={() => router.push(recommendation.href)}
>
<ArrowRight className="ml-1 h-3.5 w-3.5" />
</Button>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<Button
variant="outline"
size="sm"
className="justify-start"
onClick={() => router.push("/dashboard/agents")}
>
<Zap className="mr-2 h-3.5 w-3.5" />
Agent
</Button>
<Button
variant="outline"
size="sm"
className="justify-start"
onClick={() => router.push("/dashboard/lifecycle")}
>
<BarChart3 className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Agent Activity */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-medium text-gray-500">Agent活动</p>
<Link
href="/dashboard/agents"
className="text-xs text-primary hover:underline"
>
</Link>
</div>
<div className="flex flex-col items-center justify-center py-8 text-center">
<Zap className="h-8 w-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground mt-1">
Agent状态监控即将上线
</p>
</div>
</div>
</div>
</div>
);
}