Files
ai_mouse/static/js/collect.js
Huang Qi 4d414fd180 chore: initialize git repo, add matplotlib dep, extend config
- Add .gitignore for Python/data/models
- Add matplotlib>=3.8.0 for eval plots
- Add PretrainConfig, FinetuneConfig, BalabitAdapterConfig, EvalConfig dataclasses
2026-05-10 12:24:07 +08:00

447 lines
20 KiB
JavaScript
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.

/**
* 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 = `
<div class="view">
<!-- Sub-tab selector -->
<div class="sub-tabs">
<button class="sub-tab" :class="{active: subTab==='mouse'}" @click="subTab='mouse'">鼠标轨迹</button>
<button class="sub-tab" :class="{active: subTab==='scroll'}" @click="subTab='scroll'">滚轮事件</button>
</div>
<!-- ════════ 鼠标采集 ════════ -->
<div v-show="subTab==='mouse'">
<div class="form-grid">
<div class="field">
<label>采集条数</label>
<input type="number" v-model.number="collect.count" :disabled="collect.active" min="1" />
</div>
<div class="field">
<label>最小距离px</label>
<input type="number" v-model.number="collect.distMin" :disabled="collect.active" min="10" />
</div>
<div class="field">
<label>最大距离px</label>
<input type="number" v-model.number="collect.distMax" :disabled="collect.active" min="20" />
</div>
</div>
<div style="display:flex; gap:8px; margin-bottom:16px;">
<button class="btn btn-primary" @click="startCollect" :disabled="collect.active">开始采集</button>
<button class="btn btn-secondary" @click="skipTrace" :disabled="!collect.active" title="ESC">跳过 (ESC)</button>
</div>
<div v-if="collect.message" class="msg" :class="collect.done?'msg-success':'msg-info'">{{ collect.message }}</div>
<div class="canvas-wrap" v-show="collect.active" style="display:inline-block; max-width:100%;">
<canvas id="collectCanvas"
@mousemove="onCanvasMove"
@mousedown.left="onCanvasDown"
@mouseup.left="onCanvasUp"
tabindex="0"
></canvas>
</div>
</div>
<!-- ════════ 滚轮采集 ════════ -->
<div v-show="subTab==='scroll'">
<div class="form-grid">
<div class="field">
<label>模式</label>
<select v-model="scroll.mode" :disabled="scroll.active" class="scroll-select">
<option value="target">定点</option>
<option value="fast">快速</option>
<option value="precise">精确</option>
</select>
</div>
<div class="field">
<label>采集条数</label>
<input type="number" v-model.number="scroll.count" :disabled="scroll.active" min="1" />
</div>
</div>
<div style="display:flex; gap:8px; margin-bottom:16px;">
<button class="btn btn-primary" @click="startScrollCollect" :disabled="scroll.active">开始采集</button>
<button class="btn btn-secondary" @click="skipScrollTrace" :disabled="!scroll.active">跳过</button>
<button class="btn btn-secondary" @click="cancelScrollCollect" :disabled="!scroll.active">取消</button>
</div>
<div v-if="scroll.message" class="msg" :class="scroll.done?'msg-success':'msg-info'">{{ scroll.message }}</div>
</div>
<!-- ── 滚轮采集全屏 overlay ── -->
<teleport to="body">
<div v-if="scroll.active" class="scroll-overlay" @wheel.prevent="onScrollWheel">
<div class="scroll-overlay-inner" :style="{height: scroll.pageHeight + 'px'}">
<div class="scroll-target-band" :class="{success: scroll.hitSuccess}"
:style="{top: scroll.targetScrollY + 'px'}"></div>
</div>
<div class="scroll-hud">
<div class="scroll-success-zone">
<div class="scroll-zone-line scroll-zone-line-top"></div>
<div class="scroll-zone-line scroll-zone-line-bottom"></div>
</div>
<div class="scroll-direction">
<span class="scroll-arrow">{{ scroll.direction === 'down' ? '\\u2193' : '\\u2191' }}</span>
<span class="scroll-dist-label">{{ Math.abs(scroll.targetScrollY - scroll.currentScrollY) }} px</span>
</div>
<div class="scroll-progress-hud">
{{ scroll.collected }}/{{ scroll.count }} ({{ MODE_LABELS[scroll.mode] }})
</div>
<button class="btn btn-secondary scroll-cancel-btn" @click="cancelScrollCollect">\\u2715 关闭</button>
</div>
</div>
</teleport>
</div>
`
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,
}
}
}