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:
Autumn-27 committed 2026-09-16 11:16:26 -04:00
commit 13ce2ea6e3
8 files changed
+436 -45

No files matched your search

+101 -9
View File
@@ -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}