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

527 lines
18 KiB
TypeScript
Raw 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.

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 { jobTemplateLabel } from '../lib/jobTemplate'
import { ANALYZE_COPY_MISSION_PIPELINE_STEPS, 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<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) {
return jobTemplateLabel(job.template_type)
}
function phaseHint(job: JobData) {
if (job.template_type === 'expand-graph') {
return job.progress?.summary || '研究地圖產生中…'
}
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 === 'analyze-copy-mission') {
return job.progress?.summary || '產出研究地圖與預設搜尋標籤'
}
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<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
}
}
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 === 'analyze-copy-mission') return ANALYZE_COPY_MISSION_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 <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>
{job.status === 'succeeded' && job.template_type === 'expand-graph' ? (
<Link
to={
job.scope === 'placement_topic'
? `/placement/topics/${encodeURIComponent(job.scope_id)}/research-map`
: `/research?brand=${encodeURIComponent(job.scope_id)}`
}
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
to={
job.scope === 'placement_topic'
? `/outreach?topic=${encodeURIComponent(job.scope_id)}`
: `/outreach?brand=${encodeURIComponent(job.scope_id)}`
}
className="ac-btn-secondary mt-3 inline-flex min-h-9 items-center px-4 text-xs font-bold"
>
</Link>
) : null}
<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 },
})
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<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>
)
}