Files
klp-oa/klp-ui/src/views/aps/factoryCalendar.vue

584 lines
16 KiB
Vue
Raw Normal View History

2026-03-08 16:02:44 +08:00
<template>
<div class="factory-calendar-page app-container" v-loading="loading">
<div class="toolbar-row">
<el-form :inline="true" size="small" class="filter-row">
<el-form-item label="年份">
<el-date-picker
v-model="query.year"
type="year"
value-format="yyyy"
placeholder="选择年份"
style="width: 120px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleRefresh">查询</el-button>
<el-button icon="el-icon-refresh" @click="handleRefresh">刷新</el-button>
</el-form-item>
</el-form>
<div class="month-strip">
<el-button
v-for="m in monthOptions"
:key="`m-${m}`"
size="mini"
:type="query.monthNum === m ? 'primary' : 'default'"
plain
class="month-btn"
@click="selectMonth(m)"
>
{{ m }}
</el-button>
</div>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="k"><i class="el-icon-s-operation" />任务总数</div>
<div class="v">{{ overview.taskCount }}</div>
</div>
<div class="stat-card">
<div class="k"><i class="el-icon-data-line" />活跃产线</div>
<div class="v">{{ overview.activeLineCount }} / {{ overview.totalLineCount }}</div>
</div>
<div class="stat-card">
<div class="k"><i class="el-icon-time" />排产总工时</div>
<div class="v">{{ overview.totalHours }}</div>
</div>
<div class="stat-card">
<div class="k"><i class="el-icon-date" />峰值日</div>
<div class="v">{{ overview.peakDayText }}</div>
</div>
<div class="stat-card">
<div class="k"><i class="el-icon-s-flag" />最忙产线</div>
<div class="v">{{ overview.busiestLineText }}</div>
</div>
<div class="stat-card">
<div class="k"><i class="el-icon-collection-tag" />最忙产线-班组</div>
<div class="v">{{ overview.busiestLineShiftText }}</div>
</div>
</div>
<div class="calendar-block">
<div class="block-head">
<div class="block-title">产线日历</div>
<div class="legend">
<span class="legend-item"><i class="dot work" />工作日</span>
<span class="legend-item"><i class="dot rest" />休息日</span>
</div>
</div>
<el-tabs v-model="activeLineTab" type="card" class="sub-tabs" v-if="lineRows.length">
<el-tab-pane
v-for="line in lineRows"
:key="`line-tab-${line.lineId}`"
:name="String(line.lineId)"
:label="line.lineName"
>
<div class="calendar-full">
<div class="week-head">
<span v-for="w in weekLabels" :key="`line-week-${line.lineId}-${w}`">{{ w }}</span>
</div>
<div class="month-grid">
<div
v-for="cell in monthCells"
:key="`line-${line.lineId}-${cell.key}`"
class="day-cell"
:class="[cell.isCurrentMonth ? 'cur' : 'other', lineDayClass(line.lineId, cell.date), dayExtraClass(cell)]"
>
<div class="day-no">{{ cell.day }}</div>
<template v-if="cell.isCurrentMonth">
<div class="task-count">{{ getLineDay(line.lineId, cell.date).taskCount }} </div>
<div
v-for="(t, idx) in tasksByLineDate(line.lineId, cell.date).slice(0, 3)"
:key="`line-${line.lineId}-${cell.date}-${idx}`"
class="task-item"
:title="taskLabel(t)"
>
{{ shortTaskWithShift(t) }}
</div>
<div class="more" v-if="getLineDay(line.lineId, cell.date).taskCount > 3">+{{ getLineDay(line.lineId, cell.date).taskCount - 3 }}</div>
</template>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<el-empty v-else description="暂无产线数据" />
</div>
<div class="calendar-block">
<div class="block-head">
<div class="block-title">产线-班组日历</div>
<div class="legend">
<span class="legend-item"><i class="dot work" />工作日</span>
<span class="legend-item"><i class="dot rest" />休息日</span>
</div>
</div>
<el-tabs v-model="activeLineShiftTab" type="card" class="sub-tabs" v-if="lineShiftRows.length">
<el-tab-pane
v-for="item in lineShiftRows"
:key="`ls-tab-${item.lineId}-${item.shiftId}`"
:name="`${item.lineId}-${item.shiftId}`"
:label="`${item.lineName} · ${item.shiftName}`"
>
<div class="calendar-full">
<div class="week-head">
<span v-for="w in weekLabels" :key="`ls-week-${item.lineId}-${item.shiftId}-${w}`">{{ w }}</span>
</div>
<div class="month-grid">
<div
v-for="cell in monthCells"
:key="`ls-${item.lineId}-${item.shiftId}-${cell.key}`"
class="day-cell"
:class="[cell.isCurrentMonth ? 'cur' : 'other', lineShiftDayClass(item.lineId, item.shiftId, cell.date), dayExtraClass(cell)]"
>
<div class="day-no">{{ cell.day }}</div>
<template v-if="cell.isCurrentMonth">
<div class="task-count">{{ getLineShiftDay(item.lineId, item.shiftId, cell.date).taskCount }} </div>
<div
v-for="(t, idx) in tasksByLineShiftDate(item.lineId, item.shiftId, cell.date).slice(0, 3)"
:key="`ls-${item.lineId}-${item.shiftId}-${cell.date}-${idx}`"
class="task-item"
:title="taskLabel(t)"
>
{{ shortTask(t) }}
</div>
<div class="more" v-if="getLineShiftDay(item.lineId, item.shiftId, cell.date).taskCount > 3">+{{ getLineShiftDay(item.lineId, item.shiftId, cell.date).taskCount - 3 }}</div>
</template>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<el-empty v-else description="暂无产线-班组数据" />
</div>
</div>
</template>
<script>
import { fetchFactoryCalendar } from '@/api/aps/aps'
export default {
name: 'ApsFactoryCalendarPage',
data() {
return {
loading: false,
query: { year: String(new Date().getFullYear()), monthNum: new Date().getMonth() + 1 },
monthOptions: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
weekLabels: ['日', '一', '二', '三', '四', '五', '六'],
activeLineTab: '',
activeLineShiftTab: '',
payload: {
overview: {},
dateList: [],
lineNameMap: {},
shiftNameMap: {},
lineDayMap: {},
lineShiftDayMap: {}
},
legendVisible: true
}
},
computed: {
monthText() {
return `${this.query.year}-${`${this.query.monthNum}`.padStart(2, '0')}`
},
lineRows() {
return Object.keys(this.payload.lineNameMap || {}).map(id => ({ lineId: String(id), lineName: this.payload.lineNameMap[id] }))
},
lineShiftRows() {
const rows = []
Object.keys(this.payload.lineNameMap || {}).forEach(lineId => {
Object.keys(this.payload.shiftNameMap || {}).forEach(shiftId => {
rows.push({
lineId: String(lineId),
lineName: this.payload.lineNameMap[lineId],
shiftId: String(shiftId),
shiftName: this.payload.shiftNameMap[shiftId]
})
})
})
return rows
},
monthRange() {
const y = Number((this.query.year || '').toString())
const m = Number(this.query.monthNum || 0)
if (!y || !m) return { start: '', end: '' }
const start = new Date(y, m - 1, 1)
const end = new Date(y, m, 0)
return { start: this.formatDate(start), end: this.formatDate(end) }
},
monthCells() {
const { start, end } = this.monthRange
if (!start || !end) return []
const startDate = new Date(`${start} 00:00:00`)
const endDate = new Date(`${end} 00:00:00`)
const firstWeekDay = startDate.getDay()
const cells = []
const prefixStart = new Date(startDate.getTime())
prefixStart.setDate(prefixStart.getDate() - firstWeekDay)
const totalDays = Math.ceil((endDate.getTime() - prefixStart.getTime()) / 86400000) + 1
const totalCells = Math.ceil(totalDays / 7) * 7
for (let i = 0; i < totalCells; i++) {
const d = new Date(prefixStart.getTime())
d.setDate(prefixStart.getDate() + i)
const date = this.formatDate(d)
cells.push({ key: `${date}-${i}`, date, day: d.getDate(), isCurrentMonth: date >= start && date <= end })
}
return cells
},
overview() {
return this.payload.overview || {
taskCount: 0,
activeLineCount: 0,
totalLineCount: 0,
totalHours: '0.0',
peakDayText: '-',
busiestLineText: '-',
busiestLineShiftText: '-'
}
}
},
created() {
this.handleRefresh()
},
methods: {
selectMonth(m) {
this.query.monthNum = m
this.handleRefresh()
},
formatDate(d) {
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${y}-${m}-${day}`
},
async handleRefresh() {
const { start, end } = this.monthRange
if (!start || !end) {
this.$message.warning('请先选择月份')
return
}
this.loading = true
try {
const res = await fetchFactoryCalendar({ queryStart: `${start} 00:00:00`, queryEnd: `${end} 23:59:59` })
this.payload = res.data || {
overview: {}, dateList: [], lineNameMap: {}, shiftNameMap: {}, lineDayMap: {}, lineShiftDayMap: {}
}
if (this.lineRows.length && !this.lineRows.find(i => String(i.lineId) === this.activeLineTab)) {
this.activeLineTab = String(this.lineRows[0].lineId)
}
if (this.lineShiftRows.length && !this.lineShiftRows.find(i => `${i.lineId}-${i.shiftId}` === this.activeLineShiftTab)) {
this.activeLineShiftTab = `${this.lineShiftRows[0].lineId}-${this.lineShiftRows[0].shiftId}`
}
} finally {
this.loading = false
}
},
getLineDay(lineId, date) {
const key = `${String(lineId)}|${date}`
const m = this.payload.lineDayMap || {}
const found = m[key]
return found || { taskCount: 0, shiftCount: 0, tasks: [] }
},
getLineShiftDay(lineId, shiftId, date) {
const key = `${String(lineId)}|${String(shiftId)}|${date}`
const m = this.payload.lineShiftDayMap || {}
const found = m[key]
return found || { taskCount: 0, lineCount: 0, tasks: [] }
},
tasksByLineDate(lineId, date) {
return this.getLineDay(lineId, date).tasks || []
},
tasksByLineShiftDate(lineId, shiftId, date) {
return this.getLineShiftDay(lineId, shiftId, date).tasks || []
},
taskLabel(task) {
return task && task.title ? task.title : '-'
},
shortTask(task) {
return task && task.label ? task.label : '-'
},
shortTaskWithShift(task) {
return this.shortTask(task)
},
lineDayClass(lineId, date) {
const day = this.getLineDay(lineId, date)
if (day.dayStatus === 1) return 'workday'
if (day.dayStatus === 0) return 'restday'
return day.taskCount > 0 ? 'workday' : 'neutral'
},
lineShiftDayClass(lineId, shiftId, date) {
const day = this.getLineShiftDay(lineId, shiftId, date)
if (day.dayStatus === 1) return 'workday'
if (day.dayStatus === 0) return 'restday'
return day.taskCount > 0 ? 'workday' : 'neutral'
},
dayExtraClass(cell) {
const classes = []
const d = new Date(`${cell.date} 00:00:00`)
const week = d.getDay()
if (week === 0 || week === 6) classes.push('is-weekend')
const today = this.formatDate(new Date())
if (cell.date === today) classes.push('is-today')
return classes.join(' ')
}
}
}
</script>
<style scoped lang="scss">
.factory-calendar-page {
padding: 12px;
}
.toolbar-row {
background: #fff;
border: 1px solid #e9edf3;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 10px;
}
.filter-row {
margin: 0 0 10px;
}
::v-deep .filter-row .el-date-editor.el-input,
::v-deep .filter-row .el-date-editor.el-input__inner {
width: 120px !important;
}
::v-deep .filter-row .el-input__inner {
border-radius: 8px;
border-color: #d7deea;
}
::v-deep .filter-row .el-input__inner:focus {
border-color: #7aa9ff;
}
.month-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.month-btn {
min-width: 56px;
}
.stats-row {
display: grid;
grid-template-columns: repeat(6, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.stat-card {
padding: 10px 12px;
border: 1px solid #e9edf3;
border-radius: 10px;
background: #fff;
}
.stat-card .k {
font-size: 12px;
color: #8b95a7;
}
.stat-card .v {
margin-top: 6px;
font-size: 18px;
line-height: 1.2;
font-weight: 700;
color: #1f2d3d;
}
.calendar-block {
margin-bottom: 14px;
background: #fff;
border: 1px solid #e9edf3;
border-radius: 10px;
padding: 10px;
}
.block-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.block-title {
font-size: 14px;
color: #1f2d3d;
font-weight: 700;
}
.legend {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #6b7280;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 2px;
display: inline-block;
border: 1px solid #e5e7eb;
}
.dot.work {
background: #edf8ee;
}
.dot.rest {
background: #fff2e6;
}
::v-deep .sub-tabs .el-tabs__header {
margin-bottom: 10px;
border: none;
}
::v-deep .sub-tabs .el-tabs__nav-wrap,
::v-deep .sub-tabs .el-tabs__nav-scroll,
::v-deep .sub-tabs .el-tabs__nav {
border: none !important;
background: transparent;
}
::v-deep .sub-tabs .el-tabs__item {
border-radius: 16px;
margin-right: 8px;
border: 1px solid #dbe3ef !important;
background: #fff;
height: 30px;
line-height: 30px;
padding: 0 12px;
color: #4a5668;
transition: all .2s;
}
::v-deep .sub-tabs .el-tabs__item:hover {
color: #2f7df6;
border-color: #b8d3ff !important;
}
::v-deep .sub-tabs .el-tabs__item.is-active {
color: #2f7df6;
background: #eef5ff;
border-color: #bfd8ff !important;
font-weight: 600;
}
::v-deep .sub-tabs .el-tabs__item:last-child {
margin-right: 0;
}
::v-deep .sub-tabs .el-tabs__nav-wrap::after,
::v-deep .sub-tabs .el-tabs__active-bar {
display: none !important;
}
.calendar-full {
width: 100%;
}
.week-head {
display: grid;
grid-template-columns: repeat(7, 1fr);
border: 1px solid #edf1f6;
border-bottom: none;
border-radius: 8px 8px 0 0;
overflow: hidden;
}
.week-head span {
text-align: center;
padding: 8px 0;
font-size: 12px;
color: #7d8797;
background: #fafcff;
border-right: 1px solid #edf1f6;
}
.week-head span:last-child {
border-right: none;
}
.month-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-left: 1px solid #edf1f6;
border-top: 1px solid #edf1f6;
}
.day-cell {
height: 120px;
padding: 8px;
border-right: 1px solid #edf1f6;
border-bottom: 1px solid #edf1f6;
box-sizing: border-box;
background: #fff;
}
.day-cell.other {
background: #fafbfd;
color: #c4cad4;
}
.day-cell.cur.workday {
background: #edf8ee;
}
.day-cell.cur.restday {
background: #fff2e6;
}
.day-cell.is-today {
box-shadow: inset 0 0 0 2px #7aa9ff;
}
.day-cell.is-weekend .day-no {
color: #5f84c9;
}
.day-no {
font-size: 11px;
color: #8f99aa;
margin-bottom: 6px;
}
.task-count {
font-size: 12px;
color: #1f2d3d;
font-weight: 700;
margin-bottom: 4px;
}
.task-item {
font-size: 11px;
color: #4c596d;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.more {
font-size: 10px;
color: #9aa5b5;
margin-top: 3px;
}</style>