Files
bj_power/bj_power_workstation/frontend/src/views/Main.vue
T
2026-08-28 15:06:01 +08:00

771 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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 class="page" tabindex="-1">
<!-- 顶部:标题 / 工位选择 / 登录人 / 同步状态角标 -->
<header class="top-bar">
<div class="brand">北京动力 · 工位终端</div>
<div v-if="!fixedDock" class="dock-select">
<span class="dock-lab">工位</span>
<el-select v-model="dock" size="large" class="dock-el">
<el-option v-for="d in docks" :key="d" :label="d" :value="d" />
</el-select>
</div>
<div v-else class="dock-select">
<span class="dock-lab">工位</span>
<span class="dock-fixed">{{ fixedDock }}</span>
</div>
<div class="spacer"></div>
<div class="sync-badge" @click="manualFlush">
<el-tag v-if="pendingTotal > 0" type="warning" size="large" effect="dark" round>
待同步 {{ pendingTotal }} 条
</el-tag>
<el-tag v-else type="success" size="large" effect="dark" round>同步正常</el-tag>
</div>
<div class="user-box">
<span class="user-name">{{ user.realName || user.username || '-' }}</span>
<el-button size="large" text type="primary" @click="logout">退出</el-button>
</div>
</header>
<!-- NG 告警横幅 -->
<transition name="fade">
<div v-if="hasNg" class="alert-banner">⚠ 存在NG,请复检!</div>
</transition>
<!-- 扫码区(常驻聚焦) -->
<section class="scan-bar">
<span class="sn-label">当前工件 SN:</span>
<span class="sn-value">{{ currentSn || '——' }}</span>
<el-input
ref="scanRef"
v-model.trim="scanInput"
class="scan-input"
size="large"
placeholder="请扫描工件SN条码"
clearable
@keyup.enter="onScan"
/>
<span class="screw-progress">进度 {{ screwDone }} / {{ screwCount }} 颗</span>
</section>
<!-- 主内容:任务区 + 工艺PDF + 拧紧结果 -->
<main class="content">
<!-- 任务区 -->
<aside class="card task-card">
<h3 class="card-title">当前工单</h3>
<template v-if="task">
<div class="order-no">{{ task.orderNo || '-' }}</div>
<div class="kv"><span>产品型号</span><b>{{ task.productModel || '-' }}</b></div>
<div class="kv"><span>进度</span><b>{{ progressDone }} / {{ progressTotal }} 件</b></div>
<ul class="steps">
<li
v-for="(s, i) in taskSteps"
:key="i"
:class="{ done: isStepDone(s), current: isCurrentStep(s, i) }"
>
<span class="step-flag">{{ isStepDone(s) ? '✔' : i + 1 }}</span>
<span class="step-name">{{ s.name || s.processCode || ('工序' + (i + 1)) }}</span>
</li>
</ul>
</template>
<el-empty v-else description="当前工位暂无任务" :image-size="90" />
</aside>
<section class="right-col">
<!-- 工艺 PDF 区 -->
<div v-if="pdfFile" class="card pdf-card">
<div class="pdf-head">
<h3 class="card-title">工艺图纸 · {{ pdfFile }}</h3>
<el-button type="primary" size="large" @click="openPdf">放大查看</el-button>
</div>
<iframe v-if="pdfUrl" :src="pdfUrl" class="pdf-frame" title="工艺图纸"></iframe>
</div>
<!-- 拧紧结果区 -->
<div class="card table-card" :class="{ 'table-card-fill': !pdfFile }">
<h3 class="card-title">拧紧结果(实时)</h3>
<el-table
:data="tightRows"
height="100%"
size="large"
:row-class-name="rowClass"
empty-text="暂无拧紧数据"
>
<el-table-column label="时间" width="200">
<template #default="{ row }">{{ fmtTs(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="screwNo" label="螺丝" width="120" align="center" />
<el-table-column label="扭矩 (Nm)" width="160" align="center">
<template #default="{ row }">{{ row.torque.toFixed(1) }}</template>
</el-table-column>
<el-table-column label="角度 (°)" width="140" align="center">
<template #default="{ row }">{{ Math.round(row.angle) }}</template>
</el-table-column>
<el-table-column label="结果" align="center">
<template #default="{ row }">
<span :class="row.result === 'OK' ? 'res-ok' : 'res-ng blink'">{{ row.result }}</span>
</template>
</el-table-column>
</el-table>
</div>
</section>
</main>
<!-- 底部三大按钮 -->
<footer class="actions">
<el-button class="big-btn primary" size="large" @click="onProcessDone">工序完成上报</el-button>
<el-button class="big-btn warning" size="large" @click="onTempStore">暂存退库</el-button>
<el-button class="big-btn success" size="large" @click="manualFlush">重试同步</el-button>
</footer>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getConfig,
getSyncStats,
getTaskCurrent,
getTighteningList,
pdfOpenUrl,
reportProcessDone,
reportTempStore,
syncFlush
} from '../api'
import { clearAuth, getUser } from '../utils/request'
const DOCK_KEY = 'ws_dock'
const router = useRouter()
const user = getUser()
// ---------- 工位配置(固定工位号 + 螺丝颗数) ----------
const fixedDock = ref('')
const screwCount = ref(10)
async function loadConfig() {
try {
const cfg = await getConfig()
if (cfg?.station) fixedDock.value = cfg.station
if (cfg?.screwCount > 0) screwCount.value = cfg.screwCount
} catch {}
}
const dockCount = 21 // DOCK01 ~ DOCK21
const docks = Array.from({ length: dockCount }, (_, i) => 'DOCK' + String(i + 1).padStart(2, '0'))
const dock = ref(fixedDock.value || localStorage.getItem(DOCK_KEY) || 'DOCK01')
watch(dock, (v) => {
localStorage.setItem(DOCK_KEY, v)
refreshAll()
})
// ---------- 同步状态角标 ----------
const stats = ref({ pendingTorque: 0, pendingQueue: 0 })
const pendingTotal = computed(() => stats.value.pendingTorque + stats.value.pendingQueue)
async function loadStats() {
try {
stats.value = await getSyncStats()
} catch {}
}
// ---------- 当前任务 ----------
const task = ref(null)
const taskSteps = computed(() => {
const t = task.value
if (!t) return []
return Array.isArray(t.steps) ? t.steps : Array.isArray(t.stepList) ? t.stepList : []
})
const progressDone = computed(() => {
const p = task.value?.progress
if (p && typeof p === 'object') return Number(p.done ?? 0)
return Number(task.value?.doneCount ?? 0)
})
const progressTotal = computed(() => {
const p = task.value?.progress
if (p && typeof p === 'object') return Number(p.total ?? 0)
return Number(task.value?.totalCount ?? 0)
})
function isStepDone(s) {
return s && (s.done === true || s.status === 'done' || s.finished === true)
}
// 当前工序导航:第一个未完成的工序高亮
function isCurrentStep(s, i) {
const currentIndex = taskSteps.value.findIndex((x) => !isStepDone(x))
return i === currentIndex
}
function firstPendingStepNo() {
for (let i = 0; i < taskSteps.value.length; i++) {
if (!isStepDone(taskSteps.value[i])) {
const s = taskSteps.value[i]
return String(s.no ?? s.code ?? i + 1)
}
}
return ''
}
async function loadTask() {
try {
task.value = await getTaskCurrent(dock.value)
} catch {
task.value = null
}
}
// ---------- 工艺 PDF ----------
const pdfFile = computed(() => {
const f = task.value?.processFile || task.value?.processPdf
return typeof f === 'string' && f.trim() !== '' ? f.trim() : ''
})
const pdfUrl = computed(() => (pdfFile.value ? pdfOpenUrl(pdfFile.value) : ''))
function openPdf() {
if (pdfUrl.value) window.open(pdfUrl.value, '_blank')
}
// ---------- 扫码 ----------
const scanRef = ref()
const scanInput = ref('')
const currentSn = ref('')
function focusScan() {
scanRef.value?.focus?.()
}
function onScan() {
const sn = scanInput.value.trim()
if (!sn) return
currentSn.value = sn
scanInput.value = ''
loadTight()
}
watch(currentSn, () => loadTight())
// ---------- 拧紧结果 ----------
const tightRows = ref([])
const hasNg = computed(() => tightRows.value.some((r) => r.result === 'NG'))
const screwDone = computed(() => tightRows.value.length)
async function loadTight() {
try {
const data = await getTighteningList({ sn: currentSn.value, limit: 50 })
tightRows.value = Array.isArray(data?.list) ? data.list : []
} catch {
/* 轮询失败静默,避免刷屏 */
}
}
function rowClass({ row }) {
return row.result === 'NG' ? 'ng-row' : ''
}
function fmtTs(ts) {
if (!ts) return '-'
const d = new Date(Number(ts))
const p = (n) => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
// ---------- 底部按钮 ----------
const operatorName = () => user.realName || user.username || ''
async function onProcessDone() {
focusScan()
try {
let value
try {
const res = await ElMessageBox.prompt(
`确认对工位 ${dock.value} 执行「工序完成上报」?请输入当前工序号`,
'工序完成上报',
{
inputValue: firstPendingStepNo(),
inputPlaceholder: '如:OP10',
confirmButtonText: '确 认',
cancelButtonText: '取 消',
inputValidator: (v) => (v && String(v).trim() !== '' ? true : '请输入工序号'),
distinguishCancelAndClose: true
}
)
value = String(res.value || '').trim()
} catch (e) {
return // 用户取消/关闭
}
const data = await reportProcessDone({
dockCode: dock.value,
orderNo: task.value?.orderNo || '',
sn: currentSn.value,
processCode: value,
operator: operatorName()
})
if (data?.queued) {
ElMessage.warning('MES 暂不可达,已离线缓存,稍后自动重传')
} else {
ElMessage.warning('工序完成上报成功(如需继续请重新扫码)')
}
loadTask()
loadStats()
} catch (e) {
/* 统一错误提示已在 request 中弹出 */
}
}
async function onTempStore() {
focusScan()
try {
await ElMessageBox.confirm(
`确认将当前工件「${currentSn.value || '未扫码'}」暂存退库?`,
'暂存退库',
{ confirmButtonText: '确认暂存', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
try {
const data = await reportTempStore({
dockCode: dock.value,
orderNo: task.value?.orderNo || '',
sn: currentSn.value,
operator: operatorName()
})
if (data?.queued) {
ElMessage.warning('MES 暂不可达,已离线缓存,稍后自动重传')
} else {
ElMessage.success('暂存退库已上报')
}
loadTask()
loadStats()
} catch (e) {
/* 统一错误提示 */
}
}
async function manualFlush() {
try {
const data = await syncFlush()
ElMessage.success(`本轮同步成功 ${data?.count ?? 0} 条`)
loadStats()
} catch (e) {
/* 统一错误提示 */
}
}
// ---------- 轮询与生命周期 ----------
let timerTask = null
let timerTight = null
let timerStats = null
function refreshAll() {
loadTask()
loadTight()
loadStats()
}
function logout() {
clearAuth()
router.push('/login')
}
onMounted(async () => {
await loadConfig()
// 固定工位由配置决定,锁定后不再让用户改
if (fixedDock.value) dock.value = fixedDock.value
refreshAll()
focusScan()
window.addEventListener('focus', focusScan)
timerTask = setInterval(loadTask, 5000) // 任务 5 秒
timerTight = setInterval(loadTight, 2000) // 拧紧结果 2 秒
timerStats = setInterval(loadStats, 10000) // 同步统计 10 秒
})
onBeforeUnmount(() => {
clearInterval(timerTask)
clearInterval(timerTight)
clearInterval(timerStats)
window.removeEventListener('focus', focusScan)
})
</script>
<style scoped>
/* 触屏终端:大字号、高对比、无滚动闪烁 */
.page {
height: 100vh;
display: flex;
flex-direction: column;
background: #eef2f7;
overflow: hidden;
font-family: 'Microsoft YaHei', system-ui, sans-serif;
}
/* ---- 顶部 ---- */
.top-bar {
height: 76px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 24px;
padding: 0 28px;
background: #16324f;
color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.brand {
font-size: 30px;
font-weight: bold;
letter-spacing: 2px;
white-space: nowrap;
}
.dock-select {
display: flex;
align-items: center;
gap: 12px;
}
.dock-lab {
font-size: 22px;
font-weight: bold;
}
.dock-el {
width: 200px;
}
.dock-el :deep(.el-input__inner) {
font-size: 22px;
font-weight: bold;
}
.dock-fixed {
font-size: 24px;
font-weight: bold;
color: #9fd0ff;
padding: 0 16px;
background: rgba(255, 255, 255, 0.12);
border-radius: 8px;
line-height: 44px;
}
.spacer {
flex: 1;
}
.sync-badge {
cursor: pointer;
}
.sync-badge :deep(.el-tag) {
font-size: 18px;
height: 40px;
padding: 0 20px;
}
.user-box {
display: flex;
align-items: center;
gap: 8px;
}
.user-name {
font-size: 22px;
font-weight: bold;
}
.user-box :deep(.el-button) {
font-size: 18px;
color: #9fd0ff;
}
/* ---- NG 告警横幅 ---- */
.alert-banner {
flex-shrink: 0;
background: #d80f16;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: bold;
letter-spacing: 8px;
padding: 14px 0;
animation: blink 1.2s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* ---- 扫码区 ---- */
.scan-bar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 18px;
padding: 12px 28px;
background: #fff;
border-bottom: 1px solid #dde4ee;
}
.sn-label {
font-size: 22px;
font-weight: bold;
color: #16324f;
white-space: nowrap;
}
.sn-value {
min-width: 220px;
font-size: 26px;
font-weight: bold;
color: #d80f16;
letter-spacing: 1px;
}
.scan-input {
flex: 1;
max-width: 640px;
}
.scan-bar :deep(.el-input__inner) {
height: 52px;
font-size: 24px;
}
.screw-progress {
font-size: 26px;
font-weight: bold;
color: #e6870f;
white-space: nowrap;
}
/* ---- 主内容 ---- */
.content {
flex: 1;
min-height: 0;
display: flex;
gap: 20px;
padding: 20px 28px;
}
.card {
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(22, 50, 79, 0.08);
padding: 20px 24px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.card-title {
margin: 0 0 16px;
font-size: 24px;
font-weight: bold;
color: #16324f;
}
.task-card {
width: 360px;
flex-shrink: 0;
}
.order-no {
font-size: 32px;
font-weight: bold;
color: #16324f;
word-break: break-all;
margin-bottom: 16px;
}
.kv {
display: flex;
justify-content: space-between;
font-size: 22px;
margin-bottom: 10px;
color: #5a6a80;
}
.kv b {
color: #16324f;
}
.steps {
list-style: none;
margin: 14px 0 0;
padding: 0;
overflow-y: auto;
flex: 1;
}
.steps li {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
padding: 9px 4px;
border-bottom: 1px dashed #e6ecf4;
color: #16324f;
}
.steps li.done {
color: #2aa861;
}
.steps li.done .step-name {
text-decoration: line-through;
}
.steps li.current {
background: #eef5ff;
border-radius: 8px;
}
.steps li.current .step-flag {
background: #1668dc;
color: #fff;
}
.step-flag {
width: 36px;
height: 36px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: #eaf1f8;
font-weight: bold;
flex-shrink: 0;
}
.steps li.done .step-flag {
background: #2aa861;
color: #fff;
}
.right-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
.pdf-card {
height: 38%;
min-height: 240px;
}
.pdf-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.pdf-head .card-title {
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pdf-frame {
flex: 1;
width: 100%;
border: 1px solid #dde4ee;
border-radius: 8px;
background: #fafbfd;
}
.table-card {
flex: 1;
min-height: 220px;
}
.table-card-fill {
flex: 1;
}
.table-card :deep(.el-table) {
font-size: 20px;
}
.table-card :deep(.el-table th.el-table__cell) {
font-size: 20px;
color: #16324f;
}
.res-ok {
color: #2aa861;
font-size: 26px;
font-weight: bold;
}
.res-ng {
color: #d80f16;
font-size: 26px;
font-weight: bold;
}
/* NG 红色闪烁 */
.blink {
animation: ng-blink 0.8s infinite;
}
@keyframes ng-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
}
:deep(.ng-row) {
--el-table-tr-bg-color: #ffecec;
background: #ffecec !important;
animation: row-blink 0.8s infinite;
}
@keyframes row-blink {
0%, 100% { background: #ffecec; }
50% { background: #ffc7c7; }
}
/* ---- 底部三大按钮 ---- */
.actions {
flex-shrink: 0;
display: flex;
justify-content: center;
gap: 48px;
padding: 14px 0 18px;
background: #fff;
box-shadow: 0 -2px 10px rgba(22, 50, 79, 0.06);
}
.big-btn {
width: 320px;
height: 72px;
font-size: 27px;
font-weight: bold;
letter-spacing: 6px;
border-radius: 12px;
color: #fff;
border: none;
}
.big-btn.primary { background: #1668dc; }
.big-btn.primary:hover { background: #0e57c3; }
.big-btn.warning { background: #e6870f; }
.big-btn.warning:hover { background: #cf7508; }
.big-btn.success { background: #2aa861; }
.big-btn.success:hover { background: #239154; }
</style>