Merge tag 'v0.3.4'
This commit is contained in:
commit
48e85b33d6
33 files changed
+1456
-120
No files matched your search
@@ -1321,6 +1321,14 @@ function CompanyAvatar({ name, logo }: { name: string; logo?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 后端返回的 warnings 说的是既有数据问题(不是本次提交的行有错),保存本身已经
|
||||
// 成功。给更长的停留时间,因为它需要用户去处理具体的资产,扫一眼标题不够。
|
||||
function showScopeWarnings(warnings?: string[]) {
|
||||
for (const warning of warnings ?? []) {
|
||||
toast.warning(warning, { duration: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
function savedScopeRules(company: Company): CompanyScopeRule[] {
|
||||
return (company.scope ?? [])
|
||||
.map((scope) => ({ kind: scope.kind, value: scope.raw.trim() }))
|
||||
@@ -1442,6 +1450,7 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () =
|
||||
const errCount = res.invalid ?? 0;
|
||||
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
|
||||
else toast.success(`范围已更新,共 ${res.added} 条`);
|
||||
showScopeWarnings(res.warnings);
|
||||
setOpen(false);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
@@ -1528,6 +1537,7 @@ function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: ()
|
||||
const errCount = res.invalid ?? 0;
|
||||
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
|
||||
else toast.success(`已追加 ${res.added} 条范围`);
|
||||
showScopeWarnings(res.warnings);
|
||||
setOpen(false);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
EyeIcon,
|
||||
FolderInputIcon,
|
||||
GripVerticalIcon,
|
||||
Loader2Icon,
|
||||
PaperclipIcon,
|
||||
@@ -219,6 +220,10 @@ const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [
|
||||
{ value: "timeout", label: "已超时" },
|
||||
];
|
||||
|
||||
// Select 不接受空字符串 value,所以「无分类」在筛选器、新建表单和批量移动里
|
||||
// 统一用这个哨兵值,提交时再翻译成后端的 null。
|
||||
const UNCATEGORIZED_VALUE = "uncategorized";
|
||||
|
||||
// 可暂停 = 非终态且未暂停,与后端 applyTaskControlWithCause 的门控一致
|
||||
// (done/failed/timeout 为终态);paused 才可继续。行内按钮与批量控制共用此判断,
|
||||
// 两边不会出现一个可点、另一个不可点的分歧。
|
||||
@@ -240,13 +245,18 @@ export default function TasksPage() {
|
||||
const [pageSize, setPageSize] = React.useState(20);
|
||||
const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000));
|
||||
const [batchControlling, setBatchControlling] = React.useState<"pause" | "resume" | null>(null);
|
||||
const [movingCategory, setMovingCategory] = React.useState(false);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return tasks.filter((t) => {
|
||||
if (statusFilter !== "all" && t.status !== statusFilter) return false;
|
||||
if (categoryFilter === "uncategorized" && t.category_id != null) return false;
|
||||
if (categoryFilter !== "all" && categoryFilter !== "uncategorized" && String(t.category_id) !== categoryFilter)
|
||||
if (categoryFilter === UNCATEGORIZED_VALUE && t.category_id != null) return false;
|
||||
if (
|
||||
categoryFilter !== "all" &&
|
||||
categoryFilter !== UNCATEGORIZED_VALUE &&
|
||||
String(t.category_id) !== categoryFilter
|
||||
)
|
||||
return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
@@ -374,7 +384,7 @@ export default function TasksPage() {
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (categoryFilter === "all" || categoryFilter === "uncategorized") return;
|
||||
if (categoryFilter === "all" || categoryFilter === UNCATEGORIZED_VALUE) return;
|
||||
if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all");
|
||||
}, [categories, categoryFilter]);
|
||||
|
||||
@@ -535,6 +545,42 @@ export default function TasksPage() {
|
||||
[batchControlling, load],
|
||||
);
|
||||
|
||||
// 后端把整批分类写入放在一个事务里,所以失败项只可能是勾选后又被删掉的任务。
|
||||
const moveSelectedTasksCategory = React.useCallback(
|
||||
async (categoryID?: number) => {
|
||||
const ids = [...selectedIds];
|
||||
if (ids.length === 0 || movingCategory) return;
|
||||
if (ids.length > 100) {
|
||||
toast.error("一次最多修改 100 个任务的分类");
|
||||
return;
|
||||
}
|
||||
setMovingCategory(true);
|
||||
try {
|
||||
const result = await api.updateTasksCategory(ids, categoryID);
|
||||
const succeeded = result.items.filter((item) => item.ok);
|
||||
const failed = result.items.filter((item) => !item.ok);
|
||||
const target = result.category?.name ?? "未分类";
|
||||
if (succeeded.length > 0) {
|
||||
toast.success(`已将 ${succeeded.length} 个任务移动到「${target}」`);
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
const details = failed
|
||||
.slice(0, 3)
|
||||
.map((item) => `#${item.id}(${item.error || "任务已不存在"})`)
|
||||
.join(";");
|
||||
toast.error(`${failed.length} 个任务未能移动:${details}${failed.length > 3 ? " 等" : ""}`);
|
||||
}
|
||||
refreshCategoriesAndTasks();
|
||||
} catch (error) {
|
||||
toast.error(`修改分类失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setMovingCategory(false);
|
||||
}
|
||||
},
|
||||
[movingCategory, refreshCategoriesAndTasks, selectedIds],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 px-0 pt-6">
|
||||
@@ -580,7 +626,7 @@ export default function TasksPage() {
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">全部分类</SelectItem>
|
||||
<SelectItem value="uncategorized">未分类</SelectItem>
|
||||
<SelectItem value={UNCATEGORIZED_VALUE}>未分类</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={String(category.id)}>
|
||||
{category.name}
|
||||
@@ -628,6 +674,12 @@ export default function TasksPage() {
|
||||
继续 {resumableTaskIDs.length}
|
||||
</Button>
|
||||
)}
|
||||
<MoveTasksCategoryDialog
|
||||
categories={categories}
|
||||
count={selectedIds.size}
|
||||
moving={movingCategory}
|
||||
onMove={moveSelectedTasksCategory}
|
||||
/>
|
||||
<BulkDeleteTasksDialog ids={[...selectedIds]} onDelete={deleteTasks} />
|
||||
</>
|
||||
)}
|
||||
@@ -638,7 +690,12 @@ export default function TasksPage() {
|
||||
onChanged={refreshCategoriesAndTasks}
|
||||
onTaskMoved={applyTaskCategoryMove}
|
||||
/>
|
||||
<CreateTaskSheet tasks={tasks} categories={categories} onCreated={refreshCategoriesAndTasks} />
|
||||
<CreateTaskSheet
|
||||
tasks={tasks}
|
||||
categories={categories}
|
||||
onCreated={refreshCategoriesAndTasks}
|
||||
onCategoriesChanged={refreshCategoriesAndTasks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
@@ -1147,6 +1204,81 @@ function DeleteTaskDialog({
|
||||
// BulkDeleteTasksDialog deletes every checked task with one shared set of cleanup options.
|
||||
// The backend has no batch endpoint, so deletion runs one task at a time (see deleteTasks)
|
||||
// and the button shows live progress.
|
||||
// MoveTasksCategoryDialog confirms a target before applying, so a mis-click on a
|
||||
// large selection cannot silently re-file every task. UNCATEGORIZED_VALUE stands
|
||||
// in for "no category" because Select rejects an empty string value.
|
||||
function MoveTasksCategoryDialog({
|
||||
categories,
|
||||
count,
|
||||
moving,
|
||||
onMove,
|
||||
}: {
|
||||
categories: TaskCategory[];
|
||||
count: number;
|
||||
moving: boolean;
|
||||
onMove: (categoryID?: number) => Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [target, setTarget] = React.useState(UNCATEGORIZED_VALUE);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (moving) return;
|
||||
setOpen(next);
|
||||
if (next) setTarget(UNCATEGORIZED_VALUE);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<FolderInputIcon data-icon="inline-start" /> 修改分类
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改所选 {count} 个任务的分类</DialogTitle>
|
||||
<DialogDescription>目标分类对所选任务统一生效;选「未分类」会把它们移出当前分类。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bulk-category">目标分类</FieldLabel>
|
||||
<Select value={target} onValueChange={setTarget} disabled={moving}>
|
||||
<SelectTrigger id="bulk-category" className="w-full">
|
||||
<SelectValue placeholder="选择分类" />
|
||||
</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>
|
||||
{categories.length === 0 && <FieldDescription>还没有任何分类,先用「分类管理」创建一个。</FieldDescription>}
|
||||
</Field>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={moving}>
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
disabled={moving}
|
||||
onClick={() => {
|
||||
void onMove(target === UNCATEGORIZED_VALUE ? undefined : Number(target)).then(() => setOpen(false));
|
||||
}}
|
||||
>
|
||||
{moving && <Spinner data-icon="inline-start" />}
|
||||
移动
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkDeleteTasksDialog({
|
||||
ids,
|
||||
onDelete,
|
||||
@@ -1292,6 +1424,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",
|
||||
@@ -1799,14 +2055,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");
|
||||
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);
|
||||
@@ -1876,7 +2134,7 @@ function CreateTaskSheet({
|
||||
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致
|
||||
await api.createTask({
|
||||
name: name.trim(),
|
||||
categoryId: categoryID === "uncategorized" ? undefined : Number(categoryID),
|
||||
categoryId: categoryID,
|
||||
description: description.trim(),
|
||||
goal: goal.trim(),
|
||||
llmProfileIds: llmProfileIDs.map(Number),
|
||||
@@ -1889,7 +2147,7 @@ function CreateTaskSheet({
|
||||
});
|
||||
toast.success("任务已创建");
|
||||
setName("");
|
||||
setCategoryID("uncategorized");
|
||||
setCategoryID(undefined);
|
||||
setDescription("");
|
||||
setGoal("");
|
||||
setSelectedTemplateID(null);
|
||||
@@ -1955,22 +2213,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">未分类</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>
|
||||
|
||||
+10
-2
@@ -12,10 +12,12 @@ import type {
|
||||
AgentTrigger,
|
||||
Asset,
|
||||
Audit,
|
||||
BatchCategoryItem,
|
||||
BatchControlItem,
|
||||
ChatAttachment,
|
||||
CommandRecord,
|
||||
Company,
|
||||
CompanyScopeMutation,
|
||||
CompanyScopeRule,
|
||||
Conversation,
|
||||
ConvTokenSummary,
|
||||
@@ -218,6 +220,12 @@ export const api = {
|
||||
deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`),
|
||||
updateTaskCategory: (taskId: string, categoryId?: number) =>
|
||||
patch<Task>(`/tasks/${taskId}/category`, { category_id: categoryId ?? null }),
|
||||
// categoryId 省略/undefined = 移出分类(后端收到 null)
|
||||
updateTasksCategory: (taskIds: string[], categoryId?: number) =>
|
||||
post<{ items: BatchCategoryItem[]; category: TaskCategory | null }>("/tasks/category/batch", {
|
||||
task_ids: taskIds,
|
||||
category_id: categoryId ?? null,
|
||||
}),
|
||||
taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)),
|
||||
createTaskTemplate: (input: Pick<TaskTemplate, "name" | "description" | "goal">) =>
|
||||
post<TaskTemplate>("/task-templates", input),
|
||||
@@ -388,12 +396,12 @@ export const api = {
|
||||
},
|
||||
),
|
||||
addCompanyScope: (id: number, scope: CompanyScopeRule[], reason = "") =>
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
|
||||
post<CompanyScopeMutation>(`/companies/${id}/scope`, {
|
||||
scope,
|
||||
reason,
|
||||
}),
|
||||
updateCompanyScope: (id: number, scope: CompanyScopeRule[], reason = "") =>
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
|
||||
post<CompanyScopeMutation>(`/companies/${id}/scope`, {
|
||||
scope,
|
||||
reason,
|
||||
reset: true,
|
||||
|
||||
@@ -356,6 +356,25 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
}
|
||||
return { deleted: categoryID };
|
||||
}
|
||||
if (path === "/tasks/category/batch" && m === "POST") {
|
||||
const requested = Array.isArray(b.task_ids) ? b.task_ids.map(String) : [];
|
||||
const taskIDs = [...new Set(requested)];
|
||||
if (taskIDs.length === 0 || taskIDs.length > 100) throw new Error("task_ids 数量必须为 1-100");
|
||||
const categoryID = typeof b.category_id === "number" ? b.category_id : undefined;
|
||||
const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID);
|
||||
if (categoryID !== undefined && !category) throw new Error("任务分类不存在");
|
||||
const items = taskIDs.map((id) => {
|
||||
const task = mockTasks.find((item) => item.id === id);
|
||||
if (!task) return { id, ok: false, error: "task not found" };
|
||||
task.category_id = category?.id;
|
||||
task.category_name = category?.name;
|
||||
return { id, ok: true };
|
||||
});
|
||||
return {
|
||||
items,
|
||||
category: category ? mockTaskCategorySnapshot().find((item) => item.id === category.id) : null,
|
||||
};
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "category" && seg.length === 3 && m === "PATCH") {
|
||||
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||
if (!task) throw new Error("任务不存在");
|
||||
|
||||
@@ -298,6 +298,16 @@ export interface CompanyScopeRule {
|
||||
value: string;
|
||||
}
|
||||
|
||||
// 资产范围写入的结果。errors 是本次提交里不合法的行;warnings 是与本次提交无关、
|
||||
// 但会让归属结果不符合预期的既有数据问题(如 ip 字段存了主机名的资产)。
|
||||
export interface CompanyScopeMutation {
|
||||
added: number;
|
||||
skipped: number;
|
||||
invalid: number;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
// 公司资产范围规则的一条(归属唯一真值来源)。
|
||||
export interface ScopeRow {
|
||||
id: number;
|
||||
@@ -624,6 +634,13 @@ export interface BatchControlItem {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 批量改分类的逐任务结果。失败只可能是任务已被删除,分类本身的写入是原子的。
|
||||
export interface BatchCategoryItem {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Whole-task (all agents) token aggregate.
|
||||
export interface TokenTotal {
|
||||
input_tokens: number;
|
||||
|
||||
Reference in new issue
Block a user