gongxiangp2/components/admin/MaterialPurgeStatus.tsx

123 lines
6.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertCircle, Check, Loader2, X } from 'lucide-react'
export type PurgeJobView = {
id: string; materialId: string; name: string; status: string
totalFiles: number; processedFiles: number; sharedFiles: number; error: string | null
}
export function useMaterialPurgeJobs() {
const [jobs, setJobs] = useState<PurgeJobView[]>([])
const [error, setError] = useState<string | null>(null)
const [retryError, setRetryError] = useState<string | null>(null)
const [retrying, setRetrying] = useState<string | null>(null)
const tracked = useRef<string[]>([])
const mounted = useRef(false)
const dismissed = useRef(new Set<string>())
const loading = useRef(false)
const track = useCallback((next: PurgeJobView[]) => {
tracked.current = Array.from(new Set([...next.map(job => job.id), ...tracked.current])).slice(0, 100)
setJobs(previous => [...next, ...previous.filter(job => !next.some(item => item.id === job.id))])
}, [])
const refresh = useCallback(async () => {
if (loading.current) return
loading.current = true
try {
const params = new URLSearchParams()
tracked.current.forEach(id => params.append('id', id))
const response = await fetch(`/api/admin/materials/purge-jobs?${params}`, { cache: 'no-store' })
if (!response.ok) throw new Error('清理进度暂不可用')
const result = await response.json()
if (!mounted.current) return
// 页面刷新后接管正在处理的任务,也要跟踪它们的最终完成状态。
tracked.current = Array.from(new Set<string>([
...result.jobs.filter((job: PurgeJobView) => job.status !== 'completed').map((job: PurgeJobView) => job.id),
...tracked.current,
])).slice(0, 100)
setJobs(previous => {
const next: PurgeJobView[] = result.jobs.filter((job: PurgeJobView) => !dismissed.current.has(job.id))
// 请求开始之后新提交的任务,不能被旧的列表响应抹掉。
return [...next, ...previous.filter(job => tracked.current.includes(job.id) && !next.some(item => item.id === job.id))]
})
setError(null)
} catch {
if (mounted.current) setError('清理进度暂不可用')
} finally { loading.current = false }
}, [])
useEffect(() => {
mounted.current = true
void refresh()
return () => { mounted.current = false }
}, [refresh])
const hasPending = jobs.some(job => job.status === 'queued' || job.status === 'processing')
const pollMs = hasPending && !error ? 500 : 3000
useEffect(() => {
const timer = setInterval(() => { if (!document.hidden) void refresh() }, pollMs)
return () => clearInterval(timer)
}, [refresh, pollMs])
const retry = async (id: string) => {
setRetrying(id)
setRetryError(null)
try {
const response = await fetch('/api/admin/materials/purge-jobs', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jobId: id }),
})
const result = await response.json()
if (!response.ok) throw new Error(result.error || '重试失败,请稍后再试')
track([result.job])
setError(null)
} catch (failure: any) { setRetryError(failure.message || '重试失败,请稍后再试') }
finally { setRetrying(null) }
}
const failed = jobs.filter(job => job.status === 'failed')
const pending = jobs.filter(job => job.status === 'queued' || job.status === 'processing')
const completed = jobs.filter(job => job.status === 'completed')
const shared = completed.reduce((sum, job) => sum + job.sharedFiles, 0)
const status = jobs.length || error || retryError ? (
<section aria-label="文件清理进度" className="border-b border-gray-200 bg-gray-50 px-4 py-3 sm:px-6">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-700" role="status" aria-live="polite">
{pending.length > 0 && <span className="inline-flex items-center gap-2">
<Loader2 aria-hidden="true" className="h-4 w-4 motion-safe:animate-spin" />
正在清理 {pending.length} 条素材
</span>}
{completed.length > 0 && <span className="inline-flex flex-wrap items-center gap-2 text-emerald-800">
<Check aria-hidden="true" className="h-4 w-4" />已彻底删除 {completed.length} 条
{shared > 0 && <span>(共用文件已保留)</span>}
<button aria-label="关闭已完成提示" className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-md hover:bg-gray-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-600" onClick={() => {
completed.forEach(job => dismissed.current.add(job.id))
tracked.current = tracked.current.filter(id => !dismissed.current.has(id))
setJobs(previous => previous.filter(job => job.status !== 'completed'))
}}><X aria-hidden="true" className="h-4 w-4" /></button>
</span>}
{error && <span className="inline-flex items-center gap-2 text-red-800">
{error}<button onClick={() => void refresh()} className="min-h-11 rounded-md px-3 font-medium underline underline-offset-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-red-700">刷新进度</button>
</span>}
</div>
{retryError && <p role="alert" className="py-2 text-sm text-red-800">{retryError}</p>}
{failed.length > 0 && <ul className="max-h-64 overflow-y-auto" aria-label="需要重试的清理任务">
{failed.map(job => <li key={job.id} className="flex flex-col gap-2 py-2 text-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-2 text-red-800">
<AlertCircle aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 break-words"><span className="font-medium">{job.name}</span>:{job.error || '文件清理未完成'}
<span className="ml-2 text-gray-600">已处理 {job.processedFiles}/{job.totalFiles} 个文件</span>
</div>
</div>
<button disabled={retrying !== null} onClick={() => void retry(job.id)} className="min-h-11 shrink-0 rounded-lg border border-gray-300 bg-white px-4 font-medium text-gray-800 hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-600 disabled:opacity-50">
{retrying === job.id ? '正在重试…' : '重试清理'}
</button>
</li>)}
</ul>}
</section>
) : null
return { track, status }
}