9 changed files with 987 additions and 42 deletions
-
4src/api/haianWarehouse/autoJ1SortingBoard.js
-
4src/api/haianWarehouse/autoJ2SortingBoard.js
-
4src/api/haianWarehouse/manualSortingBoard.js
-
4src/api/haianWarehouse/sortingBoardConfig.js
-
6src/router/index.js
-
119src/utils/haianBoardSocket.js
-
353src/views/modules/haianWarehouse/dashboard/autoJ1SortingBoard54.vue
-
353src/views/modules/haianWarehouse/dashboard/autoJ2SortingBoard54.vue
-
182src/views/modules/haianWarehouse/dashboard/sortingBoard54.vue
@ -0,0 +1,4 @@ |
|||
import { createAPI } from '@/utils/httpRequest.js' |
|||
|
|||
// 自动J1H独立看板查询,显式传site及sortingStation;失败交由对应页面展示 - rqrq
|
|||
export const getAutoJ1SortingBoard = data => createAPI('/api/dashboard/haian/sorting/getAutoJ1SortingBoard', 'post', data) |
|||
@ -0,0 +1,4 @@ |
|||
import { createAPI } from '@/utils/httpRequest.js' |
|||
|
|||
// 自动J2H独立看板查询,显式传site及sortingStation;失败交由对应页面展示 - rqrq
|
|||
export const getAutoJ2SortingBoard = data => createAPI('/api/dashboard/haian/sorting/getAutoJ2SortingBoard', 'post', data) |
|||
@ -0,0 +1,4 @@ |
|||
import { createAPI } from '@/utils/httpRequest.js' |
|||
|
|||
// 人工R1H-R4H共用看板查询,显式传site及sortingStation;失败交由对应页面展示 - rqrq
|
|||
export const getManualSortingBoard = data => createAPI('/api/dashboard/haian/sorting/getManualSortingBoard', 'post', data) |
|||
@ -0,0 +1,4 @@ |
|||
import { createAPI } from '@/utils/httpRequest.js' |
|||
|
|||
// 海安看板启动前读取独立开关,显式传site;本接口不触发WCS/WMS业务查询 - rqrq
|
|||
export const getSortingBoardConfig = data => createAPI('/api/dashboard/haian/sorting/getSortingBoardConfig', 'post', data) |
|||
@ -0,0 +1,119 @@ |
|||
import SockJS from 'sockjs-client' |
|||
import Stomp from 'stompjs' |
|||
|
|||
// 海安专用传输实例:仅管理连接/心跳/订阅,不包含看板字段与业务;每页独立实例防止互相断连 - rqrq
|
|||
export default class HaianBoardSocket { |
|||
constructor (url, topic, onMessage, onState) { |
|||
this.url = url |
|||
this.topic = topic |
|||
this.onMessage = onMessage |
|||
this.onState = onState |
|||
this.client = null |
|||
this.socket = null |
|||
this.retryTimer = null |
|||
this.connectTimer = null |
|||
this.healthTimer = null |
|||
this.attempts = 0 |
|||
this.generation = 0 |
|||
this.stopped = true |
|||
} |
|||
|
|||
// 主动开启仅执行一次;页面必须先取得enabled=true,工具本身不查询开关或数据库 - rqrq
|
|||
start () { |
|||
if (!this.stopped) return |
|||
this.stopped = false |
|||
this.connect() |
|||
} |
|||
|
|||
// 使用旧看板同一SockJS/STOMP端点,每次连接成功重新订阅;代次校验隔离旧连接迟到回调 - rqrq
|
|||
connect () { |
|||
if (this.stopped) return |
|||
this.release() |
|||
const generation = this.generation |
|||
const current = () => !this.stopped && generation === this.generation |
|||
this.onState('connecting') |
|||
try { |
|||
const socket = new SockJS(this.url) |
|||
this.socket = socket |
|||
const client = Stomp.over(socket) |
|||
this.client = client |
|||
client.debug = null |
|||
client.heartbeat.outgoing = 10000 |
|||
client.heartbeat.incoming = 10000 |
|||
// 建连超时防止网络黑洞永久停在connecting;STOMP握手成功才显示绿灯 - rqrq
|
|||
this.connectTimer = setTimeout(() => { if (current()) this.fail() }, 20000) |
|||
client.connect({}, () => { |
|||
if (!current()) return |
|||
clearTimeout(this.connectTimer) |
|||
this.connectTimer = null |
|||
try { |
|||
client.subscribe(this.topic, message => { |
|||
if (!current()) return |
|||
try { |
|||
this.onMessage(JSON.parse(message.body)) |
|||
} catch (error) { |
|||
// 非法消息触发断连重订阅,避免绿灯掩盖持续无法消费的数据 - rqrq
|
|||
this.fail() |
|||
} |
|||
}) |
|||
this.attempts = 0 |
|||
this.onState('connected') |
|||
// STOMP负责心跳超时关闭;额外检测浏览器离线和SockJS状态,最长5秒反映显式断网 - rqrq
|
|||
this.healthTimer = setInterval(() => { |
|||
if (current() && (window.navigator.onLine === false || socket.readyState !== 1)) this.fail() |
|||
}, 5000) |
|||
} catch (error) { |
|||
if (current()) this.fail() |
|||
} |
|||
}, () => { if (current()) this.fail() }) |
|||
} catch (error) { |
|||
if (current()) this.fail() |
|||
} |
|||
} |
|||
|
|||
// 断线立即红灯,10/20/30…60秒无限重试;同一代异常只安排一次,恢复后重新从10秒计时 - rqrq
|
|||
fail () { |
|||
if (this.stopped || this.retryTimer) return |
|||
this.release() |
|||
this.onState('disconnected') |
|||
const delay = Math.min(++this.attempts * 10000, 60000) |
|||
this.retryTimer = setTimeout(() => { |
|||
this.retryTimer = null |
|||
this.connect() |
|||
}, delay) |
|||
} |
|||
|
|||
// 完整释放STOMP自身心跳及页面健康检测,关闭旧连接前先失效回调,防止关闭又触发重连 - rqrq
|
|||
release () { |
|||
this.generation++ |
|||
clearTimeout(this.connectTimer) |
|||
clearInterval(this.healthTimer) |
|||
this.connectTimer = null |
|||
this.healthTimer = null |
|||
const client = this.client |
|||
const socket = this.socket |
|||
this.client = null |
|||
this.socket = null |
|||
try { |
|||
if (client) client.disconnect(() => {}) |
|||
} catch (error) { |
|||
// 连接尚未打开时DISCONNECT可能失败,仍清理SockJS;该失败不恢复业务连接 - rqrq
|
|||
} finally { |
|||
// stompjs 2.3.3在发送DISCONNECT抛错时不会进入自身清理,补充释放内部心跳定时器 - rqrq
|
|||
if (client) client._cleanUp() |
|||
} |
|||
if (socket) { |
|||
socket.onclose = null |
|||
socket.onerror = null |
|||
try { socket.close() } catch (error) { /* 已关闭连接无需重复处理 - rqrq */ } |
|||
} |
|||
} |
|||
|
|||
// 页面销毁、切换工厂或配置关闭时取消全部重试,手动停用后绝不自动重连 - rqrq
|
|||
stop () { |
|||
this.stopped = true |
|||
clearTimeout(this.retryTimer) |
|||
this.retryTimer = null |
|||
this.release() |
|||
} |
|||
} |
|||
@ -0,0 +1,353 @@ |
|||
<template> |
|||
<div class="haian-sorting-board"> |
|||
<!-- 海安独立大屏:固定分拣位,工单来自标签预留,完成状态来自WMS;不展示原/目标托盘 - rqrq --> |
|||
<header class="board-header"> |
|||
<img src="~@/assets/img/cclbai.png" alt="CCL" class="board-logo"> |
|||
<div class="board-title"> |
|||
<h1>海安 · {{ boardTitle }} |
|||
<!-- 连接灯与业务状态分开:绿灯已连接,红灯断线闪烁,黄灯建连中,灰灯停用 - rqrq --> |
|||
<span :class="['ws-status-dot', wsState]" :title="connectionText" role="status" :aria-label="connectionText"></span> |
|||
</h1> |
|||
<span>分拣位 {{ sortingStation }} · 工厂 {{ site || '未设置' }}</span> |
|||
</div> |
|||
<time>{{ currentTime }}</time> |
|||
</header> |
|||
|
|||
<section class="board-summary"> |
|||
<div class="summary-counts"> |
|||
<span>标签 <strong>{{ visibleRows.length }}</strong></span> |
|||
<span>未完成 <strong class="pending-text">{{ pendingCount }}</strong></span> |
|||
<span>已完成 <strong class="completed-text">{{ completedCount }}</strong></span> |
|||
</div> |
|||
<div class="summary-refresh"> |
|||
<span class="connection-text">{{ connectionText }}</span> |
|||
<span role="status">{{ displayMessage }}</span> |
|||
<el-button size="small" :loading="queryLoading" :disabled="queryLoading || !siteReady || !pushEnabled" @click="fetchData(true)">刷新</el-button> |
|||
</div> |
|||
</section> |
|||
|
|||
<!-- 固定高度容器配合粘性表头;数据返回后按实际溢出滚动,鼠标进入暂停便于核对 - rqrq --> |
|||
<div ref="tableViewport" class="table-viewport" @mouseenter="scrollPaused = true" @mouseleave="scrollPaused = false"> |
|||
<table class="board-table"> |
|||
<colgroup> |
|||
<col style="width: 4%"> |
|||
<col style="width: 13%"> |
|||
<col style="width: 12%"> |
|||
<col style="width: 12%"> |
|||
<col style="width: 17%"> |
|||
<col style="width: 19%"> |
|||
<col style="width: 8%"> |
|||
<col style="width: 15%"> |
|||
</colgroup> |
|||
<thead> |
|||
<tr><th>序号</th><th>工单号</th><th>产品编码</th><th>物料编码</th><th>物料名称</th><th>RFID / 标签号</th><th>状态</th><th>说明</th></tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-for="(item, index) in visibleRows" :key="item.rfidBarcode"> |
|||
<td>{{ index + 1 }}</td> |
|||
<td :title="item.orderNo">{{ item.orderNo || '-' }}</td> |
|||
<td :title="item.orderPartNo">{{ item.orderPartNo || '-' }}</td> |
|||
<td :title="item.partNo">{{ item.partNo || '-' }}</td> |
|||
<td class="description" :title="item.partDesc">{{ item.partDesc || '-' }}</td> |
|||
<td class="barcode" :title="item.rfidBarcode">{{ item.rfidBarcode }}</td> |
|||
<td><span :class="['status-badge', item.status === '已完成' ? 'completed' : 'pending']">{{ item.status }}</span></td> |
|||
<td class="row-message" :title="item.message">{{ item.message || '-' }}</td> |
|||
</tr> |
|||
<tr v-if="visibleRows.length === 0"> |
|||
<td colspan="8" class="empty-message" :class="{ 'is-error': !available || stale }">{{ displayMessage }}</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<footer class="board-footer"> |
|||
<span>{{ pushEnabled ? 'WebSocket 实时推送 · 每轮间隔 5 秒' : '海安看板未启用' }}</span> |
|||
<span>数据更新时间:{{ updatedTime }}</span> |
|||
</footer> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import dayjs from 'dayjs' |
|||
import HaianBoardSocket from '@/utils/haianBoardSocket' |
|||
import { getSortingBoardConfig } from '@/api/haianWarehouse/sortingBoardConfig' |
|||
import { getAutoJ1SortingBoard } from '@/api/haianWarehouse/autoJ1SortingBoard' |
|||
|
|||
// 自动J1H独立页面,布局、字段和请求逻辑在本文件维护;不包装人工或另一自动看板 - rqrq |
|||
export default { |
|||
name: 'HaianAutoJ1SortingBoard54', |
|||
data () { |
|||
return { |
|||
// 本页面固定J1H,不接收其他位置参数,防止自动看板串屏 - rqrq |
|||
sortingStation: 'J1H', |
|||
boardTitle: '自动分拣 1', |
|||
site: '', |
|||
siteReady: false, |
|||
rows: [], |
|||
available: false, |
|||
message: '正在获取分拣数据…', |
|||
updatedAt: 0, |
|||
lastReceivedAt: 0, |
|||
now: Date.now(), |
|||
queryLoading: false, |
|||
configLoading: false, |
|||
pushEnabled: false, |
|||
wsState: 'disabled', |
|||
dataVersion: 0, |
|||
refreshTimer: null, |
|||
clockTimer: null, |
|||
scrollTimer: null, |
|||
scrollPaused: false, |
|||
scrollResumeAt: 0, |
|||
requestVersion: 0 |
|||
} |
|||
}, |
|||
computed: { |
|||
// 时钟与数据刷新独立,超过20秒未收到有效响应即标记过期,避免断网仍显示正常任务 - rqrq |
|||
currentTime () { return dayjs(this.now).format('YYYY-MM-DD HH:mm:ss') }, |
|||
updatedTime () { return this.updatedAt ? dayjs(this.updatedAt).format('YYYY-MM-DD HH:mm:ss') : '-' }, |
|||
stale () { return this.lastReceivedAt > 0 && this.now - this.lastReceivedAt > 20000 }, |
|||
visibleRows () { return this.available && !this.stale ? this.rows : [] }, |
|||
pendingCount () { return this.visibleRows.filter(item => item.status === '未完成').length }, |
|||
completedCount () { return this.visibleRows.filter(item => item.status === '已完成').length }, |
|||
// 绿灯只代表STOMP连接正常,WCS/WMS查询异常通过旁边业务提示显示,不混淆连接与任务状态 - rqrq |
|||
connectionText () { return { connected: '连接正常', disconnected: '连接断开,等待重连', connecting: '正在连接', disabled: '推送已停用' }[this.wsState] }, |
|||
displayMessage () { return this.stale ? '数据更新超时,正在重试…' : this.message } |
|||
}, |
|||
watch: { |
|||
// 地址参数变化时清空旧数据并递增请求版本,迟到响应不能覆盖新请求 - rqrq |
|||
'$route.fullPath' () { this.initializeBoard() }, |
|||
// 工厂变化立即断开旧订阅,禁止继续消费旧工厂数据 - rqrq |
|||
'$store.state.user.site' () { this.initializeBoard() } |
|||
}, |
|||
mounted () { |
|||
// 先初始化显式site再查询,初始化失败仅展示错误,不使用默认工厂发请求 - rqrq |
|||
this.initializeBoard() |
|||
this.clockTimer = setInterval(() => { this.now = Date.now() }, 1000) |
|||
// 每30秒只复查配置,关闭时不查业务数据;配置重启生效后页面自动建立或关闭连接 - rqrq |
|||
this.refreshTimer = setInterval(() => { this.checkBoardConfig() }, 30000) |
|||
this.scrollTimer = setInterval(this.scrollTable, 60) |
|||
}, |
|||
beforeDestroy () { |
|||
// 页面离开时废弃在途响应并清理全部定时器,防止卸载后持续请求和修改界面 - rqrq |
|||
this.requestVersion++ |
|||
this.disconnectWebSocket() |
|||
clearInterval(this.refreshTimer) |
|||
clearInterval(this.clockTimer) |
|||
clearInterval(this.scrollTimer) |
|||
}, |
|||
methods: { |
|||
// 固定屏支持URL显式site=54;登录态已有其他工厂则拒绝覆盖,防止看板改坏当前会话 - rqrq |
|||
initializeBoard () { |
|||
this.requestVersion++ |
|||
this.disconnectWebSocket() |
|||
this.pushEnabled = false |
|||
this.wsState = 'disabled' |
|||
this.configLoading = false |
|||
this.dataVersion = 0 |
|||
this.queryLoading = false |
|||
this.rows = [] |
|||
this.available = false |
|||
this.updatedAt = 0 |
|||
this.lastReceivedAt = 0 |
|||
this.siteReady = false |
|||
this.site = '' |
|||
const storeSite = String(this.$store.state.user.site || '').trim() |
|||
const querySite = this.$route.query.site |
|||
if (querySite !== undefined && (typeof querySite !== 'string' || querySite.trim() !== '54')) { |
|||
this.message = '海安看板的工厂参数必须为 site=54' |
|||
return |
|||
} |
|||
const site = querySite === undefined ? storeSite : querySite.trim() |
|||
if (site !== '54' || (storeSite && storeSite !== '54')) { |
|||
this.message = '请使用海安工厂打开看板;独立屏地址需带 ?site=54' |
|||
return |
|||
} |
|||
this.$store.commit('user/updateSite', site) |
|||
this.site = site |
|||
this.siteReady = true |
|||
this.message = '正在获取分拣数据…' |
|||
this.$nextTick(() => { |
|||
if (this.$refs.tableViewport) this.$refs.tableViewport.scrollTop = 0 |
|||
this.checkBoardConfig() |
|||
}) |
|||
}, |
|||
// 只探测配置,不查询业务库;后端严格true才允许连接,配置失败清空旧屏并等待下轮重试 - rqrq |
|||
async checkBoardConfig () { |
|||
if (!this.siteReady || this.configLoading) return |
|||
const version = this.requestVersion |
|||
this.configLoading = true |
|||
try { |
|||
const { data } = await getSortingBoardConfig({ site: this.$store.state.user.site }) |
|||
if (version !== this.requestVersion) return |
|||
if (!data || data.code !== 0 || !data.row || data.row.site !== this.site) throw new Error('配置查询失败') |
|||
this.pushEnabled = data.row.enabled === true |
|||
if (!this.pushEnabled) { |
|||
this.disconnectWebSocket() |
|||
this.wsState = 'disabled' |
|||
this.available = false |
|||
this.rows = [] |
|||
this.updatedAt = 0 |
|||
this.lastReceivedAt = 0 |
|||
this.message = '海安看板已停用' |
|||
return |
|||
} |
|||
if (!this._boardSocket) this.initWebSocket() |
|||
} catch (error) { |
|||
if (version !== this.requestVersion) return |
|||
this.pushEnabled = false |
|||
this.disconnectWebSocket() |
|||
this.wsState = 'disconnected' |
|||
this.available = false |
|||
this.rows = [] |
|||
this.lastReceivedAt = 0 |
|||
this.message = '看板配置连接异常,正在重试…' |
|||
} finally { |
|||
if (version === this.requestVersion) this.configLoading = false |
|||
} |
|||
}, |
|||
// 每屏独立订阅显式工厂与位置,重连由纯传输工具处理;自动两屏业务消费逻辑各自在本文件维护 - rqrq |
|||
initWebSocket () { |
|||
if (!this.siteReady || !this.pushEnabled || this._boardSocket) return |
|||
const version = this.requestVersion |
|||
const apiServer = process.env.NODE_ENV !== 'production' && process.env.OPEN_PROXY ? '/proxyApi/' : window.SITE_CONFIG.baseUrl |
|||
const url = apiServer.replace(/\/+$/, '') + '/ws/dashboard' |
|||
const topic = '/topic/dashboard/haian/sorting/' + this.site + '/' + this.sortingStation |
|||
this._boardSocket = new HaianBoardSocket(url, topic, data => { |
|||
if (version !== this.requestVersion || !this.pushEnabled) return |
|||
this.dataVersion++ |
|||
this.applyBoard(data) |
|||
}, state => { |
|||
if (version !== this.requestVersion || !this.pushEnabled) return |
|||
// 连接代次变化使之前发出的HTTP失效,避免断线后迟到响应重新展示旧屏 - rqrq |
|||
this.dataVersion++ |
|||
this.wsState = state |
|||
if (state === 'connected') { |
|||
this.lastReceivedAt = Date.now() |
|||
this.message = '连接正常,等待分拣数据…' |
|||
} else { |
|||
this.available = false |
|||
this.rows = [] |
|||
this.updatedAt = 0 |
|||
this.lastReceivedAt = 0 |
|||
this.message = state === 'connecting' ? '正在连接看板服务…' : '连接已断开,正在自动重连…' |
|||
} |
|||
}) |
|||
this._boardSocket.start() |
|||
}, |
|||
// 停用和卸载彻底清理连接及重试;先清空句柄,旧回调受requestVersion保护 - rqrq |
|||
disconnectWebSocket () { |
|||
this.dataVersion++ |
|||
const socket = this._boardSocket |
|||
this._boardSocket = null |
|||
if (socket) socket.stop() |
|||
}, |
|||
// HTTP手动刷新与推送按相同结构消费,完整核对site/位置;异常与空任务分开显示 - rqrq |
|||
applyBoard (data) { |
|||
if (!this.pushEnabled) return |
|||
const site = String(this.$store.state.user.site || '').trim() |
|||
if (!data || data.code !== 0 || !data.row || site !== this.site || data.row.site !== site || data.row.sortingStation !== this.sortingStation) { |
|||
this.available = false |
|||
this.rows = [] |
|||
this.message = '看板数据校验失败,请检查工厂和分拣位' |
|||
throw new Error(this.message) |
|||
} |
|||
const board = data.row |
|||
this.lastReceivedAt = Date.now() |
|||
this.available = board.available === true |
|||
this.rows = this.available && Array.isArray(board.rows) ? board.rows : [] |
|||
this.updatedAt = Number(board.updatedAt) || 0 |
|||
this.message = board.message || (this.rows.length ? '数据正常' : '当前分拣位暂无任务') |
|||
}, |
|||
// 仅手动刷新使用独立HTTP接口,后端也检查开关;在途HTTP不能覆盖期间收到的新推送,finally恢复按钮 - rqrq |
|||
async fetchData (manual = false) { |
|||
if (!this.siteReady || !this.pushEnabled || this.queryLoading) return |
|||
const version = this.requestVersion |
|||
const dataVersion = this.dataVersion |
|||
const station = this.sortingStation |
|||
const site = String(this.$store.state.user.site || '').trim() |
|||
if (site !== this.site) { |
|||
this.initializeBoard() |
|||
return |
|||
} |
|||
this.queryLoading = true |
|||
try { |
|||
const { data } = await getAutoJ1SortingBoard({ site: this.$store.state.user.site, sortingStation: station }) |
|||
if (version !== this.requestVersion || !this.pushEnabled || dataVersion !== this.dataVersion) return |
|||
if (!data || data.code !== 0 || !data.row) { |
|||
this.rows = [] |
|||
this.available = false |
|||
this.message = (data && data.msg) || '看板查询失败' |
|||
if (manual) this.$alert(this.message, '查询失败', { confirmButtonText: '确定' }).catch(() => {}) |
|||
return |
|||
} |
|||
this.applyBoard(data) |
|||
if (manual && !this.available) this.$alert(this.message, '数据不可用', { confirmButtonText: '确定' }).catch(() => {}) |
|||
} catch (error) { |
|||
if (version !== this.requestVersion || !this.pushEnabled || dataVersion !== this.dataVersion) return |
|||
this.rows = [] |
|||
this.available = false |
|||
this.message = '手动刷新失败,等待推送恢复…' |
|||
if (manual) this.$message.error('查询海安分拣看板失败,请检查网络或服务') |
|||
} finally { |
|||
if (version === this.requestVersion) this.queryLoading = false |
|||
} |
|||
}, |
|||
// 无论首次数据何时到达都按实际滚动高度启动;底部停留2秒后回到顶部,少量数据不滚动 - rqrq |
|||
scrollTable () { |
|||
const viewport = this.$refs.tableViewport |
|||
if (!viewport || this.scrollPaused || !this.visibleRows.length || Date.now() < this.scrollResumeAt) return |
|||
if (viewport.scrollHeight <= viewport.clientHeight) return |
|||
if (viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 1) { |
|||
viewport.scrollTop = 0 |
|||
this.scrollResumeAt = Date.now() + 2000 |
|||
} else { |
|||
viewport.scrollTop += 1 |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
/* 沿用老分拣看板蓝色渐变、CCL白色Logo、青绿色表头和连接灯;海安布局独立维护,长文本可换行 - rqrq */ |
|||
.haian-sorting-board { height: 100vh; width: 100%; box-sizing: border-box; padding: 18px 24px 12px; background: linear-gradient(135deg, #5f8cc3 0%, #749cc8 100%); color: #f4f7fb; font-family: 'Microsoft YaHei', sans-serif; display: flex; flex-direction: column; overflow: hidden; } |
|||
.board-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 82px; border-bottom: 2px solid rgba(23, 179, 163, 0.4); background: linear-gradient(180deg, rgba(23, 179, 163, 0.08), transparent); } |
|||
.board-logo { width: auto; height: 40px; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3)); } |
|||
.board-title { flex: 1; text-align: center; } |
|||
.board-title h1 { margin: 0 0 8px; font-size: 32px; letter-spacing: 3px; text-shadow: 0 0 20px rgba(23, 179, 163, 0.5); } |
|||
.board-title span { color: #d2e1ee; font-size: 16px; } |
|||
.board-header time { font-size: 18px; font-variant-numeric: tabular-nums; } |
|||
.board-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 18px 0; } |
|||
.summary-counts, .summary-refresh { display: flex; align-items: center; gap: 20px; } |
|||
.summary-counts strong { font-size: 26px; margin-left: 8px; } |
|||
.pending-text { color: #ffd17d; } |
|||
.completed-text { color: #80e5bc; } |
|||
.ws-status-dot { display: inline-block; width: 12px; height: 12px; margin-left: 12px; border-radius: 50%; vertical-align: middle; background: #94a3b8; } |
|||
.ws-status-dot.connected { background: #22c55e; box-shadow: 0 0 10px rgba(34, 197, 94, 0.8); } |
|||
.ws-status-dot.disconnected { background: #ef4444; box-shadow: 0 0 12px rgba(239, 68, 68, 0.85); animation: ws-status-blink 1s steps(1, end) infinite; } |
|||
.ws-status-dot.connecting { background: #f59e0b; box-shadow: 0 0 10px rgba(245, 158, 11, 0.7); } |
|||
@keyframes ws-status-blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0.2; } } |
|||
.summary-refresh { font-size: 14px; gap: 10px; } |
|||
.table-viewport { flex: 1; min-height: 0; overflow-y: auto; background: rgba(70, 90, 120, 0.9); border: 1px solid #637e9e; border-radius: 6px; } |
|||
.board-table { width: 100%; table-layout: fixed; border-spacing: 0; } |
|||
.board-table th { position: sticky; top: 0; z-index: 1; background: #1b6676; color: #fff; font-size: 18px; padding: 16px 8px; } |
|||
.board-table td { padding: 18px 8px; font-size: 18px; text-align: center; border-bottom: 1px solid #526b87; overflow-wrap: break-word; word-wrap: break-word; } |
|||
.board-table tbody tr:nth-child(even) { background: #344b66; } |
|||
.board-table .description { text-align: left; } |
|||
.board-table .barcode { font-family: Consolas, 'Microsoft YaHei', monospace; } |
|||
.board-table .row-message { color: #ffd17d; font-size: 14px; } |
|||
.status-badge { display: inline-block; padding: 5px 10px; border-radius: 4px; white-space: nowrap; font-size: 16px; } |
|||
.status-badge.pending { background: #745827; color: #fff0cf; } |
|||
.status-badge.completed { background: #21684e; color: #d4ffeb; } |
|||
.board-table .empty-message { height: 220px; font-size: 24px; color: #d2e1ee; } |
|||
.board-table .empty-message.is-error { color: #ffd17d; } |
|||
.board-footer { display: flex; justify-content: space-between; padding-top: 12px; color: #d2e1ee; font-size: 14px; } |
|||
@media screen and (max-width: 1280px) { |
|||
.haian-sorting-board { padding: 12px; } |
|||
.board-title h1 { font-size: 26px; } |
|||
.board-header time { font-size: 14px; } |
|||
.board-table th, .board-table td { font-size: 14px; padding: 12px 5px; } |
|||
.status-badge { padding: 4px; font-size: 13px; } |
|||
} |
|||
</style> |
|||
@ -0,0 +1,353 @@ |
|||
<template> |
|||
<div class="haian-sorting-board"> |
|||
<!-- 海安独立大屏:固定分拣位,工单来自标签预留,完成状态来自WMS;不展示原/目标托盘 - rqrq --> |
|||
<header class="board-header"> |
|||
<img src="~@/assets/img/cclbai.png" alt="CCL" class="board-logo"> |
|||
<div class="board-title"> |
|||
<h1>海安 · {{ boardTitle }} |
|||
<!-- 连接灯与业务状态分开:绿灯已连接,红灯断线闪烁,黄灯建连中,灰灯停用 - rqrq --> |
|||
<span :class="['ws-status-dot', wsState]" :title="connectionText" role="status" :aria-label="connectionText"></span> |
|||
</h1> |
|||
<span>分拣位 {{ sortingStation }} · 工厂 {{ site || '未设置' }}</span> |
|||
</div> |
|||
<time>{{ currentTime }}</time> |
|||
</header> |
|||
|
|||
<section class="board-summary"> |
|||
<div class="summary-counts"> |
|||
<span>标签 <strong>{{ visibleRows.length }}</strong></span> |
|||
<span>未完成 <strong class="pending-text">{{ pendingCount }}</strong></span> |
|||
<span>已完成 <strong class="completed-text">{{ completedCount }}</strong></span> |
|||
</div> |
|||
<div class="summary-refresh"> |
|||
<span class="connection-text">{{ connectionText }}</span> |
|||
<span role="status">{{ displayMessage }}</span> |
|||
<el-button size="small" :loading="queryLoading" :disabled="queryLoading || !siteReady || !pushEnabled" @click="fetchData(true)">刷新</el-button> |
|||
</div> |
|||
</section> |
|||
|
|||
<!-- 固定高度容器配合粘性表头;数据返回后按实际溢出滚动,鼠标进入暂停便于核对 - rqrq --> |
|||
<div ref="tableViewport" class="table-viewport" @mouseenter="scrollPaused = true" @mouseleave="scrollPaused = false"> |
|||
<table class="board-table"> |
|||
<colgroup> |
|||
<col style="width: 4%"> |
|||
<col style="width: 13%"> |
|||
<col style="width: 12%"> |
|||
<col style="width: 12%"> |
|||
<col style="width: 17%"> |
|||
<col style="width: 19%"> |
|||
<col style="width: 8%"> |
|||
<col style="width: 15%"> |
|||
</colgroup> |
|||
<thead> |
|||
<tr><th>序号</th><th>工单号</th><th>产品编码</th><th>物料编码</th><th>物料名称</th><th>RFID / 标签号</th><th>状态</th><th>说明</th></tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-for="(item, index) in visibleRows" :key="item.rfidBarcode"> |
|||
<td>{{ index + 1 }}</td> |
|||
<td :title="item.orderNo">{{ item.orderNo || '-' }}</td> |
|||
<td :title="item.orderPartNo">{{ item.orderPartNo || '-' }}</td> |
|||
<td :title="item.partNo">{{ item.partNo || '-' }}</td> |
|||
<td class="description" :title="item.partDesc">{{ item.partDesc || '-' }}</td> |
|||
<td class="barcode" :title="item.rfidBarcode">{{ item.rfidBarcode }}</td> |
|||
<td><span :class="['status-badge', item.status === '已完成' ? 'completed' : 'pending']">{{ item.status }}</span></td> |
|||
<td class="row-message" :title="item.message">{{ item.message || '-' }}</td> |
|||
</tr> |
|||
<tr v-if="visibleRows.length === 0"> |
|||
<td colspan="8" class="empty-message" :class="{ 'is-error': !available || stale }">{{ displayMessage }}</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<footer class="board-footer"> |
|||
<span>{{ pushEnabled ? 'WebSocket 实时推送 · 每轮间隔 5 秒' : '海安看板未启用' }}</span> |
|||
<span>数据更新时间:{{ updatedTime }}</span> |
|||
</footer> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import dayjs from 'dayjs' |
|||
import HaianBoardSocket from '@/utils/haianBoardSocket' |
|||
import { getSortingBoardConfig } from '@/api/haianWarehouse/sortingBoardConfig' |
|||
import { getAutoJ2SortingBoard } from '@/api/haianWarehouse/autoJ2SortingBoard' |
|||
|
|||
// 自动J2H独立页面,布局、字段和请求逻辑在本文件维护;不包装人工或另一自动看板 - rqrq |
|||
export default { |
|||
name: 'HaianAutoJ2SortingBoard54', |
|||
data () { |
|||
return { |
|||
// 本页面固定J2H,不接收其他位置参数,防止自动看板串屏 - rqrq |
|||
sortingStation: 'J2H', |
|||
boardTitle: '自动分拣 2', |
|||
site: '', |
|||
siteReady: false, |
|||
rows: [], |
|||
available: false, |
|||
message: '正在获取分拣数据…', |
|||
updatedAt: 0, |
|||
lastReceivedAt: 0, |
|||
now: Date.now(), |
|||
queryLoading: false, |
|||
configLoading: false, |
|||
pushEnabled: false, |
|||
wsState: 'disabled', |
|||
dataVersion: 0, |
|||
refreshTimer: null, |
|||
clockTimer: null, |
|||
scrollTimer: null, |
|||
scrollPaused: false, |
|||
scrollResumeAt: 0, |
|||
requestVersion: 0 |
|||
} |
|||
}, |
|||
computed: { |
|||
// 时钟与数据刷新独立,超过20秒未收到有效响应即标记过期,避免断网仍显示正常任务 - rqrq |
|||
currentTime () { return dayjs(this.now).format('YYYY-MM-DD HH:mm:ss') }, |
|||
updatedTime () { return this.updatedAt ? dayjs(this.updatedAt).format('YYYY-MM-DD HH:mm:ss') : '-' }, |
|||
stale () { return this.lastReceivedAt > 0 && this.now - this.lastReceivedAt > 20000 }, |
|||
visibleRows () { return this.available && !this.stale ? this.rows : [] }, |
|||
pendingCount () { return this.visibleRows.filter(item => item.status === '未完成').length }, |
|||
completedCount () { return this.visibleRows.filter(item => item.status === '已完成').length }, |
|||
// 绿灯只代表STOMP连接正常,WCS/WMS查询异常通过旁边业务提示显示,不混淆连接与任务状态 - rqrq |
|||
connectionText () { return { connected: '连接正常', disconnected: '连接断开,等待重连', connecting: '正在连接', disabled: '推送已停用' }[this.wsState] }, |
|||
displayMessage () { return this.stale ? '数据更新超时,正在重试…' : this.message } |
|||
}, |
|||
watch: { |
|||
// 地址参数变化时清空旧数据并递增请求版本,迟到响应不能覆盖新请求 - rqrq |
|||
'$route.fullPath' () { this.initializeBoard() }, |
|||
// 工厂变化立即断开旧订阅,禁止继续消费旧工厂数据 - rqrq |
|||
'$store.state.user.site' () { this.initializeBoard() } |
|||
}, |
|||
mounted () { |
|||
// 先初始化显式site再查询,初始化失败仅展示错误,不使用默认工厂发请求 - rqrq |
|||
this.initializeBoard() |
|||
this.clockTimer = setInterval(() => { this.now = Date.now() }, 1000) |
|||
// 每30秒只复查配置,关闭时不查业务数据;配置重启生效后页面自动建立或关闭连接 - rqrq |
|||
this.refreshTimer = setInterval(() => { this.checkBoardConfig() }, 30000) |
|||
this.scrollTimer = setInterval(this.scrollTable, 60) |
|||
}, |
|||
beforeDestroy () { |
|||
// 页面离开时废弃在途响应并清理全部定时器,防止卸载后持续请求和修改界面 - rqrq |
|||
this.requestVersion++ |
|||
this.disconnectWebSocket() |
|||
clearInterval(this.refreshTimer) |
|||
clearInterval(this.clockTimer) |
|||
clearInterval(this.scrollTimer) |
|||
}, |
|||
methods: { |
|||
// 固定屏支持URL显式site=54;登录态已有其他工厂则拒绝覆盖,防止看板改坏当前会话 - rqrq |
|||
initializeBoard () { |
|||
this.requestVersion++ |
|||
this.disconnectWebSocket() |
|||
this.pushEnabled = false |
|||
this.wsState = 'disabled' |
|||
this.configLoading = false |
|||
this.dataVersion = 0 |
|||
this.queryLoading = false |
|||
this.rows = [] |
|||
this.available = false |
|||
this.updatedAt = 0 |
|||
this.lastReceivedAt = 0 |
|||
this.siteReady = false |
|||
this.site = '' |
|||
const storeSite = String(this.$store.state.user.site || '').trim() |
|||
const querySite = this.$route.query.site |
|||
if (querySite !== undefined && (typeof querySite !== 'string' || querySite.trim() !== '54')) { |
|||
this.message = '海安看板的工厂参数必须为 site=54' |
|||
return |
|||
} |
|||
const site = querySite === undefined ? storeSite : querySite.trim() |
|||
if (site !== '54' || (storeSite && storeSite !== '54')) { |
|||
this.message = '请使用海安工厂打开看板;独立屏地址需带 ?site=54' |
|||
return |
|||
} |
|||
this.$store.commit('user/updateSite', site) |
|||
this.site = site |
|||
this.siteReady = true |
|||
this.message = '正在获取分拣数据…' |
|||
this.$nextTick(() => { |
|||
if (this.$refs.tableViewport) this.$refs.tableViewport.scrollTop = 0 |
|||
this.checkBoardConfig() |
|||
}) |
|||
}, |
|||
// 只探测配置,不查询业务库;后端严格true才允许连接,配置失败清空旧屏并等待下轮重试 - rqrq |
|||
async checkBoardConfig () { |
|||
if (!this.siteReady || this.configLoading) return |
|||
const version = this.requestVersion |
|||
this.configLoading = true |
|||
try { |
|||
const { data } = await getSortingBoardConfig({ site: this.$store.state.user.site }) |
|||
if (version !== this.requestVersion) return |
|||
if (!data || data.code !== 0 || !data.row || data.row.site !== this.site) throw new Error('配置查询失败') |
|||
this.pushEnabled = data.row.enabled === true |
|||
if (!this.pushEnabled) { |
|||
this.disconnectWebSocket() |
|||
this.wsState = 'disabled' |
|||
this.available = false |
|||
this.rows = [] |
|||
this.updatedAt = 0 |
|||
this.lastReceivedAt = 0 |
|||
this.message = '海安看板已停用' |
|||
return |
|||
} |
|||
if (!this._boardSocket) this.initWebSocket() |
|||
} catch (error) { |
|||
if (version !== this.requestVersion) return |
|||
this.pushEnabled = false |
|||
this.disconnectWebSocket() |
|||
this.wsState = 'disconnected' |
|||
this.available = false |
|||
this.rows = [] |
|||
this.lastReceivedAt = 0 |
|||
this.message = '看板配置连接异常,正在重试…' |
|||
} finally { |
|||
if (version === this.requestVersion) this.configLoading = false |
|||
} |
|||
}, |
|||
// 每屏独立订阅显式工厂与位置,重连由纯传输工具处理;自动两屏业务消费逻辑各自在本文件维护 - rqrq |
|||
initWebSocket () { |
|||
if (!this.siteReady || !this.pushEnabled || this._boardSocket) return |
|||
const version = this.requestVersion |
|||
const apiServer = process.env.NODE_ENV !== 'production' && process.env.OPEN_PROXY ? '/proxyApi/' : window.SITE_CONFIG.baseUrl |
|||
const url = apiServer.replace(/\/+$/, '') + '/ws/dashboard' |
|||
const topic = '/topic/dashboard/haian/sorting/' + this.site + '/' + this.sortingStation |
|||
this._boardSocket = new HaianBoardSocket(url, topic, data => { |
|||
if (version !== this.requestVersion || !this.pushEnabled) return |
|||
this.dataVersion++ |
|||
this.applyBoard(data) |
|||
}, state => { |
|||
if (version !== this.requestVersion || !this.pushEnabled) return |
|||
// 连接代次变化使之前发出的HTTP失效,避免断线后迟到响应重新展示旧屏 - rqrq |
|||
this.dataVersion++ |
|||
this.wsState = state |
|||
if (state === 'connected') { |
|||
this.lastReceivedAt = Date.now() |
|||
this.message = '连接正常,等待分拣数据…' |
|||
} else { |
|||
this.available = false |
|||
this.rows = [] |
|||
this.updatedAt = 0 |
|||
this.lastReceivedAt = 0 |
|||
this.message = state === 'connecting' ? '正在连接看板服务…' : '连接已断开,正在自动重连…' |
|||
} |
|||
}) |
|||
this._boardSocket.start() |
|||
}, |
|||
// 停用和卸载彻底清理连接及重试;先清空句柄,旧回调受requestVersion保护 - rqrq |
|||
disconnectWebSocket () { |
|||
this.dataVersion++ |
|||
const socket = this._boardSocket |
|||
this._boardSocket = null |
|||
if (socket) socket.stop() |
|||
}, |
|||
// HTTP手动刷新与推送按相同结构消费,完整核对site/位置;异常与空任务分开显示 - rqrq |
|||
applyBoard (data) { |
|||
if (!this.pushEnabled) return |
|||
const site = String(this.$store.state.user.site || '').trim() |
|||
if (!data || data.code !== 0 || !data.row || site !== this.site || data.row.site !== site || data.row.sortingStation !== this.sortingStation) { |
|||
this.available = false |
|||
this.rows = [] |
|||
this.message = '看板数据校验失败,请检查工厂和分拣位' |
|||
throw new Error(this.message) |
|||
} |
|||
const board = data.row |
|||
this.lastReceivedAt = Date.now() |
|||
this.available = board.available === true |
|||
this.rows = this.available && Array.isArray(board.rows) ? board.rows : [] |
|||
this.updatedAt = Number(board.updatedAt) || 0 |
|||
this.message = board.message || (this.rows.length ? '数据正常' : '当前分拣位暂无任务') |
|||
}, |
|||
// 仅手动刷新使用独立HTTP接口,后端也检查开关;在途HTTP不能覆盖期间收到的新推送,finally恢复按钮 - rqrq |
|||
async fetchData (manual = false) { |
|||
if (!this.siteReady || !this.pushEnabled || this.queryLoading) return |
|||
const version = this.requestVersion |
|||
const dataVersion = this.dataVersion |
|||
const station = this.sortingStation |
|||
const site = String(this.$store.state.user.site || '').trim() |
|||
if (site !== this.site) { |
|||
this.initializeBoard() |
|||
return |
|||
} |
|||
this.queryLoading = true |
|||
try { |
|||
const { data } = await getAutoJ2SortingBoard({ site: this.$store.state.user.site, sortingStation: station }) |
|||
if (version !== this.requestVersion || !this.pushEnabled || dataVersion !== this.dataVersion) return |
|||
if (!data || data.code !== 0 || !data.row) { |
|||
this.rows = [] |
|||
this.available = false |
|||
this.message = (data && data.msg) || '看板查询失败' |
|||
if (manual) this.$alert(this.message, '查询失败', { confirmButtonText: '确定' }).catch(() => {}) |
|||
return |
|||
} |
|||
this.applyBoard(data) |
|||
if (manual && !this.available) this.$alert(this.message, '数据不可用', { confirmButtonText: '确定' }).catch(() => {}) |
|||
} catch (error) { |
|||
if (version !== this.requestVersion || !this.pushEnabled || dataVersion !== this.dataVersion) return |
|||
this.rows = [] |
|||
this.available = false |
|||
this.message = '手动刷新失败,等待推送恢复…' |
|||
if (manual) this.$message.error('查询海安分拣看板失败,请检查网络或服务') |
|||
} finally { |
|||
if (version === this.requestVersion) this.queryLoading = false |
|||
} |
|||
}, |
|||
// 无论首次数据何时到达都按实际滚动高度启动;底部停留2秒后回到顶部,少量数据不滚动 - rqrq |
|||
scrollTable () { |
|||
const viewport = this.$refs.tableViewport |
|||
if (!viewport || this.scrollPaused || !this.visibleRows.length || Date.now() < this.scrollResumeAt) return |
|||
if (viewport.scrollHeight <= viewport.clientHeight) return |
|||
if (viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 1) { |
|||
viewport.scrollTop = 0 |
|||
this.scrollResumeAt = Date.now() + 2000 |
|||
} else { |
|||
viewport.scrollTop += 1 |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
/* 沿用老分拣看板蓝色渐变、CCL白色Logo、青绿色表头和连接灯;海安布局独立维护,长文本可换行 - rqrq */ |
|||
.haian-sorting-board { height: 100vh; width: 100%; box-sizing: border-box; padding: 18px 24px 12px; background: linear-gradient(135deg, #5f8cc3 0%, #749cc8 100%); color: #f4f7fb; font-family: 'Microsoft YaHei', sans-serif; display: flex; flex-direction: column; overflow: hidden; } |
|||
.board-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 82px; border-bottom: 2px solid rgba(23, 179, 163, 0.4); background: linear-gradient(180deg, rgba(23, 179, 163, 0.08), transparent); } |
|||
.board-logo { width: auto; height: 40px; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3)); } |
|||
.board-title { flex: 1; text-align: center; } |
|||
.board-title h1 { margin: 0 0 8px; font-size: 32px; letter-spacing: 3px; text-shadow: 0 0 20px rgba(23, 179, 163, 0.5); } |
|||
.board-title span { color: #d2e1ee; font-size: 16px; } |
|||
.board-header time { font-size: 18px; font-variant-numeric: tabular-nums; } |
|||
.board-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 18px 0; } |
|||
.summary-counts, .summary-refresh { display: flex; align-items: center; gap: 20px; } |
|||
.summary-counts strong { font-size: 26px; margin-left: 8px; } |
|||
.pending-text { color: #ffd17d; } |
|||
.completed-text { color: #80e5bc; } |
|||
.ws-status-dot { display: inline-block; width: 12px; height: 12px; margin-left: 12px; border-radius: 50%; vertical-align: middle; background: #94a3b8; } |
|||
.ws-status-dot.connected { background: #22c55e; box-shadow: 0 0 10px rgba(34, 197, 94, 0.8); } |
|||
.ws-status-dot.disconnected { background: #ef4444; box-shadow: 0 0 12px rgba(239, 68, 68, 0.85); animation: ws-status-blink 1s steps(1, end) infinite; } |
|||
.ws-status-dot.connecting { background: #f59e0b; box-shadow: 0 0 10px rgba(245, 158, 11, 0.7); } |
|||
@keyframes ws-status-blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0.2; } } |
|||
.summary-refresh { font-size: 14px; gap: 10px; } |
|||
.table-viewport { flex: 1; min-height: 0; overflow-y: auto; background: rgba(70, 90, 120, 0.9); border: 1px solid #637e9e; border-radius: 6px; } |
|||
.board-table { width: 100%; table-layout: fixed; border-spacing: 0; } |
|||
.board-table th { position: sticky; top: 0; z-index: 1; background: #1b6676; color: #fff; font-size: 18px; padding: 16px 8px; } |
|||
.board-table td { padding: 18px 8px; font-size: 18px; text-align: center; border-bottom: 1px solid #526b87; overflow-wrap: break-word; word-wrap: break-word; } |
|||
.board-table tbody tr:nth-child(even) { background: #344b66; } |
|||
.board-table .description { text-align: left; } |
|||
.board-table .barcode { font-family: Consolas, 'Microsoft YaHei', monospace; } |
|||
.board-table .row-message { color: #ffd17d; font-size: 14px; } |
|||
.status-badge { display: inline-block; padding: 5px 10px; border-radius: 4px; white-space: nowrap; font-size: 16px; } |
|||
.status-badge.pending { background: #745827; color: #fff0cf; } |
|||
.status-badge.completed { background: #21684e; color: #d4ffeb; } |
|||
.board-table .empty-message { height: 220px; font-size: 24px; color: #d2e1ee; } |
|||
.board-table .empty-message.is-error { color: #ffd17d; } |
|||
.board-footer { display: flex; justify-content: space-between; padding-top: 12px; color: #d2e1ee; font-size: 14px; } |
|||
@media screen and (max-width: 1280px) { |
|||
.haian-sorting-board { padding: 12px; } |
|||
.board-title h1 { font-size: 26px; } |
|||
.board-header time { font-size: 14px; } |
|||
.board-table th, .board-table td { font-size: 14px; padding: 12px 5px; } |
|||
.status-badge { padding: 4px; font-size: 13px; } |
|||
} |
|||
</style> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue