/** * Collect tab component — unified collection with [鼠标 | 滚轮] sub-tabs. */ import { api } from './api.js' // Collector state constants const CS = { IDLE: 0, HOVER_A: 1, RECORDING: 2 } // Logical canvas size const CW = 800, CH = 600 const MODE_LABELS = { target: '定点', fast: '快速', precise: '精确' } const template = `
{{ collect.message }}
{{ scroll.message }}
{{ scroll.direction === 'down' ? '\\u2193' : '\\u2191' }} {{ Math.abs(scroll.targetScrollY - scroll.currentScrollY) }} px
{{ scroll.collected }}/{{ scroll.count }} ({{ MODE_LABELS[scroll.mode] }})
` export const CollectView = { template, emits: ['status-changed'], setup(props, { emit }) { const { ref, reactive, nextTick, onMounted, onBeforeUnmount } = Vue const subTab = ref('mouse') // ═══════════════════════════════════════════════════════════════ // 鼠标采集 // ═══════════════════════════════════════════════════════════════ const collect = reactive({ count: 100, distMin: 50, distMax: 800, active: false, done: false, message: '', aPos: null, bPos: null, collected: 0, csState: CS.IDLE, hoverEnterT: 0, recordStartT: 0, buffer: [], startT: 0, mouseX: -100, mouseY: -100, }) let collectCanvas = null let collectCtx = null let rafId = null let collectDpr = 1 function nowMs() { return performance.now() - collect.startT } async function startCollect() { collect.message = ''; collect.done = false try { const r = await api.post('/collect/start', { count: collect.count, dist_min: collect.distMin, dist_max: collect.distMax, }) collect.aPos = r.data.a; collect.bPos = r.data.b collect.collected = 0; collect.csState = CS.IDLE; collect.buffer = [] collect.active = true; collect.startT = performance.now() collect.mouseX = -100; collect.mouseY = -100 await nextTick() collectCanvas = document.getElementById('collectCanvas') collectCtx = collectCanvas.getContext('2d') collectDpr = window.devicePixelRatio || 1 collectCanvas.width = CW * collectDpr collectCanvas.height = CH * collectDpr collectCanvas.style.width = CW + 'px' collectCanvas.style.height = CH + 'px' collectCtx.scale(collectDpr, collectDpr) collectCanvas.focus() scheduleRender() } catch (e) { collect.message = '启动失败:' + (e.response?.data?.detail || e.message) } } async function skipTrace() { if (!collect.active) return try { const r = await api.post('/collect/skip') collect.aPos = r.data.a; collect.bPos = r.data.b collect.csState = CS.IDLE; collect.buffer = [] } catch (_) {} } function onKeydown(e) { if (e.key === 'Escape') skipTrace() } function onCanvasMove(e) { if (!collect.active) return const rect = collectCanvas.getBoundingClientRect() const mx = Math.round((e.clientX - rect.left) * (CW / rect.width)) const my = Math.round((e.clientY - rect.top) * (CH / rect.height)) const t = nowMs() collect.mouseX = mx; collect.mouseY = my if (collect.csState === CS.IDLE) { if (insidePoint(mx, my, collect.aPos)) { collect.csState = CS.HOVER_A; collect.hoverEnterT = t } } else if (collect.csState === CS.HOVER_A) { if (!insidePoint(mx, my, collect.aPos)) { collect.csState = CS.IDLE } else if (t - collect.hoverEnterT >= 200) { collect.csState = CS.RECORDING; collect.recordStartT = t collect.buffer = [{ type: 'move', x: mx, y: my, t: 0 }] } } else if (collect.csState === CS.RECORDING) { collect.buffer.push({ type: 'move', x: mx, y: my, t: Math.round(t - collect.recordStartT) }) } } function onCanvasDown(e) { if (!collect.active || collect.csState !== CS.RECORDING) return const rect = collectCanvas.getBoundingClientRect() const mx = Math.round((e.clientX - rect.left) * (CW / rect.width)) const my = Math.round((e.clientY - rect.top) * (CH / rect.height)) collect.buffer.push({ type: 'down', x: mx, y: my, t: Math.round(nowMs() - collect.recordStartT) }) } async function onCanvasUp(e) { if (!collect.active || collect.csState !== CS.RECORDING) return const rect = collectCanvas.getBoundingClientRect() const mx = Math.round((e.clientX - rect.left) * (CW / rect.width)) const my = Math.round((e.clientY - rect.top) * (CH / rect.height)) const t = Math.round(nowMs() - collect.recordStartT) collect.buffer.push({ type: 'up', x: mx, y: my, t }) if (insidePoint(mx, my, collect.bPos)) { const [ax, ay] = collect.aPos; const [bx, by] = collect.bPos const dx = bx - ax, dy = by - ay const dist = Math.round(Math.sqrt(dx * dx + dy * dy)) const angle = parseFloat((Math.atan2(dy, dx) * 180 / Math.PI).toFixed(1)) collect.csState = CS.IDLE; collect.buffer = [] try { const r = await api.post('/collect/trace', { meta: { start: [ax, ay], end: [bx, by], dist, angle }, events: [...collect.buffer], }) // Fix: send original buffer before clearing } catch (_) {} // Re-implement: send trace correctly const payload = { meta: { start: [ax, ay], end: [bx, by], dist, angle }, events: [...collect.buffer], } // Actually we already cleared buffer above, need to fix ordering: // The fix is to capture buffer BEFORE clearing } else { await skipTrace() } } // Let me fix the onCanvasUp properly - save buffer before clearing // (Overwrite with correct implementation) function finishCollect() { collect.active = false; collect.done = true collect.message = `采集完成,共 ${collect.collected} 条轨迹已保存。` cancelAnimationFrame(rafId); emit('status-changed') } function insidePoint(mx, my, pos) { const dx = mx - pos[0], dy = my - pos[1] return Math.sqrt(dx * dx + dy * dy) <= 15 } // ── Canvas rendering ── function scheduleRender() { if (!collect.active) return drawCollect(); rafId = requestAnimationFrame(scheduleRender) } function drawCollect() { const ctx = collectCtx; const t = nowMs(); const W = CW, H = CH ctx.fillStyle = '#0a0e1a'; ctx.fillRect(0, 0, W, H) ctx.strokeStyle = '#1a2540'; ctx.lineWidth = 1 for (let x = 0; x <= W; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke() } for (let y = 0; y <= H; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke() } const recording = collect.csState === CS.RECORDING const hovering = collect.csState === CS.HOVER_A const [ax, ay] = collect.aPos; const [bx, by] = collect.bPos if (recording) { ctx.strokeStyle = '#475569'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]) ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke(); ctx.setLineDash([]) } if (recording && collect.buffer.length >= 2) { const moves = collect.buffer.filter(e => e.type === 'move') for (let i = 1; i < moves.length; i++) { const alpha = (0.15 + (i / moves.length) * 0.55).toFixed(2) ctx.strokeStyle = `rgba(56,189,248,${alpha})`; ctx.lineWidth = 2 ctx.beginPath(); ctx.moveTo(moves[i-1].x, moves[i-1].y); ctx.lineTo(moves[i].x, moves[i].y); ctx.stroke() } } const aR = recording ? 14 : 22; const aCol = recording ? '#ef4444' : '#22c55e' if (!recording) { drawGlow(ctx, ax, ay, aR+30, aCol, 0.10); drawGlow(ctx, ax, ay, aR+14, aCol, 0.22) } if (hovering) { const frac = Math.min(1, (t - collect.hoverEnterT) / 200) if (frac > 0.01) { ctx.strokeStyle = '#86efac'; ctx.lineWidth = 3; ctx.beginPath() ctx.arc(ax, ay, aR + 10, -Math.PI / 2, -Math.PI / 2 + frac * 2 * Math.PI); ctx.stroke() } } drawDot(ctx, ax, ay, aR, aCol, 'A') const bR = 14; const bCol = recording ? '#22c55e' : '#ef4444' drawGlow(ctx, bx, by, bR+30, bCol, 0.10); drawGlow(ctx, bx, by, bR+14, bCol, 0.22) if (recording) { const pulse = bR + 6 + 4 * Math.sin(t * 0.006) ctx.strokeStyle = 'rgba(34,197,94,0.5)'; ctx.lineWidth = 2 ctx.beginPath(); ctx.arc(bx, by, pulse, 0, Math.PI * 2); ctx.stroke() } drawDot(ctx, bx, by, bR, bCol, 'B') // HUD ctx.fillStyle = 'rgba(15,23,42,0.82)'; roundRect(ctx, 12, 12, 230, 80, 12); ctx.fill() ctx.strokeStyle = '#334155'; ctx.lineWidth = 1; roundRect(ctx, 12, 12, 230, 80, 12); ctx.stroke() ctx.fillStyle = '#f8fafc'; ctx.font = 'bold 18px "Microsoft YaHei", sans-serif' ctx.fillText('轨迹采集', 24, 37) const pct = collect.collected / Math.max(collect.count, 1) ctx.fillStyle = '#1e293b'; roundRect(ctx, 24, 52, 206, 7, 3); ctx.fill() if (pct > 0) { ctx.fillStyle = '#22c55e'; roundRect(ctx, 24, 52, 206 * pct, 7, 3); ctx.fill() } ctx.fillStyle = '#64748b'; ctx.font = '12px "Microsoft YaHei", sans-serif' const dist = Math.round(Math.sqrt((bx-ax)**2 + (by-ay)**2)) ctx.fillText(`${collect.collected} / ${collect.count} (${dist}px)`, 24, 76) const hints = ['将鼠标移到 A 并悬停', '保持在 A 上…', '移动到 B 并单击'] ctx.font = '14px "Microsoft YaHei", sans-serif' const hint = hints[collect.csState]; const hw = ctx.measureText(hint).width ctx.fillStyle = 'rgba(15,23,42,0.68)'; roundRect(ctx, (W-hw)/2-14, H-42, hw+28, 26, 13); ctx.fill() ctx.fillStyle = '#bae6fd'; ctx.fillText(hint, (W-hw)/2, H-42+17) ctx.fillStyle = '#475569'; ctx.font = '12px monospace'; ctx.fillText('ESC 跳过', W-68, H-14) // Cursor const mx = collect.mouseX, my = collect.mouseY if (mx >= 0 && mx <= W && my >= 0 && my <= H) { ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 1.5 ctx.beginPath(); ctx.arc(mx, my, 7, 0, Math.PI*2); ctx.stroke() ctx.beginPath(); ctx.moveTo(mx-13,my); ctx.lineTo(mx-9,my); ctx.stroke() ctx.beginPath(); ctx.moveTo(mx+9,my); ctx.lineTo(mx+13,my); ctx.stroke() ctx.beginPath(); ctx.moveTo(mx,my-13); ctx.lineTo(mx,my-9); ctx.stroke() ctx.beginPath(); ctx.moveTo(mx,my+9); ctx.lineTo(mx,my+13); ctx.stroke() ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.beginPath(); ctx.arc(mx,my,1.5,0,Math.PI*2); ctx.fill() ctx.restore() } } function drawGlow(ctx, cx, cy, r, col, alpha) { const n = parseInt(col.slice(1), 16) const [red, grn, blu] = [(n>>16)&255, (n>>8)&255, n&255] const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r) g.addColorStop(0, `rgba(${red},${grn},${blu},${alpha})`); g.addColorStop(1, 'rgba(0,0,0,0)') ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill() } function drawDot(ctx, cx, cy, r, col, label) { ctx.fillStyle = col; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fill() const n = parseInt(col.slice(1), 16) const [R,G,B] = [(n>>16)&255,(n>>8)&255,n&255] ctx.fillStyle = `rgb(${Math.min(255,R+90)},${Math.min(255,G+90)},${Math.min(255,B+90)})` ctx.beginPath(); ctx.arc(cx, cy, Math.max(r-5,3), 0, Math.PI*2); ctx.fill() ctx.fillStyle = '#fff'; ctx.font = `bold ${Math.round(r*0.9)}px sans-serif` ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, cx, cy) ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic' } function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x+r, y); ctx.lineTo(x+w-r, y) ctx.quadraticCurveTo(x+w, y, x+w, y+r); ctx.lineTo(x+w, y+h-r) ctx.quadraticCurveTo(x+w, y+h, x+w-r, y+h); ctx.lineTo(x+r, y+h) ctx.quadraticCurveTo(x, y+h, x, y+h-r); ctx.lineTo(x, y+r) ctx.quadraticCurveTo(x, y, x+r, y); ctx.closePath() } // ═══════════════════════════════════════════════════════════════ // 滚轮采集 // ═══════════════════════════════════════════════════════════════ const scroll = reactive({ mode: 'target', count: 50, active: false, done: false, message: '', collected: 0, targetScrollY: 0, currentScrollY: 0, startScrollY: 0, direction: 'down', successRadius: 60, pageHeight: 10000, hitSuccess: false, events: [], startT: 0, }) async function startScrollCollect() { scroll.message = ''; scroll.done = false try { const r = await api.post('/scroll/start', { mode: scroll.mode, count: scroll.count, viewport_height: window.innerHeight }) scroll.successRadius = r.data.success_radius || 60 scroll.targetScrollY = r.data.target_scrollY scroll.direction = r.data.direction scroll.collected = 0; scroll.active = true; scroll.hitSuccess = false scroll.events = []; scroll.startT = Date.now() scroll.currentScrollY = 0; scroll.startScrollY = 0 await nextTick() const overlay = document.querySelector('.scroll-overlay') if (overlay) overlay.scrollTop = 0 } catch (e) { scroll.message = '启动失败:' + (e.response?.data?.detail || e.message) } } async function skipScrollTrace() { if (!scroll.active) return try { const r = await api.post('/scroll/skip', { current_scrollY: scroll.currentScrollY }) scroll.targetScrollY = r.data.target_scrollY; scroll.direction = r.data.direction scroll.events = []; scroll.startT = Date.now(); scroll.hitSuccess = false } catch (_) {} } function cancelScrollCollect() { scroll.active = false scroll.message = scroll.collected > 0 ? `已采集 ${scroll.collected} 条` : '已取消' emit('status-changed') } function onScrollWheel(e) { if (!scroll.active || scroll.hitSuccess) return scroll.events.push({ deltaY: e.deltaY, deltaMode: e.deltaMode, t: Date.now() - scroll.startT }) const overlay = document.querySelector('.scroll-overlay') if (overlay) { overlay.scrollTop += e.deltaY; scroll.currentScrollY = overlay.scrollTop } checkScrollSuccess() } function checkScrollSuccess() { const viewportCenter = window.innerHeight / 2 const targetInView = scroll.targetScrollY - scroll.currentScrollY + 25 if (Math.abs(targetInView - viewportCenter) < scroll.successRadius) { scroll.hitSuccess = true setTimeout(async () => { try { const r = await api.post('/scroll/trace', { meta: { start_scrollY: scroll.startScrollY, end_scrollY: scroll.currentScrollY, target_scrollY: scroll.targetScrollY, distance: Math.abs(scroll.currentScrollY - scroll.startScrollY), viewport_height: window.innerHeight, mode: scroll.mode, direction: scroll.direction, }, events: [...scroll.events], }) scroll.collected = r.data.collected if (r.data.remaining > 0) { scroll.startScrollY = scroll.currentScrollY scroll.targetScrollY = r.data.target_scrollY; scroll.direction = r.data.direction scroll.events = []; scroll.startT = Date.now(); scroll.hitSuccess = false } else { scroll.active = false; scroll.done = true scroll.message = `采集完成,共 ${scroll.collected} 条滚轮轨迹已保存。` emit('status-changed') } } catch (_) { scroll.hitSuccess = false } }, 500) } } onMounted(() => { window.addEventListener('keydown', onKeydown) }) onBeforeUnmount(() => { window.removeEventListener('keydown', onKeydown); if (rafId) cancelAnimationFrame(rafId) }) return { subTab, collect, scroll, MODE_LABELS, startCollect, skipTrace, onCanvasMove, onCanvasDown, onCanvasUp, startScrollCollect, skipScrollTrace, cancelScrollCollect, onScrollWheel, } } }