Files
UHD_Media_Tool/apps/web/app/routes/console/sites.tsx
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

264 lines
9.7 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 { useListPagination } from "~/components/console/use-list-pagination.ts";
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 { Button } from "~/components/ui/button.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { cn } from "~/lib/utils.ts";
// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4);
// IA-2 起站点有类型(PT 站 / 公开 BT 站 / 自定义 RSS 源),列表可按类型筛选,?new=<类型> 直接打开添加。
const VIEW_KEY = "umt.sites.view";
type View = "cards" | "list";
type Filter = "all" | "enabled" | "attention";
const KIND_OPTIONS = (Object.keys(SITE_KINDS) as SiteKind[])
.filter((k) => SITE_KINDS[k].implemented)
.map((k) => ({ value: k, label: SITE_KINDS[k].label }));
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();
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 kindFilter = KIND_OPTIONS.find((k) => k.value === params.get("kind"))?.value ?? "";
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) &&
(!kindFilter || s.kind === kindFilter),
),
sort,
);
const paged = useListPagination(shown, { resetKey: JSON.stringify({ filter, kindFilter, sort }) });
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">
<Segmented
label="筛选"
value={filter}
onChange={(v) => setParam("filter", v === "all" ? null : v)}
options={FILTERS.map((f) => ({
...f,
label: f.value === "attention" && attention ? `${f.label} ${attention}` : f.label,
}))}
/>
{new Set(all.map((s) => s.kind)).size > 1 || kindFilter ? (
<FilterSelect
label="类型"
value={kindFilter}
onChange={(v) => setParam("kind", v || null)}
options={KIND_OPTIONS}
/>
) : null}
<FilterSelect
label="排序"
value={sort === "name" ? "" : sort}
onChange={(v) => setParam("sort", v || null)}
allLabel="名称"
options={SORTS.filter((x) => x.value !== "name")}
/>
</div>
<Segmented
label="视图"
value={view}
onChange={(v) => setView(v as View)}
options={[
{ value: "cards", label: "卡片", icon: <LayoutGrid className="h-3.5 w-3.5" /> },
{ value: "list", label: "列表", icon: <List className="h-3.5 w-3.5" /> },
]}
/>
</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 站或 RSS 源后,这里显示账号数据、健康和 RSS 状态"
action={
<Button size="sm" onClick={() => setAdding("any")}>
<Plus />
添加站点
</Button>
}
/>
) : !shown.length ? (
<EmptyState
icon={Globe}
title={kindFilter ? "没有这类站点" : filter === "attention" ? "没有需处理的站点" : "没有已启用的站点"}
description={kindFilter ? "清除类型筛选查看其余站点" : "切换到「全部」查看其余站点"}
/>
) : view === "list" ? (
<SiteTable sites={shown} checks={checks} onDialog={setDialog} />
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{paged.items?.map((s) => (
<SiteCard key={s.id} site={s} checks={checks} onDialog={setDialog} />
))}
</div>
)}
{view !== "list" && shown.length > 0 ? (
<Pagination {...paged.pagination} className="mt-2 border-t-0 px-0" />
) : null}
{adding ? <SiteAddDialog initialKind={adding === "any" ? null : adding} onClose={() => setAdding(null)} /> : null}
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
<SearchDefaultDialog open={searchDefaults} onOpenChange={setSearchDefaults} sites={all} />
</>
);
}
function Segmented({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string; icon?: React.ReactNode }[];
}) {
return (
<div role="radiogroup" aria-label={label} className="inline-flex gap-0.5 rounded-xl border border-border p-0.5">
{options.map((o) => (
// biome-ignore lint/a11y/useSemanticElements: 分段按钮
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-caption transition-colors",
value === o.value
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{o.icon}
{o.label}
</button>
))}
</div>
);
}