- 侧栏与命令面板没有「待处理」;/console/inbox?kind= 按问题类型跳到整理记录「需要处理」、下载面板、运行状态或订阅 - 整理记录加「需要处理」筛选,行内写原因与推荐动作(指定作品 / 重新整理 / 让智能体识别 / 替换·保留两者·跳过 / 忽略), 详情页同样可处理;处理按钮从原待处理页抽成 components/decisions/decision-actions.tsx 复用 - 铃铛只放等你批准的智能体操作与系统健康;首页「要我处理」与运行状态「需要关注」放汇总计数,运行状态加智能体用量 - 设置 → 智能体:预算卡改为「用量与限制」(只剩单次工具调用与时长,附花费统计),自动调查卡加「整理识别不到作品时叫智能体识别」 - 集数待确认的原因显示在作品页集数映射(可确认建议规则 / 智能体建议)与订阅详情;未识别资源在媒体库「未识别」里直接选候选或指定 - 下载、站点、任务里指向待处理的链接改到对应页面 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
344 lines
12 KiB
TypeScript
344 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 { 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 显示"—") */
|
|
export function SpeedPair({ dl, up, className }: { dl: number; up: number; className?: string }) {
|
|
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" />
|
|
{formatSpeed(dl)}
|
|
</span>
|
|
<span className="inline-flex items-center gap-0.5" title="上传速度">
|
|
<ArrowUp aria-label="上传" className="size-3.5 text-muted-foreground" />
|
|
{formatSpeed(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-sm">
|
|
<span title={perInstance} className="text-foreground">
|
|
<SpeedPair dl={transfer.dlSpeed} up={transfer.upSpeed} />
|
|
</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;
|
|
}) {
|
|
const options: { value: DownloadBoardState; label: string }[] = [
|
|
{ value: "active", label: "进行中" },
|
|
{ value: "completed", label: "已完成" },
|
|
];
|
|
return (
|
|
<div
|
|
role="radiogroup"
|
|
aria-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-3 py-1 text-caption transition-colors duration-micro",
|
|
value === o.value
|
|
? "bg-foreground text-background"
|
|
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
)}
|
|
>
|
|
{o.label}
|
|
{counts ? <span className="tabular-nums opacity-80">{counts[o.value]}</span> : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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: "下载完成(含做种)后出现在这里。",
|
|
},
|
|
};
|