docs(plan): on-demand site detail page parsing (HHClub details.php)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
94fd48bff2
commit
ac478673bc
39 files changed
+2446
-339
No files matched your search
@@ -1,21 +1,26 @@
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ManualAcquisitionResult,
|
||||
MediaServerInventory,
|
||||
ReleaseSearchPage,
|
||||
type ReleaseSearchSort,
|
||||
type SearchReleasePlanRequest,
|
||||
SearchResponse,
|
||||
type SearchScope,
|
||||
TmdbMediaDetail,
|
||||
} from "@umt/contracts";
|
||||
import { api } from "~/lib/api.ts";
|
||||
import { getParsed, parseOrThrow } from "./library.ts";
|
||||
|
||||
// 全局搜索(W9-G):命令面板查 media/actions(快);资源结果页走 /search/releases(筛选/排序/游标,与 /search 共用 30s 缓存)。
|
||||
// 全局搜索(W9-G / SEARCH-2):先查作品(本地 + TMDB,带库存对比,不碰站点);需要时按作品显式搜资源
|
||||
// /search/releases(站点可选,筛选/排序/游标,(站点, 查询词)30s 缓存)。仅 TMDB 的作品详情 /media/tmdb/{type}/{id}。
|
||||
|
||||
export const searchKeys = {
|
||||
all: ["search"] as const,
|
||||
aggregate: (q: string, scope: SearchScope, limit: number) => ["search", "aggregate", q, scope, limit] as const,
|
||||
releases: (f: ReleaseSearchFilters) => ["search", "releases", f] as const,
|
||||
tmdb: (type: string, id: string, ms: boolean) => ["search", "tmdb", type, id, ms] as const,
|
||||
mediaServers: (ref: MediaServerRef) => ["search", "media-servers", ref] as const,
|
||||
};
|
||||
|
||||
/** 聚合搜索;q 为空不发请求 */
|
||||
@@ -31,9 +36,16 @@ export function useSearch(q: string, scope: SearchScope, limit = 8, enabled = tr
|
||||
}
|
||||
|
||||
export type ReleaseSearchFilters = {
|
||||
q: string;
|
||||
/** 逗号分隔 */
|
||||
/** 关键词;按作品搜时省略,由服务端生成查询词 */
|
||||
q?: string;
|
||||
/** 按本地作品搜 */
|
||||
entityId?: string;
|
||||
/** 按 TMDB 条目搜:movie:157336 */
|
||||
tmdb?: string;
|
||||
/** 逗号分隔:只查这些站点 */
|
||||
sites?: string;
|
||||
/** 逗号分隔:只筛选结果 */
|
||||
siteFilter?: string;
|
||||
resolution?: string;
|
||||
source?: string;
|
||||
/** 字节 */
|
||||
@@ -49,7 +61,7 @@ export type ReleaseSearchFilters = {
|
||||
export function useReleaseSearch(filters: ReleaseSearchFilters, enabled = true) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: searchKeys.releases(filters),
|
||||
enabled: enabled && filters.q.trim().length > 0,
|
||||
enabled: enabled && (!!filters.q?.trim() || !!filters.entityId || !!filters.tmdb),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
getParsed(ReleaseSearchPage, "/search/releases", { ...filters, cursor: pageParam, limit: 50 }, signal),
|
||||
@@ -91,3 +103,26 @@ export function usePlanFromSearch() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 仅 TMDB 作品的详情(TMDB 信息 + 季 + 本地对比);Emby 对比另走 useMediaServerInventory */
|
||||
export function useTmdbMedia(type: string | undefined, id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: searchKeys.tmdb(type ?? "", id ?? "", false),
|
||||
enabled: (type === "movie" || type === "tv") && !!id && /^\d+$/.test(id),
|
||||
queryFn: ({ signal }) => getParsed(TmdbMediaDetail, `/media/tmdb/${type}/${id}`, {}, signal),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export type MediaServerRef = { mediaEntityId: string } | { tmdbType: "movie" | "tv"; tmdbId: string };
|
||||
|
||||
/** Emby 库存对比(按本地作品或 TMDB 条目);实例失败 / 超时为 unknown,不等于缺失 */
|
||||
export function useMediaServerInventory(ref: MediaServerRef | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: searchKeys.mediaServers(ref ?? { mediaEntityId: "" }),
|
||||
enabled: enabled && !!ref,
|
||||
queryFn: ({ signal }) => getParsed(MediaServerInventory, "/inventory/media-servers", ref ?? {}, signal),
|
||||
staleTime: 5 * 60_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@@ -166,5 +166,9 @@ export function findNavItem(pathname: string): NavItem | undefined {
|
||||
/** 顶栏标题:导航项名,或不在侧栏的页面名 */
|
||||
export function pageTitle(pathname: string): string | undefined {
|
||||
const path = pathname.replace(/\/+$/, "");
|
||||
return findNavItem(pathname)?.label ?? EXTRA_TITLES[path];
|
||||
return (
|
||||
findNavItem(pathname)?.label ??
|
||||
EXTRA_TITLES[path] ??
|
||||
(path.startsWith("/console/media/tmdb/") ? "作品详情" : undefined)
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type LocalMediaHit, type SearchAction, type TmdbCandidate, tmdbImageUrl } from "@umt/contracts";
|
||||
import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Plus, Search } from "lucide-react";
|
||||
import { type SearchAction, type SearchWork, tmdbImageUrl } from "@umt/contracts";
|
||||
import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Search } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useSearch } from "~/api/search.ts";
|
||||
@@ -7,8 +7,7 @@ import { settingsPath } from "~/components/console/nav.ts";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts";
|
||||
import { useSubscribeTmdb } from "./search-sections.tsx";
|
||||
import { actionIcon, mediaKindLabel } from "./search-labels.ts";
|
||||
import {
|
||||
isSearchShortcut,
|
||||
openSearchPalette,
|
||||
@@ -16,9 +15,10 @@ import {
|
||||
setSearchPaletteOpen,
|
||||
useSearchPalette,
|
||||
} from "./search-store.ts";
|
||||
import { inventoryBadge, workHref } from "./work-card.tsx";
|
||||
|
||||
// 命令面板(Ctrl/⌘+K):输入即查本地作品 / TMDB 候选 / 页面与操作,最后一项跳资源结果页。
|
||||
// 资源组最慢要等 15s,面板里不查,回车进结果页。
|
||||
// 命令面板(Ctrl/⌘+K):输入即查作品(本地 + TMDB 合并,带库内状态)/ 页面与操作;选中作品打开作品详情页。
|
||||
// 面板里不订阅、不查站点;最后一项进搜索结果页。
|
||||
|
||||
function useDebounced<T>(v: T, ms: number): T {
|
||||
const [d, setD] = useState(v);
|
||||
@@ -68,7 +68,7 @@ export function CommandPalette() {
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">搜索</DialogTitle>
|
||||
<DialogDescription className="sr-only">搜索作品、TMDB 和页面;回车在资源中搜索</DialogDescription>
|
||||
<DialogDescription className="sr-only">搜索作品和页面;选中作品打开详情</DialogDescription>
|
||||
{open ? <PaletteBody initial={initial} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -82,7 +82,6 @@ function PaletteBody({ initial }: { initial: string }) {
|
||||
const term = useDebounced(q.trim(), 150);
|
||||
const media = useSearch(term, "media", 6);
|
||||
const actions = useSearch(term, "actions", 6);
|
||||
const tmdbSub = useSubscribeTmdb();
|
||||
const [active, setActive] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -92,36 +91,32 @@ function PaletteBody({ initial }: { initial: string }) {
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
const subscribe = (c: TmdbCandidate) => tmdbSub.subscribe(c, close);
|
||||
|
||||
const stale = q.trim() !== term;
|
||||
const local = term ? (media.data?.media?.local.items ?? []) : [];
|
||||
const works = term ? (media.data?.media?.works ?? []) : [];
|
||||
const tmdb = term ? media.data?.media?.tmdb : undefined;
|
||||
const acts = term ? (actions.data?.actions?.items ?? []) : [];
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go/subscribe 每次渲染新建)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go 每次渲染新建)
|
||||
const items = useMemo<Item[]>(() => {
|
||||
const out: Item[] = [];
|
||||
for (const h of local) out.push(localItem(h, () => go(`/console/library/${h.entity.id}`)));
|
||||
for (const c of tmdb?.items ?? [])
|
||||
out.push(tmdbItem(c, () => go(`/console/library/${c.localEntityId}`), subscribe));
|
||||
for (const w of works) out.push(workItem(w, () => go(workHref(w))));
|
||||
for (const a of acts) out.push(actionItem(a, () => go(a.href)));
|
||||
if (term)
|
||||
out.push({
|
||||
key: "releases",
|
||||
group: "资源",
|
||||
group: "搜索页",
|
||||
icon: Search,
|
||||
title: (
|
||||
<span>
|
||||
在资源中搜索 <span className="font-medium">“{term}”</span>
|
||||
查看「<span className="font-medium">{term}</span>」的全部结果
|
||||
</span>
|
||||
),
|
||||
meta: "查询全部已启用站点,最慢约 15 秒",
|
||||
meta: "作品卡上可以选站点搜资源",
|
||||
hint: "打开",
|
||||
run: () => go(searchPagePath(term)),
|
||||
});
|
||||
return out;
|
||||
}, [local, tmdb, acts, term]);
|
||||
}, [works, acts, term]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 结果变化时高亮回到第一项
|
||||
useEffect(() => setActive(0), [items.length, term]);
|
||||
@@ -160,7 +155,7 @@ function PaletteBody({ initial }: { initial: string }) {
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="搜索作品、TMDB、页面"
|
||||
placeholder="搜索作品、页面"
|
||||
role="combobox"
|
||||
aria-expanded={items.length > 0}
|
||||
aria-controls={listId}
|
||||
@@ -190,7 +185,7 @@ function PaletteBody({ initial }: { initial: string }) {
|
||||
>
|
||||
{!term ? (
|
||||
<p className="px-3 py-6 text-center text-caption text-muted-foreground">
|
||||
输入作品名、别名或页面名称。回车在资源中搜索。
|
||||
输入作品名、别名或页面名称。选中作品打开详情页。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
@@ -261,30 +256,36 @@ function PaletteBody({ initial }: { initial: string }) {
|
||||
<span>↵ 打开</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
回车在资源中搜索
|
||||
搜资源在作品详情页
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function localItem(h: LocalMediaHit, run: () => void): Item {
|
||||
const e = h.entity;
|
||||
const parts = [mediaKindLabel(e.kind), e.year ? String(e.year) : null, slotSummary(h)].filter(Boolean);
|
||||
function workItem(w: SearchWork, run: () => void): Item {
|
||||
const inv = inventoryBadge(w.inventory);
|
||||
const parts = [
|
||||
mediaKindLabel(w.kind),
|
||||
w.year ? String(w.year) : null,
|
||||
inv.label,
|
||||
w.inventory.subscribed ? "已订阅" : null,
|
||||
];
|
||||
return {
|
||||
key: `local-${e.id}`,
|
||||
group: "本地作品",
|
||||
icon: Film,
|
||||
key: `work-${w.key}`,
|
||||
group: "作品",
|
||||
icon: w.localEntityId ? Film : Globe,
|
||||
posterPath: w.posterPath,
|
||||
title: (
|
||||
<span>
|
||||
{e.title}
|
||||
{h.matchedAlias && h.matchedAlias !== e.title ? (
|
||||
<span className="ml-1.5 text-muted-foreground">· {h.matchedAlias}</span>
|
||||
{w.title}
|
||||
{w.originalTitle && w.originalTitle !== w.title ? (
|
||||
<span className="ml-1.5 text-muted-foreground">· {w.originalTitle}</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
meta: parts.join(" · "),
|
||||
hint: "打开",
|
||||
meta: parts.filter(Boolean).join(" · "),
|
||||
hint: "详情",
|
||||
run,
|
||||
};
|
||||
}
|
||||
@@ -306,27 +307,6 @@ function ItemIcon({ it }: { it: Item }) {
|
||||
return <it.icon className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function tmdbItem(c: TmdbCandidate, view: () => void, subscribe: (c: TmdbCandidate) => void): Item {
|
||||
const local = !!c.localEntityId;
|
||||
return {
|
||||
key: `tmdb-${c.entityType}-${c.externalId}`,
|
||||
group: "TMDB",
|
||||
icon: local ? Globe : Plus,
|
||||
posterPath: c.posterPath,
|
||||
title: (
|
||||
<span>
|
||||
{c.title}
|
||||
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
|
||||
</span>
|
||||
),
|
||||
meta: [c.entityType === "movie" ? "电影" : "剧集", local ? "本地已有" : c.originalTitle, c.overview]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
hint: local ? "查看" : "订阅",
|
||||
run: local ? view : () => subscribe(c),
|
||||
};
|
||||
}
|
||||
|
||||
function actionItem(a: SearchAction, run: () => void): Item {
|
||||
return {
|
||||
key: `action-${a.id}`,
|
||||
|
||||
@@ -29,7 +29,8 @@ import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
|
||||
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts";
|
||||
|
||||
// 资源段:跨站结果的筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;行操作同站点工作台。
|
||||
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;
|
||||
// 行操作同站点工作台(库存叠加、下载、委托 Agent)。
|
||||
|
||||
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
|
||||
export type ReleaseFilterState = {
|
||||
@@ -55,12 +56,25 @@ export const RELEASE_FILTER_KEYS = [
|
||||
"order",
|
||||
] as const satisfies readonly (keyof ReleaseFilterState)[];
|
||||
|
||||
export function toApiFilters(q: string, f: ReleaseFilterState): ReleaseSearchFilters {
|
||||
/** 搜什么:关键词,或按作品(服务端生成查询词) */
|
||||
export type ReleaseTarget = { q?: string; entityId?: string; tmdb?: string };
|
||||
|
||||
/** URL 参数 work → 搜索目标:local:<id> / tmdb:<type>:<id> / q:<关键词> */
|
||||
export function parseWorkRef(ref: string): ReleaseTarget | null {
|
||||
if (ref.startsWith("local:")) return { entityId: ref.slice(6) };
|
||||
const m = /^tmdb:(movie|tv):(\d+)$/.exec(ref);
|
||||
if (m) return { tmdb: `${m[1]}:${m[2]}` };
|
||||
if (ref.startsWith("q:") && ref.slice(2).trim()) return { q: ref.slice(2).trim() };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteIds = ""): ReleaseSearchFilters {
|
||||
const bool = (v: string) => (v === "true" || v === "false" ? v : undefined);
|
||||
const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined;
|
||||
return {
|
||||
q,
|
||||
sites: f.sites || undefined,
|
||||
...target,
|
||||
sites: siteIds || undefined,
|
||||
siteFilter: f.sites || undefined,
|
||||
resolution: f.resolution || undefined,
|
||||
source: f.source || undefined,
|
||||
minSize: gbToBytes(f.minGb),
|
||||
@@ -75,17 +89,22 @@ export function toApiFilters(q: string, f: ReleaseFilterState): ReleaseSearchFil
|
||||
const csvList = (s: string) => (s ? s.split(",").filter(Boolean) : []);
|
||||
|
||||
export function ReleaseResults({
|
||||
q,
|
||||
target,
|
||||
siteIds,
|
||||
filters,
|
||||
setFilter,
|
||||
resetFilters,
|
||||
onChangeSites,
|
||||
}: {
|
||||
q: string;
|
||||
target: ReleaseTarget;
|
||||
/** 选中的站点(逗号分隔);空 = 服务端按默认站点设置 */
|
||||
siteIds: string;
|
||||
onChangeSites?: () => void;
|
||||
filters: ReleaseFilterState;
|
||||
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
|
||||
resetFilters: () => void;
|
||||
}) {
|
||||
const api = useMemo(() => toApiFilters(q, filters), [q, filters]);
|
||||
const api = useMemo(() => toApiFilters(target, filters, siteIds), [target, filters, siteIds]);
|
||||
const res = useReleaseSearch(api);
|
||||
const refresh = useRefreshReleaseSearch();
|
||||
const pages = res.data?.pages ?? [];
|
||||
@@ -109,20 +128,29 @@ export function ReleaseResults({
|
||||
) : null}
|
||||
</h2>
|
||||
{first ? (
|
||||
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
|
||||
{first.cached ? "缓存结果" : "站点实时结果"} · 抓取于 {relTime(first.fetchedAt)} · {first.tookMs} ms
|
||||
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">
|
||||
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于{" "}
|
||||
{relTime(first.fetchedAt)} · {first.tookMs} ms
|
||||
{first.queries?.length ? ` · 查询词 ${first.queries.map((x) => `「${x}」`).join("")}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refresh.isPending || res.isFetching}
|
||||
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
|
||||
>
|
||||
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
|
||||
{refresh.isPending ? "查询中…" : "刷新"}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{onChangeSites ? (
|
||||
<Button variant="ghost" size="sm" onClick={onChangeSites}>
|
||||
换站点
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refresh.isPending || res.isFetching}
|
||||
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
|
||||
>
|
||||
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
|
||||
{refresh.isPending ? "查询中…" : "刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{first ? <CoverageNotice page={first} /> : null}
|
||||
@@ -143,8 +171,8 @@ export function ReleaseResults({
|
||||
<EmptyState
|
||||
plain
|
||||
icon={PackageSearch}
|
||||
title={filtered ? "没有符合筛选条件的资源" : "各站点都没有找到资源"}
|
||||
description={filtered ? "放宽筛选条件再看。" : "换个关键词,或到站点工作台检查站点状态。"}
|
||||
title={filtered ? "没有符合筛选条件的资源" : "所选站点都没有找到资源"}
|
||||
description={filtered ? "放宽筛选条件再看。" : "换站点或换关键词再搜,或到站点页检查站点状态。"}
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import type { TmdbCandidate } from "@umt/contracts";
|
||||
import { ArrowRight, Film, Plus } from "lucide-react";
|
||||
import type { SearchWork } from "@umt/contracts";
|
||||
import { ArrowRight, Film, PackageSearch } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useEntityFromCandidate } from "~/api/files.ts";
|
||||
import { mediaServers } from "~/api/instances.ts";
|
||||
import { useSearch } from "~/api/search.ts";
|
||||
import { useCreateSubscription } from "~/api/subscriptions.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { settingsPath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts";
|
||||
import { actionIcon } from "./search-labels.ts";
|
||||
import { SitePickerDialog } from "./site-picker-dialog.tsx";
|
||||
import { releaseSearchPath, WorkCard, workRef } from "./work-card.tsx";
|
||||
|
||||
// 结果页的"作品"与"操作"两段(资源段见 release-results.tsx)
|
||||
// 结果页第一阶段:作品(本地 + TMDB 合并,带库存对比)与操作;资源要点「搜索资源」选站点后才查(release-results.tsx)
|
||||
|
||||
function SectionTitle({ id, title, extra }: { id: string; title: string; extra?: string }) {
|
||||
return (
|
||||
@@ -23,131 +22,76 @@ function SectionTitle({ id, title, extra }: { id: string; title: string; extra?:
|
||||
);
|
||||
}
|
||||
|
||||
/** 从 TMDB 候选建订阅:先按 TMDB id 建档(季/集与总集数随之就位)再订阅作品;建档失败退回按标题追踪 */
|
||||
export function useSubscribeTmdb() {
|
||||
const create = useCreateSubscription();
|
||||
const entity = useEntityFromCandidate();
|
||||
/** 「搜索资源」:先选站点,再跳到第二阶段(同一搜索页带 work / in 参数) */
|
||||
export function useReleaseSearchLauncher() {
|
||||
const navigate = useNavigate();
|
||||
const [target, setTarget] = useState<{ title: string; ref: string } | null>(null);
|
||||
return {
|
||||
pending: create.isPending || entity.isPending,
|
||||
subscribe: async (c: TmdbCandidate, onDone?: () => void) => {
|
||||
const kind = c.entityType === "movie" ? "movie" : "series";
|
||||
const mediaEntityId =
|
||||
c.localEntityId ??
|
||||
(await entity
|
||||
.mutateAsync({ provider: "tmdb", externalId: c.externalId, kind: c.entityType === "movie" ? "movie" : "tv" })
|
||||
.then((r) => r.mediaEntityId)
|
||||
.catch(() => null));
|
||||
create.mutate(
|
||||
{
|
||||
...(mediaEntityId ? { mediaEntityId } : { titleQuery: c.year ? `${c.title} ${c.year}` : c.title }),
|
||||
mediaKind: kind,
|
||||
mode: kind === "movie" ? "once" : "continuous",
|
||||
seasonScope: kind === "movie" ? "current_season" : "following_seasons",
|
||||
priority: "new_work",
|
||||
origin: "user",
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`已订阅「${c.title}」`, {
|
||||
action: { label: "查看", onClick: () => navigate("/console/subscriptions") },
|
||||
});
|
||||
onDone?.();
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e, "订阅失败")),
|
||||
},
|
||||
);
|
||||
},
|
||||
open: (t: { title: string; ref: string }) => setTarget(t),
|
||||
dialog: (
|
||||
<SitePickerDialog
|
||||
open={!!target}
|
||||
onOpenChange={(o) => !o && setTarget(null)}
|
||||
title={target?.title ?? ""}
|
||||
onConfirm={(ids) => target && navigate(releaseSearchPath(target, ids))}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function MediaSection({ q }: { q: string }) {
|
||||
export function WorksSection({ q }: { q: string }) {
|
||||
const res = useSearch(q, "media", 20);
|
||||
const sub = useSubscribeTmdb();
|
||||
const local = res.data?.media?.local.items ?? [];
|
||||
const ms = mediaServers.useList();
|
||||
const emby = (ms.data?.items ?? []).some((i) => (i as { enabled?: boolean }).enabled !== false);
|
||||
const launcher = useReleaseSearchLauncher();
|
||||
const works = res.data?.media?.works ?? [];
|
||||
const tmdb = res.data?.media?.tmdb;
|
||||
const searchReleases = (w: SearchWork) => {
|
||||
const ref = workRef(w);
|
||||
if (ref) launcher.open({ title: w.title, ref });
|
||||
};
|
||||
return (
|
||||
<section aria-labelledby="search-media" className="min-w-0">
|
||||
<SectionTitle
|
||||
id="search-media"
|
||||
title="作品"
|
||||
extra={res.data ? `${local.length + (tmdb?.items.length ?? 0)} 条` : undefined}
|
||||
/>
|
||||
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
|
||||
{res.isPending ? (
|
||||
<LoadingState rows={2} className="p-4" />
|
||||
) : res.isError ? (
|
||||
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
|
||||
<SectionTitle id="search-media" title="作品" extra={res.data ? `${works.length} 部` : undefined} />
|
||||
<Button variant="ghost" size="sm" onClick={() => launcher.open({ title: q, ref: `q:${q}` })}>
|
||||
<PackageSearch />
|
||||
按关键词搜资源
|
||||
</Button>
|
||||
</div>
|
||||
{res.isPending ? (
|
||||
<LoadingState rows={3} className="pg-slab rounded-xl p-4" />
|
||||
) : res.isError ? (
|
||||
<div className="pg-slab rounded-xl">
|
||||
<ErrorState plain error={res.error} onRetry={() => res.refetch()} />
|
||||
) : local.length === 0 && !tmdb?.items.length ? (
|
||||
</div>
|
||||
) : works.length === 0 ? (
|
||||
<div className="pg-slab rounded-xl">
|
||||
<EmptyState
|
||||
plain
|
||||
icon={Film}
|
||||
title="没有匹配的作品"
|
||||
description="换个译名、原名或别名再搜。"
|
||||
description="换个译名、原名或别名再搜;资源标题可以点「按关键词搜资源」。"
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{local.map((h) => (
|
||||
<li key={h.entity.id}>
|
||||
<Link
|
||||
to={`/console/library/${h.entity.id}`}
|
||||
className="flex min-w-0 items-center gap-3 px-4 py-3 transition-colors duration-micro hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<Film className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{h.entity.title}
|
||||
{h.entity.year ? <span className="text-muted-foreground"> ({h.entity.year})</span> : null}
|
||||
</p>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{mediaKindLabel(h.entity.kind)} · {slotSummary(h)}
|
||||
{h.matchedAlias && h.matchedAlias !== h.entity.title ? ` · 命中别名 ${h.matchedAlias}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge>本地</StatusBadge>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{(tmdb?.items ?? []).map((c) => (
|
||||
<li key={`${c.entityType}-${c.externalId}`} className="flex min-w-0 items-center gap-3 px-4 py-3">
|
||||
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{c.title}
|
||||
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
|
||||
</p>
|
||||
<p className="line-clamp-1 text-meta text-muted-foreground">
|
||||
TMDB · {c.entityType === "movie" ? "电影" : "剧集"}
|
||||
{c.originalTitle && c.originalTitle !== c.title ? ` · ${c.originalTitle}` : ""}
|
||||
{c.overview ? ` · ${c.overview}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{c.localEntityId ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/console/library/${c.localEntityId}`}>查看</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled={sub.pending} onClick={() => sub.subscribe(c)}>
|
||||
订阅
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{tmdb && !tmdb.configured ? (
|
||||
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-muted-foreground">
|
||||
TMDB 未配置,只搜了本地作品。
|
||||
<Link to={settingsPath("metadata")} className="ml-1 underline underline-offset-4">
|
||||
去配置
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
{tmdb?.error ? (
|
||||
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{works.map((w) => (
|
||||
<WorkCard key={w.key} w={w} emby={emby} onSearchReleases={searchReleases} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tmdb && !tmdb.configured ? (
|
||||
<p className="mt-2 text-meta text-muted-foreground">
|
||||
TMDB 未配置,只搜了本地作品。
|
||||
<Link to={settingsPath("metadata")} className="ml-1 underline underline-offset-4">
|
||||
去配置
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
{tmdb?.error ? <p className="mt-2 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p> : null}
|
||||
{launcher.dialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function SearchTrigger({ className }: { className?: string }) {
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-left">搜索作品、资源、页面</span>
|
||||
<span className="flex-1 truncate text-left">搜索作品、页面</span>
|
||||
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium">
|
||||
{shortcut}
|
||||
</kbd>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { MediaServerInventory, WorkInventory } from "@umt/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseWorkRef, type ReleaseFilterState, toApiFilters } from "./release-results.tsx";
|
||||
import { resolvePick } from "./site-picker-dialog.tsx";
|
||||
import { inventoryBadge, mediaServerSummary, releaseSearchPath, slotLine, workHref, workRef } from "./work-card.tsx";
|
||||
|
||||
// SEARCH-2:作品 → 详情 / 搜资源的地址,站点选择解析,资源查询参数,订阅入口只在作品详情页。
|
||||
|
||||
const inv = (p: Partial<WorkInventory>): WorkInventory => ({
|
||||
state: "not_collected",
|
||||
subscribed: false,
|
||||
versionCount: 0,
|
||||
downloadingCount: 0,
|
||||
slotTotal: 0,
|
||||
slotSatisfied: 0,
|
||||
missing: [],
|
||||
...p,
|
||||
});
|
||||
const ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
describe("作品地址", () => {
|
||||
it("本地作品进媒体库详情;仅 TMDB 的进 TMDB 详情", () => {
|
||||
expect(workHref({ localEntityId: ID, tmdb: { entityType: "movie", externalId: "1" } })).toBe(
|
||||
`/console/library/${ID}`,
|
||||
);
|
||||
expect(workHref({ localEntityId: null, tmdb: { entityType: "tv", externalId: "1399" } })).toBe(
|
||||
"/console/media/tmdb/tv/1399",
|
||||
);
|
||||
});
|
||||
|
||||
it("搜资源引用与第二阶段地址可以往返", () => {
|
||||
const ref = workRef({ localEntityId: null, tmdb: { entityType: "movie", externalId: "157336" } });
|
||||
expect(ref).toBe("tmdb:movie:157336");
|
||||
expect(parseWorkRef(ref!)).toEqual({ tmdb: "movie:157336" });
|
||||
expect(parseWorkRef(`local:${ID}`)).toEqual({ entityId: ID });
|
||||
expect(parseWorkRef("q:Dune 2024")).toEqual({ q: "Dune 2024" });
|
||||
expect(parseWorkRef("nope")).toBeNull();
|
||||
const url = new URL(releaseSearchPath({ title: "星际穿越", ref: ref! }, ["a", "b"]), "http://x");
|
||||
expect(url.pathname).toBe("/console/search");
|
||||
expect(Object.fromEntries(url.searchParams)).toEqual({ q: "星际穿越", work: ref, t: "星际穿越", in: "a,b" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("站点选择", () => {
|
||||
const sites = [
|
||||
{ id: "a", enabled: true, searchDefault: true },
|
||||
{ id: "b", enabled: true, searchDefault: false },
|
||||
{ id: "c", enabled: false, searchDefault: true },
|
||||
];
|
||||
it("默认 = 启用且默认参与;全部 = 全部启用;指定 = 选中里仍启用的", () => {
|
||||
expect(resolvePick({ mode: "default", siteIds: [] }, sites)).toEqual(["a"]);
|
||||
expect(resolvePick({ mode: "all", siteIds: [] }, sites)).toEqual(["a", "b"]);
|
||||
expect(resolvePick({ mode: "custom", siteIds: ["b", "c"] }, sites)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("资源查询:选中的站点进 sites,站点分面只进 siteFilter;按作品搜不带 q", () => {
|
||||
const f: ReleaseFilterState = {
|
||||
sites: "b",
|
||||
resolution: "",
|
||||
source: "",
|
||||
minGb: "",
|
||||
maxGb: "",
|
||||
free: "",
|
||||
hr: "",
|
||||
sort: "",
|
||||
order: "",
|
||||
};
|
||||
expect(toApiFilters({ tmdb: "movie:1" }, f, "a,b")).toMatchObject({
|
||||
tmdb: "movie:1",
|
||||
sites: "a,b",
|
||||
siteFilter: "b",
|
||||
});
|
||||
expect(toApiFilters({ tmdb: "movie:1" }, { ...f, sites: "" }, "")).not.toHaveProperty("q");
|
||||
});
|
||||
});
|
||||
|
||||
describe("库内状态文案", () => {
|
||||
it("未收录 / 已建档 / 已收录 / 正在下载;版本目标满足与缺失", () => {
|
||||
expect(inventoryBadge(inv({})).label).toBe("未收录");
|
||||
expect(inventoryBadge(inv({ state: "tracked" })).label).toBe("已建档 · 未入库");
|
||||
expect(inventoryBadge(inv({ state: "owned", versionCount: 2 })).label).toBe("已收录 · 2 个版本");
|
||||
expect(inventoryBadge(inv({ state: "downloading", downloadingCount: 1 })).label).toBe("正在下载 1 个");
|
||||
expect(
|
||||
slotLine(inv({ state: "owned", slotTotal: 2, slotSatisfied: 1, missing: [{ slotId: ID, name: "2160p" }] })),
|
||||
).toBe("版本目标满足 1/2 · 缺「2160p」");
|
||||
expect(slotLine(inv({}))).toBeNull();
|
||||
});
|
||||
|
||||
it("Emby:任一实例有 = 已有;全部没有 = 没有;有失败 = 未知(不等于缺失)", () => {
|
||||
const ms = (...st: ("present" | "not_found" | "unknown")[]): MediaServerInventory => ({
|
||||
mediaEntityId: null,
|
||||
partial: false,
|
||||
instances: st.map((status, i) => ({
|
||||
instanceId: `${ID.slice(0, -1)}${i}`,
|
||||
name: `E${i}`,
|
||||
status,
|
||||
reason: null,
|
||||
items: [],
|
||||
observedAt: "2026-09-29T00:00:00.000Z",
|
||||
})),
|
||||
});
|
||||
expect(mediaServerSummary(ms())).toBeNull();
|
||||
expect(mediaServerSummary(ms("present"))?.label).toBe("Emby 已有");
|
||||
expect(mediaServerSummary(ms("not_found", "not_found"))?.label).toBe("Emby 没有");
|
||||
expect(mediaServerSummary(ms("not_found", "unknown"))?.label).toBe("Emby 未知");
|
||||
});
|
||||
});
|
||||
|
||||
describe("订阅入口位置", () => {
|
||||
const src = (rel: string) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8");
|
||||
it("搜索结果与 ⌘K 面板不能直接订阅", () => {
|
||||
for (const f of ["./command-palette.tsx", "./search-sections.tsx", "./work-card.tsx"]) {
|
||||
const s = src(f);
|
||||
expect(s, f).not.toMatch(/useCreateSubscription|SubscriptionFormDialog|useSubscribeTmdb/);
|
||||
}
|
||||
});
|
||||
it("作品详情页(本地 / 仅 TMDB)有订阅入口", () => {
|
||||
for (const f of ["../../routes/console/library.$id.tsx", "../../routes/console/media.tmdb.$type.$id.tsx"]) {
|
||||
expect(src(f), f).toMatch(/<SubscriptionFormDialog[\s\S]*initialTarget=/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { SearchSiteMode, Site } from "@umt/contracts";
|
||||
import { PackageSearch } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useSiteOptions } from "~/api/instances.ts";
|
||||
import { useSettings } from "~/api/settings.ts";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { ChipToggleGroup } from "~/components/library/parts.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 搜资源前选站点:默认(站点页勾了「默认参与搜索」的)/ 全部启用站点 / 指定站点。
|
||||
// 初始值 = 本机上次的选择,没有就用 settings.search.defaultSiteMode。
|
||||
|
||||
export type SitePick = { mode: SearchSiteMode; siteIds: string[] };
|
||||
|
||||
const STORE_KEY = "umt.search.sitePick";
|
||||
|
||||
export function loadSitePick(): SitePick | null {
|
||||
try {
|
||||
const raw = globalThis.localStorage?.getItem(STORE_KEY);
|
||||
if (!raw) return null;
|
||||
const v = JSON.parse(raw) as Partial<SitePick>;
|
||||
if (v.mode !== "default" && v.mode !== "all" && v.mode !== "custom") return null;
|
||||
return { mode: v.mode, siteIds: Array.isArray(v.siteIds) ? v.siteIds.filter((x) => typeof x === "string") : [] };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSitePick(p: SitePick) {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(STORE_KEY, JSON.stringify(p));
|
||||
} catch {
|
||||
// 隐私模式等存不了:只是下次不记得
|
||||
}
|
||||
}
|
||||
|
||||
/** 按模式解析出站点 ID(只含启用站点) */
|
||||
export function resolvePick(pick: SitePick, sites: Pick<Site, "id" | "enabled" | "searchDefault">[]): string[] {
|
||||
const on = sites.filter((s) => s.enabled);
|
||||
if (pick.mode === "all") return on.map((s) => s.id);
|
||||
if (pick.mode === "custom") {
|
||||
const set = new Set(pick.siteIds);
|
||||
return on.filter((s) => set.has(s.id)).map((s) => s.id);
|
||||
}
|
||||
return on.filter((s) => s.searchDefault !== false).map((s) => s.id);
|
||||
}
|
||||
|
||||
const MODES: { value: SearchSiteMode; label: string }[] = [
|
||||
{ value: "default", label: "默认站点" },
|
||||
{ value: "all", label: "全部站点" },
|
||||
{ value: "custom", label: "指定站点" },
|
||||
];
|
||||
|
||||
export function SitePickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
/** 作品名,显示在说明里 */
|
||||
title: string;
|
||||
onConfirm: (siteIds: string[]) => void;
|
||||
}) {
|
||||
const sitesQ = useSiteOptions();
|
||||
const settings = useSettings();
|
||||
const sites = useMemo(() => (sitesQ.data?.items ?? []).filter((s) => s.enabled), [sitesQ.data]);
|
||||
const [pick, setPick] = useState<SitePick | null>(null);
|
||||
// 打开时取初始值:本机上次 > 全局设置 > 默认
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const cfg = settings.data?.search;
|
||||
setPick(
|
||||
loadSitePick() ?? (cfg ? { mode: cfg.defaultSiteMode, siteIds: cfg.siteIds } : { mode: "default", siteIds: [] }),
|
||||
);
|
||||
}, [open, settings.data]);
|
||||
|
||||
const cur = pick ?? { mode: "default" as const, siteIds: [] };
|
||||
const ids = resolvePick(cur, sites);
|
||||
const count = (m: SearchSiteMode) => resolvePick({ mode: m, siteIds: cur.siteIds }, sites).length;
|
||||
|
||||
const confirm = () => {
|
||||
saveSitePick(cur);
|
||||
onConfirm(ids);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>搜索资源</DialogTitle>
|
||||
<DialogDescription>在所选站点搜「{title}」。每个站点按作品原名和译名各查一次。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<fieldset aria-label="站点范围" className="m-0 grid grid-cols-3 gap-1 rounded-lg border border-border/70 p-1">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
aria-pressed={cur.mode === m.value}
|
||||
onClick={() =>
|
||||
setPick({
|
||||
mode: m.value,
|
||||
// 切到「指定」时从当前集合起步
|
||||
siteIds: m.value === "custom" && cur.mode !== "custom" ? ids : cur.siteIds,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-md px-2 py-1.5 text-caption transition-colors duration-micro",
|
||||
cur.mode === m.value
|
||||
? "bg-foreground/[0.08] font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{m.label}
|
||||
{m.value !== "custom" && sitesQ.data ? (
|
||||
<span className="ml-1 text-meta text-muted-foreground tabular-nums">{count(m.value)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
{sitesQ.isPending ? (
|
||||
<p className="text-caption text-muted-foreground">加载站点…</p>
|
||||
) : sites.length === 0 ? (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
还没有启用的站点。
|
||||
<Link to={consolePath("sites")} className="ml-1 underline underline-offset-4">
|
||||
去站点页
|
||||
</Link>
|
||||
</p>
|
||||
) : cur.mode === "custom" ? (
|
||||
<ChipToggleGroup
|
||||
label="站点"
|
||||
options={sites.map((s) => ({ value: s.id, label: s.name }))}
|
||||
value={cur.siteIds.filter((id) => sites.some((s) => s.id === id))}
|
||||
onChange={(v) => setPick({ mode: "custom", siteIds: v })}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-caption break-words text-muted-foreground">
|
||||
{ids.length
|
||||
? sites
|
||||
.filter((s) => ids.includes(s.id))
|
||||
.map((s) => s.name)
|
||||
.join("、")
|
||||
: "没有站点勾选「默认参与搜索」。在站点页打开,或改选全部站点。"}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={confirm} disabled={ids.length === 0}>
|
||||
<PackageSearch />
|
||||
搜索 {ids.length} 个站点
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { MediaServerInventory, SearchWork, WorkInventory } from "@umt/contracts";
|
||||
import { ArrowRight, PackageSearch } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { type MediaServerRef, useMediaServerInventory } from "~/api/search.ts";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { CoverPlaceholder } from "~/components/library/parts.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { mediaKindLabel } from "./search-labels.ts";
|
||||
|
||||
// 作品卡(SEARCH-2 第一阶段):TMDB 海报 / 年份 / 类型 / 简介摘要 + 库内状态;「详情」进作品详情页,「搜索资源」先选站点。
|
||||
|
||||
/** 作品详情页:本地作品走媒体库详情,仅 TMDB 的走 TMDB 详情 */
|
||||
export function workHref(w: Pick<SearchWork, "localEntityId" | "tmdb">): string {
|
||||
if (w.localEntityId) return consolePath("library", w.localEntityId);
|
||||
if (w.tmdb) return `/console/media/tmdb/${w.tmdb.entityType}/${w.tmdb.externalId}`;
|
||||
return consolePath("library");
|
||||
}
|
||||
|
||||
/** 搜资源用的作品引用(URL 参数 work):local:<id> / tmdb:<type>:<id> */
|
||||
export function workRef(w: Pick<SearchWork, "localEntityId" | "tmdb">): string | null {
|
||||
if (w.localEntityId) return `local:${w.localEntityId}`;
|
||||
if (w.tmdb) return `tmdb:${w.tmdb.entityType}:${w.tmdb.externalId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 第二阶段地址:搜索页带作品引用 + 选中的站点 */
|
||||
export function releaseSearchPath(w: { title: string; ref: string }, siteIds: string[]): string {
|
||||
const p = new URLSearchParams({ q: w.title, work: w.ref, t: w.title, in: siteIds.join(",") });
|
||||
return `/console/search?${p.toString()}`;
|
||||
}
|
||||
|
||||
export function inventoryBadge(inv: WorkInventory): { label: string; tone: StatusTone } {
|
||||
switch (inv.state) {
|
||||
case "not_collected":
|
||||
return { label: "未收录", tone: "neutral" };
|
||||
case "downloading":
|
||||
return { label: `正在下载 ${inv.downloadingCount} 个`, tone: "warning" };
|
||||
case "owned":
|
||||
return { label: `已收录 · ${inv.versionCount} 个版本`, tone: "success" };
|
||||
default:
|
||||
return { label: "已建档 · 未入库", tone: "neutral" };
|
||||
}
|
||||
}
|
||||
|
||||
/** 版本目标满足情况一行:满足 a/b · 缺「x」「y」 */
|
||||
export function slotLine(inv: WorkInventory): string | null {
|
||||
if (inv.state === "not_collected" || inv.slotTotal === 0) return null;
|
||||
const parts = [`版本目标满足 ${inv.slotSatisfied}/${inv.slotTotal}`];
|
||||
if (inv.missing.length) parts.push(`缺${inv.missing.map((m) => `「${m.name}」`).join("")}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function InventoryBadges({ inv }: { inv: WorkInventory }) {
|
||||
const b = inventoryBadge(inv);
|
||||
return (
|
||||
<>
|
||||
<StatusBadge tone={b.tone}>{b.label}</StatusBadge>
|
||||
{inv.subscribed ? <StatusBadge>已订阅</StatusBadge> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Emby 对比摘要:任一实例有 = 已有;全部没有 = 没有;有实例失败 / 超时 = 未知(不等于缺失) */
|
||||
export function mediaServerSummary(ms: MediaServerInventory): { label: string; tone: StatusTone } | null {
|
||||
if (ms.instances.length === 0) return null;
|
||||
const present = ms.instances.filter((i) => i.status === "present");
|
||||
if (present.length)
|
||||
return {
|
||||
label: `Emby 已有${ms.instances.length > 1 ? ` ${present.length}/${ms.instances.length}` : ""}`,
|
||||
tone: "success",
|
||||
};
|
||||
if (ms.instances.every((i) => i.status === "not_found")) return { label: "Emby 没有", tone: "neutral" };
|
||||
return { label: "Emby 未知", tone: "warning" };
|
||||
}
|
||||
|
||||
export function EmbyBadge({ refOf, enabled = true }: { refOf: MediaServerRef | null; enabled?: boolean }) {
|
||||
const q = useMediaServerInventory(refOf, enabled);
|
||||
if (!q.data) return null;
|
||||
const s = mediaServerSummary(q.data);
|
||||
return s ? <StatusBadge tone={s.tone}>{s.label}</StatusBadge> : null;
|
||||
}
|
||||
|
||||
export function msRefOf(w: Pick<SearchWork, "localEntityId" | "tmdb">): MediaServerRef | null {
|
||||
if (w.localEntityId) return { mediaEntityId: w.localEntityId };
|
||||
if (w.tmdb) return { tmdbType: w.tmdb.entityType, tmdbId: w.tmdb.externalId };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function WorkCard({
|
||||
w,
|
||||
emby,
|
||||
onSearchReleases,
|
||||
}: {
|
||||
w: SearchWork;
|
||||
/** 有媒体服务器实例时才查 Emby */
|
||||
emby: boolean;
|
||||
onSearchReleases: (w: SearchWork) => void;
|
||||
}) {
|
||||
const line = slotLine(w.inventory);
|
||||
const href = workHref(w);
|
||||
return (
|
||||
<article className="pg-slab flex min-w-0 gap-3 rounded-xl p-3">
|
||||
<Link to={href} className="shrink-0" aria-label={`${w.title} 详情`}>
|
||||
<CoverPlaceholder title={w.title} kind={w.kind} posterPath={w.posterPath} className="h-32 w-[5.5rem]" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Link to={href} className="min-w-0 hover:underline">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground">
|
||||
{w.title}
|
||||
{w.year ? <span className="font-normal text-muted-foreground"> ({w.year})</span> : null}
|
||||
</h3>
|
||||
</Link>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{mediaKindLabel(w.kind)}
|
||||
{w.originalTitle && w.originalTitle !== w.title ? ` · ${w.originalTitle}` : ""}
|
||||
{w.source === "tmdb" ? " · TMDB" : ""}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
<InventoryBadges inv={w.inventory} />
|
||||
{emby ? <EmbyBadge refOf={msRefOf(w)} /> : null}
|
||||
</div>
|
||||
{line ? <p className="mt-1 line-clamp-1 text-meta text-muted-foreground">{line}</p> : null}
|
||||
{w.overview ? <p className="mt-1 line-clamp-2 text-meta text-muted-foreground">{w.overview}</p> : null}
|
||||
<div className="mt-auto flex flex-wrap justify-end gap-1 pt-2">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to={href}>
|
||||
详情
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onSearchReleases(w)} disabled={!workRef(w)}>
|
||||
<PackageSearch />
|
||||
搜索资源
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { SearchSiteMode, SiteListItem } from "@umt/contracts";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useSettings, useUpdateSettings } from "~/api/settings.ts";
|
||||
import { ChipToggleGroup } from "~/components/library/parts.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 站点页「搜索站点」:settings.search.defaultSiteMode —— 搜资源时站点选择的初始值,也是 API 不带 sites 时的站点范围。
|
||||
|
||||
const MODES: { value: SearchSiteMode; label: string; hint: string }[] = [
|
||||
{ value: "default", label: "默认站点", hint: "站点卡上打开了「默认参与搜索」的站点" },
|
||||
{ value: "all", label: "全部站点", hint: "全部已启用站点" },
|
||||
{ value: "custom", label: "指定站点", hint: "只查下面选中的站点" },
|
||||
];
|
||||
|
||||
export function SearchDefaultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sites,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
sites: SiteListItem[];
|
||||
}) {
|
||||
const settings = useSettings();
|
||||
const update = useUpdateSettings();
|
||||
const [mode, setMode] = useState<SearchSiteMode>("default");
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!open || !settings.data) return;
|
||||
setMode(settings.data.search?.defaultSiteMode ?? "default");
|
||||
setIds(settings.data.search?.siteIds ?? []);
|
||||
}, [open, settings.data]);
|
||||
const enabled = sites.filter((s) => s.enabled);
|
||||
|
||||
const save = () =>
|
||||
update.mutate(
|
||||
{ search: { defaultSiteMode: mode, siteIds: mode === "custom" ? ids : [] } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("搜索站点已保存");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e, "保存失败")),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>搜索站点</DialogTitle>
|
||||
<DialogDescription>搜资源时默认查哪些站点。每次搜索前仍可改选,本机会记住上次的选择。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<fieldset aria-label="默认范围" className="m-0 space-y-1.5 border-0 p-0">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
aria-pressed={mode === m.value}
|
||||
onClick={() => setMode(m.value)}
|
||||
className={cn(
|
||||
"block w-full cursor-pointer rounded-lg border px-3 py-2 text-left transition-colors duration-micro",
|
||||
mode === m.value ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
|
||||
)}
|
||||
>
|
||||
<span className="block text-sm font-medium text-foreground">{m.label}</span>
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
{m.hint}
|
||||
{m.value === "default"
|
||||
? `(${enabled.filter((s) => s.searchDefault !== false).length} 个)`
|
||||
: m.value === "all"
|
||||
? `(${enabled.length} 个)`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
{mode === "custom" ? (
|
||||
<ChipToggleGroup
|
||||
label="站点"
|
||||
options={enabled.map((s) => ({ value: s.id, label: s.name }))}
|
||||
value={ids.filter((id) => enabled.some((s) => s.id === id))}
|
||||
onChange={setIds}
|
||||
/>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={update.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={save} disabled={update.isPending || (mode === "custom" && ids.length === 0)}>
|
||||
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { snapshotStale, useSnapshotSite, useToggleSite } from "~/api/sites.ts";
|
||||
import { snapshotStale, useSnapshotSite, useToggleSite, useUpdateSite } from "~/api/sites.ts";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
@@ -76,6 +76,31 @@ function EnableSwitch({ site, withLabel }: { site: SiteListItem; withLabel?: boo
|
||||
);
|
||||
}
|
||||
|
||||
/** 默认参与搜索:搜资源时「默认站点」集合 */
|
||||
function SearchDefaultSwitch({ site }: { site: SiteListItem }) {
|
||||
const update = useUpdateSite();
|
||||
const on = site.searchDefault !== false;
|
||||
return (
|
||||
<span className="mr-auto flex items-center gap-2 text-meta text-muted-foreground">
|
||||
<Switch
|
||||
id={`site-sd-${site.id}`}
|
||||
checked={on}
|
||||
disabled={update.isPending}
|
||||
onCheckedChange={(v) =>
|
||||
update.mutate(
|
||||
{ id: site.id, body: { searchDefault: v } },
|
||||
{
|
||||
onSuccess: () => toast.success(v ? `${site.name} 默认参与搜索` : `${site.name} 不再默认参与搜索`),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`site-sd-${site.id}`}>默认参与搜索</label>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** "更多"菜单:更新凭证、编辑、快照、启停、删除 */
|
||||
export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog: (d: SiteDialog) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -305,6 +330,7 @@ export function SiteCard({ site, checks, onDialog }: Props) {
|
||||
</dl>
|
||||
|
||||
<footer className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
|
||||
<SearchDefaultSwitch site={site} />
|
||||
{acc ? (
|
||||
<CheckButtons site={site} checks={checks} />
|
||||
) : (
|
||||
|
||||
@@ -323,6 +323,7 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
|
||||
snapshotEnabled: site.snapshotEnabled,
|
||||
intervalMin: site.snapshotIntervalSec ? String(Math.round(site.snapshotIntervalSec / 60)) : "",
|
||||
proxy: site.proxy,
|
||||
searchDefault: site.searchDefault !== false,
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const set = <K extends keyof typeof v>(k: K, val: (typeof v)[K]) => setV((s) => ({ ...s, [k]: val }));
|
||||
@@ -364,6 +365,7 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
|
||||
snapshotEnabled: v.snapshotEnabled,
|
||||
snapshotIntervalSec: interval ? interval * 60 : null,
|
||||
proxy: v.proxy,
|
||||
searchDefault: v.searchDefault,
|
||||
},
|
||||
});
|
||||
toast.success(`${v.name.trim()} 已保存`);
|
||||
@@ -453,6 +455,20 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
|
||||
<span className="block text-muted-foreground">走「站点默认」里的代理地址;没填代理地址时不生效</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mb-2 flex items-start gap-3">
|
||||
<Switch
|
||||
id="se-search"
|
||||
checked={v.searchDefault}
|
||||
onCheckedChange={(x) => set("searchDefault", x)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<label htmlFor="se-search" className="text-caption">
|
||||
<span className="block font-medium text-foreground">默认参与搜索</span>
|
||||
<span className="block text-muted-foreground">
|
||||
搜资源选「默认站点」时查这个站点;全部站点或指定站点时另选
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">User-Agent 随凭证保存,在"更新凭证"里填写。</p>
|
||||
</div>
|
||||
</DetailSheet>
|
||||
|
||||
@@ -66,9 +66,19 @@ import { SlotOrderList } from "./slot-order.tsx";
|
||||
|
||||
// 新建 / 编辑订阅(设计 §3):基础 / 进阶两栏,对齐 MP 编辑对话框。作品用全局搜索的 media 段(本地 + TMDB)。
|
||||
|
||||
type Target =
|
||||
/** 订阅的作品:媒体库作品 / TMDB 条目(保存时先建档,SEARCH-2 详情页入口)/ 按标题追踪 */
|
||||
export type SubscriptionTarget =
|
||||
| { kind: "entity"; id: string; title: string; year: number | null; mediaKind: SubscriptionMediaKind }
|
||||
| {
|
||||
kind: "tmdb";
|
||||
entityType: "movie" | "tv";
|
||||
externalId: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
mediaKind: SubscriptionMediaKind;
|
||||
}
|
||||
| { kind: "title"; titleQuery: string; mediaKind: SubscriptionMediaKind; note?: string };
|
||||
type Target = SubscriptionTarget;
|
||||
|
||||
const toSubKind = (k: string): SubscriptionMediaKind =>
|
||||
k === "movie" ? "movie" : k === "variety" ? "variety" : "series";
|
||||
@@ -263,11 +273,14 @@ function WorkPicker({ onPick, error }: { onPick: (t: Target) => void; error?: st
|
||||
export function SubscriptionFormDialog({
|
||||
sub,
|
||||
title,
|
||||
initialTarget,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
/** 缺省 = 新建 */
|
||||
sub?: Subscription | null;
|
||||
/** 新建时预填作品(作品详情页的「订阅」),不再显示作品搜索 */
|
||||
initialTarget?: SubscriptionTarget;
|
||||
/** 编辑时的作品名(单条订阅接口不带标题) */
|
||||
title?: string;
|
||||
onClose: () => void;
|
||||
@@ -278,8 +291,9 @@ export function SubscriptionFormDialog({
|
||||
const create = useCreateSubscription();
|
||||
const update = useUpdateSubscription();
|
||||
const setOrder = useSetSlotOrder();
|
||||
const [target, setTarget] = useState<Target | null>(null);
|
||||
const [target, setTarget] = useState<Target | null>(initialTarget ?? null);
|
||||
const [tab, setTab] = useState("basic");
|
||||
const entityFromTmdb = useEntityFromCandidate();
|
||||
const slotsQ = useSlots({ subscriptionId: sub?.id }, editing);
|
||||
const subSlots = useMemo(
|
||||
() =>
|
||||
@@ -289,7 +303,19 @@ export function SubscriptionFormDialog({
|
||||
[slotsQ.data, sub?.id],
|
||||
);
|
||||
const initialUpgrade: UpgradeStrategy = sub ? upgradeOf(subSlots, sub.mediaKind) : "off";
|
||||
const [d, setD] = useState<SubscriptionDraft>(() => (sub ? draftFromSubscription(sub, "off") : emptyDraft("series")));
|
||||
const [d, setD] = useState<SubscriptionDraft>(() => {
|
||||
if (sub) return draftFromSubscription(sub, "off");
|
||||
const k = initialTarget?.mediaKind ?? "series";
|
||||
const base = emptyDraft(k);
|
||||
return initialTarget
|
||||
? {
|
||||
...base,
|
||||
mediaKind: k,
|
||||
mode: k === "movie" ? "once" : "continuous",
|
||||
seasonScope: k === "movie" ? "current_season" : "following_seasons",
|
||||
}
|
||||
: base;
|
||||
});
|
||||
const [order, setOrderState] = useState<{ id: string; name: string }[] | null>(null);
|
||||
const [upgradeTouched, setUpgradeTouched] = useState(false);
|
||||
const [totalTouched, setTotalTouched] = useState(false);
|
||||
@@ -340,7 +366,7 @@ export function SubscriptionFormDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const pending = create.isPending || update.isPending || setOrder.isPending;
|
||||
const pending = create.isPending || update.isPending || setOrder.isPending || entityFromTmdb.isPending;
|
||||
|
||||
const submit = async () => {
|
||||
const { params, errors: errs } = draftToParams(d, sub?.params ?? {}, {
|
||||
@@ -372,10 +398,23 @@ export function SubscriptionFormDialog({
|
||||
toast.success("订阅已保存");
|
||||
onClose();
|
||||
} else if (target) {
|
||||
// TMDB 条目:订阅时隐式建档(季/集与总集数随之就位),同一 TMDB id 已建档则复用
|
||||
const tmdbEntity =
|
||||
target.kind === "tmdb"
|
||||
? (
|
||||
await entityFromTmdb.mutateAsync({
|
||||
provider: "tmdb",
|
||||
externalId: target.externalId,
|
||||
kind: target.entityType,
|
||||
})
|
||||
).mediaEntityId
|
||||
: null;
|
||||
const created = await create.mutateAsync({
|
||||
...(target.kind === "entity"
|
||||
? { mediaEntityId: target.id, seasonUnitId: d.seasonUnitId || undefined }
|
||||
: { titleQuery: target.titleQuery.trim() }),
|
||||
: tmdbEntity
|
||||
? { mediaEntityId: tmdbEntity }
|
||||
: { titleQuery: target.kind === "title" ? target.titleQuery.trim() : target.title }),
|
||||
mediaKind: d.mediaKind,
|
||||
mode: d.mode,
|
||||
seasonScope: isMovie ? "current_season" : d.seasonScope,
|
||||
@@ -405,7 +444,7 @@ export function SubscriptionFormDialog({
|
||||
const f = (k: string) => `${id}-${k}`;
|
||||
const titleText = sub
|
||||
? (title ?? sub.mediaEntityTitle ?? sub.titleQuery ?? "订阅")
|
||||
: target?.kind === "entity"
|
||||
: target?.kind === "entity" || target?.kind === "tmdb"
|
||||
? `${target.title}${target.year ? ` (${target.year})` : ""}`
|
||||
: (target?.titleQuery ?? "");
|
||||
|
||||
@@ -415,7 +454,7 @@ export function SubscriptionFormDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑订阅" : "新建订阅"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editing ? titleText : "从媒体库或 TMDB 选作品;选 TMDB 条目会先建档。"}
|
||||
{editing ? titleText : initialTarget ? titleText : "从媒体库或 TMDB 选作品;选 TMDB 条目会先建档。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{dup ? (
|
||||
@@ -455,13 +494,19 @@ export function SubscriptionFormDialog({
|
||||
<p className="truncate text-sm font-medium text-foreground">{titleText}</p>
|
||||
)}
|
||||
<p className="mt-0.5 text-meta text-muted-foreground">
|
||||
{target.kind === "entity" ? "媒体库作品" : (target.note ?? "按标题追踪,尚未建档")}
|
||||
{target.kind === "entity"
|
||||
? "媒体库作品"
|
||||
: target.kind === "tmdb"
|
||||
? "TMDB 条目,保存时建档"
|
||||
: (target.note ?? "按标题追踪,尚未建档")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setTarget(null)}>
|
||||
<X />
|
||||
更换
|
||||
</Button>
|
||||
{initialTarget ? null : (
|
||||
<Button variant="ghost" size="sm" onClick={() => setTarget(null)}>
|
||||
<X />
|
||||
更换
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<WorkPicker onPick={pickTarget} error={errors.target} />
|
||||
@@ -479,7 +524,7 @@ export function SubscriptionFormDialog({
|
||||
value: v,
|
||||
label: SUB_KIND_LABEL[v],
|
||||
}))}
|
||||
disabled={editing || target?.kind === "entity"}
|
||||
disabled={editing || target?.kind === "entity" || target?.kind === "tmdb"}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="方式" htmlFor={f("mode")}>
|
||||
|
||||
@@ -12,6 +12,7 @@ export default [
|
||||
route("inbox", "routes/console/inbox.tsx"),
|
||||
route("library", "routes/console/library.tsx"),
|
||||
route("library/:id", "routes/console/library.$id.tsx"),
|
||||
route("media/tmdb/:type/:id", "routes/console/media.tmdb.$type.$id.tsx"),
|
||||
route("identity", "routes/console/identity.tsx"),
|
||||
route("organize", "routes/console/organize.tsx"),
|
||||
route("organize/:id", "routes/console/organize.$id.tsx"),
|
||||
|
||||
@@ -11,11 +11,13 @@ import { EntityMetadataField as FieldEnum, FinalFieldValue } from "@umt/contract
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
ChevronRight,
|
||||
FolderTree,
|
||||
GitMerge,
|
||||
Lock,
|
||||
LockOpen,
|
||||
PackageSearch,
|
||||
PencilLine,
|
||||
RefreshCw,
|
||||
SearchX,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { mediaServers } from "~/api/instances.ts";
|
||||
import {
|
||||
useEntityDetail,
|
||||
useLibraryDetail,
|
||||
@@ -63,6 +66,9 @@ import {
|
||||
} from "~/components/library/parts.tsx";
|
||||
import { SlotCard } from "~/components/library/slot-card.tsx";
|
||||
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
|
||||
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
|
||||
import { EmbyBadge } from "~/components/search/work-card.tsx";
|
||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
@@ -576,6 +582,9 @@ export default function LibraryDetailPage() {
|
||||
const detail = useLibraryDetail(id);
|
||||
const entity = useEntityDetail(id);
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [subscribing, setSubscribing] = useState(false);
|
||||
const launcher = useReleaseSearchLauncher();
|
||||
const ms = mediaServers.useList();
|
||||
|
||||
const back = (
|
||||
<Link
|
||||
@@ -645,8 +654,29 @@ export default function LibraryDetailPage() {
|
||||
{e.originalTitle ? ` · ${e.originalTitle}` : ""}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-meta break-all text-muted-foreground">{e.id}</p>
|
||||
{(ms.data?.items.length ?? 0) > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
<EmbyBadge refOf={{ mediaEntityId: e.id }} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{e.status !== "merged" ? (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setSubscribing(true)}>
|
||||
<CalendarPlus />
|
||||
订阅
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}
|
||||
>
|
||||
<PackageSearch />
|
||||
搜索资源
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
|
||||
<CalendarClock />
|
||||
@@ -676,6 +706,19 @@ export default function LibraryDetailPage() {
|
||||
<DownloadsPanel detail={d} />
|
||||
</div>
|
||||
<MergeDialog entity={e} open={merging} onOpenChange={setMerging} />
|
||||
{subscribing ? (
|
||||
<SubscriptionFormDialog
|
||||
initialTarget={{
|
||||
kind: "entity",
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
year: e.year,
|
||||
mediaKind: e.kind === "movie" ? "movie" : e.kind === "variety" ? "variety" : "series",
|
||||
}}
|
||||
onClose={() => setSubscribing(false)}
|
||||
/>
|
||||
) : null}
|
||||
{launcher.dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { MediaServerInventory, TmdbMediaDetail } from "@umt/contracts";
|
||||
import { ArrowLeft, BookmarkPlus, CalendarPlus, FolderPlus, PackageSearch } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useEntityFromCandidate } from "~/api/files.ts";
|
||||
import { mediaServers } from "~/api/instances.ts";
|
||||
import { useMediaServerInventory, useTmdbMedia } from "~/api/search.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { MEDIA_KIND } from "~/components/library/labels.ts";
|
||||
import { CoverPlaceholder, Panel } from "~/components/library/parts.tsx";
|
||||
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
|
||||
import { InventoryBadges, mediaServerSummary, slotLine } from "~/components/search/work-card.tsx";
|
||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// 作品详情(仅 TMDB,SEARCH-2):TMDB 信息与季列表 + 本地账本 / Emby 对比;在这里「订阅」「搜索资源」「建档不订阅」。
|
||||
// 已建档的作品同时给媒体库详情入口。
|
||||
|
||||
export const meta = () => [{ title: "作品详情 · UHD 媒体自动化控制台" }];
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
export default function TmdbMediaPage() {
|
||||
const { type, id } = useParams();
|
||||
const q = useTmdbMedia(type, id);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const back = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="mb-4 inline-flex cursor-pointer items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
);
|
||||
|
||||
if (q.isPending) {
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<LoadingState rows={6} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (q.isError) {
|
||||
const code = (q.error as { code?: string }).code;
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<ErrorState
|
||||
title={code === "not_found" ? "TMDB 没有这个条目" : "作品加载失败"}
|
||||
error={q.error}
|
||||
onRetry={code === "not_found" ? undefined : () => q.refetch()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<Detail d={q.data} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ d }: { d: TmdbMediaDetail }) {
|
||||
const navigate = useNavigate();
|
||||
const [subscribing, setSubscribing] = useState(false);
|
||||
const entity = useEntityFromCandidate();
|
||||
const launcher = useReleaseSearchLauncher();
|
||||
const kind = d.entityType === "movie" ? "movie" : "series";
|
||||
const ref = d.localEntityId ? `local:${d.localEntityId}` : `tmdb:${d.entityType}:${d.externalId}`;
|
||||
|
||||
const createEntity = () =>
|
||||
entity.mutate(
|
||||
{ provider: "tmdb", externalId: d.externalId, kind: d.entityType },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
toast.success(r.created ? `已建档「${r.title}」` : `「${r.title}」已在媒体库`);
|
||||
navigate(consolePath("library", r.mediaEntityId));
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e, "建档失败")),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="mb-6 flex flex-wrap items-start gap-4">
|
||||
<CoverPlaceholder title={d.title} kind={kind} posterPath={d.posterPath} className="h-40 w-28 sm:h-48 sm:w-32" />
|
||||
<div className="min-w-0 flex-1 basis-64">
|
||||
<h1 className="text-title-lg font-semibold tracking-tight break-words text-foreground">
|
||||
{d.title}
|
||||
{d.year ? <span className="font-normal text-muted-foreground"> ({d.year})</span> : null}
|
||||
</h1>
|
||||
<p className="mt-1 text-body break-words text-muted-foreground">
|
||||
{[
|
||||
MEDIA_KIND[kind],
|
||||
d.originalTitle && d.originalTitle !== d.title ? d.originalTitle : null,
|
||||
d.releaseDate,
|
||||
d.originCountry.join(" / ") || null,
|
||||
`TMDB ${d.externalId}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<InventoryBadges inv={d.inventory} />
|
||||
</div>
|
||||
{d.overview ? <p className="mt-3 max-w-3xl text-caption text-foreground">{d.overview}</p> : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button onClick={() => setSubscribing(true)}>
|
||||
<CalendarPlus />
|
||||
订阅
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => launcher.open({ title: d.title, ref })}>
|
||||
<PackageSearch />
|
||||
搜索资源
|
||||
</Button>
|
||||
{d.localEntityId ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link to={consolePath("library", d.localEntityId)}>
|
||||
<BookmarkPlus />
|
||||
媒体库详情
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={createEntity} disabled={entity.isPending}>
|
||||
<FolderPlus />
|
||||
{entity.isPending ? "建档中…" : "建档不订阅"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-5">
|
||||
<InventoryPanel d={d} />
|
||||
{d.entityType === "tv" ? <SeasonsPanel d={d} /> : null}
|
||||
{d.aliases.length || d.externalIds.imdb || d.externalIds.tvdb ? (
|
||||
<Panel title="别名与外部身份">
|
||||
<p className="text-caption break-words text-muted-foreground">
|
||||
{[
|
||||
d.externalIds.imdb ? `IMDb ${d.externalIds.imdb}` : null,
|
||||
d.externalIds.tvdb ? `TVDB ${d.externalIds.tvdb}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
{d.aliases.length ? (
|
||||
<p className="mt-1 text-caption break-words text-foreground">{d.aliases.join("、")}</p>
|
||||
) : null}
|
||||
</Panel>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{subscribing ? (
|
||||
<SubscriptionFormDialog
|
||||
initialTarget={
|
||||
d.localEntityId
|
||||
? { kind: "entity", id: d.localEntityId, title: d.title, year: d.year, mediaKind: kind }
|
||||
: {
|
||||
kind: "tmdb",
|
||||
entityType: d.entityType,
|
||||
externalId: d.externalId,
|
||||
title: d.title,
|
||||
year: d.year,
|
||||
mediaKind: kind,
|
||||
}
|
||||
}
|
||||
onClose={() => setSubscribing(false)}
|
||||
onCreated={() => toast.success(`已订阅「${d.title}」`)}
|
||||
/>
|
||||
) : null}
|
||||
{launcher.dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryPanel({ d }: { d: TmdbMediaDetail }) {
|
||||
const ms = mediaServers.useList();
|
||||
const hasEmby = (ms.data?.items.length ?? 0) > 0;
|
||||
const emby = useMediaServerInventory({ tmdbType: d.entityType, tmdbId: d.externalId }, hasEmby);
|
||||
const inv = d.inventory;
|
||||
const line = slotLine(inv);
|
||||
return (
|
||||
<Panel title="库内对比" description="本地账本按 TMDB 身份对应;Emby 查询失败记为未知,不等于缺失">
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-meta text-muted-foreground">本地账本</dt>
|
||||
<dd className="mt-1 space-y-1 text-caption text-foreground">
|
||||
{inv.state === "not_collected" ? (
|
||||
<p>未收录。订阅或建档后开始追踪。</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{inv.versionCount} 个版本已入库
|
||||
{inv.downloadingCount ? ` · ${inv.downloadingCount} 个正在下载` : ""}
|
||||
{inv.subscribed ? " · 已订阅" : ""}
|
||||
</p>
|
||||
{line ? <p className="text-muted-foreground">{line}</p> : null}
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-meta text-muted-foreground">Emby</dt>
|
||||
<dd className="mt-1 text-caption text-foreground">
|
||||
{!hasEmby ? (
|
||||
<p className="text-muted-foreground">
|
||||
没有配置媒体服务器。
|
||||
<Link to={settingsPath("media-servers")} className="ml-1 underline underline-offset-4">
|
||||
去配置
|
||||
</Link>
|
||||
</p>
|
||||
) : emby.isPending ? (
|
||||
<p className="text-muted-foreground">查询中…</p>
|
||||
) : emby.isError ? (
|
||||
<p className="text-warning">{errorMessage(emby.error, "查询失败")}</p>
|
||||
) : (
|
||||
<EmbyInstances ms={emby.data} />
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbyInstances({ ms }: { ms: MediaServerInventory }) {
|
||||
const s = mediaServerSummary(ms);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{s ? <StatusBadge tone={s.tone}>{s.label}</StatusBadge> : null}
|
||||
<ul className="space-y-0.5">
|
||||
{ms.instances.map((i) => (
|
||||
<li key={i.instanceId} className="break-words text-muted-foreground">
|
||||
{i.name}:
|
||||
{i.status === "present"
|
||||
? i.items.map((x) => `${x.name}${x.year ? ` (${x.year})` : ""}`).join("、") || "已有"
|
||||
: i.status === "not_found"
|
||||
? "没有"
|
||||
: `未知${i.reason ? `(${i.reason})` : ""}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
|
||||
if (d.seasons.length === 0) {
|
||||
return (
|
||||
<Panel title="季">
|
||||
<EmptyState plain title="TMDB 还没有季信息" className="py-6" />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Panel title="季" description={d.localEntityId ? "本地一列是已入库集数" : "未收录,本地一列不显示"}>
|
||||
<table className="w-full text-caption">
|
||||
<thead>
|
||||
<tr className="text-left text-meta text-muted-foreground">
|
||||
<th className="py-1.5 pr-3 font-normal">季</th>
|
||||
<th className="py-1.5 pr-3 font-normal">名称</th>
|
||||
<th className="py-1.5 pr-3 font-normal">首播</th>
|
||||
<th className="py-1.5 pr-3 text-right font-normal">集数</th>
|
||||
{d.localEntityId ? <th className="py-1.5 text-right font-normal">本地</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{d.seasons.map((s) => (
|
||||
<tr key={s.seasonNumber}>
|
||||
<td className="py-1.5 pr-3 font-mono text-foreground">S{pad(s.seasonNumber)}</td>
|
||||
<td className="py-1.5 pr-3 break-words text-foreground">{s.name ?? "—"}</td>
|
||||
<td className="py-1.5 pr-3 text-muted-foreground tabular-nums">{s.airDate ?? "—"}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{s.episodeCount}</td>
|
||||
{d.localEntityId ? (
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{s.localHave === null ? "—" : `${s.localHave}/${s.episodeCount}`}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,57 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { ArrowLeft, Search } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { EmptyState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { useQueryState } from "~/components/console/use-query-state.ts";
|
||||
import { RELEASE_FILTER_KEYS, type ReleaseFilterState, ReleaseResults } from "~/components/search/release-results.tsx";
|
||||
import { ActionsSection, MediaSection } from "~/components/search/search-sections.tsx";
|
||||
import {
|
||||
parseWorkRef,
|
||||
RELEASE_FILTER_KEYS,
|
||||
type ReleaseFilterState,
|
||||
ReleaseResults,
|
||||
} from "~/components/search/release-results.tsx";
|
||||
import { ActionsSection, WorksSection } from "~/components/search/search-sections.tsx";
|
||||
import { SitePickerDialog } from "~/components/search/site-picker-dialog.tsx";
|
||||
import { releaseSearchPath } from "~/components/search/work-card.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
|
||||
// 全局搜索结果页(W9-G):/console/search?q= 三段 —— 作品 / 资源 / 操作;q 与资源筛选都写在 URL 里。
|
||||
// 全局搜索(SEARCH-2 两阶段):/console/search?q= 先列作品(本地 + TMDB,带库内状态)与操作,不查站点;
|
||||
// 点「搜索资源」选站点后进第二阶段 ?q=&work=&in=&t=(work = local:<id> / tmdb:<type>:<id> / q:<关键词>,in = 站点 ID)。
|
||||
|
||||
export const meta = () => [{ title: "搜索 · UHD 媒体自动化控制台" }];
|
||||
|
||||
export default function SearchPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const q = (params.get("q") ?? "").trim();
|
||||
const work = params.get("work") ?? "";
|
||||
const title = params.get("t") ?? q;
|
||||
const siteIds = params.get("in") ?? "";
|
||||
const target = useMemo(() => (work ? parseWorkRef(work) : null), [work]);
|
||||
const [input, setInput] = useState(q);
|
||||
const [picking, setPicking] = useState(false);
|
||||
useEffect(() => setInput(q), [q]);
|
||||
const f = useQueryState(RELEASE_FILTER_KEYS);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const next = input.trim();
|
||||
if (!next || next === q) return;
|
||||
// 换关键词时筛选随之清空(站点/分辨率分面随结果变化)
|
||||
if (!next || (next === q && !work)) return;
|
||||
// 换关键词回到第一阶段,筛选随之清空
|
||||
setParams(new URLSearchParams({ q: next }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="搜索" description="本地作品、TMDB、各站点资源和控制台页面。" />
|
||||
<PageHeader title="搜索" description="先看作品和库内状态,需要时再到站点搜资源。" />
|
||||
<form onSubmit={submit} className="mb-6 flex gap-2">
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-xl">
|
||||
<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="作品名、别名或资源标题"
|
||||
placeholder="作品名、原名或别名"
|
||||
aria-label="搜索关键词"
|
||||
className="pl-9"
|
||||
/>
|
||||
@@ -48,11 +62,39 @@ export default function SearchPage() {
|
||||
</form>
|
||||
|
||||
{!q ? (
|
||||
<EmptyState icon={Search} title="输入关键词开始搜索" description="也可以在任意页面按 Ctrl/⌘ K 打开搜索。" />
|
||||
<EmptyState icon={Search} title="输入作品名开始搜索" description="也可以在任意页面按 Ctrl/⌘ K 打开搜索。" />
|
||||
) : target ? (
|
||||
<div key={`${work}|${siteIds}`} className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<Link
|
||||
to={`/console/search?${new URLSearchParams({ q }).toString()}`}
|
||||
className="inline-flex items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回作品
|
||||
</Link>
|
||||
<h2 className="min-w-0 text-body font-semibold break-words text-foreground">
|
||||
{target.q ? `关键词「${target.q}」` : `「${title}」`}的资源
|
||||
</h2>
|
||||
</div>
|
||||
<ReleaseResults
|
||||
target={target}
|
||||
siteIds={siteIds}
|
||||
filters={f.values as ReleaseFilterState}
|
||||
setFilter={f.set}
|
||||
resetFilters={f.reset}
|
||||
onChangeSites={() => setPicking(true)}
|
||||
/>
|
||||
<SitePickerDialog
|
||||
open={picking}
|
||||
onOpenChange={setPicking}
|
||||
title={target.q ?? title}
|
||||
onConfirm={(ids) => navigate(releaseSearchPath({ title, ref: work }, ids))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div key={q} className="space-y-8">
|
||||
<MediaSection q={q} />
|
||||
<ReleaseResults q={q} filters={f.values as ReleaseFilterState} setFilter={f.set} resetFilters={f.reset} />
|
||||
<WorksSection q={q} />
|
||||
<ActionsSection q={q} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Globe, LayoutGrid, List, Plus, RefreshCw } from "lucide-react";
|
||||
import { Globe, LayoutGrid, List, PackageSearch, Plus, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useSites } from "~/api/sites.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { SearchDefaultDialog } from "~/components/sites/search-default-dialog.tsx";
|
||||
import { SiteAddDialog } from "~/components/sites/site-add-dialog.tsx";
|
||||
import { SiteCard, SiteTable } from "~/components/sites/site-card.tsx";
|
||||
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
|
||||
@@ -88,6 +89,7 @@ export default function SitesPage() {
|
||||
}, [credFor, sites.data]);
|
||||
|
||||
const all = sites.data?.items ?? [];
|
||||
const [searchDefaults, setSearchDefaults] = useState(false);
|
||||
const attention = all.filter(needsAttention).length;
|
||||
const shown = sortSites(
|
||||
all.filter((s) => (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true)),
|
||||
@@ -105,6 +107,10 @@ export default function SitesPage() {
|
||||
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setSearchDefaults(true)} disabled={!all.length}>
|
||||
<PackageSearch />
|
||||
搜索站点
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setAdding(true)}>
|
||||
<Plus />
|
||||
添加站点
|
||||
@@ -181,6 +187,7 @@ export default function SitesPage() {
|
||||
|
||||
{adding ? <SiteAddDialog onClose={() => setAdding(false)} /> : null}
|
||||
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
|
||||
<SearchDefaultDialog open={searchDefaults} onOpenChange={setSearchDefaults} sites={all} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user