From 740e2fe67d4a39c68969ac8e3a2f73aaf7cd83db Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 18:04:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=8F=91=E7=8E=B0=E9=A1=B5?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E3=80=8C=E6=8E=A8=E8=8D=90=E3=80=8D=E5=88=86?= =?UTF-8?q?=E5=8C=BA=20+=E3=80=8C=E6=8E=A2=E7=B4=A2=E3=80=8D=E7=AD=9B?= =?UTF-8?q?=E9=80=89=EF=BC=8C=E5=8D=A1=E7=89=87=E5=B8=A6=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=8E=E8=AE=A2=E9=98=85=20/=20=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/web/app/api/discover.ts | 59 ++- apps/web/app/components/discover/chips.tsx | 44 ++ .../app/components/discover/discover-card.tsx | 123 ++++++ .../app/components/discover/discover-state.ts | 389 ++++++++++++++++++ .../app/components/discover/discover.test.ts | 191 +++++++++ .../app/components/discover/explore-view.tsx | 299 ++++++++++++++ .../components/discover/recommend-view.tsx | 105 +++++ .../discover/use-discover-actions.tsx | 193 +++++++++ apps/web/app/routes/console/discover.tsx | 230 ++--------- 9 files changed, 1422 insertions(+), 211 deletions(-) create mode 100644 apps/web/app/components/discover/chips.tsx create mode 100644 apps/web/app/components/discover/discover-card.tsx create mode 100644 apps/web/app/components/discover/discover-state.ts create mode 100644 apps/web/app/components/discover/discover.test.ts create mode 100644 apps/web/app/components/discover/explore-view.tsx create mode 100644 apps/web/app/components/discover/recommend-view.tsx create mode 100644 apps/web/app/components/discover/use-discover-actions.tsx diff --git a/apps/web/app/api/discover.ts b/apps/web/app/api/discover.ts index 5d2fd907..eb6c4f98 100644 --- a/apps/web/app/api/discover.ts +++ b/apps/web/app/api/discover.ts @@ -1,30 +1,59 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { DiscoverCalendar, type DiscoverItem, DiscoverList, DiscoverMatchResult } from "@umt/contracts"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; +import { DiscoverCalendar, type DiscoverItem, DiscoverMatchResult, DiscoverPage } from "@umt/contracts"; +import type { ApiQuery } from "~/components/discover/discover-state.ts"; import { api } from "~/lib/api.ts"; import { getParsed, parseOrThrow } from "./library.ts"; -// 发现:bgm.tv 每日放送、豆瓣热门(服务端缓存 1 小时);点条目时匹配 TMDB 作品。 +// 发现:TMDB / 豆瓣 / bgm 列表(服务端走请求缓存);点豆瓣 / bgm 条目时匹配 TMDB 作品。 + +const STALE = 10 * 60_000; export const discoverKeys = { calendar: ["discover", "calendar"] as const, - hot: (kind: "movie" | "tv") => ["discover", "douban-hot", kind] as const, + list: (q: ApiQuery) => ["discover", q] as const, }; +async function fetchCalendar(signal?: AbortSignal) { + return getParsed(DiscoverCalendar, "/discover/bangumi-calendar", {}, signal); +} + +/** 一页;每日放送取那一天,不分页 */ +export async function fetchDiscoverPage(q: ApiQuery, page: number, signal?: AbortSignal): Promise { + if (q.kind === "calendar") { + const cal = await fetchCalendar(signal); + const items = cal.days.find((d) => String(d.weekday) === q.weekday)?.items ?? []; + return { items, page: 1, hasMore: false }; + } + return getParsed(DiscoverPage, q.path, { ...q.params, page }, signal); +} + +/** 推荐分区:只取第一页;enabled 由分区是否进入视口决定 */ +export function useDiscoverSection(q: ApiQuery, enabled: boolean) { + return useQuery({ + queryKey: [...discoverKeys.list(q), 1], + enabled, + queryFn: ({ signal }) => fetchDiscoverPage(q, 1, signal), + staleTime: STALE, + }); +} + +/** 探索:无限滚动 */ +export function useDiscoverInfinite(q: ApiQuery) { + return useInfiniteQuery({ + queryKey: [...discoverKeys.list(q), "all"], + initialPageParam: 1, + queryFn: ({ pageParam, signal }) => fetchDiscoverPage(q, pageParam, signal), + getNextPageParam: (last) => (last.hasMore ? last.page + 1 : undefined), + staleTime: STALE, + }); +} + export function useBangumiCalendar(enabled = true) { return useQuery({ queryKey: discoverKeys.calendar, enabled, - queryFn: ({ signal }) => getParsed(DiscoverCalendar, "/discover/bangumi-calendar", {}, signal), - staleTime: 30 * 60_000, - }); -} - -export function useDoubanHot(kind: "movie" | "tv", enabled = true) { - return useQuery({ - queryKey: discoverKeys.hot(kind), - enabled, - queryFn: ({ signal }) => getParsed(DiscoverList, "/discover/douban-hot", { kind }, signal), - staleTime: 30 * 60_000, + queryFn: ({ signal }) => fetchCalendar(signal), + staleTime: STALE, }); } diff --git a/apps/web/app/components/discover/chips.tsx b/apps/web/app/components/discover/chips.tsx new file mode 100644 index 00000000..60a374a7 --- /dev/null +++ b/apps/web/app/components/discover/chips.tsx @@ -0,0 +1,44 @@ +import { cn } from "~/lib/utils.ts"; +import type { Option } from "./discover-state.ts"; + +// 一行单选芯片(选中用 pill-selected);标签在左,窄屏横向滚动。allLabel 给了就多一个「不限」芯片(value 为空)。 + +export function ChipRow({ + label, + options, + value, + onChange, + allLabel, +}: { + label: string; + options: Option[]; + value: string | undefined; + onChange: (v: string | undefined) => void; + allLabel?: string; +}) { + const all = allLabel ? [{ value: "", label: allLabel }, ...options] : options; + return ( +
+ {label} +
+ {all.map((o) => { + const on = (value ?? "") === o.value; + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/web/app/components/discover/discover-card.tsx b/apps/web/app/components/discover/discover-card.tsx new file mode 100644 index 00000000..8f78338f --- /dev/null +++ b/apps/web/app/components/discover/discover-card.tsx @@ -0,0 +1,123 @@ +import { type DiscoverItem, tmdbImageUrl } from "@umt/contracts"; +import { CalendarPlus, LoaderCircle, PackageSearch, Star } from "lucide-react"; +import { useState } from "react"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { cn } from "~/lib/utils.ts"; +import { itemMeta, localBadges } from "./discover-state.ts"; + +// 发现卡片:海报(懒加载)、评分、本地状态、标题、年份 · 类型 · 风格;点海报 / 标题进作品,下面两个动作。 + +export type CardAction = "open" | "subscribe" | "search"; + +export function coverUrl(it: Pick): string | null { + return it.posterPath ? tmdbImageUrl(it.posterPath, "w342") : it.cover; +} + +export function DiscoverCard({ + item, + busy, + onAction, + className, +}: { + item: DiscoverItem; + /** 正在为这张卡匹配 TMDB */ + busy: CardAction | null; + onAction: (a: CardAction) => void; + className?: string; +}) { + const [failed, setFailed] = useState(false); + const src = failed ? null : coverUrl(item); + const badges = localBadges(item); + const meta = itemMeta(item); + return ( +
+ +
+ +

{meta || "—"}

+
+ + +
+
+
+ ); +} + +export function DiscoverCardSkeleton({ className }: { className?: string }) { + return ( +
+
+
+
+
+ ); +} diff --git a/apps/web/app/components/discover/discover-state.ts b/apps/web/app/components/discover/discover-state.ts new file mode 100644 index 00000000..d7bdd3a4 --- /dev/null +++ b/apps/web/app/components/discover/discover-state.ts @@ -0,0 +1,389 @@ +import { + DISCOVER_YEAR, + type DiscoverItem, + DOUBAN_COLLECTIONS, + DOUBAN_GENRES, + DOUBAN_REGIONS, + DOUBAN_SORTS, + DOUBAN_TAGS, + DOUBAN_YEAR, + TMDB_LANGUAGES, + TMDB_LIST_TYPES, + TMDB_REGIONS, + TMDB_SORTS, + type TmdbDiscoverList, + tmdbGenres, +} from "@umt/contracts"; +import type { StatusTone } from "~/components/console/status-badge.tsx"; + +// 发现页状态(都在 URL 里):view = recommend | explore;探索的来源 / 列表 / 筛选。 +// 这里是纯函数:URL ↔ 状态、筛选器联动、状态 → 接口参数、卡片状态与去处。 + +export type Src = "tmdb" | "douban" | "bangumi"; +export type View = "recommend" | "explore"; + +export interface ExploreState { + src: Src; + /** TMDB:TmdbDiscoverList;豆瓣:榜单 id / tag / explore / search;bgm:calendar / trends / rank / latest / search */ + list: string; + /** TMDB:all / movie / tv;豆瓣:movie / tv;bgm 不用 */ + type: string; + window?: string; + sort?: string; + genre?: string; + language?: string; + region?: string; + year?: string; + minRating?: string; + tag?: string; + q?: string; + /** bgm 每日放送:1-7 */ + weekday?: string; +} + +export type Option = { value: string; label: string }; +const opts = (r: Record): Option[] => Object.entries(r).map(([value, label]) => ({ value, label })); + +export const SOURCES: Option[] = [ + { value: "tmdb", label: "TMDB" }, + { value: "douban", label: "豆瓣" }, + { value: "bangumi", label: "Bangumi" }, +]; + +export const DEFAULT_LIST: Record = { + tmdb: "trending", + douban: "movie_real_time_hotest", + bangumi: "trends", +}; + +/** TMDB 列表名(按类型):正在上映 / 即将上映只有电影,正在播出 / 今日播出只有剧集 */ +export function tmdbListOptions(type: string): Option[] { + const all: [TmdbDiscoverList, string][] = [ + ["trending", "流行趋势"], + ["popular", "热门"], + ["now_playing", "正在上映"], + ["upcoming", "即将上映"], + ["on_the_air", "正在播出"], + ["airing_today", "今日播出"], + ["top_rated", "高分"], + ["discover", "筛选"], + ]; + return all + .filter(([v]) => { + const only = TMDB_LIST_TYPES[v]; + return !only || type === "all" || only === type; + }) + .map(([value, label]) => ({ value, label })); +} + +export const TMDB_TYPES: Option[] = [ + { value: "all", label: "全部" }, + { value: "movie", label: "电影" }, + { value: "tv", label: "剧集" }, +]; +export const MEDIA_TYPES: Option[] = TMDB_TYPES.slice(1); +export const WINDOWS: Option[] = [ + { value: "week", label: "本周" }, + { value: "day", label: "今日" }, +]; +export const TMDB_SORT_OPTIONS = opts(TMDB_SORTS); +export const TMDB_LANGUAGE_OPTIONS = opts(TMDB_LANGUAGES); +export const TMDB_REGION_OPTIONS = opts(TMDB_REGIONS); +export const RATING_OPTIONS: Option[] = ["6", "7", "8"].map((v) => ({ value: v, label: `${v} 分以上` })); + +/** 年份:最近 6 年 + 年代(TMDB 用 2010s,豆瓣用 2010年代) */ +export function yearOptions(src: Src, now = new Date()): Option[] { + const y = now.getFullYear(); + const years = Array.from({ length: 6 }, (_, i) => String(y - i)); + if (src === "bangumi") + return Array.from({ length: 10 }, (_, i) => String(y - i)).map((v) => ({ value: v, label: v })); + const decades = [2020, 2010, 2000, 1990, 1980]; + return [ + ...years.map((v) => ({ value: v, label: v })), + ...decades.map((d) => + src === "tmdb" + ? { value: `${d}s`, label: `${d} 年代` } + : { value: d >= 2000 ? `${d}年代` : `${String(d).slice(2)}年代`, label: `${d} 年代` }, + ), + ]; +} + +export const DOUBAN_LIST_OPTIONS: Option[] = [ + ...Object.entries(DOUBAN_COLLECTIONS).map(([value, c]) => ({ value, label: c.label })), + { value: "tag", label: "按标签" }, + { value: "explore", label: "筛选" }, +]; +export const DOUBAN_SORT_OPTIONS = opts(DOUBAN_SORTS); +export const DOUBAN_GENRE_OPTIONS = DOUBAN_GENRES.map((g) => ({ value: g, label: g })); +export const DOUBAN_REGION_OPTIONS = DOUBAN_REGIONS.map((g) => ({ value: g, label: g })); + +export const BANGUMI_LIST_OPTIONS: Option[] = [ + { value: "calendar", label: "每日放送" }, + { value: "trends", label: "热门趋势" }, + { value: "rank", label: "排行" }, + { value: "latest", label: "最新" }, +]; +export const WEEKDAYS: Option[] = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"].map((label, i) => ({ + value: String(i + 1), + label, +})); +/** bgm 周一 = 1 … 周日 = 7 */ +export const todayWeekday = (d = new Date()) => String(((d.getDay() + 6) % 7) + 1); + +const KEYS = [ + "src", + "list", + "type", + "window", + "sort", + "genre", + "language", + "region", + "year", + "minRating", + "tag", + "q", + "weekday", +] as const; + +const FILTER_KEYS = ["sort", "genre", "language", "region", "year", "minRating"] as const; + +/** 规整:去掉与当前来源 / 列表 / 类型不配的参数(和服务端校验同一套规则,不发会被拒的请求) */ +export function normalize(input: Partial): ExploreState { + const src: Src = input.src === "douban" || input.src === "bangumi" ? input.src : "tmdb"; + const s: ExploreState = { src, list: input.list || DEFAULT_LIST[src], type: input.type ?? "" }; + const q = input.q?.trim(); + if (q) { + s.list = "search"; + s.q = q; + } else if (s.list === "search") s.list = DEFAULT_LIST[src]; + + if (src === "tmdb") { + s.type = ["all", "movie", "tv"].includes(s.type) ? s.type : "all"; + const valid = ["search", ...tmdbListOptions("all").map((o) => o.value)]; + if (!valid.includes(s.list)) s.list = DEFAULT_LIST.tmdb; + const only = TMDB_LIST_TYPES[s.list as TmdbDiscoverList]; + if (only) s.type = only; + else if (s.type === "all" && !["trending", "search"].includes(s.list)) s.type = "movie"; + if (s.list === "trending") s.window = input.window === "day" ? "day" : "week"; + if (s.list === "discover") { + const t = s.type as "movie" | "tv"; + if (input.sort && input.sort in TMDB_SORTS) s.sort = input.sort; + if (input.genre && input.genre in tmdbGenres(t)) s.genre = input.genre; + if (input.language && input.language in TMDB_LANGUAGES) s.language = input.language; + if (input.region && input.region in TMDB_REGIONS) s.region = input.region; + if (input.year && DISCOVER_YEAR.test(input.year)) s.year = input.year; + if (input.minRating && /^([0-9]|10)$/.test(input.minRating)) s.minRating = input.minRating; + } + } else if (src === "douban") { + s.type = s.type === "tv" ? "tv" : "movie"; + const coll = DOUBAN_COLLECTIONS[s.list]; + if (!coll && !["tag", "explore", "search"].includes(s.list)) s.list = DEFAULT_LIST.douban; + if (s.list === "tag") s.tag = input.tag && DOUBAN_TAGS[s.type as "movie"].includes(input.tag) ? input.tag : "热门"; + if (s.list === "explore") { + if (input.sort && input.sort in DOUBAN_SORTS) s.sort = input.sort; + if (input.genre && DOUBAN_GENRES.includes(input.genre)) s.genre = input.genre; + if (input.region && DOUBAN_REGIONS.includes(input.region)) s.region = input.region; + if (input.year && DOUBAN_YEAR.test(input.year)) s.year = input.year; + } + } else { + s.type = ""; + if (!["calendar", "trends", "rank", "latest", "search"].includes(s.list)) s.list = DEFAULT_LIST.bangumi; + if (s.list === "calendar") s.weekday = /^[1-7]$/.test(input.weekday ?? "") ? input.weekday : todayWeekday(); + if ((s.list === "rank" || s.list === "latest") && input.year && /^\d{4}$/.test(input.year)) s.year = input.year; + } + return s; +} + +export function fromSearchParams(sp: URLSearchParams): ExploreState { + const raw: Partial = {}; + for (const k of KEYS) { + const v = sp.get(k); + if (v) (raw as Record)[k] = v; + } + return normalize(raw); +} + +export function toSearchParams(s: ExploreState, view: View = "explore"): URLSearchParams { + const sp = new URLSearchParams({ view }); + for (const k of KEYS) { + const v = s[k]; + if (v) sp.set(k, v); + } + return sp; +} + +/** + * 筛选器联动:改来源回到该来源的默认列表;改类型时去掉不配的风格 / 列表;离开「筛选」清掉筛选条件; + * 值等于当前值时视为取消(芯片再点一次清除)。 + */ +export function patch(s: ExploreState, p: Partial): ExploreState { + if (p.src && p.src !== s.src) return normalize({ src: p.src }); + const next: Partial = { ...s }; + for (const [k, v] of Object.entries(p) as [keyof ExploreState, string | undefined][]) { + const toggleable = (FILTER_KEYS as readonly string[]).includes(k) || k === "year"; + next[k] = (toggleable && v === s[k] ? undefined : v) as never; + } + if (p.list && p.list !== s.list) { + for (const k of FILTER_KEYS) delete next[k]; + delete next.tag; + if (p.list !== "search") delete next.q; + } + return normalize(next); +} + +/** 接口与参数;bgm 每日放送走日历接口 */ +export type ApiQuery = + | { kind: "list"; path: string; params: Record } + | { kind: "calendar"; weekday: string }; + +export function apiQuery(s: ExploreState): ApiQuery { + if (s.src === "bangumi" && s.list === "calendar") return { kind: "calendar", weekday: s.weekday ?? todayWeekday() }; + const params: Record = { list: s.list }; + const pick = (...ks: (keyof ExploreState)[]) => { + for (const k of ks) if (s[k]) params[k] = s[k]!; + }; + if (s.src === "tmdb") pick("type", "window", ...FILTER_KEYS, "q"); + else if (s.src === "douban") pick("type", "tag", "sort", "genre", "region", "year", "q"); + else pick("year", "q"); + if (s.src === "tmdb" && s.list !== "trending") delete params.window; + return { kind: "list", path: `/discover/${s.src}`, params }; +} + +// ── 卡片 ── + +const TMDB_DETAIL = "/console/media/tmdb"; + +/** 直接能去的作品页:本地作品 → 媒体库详情;有 TMDB 身份 → TMDB 作品详情;豆瓣 / bgm 没匹配过 → null(先匹配) */ +export function itemHref(it: Pick): string | null { + if (it.local) return `/console/library/${it.local.entityId}`; + if (it.tmdb) return `${TMDB_DETAIL}/${it.tmdb.type}/${it.tmdb.id}`; + return null; +} + +/** 本地状态徽章:只列非默认状态(不在媒体库不显示) */ +export function localBadges(it: Pick): { label: string; tone: StatusTone }[] { + const l = it.local; + if (!l) return []; + const out: { label: string; tone: StatusTone }[] = []; + if (l.versionCount > 0) out.push({ label: "媒体库已有", tone: "success" }); + else if (l.downloadingCount > 0) out.push({ label: "下载中", tone: "warning" }); + if (l.subscribed) out.push({ label: "已订阅", tone: "neutral" }); + if (!out.length) out.push({ label: "在媒体库", tone: "neutral" }); + return out; +} + +/** 卡片副标题:年份 · 电影 / 剧集 · 前两个风格 */ +export function itemMeta(it: Pick): string { + const parts: string[] = []; + const y = it.year ?? (it.date ? Number(it.date.slice(0, 4)) : null); + if (y) parts.push(String(y)); + if (it.kind) parts.push(it.kind === "movie" ? "电影" : "剧集"); + parts.push(...it.genres.slice(0, 2)); + return parts.join(" · "); +} + +// ── 推荐分区(对照 MP recommendSources.ts) ── + +export type SectionCategory = "movie" | "tv" | "anime" | "chart"; +export const CATEGORIES: Option[] = [ + { value: "", label: "全部" }, + { value: "movie", label: "电影" }, + { value: "tv", label: "剧集" }, + { value: "anime", label: "动画" }, + { value: "chart", label: "榜单" }, +]; + +export interface RecommendSection { + key: string; + title: string; + category: SectionCategory; + explore: Partial; +} + +export const RECOMMEND_SECTIONS: RecommendSection[] = [ + { key: "tmdb-trending", title: "TMDB 流行趋势", category: "chart", explore: { src: "tmdb", list: "trending" } }, + { + key: "tmdb-now-playing", + title: "正在热映", + category: "movie", + explore: { src: "tmdb", list: "now_playing", type: "movie" }, + }, + { + key: "tmdb-popular-movie", + title: "TMDB 热门电影", + category: "movie", + explore: { src: "tmdb", list: "popular", type: "movie" }, + }, + { + key: "tmdb-popular-tv", + title: "TMDB 热门剧集", + category: "tv", + explore: { src: "tmdb", list: "popular", type: "tv" }, + }, + { + key: "douban-movie-hot", + title: "豆瓣热门电影", + category: "movie", + explore: { src: "douban", list: "movie_real_time_hotest" }, + }, + { + key: "douban-tv-hot", + title: "豆瓣热门剧集", + category: "tv", + explore: { src: "douban", list: "tv_real_time_hotest", type: "tv" }, + }, + { key: "bangumi-today", title: "Bangumi 今日放送", category: "anime", explore: { src: "bangumi", list: "calendar" } }, + { key: "bangumi-trends", title: "Bangumi 热门趋势", category: "anime", explore: { src: "bangumi", list: "trends" } }, + { + key: "tmdb-upcoming", + title: "即将上映", + category: "movie", + explore: { src: "tmdb", list: "upcoming", type: "movie" }, + }, + { + key: "tmdb-on-the-air", + title: "正在播出", + category: "tv", + explore: { src: "tmdb", list: "on_the_air", type: "tv" }, + }, + { + key: "douban-animation", + title: "豆瓣热门动画", + category: "anime", + explore: { src: "douban", list: "tv_animation", type: "tv" }, + }, + { + key: "douban-show", + title: "豆瓣热门综艺", + category: "tv", + explore: { src: "douban", list: "show_hot", type: "tv" }, + }, + { + key: "douban-top250", + title: "豆瓣电影 Top250", + category: "chart", + explore: { src: "douban", list: "movie_top250" }, + }, + { + key: "tmdb-top-rated", + title: "TMDB 高分电影", + category: "chart", + explore: { src: "tmdb", list: "top_rated", type: "movie" }, + }, + { + key: "douban-chinese-weekly", + title: "华语口碑剧集榜", + category: "chart", + explore: { src: "douban", list: "tv_chinese_best_weekly", type: "tv" }, + }, + { + key: "douban-global-weekly", + title: "全球口碑剧集榜", + category: "chart", + explore: { src: "douban", list: "tv_global_best_weekly", type: "tv" }, + }, +]; + +export const sectionsOf = (category: string) => + category ? RECOMMEND_SECTIONS.filter((s) => s.category === category) : RECOMMEND_SECTIONS; diff --git a/apps/web/app/components/discover/discover.test.ts b/apps/web/app/components/discover/discover.test.ts new file mode 100644 index 00000000..b7b37a6b --- /dev/null +++ b/apps/web/app/components/discover/discover.test.ts @@ -0,0 +1,191 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type DiscoverItem, DiscoverItem as DiscoverItemSchema } from "@umt/contracts"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it } from "vitest"; +import { DiscoverCard } from "./discover-card.tsx"; +import { + apiQuery, + type ExploreState, + fromSearchParams, + itemHref, + itemMeta, + localBadges, + normalize, + patch, + RECOMMEND_SECTIONS, + sectionsOf, + tmdbListOptions, + toSearchParams, +} from "./discover-state.ts"; +import { ExploreFilters } from "./explore-view.tsx"; +import { RecommendView } from "./recommend-view.tsx"; + +// 发现页:URL ↔ 状态、筛选器联动、接口参数、卡片状态与去处、分区与筛选器渲染。 + +const item = (p: Partial = {}): DiscoverItem => + DiscoverItemSchema.parse({ + source: "tmdb", + id: "movie:872585", + title: "奥本海默", + aliases: [], + year: 2023, + kind: "movie", + cover: null, + rating: 8.1, + date: "2023-07-19", + episodes: null, + url: "https://www.themoviedb.org/movie/872585", + genres: ["剧情", "历史", "传记"], + posterPath: "/oppenheimer.jpg", + tmdb: { type: "movie", id: "872585" }, + ...p, + }); + +const render = (el: ReturnType) => + renderToStaticMarkup( + createElement(QueryClientProvider, { client: new QueryClient() }, createElement(MemoryRouter, null, el)), + ); + +describe("探索状态规整与联动", () => { + it("缺省:TMDB 本周流行趋势;URL 往返", () => { + const s = fromSearchParams(new URLSearchParams()); + expect(s).toEqual({ src: "tmdb", list: "trending", type: "all", window: "week" }); + const t = normalize({ src: "tmdb", list: "discover", type: "tv", genre: "10765", year: "2010s", sort: "rating" }); + expect(fromSearchParams(toSearchParams(t))).toEqual(t); + expect(toSearchParams(t).get("view")).toBe("explore"); + }); + + it("类型与列表互相约束:正在上映只有电影,全部只用于趋势 / 搜索", () => { + expect(normalize({ src: "tmdb", list: "now_playing", type: "tv" }).type).toBe("movie"); + expect(normalize({ src: "tmdb", list: "popular", type: "all" }).type).toBe("movie"); + expect(tmdbListOptions("tv").map((o) => o.value)).not.toContain("now_playing"); + expect(tmdbListOptions("movie").map((o) => o.value)).not.toContain("on_the_air"); + }); + + it("改类型去掉不配的风格;离开筛选清掉条件;芯片再点一次取消", () => { + const s = normalize({ src: "tmdb", list: "discover", type: "movie", genre: "28", region: "US" }); + expect(patch(s, { type: "tv" })).toMatchObject({ type: "tv", region: "US" }); + expect(patch(s, { type: "tv" }).genre).toBeUndefined(); // 28 动作 只有电影有 + expect(patch(s, { list: "popular" })).toEqual({ src: "tmdb", list: "popular", type: "movie" }); + expect(patch(s, { region: "US" }).region).toBeUndefined(); + expect(patch(s, { region: "JP" }).region).toBe("JP"); + }); + + it("换来源回到该来源默认列表;关键词切到搜索,清空回到默认", () => { + const s = normalize({ src: "tmdb", list: "discover", type: "movie", genre: "28" }); + expect(patch(s, { src: "douban" })).toEqual({ src: "douban", list: "movie_real_time_hotest", type: "movie" }); + const q = patch(s, { q: " 沙丘 " }); + expect(q).toMatchObject({ list: "search", q: "沙丘", type: "movie" }); + expect(q.genre).toBeUndefined(); + expect(patch(q, { q: "" }).list).toBe("trending"); + }); + + it("豆瓣标签按类型校正;bgm 每日放送补星期,年份只给排行 / 最新", () => { + expect(normalize({ src: "douban", list: "tag", type: "tv", tag: "豆瓣高分" }).tag).toBe("热门"); + expect(normalize({ src: "douban", list: "tag", type: "tv", tag: "美剧" }).tag).toBe("美剧"); + expect(normalize({ src: "douban", list: "nope" }).list).toBe("movie_real_time_hotest"); + expect(normalize({ src: "bangumi", list: "calendar", weekday: "3" }).weekday).toBe("3"); + expect(normalize({ src: "bangumi", list: "calendar" }).weekday).toMatch(/^[1-7]$/); + expect(normalize({ src: "bangumi", list: "trends", year: "2024" }).year).toBeUndefined(); + expect(normalize({ src: "bangumi", list: "rank", year: "2024" }).year).toBe("2024"); + }); + + it("接口参数:只带当前列表用得到的", () => { + expect(apiQuery(normalize({}))).toEqual({ + kind: "list", + path: "/discover/tmdb", + params: { list: "trending", type: "all", window: "week" }, + }); + expect(apiQuery(normalize({ src: "tmdb", list: "discover", type: "movie", minRating: "7" }))).toEqual({ + kind: "list", + path: "/discover/tmdb", + params: { list: "discover", type: "movie", minRating: "7" }, + }); + expect(apiQuery(normalize({ src: "douban", list: "explore", type: "tv", genre: "悬疑", sort: "S" }))).toEqual({ + kind: "list", + path: "/discover/douban", + params: { list: "explore", type: "tv", sort: "S", genre: "悬疑" }, + }); + expect(apiQuery(normalize({ src: "bangumi", list: "calendar", weekday: "5" }))).toEqual({ + kind: "calendar", + weekday: "5", + }); + expect(apiQuery(normalize({ src: "bangumi", q: "芙莉莲" }))).toEqual({ + kind: "list", + path: "/discover/bangumi", + params: { list: "search", q: "芙莉莲" }, + }); + }); +}); + +describe("卡片", () => { + it("去处:本地作品 → 媒体库;TMDB → TMDB 作品页;没匹配过的豆瓣条目 → 先匹配", () => { + const local = { entityId: "e1", subscribed: true, versionCount: 0, downloadingCount: 0 }; + expect(itemHref(item({ local }))).toBe("/console/library/e1"); + expect(itemHref(item())).toBe("/console/media/tmdb/movie/872585"); + expect(itemHref(item({ source: "douban", id: "1", tmdb: null }))).toBeNull(); + }); + + it("本地状态只列非默认:已有 / 下载中 / 已订阅;在媒体库但都没有时写在媒体库", () => { + const l = (p: object) => ({ entityId: "e", subscribed: false, versionCount: 0, downloadingCount: 0, ...p }); + expect(localBadges(item())).toEqual([]); + expect(localBadges(item({ local: l({ versionCount: 2, subscribed: true }) })).map((b) => b.label)).toEqual([ + "媒体库已有", + "已订阅", + ]); + expect(localBadges(item({ local: l({ downloadingCount: 1 }) })).map((b) => b.label)).toEqual(["下载中"]); + expect(localBadges(item({ local: l({}) })).map((b) => b.label)).toEqual(["在媒体库"]); + expect(itemMeta(item())).toBe("2023 · 电影 · 剧情 · 历史"); + }); + + it("渲染:海报懒加载、评分、状态徽章、订阅与搜索资源", () => { + const html = render( + createElement(DiscoverCard, { + item: item({ local: { entityId: "e", subscribed: true, versionCount: 1, downloadingCount: 0 } }), + busy: null, + onAction: () => {}, + }), + ); + expect(html).toContain('loading="lazy"'); + expect(html).toContain("/api/v1/img/tmdb/w342/oppenheimer.jpg"); + expect(html).toContain("8.1"); + expect(html).toContain("媒体库已有"); + expect(html).toContain("已订阅"); + expect(html).toContain("订阅"); + expect(html).toContain("搜索资源"); + }); +}); + +describe("分区与筛选器", () => { + it("推荐分区覆盖 TMDB 趋势 / 热门、豆瓣、Bangumi;按分类筛", () => { + const keys = RECOMMEND_SECTIONS.map((s) => s.key); + expect(keys).toEqual( + expect.arrayContaining(["tmdb-trending", "tmdb-popular-tv", "douban-movie-hot", "bangumi-today"]), + ); + expect(sectionsOf("anime").every((s) => s.category === "anime")).toBe(true); + // 每个分区的参数都是规整后的合法状态(查看全部能直接打开) + for (const s of RECOMMEND_SECTIONS) expect(normalize(normalize(s.explore))).toEqual(normalize(s.explore)); + const html = render(createElement(RecommendView, { category: "tv", onCategory: () => {} })); + for (const s of sectionsOf("tv")) expect(html).toContain(s.title); + expect(html).not.toContain("TMDB 流行趋势"); + expect(html).toContain("查看全部"); + expect(html).toContain('aria-hidden="true"'); // 骨架屏 + }); + + it("筛选器随来源 / 列表出现", () => { + const html = (s: ExploreState) => render(createElement(ExploreFilters, { s, onPatch: () => {} })); + const disc = html(normalize({ src: "tmdb", list: "discover", type: "movie" })); + for (const l of ["风格", "地区", "语言", "年份", "评分", "排序", "科幻"]) expect(disc).toContain(l); + const trending = html(normalize({ src: "tmdb" })); + expect(trending).toContain("本周"); + expect(trending).not.toContain("风格"); + const douban = html(normalize({ src: "douban", list: "explore" })); + expect(douban).toContain("年代"); + expect(douban).toContain("悬疑"); + const bgm = html(normalize({ src: "bangumi", list: "calendar", weekday: "1" })); + expect(bgm).toContain("周一"); + expect(bgm).toContain("在 Bangumi 搜动画"); + }); +}); diff --git a/apps/web/app/components/discover/explore-view.tsx b/apps/web/app/components/discover/explore-view.tsx new file mode 100644 index 00000000..d83f8f2c --- /dev/null +++ b/apps/web/app/components/discover/explore-view.tsx @@ -0,0 +1,299 @@ +import { DOUBAN_TAGS, tmdbGenres } from "@umt/contracts"; +import { Search, X } from "lucide-react"; +import { type FormEvent, useEffect, useRef, useState } from "react"; +import { useDiscoverInfinite } from "~/api/discover.ts"; +import { EmptyState, ErrorState } from "~/components/console/empty-state.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { ChipRow } from "./chips.tsx"; +import { DiscoverCard, DiscoverCardSkeleton } from "./discover-card.tsx"; +import { + apiQuery, + BANGUMI_LIST_OPTIONS, + DOUBAN_GENRE_OPTIONS, + DOUBAN_LIST_OPTIONS, + DOUBAN_REGION_OPTIONS, + DOUBAN_SORT_OPTIONS, + type ExploreState, + MEDIA_TYPES, + type Option, + RATING_OPTIONS, + SOURCES, + type Src, + TMDB_LANGUAGE_OPTIONS, + TMDB_REGION_OPTIONS, + TMDB_SORT_OPTIONS, + TMDB_TYPES, + tmdbListOptions, + WEEKDAYS, + WINDOWS, + yearOptions, +} from "./discover-state.ts"; +import { useDiscoverActions } from "./use-discover-actions.tsx"; + +// 探索(对照 MP discover.vue):选来源后出现该来源的筛选器;关键词搜索;结果网格无限滚动(接口分页)。 + +const GRID = "grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 2xl:grid-cols-8"; + +const SEARCH_HINT: Record = { + tmdb: "在 TMDB 搜作品", + douban: "在豆瓣搜作品", + bangumi: "在 Bangumi 搜动画", +}; + +export function ExploreView({ s, onPatch }: { s: ExploreState; onPatch: (p: Partial) => void }) { + return ( +
+ + +
+ ); +} + +export function ExploreFilters({ s, onPatch }: { s: ExploreState; onPatch: (p: Partial) => void }) { + const [input, setInput] = useState(s.q ?? ""); + useEffect(() => setInput(s.q ?? ""), [s.q]); + const submit = (e: FormEvent) => { + e.preventDefault(); + onPatch({ q: input.trim() }); + }; + const searching = s.list === "search"; + return ( +
+
+ v && onPatch({ src: v as Src })} /> +
+ + setInput(e.target.value)} + placeholder={SEARCH_HINT[s.src]} + aria-label={SEARCH_HINT[s.src]} + className="pr-9 pl-9" + /> + {searching ? ( + + ) : null} + +
+ {searching ? ( + s.src === "tmdb" ? ( + v && onPatch({ type: v })} /> + ) : null + ) : s.src === "tmdb" ? ( + + ) : s.src === "douban" ? ( + + ) : ( + + )} +
+ ); +} + +type FiltersProps = { s: ExploreState; onPatch: (p: Partial) => void }; +const set = (onPatch: FiltersProps["onPatch"], k: keyof ExploreState) => (v: string | undefined) => + onPatch({ [k]: v } as Partial); + +function TmdbFilters({ s, onPatch }: FiltersProps) { + const genres: Option[] = + s.type === "movie" || s.type === "tv" + ? Object.entries(tmdbGenres(s.type)).map(([value, label]) => ({ value, label })) + : []; + const types = s.list === "trending" ? TMDB_TYPES : MEDIA_TYPES; + return ( + <> + v && onPatch({ type: v })} /> + v && onPatch({ list: v })} + /> + {s.list === "trending" ? ( + v && onPatch({ window: v })} /> + ) : null} + {s.list === "discover" ? ( + <> + + + + + + + + ) : null} + + ); +} + +function DoubanFilters({ s, onPatch }: FiltersProps) { + const free = s.list === "tag" || s.list === "explore"; + return ( + <> + v && onPatch({ list: v })} /> + {free ? ( + v && onPatch({ type: v })} /> + ) : null} + {s.list === "tag" ? ( + ({ value: t, label: t }))} + value={s.tag} + onChange={(v) => v && onPatch({ tag: v })} + /> + ) : null} + {s.list === "explore" ? ( + <> + + + + + + ) : null} + + ); +} + +function BangumiFilters({ s, onPatch }: FiltersProps) { + return ( + <> + v && onPatch({ list: v })} /> + {s.list === "calendar" ? ( + v && onPatch({ weekday: v })} /> + ) : null} + {s.list === "rank" || s.list === "latest" ? ( + + ) : null} + + ); +} + +function ExploreResults({ s }: { s: ExploreState }) { + const q = useDiscoverInfinite(apiQuery(s)); + const actions = useDiscoverActions(); + const sentinel = useRef(null); + const { hasNextPage, isFetchingNextPage, fetchNextPage } = q; + useEffect(() => { + const el = sentinel.current; + if (!el || typeof IntersectionObserver === "undefined") return; + const io = new IntersectionObserver( + (es) => { + if (es.some((e) => e.isIntersecting) && hasNextPage && !isFetchingNextPage) void fetchNextPage(); + }, + { rootMargin: "800px 0px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (q.isPending) + return ( +
    + {Array.from({ length: 12 }, (_, i) => ( +
  • + +
  • + ))} +
+ ); + if (q.isError) return void q.refetch()} />; + // 跨页去重(榜单翻页时条目可能挪位) + const seen = new Set(); + const items = q.data.pages + .flatMap((p) => p.items) + .filter((it) => { + const k = `${it.source}:${it.id}`; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + if (!items.length) + return ( + + ); + return ( + <> +
    + {items.map((it) => ( +
  • + void actions.act(it, a)} /> +
  • + ))} +
+
+ {hasNextPage ? ( + + ) : ( + 共 {items.length} 个 + )} +
+ {actions.dialogs} + + ); +} diff --git a/apps/web/app/components/discover/recommend-view.tsx b/apps/web/app/components/discover/recommend-view.tsx new file mode 100644 index 00000000..b8c21738 --- /dev/null +++ b/apps/web/app/components/discover/recommend-view.tsx @@ -0,0 +1,105 @@ +import { ChevronRight } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router"; +import { useDiscoverSection } from "~/api/discover.ts"; +import { Button } from "~/components/ui/button.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { ChipRow } from "./chips.tsx"; +import { DiscoverCard, DiscoverCardSkeleton } from "./discover-card.tsx"; +import { + apiQuery, + CATEGORIES, + normalize, + type RecommendSection, + sectionsOf, + toSearchParams, +} from "./discover-state.ts"; +import { useDiscoverActions } from "./use-discover-actions.tsx"; + +// 推荐(对照 MP recommend.vue):按分区横排,分区进入视口附近才加载(各分区各自请求、并发), +// 每区「查看全部」带同样的参数切到探索。顶部按 全部 / 电影 / 剧集 / 动画 / 榜单 筛分区。 + +const CARD_W = "w-32 shrink-0 sm:w-36"; + +/** 进入视口(提前 600px)后为 true;没有 IntersectionObserver 时直接 true */ +function useNearViewport() { + const ref = useRef(null); + const [near, setNear] = useState(() => typeof IntersectionObserver === "undefined"); + useEffect(() => { + if (near || !ref.current) return; + const io = new IntersectionObserver((es) => es.some((e) => e.isIntersecting) && setNear(true), { + rootMargin: "600px 0px", + }); + io.observe(ref.current); + return () => io.disconnect(); + }, [near]); + return [ref, near] as const; +} + +export function RecommendView({ + category, + onCategory, +}: { + category: string; + onCategory: (c: string | undefined) => void; +}) { + const actions = useDiscoverActions(); + return ( +
+ + {sectionsOf(category).map((s) => ( +
+ ))} + {actions.dialogs} +
+ ); +} + +function Section({ section, actions }: { section: RecommendSection; actions: ReturnType }) { + const [ref, near] = useNearViewport(); + const state = normalize(section.explore); + const q = useDiscoverSection(apiQuery(state), near); + const items = q.data?.items ?? []; + const id = `discover-${section.key}`; + return ( +
+
+

+ {section.title} +

+ +
+ {q.isError ? ( +
+ {errorMessage(q.error, "加载失败")} + +
+ ) : q.isSuccess && items.length === 0 ? ( +

+ 这里暂时没有条目。 +

+ ) : ( +
    + {q.isSuccess + ? items.slice(0, 20).map((it) => ( +
  • + void actions.act(it, a)} /> +
  • + )) + : Array.from({ length: 8 }, (_, i) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/components/discover/use-discover-actions.tsx b/apps/web/app/components/discover/use-discover-actions.tsx new file mode 100644 index 00000000..83361703 --- /dev/null +++ b/apps/web/app/components/discover/use-discover-actions.tsx @@ -0,0 +1,193 @@ +import { type DiscoverItem, type DiscoverMatchCandidate, tmdbImageUrl } from "@umt/contracts"; +import { ExternalLink, Search } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { Link, useNavigate } from "react-router"; +import { toast } from "sonner"; +import { useDiscoverMatch } from "~/api/discover.ts"; +import { consolePath } from "~/components/console/nav.ts"; +import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx"; +import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import type { CardAction } from "./discover-card.tsx"; +import { itemHref } from "./discover-state.ts"; + +// 卡片动作:进作品 / 订阅 / 搜索资源。都要先知道对应的作品:本地作品或 TMDB 条目直接用; +// 豆瓣 / bgm 条目先按名称 + 年份匹配 TMDB(服务端记住确定的匹配),不确定时列候选让人选,选中后继续原来的动作。 + +interface Target { + title: string; + year: number | null; + type: "movie" | "tv"; + tmdbId: string; + entityId: string | null; +} + +const SOURCE_LABEL: Record = { douban: "豆瓣", bangumi: "bgm.tv", tmdb: "TMDB" }; + +export function useDiscoverActions() { + const navigate = useNavigate(); + const match = useDiscoverMatch(); + const launcher = useReleaseSearchLauncher(); + const [busy, setBusy] = useState<{ key: string; action: CardAction } | null>(null); + const [picking, setPicking] = useState<{ + item: DiscoverItem; + action: CardAction; + candidates: DiscoverMatchCandidate[]; + } | null>(null); + const [subscribing, setSubscribing] = useState(null); + + const run = (action: CardAction, t: Target) => { + if (action === "open") { + navigate(t.entityId ? consolePath("library", t.entityId) : consolePath("media", "tmdb", t.type, t.tmdbId)); + } else if (action === "subscribe") setSubscribing(t); + else launcher.open({ title: t.title, ref: t.entityId ? `local:${t.entityId}` : `tmdb:${t.type}:${t.tmdbId}` }); + }; + + const act = async (item: DiscoverItem, action: CardAction) => { + const known = item.tmdb; + if (action === "open") { + const href = itemHref(item); + if (href) return navigate(href); + } + if (known) { + return run(action, { + title: item.title, + year: item.year, + type: known.type, + tmdbId: known.id, + entityId: item.local?.entityId ?? null, + }); + } + setBusy({ key: `${item.source}:${item.id}`, action }); + try { + const r = await match.mutateAsync(item); + if (r.match) run(action, { ...r.match, tmdbId: r.match.id, entityId: null }); + else setPicking({ item, action, candidates: r.candidates }); + } catch (err) { + toast.error(errorMessage(err, "匹配 TMDB 失败")); + } finally { + setBusy(null); + } + }; + + const dialogs: ReactNode = ( + <> + {picking ? ( + { + setPicking(null); + run(picking.action, { ...c, tmdbId: c.id, entityId: null }); + }} + onClose={() => setPicking(null)} + /> + ) : null} + {subscribing ? ( + setSubscribing(null)} + onCreated={() => toast.success(`已订阅「${subscribing.title}」`)} + /> + ) : null} + {launcher.dialog} + + ); + + return { + act, + busyOf: (item: DiscoverItem): CardAction | null => + busy && busy.key === `${item.source}:${item.id}` ? busy.action : null, + dialogs, + }; +} + +function PickDialog({ + item, + candidates, + onPick, + onClose, +}: { + item: DiscoverItem; + candidates: DiscoverMatchCandidate[]; + onPick: (c: DiscoverMatchCandidate) => void; + onClose: () => void; +}) { + return ( + !o && onClose()}> + + + 选择 TMDB 作品 + + 「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。 + + + {candidates.length ? ( +
    + {candidates.map((c) => ( +
  • + +
  • + ))} +
+ ) : ( +

TMDB 上没有找到相近的作品。

+ )} +
+ + +
+
+
+ ); +} diff --git a/apps/web/app/routes/console/discover.tsx b/apps/web/app/routes/console/discover.tsx index b56c47da..6eba0786 100644 --- a/apps/web/app/routes/console/discover.tsx +++ b/apps/web/app/routes/console/discover.tsx @@ -1,215 +1,53 @@ -import { type DiscoverItem, type DiscoverMatchResult, tmdbImageUrl } from "@umt/contracts"; -import { ExternalLink, LoaderCircle, Search, Star } from "lucide-react"; -import { useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router"; -import { toast } from "sonner"; -import { useBangumiCalendar, useDiscoverMatch, useDoubanHot } from "~/api/discover.ts"; -import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; -import { consolePath } from "~/components/console/nav.ts"; +import { useSearchParams } from "react-router"; import { PageHeader } from "~/components/console/page-header.tsx"; -import { Button } from "~/components/ui/button.tsx"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx"; +import { + type ExploreState, + fromSearchParams, + patch, + toSearchParams, + type View, +} from "~/components/discover/discover-state.ts"; +import { ExploreView } from "~/components/discover/explore-view.tsx"; +import { RecommendView } from "~/components/discover/recommend-view.tsx"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; -import { errorMessage } from "~/lib/api.ts"; -// 发现(对照 MP 推荐页):bgm.tv 每日放送、豆瓣热门电影 / 剧集。点条目按名称 + 年份匹配 TMDB 作品, -// 确定的直接进作品页(可订阅、搜资源),不确定的列候选让人选。 +// 发现(对照 MP 推荐 + 探索,docs/porting/moviepilot-discover.md): +// 「推荐」按分区横排 TMDB / 豆瓣 / Bangumi 的热门与榜单,每区「查看全部」带参数切到「探索」; +// 「探索」选来源后按类型、风格、地区、语言、年份、评分、排序筛选或搜关键词。状态都在 URL 里。 +// 卡片标本地状态;点开进作品,豆瓣 / bgm 条目先匹配 TMDB 作品。 export const meta = () => [{ title: "发现 · UHD 媒体自动化控制台" }]; -type View = "anime" | "movie" | "tv"; -const VIEWS: { value: View; label: string }[] = [ - { value: "anime", label: "番剧放送" }, - { value: "movie", label: "热门电影" }, - { value: "tv", label: "热门剧集" }, -]; - -/** bgm 周一 = 1 … 周日 = 7 */ -const todayWeekday = () => ((new Date().getDay() + 6) % 7) + 1; - export default function DiscoverPage() { const [sp, setSp] = useSearchParams(); - const view = (VIEWS.some((v) => v.value === sp.get("view")) ? sp.get("view") : "anime") as View; - const [day, setDay] = useState(todayWeekday); - const cal = useBangumiCalendar(view === "anime"); - const hot = useDoubanHot(view === "tv" ? "tv" : "movie", view !== "anime"); - const match = useDiscoverMatch(); - const navigate = useNavigate(); - const [picking, setPicking] = useState<{ item: DiscoverItem; result: DiscoverMatchResult } | null>(null); - const [busyId, setBusyId] = useState(null); + const view: View = sp.get("view") === "explore" || sp.has("src") ? "explore" : "recommend"; + const explore = fromSearchParams(sp); + const category = sp.get("cat") ?? ""; - const open = async (it: DiscoverItem) => { - setBusyId(`${it.source}:${it.id}`); - try { - const r = await match.mutateAsync(it); - if (r.match) navigate(consolePath("media", "tmdb", r.match.type, r.match.id)); - else setPicking({ item: it, result: r }); - } catch (err) { - toast.error(errorMessage(err, "匹配 TMDB 失败")); - } finally { - setBusyId(null); - } - }; - - const q = view === "anime" ? cal : hot; - const items = - view === "anime" ? (cal.data?.days.find((d) => d.weekday === day)?.items ?? []) : (hot.data?.items ?? []); + const go = (next: ExploreState) => setSp(toSearchParams(next), { replace: false }); return ( <> - - setSp(new URLSearchParams({ view: v }), { replace: true })}> - - {VIEWS.map((v) => ( - - {v.label} - - ))} + + + setSp(v === "explore" ? toSearchParams(explore) : new URLSearchParams(category ? { cat: category } : {})) + } + > + + 推荐 + 探索 - {view === "anime" && cal.data ? ( -
- {cal.data.days.map((d) => ( - - ))} -
- ) : null} - {q.isLoading ? ( - - ) : q.error ? ( - void q.refetch()} /> - ) : items.length === 0 ? ( - + {view === "recommend" ? ( + setSp(new URLSearchParams(c ? { cat: c } : {}), { replace: true })} + /> ) : ( -
    - {items.map((it) => ( -
  • - void open(it)} /> -
  • - ))} -
+ go(patch(explore, p))} /> )} - {picking ? setPicking(null)} /> : null} ); } - -function ItemCard({ item, busy, onOpen }: { item: DiscoverItem; busy: boolean; onOpen: () => void }) { - const [failed, setFailed] = useState(false); - return ( - - ); -} - -function PickDialog({ - item, - result, - onClose, -}: { - item: DiscoverItem; - result: DiscoverMatchResult; - onClose: () => void; -}) { - return ( - !o && onClose()}> - - - 选择 TMDB 作品 - - 「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。 - - - {result.candidates.length ? ( -
    - {result.candidates.map((c) => ( -
  • - - - {c.posterPath ? ( - - ) : null} - - - {c.title} - - {c.type === "movie" ? "电影" : "剧集"} · {c.year ?? "—"} - - - -
  • - ))} -
- ) : ( -

TMDB 上没有找到相近的作品。

- )} - -
-
- ); -}