feat: add batch processing history and real-time status monitoring for team uploads
This commit is contained in:
@@ -80,6 +80,11 @@ func main() {
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// 启动时清理遗留的 running 状态批次(避免多个运行中状态)
|
||||
if affected, err := database.Instance.CleanupStuckBatchRuns(); err == nil && affected > 0 {
|
||||
fmt.Printf("%s[清理]%s 已清理 %d 个遗留的运行中批次记录\n", colorYellow, colorReset, affected)
|
||||
}
|
||||
|
||||
// 启动自动补号检查器(需在前端开启开关才会实际补号)
|
||||
api.StartAutoAddService()
|
||||
|
||||
|
||||
@@ -167,6 +167,13 @@ func HandleTeamProcess(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 启动新任务前,先清理数据库中遗留的 running 状态批次
|
||||
if database.Instance != nil {
|
||||
if affected, err := database.Instance.CleanupStuckBatchRuns(); err == nil && affected > 0 {
|
||||
logger.Warning(fmt.Sprintf("清理了 %d 个遗留的运行中批次记录", affected), "", "team")
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化状态
|
||||
teamProcessState.Running = true
|
||||
teamProcessState.StartedAt = time.Now()
|
||||
|
||||
@@ -48,6 +48,25 @@ interface BatchHistoryResponse {
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
// 实时处理状态(来自 /api/team/status)
|
||||
interface TeamProcessStatus {
|
||||
running: boolean
|
||||
started_at: string
|
||||
total_teams: number
|
||||
completed: number
|
||||
results: {
|
||||
team_index: number
|
||||
owner_email: string
|
||||
team_id: string
|
||||
registered: number
|
||||
added_to_s2a: number
|
||||
member_emails: string[]
|
||||
errors: string[]
|
||||
duration_ms: number
|
||||
}[]
|
||||
elapsed_ms: number
|
||||
}
|
||||
|
||||
interface BatchDetailResponse {
|
||||
batch: BatchRun
|
||||
results: TeamResult[]
|
||||
@@ -172,11 +191,26 @@ function TeamResultRow({ result }: { result: TeamResult }) {
|
||||
}
|
||||
|
||||
// 批次卡片组件
|
||||
function BatchCard({ batch, onLoadDetail }: { batch: BatchRun; onLoadDetail: (id: number) => Promise<TeamResult[]> }) {
|
||||
function BatchCard({ batch, onLoadDetail, liveStatus }: {
|
||||
batch: BatchRun;
|
||||
onLoadDetail: (id: number) => Promise<TeamResult[]>;
|
||||
liveStatus?: TeamProcessStatus | null;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [results, setResults] = useState<TeamResult[] | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 如果是运行中的批次,使用实时状态数据
|
||||
const isRunning = batch.status === 'running'
|
||||
const displayRegistered = isRunning && liveStatus ?
|
||||
liveStatus.results.reduce((sum, r) => sum + r.registered, 0) :
|
||||
batch.total_registered
|
||||
const displayAddedToS2A = isRunning && liveStatus ?
|
||||
liveStatus.results.reduce((sum, r) => sum + r.added_to_s2a, 0) :
|
||||
batch.total_added_to_s2a
|
||||
const displayCompleted = isRunning && liveStatus ? liveStatus.completed : 0
|
||||
const displayElapsed = isRunning && liveStatus ? Math.floor(liveStatus.elapsed_ms / 1000) : batch.duration_seconds
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (!expanded && results === null) {
|
||||
setLoading(true)
|
||||
@@ -214,8 +248,7 @@ function BatchCard({ batch, onLoadDetail }: { batch: BatchRun; onLoadDetail: (id
|
||||
<span className="font-medium text-slate-800 dark:text-slate-200">
|
||||
批次 #{batch.id}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
batch.status === 'completed'
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${batch.status === 'completed'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
|
||||
: 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400'
|
||||
}`}>
|
||||
@@ -233,15 +266,17 @@ function BatchCard({ batch, onLoadDetail }: { batch: BatchRun; onLoadDetail: (id
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded bg-slate-100 dark:bg-slate-700">
|
||||
<Users className="h-3.5 w-3.5 text-slate-500" />
|
||||
<span className="text-slate-600 dark:text-slate-300">{batch.total_owners}</span>
|
||||
<span className="text-slate-600 dark:text-slate-300">
|
||||
{isRunning && displayCompleted > 0 ? `${displayCompleted}/${batch.total_owners}` : batch.total_owners}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded bg-green-50 dark:bg-green-900/20">
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||
<span className="text-green-600 dark:text-green-400">{batch.total_registered}</span>
|
||||
<span className="text-green-600 dark:text-green-400">{displayRegistered}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded bg-purple-50 dark:bg-purple-900/20">
|
||||
<Settings className="h-3.5 w-3.5 text-purple-500" />
|
||||
<span className="text-purple-600 dark:text-purple-400">{batch.total_added_to_s2a}</span>
|
||||
<span className="text-purple-600 dark:text-purple-400">{displayAddedToS2A}</span>
|
||||
</div>
|
||||
{expanded && errorCount > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 rounded bg-red-50 dark:bg-red-900/20">
|
||||
@@ -250,7 +285,7 @@ function BatchCard({ batch, onLoadDetail }: { batch: BatchRun; onLoadDetail: (id
|
||||
</div>
|
||||
)}
|
||||
<span className="text-slate-400 text-xs">
|
||||
{formatDuration(batch.duration_seconds)}
|
||||
{formatDuration(displayElapsed)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,6 +361,10 @@ export default function BatchResultHistory() {
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [liveStatus, setLiveStatus] = useState<TeamProcessStatus | null>(null)
|
||||
|
||||
// 检查是否有运行中的批次
|
||||
const hasRunningBatch = batches.some(b => b.status === 'running')
|
||||
|
||||
// 加载批次列表
|
||||
const loadBatches = useCallback(async (p: number) => {
|
||||
@@ -365,10 +404,46 @@ export default function BatchResultHistory() {
|
||||
return []
|
||||
}, [])
|
||||
|
||||
// 获取实时处理状态
|
||||
const fetchLiveStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/team/status')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
const status = data.data as TeamProcessStatus
|
||||
setLiveStatus(status)
|
||||
// 如果任务刚完成(running 变为 false),刷新批次列表
|
||||
if (!status.running) {
|
||||
setLiveStatus(null)
|
||||
loadBatches(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取实时状态失败:', e)
|
||||
}
|
||||
}, [loadBatches, page])
|
||||
|
||||
useEffect(() => {
|
||||
loadBatches(1)
|
||||
}, [loadBatches])
|
||||
|
||||
// 当存在运行中批次时,轮询实时状态
|
||||
useEffect(() => {
|
||||
if (!hasRunningBatch) {
|
||||
setLiveStatus(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 立即获取一次
|
||||
fetchLiveStatus()
|
||||
|
||||
// 每 2 秒轮询一次
|
||||
const interval = setInterval(fetchLiveStatus, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}, [hasRunningBatch, fetchLiveStatus])
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
loadBatches(newPage)
|
||||
}
|
||||
@@ -417,6 +492,7 @@ export default function BatchResultHistory() {
|
||||
key={batch.id}
|
||||
batch={batch}
|
||||
onLoadDetail={loadBatchDetail}
|
||||
liveStatus={batch.status === 'running' ? liveStatus : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user