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

516 lines
20 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 { 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>
);
}