Files
UHD_Media_Tool/apps/web/app/components/downloads/download-board.tsx
T
rootandClaude Opus 5.5 c9b4ceb301 fix(web): 控制台界面细节与 uhd_web 控件规范对齐
- 顶栏搜索移进右侧玻璃胶囊;输入框改淡玻璃(边线 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
2026-10-08 12:02:48 +08:00

337 lines
12 KiB
TypeScript

import type {
DownloadBoard,
DownloadBoardItem,
DownloadBoardPlanItem,
DownloadBoardRow,
DownloadBoardState,
} from "@umt/contracts";
import { ArrowDown, ArrowUp, CheckCircle2, Download } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import type { DataColumn } from "~/components/console/data-table.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { SegmentedControl } from "~/components/ui/segmented.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
import { formatEta, formatSpeed, liveStateLabel, organizeState, peersText, progressOf } from "./board-format.ts";
import { DownloadStatusBadges, PauseResume, ProgressLine } from "./download-detail.tsx";
import { PlanActions, PlanWaitBadge } from "./plan-row.tsx";
// 下载面板(DL-2):顶部总速度 + 「进行中 / 已完成」分段;进行中看进度与速度,已完成看完成时间、分享率与整理状态。
// DL-3:进行中里还有没提交到下载器的下载(等你批准 / 等磁盘空间…),与下载任务按加入时间一起排。
/** 下载 / 上传速度一对(行内 0 显示"—";总速度一行传 showZero,空闲时显示 0 B/s,不留两道横线) */
export function SpeedPair({
dl,
up,
className,
showZero = false,
}: {
dl: number;
up: number;
className?: string;
showZero?: boolean;
}) {
const fmt = (v: number) => (showZero && !(v > 0) ? "0 B/s" : formatSpeed(v));
return (
<span className={cn("inline-flex items-center gap-2.5 tabular-nums whitespace-nowrap", className)}>
<span className="inline-flex items-center gap-0.5" title="下载速度">
<ArrowDown aria-label="下载" className="size-3.5 text-muted-foreground" />
{fmt(dl)}
</span>
<span className="inline-flex items-center gap-0.5" title="上传速度">
<ArrowUp aria-label="上传" className="size-3.5 text-muted-foreground" />
{fmt(up)}
</span>
</span>
);
}
/** 总速度一行;不可达的下载器单独列出 */
export function TransferLine({ transfer }: { transfer: DownloadBoard["transfer"] | undefined }) {
if (!transfer || transfer.instances.length === 0) return null;
const down = transfer.instances.filter((i) => !i.reachable);
const perInstance =
transfer.instances.length > 1
? transfer.instances.map((i) => `${i.name}:↓ ${formatSpeed(i.dlSpeed)} ↑ ${formatSpeed(i.upSpeed)}`).join("\n")
: undefined;
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-caption">
<span title={perInstance ?? "所有下载器的实时总速度"} className="inline-flex items-center gap-2 text-foreground">
<span className="text-muted-foreground">总速度</span>
<SpeedPair dl={transfer.dlSpeed} up={transfer.upSpeed} showZero />
</span>
{down.length ? (
<span className="text-meta text-warning">{down.map((i) => `「${i.name}」`).join("")}连接失败</span>
) : null}
</div>
);
}
export function BoardSegments({
value,
counts,
onChange,
}: {
value: DownloadBoardState;
counts: DownloadBoard["counts"] | undefined;
onChange: (v: DownloadBoardState) => void;
}) {
return (
<SegmentedControl
label="下载任务分组"
value={value}
onChange={onChange}
options={[
{ value: "active", label: "进行中", count: counts?.active },
{ value: "completed", label: "已完成", count: counts?.completed },
]}
/>
);
}
function WorkLine({ t, extra }: { t: Pick<DownloadBoardItem, "media">; extra?: ReactNode }) {
if (!t.media && !extra) return null;
return (
<p className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 text-meta text-muted-foreground">
{t.media ? (
<Link
to={consolePath("library", t.media.id)}
onClick={(e) => e.stopPropagation()}
className="truncate underline-offset-2 hover:text-foreground hover:underline"
>
{t.media.title}
{t.media.year ? ` (${t.media.year})` : ""}
</Link>
) : null}
{extra}
</p>
);
}
function NameCell({ t, children }: { t: Pick<DownloadBoardItem, "name">; children?: ReactNode }) {
return (
<div className="min-w-0 max-w-md">
<p className="line-clamp-2 break-all text-foreground">{t.name}</p>
{children}
</div>
);
}
function ActiveStatus({ t }: { t: DownloadBoardItem }) {
const live = liveStateLabel(t.live?.rawState);
return (
<span className="flex flex-wrap gap-1">
<DownloadStatusBadges t={t} />
{live ? <StatusBadge>{live}</StatusBadge> : null}
</span>
);
}
const dash = <span className="text-meta text-muted-foreground">—</span>;
/** 还没提交的下载:名称列(窄屏带状态与操作) */
function PlanNameCell({ p }: { p: DownloadBoardPlanItem }) {
return (
<NameCell t={p}>
<WorkLine
t={p}
extra={p.sizeBytes !== null ? <span className="tabular-nums">{formatBytes(p.sizeBytes, 1)}</span> : null}
/>
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-2 md:hidden">
<PlanWaitBadge p={p} hint />
<PlanActions p={p} size="xs" />
</div>
</NameCell>
);
}
export function activeColumns(): DataColumn<DownloadBoardRow>[] {
return [
{
key: "name",
header: "下载任务",
cell: (t) =>
t.kind === "plan" ? (
<PlanNameCell p={t} />
) : (
<NameCell t={t}>
<WorkLine t={t} />
{/* 窄屏:进度与速度并进名称列 */}
<div className="mt-1.5 space-y-1 md:hidden">
<ActiveStatus t={t} />
<ProgressLine t={{ ...t, progress: progressOf(t) }} />
{t.live?.dlSpeed || t.live?.upSpeed ? (
<p className="flex flex-wrap items-center gap-x-3 text-meta text-muted-foreground">
<SpeedPair dl={t.live.dlSpeed} up={t.live.upSpeed} />
{t.live.etaSec !== null ? <span>剩余 {formatEta(t.live.etaSec)}</span> : null}
</p>
) : null}
</div>
</NameCell>
),
},
{
key: "progress",
header: "进度",
hideBelow: "md",
className: "w-40",
cell: (t) => (t.kind === "plan" ? dash : <ProgressLine t={{ ...t, progress: progressOf(t) }} />),
},
{
key: "speed",
header: "速度",
hideBelow: "md",
cell: (t) =>
t.kind === "plan" || (!t.live?.dlSpeed && !t.live?.upSpeed) ? (
dash
) : (
<div className="space-y-0.5 text-meta tabular-nums whitespace-nowrap">
<p className="flex items-center gap-0.5 text-foreground">
<ArrowDown aria-label="下载" className="size-3.5 text-muted-foreground" />
{formatSpeed(t.live?.dlSpeed)}
</p>
<p className="flex items-center gap-0.5 text-muted-foreground">
<ArrowUp aria-label="上传" className="size-3.5" />
{formatSpeed(t.live?.upSpeed)}
</p>
</div>
),
},
{
key: "eta",
header: "剩余时间",
hideBelow: "lg",
cell: (t) =>
t.kind === "plan" ? (
dash
) : (
<div className="text-meta whitespace-nowrap">
<p className="text-foreground tabular-nums">{formatEta(t.live?.etaSec)}</p>
{peersText(t.live) ? <p className="text-muted-foreground tabular-nums">{peersText(t.live)}</p> : null}
</div>
),
},
{
key: "status",
header: "状态",
hideBelow: "md",
cell: (t) => (t.kind === "plan" ? <PlanWaitBadge p={t} hint /> : <ActiveStatus t={t} />),
},
{
key: "ops",
header: "",
align: "right",
cell: (t) =>
t.kind === "plan" ? (
<span className="hidden md:inline-flex">
<PlanActions p={t} size="xs" />
</span>
) : (
<PauseResume t={t} size="xs" />
),
},
];
}
function OrganizeBadge({ t }: { t: DownloadBoardItem }) {
const s = organizeState(t);
if (!s) return <span className="text-meta text-muted-foreground">—</span>;
const badge = <StatusBadge tone={s.tone}>{s.label}</StatusBadge>;
return (
<div className="flex max-w-56 flex-col items-start gap-0.5">
{s.to ? (
<Link
to={s.to}
onClick={(e) => e.stopPropagation()}
title={t.organize?.decision ? "去整理记录处理" : "查看整理记录"}
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{badge}
</Link>
) : (
badge
)}
{s.hint ? <span className="line-clamp-2 text-micro text-muted-foreground">{s.hint}</span> : null}
</div>
);
}
/** 完成时间以下载器记录为准(接管的任务在数据库里记的是接管时刻) */
const completedAtOf = (t: DownloadBoardItem) => t.live?.completedAt ?? t.completedAt ?? null;
const sizeText = (t: DownloadBoardItem) => (t.sizeBytes === null ? "未知" : formatBytes(t.sizeBytes, 1));
const ratioOf = (t: DownloadBoardItem) => t.live?.ratio ?? t.ratio;
export function completedColumns(): DataColumn<DownloadBoardItem>[] {
return [
{
key: "name",
header: "下载任务",
cell: (t) => (
<NameCell t={t}>
<WorkLine t={t} />
<p className="mt-1 flex flex-wrap items-center gap-x-2 text-meta text-muted-foreground tabular-nums md:hidden">
<span>{sizeText(t)}</span>
<span>{relTime(completedAtOf(t))}完成</span>
{ratioOf(t) !== null ? <span>分享率 {ratioOf(t)?.toFixed(2)}</span> : null}
{t.live?.upSpeed ? (
<span className="inline-flex items-center gap-0.5">
<ArrowUp aria-label="上传" className="size-3.5" />
{formatSpeed(t.live.upSpeed)}
</span>
) : null}
</p>
</NameCell>
),
},
{
key: "size",
header: "大小",
hideBelow: "md",
align: "right",
cell: (t) => <span className="whitespace-nowrap tabular-nums">{sizeText(t)}</span>,
},
{
key: "done",
header: "完成时间",
hideBelow: "md",
cell: (t) => (
<span className="whitespace-nowrap text-muted-foreground" title={formatDateTime(completedAtOf(t))}>
{relTime(completedAtOf(t))}
</span>
),
},
{
key: "ratio",
header: "分享率 / 上传",
hideBelow: "lg",
cell: (t) => (
<div className="text-meta whitespace-nowrap tabular-nums">
<p className="text-foreground">{ratioOf(t) === null ? "—" : ratioOf(t)?.toFixed(2)}</p>
{t.live?.upSpeed ? (
<p className="flex items-center gap-0.5 text-muted-foreground">
<ArrowUp aria-label="上传" className="size-3.5" />
{formatSpeed(t.live.upSpeed)}
</p>
) : null}
</div>
),
},
{ key: "organize", header: "整理", cell: (t) => <OrganizeBadge t={t} /> },
];
}
export const BOARD_EMPTY: Record<DownloadBoardState, { title: string; description: string; icon: typeof Download }> = {
active: {
icon: Download,
title: "没有进行中的下载",
description: "自动收录和手动下载的任务都在这里,包括等批准、等磁盘空间的。下载完成后移到「已完成」。",
},
completed: {
icon: CheckCircle2,
title: "还没有下载完成的任务",
description: "下载完成的任务在这里,包括做种中的。",
},
};