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 { 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 { Button } from "~/components/ui/button.tsx"; import { Pagination } from "~/components/ui/pagination.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 = { 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({ label, value, onChange, items, }: { label: string; value: T | ""; onChange: (v: T | "") => void; items: { value: T | ""; label: string; count?: number }[]; }) { return (
{items.map((it) => ( ))}
); } export default function SubscriptionsPage() { const f = useQueryState(["kind", "group", "q", "site", "rg", "origin", "up", "sort", "view", "month"] as const); const v = f.values; 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(null); const [selecting, setSelecting] = useState(false); const [sel, setSel] = useState>(new Set()); const [confirmDelete, setConfirmDelete] = useState(false); const [collapsed, setCollapsed] = useState>(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" ? (
{list.map((x) => ( setEditing(x)} selected={sel.has(x.id)} onSelect={selecting ? (on) => toggle(x.id, on) : undefined} /> ))}
) : (
    {list.map((x) => ( setEditing(x)} selected={sel.has(x.id)} onSelect={selecting ? (on) => toggle(x.id, on) : undefined} /> ))}
); return ( <> } />
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 }, ]} />
{calendar ? null : ( )}
{calendar ? ( f.set("month", m)} mediaKind={(v.kind || undefined) as "series" | "movie" | "variety" | undefined} /> ) : ( <> {/* 一条订阅都没有时只留空态和「新建订阅」,不铺筛选 */} {allCount === 0 && !filtered && !v.group && !v.kind ? null : ( <>
f.set("group", x)} items={[ { value: "", label: "全部", count: allCount }, ...GROUP_ORDER.map((g) => ({ value: g, label: GROUP_LABEL[g], count: counts?.[g] })), ]} />
{ setQ(""); for (const k of ["q", "site", "rg", "origin", "up"] as const) f.set(k, ""); } : undefined } > f.set("site", x)} options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))} /> f.set("rg", x)} options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))} /> f.set("origin", x)} options={opts(SUB_ORIGIN)} /> f.set("up", x)} options={[ { value: "true", label: "洗版中" }, { value: "false", label: "未洗版" }, ]} /> f.set("sort", x)} allLabel="按分组" options={opts(SORT_LABEL)} /> )} {selecting ? (
已选 {selIds.length}
) : null} {query.isPending ? ( ) : query.isError ? ( query.refetch()} /> ) : items.length === 0 ? ( ) : grouped ? (
{GROUP_ORDER.map((g) => { const list = visible.filter((x) => x.group === g); if (!list.length) return null; const closed = collapsed.has(g); return (
{closed ? null : renderItems(list)}
); })}
) : ( renderItems(visible) )} {items.length > 0 || query.hasNextPage ? : null} )} {creating ? setCreating(false)} /> : null} {editing ? ( setEditing(null)} /> ) : null} ); }