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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>