| | |
| | | import { gsap } from 'gsap'; |
| | | |
| | | import { |
| | | getLatestScanEvent, |
| | | getScanDashboard, |
| | | getScanEventPage, |
| | | inboundScanEvent, |
| | | type MesProScanEventApi, |
| | | } from '#/api/mes/pro/scanEvent'; |
| | | import { getLineSimpleList, type MesProLineApi } from '#/api/mes/pro/line'; |
| | | import { |
| | | getWarehouseLocationSimpleList, |
| | | type MesWmWarehouseLocationApi, |
| | | } from '#/api/mes/wm/warehouse/location'; |
| | | import { |
| | | getWarehouseAreaSimpleList, |
| | | type MesWmWarehouseAreaApi, |
| | | } from '#/api/mes/wm/warehouse/area'; |
| | | |
| | | interface ScanRecord extends MesProScanEventApi.ScanEvent { |
| | | key: number; |
| | | } |
| | | |
| | | const code = ref(''); |
| | | const deviceCode = ref(''); |
| | | const scanType = ref<MesProScanEventApi.ScanType>('BAG'); |
| | | const lineCode = ref(''); |
| | | const locationId = ref<number>(); |
| | | const areaId = ref<number>(); |
| | | const lines = ref<MesProLineApi.Line[]>([]); |
| | | const locations = ref<MesWmWarehouseLocationApi.WarehouseLocation[]>([]); |
| | | const areas = ref<MesWmWarehouseAreaApi.WarehouseArea[]>([]); |
| | | const loading = ref(false); |
| | | const refreshing = ref(false); |
| | | const records = ref<ScanRecord[]>([]); |
| | | const dashboard = ref<MesProScanEventApi.Dashboard>({}); |
| | | let timer: ReturnType<typeof setInterval> | undefined; |
| | | |
| | | /** 扫码类型中文名 */ |
| | | const SCAN_TYPE_LABEL: Record<MesProScanEventApi.ScanType, string> = { |
| | | BAG: '袋码', |
| | | PALLET: '托盘码', |
| | | BATCH: '批次码', |
| | | }; |
| | | /** 扫码类型下拉选项 */ |
| | | const SCAN_TYPE_OPTIONS = [ |
| | | { label: '袋码', value: 'BAG' }, |
| | | { label: '托盘码', value: 'PALLET' }, |
| | | { label: '批次码', value: 'BATCH' }, |
| | | ] as const; |
| | | |
| | | const latest = computed(() => dashboard.value.latest); |
| | | const successCount = computed(() => dashboard.value.success ?? records.value.filter((item) => item.status === 'SUCCESS').length); |
| | | const failedCount = computed(() => dashboard.value.failed ?? records.value.filter((item) => item.status === 'FAILED').length); |
| | | const isReady = computed(() => Boolean(deviceCode.value.trim() && locationId.value && areaId.value)); |
| | | const isReady = computed(() => Boolean(locationId.value && areaId.value)); |
| | | const lineOptions = computed(() => |
| | | lines.value.map((item) => ({ |
| | | label: item.code ? `${item.code} ${item.name ?? ''}`.trim() : (item.name ?? ''), |
| | | value: item.code ?? '', |
| | | })), |
| | | ); |
| | | const locationOptions = computed(() => |
| | | locations.value.map((item) => ({ |
| | | label: item.code ? `${item.code} ${item.name ?? ''}`.trim() : (item.name ?? ''), |
| | | value: item.id ?? 0, |
| | | })), |
| | | ); |
| | | const areaOptions = computed(() => |
| | | areas.value.map((item) => ({ |
| | | label: item.code ? `${item.code} ${item.name ?? ''}`.trim() : (item.name ?? ''), |
| | | value: item.id ?? 0, |
| | | })), |
| | | ); |
| | | const locationName = computed(() => locations.value.find((item) => item.id === locationId.value)?.name ?? ''); |
| | | const areaName = computed(() => areas.value.find((item) => item.id === areaId.value)?.name ?? ''); |
| | | const pageRoot = ref<HTMLElement>(); |
| | | let animationContext: gsap.Context | undefined; |
| | | let feedbackTimeline: gsap.core.Timeline | undefined; |
| | |
| | | try { |
| | | const [summary, page] = await Promise.all([ |
| | | getScanDashboard(), |
| | | getScanEventPage({ pageNo: 1, pageSize: 20, deviceCode: deviceCode.value.trim() || undefined }), |
| | | getScanEventPage({ pageNo: 1, pageSize: 20 }), |
| | | ]); |
| | | dashboard.value = summary; |
| | | records.value = (page.list ?? []).map((item, index) => ({ ...item, key: item.id ?? index })); |
| | |
| | | } |
| | | } |
| | | |
| | | async function loadOptions() { |
| | | try { |
| | | const [lineList, locationList] = await Promise.all([ |
| | | getLineSimpleList(), |
| | | getWarehouseLocationSimpleList(), |
| | | ]); |
| | | lines.value = lineList ?? []; |
| | | locations.value = locationList ?? []; |
| | | } catch { |
| | | lines.value = []; |
| | | locations.value = []; |
| | | } |
| | | } |
| | | |
| | | async function loadAreas() { |
| | | areas.value = []; |
| | | areaId.value = undefined; |
| | | if (!locationId.value) return; |
| | | try { |
| | | areas.value = (await getWarehouseAreaSimpleList(locationId.value)) ?? []; |
| | | } catch { |
| | | areas.value = []; |
| | | } |
| | | } |
| | | |
| | | function onScanTypeChange() { |
| | | code.value = ''; |
| | | } |
| | | |
| | | async function submitScan() { |
| | | const bagCode = code.value.trim(); |
| | | if (!bagCode || !deviceCode.value.trim()) { |
| | | message.warning('请先填写设备编码,再扫描袋码'); |
| | | if (!bagCode) { |
| | | message.warning('请先扫描追溯码'); |
| | | return; |
| | | } |
| | | if (!locationId.value || !areaId.value) { |
| | | message.warning('请先配置 WIP 暂存库区和库位'); |
| | | message.warning('请先选择 WIP 暂存库区和库位'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | await inboundScanEvent({ |
| | | eventId: `${deviceCode.value.trim()}-${Date.now()}`, |
| | | eventId: `${scanType.value}-${Date.now()}`, |
| | | bagCode, |
| | | scanType: scanType.value, |
| | | quantity: 1, |
| | | deviceCode: deviceCode.value.trim(), |
| | | lineCode: lineCode.value.trim() || undefined, |
| | | lineCode: lineCode.value || undefined, |
| | | locationId: locationId.value, |
| | | areaId: areaId.value, |
| | | }); |
| | | message.success(`袋码 ${bagCode} 已完成入库`); |
| | | message.success(`${SCAN_TYPE_LABEL[scanType.value]} ${bagCode} 已完成入库`); |
| | | if (animationContext && pageRoot.value && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { |
| | | feedbackTimeline?.kill(); |
| | | feedbackTimeline = gsap.timeline(); |
| | |
| | | code.value = ''; |
| | | await refresh(); |
| | | } catch (error) { |
| | | const response = error as { message?: string; msg?: string }; |
| | | message.error(response.message ?? response.msg ?? '扫码入库失败,请查看事件记录'); |
| | | const response = error as { |
| | | message?: string; |
| | | msg?: string; |
| | | response?: { data?: { message?: string; msg?: string } }; |
| | | }; |
| | | const errorMessage = |
| | | response.response?.data?.message ?? |
| | | response.response?.data?.msg ?? |
| | | response.message ?? |
| | | response.msg; |
| | | if (errorMessage && /入库事务数量必须为正数|数量.*为 0|quantity.*positive/i.test(errorMessage)) { |
| | | message.error('入库数量不能为 0,请检查该批次的袋数配置'); |
| | | } else if (!errorMessage || /UnexpectedRollbackException|系统异常|Internal Server Error/i.test(errorMessage)) { |
| | | message.error('扫码入库失败,请检查批次数量和库位配置'); |
| | | } else { |
| | | message.error(errorMessage); |
| | | } |
| | | await refresh(); |
| | | } finally { |
| | | loading.value = false; |
| | |
| | | } |
| | | |
| | | async function refreshLatest() { |
| | | if (deviceCode.value.trim()) await getLatestScanEvent(deviceCode.value.trim()); |
| | | await refresh(); |
| | | } |
| | | |
| | |
| | | } |
| | | }, pageRoot.value); |
| | | } |
| | | await loadOptions(); |
| | | await refresh(); |
| | | timer = setInterval(refreshLatest, 15000); |
| | | }); |
| | |
| | | <div class="metric-card"><div class="metric-icon blue"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><span>累计扫码事件</span><strong>{{ dashboard.total ?? 0 }}</strong><small>持续接收</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon green"><IconifyIcon icon="ant-design:check-circle-outlined" /></div><div><span>成功入库</span><strong>{{ successCount }}</strong><small>已写入库存事务</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon red"><IconifyIcon icon="ant-design:warning-outlined" /></div><div><span>失败事件</span><strong>{{ failedCount }}</strong><small>需关注异常记录</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon orange"><IconifyIcon icon="ant-design:inbox-outlined" /></div><div><span>最近入库袋码</span><strong class="text-value">{{ latest?.bagCode ?? '-' }}</strong><small>{{ latest?.eventTime ?? '暂无记录' }}</small></div></div> |
| | | <div class="metric-card"><div class="metric-icon orange"><IconifyIcon icon="ant-design:inbox-outlined" /></div><div><span>最近入库追溯码</span><strong class="text-value">{{ latest?.bagCode ?? '-' }}</strong><small>{{ latest?.eventTime ?? '暂无记录' }}</small></div></div> |
| | | </section> |
| | | |
| | | <section class="main-grid"> |
| | | <a-card class="scan-card" :bordered="false"> |
| | | <template #title><div class="card-title"><IconifyIcon icon="ant-design:scan-outlined" />扫码入库 <a-tag color="blue">自动模式</a-tag></div></template> |
| | | <div class="scan-panel"> |
| | | <div class="scan-intro"><div class="scan-ring"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><h2>等待扫码</h2><p>将袋码对准工业相机,或使用扫码枪扫描后回车确认</p></div></div> |
| | | <a-input-search v-model:value="code" class="scan-input" size="large" enter-button="确认入库" :loading="loading" :disabled="loading" placeholder="请扫描袋码,扫码枪回车后自动入库" @search="submitScan" /> |
| | | <div class="scan-hint"><IconifyIcon icon="ant-design:clock-circle-outlined" /> 服务端记录准确入库时间 <span>·</span> <IconifyIcon icon="ant-design:inbox-outlined" /> 每袋数量按 1 计</div> |
| | | <div class="scan-intro"><div class="scan-ring"><IconifyIcon icon="ant-design:scan-outlined" /></div><div><h2>等待扫码</h2><p>选择追溯码类型后扫码,或使用扫码枪扫描后回车确认</p></div></div> |
| | | <div class="scan-row"> |
| | | <a-select v-model:value="scanType" class="scan-type" :options="SCAN_TYPE_OPTIONS" size="large" @change="onScanTypeChange" /> |
| | | <a-input-search v-model:value="code" class="scan-input" size="large" enter-button="确认入库" :loading="loading" :disabled="loading" placeholder="请扫描追溯码,扫码枪回车后自动入库" @search="submitScan" /> |
| | | </div> |
| | | <div class="scan-hint"><IconifyIcon icon="ant-design:clock-circle-outlined" /> 服务端记录准确入库时间 <span>·</span> <IconifyIcon icon="ant-design:inbox-outlined" /> 袋码按 1 袋、托盘按已装袋数、批次按批次袋数入库</div> |
| | | </div> |
| | | <a-divider /> |
| | | <a-form layout="vertical" class="config-form"> |
| | | <a-form-item label="设备编码" required><a-input v-model:value="deviceCode" prefix="设备" placeholder="例如:CAM-LINE-01" /></a-form-item> |
| | | <a-form-item label="产线编码"><a-input v-model:value="lineCode" placeholder="例如:LINE-A" /></a-form-item> |
| | | <a-form-item label="WIP 暂存库区" required><a-input-number v-model:value="locationId" class="full-width" :min="1" placeholder="库区编号" /></a-form-item> |
| | | <a-form-item label="WIP 暂存库位" required><a-input-number v-model:value="areaId" class="full-width" :min="1" placeholder="库位编号" /></a-form-item> |
| | | <a-form-item label="生产产线"><a-select v-model:value="lineCode" :options="lineOptions" allow-clear show-search option-filter-prop="label" placeholder="请选择产线" /></a-form-item> |
| | | <a-form-item label="WIP 暂存库区" required><a-select v-model:value="locationId" :options="locationOptions" show-search option-filter-prop="label" placeholder="请选择库区" @change="loadAreas" /></a-form-item> |
| | | <a-form-item label="WIP 暂存库位" required><a-select v-model:value="areaId" :options="areaOptions" show-search option-filter-prop="label" :disabled="!locationId" placeholder="请选择库位" /></a-form-item> |
| | | </a-form> |
| | | </a-card> |
| | | |
| | | <a-card class="status-card" :bordered="false"> |
| | | <template #title><div class="card-title"><ApiOutlined />采集设备状态</div></template> |
| | | <div class="device-state"><span class="online-badge"><span class="status-dot" aria-hidden="true" /><span>{{ isReady ? '设备已就绪' : '等待配置' }}</span></span><span class="muted">15 秒自动刷新</span></div> |
| | | <div class="status-list"><div><span>当前设备</span><strong>{{ deviceCode || '未配置' }}</strong></div><div><span>生产产线</span><strong>{{ lineCode || '未配置' }}</strong></div><div><span>暂存位置</span><strong>{{ locationId && areaId ? `${locationId} 区 / ${areaId} 位` : '未配置' }}</strong></div><div><span>最近入库时间</span><strong>{{ latest?.eventTime || '-' }}</strong></div><div><span>最近生产批次</span><strong>{{ latest?.batchCode || '-' }}</strong></div></div> |
| | | <div class="device-state"><span class="online-badge"><span class="status-dot" aria-hidden="true" /><span>{{ isReady ? '位置已就绪' : '等待配置' }}</span></span><span class="muted">15 秒自动刷新</span></div> |
| | | <div class="status-list"><div><span>生产产线</span><strong>{{ lineCode || '未配置' }}</strong></div><div><span>暂存位置</span><strong>{{ locationId && areaId ? `${locationName || locationId} / ${areaName || areaId}` : '未配置' }}</strong></div><div><span>最近入库时间</span><strong>{{ latest?.eventTime || '-' }}</strong></div><div><span>最近生产批次</span><strong>{{ latest?.batchCode || '-' }}</strong></div><div><span>最近扫码类型</span><strong>{{ latest?.scanType ? SCAN_TYPE_LABEL[latest.scanType] : '-' }}</strong></div></div> |
| | | <div class="sync-box"><IconifyIcon icon="ant-design:check-circle-outlined" /><div><strong>仓储系统同步正常</strong><span>扫码事件与库存事务实时写入</span></div></div> |
| | | </a-card> |
| | | </section> |
| | |
| | | <a-card class="records-card" :bordered="false"> |
| | | <template #title><div class="card-title"><IconifyIcon icon="ant-design:clock-circle-outlined" />最近扫码事件 <span class="record-count">最近 20 条</span></div></template> |
| | | <a-table :data-source="records" :pagination="false" :scroll="{ x: 1050 }" row-key="key" size="middle"> |
| | | <a-table-column key="bagCode" title="袋码" data-index="bagCode" /><a-table-column key="batchCode" title="生产批次" data-index="batchCode" /><a-table-column key="deviceCode" title="设备 / 产线"><template #default="{ record }">{{ record.deviceCode || '-' }}<span class="sub-text">{{ record.lineCode || '-' }}</span></template></a-table-column> |
| | | <a-table-column key="bagCode" title="追溯码" data-index="bagCode" /><a-table-column key="scanType" title="类型"><template #default="{ record }">{{ record.scanType ? SCAN_TYPE_LABEL[record.scanType as MesProScanEventApi.ScanType] : '袋码' }}</template></a-table-column><a-table-column key="batchCode" title="生产批次" data-index="batchCode" /><a-table-column key="lineCode" title="产线"><template #default="{ record }">{{ record.lineCode || '-' }}</template></a-table-column> |
| | | <a-table-column key="status" title="状态"><template #default="{ record }"><a-tag :color="record.status === 'SUCCESS' ? 'success' : 'error'">{{ record.status === 'SUCCESS' ? '成功入库' : '失败' }}</a-tag></template></a-table-column><a-table-column key="errorCode" title="错误码" data-index="errorCode" /><a-table-column key="errorMessage" title="失败原因" data-index="errorMessage" /><a-table-column key="eventTime" title="处理时间" data-index="eventTime" /><a-table-column key="transactionId" title="库存事务" data-index="transactionId" /> |
| | | </a-table> |
| | | </a-card> |
| | |
| | | .header-status, .device-state { display: flex; align-items: center; gap: 10px; color: #16835b; font-size: 13px; font-weight: 600; }.header-status :deep(.ant-btn) { color: #607089; } |
| | | .status-dot { display: inline-block; flex: 0 0 8px; width: 8px; height: 8px; margin: 0 2px 0 1px; border-radius: 50%; background: #20b878; box-shadow: 0 0 0 4px #d9f7e9; transform-origin: center; } |
| | | .metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 18px; }.metric-card { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px; background: #fff; border: 1px solid #e8edf5; border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.metric-card > div:last-child { min-width: 0; padding-top: 1px; }.metric-icon { flex: 0 0 42px; display: grid; place-items: center; width: 42px; height: 42px; margin-top: 1px; border-radius: 9px; font-size: 20px; }.metric-icon.blue { color: #1677ff; background: #eaf3ff; }.metric-icon.green { color: #17a673; background: #e6f8f0; }.metric-icon.red { color: #e05252; background: #fff0f0; }.metric-icon.orange { color: #d58a17; background: #fff6df; }.metric-card span, .metric-card small { display: block; color: #7b8799; font-size: 12px; }.metric-card strong { display: block; margin: 4px 0; font-size: 25px; line-height: 1.1; }.metric-card .text-value { overflow: hidden; max-width: 180px; text-overflow: ellipsis; white-space: nowrap; font-size: 18px; } |
| | | .main-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .8fr); gap: 18px; margin-bottom: 18px; }.scan-card, .status-card, .records-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 10px; min-height: 24px; font-weight: 700; }.card-title :deep(.iconify) { flex: 0 0 auto; margin-top: 1px; }.card-title :deep(.anticon) { color: #1677ff; }.card-title .ant-tag { margin-left: 4px; font-weight: 500; }.scan-panel { padding: 8px 0 4px; }.scan-intro { display: flex; align-items: center; gap: 16px; margin: 4px 0 22px; }.scan-ring { display: grid; place-items: center; width: 58px; height: 58px; margin: 4px 2px 4px 4px; border: 1px solid #b9d5ff; border-radius: 50%; color: #1677ff; background: #f1f7ff; font-size: 25px; }.scan-intro h2 { margin: 0 0 7px; font-size: 19px; line-height: 1.35; }.scan-intro p { margin: 0; color: #7b8799; font-size: 13px; }.scan-input :deep(input) { height: 50px; font-size: 16px; }.scan-input :deep(.ant-input-group-addon .ant-btn) { height: 50px; font-weight: 600; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 15px; padding-left: 2px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint span { margin: 0 2px; color: #b7c0cd; }.config-form { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: 18px; }.config-form :deep(.ant-form-item) { margin-bottom: 14px; }.full-width { width: 100%; } |
| | | .main-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .8fr); gap: 18px; margin-bottom: 18px; }.scan-card, .status-card, .records-card { border-radius: 10px; box-shadow: 0 3px 12px #243b5310; }.card-title { display: flex; align-items: center; gap: 10px; min-height: 24px; font-weight: 700; }.card-title :deep(.iconify) { flex: 0 0 auto; margin-top: 1px; }.card-title :deep(.anticon) { color: #1677ff; }.card-title .ant-tag { margin-left: 4px; font-weight: 500; }.scan-panel { padding: 8px 0 4px; }.scan-intro { display: flex; align-items: center; gap: 16px; margin: 4px 0 22px; }.scan-ring { display: grid; place-items: center; width: 58px; height: 58px; margin: 4px 2px 4px 4px; border: 1px solid #b9d5ff; border-radius: 50%; color: #1677ff; background: #f1f7ff; font-size: 25px; }.scan-intro h2 { margin: 0 0 7px; font-size: 19px; line-height: 1.35; }.scan-intro p { margin: 0; color: #7b8799; font-size: 13px; }.scan-row { display: flex; align-items: stretch; gap: 10px; }.scan-type { flex: 0 0 150px; }.scan-type :deep(.ant-select-selector) { height: 50px !important; }.scan-type :deep(.ant-select-selection-item) { line-height: 48px !important; }.scan-input { flex: 1 1 auto; min-width: 0; }.scan-input :deep(input) { height: 50px; font-size: 16px; }.scan-input :deep(.ant-input-group-addon .ant-btn) { height: 50px; font-weight: 600; }.scan-hint { display: flex; align-items: center; gap: 8px; margin-top: 15px; padding-left: 2px; color: #8793a5; font-size: 12px; line-height: 1.5; }.scan-hint span { margin: 0 2px; color: #b7c0cd; }.config-form { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: 18px; }.config-form :deep(.ant-form-item) { margin-bottom: 14px; }.full-width { width: 100%; } |
| | | .device-state { justify-content: space-between; margin-bottom: 20px; }.online-badge { display: inline-flex; align-items: center; gap: 10px; min-height: 24px; color: #16835b; white-space: nowrap; }.muted, .sub-text { color: #8793a5; font-size: 12px; }.status-list { border-top: 1px solid #edf0f5; }.status-list div { display: flex; justify-content: space-between; gap: 12px; padding: 13px 0; border-bottom: 1px solid #edf0f5; font-size: 13px; }.status-list span { color: #7b8799; }.status-list strong { max-width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }.sync-box { display: flex; gap: 10px; margin-top: 20px; padding: 12px; border-radius: 7px; color: #16835b; background: #effaf5; }.sync-box div { display: flex; flex-direction: column; gap: 3px; }.sync-box span { color: #5f8b76; font-size: 12px; }.record-count { margin-left: 4px; color: #9aa5b5; font-size: 12px; font-weight: 400; }.sub-text { display: block; margin-top: 3px; } |
| | | @media (max-width: 900px) { .metric-grid { grid-template-columns: repeat(2, 1fr); }.main-grid { grid-template-columns: 1fr; } } |
| | | @media (max-width: 600px) { .scan-page { padding: 14px; }.page-header { display: block; }.header-status { margin-top: 14px; }.metric-grid { grid-template-columns: 1fr; }.config-form { grid-template-columns: 1fr; } h1 { font-size: 23px; } } |
| | | @media (max-width: 600px) { .scan-page { padding: 14px; }.page-header { display: block; }.header-status { margin-top: 14px; }.metric-grid { grid-template-columns: 1fr; }.config-form { grid-template-columns: 1fr; }.scan-row { flex-wrap: wrap; }.scan-type { flex-basis: 100%; } h1 { font-size: 23px; } } |
| | | </style> |