mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat(tasks): 新建任务的分类选择器改为可搜索、可即时新建
原先是普通下拉,只能从已有分类里选。改成可搜索的输入框(复用 base-ui Combobox):输入即过滤已有分类,库里没有的名称回车(或点下拉里的「创建」)会 即时调 createTaskCategory 新建并选中,选中项以可移除的 tag 展示。仍限单个 分类——multiple 仅用于 chip 视觉,onValueChange 只保留最新选中的一个。 - 新建的分类要等父层重新拉取才回流到 categories,组件内先本地留一份 (localExtra),避免选中 chip 和下拉在这段窗口显示成「未知分类」。 - 回车创建仅在完全无匹配时触发,有匹配项时保留 base-ui 的「回车选中高亮项」; 精确同名已存在则直接选中不重复创建。 - 新建成功通过 onCategoriesChanged 通知父层刷新,分类在筛选器等处随即可见。 - 任务列表筛选和批量改分类仍用下拉(选已有分类,无需创建),保持不变。 验证: tsc / biome / next build 均通过。
This commit is contained in:
1 parent
4cf36c7896
commit
6a8e13fcbd
2 files changed
+147
-20
No files matched your search
@@ -10,6 +10,10 @@
|
||||
|
||||
- 任务列表勾选多个任务后可批量修改分类,目标分类支持「未分类」以将任务移出当前分类;整批写入在一个事务内完成,失败项只会是勾选后已被删除的任务。
|
||||
|
||||
#### 修改的功能
|
||||
|
||||
- 新建任务的「任务分类」由下拉选择改为可搜索输入框:输入即过滤已有分类,库里没有的名称回车(或点下拉里的「创建」)即时新建并选中,选中项以可移除的 tag 展示;仍限单个分类。
|
||||
|
||||
#### 修复的问题
|
||||
|
||||
- 任务列表改为按任务 id 降序(最新创建在最前),替换原先的按创建时间排序。同一时刻创建的多个任务时间戳相同、排序不稳定,配合每 10 秒轮询和内存 map 的随机遍历,会导致它们在列表里频繁互换位置。
|
||||
|
||||
@@ -655,7 +655,12 @@ export default function TasksPage() {
|
||||
)}
|
||||
<ConcurrencySettingsDialog />
|
||||
<CategoryManagementSheet categories={categories} onChanged={refreshCategoriesAndTasks} />
|
||||
<CreateTaskSheet tasks={tasks} categories={categories} onCreated={refreshCategoriesAndTasks} />
|
||||
<CreateTaskSheet
|
||||
tasks={tasks}
|
||||
categories={categories}
|
||||
onCreated={refreshCategoriesAndTasks}
|
||||
onCategoriesChanged={loadCategories}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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<HTMLElement | null>;
|
||||
}) {
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
// 新建的分类要等父层重新拉取才回流到 categories,先本地留一份,避免选中的 chip 和
|
||||
// 下拉在这段窗口里显示成「未知分类」。
|
||||
const [localExtra, setLocalExtra] = React.useState<TaskCategory[]>([]);
|
||||
|
||||
const allCategories = React.useMemo(() => {
|
||||
const byID = new Map<number, TaskCategory>();
|
||||
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 (
|
||||
<Combobox
|
||||
items={categoryIDs}
|
||||
itemToStringValue={(id) => 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}
|
||||
>
|
||||
<ComboboxChips>
|
||||
<ComboboxValue>
|
||||
{selectedIDs.map((id) => (
|
||||
<ComboboxChip key={id}>{byID.get(id)?.name ?? "未知分类"}</ComboboxChip>
|
||||
))}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput
|
||||
id="task-category"
|
||||
placeholder={selectedIDs.length ? "" : "搜索分类,或输入新名称后回车创建"}
|
||||
onKeyDown={(e) => {
|
||||
// 完全无匹配时回车 = 创建;有匹配项时保留 base-ui 的「回车选中高亮项」。
|
||||
if (e.key === "Enter" && matchCount === 0 && trimmed) {
|
||||
e.preventDefault();
|
||||
void createAndSelect();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent portalContainer={portalContainer}>
|
||||
<ComboboxList>
|
||||
{(id: string) => (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
{byID.get(id)?.name ?? id}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
{matchCount === 0 &&
|
||||
(trimmed ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={creating}
|
||||
onClick={() => void createAndSelect()}
|
||||
className="flex w-full items-center gap-2 px-2 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
{creating ? <Spinner className="size-4" /> : <PlusIcon className="size-4" />}
|
||||
创建分类「{trimmed}」
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-2 py-2 text-sm text-muted-foreground">输入名称以搜索或创建分类</div>
|
||||
))}
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
const COMPANY_SCOPE_LABELS: Record<string, string> = {
|
||||
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<number | undefined>(undefined);
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [goal, setGoal] = React.useState("");
|
||||
const [selectedTemplateID, setSelectedTemplateID] = React.useState<number | null>(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({
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-category">任务分类</FieldLabel>
|
||||
<Select value={categoryID} onValueChange={setCategoryID}>
|
||||
<SelectTrigger id="task-category">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={UNCATEGORIZED_VALUE}>未分类</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={String(category.id)}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>用于任务列表筛选和归档,不影响 Agent 执行。</FieldDescription>
|
||||
<CategoryPicker
|
||||
categories={categories}
|
||||
value={categoryID}
|
||||
onValueChange={setCategoryID}
|
||||
onCategoryCreated={onCategoriesChanged}
|
||||
portalContainer={sheetContentRef}
|
||||
/>
|
||||
<FieldDescription>可选,单个分类;用于任务列表筛选和归档,不影响 Agent 执行。</FieldDescription>
|
||||
</Field>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
|
||||
Reference in new issue
Block a user