From f1f54a5f733be4eb8ad745c9f2d76e60ff599b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 26 Jul 2026 03:54:47 -0700 Subject: [PATCH] fix(console): approval timeline attachment chip shows its name and opens (#2820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A decision attachment in the approval inbox timeline rendered a nameless "附件" chip that did nothing on click. Three bugs: - No filename: the chip resolved its label from `/data/sys_file/{id}` — a system object a regular approver cannot read — and silently fell back to a generic label. The name now comes from the attachment descriptor the server returns (framework #3504), so the real filename shows for every approver. - Dead click: `openAttachment` called `window.open` after an `await`, so it was no longer a user gesture and the popup was blocked. It now opens the tab synchronously up front, then points it at the signed URL. - Wrong origin: the local adapter's signed URL is server-relative; it is now resolved against the API origin instead of the console origin. - Open failures were swallowed silently; the user now gets a toast. New `approvalsInbox.attachmentOpenFailed` string across all 10 locales. Verified end-to-end against app-showcase. Refs framework #3504. Co-Authored-By: Claude Opus 4.8 --- .changeset/approval-attachment-chip.md | 24 +++++++ .../src/pages/system/ApprovalsInboxPage.tsx | 71 ++++++++----------- apps/console/src/services/approvalsApi.ts | 18 ++++- packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + 13 files changed, 80 insertions(+), 43 deletions(-) create mode 100644 .changeset/approval-attachment-chip.md diff --git a/.changeset/approval-attachment-chip.md b/.changeset/approval-attachment-chip.md new file mode 100644 index 000000000..c0b2acf18 --- /dev/null +++ b/.changeset/approval-attachment-chip.md @@ -0,0 +1,24 @@ +--- +"@object-ui/console": patch +"@object-ui/i18n": patch +--- + +fix(console): make the approval timeline attachment chip show its name and open (#2820) + +A decision attachment in the approval inbox timeline (审批动态) rendered a +nameless "附件" chip that did nothing when clicked. Three separate bugs: + +- **No filename.** The chip resolved its label by fetching `/data/sys_file/{id}` + — a system object a regular approver cannot read — and silently fell back to a + generic label when that was denied. The name now comes from the attachment + descriptor the server returns (framework #3266), so no `sys_file` access is + needed and the real filename shows for every approver. +- **Dead click.** `openAttachment` called `window.open` *after* an `await`, so + it was no longer a user gesture and the browser blocked the popup. It now opens + the tab synchronously up front, then points it at the signed URL once fetched. +- **Wrong origin.** The signed URL from the local storage adapter is + server-relative; `window.open` resolved it against the console origin. It is + now resolved against the API origin. +- Every open failure was swallowed silently. The user now gets a toast on + failure — new `approvalsInbox.attachmentOpenFailed` string across all 10 + locales. diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index 1d27d65f7..e074f5488 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -100,6 +100,7 @@ import { buildApproverIdentities, type ApprovalRequestRow, type ApprovalActionRow, + type ApprovalActionAttachment, } from '../../services/approvalsApi'; type TabKey = 'pending' | 'submitted' | 'all'; @@ -420,49 +421,37 @@ export function ApprovalsInboxPage() { // collects the comment and — since the shared upload-widget renderer (#2700/ // #2707) plus the declared `attachments` file param (#2698) — file // attachments, so the inbox no longer hand-wires a decision composer. `authFetch` - // stays for the timeline's attachment-name resolution and signed-URL open. + // stays for opening an attachment's short-lived signed URL. const authFetch = useMemo(() => createAuthenticatedFetch(), []); - // Resolve attachment fileIds → sys_file display names for the timeline chips - // (#2678 P1.5). Best-effort, cached per id; unresolved ids fall back to a - // generic "Attachment" label. - const [attachmentNames, setAttachmentNames] = useState>({}); - useEffect(() => { - const ids = new Set(); - for (const a of actions) for (const id of (a.attachments ?? [])) { - if (id && attachmentNames[id] === undefined) ids.add(id); - } - if (!ids.size) return; - const base = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, ''); - let cancelled = false; - void (async () => { - const resolved: Record = {}; - await Promise.all(Array.from(ids).map(async (id) => { - try { - const res = await authFetch(`${base}/api/v1/data/sys_file/${encodeURIComponent(id)}`); - if (!res.ok) { resolved[id] = ''; return; } - const body = await res.json().catch(() => null); - resolved[id] = String(body?.record?.name ?? body?.data?.name ?? body?.name ?? ''); - } catch { resolved[id] = ''; } - })); - if (!cancelled) setAttachmentNames(prev => ({ ...prev, ...resolved })); - })(); - return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed off actions; attachmentNames is the cache being filled - }, [actions, authFetch]); - - /** Open an action's attachment via a short-lived signed URL (Bearer-authed fetch). */ - const openAttachment = useCallback(async (fileId: string) => { + + /** + * Open a timeline attachment. Three things the previous version got wrong, + * all of which made the chip look dead (framework#3266 follow-up): + * 1. `window.open` ran *after* `await` → not a user gesture → popup blocked. + * We open the tab synchronously, up front, then point it at the URL. + * 2. The signed URL is server-relative (`/api/v1/storage/_local/raw/…`) — it + * must resolve against the API origin, not the console origin. + * 3. Every failure was swallowed silently. Now the user gets a toast. + */ + const openAttachment = useCallback(async (att: ApprovalActionAttachment) => { + // Synchronous open keeps the user-gesture, so the browser won't block it. + const win = window.open('', '_blank', 'noopener'); try { const base = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, ''); - const res = await authFetch(`${base}/api/v1/storage/files/${encodeURIComponent(fileId)}/url`); + const res = await authFetch(`${base}/api/v1/storage/files/${encodeURIComponent(att.id)}/url`); if (!res.ok) throw new Error(`HTTP_${res.status}`); const body = await res.json().catch(() => null); - const url = body?.data?.url ?? body?.url; - if (url) window.open(url, '_blank', 'noopener'); + const raw = body?.data?.url ?? body?.url; + if (!raw) throw new Error('NO_URL'); + // Signed URLs from the local adapter are relative; S3/GCS are absolute. + const url = /^https?:\/\//i.test(raw) ? raw : `${base}${raw}`; + if (win) win.location.href = url; + else window.open(url, '_blank', 'noopener'); } catch { - /* open failed — non-fatal; the chip stays visible */ + win?.close(); + toast.error(tr('attachmentOpenFailed', 'Could not open the attachment — please try again')); } - }, [authFetch]); + }, [authFetch, tr]); // Search + filters. On the paginated tabs (submitted/all) the free-text // query is debounced and pushed to the server; the pending tab keeps @@ -1821,16 +1810,16 @@ export function ApprovalsInboxPage() { )} {Array.isArray(a.attachments) && a.attachments.length > 0 && (
- {a.attachments.map((fileId, i) => ( + {a.attachments.map((att, i) => ( ))} diff --git a/apps/console/src/services/approvalsApi.ts b/apps/console/src/services/approvalsApi.ts index 2227e6c60..896504c5c 100644 --- a/apps/console/src/services/approvalsApi.ts +++ b/apps/console/src/services/approvalsApi.ts @@ -104,6 +104,20 @@ export interface ApprovalRequestRow { round?: number; } +/** + * A file attached to a decision action (#3266). The server resolves the + * `sys_approval_action.attachments` file field into rich descriptors, so the + * chip has the display name + download URL without any `sys_file` lookup. + */ +export interface ApprovalActionAttachment { + id: string; + name?: string; + /** Stable download URL (`/api/v1/storage/files/:id`); may be server-relative. */ + url?: string; + mimeType?: string; + size?: number; +} + export interface ApprovalActionRow { id: string; request_id: string; @@ -112,8 +126,8 @@ export interface ApprovalActionRow { actor_id?: string | null; action: 'submit' | 'approve' | 'reject' | 'recall' | string; comment?: string | null; - /** File references attached to this action (decision attachments, #3266). */ - attachments?: string[] | null; + /** Files attached to this action (decision attachments, #3266). */ + attachments?: ApprovalActionAttachment[] | null; created_at?: string; /** Display name of the actor, resolved server-side. */ actor_name?: string; diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 80d63984a..716e2fadf 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1992,6 +1992,7 @@ const ar = { progressBar: 'تقدم القرار', declaredActions: 'الإجراءات', attachmentChip: 'مرفق', + attachmentOpenFailed: 'تعذّر فتح المرفق — يرجى المحاولة مرة أخرى', approveOneTitle: 'الموافقة على "{{title}}"؟', approveOneBody: 'ستتم الموافقة على الطلب بهويتك. لإضافة تعليق أو مرفق، افتح الطلب.', flowOrigin: 'بدأها التدفق', diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index a1ca24367..9dbcdfd41 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1994,6 +1994,7 @@ const de = { progressBar: 'Entscheidungsfortschritt', declaredActions: 'Aktionen', attachmentChip: 'Anhang', + attachmentOpenFailed: 'Anhang konnte nicht geöffnet werden – bitte erneut versuchen', approveOneTitle: '„{{title}}“ genehmigen?', approveOneBody: 'Die Anfrage wird mit Ihrer Identität genehmigt. Um einen Kommentar oder Anhang hinzuzufügen, öffnen Sie die Anfrage.', flowOrigin: 'Vom Flow gestartet', diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 8351063a9..e937e93c0 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2534,6 +2534,7 @@ const en = { progressBar: 'Decision progress', declaredActions: 'Actions', attachmentChip: 'Attachment', + attachmentOpenFailed: 'Could not open the attachment — please try again', approveOneTitle: 'Approve "{{title}}"?', approveOneBody: 'This approves the request with your identity. To add a comment or attachment, open the request instead.', flowOrigin: 'Flow-initiated', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index f4578979d..5802a29ea 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1994,6 +1994,7 @@ const es = { progressBar: 'Progreso de la decisión', declaredActions: 'Acciones', attachmentChip: 'Adjunto', + attachmentOpenFailed: 'No se pudo abrir el archivo adjunto: inténtalo de nuevo', approveOneTitle: '¿Aprobar «{{title}}»?', approveOneBody: 'Esto aprueba la solicitud con tu identidad. Para añadir un comentario o un adjunto, abre la solicitud.', flowOrigin: 'Iniciada por flujo', diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 6d3103bf1..3033aedc6 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1992,6 +1992,7 @@ const fr = { progressBar: 'Avancement de la décision', declaredActions: 'Actions', attachmentChip: 'Pièce jointe', + attachmentOpenFailed: "Impossible d'ouvrir la pièce jointe — veuillez réessayer", approveOneTitle: 'Approuver « {{title}} » ?', approveOneBody: 'Cette action approuve la demande en votre nom. Pour ajouter un commentaire ou une pièce jointe, ouvrez la demande.', flowOrigin: 'Initiée par un flux', diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 3c48b9d27..d0df9caea 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1994,6 +1994,7 @@ const ja = { progressBar: '承認の進捗', declaredActions: 'アクション', attachmentChip: '添付ファイル', + attachmentOpenFailed: '添付ファイルを開けませんでした。もう一度お試しください', approveOneTitle: '「{{title}}」を承認しますか?', approveOneBody: 'あなたの名義でこのリクエストを承認します。コメントや添付を付ける場合は、リクエストを開いて操作してください。', flowOrigin: 'フロー起動', diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 3128e491f..cd6a44694 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1994,6 +1994,7 @@ const ko = { progressBar: '결정 진행 상황', declaredActions: '작업', attachmentChip: '첨부 파일', + attachmentOpenFailed: '첨부 파일을 열 수 없습니다. 다시 시도해 주세요', approveOneTitle: '"{{title}}"을(를) 승인할까요?', approveOneBody: '내 이름으로 이 요청을 승인합니다. 의견이나 첨부 파일을 추가하려면 요청을 열어 처리하세요.', flowOrigin: '플로우 시작', diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index a810f9ad7..fadb48a8d 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1992,6 +1992,7 @@ const pt = { progressBar: 'Progresso da decisão', declaredActions: 'Ações', attachmentChip: 'Anexo', + attachmentOpenFailed: 'Não foi possível abrir o anexo — tente novamente', approveOneTitle: 'Aprovar "{{title}}"?', approveOneBody: 'Isso aprova a solicitação com a sua identidade. Para adicionar um comentário ou anexo, abra a solicitação.', flowOrigin: 'Iniciada por fluxo', diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index b1dd1e503..5ec36a022 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1992,6 +1992,7 @@ const ru = { progressBar: 'Ход решения', declaredActions: 'Действия', attachmentChip: 'Вложение', + attachmentOpenFailed: 'Не удалось открыть вложение — попробуйте ещё раз', approveOneTitle: 'Согласовать «{{title}}»?', approveOneBody: 'Запрос будет согласован от вашего имени. Чтобы добавить комментарий или вложение, откройте запрос.', flowOrigin: 'Инициировано потоком', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index da0a2cfb9..448ae7a5d 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2608,6 +2608,7 @@ const zh = { progressBar: '决策进度', declaredActions: '操作', attachmentChip: '附件', + attachmentOpenFailed: '无法打开附件,请重试', approveOneTitle: '通过“{{title}}”?', approveOneBody: '将以你的身份通过该请求。如需填写审批意见或附件,请打开该请求处理。', flowOrigin: '流程发起',