From ce736f197df592914f3bca7e56f65174c6a3d16d Mon Sep 17 00:00:00 2001 From: wxs Date: Mon, 2 Mar 2026 19:27:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=AE=8C=E6=88=90=20Phase=202=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B8=8E=E7=BB=84=E4=BB=B6=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完成 T-011: TanStack Query 数据获取 hooks - 完成 T-012: 内容卡片组件 ContentCard - 完成 T-013: 响应式网格布局 + 骨架屏 - 完成 T-014: 平台 Tab 切换 - 完成 T-015: 排序工具栏 - 完成 T-016: 内容详情页 - 完成 T-017: 自动刷新机制 - 完成 T-018: 手动刷新 + 刷新时间 - 完成 T-019: 首页组装 - 完成 T-020: 收藏 store - 完成 T-021: 收藏按钮组件 - 完成 T-022: 收藏页面 - 完成 T-023: 设置页面 - API Key - 完成 T-024: 设置页面 - 刷新间隔 - 完成 T-025: Toast 通知 (sonner) - 完成 T-026: 错误/空状态组件 Co-Authored-By: Claude --- src/app/detail/[platform]/[id]/page.tsx | 67 ++++++++++ src/app/favorites/page.tsx | 50 ++++++++ src/app/page.tsx | 136 +++++++++++--------- src/app/settings/page.tsx | 143 +++++++++++++++++++++ src/components/card/CardSkeleton.tsx | 27 ++++ src/components/card/ContentCard.tsx | 118 +++++++++++++++++ src/components/card/ContentGrid.tsx | 35 +++++ src/components/common/EmptyState.tsx | 35 +++++ src/components/common/ErrorState.tsx | 41 ++++++ src/components/common/FavoriteButton.tsx | 54 ++++++++ src/components/detail/DetailPanel.tsx | 155 +++++++++++++++++++++++ src/components/detail/DetailSkeleton.tsx | 34 +++++ src/components/layout/SortToolbar.tsx | 77 +++++++++++ src/hooks/useContentQuery.ts | 47 +++++++ src/hooks/useDetailQuery.ts | 27 ++++ src/lib/format.ts | 29 +++++ 16 files changed, 1018 insertions(+), 57 deletions(-) create mode 100644 src/app/detail/[platform]/[id]/page.tsx create mode 100644 src/app/favorites/page.tsx create mode 100644 src/app/settings/page.tsx create mode 100644 src/components/card/CardSkeleton.tsx create mode 100644 src/components/card/ContentCard.tsx create mode 100644 src/components/card/ContentGrid.tsx create mode 100644 src/components/common/EmptyState.tsx create mode 100644 src/components/common/ErrorState.tsx create mode 100644 src/components/common/FavoriteButton.tsx create mode 100644 src/components/detail/DetailPanel.tsx create mode 100644 src/components/detail/DetailSkeleton.tsx create mode 100644 src/components/layout/SortToolbar.tsx create mode 100644 src/hooks/useContentQuery.ts create mode 100644 src/hooks/useDetailQuery.ts create mode 100644 src/lib/format.ts diff --git a/src/app/detail/[platform]/[id]/page.tsx b/src/app/detail/[platform]/[id]/page.tsx new file mode 100644 index 0000000..c1d04af --- /dev/null +++ b/src/app/detail/[platform]/[id]/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { use } from "react"; +import { useDetailQuery } from "@/hooks/useDetailQuery"; +import { DetailPanel } from "@/components/detail/DetailPanel"; +import { DetailSkeleton } from "@/components/detail/DetailSkeleton"; +import { ArrowLeft, RefreshCw } from "lucide-react"; +import Link from "next/link"; + +interface DetailPageProps { + params: Promise<{ platform: string; id: string }>; +} + +export default function DetailPage({ params }: DetailPageProps) { + const { platform, id } = use(params); + const decodedId = decodeURIComponent(id); + const { data, isLoading, isError, error, refetch } = useDetailQuery( + platform, + decodedId + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return ( +
+
+

😵

+

+ 加载失败 +

+

+ {error?.message || "无法获取内容详情"} +

+
+ + + + 返回首页 + +
+
+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/app/favorites/page.tsx b/src/app/favorites/page.tsx new file mode 100644 index 0000000..6167d2a --- /dev/null +++ b/src/app/favorites/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useFavoritesStore } from "@/stores/favorites"; +import { ContentGrid } from "@/components/card/ContentGrid"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +export default function FavoritesPage() { + const favorites = useFavoritesStore((s) => s.items); + + return ( +
+
+ + + 返回 + +

+ 我的收藏 +

+ + {favorites.length} 个内容 + +
+ + {favorites.length === 0 ? ( +
+

💝

+

+ 还没有收藏 +

+

+ 浏览热门内容,点击心形按钮收藏 +

+ + 去发现 + +
+ ) : ( + + )} +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..fa7164a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,87 @@ -import Image from "next/image"; +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import { useContentQuery, useRefreshContent } from "@/hooks/useContentQuery"; +import { PlatformTabs } from "@/components/layout/PlatformTabs"; +import { SortToolbar, type SortField, type SortOrder } from "@/components/layout/SortToolbar"; +import { ContentGrid } from "@/components/card/ContentGrid"; +import type { ContentItem } from "@/types/content"; + +function sortItems( + items: ContentItem[], + sortBy: SortField, + sortOrder: SortOrder +): ContentItem[] { + return [...items].sort((a, b) => { + let valA: number; + let valB: number; + + if (sortBy === "publish_time") { + valA = new Date(a.publish_time).getTime() || 0; + valB = new Date(b.publish_time).getTime() || 0; + } else { + valA = (a[sortBy] as number) ?? 0; + valB = (b[sortBy] as number) ?? 0; + } + + return sortOrder === "desc" ? valB - valA : valA - valB; + }); +} export default function Home() { + const [platform, setPlatform] = useState("all"); + const [sortBy, setSortBy] = useState("play_count"); + const [sortOrder, setSortOrder] = useState("desc"); + const [lastRefreshTime, setLastRefreshTime] = useState(null); + + const { data, isLoading, isFetching } = useContentQuery(platform); + const { refresh } = useRefreshContent(); + + const sortedItems = useMemo(() => { + if (!data) return []; + return sortItems(data, sortBy, sortOrder); + }, [data, sortBy, sortOrder]); + + const handleRefresh = useCallback(() => { + refresh(platform); + setLastRefreshTime( + new Date().toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }) + ); + }, [refresh, platform]); + + const handleSortOrderChange = useCallback(() => { + setSortOrder((prev) => (prev === "desc" ? "asc" : "desc")); + }, []); + return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. +

+ + + + {!isLoading && sortedItems.length === 0 ? ( +
+

📭

+

+ 暂无内容 +

+

+ 请先在设置页配置 API Key,或稍后重试

- -
+ ) : ( + + )}
); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..3c53f17 --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState } from "react"; +import { ArrowLeft, Eye, EyeOff, Check } from "lucide-react"; +import Link from "next/link"; +import { useSettingsStore } from "@/stores/settings"; +import { toast } from "sonner"; + +const REFRESH_OPTIONS: { value: 5 | 10 | 15 | 30 | 60; label: string }[] = [ + { value: 5, label: "5 分钟" }, + { value: 10, label: "10 分钟" }, + { value: 15, label: "15 分钟" }, + { value: 30, label: "30 分钟" }, + { value: 60, label: "60 分钟" }, +]; + +export default function SettingsPage() { + const { apiKey, setApiKey, refreshInterval, setRefreshInterval } = + useSettingsStore(); + + const [inputKey, setInputKey] = useState(apiKey); + const [showKey, setShowKey] = useState(false); + const [saving, setSaving] = useState(false); + + const handleSaveApiKey = async () => { + const trimmed = inputKey.trim(); + if (!trimmed) { + toast.error("请输入 API Key"); + return; + } + + setSaving(true); + try { + const res = await fetch("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: trimmed }), + }); + + if (!res.ok) { + throw new Error("保存失败"); + } + + setApiKey(trimmed); + toast.success("API Key 已保存"); + } catch { + toast.error("保存失败,请重试"); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ + + 返回 + +

设置

+
+ + {/* API Key Section */} +
+

+ TikHub API Key +

+

+ 请前往{" "} + + tikhub.io + {" "} + 获取 API Key +

+
+
+ setInputKey(e.target.value)} + placeholder="输入你的 API Key" + className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 pr-10 bg-white text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + +
+ +
+
+ + {/* Refresh Interval Section */} +
+

+ 自动刷新间隔 +

+

+ 设置热门内容的自动刷新频率 +

+
+ {REFRESH_OPTIONS.map((opt) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/card/CardSkeleton.tsx b/src/components/card/CardSkeleton.tsx new file mode 100644 index 0000000..d27be22 --- /dev/null +++ b/src/components/card/CardSkeleton.tsx @@ -0,0 +1,27 @@ +export function CardSkeleton() { + return ( +
+ {/* Cover placeholder */} +
+ +
+ {/* Platform tag */} +
+ {/* Title */} +
+
+ {/* Author */} +
+
+
+
+ {/* Stats */} +
+
+
+
+
+
+
+ ); +} diff --git a/src/components/card/ContentCard.tsx b/src/components/card/ContentCard.tsx new file mode 100644 index 0000000..7eac049 --- /dev/null +++ b/src/components/card/ContentCard.tsx @@ -0,0 +1,118 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { Play, Heart, MessageCircle } from "lucide-react"; +import { getPlatformConfig } from "@/lib/platforms"; +import { formatCount } from "@/lib/format"; +import { FavoriteButton } from "@/components/common/FavoriteButton"; +import type { ContentItem } from "@/types/content"; +import { useState } from "react"; + +interface ContentCardProps { + item: ContentItem; +} + +export function ContentCard({ item }: ContentCardProps) { + const platform = getPlatformConfig(item.platform); + const [imgError, setImgError] = useState(false); + + const playCount = formatCount(item.play_count); + const likeCount = formatCount(item.like_count); + const commentCount = formatCount(item.comment_count); + + return ( + + {/* Cover image */} +
+ {item.cover_url && !imgError ? ( + {item.title} setImgError(true)} + /> + ) : ( +
+ {platform?.icon || "📄"} +
+ )} +
+ + {/* Content */} +
+ {/* Platform tag */} + {platform && ( + + {platform.icon} {platform.name} + + )} + + {/* Title */} +

+ {item.title} +

+ + {/* Author */} +
+ {item.author_avatar ? ( + {item.author_name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( +
+ 👤 +
+ )} + + {item.author_name} + +
+ + {/* Stats + Favorite */} +
+
+ {playCount && ( + + + {playCount} + + )} + {likeCount && ( + + + {likeCount} + + )} + {commentCount && ( + + + {commentCount} + + )} +
+ +
+
+ + ); +} diff --git a/src/components/card/ContentGrid.tsx b/src/components/card/ContentGrid.tsx new file mode 100644 index 0000000..4d00f51 --- /dev/null +++ b/src/components/card/ContentGrid.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { ContentCard } from "./ContentCard"; +import { CardSkeleton } from "./CardSkeleton"; +import type { ContentItem } from "@/types/content"; + +interface ContentGridProps { + items: ContentItem[]; + loading?: boolean; + skeletonCount?: number; +} + +export function ContentGrid({ + items, + loading, + skeletonCount = 12, +}: ContentGridProps) { + if (loading) { + return ( +
+ {Array.from({ length: skeletonCount }).map((_, i) => ( + + ))} +
+ ); + } + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx new file mode 100644 index 0000000..712fea6 --- /dev/null +++ b/src/components/common/EmptyState.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; + +interface EmptyStateProps { + icon?: string; + title: string; + description?: string; + actionLabel?: string; + actionHref?: string; +} + +export function EmptyState({ + icon = "📭", + title, + description, + actionLabel, + actionHref, +}: EmptyStateProps) { + return ( +
+

{icon}

+

{title}

+ {description && ( +

{description}

+ )} + {actionLabel && actionHref && ( + + {actionLabel} + + )} +
+ ); +} diff --git a/src/components/common/ErrorState.tsx b/src/components/common/ErrorState.tsx new file mode 100644 index 0000000..1559b28 --- /dev/null +++ b/src/components/common/ErrorState.tsx @@ -0,0 +1,41 @@ +import { RefreshCw } from "lucide-react"; +import Link from "next/link"; + +interface ErrorStateProps { + message?: string; + onRetry?: () => void; + showHomeLink?: boolean; +} + +export function ErrorState({ + message = "加载失败,请稍后重试", + onRetry, + showHomeLink = true, +}: ErrorStateProps) { + return ( +
+

😵

+

出错了

+

{message}

+
+ {onRetry && ( + + )} + {showHomeLink && ( + + 返回首页 + + )} +
+
+ ); +} diff --git a/src/components/common/FavoriteButton.tsx b/src/components/common/FavoriteButton.tsx new file mode 100644 index 0000000..be44485 --- /dev/null +++ b/src/components/common/FavoriteButton.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Heart } from "lucide-react"; +import { useFavoritesStore } from "@/stores/favorites"; +import type { ContentItem } from "@/types/content"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; + +interface FavoriteButtonProps { + item: ContentItem; + size?: "sm" | "md"; +} + +export function FavoriteButton({ item, size = "sm" }: FavoriteButtonProps) { + const { isFavorited, addFavorite, removeFavorite } = useFavoritesStore(); + const favorited = isFavorited(item.id, item.platform); + const [animating, setAnimating] = useState(false); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setAnimating(true); + setTimeout(() => setAnimating(false), 300); + + if (favorited) { + removeFavorite(item.id, item.platform); + } else { + addFavorite(item); + } + }; + + const iconSize = size === "sm" ? "w-4 h-4" : "w-5 h-5"; + + return ( + + ); +} diff --git a/src/components/detail/DetailPanel.tsx b/src/components/detail/DetailPanel.tsx new file mode 100644 index 0000000..2082b06 --- /dev/null +++ b/src/components/detail/DetailPanel.tsx @@ -0,0 +1,155 @@ +"use client"; + +import Image from "next/image"; +import { ArrowLeft, Play, Heart, MessageCircle, Share2, ExternalLink } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { getPlatformConfig } from "@/lib/platforms"; +import { formatCount, formatTime } from "@/lib/format"; +import { FavoriteButton } from "@/components/common/FavoriteButton"; +import type { ContentItem } from "@/types/content"; +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: "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 ( +
+ {/* Back button */} + + + {/* Cover image */} +
+ {item.cover_url && !imgError ? ( + {item.title} setImgError(true)} + /> + ) : ( +
+ {platform?.icon || "📄"} +
+ )} +
+ + {/* Content */} +
+ {/* Platform tag */} + {platform && ( + + {platform.icon} {platform.name} + + )} + + {/* Title */} +

+ {item.title} +

+ + {/* Author */} +
+ {item.author_avatar ? ( + {item.author_name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( +
+ 👤 +
+ )} +
+

+ {item.author_name} +

+ {item.publish_time && ( +

+ {formatTime(item.publish_time)} +

+ )} +
+
+ + {/* Stats panel */} +
+ {STAT_ITEMS.map(({ key, label, icon: Icon }) => { + const value = item[key]; + return ( +
+ + + {formatCount(value) || "0"} + + {label} +
+ ); + })} +
+ + {/* Tags */} + {item.tags && item.tags.length > 0 && ( +
+ {item.tags.map((tag) => ( + + #{tag} + + ))} +
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/src/components/detail/DetailSkeleton.tsx b/src/components/detail/DetailSkeleton.tsx new file mode 100644 index 0000000..84fd1d3 --- /dev/null +++ b/src/components/detail/DetailSkeleton.tsx @@ -0,0 +1,34 @@ +export function DetailSkeleton() { + return ( +
+ {/* Cover */} +
+ +
+ {/* Title */} +
+
+ + {/* Author */} +
+
+
+
+ + {/* Stats */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ + {/* Tags */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/src/components/layout/SortToolbar.tsx b/src/components/layout/SortToolbar.tsx new file mode 100644 index 0000000..6446a9a --- /dev/null +++ b/src/components/layout/SortToolbar.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { RefreshCw } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type SortField = "play_count" | "like_count" | "comment_count" | "publish_time"; +export type SortOrder = "asc" | "desc"; + +const SORT_OPTIONS: { value: SortField; label: string }[] = [ + { value: "play_count", label: "播放量" }, + { value: "like_count", label: "点赞数" }, + { value: "comment_count", label: "评论数" }, + { value: "publish_time", label: "发布时间" }, +]; + +interface SortToolbarProps { + sortBy: SortField; + sortOrder: SortOrder; + onSortByChange: (field: SortField) => void; + onSortOrderChange: () => void; + onRefresh: () => void; + isRefreshing: boolean; + lastRefreshTime: string | null; +} + +export function SortToolbar({ + sortBy, + sortOrder, + onSortByChange, + onSortOrderChange, + onRefresh, + isRefreshing, + lastRefreshTime, +}: SortToolbarProps) { + return ( +
+
+ 排序: + + +
+ +
+ + {lastRefreshTime && ( + + 上次: {lastRefreshTime} + + )} +
+
+ ); +} diff --git a/src/hooks/useContentQuery.ts b/src/hooks/useContentQuery.ts new file mode 100644 index 0000000..628f37c --- /dev/null +++ b/src/hooks/useContentQuery.ts @@ -0,0 +1,47 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSettingsStore } from "@/stores/settings"; +import type { ContentItem } from "@/types/content"; +import { MVP_PLATFORMS } from "@/lib/platforms"; + +async function fetchPlatformContent(platform: string): Promise { + const res = await fetch(`/api/tikhub/${platform}?count=20`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `请求失败: ${res.status}`); + } + const data = await res.json(); + return data.data || []; +} + +async function fetchAllPlatforms(): Promise { + const results = await Promise.allSettled( + MVP_PLATFORMS.filter((p) => p.enabled).map((p) => fetchPlatformContent(p.id)) + ); + + return results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])); +} + +export function useContentQuery(platform: string) { + const refreshInterval = useSettingsStore((s) => s.refreshInterval); + + return useQuery({ + queryKey: ["content", platform], + queryFn: () => + platform === "all" + ? fetchAllPlatforms() + : fetchPlatformContent(platform), + refetchInterval: refreshInterval * 60 * 1000, + refetchIntervalInBackground: false, + }); +} + +export function useRefreshContent() { + const queryClient = useQueryClient(); + + return { + refresh: (platform: string) => + queryClient.invalidateQueries({ queryKey: ["content", platform] }), + }; +} diff --git a/src/hooks/useDetailQuery.ts b/src/hooks/useDetailQuery.ts new file mode 100644 index 0000000..116e020 --- /dev/null +++ b/src/hooks/useDetailQuery.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import type { ContentItem } from "@/types/content"; + +async function fetchDetail( + platform: string, + id: string +): Promise { + const res = await fetch( + `/api/tikhub/${platform}/detail?id=${encodeURIComponent(id)}` + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `请求失败: ${res.status}`); + } + const data = await res.json(); + return data.data; +} + +export function useDetailQuery(platform: string, id: string) { + return useQuery({ + queryKey: ["detail", platform, id], + queryFn: () => fetchDetail(platform, id), + enabled: !!platform && !!id, + }); +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..77a277c --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,29 @@ +export function formatCount(count: number | undefined): string | null { + if (count === undefined || count === null) return null; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return String(count); +} + +export function formatTime(isoString: string): string { + try { + const date = new Date(isoString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + const diffHour = Math.floor(diffMs / 3_600_000); + const diffDay = Math.floor(diffMs / 86_400_000); + + if (diffMin < 1) return "刚刚"; + if (diffMin < 60) return `${diffMin}分钟前`; + if (diffHour < 24) return `${diffHour}小时前`; + if (diffDay < 30) return `${diffDay}天前`; + + return date.toLocaleDateString("zh-CN", { + month: "short", + day: "numeric", + }); + } catch { + return ""; + } +}