haixunMaster/haixun-backend/web/src/components/JobMonitor.tsx

527 lines
18 KiB
TypeScript
Raw Normal View History

2026-06-23 16:55:10 +00:00
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'
2026-06-24 10:02:42 +00:00
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'
2026-06-23 16:55:10 +00:00
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<string, string> = {
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 風格分析'
2026-06-24 10:02:42 +00:00
if (job.template_type === 'expand-graph') return '知識圖譜擴展'
if (job.template_type === 'placement-scan') return '雙軌海巡'
if (job.template_type === 'scan-viral') return '爆款掃描'
2026-06-23 16:55:10 +00:00
if (job.template_type === 'demo_long_task') return 'Demo 任務'
return job.template_type
}
function phaseHint(job: JobData) {
2026-06-24 10:02:42 +00:00
if (job.template_type === 'expand-graph') {
2026-06-24 16:48:56 +00:00
return job.progress?.summary || '研究地圖產生中…'
2026-06-24 10:02:42 +00:00
}
if (job.template_type === 'placement-scan') {
return job.progress?.summary || '相關軌 + 近期軌雙軌 crawl'
}
if (job.template_type === 'scan-viral') {
return job.progress?.summary || '依關鍵字搜尋 Threads 爆款候選'
}
2026-06-23 16:55:10 +00:00
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 回報階段'
}
2026-06-24 10:02:42 +00:00
function extractWorkflowHandoff(job: JobData, flow: string) {
const raw = job.result?.handoff
if (!raw || typeof raw !== 'object') return null
const handoff = raw as Record<string, unknown>
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
}
}
2026-06-23 16:55:10 +00:00
function stepDefinitions(job: JobData) {
2026-06-24 10:02:42 +00:00
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
2026-06-23 16:55:10 +00:00
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 <span className={`mt-1 h-2.5 w-2.5 shrink-0 rounded-full ${classes}`} />
}
function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Promise<void> }) {
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 (
<div className="ac-island-card p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-bold text-ink">{shortJobTitle(job)}</span>
<StatusBadge className={jobStatusBadgeClass(job.status)}>
{jobStatusLabel(job.status)}
</StatusBadge>
</div>
<p className="mt-1 text-xs text-muted">
Job {job.id.slice(0, 8)} · {job.progress?.percentage ?? 0}%
</p>
</div>
{canCancel ? (
<Button
variant="ghost"
className="min-h-8 px-3 py-1 text-xs"
onClick={() => onCancel(job.id)}
>
</Button>
) : null}
</div>
<ProgressBar value={job.progress?.percentage ?? 0} className="mt-3" />
<div className="ac-island-card__phase mt-3">
<p className="text-xs font-bold text-ink-secondary"></p>
<p className="mt-1 text-sm leading-relaxed text-ink">
{job.progress?.summary || phaseHint(job)}
</p>
{activeStep?.message ? (
<p className="mt-1 text-xs leading-relaxed text-muted">{activeStep.message}</p>
) : null}
{failedStep?.message || job.error ? (
<p className="mt-1 text-xs font-semibold leading-relaxed text-danger">
{failedStep?.message || job.error}
</p>
) : null}
</div>
<ol className="mt-3 grid gap-2">
{definitions.map((step) => {
const live = stepMap.get(step.id)
const status = live?.status ?? 'pending'
return (
<li key={step.id} className="flex gap-2 rounded-[var(--radius-md)] bg-accent-soft/60 px-3 py-2">
<StepDot status={status} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-bold text-ink">{step.title}</span>
<span className="text-xs text-muted">{STEP_STATUS_LABEL[status] ?? status}</span>
</div>
<p className="text-xs leading-relaxed text-ink-secondary">
{live?.message || step.hint}
</p>
</div>
</li>
)
})}
</ol>
2026-06-24 10:02:42 +00:00
{job.status === 'succeeded' && job.template_type === 'expand-graph' ? (
<Link
2026-06-24 16:48:56 +00:00
to={
job.scope === 'placement_topic'
? `/placement/topics/${encodeURIComponent(job.scope_id)}/research-map`
: `/research?brand=${encodeURIComponent(job.scope_id)}`
}
2026-06-24 10:02:42 +00:00
className="ac-btn-secondary mt-3 inline-flex min-h-9 items-center px-4 text-xs font-bold"
>
tag
</Link>
) : null}
{job.status === 'succeeded' && job.template_type === 'placement-scan' ? (
<Link
2026-06-24 16:48:56 +00:00
to={
job.scope === 'placement_topic'
? `/outreach?topic=${encodeURIComponent(job.scope_id)}`
: `/outreach?brand=${encodeURIComponent(job.scope_id)}`
}
2026-06-24 10:02:42 +00:00
className="ac-btn-secondary mt-3 inline-flex min-h-9 items-center px-4 text-xs font-bold"
>
</Link>
) : null}
2026-06-23 16:55:10 +00:00
<Link to={`/jobs/${job.id}`} className="ac-link mt-3 inline-block text-xs">
</Link>
</div>
)
}
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<JobData[]>([])
const [expanded, setExpanded] = useState(readExpandedPreference)
const [loading, setLoading] = useState(false)
const [position, setPosition] = useState<MonitorPosition>(defaultMonitorPosition)
const [dragging, setDragging] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<DragSession>({
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 },
})
2026-06-24 10:02:42 +00:00
const list = data.list ?? []
setJobs(list)
captureHandoffFromJobs(list)
2026-06-23 16:55:10 +00:00
} 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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 (
<div
ref={rootRef}
className={`ac-job-monitor ${dragging ? 'ac-job-monitor--dragging' : ''}`}
style={{ left: position.x, top: position.y }}
aria-label="任務觀察站"
>
<div className="ac-job-monitor__anchor">
{activeCount > 0 && !expanded && current ? (
<div className="ac-job-monitor__peek hidden max-w-[min(18rem,calc(100vw-5rem))] sm:block">
<p className="truncate text-xs font-semibold text-ink">
{current.progress?.summary || phaseHint(current)}
</p>
</div>
) : null}
<button
type="button"
onPointerDown={beginDockDrag}
onPointerMove={moveDockDrag}
onPointerUp={endDockDrag}
onPointerCancel={endDockDrag}
className={`ac-job-monitor__dock ${dockTone}`}
aria-expanded={expanded}
aria-label={expanded ? '收合任務觀察站' : '展開任務觀察站'}
>
<span className="text-base font-black"></span>
{activeCount > 0 ? (
<span className="ac-job-monitor__dock-ping" aria-hidden />
) : null}
{visibleJobs.length > 0 ? (
<span className="ac-job-monitor__dock-badge" aria-hidden>
{visibleJobs.length}
</span>
) : null}
</button>
{expanded ? (
<div
className={`ac-job-monitor__panel ac-island-card ac-bulletin--island p-4 backdrop-blur ${
openUpward ? 'ac-job-monitor__panel--above' : 'ac-job-monitor__panel--below'
}`}
>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<p className="display-en text-[10px] font-bold tracking-[0.16em] text-brand uppercase">
Task Watch
</p>
<h2 className="text-base font-black text-ink"></h2>
<p className="mt-1 text-xs leading-relaxed text-muted">
worker
</p>
</div>
<button
type="button"
onClick={() => setExpandedPersisted(false)}
className="ac-btn-secondary min-h-8 shrink-0 px-3 py-1 text-xs"
>
</button>
</div>
{loading && visibleJobs.length === 0 ? (
<p className="py-4 text-center text-sm text-muted"></p>
) : visibleJobs.length === 0 ? (
<div className="py-4 text-center">
<p className="text-sm text-muted"></p>
<Link to="/jobs" className="ac-link mt-2 inline-block text-xs">
</Link>
</div>
) : (
<div className="ac-job-monitor__list space-y-3">
{visibleJobs.map((job) => (
<JobCard key={job.id} job={job} onCancel={cancelJob} />
))}
</div>
)}
</div>
) : null}
</div>
</div>
)
}