diff --git a/.changeset/approval-center-optimization.md b/.changeset/approval-center-optimization.md new file mode 100644 index 0000000000..4bfe9d9996 --- /dev/null +++ b/.changeset/approval-center-optimization.md @@ -0,0 +1,27 @@ +--- +"@object-ui/components": patch +"@object-ui/app-shell": patch +"@object-ui/i18n": patch +--- + +fix(approvals): Approval Center UX pass — badge nowrap, approve confirm, decision progress bar, localized declared actions (#2762) + +- **Badge no longer stacks CJK text vertically (P0-1)** — `Badge` gains + `whitespace-nowrap` in its base variants (a badge is a single-line pill by + definition), and the inbox 状态 column gets a minimum width, so 待审批 can + never render as 待/审/批. +- **Quick Approve now confirms (P0-2)** — the row's right-edge ✓, the mobile + card button and the `a` keyboard shortcut all route through a confirmation + dialog before executing, mirroring the Reject flow; an irreversible decision + can no longer fire on a stray click. +- **Decision progress is visualized (P1-1)** — the drawer renders a segmented + progress bar (ARIA `progressbar`) for `decision_progress`, per-group chips + get an explicit unsatisfied ○ state next to the satisfied ✓, the eligible + approver count is spelled out, and the drawer pager now reads + "Request N of M" so it can't be misread as approval progress. +- **Declared action labels localize (P0-3)** — `DeclaredActionsBar` resolves + label / confirmText / successMessage through the `_actions..*` + translation convention (metadata literals as fallback), matching + ObjectView/RecordDetailView; with the `@objectstack/plugin-approvals` + bundle, the drawer shows 通过 / 拒绝 / 转签 instead of English in a zh-CN + workspace. New `approvalsInbox` keys shipped in all ten locales. diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index 15e4350167..fd91f69302 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -90,6 +90,7 @@ import { ChevronRight, Send, Check, + Circle, Paperclip, } from 'lucide-react'; import { @@ -393,7 +394,11 @@ export function ApprovalsInboxPage() { const [selectedRowIds, setSelectedRowIds] = useState>(new Set()); const [bulkRunning, setBulkRunning] = useState(false); - // Inline reject confirmation target (row-level quick action / keyboard) + // Inline decision confirmation targets (row-level quick action / mobile + // card / keyboard). BOTH decisions confirm before executing (#2762 P0-2): + // the right-edge icons are small, easy to misclick, and a decision is + // irreversible — one stray click must never finalize a request. + const [approveTarget, setApproveTarget] = useState(null); const [rejectTarget, setRejectTarget] = useState(null); const [inlineActing, setInlineActing] = useState(null); @@ -761,12 +766,14 @@ export function ApprovalsInboxPage() { refreshBadge(); }, [actionableSelectedRows, identities, user?.id, load, refreshBadge, tr]); - /** Row-level quick approve (hover button / `a` key). */ - const inlineApprove = useCallback(async (r: ApprovalRequestRow) => { - if (!isActionable(r) || inlineActing) return; + /** Confirmed row-level approve (from the shared dialog). */ + const inlineApprove = useCallback(async () => { + const r = approveTarget; + if (!r) return; const pending = new Set(r.pending_approvers || []); const actor = identities.find(i => pending.has(i)) || user?.id || ''; setInlineActing(r.id); + setApproveTarget(null); try { const res = await approvalsApi.approve(r.id, { actor_id: actor }); toast.success(res.finalized @@ -779,7 +786,7 @@ export function ApprovalsInboxPage() { } finally { setInlineActing(null); } - }, [isActionable, inlineActing, identities, user?.id, load, refreshBadge, humanizeError, tr]); + }, [approveTarget, identities, user?.id, load, refreshBadge, humanizeError, tr]); /** Confirmed row-level reject (from the shared dialog). */ const inlineReject = useCallback(async () => { @@ -809,7 +816,7 @@ export function ApprovalsInboxPage() { useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (selectedId || rejectTarget) return; // a sheet/dialog owns the keyboard + if (selectedId || approveTarget || rejectTarget) return; // a sheet/dialog owns the keyboard const el = e.target as HTMLElement | null; const tag = el?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el?.isContentEditable) return; @@ -830,9 +837,9 @@ export function ApprovalsInboxPage() { } else if ((e.key === 'x' || e.key === ' ') && idx >= 0 && list[idx] && tab === 'pending') { e.preventDefault(); toggleRow(list[idx].id); - } else if (e.key === 'a' && idx >= 0 && list[idx]) { + } else if (e.key === 'a' && idx >= 0 && list[idx] && isActionable(list[idx])) { e.preventDefault(); - void inlineApprove(list[idx]); + setApproveTarget(list[idx]); } else if (e.key === 'r' && idx >= 0 && list[idx] && isActionable(list[idx])) { e.preventDefault(); setRejectTarget(list[idx]); @@ -840,7 +847,7 @@ export function ApprovalsInboxPage() { }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [selectedId, rejectTarget, tab, openDrawer, toggleRow, inlineApprove, isActionable]); + }, [selectedId, approveTarget, rejectTarget, tab, openDrawer, toggleRow, isActionable]); // Drawer keyboard: ←/→ walk the visible list without going back to it. useEffect(() => { @@ -920,7 +927,7 @@ export function ApprovalsInboxPage() { variant="ghost" className="h-7 px-2 text-emerald-700 hover:text-emerald-800 hover:bg-emerald-50 dark:text-emerald-400" disabled={busy} - onClick={() => void inlineApprove(r)} + onClick={() => setApproveTarget(r)} aria-label={tr('approve', 'Approve')} > @@ -1175,7 +1182,8 @@ export function ApprovalsInboxPage() { {tr('colRequest', 'Request')} {tr('colRecord', 'Record')} {tr('colRequester', 'Requester')} - {tr('colStatus', 'Status')} + {/* min-width keeps the status pill on one line (#2762 P0-1) */} + {tr('colStatus', 'Status')} {tr('colWaiting', 'Submitted')} @@ -1257,7 +1265,7 @@ export function ApprovalsInboxPage() { {isActionable(r) && (
e.stopPropagation()}> - - {tr('positionOf', '{{index}} of {{total}}', { index: drawerIndex + 1, total: filteredRows.length })} + {/* "Request N of M" — spelled out so it can't be misread as + decision progress (#2762 P1-1). */} + {tr('positionOf', 'Request {{index}} of {{total}}', { index: drawerIndex + 1, total: filteredRows.length })}
)} {/* Aggregation progress (#3266): server-computed — "2 of 3 - approved" for quorum/unanimous, per-group ticks for 会签. */} - {selected.decision_progress && ( -
-
- {selected.decision_progress.behavior === 'per_group' - ? tr('progressGroups', 'Sign-off progress — {{got}} of {{need}} groups', { - got: selected.decision_progress.got, need: selected.decision_progress.need, - }) - : tr('progressApprovals', 'Approvals — {{got}} of {{need}}', { - got: selected.decision_progress.got, need: selected.decision_progress.need, - })} -
- {selected.decision_progress.groups && ( -
- {selected.decision_progress.groups.map((g) => ( - - {g.satisfied ? : null} - {g.group} {g.got}/{g.need} - - ))} + approved" for quorum/unanimous, per-group ticks for 会签. + Rendered as a segmented bar + per-group state (#2762 P1-1) + instead of text alone, with the eligible-approver count + spelled out so "0 of 1" can't be misread against the + drawer pager's "Request 2 of 3". */} + {selected.decision_progress && (() => { + const dp = selected.decision_progress; + const eligible = (selected.pending_approvers || []).length; + return ( +
+
+ + {dp.behavior === 'per_group' + ? tr('progressGroups', 'Sign-off progress — {{got}} of {{need}} groups', { + got: dp.got, need: dp.need, + }) + : tr('progressApprovals', 'Approvals — {{got}} of {{need}}', { + got: dp.got, need: dp.need, + })} + + {dp.behavior !== 'per_group' && selected.status === 'pending' && eligible > 0 && ( + + {tr('progressEligible', '{{count}} eligible approver(s)', { count: eligible })} + + )}
- )} -
- )} +
+ {dp.need > 0 && dp.need <= 12 ? ( + Array.from({ length: dp.need }).map((_, i) => ( +
+ )) + ) : ( +
+
0 ? Math.min(100, (dp.got / dp.need) * 100) : 0}%` }} + /> +
+ )} +
+ {dp.groups && ( +
+ {dp.groups.map((g) => ( + + {g.satisfied + ? + : } + {g.group} {g.got}/{g.need} + + ))} +
+ )} +
+ ); + })()} {selected.status === 'pending' && (selected.pending_approvers || []).length > 0 && (
diff --git a/packages/app-shell/src/views/DeclaredActionsBar.tsx b/packages/app-shell/src/views/DeclaredActionsBar.tsx index a66f2cf953..06d042d408 100644 --- a/packages/app-shell/src/views/DeclaredActionsBar.tsx +++ b/packages/app-shell/src/views/DeclaredActionsBar.tsx @@ -39,6 +39,7 @@ import { toPredicateInput, } from '@object-ui/react'; import type { ActionDef } from '@object-ui/core'; +import { useObjectLabel } from '@object-ui/i18n'; import { Loader2 } from 'lucide-react'; import { useConsoleActionRuntime } from '../hooks/useConsoleActionRuntime'; import { useAdapter } from '../providers/AdapterProvider'; @@ -96,6 +97,12 @@ const DeclaredActionButton: React.FC<{ }> = ({ action, objectName, record }) => { const { execute } = useAction(); const [loading, setLoading] = useState(false); + // Localize the SERVER-DECLARED strings through the `_actions..*` + // translation convention (objectui#2762 P0-3) — the metadata's literal + // label/confirmText/successMessage are the fallback, exactly like + // ObjectView/RecordDetailView do for their toolbars. The param dialog's + // labels localize downstream in useConsoleActionRuntime. + const { actionLabel, actionConfirm, actionSuccess } = useObjectLabel(); const recordData = record != null && typeof record === 'object' ? (record as Record) : {}; // `visible` fails CLOSED on a throwing predicate — mirrors action:button and @@ -118,6 +125,19 @@ const DeclaredActionButton: React.FC<{ const { params: rawParams, ...rest } = action as ActionDef & { params?: unknown }; const dispatch: any = { ...rest, + // Localized copies ride the dispatch: the runner reads `label` for the + // param-dialog title, `confirmText` for the confirm prompt and + // `successMessage` for the toast. A nameless action has no translation + // key, so it keeps its literal strings. + ...(action.name && { + label: actionLabel(objectName, action.name, action.label || action.name), + ...(rest.confirmText !== undefined && { + confirmText: actionConfirm(objectName, action.name, (rest as any).confirmText), + }), + ...(rest.successMessage !== undefined && { + successMessage: actionSuccess(objectName, action.name, (rest as any).successMessage), + }), + }), objectName, params: { _rowRecord: record }, }; @@ -128,7 +148,7 @@ const DeclaredActionButton: React.FC<{ } finally { setLoading(false); } - }, [action, execute, loading, objectName, record]); + }, [action, execute, loading, objectName, record, actionLabel, actionConfirm, actionSuccess]); if ((action as any).visible && !isVisible) return null; @@ -136,7 +156,8 @@ const DeclaredActionButton: React.FC<{ const variant = (action as any).variant === 'primary' ? 'default' : ((action as any).variant || 'outline'); - const label = action.label || action.name; + const fallbackLabel = action.label || action.name || ''; + const label = action.name ? actionLabel(objectName, action.name, fallbackLabel) : fallbackLabel; return (