399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { useSearchParams } from "next/navigation";
|
||
import Link from "next/link";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import {
|
||
Activity,
|
||
Search,
|
||
TrendingUp,
|
||
TrendingDown,
|
||
AlertTriangle,
|
||
ArrowRight,
|
||
Lock,
|
||
Share2,
|
||
Download,
|
||
} from "lucide-react";
|
||
import { healthScoreApi, type HealthScoreResponse } from "@/lib/api/health-score";
|
||
|
||
const HEALTH_COLORS: Record<string, { bg: string; text: string; border: string; label: string }> = {
|
||
excellent: { bg: "bg-emerald-50", text: "text-emerald-600", border: "border-emerald-200", label: "优秀" },
|
||
good: { bg: "bg-yellow-50", text: "text-yellow-600", border: "border-yellow-200", label: "良好" },
|
||
pass: { bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-200", label: "及格" },
|
||
danger: { bg: "bg-red-50", text: "text-red-600", border: "border-red-200", label: "危险" },
|
||
};
|
||
|
||
function getHealthLevel(score: number): string {
|
||
if (score >= 80) return "excellent";
|
||
if (score >= 60) return "good";
|
||
if (score >= 40) return "pass";
|
||
return "danger";
|
||
}
|
||
|
||
function getPriorityConfig(priority: string) {
|
||
switch (priority) {
|
||
case "high":
|
||
return { color: "text-red-600", bg: "bg-red-50", label: "高" };
|
||
case "medium":
|
||
return { color: "text-orange-600", bg: "bg-orange-50", label: "中" };
|
||
default:
|
||
return { color: "text-yellow-600", bg: "bg-yellow-50", label: "低" };
|
||
}
|
||
}
|
||
|
||
function LoadingState() {
|
||
return (
|
||
<div className="flex flex-col items-center py-12">
|
||
<div className="mb-6 text-center">
|
||
<div className="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-emerald-50">
|
||
<Activity className="h-8 w-8 text-emerald-600 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="space-y-4 pt-6">
|
||
<div className="flex items-center justify-center py-8">
|
||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-emerald-500" />
|
||
</div>
|
||
<Skeleton className="h-32 w-full" />
|
||
<Skeleton className="h-24 w-full" />
|
||
<Skeleton className="h-24 w-full" />
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||
return (
|
||
<div className="flex flex-col items-center py-12">
|
||
<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">{message}</p>
|
||
</div>
|
||
<Button variant="outline" onClick={onRetry}>
|
||
重新检测
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ResultView({
|
||
data,
|
||
onShowRegisterModal,
|
||
onShare,
|
||
onDownload,
|
||
}: {
|
||
data: HealthScoreResponse;
|
||
onShowRegisterModal: () => void;
|
||
onShare: () => void;
|
||
onDownload: () => void;
|
||
}) {
|
||
const level = getHealthLevel(data.overall_score);
|
||
const colors = HEALTH_COLORS[level];
|
||
const topRecommendations = data.recommendations.slice(0, 3);
|
||
|
||
return (
|
||
<div className="flex flex-col items-center py-8">
|
||
<div className="mb-6 text-center">
|
||
<h2 className="mb-2 text-2xl font-bold">
|
||
<span className="font-semibold">{data.brand_name}</span> 健康分报告
|
||
</h2>
|
||
<p className="text-muted-foreground">以下是该品牌在AI搜索中的综合表现</p>
|
||
</div>
|
||
|
||
<Card className={`w-full max-w-md ${colors.border} border-2`}>
|
||
<CardContent className="pt-6">
|
||
<div className="flex flex-col items-center">
|
||
<div className="relative mb-4">
|
||
<span className="text-7xl font-bold">{data.overall_score}</span>
|
||
<span className="absolute top-2 text-2xl text-muted-foreground">/100</span>
|
||
</div>
|
||
<Badge
|
||
variant="secondary"
|
||
className={`${colors.bg} ${colors.text} border ${colors.border} text-base px-4 py-1 mb-4`}
|
||
>
|
||
{colors.label}
|
||
</Badge>
|
||
{data.cached && (
|
||
<p className="text-xs text-muted-foreground">数据来自缓存</p>
|
||
)}
|
||
</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">
|
||
<Activity className="h-5 w-5" />
|
||
维度评分
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-4">
|
||
{data.dimensions.map((dim) => {
|
||
const dimLevel = getHealthLevel(dim.percentage);
|
||
const dimColors = HEALTH_COLORS[dimLevel];
|
||
return (
|
||
<div key={dim.name} className="space-y-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">{dim.name}</span>
|
||
<span className={`text-sm font-semibold ${dimColors.text}`}>
|
||
{dim.score}/{dim.max_score}
|
||
</span>
|
||
</div>
|
||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||
<div
|
||
className={`h-full rounded-full ${dimColors.bg.replace("50", "500")}`}
|
||
style={{ width: `${dim.percentage}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{topRecommendations.length > 0 && (
|
||
<Card className="mt-4 w-full max-w-2xl">
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="flex items-center gap-2 text-lg">
|
||
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
||
关键问题
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{topRecommendations.map((rec, idx) => {
|
||
const priorityConfig = getPriorityConfig(rec.priority);
|
||
return (
|
||
<div key={idx} className="flex items-start gap-3 rounded-lg border p-3">
|
||
<span className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs font-medium ${priorityConfig.bg} ${priorityConfig.color}`}>
|
||
{priorityConfig.label}
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-sm font-medium">{rec.title}</p>
|
||
<p className="mt-0.5 text-xs text-muted-foreground">{rec.description}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||
<Button onClick={onShowRegisterModal} className="flex-1">
|
||
查看详细修复建议
|
||
<ArrowRight className="ml-2 h-4 w-4" />
|
||
</Button>
|
||
<Button variant="outline" onClick={onShare} className="flex-1">
|
||
<Share2 className="mr-2 h-4 w-4" />
|
||
分享报告
|
||
</Button>
|
||
<Button variant="outline" onClick={onDownload} className="flex-1">
|
||
<Download className="mr-2 h-4 w-4" />
|
||
下载PDF
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RegisterModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||
if (!open) return null;
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||
<div className="mb-4 flex flex-col items-center text-center">
|
||
<div className="mb-3 inline-flex h-14 w-14 items-center justify-center rounded-full bg-emerald-50">
|
||
<Lock className="h-7 w-7 text-emerald-600" />
|
||
</div>
|
||
<h3 className="text-lg font-bold">注册后查看完整报告</h3>
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
注册 GEO 平台账户,即可解锁完整健康报告、详细修复建议和持续监控功能
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
<Link href="/register">
|
||
<Button className="w-full">立即注册</Button>
|
||
</Link>
|
||
<Button variant="ghost" onClick={onClose} className="w-full">
|
||
稍后再说
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function HealthScorePage() {
|
||
const searchParams = useSearchParams();
|
||
const brandFromUrl = searchParams.get("brand") || "";
|
||
|
||
const [brand, setBrand] = useState(brandFromUrl);
|
||
const [competitorsInput, setCompetitorsInput] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [result, setResult] = useState<HealthScoreResponse | null>(null);
|
||
const [registerModalOpen, setRegisterModalOpen] = useState(false);
|
||
const [toast, setToast] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (brandFromUrl) {
|
||
setBrand(brandFromUrl);
|
||
}
|
||
}, [brandFromUrl]);
|
||
|
||
useEffect(() => {
|
||
if (toast) {
|
||
const timer = setTimeout(() => setToast(null), 2500);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [toast]);
|
||
|
||
const handleCheck = useCallback(async () => {
|
||
const trimmedBrand = brand.trim();
|
||
if (!trimmedBrand) return;
|
||
|
||
const competitors = competitorsInput
|
||
.split(/[,,]/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
.slice(0, 3);
|
||
|
||
setLoading(true);
|
||
setError(null);
|
||
setResult(null);
|
||
|
||
try {
|
||
const data = await healthScoreApi.getHealthScore(trimmedBrand, competitors);
|
||
setResult(data);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "检测失败,请稍后重试");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [brand, competitorsInput]);
|
||
|
||
const handleShare = useCallback(async () => {
|
||
const url = `${window.location.origin}/health-score?brand=${encodeURIComponent(brand.trim())}`;
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
setToast("链接已复制到剪贴板");
|
||
} catch {
|
||
setToast("复制失败,请手动复制链接");
|
||
}
|
||
}, [brand]);
|
||
|
||
const handleDownload = useCallback(() => {
|
||
setToast("PDF 下载功能即将推出");
|
||
}, []);
|
||
|
||
return (
|
||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
|
||
{toast && (
|
||
<div className="fixed left-1/2 top-6 z-50 -translate-x-1/2 rounded-lg bg-gray-900 px-4 py-2.5 text-sm text-white shadow-lg">
|
||
{toast}
|
||
</div>
|
||
)}
|
||
|
||
<div className="mb-10 text-center">
|
||
<div className="mb-4 inline-flex h-14 w-14 items-center justify-center rounded-xl bg-emerald-50">
|
||
<Activity className="h-7 w-7 text-emerald-600" />
|
||
</div>
|
||
<h1 className="mb-2 text-3xl font-bold text-gray-900 sm:text-4xl">
|
||
品牌健康分检测
|
||
</h1>
|
||
<p className="mx-auto max-w-xl text-muted-foreground">
|
||
免费检测您的品牌在AI搜索引擎中的表现,了解品牌可见度、推荐排名和情感倾向
|
||
</p>
|
||
</div>
|
||
|
||
{!result && !loading && !error && (
|
||
<Card className="mx-auto max-w-xl">
|
||
<CardContent className="pt-6">
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-gray-700">品牌名称</label>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
placeholder="输入品牌名称,如:华为"
|
||
value={brand}
|
||
onChange={(e) => setBrand(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") handleCheck();
|
||
}}
|
||
className="h-12 text-base"
|
||
/>
|
||
<Button
|
||
onClick={handleCheck}
|
||
disabled={!brand.trim() || loading}
|
||
size="lg"
|
||
className="h-12 shrink-0 px-6"
|
||
>
|
||
<Search className="mr-2 h-4 w-4" />
|
||
开始检测
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-gray-700">
|
||
竞品名称
|
||
<span className="ml-1 text-xs text-muted-foreground">(可选,最多3个,逗号分隔)</span>
|
||
</label>
|
||
<Input
|
||
placeholder="如:小米, OPPO, vivo"
|
||
value={competitorsInput}
|
||
onChange={(e) => setCompetitorsInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") handleCheck();
|
||
}}
|
||
className="h-10"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{loading && <LoadingState />}
|
||
{error && !loading && (
|
||
<ErrorState message={error} onRetry={handleCheck} />
|
||
)}
|
||
{result && !loading && (
|
||
<ResultView
|
||
data={result}
|
||
onShowRegisterModal={() => setRegisterModalOpen(true)}
|
||
onShare={handleShare}
|
||
onDownload={handleDownload}
|
||
/>
|
||
)}
|
||
|
||
<div className="mt-12 text-center">
|
||
<p className="text-sm text-muted-foreground">
|
||
已有账户?{" "}
|
||
<Link href="/login" className="font-medium text-emerald-600 hover:underline">
|
||
登录
|
||
</Link>
|
||
</p>
|
||
</div>
|
||
|
||
<RegisterModal open={registerModalOpen} onClose={() => setRegisterModalOpen(false)} />
|
||
</div>
|
||
);
|
||
}
|