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
This commit is contained in:
2026-05-10 12:24:07 +08:00
commit 4d414fd180
44 changed files with 9681 additions and 0 deletions

39
static/js/api.js Normal file
View File

@@ -0,0 +1,39 @@
/**
* API utilities: axios instance and SSE helper.
*/
export const API_BASE = ''
export const api = axios.create({ baseURL: '/api' })
/**
* Consume a Server-Sent Events stream from a POST endpoint.
* @param {string} url - The URL to POST to (e.g. '/api/train')
* @param {object} body - JSON body to send
* @param {function} onMessage - Callback invoked with each parsed SSE message object
* @returns {Promise<void>}
*/
export async function fetchSSE(url, body, onMessage) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const reader = resp.body.getReader()
const dec = new TextDecoder()
let buf = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
const parts = buf.split('\n\n')
buf = parts.pop()
for (const part of parts) {
const line = part.trim()
if (!line.startsWith('data:')) continue
const msg = JSON.parse(line.slice(5).trim())
onMessage(msg)
}
}
}

46
static/js/app.js Normal file
View File

@@ -0,0 +1,46 @@
/**
* Main application entry point.
* Creates the Vue app, registers components, and mounts.
*/
import { CollectView } from './collect.js'
import { TrainView } from './train.js'
import { VerifyView } from './verify.js'
const { createApp, ref, reactive, onMounted } = Vue
const app = createApp({
setup() {
const view = ref('collect')
const status = reactive({ trace_count: 0, model_trained: false })
const scrollStatus = reactive({ trace_count: 0, model_trained: false })
async function refreshAll() {
try {
const r = await axios.get('/api/status')
status.trace_count = r.data.trace_count
status.model_trained = r.data.model_trained
} catch (_) {}
try {
const r = await axios.get('/api/scroll/status')
scrollStatus.trace_count = r.data.trace_count
scrollStatus.model_trained = r.data.model_trained
} catch (_) {}
}
function switchView(v) {
view.value = v
refreshAll()
}
onMounted(() => { refreshAll() })
return { view, status, scrollStatus, switchView, refreshAll }
}
})
app.component('collect-view', CollectView)
app.component('train-view', TrainView)
app.component('verify-view', VerifyView)
app.mount('#app')

41
static/js/charts.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* ECharts helpers: color palettes and create/dispose wrappers.
*/
export const TAB10_COLORS = [
'#38bdf8', '#22c55e', '#f59e0b', '#ec4899',
'#a78bfa', '#fb923c', '#34d399', '#f472b6',
'#60a5fa', '#fbbf24',
]
/**
* Map a speed value to a coolwarm hex colour.
* t in [0,1]: 0 = blue (fast), 1 = red (slow)
*/
export function coolwarmColor(t) {
const r = t < .5 ? Math.round(t * 2 * 140) : Math.round(140 + (t - .5) * 2 * 115)
const g = t < .5 ? Math.round(50 + t * 2 * 100) : Math.round(150 - (t - .5) * 2 * 150)
const b = t < .5 ? Math.round(200 - t * 2 * 100) : Math.round(100 - (t - .5) * 2 * 100)
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
}
/**
* Create an ECharts instance on a container element.
* @param {HTMLElement} container
* @returns {object} ECharts instance
*/
export function createChart(container) {
return echarts.init(container, 'dark')
}
/**
* Dispose an ECharts instance on a container if one exists.
* @param {object|null} chartInstance - The echarts instance to dispose
* @returns {null}
*/
export function disposeChart(chartInstance) {
if (chartInstance) {
chartInstance.dispose()
}
return null
}

446
static/js/collect.js Normal file
View File

@@ -0,0 +1,446 @@
/**
* 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,
}
}
}

133
static/js/train.js Normal file
View File

@@ -0,0 +1,133 @@
/**
* Train tab component — unified training with [鼠标 | 滚轮] sub-tabs.
*/
import { fetchSSE } from './api.js'
const template = `
<div class="view">
<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" style="max-width:240px;">
<div class="field">
<label>训练轮数</label>
<input type="number" v-model.number="mouseTrain.epochs" :disabled="mouseTrain.running" min="10" />
</div>
</div>
<button class="btn btn-primary" @click="startMouseTrain" :disabled="mouseTrain.running">
{{ mouseTrain.running ? '训练中…' : '开始训练' }}
</button>
<div class="progress-section" v-if="mouseTrain.running || mouseTrain.done">
<div class="progress-label">
<span>JointCVAE 联合模型</span>
<span>{{ mouseTrain.epoch }} / {{ mouseTrain.total }}</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{width: mouseTrainPct+'%'}"></div>
</div>
<div class="loss-display" v-if="mouseTrain.loss !== null">loss = {{ mouseTrain.loss.toFixed(4) }}</div>
</div>
<div v-if="mouseTrain.done" class="msg msg-success">
训练完成!点击分布:μ = {{ mouseTrain.mu.toFixed(1) }} msσ = {{ mouseTrain.sigma.toFixed(1) }} ms
</div>
<div v-if="mouseTrain.error" class="msg msg-error">{{ mouseTrain.error }}</div>
</div>
<!-- ════════ 滚轮训练 ════════ -->
<div v-show="subTab==='scroll'">
<div class="form-grid" style="max-width:240px;">
<div class="field">
<label>训练轮数</label>
<input type="number" v-model.number="scrollTrain.epochs" :disabled="scrollTrain.running" min="10" />
</div>
</div>
<button class="btn btn-primary" @click="startScrollTrain" :disabled="scrollTrain.running">
{{ scrollTrain.running ? '训练中…' : '开始训练' }}
</button>
<div class="progress-section" v-if="scrollTrain.running || scrollTrain.done">
<div class="progress-label">
<span>ScrollCVAE 滚轮模型</span>
<span>{{ scrollTrain.epoch }} / {{ scrollTrain.total }}</span>
</div>
<div class="progress-track">
<div class="progress-fill" :style="{width: scrollTrainPct+'%'}"></div>
</div>
<div class="loss-display" v-if="scrollTrain.loss !== null">loss = {{ scrollTrain.loss.toFixed(4) }}</div>
</div>
<div v-if="scrollTrain.done" class="msg msg-success">滚轮模型训练完成!</div>
<div v-if="scrollTrain.error" class="msg msg-error">{{ scrollTrain.error }}</div>
</div>
</div>
`
export const TrainView = {
template,
emits: ['status-changed'],
setup(props, { emit }) {
const { ref, reactive, computed } = Vue
const subTab = ref('mouse')
// ── 鼠标训练 ──
const mouseTrain = reactive({
epochs: 100, running: false, done: false,
epoch: 0, total: 0, loss: null, mu: 0, sigma: 0, error: '',
})
const mouseTrainPct = computed(() => {
if (!mouseTrain.total) return 0
return Math.round(mouseTrain.epoch / mouseTrain.total * 100)
})
function startMouseTrain() {
mouseTrain.running = true; mouseTrain.done = false; mouseTrain.error = ''
mouseTrain.epoch = 0; mouseTrain.total = mouseTrain.epochs; mouseTrain.loss = null
fetchSSE('/api/train', { epochs: mouseTrain.epochs }, (msg) => {
if (msg.error) { mouseTrain.error = msg.error; mouseTrain.running = false; return }
if (msg.done) {
mouseTrain.mu = msg.mu || 0; mouseTrain.sigma = msg.sigma || 0
mouseTrain.running = false; mouseTrain.done = true; emit('status-changed'); return
}
mouseTrain.epoch = msg.epoch; mouseTrain.total = msg.total; mouseTrain.loss = msg.loss
})
}
// ── 滚轮训练 ──
const scrollTrain = reactive({
epochs: 100, running: false, done: false,
epoch: 0, total: 0, loss: null, error: '',
})
const scrollTrainPct = computed(() => {
if (!scrollTrain.total) return 0
return Math.round(scrollTrain.epoch / scrollTrain.total * 100)
})
function startScrollTrain() {
scrollTrain.running = true; scrollTrain.done = false; scrollTrain.error = ''
scrollTrain.epoch = 0; scrollTrain.total = scrollTrain.epochs; scrollTrain.loss = null
fetchSSE('/api/scroll/train', { epochs: scrollTrain.epochs }, (msg) => {
if (msg.error) { scrollTrain.error = msg.error; scrollTrain.running = false; return }
if (msg.done) { scrollTrain.running = false; scrollTrain.done = true; emit('status-changed'); return }
scrollTrain.epoch = msg.epoch; scrollTrain.total = msg.total; scrollTrain.loss = msg.loss
})
}
return {
subTab, mouseTrain, scrollTrain,
mouseTrainPct, scrollTrainPct,
startMouseTrain, startScrollTrain,
}
}
}

347
static/js/verify.js Normal file
View File

@@ -0,0 +1,347 @@
/**
* Verify tab component — unified verification with [鼠标 | 滚轮] sub-tabs.
*/
import { api } from './api.js'
import { TAB10_COLORS, coolwarmColor, createChart, disposeChart } from './charts.js'
const MODE_LABELS = { target: '定点', fast: '快速', precise: '精确' }
const template = `
<div class="view">
<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>起点 x,y</label>
<input v-model="verify.startStr" :disabled="verify.loading" placeholder="100,200" />
</div>
<div class="field">
<label>终点 x,y</label>
<input v-model="verify.endStr" :disabled="verify.loading" placeholder="800,450" />
</div>
<div class="field">
<label>生成条数1-12</label>
<input type="number" v-model.number="verify.nPaths" :disabled="verify.loading" min="1" max="12" />
</div>
</div>
<button class="btn btn-primary" @click="startVerify" :disabled="verify.loading">
{{ verify.loading ? '生成中…' : '生成' }}
</button>
<div v-if="verify.error" class="msg msg-error">{{ verify.error }}</div>
<div v-show="verify.paths.length > 0">
<div class="verify-stats">
<div class="stat-pill">距离 <span>{{ verify.stats.dist }} px</span></div>
<div class="stat-pill">角度 <span>{{ verify.stats.angle }}°</span></div>
<div class="stat-pill">平均时长 <span>{{ verify.stats.avgDur }} ms</span></div>
<div class="stat-pill">均值 Δt <span>{{ verify.stats.meanDt }} ms</span></div>
</div>
<div class="verify-charts">
<div class="chart-box">
<div class="chart-title">轨迹可视化(颜色=速度)</div>
<div id="trajChart" class="echarts-box"></div>
</div>
<div class="chart-box">
<div class="chart-title">时间间隔 Δt</div>
<div id="dtChart" class="echarts-box"></div>
</div>
</div>
</div>
</div>
<!-- ════════ 滚轮验证 ════════ -->
<div v-show="subTab==='scroll'">
<div class="form-grid">
<div class="field">
<label>起始 scrollY</label>
<input type="number" v-model.number="scrollVerify.startScrollY" :disabled="scrollVerify.loading" />
</div>
<div class="field">
<label>目标 scrollY</label>
<input type="number" v-model.number="scrollVerify.targetScrollY" :disabled="scrollVerify.loading" />
</div>
<div class="field">
<label>模式</label>
<select v-model="scrollVerify.mode" :disabled="scrollVerify.loading" class="scroll-select">
<option value="target">定点</option>
<option value="fast">快速</option>
<option value="precise">精确</option>
</select>
</div>
<div class="field">
<label>生成条数1-12</label>
<input type="number" v-model.number="scrollVerify.nPaths" :disabled="scrollVerify.loading" min="1" max="12" />
</div>
</div>
<button class="btn btn-primary" @click="startScrollVerify" :disabled="scrollVerify.loading">
{{ scrollVerify.loading ? '生成中…' : '生成' }}
</button>
<div v-if="scrollVerify.error" class="msg msg-error">{{ scrollVerify.error }}</div>
<div v-show="scrollVerify.paths.length > 0">
<div class="verify-stats">
<div class="stat-pill">距离 <span>{{ scrollVerify.stats.distance }} px</span></div>
<div class="stat-pill">模式 <span>{{ scrollVerify.stats.mode }}</span></div>
<div class="stat-pill">平均时长 <span>{{ scrollVerify.stats.avgDur }} ms</span></div>
<div class="stat-pill">事件数 <span>{{ scrollVerify.stats.avgEvents }}</span></div>
<div class="stat-pill">均值 |δY| <span>{{ scrollVerify.stats.meanAbsDy }}</span></div>
</div>
<div class="verify-charts">
<div class="chart-box">
<div class="chart-title">累计滚动位置</div>
<div id="scrollPosChart" class="echarts-box"></div>
</div>
<div class="chart-box">
<div class="chart-title">deltaY 事件</div>
<div id="scrollDyChart" class="echarts-box"></div>
</div>
</div>
</div>
</div>
</div>
`
export const VerifyView = {
template,
setup() {
const { ref, reactive, nextTick } = Vue
const subTab = ref('mouse')
// ═══════════════════════════════════════════════════════════════
// 鼠标验证
// ═══════════════════════════════════════════════════════════════
const verify = reactive({
startStr: '100,200', endStr: '700,400', nPaths: 5,
loading: false, error: '', paths: [],
stats: { dist: 0, angle: 0, avgDur: 0, meanDt: 0 },
})
let trajChartInst = null, dtChartInst = null
async function startVerify() {
verify.error = ''; verify.loading = true; verify.paths = []
const start = verify.startStr.split(',').map(Number)
const end = verify.endStr.split(',').map(Number)
if (start.length !== 2 || end.length !== 2 || start.some(isNaN) || end.some(isNaN)) {
verify.error = '坐标格式错误,请输入 x,y'; verify.loading = false; return
}
try {
const r = await api.post('/verify', { start, end, n_paths: Math.max(1, Math.min(12, verify.nPaths)) })
verify.paths = r.data.paths
// Stats
const dx = end[0] - start[0], dy = end[1] - start[1]
verify.stats.dist = Math.round(Math.sqrt(dx*dx + dy*dy))
verify.stats.angle = Math.round(Math.atan2(dy, dx) * 180 / Math.PI)
let totalDur = 0, totalDt = 0, dtCount = 0
for (const path of r.data.paths) {
if (path.length > 2) totalDur += path[path.length - 3][2] // last move point
for (let i = 1; i < path.length - 2; i++) {
totalDt += path[i][2] - path[i-1][2]; dtCount++
}
}
const n = r.data.paths.length || 1
verify.stats.avgDur = Math.round(totalDur / n)
verify.stats.meanDt = dtCount > 0 ? Math.round(totalDt / dtCount) : 0
await nextTick()
drawMouseCharts(r.data.paths, start, end)
} catch (e) { verify.error = e.response?.data?.detail || e.message }
finally { verify.loading = false }
}
function drawMouseCharts(paths, start, end) {
const trajDom = document.getElementById('trajChart')
const dtDom = document.getElementById('dtChart')
trajChartInst = disposeChart(trajChartInst)
dtChartInst = disposeChart(dtChartInst)
trajChartInst = createChart(trajDom)
dtChartInst = createChart(dtDom)
// ── Trajectory chart with speed coloring ──
const allSpeeds = []
for (const path of paths) {
for (let i = 1; i < path.length - 2; i++) {
const dx = path[i][0] - path[i-1][0], dy = path[i][1] - path[i-1][1]
const dt = (path[i][2] - path[i-1][2]) || 1
allSpeeds.push(Math.sqrt(dx*dx + dy*dy) / dt)
}
}
const maxSpd = Math.max(...allSpeeds, 0.01)
const segSeries = []
for (const path of paths) {
for (let i = 1; i < path.length - 2; i++) {
const dx = path[i][0] - path[i-1][0], dy = path[i][1] - path[i-1][1]
const dt = (path[i][2] - path[i-1][2]) || 1
const spd = Math.sqrt(dx*dx + dy*dy) / dt
const col = coolwarmColor(1 - spd / maxSpd)
segSeries.push({
type: 'line', data: [[path[i-1][0], path[i-1][1]], [path[i][0], path[i][1]]],
lineStyle: { color: col, width: 2, opacity: 0.8 }, symbol: 'none', silent: true, animation: false,
})
}
}
// Start/end markers
segSeries.push({
type: 'scatter', data: [[start[0], start[1]]], symbolSize: 14,
itemStyle: { color: '#22c55e' }, label: { show: true, formatter: 'S', color: '#fff', fontSize: 10 },
})
segSeries.push({
type: 'scatter', data: [[end[0], end[1]]], symbolSize: 14,
itemStyle: { color: '#fbbf24' }, label: { show: true, formatter: 'E', color: '#fff', fontSize: 10 },
})
trajChartInst.setOption({
backgroundColor: '#0a0e1a',
grid: { left: 48, right: 20, top: 16, bottom: 32 },
xAxis: { type: 'value', axisLine: { lineStyle: { color: '#334155' } }, axisLabel: { color: '#475569', fontSize: 10 }, splitLine: { lineStyle: { color: '#1e293b' } } },
yAxis: { type: 'value', inverse: true, axisLine: { lineStyle: { color: '#334155' } }, axisLabel: { color: '#475569', fontSize: 10 }, splitLine: { lineStyle: { color: '#1e293b' } } },
series: segSeries,
}, true)
// ── Δt chart ──
const dtSeries = paths.map((path, idx) => ({
type: 'line', name: `路径 ${idx+1}`,
data: path.slice(1, -2).map((p, i) => path[i+1][2] - path[i][2]),
lineStyle: { color: TAB10_COLORS[idx % TAB10_COLORS.length], width: 1.5 },
itemStyle: { color: TAB10_COLORS[idx % TAB10_COLORS.length] },
symbol: 'none', smooth: false,
}))
// Mean line
const allDt = []; paths.forEach(p => { for (let i=1; i<p.length-2; i++) allDt.push(p[i][2]-p[i-1][2]) })
const meanDt = allDt.length > 0 ? allDt.reduce((a,b)=>a+b,0)/allDt.length : 0
dtChartInst.setOption({
backgroundColor: '#0a0e1a',
tooltip: { trigger: 'axis', backgroundColor: '#1e293b', borderColor: '#334155', textStyle: { color: '#f8fafc', fontSize: 12 } },
legend: { show: paths.length > 1, top: 6, right: 8, textStyle: { color: '#64748b', fontSize: 11 }, itemWidth: 18, itemHeight: 3, icon: 'rect' },
grid: { left: 48, right: 20, top: paths.length > 1 ? 36 : 16, bottom: 32 },
xAxis: { type: 'category', name: '点序号', axisLine: { lineStyle: { color: '#334155' } }, axisLabel: { color: '#475569', fontSize: 10 }, splitLine: { show: false } },
yAxis: { type: 'value', name: 'ms', axisLine: { lineStyle: { color: '#334155' } }, axisLabel: { color: '#475569', fontSize: 10 }, splitLine: { lineStyle: { color: '#1e293b' } } },
dataZoom: [{ type: 'inside', xAxisIndex: 0, start: 0, end: 100 }],
series: [...dtSeries, {
type: 'line', name: '均值', data: [], markLine: {
silent: true, data: [{ yAxis: meanDt, label: { formatter: `均值 ${meanDt.toFixed(1)}ms`, color: '#64748b', fontSize: 10 } }],
lineStyle: { color: '#64748b', type: 'dashed' },
}, itemStyle: { color: 'transparent' },
}],
}, true)
}
// ═══════════════════════════════════════════════════════════════
// 滚轮验证
// ═══════════════════════════════════════════════════════════════
const scrollVerify = reactive({
startScrollY: 0, targetScrollY: 2000, mode: 'target', nPaths: 5,
loading: false, error: '', paths: [],
stats: { distance: 0, mode: '', avgDur: 0, avgEvents: 0, meanAbsDy: 0 },
})
let scrollPosChartInst = null, scrollDyChartInst = null
async function startScrollVerify() {
scrollVerify.error = ''; scrollVerify.loading = true; scrollVerify.paths = []
try {
const r = await api.post('/scroll/verify', {
start_scrollY: scrollVerify.startScrollY, target_scrollY: scrollVerify.targetScrollY,
mode: scrollVerify.mode, n_paths: Math.max(1, Math.min(12, scrollVerify.nPaths)),
})
scrollVerify.paths = r.data.paths
const distance = Math.abs(scrollVerify.targetScrollY - scrollVerify.startScrollY)
let totalDur = 0, totalEvents = 0, totalAbsDy = 0, dyCount = 0
for (const path of r.data.paths) {
if (path.length > 0) { totalDur += path[path.length-1].t; totalEvents += path.length }
for (const ev of path) { totalAbsDy += Math.abs(ev.deltaY); dyCount++ }
}
const n = r.data.paths.length || 1
scrollVerify.stats = {
distance, mode: MODE_LABELS[scrollVerify.mode] || scrollVerify.mode,
avgDur: Math.round(totalDur / n), avgEvents: Math.round(totalEvents / n),
meanAbsDy: dyCount > 0 ? Math.round(totalAbsDy / dyCount) : 0,
}
await nextTick()
drawScrollCharts(r.data.paths)
} catch (e) { scrollVerify.error = e.response?.data?.detail || e.message }
finally { scrollVerify.loading = false }
}
function drawScrollCharts(paths) {
const posDom = document.getElementById('scrollPosChart')
const dyDom = document.getElementById('scrollDyChart')
scrollPosChartInst = disposeChart(scrollPosChartInst)
scrollDyChartInst = disposeChart(scrollDyChartInst)
scrollPosChartInst = createChart(posDom)
scrollDyChartInst = createChart(dyDom)
// Cumulative position chart with speed coloring
const allSpeeds = []
for (const path of paths) {
for (let i = 1; i < path.length; i++) {
const dy = Math.abs(path[i].deltaY), dt = (path[i].t - path[i-1].t) || 1
allSpeeds.push(dy / dt)
}
}
const sMax = Math.max(...allSpeeds, 0.01)
const segSeries = []
for (const path of paths) {
let cumY = 0; const pts = [{ t: 0, y: 0 }]
for (const ev of path) { cumY += ev.deltaY; pts.push({ t: ev.t, y: cumY }) }
for (let i = 1; i < pts.length; i++) {
const dy = Math.abs(pts[i].y - pts[i-1].y), dt = (pts[i].t - pts[i-1].t) || 1
const col = coolwarmColor(1 - (dy/dt) / sMax)
segSeries.push({
type: 'line', data: [[pts[i-1].t, pts[i-1].y], [pts[i].t, pts[i].y]],
lineStyle: { color: col, width: 2, opacity: 0.85 }, symbol: 'none', silent: true, animation: false,
})
}
}
scrollPosChartInst.setOption({
backgroundColor: '#0a0e1a',
tooltip: { trigger: 'axis', backgroundColor: '#1e293b', borderColor: '#334155', textStyle: { color: '#f8fafc', fontSize: 12 } },
grid: { left: 56, right: 20, top: 16, bottom: 32 },
xAxis: { type: 'value', name: 'ms', axisLine: { lineStyle: { color: '#334155' } }, axisLabel: { color: '#475569', fontSize: 10 }, splitLine: { lineStyle: { color: '#1e293b' } } },
yAxis: { type: 'value', name: 'scrollY', axisLine: { lineStyle: { color: '#334155' } }, axisLabel: { color: '#475569', fontSize: 10 }, splitLine: { lineStyle: { color: '#1e293b' } } },
series: segSeries,
}, true)
// DeltaY chart
const dySeries = paths.map((path, idx) => ({
type: 'line', name: `路径 ${idx+1}`, data: path.map(ev => ev.deltaY),
lineStyle: { color: TAB10_COLORS[idx % TAB10_COLORS.length], width: 1.8 },
itemStyle: { color: TAB10_COLORS[idx % TAB10_COLORS.length] }, symbol: 'none',
}))
scrollDyChartInst.setOption({
backgroundColor: '#0a0e1a',
tooltip: { trigger: 'axis', backgroundColor: '#1e293b', borderColor: '#334155', textStyle: { color: '#f8fafc', fontSize: 12 } },
legend: { show: paths.length > 1, top: 6, right: 8, textStyle: { color: '#64748b', fontSize: 11 }, itemWidth: 18, itemHeight: 3, icon: 'rect' },
grid: { left: 52, right: 20, top: paths.length > 1 ? 36 : 16, bottom: 32 },
xAxis: { type: 'category', name: '事件序号', axisLine: { lineStyle: { color: '#334155' } }, axisLabel: { color: '#475569', fontSize: 10 }, splitLine: { show: false } },
yAxis: { type: 'value', name: 'deltaY', axisLine: { lineStyle: { color: '#334155' } }, axisLabel: { color: '#475569', fontSize: 10 }, splitLine: { lineStyle: { color: '#1e293b' } } },
dataZoom: [{ type: 'inside', xAxisIndex: 0, start: 0, end: 100 }],
series: dySeries,
}, true)
}
return {
subTab, verify, scrollVerify,
startVerify, startScrollVerify,
}
}
}