项目从单体结构重构为 pnpm monorepo (shared/backend/frontend), 新增 YouTube、Instagram、Twitter/X、哔哩哔哩、微博 5 个平台适配器, 包含完整的单元测试和 E2E 测试覆盖。 - 完成 T-031~T-044: 5 个适配器实现、注册、配置和测试 - 重构前后端分离: Hono 后端 + Next.js 前端 - 151 个单元测试 + 21 个 Mock E2E + 25 个真实 E2E - 适配器基于真实 TikHub API 响应结构实现 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
161 lines
5.1 KiB
TypeScript
161 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import { ArrowLeft, Play, Heart, Bookmark, MessageCircle, Share2, ExternalLink } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { getPlatformConfig } from "@muse/shared";
|
|
import { formatCount, formatTime } from "@/lib/format";
|
|
import { FavoriteButton } from "@/components/common/FavoriteButton";
|
|
import type { ContentItem } from "@muse/shared";
|
|
import { useState } from "react";
|
|
|
|
interface DetailPanelProps {
|
|
item: ContentItem;
|
|
}
|
|
|
|
const STAT_ITEMS = [
|
|
{ key: "play_count" as const, label: "播放", icon: Play },
|
|
{ key: "like_count" as const, label: "点赞", icon: Heart },
|
|
{ key: "collect_count" as const, label: "收藏", icon: Bookmark },
|
|
{ key: "comment_count" as const, label: "评论", icon: MessageCircle },
|
|
{ key: "share_count" as const, label: "分享", icon: Share2 },
|
|
];
|
|
|
|
export function DetailPanel({ item }: DetailPanelProps) {
|
|
const router = useRouter();
|
|
const platform = getPlatformConfig(item.platform);
|
|
const [imgError, setImgError] = useState(false);
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto">
|
|
{/* Back button */}
|
|
<button
|
|
data-testid="detail-back"
|
|
onClick={() => router.back()}
|
|
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700 mb-4 transition-colors"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
返回
|
|
</button>
|
|
|
|
{/* Cover image */}
|
|
<div className="relative aspect-video bg-slate-100 rounded-lg overflow-hidden">
|
|
{item.cover_url && !imgError ? (
|
|
<Image
|
|
src={item.cover_url}
|
|
alt={item.title}
|
|
fill
|
|
unoptimized
|
|
className="object-cover"
|
|
priority
|
|
sizes="(max-width: 768px) 100vw, 768px"
|
|
onError={() => setImgError(true)}
|
|
/>
|
|
) : (
|
|
<div className="flex items-center justify-center h-full text-5xl text-slate-300">
|
|
{platform?.icon || "📄"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="mt-6 space-y-5">
|
|
{/* Platform tag */}
|
|
{platform && (
|
|
<span
|
|
className="inline-block text-xs px-2 py-0.5 rounded"
|
|
style={{
|
|
backgroundColor: `${platform.color}15`,
|
|
color: platform.color,
|
|
}}
|
|
>
|
|
{platform.icon} {platform.name}
|
|
</span>
|
|
)}
|
|
|
|
{/* Title */}
|
|
<h1 className="text-xl font-semibold text-slate-900 leading-relaxed">
|
|
{item.title}
|
|
</h1>
|
|
|
|
{/* Author */}
|
|
<div className="flex items-center gap-3">
|
|
{item.author_avatar ? (
|
|
<Image
|
|
src={item.author_avatar}
|
|
alt={item.author_name}
|
|
width={40}
|
|
height={40}
|
|
unoptimized
|
|
className="rounded-full object-cover"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).style.display = "none";
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center text-lg text-slate-500">
|
|
👤
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-700">
|
|
{item.author_name}
|
|
</p>
|
|
{item.publish_time && (
|
|
<p className="text-xs text-slate-400">
|
|
{formatTime(item.publish_time)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats panel */}
|
|
<div className="grid grid-cols-5 gap-3">
|
|
{STAT_ITEMS.map(({ key, label, icon: Icon }) => {
|
|
const value = item[key];
|
|
return (
|
|
<div
|
|
key={key}
|
|
className="flex flex-col items-center gap-1 py-3 bg-slate-50 rounded-lg"
|
|
>
|
|
<Icon className="w-4 h-4 text-slate-400" />
|
|
<span className="text-lg font-semibold text-slate-800">
|
|
{formatCount(value) || "0"}
|
|
</span>
|
|
<span className="text-xs text-slate-400">{label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
{item.tags && item.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{item.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="text-xs px-2.5 py-1 bg-slate-100 text-slate-600 rounded-full"
|
|
>
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-3 pt-2">
|
|
<button
|
|
data-testid="view-original"
|
|
onClick={() => window.open(item.original_url, "_blank")}
|
|
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
查看原文
|
|
</button>
|
|
<FavoriteButton item={item} size="md" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|