新增称重终端:A 毛菜加工 / B 净菜进柜 双路径

- 新增 /weighing-terminal 触摸屏路由(免登录、不挂 MainLayout)
- 新增 weighingTerminal pinia store 管理毛菜/净菜入库流水
- 终端流程:摄像头识别动画 + 网格选品 + 仿数码管称重 + B 路径出净率自动计算
- 登录页底部追加"打开称重终端"入口
- 顺手修复 trace 页 a-date-picker 类型陷阱(Dayjs|null → Dayjs|undefined)
This commit is contained in:
2026-05-27 14:45:12 +08:00
parent 38edfa2cb1
commit 34ab9b5a1b
6 changed files with 837 additions and 2 deletions
+27
View File
@@ -575,6 +575,33 @@ export const dictItems: DictItem[] = [
{ id: 'di33', typeCode: 'order_status', label: '已取消', value: 'cancelled', sort: 5, status: 'enabled', remark: '' },
]
// ==================== 称重终端 ====================
// 菜品基础库:用于称重终端的品名网格选择。emoji 仅作占位图标
export const vegetableLibrary = [
{ id: 'v01', name: '白菜', emoji: '🥬' },
{ id: 'v02', name: '萝卜', emoji: '🥕' },
{ id: 'v03', name: '番茄', emoji: '🍅' },
{ id: 'v04', name: '黄瓜', emoji: '🥒' },
{ id: 'v05', name: '茄子', emoji: '🍆' },
{ id: 'v06', name: '青椒', emoji: '🫑' },
{ id: 'v07', name: '芹菜', emoji: '🌿' },
{ id: 'v08', name: '菠菜', emoji: '🥬' },
{ id: 'v09', name: '土豆', emoji: '🥔' },
{ id: 'v10', name: '胡萝卜', emoji: '🥕' },
{ id: 'v11', name: '豆角', emoji: '🫛' },
{ id: 'v12', name: '西兰花', emoji: '🥦' },
]
// 净菜规格(形状)枚举
export const specOptions = [
{ code: 'whole', label: '整' },
{ code: 'dice', label: '丁' },
{ code: 'shred', label: '丝' },
{ code: 'slice', label: '片' },
{ code: 'segment', label: '段' },
{ code: 'chunk', label: '块' },
]
// ==================== Dashboard ====================
export function genDashboardData() {
return {
+10 -1
View File
@@ -64,6 +64,13 @@ const routes: RouteRecordRaw[] = [
name: 'login',
component: () => import('@/views/login/index.vue'),
},
// 称重终端:触摸屏现场入口,不挂 MainLayout(无侧边栏/无顶栏,全屏),免登录
{
path: '/weighing-terminal',
name: 'weighing-terminal',
meta: { title: '称重终端', public: true },
component: () => import('@/views/weighing-terminal/index.vue'),
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
@@ -79,7 +86,9 @@ const router = createRouter({
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.name !== 'login' && !token) {
// 公开路由(如登录页、称重终端)免鉴权
const isPublic = to.name === 'login' || to.meta.public === true
if (!isPublic && !token) {
next({ name: 'login' })
} else {
document.title = `${(to.meta.title as string) || ''} - 智能餐养`
+100
View File
@@ -0,0 +1,100 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 毛菜入库记录
export interface RawVeggieRecord {
id: string
time: string // YYYY-MM-DD HH:mm:ss
name: string // 品名
weight: number // 重量(g)
}
// 净菜入库记录
export interface CleanVeggieRecord {
id: string
time: string
name: string
spec: string // 规格(形状):整/丁/丝/片/段/块
weight: number // 净菜重量(g)
operator: string | null // 加工人(预留人脸识别,当前为 null)
yieldRate: number | null // 出净率(%);无毛菜参照时为 null
refRawWeight: number | null // 参照的毛菜重量(g);用于在记录里追溯出净率算法
}
const genId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
const nowStr = () => {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/**
* 称重终端 store:负责毛菜/净菜两类入库流水的增删查
* 设计要点:
* - 数据仅存活于本次会话内存(刷新即清空),原型阶段足够
* - 出净率自动算 = 当前净菜重量 / 最近一条同品名毛菜重量 * 100%
* - 加工人字段保留 null 占位,留给后期人脸识别填充
*/
export const useWeighingTerminalStore = defineStore('weighingTerminal', () => {
const rawVeggieRecords = ref<RawVeggieRecord[]>([])
const cleanVeggieRecords = ref<CleanVeggieRecord[]>([])
/** 新增一条毛菜入库记录 */
function addRawRecord(name: string, weight: number): RawVeggieRecord {
const record: RawVeggieRecord = {
id: genId(),
time: nowStr(),
name,
weight,
}
rawVeggieRecords.value.unshift(record)
return record
}
/** 取最近一条同品名毛菜记录的重量(g);找不到返回 null */
function getLatestRawWeight(name: string): number | null {
const hit = rawVeggieRecords.value.find(r => r.name === name)
return hit ? hit.weight : null
}
/** 取最近一条同品名毛菜记录(含时间,用于界面展示参照批次) */
function getLatestRawRecord(name: string): RawVeggieRecord | null {
return rawVeggieRecords.value.find(r => r.name === name) ?? null
}
/** 新增一条净菜入库记录;出净率根据最近一条同品名毛菜自动计算 */
function addCleanRecord(payload: {
name: string
spec: string
weight: number
operator?: string | null
}): CleanVeggieRecord {
const refRaw = getLatestRawWeight(payload.name)
const yieldRate = refRaw && refRaw > 0
? +((payload.weight / refRaw) * 100).toFixed(1)
: null
const record: CleanVeggieRecord = {
id: genId(),
time: nowStr(),
name: payload.name,
spec: payload.spec,
weight: payload.weight,
operator: payload.operator ?? null,
yieldRate,
refRawWeight: refRaw,
}
cleanVeggieRecords.value.unshift(record)
return record
}
return {
rawVeggieRecords,
cleanVeggieRecords,
addRawRecord,
getLatestRawWeight,
getLatestRawRecord,
addCleanRecord,
}
})
@@ -102,7 +102,7 @@ const suppliers = ['集团种养公司', '集团净菜公司', '绿源农业', '
const lands = ['A01地块', 'B02地块', 'C03基地', 'D04养殖区']
const ingredients = ['白菜', '番茄', '猪里脊', '鸡胸肉', '青椒', '土豆']
const queryDate = ref<Dayjs | null>(dayjs())
const queryDate = ref<Dayjs | undefined>(dayjs())
const queryUser = ref<string>()
const queryMeal = ref<string>()
const searched = ref(false)
+17
View File
@@ -15,6 +15,9 @@
</a-button>
</a-form-item>
</a-form>
<div class="terminal-link">
<router-link to="/weighing-terminal">📦 打开称重终端 </router-link>
</div>
</div>
</div>
</template>
@@ -70,4 +73,18 @@ async function handleLogin() {
color: #333;
}
}
.terminal-link {
text-align: center;
margin-top: 8px;
font-size: 14px;
a {
color: #888;
text-decoration: none;
transition: color 0.2s;
&:hover { color: #4c8bf5; }
}
}
</style>
@@ -0,0 +1,682 @@
<template>
<div class="terminal" :class="`step-${step}`">
<!-- 顶部状态条 -->
<header class="bar">
<span class="bar-title">智能称重终端</span>
<span class="bar-time">{{ clock }}</span>
</header>
<!-- 入口A/B 二选一 -->
<section v-if="step === 'entry'" class="entry">
<button class="big-btn a" @click="enterA">
<div class="big-btn-icon">🥬</div>
<div class="big-btn-title">A</div>
<div class="big-btn-sub">毛菜加工称重</div>
</button>
<button class="big-btn b" @click="enterB">
<div class="big-btn-icon">📦</div>
<div class="big-btn-title">B</div>
<div class="big-btn-sub">净菜进柜称重</div>
</button>
</section>
<!-- A1: 毛菜识别 -->
<section v-else-if="step === 'a-recognize'" class="recognize">
<div v-if="recognizing" class="scanning">
<div class="scanning-frame">
<div class="scanning-line"></div>
</div>
<div class="scanning-text">摄像头识别中</div>
</div>
<template v-else>
<h2 class="recognize-title">请确认或选择品名</h2>
<div class="grid">
<button
v-for="v in vegetableLibrary"
:key="v.id"
class="cell"
:class="{ active: pickedName === v.name }"
@click="pickedName = v.name"
>
<div class="cell-icon">{{ v.emoji }}</div>
<div class="cell-name">{{ v.name }}</div>
</button>
</div>
<div class="actions">
<button class="btn ghost" @click="goBack">返回</button>
<button class="btn primary" :disabled="!pickedName" @click="enterAWeigh">
确认
</button>
</div>
</template>
</section>
<!-- A2: 毛菜称重 -->
<section v-else-if="step === 'a-weigh'" class="weigh">
<div class="weigh-name">{{ pickedName }}</div>
<div class="scale">
<span class="digits">{{ displayWeight }}</span>
<span class="unit">g</span>
</div>
<div class="scale-status">
<span :class="{ stable: stable }">{{ stable ? '● 已稳定' : '○ 称重中…' }}</span>
</div>
<div class="actions">
<button class="btn ghost" @click="goBack">返回</button>
<button class="btn warn" @click="toggleStable">
{{ stable ? '继续称重' : '稳定/去皮' }}
</button>
<button
class="btn primary"
:disabled="!stable || displayWeight <= 0"
@click="confirmA"
>
开始加工
</button>
</div>
</section>
<!-- B1: 净菜识别 -->
<section v-else-if="step === 'b-recognize'" class="recognize">
<div v-if="recognizing" class="scanning">
<div class="scanning-frame">
<div class="scanning-line"></div>
</div>
<div class="scanning-text">摄像头识别中</div>
</div>
<template v-else>
<h2 class="recognize-title">请确认或选择净菜品名</h2>
<div class="grid">
<button
v-for="v in vegetableLibrary"
:key="v.id"
class="cell"
:class="{ active: pickedName === v.name }"
@click="pickedName = v.name"
>
<div class="cell-icon">{{ v.emoji }}</div>
<div class="cell-name">{{ v.name }}</div>
</button>
</div>
<div class="actions">
<button class="btn ghost" @click="goBack">返回</button>
<button class="btn primary" :disabled="!pickedName" @click="enterBSpec">
确认
</button>
</div>
</template>
</section>
<!-- B2: 规格(形状)选择 -->
<section v-else-if="step === 'b-spec'" class="recognize">
<h2 class="recognize-title">{{ pickedName }} 请选择规格(形状)</h2>
<div class="grid spec-grid">
<button
v-for="s in specOptions"
:key="s.code"
class="cell spec-cell"
:class="{ active: pickedSpec === s.code }"
@click="pickedSpec = s.code"
>
<div class="spec-label">{{ s.label }}</div>
</button>
</div>
<div class="actions">
<button class="btn ghost" @click="goBack">返回</button>
<button class="btn primary" :disabled="!pickedSpec" @click="enterBWeigh">
确认
</button>
</div>
</section>
<!-- B3: 净菜称重(含出净率) -->
<section v-else-if="step === 'b-weigh'" class="weigh">
<div class="ref-bar" :class="{ warn: !refRawRecord }">
<template v-if="refRawRecord">
参照毛菜批次{{ refRawRecord.name }} {{ refRawRecord.weight }}g · {{ refRawRecord.time }}
</template>
<template v-else>
未找到 {{ pickedName }} 的毛菜参照出净率无法计算
</template>
</div>
<div class="weigh-name">
{{ pickedName }}
<span class="spec-tag">{{ specLabel }}</span>
</div>
<div class="scale">
<span class="digits">{{ displayWeight }}</span>
<span class="unit">g</span>
</div>
<div class="yield-rate">
出净率<span :class="{ ok: yieldRateValue !== null, na: yieldRateValue === null }">{{ yieldRateText }}</span>
</div>
<div class="scale-status">
<span :class="{ stable: stable }">{{ stable ? '● 已稳定' : '○ 称重中…' }}</span>
</div>
<div class="actions">
<button class="btn ghost" @click="goBack">返回</button>
<button class="btn warn" @click="toggleStable">
{{ stable ? '继续称重' : '稳定/去皮' }}
</button>
<button
class="btn primary"
:disabled="!stable || displayWeight <= 0"
@click="confirmB"
>
确认进柜
</button>
</div>
</section>
<!-- 成功提示 -->
<transition name="fade">
<div v-if="showSuccess" class="toast">
<div class="toast-icon"></div>
<div class="toast-text">{{ successText }}</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { vegetableLibrary, specOptions } from '@/mock/data'
import { useWeighingTerminalStore } from '@/stores/weighingTerminal'
import type { RawVeggieRecord } from '@/stores/weighingTerminal'
// 终端流程状态机
type Step = 'entry' | 'a-recognize' | 'a-weigh' | 'b-recognize' | 'b-spec' | 'b-weigh'
const step = ref<Step>('entry')
const store = useWeighingTerminalStore()
// ---------- 顶部时钟 ----------
const clock = ref('')
let clockTimer: number | null = null
function tickClock() {
const d = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
clock.value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
// ---------- 共享:识别屏 / 称重屏状态 ----------
const recognizing = ref(false)
const pickedName = ref<string>('')
let recognizeTimer: number | null = null
const baseWeight = ref(0)
const displayWeight = ref(0)
const stable = ref(false)
let weighTimer: number | null = null
function startWeighLoop() {
stopWeighLoop()
weighTimer = window.setInterval(() => {
if (stable.value) return
const jitter = Math.round((Math.random() - 0.5) * 6) // [-3, +3]
displayWeight.value = Math.max(0, baseWeight.value + jitter)
}, 100)
}
function stopWeighLoop() {
if (weighTimer !== null) {
window.clearInterval(weighTimer)
weighTimer = null
}
}
function toggleStable() {
if (!stable.value) {
displayWeight.value = baseWeight.value
stable.value = true
} else {
stable.value = false
}
}
// ---------- A 路径 ----------
function enterA() {
step.value = 'a-recognize'
pickedName.value = ''
recognizing.value = true
recognizeTimer = window.setTimeout(() => {
const hit = vegetableLibrary[Math.floor(Math.random() * vegetableLibrary.length)]
pickedName.value = hit.name
recognizing.value = false
}, 1500)
}
function enterAWeigh() {
if (!pickedName.value) return
step.value = 'a-weigh'
// 中心重量随机模拟一筐毛菜:800g ~ 5000g
baseWeight.value = Math.round(800 + Math.random() * 4200)
displayWeight.value = baseWeight.value
stable.value = false
startWeighLoop()
}
function confirmA() {
const rec = store.addRawRecord(pickedName.value, displayWeight.value)
successText.value = `已入库:${rec.name} ${rec.weight}g`
flashSuccess()
resetToEntry()
}
// ---------- B 路径 ----------
const pickedSpec = ref<string>('') // 规格 code
const refRawRecord = ref<RawVeggieRecord | null>(null) // 参照毛菜批次
const specLabel = computed(() => {
return specOptions.find(s => s.code === pickedSpec.value)?.label ?? ''
})
// 实时出净率:跟随 displayWeight 跳动,找不到参照时为 null
const yieldRateValue = computed<number | null>(() => {
if (!refRawRecord.value || refRawRecord.value.weight <= 0) return null
return +((displayWeight.value / refRawRecord.value.weight) * 100).toFixed(1)
})
const yieldRateText = computed(() => {
return yieldRateValue.value === null ? '— —' : `${yieldRateValue.value}%`
})
function enterB() {
step.value = 'b-recognize'
pickedName.value = ''
pickedSpec.value = ''
recognizing.value = true
recognizeTimer = window.setTimeout(() => {
const hit = vegetableLibrary[Math.floor(Math.random() * vegetableLibrary.length)]
pickedName.value = hit.name
recognizing.value = false
}, 1500)
}
function enterBSpec() {
if (!pickedName.value) return
step.value = 'b-spec'
}
function enterBWeigh() {
if (!pickedSpec.value) return
step.value = 'b-weigh'
// 取最近一条同品名毛菜作为出净率参照
refRawRecord.value = store.getLatestRawRecord(pickedName.value)
// 模拟净菜中心重量:
// 有参照时按 70%~90% 出净率倒推,让屏幕上算出来的出净率落在合理区间
// 无参照时随机一个 500~3500g 让流程继续可走
if (refRawRecord.value) {
const yieldRatio = 0.7 + Math.random() * 0.2 // [0.7, 0.9]
baseWeight.value = Math.round(refRawRecord.value.weight * yieldRatio)
} else {
baseWeight.value = Math.round(500 + Math.random() * 3000)
}
displayWeight.value = baseWeight.value
stable.value = false
startWeighLoop()
}
function confirmB() {
const rec = store.addCleanRecord({
name: pickedName.value,
spec: specLabel.value,
weight: displayWeight.value,
operator: null, // 加工人留空,待后期人脸识别
})
const rateText = rec.yieldRate === null ? '出净率—' : `出净率${rec.yieldRate}%`
successText.value = `已入库:${rec.name}(${rec.spec}) ${rec.weight}g · ${rateText}`
flashSuccess()
resetToEntry()
}
// ---------- 入库提示 ----------
const showSuccess = ref(false)
const successText = ref('')
let successTimer: number | null = null
function flashSuccess() {
showSuccess.value = true
if (successTimer !== null) window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
showSuccess.value = false
}, 2000)
}
// ---------- 流程复位 ----------
function resetToEntry() {
stopWeighLoop()
if (recognizeTimer !== null) {
window.clearTimeout(recognizeTimer)
recognizeTimer = null
}
step.value = 'entry'
pickedName.value = ''
pickedSpec.value = ''
refRawRecord.value = null
recognizing.value = false
stable.value = false
}
function goBack() {
resetToEntry()
}
// ---------- 生命周期 ----------
onMounted(() => {
tickClock()
clockTimer = window.setInterval(tickClock, 1000)
})
onBeforeUnmount(() => {
if (clockTimer !== null) window.clearInterval(clockTimer)
stopWeighLoop()
if (recognizeTimer !== null) window.clearTimeout(recognizeTimer)
if (successTimer !== null) window.clearTimeout(successTimer)
})
</script>
<style scoped lang="scss">
.terminal {
position: fixed;
inset: 0;
background: radial-gradient(ellipse at top, #1b2a4e 0%, #0a0f1f 70%);
color: #e6eefc;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
user-select: none;
-webkit-user-select: none;
overflow: hidden;
}
.bar {
flex: 0 0 56px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 32px;
background: rgba(255, 255, 255, 0.04);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
font-size: 18px;
.bar-title { font-weight: 600; letter-spacing: 2px; }
.bar-time { font-family: 'Courier New', monospace; opacity: 0.85; }
}
/* ---------- 入口 ---------- */
.entry {
flex: 1;
display: flex;
gap: 40px;
padding: 64px;
align-items: stretch;
justify-content: center;
}
.big-btn {
flex: 1;
max-width: 600px;
border: none;
border-radius: 24px;
color: #fff;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
padding: 48px;
font-family: inherit;
transition: transform 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35);
&:active { transform: scale(0.98); }
.big-btn-icon { font-size: 120px; line-height: 1; }
.big-btn-title { font-size: 96px; font-weight: 700; letter-spacing: 6px; }
.big-btn-sub { font-size: 32px; opacity: 0.92; }
&.a { background: linear-gradient(135deg, #36b37e 0%, #1f7a4d 100%); }
&.b { background: linear-gradient(135deg, #4c8bf5 0%, #2752c4 100%); }
}
/* ---------- 通用按钮 ---------- */
.actions {
display: flex;
gap: 24px;
justify-content: center;
padding: 32px 0 48px;
}
.btn {
min-width: 200px;
height: 80px;
border-radius: 16px;
border: none;
font-size: 28px;
font-weight: 600;
font-family: inherit;
color: #fff;
cursor: pointer;
transition: opacity 0.15s, transform 0.15s;
&:active:not(:disabled) { transform: scale(0.97); }
&:disabled { opacity: 0.35; cursor: not-allowed; }
&.primary { background: linear-gradient(135deg, #36b37e, #1f7a4d); }
&.warn { background: linear-gradient(135deg, #f5a623, #c97a05); }
&.ghost {
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.35);
}
}
/* ---------- A1 识别屏 ---------- */
.recognize {
flex: 1;
display: flex;
flex-direction: column;
padding: 32px 64px 0;
}
.recognize-title { font-size: 32px; font-weight: 600; margin: 0 0 24px; }
.grid {
flex: 1;
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-auto-rows: minmax(140px, 1fr);
gap: 20px;
overflow: auto;
}
.cell {
background: rgba(255, 255, 255, 0.06);
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
color: #e6eefc;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
transition: all 0.15s;
&:active { transform: scale(0.97); }
&.active {
background: rgba(54, 179, 126, 0.25);
border-color: #36b37e;
box-shadow: 0 0 0 4px rgba(54, 179, 126, 0.25);
}
.cell-icon { font-size: 48px; }
.cell-name { font-size: 24px; font-weight: 500; }
}
/* 摄像头扫描动画 */
.scanning {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 32px;
}
.scanning-frame {
width: 320px;
height: 320px;
border: 3px solid rgba(54, 179, 126, 0.6);
border-radius: 24px;
position: relative;
overflow: hidden;
box-shadow: 0 0 40px rgba(54, 179, 126, 0.35) inset;
}
.scanning-line {
position: absolute;
left: 0; right: 0;
height: 4px;
background: linear-gradient(90deg, transparent, #36b37e, transparent);
animation: scanline 1.5s ease-in-out infinite;
}
@keyframes scanline {
0% { top: 0%; }
50% { top: 100%; }
100% { top: 0%; }
}
.scanning-text { font-size: 28px; letter-spacing: 4px; opacity: 0.85; }
/* ---------- A2 称重屏 ---------- */
.weigh {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 32px;
}
.weigh-name {
font-size: 56px;
font-weight: 600;
letter-spacing: 4px;
}
.scale {
display: flex;
align-items: baseline;
gap: 16px;
background: rgba(0, 0, 0, 0.4);
border-radius: 24px;
padding: 24px 64px;
box-shadow: inset 0 4px 16px rgba(0, 0, 0, 0.5);
.digits {
font-family: 'Courier New', 'Consolas', monospace;
font-size: 180px;
font-weight: 700;
color: #36ffae;
text-shadow: 0 0 24px rgba(54, 255, 174, 0.5);
min-width: 6ch;
text-align: right;
letter-spacing: 4px;
}
.unit { font-size: 48px; color: #36ffae; opacity: 0.85; }
}
.scale-status {
font-size: 24px;
opacity: 0.8;
.stable { color: #36ffae; }
}
/* ---------- 成功提示 ---------- */
.toast {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(54, 179, 126, 0.95);
color: #fff;
padding: 32px 56px;
border-radius: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
z-index: 100;
&.info { background: rgba(76, 139, 245, 0.95); }
.toast-icon { font-size: 80px; line-height: 1; }
.toast-text { font-size: 28px; font-weight: 600; }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* ---------- B 路径专用 ---------- */
/* 参照毛菜批次条 */
.ref-bar {
font-size: 22px;
padding: 12px 28px;
border-radius: 12px;
background: rgba(54, 179, 126, 0.18);
border: 1px solid rgba(54, 179, 126, 0.4);
color: #b9f5d6;
letter-spacing: 1px;
&.warn {
background: rgba(245, 166, 35, 0.18);
border-color: rgba(245, 166, 35, 0.5);
color: #ffd591;
}
}
/* 品名旁的规格小标签 */
.spec-tag {
display: inline-block;
margin-left: 16px;
padding: 4px 18px;
font-size: 32px;
font-weight: 600;
border-radius: 12px;
background: rgba(76, 139, 245, 0.25);
border: 2px solid rgba(76, 139, 245, 0.6);
color: #b9d2ff;
letter-spacing: 4px;
vertical-align: middle;
}
/* 出净率显示 */
.yield-rate {
font-size: 32px;
font-weight: 500;
.ok {
color: #36ffae;
font-family: 'Courier New', 'Consolas', monospace;
font-size: 56px;
margin-left: 12px;
}
.na {
color: #ffd591;
font-size: 40px;
margin-left: 12px;
}
}
/* 规格选择网格(列数更少,方块更大) */
.spec-grid {
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: minmax(200px, 1fr);
}
.spec-cell {
.spec-label {
font-size: 96px;
font-weight: 700;
letter-spacing: 12px;
color: #e6eefc;
}
&.active .spec-label { color: #36ffae; }
}
</style>