- 顶栏搜索移进右侧玻璃胶囊;输入框改淡玻璃(边线 0.46 → 0.16,悬停加深,聚焦 / 出错保留光晕) - 新增 SegmentedControl(滑动 seg-thumb),替换站点、下载、订阅、文件、智能体列表及对话框里的实心选中块;Tabs 改为同一枚滑块 - 开关型按钮统一 outline + aria-pressed 选中外观;页头 / 工具栏次操作描边、图标钮描边圆;文件排序改 Select;下载总速度加标签 - 分页一页放得下时只留总数、空列表不渲染、按容器宽度收起按钮 - 运行状态存储区重排;智能体列表页与会话页共用外壳路由,标题与左栏不再重挂载 - 站点卡片「⋯」不再单独换行;设置导航、状态计数、多选条件改玻璃选中态 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
449 lines
16 KiB
TypeScript
449 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 { 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 { 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 [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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|