Files
rootandClaude Opus 5.5 21c55dfb3a feat(web): 记录抽屉与两层导航标准
控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?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
2026-10-08 14:03:47 +08:00

470 lines
15 KiB
TypeScript
Raw Permalink 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 { 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)));