401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { useSession } from "next-auth/react";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
} from "@/components/ui/dialog";
|
||
import { schemaAdvisorApi, type SchemaSuggestion } from "@/lib/api/schema-advisor";
|
||
import { useApi } from "@/lib/hooks/use-api";
|
||
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
|
||
import {
|
||
Code2,
|
||
CheckCircle2,
|
||
XCircle,
|
||
AlertTriangle,
|
||
RefreshCw,
|
||
Eye,
|
||
FileJson,
|
||
Loader2,
|
||
} from "lucide-react";
|
||
|
||
const VALIDATION_BADGE: Record<string, { label: string; className: string }> = {
|
||
valid: { label: "有效", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100" },
|
||
invalid: { label: "无效", className: "bg-red-100 text-red-700 hover:bg-red-100" },
|
||
pending: { label: "待验证", className: "bg-amber-100 text-amber-700 hover:bg-amber-100" },
|
||
};
|
||
|
||
const STATUS_BADGE: Record<string, { label: string; className: string }> = {
|
||
pending: { label: "待处理", className: "bg-gray-100 text-gray-700 hover:bg-gray-100" },
|
||
applied: { label: "已应用", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100" },
|
||
dismissed: { label: "已忽略", className: "bg-red-100 text-red-700 hover:bg-red-100" },
|
||
};
|
||
|
||
export default function SchemaPage() {
|
||
const { data: session } = useSession();
|
||
const token = (session as { accessToken?: string })?.accessToken;
|
||
|
||
const [suggestions, setSuggestions] = useState<SchemaSuggestion[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const [adviseDialogOpen, setAdviseDialogOpen] = useState(false);
|
||
const [targetUrl, setTargetUrl] = useState("");
|
||
const [advising, setAdvising] = useState(false);
|
||
const [adviseError, setAdviseError] = useState<string | null>(null);
|
||
|
||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||
const [selectedSuggestion, setSelectedSuggestion] = useState<SchemaSuggestion | null>(null);
|
||
const [statusUpdating, setStatusUpdating] = useState(false);
|
||
const [statusError, setStatusError] = useState<string | null>(null);
|
||
|
||
const { data: brandsData } = useApi<{ items: { id: string; name: string }[] }>("/api/v1/brands/");
|
||
const brandId = brandsData?.items?.[0]?.id ?? "";
|
||
|
||
async function loadSuggestions() {
|
||
if (!token || !brandId) return;
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const result = await schemaAdvisorApi.getBrandSuggestions(token, brandId);
|
||
setSuggestions(result.suggestions ?? []);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "获取 Schema 建议失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (token && brandId) loadSuggestions();
|
||
}, [token, brandId]);
|
||
|
||
async function handleAdvise() {
|
||
if (!token || !brandId) return;
|
||
try {
|
||
setAdvising(true);
|
||
setAdviseError(null);
|
||
await schemaAdvisorApi.advise(token, {
|
||
brand_id: brandId,
|
||
target_url: targetUrl || undefined,
|
||
});
|
||
setAdviseDialogOpen(false);
|
||
setTargetUrl("");
|
||
loadSuggestions();
|
||
} catch (err) {
|
||
setAdviseError(err instanceof Error ? err.message : "生成建议失败");
|
||
} finally {
|
||
setAdvising(false);
|
||
}
|
||
}
|
||
|
||
function openDetail(suggestion: SchemaSuggestion) {
|
||
setSelectedSuggestion(suggestion);
|
||
setStatusError(null);
|
||
setDetailDialogOpen(true);
|
||
}
|
||
|
||
async function handleUpdateStatus(suggestionId: string, status: string) {
|
||
if (!token) return;
|
||
try {
|
||
setStatusUpdating(true);
|
||
setStatusError(null);
|
||
await schemaAdvisorApi.updateStatus(token, suggestionId, status);
|
||
setDetailDialogOpen(false);
|
||
setSelectedSuggestion(null);
|
||
loadSuggestions();
|
||
} catch (err) {
|
||
setStatusError(err instanceof Error ? err.message : "状态更新失败");
|
||
} finally {
|
||
setStatusUpdating(false);
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-2xl font-bold tracking-tight">Schema 建议</h2>
|
||
<p className="text-muted-foreground">结构化数据优化建议</p>
|
||
</div>
|
||
</div>
|
||
<LoadingState rows={5} rowHeight="h-14" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-2xl font-bold tracking-tight">Schema 建议</h2>
|
||
<p className="text-muted-foreground">结构化数据优化建议</p>
|
||
</div>
|
||
</div>
|
||
<ErrorState error={error} onRetry={loadSuggestions} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-2xl font-bold tracking-tight">Schema 建议</h2>
|
||
<p className="text-muted-foreground">结构化数据优化建议,提升搜索引擎与 AI 平台的理解能力</p>
|
||
</div>
|
||
<Button onClick={() => { setTargetUrl(""); setAdviseError(null); setAdviseDialogOpen(true); }} disabled={!brandId}>
|
||
<Code2 className="mr-2 h-4 w-4" />
|
||
生成建议
|
||
</Button>
|
||
</div>
|
||
|
||
{suggestions.length === 0 ? (
|
||
<EmptyState
|
||
icon={<FileJson className="h-6 w-6 text-gray-400" />}
|
||
message="暂无 Schema 建议"
|
||
description="点击右上角按钮生成结构化数据优化建议"
|
||
action={
|
||
<Button onClick={() => { setTargetUrl(""); setAdviseError(null); setAdviseDialogOpen(true); }} disabled={!brandId}>
|
||
<Code2 className="mr-2 h-4 w-4" />
|
||
生成建议
|
||
</Button>
|
||
}
|
||
/>
|
||
) : (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">建议列表</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Schema 类型</TableHead>
|
||
<TableHead>目标 URL</TableHead>
|
||
<TableHead>验证状态</TableHead>
|
||
<TableHead>优先级</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{suggestions.map((s) => {
|
||
const vCfg = VALIDATION_BADGE[s.validation_status ?? "pending"] ?? {
|
||
label: s.validation_status ?? "未知",
|
||
className: "bg-gray-100 text-gray-600",
|
||
};
|
||
const sCfg = STATUS_BADGE[s.status] ?? {
|
||
label: s.status,
|
||
className: "bg-gray-100 text-gray-600",
|
||
};
|
||
return (
|
||
<TableRow
|
||
key={s.id}
|
||
className="cursor-pointer hover:bg-muted/50"
|
||
onClick={() => openDetail(s)}
|
||
>
|
||
<TableCell className="font-medium">
|
||
<div className="flex items-center gap-2">
|
||
<FileJson className="h-4 w-4 text-muted-foreground" />
|
||
{s.schema_type}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="max-w-[200px] truncate text-muted-foreground">
|
||
{s.target_url ?? "—"}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant="secondary" className={vCfg.className}>
|
||
{s.validation_status === "valid" && <CheckCircle2 className="mr-1 h-3 w-3" />}
|
||
{s.validation_status === "invalid" && <XCircle className="mr-1 h-3 w-3" />}
|
||
{s.validation_status === "pending" && <AlertTriangle className="mr-1 h-3 w-3" />}
|
||
{vCfg.label}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
{s.priority !== null ? (
|
||
<Badge variant="outline">{s.priority}</Badge>
|
||
) : "—"}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant="secondary" className={sCfg.className}>
|
||
{sCfg.label}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground">
|
||
{new Date(s.created_at).toLocaleString("zh-CN")}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
onClick={(e) => { e.stopPropagation(); openDetail(s); }}
|
||
>
|
||
<Eye className="h-4 w-4" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<Dialog open={adviseDialogOpen} onOpenChange={setAdviseDialogOpen}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>生成 Schema 建议</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="target-url">目标 URL(可选)</Label>
|
||
<Input
|
||
id="target-url"
|
||
placeholder="https://example.com/page"
|
||
value={targetUrl}
|
||
onChange={(e) => setTargetUrl(e.target.value)}
|
||
/>
|
||
</div>
|
||
{adviseError && (
|
||
<p className="text-xs text-destructive">{adviseError}</p>
|
||
)}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setAdviseDialogOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleAdvise} disabled={advising}>
|
||
{advising && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
生成
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
|
||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<FileJson className="h-5 w-5" />
|
||
{selectedSuggestion?.schema_type}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{selectedSuggestion && (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-muted-foreground">目标 URL:</span>
|
||
<span className="font-medium">{selectedSuggestion.target_url ?? "—"}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground">优先级:</span>
|
||
<span className="font-medium">{selectedSuggestion.priority ?? "—"}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground">验证状态:</span>
|
||
<Badge
|
||
variant="secondary"
|
||
className={
|
||
VALIDATION_BADGE[selectedSuggestion.validation_status ?? "pending"]
|
||
?.className ?? "bg-gray-100 text-gray-600"
|
||
}
|
||
>
|
||
{VALIDATION_BADGE[selectedSuggestion.validation_status ?? "pending"]?.label ?? "未知"}
|
||
</Badge>
|
||
</div>
|
||
<div>
|
||
<span className="text-muted-foreground">当前状态:</span>
|
||
<Badge
|
||
variant="secondary"
|
||
className={
|
||
STATUS_BADGE[selectedSuggestion.status]?.className ?? "bg-gray-100 text-gray-600"
|
||
}
|
||
>
|
||
{STATUS_BADGE[selectedSuggestion.status]?.label ?? selectedSuggestion.status}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2 text-sm font-medium">
|
||
<Code2 className="h-4 w-4" />
|
||
JSON-LD
|
||
</div>
|
||
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
|
||
{JSON.stringify(selectedSuggestion.json_ld, null, 2)}
|
||
</pre>
|
||
</div>
|
||
|
||
{selectedSuggestion.validation_errors && selectedSuggestion.validation_errors.length > 0 && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2 text-sm font-medium text-red-600">
|
||
<XCircle className="h-4 w-4" />
|
||
验证错误
|
||
</div>
|
||
<ul className="space-y-1 rounded-lg border border-red-200 bg-red-50 p-3">
|
||
{selectedSuggestion.validation_errors.map((err, i) => (
|
||
<li key={i} className="text-xs text-red-700">
|
||
{err}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{statusError && (
|
||
<p className="text-xs text-destructive">{statusError}</p>
|
||
)}
|
||
|
||
{selectedSuggestion.status === "pending" && (
|
||
<div className="flex gap-2 pt-2">
|
||
<Button
|
||
onClick={() => handleUpdateStatus(selectedSuggestion.id, "applied")}
|
||
disabled={statusUpdating}
|
||
>
|
||
{statusUpdating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||
应用
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => handleUpdateStatus(selectedSuggestion.id, "dismissed")}
|
||
disabled={statusUpdating}
|
||
>
|
||
<XCircle className="mr-2 h-4 w-4" />
|
||
忽略
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|