feat(web): W3-3 site workbench, sources/feeds/snapshots, downloads with business timeline, organization records and agent sessions
- sites + /sites/:siteId: organized view with snapshot-first release list, inventory overlay, candidate reasons, volatile-field recheck, stale marking that keeps existing data, live search, unified download entry; origin view placeholder (M2) - sources: feed CRUD/enable/fetch/reset, cursor/health/gap hints; per-site snapshot history - tasks: download tasks with pause/resume (threshold pause cannot be bypassed), plans, cache volume thresholds and cleanup, business timeline (observation → plan → download → organization → agent/engine) - organize: record list/detail with file-level results, manual organize with dryRun preview - agent: session list/page and layout drawer with context prefix; streaming events, tool cards, rejection alerts, compaction, budget meters, model text vs server report; mock switch (?mock=1) until W2-D endpoints land Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6280d52ec3
commit
7103bc8f7c
35 files changed
+6134
-45
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import type { TimelineEvent, TimelineStage } from "~/api/tasks.ts";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Timeline } from "~/components/console/timeline.tsx";
|
||||
|
||||
// 业务时间线(参考 MovieClaw task-activity):把观察 → 计划 → 下载 → 整理 → Agent 串成一个业务过程,
|
||||
// 阶段计数与首页/侧栏同口径;每条事件都有文字说明,不只靠颜色。
|
||||
|
||||
export const STAGE_LABEL: Record<TimelineStage, string> = {
|
||||
observation: "观察",
|
||||
plan: "计划",
|
||||
download: "下载",
|
||||
organization: "整理",
|
||||
agent: "Agent",
|
||||
engine: "引擎",
|
||||
};
|
||||
|
||||
const ORDER: TimelineStage[] = ["observation", "plan", "download", "organization", "agent", "engine"];
|
||||
|
||||
export function BusinessTimeline({ events, loading }: { events: TimelineEvent[]; loading?: boolean }) {
|
||||
const counts = new Map<TimelineStage, number>();
|
||||
for (const e of events) counts.set(e.stage, (counts.get(e.stage) ?? 0) + 1);
|
||||
const ongoing = events.filter((e) => e.ongoing);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ORDER.map((s) => {
|
||||
const n = counts.get(s) ?? 0;
|
||||
const bad = events.some((e) => e.stage === s && e.tone === "destructive");
|
||||
return (
|
||||
<StatusBadge key={s} tone={bad ? "destructive" : n ? "success" : "neutral"}>
|
||||
{STAGE_LABEL[s]} {n ? `· ${n}` : "· 无"}
|
||||
</StatusBadge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{ongoing.length ? (
|
||||
<p className="text-caption text-muted-foreground">当前:{ongoing.map((e) => e.title).join(";")}</p>
|
||||
) : null}
|
||||
{loading && !events.length ? (
|
||||
<LoadingState rows={3} />
|
||||
) : (
|
||||
<Timeline
|
||||
items={events.map((e) => ({
|
||||
id: e.key,
|
||||
at: e.at,
|
||||
tone: e.tone,
|
||||
meta: STAGE_LABEL[e.stage],
|
||||
title: e.href ? (
|
||||
<Link to={e.href} className="inline-flex items-center gap-1 underline-offset-4 hover:underline">
|
||||
{e.title}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
e.title
|
||||
),
|
||||
description: e.detail ?? undefined,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{loading && events.length ? (
|
||||
<p className="text-meta text-muted-foreground">正在补全关联的整理记录与引擎 run…</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Dashboard } from "@umt/contracts";
|
||||
import { Brush, HardDrive } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useCacheCleanup, useCacheVolumes } from "~/api/downloads.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.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];
|
||||
|
||||
function VolumeBar({ v }: { v: Volume }) {
|
||||
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">{v.volumeKey}</span>
|
||||
</span>
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
{pct === null ? "使用率未知" : `${pct.toFixed(1)}%`}
|
||||
{v.usedBytes !== null && v.totalBytes !== null
|
||||
? ` · ${formatBytes(v.usedBytes, 1)} / ${formatBytes(v.totalBytes, 1)}`
|
||||
: ""}
|
||||
</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>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground">
|
||||
<span>
|
||||
硬阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}%
|
||||
</span>
|
||||
<span>在途 {formatBytes(v.pendingBytes, 1)}</span>
|
||||
<span>预留 {formatBytes(v.reservedBytes, 1)}</span>
|
||||
<span>可清理 {formatBytes(v.cleanableBytes, 1)}</span>
|
||||
<span>保留锁定 {formatBytes(v.retentionLockedBytes, 1)}</span>
|
||||
</div>
|
||||
<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();
|
||||
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>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={cleanup.isPending}>
|
||||
<Brush />
|
||||
立即清理
|
||||
</Button>
|
||||
}
|
||||
title="按规则清理下载缓存?"
|
||||
description="只清理已核验入库、且满足保留期与 H&R 的纳管任务;未纳管任务与未满足保留的不会动。"
|
||||
confirmLabel="清理"
|
||||
onConfirm={() =>
|
||||
cleanup.mutateAsync(undefined).then(
|
||||
(r) => {
|
||||
const vols = (r.report as { volumes?: { removed?: unknown[]; skipped?: unknown[] }[] }).volumes ?? [];
|
||||
const removed = vols.reduce((n, v) => n + (v.removed?.length ?? 0), 0);
|
||||
const skipped = vols.reduce((n, v) => n + (v.skipped?.length ?? 0), 0);
|
||||
toast.success(`清理完成:移除 ${removed} 个任务的缓存${skipped ? `,${skipped} 个因保留/H&R 跳过` : ""}`);
|
||||
},
|
||||
(e) => toast.error(errorMessage(e)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { DownloadTask } from "@umt/contracts";
|
||||
import { Bot, FolderInput, Pause, Play, ShieldAlert } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DOWNLOAD_STATUS_LABEL,
|
||||
DOWNLOAD_STATUS_TONE,
|
||||
PAUSE_REASON_LABEL,
|
||||
PLAN_STATUS_LABEL,
|
||||
useDownloadAction,
|
||||
useDownloaderNames,
|
||||
usePlan,
|
||||
} from "~/api/downloads.ts";
|
||||
import { useDownloadTrace } from "~/api/tasks.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { BusinessTimeline } from "./business-timeline.tsx";
|
||||
import { OrganizeDialog } from "./organize-dialog.tsx";
|
||||
|
||||
/** 进度:细线 + 文字;总量未知时不伪造百分比 */
|
||||
export function ProgressLine({ t }: { t: DownloadTask }) {
|
||||
const known = t.sizeBytes !== null;
|
||||
return (
|
||||
<div className="min-w-24">
|
||||
<div className="h-1 overflow-hidden rounded-full bg-foreground/[0.08]">
|
||||
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${Math.round(t.progress * 100)}%` }} />
|
||||
</div>
|
||||
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
|
||||
{known ? `${(t.progress * 100).toFixed(1)}%` : `已下 ${formatBytes(t.downloadedBytes, 1)}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadStatusBadges({ t }: { t: DownloadTask }) {
|
||||
return (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<StatusBadge tone={DOWNLOAD_STATUS_TONE[t.status]}>{DOWNLOAD_STATUS_LABEL[t.status]}</StatusBadge>
|
||||
{t.pauseReason === "threshold" ? (
|
||||
<StatusBadge tone="warning">阈值暂停</StatusBadge>
|
||||
) : t.pauseReason ? (
|
||||
<StatusBadge>{PAUSE_REASON_LABEL[t.pauseReason]}</StatusBadge>
|
||||
) : null}
|
||||
{!t.managed ? <StatusBadge>未纳管</StatusBadge> : null}
|
||||
{t.origin === "adopted" ? <StatusBadge>接管</StatusBadge> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 暂停/恢复:阈值暂停在水位回落前不可手动恢复(服务端强制) */
|
||||
export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" | "xs" }) {
|
||||
const act = useDownloadAction();
|
||||
const canPause = ["queued", "downloading", "stalled"].includes(t.status);
|
||||
const canResume = t.status === "paused";
|
||||
if (!canPause && !canResume) return null;
|
||||
const threshold = t.pauseReason === "threshold";
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
disabled={act.isPending}
|
||||
title={threshold ? "阈值暂停:缓存回落到恢复水位后由系统自动恢复" : undefined}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
act.mutate(
|
||||
{ id: t.id, action: canResume ? "resume" : "pause" },
|
||||
{
|
||||
onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停(记为用户暂停,阈值恢复不会自动恢复它)"),
|
||||
onError: (err) => toast.error(errorMessage(err)),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{canResume ? <Play /> : <Pause />}
|
||||
{canResume ? "恢复" : "暂停"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 下载任务详情:状态、阈值说明、业务时间线、文件、关联计划 */
|
||||
export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
const plan = usePlan(task.acquisitionPlanId);
|
||||
const names = useDownloaderNames();
|
||||
const trace = useDownloadTrace(task, plan.data);
|
||||
const [organizing, setOrganizing] = useState(false);
|
||||
const canOrganize = ["completed", "importing", "imported"].includes(task.status);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DownloadStatusBadges t={task} />
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<PauseResume t={task} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canOrganize}
|
||||
title={canOrganize ? undefined : "下载完成后才能整理"}
|
||||
onClick={() => setOrganizing(true)}
|
||||
>
|
||||
<FolderInput />
|
||||
整理
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `下载:${task.name}`,
|
||||
downloadTaskId: task.id,
|
||||
goal: task.status === "stalled" || task.status === "failed" ? "排查这个下载为什么没有进展" : "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Bot />问 Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.pauseReason === "threshold" ? (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
缓存卷达到硬阈值,系统暂停了这个纳管任务。水位回落到恢复线后自动恢复;手动恢复、优先级或 Agent
|
||||
推荐都不能绕过阈值。
|
||||
</p>
|
||||
) : null}
|
||||
{task.noProgressSince ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
自 {formatDateTime(task.noProgressSince)} 起无进展。系统只记录证据并汇入待处理,不会自动换源。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<DetailSection title="进度">
|
||||
<ProgressLine t={task} />
|
||||
<KvList
|
||||
className="mt-3"
|
||||
items={[
|
||||
{ label: "下载器", value: names.data?.get(task.instanceId) ?? task.instanceId.slice(0, 8) },
|
||||
{ label: "大小", value: task.sizeBytes === null ? "未知" : formatBytes(task.sizeBytes, 2) },
|
||||
{ label: "剩余", value: task.remainingBytes === null ? "未知" : formatBytes(task.remainingBytes, 2) },
|
||||
{ label: "分享率", value: task.ratio === null ? "—" : task.ratio.toFixed(2) },
|
||||
{ label: "最近进展", value: relTime(task.lastProgressAt) },
|
||||
{ label: "最近同步", value: relTime(task.lastSyncedAt) },
|
||||
{ label: "标签", value: task.tags.join(", ") },
|
||||
{ label: "分类", value: task.category },
|
||||
{ label: "保存路径", value: task.savePath, mono: true, wide: true },
|
||||
{ label: "内容路径", value: task.contentPath, mono: true, wide: true },
|
||||
{ label: "torrent hash", value: task.torrentHash, mono: true, wide: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="业务时间线">
|
||||
<BusinessTimeline events={trace.events} loading={trace.loading} />
|
||||
</DetailSection>
|
||||
|
||||
{plan.data ? (
|
||||
<DetailSection title="下载计划">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "状态", value: PLAN_STATUS_LABEL[plan.data.status] },
|
||||
{ label: "用途", value: plan.data.purpose },
|
||||
{ label: "预留空间", value: formatBytes(plan.data.reservedBytes, 1) },
|
||||
{ label: "说明", value: plan.data.statusReason, wide: true },
|
||||
]}
|
||||
/>
|
||||
<JsonView className="mt-2" label="评估依据" value={plan.data.evaluation} />
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{task.fileList?.length ? (
|
||||
<DetailSection title={`文件(${task.fileList.length})`}>
|
||||
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
|
||||
{task.fileList.map((f) => (
|
||||
<li key={f.path} className="flex justify-between gap-3">
|
||||
<span className="min-w-0 font-mono break-all text-foreground">{f.path}</span>
|
||||
<span className="shrink-0 text-muted-foreground tabular-nums">
|
||||
{formatBytes(f.size, 1)}
|
||||
{f.progress !== undefined ? ` · ${Math.round(f.progress * 100)}%` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{task.lastError ? <JsonView label="下载器错误" value={task.lastError} tone="destructive" defaultOpen /> : null}
|
||||
|
||||
{organizing ? (
|
||||
<OrganizeDialog open onOpenChange={(o) => !o && setOrganizing(false)} downloadTaskId={task.id} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { OrganizeRequest } from "@umt/contracts";
|
||||
import { Eye, Play } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { isMissingEndpoint } from "~/api/parse.ts";
|
||||
import { OrganizePlanPreview, useArtifact, useEntitySearch, useLibraryRoots, useOrganize } from "~/api/records.ts";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
const AUTO = "__auto__";
|
||||
|
||||
const EXCLUDE_REASON: Record<string, string> = {
|
||||
sample: "样片",
|
||||
too_small: "体积过小",
|
||||
not_imported_kind: "不入库的文件类型",
|
||||
orphan_sidecar: "孤立附属文件",
|
||||
no_episode: "解析不出集号",
|
||||
special_extra: "特典/花絮",
|
||||
extra_content: "额外内容",
|
||||
no_target_dir: "没有目标目录",
|
||||
naming_failed: "命名失败",
|
||||
};
|
||||
|
||||
function PlanPreview({ planId }: { planId: string }) {
|
||||
const art = useArtifact(planId);
|
||||
if (art.isPending) return <LoadingState rows={3} />;
|
||||
if (art.error) {
|
||||
return (
|
||||
<p className="rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground">
|
||||
计划已生成(planId {planId.slice(0, 8)}…),
|
||||
{isMissingEndpoint(art.error)
|
||||
? "但后端还没有提供读取计划内容的端点(GET /artifacts/{id}),无法逐文件预览。"
|
||||
: `读取计划失败:${errorMessage(art.error)}`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const parsed = OrganizePlanPreview.safeParse(art.data.content);
|
||||
if (!parsed.success) return <p className="text-caption text-muted-foreground">计划内容格式无法识别。</p>;
|
||||
const p = parsed.data;
|
||||
const org = p.plan?.organize;
|
||||
if (!org)
|
||||
return (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
无法生成计划:{p.reason ?? p.status ?? "未知原因"}
|
||||
</p>
|
||||
);
|
||||
const blocking = org.conflicts.filter((c) => c.blocking);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusBadge>{org.operation}</StatusBadge>
|
||||
{org.crossDevice ? <StatusBadge tone="warning">跨卷:Copy → 核验 → 删源</StatusBadge> : null}
|
||||
{org.requiresApproval ? <StatusBadge tone="warning">需要审批</StatusBadge> : null}
|
||||
{blocking.length ? <StatusBadge tone="destructive">阻塞冲突 {blocking.length}</StatusBadge> : null}
|
||||
</div>
|
||||
<ul className="max-h-56 space-y-1.5 overflow-auto text-meta">
|
||||
{org.files.map((f) => (
|
||||
<li key={f.src} className="rounded-lg border border-border/60 px-2.5 py-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge tone={f.action === "transfer" ? "success" : f.action === "skip" ? "neutral" : "warning"}>
|
||||
{f.action}
|
||||
</StatusBadge>
|
||||
<span className="text-muted-foreground">{f.kind}</span>
|
||||
<span className="text-muted-foreground">{formatBytes(f.size, 1)}</span>
|
||||
{f.skipReason ? <span className="text-muted-foreground">({f.skipReason})</span> : null}
|
||||
</div>
|
||||
<p className="mt-0.5 font-mono break-all text-muted-foreground">{f.src}</p>
|
||||
<p className="font-mono break-all text-foreground">→ {f.dst}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{org.excluded.length ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
排除 {org.excluded.length} 个:
|
||||
{org.excluded
|
||||
.slice(0, 5)
|
||||
.map((e) => `${e.src.split("/").pop()}(${EXCLUDE_REASON[e.reason] ?? e.reason})`)
|
||||
.join(",")}
|
||||
{org.excluded.length > 5 ? "…" : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{org.conflicts.map((c) => (
|
||||
<p
|
||||
key={`${c.kind}${c.src ?? ""}`}
|
||||
className={cn("text-meta", c.blocking ? "text-destructive" : "text-warning")}
|
||||
>
|
||||
{c.blocking ? "阻塞" : "提示"}:{c.kind}
|
||||
{c.detail ? ` · ${c.detail}` : ""}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动发起整理:先 dryRun 预览计划,再执行。自动与手动产生同格式整理记录。
|
||||
*/
|
||||
export function OrganizeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
downloadTaskId,
|
||||
defaultPaths,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
downloadTaskId?: string;
|
||||
defaultPaths?: string[];
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const organize = useOrganize();
|
||||
const roots = useLibraryRoots();
|
||||
const [paths, setPaths] = useState((defaultPaths ?? []).join("\n"));
|
||||
const [action, setAction] = useState<"copy" | "move">("copy");
|
||||
const [root, setRoot] = useState("");
|
||||
const [entityQ, setEntityQ] = useState("");
|
||||
const [entity, setEntity] = useState<{ id: string; title: string } | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [planId, setPlanId] = useState<string | null>(null);
|
||||
const search = useEntitySearch(entity ? "" : entityQ);
|
||||
|
||||
const body = (dryRun: boolean) => {
|
||||
const sourcePaths = paths
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return OrganizeRequest.safeParse({
|
||||
...(downloadTaskId ? { downloadTaskId } : { sourcePaths }),
|
||||
...(entity ? { mediaEntityId: entity.id } : {}),
|
||||
...(root ? { targetLibraryRootId: root } : {}),
|
||||
action,
|
||||
dryRun,
|
||||
});
|
||||
};
|
||||
|
||||
const run = (dryRun: boolean) => {
|
||||
const parsed = body(dryRun);
|
||||
if (!parsed.success) {
|
||||
const e = zodFieldErrors(parsed.error.issues);
|
||||
const pathErr = Object.entries(e).find(([k]) => k.startsWith("sourcePaths"));
|
||||
setErrors({
|
||||
...e,
|
||||
paths: pathErr ? "每行一个以 / 开头的绝对路径" : e[""] ? "填写源路径(每行一个绝对路径)" : "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
organize.mutate(parsed.data, {
|
||||
onSuccess: (r) => {
|
||||
if (dryRun) setPlanId(r.planId);
|
||||
else {
|
||||
toast.success("已发起整理");
|
||||
onOpenChange(false);
|
||||
if (r.recordId) navigate(`/console/organize/${r.recordId}`);
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>手动整理</DialogTitle>
|
||||
<DialogDescription>
|
||||
先预览计划(不动文件),确认后再执行。Move 跨卷时按 Copy → 核验 → 删源执行。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-1">
|
||||
{downloadTaskId ? (
|
||||
<p className="mb-2 text-caption text-muted-foreground">
|
||||
源:下载任务 {downloadTaskId.slice(0, 8)}… 的内容路径
|
||||
</p>
|
||||
) : (
|
||||
<FormField
|
||||
label="源路径"
|
||||
htmlFor="org-paths"
|
||||
required
|
||||
error={errors.paths}
|
||||
hint="执行进程可访问的绝对路径,每行一个(文件或目录)"
|
||||
>
|
||||
<Textarea
|
||||
id="org-paths"
|
||||
rows={3}
|
||||
className="font-mono text-caption"
|
||||
value={paths}
|
||||
aria-invalid={!!errors.paths}
|
||||
onChange={(e) => {
|
||||
setPaths(e.target.value);
|
||||
setPlanId(null);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="作品" htmlFor="org-entity" hint={entity ? undefined : "可选;不选时按文件名识别"}>
|
||||
{entity ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge>{entity.title}</StatusBadge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setEntity(null);
|
||||
setPlanId(null);
|
||||
}}
|
||||
>
|
||||
更换
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="org-entity"
|
||||
value={entityQ}
|
||||
placeholder="搜索本地作品"
|
||||
onChange={(e) => setEntityQ(e.target.value)}
|
||||
/>
|
||||
{search.data?.items.length ? (
|
||||
<ul className="pg-float absolute inset-x-0 top-full z-10 mt-1 max-h-48 overflow-auto rounded-xl p-1">
|
||||
{search.data.items.map((it) => (
|
||||
<li key={it.entity.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full cursor-pointer rounded-lg px-2.5 py-1.5 text-left text-caption hover:bg-foreground/[0.06]"
|
||||
onClick={() => {
|
||||
setEntity({
|
||||
id: it.entity.id,
|
||||
title: `${it.entity.title}${it.entity.year ? ` (${it.entity.year})` : ""}`,
|
||||
});
|
||||
setEntityQ("");
|
||||
setPlanId(null);
|
||||
}}
|
||||
>
|
||||
{it.entity.title}
|
||||
{it.entity.year ? ` (${it.entity.year})` : ""}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
<div className="grid gap-x-3 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="动作"
|
||||
htmlFor="org-action"
|
||||
hint={action === "move" ? "Move 需要对应授权" : "保留源文件(做种不受影响)"}
|
||||
>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => {
|
||||
setAction(v as "copy" | "move");
|
||||
setPlanId(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="org-action" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="copy">Copy</SelectItem>
|
||||
<SelectItem value="move">Move</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="目标库根" htmlFor="org-root">
|
||||
<Select
|
||||
value={root || AUTO}
|
||||
onValueChange={(v) => {
|
||||
setRoot(v === AUTO ? "" : v);
|
||||
setPlanId(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="org-root" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={AUTO}>按作品类型自动</SelectItem>
|
||||
{(roots.data?.items ?? []).map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
{planId ? (
|
||||
<div className="mt-1">
|
||||
<p className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
计划预览
|
||||
</p>
|
||||
<PlanPreview planId={planId} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => run(true)} disabled={organize.isPending}>
|
||||
<Eye />
|
||||
预览计划
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => run(false)}
|
||||
disabled={organize.isPending || !planId}
|
||||
title={planId ? undefined : "先预览计划"}
|
||||
>
|
||||
<Play />
|
||||
执行
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user