feat&refactor: 完成多模块功能迭代与配置优化

本次提交覆盖多个业务模块的功能完善与体验优化:
1.  **鉴权与配置调整**:
    - 统一JWT滑动续签逻辑,简化Token存储,移除RefreshToken相关冗余代码
    - 调整多项目配置文件中JWT过期时间为3600秒,统一会话闲置窗口
    - 工位配置放开1~12限制,改为仅校验大于0
2.  **术语统一替换**:全链路将"精密件"替换为"电气件",修正物料管理描述
3.  **功能新增**:
    - 新增工位类型、工艺路线与产线点位台账模块
    - 添加工艺PDF预览面板、工位终端代理转发接口
    - 新增操作日志按操作人列表筛选、工位登出日志记录
    - 新增PLC移料指令与产线点位状态管理
4.  **业务流程优化**:
    - 调整BOM物料删除校验逻辑,优化工单备料计算
    - 补充物料图号、检测单号等追溯字段
    - 完善工艺流程图与工位绑定关系说明
    - 优化前端页面文案与交互细节
5.  **代码规范与维护**:
    - 新增通用工具函数与前端静态资源
    - 整理路由权限与中间件逻辑
    - 修复部分接口与配置的不兼容问题
This commit is contained in:
SunYF
2026-09-10 16:59:15 +08:00
parent 6d8bb75696
commit 0ab21b1234
202 changed files with 21397 additions and 3172 deletions
@@ -52,7 +52,7 @@ const password = ref('')
const fixedStation = ref(0)
const loading = ref(false)
// 工位由服务端配置文件固定(1~12),一个工位一个终端,终端不允许修改工位
// 工位由服务端配置文件固定,一个工位一个终端,终端不允许修改工位
onMounted(async () => {
try {
const cfg = await getConfig()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,291 @@
<template>
<div class="mat-panel">
<!-- ① 装配物料绑定(电气件必须扫码SN / 结构件扫批次) -->
<section class="card">
<div class="card-title">
装配物料绑定
<el-tag v-if="bindPanel && bindPanel.enabled && bindPanel.complete" type="success" size="large" effect="dark">
已绑齐 ✓
</el-tag>
<el-tag v-else-if="bindPanel && bindPanel.enabled" type="danger" size="large" effect="dark">
未绑齐({{ bindPanel.missing || '缺料' }})
</el-tag>
<el-tag v-else type="info" size="large">本工位未配置需绑物料</el-tag>
</div>
<el-empty v-if="!sn" description="请先在「作业」页扫描工件SN" :image-size="70" />
<el-empty v-else-if="!required.length" description="该工件在本工位无需绑定物料" :image-size="70" />
<div v-for="r in required" :key="r.materialCode" class="bind-item">
<div class="bind-meta">
<span class="bind-name">{{ r.materialName || r.materialCode }}</span>
<span class="bind-code">{{ r.materialCode }}</span>
<el-tag size="small" :type="r.manageMode === '2' ? 'primary' : 'warning'">
{{ r.manageMode === '2' ? '电气件·扫码SN' : '结构件·批次' }}
</el-tag>
<span class="bind-count" :class="r.complete ? 'ok' : 'ng'">{{ r.boundCount }}/{{ r.need }}</span>
</div>
<div class="bind-bound">
<el-tag
v-for="b in r.bound || []"
:key="b"
size="small"
closable
@close="removeBind(r, b)"
>{{ b }}</el-tag>
<span v-if="!(r.bound || []).length" class="none">未绑定</span>
</div>
<div class="bind-input-row">
<el-input
v-model.trim="bindInputs[r.materialCode]"
size="large"
:placeholder="r.manageMode === '2' ? '扫描电气件SN(必扫)' : '扫描/输入批次号'"
clearable
@keyup.enter="doBind(r)"
/>
<el-button type="primary" size="large" :loading="binding" @click="doBind(r)">绑定</el-button>
</div>
</div>
</section>
<!-- ② 叫料(选本工位接驳台,AGV 送达该接驳台) -->
<section class="card">
<div class="card-title">叫料(送到本工位接驳台)</div>
<div class="call-row">
<span class="lab">接驳台</span>
<el-select v-model="dock" size="large" class="dock-el" placeholder="选择接驳台">
<el-option v-for="d in myDocks" :key="d.pointNo" :label="d.label || d.pointNo" :value="d.pointNo" />
</el-select>
<el-button size="large" @click="fillFromBind">按缺料填充</el-button>
<el-button size="large" @click="addRow">添加一行</el-button>
</div>
<el-table :data="items" border size="large" class="call-table">
<el-table-column label="物料编码" min-width="150">
<template #default="{ row }">
<el-input v-model.trim="row.materialCode" size="large" placeholder="物料编码" />
</template>
</el-table-column>
<el-table-column label="物料名称" min-width="150">
<template #default="{ row }">
<el-input v-model.trim="row.materialName" size="large" placeholder="物料名称" />
</template>
</el-table-column>
<el-table-column label="单位" width="110">
<template #default="{ row }">
<el-input v-model.trim="row.unit" size="large" placeholder="个/件" />
</template>
</el-table-column>
<el-table-column label="数量" width="140">
<template #default="{ row }">
<el-input-number v-model="row.qty" size="large" :min="0" :precision="2" controls-position="right" />
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template #default="{ $index }">
<el-button type="danger" size="large" @click="items.splice($index, 1)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!items.length" description="暂无叫料明细,可点「按缺料填充」或「添加一行」" :image-size="60" />
<div class="call-foot">
<el-button type="primary" size="large" class="big-btn" :loading="calling" @click="doCall">提交叫料</el-button>
</div>
</section>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { bindRecord, bindRemove, callLineMaterial } from '../../api'
import {
bindInputs,
loadBind,
myDocks,
operatorName,
orderNo,
sn,
state,
stationNo
} from '../../store/station'
const bindPanel = computed(() => state.bindPanel)
const required = computed(() => (bindPanel.value?.required || []))
const binding = ref(false)
const calling = ref(false)
const dock = ref('')
const items = ref([])
function addRow() {
items.value.push({ materialCode: '', materialName: '', unit: '个', manageMode: '1', qty: 1 })
}
// 按绑定面板的缺料情况一键填充(数量 = 需求 - 已绑)
function fillFromBind() {
const list = required.value
.filter((r) => !r.complete)
.map((r) => ({
materialCode: r.materialCode,
materialName: r.materialName || '',
unit: r.unit || '个',
manageMode: r.manageMode || '1',
qty: Math.max(Number(r.need || 0) - Number(r.boundCount || 0), 1)
}))
if (!list.length) {
ElMessage.warning('当前没有缺料')
return
}
items.value = list
}
async function doBind(r) {
const v = (bindInputs.value[r.materialCode] || '').trim()
if (!v) {
ElMessage.warning('请先扫描/输入' + (r.manageMode === '2' ? '电气件SN' : '批次号'))
return
}
if (!sn.value) {
ElMessage.warning('请先扫描工件SN')
return
}
binding.value = true
try {
await bindRecord({
sn: sn.value,
orderNo: orderNo.value || '',
processCode: Number(stationNo.value),
stationNo: stationNo.value,
items: [{ materialCode: r.materialCode, bindValue: v }]
})
ElMessage.success('绑定成功:' + (r.materialName || r.materialCode))
bindInputs.value[r.materialCode] = ''
loadBind()
} catch (e) { /* 统一错误提示 */ }
binding.value = false
}
async function removeBind(r, b) {
try {
await bindRemove({
sn: sn.value,
processCode: Number(stationNo.value),
materialCode: r.materialCode,
bindValue: b
})
ElMessage.success('已撤销')
loadBind()
} catch (e) { /* 统一错误提示 */ }
}
async function doCall() {
if (!dock.value) {
ElMessage.warning('请选择接驳台')
return
}
const list = items.value.filter((i) => i.materialCode && Number(i.qty) > 0)
if (!list.length) {
ElMessage.warning('请添加有效的叫料明细(物料编码 + 数量)')
return
}
calling.value = true
try {
const res = await callLineMaterial({
stationNo: stationNo.value,
dock: dock.value,
orderNo: orderNo.value || '',
operator: operatorName(),
items: list
})
ElMessage.success('叫料已提交,共 ' + (res?.count ?? list.length) + ' 项')
items.value = []
} catch (e) { /* 统一错误提示 */ }
calling.value = false
}
</script>
<style scoped>
.mat-panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-content: start;
}
.card {
background: #fff;
border-radius: 10px;
padding: 14px 16px;
box-shadow: 0 1px 6px rgba(0, 21, 41, 0.08);
}
.card-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.bind-item {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
}
.bind-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.bind-name {
font-size: 16px;
font-weight: 600;
}
.bind-code {
color: #909399;
}
.bind-count.ok { color: #67c23a; font-weight: 700; }
.bind-count.ng { color: #f56c6c; font-weight: 700; }
.bind-bound {
margin: 6px 0;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.none {
color: #c0c4cc;
}
.bind-input-row {
display: flex;
gap: 8px;
}
.call-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.call-row .lab {
color: #909399;
}
.dock-el {
width: 260px;
}
.call-table {
width: 100%;
}
.call-foot {
margin-top: 12px;
}
.big-btn {
min-width: 160px;
height: 48px;
font-size: 17px;
}
@media (max-width: 1100px) {
.mat-panel {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,323 @@
<template>
<div class="move-panel">
<!-- ① 产线点位图(唯一事实源:每点位当前工件) -->
<section class="card">
<div class="card-title">
产线点位(点击选择起点 → 再点终点 → 下发移料)
<el-button size="large" @click="clearSel">清空选择</el-button>
<el-button size="large" @click="loadPoints">刷新</el-button>
</div>
<div class="belt">
<div
v-for="p in beltPoints"
:key="p.pointNo"
class="pt"
:class="[typeClass(p), { sel: selected === p.pointNo, mine: String(p.stationNo) === String(stationNo) }]"
@click="onPick(p)"
>
<div class="pt-name">{{ p.pointNo }}</div>
<div class="pt-sn">{{ p.occupied ? p.currentSn : '空' }}</div>
</div>
</div>
<div class="dock-line" v-if="myDocks.length">
<span class="lab">本工位接驳台(AGV 点位)</span>
<div v-for="d in myDocks" :key="d.pointNo" class="pt dock">
<div class="pt-name">{{ d.pointNo }}</div>
<div class="pt-sn">物料</div>
</div>
</div>
<div class="ops">
<div class="sel-text">
起点:<b>{{ fromText }}</b> | 终点:<b>{{ toText }}</b>
</div>
<el-button type="primary" size="large" :disabled="!canIssue" @click="onIssue">下发移料</el-button>
<el-button size="large" :disabled="!selected" @click="onIn">上料登记</el-button>
<el-button type="danger" size="large" :disabled="!selected" @click="onOut">下料释放</el-button>
</div>
</section>
<!-- ② 移料指令 -->
<section class="card">
<div class="card-title">移料指令(最近 20 条)</div>
<el-table :data="moves" border size="large" height="420">
<el-table-column prop="cmdNo" label="指令号" min-width="150" />
<el-table-column prop="sn" label="工件SN" min-width="140" />
<el-table-column label="起点 → 终点" min-width="140">
<template #default="{ row }">{{ row.fromPoint }} → {{ row.toPoint }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" effect="dark">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="110" />
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button
v-if="row.status === 'ISSUED'"
size="large"
@click="onAck(row)"
>应答</el-button>
<el-button
v-if="row.status === 'ISSUED' || row.status === 'ACK'"
type="primary"
size="large"
@click="onArrive(row)"
>确认到位</el-button>
</template>
</el-table-column>
</el-table>
</section>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ackLineMove, arriveLineMove, issueLineMove, occupyLinePoint } from '../../api'
import {
beltPoints,
loadMoves,
loadPoints,
myDocks,
orderNo,
pointByNo,
stationNo,
state
} from '../../store/station'
const moves = computed(() => state.moves)
const selected = ref('')
const from = ref('')
const to = ref('')
const fromText = computed(() => from.value || '未选择')
const toText = computed(() => to.value || '未选择')
const canIssue = computed(() => {
if (!from.value || !to.value || from.value === to.value) return false
return !!pointByNo(from.value)?.occupied
})
function clearSel() {
selected.value = ''
from.value = ''
to.value = ''
}
function typeClass(p) {
if (p.pointType === 'STATION') return 't-station'
if (p.pointType === 'CACHE') return 't-cache'
if (p.pointType === 'FEED') return 't-feed'
return 't-end'
}
function onPick(p) {
selected.value = p.pointNo
if (!from.value) {
// 起点必须是占用点位
if (!p.occupied) {
ElMessage.warning(p.pointNo + ' 当前无工件,不能作为起点')
return
}
from.value = p.pointNo
return
}
if (p.pointNo === from.value) {
to.value = ''
return
}
if (p.occupied) {
ElMessage.warning(p.pointNo + ' 已被占用,不能作为终点')
return
}
to.value = p.pointNo
}
async function onIssue() {
const src = pointByNo(from.value)
if (!src) return
try {
await ElMessageBox.confirm(
`确认把「${src.currentSn}」从 ${from.value} 移到 ${to.value}?`,
'移料确认',
{ confirmButtonText: '确认下发', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
try {
await issueLineMove({
sn: src.currentSn,
fromPoint: from.value,
toPoint: to.value,
orderNo: orderNo.value || src.orderNo || ''
})
ElMessage.success('移料指令已下发')
clearSel()
loadPoints()
loadMoves()
} catch (e) { /* 统一错误提示 */ }
}
async function onIn() {
const p = pointByNo(selected.value)
if (!p) return
if (p.occupied) {
ElMessage.warning(p.pointNo + ' 已被 ' + p.currentSn + ' 占用')
return
}
let sn = ''
try {
const r = await ElMessageBox.prompt('输入要登记到 ' + p.pointNo + ' 的工件SN', '上料登记', {
confirmButtonText: '确认',
cancelButtonText: '取消',
inputPlaceholder: '工件SN'
})
sn = (r.value || '').trim()
} catch {
return
}
if (!sn) return
try {
await occupyLinePoint({ pointNo: p.pointNo, sn, orderNo: orderNo.value || '', action: 'IN' })
ElMessage.success('已登记上料')
loadPoints()
} catch (e) { /* 统一错误提示 */ }
}
async function onOut() {
const p = pointByNo(selected.value)
if (!p || !p.occupied) {
ElMessage.warning('该点位当前无工件')
return
}
try {
await ElMessageBox.confirm(`确认把「${p.currentSn}」从 ${p.pointNo} 下料释放?`, '下料确认', {
confirmButtonText: '确认下料',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
try {
await occupyLinePoint({ pointNo: p.pointNo, sn: p.currentSn, orderNo: '', action: 'OUT' })
ElMessage.success('已释放点位')
clearSel()
loadPoints()
} catch (e) { /* 统一错误提示 */ }
}
async function onAck(row) {
try {
await ackLineMove({ cmdNo: row.cmdNo })
ElMessage.success('已应答')
loadMoves()
} catch (e) { /* 统一错误提示 */ }
}
async function onArrive(row) {
try {
await arriveLineMove({ cmdNo: row.cmdNo })
ElMessage.success('到位已记账')
loadMoves()
loadPoints()
} catch (e) { /* 统一错误提示 */ }
}
function statusText(s) {
return { ISSUED: '已下发', ACK: '已应答', ARRIVED: '已到位', CLOSED: '已闭环', FAILED: '失败' }[s] || s
}
function statusType(s) {
return { ISSUED: 'warning', ACK: 'primary', ARRIVED: 'success', CLOSED: 'info', FAILED: 'danger' }[s] || 'info'
}
</script>
<style scoped>
.move-panel {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.card {
background: #fff;
border-radius: 10px;
padding: 14px 16px;
box-shadow: 0 1px 6px rgba(0, 21, 41, 0.08);
}
.card-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.belt {
display: flex;
gap: 6px;
overflow-x: auto;
padding-bottom: 6px;
}
.pt {
min-width: 78px;
border: 2px solid #dcdfe6;
border-radius: 8px;
padding: 8px 6px;
text-align: center;
cursor: pointer;
background: #fafafa;
}
.pt-name {
font-weight: 700;
font-size: 15px;
}
.pt-sn {
font-size: 12px;
color: #606266;
margin-top: 4px;
word-break: break-all;
}
.t-station { background: #e8f3ff; border-color: #79bbff; }
.t-cache { background: #f4f4f5; border-color: #d3dce6; }
.t-end { background: #f3e8ff; border-color: #b488f0; }
.t-feed { background: #fff7e6; border-color: #ffc069; }
.pt.mine { box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.25); }
.pt.sel { border-color: #f56c6c; background: #fef0f0; }
.dock-line {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.dock-line .lab {
color: #909399;
font-size: 14px;
}
.pt.dock {
background: #eafaf1;
border-color: #67c23a;
min-width: 92px;
cursor: default;
}
.ops {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
flex-wrap: wrap;
}
.sel-text {
font-size: 15px;
margin-right: 8px;
}
@media (max-width: 1100px) {
.belt {
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,53 @@
<template>
<div class="pdf-panel">
<section class="card">
<div class="card-title">
工艺文件
<el-tag v-if="pdfFile" type="success" size="large" effect="dark">{{ flowName }}</el-tag>
<el-button type="primary" size="large" :disabled="!pdfFile" @click="openPdf">放大查看</el-button>
</div>
<el-empty v-if="!pdfFile" description="本工位当前流程未上传工艺文件" :image-size="90" />
<iframe v-else :src="pdfUrl" class="pdf-frame" title="工艺文件"></iframe>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { pdfOpenUrl } from '../../api'
import { flowName, state } from '../../store/station'
const pdfFile = computed(() => {
const f = state.task?.flow?.pdfFile
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')
}
</script>
<style scoped>
.card {
background: #fff;
border-radius: 10px;
padding: 14px 16px;
box-shadow: 0 1px 6px rgba(0, 21, 41, 0.08);
}
.card-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.pdf-frame {
width: 100%;
height: calc(100vh - 260px);
min-height: 420px;
border: 1px solid #ebeef5;
border-radius: 8px;
}
</style>
@@ -0,0 +1,503 @@
<template>
<div class="work-panel">
<!-- ① 工件区:扫码上料 / 取料 -->
<section class="card sn-card">
<div class="card-title">当前工件</div>
<div class="sn-big" :class="{ empty: !sn }">{{ sn || '未上料' }}</div>
<div class="sn-row">
<el-input
ref="scanRef"
v-model.trim="scanInput"
size="large"
class="scan-input"
placeholder="扫描或输入工件SN"
clearable
@keyup.enter="onScan"
/>
<el-button type="primary" size="large" :loading="taking" @click="onTakeIn">上料 / 取料到本工位</el-button>
<el-button size="large" :disabled="!sn" @click="onMoveOut">移出本工位</el-button>
</div>
<div class="point-line">
本工位点位:<b>{{ myPointLabel }}</b> · 状态:
<el-tag :type="myPointSn ? 'success' : 'info'" effect="dark" size="large">
{{ myPointSn ? '在制 ' + myPointSn : '空闲' }}
</el-tag>
<span class="seg-text">路线段:{{ segmentText }}</span>
</div>
</section>
<!-- ② 任务区(工单 + 流程) -->
<section class="card task-card">
<div class="card-title">当前任务</div>
<div class="task-grid">
<div class="kv">
<span>工艺流程</span>
<b>{{ flowName }}</b>
<el-tag :type="flowActive ? 'success' : 'info'" size="large" effect="dark">
{{ flowActive ? '启用' : '停用' }}
</el-tag>
</div>
<div class="kv">
<span>工单号</span>
<el-select v-model="orderNo" size="large" class="order-el" placeholder="选择工单号">
<el-option v-for="(o, i) in orderNos" :key="i" :label="o" :value="o" />
</el-select>
</div>
</div>
</section>
<!-- ③ 步骤采集 -->
<section class="card steps-card">
<div class="steps-head">
<span class="card-title">步骤采集</span>
<el-tag size="large" effect="dark" :type="stepSummaryType">{{ stepSummaryText }}</el-tag>
</div>
<el-empty v-if="!manualSteps.length && !autoSteps.length" description="本工位暂无工艺步骤" :image-size="80" />
<div v-for="s in manualSteps" :key="s.id" class="step-item">
<div class="step-head">
<span class="step-name">{{ s.name || '步骤 ' + s.seq }}</span>
<el-tag size="large" :type="stepDisplay(s).type" effect="dark">{{ stepDisplay(s).text }}</el-tag>
</div>
<div class="step-input">
<el-input-number
v-model="stepValues[s.id]"
size="large"
:controls-position="'right'"
:precision="2"
class="num-input"
/>
<span class="step-remark">{{ s.remark }}</span>
</div>
<div v-if="s.criteria && s.criteria.length" class="criteria">
<div v-for="c in s.criteria" :key="c.id" class="criterion" :class="criterionClass(c, stepValues[s.id])">
<span class="c-name">{{ c.name }}</span>
<span class="c-target">{{ criterionText(c) }}</span>
<span class="c-result">{{ criterionMark(c, stepValues[s.id]) }}</span>
</div>
</div>
<div v-if="s.needCheck" class="check-row">
<el-checkbox v-model="stepChecks[s.id]" size="large" border>检测确认(合格)</el-checkbox>
</div>
</div>
<div v-for="s in autoSteps" :key="s.id" class="step-item auto">
<div class="step-head">
<span class="step-name">{{ s.name || '自动采集' }}</span>
<el-tag size="large" :type="autoTorqueState.ok ? 'success' : 'warning'" effect="dark">
{{ autoTorqueState.text }}
</el-tag>
</div>
<div class="step-input">
<span class="tight-progress">
拧紧 {{ autoTorqueOk }} / {{ tightRows.length }}
<template v-if="screwCount"> (本工位需拧 {{ screwCount }} 颗)</template>
</span>
</div>
</div>
</section>
<!-- ④ 底部操作 -->
<footer class="action-bar">
<el-button type="success" size="large" class="big-btn" :disabled="!canReport" @click="onProcessDone">
报工完成
</el-button>
<el-button type="warning" size="large" class="big-btn" :disabled="!sn" @click="onTempStore">暂存退库</el-button>
<div class="block-tip" v-if="blockTip">{{ blockTip }}</div>
</footer>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { arriveLineMove, reportProcessDone, reportTempStore } from '../../api'
import {
autoSteps,
autoTorqueOk,
autoTorqueState,
bindBlocking,
bringSnToStation,
canReport,
criterionOk,
flowActive,
flowName,
hasNg,
loadBind,
loadMoves,
loadPoints,
loadStats,
loadTask,
manualSteps,
missingChecks,
missingSteps,
moveOut,
myPoint,
myPointSn,
operatorName,
orderNos,
orderNo,
screwCount,
segmentText,
setCurrentSn,
sn,
state,
stepChecks,
stepDisplay,
stepValues,
stationNo,
suggestOutPoint,
tightRows
} from '../../store/station'
const scanRef = ref()
const scanInput = ref('')
const taking = ref(false)
const myPointLabel = computed(() => (myPoint.value ? myPoint.value.label || myPoint.value.pointNo : '工位' + stationNo.value))
const stepSummaryText = computed(() => {
const total = manualSteps.value.length
const done = total - missingSteps.value.length
return `步骤 ${done}/${total}`
})
const stepSummaryType = computed(() => (missingSteps.value.length ? 'warning' : 'success'))
const blockTip = computed(() => {
if (!sn.value) return '请先扫描工件 SN'
if (!orderNo.value) return '请选择工单号'
if (bindBlocking.value) return '装配物料未绑齐:' + (state.bindPanel?.missing || '缺料')
if (missingSteps.value.length) return '还有步骤未录入:' + (missingSteps.value[0].name || '未命名步骤')
if (missingChecks.value.length) return '还有步骤未做检测确认:' + (missingChecks.value[0].name || '未命名步骤')
if (autoSteps.value.some((s) => s.isTorque) && !autoTorqueState.value.ok) return '拧紧步骤尚未完成'
return ''
})
function focusScan() {
scanRef.value?.focus?.()
}
// 扫码后:若 SN 已在本工位直接开干;否则提示去"上料/取料"
async function onScan() {
const v = scanInput.value.trim()
if (!v) return
scanInput.value = ''
const onPoint = state.points.find((p) => p.currentSn === v && p.occupied)
if (onPoint && String(onPoint.pointNo) === String(stationNo.value)) {
setCurrentSn(v)
ElMessage.success('已装载工件 ' + v)
return
}
if (onPoint) {
setCurrentSn(v)
ElMessage.warning('该工件在 ' + (onPoint.label || onPoint.pointNo) + ',请点「上料 / 取料到本工位」调入')
return
}
setCurrentSn(v)
ElMessage.warning('产线台账无该工件,请点「上料 / 取料到本工位」登记到本工位')
}
async function onTakeIn() {
const v = (scanInput.value || '').trim() || sn.value
if (!v) {
ElMessage.warning('请先扫描或输入工件SN')
focusScan()
return
}
taking.value = true
try {
const r = await bringSnToStation(v)
await loadPoints()
if (r.mode === 'MOVE' && r.cmdNo) {
// PLC 联调前由人工确认到位;联调后由 PLC 到位信号自动闭环
try {
await ElMessageBox.confirm(
`已下发移料指令:${r.fromPoint} → 工位${stationNo.value}。工件到位后点「确认到位」记账。`,
'移料指令已下发',
{ confirmButtonText: '确认到位', cancelButtonText: '稍后', type: 'info' }
)
await arriveLineMove({ cmdNo: r.cmdNo })
await loadPoints()
await loadMoves()
} catch { /* 稍后:指令留在列表,可在移料页确认 */ }
}
setCurrentSn(v)
scanInput.value = ''
ElMessage.success(r.mode === 'IN' ? '已登记上料到本工位' : r.mode === 'HERE' ? '工件已在本工位' : '工件已调入本工位')
} catch (e) { /* 统一错误提示 */ }
taking.value = false
focusScan()
}
async function onMoveOut() {
if (!sn.value) return
const target = suggestOutPoint.value
if (!target) {
ElMessage.warning('下游无可用点位')
return
}
try {
await ElMessageBox.confirm(
`确认把「${sn.value}」移出本工位到 ${target.label || target.pointNo}?`,
'移出确认',
{ confirmButtonText: '确认移出', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
try {
const cmdNo = await moveOut(sn.value, target.pointNo)
try {
await arriveLineMove({ cmdNo })
} catch { /* 指令保留在列表 */ }
await loadPoints()
await loadMoves()
setCurrentSn('')
ElMessage.success('已移出到 ' + (target.label || target.pointNo))
} catch (e) { /* 统一错误提示 */ }
}
const OP_TEXT = { GE: '≥', LE: '≤', GT: '>', LT: '<', EQUAL: '=' }
function criterionText(c) {
if (c?.logic === 'RANGE') {
const min = Number(c.min)
const max = Number(c.max)
return `${isNaN(min) ? '-' : min} ~ ${isNaN(max) ? '-' : max} ${c.unit || ''}`
}
return `${OP_TEXT[c?.logic] || c?.logic || '='} ${c.target} ${c.unit || ''}`
}
function criterionClass(c, v) {
if (v === null || v === undefined || v === '') return 'c-pending'
return criterionOk(c, v) ? 'c-ok' : 'c-ng'
}
function criterionMark(c, v) {
if (v === null || v === undefined || v === '') return '·'
return criterionOk(c, v) ? '✓' : '✗'
}
function buildStepsPayload() {
const payload = []
for (const s of manualSteps.value) {
const raw = stepValues.value[s.id]
const val = raw === null || raw === undefined || raw === '' ? null : Number(raw)
payload.push({ stepId: s.id, name: s.name, value: val, text: `${s.name}: ${val === null ? '未录入' : val}` })
}
for (const s of autoSteps.value) {
if (s.isTorque) {
payload.push({
stepId: s.id,
name: s.name,
value: autoTorqueOk.value,
text: `拧紧 ${autoTorqueOk.value}/${tightRows.value.length} OK`
})
}
}
return payload
}
async function onProcessDone() {
if (!canReport.value) {
ElMessage.warning(blockTip.value || '当前不满足报工条件')
return
}
const allOk = !hasNg.value
try {
await ElMessageBox.confirm(
`确认报工?工件 ${sn.value} · 工单 ${orderNo.value} · 工位 ${stationNo.value}`,
'报工确认',
{ confirmButtonText: '确认报工', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
try {
const data = await reportProcessDone({
sn: sn.value,
orderNo: orderNo.value,
processCode: Number(stationNo.value),
stationNo: stationNo.value,
steps: buildStepsPayload(),
operator: operatorName()
})
ElMessage.success(data?.queued ? 'MES 暂不可达,已离线缓存,稍后自动重传' : '报工成功')
setCurrentSn('')
loadTask()
loadStats()
loadPoints()
} catch (e) { /* 统一错误提示 */ }
}
async function onTempStore() {
if (!sn.value) {
ElMessage.warning('请先扫描工件 SN')
return
}
try {
await ElMessageBox.confirm(`确认将工件「${sn.value}」暂存退库?`, '暂存退库', {
confirmButtonText: '确认暂存',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
try {
const data = await reportTempStore({
sn: sn.value,
orderNo: orderNo.value || '',
stationNo: stationNo.value,
processCode: Number(stationNo.value),
operator: operatorName()
})
ElMessage.success(data?.queued ? 'MES 暂不可达,已离线缓存' : '暂存退库已上报')
setCurrentSn('')
loadTask()
loadStats()
loadPoints()
} catch (e) { /* 统一错误提示 */ }
}
defineExpose({ focusScan })
</script>
<style scoped>
.work-panel {
display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(360px, 1.3fr) minmax(360px, 1.3fr);
gap: 12px;
align-content: start;
}
.card {
background: #fff;
border-radius: 10px;
padding: 14px 16px;
box-shadow: 0 1px 6px rgba(0, 21, 41, 0.08);
}
.card-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 10px;
}
.sn-card .sn-big {
font-size: 34px;
font-weight: 700;
letter-spacing: 1px;
color: #1f6feb;
margin-bottom: 10px;
}
.sn-big.empty {
color: #c0c4cc;
}
.sn-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.sn-row .scan-input {
flex: 1 1 220px;
}
.point-line {
margin-top: 10px;
color: #606266;
}
.seg-text {
margin-left: 12px;
}
.task-grid .kv {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
font-size: 16px;
}
.task-grid .kv span {
color: #909399;
min-width: 72px;
}
.order-el {
flex: 1;
}
.steps-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.step-item {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
}
.step-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.step-name {
font-size: 16px;
font-weight: 600;
}
.step-input {
display: flex;
align-items: center;
gap: 12px;
}
.step-remark {
color: #909399;
}
.num-input {
width: 190px;
}
.criteria {
margin-top: 8px;
}
.criterion {
display: flex;
gap: 12px;
font-size: 14px;
padding: 2px 0;
}
.c-pending { color: #909399; }
.c-ok { color: #67c23a; }
.c-ng { color: #f56c6c; }
.check-row {
margin-top: 8px;
}
.tight-progress {
font-size: 16px;
}
.action-bar {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-radius: 10px;
padding: 12px 16px;
box-shadow: 0 1px 6px rgba(0, 21, 41, 0.08);
}
.big-btn {
min-width: 160px;
height: 52px;
font-size: 18px;
}
.block-tip {
color: #e6a23c;
font-size: 15px;
}
@media (max-width: 1280px) {
.work-panel {
grid-template-columns: 1fr 1fr;
}
.steps-card {
grid-column: 1 / -1;
}
}
@media (max-width: 900px) {
.work-panel {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,113 @@
<template>
<el-dialog v-model="visible" title="我的工作量" width="80%" top="6vh" destroy-on-close>
<div class="wl-filter">
<span class="lab">工位</span>
<el-select v-model="wlStation" size="large" class="sel" placeholder="全部工位">
<el-option label="全部工位" :value="0" />
<el-option v-for="s in stationOptions" :key="s" :label="'工位 ' + s" :value="s" />
</el-select>
<span class="lab">日期</span>
<el-date-picker
v-model="wlRange"
type="daterange"
size="large"
value-format="YYYY-MM-DD"
start-placeholder="开始"
end-placeholder="结束"
/>
<el-button type="primary" size="large" @click="load">查询</el-button>
</div>
<div class="wl-summary">
<div class="sum-item">完成 <b>{{ summary.doneCount }}</b></div>
<div class="sum-item ok">合格 <b>{{ summary.okCount }}</b></div>
<div class="sum-item ng">不合格 <b>{{ summary.ngCount }}</b></div>
<div class="sum-item">合格率 <b>{{ summary.rate }}</b></div>
</div>
<el-table :data="rows" border size="large" height="420">
<el-table-column prop="date" label="日期" width="130" />
<el-table-column prop="stationNo" label="工位" width="90" />
<el-table-column prop="processName" label="工序" min-width="160" />
<el-table-column prop="operator" label="操作人" width="120" />
<el-table-column prop="doneCount" label="完成" width="90" />
<el-table-column prop="okCount" label="合格" width="90" />
<el-table-column prop="ngCount" label="不合格" width="100" />
</el-table>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import { getWorkload } from '../../api'
import { loadStationNos } from '../../utils/stations'
import { getUser } from '../../utils/request'
import { stationNo as curStation } from '../../store/station'
const user = getUser()
const visible = ref(false)
const wlStation = ref(0)
const wlRange = ref(null)
const stationOptions = ref([])
const rows = ref([])
const summary = ref({ doneCount: 0, okCount: 0, ngCount: 0, rate: '-' })
async function open() {
wlStation.value = curStation.value || 0
stationOptions.value = await loadStationNos().catch(() => [])
visible.value = true
load()
}
async function load() {
const [from, to] = wlRange.value || []
try {
const data = await getWorkload({
operator: user.name || user.username || '',
stationNo: wlStation.value || 0,
from: from || '',
to: to || ''
})
const list = (data && Array.isArray(data.rows) ? data.rows : []) || []
rows.value = list
let doneCount = 0, okCount = 0, ngCount = 0
list.forEach((r) => {
doneCount += Number(r.doneCount) || 0
okCount += Number(r.okCount) || 0
ngCount += Number(r.ngCount) || 0
})
summary.value = {
doneCount,
okCount,
ngCount,
rate: doneCount ? ((okCount / doneCount) * 100).toFixed(1) + '%' : '-'
}
} catch (e) { /* 统一错误提示 */ }
}
defineExpose({ open })
</script>
<style scoped>
.wl-filter {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.lab {
color: #909399;
}
.sel {
width: 180px;
}
.wl-summary {
display: flex;
gap: 24px;
margin-bottom: 12px;
font-size: 16px;
}
.sum-item.ok b { color: #67c23a; }
.sum-item.ng b { color: #f56c6c; }
</style>