123 lines
6.5 KiB
TypeScript
123 lines
6.5 KiB
TypeScript
'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 }
|
||
}
|