geo/frontend/app/(dashboard)/dashboard/schema/page.tsx

401 lines
15 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, 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>
);
}