Files
pickling-mes/frontend/src/views/ProcessModel.vue
wangyu 475c169d33 feat(prediction): 工艺参数沙盘 — PI 累积曲线 + 最大速度安全区间条
预测页酸洗模型计算结果加 SVG 可视化:
- PI 累积曲线(入口零点 → 5 槽累计 PI,含目标线 dashed 标注)
- 速度安全区间条(20-180 m/min 区间内 max_speed 位置 + 数值标签)
零依赖,纯 SVG 内联。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 09:51:29 +08:00

656 lines
27 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<div class="flex-between" style="margin-bottom:4px;">
<div class="sec-title" style="margin-bottom:0;">🧪 酸洗工艺段模型</div>
<div class="flex-row">
<span class="kv-label">刷新时间{{ lastRefresh }}</span>
<span :class="['badge', l1Online ? 'badge-green' : 'badge-yellow']" style="margin-left:8px;">
{{ l1Online ? 'L1在线' : '无L1数据' }}
</span>
</div>
</div>
<!-- 酸槽 1-3 -->
<div class="sec-title">酸槽 1#3#</div>
<div class="grid-3">
<div v-for="i in [0,1,2]" :key="i" class="card">
<div class="card-header">
{{ i+1 }}# 酸槽
<span :class="['badge', tankBadge(tanks[i])]" style="margin-left:auto;">{{ tankStatus(tanks[i]) }}</span>
</div>
<div class="card-body">
<div class="kv-grid">
<span class="kv-label">HCl 浓度</span>
<span class="kv-value">{{ tanks[i].conc != null ? tanks[i].conc : '—' }} <span class="kv-unit">g/L</span></span>
<span class="kv-label">温度</span>
<span class="kv-value">{{ tanks[i].temp != null ? tanks[i].temp : '—' }} <span class="kv-unit">°C</span></span>
<span class="kv-label">Fe² 含量</span>
<span class="kv-value">{{ tanks[i].fe2 != null ? tanks[i].fe2 : '—' }} <span class="kv-unit">g/L</span></span>
<span class="kv-label">停留时间</span>
<span class="kv-value">{{ tanks[i].rt != null ? tanks[i].rt : '—' }} <span class="kv-unit">s</span></span>
</div>
<div class="mt8" v-if="tanks[i].eff != null">
<div class="flex-between" style="margin-bottom:4px;">
<span class="kv-label" style="font-size:11px;">酸洗效率模型估算</span>
<span style="font-size:11px;color:#00c8ff;">{{ tanks[i].eff }}%</span>
</div>
<div class="prog-bar-wrap">
<div class="prog-bar-fill" :style="{ width: tanks[i].eff + '%', background: effColor(tanks[i].eff) }"></div>
</div>
</div>
<div v-else class="kv-label" style="margin-top:8px;text-align:center;">等待 L1 数据</div>
</div>
</div>
</div>
<!-- 酸槽 4-5 -->
<div class="sec-title mt8">酸槽 4#5#</div>
<div class="grid-3">
<div v-for="i in [3,4]" :key="i" class="card">
<div class="card-header">
{{ i+1 }}# 酸槽
<span :class="['badge', tankBadge(tanks[i])]" style="margin-left:auto;">{{ tankStatus(tanks[i]) }}</span>
</div>
<div class="card-body">
<div class="kv-grid">
<span class="kv-label">HCl 浓度</span>
<span class="kv-value">{{ tanks[i].conc != null ? tanks[i].conc : '—' }} <span class="kv-unit">g/L</span></span>
<span class="kv-label">温度</span>
<span class="kv-value">{{ tanks[i].temp != null ? tanks[i].temp : '—' }} <span class="kv-unit">°C</span></span>
<span class="kv-label">Fe² 含量</span>
<span class="kv-value">{{ tanks[i].fe2 != null ? tanks[i].fe2 : '—' }} <span class="kv-unit">g/L</span></span>
<span class="kv-label">停留时间</span>
<span class="kv-value">{{ tanks[i].rt != null ? tanks[i].rt : '—' }} <span class="kv-unit">s</span></span>
</div>
<div class="mt8" v-if="tanks[i].eff != null">
<div class="flex-between" style="margin-bottom:4px;">
<span class="kv-label" style="font-size:11px;">酸洗效率模型估算</span>
<span style="font-size:11px;color:#00c8ff;">{{ tanks[i].eff }}%</span>
</div>
<div class="prog-bar-wrap">
<div class="prog-bar-fill" :style="{ width: tanks[i].eff + '%', background: effColor(tanks[i].eff) }"></div>
</div>
</div>
<div v-else class="kv-label" style="margin-top:8px;text-align:center;">等待 L1 数据</div>
</div>
</div>
</div>
<!-- 漂洗段 -->
<div class="sec-title mt8">漂洗段5级逆流</div>
<div class="card">
<div class="card-header">漂洗段状态</div>
<div class="card-body">
<div v-if="l1Online" class="table-scroll">
<table class="data-table">
<thead>
<tr><th>漂洗级</th><th>pH值</th><th>温度 (°C)</th><th>流量 (/h)</th><th>电导率 (μS/cm)</th><th>状态</th></tr>
</thead>
<tbody>
<tr v-for="(r, idx) in rinse" :key="idx">
<td class="td-num">{{ idx+1 }}# 漂洗</td>
<td>{{ r.ph != null ? r.ph : '—' }}</td>
<td class="td-num">{{ r.temp != null ? r.temp : '—' }}</td>
<td class="td-num">{{ r.flow != null ? r.flow : '—' }}</td>
<td class="td-num">{{ r.conductivity != null ? r.conductivity : '—' }}</td>
<td><span class="badge badge-green">正常</span></td>
</tr>
</tbody>
</table>
</div>
<div v-else style="text-align:center;padding:20px;color:var(--text-muted);">
漂洗段数据来自 L1 实时报文当前无 L1 连接
</div>
</div>
</div>
<!-- 模型计算面板 -->
<div class="section-row mt8">
<div class="card" style="flex:1;">
<div class="card-header"> 最优速度计算实时自动</div>
<div class="card-body">
<div class="grid-2" style="gap:12px;margin-bottom:14px;">
<div class="form-field">
<div class="kv-label">带钢厚度 (mm)</div>
<input v-model.number="calc.thickness" type="number" class="kv-input" step="0.1" min="2.0" max="4.5" @change="doCalc" />
</div>
<div class="form-field">
<div class="kv-label">带钢宽度 (mm)</div>
<input v-model.number="calc.width" type="number" class="kv-input" step="10" min="800" max="1250" @change="doCalc" />
</div>
<div class="form-field">
<div class="kv-label">钢种</div>
<select v-model="calc.steel_grade" class="kv-input" @change="doCalc">
<option v-for="g in steelGrades" :key="g" :value="g">{{ g }}</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">目标酸洗指数 (%)</div>
<input v-model.number="calc.target_pi" type="number" class="kv-input" step="1" min="70" max="100" @change="doCalc" />
</div>
<div class="form-field">
<div class="kv-label">氧化铁皮重量 (g/)</div>
<input v-model.number="calc.scale_weight" type="number" class="kv-input" step="0.5" @change="doCalc" />
</div>
</div>
<div class="sec-title">各槽酸液参数
<span class="kv-label" style="font-size:10px;margin-left:8px;">
{{ l1Online ? '已从 L1 报文同步' : '手动输入L1 上线后自动同步)' }}
</span>
</div>
<div class="grid-3" style="gap:8px;margin-bottom:8px;">
<div v-for="i in 5" :key="i" class="form-field">
<div class="kv-label">{{ i }}# 槽浓度 (g/L)</div>
<input v-model.number="calc.acid_conc_list[i-1]" type="number" class="kv-input" step="5" @change="doCalc" />
</div>
</div>
<div class="grid-3" style="gap:8px;margin-bottom:14px;">
<div v-for="i in 5" :key="'t'+i" class="form-field">
<div class="kv-label">{{ i }}# 槽温度 (°C)</div>
<input v-model.number="calc.acid_temp_list[i-1]" type="number" class="kv-input" step="1" @change="doCalc" />
</div>
</div>
<div v-if="calculating" style="text-align:center;color:var(--text-muted);padding:8px;">计算中...</div>
</div>
</div>
<!-- 计算结果 -->
<div class="card" style="flex:1;" v-if="calcResult">
<div class="card-header">计算结果
<span :class="['badge', riskBadge(calcResult.under_pickling_risk)]" style="margin-left:auto;">
欠酸洗风险{{ calcResult.under_pickling_risk }}
</span>
</div>
<div class="card-body">
<div class="grid-2" style="gap:10px;margin-bottom:14px;">
<div class="metric-box">
<div class="mb-label">最大允许速度</div>
<div class="mb-value">{{ calcResult.max_speed }}</div>
<div class="mb-unit">m/min</div>
</div>
<div class="metric-box">
<div class="mb-label">综合酸洗指数</div>
<div class="mb-value">{{ calcResult.total_pi }}</div>
<div class="mb-unit">%</div>
</div>
</div>
<div v-if="calcResult.warning" class="warn-box mb12">{{ calcResult.warning }}</div>
<div class="sec-title">速度安全区间</div>
<div class="speed-bar-wrap">
<div class="speed-bar-track">
<div class="speed-bar-fill" :style="{ width: speedPct + '%' }"></div>
<div class="speed-bar-tick" style="left:0%;">20</div>
<div class="speed-bar-tick" style="left:100%;transform:translateX(-100%);">180</div>
<div class="speed-bar-marker" :style="{ left: speedPct + '%' }">
<div class="speed-bar-marker-label">{{ calcResult.max_speed }} m/min</div>
</div>
</div>
</div>
<div class="sec-title mt8">PI 累积曲线</div>
<svg class="pi-chart" viewBox="0 0 400 180" preserveAspectRatio="none">
<!-- 网格 -->
<g stroke="rgba(255,255,255,0.06)" stroke-width="1">
<line v-for="y in [0,25,50,75,100]" :key="'g'+y"
x1="36" :y1="160 - y*1.3" x2="392" :y2="160 - y*1.3"/>
</g>
<!-- Y 轴刻度 -->
<g fill="#7a8794" font-size="9" font-family="monospace">
<text v-for="y in [0,25,50,75,100]" :key="'yl'+y"
x="30" :y="163 - y*1.3" text-anchor="end">{{ y }}</text>
</g>
<!-- X 轴槽标签 -->
<g fill="#7a8794" font-size="10" font-family="monospace" text-anchor="middle">
<text v-for="i in 5" :key="'xl'+i" :x="36 + i*71.2" y="174">{{ i }}#</text>
</g>
<!-- 目标线 -->
<line :x1="36" :y1="160 - calc.target_pi*1.3" :x2="392" :y2="160 - calc.target_pi*1.3"
stroke="#f0a500" stroke-width="1" stroke-dasharray="4 3"/>
<text :x="392" :y="160 - calc.target_pi*1.3 - 3"
fill="#f0a500" font-size="9" text-anchor="end">目标 {{ calc.target_pi }}%</text>
<!-- 曲线 -->
<polyline fill="none" stroke="#00c8ff" stroke-width="2" :points="chartPoints"/>
<!-- 数据点 + 标签 -->
<g>
<g v-for="(pi, i) in calcResult.pi_per_tank" :key="'p'+i">
<circle :cx="36 + (i+1)*71.2" :cy="160 - pi*1.3" r="3.5" fill="#00c8ff"/>
<text :x="36 + (i+1)*71.2" :y="160 - pi*1.3 - 7"
fill="#00c8ff" font-size="9" text-anchor="middle">{{ pi }}</text>
</g>
</g>
<!-- 入口零点 -->
<circle cx="36" cy="160" r="2.5" fill="#7a8794"/>
<polyline fill="none" stroke="#00c8ff" stroke-width="2" stroke-dasharray="2 2"
:points="`36,160 ${36+71.2},${160 - calcResult.pi_per_tank[0]*1.3}`"/>
</svg>
<div class="sec-title mt8">各槽酸洗详情</div>
<table class="data-table">
<thead>
<tr><th>酸槽</th><th>停留时间 (s)</th><th>累计PI (%)</th><th>进度</th></tr>
</thead>
<tbody>
<tr v-for="(pi, idx) in calcResult.pi_per_tank" :key="idx">
<td class="td-num">{{ idx+1 }}# </td>
<td class="td-num">{{ calcResult.residence_time_per_tank[idx] }}</td>
<td class="td-num">{{ pi }}</td>
<td>
<div class="prog-bar-wrap" style="width:80px;display:inline-block;">
<div class="prog-bar-fill" :style="{ width: pi + '%', background: effColor(pi) }"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card" style="flex:1;display:flex;align-items:center;justify-content:center;" v-else>
<div style="text-align:center;color:var(--text-muted);padding:40px;">
<div style="font-size:11px;letter-spacing:2px;color:var(--text-muted);margin-bottom:12px;">[ LOADING ]</div>
<div>{{ calculating ? '模型计算中...' : '正在加载...' }}</div>
</div>
</div>
</div>
<!-- 模型校准 -->
<div class="card mt8">
<div class="card-header">
酸洗速度模型校准
<span class="ch-badge" :style="{ background: kCalColor(calib.current_kcal) }">
K_cal = {{ calib.current_kcal }}
</span>
<button class="btn btn-outline" style="margin-left:auto;padding:2px 10px;font-size:11px;"
@click="resetCalib('acid_speed')">重置系数</button>
</div>
<div class="card-body">
<div class="calib-layout">
<!-- 录入表单 -->
<div class="calib-form">
<div class="calib-hint">当模型预测速度与实际可用速度存在偏差时录入实测数据进行修正</div>
<div class="flex-col" style="gap:8px;margin-top:10px;">
<div class="form-field">
<div class="kv-label">实测最高合格速度 (m/min)</div>
<input v-model.number="calib.actual_speed" type="number" class="kv-input" step="1" min="20" max="180" />
</div>
<div class="form-field">
<div class="kv-label">该速度下质量结果</div>
<select v-model="calib.quality_ok" class="kv-input">
<option :value="true">合格酸洗充分</option>
<option :value="false">欠酸洗出现缺陷</option>
</select>
</div>
<div class="form-field">
<div class="kv-label">备注可选</div>
<input v-model="calib.note" type="text" class="kv-input" />
</div>
<div v-if="calcResult" class="calib-predict-row">
<span class="kv-label">模型预测速度</span>
<span class="kv-value">{{ calcResult.max_speed }} <span class="kv-unit">m/min</span></span>
<span class="kv-label" style="margin-left:16px;">偏差</span>
<span class="kv-value" :style="{ color: Math.abs(calib.actual_speed - calcResult.max_speed) > 10 ? '#f0a500' : '#28a745' }">
{{ calib.actual_speed && calcResult ? (calib.actual_speed - calcResult.max_speed > 0 ? '+' : '') + (calib.actual_speed - calcResult.max_speed).toFixed(1) : '—' }} m/min
</span>
</div>
<button class="btn btn-primary fw" :disabled="calib.loading || !calib.actual_speed" @click="submitCalib">
{{ calib.loading ? '提交中...' : '提交修正数据' }}
</button>
</div>
</div>
<!-- 历史记录 -->
<div class="calib-history">
<div class="sec-title" style="margin-bottom:8px;">修正记录</div>
<table class="data-table">
<thead>
<tr><th>时间</th><th>K </th><th>K </th><th>实测速度</th><th>质量</th><th>备注</th></tr>
</thead>
<tbody>
<tr v-for="h in acidHistory" :key="h.ts">
<td class="td-muted">{{ h.ts.slice(5,16) }}</td>
<td class="td-num">{{ h.k_before }}</td>
<td class="td-num" :style="{ color: h.k_after > h.k_before ? '#28a745' : '#f0a500' }">{{ h.k_after }}</td>
<td class="td-num">{{ h.input.actual_speed }} m/min</td>
<td><span :class="['badge', h.input.quality_ok ? 'badge-green' : 'badge-red']">{{ h.input.quality_ok ? '合格' : '欠酸洗' }}</span></td>
<td class="td-muted">{{ h.note || '—' }}</td>
</tr>
<tr v-if="!acidHistory.length">
<td colspan="6" class="td-muted" style="text-align:center;padding:14px;">暂无修正记录</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 当前过程参数L1实时 -->
<div class="card mt8">
<div class="card-header">当前过程参数L1 实时</div>
<div class="card-body">
<div v-if="l1Online" class="grid-5">
<div class="metric-box">
<div class="mb-label">当前线速</div>
<div class="mb-value">{{ current.speed }}</div>
<div class="mb-unit">m/min</div>
</div>
<div class="metric-box">
<div class="mb-label">入口张力</div>
<div class="mb-value">{{ current.tension_inlet }}</div>
<div class="mb-unit">kN</div>
</div>
<div class="metric-box">
<div class="mb-label">出口张力</div>
<div class="mb-value">{{ current.tension_outlet }}</div>
<div class="mb-unit">kN</div>
</div>
<div class="metric-box">
<div class="mb-label">1#槽酸液温度</div>
<div class="mb-value">{{ current.acid_temp }}</div>
<div class="mb-unit">°C</div>
</div>
<div class="metric-box">
<div class="mb-label">当前卷号</div>
<div class="mb-value" style="font-size:14px;">{{ current.coil_no || '—' }}</div>
<div class="mb-unit">在线</div>
</div>
</div>
<div v-else style="text-align:center;padding:24px;color:var(--text-muted);">
<div style="font-size:11px;letter-spacing:2px;color:var(--text-muted);margin-bottom:8px;">[ NO SIGNAL ]</div>
<div>当前无 L1 实时数据机组启动并接入 UDP 报文后自动显示</div>
<div style="font-size:11px;margin-top:6px;">L2 监听地址0.0.0.0:9000</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { predictAcidSpeed, getMessageLogs, getCalibration, calibrateAcidSpeed, resetCalibration } from '@/api'
const STEEL_GRADES = ['Q195','Q215','Q235','SPHC','SPHD','SPHE','SS400','SAPH440','QSTE420TM']
export default {
name: 'ProcessModel',
data() {
return {
lastRefresh: '--:--:--',
l1Online: false,
tanks: Array.from({ length: 5 }, () => ({ conc: null, temp: null, fe2: null, rt: null, eff: null })),
rinse: Array.from({ length: 5 }, () => ({ ph: null, temp: null, flow: null, conductivity: null })),
current: { speed: null, tension_inlet: null, tension_outlet: null, acid_temp: null, coil_no: null },
steelGrades: STEEL_GRADES,
calc: {
thickness: 3.0,
width: 1000,
steel_grade: 'Q235',
target_pi: 95,
scale_weight: 8.5,
acid_conc_list: [200, 185, 170, 155, 140],
acid_temp_list: [80, 78, 76, 75, 74],
},
calculating: false,
calcResult: null,
_timer: null,
calib: {
current_kcal: 1.0,
actual_speed: null,
quality_ok: true,
note: '',
loading: false,
},
calibHistory: [],
}
},
computed: {
acidHistory() {
return this.calibHistory.filter(h => h.model === 'acid_speed').slice(0, 10)
},
speedPct() {
if (!this.calcResult) return 0
const v = this.calcResult.max_speed
return Math.max(0, Math.min(100, ((v - 20) / 160) * 100))
},
chartPoints() {
if (!this.calcResult) return ''
return this.calcResult.pi_per_tank
.map((pi, i) => `${36 + (i+1)*71.2},${160 - pi*1.3}`)
.join(' ')
},
},
async mounted() {
await this.fetchL1Data()
await this.doCalc()
await this.loadCalibration()
this._timer = setInterval(() => this.fetchL1Data(), 5000)
},
beforeDestroy() {
clearInterval(this._timer)
},
methods: {
async fetchL1Data() {
try {
// 拉最新一条 PC03 过程数据报文
const res = await getMessageLogs({ msg_type: 'PC03', page_size: 1 })
const logs = res.data?.items || []
if (logs.length && logs[0].parsed_data) {
const d = typeof logs[0].parsed_data === 'string'
? JSON.parse(logs[0].parsed_data) : logs[0].parsed_data
this.l1Online = true
this.current.speed = d.speed ?? null
this.current.tension_inlet = d.tension_inlet ?? null
this.current.tension_outlet = d.tension_outlet ?? null
this.current.acid_temp = d.acid_temp ?? null
this.current.coil_no = d.coil_no ?? null
// 如果报文中有各槽浓度/温度,同步到计算参数
if (d.acid_conc_list) this.calc.acid_conc_list = d.acid_conc_list
if (d.acid_temp_list) this.calc.acid_temp_list = d.acid_temp_list
// 更新槽显示(用计算参数中的值,真实值未来扩展报文后替换)
this.syncTanksFromCalc()
await this.doCalc()
} else {
this.l1Online = false
}
} catch (e) {
this.l1Online = false
}
this.lastRefresh = new Date().toTimeString().slice(0, 8)
},
syncTanksFromCalc() {
this.tanks = this.calc.acid_conc_list.map((conc, i) => {
const temp = this.calc.acid_temp_list[i]
const eff = +(Math.min(98, 50 + (conc / 200) * 35 + (temp - 60) / 25 * 15)).toFixed(1)
return { conc, temp, fe2: null, rt: null, eff }
})
},
async doCalc() {
this.calculating = true
try {
const res = await predictAcidSpeed({
thickness: this.calc.thickness,
width: this.calc.width,
steel_grade: this.calc.steel_grade,
acid_conc_list: this.calc.acid_conc_list,
acid_temp_list: this.calc.acid_temp_list,
scale_weight: this.calc.scale_weight,
target_pi: this.calc.target_pi,
})
this.calcResult = res.data
// 用计算结果的停留时间更新槽显示
if (res.data.residence_time_per_tank) {
res.data.residence_time_per_tank.forEach((rt, i) => {
if (this.tanks[i]) this.tanks[i].rt = rt
})
}
} catch (e) {
// silent — user will see calcResult stay null
} finally {
this.calculating = false
}
},
tankBadge(t) {
if (t.conc == null) return 'badge-yellow'
if (t.conc < 100 || t.temp < 65) return 'badge-red'
if (t.conc < 140 || t.temp < 72) return 'badge-yellow'
return 'badge-green'
},
tankStatus(t) {
if (t.conc == null) return '待接入'
if (t.conc < 100 || t.temp < 65) return '报警'
if (t.conc < 140 || t.temp < 72) return '预警'
return '正常'
},
riskBadge(r) {
if (r === 'HIGH') return 'badge-red'
if (r === 'MEDIUM') return 'badge-yellow'
return 'badge-green'
},
effColor(v) {
if (v >= 80) return '#28a745'
if (v >= 60) return '#f0a500'
return '#da3633'
},
async loadCalibration() {
try {
const res = await getCalibration()
this.calib.current_kcal = res.data?.acid_speed_kcal ?? 1.0
this.calibHistory = res.data?.history || []
} catch (e) { /* silent */ }
},
async submitCalib() {
if (!this.calib.actual_speed) return
this.calib.loading = true
try {
await calibrateAcidSpeed({
thickness: this.calc.thickness,
width: this.calc.width,
steel_grade: this.calc.steel_grade,
acid_conc_list: this.calc.acid_conc_list,
acid_temp_list: this.calc.acid_temp_list,
scale_weight: this.calc.scale_weight,
actual_max_speed: this.calib.actual_speed,
actual_quality_ok: this.calib.quality_ok,
note: this.calib.note,
})
this.$message.success('修正数据已提交,模型系数已更新')
this.calib.actual_speed = null
this.calib.note = ''
await this.loadCalibration()
await this.doCalc()
} catch (e) {
this.$message.error('提交失败:' + (e.response?.data?.detail || e.message))
} finally {
this.calib.loading = false
}
},
async resetCalib(model) {
try {
await this.$confirm('确认将校准系数重置为 1.0', '重置确认', { type: 'warning' })
await resetCalibration(model)
this.$message.success('已重置')
await this.loadCalibration()
await this.doCalc()
} catch (e) { /* cancelled */ }
},
kCalColor(k) {
const d = Math.abs(k - 1.0)
if (d < 0.05) return '#1a3a1f'
if (d < 0.15) return '#3a2a00'
return '#3a1a1a'
},
}
}
</script>
<style lang="scss" scoped>
@import '@/assets/styles/variables';
.form-field { display: flex; flex-direction: column; gap: 5px; }
.warn-box {
background: rgba(240,165,0,.1);
border: 1px solid $accent-yellow;
border-radius: 4px;
padding: 8px 12px;
color: $accent-yellow;
font-size: 12px;
}
.mb12 { margin-bottom: 12px; }
.mt8 { margin-top: 8px; }
.calib-layout {
display: flex;
gap: 20px;
align-items: flex-start;
}
.calib-form {
flex: 0 0 300px;
}
.calib-history {
flex: 1;
overflow-x: auto;
}
.calib-hint {
font-size: 11px;
color: $text-muted;
line-height: 1.6;
border-left: 2px solid $border;
padding-left: 8px;
}
.pi-chart {
width: 100%;
height: 180px;
display: block;
background: rgba(0,0,0,.18);
border: 1px solid $border;
border-radius: 4px;
}
.speed-bar-wrap { padding: 6px 8px 22px; }
.speed-bar-track {
position: relative;
height: 8px;
background: rgba(255,255,255,.06);
border-radius: 4px;
}
.speed-bar-fill {
position: absolute;
left: 0; top: 0; bottom: 0;
background: linear-gradient(90deg, #28a745, #00c8ff);
border-radius: 4px;
transition: width .25s;
}
.speed-bar-tick {
position: absolute;
top: 12px;
font-size: 10px;
color: $text-muted;
font-family: monospace;
}
.speed-bar-marker {
position: absolute;
top: -3px;
width: 2px;
height: 14px;
background: #f0a500;
transform: translateX(-1px);
transition: left .25s;
}
.speed-bar-marker-label {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: #f0a500;
font-family: monospace;
white-space: nowrap;
}
.calib-predict-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: rgba(255,255,255,.03);
border-radius: 4px;
border: 1px solid $border;
}
</style>