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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user