You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

682 lines
17 KiB
Vue

<template>
<div class="app-container">
<div class="headTitle">{{ boardTitle }}</div>
<div class="subTitle">
<span v-if="boardCode">boardCode: {{ boardCode }}</span>
<span v-else> boardCode</span>
<span class="split">|</span>
<span>刷新: {{ refreshIntervalSec }}s</span>
<span class="split">|</span>
<span>最近更新: {{ lastUpdateText }}</span>
</div>
<div v-if="errorMsg" class="error">{{ errorMsg }}</div>
<div class="content" v-else>
<div class="stats">
<div class="stat stat-pending">
<div class="stat-label">待处理</div>
<div class="stat-value">{{ stats.pending || 0 }}</div>
</div>
<div class="stat stat-processing">
<div class="stat-label">处理中</div>
<div class="stat-value">{{ stats.processing || 0 }}</div>
</div>
<div class="stat stat-resolved">
<div class="stat-label">已解决</div>
<div class="stat-value">{{ stats.resolved || 0 }}</div>
</div>
<div class="stat stat-cancelled">
<div class="stat-label">已取消</div>
<div class="stat-value">{{ stats.cancelled || 0 }}</div>
</div>
<div class="stat stat-total">
<div class="stat-label">总数</div>
<div class="stat-value">{{ stats.total || 0 }}</div>
</div>
</div>
<div class="panels">
<div class="panel">
<div class="panel-title">待处理 / 处理中 ({{ stats.active || 0 }})</div>
<div class="table">
<div class="grid-header" :style="gridStyle(activeColumns)">
<div
v-for="col in activeColumns"
:key="col.field"
class="cell header"
:style="cellStyle(col)"
>
{{ col.label }}
</div>
</div>
<div class="grid-body">
<div
v-for="(row, rowIndex) in activeEvents"
:key="row.eventId || rowIndex"
class="grid-row"
:style="gridStyle(activeColumns)"
>
<div
v-for="col in activeColumns"
:key="col.field"
class="cell"
:style="cellStyle(col)"
>
<span
v-if="col.field === 'eventStatus'"
:class="['status', statusClass(row.eventStatus)]"
>
{{ statusText(row.eventStatus) }}
</span>
<span v-else>
{{ formatValue(row, col.field, rowIndex) }}
</span>
</div>
</div>
<div v-if="!activeEvents || activeEvents.length === 0" class="empty"></div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-title">已解决 / 已取消 ({{ closedEvents.length }})</div>
<div class="table">
<div class="grid-header" :style="gridStyle(closedColumns)">
<div
v-for="col in closedColumns"
:key="col.field"
class="cell header"
:style="cellStyle(col)"
>
{{ col.label }}
</div>
</div>
<div class="grid-body">
<div
v-for="(row, rowIndex) in closedEvents"
:key="row.eventId || rowIndex"
class="grid-row"
:style="gridStyle(closedColumns)"
>
<div
v-for="col in closedColumns"
:key="col.field"
class="cell"
:style="cellStyle(col)"
>
<span
v-if="col.field === 'eventStatus'"
:class="['status', statusClass(row.eventStatus)]"
>
{{ statusText(row.eventStatus) }}
</span>
<span v-else>
{{ formatValue(row, col.field, rowIndex) }}
</span>
</div>
</div>
<div v-if="!closedEvents || closedEvents.length === 0" class="empty"></div>
</div>
</div>
</div>
</div>
<div v-if="loading" class="loading">...</div>
</div>
</div>
</template>
<script>
import { viewAndonBoard } from '@/api/production/andonBoard'
import { parseTime } from '@/utils/ruoyi'
export default {
name: 'AndonBoard',
data() {
return {
boardCode: '',
loading: false,
fetching: false,
errorMsg: '',
config: {},
stats: {},
activeEvents: [],
closedEvents: [],
activeColumns: [],
closedColumns: [],
refreshIntervalSec: 10,
lastServerTime: null,
refreshTimer: null,
refreshTimerSec: null,
}
},
computed: {
boardTitle() {
const name = this.config && this.config.boardName ? this.config.boardName : '安灯看板'
return name
},
lastUpdateText() {
return this.lastServerTime ? parseTime(this.lastServerTime, '{y}-{m}-{d} {h}:{i}:{s}') : '-'
}
},
created() {
this.boardCode = this.getBoardCode()
this.reload()
},
watch: {
'$route.query.boardCode': function () {
const nextCode = this.getBoardCode()
if (nextCode !== this.boardCode) {
this.boardCode = nextCode
this.reload()
}
}
},
beforeDestroy() {
this.clearRefreshTimer()
},
methods: {
getBoardCode() {
return (this.$route.query && this.$route.query.boardCode) || (this.$route.params && this.$route.params.boardCode) || ''
},
reload() {
this.clearRefreshTimer()
if (!this.boardCode) {
this.errorMsg = '请在URL中传入参数 boardCode例如/andonBoard?boardCode=AD-001'
return
}
this.errorMsg = ''
this.lastServerTime = null
this.fetchBoardData()
},
fetchBoardData() {
if (this.fetching) {
return Promise.resolve()
}
const showLoading = !this.lastServerTime
this.fetching = true
if (showLoading) {
this.loading = true
}
return viewAndonBoard(this.boardCode)
.then((res) => {
const data = (res && res.data) || {}
this.errorMsg = ''
this.config = data.config || {}
this.stats = data.stats || {}
this.activeEvents = data.activeEvents || []
this.closedEvents = data.closedEvents || []
this.lastServerTime = data.serverTime || null
const interval = Number(this.config && this.config.refreshIntervalSec)
this.refreshIntervalSec = interval && interval > 0 ? interval : 10
const cols = this.parseDisplayFields(this.config && this.config.displayFields)
this.activeColumns = cols.active
this.closedColumns = cols.closed
this.resetRefreshTimer()
})
.catch((e) => {
this.errorMsg = (e && e.message) || '加载失败'
})
.finally(() => {
this.fetching = false
if (showLoading) {
this.loading = false
}
this.resetRefreshTimer()
})
},
clearRefreshTimer() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
this.refreshTimer = null
this.refreshTimerSec = null
}
},
resetRefreshTimer() {
const sec = this.refreshIntervalSec
if (!sec || sec <= 0) {
return
}
if (this.refreshTimer && this.refreshTimerSec === sec) {
return
}
this.clearRefreshTimer()
this.refreshTimerSec = sec
this.refreshTimer = setInterval(() => {
this.fetchBoardData()
}, sec * 1000)
},
parseDisplayFields(raw) {
const fallbackActive = ['callCode', 'callTypeCode', 'stationCode', 'deviceCode', 'eventStatus', 'priority', 'createTime', 'description']
const fallbackClosed = ['callCode', 'callTypeCode', 'stationCode', 'deviceCode', 'eventStatus', 'responseEndTime', 'resolution', 'cancelReason']
if (!raw) {
return {
active: this.normalizeColumns(fallbackActive),
closed: this.normalizeColumns(fallbackClosed),
}
}
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (trimmed && trimmed[0] !== '[' && trimmed[0] !== '{') {
const fields = trimmed.split(/[,;\s]+/).filter(Boolean)
if (fields.length) {
const cols = this.normalizeColumns(fields)
return { active: cols, closed: cols }
}
}
}
let parsed
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
} catch (e) {
return {
active: this.normalizeColumns(fallbackActive),
closed: this.normalizeColumns(fallbackClosed),
}
}
if (Array.isArray(parsed)) {
const cols = this.normalizeColumns(parsed)
return { active: cols, closed: cols }
}
if (parsed && typeof parsed === 'object') {
const activeRaw = parsed.activeFields || parsed.active || parsed.fields || parsed
const closedRaw = parsed.closedFields || parsed.closed || parsed.fields || parsed
const activeCols = Array.isArray(activeRaw) || typeof activeRaw === 'object' ? this.normalizeColumns(activeRaw) : this.normalizeColumns(fallbackActive)
const closedCols = Array.isArray(closedRaw) || typeof closedRaw === 'object' ? this.normalizeColumns(closedRaw) : this.normalizeColumns(fallbackClosed)
return {
active: activeCols && activeCols.length ? activeCols : this.normalizeColumns(fallbackActive),
closed: closedCols && closedCols.length ? closedCols : this.normalizeColumns(fallbackClosed),
}
}
return {
active: this.normalizeColumns(fallbackActive),
closed: this.normalizeColumns(fallbackClosed),
}
},
normalizeColumns(input) {
if (!input) return []
if (Array.isArray(input)) {
return input
.map((item) => {
if (!item) return null
if (typeof item === 'string') {
return { field: item, label: this.fieldLabel(item) }
}
if (typeof item === 'object') {
const field = item.field || item.prop || item.key
if (!field) return null
return {
field,
label: item.label || item.title || this.fieldLabel(field),
width: item.width,
align: item.align,
}
}
return null
})
.filter(Boolean)
}
if (input && typeof input === 'object') {
return Object.keys(input).map((field) => {
const val = input[field]
if (typeof val === 'string') {
return { field, label: val }
}
if (typeof val === 'object' && val) {
return {
field,
label: val.label || val.title || this.fieldLabel(field),
width: val.width,
align: val.align,
}
}
return { field, label: this.fieldLabel(field) }
})
}
return []
},
fieldLabel(field) {
const map = {
index: '序号',
eventId: '事件ID',
callCode: '呼叫单号',
callTypeCode: '呼叫类型',
sourceType: '触发源类型',
sourceRefId: '触发源',
productLineCode: '产线',
stationCode: '工位',
teamCode: '班组',
orderCode: '工单号',
materialCode: '物料编码',
deviceCode: '设备编码',
priority: '优先级',
eventStatus: '状态',
description: '描述',
ackBy: '确认人',
ackTime: '确认时间',
responseStartTime: '开始处理时间',
responseEndTime: '完成时间',
resolution: '解决措施',
cancelReason: '取消原因',
escalateLevel: '升级级别',
escalateTime: '升级时间',
ackDeadline: '确认截止',
resolveDeadline: '解决截止',
createTime: '创建时间',
updateTime: '更新时间',
}
return map[field] || field
},
formatValue(row, field, rowIndex) {
if (field === 'index') {
return rowIndex + 1
}
const v = row ? row[field] : null
if (v === null || v === undefined) return ''
if (field === 'eventStatus') {
return this.statusText(v)
}
if (field === 'sourceType') {
return this.sourceTypeText(v)
}
if (/(Time|Deadline)$/i.test(field)) {
const text = parseTime(v, '{y}-{m}-{d} {h}:{i}:{s}')
return text || ''
}
return String(v)
},
statusText(status) {
if (status === '0') return '待处理'
if (status === '1') return '处理中'
if (status === '2') return '已解决'
if (status === '3') return '已取消'
return String(status || '')
},
statusClass(status) {
if (status === '0') return 'pending'
if (status === '1') return 'processing'
if (status === '2') return 'resolved'
if (status === '3') return 'cancelled'
return ''
},
sourceTypeText(v) {
if (v === '0') return '工位'
if (v === '1') return '设备'
if (v === '2') return '报警'
if (v === '3') return '手动'
return String(v || '')
},
gridStyle(cols) {
const list = Array.isArray(cols) ? cols : []
const template = list
.map((c) => {
if (c && c.width) {
if (typeof c.width === 'number') return c.width + 'px'
return String(c.width)
}
return '1fr'
})
.join(' ')
return {
gridTemplateColumns: template || '1fr',
}
},
cellStyle(col) {
const style = {}
if (col && col.align) {
style.textAlign = col.align
}
return style
},
}
}
</script>
<style scoped lang="less">
.app-container {
background: #0f2740;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
padding: 4.2vw 2vw 1.2vw 2vw;
box-sizing: border-box;
color: #d6eaed;
overflow: hidden;
}
.headTitle {
position: absolute;
top: 5%;
left: 50%;
transform: translate(-50%, -100%);
font-size: 1.6vw;
letter-spacing: 0.3vw;
white-space: nowrap;
}
.subTitle {
position: absolute;
top: 8.1%;
left: 50%;
transform: translate(-50%, -100%);
font-size: 0.85vw;
color: #a9d5e8;
white-space: nowrap;
}
.split {
display: inline-block;
margin: 0 0.6vw;
color: rgba(255, 255, 255, 0.35);
}
.content {
height: 100%;
display: flex;
flex-direction: column;
}
.stats {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1vw;
margin-bottom: 1.2vw;
}
.stat {
height: 5.6vw;
border-radius: 0.6vw;
padding: 0.8vw 1vw;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.stat-label {
font-size: 0.95vw;
opacity: 0.9;
}
.stat-value {
font-size: 2.1vw;
font-weight: 700;
line-height: 1;
}
.stat-pending .stat-value {
color: #ff5f5f;
}
.stat-processing .stat-value {
color: #ffb020;
}
.stat-resolved .stat-value {
color: #16ceb9;
}
.stat-cancelled .stat-value {
color: #9aa3ad;
}
.stat-total .stat-value {
color: #ffffff;
}
.panels {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.2vw;
min-height: 0;
}
.panel {
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.6vw;
padding: 0.8vw;
box-sizing: border-box;
display: flex;
flex-direction: column;
min-height: 0;
}
.panel-title {
font-size: 1.05vw;
font-weight: 700;
margin-bottom: 0.6vw;
letter-spacing: 0.1vw;
}
.table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.grid-header {
display: grid;
gap: 0;
background: rgba(9, 65, 112, 0.75);
border-radius: 0.4vw;
overflow: hidden;
}
.grid-body {
flex: 1;
min-height: 0;
overflow: auto;
margin-top: 0.4vw;
}
.grid-body::-webkit-scrollbar {
width: 0;
height: 0;
}
.grid-row {
display: grid;
background: rgba(3, 45, 87, 0.55);
}
.grid-row:nth-child(2n) {
background: rgba(5, 52, 96, 0.55);
}
.cell {
padding: 0.55vw 0.6vw;
font-size: 0.85vw;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
.cell:last-child {
border-right: none;
}
.cell.header {
font-size: 0.85vw;
font-weight: 700;
color: #ffffff;
}
.status {
font-weight: 700;
}
.status.pending {
color: #ff5f5f;
}
.status.processing {
color: #ffb020;
}
.status.resolved {
color: #16ceb9;
}
.status.cancelled {
color: #9aa3ad;
}
.empty {
padding: 1vw;
text-align: center;
color: rgba(255, 255, 255, 0.55);
font-size: 0.9vw;
}
.error {
margin-top: 6vw;
padding: 1vw;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 95, 95, 0.6);
border-radius: 0.6vw;
font-size: 1vw;
color: #ffbdbd;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1vw;
color: rgba(255, 255, 255, 0.8);
background: rgba(0, 0, 0, 0.35);
padding: 0.6vw 1.2vw;
border-radius: 0.4vw;
}
</style>