import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { api } from '../api/client' import { isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus' import { VIRAL_SCAN_PIPELINE_STEPS } from '../lib/copyFlow' import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from '../lib/knowledgeGraph' import { setPlacementHandoff } from '../lib/islander/handoffStore' import { STYLE_8D_PIPELINE_STEPS } from '../lib/styleProfile' import type { JobData, Pagination } from '../types/api' import { Button, ProgressBar, StatusBadge } from './ui' const JOB_MONITOR_EXPANDED_KEY = 'haixun.job_monitor_expanded' const DOCK_SIZE = 48 const DOCK_ANCHOR = { width: DOCK_SIZE, height: DOCK_SIZE } const EDGE_PADDING = 12 const MOBILE_DOCK_HEIGHT = 88 const DRAG_THRESHOLD = 5 type MonitorPosition = { x: number; y: number } type DragSession = { active: boolean moved: boolean pointerId: number startX: number startY: number originX: number originY: number } const ACTIVE_STATUSES = new Set(['pending', 'queued', 'running', 'waiting_worker', 'cancel_requested']) const STEP_STATUS_LABEL: Record = { pending: '等待', running: '進行中', succeeded: '完成', failed: '失敗', skipped: '略過', cancelled: '取消', } const DEFAULT_STEPS = [ { id: 'prepare', title: '準備', hint: '整理任務資料與環境' }, { id: 'execute', title: '執行', hint: 'Worker 正在處理任務' }, { id: 'finalize', title: '收尾', hint: '寫入結果與完成狀態' }, ] function isActiveJob(job: JobData) { return ACTIVE_STATUSES.has(job.status) } function recentEnough(job: JobData) { if (!isTerminalJobStatus(job.status)) return false const updatedAtMs = Math.floor((job.update_at || job.create_at || 0) / 1_000_000) if (!updatedAtMs) return false return Date.now() - updatedAtMs < 3 * 60 * 1000 } function shortJobTitle(job: JobData) { if (job.template_type === 'style-8d') return '8D 風格分析' if (job.template_type === 'expand-graph') return '知識圖譜擴展' if (job.template_type === 'placement-scan') return '雙軌海巡' if (job.template_type === 'scan-viral') return '爆款掃描' if (job.template_type === 'demo_long_task') return 'Demo 任務' return job.template_type } function phaseHint(job: JobData) { if (job.template_type === 'expand-graph') { return job.progress?.summary || 'Brave 知識搜尋 + AI 合成圖譜' } if (job.template_type === 'placement-scan') { return job.progress?.summary || '相關軌 + 近期軌雙軌 crawl' } if (job.template_type === 'scan-viral') { return job.progress?.summary || '依關鍵字搜尋 Threads 爆款候選' } if (job.template_type === 'style-8d') { switch (job.phase) { case 'session': return '確認 Chrome session 與 Worker 心跳' case 'samples': return '讀取對標帳號近期貼文樣本' case 'style': return '交給 AI 分析 D1-D8 風格策略' case 'store': return '寫回人設,讓後續產文套用' default: return '等待 8D worker 更新階段' } } return job.phase ? `目前階段:${job.phase}` : '等待 Worker 回報階段' } function extractWorkflowHandoff(job: JobData, flow: string) { const raw = job.result?.handoff if (!raw || typeof raw !== 'object') return null const handoff = raw as Record if (handoff.flow !== flow) return null return handoff } function captureHandoffFromJobs(jobs: JobData[]) { for (const job of jobs) { if (job.status !== 'succeeded') continue const copyHandoff = extractWorkflowHandoff(job, 'copy') if (copyHandoff) { setPlacementHandoff({ flow: 'copy', persona_id: typeof copyHandoff.persona_id === 'string' ? copyHandoff.persona_id : job.scope_id, summary: typeof copyHandoff.summary === 'string' ? copyHandoff.summary : undefined, next_route: typeof copyHandoff.next_route === 'string' ? copyHandoff.next_route : '/matrix', job_id: job.id, template_type: job.template_type, }) return } const handoff = extractWorkflowHandoff(job, 'placement') if (!handoff) continue setPlacementHandoff({ flow: 'placement', brand_id: typeof handoff.brand_id === 'string' ? handoff.brand_id : typeof handoff.persona_id === 'string' ? handoff.persona_id : job.scope_id, pain_tag_count: typeof handoff.pain_tag_count === 'number' ? handoff.pain_tag_count : undefined, summary: typeof handoff.summary === 'string' ? handoff.summary : undefined, next_route: typeof handoff.next_route === 'string' ? handoff.next_route : undefined, needs_supplemental_expand: handoff.needs_supplemental_expand === true, search_source_mode: typeof handoff.search_source_mode === 'string' ? handoff.search_source_mode : undefined, dev_mode: handoff.dev_mode === true, job_id: job.id, template_type: job.template_type, }) return } } function stepDefinitions(job: JobData) { if (job.template_type === 'expand-graph') return EXPAND_GRAPH_PIPELINE_STEPS if (job.template_type === 'placement-scan') return PLACEMENT_SCAN_PIPELINE_STEPS if (job.template_type === 'scan-viral') return VIRAL_SCAN_PIPELINE_STEPS if (job.template_type === 'style-8d') return STYLE_8D_PIPELINE_STEPS const liveSteps = job.progress?.steps ?? [] if (liveSteps.length > 0) { return liveSteps.map((step) => ({ id: step.id, title: step.id, hint: step.message || step.id })) } return DEFAULT_STEPS } function StepDot({ status }: { status: string }) { const classes = status === 'succeeded' ? 'bg-success' : status === 'running' ? 'bg-brand shadow-[0_0_0_4px_var(--hx-brand-soft)]' : status === 'failed' ? 'bg-danger' : status === 'cancelled' ? 'bg-muted' : 'bg-line' return } function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Promise }) { const steps = job.progress?.steps ?? [] const stepMap = new Map(steps.map((step) => [step.id, step])) const definitions = stepDefinitions(job) const activeStep = steps.find((step) => step.status === 'running') const failedStep = steps.find((step) => step.status === 'failed') const canCancel = isActiveJob(job) return (
{shortJobTitle(job)} {jobStatusLabel(job.status)}

Job {job.id.slice(0, 8)} · {job.progress?.percentage ?? 0}%

{canCancel ? ( ) : null}

現在在做

{job.progress?.summary || phaseHint(job)}

{activeStep?.message ? (

細節:{activeStep.message}

) : null} {failedStep?.message || job.error ? (

錯誤:{failedStep?.message || job.error}

) : null}
    {definitions.map((step) => { const live = stepMap.get(step.id) const status = live?.status ?? 'pending' return (
  1. {step.title} {STEP_STATUS_LABEL[status] ?? status}

    {live?.message || step.hint}

  2. ) })}
{job.status === 'succeeded' && job.template_type === 'expand-graph' ? ( 前往研究頁勾選 tag → ) : null} {job.status === 'succeeded' && job.template_type === 'placement-scan' ? ( 前往獲客台 → ) : null} 查看完整事件與原始資料
) } function readExpandedPreference() { try { return localStorage.getItem(JOB_MONITOR_EXPANDED_KEY) === '1' } catch { return false } } function defaultMonitorPosition() { const mobile = window.innerWidth < 1024 const bottomGap = mobile ? MOBILE_DOCK_HEIGHT : EDGE_PADDING return { x: EDGE_PADDING, y: Math.max(EDGE_PADDING, window.innerHeight - bottomGap - DOCK_SIZE), } } function clampMonitorPosition( position: MonitorPosition, size: { width: number; height: number }, ): MonitorPosition { const maxX = Math.max(EDGE_PADDING, window.innerWidth - size.width - EDGE_PADDING) const maxY = Math.max(EDGE_PADDING, window.innerHeight - size.height - EDGE_PADDING) return { x: Math.min(Math.max(position.x, EDGE_PADDING), maxX), y: Math.min(Math.max(position.y, EDGE_PADDING), maxY), } } export function JobMonitor() { const [jobs, setJobs] = useState([]) const [expanded, setExpanded] = useState(readExpandedPreference) const [loading, setLoading] = useState(false) const [position, setPosition] = useState(defaultMonitorPosition) const [dragging, setDragging] = useState(false) const rootRef = useRef(null) const dragRef = useRef({ active: false, moved: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, }) const setExpandedPersisted = useCallback((value: boolean | ((prev: boolean) => boolean)) => { setExpanded((prev) => { const next = typeof value === 'function' ? value(prev) : value try { localStorage.setItem(JOB_MONITOR_EXPANDED_KEY, next ? '1' : '0') } catch { // ignore quota / private mode } return next }) }, []) const load = useCallback(async () => { setLoading(true) try { const data = await api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', { auth: true, query: { page: 1, pageSize: 12 }, }) const list = data.list ?? [] setJobs(list) captureHandoffFromJobs(list) } catch { // 登入初期或 gateway 重啟時保持安靜,避免干擾主要操作。 } finally { setLoading(false) } }, []) useEffect(() => { load().catch(() => undefined) const timer = window.setInterval(() => load().catch(() => undefined), 2000) return () => window.clearInterval(timer) }, [load]) const syncClampedPosition = useCallback(() => { setPosition((prev) => { const next = clampMonitorPosition(prev, DOCK_ANCHOR) if (next.x === prev.x && next.y === prev.y) return prev return next }) }, []) useLayoutEffect(() => { setPosition(defaultMonitorPosition()) }, []) useEffect(() => { const onResize = () => syncClampedPosition() window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, [syncClampedPosition]) const beginDockDrag = (event: React.PointerEvent) => { event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) dragRef.current = { active: true, moved: false, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: position.x, originY: position.y, } } const moveDockDrag = (event: React.PointerEvent) => { const drag = dragRef.current if (!drag.active || drag.pointerId !== event.pointerId) return const dx = event.clientX - drag.startX const dy = event.clientY - drag.startY if (!drag.moved && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) { drag.moved = true setDragging(true) } if (!drag.moved) return setPosition(clampMonitorPosition({ x: drag.originX + dx, y: drag.originY + dy }, DOCK_ANCHOR)) } const endDockDrag = (event: React.PointerEvent) => { const drag = dragRef.current if (!drag.active || drag.pointerId !== event.pointerId) return drag.active = false setDragging(false) if (drag.moved) { setPosition((prev) => clampMonitorPosition(prev, DOCK_ANCHOR)) return } setExpandedPersisted((value) => !value) } const visibleJobs = useMemo(() => { const active = jobs.filter(isActiveJob) const recent = jobs.filter((job) => recentEnough(job) && !active.some((item) => item.id === job.id)) return [...active, ...recent].slice(0, 4) }, [jobs]) const current = visibleJobs[0] const activeCount = visibleJobs.filter(isActiveJob).length const cancelJob = async (id: string) => { await api.post(`/api/v1/jobs/${id}/cancel`, { reason: 'ui cancel' }, { auth: true }) await load() } const dockTone = activeCount > 0 ? 'ac-job-monitor__dock--active' : current?.status === 'failed' ? 'ac-job-monitor__dock--failed' : current ? 'ac-job-monitor__dock--done' : 'ac-job-monitor__dock--idle' const openUpward = position.y > window.innerHeight * 0.42 return (
{activeCount > 0 && !expanded && current ? (

{current.progress?.summary || phaseHint(current)}

) : null} {expanded ? (

Task Watch

任務觀察站

拖曳「任」可移到螢幕任意位置;可自由切頁,worker 會在背景繼續跑。

{loading && visibleJobs.length === 0 ? (

更新任務狀態中…

) : visibleJobs.length === 0 ? (

目前沒有進行中或剛完成的任務。

前往任務列表
) : (
{visibleJobs.map((job) => ( ))}
)}
) : null}
) }