wxs 6cc703ada2 feat: monorepo 重构 + 新增 5 个平台适配器
项目从单体结构重构为 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>
2026-03-03 15:43:25 +08:00

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>
);
}