Merge branch 'search3'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	docs/plan/00-first-version-plan.md
This commit is contained in:
root committed 2026-09-29 20:52:51 +08:00
commit 4c7375b52f
20 files changed
+1273 -81

No files matched your search

+2
View File
@@ -14,6 +14,8 @@ export type ReleaseFilters = {
category?: string;
promotion?: string;
resolution?: ResolutionStatus;
/** 快速筛选(SEARCH-3):`维度:值,值;维度:值`;只筛本地数据 */
qf?: string;
};
export const releaseKeys = {
+2
View File
@@ -50,6 +50,8 @@ export type ReleaseSearchFilters = {
siteFilter?: string;
resolution?: string;
source?: string;
/** 快速筛选(SEARCH-3):`维度:值,值;维度:值` */
qf?: string;
/** 字节 */
minSize?: number;
maxSize?: number;
@@ -0,0 +1,251 @@
import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts";
import { Check, ChevronDown, X } from "lucide-react";
import { useState } from "react";
import { useIsMobile } from "~/components/files/use-mobile.ts";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { cn } from "~/lib/utils.ts";
import {
clearDims,
QUICK_GROUPS,
type QuickDim,
type QuickGroup,
type QuickSelection,
selectedCount,
selectedTags,
toggleValue,
} from "./quick-filter-state.ts";
// 资源快速筛选条(SEARCH-3,对照 MP 搜索结果筛选):一行可横向滚动的维度按钮,点开多选;
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,可「清除全部」。
// 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。
export function QuickFilterBar({
facets,
value,
onChange,
hide = [],
className,
}: {
facets: ReleaseQuickFacets | undefined;
value: QuickSelection;
onChange: (next: QuickSelection) => void;
/** 不显示的维度(站点资源 Tab 不显示站点) */
hide?: QuickDim[];
className?: string;
}) {
const mobile = useIsMobile();
const [sheet, setSheet] = useState<QuickGroup | null>(null);
const groups = QUICK_GROUPS.filter((g) => {
const dims = g.dims.filter((d) => !hide.includes(d.dim));
if (!dims.length) return false;
const opts = dims.flatMap((d) => facets?.[d.dim] ?? []);
// 站点只有一个时不用筛;季/集只在有剧集资源时出现
if (g.key === "site" && opts.length <= 1 && !selectedCount(value, ["site"])) return false;
return (
opts.length > 0 ||
selectedCount(
value,
dims.map((d) => d.dim),
) > 0
);
});
const tags = selectedTags(value, facets).filter((t) => !hide.includes(t.dim));
if (!groups.length && !tags.length) return null;
return (
<div className={cn("mb-3 min-w-0 space-y-2", className)}>
<div
role="toolbar"
aria-label="快速筛选"
className="-mx-1 flex min-w-0 gap-1.5 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]"
>
{groups.map((g) => {
const dims = g.dims.map((d) => d.dim);
const n = selectedCount(value, dims);
const chip = (
<button
key={g.key}
type="button"
aria-haspopup="dialog"
onClick={mobile ? () => setSheet(g) : undefined}
className={cn(
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1 rounded-full border px-3 text-caption font-medium whitespace-nowrap transition-[background-color,color,transform] duration-micro active:scale-[0.96]",
n
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
)}
>
{g.label}
{n ? (
<span className="min-w-4 rounded-full bg-primary px-1 text-center text-meta leading-4 text-primary-foreground tabular-nums">
{n}
</span>
) : null}
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</button>
);
return mobile ? (
chip
) : (
<Popover key={g.key}>
<PopoverTrigger asChild>{chip}</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-0">
<GroupPanel group={g} facets={facets} value={value} onChange={onChange} />
</PopoverContent>
</Popover>
);
})}
</div>
{tags.length ? (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{tags.map((t) => (
<button
key={`${t.dim}:${t.value}`}
type="button"
onClick={() => onChange(toggleValue(value, t.dim, t.value))}
aria-label={`移除筛选 ${t.dimLabel} ${t.label}`}
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-full bg-foreground/[0.06] pr-1.5 pl-2.5 text-meta text-foreground transition-colors duration-micro hover:bg-foreground/[0.1]"
>
<span className="text-muted-foreground">{t.dimLabel}</span>
<span className="max-w-40 truncate">{t.label}</span>
<X className="h-3 w-3 text-muted-foreground" />
</button>
))}
<Button variant="ghost" size="sm" className="h-7" onClick={() => onChange({})}>
清除全部
</Button>
</div>
) : null}
{mobile ? (
<Sheet open={!!sheet} onOpenChange={(o) => !o && setSheet(null)}>
<SheetContent side="bottom" className="max-h-[80dvh] gap-0 rounded-t-2xl">
<SheetHeader className="pb-2">
<SheetTitle>{sheet?.label}</SheetTitle>
<SheetDescription className="text-meta">可多选,数字是选中后的条数</SheetDescription>
</SheetHeader>
{sheet ? (
<div className="min-h-0 overflow-y-auto px-2 pb-2">
<GroupPanel group={sheet} facets={facets} value={value} onChange={onChange} bare />
</div>
) : null}
<SheetFooter className="flex-row gap-2 border-t border-border/60">
<Button
variant="outline"
className="flex-1"
disabled={
!sheet ||
!selectedCount(
value,
sheet.dims.map((d) => d.dim),
)
}
onClick={() =>
sheet &&
onChange(
clearDims(
value,
sheet.dims.map((d) => d.dim),
),
)
}
>
清除
</Button>
<Button className="flex-1" onClick={() => setSheet(null)}>
完成
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
) : null}
</div>
);
}
/** 一个入口的多选列表:每项带计数;0 计数且未选时置灰不可选 */
function GroupPanel({
group,
facets,
value,
onChange,
bare = false,
}: {
group: QuickGroup;
facets: ReleaseQuickFacets | undefined;
value: QuickSelection;
onChange: (next: QuickSelection) => void;
/** 抽屉里不显示标题栏(抽屉有自己的标题和清除) */
bare?: boolean;
}) {
const dims = group.dims.map((d) => d.dim);
const n = selectedCount(value, dims);
return (
<div className="flex max-h-[min(60dvh,24rem)] flex-col">
{bare ? null : (
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-caption font-medium text-foreground">{group.label}</span>
<button
type="button"
disabled={!n}
onClick={() => onChange(clearDims(value, dims))}
className="cursor-pointer text-meta text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-50"
>
清除
</button>
</div>
)}
<div className={cn("min-h-0 overflow-y-auto", bare ? "" : "p-1")}>
{group.dims.map((d) => {
const opts: FacetCount[] = facets?.[d.dim] ?? [];
const chosen = value[d.dim] ?? [];
const extra = chosen.filter((v) => !opts.some((o) => o.value === v));
const all = [...opts, ...extra.map((v) => ({ value: v, label: v, count: 0 }))];
if (!all.length) return null;
return (
<fieldset key={d.dim} className="m-0 border-0 p-0">
{group.dims.length > 1 ? (
<legend className="px-2 pt-2 pb-1 text-meta text-muted-foreground">{d.label}</legend>
) : (
<legend className="sr-only">{d.label}</legend>
)}
{all.map((o) => {
const on = chosen.includes(o.value);
const dead = o.count === 0 && !on;
return (
<button
key={o.value}
type="button"
aria-pressed={on}
disabled={dead}
onClick={() => onChange(toggleValue(value, d.dim, o.value))}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-left text-sm transition-colors duration-micro md:py-1.5",
on ? "text-foreground" : "text-foreground/90",
dead ? "cursor-default opacity-40" : "hover:bg-foreground/[0.06]",
)}
>
<span
aria-hidden
className={cn(
"grid h-4 w-4 shrink-0 place-items-center rounded-sm border",
on ? "border-primary bg-primary text-primary-foreground" : "border-border",
)}
>
{on ? <Check className="h-3 w-3" /> : null}
</span>
<span className="min-w-0 flex-1 truncate">{o.label}</span>
<span className="text-meta text-muted-foreground tabular-nums">{o.count}</span>
</button>
);
})}
</fieldset>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
clearDims,
formatQuickFilter,
parseStored,
persistable,
quickFilterStorageKey,
selectedCount,
selectedTags,
toggleValue,
} from "./quick-filter-state.ts";
// SEARCH-3 快速筛选:qf 参数、选择切换、已选标签、本机记忆只存固定档位
const empty = {
resolution: [],
videoCodec: [],
releaseType: [],
site: [],
promotion: [],
hdr: [],
releaseGroup: [],
season: [],
episode: [],
};
describe("快速筛选状态", () => {
it("qf 按维度顺序拼接,值编码;空选择不带参数", () => {
expect(formatQuickFilter({ hdr: ["DV"], resolution: ["2160p"], releaseGroup: ["A,B"] })).toBe(
"resolution:2160p;hdr:DV;releaseGroup:A%2CB",
);
expect(formatQuickFilter({ resolution: [] })).toBeUndefined();
});
it("切换 / 清除 / 计数", () => {
let s = toggleValue({}, "resolution", "2160p");
s = toggleValue(s, "resolution", "1080p");
s = toggleValue(s, "season", "1");
expect(selectedCount(s)).toBe(3);
s = toggleValue(s, "resolution", "2160p");
expect(s.resolution).toEqual(["1080p"]);
expect(selectedCount(clearDims(s, ["season", "episode"]))).toBe(1);
});
it("已选标签取分面里的选项名,取不到用原值", () => {
const facets = { ...empty, releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }] };
expect(selectedTags({ releaseType: ["encode"], season: ["2"] }, facets)).toEqual([
{ dim: "releaseType", value: "encode", dimLabel: "版本", label: "蓝光压制" },
{ dim: "season", value: "2", dimLabel: "季", label: "2" },
]);
});
it("本机记忆只保留分辨率/编码/版本/促销/HDR,坏数据忽略", () => {
const sel = { resolution: ["2160p"], site: ["x"], releaseGroup: ["FLUX"], hdr: ["DV"] };
expect(persistable(sel)).toEqual({ resolution: ["2160p"], hdr: ["DV"] });
expect(parseStored(JSON.stringify(sel))).toEqual({ resolution: ["2160p"], hdr: ["DV"] });
expect(parseStored("{bad")).toEqual({});
expect(parseStored(JSON.stringify({ resolution: "2160p", videoCodec: [1, "H.265"] }))).toEqual({
videoCodec: ["H.265"],
});
expect(quickFilterStorageKey("site")).not.toBe(quickFilterStorageKey("search"));
});
});
@@ -0,0 +1,131 @@
import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts";
import { useCallback, useEffect, useState } from "react";
// 资源快速筛选(SEARCH-3)的状态与参数:搜索结果页和站点资源 Tab 共用。
// 服务端口径见 @umt/domain release-facets:同一维度多选为 OR,不同维度为 AND。
export type QuickDim = keyof ReleaseQuickFacets;
export type QuickSelection = Partial<Record<QuickDim, string[]>>;
/** 筛选条上的一个入口;「季/集」一个入口里分两节 */
export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[] };
export const QUICK_GROUPS: QuickGroup[] = [
{ key: "resolution", label: "分辨率", dims: [{ dim: "resolution", label: "分辨率" }] },
{ key: "videoCodec", label: "编码", dims: [{ dim: "videoCodec", label: "编码" }] },
{ key: "releaseType", label: "版本", dims: [{ dim: "releaseType", label: "版本" }] },
{ key: "site", label: "站点", dims: [{ dim: "site", label: "站点" }] },
{ key: "promotion", label: "促销", dims: [{ dim: "promotion", label: "促销" }] },
{ key: "hdr", label: "HDR", dims: [{ dim: "hdr", label: "HDR" }] },
{ key: "releaseGroup", label: "制作组", dims: [{ dim: "releaseGroup", label: "制作组" }] },
{
key: "seasonEpisode",
label: "季/集",
dims: [
{ dim: "season", label: "季" },
{ dim: "episode", label: "集" },
],
},
];
const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim));
/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集随搜索变化,不记 */
const PERSISTED: QuickDim[] = ["resolution", "videoCodec", "releaseType", "promotion", "hdr"];
/** 查询参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent */
export function formatQuickFilter(sel: QuickSelection): string | undefined {
const s = DIM_ORDER.filter((d) => sel[d]?.length)
.map((d) => `${d}:${sel[d]!.map((v) => encodeURIComponent(v)).join(",")}`)
.join(";");
return s || undefined;
}
export const selectedCount = (sel: QuickSelection, dims: readonly QuickDim[] = DIM_ORDER) =>
dims.reduce((n, d) => n + (sel[d]?.length ?? 0), 0);
export function toggleValue(sel: QuickSelection, dim: QuickDim, value: string): QuickSelection {
const cur = sel[dim] ?? [];
const next = cur.includes(value) ? cur.filter((v) => v !== value) : [...cur, value];
return { ...sel, [dim]: next };
}
export function clearDims(sel: QuickSelection, dims: readonly QuickDim[]): QuickSelection {
const next = { ...sel };
for (const d of dims) delete next[d];
return next;
}
/** 已选条件的标签(按维度顺序),文字取分面里的选项名 */
export function selectedTags(
sel: QuickSelection,
facets: ReleaseQuickFacets | undefined,
): { dim: QuickDim; value: string; dimLabel: string; label: string }[] {
const dimLabel = new Map(QUICK_GROUPS.flatMap((g) => g.dims.map((d) => [d.dim, d.label] as const)));
return DIM_ORDER.flatMap((dim) =>
(sel[dim] ?? []).map((value) => ({
dim,
value,
dimLabel: dimLabel.get(dim) ?? dim,
label: facets?.[dim]?.find((o: FacetCount) => o.value === value)?.label ?? value,
})),
);
}
/** 本机记住的选择只保留固定档位的维度 */
export function persistable(sel: QuickSelection): QuickSelection {
const out: QuickSelection = {};
for (const d of PERSISTED) if (sel[d]?.length) out[d] = [...sel[d]!];
return out;
}
export function parseStored(raw: string | null): QuickSelection {
if (!raw) return {};
try {
const v = JSON.parse(raw) as unknown;
if (!v || typeof v !== "object") return {};
const out: QuickSelection = {};
for (const d of PERSISTED) {
const list = (v as Record<string, unknown>)[d];
if (Array.isArray(list)) {
const vals = list.filter((x): x is string => typeof x === "string" && x.length > 0);
if (vals.length) out[d] = vals;
}
}
return out;
} catch {
return {};
}
}
/** localStorage 键:按页面区分(搜索结果页 / 站点资源 Tab) */
export const quickFilterStorageKey = (page: "search" | "site") => `umt.quickFilters.${page}`;
/**
* 快速筛选状态;挂载后从 localStorage 读上次的选择(服务端渲染时没有 localStorage)。
* 读写失败(隐私模式等)只是不记忆,不影响筛选。
*/
export function useQuickFilters(page: "search" | "site"): [QuickSelection, (s: QuickSelection) => void] {
const key = quickFilterStorageKey(page);
const [sel, setSel] = useState<QuickSelection>({});
useEffect(() => {
try {
const stored = parseStored(globalThis.localStorage?.getItem(key) ?? null);
if (selectedCount(stored)) setSel(stored);
} catch {
// 读不到就不恢复
}
}, [key]);
const set = useCallback(
(next: QuickSelection) => {
setSel(next);
try {
globalThis.localStorage?.setItem(key, JSON.stringify(persistable(next)));
} catch {
// 写不了就不记忆
}
},
[key],
);
return [sel, set];
}
@@ -1,4 +1,4 @@
import type { FacetCount, ReleaseSearchPage, SearchRelease } from "@umt/contracts";
import type { ReleaseSearchPage, SearchRelease } from "@umt/contracts";
import {
ArrowDown,
ArrowDownWideNarrow,
@@ -19,17 +19,19 @@ import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch }
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { ChipToggleGroup } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import { QuickFilterBar } from "./quick-filter-bar.tsx";
import { formatQuickFilter, type QuickSelection, selectedCount, useQuickFilters } from "./quick-filter-state.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts";
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/
// 促销/HDR/制作组/季集,多选带计数)+ 大小/免费/H&R、排序、游标;
// 行操作同站点工作台(库存叠加、下载、委托 Agent)。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
@@ -68,7 +70,12 @@ export function parseWorkRef(ref: string): ReleaseTarget | null {
return null;
}
export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteIds = ""): ReleaseSearchFilters {
export function toApiFilters(
target: ReleaseTarget,
f: ReleaseFilterState,
siteIds = "",
quick: QuickSelection = {},
): 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 {
@@ -77,6 +84,7 @@ export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteI
siteFilter: f.sites || undefined,
resolution: f.resolution || undefined,
source: f.source || undefined,
qf: formatQuickFilter(quick),
minSize: gbToBytes(f.minGb),
maxSize: gbToBytes(f.maxGb),
free: bool(f.free),
@@ -86,8 +94,6 @@ export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteI
};
}
const csvList = (s: string) => (s ? s.split(",").filter(Boolean) : []);
export function ReleaseResults({
target,
siteIds,
@@ -104,14 +110,20 @@ export function ReleaseResults({
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
resetFilters: () => void;
}) {
const api = useMemo(() => toApiFilters(target, filters, siteIds), [target, filters, siteIds]);
const [quick, setQuick] = useQuickFilters("search");
const api = useMemo(() => toApiFilters(target, filters, siteIds, quick), [target, filters, siteIds, quick]);
const res = useReleaseSearch(api);
const refresh = useRefreshReleaseSearch();
const pages = res.data?.pages ?? [];
const first = pages[0];
const rows = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const [dl, setDl] = useState<SearchRelease | null>(null);
const filtered = RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
const filtered =
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
const resetAll = () => {
setQuick({});
resetFilters();
};
return (
<section aria-labelledby="search-releases" className="min-w-0">
@@ -155,9 +167,8 @@ export function ReleaseResults({
{first ? <CoverageNotice page={first} /> : null}
{first ? (
<Filters page={first} filters={filters} setFilter={setFilter} onReset={filtered ? resetFilters : undefined} />
) : null}
{first ? <QuickFilterBar facets={first.facets} value={quick} onChange={setQuick} /> : null}
{first ? <Filters filters={filters} setFilter={setFilter} onReset={filtered ? resetAll : undefined} /> : null}
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{res.isPending ? (
@@ -247,16 +258,11 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
);
}
const facetOpts = (items: FacetCount[]) =>
items.map((f) => ({ value: f.value, label: `${f.value === "unknown" ? "未知" : f.label} ${f.count}` }));
function Filters({
page,
filters,
setFilter,
onReset,
}: {
page: ReleaseSearchPage;
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
onReset?: () => void;
@@ -265,30 +271,10 @@ function Filters({
const [maxGb, setMaxGb] = useState(filters.maxGb);
useEffect(() => setMinGb(filters.minGb), [filters.minGb]);
useEffect(() => setMaxGb(filters.maxGb), [filters.maxGb]);
const facets: { key: "sites" | "resolution" | "source"; label: string; items: FacetCount[] }[] = [
{ key: "sites", label: "站点", items: page.facets.sites },
{ key: "resolution", label: "分辨率", items: page.facets.resolutions },
{ key: "source", label: "来源", items: page.facets.sources },
];
const desc = filters.order !== "asc";
return (
<div className="mb-3 space-y-2.5">
{facets
.filter((f) => f.items.length > 0)
.map((f) => (
<div key={f.key} className="flex min-w-0 flex-wrap items-start gap-2">
<span className="w-12 shrink-0 pt-1 text-meta text-muted-foreground">{f.label}</span>
<div className="min-w-0 flex-1">
<ChipToggleGroup
label={f.label}
options={facetOpts(f.items)}
value={csvList(filters[f.key])}
onChange={(v) => setFilter(f.key, v.join(","))}
/>
</div>
</div>
))}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-meta text-muted-foreground">大小</span>
@@ -73,6 +73,11 @@ describe("站点选择", () => {
siteFilter: "b",
});
expect(toApiFilters({ tmdb: "movie:1" }, { ...f, sites: "" }, "")).not.toHaveProperty("q");
// 快速筛选进 qf(同维度逗号分隔,值编码)
expect(toApiFilters({ q: "x" }, f, "", { resolution: ["2160p", "1080p"], promotion: ["50%"], hdr: [] }).qf).toBe(
"resolution:2160p,1080p;promotion:50%25",
);
expect(toApiFilters({ q: "x" }, f, "", {}).qf).toBeUndefined();
});
});
+37 -22
View File
@@ -26,6 +26,8 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { QuickFilterBar } from "~/components/search/quick-filter-bar.tsx";
import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/search/quick-filter-state.ts";
import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx";
import { SiteMoreMenu } from "~/components/sites/site-card.tsx";
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
@@ -40,13 +42,6 @@ import { ReleaseRow } from "~/components/workbench/release-list.tsx";
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
import { errorMessage } from "~/lib/api.ts";
const PROMOS = [
{ value: "free", label: "免费" },
{ value: "2xfree", label: "2x 免费" },
{ value: "50%", label: "50%" },
{ value: "30%", label: "30%" },
{ value: "2x", label: "2x 上传" },
];
const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
{ value: "resolved", label: "已识别" },
{ value: "unresolved", label: "待识别" },
@@ -120,26 +115,34 @@ export default function SiteWorkbenchPage() {
const [qInput, setQInput] = useState("");
const q = useDebounced(qInput.trim());
const [category, setCategory] = useState("");
const [promotion, setPromotion] = useState("");
const [resolution, setResolution] = useState("");
const [local, setLocal] = useState<LocalFilters>({ hr: "", seeding: "", size: "", age: "" });
const [live, setLive] = useState(false);
// 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求
const [quick, setQuick] = useQuickFilters("site");
const qf = formatQuickFilter(quick);
// 实时补查只对开启时的快速筛选生效;改快速筛选不会再次请求站点
const [liveAt, setLiveAt] = useState<string | null>(null);
const live = liveAt !== null && liveAt === (qf ?? "");
const setLive = (v: boolean) => setLiveAt(v ? (qf ?? "") : null);
const [openId, setOpenId] = useState<string | null>(null);
const filters: ReleaseFilters = {
q: q || undefined,
category: category || undefined,
promotion: promotion || undefined,
resolution: (resolution || undefined) as ResolutionStatus | undefined,
qf,
};
const releases = useSiteReleases(tab === "resources" ? siteId : "", filters, live);
// 实时补查只对当前搜索生效一次
// biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先
useEffect(() => setLive(false), [q, category, promotion, resolution]);
useEffect(() => setLiveAt(null), [q, category, resolution]);
const pages = releases.data?.pages ?? [];
const all = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const coverage = pages[0]?.coverage;
const quickTotal = pages[0]?.total;
/** 本地有资源,但快速筛选后一条都没有 */
const quickHidden = selectedCount(quick) > 0 && (pages[0]?.unfilteredTotal ?? 0) > 0 && quickTotal === 0;
const now = Date.now();
const rows = all.filter((r) => localMatch(r, local, now));
const localActive = Object.values(local).some(Boolean);
@@ -162,11 +165,11 @@ export default function SiteWorkbenchPage() {
const reset = () => {
setQInput("");
setCategory("");
setPromotion("");
setQuick({});
setResolution("");
setLocal({ hr: "", seeding: "", size: "", age: "" });
};
const anyFilter = !!(qInput || category || promotion || resolution || localActive);
const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick));
if (sites.error && !sites.data) return <ErrorState error={sites.error} onRetry={() => void sites.refetch()} />;
if (sites.data && !s)
@@ -327,7 +330,6 @@ export default function SiteWorkbenchPage() {
}
>
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
<FilterSelect label="促销" value={promotion} onChange={setPromotion} options={PROMOS} />
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
<FilterSelect
label="H&R"
@@ -361,6 +363,8 @@ export default function SiteWorkbenchPage() {
/>
</FilterBar>
<QuickFilterBar facets={pages[0]?.facets} value={quick} onChange={setQuick} hide={["site"]} />
{coverage || refreshFailed ? (
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
) : null}
@@ -373,25 +377,36 @@ export default function SiteWorkbenchPage() {
) : rows.length === 0 ? (
<EmptyState
plain
icon={all.length ? SearchX : Globe}
icon={all.length || quickHidden ? SearchX : Globe}
title={
all.length ? "已加载的结果里没有符合筛选的资源" : q ? "本地快照里没有匹配的资源" : "还没有快照数据"
all.length
? "已加载的结果里没有符合筛选的资源"
: quickHidden
? "没有符合筛选条件的资源"
: q
? "本地快照里没有匹配的资源"
: "还没有快照数据"
}
description={
all.length
? "H&R、做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。"
: coverage?.liveError
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
: q
? "点「实时补查」直接在站点搜索。"
: "点「快照」抓取站点列表。"
: quickHidden
? "减少快速筛选条件再看。"
: coverage?.liveError
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
: q
? "点「实时补查」直接在站点搜索。"
: "点「快照」抓取站点列表。"
}
/>
) : (
<>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground">
<span>
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
{quickTotal !== undefined && selectedCount(quick)
? `共 ${quickTotal} 条,已加载 ${all.length} 条`
: `已加载 ${all.length} 条`}
{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
</span>
{freeCount ? <span>其中免费 {freeCount}</span> : null}
{reqStats.data ? (