feat: make task global asset sharing opt-in

This commit is contained in:
ji ruo committed 2026-11-05 16:27:44 +08:00
1 parent d36e1c3295
commit 080a46eb2d
25 files changed
+838 -328

No files matched your search

+304 -115
View File
@@ -24,6 +24,8 @@ import {
ChevronRightIcon,
EyeIcon,
FolderInputIcon,
GlobeIcon,
GlobeOffIcon,
GripVerticalIcon,
Loader2Icon,
PaperclipIcon,
@@ -795,6 +797,30 @@ export default function TasksPage() {
[load],
);
const toggleTaskGlobalAssets = React.useCallback(
async (task: Task) => {
const enabled = task.share_global_assets === true;
try {
const updated = await api.updateTask(task.id, {
share_global_assets: !enabled,
});
setTasks((current) =>
current.map((entry) =>
entry.id === task.id
? { ...entry, share_global_assets: updated.share_global_assets }
: entry,
),
);
toast.success(`任务 #${task.id} 已${enabled ? "关闭" : "开启"}共享全局资产`);
lastRef.current = "";
load();
} catch (error) {
toast.error(`设置失败:${(error as Error).message}`);
}
},
[load],
);
const queueTaskArchive = React.useCallback(
async (task: Task) => {
try {
@@ -1284,6 +1310,7 @@ export default function TasksPage() {
onRename={renameTask}
onRemark={updateTaskRemark}
onTogglePinned={toggleTaskPinned}
onToggleGlobalAssets={toggleTaskGlobalAssets}
onArchive={queueTaskArchive}
selected={selectedIds.has(task.id)}
onSelectedChange={toggleSelected}
@@ -1547,6 +1574,7 @@ const TaskRow = React.memo(function TaskRow({
onRename,
onRemark,
onTogglePinned,
onToggleGlobalAssets,
onArchive,
selected,
onSelectedChange,
@@ -1559,6 +1587,7 @@ const TaskRow = React.memo(function TaskRow({
onRename: (task: Task, name: string) => Promise<void>;
onRemark: (task: Task, remark: string) => Promise<void>;
onTogglePinned: (task: Task) => Promise<void>;
onToggleGlobalAssets: (task: Task) => Promise<void>;
onArchive: (task: Task) => Promise<void>;
selected: boolean;
onSelectedChange: (id: string, checked: boolean) => void;
@@ -1756,19 +1785,24 @@ const TaskRow = React.memo(function TaskRow({
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
<div className="flex items-center justify-end gap-0.5">
<Button
size="icon"
variant="ghost"
asChild
aria-label="查看任务详情"
title="查看任务详情"
<TaskGlobalAssetsAction task={task} onToggle={onToggleGlobalAssets} />
<TaskActionTooltip
label="查看任务详情"
description="查看任务进度、探索记录、测试资产和发现的漏洞。"
>
<Link
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
<Button
size="icon"
variant="ghost"
asChild
aria-label="查看任务详情"
>
<EyeIcon />
</Link>
</Button>
<Link
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
>
<EyeIcon />
</Link>
</Button>
</TaskActionTooltip>
<TaskControlButton task={task} onControl={onControl} />
<TaskPinAction task={task} onTogglePinned={onTogglePinned} />
<TaskArchiveAction task={task} onArchive={onArchive} />
@@ -1779,6 +1813,40 @@ const TaskRow = React.memo(function TaskRow({
);
});
function TaskActionTooltip({
label,
description,
children,
disabled = false,
}: {
label: string;
description: string;
children: React.ReactElement;
disabled?: boolean;
}) {
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
{disabled ? (
<span className="inline-flex" aria-label={label}>
{children}
</span>
) : (
children
)}
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={6}
className="block max-w-64 space-y-1 text-left leading-relaxed"
>
<p className="font-medium">{label}</p>
<p>{description}</p>
</TooltipContent>
</Tooltip>
);
}
function TaskNameEditor({
task,
onRename,
@@ -1865,20 +1933,24 @@ function TaskNameEditor({
<span className="text-muted-foreground">未命名</span>
)}
</Link>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100"
aria-label={`重命名任务 #${task.id}`}
title="重命名"
onClick={() => {
setName(task.name ?? "");
setEditing(true);
}}
<TaskActionTooltip
label="重命名任务"
description="修改任务名称,便于识别和查找。"
>
<PencilIcon />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100"
aria-label={`重命名任务 #${task.id}`}
onClick={() => {
setName(task.name ?? "");
setEditing(true);
}}
>
<PencilIcon />
</Button>
</TaskActionTooltip>
</div>
);
}
@@ -1900,24 +1972,93 @@ function TaskPinAction({
const pinned = taskIsPinned(task);
return (
<Button
type="button"
variant="ghost"
size="icon"
<TaskActionTooltip
label={pinned ? "取消置顶" : "置顶任务"}
description={
pinned
? "取消此任务的置顶,恢复正常排序。"
: "将此任务固定在列表顶部,便于快速查看。"
}
disabled={pinning}
aria-label={pinned ? `取消置顶任务 #${task.id}` : `置顶任务 #${task.id}`}
title={pinned ? "取消置顶" : "置顶"}
onClick={async () => {
setPinning(true);
try {
await onTogglePinned(task);
} finally {
setPinning(false);
}
}}
>
{taskPinIcon(pinning, pinned)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={pinning}
aria-label={
pinned ? `取消置顶任务 #${task.id}` : `置顶任务 #${task.id}`
}
onClick={async () => {
setPinning(true);
try {
await onTogglePinned(task);
} finally {
setPinning(false);
}
}}
>
{taskPinIcon(pinning, pinned)}
</Button>
</TaskActionTooltip>
);
}
function taskGlobalAssetsIcon(saving: boolean, enabled: boolean) {
if (saving) return <Loader2Icon className="animate-spin" />;
if (enabled) return <GlobeIcon />;
return <GlobeOffIcon />;
}
function TaskGlobalAssetsAction({
task,
onToggle,
}: {
task: Task;
onToggle: (task: Task) => Promise<void>;
}) {
const [saving, setSaving] = React.useState(false);
const enabled = task.share_global_assets === true;
const label = `共享全局资产:${enabled ? "已开启,点击关闭" : "未开启,点击开启"}`;
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
aria-label={label}
aria-pressed={enabled}
aria-busy={saving}
className={enabled ? "text-foreground" : "text-muted-foreground"}
disabled={saving}
onClick={async () => {
setSaving(true);
try {
await onToggle(task);
} finally {
setSaving(false);
}
}}
>
{taskGlobalAssetsIcon(saving, enabled)}
</Button>
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={6}
className="block max-w-64 space-y-1 text-left leading-relaxed"
>
<p className="font-medium">
共享全局资产 · {enabled ? "已开启" : "已关闭"}
</p>
<p>
开启后可查询全局资产库;关闭后仅使用本任务已关联的资产及直接关联任务的资产。
</p>
<p className="opacity-75">
{enabled ? "点击关闭共享" : "点击开启共享"}
</p>
</TooltipContent>
</Tooltip>
);
}
@@ -1940,25 +2081,34 @@ function TaskControlButton({
const [pending, setPending] = React.useState(false);
const action = taskControlAction(task.status);
const label = action === "resume" ? "继续任务" : "暂停任务";
let description = "暂停当前任务的执行,之后可继续。";
if (action === "resume")
description = "继续已暂停的任务;等待中的任务将重新进入调度。";
if (!action) description = "当前任务状态不支持暂停或继续。";
return (
<Button
size="icon"
variant="ghost"
aria-label={label}
title={action ? label : "该状态不可暂停或继续"}
<TaskActionTooltip
label={label}
description={description}
disabled={!action || pending}
onClick={async () => {
if (!action) return;
setPending(true);
try {
await onControl(task.id, action);
} finally {
setPending(false);
}
}}
>
{taskControlIcon(pending, action)}
</Button>
<Button
size="icon"
variant="ghost"
aria-label={label}
disabled={!action || pending}
onClick={async () => {
if (!action) return;
setPending(true);
try {
await onControl(task.id, action);
} finally {
setPending(false);
}
}}
>
{taskControlIcon(pending, action)}
</Button>
</TaskActionTooltip>
);
}
@@ -2040,20 +2190,27 @@ function TaskArchiveAction({
);
if (reason) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">{trigger}</span>
</TooltipTrigger>
<TooltipContent>{reason}</TooltipContent>
</Tooltip>
<TaskActionTooltip label="暂不可归档" description={reason} disabled>
{trigger}
</TaskActionTooltip>
);
}
return (
<ArchiveConfirmDialog
count={1}
trigger={trigger}
onConfirm={() => onArchive(task)}
/>
<Tooltip delayDuration={200}>
<ArchiveConfirmDialog
count={1}
trigger={<TooltipTrigger asChild>{trigger}</TooltipTrigger>}
onConfirm={() => onArchive(task)}
/>
<TooltipContent
side="top"
sideOffset={6}
className="block max-w-64 space-y-1 text-left leading-relaxed"
>
<p className="font-medium">归档任务</p>
<p>将任务及相关记录压缩保存,之后可从“已归档”中还原。点击后需确认。</p>
</TooltipContent>
</Tooltip>
);
}
@@ -2545,42 +2702,51 @@ function TaskArchivesPanel({ onChanged }: { onChanged: () => void }) {
<TableCell className="text-right">
<div className="flex justify-end gap-0.5">
{ARCHIVE_FAILED_STATES.has(archive.state) && (
<Button
size="icon"
variant="ghost"
onClick={() => void retry(archive)}
aria-label="重试归档操作"
title="重试"
>
<Undo2Icon />
</Button>
<TaskActionTooltip label="重试归档操作" description="重新执行上次失败的归档、还原或删除操作。">
<Button
size="icon"
variant="ghost"
onClick={() => void retry(archive)}
aria-label="重试归档操作"
>
<Undo2Icon />
</Button>
</TaskActionTooltip>
)}
{canRestore && (
<Button
size="icon"
variant="ghost"
onClick={() => void restoreMany([archive])}
aria-label="还原任务"
title="还原任务"
>
<Undo2Icon />
</Button>
<TaskActionTooltip label="还原任务" description="将归档中的任务及相关记录恢复到当前任务列表。">
<Button
size="icon"
variant="ghost"
onClick={() => void restoreMany([archive])}
aria-label="还原任务"
>
<Undo2Icon />
</Button>
</TaskActionTooltip>
)}
{canDelete && (
<ArchiveDeleteDialog
archives={[archive]}
onConfirm={() => deleteMany([archive])}
trigger={
<Button
size="icon"
variant="ghost"
aria-label="永久删除归档"
title="永久删除"
>
<Trash2Icon />
</Button>
}
/>
<Tooltip delayDuration={200}>
<ArchiveDeleteDialog
archives={[archive]}
onConfirm={() => deleteMany([archive])}
trigger={
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
aria-label="永久删除归档"
>
<Trash2Icon />
</Button>
</TooltipTrigger>
}
/>
<TooltipContent side="top" sideOffset={6} className="block max-w-64 space-y-1 text-left leading-relaxed">
<p className="font-medium">永久删除归档</p>
<p>删除此归档及其保存的数据,删除后无法还原。点击后需确认。</p>
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
@@ -2795,11 +2961,13 @@ function DeleteTaskDialog({
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline" aria-label="删除任务">
<Trash2Icon className="text-destructive" />
</Button>
</AlertDialogTrigger>
<TaskActionTooltip label="删除任务" description="删除任务及执行记录;可在确认窗口选择是否同时清理关联数据。">
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline" aria-label="删除任务">
<Trash2Icon className="text-destructive" />
</Button>
</AlertDialogTrigger>
</TaskActionTooltip>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
@@ -3415,19 +3583,24 @@ function DraggableCategoryTask({
{moving ? (
<Spinner />
) : (
<Button
type="button"
size="icon-xs"
variant="ghost"
className="touch-none cursor-grab active:cursor-grabbing"
<TaskActionTooltip
label="移动任务分类"
description="按住并拖动此图标,将任务移动到目标分类。"
disabled={disabled}
{...listeners}
{...attributes}
aria-label={`拖动任务 #${task.id}`}
title="拖动任务"
>
<GripVerticalIcon />
</Button>
<Button
type="button"
size="icon-xs"
variant="ghost"
className="touch-none cursor-grab active:cursor-grabbing"
disabled={disabled}
{...listeners}
{...attributes}
aria-label={`拖动任务 #${task.id}`}
>
<GripVerticalIcon />
</Button>
</TaskActionTooltip>
)}
</ItemMedia>
<ItemContent className="min-w-0">
@@ -3877,6 +4050,7 @@ function CreateTaskSheet({
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
const [shareGlobalAssets, setShareGlobalAssets] = React.useState(false);
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
const [workerCount, setWorkerCount] = React.useState(""); // 本任务 Worker 数;空/0=用全局默认
const [interceptRules, setInterceptRules] = React.useState<
@@ -3972,6 +4146,7 @@ function CreateTaskSheet({
seedFirstIntent,
planHeartbeatSeconds: heartbeatSec,
coverageEnabled,
shareGlobalAssets,
workerCount: workerCountNum,
schedule: scheduleEnabled ? { ...schedule, task_ids: [] } : undefined,
interceptRules: interceptRules
@@ -3992,6 +4167,7 @@ function CreateTaskSheet({
setHeartbeatMin("10");
setSeedFirstIntent(false);
setCoverageEnabled(true);
setShareGlobalAssets(false);
setWorkerCount("");
setInterceptRules([]);
setScheduleEnabled(false);
@@ -4286,6 +4462,19 @@ function CreateTaskSheet({
onChange={(e) => setGoal(e.target.value)}
/>
</div>
<Field>
<div className="flex items-center gap-2">
<Checkbox
id="share-global-assets"
checked={shareGlobalAssets}
onCheckedChange={(checked) => setShareGlobalAssets(checked === true)}
/>
<Label htmlFor="share-global-assets">共享全局资产</Label>
</div>
<FieldDescription>
默认关闭,仅使用本任务及明确关联任务的资产。开启后可查询全局资产库,测试仍须遵守授权范围。
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="source-tasks">关联任务</FieldLabel>
<SourceTaskPicker
+18 -11
View File
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { MAX_TASK_REMARK_RUNES } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -165,17 +166,23 @@ function InlineTextEditor({
<span className="text-muted-foreground">{emptyLabel}</span>
)}
</button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-within/inline-editor:opacity-100 [@media(hover:hover)]:group-hover/inline-editor:opacity-100"
aria-label={`编辑${label}的${fieldName}`}
title={`编辑${fieldName}`}
onClick={startEditing}
>
<PencilIcon />
</Button>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-within/inline-editor:opacity-100 [@media(hover:hover)]:group-hover/inline-editor:opacity-100"
aria-label={`编辑${label}的${fieldName}`}
onClick={startEditing}
>
<PencilIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
编辑{fieldName}:输入完成后按 Enter 或点击其他位置保存,按 Esc 取消。
</TooltipContent>
</Tooltip>
</div>
);
}
+3 -1
View File
@@ -346,6 +346,7 @@ export const api = {
timeoutSeconds?: number;
seedFirstIntent?: boolean;
planHeartbeatSeconds?: number;
shareGlobalAssets?: boolean;
coverageEnabled?: boolean;
workerCount?: number;
interceptRules?: AssetInterceptRuleInput[];
@@ -363,6 +364,7 @@ export const api = {
timeout_seconds: input.timeoutSeconds ?? 0,
seed_first_intent: input.seedFirstIntent ?? false,
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
share_global_assets: input.shareGlobalAssets ?? false,
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
worker_count: input.workerCount ?? 0, // 0 = 用全局默认 workers
intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则
@@ -380,7 +382,7 @@ export const api = {
runScheduleNow: (id: number) =>
post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}),
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string }) =>
updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string; share_global_assets?: boolean }) =>
patch<Task>(`/tasks/${id}`, input),
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
+6 -1
View File
@@ -253,6 +253,7 @@ function mockTaskArchiveBlocker(taskID: string): string | undefined {
function publicMockTask(task: Task): Task {
const item = structuredClone(task);
item.share_global_assets = task.share_global_assets ?? false;
const blocker = mockTaskArchiveBlocker(task.id);
if (blocker) item.archive_blocked_by_task_id = blocker;
else delete item.archive_blocked_by_task_id;
@@ -1272,6 +1273,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
llm_failover_state: profileIDs.length ? "ready" : "default",
source_task_ids: sourceTaskIDs,
company_ids: companyIDs,
share_global_assets: b.share_global_assets === true,
};
const numericTaskID = nextMockTaskAssetID++;
mockTaskAssetIDs.set(id, numericTaskID);
@@ -1460,10 +1462,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (has("name") && b.name !== null && typeof b.name !== "string") throw new Error("任务名称必须是字符串");
if (has("remark") && b.remark !== null && typeof b.remark !== "string") throw new Error("备注必须是字符串");
if (has("pinned") && b.pinned !== null && typeof b.pinned !== "boolean") throw new Error("pinned 必须是布尔值");
if (has("share_global_assets") && b.share_global_assets !== null && typeof b.share_global_assets !== "boolean") throw new Error("share_global_assets 必须是布尔值");
const sharingPresent = has("share_global_assets") && b.share_global_assets !== null;
const namePresent = has("name") && b.name !== null;
const remarkPresent = has("remark") && b.remark !== null;
const pinnedPresent = has("pinned") && b.pinned !== null;
if (!namePresent && !remarkPresent && !pinnedPresent) throw new Error("至少需要提供 name、pinned 或 remark");
if (!namePresent && !remarkPresent && !pinnedPresent && !sharingPresent) throw new Error("至少需要提供 name、pinned、remark 或 share_global_assets");
let nextName = task.name;
let nextRemark = task.remark;
let nextPinned = task.pinned;
@@ -1480,6 +1484,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
nextPinned = pinned;
nextPinnedAt = pinned ? (task.pinned_at ?? new Date().toISOString()) : null;
}
if (sharingPresent) task.share_global_assets = b.share_global_assets as boolean;
task.name = nextName;
task.remark = nextRemark;
task.pinned = nextPinned;
+1
View File
@@ -62,6 +62,7 @@ export interface Task {
source_task_ids?: string[]; // directly related tasks inherited as read-only context
archive_blocked_by_task_id?: string; // live direct dependent that must be archived first
company_ids?: number[]; // associated company scopes; current company assets join the task at creation
share_global_assets?: boolean; // opt-in global asset visibility; default false
coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度
}