diff --git a/app/task-detail-change-requests/[id]/page.test.tsx b/app/task-detail-change-requests/[id]/page.test.tsx index c721e26..1e2318d 100644 --- a/app/task-detail-change-requests/[id]/page.test.tsx +++ b/app/task-detail-change-requests/[id]/page.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import TaskDetailChangeRequestPage from './page' @@ -11,11 +11,20 @@ const createJsonResponse = (body: T, status = 200) => ({ json: async () => body, }) +const requestRecord = (status = 'pending_manager_approval', applyError: string | null = null) => ({ + success: true, + data: { id: 'request-1', status, applyError, canDecide: status === 'pending_manager_approval', + managerName: '测试主管', requesterName: '测试提交人', material: { productModel: '测试素材', materialType: '任务-详情' }, + operations: [{ kind: 'delete', assetId: 'asset-1' }], + }, +}) + jest.mock('next/navigation', () => ({ useParams: () => ({ id: 'request-1' }), })) describe('TaskDetailChangeRequestPage', () => { + afterEach(() => { jest.useRealTimers() }) afterAll(() => { global.fetch = originalFetch }) @@ -82,4 +91,84 @@ describe('TaskDetailChangeRequestPage', () => { expect(screen.queryByRole('button', { name: '批准' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: '否决' })).not.toBeInTheDocument() }) + + test('applying is shown as approved and processing, with read-only refresh instead of decision buttons', async () => { + const user = userEvent.setup() + const fetchMock = jest.fn(async () => createJsonResponse(requestRecord('applying'))) + global.fetch = fetchMock as jest.Mock + render() + expect(await screen.findByText(/主管已批准,正在应用图片变更/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '批准' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '刷新状态' })) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls.every((call: unknown[]) => !(call[1] as RequestInit)?.method)).toBe(true) + }) + + test('failed decision refreshes persisted state and retains the specific apply error', async () => { + const user = userEvent.setup() + let submitted = false + const reason = '插图位置不存在或已被本次批量操作改动,请刷新后重试' + global.fetch = jest.fn(async (_input, init?: RequestInit) => { + if (init?.method === 'POST') { + submitted = true + return createJsonResponse({ error: reason }, 409) + } + return createJsonResponse(requestRecord(submitted ? 'apply_failed' : 'pending_manager_approval', submitted ? reason : null)) + }) as jest.Mock + render() + await user.click(await screen.findByRole('button', { name: '批准' })) + expect(await screen.findByRole('alert')).toHaveTextContent(reason) + expect(screen.getAllByText('应用失败')).toHaveLength(2) + expect(screen.getByRole('alert')).toHaveTextContent('本单不能重复批准') + await user.click(screen.getByRole('button', { name: '刷新状态' })) + expect(screen.getByRole('alert')).toHaveTextContent(reason) + expect(screen.queryByRole('button', { name: '批准' })).not.toBeInTheDocument() + }) + + test('a slow POST can be reconciled by read-only refresh without sending another approval', async () => { + const user = userEvent.setup() + let status = 'pending_manager_approval' + let finish!: (response: ReturnType) => void + const fetchMock = jest.fn((_input, init?: RequestInit) => { + if (init?.method === 'POST') { + status = 'applying' + return new Promise(resolve => { finish = resolve }) + } + return Promise.resolve(createJsonResponse(requestRecord(status))) + }) + global.fetch = fetchMock as jest.Mock + render() + await user.click(await screen.findByRole('button', { name: '批准' })) + expect(screen.getByText(/正在提交审批并处理图片变更/)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '刷新状态' })) + expect(await screen.findByText(/主管已批准,正在应用图片变更/)).toBeInTheDocument() + expect(fetchMock.mock.calls.filter(([,init]) => init?.method === 'POST')).toHaveLength(1) + status = 'applied' + await act(async () => { finish(createJsonResponse({ success: true })); await Promise.resolve() }) + expect(await screen.findAllByText('已通过')).toHaveLength(2) + }) + + test('a timed-out POST is not treated as failure of the application and later success clears the timeout hint', async () => { + jest.useFakeTimers() + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) + let status = 'pending_manager_approval' + const fetchMock = jest.fn((_input, init?: RequestInit) => { + if (init?.method === 'POST') { + status = 'applying' + return new Promise((_resolve, reject) => init.signal?.addEventListener('abort', () => reject(new DOMException('timeout', 'AbortError')))) + } + return Promise.resolve(createJsonResponse(requestRecord(status))) + }) + global.fetch = fetchMock as jest.Mock + render() + await user.click(await screen.findByRole('button', { name: '批准' })) + await act(async () => { await jest.advanceTimersByTimeAsync(30_100) }) + expect(screen.getByRole('alert')).toHaveTextContent('请求未返回结果') + expect(screen.getByText(/主管已批准,正在应用图片变更/)).toBeInTheDocument() + expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1) + status = 'applied' + await act(async () => { await jest.advanceTimersByTimeAsync(3100) }) + expect(screen.getAllByText('已通过')).toHaveLength(2) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) }) diff --git a/app/task-detail-change-requests/[id]/page.tsx b/app/task-detail-change-requests/[id]/page.tsx index 2e5d8d8..bd0e427 100644 --- a/app/task-detail-change-requests/[id]/page.tsx +++ b/app/task-detail-change-requests/[id]/page.tsx @@ -1,12 +1,12 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useParams } from 'next/navigation' import { format } from 'date-fns' type TaskDetailChangeRequestDecision = 'approve' | 'reject' -type TaskDetailChangeRequestStatus = 'pending_approval' | 'approved' | 'rejected' | 'expired' | 'apply_failed' +type TaskDetailChangeRequestStatus = 'pending_approval' | 'applying' | 'approved' | 'rejected' | 'expired' | 'apply_failed' type TaskDetailChangeRequestOperation = { kind: string @@ -30,6 +30,7 @@ type TaskDetailChangeRequestViewModel = { taskNo: string | null expiresAt: string | null decidedAt: string | null + applyError: string | null operations: TaskDetailChangeRequestOperation[] } @@ -42,6 +43,10 @@ const STATUS_META: Record(null) const [busyDecision, setBusyDecision] = useState(null) + const mounted = useRef(true) + const controllers = useRef(new Set()) + const readSequence = useRef(0) - const loadRequest = useCallback(async () => { + const requestJson = useCallback(async (url: string, init?: RequestInit, timeoutMs = 15_000) => { + const controller = new AbortController() + controllers.current.add(controller) + const timeout = setTimeout(() => controller.abort(), timeoutMs) try { - setLoading(true) - setError(null) - const response = await fetch(`/api/task-detail-change-requests/${requestId}`, { + const response = await fetch(url, { ...init, signal: controller.signal }) + const payload = await response.json().catch(() => null) + return { response, payload } + } finally { + clearTimeout(timeout) + controllers.current.delete(controller) + } + }, []) + + const loadRequest = useCallback(async (options: { initial?: boolean; keepError?: boolean } = {}) => { + const sequence = ++readSequence.current + try { + if (options.initial) setLoading(true) + if (!options.keepError) setError(null) + const { response, payload } = await requestJson(`/api/task-detail-change-requests/${requestId}`, { cache: 'no-store', }) - const payload = await response.json().catch(() => null) if (!response.ok) { throw new Error((payload as { error?: string } | null)?.error || '加载审批详情失败') } const next = normalizeChangeRequestPayload(payload) if (!next) throw new Error('审批详情格式不正确') - setRecord(next) + if (mounted.current && sequence === readSequence.current) { + setRecord(next) + if (['approved', 'rejected', 'expired'].includes(next.status)) setError(null) + } + return next } catch (loadError) { - setError(loadError instanceof Error ? loadError.message : '加载审批详情失败') + if (mounted.current && sequence === readSequence.current && !options.keepError) { + setError(loadError instanceof Error && loadError.name === 'AbortError' + ? '读取状态超时,请刷新状态重试' + : loadError instanceof Error ? loadError.message : '加载审批详情失败') + } + return null } finally { - setLoading(false) + if (mounted.current && options.initial) setLoading(false) } - }, [requestId]) + }, [requestId, requestJson]) useEffect(() => { + mounted.current = true + const activeControllers = controllers.current if (requestId) { - void loadRequest() + void loadRequest({ initial: true }) + } + return () => { + mounted.current = false + activeControllers.forEach(controller => controller.abort()) } }, [requestId, loadRequest]) + useEffect(() => { + if (!busyDecision && record?.status !== 'applying') return + let cancelled = false + const deadline = Date.now() + 2 * 60_000 + let timer: ReturnType + const poll = async () => { + const next = await loadRequest({ keepError: true }) + if (next && !['pending_approval', 'applying'].includes(next.status)) return + if (!cancelled && Date.now() < deadline) timer = setTimeout(poll, 3000) + } + timer = setTimeout(poll, 3000) + return () => { cancelled = true; clearTimeout(timer) } + }, [busyDecision, record?.status, loadRequest]) + const submitDecision = async (decision: TaskDetailChangeRequestDecision) => { if (!record || busyDecision) return try { setBusyDecision(decision) setError(null) - const response = await fetch(`/api/task-detail-change-requests/${requestId}/decision`, { + const { response, payload } = await requestJson(`/api/task-detail-change-requests/${requestId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ decision }), - }) - const payload = await response.json().catch(() => null) + }, 30_000) if (!response.ok) { throw new Error((payload as { error?: string } | null)?.error || '提交审批结果失败') } - await loadRequest() + if (mounted.current) await loadRequest() } catch (submitError) { - setError(submitError instanceof Error ? submitError.message : '提交审批结果失败') + if (mounted.current) { + setError(submitError instanceof Error && submitError.name === 'AbortError' + ? '请求未返回结果,请刷新状态确认处理进度,不要重复批准。' + : submitError instanceof Error ? submitError.message : '提交审批结果失败') + await loadRequest({ keepError: true }) + } } finally { - setBusyDecision(null) + if (mounted.current) setBusyDecision(null) } } @@ -247,7 +304,7 @@ export default function TaskDetailChangeRequestPage() {

审批详情

{!loading && record && ( - + {statusMeta.label} )} @@ -257,8 +314,8 @@ export default function TaskDetailChangeRequestPage() {

- {error && ( -
+ {error && !record?.applyError && ( +
{error}
)} @@ -301,7 +358,7 @@ export default function TaskDetailChangeRequestPage() { -