diff --git a/CHANGELOG.md b/CHANGELOG.md index 711f6e8..3fc4ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - 任务列表勾选多个任务后可批量修改分类,目标分类支持「未分类」以将任务移出当前分类;整批写入在一个事务内完成,失败项只会是勾选后已被删除的任务。 +#### 修改的功能 + +- 新建任务的「任务分类」由下拉选择改为可搜索输入框:输入即过滤已有分类,库里没有的名称回车(或点下拉里的「创建」)即时新建并选中,选中项以可移除的 tag 展示;仍限单个分类。 + #### 修复的问题 - 任务列表改为按任务 id 降序(最新创建在最前),替换原先的按创建时间排序。同一时刻创建的多个任务时间戳相同、排序不稳定,配合每 10 秒轮询和内存 map 的随机遍历,会导致它们在列表里频繁互换位置。 diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 26bb14f..caf4ce8 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -655,7 +655,12 @@ export default function TasksPage() { )} - + {tasks.length === 0 ? ( @@ -1384,6 +1389,130 @@ function SourceTaskPicker({ ); } +// CategoryPicker 是新建任务里的单选分类选择器:可搜索已有分类;输入库里没有的名称后 +// 回车(或点下拉里的「创建」)即时新建分类并选中,选中项以可移除的 tag 展示。分类是 +// 全局资源,这里即时创建与「分类管理」里手动新建等价。只允许一个分类。 +function CategoryPicker({ + categories, + value, + onValueChange, + onCategoryCreated, + portalContainer, +}: { + categories: TaskCategory[]; + value?: number; + onValueChange: (categoryID?: number) => void; + onCategoryCreated: () => void; + portalContainer?: React.RefObject; +}) { + const [inputValue, setInputValue] = React.useState(""); + const [creating, setCreating] = React.useState(false); + // 新建的分类要等父层重新拉取才回流到 categories,先本地留一份,避免选中的 chip 和 + // 下拉在这段窗口里显示成「未知分类」。 + const [localExtra, setLocalExtra] = React.useState([]); + + const allCategories = React.useMemo(() => { + const byID = new Map(); + for (const category of categories) byID.set(category.id, category); + for (const category of localExtra) if (!byID.has(category.id)) byID.set(category.id, category); + return [...byID.values()]; + }, [categories, localExtra]); + + const byID = React.useMemo(() => new Map(allCategories.map((c) => [String(c.id), c])), [allCategories]); + const categoryIDs = React.useMemo(() => allCategories.map((c) => String(c.id)), [allCategories]); + const selectedIDs = value != null ? [String(value)] : []; + + const trimmed = inputValue.trim(); + const lower = trimmed.toLowerCase(); + // 与 base-ui 默认子串过滤保持一致,用来判断「有没有相关分类」。 + const matchCount = trimmed + ? allCategories.filter((c) => c.name.toLowerCase().includes(lower)).length + : allCategories.length; + + const createAndSelect = async () => { + if (!trimmed || creating) return; + // 精确同名已存在则直接选中,不重复创建。 + const existing = allCategories.find((c) => c.name.toLowerCase() === lower); + if (existing) { + onValueChange(existing.id); + setInputValue(""); + return; + } + setCreating(true); + try { + const created = await api.createTaskCategory(trimmed); + setLocalExtra((prev) => [...prev, created]); + onValueChange(created.id); + setInputValue(""); + onCategoryCreated(); + toast.success(`已创建分类「${created.name}」`); + } catch (e) { + toast.error(`创建分类失败:${(e as Error).message}`); + } finally { + setCreating(false); + } + }; + + return ( + byID.get(id)?.name ?? id} + multiple + value={selectedIDs} + onValueChange={(next: string[]) => { + // 单选:取最新选中的一个;移除 chip(清空)则回到未分类。 + const last = next[next.length - 1]; + onValueChange(last ? Number(last) : undefined); + setInputValue(""); + }} + inputValue={inputValue} + onInputValueChange={setInputValue} + > + + + {selectedIDs.map((id) => ( + {byID.get(id)?.name ?? "未知分类"} + ))} + + { + // 完全无匹配时回车 = 创建;有匹配项时保留 base-ui 的「回车选中高亮项」。 + if (e.key === "Enter" && matchCount === 0 && trimmed) { + e.preventDefault(); + void createAndSelect(); + } + }} + /> + + + + {(id: string) => ( + + {byID.get(id)?.name ?? id} + + )} + + {matchCount === 0 && + (trimmed ? ( + + ) : ( +
输入名称以搜索或创建分类
+ ))} +
+
+ ); +} + const COMPANY_SCOPE_LABELS: Record = { domain: "域名", ip: "IP", @@ -1647,14 +1776,16 @@ function CreateTaskSheet({ tasks, categories, onCreated, + onCategoriesChanged, }: { tasks: Task[]; categories: TaskCategory[]; onCreated: () => void; + onCategoriesChanged: () => void; }) { const [open, setOpen] = React.useState(false); const [name, setName] = React.useState(""); - const [categoryID, setCategoryID] = React.useState(UNCATEGORIZED_VALUE); + const [categoryID, setCategoryID] = React.useState(undefined); const [description, setDescription] = React.useState(""); const [goal, setGoal] = React.useState(""); const [selectedTemplateID, setSelectedTemplateID] = React.useState(null); @@ -1724,7 +1855,7 @@ function CreateTaskSheet({ const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致 await api.createTask({ name: name.trim(), - categoryId: categoryID === UNCATEGORIZED_VALUE ? undefined : Number(categoryID), + categoryId: categoryID, description: description.trim(), goal: goal.trim(), llmProfileIds: llmProfileIDs.map(Number), @@ -1737,7 +1868,7 @@ function CreateTaskSheet({ }); toast.success("任务已创建"); setName(""); - setCategoryID(UNCATEGORIZED_VALUE); + setCategoryID(undefined); setDescription(""); setGoal(""); setSelectedTemplateID(null); @@ -1803,22 +1934,14 @@ function CreateTaskSheet({ 任务分类 - - 用于任务列表筛选和归档,不影响 Agent 执行。 + + 可选,单个分类;用于任务列表筛选和归档,不影响 Agent 执行。