Files
bj_power/bj_power_workstation/frontend/src/views/Main.vue
T

776 lines
18 KiB
Vue
Raw Normal View History

2026-08-28 15:06:01 +08:00
<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>
<!-- 操作说明帮助 -->
<PageHelp :data="helpWorkstation" />
2026-08-28 15:06:01 +08:00
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageHelp from '../components/PageHelp.vue'
import { helpWorkstation } from '../help'
2026-08-28 15:06:01 +08:00
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>