Merge PR #139: 支持按状态和判定来源筛选审批记录
合并 codex/approval-record-filters,解决与 #133 在 web/src/components/approval-records.tsx 的冲突: - 导入同时保留 InterceptApprovalFilter 与 InterceptPending。 - load() 采用 #133 的独立待审批队列(Promise.allSettled 拉取 /intercept/pending),并把 #139 的 filter 参数透传给历史分页调用; 筛选只作用于历史记录,待处理队列不受 filter 影响,保证跨页/被筛 记录仍可审批。 - decide() 保留 #133 的乐观更新(移除 pendingRows 对应项)叠加 #139 决策后重新拉取以对齐筛选后的计数与行。 验证:前端 tsc 通过、Biome 仅剩预存告警;go build/vet 通过; db TestInterceptApprovalFilters(8 子用例)与 server TestInterceptFilterHTTP 在本地 dev 库均通过。 Closes #139
This commit is contained in:
commit
13ce2ea6e3
8 files changed
+436
-45
No files matched your search
@@ -21,10 +21,19 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow, InterceptAudit, InterceptDetail, InterceptPending, InterceptReviewInput } from "@/lib/types";
|
||||
import type {
|
||||
InterceptApprovalFilter,
|
||||
InterceptApprovalRow,
|
||||
InterceptAudit,
|
||||
InterceptDetail,
|
||||
InterceptPending,
|
||||
InterceptReviewInput,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function fmtTime(value?: string) {
|
||||
@@ -644,6 +653,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [pageSize, setPageSize] = React.useState(20);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [filter, setFilter] = React.useState<InterceptApprovalFilter>({});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
@@ -652,6 +662,8 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
const [revision, setRevision] = React.useState(0);
|
||||
const request = React.useRef(0);
|
||||
const decisionLock = React.useRef(false);
|
||||
const filterID = React.useId();
|
||||
const filtered = Boolean(filter.status || filter.decision_source);
|
||||
|
||||
// A task can stay mounted while the user switches between task details.
|
||||
// Reset the cursor so the new scope always starts at its newest records.
|
||||
@@ -665,10 +677,11 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
const id = ++request.current;
|
||||
if (manual) setRefreshing(true);
|
||||
try {
|
||||
// The approval queue is independent of the current history page. Older
|
||||
// requests must remain actionable even when newer decisions fill it.
|
||||
// The approval queue is independent of the current history page and its
|
||||
// filter. Older requests must remain actionable even when newer decisions
|
||||
// fill the page or a filter would hide them.
|
||||
const [history, pending] = await Promise.allSettled([
|
||||
taskId ? api.interceptTaskPage(taskId, page, pageSize) : api.interceptHistoryPage(page, pageSize),
|
||||
taskId ? api.interceptTaskPage(taskId, page, pageSize, filter) : api.interceptHistoryPage(page, pageSize, filter),
|
||||
api.interceptPending(),
|
||||
]);
|
||||
if (id !== request.current) return;
|
||||
@@ -695,8 +708,10 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[taskId, page, pageSize],
|
||||
[taskId, page, pageSize, filter],
|
||||
);
|
||||
const latestLoad = React.useRef(load);
|
||||
latestLoad.current = load;
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
@@ -712,6 +727,17 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeFilter = (next: InterceptApprovalFilter) => {
|
||||
// An in-flight response for the old filter must not repopulate the table.
|
||||
request.current++;
|
||||
setFilter(next);
|
||||
setPage(1);
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError("");
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
const decide: Decide = async (id, decision) => {
|
||||
if (decisionLock.current) return;
|
||||
decisionLock.current = true;
|
||||
@@ -720,15 +746,19 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
await api.interceptDecide(id, decision);
|
||||
// Invalidate a list request started before this decision.
|
||||
request.current++;
|
||||
// Optimistically drop the decided item from the independent pending queue
|
||||
// for instant feedback; the re-fetch below reconciles with server truth.
|
||||
setRows((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
|
||||
);
|
||||
setPendingRows((prev) => prev.filter((row) => row.id !== id));
|
||||
setRevision((v) => v + 1);
|
||||
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
|
||||
// Re-fetch counts and rows: a decided item may no longer match the filter.
|
||||
await latestLoad.current(true);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
await load(true);
|
||||
await latestLoad.current(true);
|
||||
} finally {
|
||||
decisionLock.current = false;
|
||||
setDeciding(false);
|
||||
@@ -760,6 +790,62 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。</p>
|
||||
<FieldGroup className="flex-row flex-wrap items-end gap-3" aria-label="审批记录筛选">
|
||||
<Field className="w-full sm:w-40">
|
||||
<FieldLabel htmlFor={`${filterID}-status`}>审批状态</FieldLabel>
|
||||
<Select
|
||||
value={filter.status ?? "all"}
|
||||
onValueChange={(value) =>
|
||||
changeFilter({
|
||||
...filter,
|
||||
status: value === "all" ? undefined : (value as InterceptApprovalFilter["status"]),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${filterID}-status`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="denied">已拒绝</SelectItem>
|
||||
<SelectItem value="pending">待审批</SelectItem>
|
||||
<SelectItem value="allowed">已允许</SelectItem>
|
||||
<SelectItem value="timeout">已超时</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="w-full sm:w-40">
|
||||
<FieldLabel htmlFor={`${filterID}-source`}>判定来源</FieldLabel>
|
||||
<Select
|
||||
value={filter.decision_source ?? "all"}
|
||||
onValueChange={(value) =>
|
||||
changeFilter({
|
||||
...filter,
|
||||
decision_source: value === "all" ? undefined : (value as InterceptApprovalFilter["decision_source"]),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${filterID}-source`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">全部来源</SelectItem>
|
||||
<SelectItem value="model">模型判定</SelectItem>
|
||||
<SelectItem value="rule">规则判定</SelectItem>
|
||||
<SelectItem value="unknown">来源未知</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{filtered ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => changeFilter({})}>
|
||||
清除筛选
|
||||
</Button>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>记录加载失败:{error}。请点击刷新重试。</AlertDescription>
|
||||
@@ -780,7 +866,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
</section>
|
||||
) : null}
|
||||
<section className="overflow-hidden rounded-xl border">
|
||||
<div className="border-b px-4 py-3 font-medium text-sm">全部记录({total})</div>
|
||||
<div className="border-b px-4 py-3 font-medium text-sm">
|
||||
{filtered ? "筛选结果" : "全部记录"}({total})
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
@@ -794,8 +882,12 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
<EmptyMedia variant="icon">
|
||||
<ClipboardListIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>暂无审批记录</EmptyTitle>
|
||||
<EmptyDescription>规则或模型作出审批决定后,记录将显示在这里。</EmptyDescription>
|
||||
<EmptyTitle>{filtered ? "没有符合筛选条件的审批记录" : "暂无审批记录"}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{filtered
|
||||
? "请调整审批状态或判定来源,或清除筛选查看全部记录。"
|
||||
: "规则或模型作出审批决定后,记录将显示在这里。"}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : null}
|
||||
|
||||
+12
-4
@@ -43,6 +43,7 @@ import type {
|
||||
FindingTraffic,
|
||||
FindingTrafficDetail,
|
||||
IntentAsset,
|
||||
InterceptApprovalFilter,
|
||||
InterceptApprovalRow,
|
||||
InterceptDetail,
|
||||
InterceptPending,
|
||||
@@ -205,6 +206,13 @@ function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): URLSea
|
||||
return p;
|
||||
}
|
||||
|
||||
function interceptPageQuery(page: number, size: number, filter: InterceptApprovalFilter) {
|
||||
const query = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
if (filter.status) query.set("status", filter.status);
|
||||
if (filter.decision_source) query.set("decision_source", filter.decision_source);
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。
|
||||
health: () => get<{ ok: boolean; service: string; version: string }>("/health"),
|
||||
@@ -1068,8 +1076,8 @@ export const api = {
|
||||
get<import("@/lib/types").InterceptExecution>(`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`),
|
||||
interceptDetail: (id: number) => get<InterceptDetail>(`/intercept/history/${id}`),
|
||||
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
|
||||
interceptHistoryPage: (page = 1, size = 20) =>
|
||||
get<{ items: InterceptApprovalRow[]; total?: number }>(`/intercept/history?page=${page}&size=${size}`).then(
|
||||
interceptHistoryPage: (page = 1, size = 20, filter: InterceptApprovalFilter = {}) =>
|
||||
get<{ items: InterceptApprovalRow[]; total?: number }>(`/intercept/history?${interceptPageQuery(page, size, filter)}`).then(
|
||||
(r) => ({
|
||||
items: arr(r.items),
|
||||
total: r.total ?? r.items?.length ?? 0,
|
||||
@@ -1077,9 +1085,9 @@ export const api = {
|
||||
),
|
||||
interceptTask: (taskId: string) =>
|
||||
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
|
||||
interceptTaskPage: (taskId: string, page = 1, size = 20) =>
|
||||
interceptTaskPage: (taskId: string, page = 1, size = 20, filter: InterceptApprovalFilter = {}) =>
|
||||
get<{ items: InterceptApprovalRow[]; total?: number }>(
|
||||
`/intercept/task/${encodeURIComponent(taskId)}?page=${page}&size=${size}`,
|
||||
`/intercept/task/${encodeURIComponent(taskId)}?${interceptPageQuery(page, size, filter)}`,
|
||||
).then((r) => ({
|
||||
items: arr(r.items),
|
||||
total: r.total ?? r.items?.length ?? 0,
|
||||
|
||||
+11
-15
@@ -2289,21 +2289,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
}
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET")
|
||||
return mockInterceptPending.find((p) => p.id === Number(seg[2])) ?? null;
|
||||
if (path === "/intercept/history") {
|
||||
if (!q.has("page") && !q.has("size")) return { items: mockInterceptHistory, total: mockInterceptHistory.length };
|
||||
const page = Math.max(1, Number(q.get("page")) || 1);
|
||||
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
|
||||
const offset = (page - 1) * size;
|
||||
return {
|
||||
items: mockInterceptHistory.slice(offset, offset + size),
|
||||
total: mockInterceptHistory.length,
|
||||
page,
|
||||
page_size: size,
|
||||
};
|
||||
}
|
||||
if (seg[0] === "intercept" && seg[1] === "task") {
|
||||
const filtered = mockInterceptHistory.filter((r) => r.task_id === seg[2]);
|
||||
if (!q.has("page") && !q.has("size")) return { items: filtered, total: filtered.length };
|
||||
if (path === "/intercept/history" || (seg[0] === "intercept" && seg[1] === "task")) {
|
||||
const status = q.get("status") || "";
|
||||
const decisionSource = q.get("decision_source") || "";
|
||||
if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("无效审批状态");
|
||||
if (decisionSource && !["model", "rule", "unknown"].includes(decisionSource)) throw new Error("无效判定来源");
|
||||
const filtered = mockInterceptHistory.filter((row) => {
|
||||
const source = row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[模型]") ? "model" : "unknown");
|
||||
return (seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) &&
|
||||
(!status || row.status === status) && (!decisionSource || source === decisionSource);
|
||||
});
|
||||
if (!q.has("page") && !q.has("size") && !status && !decisionSource) return { items: filtered, total: filtered.length };
|
||||
const page = Math.max(1, Number(q.get("page")) || 1);
|
||||
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
|
||||
const offset = (page - 1) * size;
|
||||
|
||||
@@ -1195,6 +1195,11 @@ export interface JudgeConfig {
|
||||
ask_timeout_action: "allow" | "deny"; // 审批超时后的默认动作
|
||||
}
|
||||
|
||||
export interface InterceptApprovalFilter {
|
||||
status?: InterceptPending["status"];
|
||||
decision_source?: "rule" | "model" | "unknown";
|
||||
}
|
||||
|
||||
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
||||
export interface InterceptApprovalRow extends InterceptPending {
|
||||
conv_title: string; // "" if no linked conversation
|
||||
|
||||
Reference in new issue
Block a user