抽屉优化
This commit is contained in:
1 parent
7234874797
commit
d968727460
14 files changed
+669
-154
No files matched your search
@@ -5,15 +5,27 @@ import * as React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
EyeIcon,
|
||||
FolderKanbanIcon,
|
||||
GripVerticalIcon,
|
||||
Loader2Icon,
|
||||
PaperclipIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
SlidersHorizontalIcon,
|
||||
StarIcon,
|
||||
@@ -63,6 +75,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
@@ -73,7 +86,9 @@ import {
|
||||
FieldSet,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -100,6 +115,7 @@ import type {
|
||||
TaskCategory,
|
||||
TaskStatus,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// fmtBytes renders a human file size for the upload manifest.
|
||||
function fmtBytes(n: number): string {
|
||||
@@ -343,6 +359,20 @@ export default function TasksPage() {
|
||||
load();
|
||||
}, [load, loadCategories]);
|
||||
|
||||
const applyTaskCategoryMove = React.useCallback(
|
||||
(taskID: string, category: TaskCategory | null) => {
|
||||
setTasks((current) => {
|
||||
const next = current.map((task) =>
|
||||
task.id === taskID ? { ...task, category_id: category?.id, category_name: category?.name } : task,
|
||||
);
|
||||
lastRef.current = JSON.stringify(next);
|
||||
return next;
|
||||
});
|
||||
loadCategories();
|
||||
},
|
||||
[loadCategories],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (categoryFilter === "all" || categoryFilter === "uncategorized") return;
|
||||
if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all");
|
||||
@@ -602,7 +632,12 @@ export default function TasksPage() {
|
||||
</>
|
||||
)}
|
||||
<ConcurrencySettingsDialog />
|
||||
<CategoryManagementSheet categories={categories} onChanged={refreshCategoriesAndTasks} />
|
||||
<CategoryManagementSheet
|
||||
categories={categories}
|
||||
tasks={tasks}
|
||||
onChanged={refreshCategoriesAndTasks}
|
||||
onTaskMoved={applyTaskCategoryMove}
|
||||
/>
|
||||
<CreateTaskSheet tasks={tasks} categories={categories} onCreated={refreshCategoriesAndTasks} />
|
||||
</div>
|
||||
|
||||
@@ -1341,62 +1376,258 @@ function CompanyPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryManagementSheet({ categories, onChanged }: { categories: TaskCategory[]; onChanged: () => void }) {
|
||||
type CategoryManagementView = number | "uncategorized" | "new";
|
||||
|
||||
function CategoryDropTarget({
|
||||
value,
|
||||
name,
|
||||
count,
|
||||
selected,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: {
|
||||
value: string;
|
||||
name: string;
|
||||
count: number;
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { isOver, setNodeRef } = useDroppable({ id: `category:${value}`, disabled });
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
"min-w-0 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors",
|
||||
selected ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||
isOver && "border-primary bg-primary/10 text-foreground",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="block truncate font-medium text-sm">{name}</span>
|
||||
<span className="block truncate text-muted-foreground text-xs">{isOver ? "松开以移动" : `${count} 个任务`}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableCategoryTask({ task, disabled, moving }: { task: Task; disabled: boolean; moving: boolean }) {
|
||||
const { attributes, isDragging, listeners, setNodeRef } = useDraggable({
|
||||
id: `task:${task.id}`,
|
||||
disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<Item ref={setNodeRef} variant="outline" size="sm" className={cn(isDragging && "opacity-40")}>
|
||||
<ItemMedia className="group-has-data-[slot=item-description]/item:self-center group-has-data-[slot=item-description]/item:translate-y-0">
|
||||
{moving ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="touch-none cursor-grab active:cursor-grabbing"
|
||||
disabled={disabled}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
aria-label={`拖动任务 #${task.id}`}
|
||||
title="拖动任务"
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
</Button>
|
||||
)}
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="w-full min-w-0">
|
||||
<Link href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`} className="truncate hover:underline">
|
||||
{task.name?.trim() || task.description || `任务 #${task.id}`}
|
||||
</Link>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-1">
|
||||
#{task.id} · {task.description}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<StatusBadge domain="task" value={task.status} />
|
||||
</ItemActions>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryTaskDragPreview({ task }: { task: Task }) {
|
||||
return (
|
||||
<Item variant="outline" size="sm" className="w-80 bg-background shadow-lg">
|
||||
<ItemMedia className="group-has-data-[slot=item-description]/item:self-center group-has-data-[slot=item-description]/item:translate-y-0">
|
||||
<GripVerticalIcon className="size-4 text-muted-foreground" />
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="w-full min-w-0 truncate">
|
||||
{task.name?.trim() || task.description || `任务 #${task.id}`}
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-1">#{task.id}</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryManagementSheet({
|
||||
categories,
|
||||
tasks,
|
||||
onChanged,
|
||||
onTaskMoved,
|
||||
}: {
|
||||
categories: TaskCategory[];
|
||||
tasks: Task[];
|
||||
onChanged: () => void;
|
||||
onTaskMoved: (taskID: string, category: TaskCategory | null) => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [newName, setNewName] = React.useState("");
|
||||
const [editingID, setEditingID] = React.useState<number | null>(null);
|
||||
const [editingName, setEditingName] = React.useState("");
|
||||
const [selectedView, setSelectedView] = React.useState<CategoryManagementView>("new");
|
||||
const [draftName, setDraftName] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [deleting, setDeleting] = React.useState<TaskCategory | null>(null);
|
||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const [movingTaskID, setMovingTaskID] = React.useState<string | null>(null);
|
||||
const [activeTaskID, setActiveTaskID] = React.useState<string | null>(null);
|
||||
const wasOpen = React.useRef(false);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
async function createCategory() {
|
||||
const name = newName.trim();
|
||||
if (!name || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.createTaskCategory(name);
|
||||
setNewName("");
|
||||
toast.success("分类已创建");
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(`创建分类失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
React.useEffect(() => {
|
||||
if (open && !wasOpen.current) {
|
||||
const first = categories[0];
|
||||
if (first) {
|
||||
setSelectedView(first.id);
|
||||
setDraftName(first.name);
|
||||
} else if (tasks.some((task) => task.category_id == null)) {
|
||||
setSelectedView("uncategorized");
|
||||
setDraftName("");
|
||||
} else {
|
||||
setSelectedView("new");
|
||||
setDraftName("");
|
||||
}
|
||||
}
|
||||
}
|
||||
wasOpen.current = open;
|
||||
}, [categories, open, tasks]);
|
||||
|
||||
async function renameCategory() {
|
||||
const name = editingName.trim();
|
||||
if (!editingID || !name || saving) return;
|
||||
const selectedCategory = React.useMemo(
|
||||
() =>
|
||||
typeof selectedView === "number" ? (categories.find((category) => category.id === selectedView) ?? null) : null,
|
||||
[categories, selectedView],
|
||||
);
|
||||
|
||||
const uncategorizedCount = React.useMemo(() => tasks.filter((task) => task.category_id == null).length, [tasks]);
|
||||
|
||||
const visibleTasks = React.useMemo(() => {
|
||||
if (selectedView === "uncategorized") return tasks.filter((task) => task.category_id == null);
|
||||
if (typeof selectedView === "number") return tasks.filter((task) => task.category_id === selectedView);
|
||||
return [];
|
||||
}, [selectedView, tasks]);
|
||||
|
||||
const activeTask = React.useMemo(() => tasks.find((task) => task.id === activeTaskID) ?? null, [activeTaskID, tasks]);
|
||||
|
||||
const selectCategory = (category: TaskCategory) => {
|
||||
setSelectedView(category.id);
|
||||
setDraftName(category.name);
|
||||
};
|
||||
|
||||
const selectUncategorized = () => {
|
||||
setSelectedView("uncategorized");
|
||||
setDraftName("");
|
||||
};
|
||||
|
||||
const startNew = () => {
|
||||
setSelectedView("new");
|
||||
setDraftName("");
|
||||
};
|
||||
|
||||
async function saveCategory() {
|
||||
const name = draftName.trim();
|
||||
if (!name || saving || selectedView === "uncategorized") return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.renameTaskCategory(editingID, name);
|
||||
setEditingID(null);
|
||||
setEditingName("");
|
||||
toast.success("分类已更新");
|
||||
if (selectedView === "new") {
|
||||
const created = await api.createTaskCategory(name);
|
||||
setSelectedView(created.id);
|
||||
setDraftName(created.name);
|
||||
toast.success("分类已创建");
|
||||
} else {
|
||||
const updated = await api.renameTaskCategory(selectedView, name);
|
||||
setDraftName(updated.name);
|
||||
toast.success("分类已更新");
|
||||
}
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(`更新分类失败:${(error as Error).message}`);
|
||||
toast.error(`${selectedView === "new" ? "创建" : "更新"}分类失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCategory() {
|
||||
if (!deleting || saving) return;
|
||||
setSaving(true);
|
||||
if (!selectedCategory || deleting) return;
|
||||
const deletedID = selectedCategory.id;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteTaskCategory(deleting.id);
|
||||
await api.deleteTaskCategory(deletedID);
|
||||
const next = categories.find((category) => category.id !== deletedID);
|
||||
if (next) selectCategory(next);
|
||||
else selectUncategorized();
|
||||
toast.success("分类已删除,关联任务已移入未分类");
|
||||
setDeleting(null);
|
||||
setDeleteOpen(false);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(`删除分类失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveTask(task: Task, destination: string) {
|
||||
if (movingTaskID) return;
|
||||
const category =
|
||||
destination === "uncategorized" ? null : (categories.find((item) => item.id === Number(destination)) ?? null);
|
||||
if (destination !== "uncategorized" && !category) {
|
||||
toast.error("目标分类不存在,请刷新后重试");
|
||||
return;
|
||||
}
|
||||
if (task.category_id === category?.id || (task.category_id == null && category == null)) return;
|
||||
|
||||
setMovingTaskID(task.id);
|
||||
try {
|
||||
await api.updateTaskCategory(task.id, category?.id);
|
||||
onTaskMoved(task.id, category);
|
||||
toast.success(`任务 #${task.id} 已移至「${category?.name ?? "未分类"}」`);
|
||||
} catch (error) {
|
||||
toast.error(`移动任务失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setMovingTaskID(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
const id = String(event.active.id);
|
||||
setActiveTaskID(id.startsWith("task:") ? id.slice("task:".length) : null);
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const activeID = String(event.active.id);
|
||||
const taskID = activeID.startsWith("task:") ? activeID.slice("task:".length) : null;
|
||||
setActiveTaskID(null);
|
||||
if (!taskID || !event.over) return;
|
||||
const destination = String(event.over.id);
|
||||
if (!destination.startsWith("category:")) return;
|
||||
const task = tasks.find((item) => item.id === taskID);
|
||||
if (!task) return;
|
||||
void moveTask(task, destination.slice("category:".length));
|
||||
}
|
||||
|
||||
const saveLabel = saving ? "保存中" : selectedView === "new" ? "创建分类" : "保存修改";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
@@ -1406,108 +1637,156 @@ function CategoryManagementSheet({ categories, onChanged }: { categories: TaskCa
|
||||
分类管理
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>任务分类</SheetTitle>
|
||||
<SheetDescription>创建、重命名或删除全局分类。删除分类不会删除任务。</SheetDescription>
|
||||
<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="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="new-task-category">新分类</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="new-task-category"
|
||||
value={newName}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
placeholder="例如:外网评估"
|
||||
maxLength={80}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void createCategory();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => void createCategory()}
|
||||
disabled={!newName.trim() || saving}
|
||||
aria-label="创建分类"
|
||||
>
|
||||
{saving && editingID == null && deleting == null ? <Spinner /> : <PlusIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-col gap-2">
|
||||
{categories.length === 0 && <p className="py-8 text-center text-sm text-muted-foreground">暂无分类</p>}
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="flex min-w-0 items-center gap-2 rounded-md border p-2">
|
||||
<FolderKanbanIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
{editingID === category.id ? (
|
||||
<Input
|
||||
className="min-w-0 flex-1"
|
||||
value={editingName}
|
||||
maxLength={80}
|
||||
onChange={(event) => setEditingName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void renameCategory();
|
||||
if (event.key === "Escape") setEditingID(null);
|
||||
}}
|
||||
aria-label={`重命名分类 ${category.name}`}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={() => setActiveTaskID(null)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<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">
|
||||
<CategoryDropTarget
|
||||
value="uncategorized"
|
||||
name="未分类"
|
||||
count={uncategorizedCount}
|
||||
selected={selectedView === "uncategorized"}
|
||||
disabled={movingTaskID != null}
|
||||
onSelect={selectUncategorized}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left text-sm font-medium"
|
||||
onClick={() => {
|
||||
setEditingID(category.id);
|
||||
setEditingName(category.name);
|
||||
}}
|
||||
title="点击重命名"
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
{categories.map((category) => (
|
||||
<CategoryDropTarget
|
||||
key={category.id}
|
||||
value={String(category.id)}
|
||||
name={category.name}
|
||||
count={category.task_count}
|
||||
selected={selectedView === category.id}
|
||||
disabled={movingTaskID != null}
|
||||
onSelect={() => selectCategory(category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0">
|
||||
<FieldGroup className="p-6">
|
||||
{selectedView !== "uncategorized" && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-category-name">分类名称</FieldLabel>
|
||||
<Input
|
||||
id="task-category-name"
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
placeholder="例如:外网评估"
|
||||
maxLength={80}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void saveCategory();
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{selectedCategory
|
||||
? `当前有 ${selectedCategory.task_count} 个任务使用该分类。重命名后会同步更新任务列表。`
|
||||
: "创建后可在新建任务和任务列表筛选中使用。"}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{category.task_count} 个任务
|
||||
</span>
|
||||
{editingID === category.id && (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => void renameCategory()}
|
||||
disabled={!editingName.trim() || saving}
|
||||
aria-label="保存分类名称"
|
||||
>
|
||||
{saving ? <Spinner /> : <CheckIcon />}
|
||||
</Button>
|
||||
{selectedView !== "new" && (
|
||||
<Field>
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<FieldLabel>{selectedCategory ? "分类任务" : "未分类任务"}</FieldLabel>
|
||||
<FieldDescription>
|
||||
{selectedCategory
|
||||
? `该分类包含 ${visibleTasks.length} 个任务。`
|
||||
: `当前有 ${visibleTasks.length} 个任务尚未分类。`}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</div>
|
||||
{visibleTasks.length === 0 ? (
|
||||
<Empty className="min-h-36 border">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{selectedCategory ? "该分类暂无任务" : "暂无未分类任务"}</EmptyTitle>
|
||||
<EmptyDescription>此处将在任务归入后显示内容。</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{visibleTasks.map((task) => (
|
||||
<DraggableCategoryTask
|
||||
key={task.id}
|
||||
task={task}
|
||||
moving={movingTaskID === task.id}
|
||||
disabled={movingTaskID != null}
|
||||
/>
|
||||
))}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => setDeleting(category)}
|
||||
disabled={saving}
|
||||
aria-label={`删除分类 ${category.name}`}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</FieldGroup>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>{activeTask ? <CategoryTaskDragPreview task={activeTask} /> : null}</DragOverlay>
|
||||
</DndContext>
|
||||
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
|
||||
{selectedCategory && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="sm:mr-auto"
|
||||
disabled={saving || deleting}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
删除分类
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
{selectedView !== "uncategorized" && (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!draftName.trim() || saving || deleting}
|
||||
onClick={() => void saveCategory()}
|
||||
>
|
||||
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
|
||||
{saveLabel}
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={deleting != null} onOpenChange={(next) => !next && setDeleting(null)}>
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除分类“{deleting?.name}”?</AlertDialogTitle>
|
||||
<AlertDialogTitle>删除分类「{selectedCategory?.name || "未命名分类"}」?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
分类删除后,其中 {deleting?.task_count ?? 0} 个任务会自动移入“未分类”,任务数据不会被删除。
|
||||
分类删除后,其中 {selectedCategory?.task_count ?? 0} 个任务会自动移入“未分类”,任务数据不会被删除。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => void deleteCategory()} disabled={saving}>
|
||||
删除
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void deleteCategory();
|
||||
}}
|
||||
>
|
||||
{deleting && <Spinner data-icon="inline-start" />}
|
||||
{deleting ? "删除中" : "删除"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -415,8 +415,6 @@ function ProfileSheet({
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col gap-0 p-0 data-[side=right]:min-w-[420px] data-[side=right]:sm:max-w-xl"
|
||||
// 表单填到一半时点外面不该丢内容(Esc / ✕ 仍可关闭)。
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<SheetHeader className="px-4">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -313,6 +313,7 @@ function ToolEditor({
|
||||
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="hover:border-primary/50 hover:bg-muted/40 focus-visible:ring-ring flex flex-col gap-2 rounded-lg border p-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
@@ -337,6 +338,13 @@ function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
|
||||
已停用
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-auto px-1.5 py-0 text-[10px] tabular-nums"
|
||||
title={`累计调用 ${tool.calls ?? 0} 次`}
|
||||
>
|
||||
调用 {tool.calls ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">
|
||||
{tool.description || "(无描述)"}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn, radixOverlayWasOpenAtPointerDown } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
@@ -50,7 +50,6 @@ function SheetContent({
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
onInteractOutside,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
@@ -62,18 +61,6 @@ function SheetContent({
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
// 关闭抽屉的唯一条件:点击的是遮罩(灰色背景)本身,且此刻没有任何 Radix 弹层
|
||||
// (Select 下拉等)开着。其余"外部交互"一律挡掉(Esc、右上角 ✕ 仍可关):
|
||||
// · 点弹层里的选项 → target 不是遮罩 → 挡;
|
||||
// · 弹层开着时点抽屉外/遮罩想收起它 → 有弹层开着 → 挡(只收弹层,不关抽屉);
|
||||
// · 弹层收起时焦点移动被 Radix 误判为焦点移出 → target 不是遮罩 → 挡。
|
||||
// (onInteractOutside 在指针/焦点两条路径都会触发。)调用方仍可追加逻辑。
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.detail.originalEvent.target as Element | null
|
||||
const onOverlay = !!target?.closest?.("[data-slot='sheet-overlay']")
|
||||
if (!onOverlay || radixOverlayWasOpenAtPointerDown()) e.preventDefault()
|
||||
onInteractOutside?.(e)
|
||||
}}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className
|
||||
|
||||
@@ -2935,6 +2935,7 @@ export const tools: Tool[] = [
|
||||
agents: ["worker", "mainagent"],
|
||||
enabled: true,
|
||||
kind: "builtin",
|
||||
calls: 1842,
|
||||
},
|
||||
{
|
||||
key: "report_finding",
|
||||
@@ -2953,6 +2954,7 @@ export const tools: Tool[] = [
|
||||
agents: ["worker"],
|
||||
enabled: true,
|
||||
kind: "builtin",
|
||||
calls: 96,
|
||||
},
|
||||
{
|
||||
key: "upsert_asset",
|
||||
@@ -2962,6 +2964,7 @@ export const tools: Tool[] = [
|
||||
agents: ["worker"],
|
||||
enabled: true,
|
||||
kind: "builtin",
|
||||
calls: 417,
|
||||
},
|
||||
{
|
||||
key: "list_goals",
|
||||
@@ -2971,6 +2974,7 @@ export const tools: Tool[] = [
|
||||
agents: ["planner"],
|
||||
enabled: true,
|
||||
kind: "builtin",
|
||||
calls: 238,
|
||||
},
|
||||
{
|
||||
key: "nuclei_scan",
|
||||
@@ -2985,6 +2989,7 @@ export const tools: Tool[] = [
|
||||
enabled: true,
|
||||
kind: "command",
|
||||
exec: { command: "nuclei", args: ["-u", "{{target}}"] },
|
||||
calls: 31,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -935,6 +935,7 @@ export interface Tool {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
|
||||
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
|
||||
calls?: number; // persistent runtime invocation count (older APIs may omit it)
|
||||
}
|
||||
|
||||
// ---- Stats ----
|
||||
|
||||
Reference in new issue
Block a user