514 lines
18 KiB
TypeScript
514 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback, ChangeEvent } from "react";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { Textarea } from "@/components/ui/textarea";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Progress } from "@/components/ui/progress";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
import { fetchWithAuth } from "@/lib/api/client";
|
||
import { platformRulesApi, PlatformBrief, PlatformDetailResponse, ContentValidationResponse } from "@/lib/api/platform-rules";
|
||
|
||
interface OptimizedContent {
|
||
title: string;
|
||
content: string;
|
||
platform: string;
|
||
tips: string[];
|
||
stages?: Array<{
|
||
stage: string;
|
||
status: string;
|
||
word_count?: number;
|
||
}>;
|
||
}
|
||
|
||
// 平台配置映射(含emoji图标)
|
||
const platformIcons: Record<string, string> = {
|
||
zhihu: "📖",
|
||
wechat: "📱",
|
||
baijiahao: "📰",
|
||
toutiao: "📢",
|
||
xiaohongshu: "📕",
|
||
default: "🌐",
|
||
};
|
||
|
||
export default function ContentEditorPage() {
|
||
const { toast } = useToast();
|
||
const [platforms, setPlatforms] = useState<PlatformBrief[]>([]);
|
||
const [platformDetail, setPlatformDetail] = useState<PlatformDetailResponse | null>(null);
|
||
const [selectedPlatform, setSelectedPlatform] = useState<string>("zhihu");
|
||
const [title, setTitle] = useState("");
|
||
const [content, setContent] = useState("");
|
||
const [optimizedContent, setOptimizedContent] = useState<OptimizedContent | null>(null);
|
||
const [validationResult, setValidationResult] = useState<ContentValidationResponse | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [validating, setValidating] = useState(false);
|
||
const [optimizing, setOptimizing] = useState(false);
|
||
const [optimizeProgress, setOptimizeProgress] = useState(0);
|
||
|
||
useEffect(() => {
|
||
loadPlatforms();
|
||
}, []);
|
||
|
||
// 加载平台详情(当选中平台变化时)
|
||
useEffect(() => {
|
||
if (selectedPlatform) {
|
||
loadPlatformDetail(selectedPlatform);
|
||
}
|
||
}, [selectedPlatform]);
|
||
|
||
const loadPlatforms = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const response = await platformRulesApi.listPlatforms();
|
||
setPlatforms(response.platforms);
|
||
} catch (error) {
|
||
console.error("加载平台列表失败:", error);
|
||
toast({
|
||
title: "加载失败",
|
||
description: "无法加载平台列表,请刷新页面重试",
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadPlatformDetail = async (platformId: string) => {
|
||
try {
|
||
const detail = await platformRulesApi.getPlatformDetail(platformId);
|
||
setPlatformDetail(detail);
|
||
} catch (error) {
|
||
console.error("加载平台详情失败:", error);
|
||
setPlatformDetail(null);
|
||
}
|
||
};
|
||
|
||
const handleValidate = async () => {
|
||
if (!content || !title) {
|
||
toast({
|
||
title: "验证失败",
|
||
description: "请先填写标题和内容",
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setValidating(true);
|
||
const result = await platformRulesApi.validateContent(selectedPlatform, content, title);
|
||
setValidationResult(result);
|
||
|
||
if (result.is_valid) {
|
||
toast({
|
||
title: "验证通过",
|
||
description: `内容得分: ${result.score}分`,
|
||
});
|
||
} else {
|
||
toast({
|
||
title: "验证未通过",
|
||
description: `发现 ${result.issues.length} 个问题需要修复`,
|
||
variant: "destructive",
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("验证失败:", error);
|
||
toast({
|
||
title: "验证失败",
|
||
description: "内容验证过程中出现错误",
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setValidating(false);
|
||
}
|
||
};
|
||
|
||
const handleOptimize = async () => {
|
||
if (!content || !title) {
|
||
toast({
|
||
title: "优化失败",
|
||
description: "请先填写标题和内容",
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setOptimizing(true);
|
||
setOptimizeProgress(0);
|
||
|
||
// 阶段1: 去AI化
|
||
setOptimizeProgress(10);
|
||
const deaiResult = await fetchWithAuth(`/api/v1/content/deai`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ content, title }),
|
||
}).catch(() => ({ content }));
|
||
|
||
setOptimizeProgress(40);
|
||
let processedContent = deaiResult.content || content;
|
||
|
||
// 阶段2: 敏感词过滤
|
||
setOptimizeProgress(50);
|
||
const sensitiveResult = await fetchWithAuth(`/api/v1/content/filter-sensitive`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ content: processedContent, platform: selectedPlatform }),
|
||
}).catch(() => ({ content: processedContent }));
|
||
|
||
setOptimizeProgress(70);
|
||
processedContent = sensitiveResult.content || processedContent;
|
||
|
||
// 阶段3: SEO优化
|
||
setOptimizeProgress(80);
|
||
const seoResult = await fetchWithAuth(`/api/v1/content/seo-optimize`, {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
content: processedContent,
|
||
title,
|
||
platform: selectedPlatform
|
||
}),
|
||
}).catch(() => ({ content: processedContent }));
|
||
|
||
setOptimizeProgress(90);
|
||
processedContent = seoResult.content || processedContent;
|
||
|
||
// 获取优化建议
|
||
const tips = await platformRulesApi.getOptimizationTips(selectedPlatform);
|
||
|
||
setOptimizedContent({
|
||
title: title,
|
||
content: processedContent,
|
||
platform: selectedPlatform,
|
||
tips: tips.tips || [],
|
||
stages: [
|
||
{ stage: "去AI化", status: "success" },
|
||
{ stage: "敏感词过滤", status: "success" },
|
||
{ stage: "SEO优化", status: "success" },
|
||
],
|
||
});
|
||
|
||
setOptimizeProgress(100);
|
||
toast({
|
||
title: "优化完成",
|
||
description: "内容已成功优化,可以复制使用了",
|
||
});
|
||
} catch (error) {
|
||
console.error("优化失败:", error);
|
||
toast({
|
||
title: "优化失败",
|
||
description: "内容优化过程中出现错误,已保留原始内容",
|
||
variant: "destructive",
|
||
});
|
||
// 保留原始内容作为后备
|
||
setOptimizedContent({
|
||
title: title,
|
||
content: content,
|
||
platform: selectedPlatform,
|
||
tips: [],
|
||
});
|
||
} finally {
|
||
setOptimizing(false);
|
||
}
|
||
};
|
||
|
||
const handleCopyContent = useCallback((format: "html" | "markdown" | "text") => {
|
||
if (!optimizedContent) {
|
||
toast({
|
||
title: "复制失败",
|
||
description: "请先执行优化操作",
|
||
variant: "destructive",
|
||
});
|
||
return;
|
||
}
|
||
|
||
let copyText = "";
|
||
switch (format) {
|
||
case "html":
|
||
copyText = `<h1>${optimizedContent.title}</h1>\n<p>${optimizedContent.content.replace(/\n\n/g, "</p><p>")}</p>`;
|
||
break;
|
||
case "markdown":
|
||
copyText = `# ${optimizedContent.title}\n\n${optimizedContent.content}`;
|
||
break;
|
||
case "text":
|
||
copyText = `${optimizedContent.title}\n\n${optimizedContent.content}`;
|
||
break;
|
||
}
|
||
|
||
navigator.clipboard.writeText(copyText).then(() => {
|
||
const formatLabels = { html: "HTML", markdown: "Markdown", text: "纯文本" };
|
||
toast({
|
||
title: "复制成功",
|
||
description: `已复制为${formatLabels[format]}格式`,
|
||
});
|
||
}).catch(() => {
|
||
toast({
|
||
title: "复制失败",
|
||
description: "无法访问剪贴板,请检查浏览器权限",
|
||
variant: "destructive",
|
||
});
|
||
});
|
||
}, [optimizedContent, toast]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="text-muted-foreground">加载中...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">内容编辑器</h1>
|
||
<p className="text-muted-foreground">创建和优化GEO内容</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* 左侧: 编辑区 */}
|
||
<Card className="lg:col-span-2">
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>内容编辑</CardTitle>
|
||
<CardDescription>编写和编辑内容</CardDescription>
|
||
</div>
|
||
<Select value={selectedPlatform} onValueChange={setSelectedPlatform}>
|
||
<SelectTrigger className="w-[180px]">
|
||
<SelectValue placeholder="选择平台" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{platforms.map((p) => (
|
||
<SelectItem key={p.id} value={p.id}>
|
||
{platformIcons[p.id] || platformIcons.default} {p.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{/* 平台规则摘要 */}
|
||
{platformDetail && (
|
||
<div className="mt-3 p-3 bg-muted/50 rounded-lg text-xs space-y-1">
|
||
<div className="flex items-center gap-2 font-medium text-foreground">
|
||
<span>{platformIcons[selectedPlatform] || platformIcons.default}</span>
|
||
<span>{platformDetail.name}</span>
|
||
<Badge variant="outline" className="ml-auto">{platformDetail.content_style}</Badge>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-muted-foreground">
|
||
<span>字数: {platformDetail.content_length.min}-{platformDetail.content_length.max}</span>
|
||
<span>推荐: {platformDetail.content_length.recommended}字</span>
|
||
<span>标题: {platformDetail.title_rules.min_length}-{platformDetail.title_rules.max_length}字</span>
|
||
<span>标签: {platformDetail.tag_rules.min_tags}-{platformDetail.tag_rules.max_tags}个</span>
|
||
<span>图片: 最多{platformDetail.max_images}张</span>
|
||
<span>AI敏感度: {platformDetail.ai_sensitivity.detection_level}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="title">标题</Label>
|
||
<Input
|
||
id="title"
|
||
placeholder="输入文章标题"
|
||
value={title}
|
||
onChange={(e: ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="content">正文内容</Label>
|
||
<Textarea
|
||
id="content"
|
||
placeholder="输入文章内容..."
|
||
className="min-h-[400px] font-mono text-sm"
|
||
value={content}
|
||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setContent(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleValidate}
|
||
disabled={validating || !content || !title}
|
||
>
|
||
{validating ? "验证中..." : "校验内容"}
|
||
</Button>
|
||
<Button
|
||
onClick={handleOptimize}
|
||
disabled={optimizing || !content || !title}
|
||
>
|
||
{optimizing ? "优化中..." : "一键优化"}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 优化进度条 */}
|
||
{optimizing && (
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between text-xs text-muted-foreground">
|
||
<span>优化进度</span>
|
||
<span>{optimizeProgress}%</span>
|
||
</div>
|
||
<Progress value={optimizeProgress} className="h-2" />
|
||
<div className="flex justify-between text-xs text-muted-foreground">
|
||
<span>{optimizeProgress < 30 ? "去AI化处理中..." : optimizeProgress < 60 ? "敏感词过滤中..." : optimizeProgress < 90 ? "SEO优化中..." : "完成"}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 验证结果 */}
|
||
{validationResult && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<h4 className="font-semibold">验证结果</h4>
|
||
<Badge
|
||
variant={validationResult.is_valid ? "default" : "destructive"}
|
||
className={validationResult.is_valid ? "bg-green-600" : ""}
|
||
>
|
||
{validationResult.is_valid ? "通过" : "未通过"}
|
||
</Badge>
|
||
<Badge variant="outline">
|
||
得分: {validationResult.score}
|
||
</Badge>
|
||
</div>
|
||
|
||
{validationResult.issues.length > 0 && (
|
||
<div className="space-y-1">
|
||
{validationResult.issues.map((issue, i) => (
|
||
<div
|
||
key={i}
|
||
className={`text-sm p-2 rounded ${
|
||
issue.severity === "high"
|
||
? "bg-red-50 text-red-800"
|
||
: issue.severity === "medium"
|
||
? "bg-yellow-50 text-yellow-800"
|
||
: "bg-blue-50 text-blue-800"
|
||
}`}
|
||
>
|
||
<span className="font-medium">[{issue.severity.toUpperCase()}]</span>{" "}
|
||
{issue.message}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{validationResult.passed.length > 0 && (
|
||
<div className="space-y-1">
|
||
<h5 className="text-sm font-medium text-muted-foreground">已通过规则:</h5>
|
||
{validationResult.passed.map((p, i) => (
|
||
<div key={i} className="text-sm text-green-700">
|
||
✓ {p}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 右侧: 预览和工具 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">优化与预览</CardTitle>
|
||
<CardDescription>查看优化后的内容</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{!optimizedContent ? (
|
||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||
<div className="text-center">
|
||
<p>点击"一键优化"开始</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Tabs defaultValue="preview" className="space-y-4">
|
||
<TabsList className="grid w-full grid-cols-2">
|
||
<TabsTrigger value="preview">预览</TabsTrigger>
|
||
<TabsTrigger value="tips">优化建议</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="preview" className="space-y-4">
|
||
<div className="space-y-2">
|
||
<h4 className="font-semibold text-lg">{optimizedContent.title}</h4>
|
||
<div className="text-sm whitespace-pre-wrap">
|
||
{optimizedContent.content}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<h5 className="font-medium">复制为:</h5>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" variant="outline" onClick={() => handleCopyContent("text")}>
|
||
纯文本
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => handleCopyContent("markdown")}>
|
||
Markdown
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={() => handleCopyContent("html")}>
|
||
HTML
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="tips">
|
||
<div className="space-y-2">
|
||
{optimizedContent.tips.length > 0 ? (
|
||
optimizedContent.tips.map((tip, i) => (
|
||
<div key={i} className="text-sm p-2 bg-muted rounded">
|
||
{tip}
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">暂无优化建议</p>
|
||
)}
|
||
</div>
|
||
</TabsContent>
|
||
</Tabs>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* 优化流程说明 */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>优化流程</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<div className="p-4 border rounded-lg">
|
||
<div className="font-semibold mb-2">1. 去AI化</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
基于平台AI敏感度,消除AI写作特征,使用更自然的表达方式
|
||
</p>
|
||
</div>
|
||
<div className="p-4 border rounded-lg">
|
||
<div className="font-semibold mb-2">2. 敏感词过滤</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
根据平台敏感词规则,自动过滤或替换敏感内容
|
||
</p>
|
||
</div>
|
||
<div className="p-4 border rounded-lg">
|
||
<div className="font-semibold mb-2">3. SEO优化</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
调整关键词密度和位置,优化搜索引擎排名
|
||
</p>
|
||
</div>
|
||
<div className="p-4 border rounded-lg">
|
||
<div className="font-semibold mb-2">4. 平台适配</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
转换内容格式,适配目标平台的HTML规则和结构偏好
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|