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:
362
static/css/main.css
Normal file
362
static/css/main.css
Normal file
@@ -0,0 +1,362 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0a0e1a;
|
||||
--surface: #0f1729;
|
||||
--surface2: #1e293b;
|
||||
--border: #334155;
|
||||
--fg: #f8fafc;
|
||||
--muted: #64748b;
|
||||
--hint: #bae6fd;
|
||||
--green: #22c55e;
|
||||
--red: #ef4444;
|
||||
--accent: #22c55e;
|
||||
--sky: #38bdf8;
|
||||
--gold: #fbbf24;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.header {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.tab {
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
transition: all .15s;
|
||||
}
|
||||
.tab:hover { color: var(--fg); background: var(--surface2); }
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
background: rgba(34,197,94,.1);
|
||||
border-color: rgba(34,197,94,.3);
|
||||
}
|
||||
.status-bar {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-bar .ok { color: var(--green); }
|
||||
.status-bar .warn { color: var(--gold); }
|
||||
|
||||
/* ── Layout ── */
|
||||
.view { padding: 24px; max-width: 960px; margin: 0 auto; }
|
||||
|
||||
/* ── Sub-tabs (鼠标/滚轮 selector within a view) ── */
|
||||
.sub-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sub-tab {
|
||||
padding: 6px 18px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all .15s;
|
||||
}
|
||||
.sub-tab:hover { color: var(--fg); background: var(--surface2); }
|
||||
.sub-tab.active {
|
||||
color: var(--sky);
|
||||
background: rgba(56,189,248,.08);
|
||||
border-color: rgba(56,189,248,.25);
|
||||
}
|
||||
|
||||
/* ── Form ── */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--fg);
|
||||
padding: 7px 10px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.field input:focus { border-color: var(--accent); }
|
||||
.field input:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all .15s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.btn-primary:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn-secondary {
|
||||
background: var(--surface2);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { border-color: var(--accent); }
|
||||
.btn-secondary:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
/* ── Canvas container ── */
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
canvas { display: block; }
|
||||
|
||||
/* collect canvas: fixed logical size, cursor hidden (custom cursor drawn inside) */
|
||||
#collectCanvas {
|
||||
cursor: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── Progress ── */
|
||||
.progress-section { margin: 16px 0; }
|
||||
.progress-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.progress-track {
|
||||
height: 8px;
|
||||
background: var(--surface2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 4px;
|
||||
transition: width .2s;
|
||||
}
|
||||
.loss-display {
|
||||
font-size: 13px;
|
||||
color: var(--hint);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ── Messages ── */
|
||||
.msg { font-size: 14px; margin: 12px 0; padding: 10px 14px; border-radius: 8px; }
|
||||
.msg-success { background: rgba(34,197,94,.12); color: var(--green); border: 1px solid rgba(34,197,94,.3); }
|
||||
.msg-error { background: rgba(239,68,68,.12); color: var(--red); border: 1px solid rgba(239,68,68,.3); }
|
||||
.msg-info { background: rgba(56,189,248,.10); color: var(--hint); border: 1px solid rgba(56,189,248,.3); }
|
||||
|
||||
/* ── Verify charts ── */
|
||||
.verify-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat-pill {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.stat-pill span {
|
||||
color: var(--hint);
|
||||
font-weight: 600;
|
||||
}
|
||||
.verify-charts {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: nowrap;
|
||||
margin-top: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.verify-charts .chart-box {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.chart-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
letter-spacing: .3px;
|
||||
}
|
||||
.echarts-box {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
/* ── Scroll collection ── */
|
||||
.scroll-select {
|
||||
width: 100%;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--fg);
|
||||
padding: 7px 10px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.scroll-select:focus { border-color: var(--accent); }
|
||||
.scroll-select:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.scroll-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.scroll-overlay-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
.scroll-target-band {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 50px;
|
||||
background: rgba(239, 68, 68, 0.6);
|
||||
border: 2px solid var(--red);
|
||||
transition: background .3s, border-color .3s;
|
||||
}
|
||||
.scroll-target-band.success {
|
||||
background: rgba(34, 197, 94, 0.6);
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
/* Fixed HUD elements on top of overlay */
|
||||
.scroll-hud {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1001;
|
||||
}
|
||||
.scroll-hud > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.scroll-success-zone {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.scroll-zone-line {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 0;
|
||||
border-top: 2px dashed rgba(255,255,255,0.5);
|
||||
}
|
||||
.scroll-zone-line-top {
|
||||
top: calc(50vh - 60px);
|
||||
}
|
||||
.scroll-zone-line-bottom {
|
||||
top: calc(50vh + 60px);
|
||||
}
|
||||
|
||||
.scroll-direction {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 40px;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.scroll-arrow {
|
||||
font-size: 48px;
|
||||
color: var(--sky);
|
||||
text-shadow: 0 0 12px rgba(56,189,248,0.5);
|
||||
animation: scrollArrowPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes scrollArrowPulse {
|
||||
0%, 100% { opacity: 1; transform: translateY(0); }
|
||||
50% { opacity: 0.6; transform: translateY(4px); }
|
||||
}
|
||||
.scroll-dist-label {
|
||||
font-size: 14px;
|
||||
color: var(--hint);
|
||||
background: rgba(15,23,42,0.8);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scroll-progress-hud {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(15,23,42,0.9);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.scroll-cancel-btn {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
37
static/index.html
Normal file
37
static/index.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Mouse — Trajectory Generator</title>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/main.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="header">
|
||||
<span class="header-title">AI Mouse</span>
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{active: view==='collect'}" @click="switchView('collect')">采集数据</button>
|
||||
<button class="tab" :class="{active: view==='train'}" @click="switchView('train')">训练模型</button>
|
||||
<button class="tab" :class="{active: view==='verify'}" @click="switchView('verify')">验证效果</button>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
鼠标:<span :class="status.trace_count>0?'ok':'warn'">{{ status.trace_count }}</span> 条
|
||||
|
|
||||
滚轮:<span :class="scrollStatus.trace_count>0?'ok':'warn'">{{ scrollStatus.trace_count }}</span> 条
|
||||
|
|
||||
模型:<span :class="status.model_trained?'ok':'warn'">{{ status.model_trained ? '就绪' : '未训练' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<collect-view v-show="view==='collect'" @status-changed="refreshAll"></collect-view>
|
||||
<train-view v-show="view==='train'" @status-changed="refreshAll"></train-view>
|
||||
<verify-view v-show="view==='verify'"></verify-view>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
static/js/api.js
Normal file
39
static/js/api.js
Normal 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
46
static/js/app.js
Normal 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
41
static/js/charts.js
Normal 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
446
static/js/collect.js
Normal 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
133
static/js/train.js
Normal 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
347
static/js/verify.js
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user