新增称重终端:A 毛菜加工 / B 净菜进柜 双路径
- 新增 /weighing-terminal 触摸屏路由(免登录、不挂 MainLayout) - 新增 weighingTerminal pinia store 管理毛菜/净菜入库流水 - 终端流程:摄像头识别动画 + 网格选品 + 仿数码管称重 + B 路径出净率自动计算 - 登录页底部追加"打开称重终端"入口 - 顺手修复 trace 页 a-date-picker 类型陷阱(Dayjs|null → Dayjs|undefined)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) || ''} - 智能餐养`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user