fix(web): visual QA front-end items U4–U6, U13–U16 and copy list
- U4: form save failures toast the first problem and scroll/focus the first invalid field (policy draft, settings cards, resource drawers, subscription form) via lib/form-errors. - U5: FormField keeps 8px between a hint and the next label. - U6: file manager status bar keeps free space left-aligned, clear of the Agent button. - U13: move/copy dialog stops "target is the source folder / inside the source" before requesting a preview (transferTargetProblem + tests). - U14: account popover uses the solid float material. - U15/U16: inbox tile hint shortened; TMDB assign dialog uses the shared Select and labelled fields (type, TMDB ID, note). - Copy: "TMDB 已保存" spacing, API Key block no longer repeats the section title, whole-day intervals read "每 7 天", "最高级别:提示", "延后:已有计划在途", failed records show "未生成目标路径", upstream model column "默认模型". Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2ae780d2a1
commit
082c556a67
18 files changed
+130
-38
No files matched your search
@@ -277,7 +277,7 @@ export function AccountMenu({ session }: { session: SessionInfo }) {
|
||||
<span className="hidden truncate sm:inline">{actor?.name ?? "账户"}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 p-2">
|
||||
<PopoverContent align="end" className="pg-float-solid w-64 p-2">
|
||||
<div className="px-2 pt-1 pb-2">
|
||||
<p className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">当前账户</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-foreground">{actor?.name ?? "—"}</p>
|
||||
|
||||
@@ -27,7 +27,8 @@ export function FormField({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("grid min-w-0 gap-1.5", className)}>
|
||||
// pb-1:说明文字与下一个字段标签至少隔 8px(外层 gap-1 + 这里 4px;QA U5)
|
||||
<div className={cn("grid min-w-0 gap-1.5 pb-1", className)}>
|
||||
<Label htmlFor={htmlFor} className="text-caption font-medium text-foreground">
|
||||
{label}
|
||||
{required ? <span className="text-destructive">*</span> : null}
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
newIdempotencyKey,
|
||||
splitCommon,
|
||||
stemRange,
|
||||
transferTargetProblem,
|
||||
} from "./model.ts";
|
||||
|
||||
// ─── 共用 ───────────────────────────────────────────────────────
|
||||
@@ -485,6 +486,11 @@ export function TransferDialog({
|
||||
const runPreview = (t: FileRef, m: "move" | "copy") => {
|
||||
setPlan(null);
|
||||
setErr(null);
|
||||
const problem = transferTargetProblem(items, t);
|
||||
if (problem) {
|
||||
setErr(problem);
|
||||
return;
|
||||
}
|
||||
dry.mutate(
|
||||
{ idempotencyKey: `${key}-dry-${m}-${t.root}-${t.path}`, items, target: t, mode: m, dryRun: true },
|
||||
{ onSuccess: setPlan, onError: (e) => setErr(filesErrorText(e, "预览失败")) },
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
shiftEpisodes,
|
||||
splitCommon,
|
||||
stemRange,
|
||||
transferTargetProblem,
|
||||
} from "./model.ts";
|
||||
|
||||
const order = ["a", "b", "c", "d", "e"];
|
||||
@@ -257,3 +258,20 @@ describe("演示开关", () => {
|
||||
expect(resolveFilesMock({ dev: true, search: "?mock=0", storage })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferTargetProblem(U13)", () => {
|
||||
const items = [
|
||||
{ root: "dl", path: "a/b.mkv" },
|
||||
{ root: "dl", path: "pack" },
|
||||
];
|
||||
it("目标就是源所在目录 / 在源目录之内时拦下", () => {
|
||||
expect(transferTargetProblem(items, { root: "dl", path: "a" })).toMatch(/已在这个目录里/);
|
||||
expect(transferTargetProblem(items, { root: "dl", path: "pack/sub" })).toMatch(/目标在源目录之内/);
|
||||
expect(transferTargetProblem([{ root: "dl", path: "x.mkv" }], { root: "dl", path: "" })).toMatch(/已在/);
|
||||
});
|
||||
it("其他目录、其他根放行", () => {
|
||||
expect(transferTargetProblem(items, { root: "dl", path: "c" })).toBeNull();
|
||||
expect(transferTargetProblem(items, { root: "lib", path: "a" })).toBeNull();
|
||||
expect(transferTargetProblem(items, { root: "dl", path: "packs" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -178,6 +178,23 @@ export function parentRel(path: string): string {
|
||||
return i < 0 ? "" : path.slice(0, i);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动 / 复制目标的前端预检(与服务端 transfer 校验同口径,QA U13):目标就是源所在目录,
|
||||
* 或目标在源目录之内时直接提示,不发预览请求。
|
||||
*/
|
||||
export function transferTargetProblem(
|
||||
items: { root: string; path: string }[],
|
||||
target: { root: string; path: string },
|
||||
): string | null {
|
||||
for (const it of items) {
|
||||
if (it.root !== target.root) continue;
|
||||
if (target.path === it.path || target.path.startsWith(`${it.path}/`))
|
||||
return `目标在源目录之内(${baseName(it.path)}),换一个目录`;
|
||||
if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function baseName(path: string): string {
|
||||
const i = path.lastIndexOf("/");
|
||||
return i < 0 ? path : path.slice(i + 1);
|
||||
|
||||
@@ -128,7 +128,7 @@ export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
|
||||
reject: { label: "拒绝", tone: "destructive" },
|
||||
unknown_field: { label: "字段未知", tone: "warning" },
|
||||
skipped: { label: "跳过", tone: "neutral" },
|
||||
deferred: { label: "延后(单元在途)", tone: "warning" },
|
||||
deferred: { label: "延后:已有计划在途", tone: "warning" },
|
||||
};
|
||||
|
||||
export const DECISION_KIND: Record<DecisionKind, string> = {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formErrorSummary, revealFirstError } from "~/lib/form-errors.ts";
|
||||
import { actorText } from "~/lib/labels.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { CANDIDATE_DECISION, MEDIA_KIND, POLICY_STATUS } from "./labels.ts";
|
||||
@@ -633,7 +634,8 @@ function PolicyEditorSheet({
|
||||
open: t.open || Object.keys(r.errors).some((k) => k.startsWith(`${t.key}.`)),
|
||||
})),
|
||||
}));
|
||||
toast.error("表单有错误");
|
||||
toast.error(formErrorSummary(r.errors));
|
||||
revealFirstError();
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
|
||||
@@ -55,6 +55,8 @@ export function describeSchedule(s: ScheduleSpec): string {
|
||||
if (s.type === "cron") return `cron ${s.cron}`;
|
||||
if (s.everyMs < 1000) return `每 ${s.everyMs} 毫秒`;
|
||||
const { value, unit } = splitInterval(s.everyMs);
|
||||
// 整天的间隔按天说(每 168 小时 → 每 7 天)
|
||||
if (unit === "h" && value >= 24 && value % 24 === 0) return `每 ${value / 24} 天`;
|
||||
return `每 ${value} ${UNIT_LABEL[unit]}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,8 +104,8 @@ export function ApiKeysSection() {
|
||||
<SettingsBlock
|
||||
section="api-keys"
|
||||
anchor="keys"
|
||||
title="API Key"
|
||||
description="签发给外部程序(媒体服务器插件、脚本)的 Key,按权限范围授权。本机 Token 在「账户与安全」。"
|
||||
title="已签发的 Key"
|
||||
description="给媒体服务器插件、脚本等外部程序用,按权限范围授权。本机 Token 在「账户与安全」。"
|
||||
plain
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
|
||||
@@ -84,7 +84,7 @@ export function PiUpstreamsBlock() {
|
||||
key: "model",
|
||||
header: "模型",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => <code className="text-meta break-all">{r.defaultModel ?? "provider 默认"}</code>,
|
||||
cell: (r) => <code className="text-meta break-all">{r.defaultModel ?? "默认模型"}</code>,
|
||||
},
|
||||
{ key: "status", header: "状态", cell: (r) => <UpstreamStatusBadge u={r} /> },
|
||||
]}
|
||||
@@ -159,7 +159,7 @@ function UpstreamDrawer({
|
||||
try {
|
||||
await update.mutateAsync({ id: upstream.id, body: parsed.data });
|
||||
setV((s) => ({ ...s, apiKey: "" }));
|
||||
toast.success(`${v.name.trim()}已保存`);
|
||||
toast.success(`${v.name.trim()} 已保存`);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "保存失败"));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { revealFirstError } from "~/lib/form-errors.ts";
|
||||
|
||||
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。
|
||||
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。
|
||||
@@ -193,6 +194,7 @@ function ResourceFormDrawer<Row extends { id: string }>({
|
||||
fe[config.fields[0]?.name ?? ""] ??= fe[""];
|
||||
}
|
||||
setErrors(fe);
|
||||
revealFirstError();
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { revealFirstError } from "~/lib/form-errors.ts";
|
||||
import { FieldControl, type FieldSpec, type FormValues } from "./resource-section.tsx";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
@@ -56,13 +57,14 @@ export function SettingsCard({
|
||||
fe[spec.fields.find((f) => k.startsWith(f.name.split(".")[0] ?? ""))?.name ?? k] ??= m;
|
||||
}
|
||||
setErrors(fe);
|
||||
revealFirstError();
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
const next = await update.mutateAsync(patch);
|
||||
setValues(spec.fromSettings(next));
|
||||
toast.success(`${spec.title}已保存`);
|
||||
toast.success(`${spec.title} 已保存`);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "保存失败"));
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
|
||||
import { revealFirstError } from "~/lib/form-errors.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import {
|
||||
AUTOFILL_LABEL,
|
||||
@@ -340,6 +341,7 @@ export function SubscriptionFormDialog({
|
||||
if (Object.keys(all).length) {
|
||||
const basicKeys = ["target", "episodeStart", "episodeEnd", "total"];
|
||||
setTab(Object.keys(all).some((k) => basicKeys.includes(k)) ? "basic" : "advanced");
|
||||
revealFirstError();
|
||||
return;
|
||||
}
|
||||
setDup(null);
|
||||
|
||||
@@ -24,7 +24,7 @@ const DECISION_LABEL = {
|
||||
reject: { label: "不收录", tone: "destructive" },
|
||||
unknown_field: { label: "缺字段,待复核", tone: "warning" },
|
||||
skipped: { label: "被跳过", tone: "neutral" },
|
||||
deferred: { label: "延后(单元在途)", tone: "warning" },
|
||||
deferred: { label: "延后:已有计划在途", tone: "warning" },
|
||||
} as const;
|
||||
|
||||
/** 本地库存 + 候选理由(来自作品详情:Slot / CandidateEvaluation),不从标题猜 */
|
||||
|
||||
Reference in new issue
Block a user