Files
UHD_Media_Tool/apps/web/app/components/downloads/cache-volumes.tsx
T
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

153 lines
6.3 KiB
TypeScript

import type { Dashboard } from "@umt/contracts";
import { Brush, HardDrive, SlidersHorizontal } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import { useCacheCleanup, useCacheVolumes } from "~/api/downloads.ts";
import { downloaders } from "~/api/instances.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { settingsPath } from "~/components/console/nav.ts";
import { taskPath } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
type Volume = Dashboard["cache"]["volumes"][number];
/** 缓存卷显示名:没填缓存卷标识时服务端用 `instance:<id>`,换成下载器实例名,不露内部 ID */
export function useVolumeLabel() {
const list = downloaders.useList();
return (v: Pick<Volume, "volumeKey" | "instanceIds">) => {
if (!v.volumeKey.startsWith("instance:")) return v.volumeKey;
const names = v.instanceIds.map((id) => list.data?.items.find((i) => i.id === id)?.name).filter(Boolean);
return names.length ? names.join("、") : "下载器缓存";
};
}
function VolumeBar({ v }: { v: Volume }) {
const volumeLabel = useVolumeLabel();
const pct = v.usedPercent;
const tripped = pct !== null && pct >= v.hardThresholdPercent;
const nearing = pct !== null && !tripped && pct >= v.resumePercent;
return (
<div className="min-w-0">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="flex min-w-0 items-center gap-1.5 text-sm font-medium text-foreground">
<HardDrive className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{volumeLabel(v)}</span>
</span>
<span className="text-meta text-muted-foreground tabular-nums">
{v.usedBytes !== null && v.totalBytes !== null
? `已用 ${formatBytes(v.usedBytes, 0)} / ${formatBytes(v.totalBytes, 1)}`
: pct === null
? "用量未知"
: `已用 ${Math.round(pct)}%`}
</span>
</div>
<div className="relative mt-1.5 h-1.5 rounded-full bg-foreground/[0.08]">
{pct !== null ? (
<div
className={cn(
"h-full rounded-full",
tripped ? "bg-destructive" : nearing ? "bg-warning" : "bg-foreground/60",
)}
style={{ width: `${Math.min(100, pct)}%` }}
/>
) : null}
<span
aria-hidden
className="absolute -top-1 h-3.5 w-px bg-destructive"
style={{ left: `${v.hardThresholdPercent}%` }}
title={`暂停线 ${v.hardThresholdPercent}%`}
/>
<span
aria-hidden
className="absolute -top-1 h-3.5 w-px bg-muted-foreground"
style={{ left: `${v.resumePercent}%` }}
title={`恢复线 ${v.resumePercent}%`}
/>
</div>
<TechInfo
title="详情"
className="mt-1"
items={[
{ label: "暂停线 / 恢复线", value: `${v.hardThresholdPercent}% / ${v.resumePercent}%`, mono: false },
{ label: "下载中还要占用", value: formatBytes(v.pendingBytes, 1), mono: false },
{ label: "已预留", value: formatBytes(v.reservedBytes, 1), mono: false },
{ label: "可清理", value: formatBytes(v.cleanableBytes, 1), mono: false },
{ label: "做种保留中", value: formatBytes(v.retentionLockedBytes, 1), mono: false },
]}
/>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{pct === null ? <StatusBadge tone="warning">读不到磁盘状态,已暂停新提交</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">超过暂停线,已停止新下载</StatusBadge> : null}
{v.pausedByThreshold ? (
<StatusBadge tone="warning">超过暂停线,暂停了 {v.pausedByThreshold} 个任务</StatusBadge>
) : null}
</div>
{v.notes.map((n) => (
<p key={n} className="mt-1 text-meta text-muted-foreground">
{n}
</p>
))}
</div>
);
}
/** 缓存卷水位 / 阈值条 + 阈值设置与手动清理入口(下载队列页头) */
export function CacheVolumes() {
const vols = useCacheVolumes();
const cleanup = useCacheCleanup();
const navigate = useNavigate();
return (
<section className="pg-slab mb-6 rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">下载盘</h2>
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link to={settingsPath("downloaders", "cache")}>
<SlidersHorizontal />
调整阈值
</Link>
</Button>
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" disabled={cleanup.isPending}>
<Brush />
立即清理
</Button>
}
title="按规则清理下载缓存?"
description="只清理本工具管理、已整理且满足保留期和 H&R 要求的任务,其余不动。"
confirmLabel="清理"
onConfirm={() =>
cleanup.mutateAsync(undefined).then(
(r) => {
toast.success("清理已排队");
navigate(taskPath(r.taskId));
},
(e) => toast.error(errorMessage(e)),
)
}
/>
</div>
</div>
{vols.isPending ? (
<LoadingState rows={2} />
) : vols.error ? (
<ErrorState plain error={vols.error} onRetry={() => void vols.refetch()} />
) : !vols.data.length ? (
<p className="text-caption text-muted-foreground">还没有下载盘。添加下载器后显示。</p>
) : (
<div className="grid gap-5 md:grid-cols-2">
{vols.data.map((v) => (
<VolumeBar key={v.volumeKey} v={v} />
))}
</div>
)}
</section>
);
}