Files
UHD_Media_Tool/apps/web/app/components/settings/resource-section.tsx
T
rootandClaude Opus 5.5 082c556a67 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]>
2026-09-28 16:58:38 +08:00

347 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { CursorPage } from "@umt/contracts";
import { LoaderCircle, Pencil, Plus, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import type { z } from "zod";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
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 校验;凭证字段只写,编辑时留空表示不变。
export type FieldValue = string | boolean;
export type FormValues = Record<string, FieldValue>;
export type FieldSpec = {
name: string;
label: string;
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea";
options?: { value: string; label: string }[];
placeholder?: string;
hint?: string;
/** 编辑时的说明(如"留空表示不修改") */
editHint?: string;
required?: boolean;
/** 编辑时不可改 */
createOnly?: boolean;
/** 条件显示 */
visible?: (v: FormValues) => boolean;
wide?: boolean;
};
export type ResourceConfig<Row extends { id: string }> = {
/** 单数名,用于标题与提示:"下载器实例" */
noun: string;
description?: ReactNode;
columns: DataColumn<Row>[];
fields: FieldSpec[];
/** 新建时的初始值 */
initial: FormValues;
/** 编辑时从行取表单值(凭证字段给空串) */
fromRow: (row: Row) => FormValues;
/** 表单值 → 请求体;mode=edit 时凭证空串应省略 */
toBody: (v: FormValues, mode: "create" | "edit") => unknown;
createSchema: z.ZodType;
updateSchema: z.ZodType;
/** 行内额外动作(测试连接等) */
rowActions?: (row: Row) => ReactNode;
emptyHint?: string;
/** 删除确认的额外说明 */
deleteHint?: string;
/** 编辑抽屉里表单下方的附加内容(如该实例的路径映射) */
drawerExtra?: (row: Row) => ReactNode;
};
type Hooks<Row> = {
list: UseQueryResult<CursorPage<Row>>;
create: UseMutationResult<Row, Error, never>;
update: UseMutationResult<Row, Error, { id: string; body: never }>;
remove: UseMutationResult<unknown, Error, string>;
};
export function ResourceSection<Row extends { id: string }>({
config,
hooks,
}: {
config: ResourceConfig<Row>;
hooks: Hooks<Row>;
}) {
const [editing, setEditing] = useState<Row | "new" | null>(null);
const [deleting, setDeleting] = useState<Row | null>(null);
const { list, remove } = hooks;
const columns: DataColumn<Row>[] = [
...config.columns,
{
key: "__actions",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (row) => (
// biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,避免行内按钮触发整行打开抽屉
<div
className="flex items-center justify-end gap-1"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{config.rowActions?.(row)}
<Button variant="ghost" size="icon-sm" aria-label={`编辑${config.noun}`} onClick={() => setEditing(row)}>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`删除${config.noun}`}
className="hover:text-destructive"
onClick={() => setDeleting(row)}
>
<Trash2 />
</Button>
</div>
),
},
];
return (
<div>
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
{config.description ? (
<p className="max-w-2xl text-caption text-muted-foreground">{config.description}</p>
) : (
<span />
)}
<Button size="sm" onClick={() => setEditing("new")}>
<Plus />
添加{config.noun}
</Button>
</div>
<DataTable
columns={columns}
rows={list.data?.items}
rowKey={(r) => r.id}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
onRowClick={(r) => setEditing(r)}
empty={{ title: `还没有${config.noun}`, description: config.emptyHint }}
/>
<ResourceFormDrawer
key={editing === "new" ? "new" : (editing?.id ?? "none")}
config={config}
hooks={hooks}
row={editing}
onClose={() => setEditing(null)}
/>
<ConfirmDialog
open={!!deleting}
onOpenChange={(o) => !o && setDeleting(null)}
title={`删除${config.noun}?`}
description={config.deleteHint ?? "删除后无法恢复。"}
confirmLabel={`删除${config.noun}`}
destructive
onConfirm={async () => {
if (!deleting) return;
try {
await remove.mutateAsync(deleting.id);
toast.success(`已删除${config.noun}`);
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</div>
);
}
function ResourceFormDrawer<Row extends { id: string }>({
config,
hooks,
row,
onClose,
}: {
config: ResourceConfig<Row>;
hooks: Hooks<Row>;
row: Row | "new" | null;
onClose: () => void;
}) {
const mode = row === "new" ? "create" : "edit";
const [values, setValues] = useState<FormValues>(() =>
row && row !== "new" ? config.fromRow(row) : { ...config.initial },
);
const [errors, setErrors] = useState<Record<string, string>>({});
const pending = hooks.create.isPending || hooks.update.isPending;
const set = (name: string, v: FieldValue) => setValues((s) => ({ ...s, [name]: v }));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const body = config.toBody(values, mode);
const parsed = (mode === "create" ? config.createSchema : config.updateSchema).safeParse(body);
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
// 顶层 refine 错误(path 为空)挂到第一个字段下
if (fe[""]) {
fe[config.fields[0]?.name ?? ""] ??= fe[""];
}
setErrors(fe);
revealFirstError();
return;
}
setErrors({});
try {
if (mode === "create") await hooks.create.mutateAsync(parsed.data as never);
else if (row && row !== "new") await hooks.update.mutateAsync({ id: row.id, body: parsed.data as never });
toast.success(mode === "create" ? `已添加${config.noun}` : `已保存${config.noun}`);
onClose();
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
const formId = `resource-form-${config.noun}`;
return (
<DetailSheet
open={!!row}
onOpenChange={(o) => !o && !pending && onClose()}
title={`${mode === "create" ? "添加" : "编辑"}${config.noun}`}
description="凭证只写入,保存后不回显。"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={pending}>
取消
</Button>
<Button type="submit" form={formId} disabled={pending}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</>
}
>
<form id={formId} onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
{config.fields
.filter((f) => !f.visible || f.visible(values))
.map((f) => (
<FieldControl
key={f.name}
spec={f}
value={values[f.name]}
error={errors[f.name]}
onChange={(v) => set(f.name, v)}
disabled={pending || (mode === "edit" && !!f.createOnly)}
hint={mode === "edit" && f.editHint ? f.editHint : f.hint}
/>
))}
</form>
{row && row !== "new" && config.drawerExtra ? config.drawerExtra(row) : null}
</DetailSheet>
);
}
export function FieldControl({
spec,
value,
error,
onChange,
disabled,
hint,
}: {
spec: FieldSpec;
value: FieldValue | undefined;
error?: string;
onChange: (v: FieldValue) => void;
disabled?: boolean;
hint?: string;
}) {
const id = `f-${spec.name}`;
const wide = spec.wide || spec.kind === "textarea";
if (spec.kind === "switch") {
return (
<div className={wide ? "sm:col-span-2" : undefined}>
<label htmlFor={id} className="flex min-h-9 items-center justify-between gap-3 text-caption font-medium">
{spec.label}
<Switch id={id} checked={value === true} onCheckedChange={onChange} disabled={disabled} />
</label>
<p className="min-h-4 text-meta text-muted-foreground">{hint ?? ""}</p>
</div>
);
}
let control: ReactNode;
if (spec.kind === "select") {
control = (
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} className="w-full" aria-invalid={!!error}>
<SelectValue placeholder={spec.placeholder ?? "选择"} />
</SelectTrigger>
<SelectContent position="popper">
{(spec.options ?? []).map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (spec.kind === "textarea") {
control = (
<Textarea
id={id}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
rows={3}
/>
);
} else {
control = (
<Input
id={id}
type={
spec.kind === "password"
? "password"
: spec.kind === "number"
? "number"
: spec.kind === "url"
? "url"
: "text"
}
autoComplete={spec.kind === "password" ? "new-password" : "off"}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
/>
);
}
return (
<FormField
label={spec.label}
htmlFor={id}
error={error}
hint={hint}
required={spec.required}
className={wide ? "sm:col-span-2" : undefined}
>
{control}
</FormField>
);
}
/** 表单值工具 */
export const str = (v: FieldValue | undefined) => (typeof v === "string" ? v.trim() : "");
export const optStr = (v: FieldValue | undefined) => str(v) || undefined;
export const nullStr = (v: FieldValue | undefined) => str(v) || null;
export const num = (v: FieldValue | undefined) => (str(v) === "" ? undefined : Number(str(v)));