514 lines
18 KiB
TypeScript
514 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|