460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
|
|
import { LibraryIcon, PlusIcon, SaveIcon, Settings2Icon, Trash2Icon } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Combobox,
|
|
ComboboxContent,
|
|
ComboboxEmpty,
|
|
ComboboxInput,
|
|
ComboboxItem,
|
|
ComboboxList,
|
|
} from "@/components/ui/combobox";
|
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
|
import { Input } from "@/components/ui/input";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { api } from "@/lib/api";
|
|
import type { TaskTemplate } from "@/lib/types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface TemplateDraft {
|
|
name: string;
|
|
description: string;
|
|
goal: string;
|
|
}
|
|
|
|
interface TaskTemplateManagerProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
templates: TaskTemplate[];
|
|
seed: Pick<TemplateDraft, "description" | "goal"> | null;
|
|
onCreated: (template: TaskTemplate) => void;
|
|
onUpdated: (template: TaskTemplate) => void;
|
|
onDeleted: (id: number) => void;
|
|
}
|
|
|
|
const emptyDraft = (): TemplateDraft => ({ name: "", description: "", goal: "" });
|
|
|
|
function templateDraft(template: TaskTemplate): TemplateDraft {
|
|
return {
|
|
name: template.name,
|
|
description: template.description,
|
|
goal: template.goal,
|
|
};
|
|
}
|
|
|
|
function TaskTemplateManager({
|
|
open,
|
|
onOpenChange,
|
|
templates,
|
|
seed,
|
|
onCreated,
|
|
onUpdated,
|
|
onDeleted,
|
|
}: TaskTemplateManagerProps) {
|
|
const [selectedID, setSelectedID] = React.useState<number | null>(null);
|
|
const [draft, setDraft] = React.useState<TemplateDraft>(emptyDraft);
|
|
const [saving, setSaving] = React.useState(false);
|
|
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
|
const [deleting, setDeleting] = React.useState(false);
|
|
const wasOpen = React.useRef(false);
|
|
|
|
React.useEffect(() => {
|
|
if (open && !wasOpen.current) {
|
|
if (seed) {
|
|
setSelectedID(null);
|
|
setDraft({ name: "", description: seed.description, goal: seed.goal });
|
|
} else if (templates[0]) {
|
|
setSelectedID(templates[0].id);
|
|
setDraft(templateDraft(templates[0]));
|
|
} else {
|
|
setSelectedID(null);
|
|
setDraft(emptyDraft());
|
|
}
|
|
}
|
|
wasOpen.current = open;
|
|
}, [open, seed, templates]);
|
|
|
|
const selectTemplate = (template: TaskTemplate) => {
|
|
setSelectedID(template.id);
|
|
setDraft(templateDraft(template));
|
|
};
|
|
|
|
const startNew = () => {
|
|
setSelectedID(null);
|
|
setDraft(emptyDraft());
|
|
};
|
|
|
|
const updateDraft = (field: keyof TemplateDraft, value: string) => {
|
|
setDraft((current) => ({ ...current, [field]: value }));
|
|
};
|
|
|
|
async function save() {
|
|
const input = {
|
|
name: draft.name.trim(),
|
|
description: draft.description.trim(),
|
|
goal: draft.goal.trim(),
|
|
};
|
|
if (!input.name || !input.description || !input.goal) {
|
|
toast.error("请填写模板名称、描述和目标");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
if (selectedID == null) {
|
|
const created = await api.createTaskTemplate(input);
|
|
onCreated(created);
|
|
setSelectedID(created.id);
|
|
setDraft(templateDraft(created));
|
|
toast.success("模板已创建");
|
|
} else {
|
|
const updated = await api.updateTaskTemplate(selectedID, input);
|
|
onUpdated(updated);
|
|
setDraft(templateDraft(updated));
|
|
toast.success("模板已更新");
|
|
}
|
|
} catch (error) {
|
|
toast.error(`保存失败:${(error as Error).message}`);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function remove() {
|
|
if (selectedID == null) return;
|
|
const deletedID = selectedID;
|
|
setDeleting(true);
|
|
try {
|
|
await api.deleteTaskTemplate(deletedID);
|
|
onDeleted(deletedID);
|
|
const next = templates.find((template) => template.id !== deletedID);
|
|
if (next) {
|
|
setSelectedID(next.id);
|
|
setDraft(templateDraft(next));
|
|
} else {
|
|
startNew();
|
|
}
|
|
setDeleteOpen(false);
|
|
toast.success("模板已删除");
|
|
} catch (error) {
|
|
toast.error(`删除失败:${(error as Error).message}`);
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
}
|
|
|
|
let saveLabel = saving ? "保存中" : "保存修改";
|
|
if (!saving && selectedID == null) saveLabel = "创建模板";
|
|
|
|
return (
|
|
<>
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
|
<SheetHeader className="border-b px-6 py-5">
|
|
<SheetTitle>任务模板管理</SheetTitle>
|
|
<SheetDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
|
|
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
|
|
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
|
|
<PlusIcon data-icon="inline-start" />
|
|
新建模板
|
|
</Button>
|
|
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
|
|
<div className="flex flex-col gap-1 pr-2">
|
|
{templates.length === 0 && (
|
|
<p className="px-2 py-6 text-center text-muted-foreground text-sm">暂无模板</p>
|
|
)}
|
|
{templates.map((template) => (
|
|
<button
|
|
key={template.id}
|
|
type="button"
|
|
className={cn(
|
|
"min-w-0 rounded-md px-2.5 py-2 text-left transition-colors",
|
|
selectedID === template.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
|
)}
|
|
onClick={() => selectTemplate(template)}
|
|
>
|
|
<span className="block truncate font-medium text-sm">{template.name}</span>
|
|
<span className="block truncate text-muted-foreground text-xs">{template.description}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
<ScrollArea className="min-h-0">
|
|
<FieldGroup className="p-6">
|
|
<Field>
|
|
<FieldLabel htmlFor="task-template-name">模板名称</FieldLabel>
|
|
<Input
|
|
id="task-template-name"
|
|
value={draft.name}
|
|
maxLength={120}
|
|
placeholder="例如:外部 Web 渗透"
|
|
onChange={(event) => updateDraft("name", event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="task-template-description">描述</FieldLabel>
|
|
<Textarea
|
|
id="task-template-description"
|
|
className="min-h-28"
|
|
value={draft.description}
|
|
placeholder="测试对象与背景"
|
|
onChange={(event) => updateDraft("description", event.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="task-template-goal">目标</FieldLabel>
|
|
<Textarea
|
|
id="task-template-goal"
|
|
className="min-h-28"
|
|
value={draft.goal}
|
|
placeholder="任务需要达成的目标"
|
|
onChange={(event) => updateDraft("goal", event.target.value)}
|
|
/>
|
|
</Field>
|
|
</FieldGroup>
|
|
</ScrollArea>
|
|
</div>
|
|
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
|
|
{selectedID != null && (
|
|
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={() => setDeleteOpen(true)}>
|
|
<Trash2Icon data-icon="inline-start" />
|
|
删除模板
|
|
</Button>
|
|
)}
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
关闭
|
|
</Button>
|
|
<Button type="button" disabled={saving} onClick={() => void save()}>
|
|
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
|
|
{saveLabel}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>删除模板「{draft.name || "未命名模板"}」?</AlertDialogTitle>
|
|
<AlertDialogDescription>已由该模板创建的任务不会受到影响。</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
disabled={deleting}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
void remove();
|
|
}}
|
|
>
|
|
{deleting && <Spinner data-icon="inline-start" />}
|
|
{deleting ? "删除中" : "删除"}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface TaskTemplateControlsProps {
|
|
description: string;
|
|
goal: string;
|
|
selectedTemplateID: number | null;
|
|
onSelectedTemplateIDChange: (id: number | null) => void;
|
|
onApply: (template: TaskTemplate) => void;
|
|
portalContainer?: React.RefObject<HTMLElement | null>;
|
|
}
|
|
|
|
export function TaskTemplateControls({
|
|
description,
|
|
goal,
|
|
selectedTemplateID,
|
|
onSelectedTemplateIDChange,
|
|
onApply,
|
|
portalContainer,
|
|
}: TaskTemplateControlsProps) {
|
|
const [templates, setTemplates] = React.useState<TaskTemplate[]>([]);
|
|
const [loading, setLoading] = React.useState(true);
|
|
const [templateInputValue, setTemplateInputValue] = React.useState("");
|
|
const [pendingTemplate, setPendingTemplate] = React.useState<TaskTemplate | null>(null);
|
|
const [managerOpen, setManagerOpen] = React.useState(false);
|
|
const [managerSeed, setManagerSeed] = React.useState<Pick<TemplateDraft, "description" | "goal"> | null>(null);
|
|
|
|
const loadTemplates = React.useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
setTemplates(await api.taskTemplates());
|
|
} catch (error) {
|
|
setTemplates([]);
|
|
toast.error(`加载模板失败:${(error as Error).message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
void loadTemplates();
|
|
}, [loadTemplates]);
|
|
|
|
const selectedTemplate = React.useMemo(
|
|
() => templates.find((template) => template.id === selectedTemplateID) ?? null,
|
|
[selectedTemplateID, templates],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
setTemplateInputValue(selectedTemplate?.name ?? "");
|
|
}, [selectedTemplate]);
|
|
|
|
const applyTemplate = (template: TaskTemplate) => {
|
|
onApply(template);
|
|
onSelectedTemplateIDChange(template.id);
|
|
setPendingTemplate(null);
|
|
};
|
|
|
|
const chooseTemplate = (template: TaskTemplate | null) => {
|
|
if (!template) {
|
|
setTemplateInputValue("");
|
|
onSelectedTemplateIDChange(null);
|
|
return;
|
|
}
|
|
setTemplateInputValue(template.name);
|
|
const hasContent = description.trim() !== "" || goal.trim() !== "";
|
|
const changesContent = description !== template.description || goal !== template.goal;
|
|
if (hasContent && changesContent) {
|
|
setPendingTemplate(template);
|
|
return;
|
|
}
|
|
applyTemplate(template);
|
|
};
|
|
|
|
const openManager = (seed: Pick<TemplateDraft, "description" | "goal"> | null) => {
|
|
setManagerSeed(seed);
|
|
setManagerOpen(true);
|
|
};
|
|
|
|
const upsertTemplate = (template: TaskTemplate) => {
|
|
setTemplates((current) => [template, ...current.filter((item) => item.id !== template.id)]);
|
|
};
|
|
|
|
const deleteTemplate = (id: number) => {
|
|
setTemplates((current) => current.filter((template) => template.id !== id));
|
|
if (selectedTemplateID === id) onSelectedTemplateIDChange(null);
|
|
};
|
|
|
|
let pickerPlaceholder = loading ? "正在加载模板" : "暂无任务模板";
|
|
if (!loading && templates.length > 0) pickerPlaceholder = "搜索并选择任务模板";
|
|
|
|
return (
|
|
<>
|
|
<Field>
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<FieldLabel htmlFor="task-template-picker">任务模板</FieldLabel>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => openManager(null)}>
|
|
<Settings2Icon data-icon="inline-start" />
|
|
管理模板
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={!description.trim() || !goal.trim()}
|
|
onClick={() => openManager({ description, goal })}
|
|
>
|
|
<SaveIcon data-icon="inline-start" />
|
|
另存为模板
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<Combobox
|
|
items={templates}
|
|
itemToStringLabel={(template) => template.name}
|
|
itemToStringValue={(template) => String(template.id)}
|
|
inputValue={templateInputValue}
|
|
onInputValueChange={(value) => setTemplateInputValue(value)}
|
|
value={selectedTemplate}
|
|
onValueChange={chooseTemplate}
|
|
>
|
|
<ComboboxInput
|
|
id="task-template-picker"
|
|
className="w-full"
|
|
placeholder={pickerPlaceholder}
|
|
disabled={loading || templates.length === 0}
|
|
showClear
|
|
/>
|
|
<ComboboxContent portalContainer={portalContainer}>
|
|
<ComboboxEmpty>没有匹配的模板</ComboboxEmpty>
|
|
<ComboboxList>
|
|
{(template) => (
|
|
<ComboboxItem key={template.id} value={template}>
|
|
<LibraryIcon />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-medium text-sm">{template.name}</p>
|
|
{template.description && (
|
|
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
|
|
)}
|
|
</div>
|
|
</ComboboxItem>
|
|
)}
|
|
</ComboboxList>
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
<FieldDescription>选择后会复制模板的描述和目标,不与模板保持关联。</FieldDescription>
|
|
</Field>
|
|
|
|
<AlertDialog
|
|
open={pendingTemplate != null}
|
|
onOpenChange={(open) => {
|
|
if (open) return;
|
|
setPendingTemplate(null);
|
|
setTemplateInputValue(selectedTemplate?.name ?? "");
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>使用模板「{pendingTemplate?.name}」?</AlertDialogTitle>
|
|
<AlertDialogDescription>当前已填写的描述和目标将被模板内容覆盖。</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => pendingTemplate && applyTemplate(pendingTemplate)}>
|
|
覆盖并使用
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<TaskTemplateManager
|
|
open={managerOpen}
|
|
onOpenChange={setManagerOpen}
|
|
templates={templates}
|
|
seed={managerSeed}
|
|
onCreated={upsertTemplate}
|
|
onUpdated={upsertTemplate}
|
|
onDeleted={deleteTemplate}
|
|
/>
|
|
</>
|
|
);
|
|
}
|