feat(web): naming template form for global, library root and work overrides (FE-3)
- template editor per movie / tv / variety with variable insertion, live preview by segment role, error line:col and reset to default - settings > library: global templates, lockFileNameForBound and per-root overrides - work detail: naming override dialog (preview uses the existing path binding) Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b908b4a0c9
commit
2c6a89f309
7 files changed
+993
-1
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
EntityNamingOverridePut,
|
||||
LibraryRootNamingPut,
|
||||
type NamingOverride,
|
||||
type NamingPreviewInput,
|
||||
NamingPreviewResult,
|
||||
NamingSettings,
|
||||
NamingTemplatesView,
|
||||
NamingVariablesResponse,
|
||||
} from "@umt/contracts";
|
||||
import { api } from "~/lib/api.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { getParsed, libraryKeys, parseOrThrow } from "./library.ts";
|
||||
|
||||
// 整理命名模板(W9-C):变量表、三级模板(全局 > 库根 > 作品)、预览与保存。
|
||||
|
||||
export const namingKeys = {
|
||||
all: ["naming"] as const,
|
||||
variables: ["naming", "variables"] as const,
|
||||
templates: (q: NamingScope) => ["naming", "templates", q] as const,
|
||||
preview: (input: NamingPreviewInput) => ["naming", "preview", input] as const,
|
||||
};
|
||||
|
||||
export type NamingScope = { libraryRootId?: string; entityId?: string };
|
||||
|
||||
export function useNamingVariables() {
|
||||
return useQuery({
|
||||
queryKey: namingKeys.variables,
|
||||
queryFn: ({ signal }) => getParsed(NamingVariablesResponse, "/naming/variables", undefined, signal),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 三级模板 + MP 默认 + 按类型的生效模板与来源 */
|
||||
export function useNamingTemplates(scope: NamingScope = {}, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: namingKeys.templates(scope),
|
||||
enabled,
|
||||
queryFn: ({ signal }) => getParsed(NamingTemplatesView, "/naming/templates", scope, signal),
|
||||
});
|
||||
}
|
||||
|
||||
/** 预览:input 为 null 时不请求;调用方负责防抖 */
|
||||
export function useNamingPreview(input: NamingPreviewInput | null) {
|
||||
return useQuery({
|
||||
queryKey: namingKeys.preview(input ?? { kind: "movie" }),
|
||||
enabled: !!input,
|
||||
placeholderData: keepPreviousData,
|
||||
retry: false,
|
||||
queryFn: async () =>
|
||||
parseOrThrow(NamingPreviewResult, await api.post<unknown>("/naming/preview", input), "/naming/preview"),
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidate() {
|
||||
const qc = useQueryClient();
|
||||
return () => {
|
||||
void qc.invalidateQueries({ queryKey: namingKeys.all });
|
||||
void qc.invalidateQueries({ queryKey: qk.settings });
|
||||
void qc.invalidateQueries({ queryKey: libraryKeys.roots });
|
||||
};
|
||||
}
|
||||
|
||||
/** 保存全局模板(逐条编译,有错 422,消息带 "movie: 行:列 …") */
|
||||
export function useSaveGlobalNaming() {
|
||||
const done = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<NamingSettings>) =>
|
||||
api.put<unknown>("/naming/templates", body).then((d) => parseOrThrow(NamingSettings, d, "/naming/templates")),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
|
||||
/** 库根覆盖;null = 清除覆盖 */
|
||||
export function useSaveRootNaming() {
|
||||
const done = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, naming }: { id: string; naming: NamingOverride | null }) =>
|
||||
api
|
||||
.put<unknown>(`/library-roots/${id}/naming`, { naming })
|
||||
.then((d) => parseOrThrow(LibraryRootNamingPut, d, "/library-roots/naming")),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
|
||||
/** 作品命名覆盖;null = 清除覆盖 */
|
||||
export function useSaveEntityNaming() {
|
||||
const done = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, namingOverride }: { id: string; namingOverride: NamingOverride | null }) =>
|
||||
api
|
||||
.put<unknown>(`/media-entities/${id}/naming-override`, { namingOverride })
|
||||
.then((d) => parseOrThrow(EntityNamingOverridePut, d, "/media-entities/naming-override")),
|
||||
onSuccess: done,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { MediaKind, NamingKind } from "@umt/contracts";
|
||||
import { FileSignature, Save, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useNamingTemplates, useSaveEntityNaming } from "~/api/naming.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { overrideBody, SOURCE_LABEL, splitNamingErrors } from "./model.ts";
|
||||
import { NAMING_NOTE, NamingTemplateEditor } from "./template-editor.tsx";
|
||||
|
||||
// 作品详情 → 命名覆盖:只编辑该作品类型对应的模板;留空沿用库根 / 全局。
|
||||
|
||||
export function namingKindOf(kind: MediaKind): NamingKind {
|
||||
if (kind === "series") return "tv";
|
||||
if (kind === "variety") return "variety";
|
||||
return "movie";
|
||||
}
|
||||
|
||||
/** 作品头部按钮 + 对话框(自带开关状态,挂载处只放一行) */
|
||||
export function EntityNamingButton({ entityId, kind }: { entityId: string; kind: MediaKind }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<FileSignature />
|
||||
命名覆盖
|
||||
</Button>
|
||||
{open ? (
|
||||
<EntityNamingDialog entityId={entityId} kind={namingKindOf(kind)} onClose={() => setOpen(false)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityNamingDialog({ entityId, kind, onClose }: { entityId: string; kind: NamingKind; onClose: () => void }) {
|
||||
const q = useNamingTemplates({ entityId });
|
||||
const save = useSaveEntityNaming();
|
||||
const [draft, setDraft] = useState<Partial<Record<NamingKind, string>> | null>(null);
|
||||
const [errors, setErrors] = useState<Partial<Record<NamingKind, string>> | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (q.data && !draft) setDraft({ ...(q.data.entity ?? {}) });
|
||||
}, [q.data, draft]);
|
||||
|
||||
const saved = q.data?.entity ?? {};
|
||||
const inheritedTemplate = q.data ? (q.data.root?.[kind] ?? q.data.global[kind]) : "";
|
||||
const inheritedSource = q.data?.root?.[kind] ? SOURCE_LABEL.root : SOURCE_LABEL.global;
|
||||
const dirty = !!draft && (draft[kind] ?? "") !== (saved[kind] ?? "");
|
||||
|
||||
const put = async (clear: boolean) => {
|
||||
if (!draft) return;
|
||||
setErrors(undefined);
|
||||
// 只改本类型,其它类型的已有覆盖原样保留
|
||||
const next = clear ? { ...saved, [kind]: "" } : { ...saved, [kind]: draft[kind] ?? "" };
|
||||
const body = overrideBody(next);
|
||||
try {
|
||||
await save.mutateAsync({ id: entityId, namingOverride: body });
|
||||
setDraft({ ...(body ?? {}) });
|
||||
toast.success(clear ? "已清除作品命名覆盖" : "作品命名覆盖已保存");
|
||||
} catch (err) {
|
||||
const split = splitNamingErrors(errorMessage(err));
|
||||
if (split) setErrors(split);
|
||||
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>命名覆盖</DialogTitle>
|
||||
<DialogDescription>{NAMING_NOTE}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{q.isError ? (
|
||||
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
||||
) : q.isPending || !draft ? (
|
||||
<LoadingState rows={4} />
|
||||
) : (
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={saved[kind] ? "success" : "neutral"}>
|
||||
当前:{saved[kind] ? "作品覆盖" : `沿用${inheritedSource}`}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<NamingTemplateEditor
|
||||
kinds={[kind]}
|
||||
values={draft}
|
||||
onChange={(k, v) => {
|
||||
setDraft((d) => ({ ...d, [k]: v }));
|
||||
setErrors(undefined);
|
||||
}}
|
||||
defaults={q.data.defaults}
|
||||
inherited={{ [kind]: { template: inheritedTemplate, source: inheritedSource } }}
|
||||
entityId={entityId}
|
||||
saveErrors={errors}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:text-destructive"
|
||||
disabled={!saved[kind] || save.isPending}
|
||||
onClick={() => put(true)}
|
||||
>
|
||||
<Trash2 />
|
||||
清除覆盖
|
||||
</Button>
|
||||
<Button onClick={() => put(false)} disabled={!dirty || save.isPending}>
|
||||
<Save />
|
||||
{save.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { NamingKind, NamingOverride } from "@umt/contracts";
|
||||
|
||||
// 命名模板表单的纯函数:保存错误拆分、覆盖请求体、来源文案。
|
||||
|
||||
export const NAMING_KINDS: NamingKind[] = ["movie", "tv", "variety"];
|
||||
export const SOURCE_LABEL = { global: "全局", root: "库根", entity: "作品" } as const;
|
||||
|
||||
/** 422 消息 "movie: 1:3 …; tv: …" → 按类型拆开,行列号改写成中文 */
|
||||
export function splitNamingErrors(message: string): Partial<Record<NamingKind, string>> | null {
|
||||
const out: Partial<Record<NamingKind, string>> = {};
|
||||
for (const m of message.matchAll(/(movie|tv|variety):\s*([^;]+)/g)) {
|
||||
const [, k, msg] = m;
|
||||
const pos = /^(\d+):(\d+)\s*(.*)$/.exec((msg ?? "").trim());
|
||||
out[k as NamingKind] = pos ? `第 ${pos[1]} 行第 ${pos[2]} 列:${pos[3]}` : (msg ?? "").trim();
|
||||
}
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** 覆盖草稿 → 请求体;全部留空 = null(清除覆盖) */
|
||||
export function overrideBody(draft: Partial<Record<NamingKind, string>>): NamingOverride | null {
|
||||
const o: NamingOverride = {};
|
||||
for (const k of NAMING_KINDS) {
|
||||
const v = draft[k];
|
||||
if (v?.trim()) o[k] = v;
|
||||
}
|
||||
return Object.keys(o).length ? o : null;
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
import type { NamingKind, NamingPreviewResult, NamingVariablesResponse } from "@umt/contracts";
|
||||
import { Braces, ChevronRight, RotateCcw, Search, Undo2 } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNamingPreview, useNamingVariables } from "~/api/naming.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.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 { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// 命名模板编辑器(设置全局 / 库根覆盖 / 作品覆盖共用):按类型分段,变量点击插入,实时预览带段角色与错误位置。
|
||||
|
||||
export const NAMING_KIND_LABEL: Record<NamingKind, string> = { movie: "电影", tv: "剧集", variety: "综艺" };
|
||||
|
||||
const GROUP_LABEL: Record<NamingVariablesResponse["items"][number]["group"], string> = {
|
||||
title: "标题",
|
||||
episode: "季集",
|
||||
tech: "技术参数",
|
||||
id: "外部身份",
|
||||
ours: "其他",
|
||||
};
|
||||
const GROUP_ORDER = ["title", "episode", "tech", "id", "ours"] as const;
|
||||
|
||||
const ROLE: Record<NamingPreviewResult["segments"][number]["role"], { label: string; className: string }> = {
|
||||
category: { label: "分类", className: "text-muted-foreground" },
|
||||
root: { label: "作品根", className: "text-primary font-medium" },
|
||||
season: { label: "季目录", className: "text-foreground" },
|
||||
sub: { label: "子目录", className: "text-muted-foreground" },
|
||||
file: { label: "文件名", className: "text-foreground font-medium" },
|
||||
};
|
||||
|
||||
export const NAMING_NOTE = "已建立目录的作品不受模板影响,模板只决定新目录和新文件名。";
|
||||
|
||||
function useDebounced<T>(v: T, ms = 300): T {
|
||||
const [d, setD] = useState(v);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setD(v), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [v, ms]);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** 1 起的行列号 → 字符偏移 */
|
||||
function offsetOf(text: string, line: number, col: number): number {
|
||||
const lines = text.split("\n");
|
||||
let off = 0;
|
||||
for (let i = 0; i < Math.min(line - 1, lines.length); i++) off += (lines[i]?.length ?? 0) + 1;
|
||||
return Math.min(text.length, off + Math.max(0, col - 1));
|
||||
}
|
||||
|
||||
export type NamingEditorProps = {
|
||||
kinds: NamingKind[];
|
||||
values: Partial<Record<NamingKind, string>>;
|
||||
onChange: (kind: NamingKind, value: string) => void;
|
||||
/** MP 默认(恢复默认) */
|
||||
defaults: Record<NamingKind, string>;
|
||||
/** 覆盖模式:留空沿用的上级模板与来源说明;不传 = 全局模式(必填) */
|
||||
inherited?: Partial<Record<NamingKind, { template: string; source: string }>>;
|
||||
/** 预览上下文 */
|
||||
libraryRootId?: string;
|
||||
entityId?: string;
|
||||
/** 保存失败时服务端给的逐类型错误 */
|
||||
saveErrors?: Partial<Record<NamingKind, string>>;
|
||||
/** 各类型是否与已保存值不同 */
|
||||
dirtyKinds?: Partial<Record<NamingKind, boolean>>;
|
||||
};
|
||||
|
||||
export function NamingTemplateEditor({
|
||||
kinds,
|
||||
values,
|
||||
onChange,
|
||||
defaults,
|
||||
inherited,
|
||||
libraryRootId,
|
||||
entityId,
|
||||
saveErrors,
|
||||
dirtyKinds,
|
||||
}: NamingEditorProps) {
|
||||
const [kind, setKind] = useState<NamingKind>(kinds[0] ?? "movie");
|
||||
const active = kinds.includes(kind) ? kind : (kinds[0] ?? "movie");
|
||||
const vars = useNamingVariables();
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const override = !!inherited;
|
||||
const value = values[active] ?? "";
|
||||
const effective = value.trim() ? value : (inherited?.[active]?.template ?? "");
|
||||
|
||||
const insert = (token: string) => {
|
||||
const el = ref.current;
|
||||
const start = el?.selectionStart ?? value.length;
|
||||
const end = el?.selectionEnd ?? value.length;
|
||||
const next = value.slice(0, start) + token + value.slice(end);
|
||||
onChange(active, next);
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(start + token.length, start + token.length);
|
||||
});
|
||||
};
|
||||
|
||||
const jumpTo = (line: number, col: number) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const off = offsetOf(el.value, line, col);
|
||||
el.focus();
|
||||
el.setSelectionRange(off, Math.min(el.value.length, off + 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{kinds.length > 1 ? (
|
||||
<Tabs value={active} onValueChange={(v) => setKind(v as NamingKind)}>
|
||||
<TabsList aria-label="作品类型">
|
||||
{kinds.map((k) => (
|
||||
<TabsTrigger key={k} value={k}>
|
||||
{NAMING_KIND_LABEL[k]}
|
||||
{dirtyKinds?.[k] ? (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warning">
|
||||
<span className="sr-only">未保存</span>
|
||||
</span>
|
||||
) : null}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : (
|
||||
<span className="text-body font-medium text-foreground">{NAMING_KIND_LABEL[active]}模板</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{override ? (
|
||||
<Button type="button" variant="ghost" size="sm" disabled={!value} onClick={() => onChange(active, "")}>
|
||||
<Undo2 />
|
||||
沿用上级
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={value === defaults[active]}
|
||||
onClick={() => onChange(active, defaults[active])}
|
||||
>
|
||||
<RotateCcw />
|
||||
恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(e) => onChange(active, e.target.value)}
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
aria-label={`${NAMING_KIND_LABEL[active]}命名模板`}
|
||||
placeholder={override ? (inherited?.[active]?.template ?? "") : "{{title}}/{{title}}{{fileExt}}"}
|
||||
aria-invalid={!!saveErrors?.[active] || undefined}
|
||||
className="max-h-60 min-h-24 font-mono text-caption leading-relaxed break-all field-sizing-content"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-meta text-muted-foreground">
|
||||
<span>
|
||||
{override && !value.trim()
|
||||
? `留空:沿用${inherited?.[active]?.source ?? "上级"}模板`
|
||||
: "/ 分目录,最后一段是文件名,须以 {{fileExt}} 结尾"}
|
||||
</span>
|
||||
<span className="tabular-nums">{value.length} / 1024</span>
|
||||
</div>
|
||||
{saveErrors?.[active] ? <p className="text-caption text-destructive">{saveErrors[active]}</p> : null}
|
||||
</div>
|
||||
|
||||
<NamingPreview
|
||||
kind={active}
|
||||
template={effective}
|
||||
libraryRootId={libraryRootId}
|
||||
entityId={entityId}
|
||||
samples={vars.data?.samples ?? []}
|
||||
onJump={jumpTo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VariablePalette data={vars.data} loading={vars.isPending} error={vars.error} onInsert={insert} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 预览 ──
|
||||
|
||||
const CUSTOM = "custom";
|
||||
|
||||
function NamingPreview({
|
||||
kind,
|
||||
template,
|
||||
libraryRootId,
|
||||
entityId,
|
||||
samples,
|
||||
onJump,
|
||||
}: {
|
||||
kind: NamingKind;
|
||||
template: string;
|
||||
libraryRootId?: string;
|
||||
entityId?: string;
|
||||
samples: NamingVariablesResponse["samples"];
|
||||
onJump: (line: number, col: number) => void;
|
||||
}) {
|
||||
const ofKind = samples.filter((s) => s.kind === kind);
|
||||
const [sample, setSample] = useState<string>("");
|
||||
const [fileName, setFileName] = useState("");
|
||||
// 切换类型后选中该类型的第一条样本
|
||||
const sampleId = sample && (sample === CUSTOM || ofKind.some((s) => String(s.id) === sample)) ? sample : "";
|
||||
const selected = sampleId || (ofKind[0] ? String(ofKind[0].id) : "");
|
||||
|
||||
const input = useMemo(() => {
|
||||
if (!template.trim()) return null;
|
||||
const s =
|
||||
selected === CUSTOM
|
||||
? fileName.trim()
|
||||
? { fileName: fileName.trim() }
|
||||
: undefined
|
||||
: selected
|
||||
? { builtin: Number(selected) }
|
||||
: undefined;
|
||||
return {
|
||||
template,
|
||||
kind,
|
||||
...(s ? { sample: s } : {}),
|
||||
...(libraryRootId ? { libraryRootId } : {}),
|
||||
...(entityId ? { entityId } : {}),
|
||||
};
|
||||
}, [template, kind, selected, fileName, libraryRootId, entityId]);
|
||||
const debounced = useDebounced(input, 300);
|
||||
const q = useNamingPreview(debounced);
|
||||
const r = q.data;
|
||||
const stale = q.isFetching || debounced !== input;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 rounded-xl border border-border/70">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<span className="text-caption font-medium text-foreground">预览</span>
|
||||
<Select value={selected} onValueChange={setSample}>
|
||||
<SelectTrigger size="sm" aria-label="预览样本" className="max-w-full min-w-0 sm:max-w-80">
|
||||
<SelectValue placeholder="选择样本" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{ofKind.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM}>输入文件名</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{stale && input ? <span className="text-meta text-muted-foreground">预览中…</span> : null}
|
||||
</div>
|
||||
<div className="space-y-3 px-3 py-3">
|
||||
{selected === CUSTOM ? (
|
||||
<Input
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
placeholder="Show.Name.S01E02.1080p.WEB-DL.H264-GROUP.mkv"
|
||||
aria-label="样例文件名"
|
||||
className="font-mono text-caption"
|
||||
/>
|
||||
) : null}
|
||||
{!input ? (
|
||||
<p className="text-caption text-muted-foreground">模板为空,没有可预览的内容。</p>
|
||||
) : q.isError && !r ? (
|
||||
<p className="text-caption text-destructive">预览失败:{(q.error as Error).message}</p>
|
||||
) : !r ? (
|
||||
<p className="text-caption text-muted-foreground">预览中…</p>
|
||||
) : (
|
||||
<PreviewBody r={r} template={template} onJump={onJump} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewBody({
|
||||
r,
|
||||
template,
|
||||
onJump,
|
||||
}: {
|
||||
r: NamingPreviewResult;
|
||||
template: string;
|
||||
onJump: (line: number, col: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<p className="text-meta break-all text-muted-foreground">
|
||||
样本:<span className="font-mono">{r.sample.fileName}</span>
|
||||
{r.sample.entityTitle ? ` · 作品 ${r.sample.entityTitle}` : ""}
|
||||
</p>
|
||||
{r.errors.length ? (
|
||||
<ul className="space-y-2">
|
||||
{r.errors.map((e, i) => (
|
||||
<li key={`${e.code}-${i}`}>
|
||||
<ErrorExcerpt template={template} line={e.line} col={e.col} message={e.message} onJump={onJump} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-1 gap-y-2 font-mono text-caption">
|
||||
{r.segments.map((s, i) => (
|
||||
<span key={`${i}-${s.text}`} className="flex min-w-0 items-start gap-1">
|
||||
{i > 0 ? <span className="text-muted-foreground">/</span> : null}
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className={cn("break-all", ROLE[s.role].className)}>{s.text}</span>
|
||||
<span className="font-sans text-micro text-muted-foreground">{ROLE[s.role].label}</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{r.usesBinding ? <Note>这部作品已有目录,目录沿用已建立的路径,模板只决定文件名。</Note> : null}
|
||||
{r.missing.length ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
无值的变量(输出为空):<span className="font-mono">{r.missing.join("、")}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{r.warnings.map((w) => (
|
||||
<p key={w} className="text-meta text-warning">
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Note({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="rounded-lg border border-border/70 bg-foreground/[0.03] px-3 py-2 text-caption text-foreground">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** 出错行摘录,出错位置之后的一个词加下划线;点击跳到模板里对应位置 */
|
||||
function ErrorExcerpt({
|
||||
template,
|
||||
line,
|
||||
col,
|
||||
message,
|
||||
onJump,
|
||||
}: {
|
||||
template: string;
|
||||
line: number;
|
||||
col: number;
|
||||
message: string;
|
||||
onJump: (line: number, col: number) => void;
|
||||
}) {
|
||||
const text = template.split("\n")[line - 1] ?? "";
|
||||
const at = Math.max(0, Math.min(text.length, col - 1));
|
||||
const m = /^[\w.]+|^./.exec(text.slice(at));
|
||||
const len = m ? m[0].length : 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(line, col)}
|
||||
className="block w-full min-w-0 cursor-pointer rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-left transition-colors duration-micro hover:bg-destructive/15"
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone="destructive">
|
||||
第 {line} 行第 {col} 列
|
||||
</StatusBadge>
|
||||
<span className="text-caption break-words text-destructive">{message}</span>
|
||||
</span>
|
||||
{text ? (
|
||||
<span className="mt-1.5 block font-mono text-meta break-all text-foreground">
|
||||
{text.slice(Math.max(0, at - 40), at)}
|
||||
<mark className="rounded-sm bg-destructive/20 text-destructive underline decoration-wavy underline-offset-4">
|
||||
{text.slice(at, at + len) || " "}
|
||||
</mark>
|
||||
{text.slice(at + len, at + len + 40)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 变量清单 ──
|
||||
|
||||
function VariablePalette({
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
onInsert,
|
||||
}: {
|
||||
data: NamingVariablesResponse | undefined;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
onInsert: (token: string) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const term = q.trim().toLowerCase();
|
||||
const items = (data?.items ?? []).filter(
|
||||
(v) => !term || v.name.toLowerCase().includes(term) || v.label.toLowerCase().includes(term),
|
||||
);
|
||||
return (
|
||||
<aside className="min-w-0 space-y-3 rounded-xl border border-border/70 px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Braces className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-caption font-medium text-foreground">变量</span>
|
||||
<span className="text-meta text-muted-foreground">点击插入到光标处</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="搜索变量"
|
||||
aria-label="搜索变量"
|
||||
className="h-8 pl-8 text-caption"
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-meta text-muted-foreground">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="text-meta text-destructive">变量表加载失败:{(error as Error).message}</p>
|
||||
) : (
|
||||
<div className="max-h-96 space-y-3 overflow-y-auto pr-1">
|
||||
{GROUP_ORDER.map((g) => {
|
||||
const list = items.filter((v) => v.group === g);
|
||||
if (!list.length) return null;
|
||||
return (
|
||||
<div key={g}>
|
||||
<p className="mb-1 text-micro font-semibold tracking-[0.12em] text-muted-foreground uppercase">
|
||||
{GROUP_LABEL[g]}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{list.map((v) => (
|
||||
<li key={v.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInsert(`{{${v.name}}}`)}
|
||||
title={`插入 {{${v.name}}}`}
|
||||
className="block w-full min-w-0 cursor-pointer rounded-md px-2 py-1 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:bg-foreground/[0.06] focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-mono text-caption text-foreground">{v.name}</span>
|
||||
{v.ours ? <span className="text-micro text-muted-foreground">MP 无</span> : null}
|
||||
{v.example ? (
|
||||
<span className="ml-auto max-w-full truncate font-mono text-meta text-muted-foreground">
|
||||
{v.example}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="block text-meta text-muted-foreground">{v.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 ? <p className="text-meta text-muted-foreground">没有匹配的变量</p> : null}
|
||||
</div>
|
||||
)}
|
||||
{data?.filters.length ? (
|
||||
<div className="border-t border-border/60 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={filtersOpen}
|
||||
onClick={() => setFiltersOpen((o) => !o)}
|
||||
className="flex w-full cursor-pointer items-center gap-1 text-caption font-medium text-foreground"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-3.5 w-3.5 transition-transform duration-micro", filtersOpen && "rotate-90")}
|
||||
/>
|
||||
过滤器与语法
|
||||
</button>
|
||||
{filtersOpen ? (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
<li className="text-meta text-muted-foreground">
|
||||
<span className="font-mono text-foreground">{"{% if 变量 %}…{% endif %}"}</span> 条件;
|
||||
<span className="font-mono text-foreground">{"{{ a or b }}"}</span> 取第一个有值的
|
||||
</li>
|
||||
{data.filters.map((f) => (
|
||||
<li key={f.name} className="text-meta text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInsert(` | ${f.name}`)}
|
||||
className="cursor-pointer font-mono text-foreground hover:underline"
|
||||
title="插入到光标处"
|
||||
>
|
||||
{f.name}
|
||||
</button>{" "}
|
||||
{f.doc}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { LibraryRootsTable } from "./instances.tsx";
|
||||
import { NamingSection } from "./naming.tsx";
|
||||
import { str } from "./resource-section.tsx";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
|
||||
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式
|
||||
// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式 + 命名模板
|
||||
|
||||
const ORGANIZE_CARD: CardSpec = {
|
||||
anchor: "organize",
|
||||
@@ -45,6 +46,7 @@ export function LibrarySection() {
|
||||
<LibraryRootsTable />
|
||||
</SettingsBlock>
|
||||
<SettingsGate>{(s) => <SettingsCard section="library" spec={ORGANIZE_CARD} settings={s} />}</SettingsGate>
|
||||
<NamingSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { LibraryRoot, NamingKind, NamingOverride, NamingTemplatesView } from "@umt/contracts";
|
||||
import { Save, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useLibraryRoots } from "~/api/library.ts";
|
||||
import { useNamingTemplates, useSaveGlobalNaming, useSaveRootNaming } from "~/api/naming.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { NAMING_KINDS, overrideBody, SOURCE_LABEL, splitNamingErrors } from "~/components/naming/model.ts";
|
||||
import { NAMING_KIND_LABEL, NAMING_NOTE, NamingTemplateEditor } from "~/components/naming/template-editor.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
// 设置 → 媒体库与目录 → 命名模板:全局模板 + 库根覆盖(作品覆盖在作品详情)。
|
||||
|
||||
const KINDS = NAMING_KINDS;
|
||||
|
||||
export function NamingSection() {
|
||||
return (
|
||||
<>
|
||||
<GlobalNaming />
|
||||
<RootNaming />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalNaming() {
|
||||
const q = useNamingTemplates();
|
||||
const save = useSaveGlobalNaming();
|
||||
const [draft, setDraft] = useState<Record<NamingKind, string> | null>(null);
|
||||
const [lock, setLock] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<Record<NamingKind, string>> | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (q.data && !draft) {
|
||||
setDraft({ movie: q.data.global.movie, tv: q.data.global.tv, variety: q.data.global.variety });
|
||||
setLock(q.data.global.lockFileNameForBound);
|
||||
}
|
||||
}, [q.data, draft]);
|
||||
|
||||
const saved = q.data?.global;
|
||||
const dirtyKinds = Object.fromEntries(KINDS.map((k) => [k, !!saved && !!draft && draft[k] !== saved[k]]));
|
||||
const dirty = KINDS.some((k) => dirtyKinds[k]) || (!!saved && lock !== saved.lockFileNameForBound);
|
||||
|
||||
const onSave = async () => {
|
||||
if (!draft) return;
|
||||
setErrors(undefined);
|
||||
try {
|
||||
const r = await save.mutateAsync({ ...draft, lockFileNameForBound: lock });
|
||||
setDraft({ movie: r.movie, tv: r.tv, variety: r.variety });
|
||||
setLock(r.lockFileNameForBound);
|
||||
toast.success("命名模板已保存");
|
||||
} catch (err) {
|
||||
const split = splitNamingErrors(errorMessage(err));
|
||||
if (split) setErrors(split);
|
||||
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="library"
|
||||
anchor="naming"
|
||||
title="命名模板"
|
||||
description={NAMING_NOTE}
|
||||
dirty={dirty}
|
||||
actions={
|
||||
<Button size="sm" onClick={onSave} disabled={!dirty || save.isPending || !draft}>
|
||||
<Save />
|
||||
{save.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!q.data || !draft ? (
|
||||
q.isError ? (
|
||||
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
||||
) : (
|
||||
<LoadingState rows={4} />
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<NamingTemplateEditor
|
||||
kinds={KINDS}
|
||||
values={draft}
|
||||
onChange={(k, v) => {
|
||||
setDraft((d) => (d ? { ...d, [k]: v } : d));
|
||||
setErrors((e) => (e?.[k] ? { ...e, [k]: undefined } : e));
|
||||
}}
|
||||
defaults={q.data.defaults}
|
||||
saveErrors={errors}
|
||||
dirtyKinds={dirtyKinds}
|
||||
/>
|
||||
<label
|
||||
htmlFor="naming-lock-filename"
|
||||
className="flex cursor-pointer items-start justify-between gap-4 border-t border-border/60 pt-4"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-body text-foreground">已有目录的作品固定文件名</span>
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
开启后,这些作品的新文件按建立目录时的模板版本命名
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
id="naming-lock-filename"
|
||||
checked={lock}
|
||||
onCheckedChange={setLock}
|
||||
aria-label="已有目录的作品固定文件名"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function RootNaming() {
|
||||
const roots = useLibraryRoots();
|
||||
const [rootId, setRootId] = useState("");
|
||||
const list = roots.data?.items ?? [];
|
||||
const root = list.find((r) => r.id === rootId) ?? list[0];
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="library"
|
||||
anchor="naming-roots"
|
||||
title="库根命名覆盖"
|
||||
description="按类型覆盖全局模板;留空的类型沿用全局。"
|
||||
>
|
||||
{roots.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : roots.isError ? (
|
||||
<ErrorState plain error={roots.error} onRetry={() => roots.refetch()} />
|
||||
) : !root ? (
|
||||
<p className="text-caption text-muted-foreground">还没有库根。先在上方「库根」添加。</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={root.id} onValueChange={setRootId}>
|
||||
<SelectTrigger size="sm" aria-label="库根" className="max-w-full min-w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{list.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
{r.naming && Object.keys(r.naming).length ? "(已覆盖)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="min-w-0 truncate font-mono text-meta text-muted-foreground">{root.localPath}</span>
|
||||
</div>
|
||||
<RootNamingEditor key={root.id} root={root} />
|
||||
</div>
|
||||
)}
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function RootNamingEditor({ root }: { root: LibraryRoot }) {
|
||||
const q = useNamingTemplates({ libraryRootId: root.id });
|
||||
const save = useSaveRootNaming();
|
||||
const [draft, setDraft] = useState<Partial<Record<NamingKind, string>> | null>(null);
|
||||
const [errors, setErrors] = useState<Partial<Record<NamingKind, string>> | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (q.data && !draft) setDraft({ ...(q.data.root ?? {}) });
|
||||
}, [q.data, draft]);
|
||||
|
||||
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
|
||||
if (q.isPending || !draft) return <LoadingState rows={3} />;
|
||||
const view: NamingTemplatesView = q.data;
|
||||
const saved = view.root ?? {};
|
||||
const dirtyKinds = Object.fromEntries(KINDS.map((k) => [k, (draft[k] ?? "") !== (saved[k] ?? "")]));
|
||||
const dirty = KINDS.some((k) => dirtyKinds[k]);
|
||||
const hasOverride = !!view.root && Object.keys(view.root).length > 0;
|
||||
|
||||
const put = async (body: NamingOverride | null) => {
|
||||
setErrors(undefined);
|
||||
try {
|
||||
await save.mutateAsync({ id: root.id, naming: body });
|
||||
setDraft({ ...(body ?? {}) });
|
||||
toast.success(body ? "库根命名覆盖已保存" : "已清除库根命名覆盖");
|
||||
} catch (err) {
|
||||
const split = splitNamingErrors(errorMessage(err));
|
||||
if (split) setErrors(split);
|
||||
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{KINDS.map((k) => (
|
||||
<StatusBadge key={k} tone={saved[k] ? "success" : "neutral"}>
|
||||
{NAMING_KIND_LABEL[k]}:{saved[k] ? "库根覆盖" : "沿用全局"}
|
||||
</StatusBadge>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover:text-destructive"
|
||||
disabled={!hasOverride || save.isPending}
|
||||
onClick={() => put(null)}
|
||||
>
|
||||
<Trash2 />
|
||||
清除覆盖
|
||||
</Button>
|
||||
<Button size="sm" disabled={!dirty || save.isPending} onClick={() => put(overrideBody(draft))}>
|
||||
<Save />
|
||||
{save.isPending ? "保存中…" : "保存覆盖"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<NamingTemplateEditor
|
||||
kinds={KINDS}
|
||||
values={draft}
|
||||
onChange={(k, v) => {
|
||||
setDraft((d) => ({ ...d, [k]: v }));
|
||||
setErrors((e) => (e?.[k] ? { ...e, [k]: undefined } : e));
|
||||
}}
|
||||
defaults={view.defaults}
|
||||
inherited={Object.fromEntries(KINDS.map((k) => [k, { template: view.global[k], source: SOURCE_LABEL.global }]))}
|
||||
libraryRootId={root.id}
|
||||
saveErrors={errors}
|
||||
dirtyKinds={dirtyKinds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
} from "~/components/library/labels.ts";
|
||||
import { Chip, ChipList, CoverPlaceholder, Panel, ProgressLine, relTime, Tone } from "~/components/library/parts.tsx";
|
||||
import { SlotCard } from "~/components/library/slot-card.tsx";
|
||||
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
@@ -644,6 +645,7 @@ export default function LibraryDetailPage() {
|
||||
合并作品
|
||||
</Button>
|
||||
) : null}
|
||||
<EntityNamingButton entityId={e.id} kind={e.kind} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in new issue
Block a user