feat: prepare 0.3.3 worker llm agent and ui enhancements
This commit is contained in:
1 parent
d3183dbbd2
commit
1bebdce7f1
28 files changed
+1331
-978
No files matched your search
@@ -35,7 +35,7 @@ export function MainContent({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
api
|
||||
.health()
|
||||
.then((h) => setVersion(h.version ?? ""))
|
||||
.then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, "")))
|
||||
.catch(() => setVersion(""));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
Bot,
|
||||
ChevronDownIcon,
|
||||
Loader2Icon,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
PlusIcon,
|
||||
SendIcon,
|
||||
Square,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
@@ -202,8 +202,14 @@ function Composer({
|
||||
<Square className="size-3.5 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" onClick={onSend} disabled={disabled || (!value.trim() && atts.length === 0)}>
|
||||
<SendIcon className="size-4" />
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={onSend}
|
||||
disabled={disabled || (!value.trim() && atts.length === 0)}
|
||||
title="发送消息"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
BugIcon,
|
||||
ClockIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
TargetIcon,
|
||||
ZapIcon,
|
||||
@@ -177,7 +176,7 @@ export default function DashboardPage() {
|
||||
/* transient errors — next poll retries */
|
||||
}
|
||||
};
|
||||
load();
|
||||
void load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => {
|
||||
alive = false;
|
||||
@@ -213,7 +212,7 @@ export default function DashboardPage() {
|
||||
/* transient errors */
|
||||
}
|
||||
};
|
||||
load();
|
||||
void load();
|
||||
const t = setInterval(load, 15000);
|
||||
return () => {
|
||||
alive = false;
|
||||
@@ -461,17 +460,11 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 pb-6">
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">总览</h1>
|
||||
<p className="text-xs text-muted-foreground">系统全局状态 · 实时刷新</p>
|
||||
</div>
|
||||
<Link href="/function/tasks">
|
||||
<button className="flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-90">
|
||||
<PlusIcon className="size-3.5" />
|
||||
新建任务
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ── Row 1: 5 stat cards ── */}
|
||||
@@ -575,6 +568,7 @@ export default function DashboardPage() {
|
||||
] as const
|
||||
).map(({ v, label }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={v}
|
||||
onClick={() => setTokenVersion(v)}
|
||||
title={
|
||||
@@ -600,6 +594,7 @@ export default function DashboardPage() {
|
||||
{/* Profile tabs */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTokenTab("all")}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-[10px] font-medium transition-colors",
|
||||
@@ -616,6 +611,7 @@ export default function DashboardPage() {
|
||||
const resolvedKey = tokenByProfile.has(key) ? key : p.is_default ? defaultProfileId : key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={p.id}
|
||||
onClick={() => setTokenTab(resolvedKey)}
|
||||
className={cn(
|
||||
@@ -745,6 +741,7 @@ export default function DashboardPage() {
|
||||
] as const
|
||||
).map(({ days, label }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={days}
|
||||
onClick={() => setTokenDays(days)}
|
||||
className={cn(
|
||||
@@ -993,7 +990,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums">
|
||||
{(t.in_flight ?? 0 > 0) ? (
|
||||
{(t.in_flight ?? 0) > 0 ? (
|
||||
<span className="font-semibold">{t.in_flight}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
SmartphoneIcon,
|
||||
@@ -46,7 +45,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -59,7 +57,9 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import { MAX_COMPANY_SCOPE_RULES, type ParsedCompanyScopeText, parseCompanyScopeText } from "@/lib/company-scope";
|
||||
import type { Asset, Company, CompanyScopeKind, CompanyScopeRule } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1254,11 +1254,13 @@ function AssetCard({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} / 页
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} / 页
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="tabular-nums">
|
||||
@@ -1319,171 +1321,94 @@ function CompanyAvatar({ name, logo }: { name: string; logo?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const COMPANY_SCOPE_KINDS: { value: CompanyScopeKind; label: string; placeholder: string }[] = [
|
||||
{ value: "domain", label: "域名", placeholder: "example.com" },
|
||||
{ value: "ip", label: "IP", placeholder: "203.0.113.10" },
|
||||
{ value: "cidr", label: "CIDR", placeholder: "198.51.100.0/24" },
|
||||
{ value: "icp", label: "ICP", placeholder: "京ICP备12345678号-1" },
|
||||
{ value: "keyword", label: "企业关键词", placeholder: "Acme / 产品品牌名" },
|
||||
];
|
||||
const COMPANY_SCOPE_KIND_LABELS: Record<CompanyScopeKind, string> = {
|
||||
domain: "域名",
|
||||
ip: "IP",
|
||||
cidr: "CIDR",
|
||||
icp: "ICP",
|
||||
keyword: "关键词",
|
||||
};
|
||||
|
||||
const MAX_COMPANY_SCOPE_RULES = 256;
|
||||
const MAX_COMPANY_SCOPE_VALUE_LENGTH = 1024;
|
||||
|
||||
type EditableScopeRule = CompanyScopeRule & { key: string };
|
||||
let nextScopeRuleKey = 0;
|
||||
|
||||
function editableScopeRule(kind: CompanyScopeKind = "domain", value = ""): EditableScopeRule {
|
||||
nextScopeRuleKey += 1;
|
||||
return { kind, value, key: `company-scope-${nextScopeRuleKey}` };
|
||||
}
|
||||
|
||||
function isIPv4(value: string): boolean {
|
||||
const parts = value.split(".");
|
||||
return (
|
||||
parts.length === 4 &&
|
||||
parts.every((part) => (part === "0" || /^[1-9]\d{0,2}$/.test(part)) && Number(part) >= 0 && Number(part) <= 255)
|
||||
);
|
||||
}
|
||||
|
||||
function isIPv6(value: string): boolean {
|
||||
if (!value.includes(":") || /[^0-9a-f:.]/i.test(value) || value.includes(":::")) return false;
|
||||
const halves = value.split("::");
|
||||
if (halves.length > 2) return false;
|
||||
const countSegments = (half: string): number | null => {
|
||||
if (!half) return 0;
|
||||
const segments = half.split(":");
|
||||
let count = 0;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (segment.includes(".")) {
|
||||
if (index !== segments.length - 1 || !isIPv4(segment)) return null;
|
||||
count += 2;
|
||||
} else {
|
||||
if (!/^[0-9a-f]{1,4}$/i.test(segment)) return null;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const left = countSegments(halves[0]);
|
||||
const right = countSegments(halves[1] ?? "");
|
||||
if (left === null || right === null) return false;
|
||||
return halves.length === 2 ? left + right < 8 : left === 8;
|
||||
}
|
||||
|
||||
function ipVersion(value: string): 4 | 6 | null {
|
||||
if (isIPv4(value)) return 4;
|
||||
if (isIPv6(value)) return 6;
|
||||
return null;
|
||||
}
|
||||
|
||||
function scopeRuleError(rule: CompanyScopeRule): string {
|
||||
const value = rule.value.trim();
|
||||
if (!value) return "请填写范围值";
|
||||
if (rule.kind === "domain" && (!value.includes(".") || /\s/.test(value))) return "请输入有效域名";
|
||||
if (rule.kind === "ip" && ipVersion(value) === null) return "请输入有效 IP";
|
||||
if (rule.kind === "cidr") {
|
||||
const separator = value.lastIndexOf("/");
|
||||
if (separator <= 0) return "请输入 CIDR 网段";
|
||||
const address = value.slice(0, separator);
|
||||
const prefixText = value.slice(separator + 1);
|
||||
const version = ipVersion(address);
|
||||
if (version === null || !/^\d+$/.test(prefixText)) return "请输入 CIDR 网段";
|
||||
const prefix = Number(prefixText);
|
||||
if (version === 4 && (prefix < 16 || prefix > 32)) return "IPv4 网段前缀需为 /16 至 /32";
|
||||
if (version === 6 && (prefix < 32 || prefix > 128)) return "IPv6 网段前缀需为 /32 至 /128";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function ScopeRulesEditor({
|
||||
function ScopeTextEditor({
|
||||
id,
|
||||
value,
|
||||
onValueChange,
|
||||
parsed,
|
||||
}: {
|
||||
value: EditableScopeRule[];
|
||||
onValueChange: (value: EditableScopeRule[]) => void;
|
||||
id: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
parsed: ParsedCompanyScopeText;
|
||||
}) {
|
||||
const update = (index: number, patch: Partial<EditableScopeRule>) => {
|
||||
onValueChange(value.map((rule, i) => (i === index ? { ...rule, ...patch } : rule)));
|
||||
};
|
||||
const counts = React.useMemo(() => {
|
||||
const result = new Map<CompanyScopeKind, number>();
|
||||
for (const rule of parsed.rules) result.set(rule.kind, (result.get(rule.kind) ?? 0) + 1);
|
||||
return result;
|
||||
}, [parsed.rules]);
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{value.map((rule, index) => {
|
||||
const meta = COMPANY_SCOPE_KINDS.find((item) => item.value === rule.kind) ?? COMPANY_SCOPE_KINDS[0];
|
||||
// Empty rows are omitted on submit, so the initial optional row should
|
||||
// not appear invalid before the user enters a value.
|
||||
const error = rule.value.trim() ? scopeRuleError(rule) : "";
|
||||
return (
|
||||
<Field key={rule.key} data-invalid={!!error}>
|
||||
<div className="flex items-start gap-2">
|
||||
<Select value={rule.kind} onValueChange={(kind) => update(index, { kind: kind as CompanyScopeKind })}>
|
||||
<SelectTrigger className="w-32 shrink-0" aria-label={`范围 ${index + 1} 类型`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{COMPANY_SCOPE_KINDS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={rule.value}
|
||||
placeholder={meta.placeholder}
|
||||
aria-label={`范围 ${index + 1} 内容`}
|
||||
aria-invalid={!!error}
|
||||
maxLength={MAX_COMPANY_SCOPE_VALUE_LENGTH}
|
||||
onChange={(event) => update(index, { value: event.target.value })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`删除范围 ${index + 1}`}
|
||||
disabled={value.length === 1}
|
||||
onClick={() => onValueChange(value.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
{rule.value && error ? <FieldError>{error}</FieldError> : null}
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="self-start"
|
||||
disabled={value.length >= MAX_COMPANY_SCOPE_RULES}
|
||||
title={value.length >= MAX_COMPANY_SCOPE_RULES ? `每个企业最多 ${MAX_COMPANY_SCOPE_RULES} 条范围` : undefined}
|
||||
onClick={() => onValueChange([...value, editableScopeRule()])}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
添加范围
|
||||
</Button>
|
||||
</div>
|
||||
<Field data-invalid={parsed.errors.length > 0}>
|
||||
<FieldLabel htmlFor={id}>资产范围</FieldLabel>
|
||||
<FieldDescription>每行一条,自动识别域名、IP、CIDR、ICP 备案和企业关键词。</FieldDescription>
|
||||
<Textarea
|
||||
id={id}
|
||||
rows={8}
|
||||
value={value}
|
||||
aria-invalid={parsed.errors.length > 0}
|
||||
placeholder={"example.com\n203.0.113.10\n198.51.100.0/24\n京ICP备12345678号-1\n企业名称关键词"}
|
||||
className="min-h-36 resize-y font-mono text-sm"
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
/>
|
||||
{parsed.rules.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>已识别 {parsed.rules.length} 条</span>
|
||||
{Object.entries(COMPANY_SCOPE_KIND_LABELS).map(([kind, label]) => {
|
||||
const count = counts.get(kind as CompanyScopeKind) ?? 0;
|
||||
return count > 0 ? (
|
||||
<Badge key={kind} variant="secondary" className="font-mono tabular-nums">
|
||||
{label} {count}
|
||||
</Badge>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{parsed.errors.length > 0 && (
|
||||
<FieldError>
|
||||
{parsed.errors.slice(0, 5).map((item) => (
|
||||
<span key={`${item.line}-${item.error}`} className="block">
|
||||
第 {item.line} 行:{item.error}
|
||||
</span>
|
||||
))}
|
||||
{parsed.errors.length > 5 && <span className="block">另有 {parsed.errors.length - 5} 行错误</span>}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function savedScopeRules(company: Company): EditableScopeRule[] {
|
||||
const rules = (company.scope ?? []).map((scope) => editableScopeRule(scope.kind, scope.raw));
|
||||
return rules.length > 0 ? rules : [editableScopeRule()];
|
||||
function savedScopeRules(company: Company): CompanyScopeRule[] {
|
||||
return (company.scope ?? [])
|
||||
.map((scope) => ({ kind: scope.kind, value: scope.raw.trim() }))
|
||||
.filter((scope) => scope.value);
|
||||
}
|
||||
|
||||
function savedScopeText(company: Company): string {
|
||||
return savedScopeRules(company)
|
||||
.map((scope) => scope.value)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// 新增企业使用与任务、LLM 编辑一致的右侧抽屉。
|
||||
function CompanyDialog({ onSaved }: { onSaved: () => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [name, setName] = React.useState("");
|
||||
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
|
||||
const [scopeText, setScopeText] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const parsedScope = React.useMemo(() => parseCompanyScopeText(scopeText), [scopeText]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName("");
|
||||
setScope([editableScopeRule()]);
|
||||
setScopeText("");
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
@@ -1491,16 +1416,13 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
|
||||
toast.error("请填写企业名称");
|
||||
return;
|
||||
}
|
||||
const rules: CompanyScopeRule[] = scope
|
||||
.filter((rule) => rule.value.trim())
|
||||
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
|
||||
if (rules.some(scopeRuleError)) {
|
||||
if (parsedScope.errors.length > 0) {
|
||||
toast.error("请修正无效的资产范围");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.createCompany(name.trim(), rules);
|
||||
const res = await api.createCompany(name.trim(), parsedScope.rules);
|
||||
const added = res.scope_added ?? 0;
|
||||
const invalid = res.scope_invalid ?? 0;
|
||||
if (invalid > 0) toast.warning(`已创建企业,添加 ${added} 条范围;${invalid} 行无效`);
|
||||
@@ -1539,18 +1461,14 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>资产范围</FieldLabel>
|
||||
<FieldDescription>支持域名、IP、CIDR、ICP 备案和企业名称关键词。</FieldDescription>
|
||||
<ScopeRulesEditor value={scope} onValueChange={setScope} />
|
||||
</Field>
|
||||
<ScopeTextEditor id="cn-scope" value={scopeText} onValueChange={setScopeText} parsed={parsedScope} />
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>
|
||||
<Button onClick={submit} disabled={busy || !name.trim() || parsedScope.errors.length > 0}>
|
||||
{busy ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
@@ -1562,27 +1480,29 @@ function CompanyDialog({ onSaved }: { onSaved: () => void }) {
|
||||
// 编辑(覆盖)资产范围弹窗
|
||||
function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
|
||||
const [scopeText, setScopeText] = React.useState("");
|
||||
const [reason, setReason] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const preservedScopeRules = React.useMemo(() => savedScopeRules(company), [company]);
|
||||
const parsedScope = React.useMemo(
|
||||
() => parseCompanyScopeText(scopeText, { preservedRules: preservedScopeRules }),
|
||||
[preservedScopeRules, scopeText],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setScope(savedScopeRules(company));
|
||||
setScopeText(savedScopeText(company));
|
||||
setReason("");
|
||||
}, [open, company]);
|
||||
|
||||
const submit = async () => {
|
||||
const rules: CompanyScopeRule[] = scope
|
||||
.filter((rule) => rule.value.trim())
|
||||
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
|
||||
if (rules.some(scopeRuleError)) {
|
||||
if (parsedScope.errors.length > 0) {
|
||||
toast.error("请修正无效的资产范围");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.updateCompanyScope(company.id, rules, reason);
|
||||
const res = await api.updateCompanyScope(company.id, parsedScope.rules, reason);
|
||||
const errCount = res.invalid ?? 0;
|
||||
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
|
||||
else toast.success(`范围已更新,共 ${res.added} 条`);
|
||||
@@ -1609,23 +1529,28 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () =
|
||||
编辑后将替换全部现有范围。ICP 精确匹配资产,企业关键词仅作为 Agent 提示。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<ScopeRulesEditor value={scope} onValueChange={setScope} />
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="es-reason">归属依据(可选)</Label>
|
||||
<FieldGroup className="py-2">
|
||||
<ScopeTextEditor
|
||||
id={`edit-company-scope-${company.id}`}
|
||||
value={scopeText}
|
||||
onValueChange={setScopeText}
|
||||
parsed={parsedScope}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="es-reason">归属依据(可选)</FieldLabel>
|
||||
<Input
|
||||
id="es-reason"
|
||||
placeholder="如 证书 / whois / ASN 佐证"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy}>
|
||||
<Button onClick={submit} disabled={busy || parsedScope.errors.length > 0}>
|
||||
{busy ? "保存中…" : "覆盖保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -1637,31 +1562,33 @@ function EditScopeDialog({ company, onSaved }: { company: Company; onSaved: () =
|
||||
// 追加资产范围弹窗
|
||||
function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: () => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [scope, setScope] = React.useState<EditableScopeRule[]>(() => [editableScopeRule()]);
|
||||
const [scopeText, setScopeText] = React.useState("");
|
||||
const [reason, setReason] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const remainingScopeRules = Math.max(0, MAX_COMPANY_SCOPE_RULES - (company.scope?.length || 0));
|
||||
const parsedScope = React.useMemo(
|
||||
() => parseCompanyScopeText(scopeText, { maxRules: remainingScopeRules }),
|
||||
[remainingScopeRules, scopeText],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setScope([editableScopeRule()]);
|
||||
setScopeText("");
|
||||
setReason("");
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
const rules: CompanyScopeRule[] = scope
|
||||
.filter((rule) => rule.value.trim())
|
||||
.map((rule) => ({ kind: rule.kind, value: rule.value.trim() }));
|
||||
if (rules.length === 0) {
|
||||
if (parsedScope.rules.length === 0) {
|
||||
toast.error("请填写要追加的范围");
|
||||
return;
|
||||
}
|
||||
if (rules.some(scopeRuleError)) {
|
||||
if (parsedScope.errors.length > 0) {
|
||||
toast.error("请修正无效的资产范围");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.addCompanyScope(company.id, rules, reason);
|
||||
const res = await api.addCompanyScope(company.id, parsedScope.rules, reason);
|
||||
const errCount = res.invalid ?? 0;
|
||||
if (errCount > 0) toast.warning(`已保存;${errCount} 行无效`);
|
||||
else toast.success(`已追加 ${res.added} 条范围`);
|
||||
@@ -1686,23 +1613,28 @@ function AppendScopeDialog({ company, onSaved }: { company: Company; onSaved: ()
|
||||
<DialogTitle>追加资产范围 · {company.name}</DialogTitle>
|
||||
<DialogDescription>新范围会追加到现有范围。ICP 精确匹配资产,企业关键词仅作为 Agent 提示。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<ScopeRulesEditor value={scope} onValueChange={setScope} />
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="as-reason">归属依据(可选)</Label>
|
||||
<FieldGroup className="py-2">
|
||||
<ScopeTextEditor
|
||||
id={`append-company-scope-${company.id}`}
|
||||
value={scopeText}
|
||||
onValueChange={setScopeText}
|
||||
parsed={parsedScope}
|
||||
/>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="as-reason">归属依据(可选)</FieldLabel>
|
||||
<Input
|
||||
id="as-reason"
|
||||
placeholder="如 证书 / whois / ASN 佐证"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy}>
|
||||
<Button onClick={submit} disabled={busy || parsedScope.rules.length === 0 || parsedScope.errors.length > 0}>
|
||||
{busy ? "保存中…" : "追加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
TerminalIcon,
|
||||
SearchIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
Loader2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CommandRecord } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
@@ -50,7 +32,9 @@ function toolInput(raw: string): string {
|
||||
const obj = JSON.parse(raw);
|
||||
if (obj && typeof obj.command === "string") return obj.command;
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch { /* not JSON */ }
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -75,7 +59,7 @@ export default function CommandsPage() {
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
// Inline detail panel (like traffic page, not a dialog)
|
||||
// Selected execution is rendered in a right-side detail sheet.
|
||||
const [selected, setSelected] = React.useState<CommandRecord | null>(null);
|
||||
|
||||
// Debounce search input.
|
||||
@@ -85,6 +69,7 @@ export default function CommandsPage() {
|
||||
}, [query]);
|
||||
|
||||
// Reset page on filter change.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: every filter change intentionally resets pagination.
|
||||
React.useEffect(() => {
|
||||
setPage(0);
|
||||
}, [queryQ, taskFilter, size]);
|
||||
@@ -100,9 +85,15 @@ export default function CommandsPage() {
|
||||
setCommands(r.commands ?? []);
|
||||
setTotal(r.total ?? 0);
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => {
|
||||
if (!alive) return;
|
||||
setCommands([]);
|
||||
setTotal(0);
|
||||
})
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => { alive = false; };
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [page, size, queryQ, taskFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||
@@ -178,8 +169,8 @@ export default function CommandsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History table + inline detail (Burp-style split) */}
|
||||
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col gap-3">
|
||||
{/* History table */}
|
||||
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col">
|
||||
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<Table>
|
||||
@@ -210,18 +201,13 @@ export default function CommandsPage() {
|
||||
commands.map((cmd) => (
|
||||
<TableRow
|
||||
key={cmd.id}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selected?.id === cmd.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
className={cn("cursor-pointer", selected?.id === cmd.id && "bg-accent hover:bg-accent")}
|
||||
onClick={() => setSelected(cmd)}
|
||||
>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||
{fmtTime(cmd.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
#{cmd.exploration_id}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">#{cmd.exploration_id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{cmd.worker || "-"}
|
||||
@@ -239,9 +225,13 @@ export default function CommandsPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{cmd.is_error ? (
|
||||
<Badge variant="destructive" className="text-xs">失败</Badge>
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
失败
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
成功
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -251,67 +241,63 @@ export default function CommandsPage() {
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Inline detail panel */}
|
||||
{selected && (
|
||||
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
|
||||
{/* Detail header */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
#{selected.exploration_id}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{selected.worker || "-"}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{selected.tool || "-"}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fmtTime(selected.created_at)}
|
||||
</span>
|
||||
{selected.is_error ? (
|
||||
<Badge variant="destructive" className="text-xs">失败</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto size-7 shrink-0"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Input / Output split */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
输入 Input
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{toolInput(selected.command)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
输出 Output
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<pre className={cn(
|
||||
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.is_error && "text-red-600 dark:text-red-400",
|
||||
)}>
|
||||
{selected.output || "(空)"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[46rem]! sm:max-w-[46rem]!">
|
||||
{selected && (
|
||||
<>
|
||||
<SheetHeader className="border-b px-5 py-4">
|
||||
<SheetTitle className="pr-8">工具执行详情</SheetTitle>
|
||||
<SheetDescription>{fmtTime(selected.created_at)}</SheetDescription>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
#{selected.exploration_id}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{selected.worker || "-"}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{selected.tool || "-"}
|
||||
</Badge>
|
||||
{selected.is_error ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
失败
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
成功
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="grid min-h-0 flex-1 grid-rows-2 divide-y">
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">输入 Input</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<pre className="p-5 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{toolInput(selected.command)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">输出 Output</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<pre
|
||||
className={cn(
|
||||
"p-5 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.is_error && "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
{selected.output || "(空)"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,16 @@ import Link from "next/link";
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BugIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
InfoIcon,
|
||||
ShieldAlertIcon,
|
||||
Trash2Icon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -30,7 +34,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -529,13 +532,13 @@ export default function FindingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const statCards: { label: string; value: number; tone?: string }[] = [
|
||||
{ label: "发现总数", value: stats.total },
|
||||
{ label: "待处理", value: stats.pending, tone: "text-amber-500" },
|
||||
{ label: "严重", value: stats.critical, tone: "text-rose-600" },
|
||||
{ label: "高危", value: stats.high, tone: "text-red-500" },
|
||||
{ label: "中危", value: stats.medium, tone: "text-amber-500" },
|
||||
{ label: "低危", value: stats.low, tone: "text-slate-500" },
|
||||
const statCards = [
|
||||
{ label: "发现总数", value: stats.total, icon: BugIcon },
|
||||
{ label: "待处理", value: stats.pending, tone: "text-amber-500", icon: ClockIcon },
|
||||
{ label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
|
||||
{ label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
|
||||
{ label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
|
||||
{ label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -546,14 +549,20 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{statCards.map((s) => (
|
||||
<Card key={s.label} className="gap-1 py-4">
|
||||
<CardHeader className="px-4">
|
||||
<CardDescription>{s.label}</CardDescription>
|
||||
<CardTitle className={cn("text-2xl tabular-nums", s.tone)}>{s.value}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
{statCards.map((stat) => {
|
||||
const StatIcon = stat.icon;
|
||||
return (
|
||||
<Card key={stat.label} className="gap-1 py-4">
|
||||
<CardHeader className="px-4">
|
||||
<CardDescription>{stat.label}</CardDescription>
|
||||
<CardTitle className={cn("flex items-center gap-2 text-2xl tabular-nums", stat.tone)}>
|
||||
<StatIcon className="size-5" aria-hidden="true" />
|
||||
{stat.value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -624,7 +633,10 @@ export default function FindingsPage() {
|
||||
<span className="max-w-[14rem] truncate" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">{t.count}</span>
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground tabular-nums">
|
||||
<BugIcon className="size-3.5" aria-hidden="true" />
|
||||
{t.count}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
@@ -646,7 +658,6 @@ export default function FindingsPage() {
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">已选 {selectedIds.size} 条</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground tabular-nums">共 {total} 条</span>
|
||||
<Button size="sm" variant="outline" onClick={openExport}>
|
||||
<DownloadIcon /> 导出
|
||||
</Button>
|
||||
@@ -712,9 +723,6 @@ export default function FindingsPage() {
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
共 {group.count} 条
|
||||
</Badge>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{fmtTime(group.last_found_at)}</span>
|
||||
{group.task_id !== null && (
|
||||
<Button size="icon-sm" variant="ghost" asChild>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
BrainIcon,
|
||||
CircleCheckIcon,
|
||||
CircleSlashIcon,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
PlayIcon,
|
||||
RadioIcon,
|
||||
RotateCwIcon,
|
||||
SendIcon,
|
||||
ShieldAlertIcon,
|
||||
SquareIcon,
|
||||
Trash2Icon,
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -48,7 +47,6 @@ import { api, sseUrl } from "@/lib/api";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type {
|
||||
Activity,
|
||||
BatchControlItem,
|
||||
ChatAttachment,
|
||||
InterceptApprovalRow,
|
||||
Session,
|
||||
@@ -163,6 +161,36 @@ function fmtTokens(n: number): string {
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const TokenMetrics = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span"> & {
|
||||
input: number;
|
||||
cache: number;
|
||||
output: number;
|
||||
labels?: "short" | "long";
|
||||
}
|
||||
>(({ input, cache, output, labels = "short", className, ...props }, ref) => {
|
||||
const names = labels === "short" ? ["入", "缓", "出"] : ["input", "cache", "output"];
|
||||
const values = [input, cache, output];
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn("inline-flex min-w-0 flex-wrap items-center gap-1 text-muted-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
{values.map((value, index) => (
|
||||
<React.Fragment key={names[index]}>
|
||||
<span>{names[index]}</span>
|
||||
<Badge variant="secondary" className="h-5 px-1.5 font-mono tabular-nums">
|
||||
{fmtTokens(value)}
|
||||
</Badge>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
TokenMetrics.displayName = "TokenMetrics";
|
||||
|
||||
// fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s).
|
||||
function fmtDuration(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
@@ -277,12 +305,6 @@ function SessionItem({
|
||||
onResume,
|
||||
onCancel,
|
||||
controlling,
|
||||
resolution,
|
||||
tokens,
|
||||
selectable,
|
||||
selected,
|
||||
onSelectedChange,
|
||||
selectionDisabled,
|
||||
}: {
|
||||
s: Session;
|
||||
active: boolean;
|
||||
@@ -294,16 +316,7 @@ function SessionItem({
|
||||
onResume?: () => void;
|
||||
onCancel?: () => void;
|
||||
controlling?: boolean;
|
||||
resolution?: TaskLLMResolution;
|
||||
tokens?: TokenTotal;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onSelectedChange?: (checked: boolean) => void;
|
||||
selectionDisabled?: boolean;
|
||||
}) {
|
||||
const tokenCount = tokens
|
||||
? tokens.input_tokens + tokens.output_tokens + tokens.cache_read_tokens + tokens.cache_write_tokens
|
||||
: 0;
|
||||
const icon =
|
||||
s.role === "worker" ? (
|
||||
statusIcon(s.status)
|
||||
@@ -319,16 +332,6 @@ function SessionItem({
|
||||
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{selectable && (
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={(value) => onSelectedChange?.(value === true)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
disabled={selectionDisabled}
|
||||
className="ml-1 shrink-0"
|
||||
aria-label={`选择 Worker ${s.intent_id}`}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
@@ -345,32 +348,7 @@ function SessionItem({
|
||||
来源 #{s.source_task_id}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
|
||||
{resolution && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="min-w-0 max-w-24 shrink-0">
|
||||
<span className="min-w-0 truncate">{resolution.available ? resolution.model : "模型不可用"}</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs [overflow-wrap:anywhere]">
|
||||
{resolution.available
|
||||
? `${resolution.name} / ${resolution.format} / ${resolution.model}`
|
||||
: resolution.reason || "没有可用的 LLM 配置"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{tokens && tokenCount > 0 && s.role === "worker" && !s.inherited && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge className="shrink-0 font-mono tabular-nums">{fmtTokens(tokenCount)}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
输入 {tokens.input_tokens.toLocaleString()} · 输出 {tokens.output_tokens.toLocaleString()} · 缓存读取{" "}
|
||||
{tokens.cache_read_tokens.toLocaleString()} · 缓存写入 {tokens.cache_write_tokens.toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{displayTitle}</span>
|
||||
{hasPending && <ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />}
|
||||
{!active && unread ? (
|
||||
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-blue-500/15 px-1 text-[10px] font-medium tabular-nums text-blue-600 dark:text-blue-400">
|
||||
@@ -446,8 +424,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [mainChatRunning, setMainChatRunning] = React.useState<boolean | null>(null);
|
||||
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
|
||||
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
|
||||
const [selectedIntentIds, setSelectedIntentIds] = React.useState<Set<string>>(() => new Set());
|
||||
const [batchControlling, setBatchControlling] = React.useState<"pause" | "resume" | null>(null);
|
||||
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
|
||||
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
@@ -478,7 +454,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
|
||||
const controlWorker = React.useCallback(
|
||||
async (session: Session, action: "pause" | "resume" | "cancel") => {
|
||||
if (!session.intent_id || session.inherited || controllingIntent || batchControlling) return;
|
||||
if (!session.intent_id || session.inherited || controllingIntent) return;
|
||||
setControllingIntent(session.intent_id);
|
||||
try {
|
||||
const result = await api.controlIntent(taskId, session.intent_id, action);
|
||||
@@ -505,7 +481,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
setCancelIntent(null);
|
||||
}
|
||||
},
|
||||
[activeId, batchControlling, controllingIntent, patchIntentState, taskId],
|
||||
[activeId, controllingIntent, patchIntentState, taskId],
|
||||
);
|
||||
// SSE connection state — surfaced so a dropped realtime link is visible, never
|
||||
// silently shown as "no messages".
|
||||
@@ -758,7 +734,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
setFirstIntentsHasMore(false);
|
||||
setOlderIntentsHasMore(false);
|
||||
setHasLoadedOlderIntentsPage(false);
|
||||
setSelectedIntentIds(new Set());
|
||||
setTaskTokens(null);
|
||||
setSessionTokens({});
|
||||
setLLMResolutions(null);
|
||||
@@ -978,77 +953,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const workerSessions = React.useMemo(() => allIntents.map(intentToSession), [allIntents]);
|
||||
const intentsHasMore = hasLoadedOlderIntentsPage ? olderIntentsHasMore : firstIntentsHasMore;
|
||||
|
||||
const selectableIntentIds = React.useMemo(
|
||||
() =>
|
||||
allIntents
|
||||
.filter((node) => !node.inherited && (node.state === "running" || node.state === "paused"))
|
||||
.map((node) => node.id),
|
||||
[allIntents],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const allowed = new Set(selectableIntentIds);
|
||||
setSelectedIntentIds((current) => {
|
||||
const next = new Set([...current].filter((id) => allowed.has(id)));
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
}, [selectableIntentIds]);
|
||||
|
||||
const toggleIntentSelection = React.useCallback((id: string, checked: boolean) => {
|
||||
setSelectedIntentIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const batchControlWorkers = React.useCallback(
|
||||
async (action: "pause" | "resume") => {
|
||||
const requiredState = action === "pause" ? "running" : "paused";
|
||||
const ids = allIntents
|
||||
.filter((node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === requiredState)
|
||||
.map((node) => node.id);
|
||||
if (ids.length === 0 || batchControlling) return;
|
||||
setBatchControlling(action);
|
||||
try {
|
||||
const items: BatchControlItem[] = [];
|
||||
const requestErrors: Error[] = [];
|
||||
for (let offset = 0; offset < ids.length; offset += 100) {
|
||||
const chunk = ids.slice(offset, offset + 100);
|
||||
try {
|
||||
const response = await api.controlIntentsBatch(taskId, chunk, action);
|
||||
items.push(...response.items);
|
||||
} catch (error) {
|
||||
requestErrors.push(error as Error);
|
||||
items.push(...chunk.map((id) => ({ id, ok: false, error: (error as Error).message })));
|
||||
}
|
||||
}
|
||||
const succeeded = items.filter((item) => item.ok);
|
||||
const failed = items.length - succeeded.length;
|
||||
for (const item of succeeded) {
|
||||
patchIntentState(item.id, item.state ?? (action === "pause" ? "paused" : "open"));
|
||||
}
|
||||
setSelectedIntentIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const item of succeeded) next.delete(item.id);
|
||||
return next;
|
||||
});
|
||||
if (succeeded.length > 0)
|
||||
toast.success(`已${action === "pause" ? "暂停" : "继续"} ${succeeded.length} 个 Worker`);
|
||||
if (requestErrors.length > 0) {
|
||||
toast.error(`${requestErrors.length} 组批量请求失败,${failed} 个 Worker 未执行`);
|
||||
} else if (failed > 0) {
|
||||
toast.warning(`${failed} 个 Worker 状态已变化,未执行`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`批量操作失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
setBatchControlling(null);
|
||||
}
|
||||
},
|
||||
[allIntents, batchControlling, patchIntentState, selectedIntentIds, taskId],
|
||||
);
|
||||
|
||||
// For each worker session (intent), derive the display title from the intent
|
||||
// payload summary. Also store the full TaskNode for the hover-JSON tooltip.
|
||||
const sessionMeta = React.useMemo(() => {
|
||||
@@ -1217,7 +1121,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
i: activeTokens.input_tokens,
|
||||
o: activeTokens.output_tokens,
|
||||
cr: activeTokens.cache_read_tokens,
|
||||
cw: activeTokens.cache_write_tokens,
|
||||
any:
|
||||
activeTokens.input_tokens +
|
||||
activeTokens.output_tokens +
|
||||
@@ -1331,25 +1234,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const mainLoaded = !!store.main?.loaded;
|
||||
// What the transcript pane should show for the active session.
|
||||
const showLoader = !activeState || (activeState.loading && !activeState.loaded);
|
||||
const selectedPauseCount = allIntents.filter(
|
||||
(node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === "running",
|
||||
).length;
|
||||
const selectedResumeCount = allIntents.filter(
|
||||
(node) => selectedIntentIds.has(node.id) && !node.inherited && node.state === "paused",
|
||||
).length;
|
||||
const selectedSelectableCount = selectableIntentIds.filter((id) => selectedIntentIds.has(id)).length;
|
||||
let allSelectableChecked: boolean | "indeterminate" = false;
|
||||
if (selectableIntentIds.length > 0 && selectedSelectableCount === selectableIntentIds.length) {
|
||||
allSelectableChecked = true;
|
||||
} else if (selectedSelectableCount > 0) {
|
||||
allSelectableChecked = "indeterminate";
|
||||
}
|
||||
const resolutionForSession = (session: Session): TaskLLMResolution | undefined => {
|
||||
if (!llmResolutions || session.inherited || session.role === "system") return undefined;
|
||||
if (session.role === "mainagent") return llmResolutions.mainagent;
|
||||
if (session.role === "planner") return llmResolutions.planner;
|
||||
return llmResolutions.worker;
|
||||
};
|
||||
const activeResolution = resolutionForSession(active);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
@@ -1384,10 +1275,15 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
{taskTokens && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge className="mt-2 w-full min-w-0 justify-start font-mono tabular-nums">
|
||||
任务总计 · 入 {fmtTokens(taskTokens.input_tokens)} · 缓 {fmtTokens(taskTokens.cache_read_tokens)} ·
|
||||
出 {fmtTokens(taskTokens.output_tokens)}
|
||||
</Badge>
|
||||
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>任务总计</span>
|
||||
<span>·</span>
|
||||
<TokenMetrics
|
||||
input={taskTokens.input_tokens}
|
||||
cache={taskTokens.cache_read_tokens}
|
||||
output={taskTokens.output_tokens}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
输入 {taskTokens.input_tokens.toLocaleString()} · 输出 {taskTokens.output_tokens.toLocaleString()} ·
|
||||
@@ -1396,35 +1292,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedIntentIds.size > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<span className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
已选 {selectedIntentIds.size} 个 Worker
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
disabled={selectedPauseCount === 0 || batchControlling !== null}
|
||||
onClick={() => void batchControlWorkers("pause")}
|
||||
title={`暂停 ${selectedPauseCount} 个运行中 Worker`}
|
||||
aria-label="批量暂停 Worker"
|
||||
>
|
||||
{batchControlling === "pause" ? <Loader2Icon className="animate-spin" /> : <PauseIcon />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
disabled={selectedResumeCount === 0 || batchControlling !== null}
|
||||
onClick={() => void batchControlWorkers("resume")}
|
||||
title={`继续 ${selectedResumeCount} 个已暂停 Worker`}
|
||||
aria-label="批量继续 Worker"
|
||||
>
|
||||
{batchControlling === "resume" ? <Loader2Icon className="animate-spin" /> : <PlayIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea type="auto" className="min-h-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
|
||||
<div className="flex w-full flex-col gap-3 p-2">
|
||||
@@ -1435,23 +1302,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
return (
|
||||
<div key={role} className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{role === "worker" && selectableIntentIds.length > 0 && (
|
||||
<Checkbox
|
||||
checked={allSelectableChecked}
|
||||
onCheckedChange={(value) => {
|
||||
const checked = value === true;
|
||||
setSelectedIntentIds((current) => {
|
||||
const next = new Set(current);
|
||||
for (const id of selectableIntentIds) {
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
aria-label="选择全部可控制 Worker"
|
||||
/>
|
||||
)}
|
||||
<Meta.icon className="size-3.5" />
|
||||
{Meta.label}
|
||||
</div>
|
||||
@@ -1466,19 +1316,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
hasPending={hasPendingForSession(s)}
|
||||
unread={store[keyForSession(s)]?.unread}
|
||||
onClick={() => setActiveId(s.id)}
|
||||
controlling={batchControlling !== null || controllingIntent === s.intent_id}
|
||||
resolution={resolutionForSession(s)}
|
||||
tokens={tokenForSession(keyForSession(s))}
|
||||
selectable={
|
||||
s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused")
|
||||
}
|
||||
selected={selectedIntentIds.has(s.intent_id ?? "")}
|
||||
selectionDisabled={batchControlling !== null}
|
||||
onSelectedChange={(checked) => s.intent_id && toggleIntentSelection(s.intent_id, checked)}
|
||||
controlling={controllingIntent === s.intent_id}
|
||||
onPause={() => void controlWorker(s, "pause")}
|
||||
onResume={() => void controlWorker(s, "resume")}
|
||||
onCancel={() => {
|
||||
if (!batchControlling) setCancelIntent(s);
|
||||
setCancelIntent(s);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -1529,6 +1371,26 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
titleEl
|
||||
);
|
||||
})()}
|
||||
{activeResolution && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="max-w-28 shrink-0 font-mono font-normal"
|
||||
aria-label={activeResolution.available ? `当前模型:${activeResolution.model}` : "模型不可用"}
|
||||
>
|
||||
<span className="truncate">
|
||||
{activeResolution.available ? activeResolution.model : "模型不可用"}
|
||||
</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs [overflow-wrap:anywhere]">
|
||||
{activeResolution.available
|
||||
? `${activeResolution.name} / ${activeResolution.format} / ${activeResolution.model}`
|
||||
: activeResolution.reason || "没有可用的 LLM 配置"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{active.inherited && active.source_task_id && (
|
||||
<Badge variant="outline">来源任务 #{active.source_task_id} · 只读历史</Badge>
|
||||
)}
|
||||
@@ -1545,9 +1407,17 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
)}
|
||||
<div className="ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs text-muted-foreground max-sm:w-full max-sm:flex-wrap">
|
||||
{tokenTotal.any && (
|
||||
<span className="min-w-0 truncate" title="input / cache(read) / output tokens">
|
||||
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TokenMetrics input={tokenTotal.i} cache={tokenTotal.cr} output={tokenTotal.o} labels="long" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
输入 {activeTokens.input_tokens.toLocaleString()} · 输出{" "}
|
||||
{activeTokens.output_tokens.toLocaleString()} · 缓存读取{" "}
|
||||
{activeTokens.cache_read_tokens.toLocaleString()} · 缓存写入{" "}
|
||||
{activeTokens.cache_write_tokens.toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{runDuration != null && (
|
||||
<span className="inline-flex items-center gap-1" title="运行时长(首步 → 末步)">
|
||||
@@ -1673,7 +1543,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
title="发送消息"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
|
||||
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
RadioTowerIcon,
|
||||
SearchIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
XIcon,
|
||||
Loader2Icon,
|
||||
Trash2Icon,
|
||||
ListChecksIcon,
|
||||
Loader2Icon,
|
||||
RadioTowerIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -22,35 +22,19 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { TrafficExchange, TrafficResp, TrafficDetail, TrafficHost } from "@/lib/types";
|
||||
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
@@ -66,7 +50,7 @@ function fmtBytes(n: number) {
|
||||
if (n <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB"];
|
||||
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
|
||||
const v = n / Math.pow(1024, i);
|
||||
const v = n / 1024 ** i;
|
||||
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
@@ -78,6 +62,141 @@ function statusTone(status: number) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function MethodBadge({ method }: { method: string }) {
|
||||
return <Badge className="shrink-0 font-mono">{method}</Badge>;
|
||||
}
|
||||
|
||||
// Older captures may predate Host persistence because net/http keeps Host
|
||||
// outside Request.Header. Fill it for display while newly recorded traffic is
|
||||
// fixed at the recorder layer as well.
|
||||
function requestWithHost(raw: string, exchange: TrafficExchange): string {
|
||||
if (!raw.trim() || /^host\s*:/im.test(raw)) return raw;
|
||||
let host = exchange.host;
|
||||
try {
|
||||
host = new URL(exchange.url).host || host;
|
||||
} catch {
|
||||
// Relative or legacy URLs fall back to the indexed host.
|
||||
}
|
||||
const newline = raw.includes("\r\n") ? "\r\n" : "\n";
|
||||
const firstLineEnd = raw.indexOf(newline);
|
||||
if (firstLineEnd < 0) return `${raw}${newline}Host: ${host}`;
|
||||
return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`;
|
||||
}
|
||||
|
||||
function StartLine({ line }: { line: string }) {
|
||||
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
|
||||
if (response) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground">{response[1]}</span>
|
||||
{response[2]}
|
||||
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
|
||||
{response[4]}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const request = /^([A-Z]+)(\s+)(\S+)(\s+)(HTTP\/\S+)$/.exec(line);
|
||||
if (request) {
|
||||
return (
|
||||
<>
|
||||
<span className="font-semibold text-primary">{request[1]}</span>
|
||||
{request[2]}
|
||||
<span className="text-chart-2">{request[3]}</span>
|
||||
{request[4]}
|
||||
<span className="text-muted-foreground">{request[5]}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
function HeaderLine({ line }: { line: string }) {
|
||||
const separator = line.indexOf(":");
|
||||
if (separator <= 0) return line;
|
||||
return (
|
||||
<>
|
||||
<span className="text-primary">{line.slice(0, separator)}</span>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<span className="text-chart-2">{line.slice(separator + 1)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonBody({ body }: { body: string }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const tokens = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
|
||||
let cursor = 0;
|
||||
for (const match of body.matchAll(tokens)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > cursor) parts.push(body.slice(cursor, index));
|
||||
let className = "text-chart-4";
|
||||
if (match[1]) className = match[2] ? "text-primary" : "text-chart-2";
|
||||
else if (match[3]) className = "text-chart-3";
|
||||
parts.push(
|
||||
<span key={`${index}-${match[0].length}`} className={className}>
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (cursor < body.length) parts.push(body.slice(cursor));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function MarkupBody({ body }: { body: string }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const tags = /<\/?[A-Za-z][^>]*>|<!--[\s\S]*?-->/g;
|
||||
let cursor = 0;
|
||||
for (const match of body.matchAll(tags)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > cursor) parts.push(body.slice(cursor, index));
|
||||
parts.push(
|
||||
<span key={`${index}-${match[0].length}`} className="text-primary">
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (cursor < body.length) parts.push(body.slice(cursor));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function HighlightedBody({ body }: { body: string }) {
|
||||
const trimmed = body.trimStart();
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return <JsonBody body={body} />;
|
||||
if (trimmed.startsWith("<")) return <MarkupBody body={body} />;
|
||||
return body;
|
||||
}
|
||||
|
||||
function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
const value = raw || "(空)";
|
||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||
const separator = lines.indexOf("");
|
||||
const headerLines = separator >= 0 ? lines.slice(0, separator) : lines;
|
||||
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
|
||||
|
||||
return (
|
||||
<pre className="min-w-full whitespace-pre p-5 font-mono text-xs leading-relaxed">
|
||||
<code>
|
||||
{headerLines.map((line, index) => (
|
||||
<React.Fragment key={`${index}-${line}`}>
|
||||
{index === 0 ? <StartLine line={line} /> : <HeaderLine line={line} />}
|
||||
{index < headerLines.length - 1 ? "\n" : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{separator >= 0 && (
|
||||
<>
|
||||
{"\n\n"}
|
||||
<HighlightedBody body={body} />
|
||||
</>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed method set (server filters exact-match); avoids deriving options from a
|
||||
// single page, which would only ever list the methods on that page.
|
||||
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
|
||||
@@ -116,12 +235,14 @@ export default function TrafficPage() {
|
||||
}, [query]);
|
||||
|
||||
// Any filter/size change resets to the first page.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset.
|
||||
React.useEffect(() => {
|
||||
setPage(0);
|
||||
}, [hostQ, queryQ, method, size]);
|
||||
|
||||
// Load the current page. Auto-refresh only on page 0 (newest) so paging back
|
||||
// through history isn't yanked out from under the user.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => {
|
||||
@@ -130,13 +251,17 @@ export default function TrafficPage() {
|
||||
.then((r) => {
|
||||
if (alive) setTraffic(r);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
// Keep the last successful snapshot during transient refresh failures.
|
||||
});
|
||||
api
|
||||
.trafficHosts()
|
||||
.then((r) => {
|
||||
if (alive) setHosts(r.hosts ?? []);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
// Keep the last successful host list during transient refresh failures.
|
||||
});
|
||||
};
|
||||
load();
|
||||
const t = setInterval(() => {
|
||||
@@ -155,22 +280,21 @@ export default function TrafficPage() {
|
||||
|
||||
const confirmDelete = () => {
|
||||
setDeleting(true);
|
||||
const p =
|
||||
deleteMode === "selected"
|
||||
? api.trafficDeleteHosts(selectedHosts)
|
||||
: api.trafficDeleteHost(hostQ);
|
||||
const p = deleteMode === "selected" ? api.trafficDeleteHosts(selectedHosts) : api.trafficDeleteHost(hostQ);
|
||||
p.then(() => {
|
||||
setDeleteMode(null);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
if (deleteMode === "selected") {
|
||||
setSelectedHosts([]);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
setPage(0);
|
||||
setReloadTick((t) => t + 1);
|
||||
setDeleteMode(null);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
if (deleteMode === "selected") {
|
||||
setSelectedHosts([]);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
setPage(0);
|
||||
setReloadTick((t) => t + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the confirmation open so the user can retry a failed deletion.
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setDeleting(false));
|
||||
};
|
||||
|
||||
@@ -210,9 +334,7 @@ export default function TrafficPage() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">流量</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
全局录制代理 · 所有 HTTP 往来
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">全局录制代理 · 所有 HTTP 往来</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span
|
||||
@@ -226,11 +348,7 @@ export default function TrafficPage() {
|
||||
<RadioTowerIcon className="size-3.5" />
|
||||
{traffic?.enabled ? "录制中" : "已停用"}
|
||||
</span>
|
||||
{traffic?.proxy && (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{traffic.proxy}
|
||||
</span>
|
||||
)}
|
||||
{traffic?.proxy && <span className="font-mono text-xs text-muted-foreground">{traffic.proxy}</span>}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
共 <span className="tabular-nums">{traffic?.count ?? 0}</span> 条
|
||||
</span>
|
||||
@@ -266,29 +384,25 @@ export default function TrafficPage() {
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{hosts.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
暂无流量记录
|
||||
</div>
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">暂无流量记录</div>
|
||||
) : (
|
||||
hosts.map((h) => (
|
||||
hosts.map((h, index) => (
|
||||
<label
|
||||
key={h.host}
|
||||
htmlFor={`traffic-host-${index}`}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
id={`traffic-host-${index}`}
|
||||
checked={selectedHosts.includes(h.host)}
|
||||
onCheckedChange={() =>
|
||||
setSelectedHosts((prev) =>
|
||||
prev.includes(h.host)
|
||||
? prev.filter((x) => x !== h.host)
|
||||
: [...prev, h.host],
|
||||
prev.includes(h.host) ? prev.filter((x) => x !== h.host) : [...prev, h.host],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="truncate font-mono">{h.host}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">
|
||||
{h.count}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">{h.count}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
@@ -310,12 +424,7 @@ export default function TrafficPage() {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="relative w-48">
|
||||
<Input
|
||||
placeholder="host…"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
<Input placeholder="host…" value={host} onChange={(e) => setHost(e.target.value)} className="h-8" />
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -391,8 +500,8 @@ export default function TrafficPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History table + detail (Burp-style split) */}
|
||||
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col gap-3">
|
||||
{/* History table */}
|
||||
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col">
|
||||
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<Table>
|
||||
@@ -411,50 +520,29 @@ export default function TrafficPage() {
|
||||
{exchanges.map((e) => (
|
||||
<TableRow
|
||||
key={e.id}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
selected?.id === e.id && "bg-accent hover:bg-accent",
|
||||
)}
|
||||
className={cn("cursor-pointer", selected?.id === e.id && "bg-accent hover:bg-accent")}
|
||||
onClick={() => setSelected(e)}
|
||||
>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||||
{fmtTime(e.ts)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.host}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{e.method}
|
||||
</Badge>
|
||||
<MethodBadge method={e.method} />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0">
|
||||
<span className="block truncate font-mono text-xs">
|
||||
{e.url}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs">{e.url}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs font-semibold tabular-nums",
|
||||
statusTone(e.status),
|
||||
)}
|
||||
>
|
||||
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(e.status))}>
|
||||
{e.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{e.content_type}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
{fmtBytes(e.resp_len)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{e.content_type}</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">{fmtBytes(e.resp_len)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{exchanges.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="py-12 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<TableCell colSpan={7} className="py-12 text-center text-sm text-muted-foreground">
|
||||
{traffic === null ? "加载中…" : "没有匹配的流量。"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -463,66 +551,60 @@ export default function TrafficPage() {
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Raw request/response pane */}
|
||||
{selected && (
|
||||
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{selected.method}
|
||||
</Badge>
|
||||
<span className="truncate font-mono text-xs">
|
||||
{selected.host}
|
||||
{selected.url}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto font-mono text-xs font-semibold tabular-nums",
|
||||
statusTone(selected.status),
|
||||
)}
|
||||
>
|
||||
{selected.status}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
|
||||
{(
|
||||
[
|
||||
["请求 Request", detail?.req],
|
||||
["响应 Response", detail?.resp],
|
||||
] as const
|
||||
).map(([label, body]) => (
|
||||
<div key={label} className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{body || "(空)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteMode !== null} onOpenChange={(o) => { if (!o) setDeleteMode(null); }}>
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
{selected && (
|
||||
<>
|
||||
<SheetHeader className="border-b px-5 py-4">
|
||||
<div className="flex items-center gap-2 pr-8">
|
||||
<MethodBadge method={selected.method} />
|
||||
<Badge variant="secondary" className={cn("font-mono tabular-nums", statusTone(selected.status))}>
|
||||
{selected.status}
|
||||
</Badge>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{fmtTime(selected.ts)}</span>
|
||||
</div>
|
||||
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
|
||||
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Tabs defaultValue="request" className="min-h-0 flex-1 gap-0">
|
||||
<TabsList className="mx-5 mt-4 grid w-auto grid-cols-2">
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={requestWithHost(detail?.req ?? "", selected)} />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="response" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={detail?.resp ?? ""} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteMode !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeleteMode(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
@@ -533,9 +615,7 @@ export default function TrafficPage() {
|
||||
<AlertDialogDescription>
|
||||
{deleteMode === "selected" ? (
|
||||
<>
|
||||
将永久删除{" "}
|
||||
<span className="font-semibold tabular-nums">{selectedHosts.length}</span>{" "}
|
||||
个目标(
|
||||
将永久删除 <span className="font-semibold tabular-nums">{selectedHosts.length}</span> 个目标(
|
||||
<span className="font-mono">
|
||||
{selectedHosts.slice(0, 3).join("、")}
|
||||
{selectedHosts.length > 3 ? "…" : ""}
|
||||
@@ -544,8 +624,7 @@ export default function TrafficPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
将永久删除 host 包含{" "}
|
||||
<span className="font-mono font-semibold">{hostQ}</span>{" "}
|
||||
将永久删除 host 包含 <span className="font-mono font-semibold">{hostQ}</span>{" "}
|
||||
的所有流量记录(含请求/响应原文),此操作不可撤销。
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -24,17 +24,10 @@ import {
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -172,19 +165,19 @@ function TaskTemplateManager({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="grid max-h-[90svh] max-w-4xl grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-6 py-5">
|
||||
<DialogTitle>任务模板管理</DialogTitle>
|
||||
<DialogDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid min-h-0 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)] md:overflow-hidden">
|
||||
<div className="flex min-h-0 flex-col border-b p-3 md:border-r md:border-b-0">
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
<SheetHeader className="border-b px-6 py-5">
|
||||
<SheetTitle>任务模板管理</SheetTitle>
|
||||
<SheetDescription>模板只保存描述与目标;修改不会影响已经创建的任务。</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
|
||||
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
|
||||
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
新建模板
|
||||
</Button>
|
||||
<ScrollArea className="mt-2 max-h-44 md:max-h-[27rem]">
|
||||
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
|
||||
<div className="flex flex-col gap-1 pr-2">
|
||||
{templates.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-muted-foreground text-sm">暂无模板</p>
|
||||
@@ -206,7 +199,7 @@ function TaskTemplateManager({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[58svh]">
|
||||
<ScrollArea className="min-h-0">
|
||||
<FieldGroup className="p-6">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-name">模板名称</FieldLabel>
|
||||
@@ -241,7 +234,7 @@ function TaskTemplateManager({
|
||||
</FieldGroup>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
|
||||
{selectedID != null && (
|
||||
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
@@ -255,9 +248,9 @@ function TaskTemplateManager({
|
||||
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -303,6 +296,7 @@ export function TaskTemplateControls({
|
||||
}: TaskTemplateControlsProps) {
|
||||
const [templates, setTemplates] = React.useState<TaskTemplate[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [templateInputValue, setTemplateInputValue] = React.useState("");
|
||||
const [pendingTemplate, setPendingTemplate] = React.useState<TaskTemplate | null>(null);
|
||||
const [managerOpen, setManagerOpen] = React.useState(false);
|
||||
const [managerSeed, setManagerSeed] = React.useState<Pick<TemplateDraft, "description" | "goal"> | null>(null);
|
||||
@@ -323,25 +317,28 @@ export function TaskTemplateControls({
|
||||
void loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
const templateIDs = React.useMemo(() => templates.map((template) => String(template.id)), [templates]);
|
||||
const templatesByID = React.useMemo(
|
||||
() => new Map(templates.map((template) => [String(template.id), template])),
|
||||
[templates],
|
||||
const selectedTemplate = React.useMemo(
|
||||
() => templates.find((template) => template.id === selectedTemplateID) ?? null,
|
||||
[selectedTemplateID, templates],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTemplateInputValue(selectedTemplate?.name ?? "");
|
||||
}, [selectedTemplate]);
|
||||
|
||||
const applyTemplate = (template: TaskTemplate) => {
|
||||
onApply(template);
|
||||
onSelectedTemplateIDChange(template.id);
|
||||
setPendingTemplate(null);
|
||||
};
|
||||
|
||||
const chooseTemplate = (value: string | null) => {
|
||||
if (!value) {
|
||||
const chooseTemplate = (template: TaskTemplate | null) => {
|
||||
if (!template) {
|
||||
setTemplateInputValue("");
|
||||
onSelectedTemplateIDChange(null);
|
||||
return;
|
||||
}
|
||||
const template = templatesByID.get(value);
|
||||
if (!template) return;
|
||||
setTemplateInputValue(template.name);
|
||||
const hasContent = description.trim() !== "" || goal.trim() !== "";
|
||||
const changesContent = description !== template.description || goal !== template.goal;
|
||||
if (hasContent && changesContent) {
|
||||
@@ -391,9 +388,12 @@ export function TaskTemplateControls({
|
||||
</div>
|
||||
</div>
|
||||
<Combobox
|
||||
items={templateIDs}
|
||||
itemToStringValue={(id) => templatesByID.get(id)?.name ?? id}
|
||||
value={selectedTemplateID == null ? null : String(selectedTemplateID)}
|
||||
items={templates}
|
||||
itemToStringLabel={(template) => template.name}
|
||||
itemToStringValue={(template) => String(template.id)}
|
||||
inputValue={templateInputValue}
|
||||
onInputValueChange={(value) => setTemplateInputValue(value)}
|
||||
value={selectedTemplate}
|
||||
onValueChange={chooseTemplate}
|
||||
>
|
||||
<ComboboxInput
|
||||
@@ -406,27 +406,31 @@ export function TaskTemplateControls({
|
||||
<ComboboxContent portalContainer={portalContainer}>
|
||||
<ComboboxEmpty>没有匹配的模板</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id) => {
|
||||
const template = templatesByID.get(id);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id}>
|
||||
<LibraryIcon />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-sm">{template?.name ?? `模板 #${id}`}</p>
|
||||
{template?.description && (
|
||||
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
{(template) => (
|
||||
<ComboboxItem key={template.id} value={template}>
|
||||
<LibraryIcon />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-sm">{template.name}</p>
|
||||
{template.description && (
|
||||
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<FieldDescription>选择后会复制模板的描述和目标,不与模板保持关联。</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<AlertDialog open={pendingTemplate != null} onOpenChange={(open) => !open && setPendingTemplate(null)}>
|
||||
<AlertDialog
|
||||
open={pendingTemplate != null}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
setPendingTemplate(null);
|
||||
setTemplateInputValue(selectedTemplate?.name ?? "");
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>使用模板「{pendingTemplate?.name}」?</AlertDialogTitle>
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
AgentDetail,
|
||||
AgentTrigger,
|
||||
Asset,
|
||||
AssetNode,
|
||||
Audit,
|
||||
BatchControlItem,
|
||||
ChatAttachment,
|
||||
@@ -56,7 +55,6 @@ import type {
|
||||
SSTask,
|
||||
Stats,
|
||||
Task,
|
||||
TaskAssetView,
|
||||
TaskLLMResolutions,
|
||||
TaskNode,
|
||||
TaskScopeRow,
|
||||
@@ -230,11 +228,6 @@ export const api = {
|
||||
state: "paused" | "open" | "cancelled";
|
||||
deleted?: { intents: number; facts: number; findings: number; activities: number };
|
||||
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action }),
|
||||
controlIntentsBatch: (taskId: string, intentIds: string[], action: "pause" | "resume") =>
|
||||
post<{ items: BatchControlItem[] }>(`/tasks/${taskId}/intents/control/batch`, {
|
||||
intent_ids: intentIds,
|
||||
action,
|
||||
}),
|
||||
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/tasks/${id}/llm/resolution`),
|
||||
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
|
||||
rerunIntent: (taskId: string, intentId: string) =>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { CompanyScopeKind, CompanyScopeRule } from "@/lib/types";
|
||||
|
||||
export const MAX_COMPANY_SCOPE_RULES = 256;
|
||||
export const MAX_COMPANY_SCOPE_VALUE_LENGTH = 1024;
|
||||
|
||||
export type CompanyScopeTextIssue = {
|
||||
line: number;
|
||||
rule?: CompanyScopeRule;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ParsedCompanyScopeText = {
|
||||
rules: CompanyScopeRule[];
|
||||
errors: Array<{ line: number; error: string }>;
|
||||
};
|
||||
|
||||
export const COMPANY_SCOPE_KINDS = new Set<CompanyScopeKind>(["domain", "ip", "cidr", "icp", "keyword"]);
|
||||
|
||||
export function isCompanyScopeKind(value: unknown): value is CompanyScopeKind {
|
||||
return typeof value === "string" && COMPANY_SCOPE_KINDS.has(value as CompanyScopeKind);
|
||||
}
|
||||
|
||||
function isIPv4(value: string): boolean {
|
||||
const parts = value.split(".");
|
||||
return (
|
||||
parts.length === 4 && parts.every((part) => (part === "0" || /^[1-9]\d{0,2}$/.test(part)) && Number(part) <= 255)
|
||||
);
|
||||
}
|
||||
|
||||
function isIPv6(value: string): boolean {
|
||||
if (!value.includes(":") || /[^0-9a-f:.]/i.test(value) || value.includes(":::")) return false;
|
||||
const halves = value.split("::");
|
||||
if (halves.length > 2) return false;
|
||||
const countSegments = (half: string): number | null => {
|
||||
if (!half) return 0;
|
||||
const segments = half.split(":");
|
||||
let count = 0;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (segment.includes(".")) {
|
||||
if (index !== segments.length - 1 || !isIPv4(segment)) return null;
|
||||
count += 2;
|
||||
} else {
|
||||
if (!/^[0-9a-f]{1,4}$/i.test(segment)) return null;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const left = countSegments(halves[0]);
|
||||
const right = countSegments(halves[1] ?? "");
|
||||
if (left === null || right === null) return false;
|
||||
return halves.length === 2 ? left + right < 8 : left === 8;
|
||||
}
|
||||
|
||||
function ipVersion(value: string): 4 | 6 | null {
|
||||
if (isIPv4(value)) return 4;
|
||||
if (isIPv6(value)) return 6;
|
||||
return null;
|
||||
}
|
||||
|
||||
function looksLikeIPAddress(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.includes("://") || /\s/.test(trimmed)) return false;
|
||||
if ((trimmed.match(/:/g) ?? []).length >= 2) {
|
||||
const parts = trimmed.split(":");
|
||||
let validSegments = 0;
|
||||
for (const part of parts) {
|
||||
if (!part) {
|
||||
if (validSegments > 0 || trimmed.startsWith("::")) return true;
|
||||
continue;
|
||||
}
|
||||
if (part.length > 4 || !/^[0-9a-f]+$/i.test(part)) return false;
|
||||
validSegments++;
|
||||
if (validSegments >= 2) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return trimmed.includes(".") && /^[0-9.]+$/.test(trimmed);
|
||||
}
|
||||
|
||||
function domainHostname(value: string): string | null {
|
||||
try {
|
||||
let candidate = value;
|
||||
if (value.startsWith("//")) candidate = `http:${value}`;
|
||||
else if (!value.includes("://")) candidate = `http://${value}`;
|
||||
const url = new URL(candidate);
|
||||
return (
|
||||
url.hostname
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.replace(/\.$/, "")
|
||||
.toLowerCase() || null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function companyScopeRuleError(rule: CompanyScopeRule): string {
|
||||
const value = rule.value.trim();
|
||||
if (!value) return "请填写范围值";
|
||||
if (Array.from(value).length > MAX_COMPANY_SCOPE_VALUE_LENGTH) {
|
||||
return `最多 ${MAX_COMPANY_SCOPE_VALUE_LENGTH} 个字符`;
|
||||
}
|
||||
if (rule.kind === "domain") {
|
||||
if (/\s/.test(value)) return "请输入不含空格的有效域名或 URL";
|
||||
const hostname = domainHostname(value);
|
||||
if (!hostname || ipVersion(hostname) !== null) return "请输入有效域名或 URL";
|
||||
const labels = hostname.split(".");
|
||||
if (
|
||||
hostname.length > 253 ||
|
||||
labels.length < 2 ||
|
||||
labels.some(
|
||||
(label) =>
|
||||
!label || label.length > 63 || label.startsWith("-") || label.endsWith("-") || !/^[a-z0-9-]+$/i.test(label),
|
||||
)
|
||||
) {
|
||||
return "请输入有效域名或 URL";
|
||||
}
|
||||
}
|
||||
if (rule.kind === "ip" && ipVersion(value) === null) return "请输入有效 IP";
|
||||
if (rule.kind === "cidr") {
|
||||
const separator = value.lastIndexOf("/");
|
||||
if (separator <= 0) return "请输入 CIDR 网段";
|
||||
const address = value.slice(0, separator);
|
||||
const prefixText = value.slice(separator + 1);
|
||||
const version = ipVersion(address);
|
||||
if (version === null || !/^\d+$/.test(prefixText)) return "请输入 CIDR 网段";
|
||||
const prefix = Number(prefixText);
|
||||
if (version === 4 && (prefix < 16 || prefix > 32)) return "IPv4 网段前缀需为 /16 至 /32";
|
||||
if (version === 6 && (prefix < 32 || prefix > 128)) return "IPv6 网段前缀需为 /32 至 /128";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function classifyCompanyScopeLine(
|
||||
raw: string,
|
||||
line: number,
|
||||
preservedRule?: CompanyScopeRule,
|
||||
): CompanyScopeTextIssue {
|
||||
const value = raw.trim();
|
||||
if (Array.from(value).length > MAX_COMPANY_SCOPE_VALUE_LENGTH) {
|
||||
return { line, error: `最多 ${MAX_COMPANY_SCOPE_VALUE_LENGTH} 个字符` };
|
||||
}
|
||||
if (preservedRule) {
|
||||
const rule = { kind: preservedRule.kind, value };
|
||||
const error = companyScopeRuleError(rule);
|
||||
return error ? { line, error } : { line, rule };
|
||||
}
|
||||
|
||||
const separator = value.lastIndexOf("/");
|
||||
if (
|
||||
separator > 0 &&
|
||||
(ipVersion(value.slice(0, separator)) !== null || looksLikeIPAddress(value.slice(0, separator)))
|
||||
) {
|
||||
const rule: CompanyScopeRule = { kind: "cidr", value };
|
||||
const error = companyScopeRuleError(rule);
|
||||
return error ? { line, error } : { line, rule };
|
||||
}
|
||||
if (ipVersion(value) !== null) return { line, rule: { kind: "ip", value } };
|
||||
if (looksLikeIPAddress(value)) return { line, error: "请输入有效 IP" };
|
||||
|
||||
const looksLikeDomain = value.includes("://") || (!/\s/.test(value) && value.includes("."));
|
||||
if (looksLikeDomain) {
|
||||
const hostname = domainHostname(value);
|
||||
if (hostname && ipVersion(hostname) !== null) return { line, rule: { kind: "ip", value: hostname } };
|
||||
const rule: CompanyScopeRule = { kind: "domain", value };
|
||||
const error = companyScopeRuleError(rule);
|
||||
return error ? { line, error } : { line, rule };
|
||||
}
|
||||
if (/icp|备案/i.test(value)) return { line, rule: { kind: "icp", value } };
|
||||
return { line, rule: { kind: "keyword", value } };
|
||||
}
|
||||
|
||||
export function parseCompanyScopeText(
|
||||
value: string,
|
||||
options: { preservedRules?: CompanyScopeRule[]; maxRules?: number } = {},
|
||||
): ParsedCompanyScopeText {
|
||||
const nonEmpty = value
|
||||
.split(/\r?\n/)
|
||||
.map((raw, index) => ({ raw, line: index + 1 }))
|
||||
.filter(({ raw }) => raw.trim());
|
||||
const preserved = new Map<string, CompanyScopeRule[]>();
|
||||
for (const rule of options.preservedRules ?? []) {
|
||||
const key = rule.value.trim();
|
||||
preserved.set(key, [...(preserved.get(key) ?? []), rule]);
|
||||
}
|
||||
const issues = nonEmpty.map(({ raw, line }) => {
|
||||
const key = raw.trim();
|
||||
const queue = preserved.get(key);
|
||||
const preservedRule = queue?.shift();
|
||||
return classifyCompanyScopeLine(raw, line, preservedRule);
|
||||
});
|
||||
const maxRules = options.maxRules ?? MAX_COMPANY_SCOPE_RULES;
|
||||
if (nonEmpty.length > maxRules) {
|
||||
issues.push({
|
||||
line: nonEmpty[Math.max(0, maxRules)]?.line ?? 1,
|
||||
error: `最多允许 ${maxRules} 条范围,当前 ${nonEmpty.length} 条`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
rules: issues.flatMap((item) => (item.rule ? [item.rule] : [])),
|
||||
errors: issues.flatMap((item) => (item.error ? [{ line: item.line, error: item.error }] : [])),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCompanyScopeValue(rule: CompanyScopeRule): string {
|
||||
const value = rule.value.trim();
|
||||
if (rule.kind === "domain") return domainHostname(value) ?? value.toLowerCase();
|
||||
if (rule.kind === "icp") return value.toLowerCase().replace(/\s/gu, "");
|
||||
if (rule.kind === "keyword") return value.toLowerCase().split(/\s+/u).filter(Boolean).join(" ");
|
||||
return value.toLowerCase();
|
||||
}
|
||||
+69
-27
@@ -2,6 +2,13 @@
|
||||
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
|
||||
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
|
||||
|
||||
import {
|
||||
classifyCompanyScopeLine,
|
||||
companyScopeRuleError,
|
||||
isCompanyScopeKind,
|
||||
MAX_COMPANY_SCOPE_RULES,
|
||||
normalizeCompanyScopeValue,
|
||||
} from "../company-scope";
|
||||
import type {
|
||||
Asset,
|
||||
BatchControlItem,
|
||||
@@ -36,24 +43,53 @@ function mockAssetCounts(): Record<string, number> {
|
||||
}, {});
|
||||
}
|
||||
|
||||
function mockScopeRows(companyID: number, input: unknown): ScopeRow[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
function mockScopeRows(
|
||||
companyID: number,
|
||||
input: unknown,
|
||||
existing: ScopeRow[] = [],
|
||||
): { rows: ScopeRow[]; invalid: number; skipped: number } {
|
||||
if (!Array.isArray(input)) return { rows: [], invalid: 0, skipped: 0 };
|
||||
if (input.length > MAX_COMPANY_SCOPE_RULES) throw new Error(`企业范围最多支持 ${MAX_COMPANY_SCOPE_RULES} 条`);
|
||||
let nextID =
|
||||
mockCompanies.flatMap((company) => company.scope ?? []).reduce((max, row) => Math.max(max, row.id), 0) + 1;
|
||||
const rows: ScopeRow[] = [];
|
||||
for (const candidate of input) {
|
||||
if (!candidate || typeof candidate !== "object") continue;
|
||||
const rule = candidate as Partial<CompanyScopeRule>;
|
||||
const kind = rule.kind;
|
||||
const value = String(rule.value ?? "").trim();
|
||||
if (!kind || !value) continue;
|
||||
const row: ScopeRow = { id: nextID++, company_id: companyID, kind, raw: value };
|
||||
if (kind === "domain") row.domain = value.toLowerCase();
|
||||
else if (kind === "ip" || kind === "cidr") row.net = value;
|
||||
else row.value = value;
|
||||
let invalid = 0;
|
||||
let skipped = 0;
|
||||
const keys = new Set(
|
||||
existing.map((row) => `${row.kind}|${row.domain ?? row.net ?? row.value ?? row.raw.trim().toLowerCase()}`),
|
||||
);
|
||||
for (const [index, candidate] of input.entries()) {
|
||||
let rule: CompanyScopeRule | undefined;
|
||||
if (typeof candidate === "string") {
|
||||
rule = classifyCompanyScopeLine(candidate, index + 1).rule;
|
||||
} else if (candidate && typeof candidate === "object") {
|
||||
const item = candidate as { kind?: unknown; value?: unknown };
|
||||
const value = String(item.value ?? "").trim();
|
||||
if (item.kind === undefined || item.kind === "") rule = classifyCompanyScopeLine(value, index + 1).rule;
|
||||
else if (isCompanyScopeKind(item.kind)) rule = { kind: item.kind, value };
|
||||
}
|
||||
if (!rule || companyScopeRuleError(rule)) {
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeCompanyScopeValue(rule);
|
||||
const row: ScopeRow = { id: nextID++, company_id: companyID, kind: rule.kind, raw: rule.value.trim() };
|
||||
if (rule.kind === "domain") row.domain = normalized;
|
||||
else if (rule.kind === "ip") row.net = `${normalized}/${normalized.includes(":") ? 128 : 32}`;
|
||||
else if (rule.kind === "cidr") row.net = normalized;
|
||||
else row.value = normalized;
|
||||
const key = `${row.kind}|${row.domain ?? row.net ?? row.value}`;
|
||||
if (keys.has(key)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
keys.add(key);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
if (existing.length + rows.length > MAX_COMPANY_SCOPE_RULES) {
|
||||
throw new Error(`企业范围规则过多:每个企业最多 ${MAX_COMPANY_SCOPE_RULES} 条`);
|
||||
}
|
||||
return { rows, invalid, skipped };
|
||||
}
|
||||
|
||||
function mockProfileResolution(profileID: number | undefined, source: TaskLLMResolution["source"]): TaskLLMResolution {
|
||||
@@ -109,15 +145,17 @@ function bodyIDs(value: unknown): string[] {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function controlMockIntent(id: string, action: "pause" | "resume"): BatchControlItem {
|
||||
type MockIntentControlResult = { ok: boolean; state?: string; error?: string };
|
||||
|
||||
function controlMockIntent(id: string, action: "pause" | "resume"): MockIntentControlResult {
|
||||
const intent = mockIntents.find((item) => item.id === id);
|
||||
if (!intent) return { id, ok: false, error: "意图不存在" };
|
||||
if (!intent) return { ok: false, error: "意图不存在" };
|
||||
const requiredState = action === "pause" ? "running" : "paused";
|
||||
if (intent.inherited || intent.state !== requiredState) {
|
||||
return { id, ok: false, state: intent.state, error: "Worker 状态已变化" };
|
||||
return { ok: false, state: intent.state, error: "Worker 状态已变化" };
|
||||
}
|
||||
intent.state = action === "pause" ? "paused" : "open";
|
||||
return { id, ok: true, state: intent.state };
|
||||
return { ok: true, state: intent.state };
|
||||
}
|
||||
|
||||
function controlMockTask(id: string, action: "pause" | "resume"): BatchControlItem {
|
||||
@@ -357,10 +395,6 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const action = b.action === "resume" ? "resume" : "pause";
|
||||
return { items: bodyIDs(b.task_ids).map((id) => controlMockTask(id, action)) };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "intents" && seg[3] === "control" && seg[4] === "batch" && m === "POST") {
|
||||
const action = b.action === "resume" ? "resume" : "pause";
|
||||
return { items: bodyIDs(b.intent_ids).map((id) => controlMockIntent(id, action)) };
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "intents" && seg[4] === "control" && m === "POST") {
|
||||
const id = seg[3];
|
||||
if (b.action === "cancel") {
|
||||
@@ -515,17 +549,25 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
throw new Error("企业已存在");
|
||||
}
|
||||
const id = mockCompanies.reduce((max, company) => Math.max(max, company.id), 0) + 1;
|
||||
const scope = mockScopeRows(id, b.scope);
|
||||
const company: Company = { id, name, asset_count: 0, scope };
|
||||
const scopeResult = mockScopeRows(id, b.scope);
|
||||
const company: Company = { id, name, asset_count: 0, scope: scopeResult.rows };
|
||||
mockCompanies.push(company);
|
||||
return { id, created: true, scope_added: scope.length, scope_invalid: 0 };
|
||||
return {
|
||||
id,
|
||||
created: true,
|
||||
scope_added: scopeResult.rows.length,
|
||||
scope_skipped: scopeResult.skipped,
|
||||
scope_invalid: scopeResult.invalid,
|
||||
};
|
||||
}
|
||||
if (seg[0] === "companies" && seg[2] === "scope" && m === "POST") {
|
||||
const company = mockCompanies.find((item) => item.id === Number(seg[1]));
|
||||
if (!company) throw new Error("企业不存在");
|
||||
const rows = mockScopeRows(company.id, b.scope);
|
||||
company.scope = b.reset === true ? rows : [...(company.scope ?? []), ...rows];
|
||||
return { added: rows.length, skipped: 0, invalid: 0 };
|
||||
const reset = b.reset === true;
|
||||
const scopeResult = mockScopeRows(company.id, b.scope, reset ? [] : (company.scope ?? []));
|
||||
if (reset && scopeResult.invalid > 0) throw new Error("企业范围包含无效规则,未覆盖原有范围");
|
||||
company.scope = reset ? scopeResult.rows : [...(company.scope ?? []), ...scopeResult.rows];
|
||||
return { added: scopeResult.rows.length, skipped: scopeResult.skipped, invalid: scopeResult.invalid };
|
||||
}
|
||||
if (seg[0] === "companies" && seg.length === 2 && m === "DELETE") {
|
||||
const id = Number(seg[1]);
|
||||
|
||||
@@ -555,7 +555,6 @@ export interface BatchControlItem {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
status?: string;
|
||||
state?: string;
|
||||
queued?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user