feat(web): 发现页改为「推荐」分区 +「探索」筛选,卡片带本地状态与订阅 / 搜索资源

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:09:50 +08:00
1 parent 9a154bc249
commit 740e2fe67d
9 files changed
+1422 -211

No files matched your search

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