fix(web): organize dialog blocks execute on unusable plan; feed table fits phones
- manual organize: Execute is disabled when the preview has no organize plan or has blocking conflicts (it used to create a failed record); plan preview shows Chinese labels for operation/file action/kind and known failure reasons - feed table: name column narrower below sm so health and actions fit 390px Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0c0907ad4c
commit
d7eb6a45d0
2 files changed
+26
-7
No files matched your search
@@ -21,10 +21,14 @@ import { Input } from "~/components/ui/input.tsx";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
import { organizationActionLabel } from "~/lib/labels.ts";
|
||||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||||
|
|
||||||
const AUTO = "__auto__";
|
const AUTO = "__auto__";
|
||||||
|
|
||||||
|
const FILE_ACTION: Record<string, string> = { transfer: "入库", skip: "跳过", replace: "替换", hold: "暂缓" };
|
||||||
|
const FILE_KIND: Record<string, string> = { video: "视频", subtitle: "字幕", audio: "音轨", nfo: "NFO", other: "其它" };
|
||||||
|
|
||||||
const EXCLUDE_REASON: Record<string, string> = {
|
const EXCLUDE_REASON: Record<string, string> = {
|
||||||
sample: "样片",
|
sample: "样片",
|
||||||
too_small: "体积过小",
|
too_small: "体积过小",
|
||||||
@@ -37,6 +41,20 @@ const EXCLUDE_REASON: Record<string, string> = {
|
|||||||
naming_failed: "命名失败",
|
naming_failed: "命名失败",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 预览里的计划能否执行:没生成整理计划或有阻塞冲突时不能执行(执行只会得到一条失败记录) */
|
||||||
|
function usePlanExecutable(planId: string | null) {
|
||||||
|
const art = useArtifact(planId);
|
||||||
|
const parsed = art.data ? OrganizePlanPreview.safeParse(art.data.content) : null;
|
||||||
|
const org = parsed?.success ? parsed.data.plan?.organize : undefined;
|
||||||
|
return !!org && !org.conflicts.some((c) => c.blocking);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAN_FAIL_TEXT: Record<string, string> = {
|
||||||
|
source_size_mismatch: "源文件大小与下载器记录不一致,可能还在写入;稍后重新预览",
|
||||||
|
source_missing: "源文件不存在,检查路径映射与挂载",
|
||||||
|
source_path_unknown: "找不到下载任务的保存路径或路径映射",
|
||||||
|
};
|
||||||
|
|
||||||
function PlanPreview({ planId }: { planId: string }) {
|
function PlanPreview({ planId }: { planId: string }) {
|
||||||
const art = useArtifact(planId);
|
const art = useArtifact(planId);
|
||||||
if (art.isPending) return <LoadingState rows={3} />;
|
if (art.isPending) return <LoadingState rows={3} />;
|
||||||
@@ -55,14 +73,14 @@ function PlanPreview({ planId }: { planId: string }) {
|
|||||||
if (!org)
|
if (!org)
|
||||||
return (
|
return (
|
||||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||||
无法生成计划:{p.reason ?? p.status ?? "未知原因"}
|
无法生成计划:{(p.reason && PLAN_FAIL_TEXT[p.reason]) ?? p.reason ?? p.status ?? "未知原因"}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
const blocking = org.conflicts.filter((c) => c.blocking);
|
const blocking = org.conflicts.filter((c) => c.blocking);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<StatusBadge>{org.operation}</StatusBadge>
|
<StatusBadge>{organizationActionLabel(org.operation)}</StatusBadge>
|
||||||
{org.crossDevice ? <StatusBadge tone="warning">跨卷:复制 → 核验 → 删源</StatusBadge> : null}
|
{org.crossDevice ? <StatusBadge tone="warning">跨卷:复制 → 核验 → 删源</StatusBadge> : null}
|
||||||
{org.requiresApproval ? <StatusBadge tone="warning">需要审批</StatusBadge> : null}
|
{org.requiresApproval ? <StatusBadge tone="warning">需要审批</StatusBadge> : null}
|
||||||
{blocking.length ? <StatusBadge tone="destructive">阻塞冲突 {blocking.length}</StatusBadge> : null}
|
{blocking.length ? <StatusBadge tone="destructive">阻塞冲突 {blocking.length}</StatusBadge> : null}
|
||||||
@@ -72,9 +90,9 @@ function PlanPreview({ planId }: { planId: string }) {
|
|||||||
<li key={f.src} className="rounded-lg border border-border/60 px-2.5 py-1.5">
|
<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">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<StatusBadge tone={f.action === "transfer" ? "success" : f.action === "skip" ? "neutral" : "warning"}>
|
<StatusBadge tone={f.action === "transfer" ? "success" : f.action === "skip" ? "neutral" : "warning"}>
|
||||||
{f.action}
|
{FILE_ACTION[f.action] ?? f.action}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<span className="text-muted-foreground">{f.kind}</span>
|
<span className="text-muted-foreground">{FILE_KIND[f.kind] ?? f.kind}</span>
|
||||||
<span className="text-muted-foreground">{formatBytes(f.size, 1)}</span>
|
<span className="text-muted-foreground">{formatBytes(f.size, 1)}</span>
|
||||||
{f.skipReason ? <span className="text-muted-foreground">({f.skipReason})</span> : null}
|
{f.skipReason ? <span className="text-muted-foreground">({f.skipReason})</span> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -131,6 +149,7 @@ export function OrganizeDialog({
|
|||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [planId, setPlanId] = useState<string | null>(null);
|
const [planId, setPlanId] = useState<string | null>(null);
|
||||||
const search = useEntitySearch(entity ? "" : entityQ);
|
const search = useEntitySearch(entity ? "" : entityQ);
|
||||||
|
const executable = usePlanExecutable(planId);
|
||||||
|
|
||||||
const body = (dryRun: boolean) => {
|
const body = (dryRun: boolean) => {
|
||||||
const sourcePaths = paths
|
const sourcePaths = paths
|
||||||
@@ -318,8 +337,8 @@ export function OrganizeDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => run(false)}
|
onClick={() => run(false)}
|
||||||
disabled={organize.isPending || !planId}
|
disabled={organize.isPending || !planId || !executable}
|
||||||
title={planId ? undefined : "先预览计划"}
|
title={!planId ? "先预览计划" : executable ? undefined : "计划不可执行"}
|
||||||
>
|
>
|
||||||
<Play />
|
<Play />
|
||||||
执行
|
执行
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function FeedTable({
|
|||||||
key: "name",
|
key: "name",
|
||||||
header: "名称",
|
header: "名称",
|
||||||
cell: (f) => (
|
cell: (f) => (
|
||||||
<div className="min-w-0 max-w-xs">
|
<div className="min-w-0 max-w-32 sm:max-w-xs">
|
||||||
<p className="truncate font-medium text-foreground">{f.name}</p>
|
<p className="truncate font-medium text-foreground">{f.name}</p>
|
||||||
<p className="truncate font-mono text-meta text-muted-foreground" title={f.urlDisplay}>
|
<p className="truncate font-mono text-meta text-muted-foreground" title={f.urlDisplay}>
|
||||||
{f.urlDisplay}
|
{f.urlDisplay}
|
||||||
|
|||||||
Reference in new issue
Block a user