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 执行。