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

678 lines
25 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, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sparkles,
Calendar,
Type,
Loader2,
Check,
Circle,
Rocket,
PenTool,
Wand2,
Eye,
AlertCircle,
RefreshCw,
} from "lucide-react";
import {
contentsApi,
contentGenerationApi,
knowledgeApi,
type Content,
type KnowledgeBase,
} from "@/lib/api";
// ─── Types ───────────────────────────────────────────────────────────────────
type PipelineStepStatus = "completed" | "active" | "pending";
interface PipelineStep {
id: string;
title: string;
status: PipelineStepStatus;
}
// ─── Static Options ───────────────────────────────────────────────────────────
const platformOptions = [
{ value: "wechat", label: "微信公众号" },
{ value: "zhihu", label: "知乎" },
{ value: "xiaohongshu", label: "小红书" },
{ value: "baijiahao", label: "百家号" },
{ value: "douyin", label: "抖音" },
{ value: "general", label: "通用" },
];
const styleOptions = [
{ value: "professional", label: "专业严谨" },
{ value: "casual", label: "轻松活泼" },
{ value: "academic", label: "学术深度" },
];
const wordCountOptions = [
{ value: "1000", label: "1000字" },
{ value: "2000", label: "2000字" },
{ value: "3000", label: "3000字" },
{ value: "5000", label: "5000字" },
];
const pipelineStepsTemplate: PipelineStep[] = [
{ id: "topic", title: "选题分析", status: "pending" },
{ id: "generate", title: "内容生成", status: "pending" },
{ id: "deai", title: "去AI化处理", status: "pending" },
{ id: "geo", title: "GEO优化", status: "pending" },
{ id: "rule", title: "规则验证", status: "pending" },
{ id: "media", title: "多媒体内容插入", status: "pending" },
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getStatusBadgeConfig(status: Content["status"]) {
switch (status) {
case "draft":
return { label: "草稿", className: "bg-gray-50 text-gray-600 border-gray-200" };
case "review":
return { label: "待审核", className: "bg-purple-50 text-purple-600 border-purple-200" };
case "approved":
return { label: "已审核", className: "bg-blue-50 text-blue-600 border-blue-200" };
case "published":
return { label: "已发布", className: "bg-primary/10 text-primary border-primary/20" };
case "archived":
return { label: "已归档", className: "bg-slate-50 text-slate-600 border-slate-200" };
default:
return { label: "未知", className: "bg-muted text-muted-foreground" };
}
}
function getContentTypeBadge(type: Content["content_type"]) {
const map: Record<string, string> = {
article: "bg-blue-50 text-blue-700 border-blue-200",
qa: "bg-green-50 text-green-700 border-green-200",
knowledge_base: "bg-amber-50 text-amber-700 border-amber-200",
social_post: "bg-red-50 text-red-700 border-red-200",
other: "bg-gray-50 text-gray-600 border-gray-200",
};
const labelMap: Record<string, string> = {
article: "文章",
qa: "问答",
knowledge_base: "知识库",
social_post: "社媒",
other: "其他",
};
return { className: map[type] ?? map.other, label: labelMap[type] ?? type };
}
// ─── Sub-components ──────────────────────────────────────────────────────────
function ContentCard({ item }: { item: Content }) {
const statusConfig = getStatusBadgeConfig(item.status);
const typeConfig = getContentTypeBadge(item.content_type);
const wordCount = item.body ? item.body.length : 0;
const dateStr = new Date(item.created_at).toLocaleDateString("zh-CN");
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 hover:border-gray-300 transition-colors group cursor-pointer">
<div>
<div className="flex items-start justify-between gap-3 mb-3">
<h3 className="text-base font-semibold text-gray-900 leading-snug line-clamp-2 group-hover:text-primary transition-colors">
{item.title}
</h3>
</div>
<div className="flex flex-wrap items-center gap-2 mb-4">
<Badge variant="outline" className={`text-xs font-medium ${typeConfig.className}`}>
{typeConfig.label}
</Badge>
<Badge variant="outline" className={`text-xs font-medium ${statusConfig.className}`}>
{statusConfig.label}
</Badge>
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Type className="h-3.5 w-3.5" />
{wordCount > 0 ? `${wordCount}` : "暂无内容"}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{dateStr}
</span>
</div>
{item.tags && item.tags.length > 0 && (
<span className="text-muted-foreground truncate max-w-[100px]">
{item.tags.slice(0, 2).join(", ")}
</span>
)}
</div>
</div>
</div>
);
}
function PipelineTimeline({ steps }: { steps: PipelineStep[] }) {
return (
<div className="flex flex-col gap-0 py-2">
{steps.map((step, idx) => {
const isLast = idx === steps.length - 1;
const isCompleted = step.status === "completed";
const isActive = step.status === "active";
const isPending = step.status === "pending";
return (
<div key={step.id} className="flex gap-4">
<div className="flex flex-col items-center">
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors duration-300 ${
isCompleted
? "bg-primary text-primary-foreground"
: isActive
? "bg-blue-50 border-2 border-blue-500 text-blue-500"
: "bg-white border-2 border-gray-200 text-gray-300"
}`}
>
{isCompleted && <Check className="h-4 w-4" />}
{isActive && <Loader2 className="h-4 w-4 animate-spin" />}
{isPending && <Circle className="h-3 w-3" />}
</div>
{!isLast && (
<div
className={`w-0.5 flex-1 my-1 min-h-[1.5rem] transition-colors duration-300 ${
isCompleted ? "bg-primary" : "bg-gray-200"
}`}
/>
)}
</div>
<div className={`flex-1 pb-5 ${isLast ? "pb-0" : ""}`}>
<div className="flex items-center gap-2 h-8">
<span
className={`text-sm font-medium ${
isCompleted || isActive ? "text-foreground" : "text-muted-foreground"
}`}
>
{step.title}
</span>
{isCompleted && <span className="text-xs text-primary"></span>}
{isActive && <span className="text-xs text-blue-500"></span>}
{isPending && <span className="text-xs text-muted-foreground"></span>}
</div>
</div>
</div>
);
})}
</div>
);
}
function EmptyState({ onGenerate }: { onGenerate: () => void }) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-gray-200 bg-white py-20 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<PenTool className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-base font-semibold text-gray-900"></h3>
<p className="mt-2 mb-6 max-w-sm text-sm text-gray-500">
AI帮你创作第一篇内容
</p>
<Button
variant="outline"
onClick={onGenerate}
>
<Sparkles className="mr-2 h-4 w-4" />
AI生成新内容
</Button>
</div>
);
}
// ─── Main Page ───────────────────────────────────────────────────────────────
export default function ContentPage() {
const router = useRouter();
// Data states
const [contents, setContents] = useState<Content[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [pageLoading, setPageLoading] = useState(true);
const [pageError, setPageError] = useState<string | null>(null);
// Dialog states
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<"form" | "pipeline" | "result">("form");
// Form states
const [keyword, setKeyword] = useState("");
const [platform, setPlatform] = useState("");
const [selectedKbs, setSelectedKbs] = useState<string[]>([]);
const [style, setStyle] = useState("professional");
const [wordCount, setWordCount] = useState("2000");
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [generatedContent, setGeneratedContent] = useState<string | null>(null);
// Pipeline states
const [pipelineSteps, setPipelineSteps] = useState<PipelineStep[]>(
pipelineStepsTemplate.map((s) => ({ ...s }))
);
// Fetch content list and knowledge bases on mount
useEffect(() => {
async function fetchPageData() {
try {
setPageLoading(true);
setPageError(null);
const [contentList, kbList] = await Promise.all([
contentsApi.list(),
knowledgeApi.listBases(undefined, "enterprise"),
]);
setContents(contentList ?? []);
setKnowledgeBases(kbList ?? []);
} catch (err) {
console.error("Content page fetch error:", err);
setPageError(err instanceof Error ? err.message : "数据加载失败");
} finally {
setPageLoading(false);
}
}
fetchPageData();
}, []);
const resetForm = useCallback(() => {
setKeyword("");
setPlatform("");
setSelectedKbs([]);
setStyle("professional");
setWordCount("2000");
setDialogMode("form");
setPipelineSteps(pipelineStepsTemplate.map((s) => ({ ...s })));
setIsSubmitting(false);
setSubmitError(null);
setGeneratedContent(null);
}, []);
const handleOpenDialog = useCallback(() => {
resetForm();
setDialogOpen(true);
}, [resetForm]);
const handleCloseDialog = useCallback(() => {
setDialogOpen(false);
setTimeout(() => resetForm(), 300);
}, [resetForm]);
const handleSubmit = useCallback(async () => {
if (!keyword.trim() || !platform) return;
setIsSubmitting(true);
setSubmitError(null);
setDialogMode("pipeline");
// Start pipeline animation
const steps = pipelineStepsTemplate.map((s) => ({ ...s }));
steps[0].status = "active";
setPipelineSteps([...steps]);
try {
// Call real API
const result = await contentGenerationApi.generateContent(undefined, {
target_keyword: keyword.trim(),
target_platform: platform,
knowledge_base_ids: selectedKbs.length > 0 ? selectedKbs : undefined,
content_style: style,
word_count: parseInt(wordCount, 10),
run_deai: true,
run_geo: true,
});
// Animate pipeline steps based on result
const stageMap: Record<string, number> = {
topic_analysis: 0,
content_generation: 1,
deai_processing: 2,
geo_optimization: 3,
rule_validation: 4,
media_insertion: 5,
};
if (result.pipeline_stages) {
for (let i = 0; i < result.pipeline_stages.length; i++) {
const stage = result.pipeline_stages[i];
const stepIdx = stageMap[stage.stage] ?? i;
await new Promise<void>((resolve) => {
setTimeout(() => {
setPipelineSteps((prev) =>
prev.map((s, idx) => ({
...s,
status:
idx < stepIdx
? "completed"
: idx === stepIdx
? "active"
: "pending",
}))
);
resolve();
}, 600 * i);
});
}
}
// Mark all completed
await new Promise<void>((resolve) => setTimeout(resolve, 800));
setPipelineSteps(pipelineStepsTemplate.map((s) => ({ ...s, status: "completed" })));
setGeneratedContent(result.optimized_content || result.content);
// Save content to backend
try {
const saved = await contentsApi.create(undefined, {
title: keyword.trim(),
body: result.optimized_content || result.content,
content_type: "article",
tags: [platform, keyword.trim()],
});
setContents((prev) => [saved, ...prev]);
} catch {
// Ignore save error, content was still generated
}
setDialogMode("result");
} catch (err) {
console.error("Content generation error:", err);
setSubmitError(err instanceof Error ? err.message : "内容生成失败,请重试");
setDialogMode("form");
} finally {
setIsSubmitting(false);
}
}, [keyword, platform, selectedKbs, style, wordCount]);
const toggleKb = useCallback((kbId: string) => {
setSelectedKbs((prev) =>
prev.includes(kbId) ? prev.filter((id) => id !== kbId) : [...prev, kbId]
);
}, []);
const isFormValid = keyword.trim() && platform;
// Loading skeleton
if (pageLoading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-10 w-32" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-2xl" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* ── Top Area ── */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-500">AI驱动的内容生产流水线</p>
</div>
<Button
variant="outline"
onClick={handleOpenDialog}
className="shrink-0"
>
<Sparkles className="mr-2 h-4 w-4" />
AI生成新内容
</Button>
</div>
{/* ── Error Banner ── */}
{pageError && (
<div className="flex items-center gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{pageError}</span>
<Button
variant="ghost"
size="sm"
className="ml-auto h-7 text-xs"
onClick={() => window.location.reload()}
>
<RefreshCw className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
)}
{/* ── Content List ── */}
{!pageError && contents.length === 0 ? (
<EmptyState onGenerate={handleOpenDialog} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{contents.map((item) => (
<ContentCard key={item.id} item={item} />
))}
</div>
)}
{/* ── AI Generation Dialog ── */}
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!open) handleCloseDialog(); }}>
<DialogContent className="max-w-lg rounded-2xl p-0 overflow-hidden gap-0">
{dialogMode === "form" && (
<>
<DialogHeader className="p-6 pb-4">
<DialogTitle className="text-lg font-semibold flex items-center gap-2">
<Wand2 className="h-5 w-5 text-primary" />
AI生成新内容
</DialogTitle>
<DialogDescription>
AI将为您生成符合品牌调性的优化内容
</DialogDescription>
</DialogHeader>
<div className="px-6 py-2 space-y-5">
{submitError && (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-600">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{submitError}
</div>
)}
{/* 目标关键词 */}
<div className="space-y-2">
<Label htmlFor="keyword">
<span className="text-destructive ml-0.5">*</span>
</Label>
<Input
id="keyword"
placeholder="输入核心关键词,如'AI营销'"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="rounded-xl"
/>
</div>
{/* 目标平台 */}
<div className="space-y-2">
<Label htmlFor="platform">
<span className="text-destructive ml-0.5">*</span>
</Label>
<Select value={platform} onValueChange={setPlatform}>
<SelectTrigger id="platform" className="rounded-xl">
<SelectValue placeholder="选择发布平台" />
</SelectTrigger>
<SelectContent>
{platformOptions.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 选择知识库 */}
{knowledgeBases.length > 0 && (
<div className="space-y-2">
<Label></Label>
<div className="flex flex-col gap-2">
{knowledgeBases.map((kb) => (
<label
key={kb.id}
className="flex items-center gap-2.5 p-2.5 rounded-xl border border-geo-border hover:border-primary/30 hover:bg-primary/[0.02] transition-colors cursor-pointer"
>
<input
type="checkbox"
checked={selectedKbs.includes(kb.id)}
onChange={() => toggleKb(kb.id)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary accent-primary"
/>
<span className="text-sm text-geo-text-primary">{kb.name}</span>
</label>
))}
</div>
</div>
)}
{/* 内容风格 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{styleOptions.map((s) => (
<label
key={s.value}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl border cursor-pointer transition-all duration-200 text-sm ${
style === s.value
? "border-primary bg-primary/5 text-primary font-medium"
: "border-geo-border text-geo-text-secondary hover:border-primary/30"
}`}
>
<input
type="radio"
name="style"
value={s.value}
checked={style === s.value}
onChange={() => setStyle(s.value)}
className="sr-only"
/>
{s.label}
</label>
))}
</div>
</div>
{/* 期望字数 */}
<div className="space-y-2">
<Label htmlFor="wordCount"></Label>
<Select value={wordCount} onValueChange={setWordCount}>
<SelectTrigger id="wordCount" className="rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
{wordCountOptions.map((w) => (
<SelectItem key={w.value} value={w.value}>{w.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="p-6 pt-4">
<Button variant="outline" onClick={handleCloseDialog} className="rounded-xl">
</Button>
<Button
onClick={handleSubmit}
disabled={!isFormValid || isSubmitting}
className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-xl"
>
{isSubmitting ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />...</>
) : (
<><Rocket className="mr-2 h-4 w-4" />AI生成</>
)}
</Button>
</DialogFooter>
</>
)}
{dialogMode === "pipeline" && (
<>
<DialogHeader className="p-6 pb-2">
<DialogTitle className="text-lg font-semibold flex items-center gap-2">
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
</DialogTitle>
<DialogDescription>AI正在为您生成优化内容...</DialogDescription>
</DialogHeader>
<div className="px-6 py-4">
<PipelineTimeline steps={pipelineSteps} />
</div>
</>
)}
{dialogMode === "result" && (
<>
<DialogHeader className="p-6 pb-2">
<DialogTitle className="text-lg font-semibold flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary">
<Check className="h-4 w-4 text-primary-foreground" />
</div>
</DialogTitle>
<DialogDescription>
AI已成功生成并优化您的内容
</DialogDescription>
</DialogHeader>
{generatedContent && (
<div className="px-6 py-2">
<div className="rounded-xl border border-geo-border bg-geo-bg p-3 text-xs text-geo-text-secondary line-clamp-4 leading-relaxed">
{generatedContent.slice(0, 300)}{generatedContent.length > 300 ? "..." : ""}
</div>
</div>
)}
<div className="px-6 py-2">
<PipelineTimeline
steps={pipelineSteps.map((s) => ({ ...s, status: "completed" as PipelineStepStatus }))}
/>
</div>
<DialogFooter className="p-6 pt-2">
<Button variant="outline" onClick={handleCloseDialog} className="rounded-xl">
</Button>
<Button
onClick={() => { handleCloseDialog(); router.push("/dashboard/distribution"); }}
className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-xl"
>
<Eye className="mr-2 h-4 w-4" />
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}