geo/frontend/app/(dashboard)/onboarding/Step4HealthReport.tsx

351 lines
12 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 } from "react";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Loader2,
Activity,
TrendingUp,
TrendingDown,
Minus,
ArrowRight,
ArrowLeft,
BarChart3,
Trophy,
AlertTriangle,
} from "lucide-react";
import { api } from "@/lib/api";
import { PLATFORM_MAP } from "@/lib/platforms";
import {
getHealthLevel,
HEALTH_LEVELS,
type BrandHealthReport,
} from "@/types/onboarding";
interface Step4HealthReportProps {
brandId: string;
brandName: string;
competitors: string[];
platforms: string[];
onNext: (report: BrandHealthReport) => void;
onBack: () => void;
}
export function Step4HealthReport({
brandId,
brandName,
competitors: _competitors,
platforms,
onNext,
onBack,
}: Step4HealthReportProps) {
const { data: session } = useSession();
const [report, setReport] = useState<BrandHealthReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchReport = async () => {
if (!session?.accessToken) return;
try {
setLoading(true);
setError(null);
const data = (await api.onboarding.getHealthReport(
session.accessToken,
brandId,
)) as BrandHealthReport;
setReport(data);
} catch (err) {
console.error("获取健康报告失败:", err);
setError("获取健康报告失败,请重试");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchReport();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.accessToken, brandId]);
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-8">
<div className="mb-6 text-center">
<div className="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Activity className="h-8 w-8 text-primary animate-pulse" />
</div>
<h2 className="mb-2 text-2xl font-bold">...</h2>
<p className="text-muted-foreground">
AI搜索中的表现
</p>
</div>
<Card className="w-full max-w-2xl">
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
</div>
);
}
if (error || (!loading && !report)) {
return (
<div className="flex flex-col items-center justify-center py-8">
<div className="mb-6 text-center">
<div className="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-red-50">
<AlertTriangle className="h-8 w-8 text-red-600" />
</div>
<h2 className="mb-2 text-2xl font-bold"></h2>
<p className="text-muted-foreground">
{error || "无法生成健康报告,请稍后重试"}
</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={fetchReport}></Button>
<Button variant="ghost" onClick={() => onNext({
brand_id: brandId,
brand_name: brandName,
overall_score: 0,
platform_scores: {},
competitor_scores: [],
strengths: [],
weaknesses: [],
})}></Button>
</div>
</div>
);
}
// TypeScript 类型守卫:经过 loading 和 error 检查后report 必定存在
if (!report) return null;
const healthLevel = getHealthLevel(report.overall_score);
const healthConfig = HEALTH_LEVELS[healthLevel];
// 计算领先/落后竞品数量
const leadingCount = report.competitor_scores.filter(
(c) => c.is_leading,
).length;
const laggingCount = report.competitor_scores.length - leadingCount;
return (
<div className="flex flex-col items-center justify-center py-8">
<div className="mb-6 text-center">
<div className="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Activity className="h-8 w-8 text-primary" />
</div>
<h2 className="mb-2 text-2xl font-bold">
<span className="font-semibold">{brandName}</span>
</h2>
<p className="text-muted-foreground">
AI搜索中的综合表现
</p>
</div>
{/* 综合评分卡片 */}
<Card className={`w-full max-w-md ${healthConfig.borderColor} border-2`}>
<CardContent className="pt-6">
<div className="flex flex-col items-center">
{/* 评分大数字 */}
<div className="relative mb-4">
<span className="text-7xl font-bold">{report.overall_score}</span>
<span className="absolute top-2 text-2xl text-muted-foreground">
/100
</span>
</div>
{/* 健康等级标签 */}
<Badge
variant="secondary"
className={`${healthConfig.bgColor} ${healthConfig.color} border ${healthConfig.borderColor} text-base px-4 py-1 mb-4`}
>
{healthConfig.label}
</Badge>
{/* 趋势指示 */}
<div className="flex items-center gap-2 text-sm">
<TrendingUp className="h-4 w-4 text-emerald-600" />
<span className="text-emerald-600">
{leadingCount}
</span>
<span className="mx-1 text-muted-foreground">|</span>
<TrendingDown className="h-4 w-4 text-red-600" />
<span className="text-red-600"> {laggingCount} </span>
</div>
</div>
</CardContent>
</Card>
{/* 平台评分 */}
<Card className="mt-4 w-full max-w-2xl">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<BarChart3 className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{Object.entries(report.platform_scores)
.filter(([key]) => platforms.includes(key))
.map(([platform, score]) => {
const level = getHealthLevel(score);
const config = HEALTH_LEVELS[level];
const platformName = PLATFORM_MAP[platform] || platform;
return (
<div key={platform} className="flex items-center gap-3">
<span className="w-24 text-sm truncate">
{platformName}
</span>
<div className="flex-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-100">
<div
className={`h-full rounded-full ${config.bgColor.replace("50", "500")}`}
style={{ width: `${score}%` }}
/>
</div>
</div>
<span
className={`w-12 text-right font-semibold ${config.color}`}
>
{score}
</span>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* 竞品对比 */}
{report.competitor_scores.length > 0 && (
<Card className="mt-4 w-full max-w-2xl">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Trophy className="h-5 w-5 text-amber-500" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{report.competitor_scores.map((competitor, index) => (
<div
key={index}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full ${
competitor.is_leading
? "bg-emerald-100 text-emerald-600"
: "bg-gray-100 text-gray-600"
}`}
>
{competitor.is_leading ? (
<TrendingUp className="h-4 w-4" />
) : (
<Minus className="h-4 w-4" />
)}
</div>
<span className="font-medium">{competitor.name}</span>
</div>
<div className="flex items-center gap-3">
<span
className={`font-semibold ${
competitor.score >= 70
? "text-emerald-600"
: competitor.score >= 40
? "text-amber-600"
: "text-red-600"
}`}
>
{competitor.score}
</span>
{competitor.is_leading && (
<Badge variant="default" className="bg-emerald-600">
</Badge>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 优劣势分析 */}
<Card className="mt-4 w-full max-w-2xl">
<CardHeader className="pb-3">
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<h4 className="font-medium text-emerald-600 mb-2 flex items-center gap-1">
<TrendingUp className="h-4 w-4" />
</h4>
<ul className="space-y-1 text-sm text-muted-foreground">
{report.strengths.map((strength, index) => (
<li key={index} className="flex items-start gap-2">
<span className="text-emerald-500">+</span>
{strength}
</li>
))}
</ul>
</div>
<div>
<h4 className="font-medium text-amber-600 mb-2 flex items-center gap-1">
<TrendingDown className="h-4 w-4" />
</h4>
<ul className="space-y-1 text-sm text-muted-foreground">
{report.weaknesses.map((weakness, index) => (
<li key={index} className="flex items-start gap-2">
<span className="text-amber-500">-</span>
{weakness}
</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<Button
type="button"
variant="outline"
onClick={onBack}
className="flex-1"
>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<Button type="button" onClick={() => onNext(report)} className="flex-1">
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
}