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') {
|
|
|
|
|
|
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 爆款候選'
|
|
|
|
|
|
}
|
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
|
|
|
|
|
|
to={`/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={`/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}
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|