Files
rootandClaude Opus 5.5 21c55dfb3a feat(web): 记录抽屉与两层导航标准
控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。

- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 14:03:47 +08:00

453 lines
16 KiB
TypeScript

import type { SubscriptionGroup, SubscriptionOverviewItem, SubscriptionSort } from "@umt/contracts";
import {
CalendarClock,
CalendarDays,
ChevronDown,
LayoutGrid,
List,
ListChecks,
Pause,
Play,
Plus,
Search,
Trash2,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import { useSiteOptions } from "~/api/instances.ts";
import { useRuleGroups } from "~/api/rules.ts";
import { type OverviewFilters, useSubscriptionOverview } from "~/api/subscriptions.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
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 { useRecordSheet } from "~/components/console/record-sheet.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { useQueryState } from "~/components/console/use-query-state.ts";
import { opts, SUB_ORIGIN } from "~/components/library/labels.ts";
import { DeleteSubscriptionsDialog, useSubscriptionOps } from "~/components/subscriptions/actions.tsx";
import { SubscriptionCalendarView } from "~/components/subscriptions/calendar.tsx";
import { GROUP_LABEL, GROUP_ORDER, SUB_KIND_LABEL } from "~/components/subscriptions/model.ts";
import { SubscriptionCard, SubscriptionRow } from "~/components/subscriptions/overview-item.tsx";
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
import { SubscriptionSheet } from "~/components/subscriptions/subscription-sheet.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { SegmentedControl } from "~/components/ui/segmented.tsx";
import { cn } from "~/lib/utils.ts";
import { legacyRedirectLoader } from "./redirects.ts";
// 订阅总览(FE-5,docs/design/subscriptions.md §1):类型页签 → 五组分段(计数来自 counts)→ 卡片 / 列表。
// 筛选、分组、视图都进 URL;组内按上次更新倒序(服务端 sort=lastUpdated)。
export const meta = () => [{ title: "订阅 · UHD 媒体自动化控制台" }];
// 旧的 ?tab=policies 跳到收录策略
export const clientLoader = legacyRedirectLoader;
const SORT_LABEL: Record<SubscriptionSort, string> = {
lastUpdated: "上次更新",
nextSearch: "下次搜索",
missing: "缺集数",
created: "添加时间",
name: "名称",
};
const VIEW_KEY = "umt.subscriptions.view";
function useDebounced(v: string, ms = 300) {
const [d, setD] = useState(v);
useEffect(() => {
const t = setTimeout(() => setD(v), ms);
return () => clearTimeout(t);
}, [v, ms]);
return d;
}
function Pills<T extends string>({
label,
value,
onChange,
items,
}: {
label: string;
value: T | "";
onChange: (v: T | "") => void;
items: { value: T | ""; label: string; count?: number }[];
}) {
return (
<div className="min-w-0 overflow-x-auto scroll-bleed">
<SegmentedControl<T | "all">
label={label}
value={value || "all"}
onChange={(v) => onChange(v === "all" ? "" : v)}
options={items.map((it) => ({ value: it.value || "all", label: it.label, count: it.count }))}
/>
</div>
);
}
export default function SubscriptionsPage() {
const f = useQueryState(["kind", "group", "q", "site", "rg", "origin", "up", "sort", "view", "month"] as const);
const v = f.values;
const sheet = useRecordSheet();
const [q, setQ] = useState(v.q);
const dq = useDebounced(q.trim());
useEffect(() => {
if (dq !== v.q) f.set("q", dq);
}, [dq, v.q, f.set]);
const [view, setViewState] = useState<"card" | "list">("card");
useEffect(() => {
try {
if (localStorage.getItem(VIEW_KEY) === "list") setViewState("list");
} catch {
/* 无存储时用卡片 */
}
}, []);
const calendar = v.view === "calendar";
const setView = (x: "card" | "list") => {
if (calendar) f.set("view", "");
setViewState(x);
try {
localStorage.setItem(VIEW_KEY, x);
} catch {
/* 忽略 */
}
};
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<SubscriptionOverviewItem | null>(null);
const [selecting, setSelecting] = useState(false);
const [sel, setSel] = useState<Set<string>>(new Set());
const [confirmDelete, setConfirmDelete] = useState(false);
const [collapsed, setCollapsed] = useState<Set<SubscriptionGroup>>(new Set());
const ops = useSubscriptionOps();
const filters: OverviewFilters = {
mediaKind: (v.kind || undefined) as OverviewFilters["mediaKind"],
group: (v.group || undefined) as OverviewFilters["group"],
q: v.q || undefined,
siteId: v.site || undefined,
ruleGroupId: v.rg || undefined,
origin: (v.origin || undefined) as OverviewFilters["origin"],
upgrading: (v.up || undefined) as OverviewFilters["upgrading"],
sort: (v.sort || undefined) as OverviewFilters["sort"],
};
const query = useSubscriptionOverview(filters);
const items = useMemo(() => query.data?.pages.flatMap((p) => p.items) ?? [], [query.data]);
const paged = useListPagination(items, {
hasMore: query.hasNextPage,
onLoadMore: () => query.fetchNextPage(),
loadingMore: query.isFetchingNextPage,
error: query.error,
resetKey: JSON.stringify(filters),
});
const visible = paged.items ?? [];
const first = query.data?.pages[0];
const counts = first?.counts;
const allCount = counts ? GROUP_ORDER.reduce((n, g) => n + (counts[g] ?? 0), 0) : undefined;
const sites = useSiteOptions();
const groups = useRuleGroups({});
const filtered = !!(v.q || v.site || v.rg || v.origin || v.up);
const grouped = !v.group && !v.sort;
const toggle = (id: string, on: boolean) =>
setSel((s) => {
const n = new Set(s);
if (on) n.add(id);
else n.delete(id);
return n;
});
const selIds = [...sel].filter((id) => items.some((x) => x.id === id));
const exitSelect = () => {
setSelecting(false);
setSel(new Set());
};
const renderItems = (list: SubscriptionOverviewItem[]) =>
view === "card" ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{list.map((x) => (
<SubscriptionCard
key={x.id}
x={x}
onEdit={() => setEditing(x)}
selected={sel.has(x.id)}
onSelect={selecting ? (on) => toggle(x.id, on) : undefined}
/>
))}
</div>
) : (
<ul className="pg-slab divide-y divide-border/60 overflow-hidden rounded-xl">
{list.map((x) => (
<SubscriptionRow
key={x.id}
x={x}
onEdit={() => setEditing(x)}
selected={sel.has(x.id)}
onSelect={selecting ? (on) => toggle(x.id, on) : undefined}
/>
))}
</ul>
);
return (
<>
<PageHeader
title="订阅"
actions={
<>
<Button asChild variant="outline" size="sm">
<Link to={consolePath("rules")}>
<ListChecks />
规则
</Link>
</Button>
<Button onClick={() => setCreating(true)}>
<Plus />
新建订阅
</Button>
</>
}
/>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<Pills
label="类型"
value={v.kind as "" | "series" | "movie" | "variety"}
onChange={(x) => f.set("kind", x)}
items={[
{ value: "", label: "全部" },
{ value: "series", label: SUB_KIND_LABEL.series },
{ value: "movie", label: SUB_KIND_LABEL.movie },
{ value: "variety", label: SUB_KIND_LABEL.variety },
]}
/>
<div className="flex items-center gap-1">
{calendar ? null : (
<Button
variant="outline"
size="sm"
aria-pressed={selecting}
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
>
{selecting ? <X /> : null}
{selecting ? "退出批量" : "批量"}
</Button>
)}
<SegmentedControl
label="视图"
value={calendar ? "calendar" : view}
onChange={(x) => (x === "calendar" ? f.set("view", "calendar") : setView(x))}
options={[
{ value: "card", label: "", title: "卡片视图", icon: <LayoutGrid /> },
{ value: "list", label: "", title: "列表视图", icon: <List /> },
{ value: "calendar", label: "", title: "日历", icon: <CalendarDays /> },
]}
/>
</div>
</div>
{calendar ? (
<SubscriptionCalendarView
month={v.month}
onMonth={(m) => f.set("month", m)}
mediaKind={(v.kind || undefined) as "series" | "movie" | "variety" | undefined}
/>
) : (
<>
{/* 一条订阅都没有时只留空态和「新建订阅」,不铺筛选 */}
{allCount === 0 && !filtered && !v.group && !v.kind ? null : (
<>
<div className="mb-3">
<Pills
label="分组"
value={v.group as SubscriptionGroup | ""}
onChange={(x) => f.set("group", x)}
items={[
{ value: "", label: "全部", count: allCount },
...GROUP_ORDER.map((g) => ({ value: g, label: GROUP_LABEL[g], count: counts?.[g] })),
]}
/>
</div>
<FilterBar
search={{ value: q, onChange: setQ, placeholder: "作品标题 / 别名 / 检索词" }}
onReset={
filtered
? () => {
setQ("");
for (const k of ["q", "site", "rg", "origin", "up"] as const) f.set(k, "");
}
: undefined
}
>
<FilterSelect
label="站点"
value={v.site}
onChange={(x) => f.set("site", x)}
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
/>
<FilterSelect
label="规则组"
value={v.rg}
onChange={(x) => f.set("rg", x)}
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))}
/>
<FilterSelect
label="来源"
value={v.origin}
onChange={(x) => f.set("origin", x)}
options={opts(SUB_ORIGIN)}
/>
<FilterSelect
label="洗版"
value={v.up}
onChange={(x) => f.set("up", x)}
options={[
{ value: "true", label: "洗版中" },
{ value: "false", label: "未洗版" },
]}
/>
<FilterSelect
label="排序"
value={v.sort}
onChange={(x) => f.set("sort", x)}
allLabel="按分组"
options={opts(SORT_LABEL)}
/>
</FilterBar>
</>
)}
{selecting ? (
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
<span className="text-sm text-foreground tabular-nums">已选 {selIds.length}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSel((previous) => {
const next = new Set(previous);
const selected = visible.every((x) => previous.has(x.id));
for (const x of visible) {
if (selected) next.delete(x.id);
else next.add(x.id);
}
return next;
})
}
>
{visible.length > 0 && visible.every((x) => sel.has(x.id)) ? "取消本页全选" : "全选本页"}
</Button>
<span className="ml-auto flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={!selIds.length || ops.pending}
onClick={() => ops.batch(selIds, "search")}
>
<Search />
立即搜索
</Button>
<Button
size="sm"
variant="outline"
disabled={!selIds.length || ops.pending}
onClick={() => ops.batch(selIds, "pause")}
>
<Pause />
暂停
</Button>
<Button
size="sm"
variant="outline"
disabled={!selIds.length || ops.pending}
onClick={() => ops.batch(selIds, "resume")}
>
<Play />
恢复
</Button>
<Button
size="sm"
variant="destructive"
disabled={!selIds.length}
onClick={() => setConfirmDelete(true)}
>
<Trash2 />
删除
</Button>
</span>
</div>
) : null}
{query.isPending ? (
<LoadingState rows={4} />
) : query.isError ? (
<ErrorState error={query.error} onRetry={() => query.refetch()} />
) : items.length === 0 ? (
<EmptyState
icon={CalendarClock}
title={filtered || v.group || v.kind ? "没有符合条件的订阅" : "还没有订阅"}
description={
filtered || v.group || v.kind ? undefined : "在搜索或媒体库里点「订阅」,或点右上角「新建订阅」。"
}
/>
) : grouped ? (
<div className="space-y-6">
{GROUP_ORDER.map((g) => {
const list = visible.filter((x) => x.group === g);
if (!list.length) return null;
const closed = collapsed.has(g);
return (
<section key={g} aria-labelledby={`grp-${g}`}>
<button
type="button"
id={`grp-${g}`}
aria-expanded={!closed}
onClick={() =>
setCollapsed((s) => {
const n = new Set(s);
if (closed) n.delete(g);
else n.add(g);
return n;
})
}
className="mb-2 flex cursor-pointer items-center gap-1.5 text-body font-semibold text-foreground"
>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform", closed && "-rotate-90")}
/>
{GROUP_LABEL[g]}
<span className="text-meta font-normal text-muted-foreground tabular-nums">
{counts?.[g] ?? list.length}
</span>
</button>
{closed ? null : renderItems(list)}
</section>
);
})}
</div>
) : (
renderItems(visible)
)}
{items.length > 0 || query.hasNextPage ? <Pagination {...paged.pagination} hideSingle /> : null}
</>
)}
{creating ? <SubscriptionFormDialog onClose={() => setCreating(false)} /> : null}
{editing ? (
<SubscriptionFormDialog
sub={editing}
title={editing.mediaEntityTitle ?? editing.titleQuery ?? undefined}
onClose={() => setEditing(null)}
/>
) : null}
<DeleteSubscriptionsDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
ids={selIds}
onDone={exitSelect}
/>
<SubscriptionSheet id={sheet.id} onClose={sheet.close} />
</>
);
}