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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user