控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?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
233 lines
9.3 KiB
TypeScript
233 lines
9.3 KiB
TypeScript
import { SITE_KINDS, type SiteKind } from "@umt/contracts";
|
|
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 { useRecordSheet } from "~/components/console/record-sheet.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";
|
|
import { needsAttention, type SortKey, sortSites } from "~/components/sites/site-fields.ts";
|
|
import { SiteSheet } from "~/components/sites/site-sheet.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { SegmentedControl } from "~/components/ui/segmented.tsx";
|
|
|
|
// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4);
|
|
// 按类型分组显示:PT 站 / 公开 BT 站(旧的自定义 RSS 源有的话单列);?new=<类型> 直接打开添加。
|
|
// 站点详情是本页的宽抽屉(?open=<站点 id>),旧地址 /console/sites/:id 跳到这里。
|
|
|
|
const VIEW_KEY = "umt.sites.view";
|
|
type View = "cards" | "list";
|
|
type Filter = "all" | "enabled" | "attention";
|
|
/** 分组顺序;公开 BT 站的表只列它用得到的列 */
|
|
const GROUPS: { kind: SiteKind; title: string; variant: "pt" | "public" | "subtitle" }[] = [
|
|
{ kind: "pt", title: "PT 站", variant: "pt" },
|
|
{ kind: "public_bt", title: "公开 BT 站", variant: "public" },
|
|
{ kind: "rss_feed", title: "自定义 RSS 源", variant: "public" },
|
|
{ kind: "torznab", title: "Torznab 索引器", variant: "public" },
|
|
{ kind: "subtitle", title: "字幕站", variant: "subtitle" },
|
|
];
|
|
|
|
const FILTERS: { value: Filter; label: string }[] = [
|
|
{ value: "all", label: "全部" },
|
|
{ value: "enabled", label: "已启用" },
|
|
{ value: "attention", label: "需要处理" },
|
|
];
|
|
const SORTS: { value: SortKey; label: string }[] = [
|
|
{ value: "name", label: "名称" },
|
|
{ value: "ratio", label: "分享率" },
|
|
{ value: "upload", label: "上传" },
|
|
{ value: "lastLogin", label: "上次登录" },
|
|
];
|
|
|
|
function readStoredView(): View | null {
|
|
try {
|
|
const v = localStorage.getItem(VIEW_KEY);
|
|
return v === "cards" || v === "list" ? v : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
|
|
|
|
export default function SitesPage() {
|
|
const sites = useSites();
|
|
const [params, setParams] = useSearchParams();
|
|
// 记录抽屉(console-ia.md §9):?open=<站点 id>,抽屉里的页签 ?tab=、RSS ?feed= 随抽屉走
|
|
const sheet = useRecordSheet({ scoped: ["tab", "feed"] });
|
|
const newParam = params.get("new");
|
|
const [adding, setAdding] = useState<SiteKind | "any" | null>(null);
|
|
const [dialog, setDialog] = useState<SiteDialog | null>(null);
|
|
const checks = useSiteChecks(setDialog);
|
|
|
|
const urlView = params.get("view");
|
|
const [storedView, setStoredView] = useState<View>("cards");
|
|
useEffect(() => {
|
|
const v = readStoredView();
|
|
if (v) setStoredView(v);
|
|
}, []);
|
|
const view: View = urlView === "list" || urlView === "cards" ? urlView : storedView;
|
|
const filter = (FILTERS.find((f) => f.value === params.get("filter"))?.value ?? "all") as Filter;
|
|
const sort = (SORTS.find((s) => s.value === params.get("sort"))?.value ?? "name") as SortKey;
|
|
|
|
const setParam = (k: string, v: string | null) =>
|
|
setParams(
|
|
(p) => {
|
|
if (v) p.set(k, v);
|
|
else p.delete(k);
|
|
return p;
|
|
},
|
|
{ replace: true },
|
|
);
|
|
const setView = (v: View) => {
|
|
setStoredView(v);
|
|
try {
|
|
localStorage.setItem(VIEW_KEY, v);
|
|
} catch {
|
|
// 隐私模式等:只在本次会话记住
|
|
}
|
|
setParam("view", v);
|
|
};
|
|
|
|
// 命令面板 / 旧 RSS 页的 ?new=1、?new=rss_feed:打开添加(带类型时跳过选类型)
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在参数出现时处理一次
|
|
useEffect(() => {
|
|
if (!newParam) return;
|
|
setAdding(newParam in SITE_KINDS ? (newParam as SiteKind) : "any");
|
|
setParam("new", null);
|
|
}, [newParam]);
|
|
|
|
// 待处理"改用 Cookie"深链:?credentials=<siteId>
|
|
const credFor = params.get("credentials");
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在数据到达时处理一次
|
|
useEffect(() => {
|
|
if (!credFor || !sites.data) return;
|
|
const s = sites.data.items.find((x) => x.id === credFor);
|
|
if (s) setDialog({ kind: "credentials", site: s, loginKind: "cookie" });
|
|
setParam("credentials", null);
|
|
}, [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)),
|
|
sort,
|
|
);
|
|
const groups = GROUPS.map((g) => ({ ...g, sites: shown.filter((s) => s.kind === g.kind) })).filter(
|
|
(g) => g.sites.length,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="站点"
|
|
actions={
|
|
<>
|
|
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
|
|
<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("any")}>
|
|
<Plus />
|
|
添加站点
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{all.length ? (
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<SegmentedControl
|
|
label="筛选"
|
|
value={filter}
|
|
onChange={(v) => setParam("filter", v === "all" ? null : v)}
|
|
options={FILTERS.map((f) => ({
|
|
...f,
|
|
count: f.value === "attention" && attention ? attention : undefined,
|
|
}))}
|
|
/>
|
|
<FilterSelect
|
|
label="排序"
|
|
value={sort === "name" ? "" : sort}
|
|
onChange={(v) => setParam("sort", v || null)}
|
|
allLabel="名称"
|
|
options={SORTS.filter((x) => x.value !== "name")}
|
|
/>
|
|
</div>
|
|
<SegmentedControl
|
|
label="视图"
|
|
value={view}
|
|
onChange={(v) => setView(v as View)}
|
|
options={[
|
|
{ value: "cards", label: "卡片", icon: <LayoutGrid /> },
|
|
{ value: "list", label: "列表", icon: <List /> },
|
|
]}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{sites.isPending ? (
|
|
<div className="pg-slab rounded-xl p-4">
|
|
<LoadingState rows={4} />
|
|
</div>
|
|
) : sites.error && !sites.data ? (
|
|
<ErrorState error={sites.error} onRetry={() => void sites.refetch()} />
|
|
) : !all.length ? (
|
|
<EmptyState
|
|
icon={Globe}
|
|
title="还没有站点"
|
|
description="添加 PT 站或公开 BT 站后,这里显示账号数据、健康和资源更新"
|
|
action={
|
|
<Button size="sm" onClick={() => setAdding("any")}>
|
|
<Plus />
|
|
添加站点
|
|
</Button>
|
|
}
|
|
/>
|
|
) : !shown.length ? (
|
|
<EmptyState
|
|
icon={Globe}
|
|
title={filter === "attention" ? "没有需要处理的站点" : "没有已启用的站点"}
|
|
description="切换到「全部」查看其余站点"
|
|
/>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{groups.map((g) => (
|
|
<section key={g.kind} aria-label={g.title} className="min-w-0">
|
|
<h2 className="mb-2 flex items-baseline gap-2 text-caption font-semibold text-foreground">
|
|
{g.title}
|
|
<span className="text-meta font-normal text-muted-foreground">{g.sites.length}</span>
|
|
</h2>
|
|
{view === "list" ? (
|
|
<SiteTable sites={g.sites} checks={checks} onDialog={setDialog} variant={g.variant} />
|
|
) : (
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
{g.sites.map((s) => (
|
|
<SiteCard key={s.id} site={s} checks={checks} onDialog={setDialog} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{adding ? <SiteAddDialog initialKind={adding === "any" ? null : adding} onClose={() => setAdding(null)} /> : null}
|
|
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
|
|
<SearchDefaultDialog open={searchDefaults} onOpenChange={setSearchDefaults} sites={all} />
|
|
<SiteSheet id={sheet.id} params={params} onPatch={sheet.patch} onClose={sheet.close} />
|
|
</>
|
|
);
|
|
}
|