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

514 lines
18 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, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useApi } from "@/lib/hooks/use-api";
import { strategyApi } from "@/lib/api/strategy";
import type { GeoPlan, GeoPlanAction, GeoPlanListResponse } from "@/types/strategy";
import {
Sparkles,
Loader2,
FileText,
Edit3,
Search,
Tag,
Target,
ArrowRight,
CheckCircle2,
Clock,
SkipForward,
Play,
Zap,
Calendar,
TrendingUp,
AlertTriangle,
Users,
} from "lucide-react";
import { useRouter } from "next/navigation";
const ACTION_TYPE_CONFIG: Record<
GeoPlanAction["action_type"],
{ label: string; icon: React.ReactNode; color: string; bgColor: string }
> = {
content_creation: {
label: "内容创建",
icon: <FileText className="h-4 w-4" />,
color: "text-blue-600",
bgColor: "bg-blue-50",
},
content_optimization: {
label: "内容优化",
icon: <Edit3 className="h-4 w-4" />,
color: "text-emerald-600",
bgColor: "bg-emerald-50",
},
query_expansion: {
label: "查询扩展",
icon: <Search className="h-4 w-4" />,
color: "text-purple-600",
bgColor: "bg-purple-50",
},
schema_optimization: {
label: "Schema优化",
icon: <Tag className="h-4 w-4" />,
color: "text-amber-600",
bgColor: "bg-amber-50",
},
platform_targeting: {
label: "平台定向",
icon: <Target className="h-4 w-4" />,
color: "text-rose-600",
bgColor: "bg-rose-50",
},
};
const PRIORITY_CONFIG: Record<
GeoPlanAction["priority"],
{ label: string; className: string }
> = {
high: { label: "高优先级", className: "bg-red-100 text-red-700 hover:bg-red-100" },
medium: { label: "中优先级", className: "bg-amber-100 text-amber-700 hover:bg-amber-100" },
low: { label: "低优先级", className: "bg-blue-100 text-blue-700 hover:bg-blue-100" },
};
const DIFFICULTY_CONFIG: Record<
GeoPlanAction["difficulty"],
{ label: string; className: string }
> = {
easy: { label: "简单", className: "bg-emerald-50 text-emerald-700 hover:bg-emerald-50" },
medium: { label: "中等", className: "bg-amber-50 text-amber-700 hover:bg-amber-50" },
hard: { label: "困难", className: "bg-red-50 text-red-700 hover:bg-red-50" },
};
const STATUS_CONFIG: Record<
GeoPlanAction["status"],
{ label: string; className: string; icon: React.ReactNode }
> = {
pending: {
label: "待处理",
className: "bg-gray-100 text-gray-600 hover:bg-gray-100",
icon: <Clock className="h-3.5 w-3.5" />,
},
in_progress: {
label: "进行中",
className: "bg-blue-100 text-blue-700 hover:bg-blue-100",
icon: <Play className="h-3.5 w-3.5" />,
},
completed: {
label: "已完成",
className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100",
icon: <CheckCircle2 className="h-3.5 w-3.5" />,
},
skipped: {
label: "已跳过",
className: "bg-gray-50 text-gray-400 hover:bg-gray-50",
icon: <SkipForward className="h-3.5 w-3.5" />,
},
};
function ActionCard({
action,
onExecute,
executing,
}: {
action: GeoPlanAction;
onExecute: (actionId: string) => void;
executing: boolean;
}) {
const typeConfig = ACTION_TYPE_CONFIG[action.action_type];
const priorityConfig = PRIORITY_CONFIG[action.priority];
const difficultyConfig = DIFFICULTY_CONFIG[action.difficulty];
const statusConfig = STATUS_CONFIG[action.status];
const canExecute =
(action.action_type === "content_creation" || action.action_type === "content_optimization") &&
action.status !== "completed" &&
action.status !== "skipped";
return (
<div className="rounded-lg border bg-white p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${typeConfig.bgColor} ${typeConfig.color}`}>
{typeConfig.icon}
</div>
<div>
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${typeConfig.color}`}>{typeConfig.label}</span>
<Badge className={`text-xs ${priorityConfig.className}`}>{priorityConfig.label}</Badge>
</div>
<h4 className="text-sm font-semibold mt-0.5">{action.title}</h4>
</div>
</div>
<Badge className={`text-xs ${statusConfig.className}`}>
<span className="mr-1">{statusConfig.icon}</span>
{statusConfig.label}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{action.reason}</p>
{action.estimated_impact && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Zap className="h-3.5 w-3.5 text-amber-500" />
<span>: {action.estimated_impact}</span>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex items-center gap-2">
<Badge variant="outline" className={`text-xs ${difficultyConfig.className}`}>
{difficultyConfig.label}
</Badge>
{action.target_keyword && (
<span className="text-xs text-muted-foreground">
: {action.target_keyword}
</span>
)}
{action.target_platform && (
<span className="text-xs text-muted-foreground">
: {action.target_platform}
</span>
)}
</div>
{canExecute && (
<Button
size="sm"
onClick={() => onExecute(action.id)}
disabled={executing}
>
{executing ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
...
</>
) : (
<>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
AI生成内容
</>
)}
</Button>
)}
</div>
</div>
);
}
function WeeklyTimeline({ weeklyPlan }: { weeklyPlan: Record<string, unknown>[] }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Calendar className="h-5 w-5 text-blue-500" />
线
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{weeklyPlan.map((week, idx) => {
const weekNum = (week.week as number) ?? idx + 1;
const title = (week.title as string) ?? `${weekNum}`;
const tasks = (week.tasks as string[]) ?? [];
return (
<div key={idx} className="flex gap-4">
<div className="flex flex-col items-center">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-50 text-blue-600 text-sm font-semibold">
{weekNum}
</div>
{idx < weeklyPlan.length - 1 && (
<div className="w-0.5 flex-1 my-1 min-h-[1.5rem] bg-gray-200" />
)}
</div>
<div className="flex-1 pb-4">
<p className="text-sm font-medium">{title}</p>
{tasks.length > 0 && (
<ul className="mt-1 space-y-1">
{tasks.map((task, taskIdx) => (
<li key={taskIdx} className="text-xs text-muted-foreground flex items-center gap-1.5">
<ArrowRight className="h-3 w-3" />
{task}
</li>
))}
</ul>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
function CompetitorWarning({ brandId }: { brandId: string }) {
const router = useRouter();
const { data: brandDetail } = useApi<{ competitors: unknown[] }>(
brandId ? `/api/v1/brands/${brandId}` : null
);
const hasCompetitors = (brandDetail?.competitors?.length ?? 0) > 0;
if (hasCompetitors) return null;
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-semibold text-amber-800"></h4>
<p className="text-xs text-amber-700 mt-1">
AI将生成更精准的对比分析和优化建议
</p>
<Button
size="sm"
variant="outline"
className="mt-2 border-amber-300 text-amber-800 hover:bg-amber-100"
onClick={() => router.push(`/dashboard/brands/${brandId}`)}
>
<Users className="mr-1.5 h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
export default function StrategyPage() {
const { data: session } = useSession();
const token = (session as unknown as Record<string, unknown>)?.accessToken as string | undefined;
const [brandId, setBrandId] = useState<string>("");
const [currentPlan, setCurrentPlan] = useState<GeoPlan | null>(null);
const [generating, setGenerating] = useState(false);
const [executingActionId, setExecutingActionId] = useState<string | null>(null);
const { data: brandsData } = useApi<{ items: { id: string }[] }>("/api/v1/brands/?limit=1");
useEffect(() => {
if (brandsData?.items && brandsData.items.length > 0 && !brandId) {
setBrandId(brandsData.items[0].id);
}
}, [brandsData, brandId]);
const { data: plansData, isLoading: plansLoading, refresh: refreshPlans } = useApi<GeoPlanListResponse>(
brandId ? `/api/v1/strategy/brand/${brandId}` : null
);
useEffect(() => {
if (plansData?.plans && plansData.plans.length > 0) {
const activePlan = plansData.plans.find((p) => p.status === "active") ?? plansData.plans[0];
setCurrentPlan(activePlan);
}
}, [plansData]);
const handleGeneratePlan = useCallback(async () => {
if (!token || !brandId) return;
setGenerating(true);
try {
const plan = await strategyApi.generatePlan(token, brandId) as GeoPlan;
setCurrentPlan(plan);
refreshPlans();
} catch (err) {
console.error("Failed to generate plan:", err);
} finally {
setGenerating(false);
}
}, [token, brandId, refreshPlans]);
const handleExecuteAction = useCallback(async (actionId: string) => {
if (!token) return;
setExecutingActionId(actionId);
try {
await strategyApi.executeAction(token, actionId);
await strategyApi.updateActionStatus(token, actionId, "completed");
if (currentPlan) {
setCurrentPlan({
...currentPlan,
actions: currentPlan.actions.map((a) =>
a.id === actionId ? { ...a, status: "completed" as const } : a
),
});
}
} catch (err) {
console.error("Failed to execute action:", err);
} finally {
setExecutingActionId(null);
}
}, [token, currentPlan]);
const completedCount = currentPlan?.actions.filter((a) => a.status === "completed").length ?? 0;
const totalActions = currentPlan?.actions.length ?? 0;
const progressPercent = totalActions > 0 ? Math.round((completedCount / totalActions) * 100) : 0;
const weeklyPlan = currentPlan?.plan_data?.weekly_plan as Record<string, unknown>[] | undefined;
if (plansLoading && !currentPlan) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">GEO优化策略</p>
</div>
<Skeleton className="h-10 w-32" />
</div>
<div className="grid gap-4 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<Skeleton className="h-4 w-20 mb-2" />
<Skeleton className="h-8 w-12" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardContent className="p-6 space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-28 w-full" />
))}
</CardContent>
</Card>
</div>
);
}
if (!currentPlan) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">GEO优化策略</p>
</div>
<Button onClick={handleGeneratePlan} disabled={generating || !brandId}>
{generating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<TrendingUp className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-base font-semibold text-gray-900"></h3>
<p className="mt-2 mb-6 max-w-sm text-sm text-gray-500">
AI将为您制定个性化GEO优化方案
</p>
<Button onClick={handleGeneratePlan} disabled={generating || !brandId}>
{generating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
</>
)}
</Button>
</CardContent>
</Card>
{brandId && <CompetitorWarning brandId={brandId} />}
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">GEO优化策略</p>
</div>
<Button onClick={handleGeneratePlan} disabled={generating || !brandId} variant="outline">
{generating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="p-4">
<div className="text-sm text-muted-foreground"> </div>
<div className="flex items-center gap-2 mt-1">
<span className="text-2xl font-bold text-red-500">{currentPlan.diagnosis_score}</span>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<span className="text-2xl font-bold text-emerald-600">{currentPlan.target_score}</span>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="flex items-center gap-2 mt-1">
<Calendar className="h-5 w-5 text-blue-500" />
<span className="text-2xl font-bold">{currentPlan.estimated_weeks} </span>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1">
<span className="text-2xl font-bold">{completedCount}</span>
<span className="text-sm text-muted-foreground"> / {totalActions} </span>
</div>
<div className="mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
</CardContent>
</Card>
</div>
{brandId && <CompetitorWarning brandId={brandId} />}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>"AI生成内容"</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{currentPlan.actions
.sort((a, b) => a.sort_order - b.sort_order)
.map((action) => (
<ActionCard
key={action.id}
action={action}
onExecute={handleExecuteAction}
executing={executingActionId === action.id}
/>
))}
</CardContent>
</Card>
{weeklyPlan && weeklyPlan.length > 0 && (
<WeeklyTimeline weeklyPlan={weeklyPlan} />
)}
</div>
);
}