/**
* 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 }}
`
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,
}
}
}