控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。
- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
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 { useRecordSheet } from "~/components/console/record-sheet.tsx";
|
||
import { SecretInput } from "~/components/console/secret-input.tsx";
|
||
import { UnitInput } from "~/components/console/unit-input.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";
|
||
import { secretHint } from "~/lib/secret.ts";
|
||
import { type TokenRange, tokenInputError, tokenRangeText } from "~/lib/tokens.ts";
|
||
import { cn } from "~/lib/utils.ts";
|
||
|
||
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。
|
||
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写(kind=secret,值约定见 ~/lib/secret.ts):
|
||
// 留空不变、SECRET_CLEAR 清除;token 数字段(kind=tokens)以 K 输入,toBody 里用 kToTokens 换回原始值。
|
||
|
||
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" | "tokens" | "secret";
|
||
options?: { value: string; label: string }[];
|
||
placeholder?: string;
|
||
hint?: string;
|
||
/** 编辑时的说明(如"留空表示不修改") */
|
||
editHint?: string;
|
||
required?: boolean;
|
||
/** 编辑时不可改 */
|
||
createOnly?: boolean;
|
||
/** 条件显示 */
|
||
visible?: (v: FormValues) => boolean;
|
||
wide?: boolean;
|
||
/** kind=tokens:以 K 为单位输入(表单值是 K 字符串),范围按原始 token 数 */
|
||
tokens?: TokenRange;
|
||
/** kind=secret:只写凭证。set/mask 从表单值(fromRow / fromSettings 放进去的隐藏键)或闭包取 */
|
||
secret?: {
|
||
set: (v: FormValues) => boolean;
|
||
mask?: (v: FormValues) => string | null | undefined;
|
||
clearable?: boolean;
|
||
multiline?: boolean;
|
||
};
|
||
};
|
||
|
||
/** kind=tokens 字段先在本地校验(错误用 K 表述),通过后再走 zod */
|
||
export function localFieldErrors(fields: FieldSpec[], values: FormValues): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
for (const f of fields) {
|
||
if (f.visible && !f.visible(values)) continue;
|
||
if (f.kind === "tokens") {
|
||
const e = tokenInputError(str(values[f.name]), { ...f.tokens, required: f.tokens?.required ?? f.required });
|
||
if (e) out[f.name] = e;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 字段说明:凭证字段已设置时补"留空不修改",token 字段缺省给范围 */
|
||
export function fieldHint(spec: FieldSpec, value: FieldValue | undefined, values: FormValues, hint?: string) {
|
||
if (spec.kind === "secret") return secretHint(hint, value, spec.secret?.set(values) ?? false);
|
||
if (spec.kind === "tokens" && hint === undefined && spec.tokens) return tokenRangeText(spec.tokens) ?? undefined;
|
||
return hint;
|
||
}
|
||
|
||
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 [deleting, setDeleting] = useState<Row | null>(null);
|
||
const { list, remove } = hooks;
|
||
// 记录抽屉(console-ia.md §9):编辑哪一个写在 ?open=<id>(同页几个列表各认自己的 id);新建只在本页状态里
|
||
const sheet = useRecordSheet();
|
||
const [creating, setCreating] = useState(false);
|
||
const found = sheet.id ? (list.data?.items.find((r) => r.id === sheet.id) ?? null) : null;
|
||
const editing: Row | "new" | null = creating ? "new" : found;
|
||
const setEditing = (row: Row | "new") => (row === "new" ? setCreating(true) : sheet.open(row.id));
|
||
|
||
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" className="ml-auto" 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={() => (creating ? setCreating(false) : sheet.close())}
|
||
/>
|
||
<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 local = localFieldErrors(config.fields, values);
|
||
if (Object.keys(local).length) {
|
||
setErrors(local);
|
||
revealFirstError();
|
||
return;
|
||
}
|
||
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}
|
||
values={values}
|
||
/>
|
||
))}
|
||
</form>
|
||
{row && row !== "new" && config.drawerExtra ? config.drawerExtra(row) : null}
|
||
</DetailSheet>
|
||
);
|
||
}
|
||
|
||
export function FieldControl({
|
||
spec,
|
||
value,
|
||
error,
|
||
onChange,
|
||
disabled,
|
||
hint,
|
||
values,
|
||
}: {
|
||
spec: FieldSpec;
|
||
value: FieldValue | undefined;
|
||
error?: string;
|
||
onChange: (v: FieldValue) => void;
|
||
disabled?: boolean;
|
||
hint?: string;
|
||
/** 整个表单的值(凭证字段据此判断是否已设置);单独使用时可省略 */
|
||
values?: FormValues;
|
||
}) {
|
||
const id = `f-${spec.name}`;
|
||
const all = values ?? {};
|
||
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>
|
||
);
|
||
}
|
||
return (
|
||
<FormField
|
||
label={spec.label}
|
||
htmlFor={id}
|
||
error={error}
|
||
hint={fieldHint(spec, value, all, hint)}
|
||
required={spec.required}
|
||
className={wide ? "sm:col-span-2" : undefined}
|
||
>
|
||
<FieldInput
|
||
id={id}
|
||
spec={spec}
|
||
value={value}
|
||
values={all}
|
||
error={error}
|
||
onChange={onChange}
|
||
disabled={disabled}
|
||
/>
|
||
</FormField>
|
||
);
|
||
}
|
||
|
||
/** 字段的控件本体(不含标签与说明);开关也在这里 */
|
||
export function FieldInput({
|
||
id,
|
||
spec,
|
||
value,
|
||
error,
|
||
onChange,
|
||
disabled,
|
||
className,
|
||
values,
|
||
}: {
|
||
id: string;
|
||
spec: FieldSpec;
|
||
value: FieldValue | undefined;
|
||
error?: string;
|
||
onChange: (v: FieldValue) => void;
|
||
disabled?: boolean;
|
||
className?: string;
|
||
values?: FormValues;
|
||
}) {
|
||
if (spec.kind === "secret") {
|
||
const all = values ?? {};
|
||
return (
|
||
<SecretInput
|
||
id={id}
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={onChange}
|
||
isSet={spec.secret?.set(all) ?? false}
|
||
mask={spec.secret?.mask?.(all)}
|
||
clearable={spec.secret?.clearable}
|
||
multiline={spec.secret?.multiline}
|
||
label={spec.label}
|
||
placeholder={spec.placeholder}
|
||
disabled={disabled}
|
||
invalid={!!error}
|
||
className={className}
|
||
/>
|
||
);
|
||
}
|
||
if (spec.kind === "tokens") {
|
||
return (
|
||
<UnitInput
|
||
id={id}
|
||
unit="K"
|
||
value={String(value ?? "")}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={spec.placeholder}
|
||
disabled={disabled}
|
||
aria-invalid={!!error}
|
||
className={className}
|
||
/>
|
||
);
|
||
}
|
||
if (spec.kind === "switch") {
|
||
return (
|
||
<Switch
|
||
id={id}
|
||
checked={value === true}
|
||
onCheckedChange={onChange}
|
||
disabled={disabled}
|
||
className={className}
|
||
aria-invalid={!!error}
|
||
/>
|
||
);
|
||
}
|
||
if (spec.kind === "select") {
|
||
return (
|
||
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
|
||
<SelectTrigger id={id} className={cn("w-full", className)} 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>
|
||
);
|
||
}
|
||
if (spec.kind === "textarea") {
|
||
return (
|
||
<Textarea
|
||
id={id}
|
||
value={String(value ?? "")}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={spec.placeholder}
|
||
disabled={disabled}
|
||
aria-invalid={!!error}
|
||
rows={3}
|
||
className={className}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<Input
|
||
id={id}
|
||
type={
|
||
spec.kind === "password" ? "password" : spec.kind === "number" ? "number" : spec.kind === "url" ? "url" : "text"
|
||
}
|
||
inputMode={spec.kind === "number" ? "decimal" : undefined}
|
||
autoComplete={spec.kind === "password" ? "new-password" : "off"}
|
||
value={String(value ?? "")}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={spec.placeholder}
|
||
disabled={disabled}
|
||
aria-invalid={!!error}
|
||
className={className}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/** 表单值工具 */
|
||
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)));
|