geo/frontend/app/(public)/health-score/page.tsx

399 lines
14 KiB
TypeScript
Raw Permalink 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 { 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>
);
}