このコミットが含まれているのは:
2026-10-29 23:31:04 +09:00
コミット efbd640963
8個のファイルの変更、201行の追加、19行の削除
+48 -5
ファイルの表示
@@ -13,6 +13,7 @@ export type CountsExportHandle = { cancel: () => Promise<boolean> }
type ExportStatus = {
status: 'starting' | 'queued' | 'running' | 'cancelling' | 'cancelled' | 'ready' | 'failed'
processed: number; total: number | null; token?: string
downloadAttempt?: string; downloadStatus?: string; error?: string
phase?: 'queued' | 'aggregating' | 'writing'; heartbeatAt?: number; updatedAt?: number }
@@ -31,10 +32,15 @@ const CountsExportProgress = (
const job = useRef<string | null> (null)
const creation = useRef<Promise<{ job: string }> | null> (null)
const downloaded = useRef (false)
const attempt = useRef<string | null> (null)
const downloadDeadline = useRef (0)
const releaseDownload = useRef<(() => void) | null> (null)
const [downloadError, setDownloadError] = useState<string | null> (null)
const pollRequest = useRef<AbortController | null> (null)
useEffect (() => {
handle.cancel = async () => {
if (terminal.current) return true
releaseDownload.current?.()
cancelled.current = true
if (job.current != null)
{
@@ -81,13 +87,46 @@ const CountsExportProgress = (
if (!active) return
lastResponse = Date.now ()
setState (result)
terminal.current = ['ready', 'failed', 'cancelled'].includes (result.status)
terminal.current = ['failed', 'cancelled'].includes (result.status)
if (result.status === 'ready' && result.token != null && !downloaded.current)
{
downloaded.current = true
startAPIDownload ('/counts/export', result.token, () => {
if (active) setState (previous => ({ ...previous, status: 'failed' }))
})
const receipt = await apiPost<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
locale, signal: controller.signal })
if (!active || cancelled.current) return
if (receipt.downloadAttempt == null) throw new Error ('Missing download attempt')
attempt.current = receipt.downloadAttempt
downloadDeadline.current = Date.now () + 30_000
releaseDownload.current = startAPIDownload ('/counts/export', result.token, () => {
if (active)
{
terminal.current = true
setDownloadError ('failed')
}
}, new URLSearchParams ({ job: token, attempt: receipt.downloadAttempt }))
}
if (attempt.current != null && result.downloadAttempt === attempt.current)
{
if (result.downloadStatus === 'started' && !cancelled.current)
{
terminal.current = true
// Native navigation continues after the dialogue unmounts.
releaseDownload.current = null
controls.close ()
return
}
if (['failed', 'unavailable'].includes (result.downloadStatus ?? ''))
{
terminal.current = true
setDownloadError (result.downloadStatus!)
}
}
if (attempt.current != null && Date.now () > downloadDeadline.current)
{
terminal.current = true
setDownloadError ('timeout')
releaseDownload.current?.()
}
if (!terminal.current) timer = setTimeout (() => poll (token), 800)
}
@@ -99,6 +138,7 @@ const CountsExportProgress = (
|| Date.now () - lastResponse > 30_000)
{
terminal.current = true
if (downloaded.current) setDownloadError ('unavailable')
setState (previous => ({ ...previous, status: 'failed' }))
return
}
@@ -135,8 +175,9 @@ const CountsExportProgress = (
active = false
if (timer != null) clearTimeout (timer)
pollRequest.current?.abort ()
releaseDownload.current?.()
}
}, [handle, locale, payload])
}, [handle, locale, payload, controls])
useEffect (() => {
controls.setActions ([])
}, [controls])
@@ -161,6 +202,8 @@ const CountsExportProgress = (
? label ('exportProgress.processed', { count: state.processed })
: label ('exportProgress.measured', {
processed: state.processed, total: state.total ?? 0, percent })}</p>
{downloadError != null && <p role="alert">
{label (`downloadErrors.${downloadError}`)}</p>}
{cancelError && <p role="alert">{label ('common:fetchError')}</p>}
</div>
}
+6 -3
ファイルの表示
@@ -219,7 +219,8 @@ export const startAPIDownload = (
path: string,
token: string,
onError?: () => void,
): void => {
query?: URLSearchParams,
): (() => void) => {
const frame = document.createElement ('iframe')
frame.hidden = true
frame.addEventListener ('load', () => {
@@ -231,8 +232,10 @@ export const startAPIDownload = (
}
})
frame.addEventListener ('error', () => { onError?.(); frame.remove () })
frame.src = `${API_BASE_URL}${path}/files/${encodeURIComponent (token)}`
const suffix = query == null ? '' : `?${query.toString ()}`
frame.src = `${API_BASE_URL}${path}/files/${encodeURIComponent (token)}${suffix}`
document.body.append (frame)
// Attachments do not reliably fire load; keep this isolated from page navigation.
window.setTimeout (() => frame.remove (), 10 * 60_000)
const timer = window.setTimeout (() => frame.remove (), 10 * 60_000)
return () => { window.clearTimeout (timer); frame.remove () }
}