613 lines
18 KiB
TypeScript
613 lines
18 KiB
TypeScript
"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">
|
||
“{ctx}”
|
||
</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">
|
||
“{highlightBrand(result.brand_context)}”
|
||
</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>
|
||
);
|
||
}
|