diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 7c26ace0..fd01480a 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1585,6 +1585,10 @@ export const en = { noDefinition: "(empty)", approve: "Confirm", reject: "Reject", + selectPage: "Select this page", + selectMapping: (name: string, source: string) => + `Select ${name} from ${source}`, + selected: (n: number) => (n === 1 ? "1 selected" : `${n} selected`), edit: "Edit", editTitle: "Revise definition", fieldTable: "Table", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 0bc04d82..0048b36f 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1377,6 +1377,9 @@ export const zh: Strings = { noDefinition: "(未填)", approve: "确认", reject: "拒绝", + selectPage: "全选本页", + selectMapping: (name, source) => `选择 ${source} 中的 ${name}`, + selected: (n) => `已选 ${n} 条`, edit: "编辑", editTitle: "改口径", fieldTable: "表", diff --git a/web/src/pages/Mappings.test.ts b/web/src/pages/Mappings.test.ts new file mode 100644 index 00000000..d8a41c18 --- /dev/null +++ b/web/src/pages/Mappings.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { en } from "../i18n/en"; +import { zh } from "../i18n/zh"; +import { decideMappings, selectedMappingIds } from "./Mappings"; + +describe("bulk mapping decisions", () => { + it("decides every selected mapping exactly once", async () => { + const decide = vi.fn(async (_id: string) => ({ ok: true })); + + await decideMappings(["mapping-a", "mapping-b", "mapping-c"], decide); + + expect(decide.mock.calls).toEqual([ + ["mapping-a"], + ["mapping-b"], + ["mapping-c"], + ]); + }); + + it("waits for every decision before reporting a failure", async () => { + const failure = new Error("mapping-a failed"); + let release!: () => void; + const delayed = new Promise((resolve) => { + release = resolve; + }); + const decide = vi.fn((id: string) => + id === "mapping-a" ? Promise.reject(failure) : delayed, + ); + const batch = decideMappings(["mapping-a", "mapping-b"], decide); + let settled = false; + void batch.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(settled).toBe(false); + expect(decide.mock.calls).toEqual([["mapping-a"], ["mapping-b"]]); + + release(); + await expect(batch).rejects.toBe(failure); + }); + + it("never resubmits a selected mapping that is no longer proposed", () => { + const picked = new Set(["mapping-a", "mapping-b"]); + + expect( + selectedMappingIds( + [ + { id: "mapping-a", status: "confirmed" }, + { id: "mapping-b", status: "proposed" }, + { id: "mapping-c", status: "proposed" }, + ], + picked, + ), + ).toEqual(["mapping-b"]); + }); + + it("never submits a mapping with an individual decision in flight", () => { + expect( + selectedMappingIds( + [{ id: "mapping-a", status: "proposed" }], + new Set(["mapping-a"]), + new Set(["mapping-a"]), + ), + ).toEqual([]); + }); + + it("names mapping checkboxes by concept and source in both locales", () => { + expect(en.mapping.selectMapping("Revenue", "warehouse")).toBe( + "Select Revenue from warehouse", + ); + expect(zh.mapping.selectMapping("营收", "数据仓库")).toBe( + "选择 数据仓库 中的 营收", + ); + expect(en.mapping.selected(2)).toBe("2 selected"); + expect(zh.mapping.selected(2)).toBe("已选 2 条"); + }); +}); diff --git a/web/src/pages/Mappings.tsx b/web/src/pages/Mappings.tsx index ad379695..92eb045b 100644 --- a/web/src/pages/Mappings.tsx +++ b/web/src/pages/Mappings.tsx @@ -6,7 +6,7 @@ // // 审批留在同一个端点上(`review/mappings/{id}`,那里已经在写审计流水), // 搬的是界面不是逻辑:判断一条口径对不对要看得见表结构,而那在这一页。 -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Database, Plus } from "lucide-react"; import { useNavigate } from "@tanstack/react-router"; @@ -33,6 +33,34 @@ import { const PAGE = 25; type StatusFilter = "all" | "proposed" | "confirmed" | "rejected"; +type MappingDecision = Extract< + ConceptMapping["status"], + "confirmed" | "rejected" +>; +const NO_PENDING_MAPPING_IDS: ReadonlySet = new Set(); + +export const decideMappings = async ( + ids: string[], + decide: (id: string) => Promise, +) => { + const outcomes = await Promise.allSettled(ids.map((id) => decide(id))); + const failure = outcomes.find((outcome) => outcome.status === "rejected"); + if (failure?.status === "rejected") throw failure.reason; +}; + +export const selectedMappingIds = ( + mappings: Pick[], + picked: ReadonlySet, + pending: ReadonlySet = NO_PENDING_MAPPING_IDS, +) => + mappings + .filter( + (mapping) => + mapping.status === "proposed" && + picked.has(mapping.id) && + !pending.has(mapping.id), + ) + .map((mapping) => mapping.id); const TONE: Record = { proposed: "warn", @@ -59,6 +87,9 @@ export function Mappings() { const [status, setStatus] = useState("all"); const [q, setQ] = useState(""); const [page, setPage] = useState(0); + const [picked, setPicked] = useState>(() => new Set()); + const [deciding, setDeciding] = useState>(() => new Set()); + useEffect(() => setPicked(new Set()), [kb?.id, page, q, status, tab]); const data = useQuery({ queryKey: ["mappings", kb?.id, status, q, page], @@ -75,9 +106,27 @@ export function Mappings() { const refresh = () => queryClient.invalidateQueries({ queryKey: ["mappings", kb?.id] }); + const batch = useMutation({ + mutationFn: ({ ids, status }: { ids: string[]; status: MappingDecision }) => + decideMappings(ids, (id) => api.decideMapping(kb!.id, id, status)), + onSuccess: () => setPicked(new Set()), + onError: (e: unknown) => toast.error((e as Error).message), + onSettled: refresh, + }); + if (!kb) return {S.nav.loading}; const counts = data.data?.counts; + const selectable = + data.data?.items.filter( + (m) => m.status === "proposed" && !deciding.has(m.id), + ) ?? []; + const selectedIds = selectedMappingIds( + data.data?.items ?? [], + picked, + deciding, + ); + const individualPending = deciding.size > 0; const FILTERS: { key: StatusFilter; label: string; n?: number }[] = [ { key: "all", label: S.mapping.filterAll }, { key: "proposed", label: S.mapping.filterProposed, n: counts?.proposed }, @@ -158,6 +207,55 @@ export function Mappings() { /> + {selectable.length > 0 && ( +
+ picked.has(m.id))} + disabled={batch.isPending || individualPending} + onChange={(e) => + setPicked( + e.target.checked + ? new Set(selectable.map((m) => m.id)) + : new Set(), + ) + } + label={S.mapping.selectPage} + /> + {selectedIds.length > 0 && ( + <> + + {S.mapping.selected(selectedIds.length)} + + + + batch.mutate({ + ids: selectedIds, + status: "rejected", + }) + } + > + {S.mapping.reject} + + + )} +
+ )} + {status === "rejected" && (

{S.mapping.rejectedHint}

)} @@ -181,6 +279,24 @@ export function Mappings() { key={m.id} kbId={kb.id} mapping={m} + picked={selectedIds.includes(m.id)} + batchPending={batch.isPending} + onDecisionPending={(pending) => + setDeciding((prev) => { + const next = new Set(prev); + if (pending) next.add(m.id); + else next.delete(m.id); + return next; + }) + } + onPick={(on) => + setPicked((prev) => { + const next = new Set(prev); + if (on) next.add(m.id); + else next.delete(m.id); + return next; + }) + } onChanged={refresh} /> ))} @@ -203,10 +319,18 @@ export function Mappings() { function MappingRow({ kbId, mapping: m, + picked, + batchPending, + onDecisionPending, + onPick, onChanged, }: { kbId: string; mapping: ConceptMapping; + picked: boolean; + batchPending: boolean; + onDecisionPending: (pending: boolean) => void; + onPick: (picked: boolean) => void; onChanged: () => void; }) { const [editing, setEditing] = useState(false); @@ -215,14 +339,29 @@ function MappingRow({ const decide = useMutation({ mutationFn: (s: "confirmed" | "rejected") => api.decideMapping(kbId, m.id, s), + onMutate: () => onDecisionPending(true), onSuccess: onChanged, onError: (e: unknown) => toast.error((e as Error).message), + onSettled: () => onDecisionPending(false), }); const how = howComputed(m); return ( -
+
+ {m.status === "proposed" && ( + onPick(e.target.checked)} + label={ + + {S.mapping.selectMapping(m.concept_name, m.source)} + + } + /> + )} {m.concept_name} {m.source} {m.unit && ( @@ -269,14 +408,17 @@ function MappingRow({ {m.status === "proposed" && ( <> !decide.isPending && decide.mutate("rejected")} + disabled={batchPending || picked} + onClick={() => + !picked && !decide.isPending && decide.mutate("rejected") + } > {S.mapping.reject}