516 lines
20 KiB
TypeScript
516 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Tabs,
|
||
TabsContent,
|
||
TabsList,
|
||
TabsTrigger,
|
||
} from "@/components/ui/tabs";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
Send,
|
||
CheckCircle,
|
||
AlertCircle,
|
||
XCircle,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Eye,
|
||
Clock,
|
||
Tag,
|
||
AlertTriangle,
|
||
Check,
|
||
RefreshCw,
|
||
Globe,
|
||
Settings,
|
||
} from "lucide-react";
|
||
import {
|
||
distributionApi,
|
||
analyticsApi,
|
||
type PlatformInfo,
|
||
type PublishRecordResponse,
|
||
} from "@/lib/api";
|
||
|
||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||
|
||
type ValidationResult = "pass" | "warn" | "fail";
|
||
|
||
interface PlatformValidation {
|
||
platform: string;
|
||
result: ValidationResult;
|
||
message: string;
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
const PLATFORM_COLOR_MAP: Record<string, { label: string; color: string; bg: string }> = {
|
||
wechat: { label: "微信", color: "text-green-700", bg: "bg-green-100" },
|
||
zhihu: { label: "知乎", color: "text-blue-700", bg: "bg-blue-100" },
|
||
xiaohongshu: { label: "小红书", color: "text-red-700", bg: "bg-red-100" },
|
||
douyin: { label: "抖音", color: "text-slate-700", bg: "bg-slate-100" },
|
||
baijiahao: { label: "百家号", color: "text-orange-700", bg: "bg-orange-100" },
|
||
weibo: { label: "微博", color: "text-red-600", bg: "bg-red-100" },
|
||
general: { label: "通用", color: "text-gray-700", bg: "bg-gray-100" },
|
||
};
|
||
|
||
function getPlatformConfig(platformId: string) {
|
||
return PLATFORM_COLOR_MAP[platformId] ?? { label: platformId, color: "text-gray-700", bg: "bg-gray-100" };
|
||
}
|
||
|
||
function ValidationIcon({ result }: { result: ValidationResult }) {
|
||
switch (result) {
|
||
case "pass":
|
||
return <CheckCircle className="h-4 w-4 text-emerald-500 shrink-0" />;
|
||
case "warn":
|
||
return <AlertCircle className="h-4 w-4 text-amber-500 shrink-0" />;
|
||
case "fail":
|
||
return <XCircle className="h-4 w-4 text-red-500 shrink-0" />;
|
||
}
|
||
}
|
||
|
||
function PlatformAvatar({ platformId, name }: { platformId: string; name: string }) {
|
||
const config = getPlatformConfig(platformId);
|
||
return (
|
||
<span
|
||
className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${config.bg} ${config.color}`}
|
||
title={name || config.label}
|
||
>
|
||
{(name || config.label).charAt(0)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function getPublishedStatusConfig(status: string) {
|
||
switch (status) {
|
||
case "published":
|
||
return { label: "发布成功", className: "bg-emerald-50 text-emerald-600 border-emerald-200" };
|
||
case "failed":
|
||
return { label: "发布失败", className: "bg-red-50 text-red-600 border-red-200" };
|
||
case "pending":
|
||
return { label: "待发布", className: "bg-amber-50 text-amber-600 border-amber-200" };
|
||
default:
|
||
return { label: status, className: "bg-gray-50 text-gray-600 border-gray-200" };
|
||
}
|
||
}
|
||
|
||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||
|
||
function PlatformCard({ platform }: { platform: PlatformInfo }) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
const config = getPlatformConfig(platform.id);
|
||
|
||
const validations: PlatformValidation[] = [
|
||
{
|
||
platform: platform.id,
|
||
result: "pass",
|
||
message: `最大标题长度:${platform.max_title_length} 字`,
|
||
},
|
||
{
|
||
platform: platform.id,
|
||
result: platform.max_content_length >= 1000 ? "pass" : "warn",
|
||
message: `内容长度限制:${platform.min_content_length}–${platform.max_content_length} 字`,
|
||
},
|
||
{
|
||
platform: platform.id,
|
||
result: platform.format_features?.supports_markdown ? "pass" : "warn",
|
||
message: platform.format_features?.supports_markdown
|
||
? "支持 Markdown 格式"
|
||
: "不支持 Markdown,需转换格式",
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-5 hover:border-gray-300 transition-colors">
|
||
<div className="p-5">
|
||
{/* Title + Avatar */}
|
||
<div className="flex items-start justify-between gap-3 mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<PlatformAvatar platformId={platform.id} name={platform.name} />
|
||
<div>
|
||
<h3 className="text-base font-semibold text-gray-900">{platform.name}</h3>
|
||
<p className="text-xs text-gray-500 mt-0.5">
|
||
{platform.max_content_length.toLocaleString()} 字上限
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Badge variant="outline" className="text-xs font-medium bg-primary/10 text-primary border-primary/20">
|
||
已配置
|
||
</Badge>
|
||
</div>
|
||
|
||
{/* Best publish times */}
|
||
{platform.best_publish_times.length > 0 && (
|
||
<div className="flex items-center gap-1.5 text-xs text-gray-500 mb-3">
|
||
<Clock className="h-3.5 w-3.5 text-primary" />
|
||
<span>最佳发布时段:{platform.best_publish_times.slice(0, 3).join("、")}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* SEO tips preview */}
|
||
{platform.seo_tips.length > 0 && (
|
||
<div className="text-xs text-gray-400 mb-3 line-clamp-1">
|
||
💡 {platform.seo_tips[0]}
|
||
</div>
|
||
)}
|
||
|
||
{/* Expandable rules */}
|
||
<button
|
||
onClick={() => setExpanded(!expanded)}
|
||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors mb-2"
|
||
>
|
||
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||
平台规则详情
|
||
</button>
|
||
|
||
{expanded && (
|
||
<div className="space-y-2 mb-4 pl-1">
|
||
{validations.map((v, idx) => (
|
||
<div key={idx} className="flex items-start gap-2 text-sm">
|
||
<ValidationIcon result={v.result} />
|
||
<span className="text-gray-500">{v.message}</span>
|
||
</div>
|
||
))}
|
||
{platform.rules.slice(0, 3).map((rule, idx) => (
|
||
<div key={`rule-${idx}`} className="flex items-start gap-2 text-sm">
|
||
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
||
<span className="text-gray-500">{rule}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Tags */}
|
||
{platform.supported_media.length > 0 && (
|
||
<div className="flex items-center gap-1 flex-wrap pt-2 border-t border-geo-border">
|
||
<Tag className="h-3 w-3 text-gray-400" />
|
||
{platform.supported_media.slice(0, 3).map((media) => (
|
||
<Badge key={media} variant="outline" className="text-xs bg-gray-50 border-gray-200">
|
||
{media}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyPlatforms() {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center rounded-xl border border-gray-200 bg-white py-16 text-center">
|
||
<Globe className="h-12 w-12 text-gray-300 mb-3" />
|
||
<h3 className="text-base font-semibold text-gray-900">暂无平台配置</h3>
|
||
<p className="mt-2 text-sm text-gray-500 max-w-xs mx-auto">
|
||
后端暂无平台规则数据,请检查后端服务状态
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyPublished() {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||
<Send className="h-10 w-10 text-gray-300 mb-3" />
|
||
<p className="text-sm text-gray-500">暂无发布记录</p>
|
||
<p className="text-xs text-gray-400 mt-1">在内容工坊生成内容并发布后,记录将在此显示</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Main Page ───────────────────────────────────────────────────────────────
|
||
|
||
export default function DistributionPage() {
|
||
const [platforms, setPlatforms] = useState<PlatformInfo[]>([]);
|
||
const [publishRecords, setPublishRecords] = useState<PublishRecordResponse[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// Strategy dialog
|
||
const [strategyOpen, setStrategyOpen] = useState(false);
|
||
const [activePlatform, setActivePlatform] = useState<PlatformInfo | null>(null);
|
||
|
||
useEffect(() => {
|
||
async function fetchData() {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const [platformsData, recordsData] = await Promise.all([
|
||
distributionApi.getPlatforms(),
|
||
// Use analytics publish endpoint to get publish records
|
||
analyticsApi.getTopContent(undefined, { limit: 20 })
|
||
.then((r) => r?.items ?? [])
|
||
.catch(() => []),
|
||
]);
|
||
setPlatforms(platformsData?.platforms ?? []);
|
||
// Convert top content to publish-record-like shape for display
|
||
setPublishRecords(
|
||
recordsData.map((item) => ({
|
||
id: item.publish_record_id,
|
||
organization_id: "",
|
||
content_title: item.content_title,
|
||
content_id: null,
|
||
platform: item.platform,
|
||
published_url: item.published_url,
|
||
status: "published",
|
||
published_at: item.recorded_at,
|
||
created_at: item.recorded_at ?? "",
|
||
}))
|
||
);
|
||
} catch (err) {
|
||
console.error("Distribution fetch error:", err);
|
||
setError(err instanceof Error ? err.message : "数据加载失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
fetchData();
|
||
}, []);
|
||
|
||
const handleViewStrategy = (platform: PlatformInfo) => {
|
||
setActivePlatform(platform);
|
||
setStrategyOpen(true);
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<Skeleton className="h-8 w-48" />
|
||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||
{Array.from({ length: 3 }).map((_, i) => (
|
||
<Skeleton key={i} className="h-52 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">多平台智能分发与发布管理</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error Banner */}
|
||
{error && (
|
||
<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>{error}</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>
|
||
)}
|
||
|
||
{/* Tabs */}
|
||
<Tabs defaultValue="platforms" className="space-y-6">
|
||
<TabsList className="rounded-xl bg-white border border-gray-200 p-1">
|
||
<TabsTrigger
|
||
value="platforms"
|
||
className="rounded-lg data-[state=active]:bg-primary data-[state=active]:text-primary-foreground text-sm px-4 py-1.5"
|
||
>
|
||
平台规则 ({platforms.length})
|
||
</TabsTrigger>
|
||
<TabsTrigger
|
||
value="published"
|
||
className="rounded-lg data-[state=active]:bg-primary data-[state=active]:text-primary-foreground text-sm px-4 py-1.5"
|
||
>
|
||
发布记录 ({publishRecords.length})
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* Platforms Tab */}
|
||
<TabsContent value="platforms" className="space-y-4">
|
||
{platforms.length === 0 ? (
|
||
<EmptyPlatforms />
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||
{platforms.map((platform) => (
|
||
<div key={platform.id} className="space-y-2">
|
||
<PlatformCard platform={platform} />
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="flex-1 rounded-xl text-xs h-8"
|
||
onClick={() => handleViewStrategy(platform)}
|
||
>
|
||
<Eye className="mr-1 h-3.5 w-3.5" />
|
||
最佳发布策略
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="rounded-xl text-xs h-8 px-3"
|
||
>
|
||
<Settings className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
|
||
{/* Published Records Tab */}
|
||
<TabsContent value="published">
|
||
<Card className="rounded-xl border border-gray-200 bg-white">
|
||
<CardContent className="p-0">
|
||
{publishRecords.length === 0 ? (
|
||
<EmptyPublished />
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="hover:bg-transparent">
|
||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider">标题</TableHead>
|
||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider">平台</TableHead>
|
||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider">发布时间</TableHead>
|
||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider">状态</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{publishRecords.map((item) => {
|
||
const statusConfig = getPublishedStatusConfig(item.status);
|
||
const platformConfig = getPlatformConfig(item.platform);
|
||
const publishedAt = item.published_at
|
||
? new Date(item.published_at).toLocaleString("zh-CN")
|
||
: "—";
|
||
return (
|
||
<TableRow key={item.id} className="hover:bg-gray-50">
|
||
<TableCell className="text-sm font-medium text-gray-900 max-w-[280px] truncate">
|
||
{item.content_title}
|
||
</TableCell>
|
||
<TableCell>
|
||
<span
|
||
className={`inline-flex h-6 px-2 items-center justify-center rounded-full text-xs font-bold ${platformConfig.bg} ${platformConfig.color}`}
|
||
>
|
||
{platformConfig.label}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="text-sm text-gray-500">{publishedAt}</TableCell>
|
||
<TableCell>
|
||
<Badge variant="outline" className={`text-xs font-medium ${statusConfig.className}`}>
|
||
{statusConfig.label}
|
||
</Badge>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* Platform Strategy Dialog */}
|
||
<Dialog open={strategyOpen} onOpenChange={setStrategyOpen}>
|
||
<DialogContent className="max-w-lg rounded-2xl p-0 overflow-hidden gap-0">
|
||
<DialogHeader className="p-6 pb-4">
|
||
<DialogTitle className="text-lg font-semibold flex items-center gap-2">
|
||
<Send className="h-5 w-5 text-primary" />
|
||
{activePlatform?.name} 发布策略
|
||
</DialogTitle>
|
||
<DialogDescription>基于平台特性的最佳发布方案</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{activePlatform && (
|
||
<div className="px-6 pb-2 space-y-5">
|
||
{/* Best times */}
|
||
{activePlatform.best_publish_times.length > 0 && (
|
||
<div>
|
||
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-1.5">
|
||
<Clock className="h-3.5 w-3.5" />
|
||
最佳发布时段
|
||
</h4>
|
||
<div className="flex flex-wrap gap-2">
|
||
{activePlatform.best_publish_times.map((time) => (
|
||
<Badge key={time} variant="outline" className="bg-primary/5 text-primary border-primary/20 text-xs">
|
||
{time}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
{activePlatform.best_publish_days.length > 0 && (
|
||
<p className="text-xs text-geo-text-secondary mt-1">
|
||
推荐日期:{activePlatform.best_publish_days.join("、")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* SEO Tips */}
|
||
{activePlatform.seo_tips.length > 0 && (
|
||
<div>
|
||
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-1.5">
|
||
<Tag className="h-3.5 w-3.5" />
|
||
SEO 优化建议
|
||
</h4>
|
||
<ul className="space-y-1.5">
|
||
{activePlatform.seo_tips.map((tip, idx) => (
|
||
<li key={idx} className="flex items-start gap-2 text-xs text-gray-500">
|
||
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-primary shrink-0" />
|
||
{tip}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* Rules */}
|
||
{activePlatform.rules.length > 0 && (
|
||
<div>
|
||
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-1.5">
|
||
<AlertTriangle className="h-3.5 w-3.5" />
|
||
发布规则
|
||
</h4>
|
||
<ul className="space-y-1.5">
|
||
{activePlatform.rules.map((rule, idx) => (
|
||
<li key={idx} className="flex items-start gap-2 text-xs text-gray-500">
|
||
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-amber-400 shrink-0" />
|
||
{rule}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="p-6 pt-2">
|
||
<Button
|
||
onClick={() => setStrategyOpen(false)}
|
||
className="w-full rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground"
|
||
>
|
||
<Check className="mr-2 h-4 w-4" />
|
||
知道了
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|