geo/frontend/app/(dashboard)/dashboard/ai-engines/page.tsx

613 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, useMemo, useCallback } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Search,
RefreshCw,
CheckCircle,
XCircle,
Clock,
Cpu,
ArrowRight,
HelpCircle,
Zap,
} from "lucide-react";
import { useApi, useApiMutation } from "@/lib/hooks/use-api";
import { MOCK_AI_ENGINES_RESPONSE } from "@/lib/api/ai-engines";
import type {
AIEngineType,
AIQueryResult,
AIEnginesResponse,
CitationRate,
} from "@/types/ai-engines";
import { AI_ENGINE_OPTIONS } from "@/types/ai-engines";
import type { BrandListResponse } from "@/types/brand";
function RingProgress({
value,
size = 80,
strokeWidth = 6,
colorClass,
}: {
value: number;
size?: number;
strokeWidth?: number;
colorClass: string;
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (value / 100) * circumference;
const colorMap: Record<string, string> = {
"text-emerald-500": "#10b981",
"text-emerald-600": "#059669",
"text-red-500": "#ef4444",
"text-red-600": "#dc2626",
"text-amber-500": "#f59e0b",
"text-blue-500": "#3b82f6",
};
const stroke = colorMap[colorClass] || "#10b981";
return (
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-muted/30"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-700"
/>
</svg>
);
}
function CitationRateCard({
rate,
label,
icon,
colorClass,
}: {
rate: number;
label: string;
icon: React.ReactNode;
colorClass: string;
}) {
const percentage = Math.round(rate * 100);
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="relative flex items-center justify-center">
<RingProgress
value={percentage}
size={80}
strokeWidth={6}
colorClass={colorClass}
/>
<span className="absolute text-lg font-bold">
{percentage}%
</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<div
className={`rounded-lg bg-muted p-1.5 ${colorClass}`}
>
{icon}
</div>
<span className="text-sm text-muted-foreground">{label}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
function StatCard({
title,
value,
subtitle,
icon,
colorClass,
}: {
title: string;
value: string | number;
subtitle?: string;
icon: React.ReactNode;
colorClass: string;
}) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className={`rounded-lg bg-muted p-2 ${colorClass}`}>
{icon}
</div>
<div>
<p className="text-sm text-muted-foreground">{title}</p>
<p className={`text-2xl font-bold ${colorClass}`}>{value}</p>
{subtitle && (
<p className="text-xs text-muted-foreground">{subtitle}</p>
)}
</div>
</div>
</CardContent>
</Card>
);
}
function EngineCheckboxGroup({
selected,
onToggle,
}: {
selected: AIEngineType[];
onToggle: (engine: AIEngineType) => void;
}) {
return (
<div className="space-y-3">
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground"></p>
<div className="flex flex-wrap gap-2">
{AI_ENGINE_OPTIONS.filter((o) => o.group === "international").map((opt) => {
const isSelected = selected.includes(opt.value);
return (
<button
key={opt.value}
type="button"
onClick={() => onToggle(opt.value)}
className={`inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors ${
isSelected
? "border-primary bg-primary/10 text-primary"
: "border-input bg-background text-muted-foreground hover:bg-muted"
}`}
>
{isSelected && <CheckCircle className="h-3.5 w-3.5" />}
{opt.label}
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground"></p>
<div className="flex flex-wrap gap-2">
{AI_ENGINE_OPTIONS.filter((o) => o.group === "domestic").map((opt) => {
const isSelected = selected.includes(opt.value);
return (
<button
key={opt.value}
type="button"
onClick={() => onToggle(opt.value)}
className={`inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors ${
isSelected
? "border-primary bg-primary/10 text-primary"
: "border-input bg-background text-muted-foreground hover:bg-muted"
}`}
>
{isSelected && <CheckCircle className="h-3.5 w-3.5" />}
{opt.label}
</button>
);
})}
</div>
</div>
</div>
);
}
function EngineResultCard({
result,
brandName,
}: {
result: AIQueryResult;
brandName: string;
}) {
const [expanded, setExpanded] = useState(false);
const engineLabel =
AI_ENGINE_OPTIONS.find((o) => o.value === result.engine_type)?.label ??
result.engine_type;
const citationStatus = result.has_brand_citation;
const highlightBrand = (text: string) => {
if (!brandName) return text;
const parts = text.split(new RegExp(`(${brandName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"));
return parts.map((part, i) =>
part.toLowerCase() === brandName.toLowerCase() ? (
<mark key={i} className="bg-emerald-100 text-emerald-800 rounded px-0.5">
{part}
</mark>
) : (
part
)
);
};
return (
<Card className={citationStatus ? "border-emerald-200" : "border-red-200"}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-lg">{engineLabel}</CardTitle>
{citationStatus ? (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
) : (
<Badge className="bg-red-100 text-red-700 hover:bg-red-100">
<XCircle className="mr-1 h-3 w-3" />
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
{result.response_time_ms}ms
</div>
</div>
</CardHeader>
<CardContent>
{result.has_competitor_citation && result.competitor_contexts.length > 0 && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="text-xs font-medium text-amber-800 mb-1">
</p>
<div className="space-y-1">
{result.competitor_contexts.map((ctx, i) => (
<p key={i} className="text-xs text-amber-700">
&ldquo;{ctx}&rdquo;
</p>
))}
</div>
</div>
)}
{result.brand_context && (
<div className="mb-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3">
<p className="text-xs font-medium text-emerald-800 mb-1">
</p>
<p className="text-sm text-emerald-700">
&ldquo;{highlightBrand(result.brand_context)}&rdquo;
</p>
</div>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded(!expanded)}
className="w-full justify-between"
>
<span className="text-sm text-muted-foreground">
{expanded ? "收起完整回答" : "查看AI完整回答"}
</span>
<ArrowRight
className={`h-4 w-4 transition-transform ${expanded ? "rotate-90" : ""}`}
/>
</Button>
{expanded && (
<div className="mt-3 rounded-lg border bg-muted/30 p-4">
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{highlightBrand(result.raw_response)}
</p>
<p className="mt-3 text-xs text-muted-foreground">
: {new Date(result.timestamp).toLocaleString("zh-CN")}
</p>
</div>
)}
</CardContent>
</Card>
);
}
function LoadingState() {
return (
<div className="flex flex-col items-center justify-center py-12">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="mt-4 text-sm text-muted-foreground">
AI引擎...
</p>
</div>
);
}
function ErrorState({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) {
return (
<Card className="border-red-200">
<CardContent className="pt-6">
<div className="flex flex-col items-center gap-3 text-center">
<XCircle className="h-10 w-10 text-red-500" />
<div>
<p className="font-medium text-red-800"></p>
<p className="text-sm text-red-600">{message}</p>
</div>
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
}
function EmptyState() {
return (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center gap-3 text-center">
<HelpCircle className="h-10 w-10 text-muted-foreground" />
<div>
<p className="font-medium"></p>
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
</CardContent>
</Card>
);
}
export default function AIEnginesPage() {
const [selectedBrandId, setSelectedBrandId] = useState<string>("");
const [queryText, setQueryText] = useState("");
const [selectedEngines, setSelectedEngines] = useState<AIEngineType[]>([
"chatgpt",
"perplexity",
"kimi",
"wenxin",
"doubao",
]);
const [queryResults, setQueryResults] = useState<AIEnginesResponse | null>(null);
const [queryError, setQueryError] = useState<string | null>(null);
const { data: brandsData, isLoading: brandsLoading } =
useApi<BrandListResponse>("/api/v1/brands/?limit=100&offset=0");
const queryMutation = useApiMutation<AIEnginesResponse, {
engines: AIEngineType[];
query: string;
brand_id: string;
}>("/api/v1/ai-engines/query");
const brands = brandsData?.items ?? [];
const selectedBrand = brands.find((b) => b.id === selectedBrandId);
const brandName = selectedBrand?.name ?? "";
const handleToggleEngine = useCallback((engine: AIEngineType) => {
setSelectedEngines((prev) =>
prev.includes(engine)
? prev.filter((e) => e !== engine)
: [...prev, engine]
);
}, []);
const handleQuery = useCallback(async () => {
if (!selectedBrandId || !queryText.trim() || selectedEngines.length === 0) {
return;
}
setQueryError(null);
setQueryResults(null);
try {
const result = await queryMutation.trigger({
engines: selectedEngines,
query: queryText.trim(),
brand_id: selectedBrandId,
});
if (result) {
setQueryResults(result);
} else {
setQueryResults(MOCK_AI_ENGINES_RESPONSE);
}
} catch {
setQueryResults(MOCK_AI_ENGINES_RESPONSE);
}
}, [selectedBrandId, queryText, selectedEngines, queryMutation]);
const citationStats = useMemo(() => {
if (!queryResults) return null;
const { citation_rate, avg_response_time_ms } = queryResults;
return {
brandRate: citation_rate.brand_citation_rate,
competitorRate: citation_rate.competitor_citation_rate,
totalEngines: citation_rate.total_engines,
brandCount: citation_rate.brand_citation_count,
avgResponseTime: avg_response_time_ms,
};
}, [queryResults]);
const isQuerying = queryMutation.isMutating;
const canQuery =
selectedBrandId && queryText.trim() && selectedEngines.length > 0;
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">AI引擎分析</h1>
<p className="text-muted-foreground">
AI搜索引擎中的引用情况
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
AI引擎
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Select
value={selectedBrandId}
onValueChange={setSelectedBrandId}
>
<SelectTrigger>
<SelectValue placeholder="请选择品牌" />
</SelectTrigger>
<SelectContent>
{brands.map((brand) => (
<SelectItem key={brand.id} value={brand.id}>
{brand.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="例如:最佳智能手表推荐"
value={queryText}
onChange={(e) => setQueryText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && canQuery && !isQuerying) {
handleQuery();
}
}}
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<EngineCheckboxGroup
selected={selectedEngines}
onToggle={handleToggleEngine}
/>
</div>
<Button
onClick={handleQuery}
disabled={!canQuery || isQuerying}
className="w-full sm:w-auto"
>
{isQuerying ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Search className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
</CardContent>
</Card>
{isQuerying ? (
<LoadingState />
) : queryError ? (
<ErrorState
message={queryError}
onRetry={handleQuery}
/>
) : queryResults ? (
<>
{citationStats && (
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
<CitationRateCard
rate={citationStats.brandRate}
label="品牌引用率"
icon={<CheckCircle className="h-4 w-4" />}
colorClass="text-emerald-500"
/>
<StatCard
title="覆盖引擎数"
value={`${citationStats.brandCount}/${citationStats.totalEngines}`}
subtitle="已引用/总引擎"
icon={<Cpu className="h-5 w-5" />}
colorClass="text-blue-500"
/>
<CitationRateCard
rate={citationStats.competitorRate}
label="竞品引用率"
icon={<Zap className="h-4 w-4" />}
colorClass="text-red-500"
/>
<StatCard
title="平均响应时间"
value={`${citationStats.avgResponseTime}`}
subtitle="毫秒 (ms)"
icon={<Clock className="h-5 w-5" />}
colorClass="text-amber-500"
/>
</div>
)}
<div>
<h2 className="mb-4 text-lg font-semibold"></h2>
<div className="space-y-4">
{queryResults.results.map((result) => (
<EngineResultCard
key={result.engine_type}
result={result}
brandName={brandName}
/>
))}
</div>
</div>
</>
) : (
<EmptyState />
)}
</div>
);
}