chore(web): 删除没有引用的整理向导旧步骤与工具审批组件

step-files / step-options / agentui/tool-approval 全仓无引用。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-07 02:10:24 +08:00
1 parent 592f40ccf8
commit a4c554f841
3 files changed
-932

No files matched your search

@@ -1,184 +0,0 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/tool-approval.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:状态徽章 amber/blue/emerald/rose → warning/中性/success/destructive;去掉 "Always allow"(本项目的持续授权
// 只在设置页按策略版本发放,不在对话里一键放行);新增 `rejected` 醒目态:服务端拒绝了一次工具调用时,
// 整卡换 destructive 材质、role=alert、给出拒绝原因与授权说明;新增 `badges` 槽(scope/风险级别);参数区用 Disclosure。
import { Check, ChevronDown, CircleAlert, LoaderCircle, ShieldAlert, ShieldCheck, X } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export type ToolApprovalStatus =
| "pending"
| "approving"
| "approved"
| "denied"
| "rejected"
| "running"
| "complete"
| "error";
export interface ToolApprovalParameter {
id: string;
label: ReactNode;
value: ReactNode;
}
const COPY: Record<ToolApprovalStatus, string> = {
pending: "等你批准",
approving: "提交中",
approved: "已批准",
denied: "已拒绝",
rejected: "服务端拒绝",
running: "执行中",
complete: "已完成",
error: "失败",
};
const BADGE: Record<ToolApprovalStatus, string> = {
pending: "border-warning/30 bg-warning/10 text-warning",
approving: "border-border bg-foreground/[0.04] text-muted-foreground",
running: "border-border bg-foreground/[0.04] text-muted-foreground",
approved: "border-success/30 bg-success/10 text-success",
complete: "border-success/30 bg-success/10 text-success",
denied: "border-destructive/30 bg-destructive/10 text-destructive",
rejected: "border-destructive/40 bg-destructive/15 text-destructive",
error: "border-destructive/30 bg-destructive/10 text-destructive",
};
export interface ToolApprovalProps {
tool: ReactNode;
title?: ReactNode;
description?: ReactNode;
parameters?: ToolApprovalParameter[];
status?: ToolApprovalStatus;
badges?: ReactNode;
/** rejected 态:拒绝原因(服务端给出) */
reason?: ReactNode;
/** rejected 态:为什么会被拒绝、怎样才能放行 */
explanation?: ReactNode;
defaultOpen?: boolean;
onApprove?: () => void;
onDeny?: () => void;
/** 为什么按钮不可用(如非管理员会话) */
actionsDisabledReason?: string;
className?: string;
}
export function ToolApproval({
tool,
title = "允许调用这个工具?",
description,
parameters = [],
status = "pending",
badges,
reason,
explanation,
defaultOpen = false,
onApprove,
onDeny,
actionsDisabledReason,
className,
}: ToolApprovalProps) {
const id = useId();
const [open, setOpen] = useState(defaultOpen);
const busy = status === "approving" || status === "running";
const rejected = status === "rejected";
return (
<div
data-state={status}
aria-busy={busy}
role={rejected ? "alert" : undefined}
className={cn(
"w-full min-w-0 overflow-hidden rounded-xl border text-sm",
rejected ? "border-destructive/50 bg-destructive/10" : "border-border/70 bg-foreground/[0.02]",
className,
)}
>
<div className="flex items-start gap-3 p-3">
<span
aria-hidden
className={cn(
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border",
rejected
? "border-destructive/40 bg-destructive/15 text-destructive"
: "border-border/60 text-muted-foreground",
status === "error" && "text-destructive",
)}
>
{busy ? (
<LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />
) : rejected ? (
<ShieldAlert className="size-4" />
) : status === "error" ? (
<CircleAlert className="size-4" />
) : status === "denied" ? (
<X className="size-4" />
) : status === "approved" || status === "complete" ? (
<Check className="size-4" />
) : (
<ShieldCheck className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-x-3 gap-y-1">
<div className="min-w-0">
<div className={cn("font-medium", rejected ? "text-destructive" : "text-foreground")}>{title}</div>
<div className="mt-0.5 font-mono text-meta break-all text-muted-foreground">{tool}</div>
</div>
<span className={cn("shrink-0 rounded-full border px-2 py-0.5 text-label font-medium", BADGE[status])}>
{COPY[status]}
</span>
</div>
{badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1">{badges}</div> : null}
{rejected && reason ? (
<p className="mt-2 text-caption font-medium break-words text-destructive">原因:{reason}</p>
) : null}
{description ? <p className="mt-1.5 text-caption leading-5 text-muted-foreground">{description}</p> : null}
{rejected && explanation ? (
<p className="mt-1.5 text-meta leading-5 text-muted-foreground">{explanation}</p>
) : null}
{parameters.length ? (
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
className="mt-2 inline-flex cursor-pointer items-center gap-1 rounded-md text-meta font-medium text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{open ? "收起详情" : "查看详情"}
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
) : null}
</div>
</div>
{parameters.length ? (
<Disclosure id={id} open={open}>
<dl className="mx-3 mb-3 grid gap-1.5 rounded-lg border border-border/50 p-2.5">
{parameters.map((p) => (
<div key={p.id} className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-start gap-3 text-meta">
<dt className="text-muted-foreground">{p.label}</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">{p.value}</dd>
</div>
))}
</dl>
</Disclosure>
) : null}
{status === "pending" && (onApprove || onDeny) ? (
<div className="flex flex-wrap items-center gap-2 border-t border-border/60 px-3 py-2.5">
<Button size="sm" onClick={onApprove} disabled={!!actionsDisabledReason || !onApprove}>
批准
</Button>
<Button size="sm" variant="ghost" onClick={onDeny} disabled={!!actionsDisabledReason || !onDeny}>
拒绝
</Button>
{actionsDisabledReason ? (
<span className="text-meta text-muted-foreground">{actionsDisabledReason}</span>
) : null}
</div>
) : null}
</div>
);
}
@@ -1,519 +0,0 @@
import { ChevronDown, Undo2, X } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { filesMockEnabled } from "~/api/files.ts";
import { useSlots } from "~/api/slots.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Checkbox } from "~/components/files/file-list.tsx";
import { baseName, contentUnitLabel } from "~/components/files/model.ts";
import { SLOT_STATUS } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import { EntityPicker } from "./entity-picker.tsx";
import {
groupRows,
isMovieKind,
MIB,
rowProblem,
type WizardEntity,
type WizardRow,
type WizardStats,
} from "./model.ts";
type RowPatch = Partial<Pick<WizardRow, "season" | "episodes" | "slotId" | "excluded">>;
export type StepFilesProps = {
rows: WizardRow[];
stats: WizardStats;
selected: Set<string>;
onSelected: (s: Set<string>) => void;
onEntity: (key: string, e: WizardEntity | null) => void;
onPatch: (key: string, p: RowPatch) => void;
onApplyGroup: (group: string) => void;
onBulkEntity: (e: WizardEntity) => void;
onBulkSeason: (s: string) => void;
onBulkOffset: (n: number) => void;
minSizeMb: string;
onMinSizeMb: (v: string) => void;
truncated: boolean;
mobile: boolean;
rootName: (rootId: string) => string;
};
/** 向导 ① 批量条(§6.1 WizardBulkBar):作品 / 季 / 集号偏移 / 最小体积 + 统计 */
export function WizardBulkBar({
selectedCount,
onBulkEntity,
onBulkSeason,
onBulkOffset,
minSizeMb,
onMinSizeMb,
stats,
}: {
selectedCount: number;
onBulkEntity: (e: WizardEntity) => void;
onBulkSeason: (s: string) => void;
onBulkOffset: (n: number) => void;
minSizeMb: string;
onMinSizeMb: (v: string) => void;
stats: WizardStats;
}) {
const [season, setSeason] = useState("");
const [offset, setOffset] = useState("");
const off = Number(offset);
const none = selectedCount === 0;
const hint = none ? "先勾选要改的行" : undefined;
return (
<div className="space-y-2 rounded-xl border border-border bg-foreground/[0.02] p-2.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-caption">
<span className="text-muted-foreground">已选 {selectedCount} 项</span>
<div className="flex min-w-0 items-center gap-1.5" title={hint}>
<span className="shrink-0 text-muted-foreground">作品</span>
<div className={cn("w-52 min-w-0", none && "pointer-events-none opacity-50")}>
<EntityPicker value={null} onChange={(e) => e && onBulkEntity(e)} placeholder="应用到选中…" />
</div>
</div>
<div className="flex items-center gap-1.5" title={hint}>
<span className="text-muted-foreground">季</span>
<Input
value={season}
onChange={(e) => setSeason(e.target.value)}
inputMode="numeric"
aria-label="批量季号"
className="h-8 w-14 text-center"
/>
<Button
size="sm"
variant="outline"
disabled={none || !/^\d{1,4}$/.test(season.trim())}
onClick={() => onBulkSeason(season)}
>
应用
</Button>
</div>
<div className="flex items-center gap-1.5" title={hint}>
<span className="text-muted-foreground">集号偏移</span>
<Input
value={offset}
onChange={(e) => setOffset(e.target.value)}
placeholder="±N"
aria-label="集号偏移量(可为负)"
className="h-8 w-16 text-center"
/>
<Button
size="sm"
variant="outline"
disabled={none || !/^[+-]?\d{1,4}$/.test(offset.trim()) || !off}
onClick={() => onBulkOffset(off)}
>
应用
</Button>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">最小体积</span>
<Input
value={minSizeMb}
onChange={(e) => onMinSizeMb(e.target.value)}
inputMode="numeric"
aria-label="最小体积(MB)"
className="h-8 w-16 text-center"
/>
<span className="text-muted-foreground">MB</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<StatusBadge>{stats.videos} 个视频</StatusBadge>
{stats.pending ? (
<StatusBadge tone="warning">{stats.pending} 项待定</StatusBadge>
) : (
<StatusBadge tone="success">没有待定项</StatusBadge>
)}
{stats.excluded ? (
<StatusBadge>
{stats.excluded} 项已排除{stats.bySize ? `(${stats.bySize} 项低于最小体积)` : ""}
</StatusBadge>
) : null}
</div>
</div>
);
}
function RowMarks({ row }: { row: WizardRow }) {
const it = row.item;
return (
<span className="flex flex-wrap gap-1">
{it.existing.assetId ? (
<StatusBadge tone="neutral" className="whitespace-nowrap">
已整理
</StatusBadge>
) : null}
{it.records?.count ? (
it.records.lastRecordId ? (
<Link to={`/console/organize/${it.records.lastRecordId}`} target="_blank" className="hover:underline">
<StatusBadge tone="neutral">有 {it.records.count} 条整理记录</StatusBadge>
</Link>
) : (
<StatusBadge tone="neutral">有 {it.records.count} 条整理记录</StatusBadge>
)
) : null}
{row.hnrBlocked ? <StatusBadge tone="warning">做种中·H&R 未满足</StatusBadge> : null}
{row.excluded === "below_min_size" ? <StatusBadge>低于最小体积</StatusBadge> : null}
{row.entity && !row.entity.id ? <StatusBadge tone="warning">将新建作品</StatusBadge> : null}
</span>
);
}
function parsedLine(row: WizardRow) {
const p = row.item.parsed;
return [
p.title ?? "解析不出标题",
p.year ? String(p.year) : null,
p.season != null ? contentUnitLabel(p.season, p.episodes) : null,
p.resolution,
p.source,
p.releaseGroup,
row.item.size != null ? formatBytes(row.item.size, 1) : null,
]
.filter(Boolean)
.join(" · ");
}
function SlotSelect({
entity,
value,
onChange,
}: {
entity: WizardEntity | null;
value: string;
onChange: (v: string) => void;
}) {
const mock = filesMockEnabled();
const id = entity?.id ?? null;
const slots = useSlots({ mediaEntityId: id ?? undefined }, !!id && !mock);
const items = slots.data?.items ?? [];
return (
<select
aria-label="收录版本"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={!id}
title={entity && !id ? "新建的作品还没有收录版本" : undefined}
className="h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2 text-caption disabled:opacity-50"
>
<option value="">自动</option>
{items.map((s) => (
<option key={s.id} value={s.id}>
{s.name} · {SLOT_STATUS[s.status]?.label ?? s.status}
</option>
))}
</select>
);
}
function ExcludeButton({ row, onPatch }: { row: WizardRow; onPatch: (p: RowPatch) => void }) {
return (
<Button
variant="ghost"
size="xs"
onClick={() => onPatch({ excluded: row.excluded ? null : "manual" })}
aria-label={row.excluded ? `恢复 ${baseName(row.path)}` : `排除 ${baseName(row.path)}`}
>
{row.excluded ? <Undo2 /> : <X />}
{row.excluded ? "恢复" : "排除"}
</Button>
);
}
/** 向导 ①:按源目录分组的表格(桌面)/ 紧凑卡片(手机) */
export function StepFiles(p: StepFilesProps) {
const groups = groupRows(p.rows);
const allKeys = p.rows.filter((r) => !r.excluded).map((r) => r.key);
const toggle = (k: string) => {
const n = new Set(p.selected);
if (n.has(k)) n.delete(k);
else n.add(k);
p.onSelected(n);
};
const [bulkOpen, setBulkOpen] = useState(false);
const bulk = (
<WizardBulkBar
selectedCount={p.selected.size}
onBulkEntity={p.onBulkEntity}
onBulkSeason={p.onBulkSeason}
onBulkOffset={p.onBulkOffset}
minSizeMb={p.minSizeMb}
onMinSizeMb={p.onMinSizeMb}
stats={p.stats}
/>
);
return (
<div className="space-y-3">
{p.mobile ? (
<div>
<button
type="button"
aria-expanded={bulkOpen}
onClick={() => setBulkOpen((v) => !v)}
className="flex w-full items-center justify-between rounded-xl border border-border px-3 py-2 text-caption"
>
<span>
批量设置 · {p.stats.videos} 个视频{p.stats.pending ? ` · ${p.stats.pending} 项待定` : ""}
</span>
<ChevronDown className={cn("h-4 w-4 transition-transform", bulkOpen && "rotate-180")} />
</button>
{bulkOpen ? <div className="mt-2">{bulk}</div> : null}
</div>
) : (
bulk
)}
{p.truncated ? (
<p className="rounded-lg border border-warning/35 bg-warning/10 px-3 py-1.5 text-meta text-warning">
目录里视频超过 200 个,只识别了前 200 个。进入子目录分批整理。
</p>
) : null}
{p.mobile ? (
<div className="space-y-4">
{groups.map((g) => (
<section key={g.group} className="space-y-2">
<GroupHead
dir={g.dir}
rootName={p.rootName(g.rows[0]?.root ?? "")}
count={g.rows.length}
onApply={() => p.onApplyGroup(g.group)}
canApply={g.rows.length > 1 && g.rows.some((r) => r.entity && !r.excluded)}
/>
<ul className="space-y-2">
{g.rows.map((r) => {
const problem = rowProblem(r);
const movie = isMovieKind(r.entity?.kind);
return (
<li
key={r.key}
className={cn(
"rounded-xl border px-3 py-2",
r.excluded
? "border-border opacity-60"
: problem
? "border-warning/50 bg-warning/[0.06]"
: "border-border",
)}
>
<div className="flex items-start gap-2">
<Checkbox
checked={p.selected.has(r.key)}
label={`选择 ${baseName(r.path)}`}
onToggle={() => toggle(r.key)}
/>
<p className="min-w-0 flex-1 truncate font-mono text-caption" title={r.path}>
{baseName(r.path)}
</p>
<ExcludeButton row={r} onPatch={(x) => p.onPatch(r.key, x)} />
</div>
{!r.excluded ? (
<div className="mt-1.5 grid grid-cols-[minmax(0,1fr)_3.25rem_4.5rem] gap-1.5">
<EntityPicker
candidates={r.item.candidates}
value={r.entity}
onChange={(e) => p.onEntity(r.key, e)}
invalid={!r.entity}
/>
<Input
aria-label="季"
placeholder="季"
value={r.season}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { season: e.target.value })}
className="h-8 px-1 text-center"
/>
<Input
aria-label="集"
placeholder="集"
value={r.episodes}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { episodes: e.target.value })}
className="h-8 px-1 text-center"
/>
</div>
) : null}
<div className="mt-1">
<RowMarks row={r} />
</div>
{problem ? <p className="mt-1 text-meta text-warning">{problem}</p> : null}
</li>
);
})}
</ul>
</section>
))}
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full min-w-[56rem] border-collapse text-caption">
<thead>
<tr className="border-b border-border text-left text-meta text-muted-foreground">
<th className="w-9 px-2 py-2">
<Checkbox
checked={allKeys.length > 0 && allKeys.every((k) => p.selected.has(k))}
partial={p.selected.size > 0 && !allKeys.every((k) => p.selected.has(k))}
label="全选"
onToggle={() =>
p.onSelected(allKeys.every((k) => p.selected.has(k)) ? new Set() : new Set(allKeys))
}
/>
</th>
<th className="px-2 py-2 font-medium">源文件</th>
<th className="w-[15rem] px-2 py-2 font-medium">作品</th>
<th className="w-16 px-2 py-2 font-medium">季</th>
<th className="w-24 px-2 py-2 font-medium">集</th>
<th className="w-40 px-2 py-2 font-medium">收录版本</th>
<th className="w-20 px-2 py-2">
<span className="sr-only">操作</span>
</th>
</tr>
</thead>
{groups.map((g) => (
<tbody key={g.group}>
<tr className="border-b border-border/60 bg-foreground/[0.03]">
<td colSpan={7} className="px-2 py-1.5">
<GroupHead
dir={g.dir}
rootName={p.rootName(g.rows[0]?.root ?? "")}
count={g.rows.length}
onApply={() => p.onApplyGroup(g.group)}
canApply={g.rows.length > 1 && g.rows.some((r) => r.entity && !r.excluded)}
/>
</td>
</tr>
{g.rows.map((r) => {
const problem = rowProblem(r);
const movie = isMovieKind(r.entity?.kind);
return (
<tr
key={r.key}
className={cn(
"border-b border-border/50 align-top",
r.excluded ? "opacity-55" : problem ? "bg-warning/[0.07]" : "",
)}
>
<td className="px-2 py-2">
<Checkbox
checked={p.selected.has(r.key)}
label={`选择 ${baseName(r.path)}`}
onToggle={() => toggle(r.key)}
/>
</td>
<td className="max-w-0 px-2 py-1.5">
<p className="truncate font-mono text-caption text-foreground" title={r.path}>
{baseName(r.path)}
</p>
<p className="truncate text-meta text-muted-foreground" title={parsedLine(r)}>
{parsedLine(r)}
</p>
<div className="mt-0.5 empty:hidden">
<RowMarks row={r} />
</div>
{problem ? <p className="text-meta text-warning">{problem}</p> : null}
{(r.item.skipped ? [] : r.item.warnings).map((w) => (
<p key={w} className="text-meta text-warning">
{w}
</p>
))}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<EntityPicker
candidates={r.item.candidates}
value={r.entity}
onChange={(e) => p.onEntity(r.key, e)}
invalid={!r.entity}
/>
)}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<Input
aria-label={`${baseName(r.path)} 季`}
value={movie ? "" : r.season}
placeholder={movie ? "—" : ""}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { season: e.target.value })}
className="h-8 px-1 text-center"
/>
)}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<Input
aria-label={`${baseName(r.path)} 集`}
value={movie ? "" : r.episodes}
placeholder={movie ? "—" : "如 3-5"}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { episodes: e.target.value })}
className="h-8 px-1 text-center"
/>
)}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<SlotSelect
entity={r.entity}
value={r.slotId}
onChange={(v) => p.onPatch(r.key, { slotId: v })}
/>
)}
</td>
<td className="px-1 py-1.5 text-right">
<ExcludeButton row={r} onPatch={(x) => p.onPatch(r.key, x)} />
</td>
</tr>
);
})}
</tbody>
))}
</table>
</div>
)}
</div>
);
}
function GroupHead({
dir,
rootName,
count,
onApply,
canApply,
}: {
dir: string;
rootName: string;
count: number;
onApply: () => void;
canApply: boolean;
}) {
return (
<div className="flex min-w-0 items-center gap-2 text-meta">
<span className="min-w-0 truncate font-mono text-foreground" title={`${rootName}/${dir}`}>
{dir ? dir.split("/").pop() : rootName}
</span>
<span className="shrink-0 text-muted-foreground">{count} 项</span>
{canApply ? (
<button
type="button"
onClick={onApply}
className="ml-auto shrink-0 text-muted-foreground underline underline-offset-2 hover:text-foreground"
title="把本组第一项的作品应用到本组全部"
>
应用到本组
</button>
) : null}
</div>
);
}
export const mbToBytes = (v: string) => {
const n = Number(v.trim());
return Number.isFinite(n) && n >= 0 ? Math.round(n * MIB) : null;
};
@@ -1,229 +0,0 @@
import { Link } from "react-router";
import type { FileRoot } from "~/api/files.ts";
import { DirPicker } from "~/components/files/dir-picker.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import type { WizardOptions } from "./model.ts";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="space-y-2">
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">{title}</h3>
{children}
</section>
);
}
function Choice({
checked,
disabled,
onSelect,
title,
hint,
}: {
checked: boolean;
disabled?: string | null;
onSelect: () => void;
title: string;
hint?: React.ReactNode;
}) {
return (
// biome-ignore lint/a11y/useSemanticElements: 带说明文字的整卡单选,原生 radio 无法套卡片样式;键盘可达(button)
<button
type="button"
role="radio"
aria-checked={checked}
aria-disabled={!!disabled || undefined}
title={disabled ?? undefined}
onClick={() => !disabled && onSelect()}
className={cn(
"flex w-full items-start gap-2.5 rounded-xl border px-3 py-2 text-left transition-colors duration-micro",
checked ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
disabled && "cursor-not-allowed opacity-50 hover:bg-transparent",
)}
>
<span
aria-hidden
className={cn(
"mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full border",
checked ? "border-foreground" : "border-border",
)}
>
{checked ? <span className="h-2 w-2 rounded-full bg-foreground" /> : null}
</span>
<span className="min-w-0">
<span className="block text-body">{title}</span>
{disabled ? (
<span className="block text-meta text-muted-foreground">{disabled}</span>
) : hint ? (
<span className="block text-meta text-muted-foreground">{hint}</span>
) : null}
</span>
</button>
);
}
/** 向导 ②:MP 手动整理字段的映射(§6.2) */
export function StepOptions({
options,
onChange,
roots,
hnrCount,
kinds,
profileLabel,
}: {
options: WizardOptions;
onChange: (o: WizardOptions) => void;
roots: FileRoot[];
/** 做种中且 H&R 未满足的行数:大于 0 时不能移动 */
hnrCount: number;
/** 本次涉及的作品类型(过滤库根) */
kinds: Set<string>;
profileLabel?: string;
}) {
const set = (p: Partial<WizardOptions>) => onChange({ ...options, ...p });
const libRoots = roots.filter((r) => r.kind === "library" && r.writable && r.health !== "offline");
/** 库根类型与本次作品类型不符时的原因 */
const rootMismatch = (r: FileRoot): string | null => {
if (r.mediaKind === "mixed" || r.mediaKind === "other" || !r.mediaKind) return null;
const hasMovie = [...kinds].some((k) => k === "movie");
const hasShow = [...kinds].some((k) => k !== "movie");
if (r.mediaKind === "movie" && hasShow) return "电影库,本次含剧集";
if (r.mediaKind !== "movie" && hasMovie) return "剧集库,本次含电影";
return null;
};
const t = options.target;
return (
<div className="grid max-w-4xl gap-6 lg:grid-cols-2">
<div className="space-y-6">
<Section title="整理方式">
<div role="radiogroup" aria-label="整理方式" className="space-y-1.5">
<Choice
checked={options.action === "copy"}
onSelect={() => set({ action: "copy" })}
title="复制"
hint="保留源文件,不影响做种"
/>
<Choice
checked={options.action === "move"}
onSelect={() => set({ action: "move" })}
disabled={hnrCount ? `${hnrCount} 项做种中且 H&R 未满足,不能移动` : null}
title="移动"
hint="整理后删除源文件;跨卷移动会先复制、核验,再删除源文件"
/>
</div>
</Section>
<Section title="目标已存在">
<div role="radiogroup" aria-label="目标已存在" className="space-y-1.5">
<Choice
checked={options.onExisting === "conflict"}
onSelect={() => set({ onExisting: "conflict" })}
title="标为需要处理"
hint="这组不执行,在整理记录里标为需要处理"
/>
<Choice
checked={options.onExisting === "skip"}
onSelect={() => set({ onExisting: "skip" })}
title="跳过"
hint="只跳过已存在的文件,其余照常整理"
/>
<Choice
checked={options.onExisting === "replace_with_verify"}
onSelect={() => set({ onExisting: "replace_with_verify" })}
title="替换"
hint="新文件核验通过后,旧文件移入回收站"
/>
</div>
</Section>
</div>
<div className="space-y-6">
<Section title="目标">
<div role="radiogroup" aria-label="目标" className="space-y-1.5">
<Choice
checked={t.mode === "auto"}
onSelect={() => set({ target: { mode: "auto" } })}
title="自动"
hint={
profileLabel
? `按来源「${profileLabel}」的目标目录与分类规则整理`
: "已绑定的作品沿用原目录,否则按作品类型选媒体库目录"
}
/>
{libRoots.map((r) => (
<Choice
key={r.id}
checked={t.mode === "root" && t.rootId === r.id}
disabled={rootMismatch(r)}
onSelect={() => set({ target: { mode: "root", rootId: r.id } })}
title={r.name}
hint={
<span className="font-mono">
{r.hostPath}
{r.usage ? ` · 剩余 ${formatBytes(r.usage.freeBytes, 1)}` : ""}
</span>
}
/>
))}
<Choice
checked={t.mode === "dir"}
onSelect={() => set({ target: { mode: "dir", ref: t.mode === "dir" ? t.ref : { root: "", path: "" } } })}
title="指定目录"
hint="文件按模板命名后直接放进这个目录,不建路径绑定"
/>
{t.mode === "dir" ? (
<DirPicker
roots={roots}
value={t.ref.root ? t.ref : null}
onChange={(ref) => set({ target: { mode: "dir", ref } })}
className="max-h-64"
/>
) : null}
</div>
</Section>
<Section title="命名">
<div role="radiogroup" aria-label="命名" className="space-y-1.5">
<Choice
checked={options.naming === "template"}
onSelect={() => set({ naming: "template" })}
title="按命名模板"
hint={
<>
模板按 作品覆盖 → 媒体库目录 → 全局 取第一个,预览里显示实际来源。
<Link to="/console/settings#library/naming" className="ml-1 underline underline-offset-2">
去设置
</Link>
</>
}
/>
<Choice
checked={options.naming === "keep"}
onSelect={() => set({ naming: "keep" })}
title="保留原文件名"
hint="目录仍按模板或已有绑定"
/>
</div>
</Section>
<Section title="整理后">
<div className="flex items-start justify-between gap-3 rounded-xl border border-border px-3 py-2">
<span className="text-body">
刷新元数据
<span className="block text-meta text-muted-foreground">按字段刷新,锁定的字段不变</span>
</span>
<Switch
checked={options.refreshMetadata}
onCheckedChange={(v) => set({ refreshMetadata: v })}
aria-label="整理后刷新元数据"
/>
</div>
<p className="text-meta text-muted-foreground">
同目录同名字幕和外挂音轨会随视频整理,预览里列出。现有 NFO、图片不搬入媒体库,可在整理后刮削生成。
</p>
</Section>
</div>
</div>
);
}