feat(web): 发现页改为「推荐」分区 +「探索」筛选,卡片带本地状态与订阅 / 搜索资源
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
9a154bc249
commit
740e2fe67d
9 files changed
+1422
-211
No files matched your search
@@ -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 (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="w-10 shrink-0 text-meta text-muted-foreground">{label}</span>
|
||||
<fieldset aria-label={label} className="scroll-bleed flex min-w-0 gap-1 overflow-x-auto border-0">
|
||||
{all.map((o) => {
|
||||
const on = (value ?? "") === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value || "_all"}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onChange(o.value || undefined)}
|
||||
className={cn(
|
||||
"h-7 shrink-0 cursor-pointer rounded-full border border-transparent px-2.5 text-meta whitespace-nowrap text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
on && "pill-selected text-foreground",
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<DiscoverItem, "cover" | "posterPath">): 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 (
|
||||
<article className={cn("group flex min-w-0 flex-col", className)} aria-label={item.title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction("open")}
|
||||
disabled={!!busy}
|
||||
className="relative block aspect-[2/3] w-full cursor-pointer overflow-hidden rounded-xl border border-border/70 bg-foreground/[0.05] text-left transition-colors duration-base hover:border-foreground/40 disabled:cursor-wait"
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setFailed(true)}
|
||||
className="h-full w-full object-cover transition-transform duration-slow group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-full w-full place-items-center px-2 text-center text-title font-semibold text-foreground/30">
|
||||
{item.title.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
{item.rating ? (
|
||||
<span className="absolute top-1.5 left-1.5 inline-flex items-center gap-0.5 rounded-full bg-background/80 px-1.5 py-0.5 text-micro font-medium text-foreground backdrop-blur">
|
||||
<Star className="h-3 w-3" aria-hidden />
|
||||
{item.rating.toFixed(1)}
|
||||
</span>
|
||||
) : null}
|
||||
{badges.length ? (
|
||||
<span className="absolute inset-x-1.5 bottom-1.5 flex flex-wrap gap-1">
|
||||
{badges.map((b) => (
|
||||
<StatusBadge key={b.label} tone={b.tone} className="bg-background/85 backdrop-blur">
|
||||
{b.label}
|
||||
</StatusBadge>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
{busy ? (
|
||||
<span className="absolute inset-0 grid place-items-center bg-background/60">
|
||||
<LoaderCircle className="h-5 w-5 animate-spin" aria-label="匹配中" />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<div className="min-w-0 pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction("open")}
|
||||
disabled={!!busy}
|
||||
className="block w-full cursor-pointer truncate text-left text-caption font-medium text-foreground hover:underline"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
<p className="truncate text-meta text-muted-foreground">{meta || "—"}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onAction("subscribe")}
|
||||
disabled={!!busy}
|
||||
aria-label={`订阅「${item.title}」`}
|
||||
>
|
||||
<CalendarPlus />
|
||||
订阅
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onAction("search")}
|
||||
disabled={!!busy}
|
||||
aria-label={`搜索「${item.title}」的资源`}
|
||||
>
|
||||
<PackageSearch />
|
||||
搜索资源
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscoverCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex min-w-0 flex-col", className)} aria-hidden>
|
||||
<div className="aspect-[2/3] w-full animate-pulse rounded-xl bg-foreground/[0.07]" />
|
||||
<div className="mt-2 h-3.5 w-4/5 animate-pulse rounded-tight bg-foreground/[0.07]" />
|
||||
<div className="mt-1.5 h-3 w-1/2 animate-pulse rounded-tight bg-foreground/[0.05]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>): 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<Src, string> = {
|
||||
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>): 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<ExploreState> = {};
|
||||
for (const k of KEYS) {
|
||||
const v = sp.get(k);
|
||||
if (v) (raw as Record<string, string>)[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>): ExploreState {
|
||||
if (p.src && p.src !== s.src) return normalize({ src: p.src });
|
||||
const next: Partial<ExploreState> = { ...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<string, string> }
|
||||
| { 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<string, string> = { 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<DiscoverItem, "local" | "tmdb">): 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<DiscoverItem, "local">): { 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<DiscoverItem, "year" | "date" | "kind" | "genres">): 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<ExploreState>;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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> = {}): 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<typeof createElement>) =>
|
||||
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 搜动画");
|
||||
});
|
||||
});
|
||||
@@ -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<Src, string> = {
|
||||
tmdb: "在 TMDB 搜作品",
|
||||
douban: "在豆瓣搜作品",
|
||||
bangumi: "在 Bangumi 搜动画",
|
||||
};
|
||||
|
||||
export function ExploreView({ s, onPatch }: { s: ExploreState; onPatch: (p: Partial<ExploreState>) => void }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<ExploreFilters s={s} onPatch={onPatch} />
|
||||
<ExploreResults key={JSON.stringify(apiQuery(s))} s={s} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExploreFilters({ s, onPatch }: { s: ExploreState; onPatch: (p: Partial<ExploreState>) => 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 (
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ChipRow label="来源" options={SOURCES} value={s.src} onChange={(v) => v && onPatch({ src: v as Src })} />
|
||||
<form onSubmit={submit} className="relative ml-auto w-full sm:w-72">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={SEARCH_HINT[s.src]}
|
||||
aria-label={SEARCH_HINT[s.src]}
|
||||
className="pr-9 pl-9"
|
||||
/>
|
||||
{searching ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="清除搜索"
|
||||
onClick={() => onPatch({ q: "" })}
|
||||
className="absolute top-1/2 right-2 grid size-6 -translate-y-1/2 cursor-pointer place-items-center rounded-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
{searching ? (
|
||||
s.src === "tmdb" ? (
|
||||
<ChipRow label="类型" options={TMDB_TYPES} value={s.type} onChange={(v) => v && onPatch({ type: v })} />
|
||||
) : null
|
||||
) : s.src === "tmdb" ? (
|
||||
<TmdbFilters s={s} onPatch={onPatch} />
|
||||
) : s.src === "douban" ? (
|
||||
<DoubanFilters s={s} onPatch={onPatch} />
|
||||
) : (
|
||||
<BangumiFilters s={s} onPatch={onPatch} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FiltersProps = { s: ExploreState; onPatch: (p: Partial<ExploreState>) => void };
|
||||
const set = (onPatch: FiltersProps["onPatch"], k: keyof ExploreState) => (v: string | undefined) =>
|
||||
onPatch({ [k]: v } as Partial<ExploreState>);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<ChipRow label="类型" options={types} value={s.type} onChange={(v) => v && onPatch({ type: v })} />
|
||||
<ChipRow
|
||||
label="列表"
|
||||
options={tmdbListOptions(s.type)}
|
||||
value={s.list}
|
||||
onChange={(v) => v && onPatch({ list: v })}
|
||||
/>
|
||||
{s.list === "trending" ? (
|
||||
<ChipRow label="时间" options={WINDOWS} value={s.window} onChange={(v) => v && onPatch({ window: v })} />
|
||||
) : null}
|
||||
{s.list === "discover" ? (
|
||||
<>
|
||||
<ChipRow
|
||||
label="排序"
|
||||
options={TMDB_SORT_OPTIONS}
|
||||
value={s.sort ?? "popularity"}
|
||||
onChange={set(onPatch, "sort")}
|
||||
/>
|
||||
<ChipRow label="风格" options={genres} allLabel="不限" value={s.genre} onChange={set(onPatch, "genre")} />
|
||||
<ChipRow
|
||||
label="地区"
|
||||
options={TMDB_REGION_OPTIONS}
|
||||
allLabel="不限"
|
||||
value={s.region}
|
||||
onChange={set(onPatch, "region")}
|
||||
/>
|
||||
<ChipRow
|
||||
label="语言"
|
||||
options={TMDB_LANGUAGE_OPTIONS}
|
||||
allLabel="不限"
|
||||
value={s.language}
|
||||
onChange={set(onPatch, "language")}
|
||||
/>
|
||||
<ChipRow
|
||||
label="年份"
|
||||
options={yearOptions("tmdb")}
|
||||
allLabel="不限"
|
||||
value={s.year}
|
||||
onChange={set(onPatch, "year")}
|
||||
/>
|
||||
<ChipRow
|
||||
label="评分"
|
||||
options={RATING_OPTIONS}
|
||||
allLabel="不限"
|
||||
value={s.minRating}
|
||||
onChange={set(onPatch, "minRating")}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DoubanFilters({ s, onPatch }: FiltersProps) {
|
||||
const free = s.list === "tag" || s.list === "explore";
|
||||
return (
|
||||
<>
|
||||
<ChipRow label="列表" options={DOUBAN_LIST_OPTIONS} value={s.list} onChange={(v) => v && onPatch({ list: v })} />
|
||||
{free ? (
|
||||
<ChipRow label="类型" options={MEDIA_TYPES} value={s.type} onChange={(v) => v && onPatch({ type: v })} />
|
||||
) : null}
|
||||
{s.list === "tag" ? (
|
||||
<ChipRow
|
||||
label="标签"
|
||||
options={DOUBAN_TAGS[s.type as "movie" | "tv"].map((t) => ({ value: t, label: t }))}
|
||||
value={s.tag}
|
||||
onChange={(v) => v && onPatch({ tag: v })}
|
||||
/>
|
||||
) : null}
|
||||
{s.list === "explore" ? (
|
||||
<>
|
||||
<ChipRow label="排序" options={DOUBAN_SORT_OPTIONS} value={s.sort ?? "U"} onChange={set(onPatch, "sort")} />
|
||||
<ChipRow
|
||||
label="风格"
|
||||
options={DOUBAN_GENRE_OPTIONS}
|
||||
allLabel="不限"
|
||||
value={s.genre}
|
||||
onChange={set(onPatch, "genre")}
|
||||
/>
|
||||
<ChipRow
|
||||
label="地区"
|
||||
options={DOUBAN_REGION_OPTIONS}
|
||||
allLabel="不限"
|
||||
value={s.region}
|
||||
onChange={set(onPatch, "region")}
|
||||
/>
|
||||
<ChipRow
|
||||
label="年代"
|
||||
options={yearOptions("douban")}
|
||||
allLabel="不限"
|
||||
value={s.year}
|
||||
onChange={set(onPatch, "year")}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BangumiFilters({ s, onPatch }: FiltersProps) {
|
||||
return (
|
||||
<>
|
||||
<ChipRow label="列表" options={BANGUMI_LIST_OPTIONS} value={s.list} onChange={(v) => v && onPatch({ list: v })} />
|
||||
{s.list === "calendar" ? (
|
||||
<ChipRow label="星期" options={WEEKDAYS} value={s.weekday} onChange={(v) => v && onPatch({ weekday: v })} />
|
||||
) : null}
|
||||
{s.list === "rank" || s.list === "latest" ? (
|
||||
<ChipRow
|
||||
label="年份"
|
||||
options={yearOptions("bangumi")}
|
||||
allLabel="不限"
|
||||
value={s.year}
|
||||
onChange={set(onPatch, "year")}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ExploreResults({ s }: { s: ExploreState }) {
|
||||
const q = useDiscoverInfinite(apiQuery(s));
|
||||
const actions = useDiscoverActions();
|
||||
const sentinel = useRef<HTMLDivElement>(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 (
|
||||
<ul className={GRID} aria-busy>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<li key={i}>
|
||||
<DiscoverCardSkeleton />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
if (q.isError) return <ErrorState error={q.error} onRetry={() => void q.refetch()} />;
|
||||
// 跨页去重(榜单翻页时条目可能挪位)
|
||||
const seen = new Set<string>();
|
||||
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 (
|
||||
<EmptyState
|
||||
title={s.list === "search" ? "没有搜到作品" : "没有条目"}
|
||||
description={s.list === "search" ? "换个关键词,或换个来源再搜。" : "换个列表或放宽筛选条件。"}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ul className={GRID}>
|
||||
{items.map((it) => (
|
||||
<li key={`${it.source}:${it.id}`}>
|
||||
<DiscoverCard item={it} busy={actions.busyOf(it)} onAction={(a) => void actions.act(it, a)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div ref={sentinel} className="flex justify-center py-4">
|
||||
{hasNextPage ? (
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchNextPage()} disabled={isFetchingNextPage}>
|
||||
{isFetchingNextPage ? "加载中…" : "加载更多"}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-meta text-muted-foreground">共 {items.length} 个</span>
|
||||
)}
|
||||
</div>
|
||||
{actions.dialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<T extends Element>() {
|
||||
const ref = useRef<T>(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 (
|
||||
<div className="space-y-7">
|
||||
<ChipRow label="分类" options={CATEGORIES.slice(1)} allLabel="全部" value={category} onChange={onCategory} />
|
||||
{sectionsOf(category).map((s) => (
|
||||
<Section key={s.key} section={s} actions={actions} />
|
||||
))}
|
||||
{actions.dialogs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ section, actions }: { section: RecommendSection; actions: ReturnType<typeof useDiscoverActions> }) {
|
||||
const [ref, near] = useNearViewport<HTMLElement>();
|
||||
const state = normalize(section.explore);
|
||||
const q = useDiscoverSection(apiQuery(state), near);
|
||||
const items = q.data?.items ?? [];
|
||||
const id = `discover-${section.key}`;
|
||||
return (
|
||||
<section ref={ref} aria-labelledby={id} className="min-w-0" data-section={section.key}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h2 id={id} className="text-body font-semibold text-foreground">
|
||||
{section.title}
|
||||
</h2>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to={`?${toSearchParams(state)}`}>
|
||||
查看全部
|
||||
<ChevronRight />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{q.isError ? (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-6 text-caption text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{errorMessage(q.error, "加载失败")}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => void q.refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : q.isSuccess && items.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border px-4 py-6 text-caption text-muted-foreground">
|
||||
这里暂时没有条目。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="scroll-bleed flex gap-3 overflow-x-auto">
|
||||
{q.isSuccess
|
||||
? items.slice(0, 20).map((it) => (
|
||||
<li key={`${it.source}:${it.id}`} className={CARD_W}>
|
||||
<DiscoverCard item={it} busy={actions.busyOf(it)} onAction={(a) => void actions.act(it, a)} />
|
||||
</li>
|
||||
))
|
||||
: Array.from({ length: 8 }, (_, i) => (
|
||||
<li key={i} className={CARD_W}>
|
||||
<DiscoverCardSkeleton />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = { 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<Target | null>(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 ? (
|
||||
<PickDialog
|
||||
item={picking.item}
|
||||
candidates={picking.candidates}
|
||||
onPick={(c) => {
|
||||
setPicking(null);
|
||||
run(picking.action, { ...c, tmdbId: c.id, entityId: null });
|
||||
}}
|
||||
onClose={() => setPicking(null)}
|
||||
/>
|
||||
) : null}
|
||||
{subscribing ? (
|
||||
<SubscriptionFormDialog
|
||||
initialTarget={
|
||||
subscribing.entityId
|
||||
? {
|
||||
kind: "entity",
|
||||
id: subscribing.entityId,
|
||||
title: subscribing.title,
|
||||
year: subscribing.year,
|
||||
mediaKind: subscribing.type === "movie" ? "movie" : "series",
|
||||
}
|
||||
: {
|
||||
kind: "tmdb",
|
||||
entityType: subscribing.type,
|
||||
externalId: subscribing.tmdbId,
|
||||
title: subscribing.title,
|
||||
year: subscribing.year,
|
||||
mediaKind: subscribing.type === "movie" ? "movie" : "series",
|
||||
}
|
||||
}
|
||||
onClose={() => 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 (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择 TMDB 作品</DialogTitle>
|
||||
<DialogDescription>
|
||||
「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{candidates.length ? (
|
||||
<ul className="grid gap-2">
|
||||
{candidates.map((c) => (
|
||||
<li key={`${c.type}:${c.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(c)}
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-lg border border-border/70 p-2 text-left hover:border-foreground/40 hover:bg-accent"
|
||||
>
|
||||
<span className="h-14 w-10 shrink-0 overflow-hidden rounded-tight bg-muted">
|
||||
{c.posterPath ? (
|
||||
<img
|
||||
src={tmdbImageUrl(c.posterPath, "w92") ?? ""}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-caption font-medium">{c.title}</span>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{c.type === "movie" ? "电影" : "剧集"} · {c.year ?? "—"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">TMDB 上没有找到相近的作品。</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`${consolePath("search")}?q=${encodeURIComponent(item.title)}`} onClick={onClose}>
|
||||
<Search />
|
||||
按名称搜索
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a href={item.url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink />
|
||||
{SOURCE_LABEL[item.source] ?? item.source}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user